oxicode/tui_vt/
notifications.rs1use std::io::Write;
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum NotificationProtocol {
19 Osc9,
21 Osc99,
23 Osc777,
25 Bel,
27 None,
29}
30
31pub fn detect_protocol_from(term_program: &str, term: &str, in_tmux: bool) -> NotificationProtocol {
34 let tp = term_program.to_ascii_lowercase();
35 let term = term.to_ascii_lowercase();
36 if tp.contains("wezterm") || tp.contains("warp") || tp.contains("iterm") {
37 NotificationProtocol::Osc9
38 } else if tp.contains("ghostty") || tp.contains("kitty") {
39 NotificationProtocol::Osc99
40 } else if term.contains("rxvt") || tp.contains("foot") || tp.contains("tmux") {
41 NotificationProtocol::Osc777
45 } else if in_tmux {
46 NotificationProtocol::Osc777
47 } else {
48 NotificationProtocol::Bel
49 }
50}
51
52pub fn detect_protocol() -> NotificationProtocol {
54 let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
55 let term = std::env::var("TERM").unwrap_or_default();
56 let in_tmux = std::env::var("TMUX").is_ok();
57 detect_protocol_from(&term_program, &term, in_tmux)
58}
59
60pub fn tmux_wrap(seq: &str) -> String {
63 let escaped = seq.replace('\x1b', "\x1b\x1b");
64 format!("\x1bPtmux;{}\x1b\\", escaped)
65}
66
67pub fn render_sequence(protocol: NotificationProtocol, title: &str, message: &str) -> String {
69 match protocol {
70 NotificationProtocol::Osc9 => format!("\x1b]9;{message}\x07"),
71 NotificationProtocol::Osc99 => format!("\x1b]99;i=oxicode;{message}\x07"),
72 NotificationProtocol::Osc777 => format!("\x1b]777;notify;{title};{message}\x07"),
73 NotificationProtocol::Bel => "\x07".to_string(),
74 NotificationProtocol::None => String::new(),
75 }
76}
77
78pub fn emit_notification(title: &str, message: &str) {
81 let protocol = detect_protocol();
82 if protocol == NotificationProtocol::None {
83 return;
84 }
85 let seq = render_sequence(protocol, title, message);
86 let final_seq = if std::env::var("TMUX").is_ok() {
87 tmux_wrap(&seq)
88 } else {
89 seq
90 };
91 let _ = write!(std::io::stderr(), "{final_seq}");
92 let _ = std::io::stderr().flush();
93}
94
95pub fn osc8_hyperlink(url: &str, text: &str) -> String {
114 format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\")
117}
118
119pub fn osc8_file_link(path: &str, text: &str) -> String {
121 let url = if path.starts_with('/') {
122 format!("file://{path}")
123 } else {
124 format!("file://{}/{path}", "")
125 };
126 osc8_hyperlink(&url, text)
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 #[test]
134 fn wezterm_picks_osc9() {
135 assert_eq!(
136 detect_protocol_from("WezTerm", "xterm-256color", false),
137 NotificationProtocol::Osc9
138 );
139 }
140
141 #[test]
142 fn iterm_picks_osc9() {
143 assert_eq!(
144 detect_protocol_from("iTerm.app", "xterm-256color", false),
145 NotificationProtocol::Osc9
146 );
147 }
148
149 #[test]
150 fn ghostty_picks_osc99() {
151 assert_eq!(
152 detect_protocol_from("ghostty", "xterm-256color", false),
153 NotificationProtocol::Osc99
154 );
155 }
156
157 #[test]
158 fn kitty_picks_osc99() {
159 assert_eq!(
160 detect_protocol_from("kitty", "xterm-kitty", false),
161 NotificationProtocol::Osc99
162 );
163 }
164
165 #[test]
166 fn rxvt_picks_osc777() {
167 assert_eq!(
168 detect_protocol_from("", "rxvt-unicode-256color", false),
169 NotificationProtocol::Osc777
170 );
171 }
172
173 #[test]
174 fn unknown_falls_back_to_bel() {
175 assert_eq!(
176 detect_protocol_from("", "xterm-256color", false),
177 NotificationProtocol::Bel
178 );
179 }
180
181 #[test]
182 fn tmux_falls_back_to_osc777() {
183 assert_eq!(
184 detect_protocol_from("", "xterm-256color", true),
185 NotificationProtocol::Osc777
186 );
187 }
188
189 #[test]
190 fn osc9_sequence_is_message_only() {
191 let seq = render_sequence(NotificationProtocol::Osc9, "t", "hello");
192 assert_eq!(seq, "\x1b]9;hello\x07");
193 }
194
195 #[test]
196 fn osc777_sequence_carries_title_and_message() {
197 let seq = render_sequence(NotificationProtocol::Osc777, "Title", "Body");
198 assert_eq!(seq, "\x1b]777;notify;Title;Body\x07");
199 }
200
201 #[test]
202 fn bel_sequence_is_just_bell() {
203 assert_eq!(render_sequence(NotificationProtocol::Bel, "t", "m"), "\x07");
204 }
205
206 #[test]
207 fn none_sequence_is_empty() {
208 assert!(render_sequence(NotificationProtocol::None, "t", "m").is_empty());
209 }
210
211 #[test]
212 fn tmux_wrap_doubles_esc_and_frames() {
213 let wrapped = tmux_wrap("\x1b]9;hi\x07");
214 assert!(wrapped.starts_with("\x1bPtmux;"));
215 assert!(wrapped.ends_with("\x1b\\"));
216 assert!(wrapped.contains("\x1b\x1b]9;hi\x07"));
217 }
218
219 #[test]
220 fn osc8_hyperlink_wraps_url_and_text() {
221 let link = osc8_hyperlink("https://example.com", "click here");
222 assert!(link.starts_with("\x1b]8;;https://example.com\x1b\\"));
224 assert!(link.ends_with("\x1b]8;;\x1b\\"));
225 assert!(link.contains("click here"));
226 }
227
228 #[test]
229 fn osc8_file_link_uses_file_scheme() {
230 let link = osc8_file_link("/abs/path", "file.rs");
231 assert!(link.starts_with("\x1b]8;;file:///abs/path\x1b\\"));
232 assert!(link.contains("file.rs"));
233 }
234}