diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index f9693306..0479e857 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -1,6 +1,7 @@ use crate::config_profile::ConfigProfile; use crate::config_types::History; use crate::config_types::McpServerConfig; +use crate::config_types::Notifications; use crate::config_types::ReasoningSummaryFormat; use crate::config_types::SandboxWorkspaceWrite; use crate::config_types::ShellEnvironmentPolicy; @@ -117,6 +118,10 @@ pub struct Config { /// If unset the feature is disabled. pub notify: Option>, + /// TUI notifications preference. When set, the TUI will send OSC 9 notifications on approvals + /// and turn completions when not focused. + pub tui_notifications: Notifications, + /// The directory that should be treated as the current working directory /// for the session. All relative paths inside the business-logic layer are /// resolved against this path. @@ -1043,6 +1048,11 @@ impl Config { include_view_image_tool, active_profile: active_profile_name, disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), + tui_notifications: cfg + .tui + .as_ref() + .map(|t| t.notifications.clone()) + .unwrap_or_default(), }; Ok(config) } @@ -1606,6 +1616,7 @@ model_verbosity = "high" include_view_image_tool: true, active_profile: Some("o3".to_string()), disable_paste_burst: false, + tui_notifications: Default::default(), }, o3_profile_config ); @@ -1663,6 +1674,7 @@ model_verbosity = "high" include_view_image_tool: true, active_profile: Some("gpt3".to_string()), disable_paste_burst: false, + tui_notifications: Default::default(), }; assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); @@ -1735,6 +1747,7 @@ model_verbosity = "high" include_view_image_tool: true, active_profile: Some("zdr".to_string()), disable_paste_burst: false, + tui_notifications: Default::default(), }; assert_eq!(expected_zdr_profile_config, zdr_profile_config); @@ -1793,6 +1806,7 @@ model_verbosity = "high" include_view_image_tool: true, active_profile: Some("gpt5".to_string()), disable_paste_burst: false, + tui_notifications: Default::default(), }; assert_eq!(expected_gpt5_profile_config, gpt5_profile_config); @@ -1896,3 +1910,46 @@ trust_level = "trusted" Ok(()) } } + +#[cfg(test)] +mod notifications_tests { + use crate::config_types::Notifications; + use serde::Deserialize; + + #[derive(Deserialize, Debug, PartialEq)] + struct TuiTomlTest { + notifications: Notifications, + } + + #[derive(Deserialize, Debug, PartialEq)] + struct RootTomlTest { + tui: TuiTomlTest, + } + + #[test] + fn test_tui_notifications_true() { + let toml = r#" + [tui] + notifications = true + "#; + let parsed: RootTomlTest = toml::from_str(toml).expect("deserialize notifications=true"); + assert!(matches!( + parsed.tui.notifications, + Notifications::Enabled(true) + )); + } + + #[test] + fn test_tui_notifications_custom_array() { + let toml = r#" + [tui] + notifications = ["foo"] + "#; + let parsed: RootTomlTest = + toml::from_str(toml).expect("deserialize notifications=[\"foo\"]"); + assert!(matches!( + parsed.tui.notifications, + Notifications::Custom(ref v) if v == &vec!["foo".to_string()] + )); + } +} diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 0722dfc0..ec8e8e67 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -76,9 +76,26 @@ pub enum HistoryPersistence { None, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +pub enum Notifications { + Enabled(bool), + Custom(Vec), +} + +impl Default for Notifications { + fn default() -> Self { + Self::Enabled(false) + } +} + /// Collection of settings that are specific to the TUI. #[derive(Deserialize, Debug, Clone, PartialEq, Default)] -pub struct Tui {} +pub struct Tui { + /// Enable desktop notifications from the TUI when the terminal is unfocused. + /// Defaults to `false`. + pub notifications: Notifications, +} #[derive(Deserialize, Debug, Clone, PartialEq, Default)] pub struct SandboxWorkspaceWrite { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index bfa9c0ca..205986c9 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -177,6 +177,7 @@ impl App { self.chat_widget.handle_paste(pasted); } TuiEvent::Draw => { + self.chat_widget.maybe_post_pending_notification(tui); if self .chat_widget .handle_paste_burst_tick(tui.frame_requester()) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 367a63cf..c03e7d3b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use std::sync::Arc; use codex_core::config::Config; +use codex_core::config_types::Notifications; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningDeltaEvent; @@ -59,6 +60,7 @@ use crate::bottom_pane::InputResult; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::clipboard_paste::paste_image_to_temp_png; +use crate::diff_render::display_path_for; use crate::get_git_diff::get_git_diff; use crate::history_cell; use crate::history_cell::CommandOutput; @@ -66,6 +68,7 @@ use crate::history_cell::ExecCell; use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; use crate::slash_command::SlashCommand; +use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; // streaming internals are provided by crate::streaming and crate::markdown_stream use crate::user_approval_widget::ApprovalRequest; @@ -136,6 +139,8 @@ pub(crate) struct ChatWidget { suppress_session_configured_redraw: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, + // Pending notification to show when unfocused on next Draw + pending_notification: Option, } struct UserMessage { @@ -265,6 +270,8 @@ impl ChatWidget { // If there is a queued user message, send exactly one now to begin the next turn. self.maybe_send_next_queued_input(); + // Emit a notification when the turn completes (suppressed if focused). + self.notify(Notification::AgentTurnComplete); } pub(crate) fn set_token_info(&mut self, info: Option) { @@ -531,6 +538,9 @@ impl ChatWidget { self.flush_answer_stream_with_separator(); // Emit the proposed command into history (like proposed patches) self.add_to_history(history_cell::new_proposed_command(&ev.command)); + let command = shlex::try_join(ev.command.iter().map(|s| s.as_str())) + .unwrap_or_else(|_| ev.command.join(" ")); + self.notify(Notification::ExecApprovalRequested { command }); let request = ApprovalRequest::Exec { id, @@ -560,6 +570,10 @@ impl ChatWidget { }; 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(), + }); } pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { @@ -686,6 +700,7 @@ impl ChatWidget { queued_user_messages: VecDeque::new(), show_welcome_banner: true, suppress_session_configured_redraw: false, + pending_notification: None, } } @@ -741,6 +756,7 @@ impl ChatWidget { queued_user_messages: VecDeque::new(), show_welcome_banner: true, suppress_session_configured_redraw: true, + pending_notification: None, } } @@ -1137,6 +1153,20 @@ impl ChatWidget { self.frame_requester.schedule_frame(); } + fn notify(&mut self, notification: Notification) { + if !notification.allowed_for(&self.config.tui_notifications) { + return; + } + self.pending_notification = Some(notification); + self.request_redraw(); + } + + 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()); + } + } + /// Mark the active exec cell as failed (✗) and flush it into history. fn finalize_active_exec_cell_as_failed(&mut self) { if let Some(cell) = self.active_exec_cell.take() { @@ -1449,6 +1479,49 @@ impl WidgetRef for &ChatWidget { } } +enum Notification { + AgentTurnComplete, + ExecApprovalRequested { command: String }, + EditApprovalRequested { cwd: PathBuf, changes: Vec }, +} + +impl Notification { + fn display(&self) -> String { + match self { + Notification::AgentTurnComplete => "Agent turn complete".to_string(), + Notification::ExecApprovalRequested { command } => { + format!("Approval requested: {}", truncate_text(command, 30)) + } + Notification::EditApprovalRequested { cwd, changes } => { + format!( + "Codex wants to edit {}", + if changes.len() == 1 { + #[allow(clippy::unwrap_used)] + display_path_for(changes.first().unwrap(), cwd) + } else { + format!("{} files", changes.len()) + } + ) + } + } + } + + fn type_name(&self) -> &str { + match self { + Notification::AgentTurnComplete => "agent-turn-complete", + Notification::ExecApprovalRequested { .. } + | Notification::EditApprovalRequested { .. } => "approval-requested", + } + } + + fn allowed_for(&self, settings: &Notifications) -> bool { + match settings { + Notifications::Enabled(enabled) => *enabled, + Notifications::Custom(allowed) => allowed.iter().any(|a| a == self.type_name()), + } + } +} + const EXAMPLE_PROMPTS: [&str; 6] = [ "Explain this codebase", "Summarize recent commits", diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 73845bfe..fb4ea5e8 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -251,6 +251,7 @@ fn make_chatwidget_manual() -> ( show_welcome_banner: true, queued_user_messages: VecDeque::new(), suppress_session_configured_redraw: false, + pending_notification: None, }; (widget, rx, op_rx) } diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 20d26106..2787f766 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -265,7 +265,7 @@ fn render_changes_block( out } -fn display_path_for(path: &Path, cwd: &Path) -> String { +pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String { let path_in_same_repo = match (get_git_repo_root(cwd), get_git_repo_root(path)) { (Some(cwd_repo), Some(path_repo)) => cwd_repo == path_repo, _ => false, diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 26c18866..6d7b9e81 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -17,7 +17,9 @@ use crossterm::SynchronizedUpdate; use crossterm::cursor; use crossterm::cursor::MoveTo; use crossterm::event::DisableBracketedPaste; +use crossterm::event::DisableFocusChange; use crossterm::event::EnableBracketedPaste; +use crossterm::event::EnableFocusChange; use crossterm::event::Event; use crossterm::event::KeyEvent; use crossterm::event::KeyboardEnhancementFlags; @@ -60,6 +62,8 @@ pub fn set_modes() -> Result<()> { | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS ) ); + + let _ = execute!(stdout(), EnableFocusChange); Ok(()) } @@ -111,6 +115,7 @@ pub fn restore() -> Result<()> { // Pop may fail on platforms that didn't support the push; ignore errors. let _ = execute!(stdout(), PopKeyboardEnhancementFlags); execute!(stdout(), DisableBracketedPaste)?; + let _ = execute!(stdout(), DisableFocusChange); disable_raw_mode()?; let _ = execute!(stdout(), crossterm::cursor::Show); Ok(()) @@ -163,6 +168,8 @@ pub struct Tui { suspend_cursor_y: Arc, // Bottom line of inline viewport // True when overlay alt-screen UI is active alt_screen_active: Arc, + // True when terminal/tab is focused; updated internally from crossterm events + terminal_focused: Arc, } #[cfg(unix)] @@ -214,6 +221,16 @@ impl FrameRequester { } impl Tui { + /// Emit a desktop notification now if the terminal is unfocused. + /// Returns true if a notification was posted. + pub fn notify(&mut self, message: impl AsRef) -> bool { + if !self.terminal_focused.load(Ordering::Relaxed) { + let _ = execute!(stdout(), PostNotification(message.as_ref().to_string())); + true + } else { + false + } + } pub fn new(terminal: Terminal) -> Self { let (frame_schedule_tx, frame_schedule_rx) = tokio::sync::mpsc::unbounded_channel(); let (draw_tx, _) = tokio::sync::broadcast::channel(1); @@ -270,6 +287,7 @@ impl Tui { #[cfg(unix)] suspend_cursor_y: Arc::new(AtomicU16::new(0)), alt_screen_active: Arc::new(AtomicBool::new(false)), + terminal_focused: Arc::new(AtomicBool::new(true)), } } @@ -289,6 +307,7 @@ impl Tui { let alt_screen_active = self.alt_screen_active.clone(); #[cfg(unix)] let suspend_cursor_y = self.suspend_cursor_y.clone(); + let terminal_focused = self.terminal_focused.clone(); let event_stream = async_stream::stream! { loop { select! { @@ -332,6 +351,12 @@ impl Tui { Event::Paste(pasted) => { yield TuiEvent::Paste(pasted); } + Event::FocusGained => { + terminal_focused.store(true, Ordering::Relaxed); + } + Event::FocusLost => { + terminal_focused.store(false, Ordering::Relaxed); + } _ => {} } } @@ -535,3 +560,25 @@ impl Tui { })? } } + +/// Command that emits an OSC 9 desktop notification with a message. +#[derive(Debug, Clone)] +pub struct PostNotification(pub String); + +impl Command for PostNotification { + fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result { + write!(f, "\x1b]9;{}\x07", self.0) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + Err(std::io::Error::other( + "tried to execute PostNotification using WinAPI; use ANSI instead", + )) + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + true + } +} diff --git a/docs/config.md b/docs/config.md index 6f10cb3f..01b3efe0 100644 --- a/docs/config.md +++ b/docs/config.md @@ -504,6 +504,9 @@ To have Codex use this script for notifications, you would configure it via `not notify = ["python3", "/Users/mbolin/.codex/notify.py"] ``` +> [!NOTE] +> Use `notify` for automation and integrations: Codex invokes your external program with a single JSON argument for each event, independent of the TUI. If you only want lightweight desktop notifications while using the TUI, prefer `tui.notifications`, which uses terminal escape codes and requires no external program. You can enable both; `tui.notifications` covers in‑TUI alerts (e.g., approval prompts), while `notify` is best for system‑level hooks or custom notifiers. Currently, `notify` emits only `agent-turn-complete`, whereas `tui.notifications` supports `agent-turn-complete` and `approval-requested` with optional filtering. + ## history By default, Codex CLI records messages sent to the model in `$CODEX_HOME/history.jsonl`. Note that on UNIX, the file permissions are set to `o600`, so it should only be readable and writable by the owner. @@ -576,9 +579,21 @@ Options that are specific to the TUI. ```toml [tui] -# More to come here +# Send desktop notifications when approvals are required or a turn completes. +# Defaults to false. +notifications = true + +# You can optionally filter to specific notification types. +# Available types are "agent-turn-complete" and "approval-requested". +notifications = [ "agent-turn-complete", "approval-requested" ] ``` +> [!NOTE] +> Codex emits desktop notifications using terminal escape codes. Not all terminals support these (notably, macOS Terminal.app and VS Code's terminal do not support custom notifications. iTerm2, Ghostty and WezTerm do support these notifications). + +> [!NOTE] +> `tui.notifications` is built‑in and limited to the TUI session. For programmatic or cross‑environment notifications—or to integrate with OS‑specific notifiers—use the top‑level `notify` option to run an external program that receives event JSON. The two settings are independent and can be used together. + ## Config reference | Key | Type / Values | Notes | @@ -616,7 +631,8 @@ Options that are specific to the TUI. | `history.persistence` | `save-all` \| `none` | History file persistence (default: `save-all`). | | `history.max_bytes` | number | Currently ignored (not enforced). | | `file_opener` | `vscode` \| `vscode-insiders` \| `windsurf` \| `cursor` \| `none` | URI scheme for clickable citations (default: `vscode`). | -| `tui` | table | TUI‑specific options (reserved). | +| `tui` | table | TUI‑specific options. | +| `tui.notifications` | boolean \| array | Enable desktop notifications in the tui (default: false). | | `hide_agent_reasoning` | boolean | Hide model reasoning events. | | `show_raw_agent_reasoning` | boolean | Show raw reasoning (when available). | | `model_reasoning_effort` | `minimal` \| `low` \| `medium` \| `high` | Responses API reasoning effort. |