feat(tui): harvest custom completion sound files

Add completion_sound = "file" with [notifications].sound_file for Windows custom WAV completion sounds without changing the global Windows sound scheme.

The Windows path uses PlaySoundW asynchronously with no default fallback. Non-Windows file mode warns and no-ops, missing paths warn once, and setting a valid path resets the missing-path warning latch so later misconfiguration is visible again.

Fixes #2484

Reported by @LHqweasd

Harvested from PR #2512 by @cyq1017

Co-authored-by: cyq1017 <61975706+cyq1017@users.noreply.github.com>
This commit is contained in:
Hunter B
2026-06-04 19:56:51 -07:00
parent 8cbdf95af9
commit 91215d5f4f
9 changed files with 188 additions and 25 deletions
+7 -2
View File
@@ -46,6 +46,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`DEEPSEEK_SEARCH_BASE_URL` as a legacy alias. Custom endpoints are gated by
their configured host, do not fall back to public Bing, and report the custom
host as the result source for diagnostics (#2436, #2510).
- Added `completion_sound = "file"` with `[notifications].sound_file` so
Windows users can play a custom WAV file for turn-completion sounds without
changing the global Windows sound scheme (#2484, #2512).
### Changed
@@ -160,9 +163,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Thanks to **@sximelon** for reporting and fixing the saved-session resume
footer hint (#2758, #2760), **@cyq1017** for the custom
DuckDuckGo-compatible search endpoint, restore-listing implementation, and
pending-input delivery-mode label work (#2510, #2513, #2532, #2054),
DuckDuckGo-compatible search endpoint, custom completion sound file support,
restore-listing implementation, and pending-input delivery-mode label work
(#2510, #2512, #2513, #2532, #2054),
**@Artenx** for the private-search endpoint report (#2436),
**@LHqweasd** for the Windows custom notification sound request (#2484),
**@wywsoor** for the broader macOS/iTerm rollback UX report (#2494),
**@HUQIANTAO** for the `web_run` lock-splitting work (#2502), turn-metadata
prefix-cache stability work (#2517), and project-context cache direction
+4 -5
View File
@@ -620,21 +620,20 @@ default_text_model = "deepseek-ai/deepseek-v4-pro"
# method = "auto" # auto | osc9 | bel | off
# auto: OSC 9 for iTerm.app / Ghostty / WezTerm.
# On macOS / Linux, falls back to BEL.
# On Windows, falls back to "off" — BEL maps to the
# system error chime (SystemAsterisk / MB_OK), which
# sounds like an error popup. Set method = "bel"
# explicitly to opt back in (#583).
# On Windows, BEL is routed through MessageBeep(MB_OK).
# osc9: \x1b]9;<msg>\x07 (iTerm2-style; shows macOS notification)
# bel: plain \x07 beep
# off: disable entirely
# threshold_secs = 30 # only notify when the turn took >= this many seconds
# include_summary = false # include elapsed time + cost in the notification body
# completion_sound = "beep" # off | beep | bell — sound on turn completion (✅ marker)
# completion_sound = "beep" # off | beep | bell | file — sound on turn completion (✅ marker)
# sound_file = "E:\\google\\downloads\\notify.wav" # WAV used when completion_sound = "file" (Windows)
[notifications]
# method = "auto"
# threshold_secs = 30
# include_summary = false
# completion_sound = "beep"
# sound_file = "E:\\google\\downloads\\notify.wav"
# ─────────────────────────────────────────────────────────────────────────────────
# Workspace Snapshots (#137)
+7 -2
View File
@@ -46,6 +46,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`DEEPSEEK_SEARCH_BASE_URL` as a legacy alias. Custom endpoints are gated by
their configured host, do not fall back to public Bing, and report the custom
host as the result source for diagnostics (#2436, #2510).
- Added `completion_sound = "file"` with `[notifications].sound_file` so
Windows users can play a custom WAV file for turn-completion sounds without
changing the global Windows sound scheme (#2484, #2512).
### Changed
@@ -160,9 +163,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Thanks to **@sximelon** for reporting and fixing the saved-session resume
footer hint (#2758, #2760), **@cyq1017** for the custom
DuckDuckGo-compatible search endpoint, restore-listing implementation, and
pending-input delivery-mode label work (#2510, #2513, #2532, #2054),
DuckDuckGo-compatible search endpoint, custom completion sound file support,
restore-listing implementation, and pending-input delivery-mode label work
(#2510, #2512, #2513, #2532, #2054),
**@Artenx** for the private-search endpoint report (#2436),
**@LHqweasd** for the Windows custom notification sound request (#2484),
**@wywsoor** for the broader macOS/iTerm rollback UX report (#2494),
**@HUQIANTAO** for the `web_run` lock-splitting work (#2502), turn-metadata
prefix-cache stability work (#2517), and project-context cache direction
+1 -1
View File
@@ -100,4 +100,4 @@ objc2 = "0.6.3"
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSError", "NSObject", "NSString", "NSURL"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.60", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] }
windows = { version = "0.60", features = ["Win32_Foundation", "Win32_Media_Audio", "Win32_Security", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] }
+29 -4
View File
@@ -915,6 +915,8 @@ pub enum CompletionSound {
Beep,
/// Terminal BEL character (`\x07`).
Bell,
/// Play a configured WAV sound file.
File,
}
/// Desktop-notification configuration (OSC 9 / BEL on turn completion).
@@ -922,9 +924,9 @@ pub enum CompletionSound {
pub struct NotificationsConfig {
/// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.
/// `auto` resolves to OSC 9 for iTerm.app / Ghostty / WezTerm / Cmux
/// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); on macOS / Linux
/// it falls back to BEL, and on Windows it falls back to `Off` so the
/// post-turn notification doesn't ring the system error chime (#583).
/// (detected via `$TERM_PROGRAM` then `$LC_TERMINAL`); otherwise it
/// falls back to BEL. On Windows the BEL path is routed through
/// `MessageBeep(MB_OK)`.
/// Use `method = "osc9"` explicitly when your terminal is OSC-9 capable
/// but sets neither env var (e.g. Cmux without `LC_TERMINAL`).
#[serde(default)]
@@ -937,10 +939,14 @@ pub struct NotificationsConfig {
#[serde(default)]
pub include_summary: bool,
/// Completion sound: `"off"` | `"beep"` | `"bell"`. Default: `"beep"`.
/// Completion sound: `"off"` | `"beep"` | `"bell"` | `"file"`. Default: `"beep"`.
/// Plays a sound when every turn finishes (alongside the ✅ marker).
#[serde(default)]
pub completion_sound: CompletionSound,
/// Path to the WAV sound file used when `completion_sound = "file"`.
#[serde(default)]
pub sound_file: Option<PathBuf>,
}
fn default_snapshots_enabled() -> bool {
@@ -10220,4 +10226,23 @@ model = "deepseek-ai/deepseek-v4-pro"
assert_eq!(config.default_model(), "meta-llama/Llama-3-70B");
Ok(())
}
#[test]
fn notifications_parse_custom_completion_sound_file() {
let config: Config = toml::from_str(
r#"
[notifications]
completion_sound = "file"
sound_file = "E:\\google\\downloads\\xm4114.wav"
"#,
)
.expect("custom completion sound config should parse");
let notifications = config.notifications_config();
assert_eq!(notifications.completion_sound, CompletionSound::File);
assert_eq!(
notifications.sound_file.as_deref(),
Some(std::path::Path::new("E:\\google\\downloads\\xm4114.wav"))
);
}
}
+122 -5
View File
@@ -17,10 +17,19 @@ use windows::Win32::System::Diagnostics::Debug::MessageBeep;
use windows::Win32::UI::WindowsAndMessaging::MESSAGEBOX_STYLE;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicU8;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
#[cfg(target_os = "windows")]
use std::os::windows::ffi::OsStrExt;
#[cfg(target_os = "windows")]
use windows::Win32::Media::Audio::{PlaySoundW, SND_ASYNC, SND_FILENAME, SND_NODEFAULT};
#[cfg(target_os = "windows")]
use windows::core::PCWSTR;
/// Notification delivery method.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Method {
@@ -354,18 +363,31 @@ pub fn reset_title_on_interaction() {
}
}
/// Completion sound mode (0 = off, 1 = beep, 2 = bell).
/// Completion sound mode (0 = off, 1 = beep, 2 = bell, 3 = file).
static COMPLETION_SOUND_MODE: AtomicU8 = AtomicU8::new(1);
static COMPLETION_SOUND_FILE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
#[cfg(not(target_os = "windows"))]
static COMPLETION_SOUND_FILE_UNSUPPORTED_WARNED: AtomicBool = AtomicBool::new(false);
static COMPLETION_SOUND_FILE_MISSING_WARNED: AtomicBool = AtomicBool::new(false);
/// 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) {
fn completion_sound_file_slot() -> &'static Mutex<Option<PathBuf>> {
COMPLETION_SOUND_FILE.get_or_init(|| Mutex::new(None))
}
fn set_completion_sound(mode: crate::config::CompletionSound, sound_file: Option<PathBuf>) {
let val = match mode {
crate::config::CompletionSound::Off => 0u8,
crate::config::CompletionSound::Beep => 1u8,
crate::config::CompletionSound::Bell => 2u8,
crate::config::CompletionSound::File => 3u8,
};
COMPLETION_SOUND_MODE.store(val, Ordering::SeqCst);
if let Ok(mut slot) = completion_sound_file_slot().lock() {
if sound_file.is_some() {
COMPLETION_SOUND_FILE_MISSING_WARNED.store(false, Ordering::SeqCst);
}
*slot = sound_file;
}
}
/// Play the configured completion sound (if not `Off`).
@@ -378,6 +400,9 @@ pub fn play_completion_sound() {
2 => {
bell_sound();
}
3 => {
file_sound();
}
_ => {}
}
}
@@ -402,6 +427,54 @@ fn bell_sound() {
let _ = io::stdout().write_all(b"\x07");
}
fn configured_sound_file() -> Option<PathBuf> {
completion_sound_file_slot()
.lock()
.ok()
.and_then(|slot| slot.clone())
}
#[cfg(target_os = "windows")]
fn play_sound_file(path: &Path) {
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
// Best-effort and async: notification sound failure should not block or
// fail a completed agent turn.
unsafe {
let _ = PlaySoundW(
PCWSTR(wide.as_ptr()),
None,
SND_FILENAME | SND_ASYNC | SND_NODEFAULT,
);
}
}
#[cfg(not(target_os = "windows"))]
fn play_sound_file(_path: &Path) {
if !COMPLETION_SOUND_FILE_UNSUPPORTED_WARNED.swap(true, Ordering::SeqCst) {
tracing::warn!("completion_sound = \"file\" is currently supported on Windows only");
}
}
fn file_sound() {
if let Some(path) = configured_sound_file() {
play_sound_file(&path);
} else if !COMPLETION_SOUND_FILE_MISSING_WARNED.swap(true, Ordering::SeqCst) {
tracing::warn!("completion_sound = \"file\" requires [notifications].sound_file");
}
}
#[cfg(test)]
fn completion_sound_state_for_tests() -> (crate::config::CompletionSound, Option<PathBuf>) {
let mode = match COMPLETION_SOUND_MODE.load(Ordering::SeqCst) {
0 => crate::config::CompletionSound::Off,
1 => crate::config::CompletionSound::Beep,
2 => crate::config::CompletionSound::Bell,
3 => crate::config::CompletionSound::File,
_ => crate::config::CompletionSound::Off,
};
(mode, configured_sound_file())
}
/// Show a macOS Notification Center alert via `osascript`.
///
/// Runs on a dedicated background thread so the caller is not blocked.
@@ -585,7 +658,7 @@ use crate::tui::app::App;
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);
set_completion_sound(notif.completion_sound, notif.sound_file);
let method = match notif.method {
crate::config::NotificationMethod::Auto => Method::Auto,
crate::config::NotificationMethod::Osc9 => Method::Osc9,
@@ -1187,4 +1260,48 @@ mod tests {
"3w 2d"
);
}
#[test]
fn settings_installs_custom_completion_sound_file() {
let config: crate::config::Config = toml::from_str(
r#"
[notifications]
completion_sound = "file"
sound_file = "E:\\google\\downloads\\xm4114.wav"
"#,
)
.expect("custom completion sound config should parse");
let _ = settings(&config);
let (mode, file) = completion_sound_state_for_tests();
assert_eq!(mode, crate::config::CompletionSound::File);
assert_eq!(
file.as_deref(),
Some(std::path::Path::new("E:\\google\\downloads\\xm4114.wav"))
);
}
#[test]
fn setting_valid_sound_file_resets_missing_file_warning_latch() {
let _lock = env_lock();
COMPLETION_SOUND_FILE_MISSING_WARNED.store(true, Ordering::SeqCst);
set_completion_sound(
crate::config::CompletionSound::File,
Some(std::path::PathBuf::from(
"E:\\google\\downloads\\xm4114.wav",
)),
);
assert!(!COMPLETION_SOUND_FILE_MISSING_WARNED.load(Ordering::SeqCst));
set_completion_sound(crate::config::CompletionSound::File, None);
file_sound();
assert!(COMPLETION_SOUND_FILE_MISSING_WARNED.load(Ordering::SeqCst));
set_completion_sound(crate::config::CompletionSound::Beep, None);
COMPLETION_SOUND_FILE_MISSING_WARNED.store(false, Ordering::SeqCst);
}
}
+2
View File
@@ -8369,6 +8369,7 @@ fn notification_settings_tui_always_keeps_configured_method_no_threshold() {
method: crate::config::NotificationMethod::Bel,
threshold_secs: 120,
completion_sound: crate::config::CompletionSound::Beep,
sound_file: None,
include_summary: true,
}),
..Config::default()
@@ -8401,6 +8402,7 @@ fn notification_settings_no_tui_override_uses_notifications_block() {
method: crate::config::NotificationMethod::Osc9,
threshold_secs: 45,
completion_sound: crate::config::CompletionSound::Beep,
sound_file: None,
include_summary: false,
}),
..Config::default()
+14 -5
View File
@@ -951,15 +951,18 @@ If you are upgrading from older releases:
turns whose elapsed time meets `threshold_secs`; failed and cancelled
turns are silent. `auto` resolves to `osc9` for `iTerm.app`, `Ghostty`,
and `WezTerm` (detected via `$TERM_PROGRAM`). Otherwise the fallback is
`bel` on macOS / Linux and `off` on Windows (where BEL maps to the
system error chime — see the [Notifications](#notifications) section
for the full rationale, #583).
`bel`; on Windows the BEL path is routed through `MessageBeep(MB_OK)`.
- `[notifications].threshold_secs` (int, optional): defaults to `30`.
Only completed turns whose elapsed time meets or exceeds this fire a
notification.
- `[notifications].include_summary` (bool, optional): defaults to
`false`. When `true`, the notification body includes the elapsed
duration and the turn's cost in the configured display currency.
- `[notifications].completion_sound` (string, optional): `off`, `beep`,
`bell`, or `file`. Defaults to `beep`. `file` plays the WAV path from
`[notifications].sound_file` on Windows.
- `[notifications].sound_file` (path, optional): path to a custom WAV file
used when `completion_sound = "file"`.
- `tui.alternate_screen` (string, optional): `auto`, `always`, or `never`. This is retained for config compatibility, but interactive sessions now always use the TUI-owned alternate screen so host terminal scrollback cannot hijack the viewport.
- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and keeps selection scoped to the transcript pane. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right sidebar and include visual wraps because the terminal, not the TUI, owns the selection.
- `tui.terminal_probe_timeout_ms` (int, optional, default `500`): startup terminal-mode probe timeout in milliseconds. Values are clamped to `100..=5000`; timeout emits a warning and aborts startup instead of hanging indefinitely.
@@ -1022,16 +1025,22 @@ The TUI can emit a desktop notification (OSC 9 escape or plain BEL) when a turn
method = "auto" # auto | osc9 | bel | off
threshold_secs = 30 # only notify when the turn took >= this many seconds
include_summary = false # include elapsed time + cost in the notification body
completion_sound = "beep" # off | beep | bell | file
sound_file = "E:\\google\\downloads\\notify.wav" # for completion_sound = "file"
```
Method semantics:
- `auto` (default) — picks `osc9` for `iTerm.app`, `Ghostty`, and `WezTerm` (detected via `$TERM_PROGRAM`). On macOS and Linux it falls back to `bel`. **On Windows the fallback is `off`** instead of `bel`, because the Windows audio stack maps `\x07` to the `SystemAsterisk` / `MB_OK` chime — the same sound application error popups use, so a successful-turn notification ends up sounding like an error (#583).
- `auto` (default) — picks `osc9` for `iTerm.app`, `Ghostty`, and `WezTerm` (detected via `$TERM_PROGRAM`). Otherwise it falls back to `bel`; on Windows that BEL path is routed through `MessageBeep(MB_OK)`.
- `osc9` — emit `\x1b]9;<msg>\x07`. Inside tmux the sequence is wrapped in DCS passthrough so it reaches the outer terminal.
- `bel` — emit a single `\x07` byte. Use this on Windows only if you actively want the chime back.
- `off` — disable post-turn notifications entirely.
Windows users who run inside a known OSC-9 terminal (e.g. WezTerm on Windows) keep getting OSC-9 notifications; the `off` fallback only applies when no recognised `TERM_PROGRAM` is detected.
Windows users who run inside a known OSC-9 terminal (e.g. WezTerm on Windows) keep getting OSC-9 notifications. Set `method = "off"` to disable threshold-based desktop notifications entirely.
`completion_sound = "file"` is for Windows users who want a per-application
completion sound without changing the global Windows sound scheme. It plays the
configured WAV `sound_file` asynchronously via the native Windows audio API.
### Parsed but currently unused (reserved for future versions)
+2 -1
View File
@@ -65,6 +65,7 @@ harvest/stewardship commits:
| #2530 mention depth-cap hint | Already present; original closed on 2026-06-05 after public integration branch. | Present in the current v0.9 stack as `a97675824` and `29f57665e`. `cargo test -p codewhale-tui --locked try_autocomplete_file_mention_no_match` passed. |
| #2513 restore snapshot listing | Harvested; original closed on 2026-06-05 after public integration branch. | Manually harvested as `311eb4002` with explicit `/restore list 101` cap rejection. `cargo test -p codewhale-tui --locked restore_`; `cargo fmt --all -- --check`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. Keep #2494 open because this is only the restore-listing slice. |
| #2510 custom DuckDuckGo-compatible endpoint | Harvested into a focused review branch; close original after review PR lands. | Adds `[search].base_url`, preferred `CODEWHALE_SEARCH_BASE_URL`, and legacy `DEEPSEEK_SEARCH_BASE_URL` for private DDG-compatible HTML endpoints. Network policy gates the configured host, custom endpoints do not fall back to public Bing, non-DDG provider/base_url combinations and challenge pages return explicit errors, and custom results report the configured host as `source`. Credit @cyq1017 for #2510 and @Artenx for the DDG-style endpoint clarification in #2436. |
| #2512 custom completion sound files | Harvested into a focused review branch; close original after review PR lands. | Adds `completion_sound = "file"` plus `[notifications].sound_file` so Windows users can play a per-app custom WAV through `PlaySoundW(SND_FILENAME | SND_ASYNC | SND_NODEFAULT)`. Non-Windows file mode warns and no-ops, missing paths warn once, and setting a valid path resets the missing-path warning latch so later misconfiguration is visible again. Credit @cyq1017 for #2512 and @LHqweasd for the Windows custom notification request in #2484. |
| #2576 PrefixCacheChange first-freeze event | Already present; original closed on 2026-06-05 after public integration branch. | Present in the current v0.9 stack through `29acb87a9d`. `cargo test -p codewhale-tui --locked prefix_cache` passed. |
| #2502 web_run RwLock split | Harvested; original closed on 2026-06-05 after public integration branch. | Manually harvested as `60f8e7d62` with panic-safe state write-back, `Arc<WebPage>` cache reads, and serialized cache tests. `cargo test -p codewhale-tui --locked web_run`; `cargo clippy -p codewhale-tui --locked -- -D warnings`; `cargo fmt --all -- --check` passed. |
| #2517 turn_meta tail relocation | Manually harvested with the user-text content block first and volatile turn metadata last. | `cargo test -p codewhale-tui --locked turn_metadata`; `cargo test -p codewhale-tui --locked user_message_turn_meta_is_appended_not_prepended`; `cargo test -p codewhale-tui --locked post_edit_hook_injects_diagnostics_message_before_next_request`; `cargo test -p codewhale-tui --locked request_builder_keeps_tail_turn_meta_after_user_text_for_wire`; `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. |
@@ -117,7 +118,7 @@ v0.9 branch so the remaining Windows/manual checks are explicit.
| #2509 parallel read-only web search | Closed / already merged via #2504 | Already present in `origin/main` as `a09af2024`; closed as harvested/superseded on 2026-06-04. |
| #2510 custom DuckDuckGo endpoint | Draft/mergeable / harvested in focused branch | Close/comment after the focused review PR lands. Keep credit for @cyq1017 and issue reporter @Artenx. |
| #2511 ToolCallBefore hooks | Conflicting | Defer to hook lifecycle lane. |
| #2512 custom completion sounds | Draft/conflicting | Defer. |
| #2512 custom completion sounds | Draft/conflicting / harvested in focused branch | Close/comment after the focused review PR lands. Keep credit for @cyq1017 and issue reporter @LHqweasd. |
| #2513 restore snapshot listing | Closed / harvested | Manually harvested as `311eb4002` with cap-rejection polish; original closed on 2026-06-05, leave #2494 open. |
| #2517 turn_meta tail relocation | Mergeable | Manually harvested on the v0.9 branch; close/comment after branch is public. |
| #2520 prompt base disk cache | Mergeable | Defer. Review found unused prompt-cache infrastructure with no runtime wiring, cache keys that still require building the prompt first, real-home cache writes in tests, and a contract that depends on the deferred #2687 prompt split. |