Skip to main content

tui_test/terminal/
integration.rs

1//! Command and output tracking from the PTY byte stream.
2
3use 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
33/// Tracks command boundaries, exit codes, and cwd from the PTY byte stream.
34pub 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    /// Whether the shell is sitting at an idle, ready prompt.
58    pub fn is_ready(&self) -> bool {
59        self.state.prompt_active
60    }
61
62    /// Whether at least one prompt has been seen.
63    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}