diff --git a/config.example.toml b/config.example.toml index 3a9aee2f..5c442932 100644 --- a/config.example.toml +++ b/config.example.toml @@ -241,6 +241,10 @@ alternate_screen = "auto" # auto | always | never mouse_capture = true # true copies only transcript user/assistant text; false uses raw terminal selection/copy terminal_probe_timeout_ms = 500 # optional startup terminal-mode timeout (100-5000ms) osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTerm2/Ghostty/Kitty/WezTerm/Terminal.app 13+); set false for terminals that misrender +# notification_condition = "always" # always | never — overrides [notifications].threshold_secs. +# "always" = notify on every successful turn (no threshold); +# "never" = suppress all turn-completion notifications; +# unset = use [notifications] defaults (recommended). # locale = "auto" # UI chrome language: auto | en | ja | zh-Hans | pt-BR # # "auto" reads LC_ALL → LC_MESSAGES → LANG; falls back to English. # # Override: `locale = "zh-Hans"` for Simplified Chinese regardless of OS locale. diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index da462752..cc139bd1 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -289,6 +289,27 @@ pub struct TuiConfig { /// label and ignore the escape. Defaults to `true`; set `false` for /// terminals that misrender the sequence. pub osc8_links: Option, + /// High-level notification trigger condition. When set, overrides the + /// `[notifications].threshold_secs` gate from the lower-level + /// `[notifications]` block: + /// + /// - `Always` — fire a turn-completion notification on every successful + /// turn regardless of duration. The configured `[notifications].method` + /// and `include_summary` flag are still respected. + /// - `Never` — suppress all turn-completion notifications. + /// - Unset (default) — fall back to the `[notifications]` defaults. + pub notification_condition: Option, +} + +/// High-level notification trigger override. See +/// [`TuiConfig::notification_condition`]. +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum NotificationCondition { + /// Notify on every successful turn (no duration threshold). + Always, + /// Suppress notifications entirely. + Never, } /// Notification delivery method (mirrors `tui::notifications::Method`). diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 02dcb9b6..cadfa8b8 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -4348,6 +4348,7 @@ mod terminal_mode_tests { terminal_probe_timeout_ms: None, status_items: None, osc8_links: None, + notification_condition: None, }), ..Config::default() }; @@ -4373,6 +4374,7 @@ mod terminal_mode_tests { terminal_probe_timeout_ms: None, status_items: None, osc8_links: None, + notification_condition: None, }), ..Config::default() }; @@ -4446,6 +4448,7 @@ mod terminal_mode_tests { terminal_probe_timeout_ms: None, status_items: None, osc8_links: None, + notification_condition: None, }), ..Config::default() }; diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index 2d777cb2..88188349 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -34,19 +34,6 @@ pub enum Method { Off, } -impl Method { - /// Parse from a configuration string (case-insensitive). - #[must_use] - pub fn from_str(s: &str) -> Self { - match s.trim().to_ascii_lowercase().as_str() { - "osc9" | "osc-9" => Self::Osc9, - "bel" => Self::Bel, - "off" | "disabled" | "none" => Self::Off, - _ => Self::Auto, - } - } -} - /// Emit a Windows system beep via `MessageBeep(MB_OK)`. /// /// Writing BEL (`\\x07`) to the terminal is silent on most Windows diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 13ca4425..a6283172 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -956,33 +956,18 @@ async fn run_event_loop( } // Emit OSC 9 / BEL desktop notification for long turns. - if status == crate::core::events::TurnOutcomeStatus::Completed { - let notif = config.notifications_config(); - let method = - crate::tui::notifications::Method::from_str(match ¬if.method { - crate::config::NotificationMethod::Auto => "auto", - crate::config::NotificationMethod::Osc9 => "osc9", - crate::config::NotificationMethod::Bel => "bel", - crate::config::NotificationMethod::Off => "off", - }); - let threshold = std::time::Duration::from_secs(notif.threshold_secs); + if status == crate::core::events::TurnOutcomeStatus::Completed + && let Some((method, threshold, include_summary)) = + notification_settings(config) + { let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty()); - let msg = if notif.include_summary { - let human = - crate::tui::notifications::humanize_duration(turn_elapsed); - match turn_cost { - Some(c) => { - let cost = crate::pricing::format_cost_estimate( - c, - app.cost_currency, - ); - format!("deepseek: turn complete ({human}, {cost})") - } - None => format!("deepseek: turn complete ({human})"), - } - } else { - "deepseek: turn complete".to_string() - }; + let msg = completed_turn_notification_message( + app, + ¤t_streaming_text, + include_summary, + turn_elapsed, + turn_cost, + ); crate::tui::notifications::notify_done( method, in_tmux, @@ -3030,6 +3015,130 @@ fn sanitize_stream_chunk(chunk: &str) -> String { .collect() } +/// Resolve the effective notification method/threshold/include-summary tuple +/// for a completed turn, taking the high-level +/// `[tui].notification_condition` override into account on top of the +/// lower-level `[notifications]` block. +/// +/// Returns `None` to mean "do not notify" (either because the user set +/// `notification_condition = "never"` or because the resolved method is +/// `Off`). +fn notification_settings( + config: &Config, +) -> Option<(crate::tui::notifications::Method, Duration, bool)> { + let notif = config.notifications_config(); + let method = match notif.method { + crate::config::NotificationMethod::Auto => crate::tui::notifications::Method::Auto, + crate::config::NotificationMethod::Osc9 => crate::tui::notifications::Method::Osc9, + crate::config::NotificationMethod::Bel => crate::tui::notifications::Method::Bel, + crate::config::NotificationMethod::Off => crate::tui::notifications::Method::Off, + }; + + if let Some(condition) = config + .tui + .as_ref() + .and_then(|tui| tui.notification_condition) + { + match condition { + crate::config::NotificationCondition::Always => { + return Some((method, Duration::ZERO, notif.include_summary)); + } + crate::config::NotificationCondition::Never => return None, + } + } + + Some(( + method, + Duration::from_secs(notif.threshold_secs), + notif.include_summary, + )) +} + +/// Build the notification body for a completed turn. Prefers the live +/// streaming text the user just saw; falls back to the latest assistant +/// message in `api_messages` if streaming text is empty (for example, the +/// turn finished entirely through tool output). When `include_summary` is +/// true, an elapsed/cost line is appended. +fn completed_turn_notification_message( + app: &App, + current_streaming_text: &str, + include_summary: bool, + turn_elapsed: Duration, + turn_cost: Option, +) -> String { + let mut msg = notification_text_summary(current_streaming_text) + .or_else(|| latest_assistant_notification_text(&app.api_messages)) + .unwrap_or_else(|| "deepseek: turn complete".to_string()); + + if include_summary { + let human = crate::tui::notifications::humanize_duration(turn_elapsed); + let summary = match turn_cost { + Some(c) => { + let cost = crate::pricing::format_cost_estimate(c, app.cost_currency); + format!("deepseek: turn complete ({human}, {cost})") + } + None => format!("deepseek: turn complete ({human})"), + }; + if msg == "deepseek: turn complete" { + msg = summary; + } else { + msg.push('\n'); + msg.push_str(&summary); + } + } + + msg +} + +fn latest_assistant_notification_text(messages: &[Message]) -> Option { + messages + .iter() + .rev() + .find(|message| message.role == "assistant") + .and_then(|message| { + let text = message + .content + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text, .. } => Some(text.as_str()), + ContentBlock::Thinking { .. } + | ContentBlock::ToolUse { .. } + | ContentBlock::ToolResult { .. } + | ContentBlock::ServerToolUse { .. } + | ContentBlock::ToolSearchToolResult { .. } + | ContentBlock::CodeExecutionToolResult { .. } => None, + }) + .collect::>() + .join("\n"); + notification_text_summary(&text) + }) +} + +fn notification_text_summary(text: &str) -> Option { + const MAX_CHARS: usize = 360; + + let sanitized = sanitize_stream_chunk(text); + let collapsed = sanitized + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join("\n"); + let trimmed = collapsed.trim(); + if trimmed.is_empty() { + return None; + } + + if let Some((idx, _)) = trimmed.char_indices().nth(MAX_CHARS) { + let mut s = String::with_capacity(idx + 3); + s.push_str(&trimmed[..idx]); + s.push_str("..."); + Some(s) + } else { + Some(trimmed.to_string()) + } +} + /// Ensure an in-flight streaming Assistant cell exists in history and return /// its index. Thinking cells go through `ensure_streaming_thinking_active_entry` /// (active cell) instead. diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 32337bd3..9c139bd7 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -662,6 +662,7 @@ fn terminal_probe_timeout_uses_tui_config_and_clamps() { terminal_probe_timeout_ms: Some(750), status_items: None, osc8_links: None, + notification_condition: None, }), ..Config::default() }; @@ -3390,3 +3391,123 @@ fn scroll_with_arrows_returns_false_when_input_has_text() { "text in composer: Up/Down should navigate history" ); } + +#[test] +fn notification_settings_tui_always_keeps_configured_method_no_threshold() { + let config = Config { + tui: Some(crate::config::TuiConfig { + notification_condition: Some(crate::config::NotificationCondition::Always), + ..Default::default() + }), + notifications: Some(crate::config::NotificationsConfig { + method: crate::config::NotificationMethod::Bel, + threshold_secs: 120, + include_summary: true, + }), + ..Config::default() + }; + + let (method, threshold, include_summary) = + super::notification_settings(&config).expect("notification should be enabled"); + assert_eq!(method, crate::tui::notifications::Method::Bel); + assert_eq!(threshold, Duration::ZERO); + assert!(include_summary); +} + +#[test] +fn notification_settings_tui_never_disables_notifications() { + let config = Config { + tui: Some(crate::config::TuiConfig { + notification_condition: Some(crate::config::NotificationCondition::Never), + ..Default::default() + }), + ..Config::default() + }; + + assert!(super::notification_settings(&config).is_none()); +} + +#[test] +fn notification_settings_no_tui_override_uses_notifications_block() { + let config = Config { + notifications: Some(crate::config::NotificationsConfig { + method: crate::config::NotificationMethod::Osc9, + threshold_secs: 45, + include_summary: false, + }), + ..Config::default() + }; + + let (method, threshold, include_summary) = + super::notification_settings(&config).expect("notification should be enabled"); + assert_eq!(method, crate::tui::notifications::Method::Osc9); + assert_eq!(threshold, Duration::from_secs(45)); + assert!(!include_summary); +} + +#[test] +fn completed_turn_notification_uses_streaming_text() { + let app = create_test_app(); + let msg = super::completed_turn_notification_message( + &app, + "Hello there.\n\nWhat's next?", + false, + Duration::from_secs(12), + None, + ); + assert_eq!(msg, "Hello there.\nWhat's next?"); +} + +#[test] +fn completed_turn_notification_falls_back_to_latest_assistant_message() { + let mut app = create_test_app(); + app.api_messages.push(crate::models::Message { + role: "assistant".to_string(), + content: vec![crate::models::ContentBlock::Text { + text: "Earlier turn".to_string(), + cache_control: None, + }], + }); + app.api_messages.push(crate::models::Message { + role: "user".to_string(), + content: vec![crate::models::ContentBlock::Text { + text: "next".to_string(), + cache_control: None, + }], + }); + app.api_messages.push(crate::models::Message { + role: "assistant".to_string(), + content: vec![crate::models::ContentBlock::Text { + text: "Latest reply".to_string(), + cache_control: None, + }], + }); + + let msg = + super::completed_turn_notification_message(&app, "", false, Duration::from_secs(75), None); + assert_eq!(msg, "Latest reply"); +} + +#[test] +fn completed_turn_notification_falls_back_to_default_when_empty() { + let app = create_test_app(); + let msg = + super::completed_turn_notification_message(&app, "", false, Duration::from_secs(5), None); + assert_eq!(msg, "deepseek: turn complete"); +} + +#[test] +fn completed_turn_notification_truncates_long_text() { + let app = create_test_app(); + let long = "a".repeat(500); + let msg = super::completed_turn_notification_message( + &app, + &long, + false, + Duration::from_secs(5), + None, + ); + assert!(msg.ends_with("...")); + // 360-char body + 3-char ellipsis + assert_eq!(msg.chars().count(), 363); +}