diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 04bc18e8c..05cb1b4e2 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -516,6 +516,19 @@ fn default_threshold_secs() -> u64 { 30 } +/// Completion sound options. +#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum CompletionSound { + /// No sound on turn completion. + Off, + /// System notification beep (default). On Windows uses `MessageBeep`. + #[default] + Beep, + /// Terminal BEL character (`\x07`). + Bell, +} + /// Desktop-notification configuration (OSC 9 / BEL on turn completion). #[derive(Debug, Clone, Deserialize, Default)] pub struct NotificationsConfig { @@ -535,6 +548,11 @@ pub struct NotificationsConfig { /// Default: `false`. #[serde(default)] pub include_summary: bool, + + /// Completion sound: `"off"` | `"beep"` | `"bell"`. Default: `"beep"`. + /// Plays a sound when every turn finishes (alongside the ✅ marker). + #[serde(default)] + pub completion_sound: CompletionSound, } fn default_snapshots_enabled() -> bool { diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 1cfe8f097..3934aad51 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -20,6 +20,11 @@ impl Engine { mode: AppMode, force_update_plan_first: bool, ) -> (TurnOutcomeStatus, Option) { + // Signal to the terminal / taskbar that a turn is in progress + // (OSC 9 ; 4 indeterminate progress + title spinner). + crate::tui::notifications::set_taskbar_progress_busy(); + crate::tui::notifications::start_title_animation("DeepSeek TUI"); + let client = self .deepseek_client .clone() diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index fcfe4ba3e..d4276645e 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -17,6 +17,8 @@ use windows::Win32::System::Diagnostics::Debug::MessageBeep; use windows::Win32::UI::WindowsAndMessaging::MESSAGEBOX_STYLE; use std::io::{self, Write}; +use std::sync::atomic::AtomicU8; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; /// Notification delivery method. @@ -207,6 +209,170 @@ pub fn notify_done( notify_done_to(method, in_tmux, msg, threshold, elapsed, &mut io::stdout()); } +/// Set the terminal taskbar progress state via OSC 9 ; 4. +/// +/// Windows Terminal supports this to show progress on the taskbar icon: +/// - `state = 0` — no progress (clear) +/// - `state = 1` — indeterminate (cycling green) +/// - `state = 2` — normal (0-100, requires progress param) +/// - `state = 3` — error (red) +/// - `state = 4` — paused (yellow) +/// +/// Other terminals (iTerm2, WezTerm) ignore the sequence silently. +/// Best-effort — write failures are ignored. +pub fn set_taskbar_progress(state: u8, progress: Option) { + let seq = if let Some(pct) = progress { + format!("\x1b]9;4;{state};{pct}\x07") + } else { + format!("\x1b]9;4;{state}\x07") + }; + let mut stdout = io::stdout(); + let _ = stdout.write_all(seq.as_bytes()); + let _ = stdout.flush(); +} + +/// Set taskbar progress to indeterminate (cycling) — call at turn start. +pub fn set_taskbar_progress_busy() { + set_taskbar_progress(1, None); +} + +/// Clear taskbar progress — call at turn end. +pub fn clear_taskbar_progress() { + set_taskbar_progress(0, None); +} + +/// Animation frame characters for the terminal title. +/// Uses the DeepSeek whale emoji (🐳 spouting, 🐋 resting) to match the +/// existing header status indicator in the TUI. +const TITLE_FRAMES: &[&str] = &["🐳", "🐋", "🐳", "🐋"]; +const TITLE_ANIMATION_INTERVAL: Duration = Duration::from_millis(800); + +/// Shared flag controlling the title animation loop. Set to `true` by +/// `start_title_animation()`, cleared by `stop_title_animation()`. +static TITLE_ANIMATION_RUNNING: AtomicBool = AtomicBool::new(false); + +/// Write OSC 0 (set window title) sequence. +fn set_terminal_title(title: &str) { + let seq = format!("\x1b]0;{title}\x07"); + let mut stdout = io::stdout(); + let _ = stdout.write_all(seq.as_bytes()); + let _ = stdout.flush(); +} + +/// Tracks whether the ✅ completion marker was set, so +/// `reset_title_on_interaction()` can skip redundant writes. +static COMPLETION_MARKER_SHOWN: AtomicBool = AtomicBool::new(false); + +/// Start an animated terminal title spinner. +/// +/// Cycles the terminal title between 🐳→🐋 every 800ms while processing, +/// matching the whale status indicator in the TUI header, so alt-tabbed +/// users can see activity. +/// +/// The animation runs in a background tokio task that checks +/// `TITLE_ANIMATION_RUNNING`. Each call restarts the animation with the +/// given `original` base title — safe to call on every turn start. +pub fn start_title_animation(original: &str) { + // Signal any existing animation loop to exit, then start fresh. + TITLE_ANIMATION_RUNNING.store(true, Ordering::SeqCst); + let base = original.to_string(); + tokio::spawn(async move { + let mut frame = 0usize; + while TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst) { + // Yield once per frame so a racing stop_title_animation() + // can observe the cleared flag and apply the completion + // marker before the next frame write. Without this yield + // the background task could overwrite the ✅ marker with + // the next whale frame. + tokio::task::yield_now().await; + if !TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst) { + break; + } + let spinner = TITLE_FRAMES[frame % TITLE_FRAMES.len()]; + set_terminal_title(&format!("{spinner} {base}")); + frame += 1; + tokio::time::sleep(TITLE_ANIMATION_INTERVAL).await; + } + // Don't restore title here — stop_title_animation() handles + // what to show on completion (e.g. ✅ marker). + }); +} + +/// Stop the title animation and show a completion marker. +/// +/// Sets the title to `✅ ` so alt-tabbed users see at a glance +/// that processing finished. The marker is overwritten on the next turn +/// by [`start_title_animation`]. +pub fn stop_title_animation() { + TITLE_ANIMATION_RUNNING.store(false, Ordering::SeqCst); + COMPLETION_MARKER_SHOWN.store(false, Ordering::SeqCst); + // Show ✅ marker only for beep mode. Bell mode already has its own + // terminal-level visual indicator (flash/icon). + let mode = COMPLETION_SOUND_MODE.load(Ordering::SeqCst); + if mode == 1 { + set_terminal_title("✅ DeepSeek TUI"); + } + play_completion_sound(); +} + +/// Clear the ✅ completion marker from the title when the user interacts. +/// +/// Call this on every user input event (key press, mouse click) so the +/// marker doesn't persist once the user is back at the terminal. +pub fn reset_title_on_interaction() { + if COMPLETION_MARKER_SHOWN.swap(false, Ordering::SeqCst) { + set_terminal_title("DeepSeek TUI"); + } +} + +/// Completion sound mode (0 = off, 1 = beep, 2 = bell). +static COMPLETION_SOUND_MODE: AtomicU8 = AtomicU8::new(1); + +/// Set the completion sound mode from config. +/// Call once at startup or on `/settings` change. +pub fn set_completion_sound_mode(mode: crate::config::CompletionSound) { + let val = match mode { + crate::config::CompletionSound::Off => 0u8, + crate::config::CompletionSound::Beep => 1u8, + crate::config::CompletionSound::Bell => 2u8, + }; + COMPLETION_SOUND_MODE.store(val, Ordering::SeqCst); +} + +/// Play the configured completion sound (if not `Off`). +pub fn play_completion_sound() { + match COMPLETION_SOUND_MODE.load(Ordering::SeqCst) { + 0 => {} // Off + 1 => { + beep_sound(); + } + 2 => { + bell_sound(); + } + _ => {} + } +} + +/// Play a short completion sound via the system beep. +/// +/// On Windows uses `MessageBeep(MB_OK)` which plays the default system +/// notification sound. On other platforms writes `BEL` (`\x07`) to stdout. +#[cfg(target_os = "windows")] +fn beep_sound() { + windows_bell(); +} + +/// Non-Windows: write BEL to stdout for the terminal bell. +#[cfg(not(target_os = "windows"))] +fn beep_sound() { + let _ = io::stdout().write_all(b"\x07"); +} + +/// Pure terminal BEL character. +fn bell_sound() { + let _ = io::stdout().write_all(b"\x07"); +} + /// Return a human-readable duration string, capped at two units so /// it stays compact in headers and notifications. /// @@ -289,6 +455,8 @@ use crate::tui::app::App; /// `Off`). pub fn settings(config: &crate::config::Config) -> Option<(Method, Duration, bool)> { let notif = config.notifications_config(); + // Initialize completion sound mode from config. + set_completion_sound_mode(notif.completion_sound); let method = match notif.method { crate::config::NotificationMethod::Auto => Method::Auto, crate::config::NotificationMethod::Osc9 => Method::Osc9, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 3cf028a40..247566b01 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1422,6 +1422,8 @@ async fn run_event_loop( threshold, turn_elapsed, ); + crate::tui::notifications::clear_taskbar_progress(); + crate::tui::notifications::stop_title_animation(); } // Auto-save completed turn and clear crash checkpoint. @@ -2194,6 +2196,8 @@ async fn run_event_loop( if app.use_mouse_capture && let Event::Mouse(mouse) = evt { + // Mouse interaction clears the ✅ completion marker. + crate::tui::notifications::reset_title_on_interaction(); if should_drop_loading_mouse_motion(app, mouse) { continue; } @@ -2214,6 +2218,9 @@ async fn run_event_loop( continue; } + // User interaction — clear the ✅ completion marker from the title. + crate::tui::notifications::reset_title_on_interaction(); + let Event::Key(key) = evt else { continue; }; diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 5ec4f7883..25d81424a 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -5841,6 +5841,7 @@ fn notification_settings_tui_always_keeps_configured_method_no_threshold() { notifications: Some(crate::config::NotificationsConfig { method: crate::config::NotificationMethod::Bel, threshold_secs: 120, + completion_sound: crate::config::CompletionSound::Beep, include_summary: true, }), ..Config::default() @@ -5872,6 +5873,7 @@ fn notification_settings_no_tui_override_uses_notifications_block() { notifications: Some(crate::config::NotificationsConfig { method: crate::config::NotificationMethod::Osc9, threshold_secs: 45, + completion_sound: crate::config::CompletionSound::Beep, include_summary: false, }), ..Config::default()