feat: #132 emit OSC 9 / BEL desktop notification on long turn completion

Adds crates/tui/src/tui/notifications.rs with Method enum (Auto/Osc9/Bel/Off),
notify_done / notify_done_to helpers, tmux DCS passthrough, and 9 unit tests.
Wires the hook at the TurnComplete event in tui/ui.rs so turns >= threshold_secs
(default 30 s) emit an escape to stdout; method auto-detects iTerm.app/Ghostty/
WezTerm for OSC 9 and falls back to BEL. Config exposed under [notifications] in
config.toml and documented in config.example.toml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-04-27 23:52:58 -05:00
parent 693fbca4ea
commit 6432d47c53
5 changed files with 361 additions and 4 deletions
+18
View File
@@ -161,6 +161,24 @@ api_key = "YOUR_NVIDIA_API_KEY"
base_url = "https://integrate.api.nvidia.com/v1"
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.
#
# method = "auto" # auto | osc9 | bel | off
# auto: OSC 9 for iTerm.app / Ghostty / WezTerm, BEL otherwise.
# 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
[notifications]
# method = "auto"
# threshold_secs = 30
# include_summary = false
# ─────────────────────────────────────────────────────────────────────────────────
# Hooks (optional)
# ─────────────────────────────────────────────────────────────────────────────────
+45
View File
@@ -158,6 +158,40 @@ pub struct TuiConfig {
pub status_items: Option<Vec<StatusItem>>,
}
/// Notification delivery method (mirrors `tui::notifications::Method`).
#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum NotificationMethod {
/// Auto-detect: OSC 9 for iTerm.app / Ghostty / WezTerm; BEL otherwise.
#[default]
Auto,
/// OSC 9 escape.
Osc9,
/// Plain BEL character.
Bel,
/// Disable notifications.
Off,
}
fn default_threshold_secs() -> u64 {
30
}
/// Desktop-notification configuration (OSC 9 / BEL on turn completion).
#[derive(Debug, Clone, Deserialize, Default)]
pub struct NotificationsConfig {
/// Delivery method: `auto` | `osc9` | `bel` | `off`. Default: `auto`.
#[serde(default)]
pub method: NotificationMethod,
/// Only notify when the turn took at least this many seconds. Default: 30.
#[serde(default = "default_threshold_secs")]
pub threshold_secs: u64,
/// Include a short summary (elapsed time + cost) in the notification body.
/// Default: `false`.
#[serde(default)]
pub include_summary: bool,
}
/// One configurable footer item.
///
/// Order in the user's `Vec<StatusItem>` is preserved: items in the left
@@ -386,6 +420,10 @@ pub struct Config {
/// Provider-specific credentials and defaults shared with the `deepseek` facade.
#[serde(default)]
pub providers: Option<ProvidersConfig>,
/// Desktop notification settings (OSC 9 / BEL on long turn completion).
#[serde(default)]
pub notifications: Option<NotificationsConfig>,
}
#[derive(Debug, Clone, Default, Deserialize)]
@@ -776,6 +814,12 @@ impl Config {
self.hooks.clone().unwrap_or_default()
}
/// Resolve the notifications configuration with defaults applied.
#[must_use]
pub fn notifications_config(&self) -> NotificationsConfig {
self.notifications.clone().unwrap_or_default()
}
/// Resolve enabled features from defaults and config entries.
#[must_use]
pub fn features(&self) -> Features {
@@ -1283,6 +1327,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
hooks: override_cfg.hooks.or(base.hooks),
providers: merge_providers(base.providers, override_cfg.providers),
features: merge_features(base.features, override_cfg.features),
notifications: override_cfg.notifications.or(base.notifications),
}
}
+1
View File
@@ -18,6 +18,7 @@ pub mod keybindings;
pub mod live_transcript;
pub mod markdown_render;
pub mod model_picker;
pub mod notifications;
pub mod onboarding;
pub mod pager;
pub mod paste;
+256
View File
@@ -0,0 +1,256 @@
//! OSC 9 / BEL desktop notifications for long agent-turn completion.
//!
//! Writes a terminal escape to the provided sink (or stdout for the public
//! API) when a turn takes longer than the configured threshold. Supports
//! tmux DCS passthrough so OSC 9 reaches the outer terminal even when
//! running inside a tmux session.
use std::io::{self, Write};
use std::time::Duration;
/// Notification delivery method.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Method {
/// Automatically pick `Osc9` for known capable terminals
/// (`iTerm.app`, `Ghostty`, `WezTerm`) and fall back to `Bel` otherwise.
#[default]
Auto,
/// OSC 9 escape: `\x1b]9;<msg>\x07`
Osc9,
/// Plain BEL character: `\x07`
Bel,
/// Suppress all notifications.
Off,
}
impl Method {
/// Parse from a configuration string (case-insensitive).
#[must_use]
pub fn from_str(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"osc9" | "osc-9" => Self::Osc9,
"bel" => Self::Bel,
"off" | "disabled" | "none" => Self::Off,
_ => Self::Auto,
}
}
}
/// Resolve `Auto` to a concrete method by inspecting `$TERM_PROGRAM`.
///
/// Known OSC-9 capable programs: `iTerm.app`, `Ghostty`, `WezTerm`.
/// Everything else falls back to `Bel`.
#[must_use]
fn resolve_method() -> Method {
let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
match term_program.as_str() {
"iTerm.app" | "Ghostty" | "WezTerm" => Method::Osc9,
_ => Method::Bel,
}
}
/// Build the raw escape bytes for the given method and message.
///
/// When `in_tmux` is `true` and the method is `Osc9`, the sequence is
/// wrapped in a DCS passthrough so tmux forwards it to the outer terminal:
/// `\x1bPtmux;\x1b<OSC-9>\x1b\\`
#[must_use]
fn build_escape(method: Method, in_tmux: bool, msg: &str) -> Vec<u8> {
match method {
Method::Bel => vec![b'\x07'],
Method::Osc9 => {
let inner = format!("\x1b]9;{msg}\x07");
if in_tmux {
// DCS passthrough: every ESC inside the payload must be
// doubled so tmux does not interpret it as DCS end.
let escaped_inner = inner.replace('\x1b', "\x1b\x1b");
format!("\x1bPtmux;{escaped_inner}\x1b\\").into_bytes()
} else {
inner.into_bytes()
}
}
// Auto and Off should not reach build_escape.
Method::Auto | Method::Off => vec![],
}
}
/// Emit a turn-complete notification to `sink` if the elapsed time meets or
/// exceeds `threshold`, and `method` is not `Off`.
///
/// This variant takes a `W: Write` sink for testability.
pub fn notify_done_to<W: Write>(
method: Method,
in_tmux: bool,
msg: &str,
threshold: Duration,
elapsed: Duration,
sink: &mut W,
) {
if elapsed < threshold {
return;
}
let effective = match method {
Method::Off => return,
Method::Auto => resolve_method(),
other => other,
};
let bytes = build_escape(effective, in_tmux, msg);
if bytes.is_empty() {
return;
}
// Best-effort: ignore write errors (e.g. stdout closed).
let _ = sink.write_all(&bytes);
let _ = sink.flush();
}
/// 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.
pub fn notify_done(
method: Method,
in_tmux: bool,
msg: &str,
threshold: Duration,
elapsed: Duration,
) {
notify_done_to(method, in_tmux, msg, threshold, elapsed, &mut io::stdout());
}
/// Return a human-readable duration string, e.g. `"1m 12s"` or `"45s"`.
#[must_use]
pub fn humanize_duration(d: Duration) -> String {
let total = d.as_secs();
if total == 0 {
return "0s".to_string();
}
let minutes = total / 60;
let seconds = total % 60;
if minutes == 0 {
format!("{seconds}s")
} else if seconds == 0 {
format!("{minutes}m")
} else {
format!("{minutes}m {seconds}s")
}
}
#[cfg(test)]
mod tests {
use std::sync::{Mutex, OnceLock};
use super::*;
/// Serialise all tests that mutate `TERM_PROGRAM` to prevent data races
/// when the test harness runs them in parallel threads.
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
}
fn capture(
method: Method,
in_tmux: bool,
msg: &str,
threshold_secs: u64,
elapsed_secs: u64,
) -> Vec<u8> {
let mut buf = Vec::new();
notify_done_to(
method,
in_tmux,
msg,
Duration::from_secs(threshold_secs),
Duration::from_secs(elapsed_secs),
&mut buf,
);
buf
}
#[test]
fn osc9_body_format() {
let out = capture(Method::Osc9, false, "deepseek: done", 0, 1);
assert_eq!(out, b"\x1b]9;deepseek: done\x07");
}
#[test]
fn bel_emits_exactly_one_byte() {
let out = capture(Method::Bel, false, "ignored", 0, 1);
assert_eq!(out, b"\x07");
}
#[test]
fn off_mode_emits_nothing() {
let out = capture(Method::Off, false, "ignored", 0, 9999);
assert!(out.is_empty());
}
#[test]
fn below_threshold_emits_nothing() {
let out = capture(Method::Osc9, false, "msg", 30, 29);
assert!(out.is_empty());
}
#[test]
fn at_threshold_emits() {
let out = capture(Method::Osc9, false, "msg", 30, 30);
assert!(!out.is_empty());
}
#[test]
fn tmux_dcs_passthrough_wraps_osc9() {
let out = capture(Method::Osc9, true, "hello", 0, 1);
let s = String::from_utf8(out).unwrap();
assert!(
s.starts_with("\x1bPtmux;"),
"should start with DCS passthrough"
);
assert!(s.ends_with("\x1b\\"), "should end with ST");
assert!(s.contains("hello"), "should contain message");
}
#[test]
fn auto_detect_picks_osc9_for_iterm() {
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", "iTerm.app") };
let resolved = resolve_method();
// Restore previous value.
// 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 auto_detect_picks_bel_for_unknown() {
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", "xterm-256color") };
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::Bel);
}
#[test]
fn humanize_duration_formats_correctly() {
assert_eq!(humanize_duration(Duration::from_secs(0)), "0s");
assert_eq!(humanize_duration(Duration::from_secs(45)), "45s");
assert_eq!(humanize_duration(Duration::from_secs(60)), "1m");
assert_eq!(humanize_duration(Duration::from_secs(72)), "1m 12s");
assert_eq!(humanize_duration(Duration::from_secs(3661)), "61m 1s");
}
}
+41 -4
View File
@@ -617,6 +617,10 @@ async fn run_event_loop(
app.is_loading = false;
app.offline_mode = false;
app.streaming_state.reset();
// Capture elapsed before clearing turn_started_at so
// notifications can use the real wall-clock duration.
let turn_elapsed =
app.turn_started_at.map(|t| t.elapsed()).unwrap_or_default();
app.turn_started_at = None;
// Stream lock applies per-turn; clear it so the next
// turn's chunks pull the view down again until the
@@ -645,10 +649,43 @@ async fn run_event_loop(
}
// Update session cost
if let Some(turn_cost) =
crate::pricing::calculate_turn_cost_from_usage(&app.model, &usage)
{
app.session_cost += turn_cost;
let turn_cost =
crate::pricing::calculate_turn_cost_from_usage(&app.model, &usage);
if let Some(cost) = turn_cost {
app.session_cost += cost;
}
// Emit OSC 9 / BEL desktop notification for long turns.
if status == crate::core::events::TurnOutcomeStatus::Completed {
let notif = config.notifications_config();
let method =
crate::tui::notifications::Method::from_str(match &notif.method {
crate::config::NotificationMethod::Auto => "auto",
crate::config::NotificationMethod::Osc9 => "osc9",
crate::config::NotificationMethod::Bel => "bel",
crate::config::NotificationMethod::Off => "off",
});
let threshold = std::time::Duration::from_secs(notif.threshold_secs);
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
let msg = if notif.include_summary {
let human =
crate::tui::notifications::humanize_duration(turn_elapsed);
match turn_cost {
Some(c) => {
format!("deepseek: turn complete ({human}, ${c:.2})")
}
None => format!("deepseek: turn complete ({human})"),
}
} else {
"deepseek: turn complete".to_string()
};
crate::tui::notifications::notify_done(
method,
in_tmux,
&msg,
threshold,
turn_elapsed,
);
}
// Auto-save completed turn and clear crash checkpoint.