#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TermKind {
Kitty,
Iterm2,
AppleTerminal,
Other,
}
#[derive(Debug, Clone, Copy)]
pub struct NotifyPath {
pub kind: TermKind,
pub tmux: bool,
}
impl NotifyPath {
pub fn detect() -> Self {
Self::from_env(
std::env::var("TERM_PROGRAM").ok().as_deref(),
std::env::var("TERM").ok().as_deref(),
std::env::var_os("KITTY_WINDOW_ID").is_some(),
std::env::var_os("TMUX").is_some(),
)
}
fn from_env(
term_program: Option<&str>,
term: Option<&str>,
kitty_window: bool,
tmux: bool,
) -> Self {
let kind = if kitty_window || term.is_some_and(|t| t.contains("kitty")) {
TermKind::Kitty
} else {
match term_program {
Some("iTerm.app") => TermKind::Iterm2,
Some("Apple_Terminal") => TermKind::AppleTerminal,
_ => TermKind::Other,
}
};
Self { kind, tmux }
}
pub fn notification(&self, title: &str, body: &str) -> Option<Vec<u8>> {
let title = sanitize(title);
let body = sanitize(body);
let seq = match self.kind {
TermKind::AppleTerminal => return None,
TermKind::Kitty => format!("\x1b]99;;{title}: {body}\x1b\\"),
TermKind::Iterm2 => format!("\x1b]9;{title}: {body}\x07"),
TermKind::Other => {
format!("\x1b]777;notify;{};{body}\x07", title.replace(';', ","))
}
};
Some(self.passthrough(seq.into_bytes()))
}
pub fn title(&self, text: &str) -> Vec<u8> {
self.passthrough(format!("\x1b]2;{}\x07", sanitize(text)).into_bytes())
}
pub fn title_push(&self) -> Vec<u8> {
self.passthrough(b"\x1b[22;0t".to_vec())
}
pub fn title_pop(&self) -> Vec<u8> {
self.passthrough(b"\x1b[23;0t".to_vec())
}
fn passthrough(&self, seq: Vec<u8>) -> Vec<u8> {
if !self.tmux {
return seq;
}
let mut out = b"\x1bPtmux;".to_vec();
for byte in seq {
if byte == 0x1b {
out.push(0x1b);
}
out.push(byte);
}
out.extend_from_slice(b"\x1b\\");
out
}
}
fn sanitize(text: &str) -> String {
let mut out: String = text.chars().filter(|c| !c.is_control()).take(120).collect();
if text.chars().count() > 120 {
out.push('…');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detection_picks_one_dialect_per_terminal() {
let kitty = NotifyPath::from_env(None, Some("xterm-kitty"), false, false);
assert_eq!(kitty.kind, TermKind::Kitty);
let kitty_env = NotifyPath::from_env(Some("WezTerm"), Some("xterm-256color"), true, false);
assert_eq!(kitty_env.kind, TermKind::Kitty);
let iterm = NotifyPath::from_env(Some("iTerm.app"), Some("xterm-256color"), false, false);
assert_eq!(iterm.kind, TermKind::Iterm2);
let apple =
NotifyPath::from_env(Some("Apple_Terminal"), Some("xterm-256color"), false, false);
assert_eq!(apple.kind, TermKind::AppleTerminal);
let wez = NotifyPath::from_env(Some("WezTerm"), Some("xterm-256color"), false, false);
assert_eq!(wez.kind, TermKind::Other);
}
#[test]
fn each_dialect_encodes_exactly_its_escape() {
let enc = |kind| NotifyPath { kind, tmux: false }.notification("api-1 finished", "done");
assert_eq!(
enc(TermKind::Iterm2),
Some(b"\x1b]9;api-1 finished: done\x07".to_vec())
);
assert_eq!(
enc(TermKind::Kitty),
Some(b"\x1b]99;;api-1 finished: done\x1b\\".to_vec())
);
assert_eq!(
enc(TermKind::Other),
Some(b"\x1b]777;notify;api-1 finished;done\x07".to_vec())
);
assert_eq!(enc(TermKind::AppleTerminal), None, "bell only");
}
#[test]
fn osc_777_title_field_cannot_smuggle_a_separator() {
let path = NotifyPath {
kind: TermKind::Other,
tmux: false,
};
let seq = path.notification("a;b", "body;with;semis").expect("some");
let text = String::from_utf8(seq).expect("utf8");
assert!(text.starts_with("\x1b]777;notify;a,b;"), "{text:?}");
assert!(text.contains("body;with;semis"), "body keeps its semis");
}
#[test]
fn tmux_passthrough_wraps_and_doubles_escapes() {
let path = NotifyPath {
kind: TermKind::Iterm2,
tmux: true,
};
let seq = path.notification("t", "b").expect("some");
assert!(seq.starts_with(b"\x1bPtmux;\x1b\x1b]9;"), "{seq:?}");
assert!(seq.ends_with(b"\x1b\\"), "{seq:?}");
}
#[test]
fn sanitize_strips_controls_and_caps_length() {
assert_eq!(sanitize("a\x1b]2;evil\x07b"), "a]2;evilb");
let long = "x".repeat(200);
let cleaned = sanitize(&long);
assert_eq!(cleaned.chars().count(), 121, "120 chars + ellipsis");
assert!(cleaned.ends_with('…'));
}
#[test]
fn title_badge_sequence_is_an_osc_2() {
let path = NotifyPath {
kind: TermKind::Other,
tmux: false,
};
assert_eq!(
path.title("bitrouter ⚠1"),
"\x1b]2;bitrouter ⚠1\x07".as_bytes()
);
assert_eq!(path.title_push(), b"\x1b[22;0t".to_vec());
assert_eq!(path.title_pop(), b"\x1b[23;0t".to_vec());
}
}