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
//! Desktop notifications for when a turn finishes or needs the user while the
//! terminal is unfocused.
//!
//! Owners report attention events directly: approval and questionnaire
//! prompts call [`TerminalNotifier::user_wait`], which notifies at once, and
//! each finished turn calls [`TerminalNotifier::turn_finished`]. A finished
//! turn is held until the event loop is about to wait for input
//! ([`TerminalNotifier::take_ready`]), so a goal run or queued follow-ups
//! notify once at the end instead of after every turn.
use std::io::{self, Write};
use crossterm::event::Event;
use super::UserWait;
/// How the host terminal is asked to get the user's attention.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum NotificationChannel {
/// OSC 9 desktop notification (iTerm2, WezTerm, Ghostty, kitty).
Osc9,
/// Plain BEL. Terminals and multiplexers map it to a sound, dock bounce,
/// or tab marker.
Bell,
}
impl NotificationChannel {
/// Picks a channel from the terminal's environment. Multiplexers come
/// first: they swallow OSC 9 but forward BEL, and they inherit variables
/// such as `KITTY_WINDOW_ID` from the outer terminal.
pub(super) fn detect(get_var: impl Fn(&str) -> Option<String>) -> Self {
let var = |key| get_var(key).unwrap_or_default();
if !var("TMUX").is_empty() || !var("STY").is_empty() {
return Self::Bell;
}
if !var("KITTY_WINDOW_ID").is_empty() {
return Self::Osc9;
}
let osc9 = matches!(
var("TERM_PROGRAM").as_str(),
"iTerm.app" | "WezTerm" | "ghostty"
) || matches!(var("TERM").as_str(), "xterm-kitty" | "xterm-ghostty");
if osc9 {
Self::Osc9
} else {
Self::Bell
}
}
/// Bytes that deliver `body` on this channel.
pub(super) fn encode(self, body: &str) -> Vec<u8> {
match self {
Self::Osc9 => format!("\x1b]9;rho: {body}\x07").into_bytes(),
Self::Bell => b"\x07".to_vec(),
}
}
}
/// Decides when to notify. Owned by the TUI app; holds no terminal handle.
/// Whether notifications are enabled at all is the caller's policy.
#[derive(Debug)]
pub(super) struct TerminalNotifier {
channel: NotificationChannel,
/// Terminals report focus changes, not the initial focus. The user just
/// launched Rho, so start focused; terminals without focus reporting
/// never notify.
focused: bool,
/// A turn finished since the event loop last waited for input.
finished_turn: bool,
}
impl TerminalNotifier {
pub(super) fn new(channel: NotificationChannel) -> Self {
Self {
channel,
focused: true,
finished_turn: false,
}
}
/// Tracks terminal focus. Call for every terminal event before any
/// exclusive screen consumes it.
pub(super) fn observe_focus(&mut self, event: &Event) {
match event {
Event::FocusGained => self.focused = true,
Event::FocusLost => self.focused = false,
Event::Key(_) | Event::Mouse(_) | Event::Paste(_) | Event::Resize(..) => {}
}
}
pub(super) fn turn_finished(&mut self) {
self.finished_turn = true;
}
/// Bytes for a prompt that now waits on the user.
pub(super) fn user_wait(&self, wait: UserWait) -> Option<Vec<u8>> {
self.encode(wait.message())
}
/// Bytes for finished turns once the event loop is about to wait for
/// input. Clears them either way.
pub(super) fn take_ready(&mut self) -> Option<Vec<u8>> {
if !std::mem::take(&mut self.finished_turn) {
return None;
}
self.encode("turn finished")
}
fn encode(&self, body: &str) -> Option<Vec<u8>> {
(!self.focused).then(|| self.channel.encode(body))
}
}
/// Writes notification bytes straight to the terminal. OSC 9 and BEL do not
/// move the cursor, so this is safe between frames.
pub(super) fn write_to_terminal(bytes: &[u8]) {
let mut stdout = io::stdout().lock();
// Best effort: a lost notification is not worth failing the turn over.
let _ = stdout.write_all(bytes).and_then(|()| stdout.flush());
}
#[cfg(test)]
#[path = "notifications_tests.rs"]
mod tests;