use std::io::{self, Write};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Method {
#[default]
Auto,
Osc9,
Bel,
Off,
}
impl Method {
#[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,
}
}
}
#[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,
}
}
#[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 {
let escaped_inner = inner.replace('\x1b', "\x1b\x1b");
format!("\x1bPtmux;{escaped_inner}\x1b\\").into_bytes()
} else {
inner.into_bytes()
}
}
Method::Auto | Method::Off => vec![],
}
}
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;
}
let _ = sink.write_all(&bytes);
let _ = sink.flush();
}
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());
}
#[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::*;
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");
unsafe { std::env::set_var("TERM_PROGRAM", "iTerm.app") };
let resolved = resolve_method();
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");
unsafe { std::env::set_var("TERM_PROGRAM", "xterm-256color") };
let resolved = resolve_method();
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");
}
}