返回 CodeWhale
host_terminal.rs
根目录 / crates / runtime / src / host_terminal.rs
1 //! The one port through which runtime code causes terminal side effects.
2 //!
3 //! The terminal UI is the only owner of the terminal: it alone toggles raw
4 //! mode and writes escape sequences. Runtime code (tools, the engine, the
5 //! runtime API) never links a terminal library; when it needs a terminal
6 //! effect it asks the installed [`HostTerminal`]. The composition root
7 //! installs the TUI's implementation once at startup, for every host it
8 //! launches today (interactive TUI, `exec`, the runtime API server), so
9 //! behavior is unchanged from when the calls were inline.
10 //!
11 //! With nothing installed every method is a no-op. That is the intended
12 //! future shape for stdio hosts (ACP, MCP server, app-server), whose stdout is
13 //! a JSON-RPC stream that must never receive terminal bytes.
14 //!
15 //! Known limitations: the install is process-global and first-wins; a
16 //! process cannot swap hosts after startup, and tests that need the real
17 //! terminal behavior must go through the TUI's own implementation.
18
19 use std::sync::OnceLock;
20
21 use codewhale_config::notifications::NotificationsConfig;
22
23 /// Terminal side effects the runtime may request from its host.
24 pub trait HostTerminal: Send + Sync {
25 /// Leave raw mode if it is on, so an interactive child process sees a
26 /// cooked terminal. Returns whether raw mode was on (and must be resumed).
27 fn suspend_raw_mode(&self) -> bool;
28
29 /// Re-enter raw mode after [`HostTerminal::suspend_raw_mode`] returned
30 /// `true`.
31 fn resume_raw_mode(&self);
32
33 /// Deliver a model-authored notification (the `notify` tool) now,
34 /// honoring the installed notification settings. Returns the delivery
35 /// receipt the tool reports to the model.
36 fn notify_model(&self, title: &str, body: Option<&str>) -> &'static str;
37
38 /// Record whether the terminal has focus; attention policy reads it.
39 fn set_terminal_focused(&self, focused: bool);
40
41 /// Install the process-wide notification method, category gate, sound
42 /// policy and attention condition from `[notifications]`.
43 fn apply_notification_settings(&self, config: &NotificationsConfig);
44 }
45
46 struct NoHostTerminal;
47
48 impl HostTerminal for NoHostTerminal {
49 fn suspend_raw_mode(&self) -> bool {
50 false
51 }
52
53 fn resume_raw_mode(&self) {}
54
55 fn notify_model(&self, _title: &str, _body: Option<&str>) -> &'static str {
56 "notification not sent: no terminal host"
57 }
58
59 fn set_terminal_focused(&self, _focused: bool) {}
60
61 fn apply_notification_settings(&self, _config: &NotificationsConfig) {}
62 }
63
64 static HOST: OnceLock<Box<dyn HostTerminal>> = OnceLock::new();
65
66 /// Install the process's terminal host. The first install wins; later calls
67 /// are ignored and return `false`.
68 pub fn install(host: Box<dyn HostTerminal>) -> bool {
69 HOST.set(host).is_ok()
70 }
71
72 /// The installed terminal host, or a no-op host when none is installed.
73 #[must_use]
74 pub fn host() -> &'static dyn HostTerminal {
75 match HOST.get() {
76 Some(host) => host.as_ref(),
77 None => &NoHostTerminal,
78 }
79 }
80
81 /// Raw mode suspended for the lifetime of this guard (issue #1690).
82 ///
83 /// Created by [`suspend_raw_mode`]; dropping it resumes raw mode only if it
84 /// was on when the guard was created.
85 #[must_use = "raw mode is resumed when the guard drops"]
86 pub struct RawModeSuspension {
87 /// The host that suspended raw mode resumes it.
88 host: &'static dyn HostTerminal,
89 resume: bool,
90 }
91
92 /// Leave raw mode around an interactive child; resume it when the returned
93 /// guard drops, only if it was on to begin with.
94 pub fn suspend_raw_mode() -> RawModeSuspension {
95 suspend_raw_mode_on(host())
96 }
97
98 fn suspend_raw_mode_on(host: &'static dyn HostTerminal) -> RawModeSuspension {
99 RawModeSuspension {
100 host,
101 resume: host.suspend_raw_mode(),
102 }
103 }
104
105 impl Drop for RawModeSuspension {
106 fn drop(&mut self) {
107 if self.resume {
108 self.host.resume_raw_mode();
109 }
110 }
111 }
112
113 #[cfg(test)]
114 mod tests {
115 use super::*;
116 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
117
118 /// Records raw-mode calls. Each test owns its own `static` instance, so
119 /// they never share counters.
120 struct CountingHost {
121 raw: AtomicBool,
122 suspends: AtomicUsize,
123 resumes: AtomicUsize,
124 }
125
126 impl CountingHost {
127 const fn new(raw: bool) -> Self {
128 Self {
129 raw: AtomicBool::new(raw),
130 suspends: AtomicUsize::new(0),
131 resumes: AtomicUsize::new(0),
132 }
133 }
134 }
135
136 impl HostTerminal for CountingHost {
137 fn suspend_raw_mode(&self) -> bool {
138 self.suspends.fetch_add(1, Ordering::SeqCst);
139 self.raw.swap(false, Ordering::SeqCst)
140 }
141
142 fn resume_raw_mode(&self) {
143 self.resumes.fetch_add(1, Ordering::SeqCst);
144 self.raw.store(true, Ordering::SeqCst);
145 }
146
147 fn notify_model(&self, _title: &str, _body: Option<&str>) -> &'static str {
148 "unused"
149 }
150
151 fn set_terminal_focused(&self, _focused: bool) {}
152
153 fn apply_notification_settings(&self, _config: &NotificationsConfig) {}
154 }
155
156 #[test]
157 fn no_host_suspension_is_a_no_op() {
158 // No host is installed in this test binary: suspending reports raw
159 // mode off, so the guard never asks to resume it.
160 let guard = suspend_raw_mode();
161 assert!(!guard.resume);
162 }
163
164 #[test]
165 fn raw_mode_that_was_on_is_resumed_exactly_once_when_the_guard_drops() {
166 static HOST: CountingHost = CountingHost::new(true);
167 let guard = suspend_raw_mode_on(&HOST);
168 assert!(
169 !HOST.raw.load(Ordering::SeqCst),
170 "raw mode is off while suspended"
171 );
172 assert_eq!(HOST.resumes.load(Ordering::SeqCst), 0);
173 drop(guard);
174 assert!(
175 HOST.raw.load(Ordering::SeqCst),
176 "raw mode is back on after the guard"
177 );
178 assert_eq!(HOST.suspends.load(Ordering::SeqCst), 1);
179 assert_eq!(HOST.resumes.load(Ordering::SeqCst), 1);
180 }
181
182 #[test]
183 fn raw_mode_that_was_off_is_never_turned_on_by_the_guard() {
184 static HOST: CountingHost = CountingHost::new(false);
185 drop(suspend_raw_mode_on(&HOST));
186 assert_eq!(HOST.suspends.load(Ordering::SeqCst), 1);
187 assert_eq!(HOST.resumes.load(Ordering::SeqCst), 0);
188 assert!(!HOST.raw.load(Ordering::SeqCst));
189 }
190 }
191
191 lines RUST