Skip to main content

oxicode/tui_vt/
notifications.rs

1//! Terminal desktop-notification protocols (grok-build parity).
2//!
3//! Different terminals accept desktop notifications through different OSC
4//! escape sequences. This module picks the best one the host terminal
5//! supports and wraps it for tmux passthrough when needed.
6//!
7//! | Protocol | Terminals                  | Sequence                                  |
8//! |----------|----------------------------|-------------------------------------------|
9//! | OSC 9    | iTerm2, WezTerm, Warp      | `ESC ] 9 ; <msg> BEL`                      |
10//! | OSC 99   | Kitty, Ghostty             | `ESC ] 99 ; i=<id> ; <msg> BEL`            |
11//! | OSC 777  | rxvt-unicode, foot, VTE    | `ESC ] 777 ; notify ; <title> ; <msg> BEL` |
12//! | BEL      | fallback                   | `BEL`                                     |
13
14use std::io::Write;
15
16/// The notification protocol a terminal understands.
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum NotificationProtocol {
19    /// iTerm2 / WezTerm / Warp — `OSC 9`.
20    Osc9,
21    /// Kitty / Ghostty — `OSC 99`.
22    Osc99,
23    /// rxvt / foot / VTE — `OSC 777`.
24    Osc777,
25    /// Plain bell — every terminal, but no rich notification.
26    Bel,
27    /// Notifications disabled.
28    None,
29}
30
31/// Decide the best protocol from environment signals. Pure (no I/O) so it
32/// can be unit-tested against synthetic env values.
33pub 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        // tmux itself doesn't render notifications; the inner terminal does,
42        // but detecting it reliably through tmux is fragile — fall back to
43        // OSC 777 which several VTE-based terms honor.
44        NotificationProtocol::Osc777
45    } else if in_tmux {
46        NotificationProtocol::Osc777
47    } else {
48        NotificationProtocol::Bel
49    }
50}
51
52/// Detect the protocol from the live environment.
53pub 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
60/// Wrap an escape sequence for tmux passthrough: every `ESC` (0x1b) is
61/// doubled and the whole thing is framed with `DCS tmux ; … ST`.
62pub fn tmux_wrap(seq: &str) -> String {
63    let escaped = seq.replace('\x1b', "\x1b\x1b");
64    format!("\x1bPtmux;{}\x1b\\", escaped)
65}
66
67/// Build the raw escape sequence for `protocol` carrying `title`/`message`.
68pub 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
78/// Emit a desktop notification to stderr using the best detected protocol,
79/// wrapping for tmux passthrough when `TMUX` is set.
80pub 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
95// ─────────────────────────────────────────────────────────────────────────
96// OSC 8 — Terminal hyperlinks
97// ─────────────────────────────────────────────────────────────────────────
98
99/// Wrap `text` in an OSC 8 hyperlink escape pointing to `url`.
100///
101/// Format: `ESC ] 8 ; ; <url> ESC \ <text> ESC ] 8 ; ; ESC \`
102///
103/// Supported by: iTerm2, Kitty, Ghostty, WezTerm, gnome-terminal, Windows
104/// Terminal, and others. Terminals that don't understand OSC 8 render the
105/// inner `text` as-is (the escape sequences are invisible/no-op).
106///
107/// ```
108/// use oxicode::tui_vt::notifications::osc8_hyperlink;
109/// let link = osc8_hyperlink("https://example.com", "click here");
110/// assert!(link.contains("https://example.com"));
111/// assert!(link.contains("click here"));
112/// ```
113pub fn osc8_hyperlink(url: &str, text: &str) -> String {
114    // ST (String Terminator) is `ESC \`. Some terminals accept BEL as an
115    // alternative ST; we use `ESC \` which is the spec-compliant form.
116    format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\")
117}
118
119/// Wrap a local file path as a `file://` OSC 8 hyperlink.
120pub 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        // Must start with OSC 8 opener and end with closer.
223        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}