#[cfg(target_os = "windows")]
use windows::Win32::System::Diagnostics::Debug::MessageBeep;
#[cfg(target_os = "windows")]
use windows::Win32::UI::WindowsAndMessaging::MESSAGEBOX_STYLE;
use std::io::{self, Write};
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Method {
#[default]
Auto,
Osc9,
Bel,
Kitty,
Ghostty,
Off,
}
#[cfg(target_os = "windows")]
fn windows_bell() {
unsafe {
let _ = MessageBeep(MESSAGEBOX_STYLE(0));
}
}
#[must_use]
fn resolve_method() -> Method {
let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
match term_program.as_str() {
"iTerm.app" | "WezTerm" | "Cmux" => return Method::Osc9,
"Ghostty" => return Method::Ghostty,
"kitty" => return Method::Kitty,
_ => {}
}
let lc_terminal = std::env::var("LC_TERMINAL").unwrap_or_default();
match lc_terminal.as_str() {
"iTerm.app" | "Ghostty" | "WezTerm" | "Cmux" => return Method::Osc9,
_ => {}
}
if cfg!(target_os = "windows") {
return Method::Off;
}
let term = std::env::var("TERM").unwrap_or_default();
if term.contains("ghostty") {
Method::Osc9
} else if term.contains("kitty") {
Method::Kitty
} else {
Method::Bel
}
}
fn wrap_for_multiplexer(seq: &str, in_tmux: bool) -> String {
if in_tmux {
let escaped = seq.replace('\x1b', "\x1b\x1b");
format!("\x1bPtmux;{escaped}\x1b\\")
} else {
seq.to_string()
}
}
#[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::Kitty => {
let title_seq = "\x1b]99;d=0:p=title\x1b\\";
let body_seq = format!("\x1b]99;p=body;{msg}\x1b\\");
let focus_seq = "\x1b]99;d=1:a=focus\x1b\\";
let combined = format!("{title_seq}{body_seq}{focus_seq}");
wrap_for_multiplexer(&combined, in_tmux).into_bytes()
}
Method::Ghostty => {
let seq = format!("\x1b]777;notify;DeepSeek TUI;{msg}\x07");
wrap_for_multiplexer(&seq, in_tmux).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();
#[cfg(target_os = "windows")]
if effective == Method::Bel {
windows_bell();
}
}
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 {
const MINUTE: u64 = 60;
const HOUR: u64 = 60 * MINUTE;
const DAY: u64 = 24 * HOUR;
const WEEK: u64 = 7 * DAY;
let total = d.as_secs();
if total == 0 {
return "0s".to_string();
}
if total >= WEEK {
let w = total / WEEK;
let days = (total % WEEK) / DAY;
return if days == 0 {
format!("{w}w")
} else {
format!("{w}w {days}d")
};
}
if total >= DAY {
let days = total / DAY;
let h = (total % DAY) / HOUR;
return if h == 0 {
format!("{days}d")
} else {
format!("{days}d {h}h")
};
}
if total >= HOUR {
let h = total / HOUR;
let m = (total % HOUR) / MINUTE;
return if m == 0 {
format!("{h}h")
} else {
format!("{h}h {m}m")
};
}
if total >= MINUTE {
let m = total / MINUTE;
let s = total % MINUTE;
return if s == 0 {
format!("{m}m")
} else {
format!("{m}m {s}s")
};
}
format!("{total}s")
}
use crate::models::{ContentBlock, Message};
use crate::tui::app::App;
pub fn settings(config: &crate::config::Config) -> Option<(Method, Duration, bool)> {
let notif = config.notifications_config();
let method = match notif.method {
crate::config::NotificationMethod::Auto => Method::Auto,
crate::config::NotificationMethod::Osc9 => Method::Osc9,
crate::config::NotificationMethod::Bel => Method::Bel,
crate::config::NotificationMethod::Kitty => Method::Kitty,
crate::config::NotificationMethod::Ghostty => Method::Ghostty,
crate::config::NotificationMethod::Off => Method::Off,
};
if let Some(condition) = config
.tui
.as_ref()
.and_then(|tui| tui.notification_condition)
{
match condition {
crate::config::NotificationCondition::Always => {
return Some((method, Duration::ZERO, notif.include_summary));
}
crate::config::NotificationCondition::Never => return None,
}
}
Some((
method,
Duration::from_secs(notif.threshold_secs),
notif.include_summary,
))
}
pub fn completed_turn_message(
app: &App,
current_streaming_text: &str,
include_summary: bool,
turn_elapsed: Duration,
turn_cost: Option<crate::pricing::CostEstimate>,
) -> String {
let mut msg = text_summary(current_streaming_text)
.or_else(|| latest_assistant_text(&app.api_messages))
.unwrap_or_else(|| "deepseek: turn complete".to_string());
if include_summary {
let human = humanize_duration(turn_elapsed);
let summary = match turn_cost {
Some(c) => {
let cost = crate::pricing::format_cost_estimate(c, app.cost_currency);
format!("deepseek: turn complete ({human}, {cost})")
}
None => format!("deepseek: turn complete ({human})"),
};
if msg == "deepseek: turn complete" {
msg = summary;
} else {
msg.push('\n');
msg.push_str(&summary);
}
}
msg
}
pub fn subagent_completion_message(
id: &str,
result: &str,
include_summary: bool,
elapsed: Duration,
) -> String {
let result_line = result
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with("<deepseek:subagent.done>"));
let mut msg = result_line
.and_then(text_summary)
.map(|summary| format!("sub-agent {id}: {summary}"))
.unwrap_or_else(|| format!("deepseek: sub-agent {id} complete"));
if include_summary {
let human = humanize_duration(elapsed);
msg.push('\n');
msg.push_str(&format!("deepseek: sub-agent complete ({human})"));
}
msg
}
pub fn latest_assistant_text(messages: &[Message]) -> Option<String> {
messages
.iter()
.rev()
.find(|message| message.role == "assistant")
.and_then(|message| {
let text = message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text, .. } => Some(text.as_str()),
ContentBlock::Thinking { .. }
| ContentBlock::ToolUse { .. }
| ContentBlock::ToolResult { .. }
| ContentBlock::ServerToolUse { .. }
| ContentBlock::ToolSearchToolResult { .. }
| ContentBlock::CodeExecutionToolResult { .. } => None,
})
.collect::<Vec<_>>()
.join("\n");
text_summary(&text)
})
}
pub fn text_summary(text: &str) -> Option<String> {
const MAX_CHARS: usize = 360;
let sanitized = super::ui::sanitize_stream_chunk(text);
let collapsed = sanitized
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n");
let trimmed = collapsed.trim();
if trimmed.is_empty() {
return None;
}
if let Some((idx, _)) = trimmed.char_indices().nth(MAX_CHARS) {
let mut s = String::with_capacity(idx + 3);
s.push_str(&trimmed[..idx]);
s.push_str("...");
Some(s)
} else {
Some(trimmed.to_string())
}
}
#[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 kitty_escape_uses_st_terminator() {
let out = capture(Method::Kitty, false, "done", 0, 1);
let s = String::from_utf8(out).unwrap();
assert!(s.contains("99;"), "should have kitty OSC 99");
assert!(s.contains("\x1b\\"), "kitty uses ST terminator");
assert!(!s.contains("\x07"), "kitty should NOT use BEL");
}
#[test]
fn ghostty_escape_format() {
let out = capture(Method::Ghostty, false, "done", 0, 1);
let s = String::from_utf8(out).unwrap();
assert!(
s.contains("777;notify;DeepSeek TUI;done"),
"should have ghostty seq"
);
}
#[test]
fn kitty_tmux_dcs_passthrough() {
let out = capture(Method::Kitty, true, "hello", 0, 1);
let s = String::from_utf8(out).unwrap();
assert!(s.starts_with("\x1bPtmux;"), "should start with DCS");
assert!(s.ends_with("\x1b\\"), "should end with ST");
}
#[test]
fn ghostty_tmux_dcs_passthrough() {
let out = capture(Method::Ghostty, true, "hello", 0, 1);
let s = String::from_utf8(out).unwrap();
assert!(s.starts_with("\x1bPtmux;"), "should start with DCS");
assert!(s.ends_with("\x1b\\"), "should end with ST");
}
#[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_osc9_for_cmux_via_lc_terminal() {
let _lock = env_lock();
let prev_tp = std::env::var_os("TERM_PROGRAM");
let prev_lc = std::env::var_os("LC_TERMINAL");
unsafe {
std::env::remove_var("TERM_PROGRAM");
std::env::set_var("LC_TERMINAL", "Cmux");
}
let resolved = resolve_method();
unsafe {
match prev_tp {
Some(v) => std::env::set_var("TERM_PROGRAM", v),
None => std::env::remove_var("TERM_PROGRAM"),
}
match prev_lc {
Some(v) => std::env::set_var("LC_TERMINAL", v),
None => std::env::remove_var("LC_TERMINAL"),
}
}
assert_eq!(resolved, Method::Osc9);
}
#[test]
fn auto_detect_picks_osc9_for_wezterm_via_lc_terminal() {
let _lock = env_lock();
let prev_tp = std::env::var_os("TERM_PROGRAM");
let prev_lc = std::env::var_os("LC_TERMINAL");
unsafe {
std::env::remove_var("TERM_PROGRAM");
std::env::set_var("LC_TERMINAL", "WezTerm");
}
let resolved = resolve_method();
unsafe {
match prev_tp {
Some(v) => std::env::set_var("TERM_PROGRAM", v),
None => std::env::remove_var("TERM_PROGRAM"),
}
match prev_lc {
Some(v) => std::env::set_var("LC_TERMINAL", v),
None => std::env::remove_var("LC_TERMINAL"),
}
}
assert_eq!(resolved, Method::Osc9);
}
#[test]
#[cfg(not(target_os = "windows"))]
fn auto_detect_picks_bel_for_unknown_on_unix() {
let _lock = env_lock();
let prev_tp = std::env::var_os("TERM_PROGRAM");
let prev_lc = std::env::var_os("LC_TERMINAL");
let prev_term = std::env::var_os("TERM");
unsafe {
std::env::set_var("TERM_PROGRAM", "xterm-256color");
std::env::remove_var("LC_TERMINAL");
std::env::set_var("TERM", "xterm-256color");
}
let resolved = resolve_method();
unsafe {
match prev_tp {
Some(v) => std::env::set_var("TERM_PROGRAM", v),
None => std::env::remove_var("TERM_PROGRAM"),
}
match prev_lc {
Some(v) => std::env::set_var("LC_TERMINAL", v),
None => std::env::remove_var("LC_TERMINAL"),
}
match prev_term {
Some(v) => std::env::set_var("TERM", v),
None => std::env::remove_var("TERM"),
}
}
assert_eq!(resolved, Method::Bel);
}
#[test]
#[cfg(target_os = "windows")]
fn auto_detect_picks_off_for_unknown_on_windows() {
let _lock = env_lock();
let prev = std::env::var_os("TERM_PROGRAM");
unsafe { std::env::set_var("TERM_PROGRAM", "Windows Terminal") };
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::Off);
}
#[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");
unsafe { std::env::set_var("TERM_PROGRAM", "WezTerm") };
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]
#[cfg(not(target_os = "windows"))]
fn auto_detect_picks_osc9_for_xterm_ghostty_term_fallback() {
let _lock = env_lock();
let prev_tp = std::env::var_os("TERM_PROGRAM");
let prev_lc = std::env::var_os("LC_TERMINAL");
let prev_term = std::env::var_os("TERM");
unsafe {
std::env::remove_var("TERM_PROGRAM");
std::env::remove_var("LC_TERMINAL");
std::env::set_var("TERM", "xterm-ghostty");
}
let resolved = resolve_method();
unsafe {
match prev_tp {
Some(v) => std::env::set_var("TERM_PROGRAM", v),
None => std::env::remove_var("TERM_PROGRAM"),
}
match prev_lc {
Some(v) => std::env::set_var("LC_TERMINAL", v),
None => std::env::remove_var("LC_TERMINAL"),
}
match prev_term {
Some(v) => std::env::set_var("TERM", v),
None => std::env::remove_var("TERM"),
}
}
assert_eq!(resolved, Method::Osc9);
}
#[test]
fn auto_detect_picks_ghostty_from_term_program() {
let _lock = env_lock();
let prev = std::env::var_os("TERM_PROGRAM");
unsafe { std::env::set_var("TERM_PROGRAM", "Ghostty") };
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::Ghostty);
}
#[test]
fn auto_detect_picks_kitty_from_term_program() {
let _lock = env_lock();
let prev = std::env::var_os("TERM_PROGRAM");
unsafe { std::env::set_var("TERM_PROGRAM", "kitty") };
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::Kitty);
}
#[test]
#[cfg(not(target_os = "windows"))]
fn auto_detect_picks_kitty_from_term_fallback() {
let _lock = env_lock();
let prev_tp = std::env::var_os("TERM_PROGRAM");
let prev_lc = std::env::var_os("LC_TERMINAL");
let prev_term = std::env::var_os("TERM");
unsafe {
std::env::remove_var("TERM_PROGRAM");
std::env::remove_var("LC_TERMINAL");
std::env::set_var("TERM", "xterm-kitty");
}
let resolved = resolve_method();
unsafe {
match prev_tp {
Some(v) => std::env::set_var("TERM_PROGRAM", v),
None => std::env::remove_var("TERM_PROGRAM"),
}
match prev_lc {
Some(v) => std::env::set_var("LC_TERMINAL", v),
None => std::env::remove_var("LC_TERMINAL"),
}
match prev_term {
Some(v) => std::env::set_var("TERM", v),
None => std::env::remove_var("TERM"),
}
}
assert_eq!(resolved, Method::Kitty);
}
#[test]
#[cfg(not(target_os = "windows"))]
fn auto_detect_falls_back_to_bel_for_unrelated_term() {
let _lock = env_lock();
let prev_tp = std::env::var_os("TERM_PROGRAM");
let prev_lc = std::env::var_os("LC_TERMINAL");
let prev_term = std::env::var_os("TERM");
unsafe {
std::env::remove_var("TERM_PROGRAM");
std::env::remove_var("LC_TERMINAL");
std::env::set_var("TERM", "xterm-256color");
}
let resolved = resolve_method();
unsafe {
match prev_tp {
Some(v) => std::env::set_var("TERM_PROGRAM", v),
None => std::env::remove_var("TERM_PROGRAM"),
}
match prev_lc {
Some(v) => std::env::set_var("LC_TERMINAL", v),
None => std::env::remove_var("LC_TERMINAL"),
}
match prev_term {
Some(v) => std::env::set_var("TERM", v),
None => std::env::remove_var("TERM"),
}
}
assert_eq!(resolved, Method::Bel);
}
#[test]
fn humanize_duration_seconds_and_minutes() {
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(3599)), "59m 59s");
}
#[test]
fn humanize_duration_promotes_to_hours_at_one_hour() {
assert_eq!(humanize_duration(Duration::from_secs(3661)), "1h 1m");
assert_eq!(humanize_duration(Duration::from_secs(3600)), "1h");
assert_eq!(humanize_duration(Duration::from_secs(7200)), "2h");
assert_eq!(humanize_duration(Duration::from_secs(7320)), "2h 2m");
assert_eq!(humanize_duration(Duration::from_secs(11_550)), "3h 12m");
}
#[test]
fn humanize_duration_handles_multi_day_sessions() {
assert_eq!(humanize_duration(Duration::from_secs(86_400)), "1d");
assert_eq!(humanize_duration(Duration::from_secs(90_000)), "1d 1h");
assert_eq!(
humanize_duration(Duration::from_secs(2 * 86_400 + 5 * 3600 + 17 * 60)),
"2d 5h"
);
}
#[test]
fn humanize_duration_promotes_to_weeks_after_seven_days() {
assert_eq!(humanize_duration(Duration::from_secs(604_800)), "1w");
assert_eq!(
humanize_duration(Duration::from_secs(604_800 + 86_400)),
"1w 1d"
);
assert_eq!(
humanize_duration(Duration::from_secs(3 * 604_800 + 2 * 86_400 + 17 * 3600)),
"3w 2d"
);
}
}