diff --git a/src-rust/crates/cli/src/main.rs b/src-rust/crates/cli/src/main.rs index 6ac509f..611d65e 100644 --- a/src-rust/crates/cli/src/main.rs +++ b/src-rust/crates/cli/src/main.rs @@ -1715,6 +1715,8 @@ async fn run_interactive( app.provider_registry = base_query_config.provider_registry.clone(); app.refresh_context_window_size(); app.auto_compact_enabled = live_config.auto_compact; + app.completion_toast_enabled = settings.completion_toast_enabled(); + app.bell_on_complete = settings.bell_on_complete; // Background: refresh the model registry from models.dev. // The fetched JSON is saved as a cache file; the App will reload it from @@ -1911,6 +1913,10 @@ async fn run_interactive( // Current cancel token (replaced each turn) let mut cancel: Option = None; let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); + // Background bash tasks with notify_on_complete send their terminal-state + // info here, where the main loop fans-out to a TUI toast + optional bell. + let (bg_completion_tx, mut bg_completion_rx) = + mpsc::unbounded_channel::(); type MessagesArc = Arc>>; let mut current_query: Option<(tokio::task::JoinHandle, MessagesArc)> = None; // Active effort level (None = use model default / High). @@ -2673,13 +2679,22 @@ async fn run_interactive( qcfg.effort_level = Some(level); } // Wire completion_notifier if a command queue is available. + // The closure fans-out the structured completion info to: + // 1. The command queue (system-message injection for the agent) + // 2. The TUI channel (toast + optional terminal bell) if let Some(ref cq) = qcfg.command_queue { let cq = cq.clone(); - ctx_clone.completion_notifier = Some(claurst_tools::CompletionNotifier::new(move |msg| { + let aux_tx = bg_completion_tx.clone(); + ctx_clone.completion_notifier = Some(claurst_tools::CompletionNotifier::new(move |info: claurst_tools::BgTaskCompletion| { + let msg = format!( + "[Monitor] Background task {} completed ({}).\nCommand: {}\nOutput (last 2000 chars):\n{}", + info.task_id, info.exit_info, info.command, info.output_tail + ); cq.push( claurst_query::QueuedCommand::InjectSystemMessage(msg), claurst_query::CommandPriority::Normal, ); + let _ = aux_tx.send(info); })); } let tracker = cost_tracker.clone(); @@ -2973,6 +2988,17 @@ async fn run_interactive( app.handle_query_event(evt); } + // Drain background-task completion events: surface a toast and + // (optionally) ring the terminal bell so the user can leave their + // attention away from the chat and still notice long builds finishing. + while let Ok(info) = bg_completion_rx.try_recv() { + app.signal_bg_task_completion( + &info, + settings.completion_toast_enabled(), + settings.bell_on_complete, + ); + } + // Auto-compact: when context usage hits 99% and no query is running, // automatically submit a compact request. if app.context_window_size > 0 @@ -3702,11 +3728,17 @@ async fn run_interactive( } if let Some(ref cq) = qcfg.command_queue { let cq = cq.clone(); - ctx_clone.completion_notifier = Some(claurst_tools::CompletionNotifier::new(move |msg| { + let aux_tx = bg_completion_tx.clone(); + ctx_clone.completion_notifier = Some(claurst_tools::CompletionNotifier::new(move |info: claurst_tools::BgTaskCompletion| { + let msg = format!( + "[Monitor] Background task {} completed ({}).\nCommand: {}\nOutput (last 2000 chars):\n{}", + info.task_id, info.exit_info, info.command, info.output_tail + ); cq.push( claurst_query::QueuedCommand::InjectSystemMessage(msg), claurst_query::CommandPriority::Normal, ); + let _ = aux_tx.send(info); })); } let tracker = cost_tracker.clone(); diff --git a/src-rust/crates/core/src/lib.rs b/src-rust/crates/core/src/lib.rs index 2544b11..9801c2a 100644 --- a/src-rust/crates/core/src/lib.rs +++ b/src-rust/crates/core/src/lib.rs @@ -1162,6 +1162,20 @@ pub mod config { /// Note: @include in CLAUDE.md/AGENTS.md always injects regardless of this limit. #[serde(default = "default_file_injection_max_size", rename = "fileInjectionMaxSize")] pub file_injection_max_size: usize, + /// Show a toast when a background bash task or assistant turn finishes. + /// `None` (default) → enabled. `Some(false)` → explicitly disabled. + #[serde(default, rename = "completionToast", skip_serializing_if = "Option::is_none")] + pub completion_toast: Option, + /// Ring the terminal bell (\x07) when a background bash task or assistant turn finishes. Defaults to false. + #[serde(default, rename = "bellOnComplete")] + pub bell_on_complete: bool, + } + + impl Settings { + /// Whether to show completion toasts (default: enabled). + pub fn completion_toast_enabled(&self) -> bool { + self.completion_toast.unwrap_or(true) + } } /// A user-defined slash command template. @@ -1729,6 +1743,8 @@ pub mod config { file_autocomplete_show_hidden_files: over.file_autocomplete_show_hidden_files || base.file_autocomplete_show_hidden_files, file_injection_enabled: over.file_injection_enabled || base.file_injection_enabled, file_injection_max_size: if over.file_injection_max_size != 0 { over.file_injection_max_size } else { base.file_injection_max_size }, + completion_toast: over.completion_toast.or(base.completion_toast), + bell_on_complete: over.bell_on_complete || base.bell_on_complete, } } } diff --git a/src-rust/crates/tools/src/bash.rs b/src-rust/crates/tools/src/bash.rs index 44454df..c4bc6c8 100644 --- a/src-rust/crates/tools/src/bash.rs +++ b/src-rust/crates/tools/src/bash.rs @@ -251,11 +251,13 @@ async fn run_in_background( // If notify_on_complete is requested and a notifier is available, spawn a // watcher task that polls the registry until the task reaches a terminal - // state, then injects a completion message into the agent's next turn. + // state, then dispatches a structured completion event. Consumers fan-out + // to system-message injection, TUI toasts, terminal bell, etc. if notify_on_complete { if let Some(notifier) = completion_notifier { let watcher_task_id = task_id.clone(); let watcher_command = command.clone(); + let started_at = std::time::Instant::now(); tokio::spawn(async move { loop { tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; @@ -267,27 +269,32 @@ async fn run_in_background( | claurst_core::tasks::TaskStatus::Failed(_) | claurst_core::tasks::TaskStatus::Cancelled ) => { - let exit_info = match &t.status { - claurst_core::tasks::TaskStatus::Completed => "exit 0".to_string(), + let (success, exit_info) = match &t.status { + claurst_core::tasks::TaskStatus::Completed => { + (true, "exit 0".to_string()) + } claurst_core::tasks::TaskStatus::Failed(msg) => { - format!("failed: {}", msg) + (false, format!("failed: {}", msg)) } claurst_core::tasks::TaskStatus::Cancelled => { - "cancelled".to_string() + (false, "cancelled".to_string()) } _ => unreachable!(), }; let output = t.output.join("\n"); let output_tail = if output.len() > 2000 { - &output[output.len() - 2000..] + output[output.len() - 2000..].to_string() } else { - &output + output }; - let msg = format!( - "[Monitor] Background task {} completed ({}).\nCommand: {}\nOutput (last 2000 chars):\n{}", - watcher_task_id, exit_info, watcher_command, output_tail - ); - notifier.notify(msg); + notifier.notify(crate::BgTaskCompletion { + task_id: watcher_task_id.clone(), + command: watcher_command.clone(), + success, + exit_info, + output_tail, + duration_secs: started_at.elapsed().as_secs(), + }); break; } None => break, // Task disappeared from registry diff --git a/src-rust/crates/tools/src/lib.rs b/src-rust/crates/tools/src/lib.rs index 96ae1ba..ec81b68 100644 --- a/src-rust/crates/tools/src/lib.rs +++ b/src-rust/crates/tools/src/lib.rs @@ -231,17 +231,38 @@ pub fn clear_session_shadow(working_dir: &std::path::Path) { } -/// A cloneable handle for injecting notification messages into the next agent turn. -/// Used by background tasks with `notify_on_complete` to signal completion without polling. +/// Structured payload describing a background task's terminal state. +/// Carried by [`CompletionNotifier`] so consumers can build their own UI +/// (system-message injection, TUI toasts, terminal bell, etc.) without +/// re-parsing a string. +#[derive(Debug, Clone)] +pub struct BgTaskCompletion { + pub task_id: String, + pub command: String, + /// `true` if the task exited 0; `false` for non-zero, cancellation, or timeout. + pub success: bool, + /// Short human-readable status: `"exit 0"`, `"failed: ..."`, `"cancelled"`. + pub exit_info: String, + /// Last ~2000 characters of stdout/stderr (for system-message injection). + pub output_tail: String, + /// How long the task ran before reaching the terminal state. + pub duration_secs: u64, +} + +/// A cloneable handle invoked when a background bash task reaches a terminal state. +/// Used by `notify_on_complete` to signal completion without polling. +/// +/// The closure receives a structured [`BgTaskCompletion`] so it can fan-out to +/// multiple sinks (e.g., inject a system message AND show a TUI toast). #[derive(Clone)] -pub struct CompletionNotifier(Arc); +pub struct CompletionNotifier(Arc); impl CompletionNotifier { - pub fn new(f: impl Fn(String) + Send + Sync + 'static) -> Self { + pub fn new(f: impl Fn(BgTaskCompletion) + Send + Sync + 'static) -> Self { Self(Arc::new(f)) } - pub fn notify(&self, msg: String) { - (self.0)(msg); + pub fn notify(&self, info: BgTaskCompletion) { + (self.0)(info); } } diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 50a9eee..94ae910 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -978,6 +978,15 @@ pub struct App { /// Guard to prevent re-triggering auto-compact while one is in flight. pub auto_compact_running: bool, + /// Show a toast when an assistant turn or background task completes + /// (`Settings.completionToast`, default true). + pub completion_toast_enabled: bool, + /// Ring the terminal bell on completion (`Settings.bellOnComplete`, default false). + pub bell_on_complete: bool, + /// Minimum turn duration (seconds) before we surface a turn-complete toast. + /// Below this, the existing in-status "✽ Worked for Ns" line is enough. + pub completion_toast_min_secs: u64, + // ---- Voice hold-to-talk ------------------------------------------------ /// The global voice recorder, Some when voice is enabled in config. @@ -1203,6 +1212,25 @@ fn format_elapsed_ms(ms: u128) -> String { } } +pub(crate) fn format_duration_secs(secs: u64) -> String { + if secs < 60 { + format!("{}s", secs) + } else if secs < 3600 { + format!("{}m {}s", secs / 60, secs % 60) + } else { + format!("{}h {}m", secs / 3600, (secs % 3600) / 60) + } +} + +/// Write a BEL byte (`\x07`) to stdout. Most terminals translate this into a +/// visual flash or audible beep depending on user preference. +pub(crate) fn ring_terminal_bell() { + use std::io::Write; + let mut out = std::io::stdout(); + let _ = out.write_all(b"\x07"); + let _ = out.flush(); +} + fn format_turn_time_label() -> String { chrono::Local::now() .format("%I:%M %p") @@ -1386,6 +1414,9 @@ impl App { auto_compact_enabled: false, auto_compact_threshold: 95, auto_compact_running: false, + completion_toast_enabled: true, + bell_on_complete: false, + completion_toast_min_secs: 8, voice_recorder: { // Check whether voice input has been enabled via the /voice command // (stored in ~/.coven-code/ui-settings.json). We also accept @@ -2671,6 +2702,45 @@ impl App { self.notifications.push(kind, msg, duration_secs); } + /// Surface a completion signal for a long-running background bash task + /// (one launched with `run_in_background: true, notify_on_complete: true`). + /// + /// Renders a toast in the top-right and optionally rings the terminal + /// bell so the user notices builds/tests finishing while their attention + /// is elsewhere. + pub fn signal_bg_task_completion( + &mut self, + info: &claurst_tools::BgTaskCompletion, + toast_enabled: bool, + bell_enabled: bool, + ) { + if toast_enabled { + let cmd_preview: String = info.command.chars().take(48).collect(); + let cmd_suffix = if info.command.chars().count() > 48 { "…" } else { "" }; + let duration = format_duration_secs(info.duration_secs); + let (kind, msg) = if info.success { + ( + NotificationKind::Success, + format!("Build done · {}{} · {}", cmd_preview, cmd_suffix, duration), + ) + } else { + ( + NotificationKind::Error, + format!( + "Build failed · {}{} · {} · {}", + cmd_preview, cmd_suffix, info.exit_info, duration + ), + ) + }; + // Success toasts auto-dismiss after 8s; failures stay until acknowledged. + let duration_secs = if info.success { Some(8) } else { None }; + self.push_notification(kind, msg, duration_secs); + } + if bell_enabled { + ring_terminal_bell(); + } + } + pub fn push_system_message(&mut self, text: String, style: SystemMessageStyle) { self.system_annotations.push(SystemAnnotation { after_index: self.messages.len(), @@ -6167,17 +6237,37 @@ impl App { } // Record elapsed time and pick a completion verb let seed = self.frame_count as usize ^ (self.messages.len() * 7); + let turn_elapsed_secs = self.turn_start + .as_ref() + .map(|s| s.elapsed().as_secs()) + .unwrap_or(0); let elapsed = self.turn_start.take() .map(|start| format_elapsed_ms(start.elapsed().as_millis())); - self.last_turn_elapsed = Some( - elapsed.unwrap_or_else(|| "0s".to_string()) - ); + let elapsed_label = elapsed.unwrap_or_else(|| "0s".to_string()); + self.last_turn_elapsed = Some(elapsed_label.clone()); self.last_turn_verb = Some(sample_completion_verb(seed)); self.flush_streamed_assistant_message(); self.tool_use_blocks.retain(|b| b.status != ToolStatus::Running); - self.complete_current_turn_snapshot(stop_reason.contains("abort") || stop_reason.contains("cancel")); + let cancelled = stop_reason.contains("abort") || stop_reason.contains("cancel"); + self.complete_current_turn_snapshot(cancelled); self.invalidate_transcript(); self.refresh_turn_diff_from_history(); + + // Surface a clear "turn done" signal for long-running turns the + // user might have stepped away from. The faint "✽ Worked for Ns" + // status line is too quiet on its own. + if !cancelled && turn_elapsed_secs >= self.completion_toast_min_secs { + if self.completion_toast_enabled { + self.push_notification( + NotificationKind::Success, + format!("Turn done · {}", elapsed_label), + Some(6), + ); + } + if self.bell_on_complete { + ring_terminal_bell(); + } + } } QueryEvent::Status(msg) => { @@ -6598,6 +6688,58 @@ mod tests { } } + // ---- signal_bg_task_completion tests ---- + + fn make_bg_completion(success: bool) -> claurst_tools::BgTaskCompletion { + claurst_tools::BgTaskCompletion { + task_id: "task-1".to_string(), + command: "cargo build --release".to_string(), + success, + exit_info: if success { "exit 0".to_string() } else { "failed: exit 1".to_string() }, + output_tail: "compiling...".to_string(), + duration_secs: 42, + } + } + + #[test] + fn bg_task_success_pushes_success_toast() { + let mut app = make_app(); + let info = make_bg_completion(true); + app.signal_bg_task_completion(&info, true, false); + let n = app.notifications.current().expect("toast pushed"); + assert_eq!(n.kind, NotificationKind::Success); + assert!(n.message.contains("cargo build --release")); + assert!(n.message.contains("42s")); + } + + #[test] + fn bg_task_failure_pushes_error_toast_no_autoexpire() { + let mut app = make_app(); + let info = make_bg_completion(false); + app.signal_bg_task_completion(&info, true, false); + let n = app.notifications.current().expect("toast pushed"); + assert_eq!(n.kind, NotificationKind::Error); + assert!(n.message.contains("failed")); + assert!(n.expires_at.is_none(), "errors should stay until acknowledged"); + } + + #[test] + fn bg_task_toast_disabled_pushes_nothing() { + let mut app = make_app(); + let info = make_bg_completion(true); + app.signal_bg_task_completion(&info, false, false); + assert!(app.notifications.current().is_none()); + } + + #[test] + fn format_duration_secs_buckets() { + assert_eq!(format_duration_secs(0), "0s"); + assert_eq!(format_duration_secs(59), "59s"); + assert_eq!(format_duration_secs(60), "1m 0s"); + assert_eq!(format_duration_secs(125), "2m 5s"); + assert_eq!(format_duration_secs(3661), "1h 1m"); + } + // ---- normalize_char_with_shift tests ---- #[test] diff --git a/src-rust/crates/tui/src/render.rs b/src-rust/crates/tui/src/render.rs index 4160e3e..5dfdb12 100644 --- a/src-rust/crates/tui/src/render.rs +++ b/src-rust/crates/tui/src/render.rs @@ -1943,11 +1943,24 @@ fn render_status_row(frame: &mut Frame, app: &App, area: Rect) { s.extend(shimmer_spans(&label, app.frame_count)); s } else if let (Some(verb), Some(elapsed)) = (app.last_turn_verb, app.last_turn_elapsed.as_deref()) { - // "✽ Worked for 2m 5s" — mirrors TS TeammateSpinnerLine idle state - vec![Span::styled( - format!("{} {} for {}", figures::TEARDROP_ASTERISK, verb, elapsed), - Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM), - )] + // "✓ Worked for 2m 5s · done" — turn-complete idle marker. Used to be + // DIM DarkGray; bumped to a bright check mark + softer label so the + // user can tell at a glance that the model is finished and waiting. + let accent = Color::Rgb(80, 200, 120); // matches NotificationKind::Success + vec![ + Span::styled( + "✓ ".to_string(), + Style::default().fg(accent).add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{} for {}", verb, elapsed), + Style::default().fg(Color::Gray), + ), + Span::styled( + " · done".to_string(), + Style::default().fg(Color::DarkGray), + ), + ] } else if let Some(status) = app.status_message.as_deref() { vec![Span::styled(status.to_string(), Style::default().fg(Color::DarkGray))] } else {