aboutsummaryrefslogtreecommitdiff
path: root/src/terminal_notify.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/terminal_notify.rs')
-rw-r--r--src/terminal_notify.rs143
1 files changed, 143 insertions, 0 deletions
diff --git a/src/terminal_notify.rs b/src/terminal_notify.rs
new file mode 100644
index 00000000..e6380c7b
--- /dev/null
+++ b/src/terminal_notify.rs
@@ -0,0 +1,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\\");
+ }
+}