Merge branch 'feat/v070-osc9' (#132 OSC 9 desktop notification on long-turn completion)
- crates/tui/src/tui/notifications.rs (NEW) — Method enum {Auto/Osc9/Bel/Off}, notify_done()
- crates/tui/src/tui/mod.rs + ui.rs — register module + hook EngineEvent::TurnComplete
- crates/tui/src/config.rs — NotificationsConfig (method/threshold_secs/include_summary)
- config.example.toml — [notifications] section
- 9 unit tests including tmux DCS passthrough wrapping
This commit is contained in:
@@ -171,6 +171,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)
|
||||
# ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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)]
|
||||
@@ -771,6 +809,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 {
|
||||
@@ -1278,6 +1322,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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 ¬if.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.
|
||||
|
||||
Reference in New Issue
Block a user