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