notifications on approvals and turn end (#3329)

uses OSC 9 to notify when a turn ends or approval is required. won't
work in vs code or terminal.app but iterm2/kitty/wezterm supports it :)
This commit is contained in:
Jeremy Rose
2025-09-15 10:22:02 -07:00
committed by GitHub
parent 0de154194d
commit 0560079c41
8 changed files with 216 additions and 4 deletions

View File

@@ -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())

View File

@@ -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<UserMessage>,
// Pending notification to show when unfocused on next Draw
pending_notification: Option<Notification>,
}
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<TokenUsageInfo>) {
@@ -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<PathBuf> },
}
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",

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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<AtomicU16>, // Bottom line of inline viewport
// True when overlay alt-screen UI is active
alt_screen_active: Arc<AtomicBool>,
// True when terminal/tab is focused; updated internally from crossterm events
terminal_focused: Arc<AtomicBool>,
}
#[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<str>) -> 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
}
}