feat: QoL — taskbar progress, animated title spinner, and configurable completion sound (#1871)
This commit is contained in:
committed by
GitHub
parent
9f5c552ae1
commit
cd357de0c8
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user