diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index 94f2e7c2..f558b67b 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -270,6 +270,173 @@ pub fn humanize_duration(d: Duration) -> String { format!("{total}s") } +// ── Per-turn notification composition ──────────────────────────────── +// +// The helpers below decide *whether* to notify on a completed turn and +// *what message* to put in the body. The low-level dispatcher is +// `notify_done`; everything in this block sits in front of it. + +use crate::models::{ContentBlock, Message}; +use crate::tui::app::App; + +/// 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`). +pub fn settings(config: &crate::config::Config) -> Option<(Method, Duration, bool)> { + let notif = config.notifications_config(); + let method = match notif.method { + crate::config::NotificationMethod::Auto => Method::Auto, + crate::config::NotificationMethod::Osc9 => Method::Osc9, + crate::config::NotificationMethod::Bel => Method::Bel, + crate::config::NotificationMethod::Kitty => Method::Kitty, + crate::config::NotificationMethod::Ghostty => Method::Ghostty, + crate::config::NotificationMethod::Off => 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. +pub fn completed_turn_message( + app: &App, + current_streaming_text: &str, + include_summary: bool, + turn_elapsed: Duration, + turn_cost: Option, +) -> String { + let mut msg = text_summary(current_streaming_text) + .or_else(|| latest_assistant_text(&app.api_messages)) + .unwrap_or_else(|| "deepseek: turn complete".to_string()); + + if include_summary { + let human = 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 +} + +/// Compose a notification body for a sub-agent completion. Falls back +/// to a generic "sub-agent X complete" if no human-readable line can +/// be teased out of the child's transcript. +pub fn subagent_completion_message( + id: &str, + result: &str, + include_summary: bool, + elapsed: Duration, +) -> String { + let result_line = result + .lines() + .map(str::trim) + .find(|line| !line.is_empty() && !line.starts_with("")); + let mut msg = result_line + .and_then(text_summary) + .map(|summary| format!("sub-agent {id}: {summary}")) + .unwrap_or_else(|| format!("deepseek: sub-agent {id} complete")); + + if include_summary { + let human = humanize_duration(elapsed); + msg.push('\n'); + msg.push_str(&format!("deepseek: sub-agent complete ({human})")); + } + + msg +} + +/// Find the latest assistant message in `messages` and return a +/// notification-ready summary of its `Text` content. Thinking blocks, +/// tool calls, and tool results are skipped — only the user-visible +/// reply contributes to the body. +pub fn latest_assistant_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"); + text_summary(&text) + }) +} + +/// Sanitize + collapse + truncate streaming text into something fit to +/// hand the OS notification system. Returns `None` when nothing +/// useful remains after sanitization. +pub fn text_summary(text: &str) -> Option { + const MAX_CHARS: usize = 360; + + let sanitized = super::ui::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()) + } +} + #[cfg(test)] mod tests { use std::sync::{Mutex, OnceLock}; diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 671c6b0a..3058a79a 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -73,6 +73,7 @@ use crate::tui::auto_router; use crate::tui::vim_mode; use crate::tui::streaming_thinking; use crate::tui::workspace_context; +use crate::tui::notifications; use crate::tui::onboarding; use crate::tui::pager::PagerView; use crate::tui::persistence_actor::{self, PersistRequest}; @@ -1308,10 +1309,10 @@ async fn run_event_loop( // Emit OSC 9 / BEL desktop notification for long turns. if status == crate::core::events::TurnOutcomeStatus::Completed && let Some((method, threshold, include_summary)) = - notification_settings(config) + notifications::settings(config) { let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty()); - let msg = completed_turn_notification_message( + let msg = notifications::completed_turn_message( app, ¤t_streaming_text, include_summary, @@ -1567,10 +1568,10 @@ async fn run_event_loop( !has_other_running_subagents && app.use_alt_screen; if !has_other_running_subagents && let Some((method, threshold, include_summary)) = - notification_settings(config) + notifications::settings(config) { let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty()); - let msg = subagent_completion_notification_message( + let msg = notifications::subagent_completion_message( &id, &result, include_summary, @@ -3644,7 +3645,10 @@ fn persist_offline_queue_state(app: &App) { } } -fn sanitize_stream_chunk(chunk: &str) -> String { +/// Strip ANSI control codes / non-printable bytes from a streaming +/// text chunk. `pub(super)` because `tui::notifications` consumes it +/// from `super::ui` for its per-turn message composition. +pub(super) fn sanitize_stream_chunk(chunk: &str) -> String { // Keep printable characters and common whitespace; drop control bytes. chunk .chars() @@ -3652,155 +3656,8 @@ 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::Kitty => crate::tui::notifications::Method::Kitty, - crate::config::NotificationMethod::Ghostty => crate::tui::notifications::Method::Ghostty, - 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 subagent_completion_notification_message( - id: &str, - result: &str, - include_summary: bool, - elapsed: Duration, -) -> String { - let result_line = result - .lines() - .map(str::trim) - .find(|line| !line.is_empty() && !line.starts_with("")); - let mut msg = result_line - .and_then(notification_text_summary) - .map(|summary| format!("sub-agent {id}: {summary}")) - .unwrap_or_else(|| format!("deepseek: sub-agent {id} complete")); - - if include_summary { - let human = crate::tui::notifications::humanize_duration(elapsed); - msg.push('\n'); - msg.push_str(&format!("deepseek: sub-agent complete ({human})")); - } - - 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()) - } -} +// Per-turn notification composition (settings, message body, summary) +// moved to `tui/notifications.rs` alongside the dispatch primitives. /// Ensure an in-flight streaming Assistant cell exists in history and return /// its index. Thinking cells go through `streaming_thinking::ensure_active_entry` diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 378a6496..ebb1a318 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::config::{ApiProvider, Config}; +use crate::tui::active_cell::ActiveCell; use crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent}; use crate::core::engine::mock_engine_handle; use crate::tui::file_mention::{ @@ -5160,7 +5161,7 @@ fn notification_settings_tui_always_keeps_configured_method_no_threshold() { }; let (method, threshold, include_summary) = - super::notification_settings(&config).expect("notification should be enabled"); + crate::tui::notifications::settings(&config).expect("notification should be enabled"); assert_eq!(method, crate::tui::notifications::Method::Bel); assert_eq!(threshold, Duration::ZERO); assert!(include_summary); @@ -5176,7 +5177,7 @@ fn notification_settings_tui_never_disables_notifications() { ..Config::default() }; - assert!(super::notification_settings(&config).is_none()); + assert!(crate::tui::notifications::settings(&config).is_none()); } #[test] @@ -5191,7 +5192,7 @@ fn notification_settings_no_tui_override_uses_notifications_block() { }; let (method, threshold, include_summary) = - super::notification_settings(&config).expect("notification should be enabled"); + crate::tui::notifications::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); @@ -5200,7 +5201,7 @@ fn notification_settings_no_tui_override_uses_notifications_block() { #[test] fn completed_turn_notification_uses_streaming_text() { let app = create_test_app(); - let msg = super::completed_turn_notification_message( + let msg = crate::tui::notifications::completed_turn_message( &app, "Hello there.\n\nWhat's next?", false, @@ -5236,7 +5237,7 @@ fn completed_turn_notification_falls_back_to_latest_assistant_message() { }); let msg = - super::completed_turn_notification_message(&app, "", false, Duration::from_secs(75), None); + crate::tui::notifications::completed_turn_message(&app, "", false, Duration::from_secs(75), None); assert_eq!(msg, "Latest reply"); } @@ -5244,7 +5245,7 @@ fn completed_turn_notification_falls_back_to_latest_assistant_message() { 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); + crate::tui::notifications::completed_turn_message(&app, "", false, Duration::from_secs(5), None); assert_eq!(msg, "deepseek: turn complete"); } @@ -5252,7 +5253,7 @@ fn completed_turn_notification_falls_back_to_default_when_empty() { fn completed_turn_notification_truncates_long_text() { let app = create_test_app(); let long = "a".repeat(500); - let msg = super::completed_turn_notification_message( + let msg = crate::tui::notifications::completed_turn_message( &app, &long, false, @@ -5266,7 +5267,7 @@ fn completed_turn_notification_truncates_long_text() { #[test] fn subagent_completion_notification_uses_summary_line_not_sentinel() { - let msg = super::subagent_completion_notification_message( + let msg = crate::tui::notifications::subagent_completion_message( "agent_live", "Finished the docs audit.\n{}", false, @@ -5279,7 +5280,7 @@ fn subagent_completion_notification_uses_summary_line_not_sentinel() { #[test] fn subagent_completion_notification_can_include_elapsed_summary() { - let msg = super::subagent_completion_notification_message( + let msg = crate::tui::notifications::subagent_completion_message( "agent_live", "", true,