Skip to main content

command_stream/terminal/
capture.rs

1use super::artifacts::{unroll_terminal_frames, write_terminal_artifacts};
2use super::types::{
3    Asciicast, AsciicastEvent, AsciicastHeader, TerminalCapture, TerminalCaptureError,
4    TerminalCaptureOptions, TerminalCursor, TerminalFrame, TerminalInteraction, TerminalResize,
5};
6use portable_pty::{native_pty_system, Child, CommandBuilder, ExitStatus, MasterPty, PtySize};
7use regex::Regex;
8use std::collections::HashMap;
9use std::io::{Read, Write};
10use std::sync::mpsc;
11use std::time::{Duration, Instant};
12
13const ERASE_SCREEN: &[u8] = b"\x1b[2J";
14
15fn elapsed(started: Instant) -> f64 {
16    (started.elapsed().as_secs_f64() * 1_000_000.0).round() / 1_000_000.0
17}
18
19fn trim_trailing_blank(mut lines: Vec<String>) -> Vec<String> {
20    while lines.last().is_some_and(String::is_empty) {
21        lines.pop();
22    }
23    lines
24}
25
26fn frame(parser: &vt100::Parser, started: Instant) -> TerminalFrame {
27    let screen = parser.screen();
28    let (rows, cols) = screen.size();
29    let (cursor_y, cursor_x) = screen.cursor_position();
30    let lines = trim_trailing_blank(screen.rows(0, cols).collect());
31    TerminalFrame {
32        time: elapsed(started),
33        cols,
34        rows,
35        cursor: TerminalCursor {
36            x: cursor_x,
37            y: cursor_y,
38        },
39        alternate: screen.alternate_screen(),
40        screen: lines.clone(),
41        lines,
42    }
43}
44
45fn same_frame(left: &TerminalFrame, right: &TerminalFrame) -> bool {
46    left.cols == right.cols
47        && left.rows == right.rows
48        && left.cursor == right.cursor
49        && left.alternate == right.alternate
50        && left.lines == right.lines
51}
52
53fn append_frame(frames: &mut Vec<TerminalFrame>, parser: &vt100::Parser, started: Instant) {
54    let next = frame(parser, started);
55    if frames
56        .last()
57        .is_none_or(|previous| !same_frame(previous, &next))
58    {
59        frames.push(next);
60    }
61}
62
63fn render_segments(data: &[u8]) -> Vec<&[u8]> {
64    let positions = data
65        .windows(ERASE_SCREEN.len())
66        .enumerate()
67        .filter_map(|(index, window)| (window == ERASE_SCREEN).then_some(index))
68        .collect::<Vec<_>>();
69    if positions.is_empty() {
70        return vec![data];
71    }
72
73    let mut segments = Vec::new();
74    if positions[0] > 0 {
75        segments.push(&data[..positions[0]]);
76    }
77    for (index, position) in positions.iter().enumerate() {
78        let end = positions.get(index + 1).copied().unwrap_or(data.len());
79        segments.push(&data[*position..end]);
80    }
81    segments
82}
83
84fn drain_complete_render_data(pending: &mut Vec<u8>) -> Vec<u8> {
85    let maximum = pending.len().min(ERASE_SCREEN.len() - 1);
86    let pending_length = (1..=maximum)
87        .rev()
88        .find(|length| ERASE_SCREEN.starts_with(&pending[pending.len() - length..]))
89        .unwrap_or(0);
90    pending.drain(..pending.len() - pending_length).collect()
91}
92
93fn record(asciicast: &mut Asciicast, started: Instant, code: &str, data: impl Into<String>) {
94    asciicast.events.push(AsciicastEvent {
95        time: elapsed(started),
96        code: code.into(),
97        data: data.into(),
98    });
99}
100
101fn validate_interaction(interaction: &TerminalInteraction) -> Result<(), TerminalCaptureError> {
102    let has_action =
103        interaction.text.is_some() || interaction.key.is_some() || interaction.resize.is_some();
104    let has_wait = interaction.after.is_some()
105        || interaction.after_regex.is_some()
106        || interaction.idle_duration > Duration::ZERO;
107    if has_action || has_wait {
108        return Ok(());
109    }
110
111    Err(TerminalCaptureError::new(
112        format!(
113            "invalid terminal interaction {interaction:?}; expected an action with text, key, or \
114             resize, or a wait with after, after_regex, or idle_duration"
115        ),
116        None,
117    ))
118}
119
120fn apply_interaction(
121    interaction: &TerminalInteraction,
122    writer: &mut dyn Write,
123    master: &dyn MasterPty,
124    parser: &mut vt100::Parser,
125    asciicast: &mut Asciicast,
126    started: Instant,
127) -> Result<(), TerminalCaptureError> {
128    validate_interaction(interaction)?;
129    if let Some(text) = &interaction.text {
130        writer
131            .write_all(text.as_bytes())
132            .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
133        writer
134            .flush()
135            .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
136        record(asciicast, started, "i", text.clone());
137    }
138    if let Some(key) = &interaction.key {
139        writer
140            .write_all(key.sequence().as_bytes())
141            .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
142        writer
143            .flush()
144            .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
145        record(asciicast, started, "i", key.sequence());
146    }
147    if let Some(resize) = interaction.resize {
148        resize_terminal(master, parser, resize)?;
149        record(
150            asciicast,
151            started,
152            "r",
153            format!("{}x{}", resize.cols, resize.rows),
154        );
155    }
156    Ok(())
157}
158
159fn resize_terminal(
160    master: &dyn MasterPty,
161    parser: &mut vt100::Parser,
162    resize: TerminalResize,
163) -> Result<(), TerminalCaptureError> {
164    master
165        .resize(PtySize {
166            rows: resize.rows,
167            cols: resize.cols,
168            pixel_width: 0,
169            pixel_height: 0,
170        })
171        .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
172    parser.set_size(resize.rows, resize.cols);
173    Ok(())
174}
175
176fn asciicast(options: &TerminalCaptureOptions) -> Asciicast {
177    let mut env = HashMap::new();
178    env.insert("SHELL".into(), options.file.clone());
179    env.insert(
180        "TERM".into(),
181        options
182            .env
183            .get("TERM")
184            .cloned()
185            .unwrap_or_else(|| "xterm-256color".into()),
186    );
187    Asciicast {
188        header: AsciicastHeader {
189            version: 2,
190            width: options.cols,
191            height: options.rows,
192            timestamp: chrono::Utc::now().timestamp(),
193            env,
194        },
195        events: Vec::new(),
196    }
197}
198
199fn spawn_reader(mut reader: Box<dyn Read + Send>) -> mpsc::Receiver<Vec<u8>> {
200    let (sender, receiver) = mpsc::channel();
201    std::thread::spawn(move || {
202        let mut buffer = [0_u8; 8192];
203        loop {
204            match reader.read(&mut buffer) {
205                Ok(0) | Err(_) => break,
206                Ok(length) => {
207                    if sender.send(buffer[..length].to_vec()).is_err() {
208                        break;
209                    }
210                }
211            }
212        }
213    });
214    receiver
215}
216
217fn capture_result(
218    status: portable_pty::ExitStatus,
219    output: String,
220    frames: Vec<TerminalFrame>,
221    interaction_count: usize,
222    asciicast: Asciicast,
223) -> TerminalCapture {
224    TerminalCapture {
225        exit_code: status.exit_code() as i32,
226        signal: status.signal().map(str::to_owned),
227        transcript: unroll_terminal_frames(&frames),
228        output,
229        frames,
230        interaction_count,
231        asciicast,
232    }
233}
234
235/// Readiness condition for [`TerminalSession::wait_for`], mirroring the
236/// `after` / `after_regex` vocabulary of [`TerminalInteraction`].
237#[derive(Debug, Clone)]
238pub enum TerminalPattern {
239    Text(String),
240    Regex(Regex),
241}
242
243impl TerminalPattern {
244    pub fn text(value: impl Into<String>) -> Self {
245        Self::Text(value.into())
246    }
247
248    pub fn regex(pattern: &str) -> Result<Self, TerminalCaptureError> {
249        Regex::new(pattern).map(Self::Regex).map_err(|error| {
250            TerminalCaptureError::new(format!("invalid terminal pattern regex: {error}"), None)
251        })
252    }
253
254    fn matches(&self, output: &str) -> bool {
255        match self {
256            Self::Text(value) => output.contains(value),
257            Self::Regex(pattern) => pattern.is_match(output),
258        }
259    }
260}
261
262/// A pseudoterminal that stays open until the caller closes it, so input may be
263/// sent long after the process started.
264pub struct TerminalSession {
265    options: TerminalCaptureOptions,
266    interaction_regexes: Vec<Option<Regex>>,
267    master: Box<dyn MasterPty + Send>,
268    writer: Box<dyn Write + Send>,
269    child: Box<dyn Child + Send + Sync>,
270    receiver: mpsc::Receiver<Vec<u8>>,
271    started: Instant,
272    parser: vt100::Parser,
273    recording: Asciicast,
274    output: String,
275    frames: Vec<TerminalFrame>,
276    pending_render: Vec<u8>,
277    terminal_has_output: bool,
278    interaction_index: usize,
279    last_output: Option<Instant>,
280    dirty: bool,
281    reader_closed: bool,
282    status: Option<ExitStatus>,
283    timed_out: bool,
284    stop_deadline: Option<Instant>,
285}
286
287impl TerminalSession {
288    fn open(options: TerminalCaptureOptions) -> Result<Self, TerminalCaptureError> {
289        if options.file.is_empty() {
290            return Err(TerminalCaptureError::new(
291                "open_terminal requires a file",
292                None,
293            ));
294        }
295        for interaction in &options.interactions {
296            validate_interaction(interaction)?;
297        }
298        let interaction_regexes = options
299            .interactions
300            .iter()
301            .map(|interaction| {
302                interaction
303                    .after_regex
304                    .as_ref()
305                    .map(|pattern| {
306                        Regex::new(pattern).map_err(|error| {
307                            TerminalCaptureError::new(
308                                format!("invalid terminal interaction regex: {error}"),
309                                None,
310                            )
311                        })
312                    })
313                    .transpose()
314            })
315            .collect::<Result<Vec<_>, _>>()?;
316        let pty = native_pty_system()
317            .openpty(PtySize {
318                rows: options.rows,
319                cols: options.cols,
320                pixel_width: 0,
321                pixel_height: 0,
322            })
323            .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
324        let mut command = CommandBuilder::new(&options.file);
325        command.args(&options.args);
326        if let Some(cwd) = &options.cwd {
327            command.cwd(cwd);
328        }
329        command.env(
330            "TERM",
331            options
332                .env
333                .get("TERM")
334                .map_or("xterm-256color", String::as_str),
335        );
336        for (name, value) in &options.env {
337            command.env(name, value);
338        }
339        let child = pty
340            .slave
341            .spawn_command(command)
342            .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
343        drop(pty.slave);
344        let reader = pty
345            .master
346            .try_clone_reader()
347            .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
348        let writer = pty
349            .master
350            .take_writer()
351            .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
352        let receiver = spawn_reader(reader);
353        let recording = asciicast(&options);
354        let parser = vt100::Parser::new(options.rows, options.cols, 100_000);
355        Ok(Self {
356            interaction_regexes,
357            master: pty.master,
358            writer,
359            child,
360            receiver,
361            started: Instant::now(),
362            parser,
363            recording,
364            output: String::new(),
365            frames: Vec::new(),
366            pending_render: Vec::new(),
367            terminal_has_output: false,
368            interaction_index: 0,
369            last_output: None,
370            dirty: false,
371            reader_closed: false,
372            status: None,
373            timed_out: false,
374            stop_deadline: None,
375            options,
376        })
377    }
378
379    /// Raw PTY output seen so far.
380    pub fn output(&self) -> &str {
381        &self.output
382    }
383
384    /// Settled states retained so far.
385    pub fn frames(&self) -> &[TerminalFrame] {
386        &self.frames
387    }
388
389    /// Unrolled transcript of the states retained so far.
390    pub fn transcript(&self) -> String {
391        unroll_terminal_frames(&self.frames)
392    }
393
394    /// Whether the child is still alive.
395    pub fn running(&self) -> bool {
396        self.status.is_none()
397    }
398
399    fn read_available(&mut self) {
400        match self.receiver.recv_timeout(Duration::from_millis(5)) {
401            Ok(data) => {
402                let text = String::from_utf8_lossy(&data);
403                self.output.push_str(&text);
404                record(&mut self.recording, self.started, "o", text.into_owned());
405                self.pending_render.extend_from_slice(&data);
406                let render_data = drain_complete_render_data(&mut self.pending_render);
407                let segments = render_segments(&render_data);
408                let segment_count = segments.len();
409                if self.terminal_has_output && render_data.starts_with(ERASE_SCREEN) {
410                    append_frame(&mut self.frames, &self.parser, self.started);
411                }
412                for (index, segment) in segments.into_iter().enumerate() {
413                    self.parser.process(segment);
414                    self.terminal_has_output |= !segment.is_empty();
415                    if index + 1 < segment_count {
416                        append_frame(&mut self.frames, &self.parser, self.started);
417                    }
418                }
419                self.last_output = Some(Instant::now());
420                self.dirty = true;
421                if self
422                    .options
423                    .stop_marker
424                    .as_ref()
425                    .is_some_and(|marker| self.output.contains(marker))
426                    && self.stop_deadline.is_none()
427                {
428                    append_frame(&mut self.frames, &self.parser, self.started);
429                    self.stop_deadline = Some(Instant::now() + self.options.stop_marker_grace);
430                }
431            }
432            Err(mpsc::RecvTimeoutError::Disconnected) => self.reader_closed = true,
433            Err(mpsc::RecvTimeoutError::Timeout) => {}
434        }
435    }
436
437    fn idle_for(&self) -> Duration {
438        self.last_output
439            .map_or_else(|| self.started.elapsed(), |instant| instant.elapsed())
440    }
441
442    fn apply_scripted_interactions(&mut self) -> Result<(), TerminalCaptureError> {
443        while let Some(interaction) = self.options.interactions.get(self.interaction_index) {
444            if interaction
445                .after
446                .as_ref()
447                .is_some_and(|marker| !self.output.contains(marker))
448            {
449                break;
450            }
451            if self.interaction_regexes[self.interaction_index]
452                .as_ref()
453                .is_some_and(|pattern| !pattern.is_match(&self.output))
454            {
455                break;
456            }
457            if interaction.idle_duration > Duration::ZERO
458                && self.idle_for() < interaction.idle_duration
459            {
460                break;
461            }
462            let interaction = interaction.clone();
463            append_frame(&mut self.frames, &self.parser, self.started);
464            apply_interaction(
465                &interaction,
466                self.writer.as_mut(),
467                self.master.as_ref(),
468                &mut self.parser,
469                &mut self.recording,
470                self.started,
471            )?;
472            self.interaction_index += 1;
473        }
474        Ok(())
475    }
476
477    /// Advance the capture by one step: read pending output, apply any scripted
478    /// interaction whose readiness condition became true, retain settled states,
479    /// and enforce the optional deadline.
480    fn poll(&mut self) -> Result<(), TerminalCaptureError> {
481        self.read_available();
482        self.apply_scripted_interactions()?;
483
484        if self.dirty
485            && self
486                .last_output
487                .is_some_and(|instant| instant.elapsed() >= self.options.settle_duration)
488        {
489            append_frame(&mut self.frames, &self.parser, self.started);
490            self.dirty = false;
491        }
492        if self.status.is_none() {
493            self.status = self
494                .child
495                .try_wait()
496                .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?;
497        }
498        if self.status.is_none() {
499            let expired = self
500                .options
501                .timeout
502                .is_some_and(|timeout| self.started.elapsed() >= timeout);
503            let stopped = self
504                .stop_deadline
505                .is_some_and(|deadline| Instant::now() >= deadline);
506            if expired || stopped {
507                self.timed_out = expired;
508                self.stop()?;
509            }
510        }
511        Ok(())
512    }
513
514    fn finished(&self) -> bool {
515        self.status.is_some() && self.reader_closed
516    }
517
518    fn stop(&mut self) -> Result<(), TerminalCaptureError> {
519        let _ = self.child.kill();
520        self.status = Some(
521            self.child
522                .wait()
523                .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?,
524        );
525        Ok(())
526    }
527
528    /// Block until `pattern` has been seen and, when `idle` is non-zero, no
529    /// further output arrived for that long. New output restarts the idle wait.
530    pub fn wait_for(
531        &mut self,
532        pattern: &TerminalPattern,
533        idle: Duration,
534        timeout: Option<Duration>,
535    ) -> Result<(), TerminalCaptureError> {
536        let deadline = timeout.map(|limit| Instant::now() + limit);
537        loop {
538            if pattern.matches(&self.output) && self.idle_for() >= idle {
539                return Ok(());
540            }
541            if self.status.is_some() {
542                self.poll()?;
543                if pattern.matches(&self.output) {
544                    return Ok(());
545                }
546                if self.finished() {
547                    return Err(TerminalCaptureError::new(
548                        "terminal exited before the expected output arrived",
549                        None,
550                    ));
551                }
552                continue;
553            }
554            if deadline.is_some_and(|limit| Instant::now() >= limit) {
555                return Err(TerminalCaptureError::new(
556                    format!(
557                        "terminal wait_for timed out after {} ms",
558                        timeout.unwrap_or_default().as_millis()
559                    ),
560                    None,
561                ));
562            }
563            self.poll()?;
564        }
565    }
566
567    /// Send text, a named key, or a resize to the live terminal, using the same
568    /// vocabulary as [`TerminalInteraction`].
569    pub fn send(&mut self, interaction: &TerminalInteraction) -> Result<(), TerminalCaptureError> {
570        validate_interaction(interaction)?;
571        if let Some(marker) = &interaction.after {
572            let pattern = TerminalPattern::text(marker.clone());
573            self.wait_for(&pattern, interaction.idle_duration, None)?;
574        } else if let Some(expression) = &interaction.after_regex {
575            let pattern = TerminalPattern::regex(expression)?;
576            self.wait_for(&pattern, interaction.idle_duration, None)?;
577        } else if interaction.idle_duration > Duration::ZERO {
578            while self.idle_for() < interaction.idle_duration && self.status.is_none() {
579                self.poll()?;
580            }
581        }
582        if self.status.is_some() {
583            return Err(TerminalCaptureError::new(
584                "terminal session has already exited",
585                None,
586            ));
587        }
588        append_frame(&mut self.frames, &self.parser, self.started);
589        apply_interaction(
590            interaction,
591            self.writer.as_mut(),
592            self.master.as_ref(),
593            &mut self.parser,
594            &mut self.recording,
595            self.started,
596        )
597    }
598
599    /// Wait for a child that exits on its own, then produce the capture.
600    pub fn finish(mut self) -> Result<TerminalCapture, TerminalCaptureError> {
601        while !self.finished() {
602            self.poll()?;
603        }
604        self.into_capture()
605    }
606
607    /// Stop the child if it is still running, then produce the capture and write
608    /// any configured artifacts.
609    pub fn close(mut self) -> Result<TerminalCapture, TerminalCaptureError> {
610        if self.status.is_none() {
611            self.stop()?;
612        }
613        while !self.finished() {
614            self.read_available();
615        }
616        self.into_capture()
617    }
618
619    fn into_capture(mut self) -> Result<TerminalCapture, TerminalCaptureError> {
620        let pending = std::mem::take(&mut self.pending_render);
621        self.parser.process(&pending);
622        append_frame(&mut self.frames, &self.parser, self.started);
623        let capture = capture_result(
624            self.status
625                .clone()
626                .expect("child status is available after the capture loop"),
627            std::mem::take(&mut self.output),
628            std::mem::take(&mut self.frames),
629            self.interaction_index,
630            std::mem::replace(&mut self.recording, asciicast(&self.options)),
631        );
632        if let Some(directory) = &self.options.artifact_directory {
633            write_terminal_artifacts(
634                directory,
635                &capture.frames,
636                &capture.transcript,
637                &capture.asciicast,
638            )?;
639        }
640        if self.timed_out {
641            return Err(TerminalCaptureError::new(
642                format!(
643                    "terminal command timed out after {} ms",
644                    self.options.timeout.unwrap_or_default().as_millis()
645                ),
646                Some(capture),
647            ));
648        }
649        Ok(capture)
650    }
651}
652
653/// Open a terminal session that stays alive until the caller closes it.
654///
655/// Unlike [`capture_terminal`], input may be sent at any later point through
656/// [`TerminalSession::send`], and readiness can be awaited with
657/// [`TerminalSession::wait_for`]. `options.timeout` defaults to `None` here, so
658/// nothing terminates the child until [`TerminalSession::close`] is called.
659pub fn open_terminal(
660    options: TerminalCaptureOptions,
661) -> Result<TerminalSession, TerminalCaptureError> {
662    TerminalSession::open(TerminalCaptureOptions {
663        timeout: None,
664        ..options
665    })
666}
667
668/// Run a command inside a real pseudoterminal and retain its settled TUI states.
669pub fn capture_terminal(
670    options: TerminalCaptureOptions,
671) -> Result<TerminalCapture, TerminalCaptureError> {
672    if options.file.is_empty() {
673        return Err(TerminalCaptureError::new(
674            "capture_terminal requires a file",
675            None,
676        ));
677    }
678    TerminalSession::open(options)?.finish()
679}
680
681pub async fn capture_terminal_async(
682    options: TerminalCaptureOptions,
683) -> Result<TerminalCapture, TerminalCaptureError> {
684    tokio::task::spawn_blocking(move || capture_terminal(options))
685        .await
686        .map_err(|error| TerminalCaptureError::new(error.to_string(), None))?
687}