1use std::collections::BTreeMap;
2use std::io::{Read, Write};
3use std::path::Path;
4use std::process::{Command as ProcessCommand, Stdio};
5use std::sync::mpsc::{self, RecvTimeoutError};
6use std::thread;
7use std::time::{Duration, Instant};
8
9use anyhow::{Context, Result, bail};
10use portable_pty::{CommandBuilder, PtySize, native_pty_system};
11use serde::{Deserialize, Serialize};
12use vt100::Parser;
13
14use crate::frame::{DEFAULT_BACKGROUND, DEFAULT_FOREGROUND, Frame, from_screen};
15
16const OPENTUI_QUERY: &[u8] = b"\x1b]10;?\x07\x1b]11;?\x07";
17const PALETTE_QUERY: &[u8] = b"\x1b]4;0;?\x07";
18const KITTY_QUERY: &[u8] = b"\x1b_Gi=31337";
19
20#[derive(Clone, Debug)]
22pub struct Options {
23 pub cols: u16,
24 pub rows: u16,
25 pub cell_width: u16,
26 pub cell_height: u16,
27 pub settle: Duration,
28 pub deadline: Duration,
29 pub input: Vec<u8>,
30 pub initial_delay: Duration,
31 pub wait_for: Option<String>,
32 pub max_bytes: usize,
33 pub opentui_host: bool,
34 pub color: ColorMode,
35 pub env: BTreeMap<String, String>,
37 pub inherit_env: bool,
39}
40
41impl Default for Options {
42 fn default() -> Self {
43 Self {
44 cols: 80,
45 rows: 24,
46 cell_width: 9,
47 cell_height: 18,
48 settle: Duration::from_millis(250),
49 deadline: Duration::from_secs(5),
50 input: Vec::new(),
51 initial_delay: Duration::ZERO,
52 wait_for: None,
53 max_bytes: 16 * 1024 * 1024,
54 opentui_host: false,
55 color: ColorMode::Auto,
56 env: BTreeMap::new(),
57 inherit_env: true,
58 }
59 }
60}
61
62#[derive(Deserialize, Serialize)]
64pub struct Shot {
65 pub frame: Frame,
66 pub ansi: Vec<u8>,
67}
68
69#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
71#[serde(rename_all = "lowercase")]
72pub enum ColorMode {
73 Auto,
74 Always,
75 Never,
76}
77
78pub fn from_ansi(bytes: Vec<u8>, rows: u16, cols: u16, max_bytes: usize) -> Result<Shot> {
80 validate_geometry(rows, cols)?;
81 if bytes.len() > max_bytes {
82 bail!("terminal input exceeds --max-bytes ({max_bytes})");
83 }
84 let mut parser = terminal(rows, cols);
85 parser.process(&bytes);
86 Ok(Shot {
87 frame: from_screen(parser.screen()),
88 ansi: bytes,
89 })
90}
91
92pub fn from_command(command: &[String], cwd: Option<&Path>, options: &Options) -> Result<Shot> {
94 if command.is_empty() {
95 bail!("provide a command after --");
96 }
97 validate_geometry(options.rows, options.cols)?;
98 let pair = native_pty_system()
99 .openpty(PtySize {
100 rows: options.rows,
101 cols: options.cols,
102 pixel_width: options.cell_width,
103 pixel_height: options.cell_height,
104 })
105 .context("open pseudo-terminal")?;
106 let mut builder = CommandBuilder::new(&command[0]);
107 builder.args(&command[1..]);
108 configure_pty_environment(&mut builder, options);
109 if let Some(cwd) = cwd {
110 builder.cwd(cwd);
111 }
112 let mut reader = pair.master.try_clone_reader().context("open PTY reader")?;
113 let writer = pair.master.take_writer().context("open PTY writer")?;
114 let mut child = pair
115 .slave
116 .spawn_command(builder)
117 .context("spawn terminal command")?;
118 drop(pair.slave);
119 #[cfg(unix)]
120 let process_group = child.process_id().and_then(|pid| i32::try_from(pid).ok());
121 let (send, receive) = mpsc::sync_channel::<Option<Vec<u8>>>(32);
122 let _reader_thread = thread::spawn(move || {
123 let mut buffer = [0_u8; 16 * 1024];
124 loop {
125 match reader.read(&mut buffer) {
126 Ok(0) => break,
127 Ok(len) => {
128 if send.send(Some(buffer[..len].to_vec())).is_err() {
129 return;
130 }
131 }
132 Err(_) => break,
133 }
134 }
135 let _ = send.send(None);
136 });
137 let result = (|| {
138 let mut terminal = terminal(options.rows, options.cols);
139 let mut ansi = Vec::new();
140 let mut host = Host::new(writer, options);
141 let started = Instant::now();
142 let mut clock = Clock {
143 started,
144 deadline: started + options.deadline,
145 last_output: None,
146 };
147 let closed = consume_until_ready(
148 &receive,
149 &mut terminal,
150 &mut ansi,
151 &mut host,
152 options,
153 &mut clock,
154 )?;
155 if let Some(pattern) = options.wait_for.as_deref()
156 && !terminal.screen().contents().contains(pattern)
157 {
158 bail!(
159 "visible terminal did not include --wait-for {pattern:?} before command ended or deadline elapsed"
160 );
161 }
162 if !closed && Instant::now() < clock.deadline && !options.input.is_empty() {
163 clock.last_output = None;
165 host.send(&options.input)?;
166 consume_until_settled(
167 &receive,
168 &mut terminal,
169 &mut ansi,
170 &mut host,
171 options,
172 &mut clock,
173 )?;
174 }
175 Ok(Shot {
176 frame: from_screen(terminal.screen()),
177 ansi,
178 })
179 })();
180 #[cfg(unix)]
181 if let Some(process_group) = process_group {
182 unsafe {
185 libc::kill(-process_group, libc::SIGKILL);
186 }
187 }
188 let _ = child.kill();
189 drop(receive);
190 let teardown_deadline = Instant::now() + Duration::from_secs(1);
191 while Instant::now() < teardown_deadline {
192 if child.try_wait().ok().flatten().is_some() {
193 break;
194 }
195 thread::sleep(Duration::from_millis(10));
196 }
197 result
198}
199
200pub fn from_pipe_command(
202 command: &[String],
203 cwd: Option<&Path>,
204 options: &Options,
205) -> Result<Shot> {
206 if command.is_empty() {
207 bail!("provide a command after --");
208 }
209 validate_geometry(options.rows, options.cols)?;
210 let mut builder = ProcessCommand::new(&command[0]);
211 builder
212 .args(&command[1..])
213 .stdin(Stdio::null())
214 .stdout(Stdio::piped())
215 .stderr(Stdio::piped());
216 #[cfg(unix)]
217 {
218 use std::os::unix::process::CommandExt;
219 builder.process_group(0);
220 }
221 configure_process_environment(&mut builder, options);
222 if let Some(cwd) = cwd {
223 builder.current_dir(cwd);
224 }
225 let mut child = builder
226 .spawn()
227 .with_context(|| format!("spawn command {:?}", command[0]))?;
228 #[cfg(unix)]
229 let process_group = i32::try_from(child.id()).ok();
230 let stdout = child.stdout.take().context("open command stdout")?;
231 let stderr = child.stderr.take().context("open command stderr")?;
232 let (send, receive) = mpsc::sync_channel::<Option<Vec<u8>>>(32);
233 spawn_pipe_reader(stdout, send.clone());
234 spawn_pipe_reader(stderr, send);
235
236 let result = (|| {
237 let mut terminal = terminal(options.rows, options.cols);
238 let mut ansi = Vec::new();
239 let mut normalizer = LinefeedNormalizer::default();
240 let deadline = Instant::now() + options.deadline;
241 let mut open_streams = 2_usize;
242 let mut exited = false;
243 while open_streams > 0 || !exited {
244 let timeout = deadline
245 .saturating_duration_since(Instant::now())
246 .min(Duration::from_millis(20));
247 if timeout.is_zero() {
248 break;
249 }
250 match receive.recv_timeout(timeout) {
251 Ok(Some(bytes)) => {
252 let bytes = normalizer.normalize(&bytes);
253 retain(&mut ansi, &bytes, options.max_bytes)?;
254 terminal.process(&bytes);
255 }
256 Ok(None) => open_streams = open_streams.saturating_sub(1),
257 Err(RecvTimeoutError::Disconnected) => open_streams = 0,
258 Err(RecvTimeoutError::Timeout) => {}
259 }
260 if !exited {
261 exited = child.try_wait().context("wait for command")?.is_some();
262 }
263 }
264
265 if let Some(pattern) = options.wait_for.as_deref()
266 && !terminal.screen().contents().contains(pattern)
267 {
268 bail!(
269 "visible terminal did not include --wait-for {pattern:?} before command ended or deadline elapsed"
270 );
271 }
272 Ok(Shot {
273 frame: from_screen(terminal.screen()),
274 ansi,
275 })
276 })();
277 #[cfg(unix)]
278 if let Some(process_group) = process_group {
279 unsafe {
281 libc::kill(-process_group, libc::SIGKILL);
282 }
283 }
284 let _ = child.kill();
285 let _ = child.wait();
286 result
287}
288
289fn spawn_pipe_reader(
290 mut reader: impl Read + Send + 'static,
291 send: mpsc::SyncSender<Option<Vec<u8>>>,
292) {
293 thread::spawn(move || {
294 let mut buffer = [0_u8; 16 * 1024];
295 loop {
296 match reader.read(&mut buffer) {
297 Ok(0) => break,
298 Ok(len) => {
299 if send.send(Some(buffer[..len].to_vec())).is_err() {
300 return;
301 }
302 }
303 Err(_) => break,
304 }
305 }
306 let _ = send.send(None);
307 });
308}
309
310#[derive(Default)]
311struct LinefeedNormalizer {
312 previous_was_cr: bool,
313}
314
315impl LinefeedNormalizer {
316 fn normalize(&mut self, bytes: &[u8]) -> Vec<u8> {
317 let mut normalized = Vec::with_capacity(bytes.len());
318 for &byte in bytes {
319 if byte == b'\n' && !self.previous_was_cr {
320 normalized.push(b'\r');
321 }
322 normalized.push(byte);
323 self.previous_was_cr = byte == b'\r';
324 }
325 normalized
326 }
327}
328
329pub(crate) fn configure_pty_environment(builder: &mut CommandBuilder, options: &Options) {
330 if !options.inherit_env {
331 builder.env_clear();
332 }
333 builder.env("TERM", "xterm-truecolor");
334 builder.env("COLORTERM", "truecolor");
335 match options.color {
336 ColorMode::Auto => {}
337 ColorMode::Always => {
338 builder.env_remove("NO_COLOR");
339 builder.env("FORCE_COLOR", "1");
340 builder.env("CLICOLOR", "1");
341 builder.env("CLICOLOR_FORCE", "1");
342 }
343 ColorMode::Never => {
344 builder.env("NO_COLOR", "1");
345 builder.env("FORCE_COLOR", "0");
346 builder.env("CLICOLOR", "0");
347 builder.env("CLICOLOR_FORCE", "0");
348 }
349 }
350 for (key, value) in &options.env {
351 builder.env(key, value);
352 }
353}
354
355fn configure_process_environment(builder: &mut ProcessCommand, options: &Options) {
356 if !options.inherit_env {
357 builder.env_clear();
358 }
359 builder.env("TERM", "xterm-truecolor");
360 builder.env("COLORTERM", "truecolor");
361 match options.color {
362 ColorMode::Auto => {}
363 ColorMode::Always => {
364 builder.env_remove("NO_COLOR");
365 builder.env("FORCE_COLOR", "1");
366 builder.env("CLICOLOR", "1");
367 builder.env("CLICOLOR_FORCE", "1");
368 }
369 ColorMode::Never => {
370 builder.env("NO_COLOR", "1");
371 builder.env("FORCE_COLOR", "0");
372 builder.env("CLICOLOR", "0");
373 builder.env("CLICOLOR_FORCE", "0");
374 }
375 }
376 builder.envs(&options.env);
377}
378
379pub(crate) fn terminal(rows: u16, cols: u16) -> Parser {
380 Parser::new(rows, cols, 0)
381}
382
383pub(crate) fn validate_geometry(rows: u16, cols: u16) -> Result<()> {
384 if rows == 0 || cols == 0 {
385 bail!("terminal dimensions must be greater than zero");
386 }
387 Ok(())
388}
389
390fn consume_until_ready(
391 receive: &mpsc::Receiver<Option<Vec<u8>>>,
392 terminal: &mut Parser,
393 ansi: &mut Vec<u8>,
394 host: &mut Host,
395 options: &Options,
396 clock: &mut Clock,
397) -> Result<bool> {
398 let mut closed = false;
399 let delay_end = (clock.started + options.initial_delay).min(clock.deadline);
400 while !closed && Instant::now() < delay_end {
401 closed = matches!(
402 receive_chunk(receive, terminal, ansi, host, options.max_bytes, clock)?,
403 Chunk::Closed
404 );
405 }
406 if let Some(pattern) = &options.wait_for {
407 while !closed
408 && Instant::now() < clock.deadline
409 && !terminal.screen().contents().contains(pattern)
410 {
411 closed = matches!(
412 receive_chunk(receive, terminal, ansi, host, options.max_bytes, clock)?,
413 Chunk::Closed
414 );
415 }
416 }
417 if closed || Instant::now() >= clock.deadline {
418 return Ok(closed);
419 }
420 if !options.input.is_empty() && (options.wait_for.is_some() || !options.initial_delay.is_zero())
421 {
422 return Ok(false);
423 }
424 consume_until_settled(receive, terminal, ansi, host, options, clock)
425}
426
427enum Chunk {
428 Output,
429 Timeout,
430 Closed,
431}
432
433struct Clock {
434 started: Instant,
435 deadline: Instant,
436 last_output: Option<Instant>,
437}
438
439fn receive_chunk(
440 receive: &mpsc::Receiver<Option<Vec<u8>>>,
441 terminal: &mut Parser,
442 ansi: &mut Vec<u8>,
443 host: &mut Host,
444 max_bytes: usize,
445 clock: &mut Clock,
446) -> Result<Chunk> {
447 let timeout = clock
448 .deadline
449 .saturating_duration_since(Instant::now())
450 .min(Duration::from_millis(20));
451 if timeout.is_zero() {
452 return Ok(Chunk::Timeout);
453 }
454 match receive.recv_timeout(timeout) {
455 Ok(Some(bytes)) => {
456 host.respond(&bytes)?;
457 retain(ansi, &bytes, max_bytes)?;
458 terminal.process(&bytes);
459 clock.last_output = Some(Instant::now());
460 Ok(Chunk::Output)
461 }
462 Ok(None) | Err(RecvTimeoutError::Disconnected) => Ok(Chunk::Closed),
463 Err(RecvTimeoutError::Timeout) => Ok(Chunk::Timeout),
464 }
465}
466
467fn consume_until_settled(
468 receive: &mpsc::Receiver<Option<Vec<u8>>>,
469 terminal: &mut Parser,
470 ansi: &mut Vec<u8>,
471 host: &mut Host,
472 options: &Options,
473 clock: &mut Clock,
474) -> Result<bool> {
475 loop {
476 match receive_chunk(receive, terminal, ansi, host, options.max_bytes, clock)? {
477 Chunk::Output => {}
478 Chunk::Closed => return Ok(true),
479 Chunk::Timeout => {
480 if Instant::now() >= clock.deadline {
481 return Ok(false);
482 }
483 }
484 }
485 if clock
486 .last_output
487 .is_some_and(|last| last.elapsed() >= options.settle)
488 {
489 return Ok(false);
490 }
491 }
492}
493
494pub(crate) fn retain(ansi: &mut Vec<u8>, bytes: &[u8], max_bytes: usize) -> Result<()> {
495 if ansi.len() + bytes.len() > max_bytes {
496 bail!("terminal output exceeds --max-bytes ({max_bytes})");
497 }
498 ansi.extend_from_slice(bytes);
499 Ok(())
500}
501
502pub(crate) struct Host {
503 writer: Box<dyn Write + Send>,
504 enabled: bool,
505 opentui_replied: bool,
506 palette_replied: bool,
507 kitty_replied: bool,
508 probe: Vec<u8>,
509 pixel_width: u32,
510 pixel_height: u32,
511}
512
513impl Host {
514 pub(crate) fn new(writer: Box<dyn Write + Send>, options: &Options) -> Self {
515 Self {
516 writer,
517 enabled: options.opentui_host,
518 opentui_replied: false,
519 palette_replied: false,
520 kitty_replied: false,
521 probe: Vec::new(),
522 pixel_width: u32::from(options.cols) * u32::from(options.cell_width),
523 pixel_height: u32::from(options.rows) * u32::from(options.cell_height),
524 }
525 }
526
527 pub(crate) fn send(&mut self, input: &[u8]) -> Result<()> {
528 self.writer
529 .write_all(input)
530 .context("send terminal input")?;
531 self.writer.flush().context("flush terminal input")
532 }
533
534 pub(crate) fn resize(&mut self, cols: u16, rows: u16, cell_width: u16, cell_height: u16) {
535 self.pixel_width = u32::from(cols) * u32::from(cell_width);
536 self.pixel_height = u32::from(rows) * u32::from(cell_height);
537 }
538
539 pub(crate) fn respond(&mut self, output: &[u8]) -> Result<Vec<u8>> {
540 if !self.enabled {
541 return Ok(Vec::new());
542 }
543 let mut response = Vec::new();
544 self.probe.extend_from_slice(output);
545 if !self.opentui_replied
546 && self
547 .probe
548 .windows(OPENTUI_QUERY.len())
549 .any(|window| window == OPENTUI_QUERY)
550 {
551 response.extend_from_slice(
552 format!(
553 "\x1b]10;rgb:{:02x}{:02x}/{:02x}{:02x}/{:02x}{:02x}\x1b\\\x1b]11;rgb:{:02x}{:02x}/{:02x}{:02x}/{:02x}{:02x}\x1b\\\x1bP>|termctrl {}\x1b\\\x1b[1;1R\x1b[?1016;0$y\x1b[?2027;0$y\x1b[?2031;2$y\x1b[?1004;1$y\x1b[?2004;2$y\x1b[?2026;2$y\x1b[?0u\x1b[1;1R\x1b[1;1R\x1b[4;{};{}t\x1b[?6c",
554 DEFAULT_FOREGROUND.r,
555 DEFAULT_FOREGROUND.r,
556 DEFAULT_FOREGROUND.g,
557 DEFAULT_FOREGROUND.g,
558 DEFAULT_FOREGROUND.b,
559 DEFAULT_FOREGROUND.b,
560 DEFAULT_BACKGROUND.r,
561 DEFAULT_BACKGROUND.r,
562 DEFAULT_BACKGROUND.g,
563 DEFAULT_BACKGROUND.g,
564 DEFAULT_BACKGROUND.b,
565 DEFAULT_BACKGROUND.b,
566 env!("CARGO_PKG_VERSION"),
567 self.pixel_height,
568 self.pixel_width,
569 )
570 .as_bytes(),
571 );
572 self.opentui_replied = true;
573 }
574 if !self.palette_replied
575 && self
576 .probe
577 .windows(PALETTE_QUERY.len())
578 .any(|window| window == PALETTE_QUERY)
579 {
580 response.extend_from_slice(b"\x1b]4;0;rgb:0000/0000/0000\x1b\\");
581 self.palette_replied = true;
582 }
583 if !self.kitty_replied
584 && self
585 .probe
586 .windows(KITTY_QUERY.len())
587 .any(|window| window == KITTY_QUERY)
588 {
589 response.extend_from_slice(b"\x1b_Gi=31337;EINVAL:graphics unavailable\x1b\\");
590 self.kitty_replied = true;
591 }
592 if !response.is_empty() {
593 self.writer
594 .write_all(&response)
595 .context("write OpenTUI host response")?;
596 self.writer.flush().context("flush OpenTUI host response")?;
597 }
598 if self.probe.len() > 64 {
599 self.probe.drain(..self.probe.len() - 64);
600 }
601 Ok(response)
602 }
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608 use std::sync::{Arc, Mutex};
609
610 struct Writer(Arc<Mutex<Vec<u8>>>);
611
612 impl Write for Writer {
613 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
614 self.0.lock().unwrap().extend_from_slice(buf);
615 Ok(buf.len())
616 }
617
618 fn flush(&mut self) -> std::io::Result<()> {
619 Ok(())
620 }
621 }
622
623 #[test]
624 fn normalizes_plain_line_feeds_for_pipe_output() {
625 let mut normalizer = LinefeedNormalizer::default();
626 assert_eq!(normalizer.normalize(b"one\n"), b"one\r\n");
627 assert_eq!(normalizer.normalize(b"two\r"), b"two\r");
628 assert_eq!(normalizer.normalize(b"\nthree"), b"\nthree");
629 }
630
631 #[cfg(unix)]
632 #[test]
633 fn pipe_command_captures_non_tty_output() {
634 let captured = from_pipe_command(
635 &[
636 "sh".to_owned(),
637 "-c".to_owned(),
638 "printf 'one\\ntwo\\n'".to_owned(),
639 ],
640 None,
641 &Options {
642 cols: 20,
643 rows: 4,
644 cell_width: 9,
645 cell_height: 18,
646 settle: Duration::ZERO,
647 deadline: Duration::from_secs(2),
648 input: Vec::new(),
649 initial_delay: Duration::ZERO,
650 wait_for: None,
651 max_bytes: 1024,
652 opentui_host: false,
653 color: ColorMode::Auto,
654 env: BTreeMap::new(),
655 inherit_env: true,
656 },
657 )
658 .unwrap();
659
660 assert_eq!(captured.frame.text(), "one\ntwo");
661 assert!(captured.ansi.windows(2).any(|window| window == b"\r\n"));
662 }
663
664 #[cfg(unix)]
665 #[test]
666 fn pipe_shot_terminates_descendant_processes() {
667 let captured = from_pipe_command(
668 &[
669 "sh".to_owned(),
670 "-c".to_owned(),
671 "sleep 30 & printf '%s' \"$!\"".to_owned(),
672 ],
673 None,
674 &Options {
675 deadline: Duration::from_millis(50),
676 ..Options::default()
677 },
678 )
679 .unwrap();
680 let pid = captured.frame.text().parse::<i32>().unwrap();
681 thread::sleep(Duration::from_millis(20));
682
683 assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
684 }
685
686 #[test]
687 fn responds_to_split_opentui_query_with_requested_geometry() {
688 let result = Arc::new(Mutex::new(Vec::new()));
689 let mut host = Host::new(
690 Box::new(Writer(result.clone())),
691 &Options {
692 cols: 100,
693 rows: 24,
694 cell_width: 9,
695 cell_height: 20,
696 settle: Duration::ZERO,
697 deadline: Duration::ZERO,
698 input: Vec::new(),
699 initial_delay: Duration::ZERO,
700 wait_for: None,
701 max_bytes: 1,
702 opentui_host: true,
703 color: ColorMode::Auto,
704 env: BTreeMap::new(),
705 inherit_env: true,
706 },
707 );
708
709 host.respond(b"\x1b]10;?\x07").unwrap();
710 host.respond(b"\x1b]11;?\x07").unwrap();
711 host.respond(b"\x1b]4;0;?\x07").unwrap();
712 host.respond(b"\x1b_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\")
713 .unwrap();
714
715 let output = String::from_utf8(result.lock().unwrap().clone()).unwrap();
716 assert!(output.contains("\x1b[4;480;900t"));
717 assert!(output.contains("\x1b]4;0;rgb:0000/0000/0000\x1b\\"));
718 assert!(output.contains("\x1b_Gi=31337;EINVAL:graphics unavailable\x1b\\"));
719 }
720
721 #[test]
722 fn rejects_zero_terminal_geometry_before_parsing() {
723 assert!(from_ansi(Vec::new(), 0, 1, 1).is_err());
724 assert!(from_ansi(Vec::new(), 1, 0, 1).is_err());
725 }
726}