tui_test/terminal/
integration.rs1use alacritty_terminal::vte::{Parser, Perform};
4
5const OSC_CMD: &[u8] = b"133";
6const OSC_VSCODE: &[u8] = b"633";
7const OSC_CWD: &[u8] = b"7";
8
9#[derive(Default, PartialEq, Clone, Copy)]
10enum Region {
11 #[default]
12 None,
13 Command,
14 Output,
15}
16
17#[derive(Default)]
18struct TrackerState {
19 region: Region,
20 command_buf: String,
21 output_buf: String,
22
23 started: bool,
24 prompt_active: bool,
25 last_exit: Option<i32>,
26 cwd: Option<String>,
27 last_command: Option<String>,
28 last_output: Option<String>,
29 started_count: u64,
30 finished_count: u64,
31}
32
33pub struct CommandTracker {
35 parser: Parser,
36 state: TrackerState,
37}
38
39impl Default for CommandTracker {
40 fn default() -> Self {
41 Self::new()
42 }
43}
44
45impl CommandTracker {
46 pub fn new() -> Self {
47 CommandTracker {
48 parser: Parser::new(),
49 state: TrackerState::default(),
50 }
51 }
52
53 pub fn feed(&mut self, bytes: &[u8]) {
54 self.parser.advance(&mut self.state, bytes);
55 }
56
57 pub fn is_ready(&self) -> bool {
59 self.state.prompt_active
60 }
61
62 pub fn started(&self) -> bool {
64 self.state.started
65 }
66
67 pub fn started_count(&self) -> u64 {
68 self.state.started_count
69 }
70
71 pub fn executing(&self) -> bool {
72 self.state.region == Region::Output
73 }
74
75 pub fn finished_count(&self) -> u64 {
76 self.state.finished_count
77 }
78
79 pub fn last_exit(&self) -> Option<i32> {
80 self.state.last_exit
81 }
82
83 pub fn cwd(&self) -> Option<&str> {
84 self.state.cwd.as_deref()
85 }
86
87 pub fn last_command(&self) -> Option<&str> {
88 self.state.last_command.as_deref()
89 }
90
91 pub fn last_output(&self) -> Option<&str> {
92 self.state.last_output.as_deref()
93 }
94}
95
96impl TrackerState {
97 fn command_marker(&mut self, marker: &str, exit: Option<&str>) {
98 match marker {
99 "A" => {
100 self.started = true;
101 self.region = Region::None;
102 }
103 "B" => {
104 self.started = true;
105 self.prompt_active = true;
106 self.region = Region::Command;
107 self.command_buf.clear();
108 }
109 "C" => {
110 self.prompt_active = false;
111 let cmd = clean(&self.command_buf);
112 let cmd = cmd.strip_prefix("> ").unwrap_or(&cmd).to_string();
113 self.last_command = Some(cmd);
114 self.region = Region::Output;
115 self.output_buf.clear();
116 self.started_count += 1;
117 }
118 "D" => {
119 self.last_output = Some(clean(&self.output_buf));
120 self.region = Region::None;
121 self.last_exit = exit.and_then(|s| s.trim().parse::<i32>().ok());
122 self.finished_count += 1;
123 }
124 _ => {}
125 }
126 }
127}
128
129impl Perform for TrackerState {
130 fn print(&mut self, c: char) {
131 match self.region {
132 Region::Command => self.command_buf.push(c),
133 Region::Output => self.output_buf.push(c),
134 Region::None => {}
135 }
136 }
137
138 fn execute(&mut self, byte: u8) {
139 if byte == b'\n' && self.region == Region::Output {
140 self.output_buf.push('\n');
141 }
142 }
143
144 fn osc_dispatch(&mut self, params: &[&[u8]], _bell_terminated: bool) {
145 match params.first().copied() {
146 Some(OSC_CMD) | Some(OSC_VSCODE) => {
147 let Some(marker) = params.get(1).and_then(|m| std::str::from_utf8(m).ok()) else {
148 return;
149 };
150 let exit = params.get(2).and_then(|p| std::str::from_utf8(p).ok());
151 self.command_marker(marker, exit);
152 }
153 Some(OSC_CWD) if params.len() > 1 => {
154 let value = params[1..]
155 .iter()
156 .map(|p| String::from_utf8_lossy(p))
157 .collect::<Vec<_>>()
158 .join(";");
159 if let Some(path) = parse_file_url(&value) {
160 self.cwd = Some(path);
161 }
162 }
163 _ => {}
164 }
165 }
166}
167
168fn clean(s: &str) -> String {
169 s.trim_matches([' ', '\n', '\r']).to_string()
170}
171
172fn parse_file_url(value: &str) -> Option<String> {
173 let rest = value.strip_prefix("file://")?;
174 let slash = rest.find('/')?;
175 let path = percent_decode(&rest[slash..]);
176 let bytes = path.as_bytes();
177 if bytes.len() >= 3 && bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' {
178 Some(path[1..].to_string())
179 } else {
180 Some(path)
181 }
182}
183
184fn percent_decode(s: &str) -> String {
185 let bytes = s.as_bytes();
186 let mut out = Vec::with_capacity(bytes.len());
187 let mut i = 0;
188 while i < bytes.len() {
189 if bytes[i] == b'%' && i + 2 < bytes.len() {
190 if let (Some(h), Some(l)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
191 out.push(h * 16 + l);
192 i += 3;
193 continue;
194 }
195 }
196 out.push(bytes[i]);
197 i += 1;
198 }
199 String::from_utf8_lossy(&out).into_owned()
200}
201
202fn hex_val(b: u8) -> Option<u8> {
203 match b {
204 b'0'..=b'9' => Some(b - b'0'),
205 b'a'..=b'f' => Some(b - b'a' + 10),
206 b'A'..=b'F' => Some(b - b'A' + 10),
207 _ => None,
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 fn osc(marker: &str) -> Vec<u8> {
216 format!("\x1b]133;{marker}\x07").into_bytes()
217 }
218
219 fn osc633(marker: &str) -> Vec<u8> {
220 format!("\x1b]633;{marker}\x07").into_bytes()
221 }
222
223 #[test]
224 fn tracks_full_command_cycle() {
225 let mut t = CommandTracker::new();
226 t.feed(&osc("A"));
227 t.feed(b"> ");
228 t.feed(&osc("B"));
229 assert!(t.is_ready());
230 assert!(t.started());
231 t.feed(b"echo hi");
232 t.feed(&osc("C"));
233 assert!(!t.is_ready());
234 assert!(t.executing());
235 assert_eq!(t.started_count(), 1);
236 assert_eq!(t.last_command(), Some("echo hi"));
237 t.feed(b"hi\r\n");
238 t.feed(&osc("D;0"));
239 assert!(!t.executing());
240 assert_eq!(t.last_exit(), Some(0));
241 assert_eq!(t.finished_count(), 1);
242 assert_eq!(t.last_output(), Some("hi"));
243 }
244
245 #[test]
246 fn a_prompt_repaint_replays_d_but_never_starts_a_command() {
247 let mut t = CommandTracker::new();
248 for marker in ["A", "B", "C"] {
249 t.feed(&osc(marker));
250 }
251 t.feed(&osc("D;0"));
252 assert_eq!(t.started_count(), 1);
253
254 for marker in ["D;0", "A", "B"] {
255 t.feed(&osc(marker));
256 }
257 assert_eq!(
258 t.started_count(),
259 1,
260 "a repaint must not look like a new command"
261 );
262 assert!(!t.executing());
263 assert_eq!(t.finished_count(), 2, "the replayed D is still counted");
264
265 t.feed(&osc("C"));
266 assert_eq!(t.started_count(), 2);
267 assert!(t.executing());
268 }
269
270 #[test]
271 fn parses_exit_code() {
272 let mut t = CommandTracker::new();
273 t.feed(&osc("B"));
274 t.feed(&osc("C"));
275 t.feed(&osc("D;7"));
276 assert_eq!(t.last_exit(), Some(7));
277 }
278
279 #[test]
280 fn finished_without_exit_code() {
281 let mut t = CommandTracker::new();
282 t.feed(&osc("B"));
283 t.feed(&osc("C"));
284 t.feed(&osc("D"));
285 assert_eq!(t.last_exit(), None);
286 assert_eq!(t.finished_count(), 1);
287 }
288
289 #[test]
290 fn cwd_via_osc7_file_url() {
291 let mut t = CommandTracker::new();
292 t.feed(b"\x1b]7;file://myhost/home/x\x07");
293 assert_eq!(t.cwd(), Some("/home/x"));
294 }
295
296 #[test]
297 fn cwd_osc7_windows_drive_and_percent() {
298 let mut t = CommandTracker::new();
299 t.feed(b"\x1b]7;file:///C:/Users/My%20Code\x1b\\");
300 assert_eq!(t.cwd(), Some("C:/Users/My Code"));
301 }
302
303 #[test]
304 fn ignores_unrelated_osc() {
305 let mut t = CommandTracker::new();
306 t.feed(b"\x1b]0;window title\x07");
307 assert!(!t.started());
308 }
309
310 #[test]
311 fn prompt_prefix_not_captured_in_command() {
312 let mut t = CommandTracker::new();
313 t.feed(b"> ");
314 t.feed(&osc("B"));
315 t.feed(b"echo hello");
316 t.feed(&osc("C"));
317 assert_eq!(t.last_command(), Some("echo hello"));
318 }
319
320 #[test]
321 fn prompt_prefix_stripped_when_repainted_into_command() {
322 let mut t = CommandTracker::new();
323 t.feed(&osc("B"));
324 t.feed(b"> echo hello");
325 t.feed(&osc("C"));
326 assert_eq!(t.last_command(), Some("echo hello"));
327 }
328
329 #[test]
330 fn osc_terminated_by_st_not_just_bel() {
331 let mut t = CommandTracker::new();
332 t.feed(b"\x1b]133;D;3\x1b\\");
333 assert_eq!(t.last_exit(), Some(3));
334 }
335
336 #[test]
337 fn marker_split_across_feeds() {
338 let mut t = CommandTracker::new();
339 t.feed(b"\x1b]133;");
340 t.feed(b"D;5\x07");
341 assert_eq!(t.last_exit(), Some(5));
342 }
343
344 #[test]
345 fn output_strips_embedded_csi_colors() {
346 let mut t = CommandTracker::new();
347 t.feed(&osc("B"));
348 t.feed(&osc("C"));
349 t.feed(b"a\x1b[31mb\x1b[0mc");
350 t.feed(&osc("D;0"));
351 assert_eq!(t.last_output(), Some("abc"));
352 }
353
354 #[test]
355 fn output_ignores_dcs_payload() {
356 let mut t = CommandTracker::new();
357 t.feed(&osc("B"));
358 t.feed(&osc("C"));
359 t.feed(b"x\x1bP1;2;3qSIXELDATA\x1b\\y");
360 t.feed(&osc("D;0"));
361 assert_eq!(t.last_output(), Some("xy"));
362 }
363
364 #[test]
365 fn osc_like_bytes_inside_dcs_do_not_trigger() {
366 let mut t = CommandTracker::new();
367 t.feed(b"\x1bP133;D;99\x1b\\");
368 assert_eq!(t.last_exit(), None);
369 assert_eq!(t.finished_count(), 0);
370 }
371
372 #[test]
373 fn osc633_full_cycle_with_exit() {
374 let mut t = CommandTracker::new();
375 t.feed(&osc633("A"));
376 t.feed(&osc633("B"));
377 assert!(t.is_ready());
378 t.feed(&osc633("C"));
379 assert!(!t.is_ready());
380 t.feed(b"out\r\n");
381 t.feed(&osc633("D;5"));
382 assert_eq!(t.last_exit(), Some(5));
383 assert_eq!(t.finished_count(), 1);
384 assert_eq!(t.last_output(), Some("out"));
385 }
386
387 #[test]
388 fn osc633_e_and_p_are_ignored() {
389 let mut t = CommandTracker::new();
390 t.feed(&osc633("B"));
391 t.feed(b"scraped cmd");
392 t.feed(&osc633("C"));
393 t.feed(&osc633("D;0"));
394 t.feed(&osc633("E;totally different"));
395 t.feed(b"\x1b]633;P;Cwd=C:/should/be/ignored\x07");
396 assert_eq!(t.last_command(), Some("scraped cmd"));
397 assert_eq!(t.cwd(), None);
398 }
399
400 #[test]
401 fn osc633_a_with_extra_param_ignored() {
402 let mut t = CommandTracker::new();
403 t.feed(b"\x1b]633;A;k=i\x07");
404 assert!(t.started());
405 }
406}