Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions codex-rs/core/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,24 @@ The `rollout/` module handles session persistence:
- `list.rs`: Lists and queries saved sessions
- Sessions stored in `~/.codex/sessions/` with JSON-lines format

**User Notifications (`user_notification.rs`):**

The `UserNotifier` and `UserNotification` types enable external notification handlers to receive events from Codex:

- `UserNotifier::new(notify: Option<Vec<String>>)` - Creates a notifier with an optional external command
- `UserNotifier::notify(&notification)` - Serializes the notification to JSON and invokes the configured command
- `UserNotification::AgentTurnComplete` - Sent when an agent turn finishes
- `UserNotification::ApprovalRequested` - Sent when user approval is needed (exec, edit, or elicitation)

The `ApprovalRequested` variant includes:
- `request_type`: "exec", "edit", or "elicitation"
- `message`: Human-readable description
- `command`: For exec requests, the command being requested
- `file_paths`: For edit requests, the files being modified
- `idle_duration_secs`: Present when idle threshold was exceeded before the request

Both types are publicly exported from `lib.rs` for TUI consumption.

**MCP Integration:**

The `mcp/` and `mcp_connection_manager.rs` modules manage MCP server connections defined in config.
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/src/config/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use toml_edit::Table as TomlTable;
use toml_edit::value;

// Re-export for users of ConfigEditsBuilder::set_path
pub use toml_edit::Array;
pub use toml_edit::Item;
pub use toml_edit::value as toml_value;

Expand Down
3 changes: 3 additions & 0 deletions codex-rs/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ mod state;
mod tasks;
mod user_notification;
mod user_shell_command;

pub use user_notification::UserNotification;
pub use user_notification::UserNotifier;
pub mod util;

pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
Expand Down
70 changes: 66 additions & 4 deletions codex-rs/core/src/user_notification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ use tracing::error;
use tracing::warn;

