Skip to main content

terminal_control/
shot.rs

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/// Configuration for observing one terminal shot or starting a live session.
21#[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    /// Additional environment values set in the observed terminal application.
36    pub env: BTreeMap<String, String>,
37    /// Whether the terminal application inherits the parent process environment.
38    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/// A visible terminal frame together with its source ANSI/VT stream.
63#[derive(Deserialize, Serialize)]
64pub struct Shot {
65    pub frame: Frame,
66    pub ansi: Vec<u8>,
67}
68
69/// Environment policy applied to a launched command's color configuration.
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
71#[serde(rename_all = "lowercase")]
72pub enum ColorMode {
73    Auto,
74    Always,
75    Never,
76}
77
78/// Construct a shot by replaying an ANSI/VT byte stream into a terminal frame.
79pub 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
92/// Observe a command launched inside a pseudo-terminal and return its settled shot.
93pub 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            // Once input is sent, the pre-input idle frame is no longer the shot target.
164            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        // portable-pty spawns the application as a session leader; kill its group so helpers do
183        // not retain the slave PTY after a frozen shot is returned.
184        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
200/// Observe piped command output and return its final rendered shot.
201pub 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        // Pipe shots own their launched command tree; do not leave diagnostic descendants alive.
280        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}