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
230fn 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#[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
318pub 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 pub fn output(&self) -> &str {
434 &self.output
435 }
436
437 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 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 pub fn transcript(&self) -> String {
465 unroll_terminal_frames(&self.frames)
466 }
467
468 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 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 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 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 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 pub fn finish(mut self) -> Result<TerminalCapture, TerminalCaptureError> {
746 while !self.finished() {
747 self.poll()?;
748 }
749 self.into_capture()
750 }
751
752 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
801pub fn open_terminal(
808 options: TerminalCaptureOptions,
809) -> Result<TerminalSession, TerminalCaptureError> {
810 TerminalSession::open(TerminalCaptureOptions {
811 timeout: None,
812 ..options
813 })
814}
815
816pub 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}