diff --git a/config.example.toml b/config.example.toml index a40e59e3..6f097e7a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -281,8 +281,10 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # ───────────────────────────────────────────────────────────────────────────────── # Desktop Notifications (OSC 9 / BEL on long agent-turn completion) # ───────────────────────────────────────────────────────────────────────────────── -# Emits an escape sequence to the terminal when a turn finishes and took longer -# than `threshold_secs`. Useful when you tab away from the TUI and want an alert. +# Emits an escape sequence to the terminal when a turn **completes successfully** +# and took longer than `threshold_secs`. Failed or cancelled turns are +# intentionally silent. Useful when you tab away from the TUI and want an alert +# for "your task is ready". # # method = "auto" # auto | osc9 | bel | off # auto: OSC 9 for iTerm.app / Ghostty / WezTerm. diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index 944c9273..df7992ed 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -122,9 +122,13 @@ pub fn notify_done_to( /// Emit a turn-complete notification to **stdout** if `elapsed >= threshold`. /// -/// With `method = Auto`, selects `Osc9` for known capable terminals and `Bel` -/// otherwise. Pass `in_tmux = true` (i.e. `$TMUX` is non-empty at runtime) -/// to wrap OSC 9 in a DCS passthrough. +/// With `method = Auto`, selects `Osc9` for known capable terminals +/// (`iTerm.app`, `Ghostty`, `WezTerm`); the unknown-terminal fallback is +/// platform-aware — `Bel` on macOS / Linux, `Off` on Windows (where BEL +/// maps to the `SystemAsterisk` / `MB_OK` error chime, #583). See +/// [`resolve_method`] for the canonical resolution table. Pass +/// `in_tmux = true` (i.e. `$TMUX` is non-empty at runtime) to wrap OSC 9 +/// in a DCS passthrough. pub fn notify_done( method: Method, in_tmux: bool, @@ -310,9 +314,7 @@ mod tests { /// #583: on Windows, an unknown TERM_PROGRAM resolves to `Off` /// (not `Bel`) so the post-turn notification doesn't ring the - /// `SystemAsterisk` / `MB_OK` chime. Known OSC-9 terminals like - /// WezTerm still resolve to `Osc9` — see the iTerm test, which - /// also exercises the OSC-9 branch on Windows. + /// `SystemAsterisk` / `MB_OK` chime. #[test] #[cfg(target_os = "windows")] fn auto_detect_picks_off_for_unknown_on_windows() { @@ -331,6 +333,31 @@ mod tests { assert_eq!(resolved, Method::Off); } + /// #583: known OSC-9 terminals must still resolve to `Osc9` on + /// Windows — the off-fallback only applies to unrecognised + /// `TERM_PROGRAM`. The cross-platform iTerm test above is a thin + /// proxy because iTerm itself only runs on macOS; if the WezTerm + /// arm of the match silently disappeared, that test would still + /// pass on the Windows runner and we'd lose the WezTerm-on-Windows + /// compatibility guarantee. Pin it directly. + #[test] + #[cfg(target_os = "windows")] + fn auto_detect_picks_osc9_for_wezterm_on_windows() { + let _lock = env_lock(); + let prev = std::env::var_os("TERM_PROGRAM"); + // SAFETY: test-only; serialised by env_lock(). + unsafe { std::env::set_var("TERM_PROGRAM", "WezTerm") }; + let resolved = resolve_method(); + // SAFETY: test-only; serialised by env_lock(). + unsafe { + match prev { + Some(v) => std::env::set_var("TERM_PROGRAM", v), + None => std::env::remove_var("TERM_PROGRAM"), + } + } + assert_eq!(resolved, Method::Osc9); + } + #[test] fn humanize_duration_seconds_and_minutes() { assert_eq!(humanize_duration(Duration::from_secs(0)), "0s"); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2d8fe52f..9768e5a5 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -368,6 +368,20 @@ If you are upgrading from older releases: - `[capacity].deepseek_v4_pro_prior` (float, default `3.5`) - `[capacity].deepseek_v4_flash_prior` (float, default `4.2`) - `[capacity].fallback_default_prior` (float, default `3.8`) +- `[notifications].method` (string, optional): `auto`, `osc9`, `bel`, or + `off`. Defaults to `auto`. The TUI fires this on completed (successful) + 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). +- `[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 USD cost. - `tui.alternate_screen` (string, optional): `auto`, `always`, or `never`. `auto` disables the alternate screen in Zellij; `--no-alt-screen` forces inline mode. Set `never` or run with `--no-alt-screen` when you want real terminal scrollback. - `tui.mouse_capture` (bool, optional, default `true` when the alternate screen is active): enable internal mouse scrolling, transcript selection, and right-click context actions. TUI-owned drag selection copies only user/assistant transcript text. Set this to `false` or run with `--no-mouse-capture` for raw terminal 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. @@ -402,7 +416,7 @@ Notes: ### Notifications -The TUI can emit a desktop notification (OSC 9 escape or plain BEL) when a turn takes longer than a threshold, so you can tab away while a long task runs. Configuration lives under `[notifications]`: +The TUI can emit a desktop notification (OSC 9 escape or plain BEL) when a turn **completes successfully** and took longer than a threshold, so you can tab away while a long task runs. Failed or cancelled turns are intentionally silent — the notification is a "your task is ready" cue, not a generic ping. Configuration lives under `[notifications]`: ```toml [notifications]