Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions crates/tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions crates/tui/src/core/engine/turn_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ impl Engine {
mode: AppMode,
force_update_plan_first: bool,
) -> (TurnOutcomeStatus, Option<String>) {
// 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()
Expand Down
168 changes: 168 additions & 0 deletions crates/tui/src/tui/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<u8>) {
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 `βœ… <base>` 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.
///
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions crates/tui/src/tui/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
};
Expand Down
2 changes: 2 additions & 0 deletions crates/tui/src/tui/ui/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Loading