Skip to main content

command_stream/terminal/
capture.rs

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