feat: QoL — taskbar progress, animated title spinner, and configurable completion sound (#1871)

This commit is contained in:
Paulo Aboim Pinto
2026-05-26 17:28:21 +02:00
committed by GitHub
parent 9f5c552ae1
commit cd357de0c8
5 changed files with 200 additions and 0 deletions
+18
View File
@@ -542,6 +542,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 {
@@ -561,6 +574,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 {
+5
View File
@@ -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()
+168
View File
@@ -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<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.
///
@@ -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,
+7
View File
@@ -1541,6 +1541,8 @@ async fn run_event_loop(
threshold,
turn_elapsed,
);
crate::tui::notifications::clear_taskbar_progress();
crate::tui::notifications::stop_title_animation();
}
// Generate post-turn receipt for completed turns.
@@ -2333,6 +2335,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;
}
@@ -2353,6 +2357,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;
};
+2
View File
@@ -6089,6 +6089,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()
@@ -6120,6 +6121,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()