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:
@@ -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<Vec<String>>,
|
||||
|
||||
/// 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()]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,9 +76,26 @@ pub enum HistoryPersistence {
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Notifications {
|
||||
Enabled(bool),
|
||||
Custom(Vec<String>),
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user