#[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::path::{Path, PathBuf};
use std::sync::atomic::AtomicU8;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
#[cfg(target_os = "windows")]
use std::os::windows::ffi::OsStrExt;
#[cfg(target_os = "windows")]
use windows::Win32::Media::Audio::{PlaySoundW, SND_ASYNC, SND_FILENAME, SND_NODEFAULT};
#[cfg(target_os = "windows")]
use windows::core::PCWSTR;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Method {
#[default]
Auto,
Osc9,
Bel,
MacOS,
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::Bel;
}
if cfg!(target_os = "macos") {
return Method::MacOS;
}
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;codewhale;{msg}\x07");
wrap_for_multiplexer(&seq, in_tmux).into_bytes()
}
Method::Auto | Method::Off | Method::MacOS => 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,
};
#[cfg(target_os = "macos")]
if Method::MacOS == effective {
macos_display_notification(msg);
return;
}
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());
}
pub fn set_taskbar_progress(state: u8, progress: Option<u8>) {
let seq = if let Some(pct) = progress {
format!("\x1b]9;4;{state};{pct}\x07")
} else {
format!("\x1b]9;4;{state}\x07")
};
let mut stdout = io::stdout();
let _ = stdout.write_all(seq.as_bytes());
let _ = stdout.flush();
}
pub fn set_taskbar_progress_busy() {
set_taskbar_progress(1, None);
}
pub fn clear_taskbar_progress() {
set_taskbar_progress(0, None);
}
const TITLE_FRAMES: &[&str] = &["🐳", "🐋", "🐳", "🐋"];
const TITLE_ANIMATION_INTERVAL: Duration = Duration::from_millis(800);
static TITLE_ANIMATION_RUNNING: AtomicBool = AtomicBool::new(false);
fn set_terminal_title(title: &str) {
let seq = format!("\x1b]0;{title}\x07");
let mut stdout = io::stdout();
let _ = stdout.write_all(seq.as_bytes());
let _ = stdout.flush();
}
static COMPLETION_MARKER_SHOWN: AtomicBool = AtomicBool::new(false);
pub fn start_title_animation(original: &str) {
TITLE_ANIMATION_RUNNING.store(true, Ordering::SeqCst);
let base = original.to_string();
tokio::spawn(async move {
let mut frame = 0usize;
while TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst) {
tokio::task::yield_now().await;
if !TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst) {
break;
}
let spinner = TITLE_FRAMES[frame % TITLE_FRAMES.len()];
set_terminal_title(&format!("{spinner} {base}"));
frame += 1;
tokio::time::sleep(TITLE_ANIMATION_INTERVAL).await;
}
});
}
pub fn stop_title_animation() {
TITLE_ANIMATION_RUNNING.store(false, Ordering::SeqCst);
COMPLETION_MARKER_SHOWN.store(false, Ordering::SeqCst);
let mode = COMPLETION_SOUND_MODE.load(Ordering::SeqCst);
if mode == 1 {
set_terminal_title("✅ CodeWhale");
}
play_completion_sound();
}
pub fn stop_title_animation_quietly() {
TITLE_ANIMATION_RUNNING.store(false, Ordering::SeqCst);
COMPLETION_MARKER_SHOWN.store(false, Ordering::SeqCst);
set_terminal_title("CodeWhale");
}
pub fn reset_title_on_interaction() {
if COMPLETION_MARKER_SHOWN.swap(false, Ordering::SeqCst) {
set_terminal_title("CodeWhale");
}
}
static COMPLETION_SOUND_MODE: AtomicU8 = AtomicU8::new(1);
static COMPLETION_SOUND_FILE: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
#[cfg(not(target_os = "windows"))]
static COMPLETION_SOUND_FILE_UNSUPPORTED_WARNED: AtomicBool = AtomicBool::new(false);
static COMPLETION_SOUND_FILE_MISSING_WARNED: AtomicBool = AtomicBool::new(false);
fn completion_sound_file_slot() -> &'static Mutex<Option<PathBuf>> {
COMPLETION_SOUND_FILE.get_or_init(|| Mutex::new(None))
}
fn set_completion_sound(mode: crate::config::CompletionSound, sound_file: Option<PathBuf>) {
let val = match mode {
crate::config::CompletionSound::Off => 0u8,
crate::config::CompletionSound::Beep => 1u8,
crate::config::CompletionSound::Bell => 2u8,
crate::config::CompletionSound::File => 3u8,
};
COMPLETION_SOUND_MODE.store(val, Ordering::SeqCst);
if let Ok(mut slot) = completion_sound_file_slot().lock() {
if sound_file.is_some() {
COMPLETION_SOUND_FILE_MISSING_WARNED.store(false, Ordering::SeqCst);
}
*slot = sound_file;
}
}
pub fn play_completion_sound() {
match COMPLETION_SOUND_MODE.load(Ordering::SeqCst) {
0 => {} 1 => {
beep_sound();
}
2 => {
bell_sound();
}
3 => {
file_sound();
}
_ => {}
}
}
#[cfg(target_os = "windows")]
fn beep_sound() {
windows_bell();
}
#[cfg(not(target_os = "windows"))]
fn beep_sound() {
let _ = io::stdout().write_all(b"\x07");
}
fn bell_sound() {
let _ = io::stdout().write_all(b"\x07");
}
fn configured_sound_file() -> Option<PathBuf> {
completion_sound_file_slot()
.lock()
.ok()
.and_then(|slot| slot.clone())
}
#[cfg(target_os = "windows")]
fn play_sound_file(path: &Path) {
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
unsafe {
let _ = PlaySoundW(
PCWSTR(wide.as_ptr()),
None,
SND_FILENAME | SND_ASYNC | SND_NODEFAULT,
);
}
}
#[cfg(not(target_os = "windows"))]
fn play_sound_file(_path: &Path) {
if !COMPLETION_SOUND_FILE_UNSUPPORTED_WARNED.swap(true, Ordering::SeqCst) {
tracing::warn!("completion_sound = \"file\" is currently supported on Windows only");
}
}
fn file_sound() {
if let Some(path) = configured_sound_file() {
play_sound_file(&path);
} else if !COMPLETION_SOUND_FILE_MISSING_WARNED.swap(true, Ordering::SeqCst) {
tracing::warn!("completion_sound = \"file\" requires [notifications].sound_file");
}
}
#[cfg(test)]
fn completion_sound_state_for_tests() -> (crate::config::CompletionSound, Option<PathBuf>) {
let mode = match COMPLETION_SOUND_MODE.load(Ordering::SeqCst) {
0 => crate::config::CompletionSound::Off,
1 => crate::config::CompletionSound::Beep,
2 => crate::config::CompletionSound::Bell,
3 => crate::config::CompletionSound::File,
_ => crate::config::CompletionSound::Off,
};
(mode, configured_sound_file())
}
#[cfg(target_os = "macos")]
fn macos_display_notification(msg: &str) {
let message = msg.to_string();
let _ = std::thread::Builder::new()
.name("osascript-notif".into())
.spawn(move || {
let (subtitle, body) = macos_notification_parts(&message);
let args = [
"-e".to_string(),
"on run argv".to_string(),
"-e".to_string(),
"set theBody to item 1 of argv".to_string(),
"-e".to_string(),
"set theSubtitle to item 2 of argv".to_string(),
"-e".to_string(),
"display notification theBody with title \"CodeWhale\" subtitle theSubtitle sound name \"default\"".to_string(),
"-e".to_string(),
"end run".to_string(),
"--".to_string(),
body,
subtitle,
];
match std::process::Command::new("osascript")
.args(&args)
.output()
{
Ok(output) if !output.status.success() => {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!(stderr = %stderr, "osascript notification failed");
}
Err(e) => {
tracing::warn!(error = %e, "osascript notification error");
}
_ => {}
}
});
}
#[cfg(target_os = "macos")]
fn macos_notification_parts(msg: &str) -> (String, String) {
const SUBTITLE_MAX_CHARS: usize = 80;
const BODY_MAX_CHARS: usize = 200;
let sanitized = super::ui::sanitize_stream_chunk(msg);
let lines: Vec<&str> = sanitized
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect();
if lines.is_empty() {
return ("CodeWhale".to_string(), String::new());
}
let subtitle = truncate_notification_text(lines[0], SUBTITLE_MAX_CHARS);
let body = truncate_notification_text(&lines[1..].join("\n"), BODY_MAX_CHARS);
(subtitle, body)
}
#[cfg(target_os = "macos")]
fn truncate_notification_text(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();
}
let take = max_chars.saturating_sub(3);
let mut out = text.chars().take(take).collect::<String>();
out.push_str("...");
out
}
#[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::localization::Locale;
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();
set_completion_sound(notif.completion_sound, notif.sound_file);
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 = completion_status(
notification_turn_complete(app.ui_locale),
include_summary,
turn_elapsed,
turn_cost.map(|cost| crate::pricing::format_cost_estimate(cost, app.cost_currency)),
);
if let Some(preview) =
text_summary(current_streaming_text).or_else(|| latest_assistant_text(&app.api_messages))
{
msg.push('\n');
msg.push_str(&preview);
}
msg
}
pub fn subagent_completion_message(
locale: Locale,
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("<codewhale:subagent.done>"));
let mut msg = completion_status(
notification_subagent_complete(locale),
include_summary,
elapsed,
None,
);
let detail = result_line
.and_then(text_summary)
.map(|summary| format!("{id}: {summary}"))
.unwrap_or_else(|| id.to_string());
msg.push('\n');
msg.push_str(&detail);
msg
}
fn completion_status(
label: &str,
include_summary: bool,
elapsed: Duration,
cost: Option<String>,
) -> String {
if !include_summary {
return label.to_string();
}
let human = humanize_duration(elapsed);
match cost {
Some(cost) => format!("{label} ({human}, {cost})"),
None => format!("{label} ({human})"),
}
}
fn notification_turn_complete(locale: Locale) -> &'static str {
match locale {
Locale::En => "Turn complete",
Locale::Ja => "ターン完了",
Locale::ZhHans => "本轮已完成",
Locale::ZhHant => "本輪已完成",
Locale::PtBr => "Turno concluído",
Locale::Es419 => "Turno completado",
Locale::Vi => "Lượt hoàn tất",
}
}
fn notification_subagent_complete(locale: Locale) -> &'static str {
match locale {
Locale::En => "Sub-agent complete",
Locale::Ja => "サブエージェント完了",
Locale::ZhHans => "子代理已完成",
Locale::ZhHant => "子代理已完成",
Locale::PtBr => "Subagente concluído",
Locale::Es419 => "Subagente completado",
Locale::Vi => "Sub-agent hoàn tất",
}
}
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,
ContentBlock::ImageUrl { .. } => 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: &&str| !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_or_else(|poisoned| poisoned.into_inner())
}
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, "codewhale: done", 0, 1);
assert_eq!(out, b"\x1b]9;codewhale: 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;codewhale;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());
}
#[cfg(target_os = "macos")]
#[test]
fn macos_notification_keeps_localized_status_as_subtitle() {
let (subtitle, body) = macos_notification_parts("ターン完了 (1m 5s)\n完了しました。");
assert_eq!(subtitle, "ターン完了 (1m 5s)");
assert_eq!(body, "完了しました。");
}
#[cfg(target_os = "macos")]
#[test]
fn macos_notification_truncates_body_after_status_line() {
let msg = format!("Turn complete\n{}", "assistant preview ".repeat(40));
let (subtitle, body) = macos_notification_parts(&msg);
assert_eq!(subtitle, "Turn complete");
assert!(body.starts_with("assistant preview"));
assert!(body.ends_with("..."));
assert_eq!(body.chars().count(), 200);
}
#[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(any(target_os = "windows", target_os = "macos")))]
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_bel_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::Bel);
}
#[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(any(target_os = "windows", target_os = "macos")))]
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(any(target_os = "windows", target_os = "macos")))]
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(any(target_os = "windows", target_os = "macos")))]
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"
);
}
#[test]
fn settings_installs_custom_completion_sound_file() {
let _lock = env_lock();
let config: crate::config::Config = toml::from_str(
r#"
[notifications]
completion_sound = "file"
sound_file = "E:\\google\\downloads\\xm4114.wav"
"#,
)
.expect("custom completion sound config should parse");
let _ = settings(&config);
let (mode, file) = completion_sound_state_for_tests();
assert_eq!(mode, crate::config::CompletionSound::File);
assert_eq!(
file.as_deref(),
Some(std::path::Path::new("E:\\google\\downloads\\xm4114.wav"))
);
}
#[test]
fn setting_valid_sound_file_resets_missing_file_warning_latch() {
let _lock = env_lock();
COMPLETION_SOUND_FILE_MISSING_WARNED.store(true, Ordering::SeqCst);
set_completion_sound(
crate::config::CompletionSound::File,
Some(std::path::PathBuf::from(
"E:\\google\\downloads\\xm4114.wav",
)),
);
assert!(!COMPLETION_SOUND_FILE_MISSING_WARNED.load(Ordering::SeqCst));
set_completion_sound(crate::config::CompletionSound::File, None);
file_sound();
assert!(COMPLETION_SOUND_FILE_MISSING_WARNED.load(Ordering::SeqCst));
set_completion_sound(crate::config::CompletionSound::Beep, None);
COMPLETION_SOUND_FILE_MISSING_WARNED.store(false, Ordering::SeqCst);
}
}