#[derive(Debug, Default)]
pub(crate) struct UserNotifier {
pub struct UserNotifier {
notify_command: Option<Vec<String>>,
}

impl UserNotifier {
pub(crate) fn notify(&self, notification: &UserNotification) {
pub fn notify(&self, notification: &UserNotification) {
if let Some(notify_command) = &self.notify_command
&& !notify_command.is_empty()
{
Expand All @@ -34,7 +34,7 @@ impl UserNotifier {
}
}

pub(crate) fn new(notify: Option<Vec<String>>) -> Self {
pub fn new(notify: Option<Vec<String>>) -> Self {
Self {
notify_command: notify,
}
Expand All @@ -46,7 +46,7 @@ impl UserNotifier {
/// program.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub(crate) enum UserNotification {
pub enum UserNotification {
#[serde(rename_all = "kebab-case")]
AgentTurnComplete {
thread_id: String,
Expand All @@ -59,6 +59,31 @@ pub(crate) enum UserNotification {
/// The last message sent by the assistant in the turn.
last_assistant_message: Option<String>,
},

/// Notification sent when an approval request arrives, optionally after
/// an idle period. This allows external notification handlers to alert
/// the user when the agent needs their attention.
#[serde(rename_all = "kebab-case")]
ApprovalRequested {
/// The type of approval request: "exec", "edit", or "elicitation"
request_type: String,

/// Human-readable message describing the approval request
message: String,

/// For exec requests, the command being requested
#[serde(skip_serializing_if = "Option::is_none")]
command: Option<String>,

/// For edit requests, the list of file paths being modified
#[serde(skip_serializing_if = "Option::is_none")]
file_paths: Option<Vec<String>>,

/// How long the system was idle before this request, in seconds.
/// Present when the idle threshold was exceeded.
#[serde(skip_serializing_if = "Option::is_none")]
idle_duration_secs: Option<u64>,
},
}

#[cfg(test)]
Expand All @@ -84,4 +109,41 @@ mod tests {
);
Ok(())
}

#[test]
fn test_approval_requested_exec_notification() -> Result<()> {
let notification = UserNotification::ApprovalRequested {
request_type: "exec".to_string(),
message: "Approval needed for command execution".to_string(),
command: Some("rm -rf /tmp/test".to_string()),
file_paths: None,
idle_duration_secs: Some(5),
};
let serialized = serde_json::to_string(&notification)?;
assert!(serialized.contains(r#""type":"approval-requested""#));
assert!(serialized.contains(r#""request-type":"exec""#));
assert!(serialized.contains(r#""command":"rm -rf /tmp/test""#));
assert!(serialized.contains(r#""idle-duration-secs":5"#));
Ok(())
}

#[test]
fn test_approval_requested_edit_notification() -> Result<()> {
let notification = UserNotification::ApprovalRequested {
request_type: "edit".to_string(),
message: "Approval needed for file edits".to_string(),
command: None,
file_paths: Some(vec![
"/path/to/file1.rs".to_string(),
"/path/to/file2.rs".to_string(),
]),
idle_duration_secs: None,
};
let serialized = serde_json::to_string(&notification)?;
assert!(serialized.contains(r#""type":"approval-requested""#));
assert!(serialized.contains(r#""request-type":"edit""#));
assert!(serialized.contains(r#""file-paths""#));
assert!(serialized.contains(r#"/path/to/file1.rs"#));
Ok(())
}
}
31 changes: 31 additions & 0 deletions codex-rs/tui/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,37 @@ Most event types (exec begin/end, MCP calls, elicitation) are queued during acti
- The `InterruptManager` still contains `ExecApproval` and `ApplyPatchApproval` variants for completeness, but these methods are marked `#[allow(dead_code)]`
- `on_task_complete()` calls `flush_interrupt_queue()` for any remaining queued items

**OS-Level Approval Notifications:**

The TUI supports OS-level notifications when approval is needed, implemented via `UserNotifier` from `codex-core` and `IdleDetector`:

```
┌─────────────────────┐ check_idle() ┌─────────────────┐
│ IdleDetector │◄───────────────────│ │
│ (5-second threshold) │ ChatWidget │
└─────────────────────┘ │ │
│ - user_notifier│
┌─────────────────────┐ notify() │ - idle_detector│
│ UserNotifier │◄───────────────────│ │
│ (spawns notify │ └─────────────────┘
│ hook command) │ ▲
└─────────────────────┘ │
│ │
▼ record_activity()
┌─────────────────────┐ (on user messages,
│ ~/.nori/cli/ │ agent starts work)
│ notify-hook.sh │
└─────────────────────┘
```

- `IdleDetector` (`idle_detector.rs`): Tracks idle state using a 5-second threshold. `check_idle()` returns the idle duration once per idle period (prevents duplicate notifications). `record_activity()` resets the timer.
- `ChatWidget` fields: `user_notifier: UserNotifier` and `idle_detector: IdleDetector`
- `send_approval_notification()`: Creates `UserNotification::ApprovalRequested` with idle duration if threshold exceeded
- Activity is recorded when user submits messages (`submit_user_message`) and when agent starts (`on_task_started`)
- Approval handlers call `send_approval_notification()`: `handle_exec_approval_now()`, `handle_apply_patch_approval_now()`, `handle_elicitation_request_now()`

The notify hook is deployed to `~/.nori/cli/notify-hook.sh` during first-launch onboarding and configured in `config.toml` via `notify = ["/path/to/notify-hook.sh"]`. See `@/codex-rs/tui/src/nori/docs.md` for deployment details.

**Approval Overlay Model Display Name:**

The approval overlay displays the current agent's display name (e.g., "Claude", "Gemini") instead of a hardcoded name in options like "No, and tell Claude what to do differently". The display name flows through:
Expand Down
90 changes: 87 additions & 3 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ use std::time::Duration;
use codex_app_server_protocol::AuthMode;
#[cfg(feature = "backend-client")]
use codex_backend_client::Client as BackendClient;
use codex_core::UserNotification;
use codex_core::UserNotifier;
use codex_core::config::Config;
use codex_core::config::types::Notifications;
use codex_core::git_info::current_branch_name;
Expand Down Expand Up @@ -100,6 +102,7 @@ use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::McpToolCallCell;
use crate::history_cell::PlainHistoryCell;
use crate::idle_detector::IdleDetector;
use crate::login_handler::AgentLoginSupport;
use crate::login_handler::LoginHandler;
#[allow(unused_imports)]
Expand Down Expand Up @@ -361,6 +364,10 @@ pub(crate) struct ChatWidget {
session_stats: SessionStats,
// Login handler for /login command
login_handler: Option<LoginHandler>,
// OS-level notification handler for approval events
user_notifier: UserNotifier,
// Idle detection for approval notifications
idle_detector: IdleDetector,
}

/// Information about a pending agent switch in ChatWidget.
Expand Down Expand Up @@ -482,6 +489,8 @@ impl ChatWidget {
}

fn on_agent_message(&mut self, message: String) {
// Reset idle timer when agent produces output
self.record_activity();
// Track assistant message for session statistics
self.session_stats.record_assistant_message();

Expand Down Expand Up @@ -1210,7 +1219,16 @@ impl ChatWidget {
self.flush_answer_stream_with_separator();
let command = shlex::try_join(ev.command.iter().map(String::as_str))
.unwrap_or_else(|_| ev.command.join(" "));
self.notify(Notification::ExecApprovalRequested { command });
self.notify(Notification::ExecApprovalRequested {
command: command.clone(),
});
// Send OS-level notification via notify hook
self.send_approval_notification(
"exec",
format!("Approval requested: {}", truncate_text(&command, 50)),
Some(command),
None,
);

let request = ApprovalRequest::Exec {
id,
Expand All @@ -1229,18 +1247,40 @@ impl ChatWidget {
) {
self.flush_answer_stream_with_separator();

let file_paths: Vec<PathBuf> = ev.changes.keys().cloned().collect();
let request = ApprovalRequest::ApplyPatch {
id,
reason: ev.reason,
changes: ev.changes.clone(),
changes: ev.changes,
cwd: self.config.cwd.clone(),
};
self.bottom_pane.push_approval_request(request);
self.request_redraw();
self.notify(Notification::EditApprovalRequested {
cwd: self.config.cwd.clone(),
changes: ev.changes.keys().cloned().collect(),
changes: file_paths.clone(),
});
// Send OS-level notification via notify hook
let file_count = file_paths.len();
let message = if file_count == 1 {
format!(
"Nori wants to edit {}",
display_path_for(&file_paths[0], &self.config.cwd)
)
} else {
format!("Nori wants to edit {file_count} files")
};
self.send_approval_notification(
"edit",
message,
None,
Some(
file_paths
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect(),
),
);
}

pub(crate) fn handle_elicitation_request_now(&mut self, ev: ElicitationRequestEvent) {
Expand All @@ -1249,6 +1289,13 @@ impl ChatWidget {
self.notify(Notification::ElicitationRequested {
server_name: ev.server_name.clone(),
});
// Send OS-level notification via notify hook
self.send_approval_notification(
"elicitation",
format!("Approval requested by {}", ev.server_name),
None,
None,
);

let request = ApprovalRequest::McpElicitation {
server_name: ev.server_name,
Expand All @@ -1260,6 +1307,8 @@ impl ChatWidget {
}

pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) {
// Reset idle timer when agent starts work
self.record_activity();
// Track Bash tool call for session statistics
self.session_stats.record_tool_call("Bash");

Expand Down Expand Up @@ -1499,6 +1548,8 @@ impl ChatWidget {
acp_handle: spawn_result.acp_handle,
session_stats: SessionStats::new(),
login_handler: None,
user_notifier: UserNotifier::new(config.notify.clone()),
idle_detector: IdleDetector::new(Duration::from_secs(5)),
};

widget.prefetch_rate_limits();
Expand Down Expand Up @@ -1593,6 +1644,8 @@ impl ChatWidget {
acp_handle: None,
session_stats: SessionStats::new(),
login_handler: None,
user_notifier: UserNotifier::new(config.notify.clone()),
idle_detector: IdleDetector::new(Duration::from_secs(5)),
};

widget.prefetch_rate_limits();
Expand Down Expand Up @@ -1938,6 +1991,9 @@ impl ChatWidget {
return;
}

// Reset idle timer when user sends a message
self.record_activity();

// Special-case: "/login <agent>" triggers login for a specific agent
// This intercepts before the message is sent to the agent
if let Some(agent_name) = text.strip_prefix("/login ").map(str::trim)
Expand Down Expand Up @@ -2256,6 +2312,34 @@ impl ChatWidget {
self.request_redraw();
}

/// Send an OS-level notification for an approval request.
/// This triggers the external notify hook if configured.
fn send_approval_notification(
&mut self,
request_type: &str,
message: String,
command: Option<String>,
file_paths: Option<Vec<String>>,
) {
// Check idle duration and include it if threshold was exceeded
let idle_duration = self.idle_detector.check_idle();
let idle_duration_secs = idle_duration.map(|d| d.as_secs());

let notification = UserNotification::ApprovalRequested {
request_type: request_type.to_string(),
message,
command,
file_paths,
idle_duration_secs,
};
self.user_notifier.notify(&notification);
}

/// Record user/agent activity to reset the idle timer.
fn record_activity(&mut self) {
self.idle_detector.record_activity();
}

pub(crate) fn maybe_post_pending_notification(&mut self, tui: &mut crate::tui::Tui) {
if let Some(notif) = self.pending_notification.take() {
tui.notify(notif.display());
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/tui/src/chatwidget/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,8 @@ pub(crate) fn make_chatwidget_manual() -> (
acp_handle: None,
session_stats: crate::session_stats::SessionStats::new(),
login_handler: None,
user_notifier: UserNotifier::new(cfg.notify.clone()),
idle_detector: IdleDetector::new(Duration::from_secs(5)),
};
(widget, rx, op_rx)
}
Expand Down
Loading
Loading