#[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::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicU8, AtomicU64};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use super::notification_payload::NotificationKind;
pub use super::notification_payload::NotificationPayload;
#[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![],
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NotificationGate {
pub quiet: bool,
pub turn_complete: bool,
pub subagent_terminal: bool,
pub approval_needed: bool,
pub input_needed: bool,
pub elevation_needed: bool,
pub model_notify: bool,
}
impl Default for NotificationGate {
fn default() -> Self {
Self {
quiet: false,
turn_complete: true,
subagent_terminal: true,
approval_needed: true,
input_needed: true,
elevation_needed: true,
model_notify: true,
}
}
}
impl NotificationGate {
#[must_use]
pub fn from_config(notif: &crate::config::NotificationsConfig) -> Self {
Self {
quiet: notif.quiet,
turn_complete: notif.events.turn_complete,
subagent_terminal: notif.events.subagent_terminal,
approval_needed: notif.events.approval_needed,
input_needed: notif.events.input_needed,
elevation_needed: notif.events.elevation_needed,
model_notify: notif.events.model_notify,
}
}
#[must_use]
pub fn allows(self, kind: NotificationKind) -> bool {
if self.quiet {
return false;
}
match kind {
NotificationKind::TurnComplete => self.turn_complete,
NotificationKind::SubagentTerminal => self.subagent_terminal,
NotificationKind::ApprovalNeeded => self.approval_needed,
NotificationKind::InputNeeded => self.input_needed,
NotificationKind::ElevationNeeded => self.elevation_needed,
NotificationKind::ModelNotify => self.model_notify,
}
}
const QUIET_BIT: u8 = 1 << 0;
const TURN_COMPLETE_BIT: u8 = 1 << 1;
const SUBAGENT_TERMINAL_BIT: u8 = 1 << 2;
const APPROVAL_NEEDED_BIT: u8 = 1 << 3;
const INPUT_NEEDED_BIT: u8 = 1 << 4;
const ELEVATION_NEEDED_BIT: u8 = 1 << 5;
const MODEL_NOTIFY_BIT: u8 = 1 << 6;
const fn to_bits(self) -> u8 {
(self.quiet as u8 * Self::QUIET_BIT)
| (self.turn_complete as u8 * Self::TURN_COMPLETE_BIT)
| (self.subagent_terminal as u8 * Self::SUBAGENT_TERMINAL_BIT)
| (self.approval_needed as u8 * Self::APPROVAL_NEEDED_BIT)
| (self.input_needed as u8 * Self::INPUT_NEEDED_BIT)
| (self.elevation_needed as u8 * Self::ELEVATION_NEEDED_BIT)
| (self.model_notify as u8 * Self::MODEL_NOTIFY_BIT)
}
const fn from_bits(bits: u8) -> Self {
Self {
quiet: bits & Self::QUIET_BIT != 0,
turn_complete: bits & Self::TURN_COMPLETE_BIT != 0,
subagent_terminal: bits & Self::SUBAGENT_TERMINAL_BIT != 0,
approval_needed: bits & Self::APPROVAL_NEEDED_BIT != 0,
input_needed: bits & Self::INPUT_NEEDED_BIT != 0,
elevation_needed: bits & Self::ELEVATION_NEEDED_BIT != 0,
model_notify: bits & Self::MODEL_NOTIFY_BIT != 0,
}
}
}
const GATE_DEFAULT_BITS: u8 = 0b0111_1110;
static NOTIFICATION_GATE: AtomicU8 = AtomicU8::new(GATE_DEFAULT_BITS);
pub fn install_notification_gate(gate: NotificationGate) {
NOTIFICATION_GATE.store(gate.to_bits(), Ordering::SeqCst);
}
#[must_use]
pub fn current_notification_gate() -> NotificationGate {
NotificationGate::from_bits(NOTIFICATION_GATE.load(Ordering::SeqCst))
}
pub fn notify_done_to<W: Write>(
method: Method,
in_tmux: bool,
payload: &NotificationPayload,
threshold: Duration,
elapsed: Duration,
gate: NotificationGate,
sink: &mut W,
) {
if elapsed < threshold {
return;
}
if method == Method::Off {
return;
}
if !gate.allows(payload.kind()) {
tracing::debug!(
kind = ?payload.kind(),
quiet = gate.quiet,
"notification suppressed by [notifications] gate"
);
return;
}
let effective = match method {
Method::Off => unreachable!("Method::Off returned before gate evaluation"),
Method::Auto => resolve_method(),
other => other,
};
tracing::debug!(
kind = ?payload.kind(),
method = ?effective,
in_tmux,
"emitting desktop notification"
);
crate::tui::sound_policy::handle_notification_kind_to(
payload.kind(),
crate::tui::sound_policy::epoch_millis_now(),
sink,
);
#[cfg(target_os = "macos")]
if Method::MacOS == effective {
macos_display_notification(payload);
return;
}
let bytes = build_escape(effective, in_tmux, &payload.render_inline());
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,
payload: &NotificationPayload,
threshold: Duration,
elapsed: Duration,
) {
notify_done_to(
method,
in_tmux,
payload,
threshold,
elapsed,
current_notification_gate(),
&mut io::stdout(),
);
}
#[must_use]
fn taskbar_progress_sequence(state: u8, progress: Option<u8>) -> String {
match progress {
Some(pct) => format!("\x1b]9;4;{state};{pct}\x07"),
None => format!("\x1b]9;4;{state}\x07"),
}
}
const MAX_TERMINAL_TITLE_CHARS: usize = 160;
#[must_use]
fn terminal_title_sequence(title: &str) -> String {
let safe: String = crate::session_manager::sanitize_session_title(title)
.chars()
.take(MAX_TERMINAL_TITLE_CHARS)
.collect();
format!("\x1b]0;{safe}\x07")
}
fn stdout_accepts_control_sequences() -> bool {
use std::io::IsTerminal;
io::stdout().is_terminal()
}
pub fn set_taskbar_progress(state: u8, progress: Option<u8>) {
if !stdout_accepts_control_sequences() {
return;
}
let seq = taskbar_progress_sequence(state, progress);
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);
}
static TITLE_PREFIX: OnceLock<Mutex<String>> = OnceLock::new();
pub(crate) fn title_prefix_slot() -> &'static Mutex<String> {
TITLE_PREFIX.get_or_init(|| Mutex::new(String::new()))
}
#[cfg(test)]
pub(crate) fn title_prefix_test_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())
}
pub fn set_title_prefix(prefix: Option<&str>) {
let prefix = prefix.unwrap_or_default().trim();
let changed = {
let mut slot = title_prefix_slot()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if slot.as_str() == prefix {
false
} else {
slot.clear();
slot.push_str(prefix);
true
}
};
if !changed {
return;
}
if TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst) {
let base = title_animation_base()
.lock()
.map_or_else(|_| "Codewhale".to_string(), |base| base.clone());
let motion = TITLE_MOTION_ENABLED.load(Ordering::SeqCst);
set_terminal_title(&title_activity_label(
&base,
Duration::ZERO,
TERMINAL_FOCUSED.load(Ordering::SeqCst),
motion,
));
} else {
set_terminal_title(&decorate_title(resting_title_body()));
}
}
fn resting_title_body() -> &'static str {
if COMPLETION_MARKER_SHOWN.load(Ordering::SeqCst) {
"✓ done"
} else {
"Codewhale"
}
}
static TITLE_ANIMATION_RUNNING: AtomicBool = AtomicBool::new(false);
static TERMINAL_FOCUSED: AtomicBool = AtomicBool::new(true);
static TITLE_MOTION_ENABLED: AtomicBool = AtomicBool::new(true);
static TITLE_ANIMATION_GENERATION: AtomicU64 = AtomicU64::new(0);
static TITLE_ANIMATION_BASE: OnceLock<Mutex<String>> = OnceLock::new();
static TITLE_ACTIVITY_VERB: OnceLock<Mutex<String>> = OnceLock::new();
const TITLE_FRAME_HOLD: Duration = Duration::from_millis(800);
const TITLE_WHALE_FRAMES: &[&str] = &["🐳", "🐋", "🐳", "🐋"];
fn title_animation_base() -> &'static Mutex<String> {
TITLE_ANIMATION_BASE.get_or_init(|| Mutex::new("Codewhale".to_string()))
}
fn title_activity_verb() -> &'static Mutex<String> {
TITLE_ACTIVITY_VERB.get_or_init(|| Mutex::new("working…".to_string()))
}
pub fn set_title_motion_enabled(enabled: bool) {
TITLE_MOTION_ENABLED.store(enabled, Ordering::SeqCst);
}
pub fn set_title_activity_verb(verb: &str) {
let verb = verb.trim();
if verb.is_empty() {
return;
}
if let Ok(mut slot) = title_activity_verb().lock() {
if slot.as_str() == verb {
return;
}
verb.clone_into(&mut *slot);
}
if !TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst) {
return;
}
let base = title_animation_base()
.lock()
.map_or_else(|_| "Codewhale".to_string(), |base| base.clone());
set_terminal_title(&title_activity_label(
&base,
Duration::ZERO,
TERMINAL_FOCUSED.load(Ordering::SeqCst),
TITLE_MOTION_ENABLED.load(Ordering::SeqCst),
));
}
#[must_use]
fn title_activity_label(base: &str, elapsed: Duration, focused: bool, motion: bool) -> String {
let verb = title_activity_verb()
.lock()
.map_or_else(|_| "working…".to_string(), |v| v.clone());
let body = if verb.is_empty() {
base.to_string()
} else {
verb
};
if !motion || focused {
return decorate_title(&format!("🐳 {body}"));
}
let frame = TITLE_WHALE_FRAMES
[(elapsed.as_millis() / TITLE_FRAME_HOLD.as_millis()) as usize % TITLE_WHALE_FRAMES.len()];
decorate_title(&format!("{frame} {body}"))
}
fn decorate_title(raw: &str) -> String {
let prefix = title_prefix_slot()
.lock()
.map_or_else(|_| String::new(), |prefix| prefix.clone());
if prefix.is_empty() {
raw.to_string()
} else {
format!("[{prefix}] {raw}")
}
}
fn set_terminal_title(title: &str) {
if !stdout_accepts_control_sequences() {
return;
}
let seq = terminal_title_sequence(title);
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) {
if let Ok(mut base) = title_animation_base().lock() {
original.clone_into(&mut base);
}
if let Ok(mut verb) = title_activity_verb().lock()
&& verb.is_empty()
{
"working…".clone_into(&mut *verb);
}
COMPLETION_MARKER_SHOWN.store(false, Ordering::SeqCst);
TITLE_ANIMATION_RUNNING.store(true, Ordering::SeqCst);
let generation = TITLE_ANIMATION_GENERATION
.fetch_add(1, Ordering::SeqCst)
.saturating_add(1);
let focused = TERMINAL_FOCUSED.load(Ordering::SeqCst);
let motion = TITLE_MOTION_ENABLED.load(Ordering::SeqCst);
set_terminal_title(&title_activity_label(
original,
Duration::ZERO,
focused,
motion,
));
let base = original.to_string();
std::thread::spawn(move || {
let started_at = std::time::Instant::now();
loop {
std::thread::sleep(TITLE_FRAME_HOLD);
if !TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst)
|| TITLE_ANIMATION_GENERATION.load(Ordering::SeqCst) != generation
{
break;
}
let motion = TITLE_MOTION_ENABLED.load(Ordering::SeqCst);
if motion && !TERMINAL_FOCUSED.load(Ordering::SeqCst) {
set_terminal_title(&title_activity_label(
&base,
started_at.elapsed(),
false,
true,
));
}
}
});
}
pub fn set_terminal_focused(focused: bool) {
TERMINAL_FOCUSED.store(focused, Ordering::SeqCst);
if !TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst) {
return;
}
let base = title_animation_base()
.lock()
.map_or_else(|_| "Codewhale".to_string(), |base| base.clone());
let motion = TITLE_MOTION_ENABLED.load(Ordering::SeqCst);
set_terminal_title(&title_activity_label(
&base,
Duration::ZERO,
focused,
motion,
));
}
pub fn stop_title_animation() {
TITLE_ANIMATION_RUNNING.store(false, Ordering::SeqCst);
TITLE_ANIMATION_GENERATION.fetch_add(1, Ordering::SeqCst);
COMPLETION_MARKER_SHOWN.store(true, Ordering::SeqCst);
set_terminal_title(&decorate_title("✓ done"));
play_completion_sound();
}
pub fn stop_title_animation_quietly() {
TITLE_ANIMATION_RUNNING.store(false, Ordering::SeqCst);
TITLE_ANIMATION_GENERATION.fetch_add(1, Ordering::SeqCst);
COMPLETION_MARKER_SHOWN.store(false, Ordering::SeqCst);
set_terminal_title(&decorate_title("Codewhale"));
}
pub fn reset_title_on_interaction() {
if COMPLETION_MARKER_SHOWN.swap(false, Ordering::SeqCst) {
set_terminal_title(&decorate_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(payload: &NotificationPayload) {
let (subtitle, body) = macos_notification_parts(payload);
let _ = std::thread::Builder::new()
.name("osascript-notif".into())
.spawn(move || {
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(payload: &NotificationPayload) -> (String, String) {
(payload.headline().to_string(), payload.body())
}
use crate::localization::{Locale, MessageId, tr};
use crate::models::{ContentBlock, Message};
use crate::tools::subagent::SubAgentStatus;
use crate::tui::app::App;
pub fn settings(config: &crate::config::Config) -> Option<(Method, Duration, bool)> {
let notif = config.notifications_config();
install_notification_gate(NotificationGate::from_config(¬if));
set_completion_sound(notif.completion_sound, notif.sound_file);
crate::tui::sound_policy::configure(crate::tui::sound_policy::EventSoundPolicy::from_config(
¬if.event_sound,
notif.completion_sound != crate::config::CompletionSound::Off,
));
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_payload(
app: &App,
current_streaming_text: &str,
include_summary: bool,
turn_elapsed: Duration,
turn_cost: Option<crate::pricing::CostEstimate>,
) -> NotificationPayload {
let headline = completion_status(
&tr(app.ui_locale, MessageId::NotificationTurnComplete),
include_summary,
turn_elapsed,
turn_cost.map(|cost| crate::pricing::format_cost_estimate(cost, app.cost_currency)),
);
let preview =
text_summary(current_streaming_text).or_else(|| latest_assistant_text(&app.api_messages));
NotificationPayload::turn_complete(&headline).with_preview(preview.as_deref())
}
pub fn subagent_terminal_payload(
locale: Locale,
id: &str,
result: &str,
status: &SubAgentStatus,
include_summary: bool,
elapsed: Duration,
) -> NotificationPayload {
let result_line = result
.lines()
.map(str::trim)
.find(|line| !line.is_empty() && !line.starts_with("<codewhale:subagent.done>"));
let label = match status {
SubAgentStatus::Completed => MessageId::NotificationSubagentComplete,
SubAgentStatus::Failed(_) => MessageId::NotificationSubagentFailed,
SubAgentStatus::Interrupted(_) => MessageId::NotificationSubagentInterrupted,
SubAgentStatus::Cancelled => MessageId::NotificationSubagentCancelled,
SubAgentStatus::BudgetExhausted => MessageId::NotificationSubagentBudgetExhausted,
SubAgentStatus::Running => MessageId::NotificationSubagentComplete,
};
let headline = completion_status(&tr(locale, label), include_summary, elapsed, None);
let preview = result_line.and_then(text_summary);
NotificationPayload::subagent_terminal(&headline, id).with_preview(preview.as_deref())
}
#[must_use]
pub fn approval_needed_payload(tool_name: &str) -> NotificationPayload {
NotificationPayload::approval_needed(
&format!("Approve or deny '{tool_name}' to continue"),
tool_name,
)
}
#[must_use]
pub fn input_needed_payload() -> NotificationPayload {
NotificationPayload::input_needed("Answer the question in the terminal to continue")
}
#[must_use]
pub fn elevation_needed_payload(tool_name: &str, denial_reason: &str) -> NotificationPayload {
NotificationPayload::elevation_needed(
&format!("Allow or deny elevated access for '{tool_name}'"),
tool_name,
denial_reason,
)
}
fn completion_status(
label: &str,
include_summary: bool,
elapsed: Duration,
cost: Option<String>,
) -> String {
if !include_summary {
return label.to_string();
}
let human = crate::elapsed::format_elapsed_secs(elapsed.as_secs());
match cost {
Some(cost) => format!("{label} ({human}, {cost})"),
None => format!("{label} ({human})"),
}
}
pub fn latest_assistant_text(messages: &[Message]) -> Option<String> {
messages
.iter()
.rev()
.find(|message| {
message.role == "assistant" || message.role == crate::models::INTERRUPTED_ASSISTANT_ROLE
})
.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::*;
#[test]
fn title_whale_is_static_when_focused_or_motion_disabled() {
let _guard = prefix_lock();
if let Ok(mut verb) = title_activity_verb().lock() {
"working…".clone_into(&mut *verb);
}
assert_eq!(
title_activity_label("Codewhale", Duration::ZERO, true, true),
"🐳 working…"
);
assert_eq!(
title_activity_label("Codewhale", Duration::ZERO, false, false),
"🐳 working…"
);
assert_eq!(
title_activity_label("Codewhale", Duration::ZERO, false, true),
"🐳 working…"
);
assert_eq!(
title_activity_label("Codewhale", Duration::from_millis(800), false, true),
"🐋 working…"
);
}
#[test]
fn title_whale_frames_are_the_restored_emoji_pair() {
assert_eq!(TITLE_WHALE_FRAMES, &["🐳", "🐋", "🐳", "🐋"]);
assert_eq!(TITLE_FRAME_HOLD, Duration::from_millis(800));
}
fn prefix_lock() -> std::sync::MutexGuard<'static, ()> {
title_prefix_test_lock()
}
#[test]
fn title_prefix_decorates_activity_label() {
let _guard = prefix_lock();
set_title_prefix(Some("task-7"));
if let Ok(mut verb) = title_activity_verb().lock() {
"reasoning…".clone_into(&mut *verb);
}
assert_eq!(
title_activity_label("Codewhale", Duration::ZERO, true, true),
"[task-7] 🐳 reasoning…"
);
assert_eq!(
title_activity_label("Codewhale", Duration::ZERO, false, true),
"[task-7] 🐳 reasoning…"
);
set_title_prefix(None);
assert_eq!(
title_activity_label("Codewhale", Duration::ZERO, true, true),
"🐳 reasoning…"
);
}
#[test]
fn title_prefix_decorates_rest_and_completion_titles() {
let _guard = prefix_lock();
set_title_prefix(Some("feature/x"));
assert_eq!(decorate_title("Codewhale"), "[feature/x] Codewhale");
assert_eq!(decorate_title("✓ done"), "[feature/x] ✓ done");
set_title_prefix(None);
assert_eq!(decorate_title("Codewhale"), "Codewhale");
assert_eq!(decorate_title("✓ done"), "✓ done");
set_title_prefix(Some(" "));
assert_eq!(decorate_title("Codewhale"), "Codewhale");
set_title_prefix(None);
}
#[test]
fn title_prefix_change_detection_skips_redundant_writes() {
let _guard = prefix_lock();
set_title_prefix(Some("alpha"));
assert_eq!(title_prefix_slot().lock().unwrap().as_str(), "alpha");
set_title_prefix(Some("alpha"));
assert_eq!(title_prefix_slot().lock().unwrap().as_str(), "alpha");
set_title_prefix(Some("beta"));
assert_eq!(title_prefix_slot().lock().unwrap().as_str(), "beta");
set_title_prefix(None);
assert_eq!(title_prefix_slot().lock().unwrap().as_str(), "");
}
#[test]
fn set_title_prefix_redraws_without_deadlocking_while_animating() {
let _guard = prefix_lock();
start_title_animation("Codewhale");
assert!(TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst));
set_title_prefix(Some("task-7"));
assert_eq!(title_prefix_slot().lock().unwrap().as_str(), "task-7");
set_title_prefix(None);
assert_eq!(title_prefix_slot().lock().unwrap().as_str(), "");
stop_title_animation_quietly();
assert!(!TITLE_ANIMATION_RUNNING.load(Ordering::SeqCst));
}
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())
}
struct NotificationGateRestore(NotificationGate);
impl NotificationGateRestore {
fn capture() -> Self {
Self(current_notification_gate())
}
}
impl Drop for NotificationGateRestore {
fn drop(&mut self) {
install_notification_gate(self.0);
}
}
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,
&NotificationPayload::input_needed(msg),
Duration::from_secs(threshold_secs),
Duration::from_secs(elapsed_secs),
NotificationGate::default(),
&mut buf,
);
buf
}
fn capture_gated(payload: &NotificationPayload, gate: NotificationGate) -> Vec<u8> {
let mut buf = Vec::new();
notify_done_to(
Method::Osc9,
false,
payload,
Duration::ZERO,
Duration::from_secs(1),
gate,
&mut buf,
);
buf
}
#[test]
fn gate_defaults_allow_every_kind() {
let gate = NotificationGate::default();
for kind in [
NotificationKind::TurnComplete,
NotificationKind::SubagentTerminal,
NotificationKind::ApprovalNeeded,
NotificationKind::InputNeeded,
NotificationKind::ElevationNeeded,
NotificationKind::ModelNotify,
] {
assert!(gate.allows(kind), "default gate must allow {kind:?}");
}
}
#[test]
fn quiet_gate_suppresses_every_kind() {
let gate = NotificationGate {
quiet: true,
..NotificationGate::default()
};
for kind in [
NotificationKind::TurnComplete,
NotificationKind::SubagentTerminal,
NotificationKind::ApprovalNeeded,
NotificationKind::InputNeeded,
NotificationKind::ElevationNeeded,
NotificationKind::ModelNotify,
] {
assert!(!gate.allows(kind), "quiet gate must suppress {kind:?}");
}
}
#[test]
fn disabled_category_suppresses_only_that_kind() {
let gate = NotificationGate {
approval_needed: false,
..NotificationGate::default()
};
assert!(!gate.allows(NotificationKind::ApprovalNeeded));
assert!(gate.allows(NotificationKind::TurnComplete));
assert!(gate.allows(NotificationKind::InputNeeded));
assert!(gate.allows(NotificationKind::ModelNotify));
}
#[test]
fn gate_bits_roundtrip_and_default_constant_agree() {
assert_eq!(NotificationGate::default().to_bits(), GATE_DEFAULT_BITS);
let odd = NotificationGate {
quiet: true,
turn_complete: false,
subagent_terminal: true,
approval_needed: false,
input_needed: true,
elevation_needed: false,
model_notify: true,
};
assert_eq!(NotificationGate::from_bits(odd.to_bits()), odd);
}
#[test]
fn gated_emission_produces_no_bytes() {
let payload = approval_needed_payload("bash");
let quiet = NotificationGate {
quiet: true,
..NotificationGate::default()
};
assert!(capture_gated(&payload, quiet).is_empty());
let no_approvals = NotificationGate {
approval_needed: false,
..NotificationGate::default()
};
assert!(capture_gated(&payload, no_approvals).is_empty());
let out = capture_gated(&payload, NotificationGate::default());
assert!(!out.is_empty(), "enabled category must still emit");
}
#[test]
fn settings_installs_gate_from_config() {
let _lock = env_lock();
let _gate_restore = NotificationGateRestore::capture();
let config: crate::config::Config = toml::from_str(
r#"
[notifications]
quiet = true
[notifications.events]
approval-needed = false
"#,
)
.expect("gated notifications config should parse");
let _ = settings(&config);
let gate = current_notification_gate();
assert!(gate.quiet);
assert!(!gate.approval_needed);
assert!(gate.turn_complete);
}
#[test]
fn interactive_banners_are_action_first_and_name_the_subject() {
let approval = approval_needed_payload("bash");
assert_eq!(approval.headline(), "Approve or deny 'bash' to continue");
let input = input_needed_payload();
assert_eq!(
input.headline(),
"Answer the question in the terminal to continue"
);
let elevation = elevation_needed_payload("bash", "network blocked");
assert_eq!(
elevation.headline(),
"Allow or deny elevated access for 'bash'"
);
assert!(elevation.body().contains("network blocked"));
}
#[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 control_sequences_have_the_exact_documented_bytes() {
assert_eq!(taskbar_progress_sequence(1, None), "\x1b]9;4;1\x07");
assert_eq!(taskbar_progress_sequence(1, Some(42)), "\x1b]9;4;1;42\x07");
assert_eq!(taskbar_progress_sequence(0, None), "\x1b]9;4;0\x07");
assert_eq!(
terminal_title_sequence("🐳 working…"),
"\x1b]0;🐳 working…\x07"
);
}
#[test]
fn terminal_title_sequence_strips_control_and_bidi_injection() {
assert_eq!(
terminal_title_sequence("safe\u{1b}]2;owned\u{7}\u{202e}title"),
"\x1b]0;safe]2;ownedtitle\x07"
);
let oversized = "x".repeat(MAX_TERMINAL_TITLE_CHARS + 20);
assert_eq!(
terminal_title_sequence(&oversized),
format!("\x1b]0;{}\x07", "x".repeat(MAX_TERMINAL_TITLE_CHARS))
);
}
#[test]
fn terminal_title_sequence_strips_zero_width_and_bidi_marks_but_keeps_cjk() {
assert_eq!(
terminal_title_sequence(
"会\u{9d}0;議\u{9c}\u{200b}A\u{200f}B\u{061c}C\u{2066}D\u{2069}\u{feff}E\u{00ad}F\u{2028}G 🐳!"
),
"\x1b]0;会0;議ABCDEFG 🐳!\x07"
);
let cjk = "漢".repeat(MAX_TERMINAL_TITLE_CHARS + 5);
assert_eq!(
terminal_title_sequence(&cjk),
format!("\x1b]0;{}\x07", "漢".repeat(MAX_TERMINAL_TITLE_CHARS))
);
}
#[test]
fn title_prefix_change_at_rest_repaints_the_resting_title() {
let _guard = prefix_lock();
TITLE_ANIMATION_RUNNING.store(false, Ordering::SeqCst);
COMPLETION_MARKER_SHOWN.store(false, Ordering::SeqCst);
set_title_prefix(Some("Alpha"));
assert_eq!(decorate_title(resting_title_body()), "[Alpha] Codewhale");
COMPLETION_MARKER_SHOWN.store(true, Ordering::SeqCst);
assert_eq!(decorate_title(resting_title_body()), "[Alpha] ✓ done");
COMPLETION_MARKER_SHOWN.store(false, Ordering::SeqCst);
set_title_prefix(None);
assert_eq!(decorate_title(resting_title_body()), "Codewhale");
}
#[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 payload = NotificationPayload::turn_complete("ターン完了 (1m 5s)")
.with_preview(Some("完了しました。"));
let (subtitle, body) = macos_notification_parts(&payload);
assert_eq!(subtitle, "ターン完了 (1m 5s)");
assert_eq!(body, "完了しました。");
}
#[cfg(target_os = "macos")]
#[test]
fn macos_notification_truncates_preview() {
let payload = NotificationPayload::turn_complete("Turn complete")
.with_preview(Some(&"assistant preview ".repeat(40)));
let (subtitle, body) = macos_notification_parts(&payload);
assert_eq!(subtitle, "Turn complete");
assert!(body.starts_with("assistant preview"));
assert!(body.ends_with("..."));
assert_eq!(
body.chars().count(),
super::super::notification_payload::PREVIEW_MAX_CHARS
);
}
#[cfg(target_os = "macos")]
#[test]
fn macos_approval_notification_never_carries_the_command() {
let payload = NotificationPayload::approval_needed("Approval needed", "bash");
let (subtitle, body) = macos_notification_parts(&payload);
assert_eq!(subtitle, "Approval needed");
assert_eq!(body, "bash");
}
#[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 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);
}
}