1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
|
use std::io::{self, Write as _};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TerminalNotificationBackend {
Ghostty,
Iterm2,
Kitty,
WezTerm,
}
pub fn detect_backend() -> Option<TerminalNotificationBackend> {
let term_program = std::env::var("TERM_PROGRAM").ok();
let term = std::env::var("TERM").ok();
match term_program.as_deref() {
Some("ghostty") => return Some(TerminalNotificationBackend::Ghostty),
Some("iTerm.app") => return Some(TerminalNotificationBackend::Iterm2),
Some("WezTerm") => return Some(TerminalNotificationBackend::WezTerm),
_ => {}
}
if std::env::var_os("KITTY_WINDOW_ID").is_some() {
return Some(TerminalNotificationBackend::Kitty);
}
match term.as_deref() {
Some("xterm-ghostty") => Some(TerminalNotificationBackend::Ghostty),
Some("xterm-kitty") => Some(TerminalNotificationBackend::Kitty),
Some(term) if term.contains("wezterm") => Some(TerminalNotificationBackend::WezTerm),
_ => None,
}
}
pub fn show_notification(title: &str, body: Option<&str>) -> io::Result<bool> {
let Some(backend) = detect_backend() else {
return Ok(false);
};
let sequence = match backend {
TerminalNotificationBackend::Ghostty
| TerminalNotificationBackend::Iterm2
| TerminalNotificationBackend::WezTerm => build_osc9_notification(title, body),
TerminalNotificationBackend::Kitty => build_osc99_notification(title, body),
};
let sequence = if std::env::var_os("TMUX").is_some() {
wrap_tmux_passthrough(&sequence)
} else {
sequence
};
let mut stdout = io::stdout();
stdout.write_all(&sequence)?;
stdout.flush()?;
Ok(true)
}
pub fn split_message(message: &str) -> (&str, Option<&str>) {
match message.split_once(": ") {
Some((title, body)) if !title.is_empty() && !body.is_empty() => (title, Some(body)),
_ => (message, None),
}
}
fn build_osc9_notification(title: &str, body: Option<&str>) -> Vec<u8> {
let message = sanitize_text(match body {
Some(body) if !body.is_empty() => format!("{title}: {body}"),
_ => title.to_string(),
});
format!("\x1b]9;{message}\x1b\\").into_bytes()
}
fn build_osc99_notification(title: &str, body: Option<&str>) -> Vec<u8> {
let title = sanitize_text(title);
match body {
Some(body) if !body.is_empty() => {
let body = sanitize_text(body);
format!("\x1b]99;i=1:d=0;{title}\x1b\\\x1b]99;i=1:p=body;{body}\x1b\\").into_bytes()
}
_ => format!("\x1b]99;;{title}\x1b\\").into_bytes(),
}
}
fn sanitize_text(text: impl AsRef<str>) -> String {
text.as_ref()
.chars()
.filter(|ch| *ch != '\u{1b}' && *ch != '\u{7}' && *ch != '\u{9c}')
.map(|ch| match ch {
'\n' | '\r' | '\t' => ' ',
_ => ch,
})
.collect()
}
fn wrap_tmux_passthrough(sequence: &[u8]) -> Vec<u8> {
let mut wrapped = Vec::with_capacity(sequence.len() + 16);
wrapped.extend_from_slice(b"\x1bPtmux;");
for &byte in sequence {
if byte == 0x1b {
wrapped.push(0x1b);
}
wrapped.push(byte);
}
wrapped.extend_from_slice(b"\x1b\\");
wrapped
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_message_splits_title_and_body() {
assert_eq!(
split_message("agent done: ws · 1"),
("agent done", Some("ws · 1"))
);
}
#[test]
fn split_message_leaves_plain_message_alone() {
assert_eq!(split_message("agent done"), ("agent done", None));
}
#[test]
fn sanitize_text_strips_control_bytes() {
assert_eq!(sanitize_text("a\n\tb\u{1b}c\u{7}"), "a bc");
}
#[test]
fn kitty_notification_uses_structured_title_and_body() {
let sequence = String::from_utf8(build_osc99_notification("pi finished", Some("ws · 1")))
.expect("utf8");
assert!(sequence.contains("]99;i=1:d=0;pi finished"));
assert!(sequence.contains("]99;i=1:p=body;ws · 1"));
}
#[test]
fn tmux_passthrough_wraps_and_escapes() {
let wrapped = wrap_tmux_passthrough(b"\x1b]9;hi\x1b\\");
assert_eq!(wrapped, b"\x1bPtmux;\x1b\x1b]9;hi\x1b\x1b\\\x1b\\");
}
}
|