Skip to main content

mira/
term.rs

1//! A terminal, hand-rolled on `libc`.
2//!
3//! ratatui is the obvious answer, and adding it to this workspace resolves 35
4//! crates that are not already here — a 30% increase on a tree whose size (117
5//! crates, 5.63 MiB) is a stated property of the product.
6//! What it buys over this file is a constraint-solving layout engine and a
7//! damage-tracked cell buffer; the TUI here has fixed panes and redraws one
8//! screenful per keystroke. So: `termios` for raw mode, `TIOCGWINSZ` for the
9//! size, `poll(2)` for input, three `sigaction`s to survive a resize and a
10//! `kill`, ANSI for the rest. `libc` is already in the tree for `statfs`.
11//!
12//! Unix only, which is the same bet `mmap`, `SIGTERM` and the filesystem guard
13//! already make.
14
15use std::io::{self, IsTerminal, Read, Write};
16use std::sync::OnceLock;
17
18/// The terminal settings as they were before [`Term::enter`].
19///
20/// A static rather than a field because the panic hook has to reach them, and
21/// the hook outlives any borrow we could hand it. `panic = "abort"` in the
22/// release profile does not skip hooks — it skips *unwinding* — so this is
23/// still the last thing that runs before a crash, and without it a panic leaves
24/// the shell in raw mode with no echo and no cursor.
25static ORIG: OnceLock<libc::termios> = OnceLock::new();
26
27pub struct Term {
28    /// Bytes read from stdin but not yet consumed as a key.
29    ///
30    /// One `read` can return a whole escape sequence, several keystrokes from a
31    /// fast typist, or half of either. Without this, the tail of a burst is
32    /// silently dropped.
33    pending: Vec<u8>,
34    out: io::BufWriter<io::Stdout>,
35}
36
37impl Term {
38    pub fn enter() -> io::Result<Term> {
39        // Checked before `tcgetattr`, which would otherwise report a redirected
40        // stdin as `ENOTTY` — "Inappropriate ioctl for device" — and says nothing
41        // at all about a redirected stdout, which instead fills the file with
42        // escape sequences.
43        if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
44            return Err(io::Error::new(
45                io::ErrorKind::Unsupported,
46                "not a terminal; the TUI needs stdin and stdout on a tty",
47            ));
48        }
49        // SAFETY: `termios` is integers and the `c_cc` byte array — no pointer,
50        // no niche, no field for which zero is not a value — so all-zero is a
51        // valid `termios` to hold until `tcgetattr` overwrites it.
52        let mut t: libc::termios = unsafe { std::mem::zeroed() };
53        // SAFETY: `&mut t` is a live, correctly typed, initialised `termios`, and
54        // `tcgetattr` writes nothing past its end.
55        ok_or_errno(unsafe { libc::tcgetattr(0, &mut t) })?;
56        let _ = ORIG.set(t);
57
58        let mut raw = t;
59        // SAFETY: `raw` is a copy of the struct `tcgetattr` just filled, so
60        // `cfmakeraw` reads and writes only initialised fields of a live struct.
61        unsafe { libc::cfmakeraw(&mut raw) };
62        // VMIN 0 / VTIME 0: `read` returns immediately with whatever is there.
63        // Blocking is `poll`'s job, and doing it in both places is how a
64        // keystroke ends up waiting for the next one.
65        raw.c_cc[libc::VMIN] = 0;
66        raw.c_cc[libc::VTIME] = 0;
67        // SAFETY: `raw` is fully initialised — copied out of `tcgetattr`, edited
68        // field by field — and `tcsetattr` only reads it, for the duration of
69        // the call.
70        ok_or_errno(unsafe { libc::tcsetattr(0, libc::TCSANOW, &raw) })?;
71
72        let prev = std::panic::take_hook();
73        std::panic::set_hook(Box::new(move |info| {
74            restore();
75            prev(info);
76        }));
77
78        // SIGWINCH so a resize breaks the `poll` in `key` and the loop repaints
79        // at the new size; SIGTERM and SIGHUP so a `kill` or a closed ssh
80        // session gives the terminal back the way the panic hook does. Their
81        // default dispositions — discard, and die on the spot — are both wrong
82        // for a process that owns the screen.
83        on_signal(libc::SIGWINCH, winch);
84        on_signal(libc::SIGTERM, bail);
85        on_signal(libc::SIGHUP, bail);
86
87        let mut out = io::BufWriter::new(io::stdout());
88        // Alternate screen, then hide the cursor. Leaving on the alternate
89        // screen is what puts the user's scrollback back the way they left it.
90        out.write_all(b"\x1b[?1049h\x1b[?25l\x1b[2J")?;
91        out.flush()?;
92        Ok(Term {
93            pending: Vec::with_capacity(64),
94            out,
95        })
96    }
97
98    /// Visible size, re-read every frame.
99    ///
100    /// The `SIGWINCH` handler [`enter`](Term::enter) installs records nothing;
101    /// it exists only to interrupt the `poll` the draw loop is parked in. This
102    /// call is what learns the new size, on the frame that interruption paints.
103    pub fn size(&self) -> (usize, usize) {
104        // SAFETY: `winsize` is four `u16`s, so zero is a valid value — and it
105        // has to be, because a failing `ioctl` leaves the struct untouched and
106        // the `ws_col == 0` arm below is what reads it back.
107        let mut ws: libc::winsize = unsafe { std::mem::zeroed() };
108        // SAFETY: TIOCGWINSZ is the request whose argument is `*mut winsize`,
109        // which is exactly what `&mut ws` is. `ioctl` is variadic, so nothing
110        // checks that pairing but this comment — a different request constant
111        // with this argument is the way the call goes wrong.
112        if unsafe { libc::ioctl(1, libc::TIOCGWINSZ as _, &mut ws) } != 0 || ws.ws_col == 0 {
113            return (80, 24);
114        }
115        (ws.ws_col as usize, ws.ws_row as usize)
116    }
117
118    /// Paint one frame: home the cursor, write every row, erase whatever the
119    /// last frame left below.
120    ///
121    /// A full repaint rather than a diff. At 200×60 that is 12 000 cells, and
122    /// one `write` of 30 KB costs less than tracking which of them changed.
123    pub fn draw(&mut self, rows: &[String]) -> io::Result<()> {
124        self.out.write_all(b"\x1b[H")?;
125        for row in rows {
126            self.out.write_all(row.as_bytes())?;
127            self.out.write_all(b"\x1b[K\r\n")?;
128        }
129        self.out.write_all(b"\x1b[J")?;
130        self.out.flush()
131    }
132
133    /// Next key, or `None` if `timeout_ms` passed with nothing to read.
134    pub fn key(&mut self, timeout_ms: i32) -> io::Result<Option<Key>> {
135        loop {
136            match parse(&mut self.pending) {
137                Parsed::Key(k) => return Ok(Some(k)),
138                Parsed::Need => {}
139            }
140            // A lone ESC and the start of an arrow key are the same first byte.
141            // The only way to tell them apart is that the rest of the sequence
142            // is already in flight, so wait a beat before calling it ESC.
143            let wait = if self.pending.is_empty() {
144                timeout_ms
145            } else {
146                20
147            };
148            if !poll_in(wait)? {
149                return Ok(match self.pending.is_empty() {
150                    true => None,
151                    // Incomplete after the grace period: emit the first byte for
152                    // what it is and resynchronise rather than wedging.
153                    false => Some(take_one(&mut self.pending)),
154                });
155            }
156            let mut buf = [0u8; 256];
157            let n = io::stdin().read(&mut buf)?;
158            if n == 0 {
159                return Ok(Some(Key::Ctrl('c')));
160            }
161            self.pending.extend_from_slice(&buf[..n]);
162        }
163    }
164}
165
166enum Parsed {
167    Key(Key),
168    Need,
169}
170
171/// One key off the front of `b`, or [`Parsed::Need`] if what is there could
172/// still grow into a longer sequence.
173///
174/// Free rather than a method so the tests can drive it with a plain `Vec`: a
175/// `Term` restores the terminal when it drops, and constructing one in a test
176/// sprays escape codes across the test runner's output.
177fn parse(b: &mut Vec<u8>) -> Parsed {
178    let Some(&first) = b.first() else {
179        return Parsed::Need;
180    };
181    let one = |b: &mut Vec<u8>, k| {
182        b.remove(0);
183        Parsed::Key(k)
184    };
185    match first {
186        0x1b => {
187            match b.get(1) {
188                None => Parsed::Need,
189                // CSI: parameters, then one byte in 0x40..=0x7e ends it.
190                Some(b'[') => {
191                    let Some(end) = b[2..].iter().position(|c| (0x40..=0x7e).contains(c)) else {
192                        return Parsed::Need;
193                    };
194                    let seq: Vec<u8> = b[2..2 + end + 1].to_vec();
195                    b.drain(..3 + end);
196                    Parsed::Key(csi(&seq))
197                }
198                // SS3, which is what some terminals send for the arrows in
199                // application cursor mode.
200                Some(b'O') => match b.get(2) {
201                    None => Parsed::Need,
202                    Some(&c) => {
203                        b.drain(..3);
204                        Parsed::Key(csi(&[c]))
205                    }
206                },
207                // Alt+key. Nothing here binds one, so drop the modifier and keep
208                // the key rather than swallowing both.
209                Some(_) => {
210                    b.remove(0);
211                    Parsed::Need
212                }
213            }
214        }
215        b'\r' | b'\n' => one(b, Key::Enter),
216        b'\t' => one(b, Key::Tab),
217        0x7f | 0x08 => one(b, Key::Backspace),
218        c if c < 0x20 => one(b, Key::Ctrl((c + b'a' - 1) as char)),
219        c if c < 0x80 => one(b, Key::Char(c as char)),
220        c => {
221            // UTF-8 continuation bytes may not have arrived yet.
222            let len = match c {
223                0xc0..=0xdf => 2,
224                0xe0..=0xef => 3,
225                _ => 4,
226            };
227            if b.len() < len {
228                return Parsed::Need;
229            }
230            let s = String::from_utf8_lossy(&b[..len]).into_owned();
231            b.drain(..len);
232            Parsed::Key(s.chars().next().map_or(Key::Esc, Key::Char))
233        }
234    }
235}
236
237fn take_one(b: &mut Vec<u8>) -> Key {
238    match b.remove(0) {
239        0x1b => Key::Esc,
240        c if c < 0x20 => Key::Ctrl((c + b'a' - 1) as char),
241        c => Key::Char(c as char),
242    }
243}
244
245impl Drop for Term {
246    fn drop(&mut self) {
247        restore();
248    }
249}
250
251fn csi(seq: &[u8]) -> Key {
252    match seq {
253        b"A" => Key::Up,
254        b"B" => Key::Down,
255        b"C" => Key::Right,
256        b"D" => Key::Left,
257        b"H" | b"1~" | b"7~" => Key::Home,
258        b"F" | b"4~" | b"8~" => Key::End,
259        b"5~" => Key::PageUp,
260        b"6~" => Key::PageDown,
261        b"Z" => Key::BackTab,
262        _ => Key::Esc,
263    }
264}
265
266/// Put the terminal back. Idempotent, because `Drop`, the panic hook and a
267/// fatal signal can all reach it and a double panic would otherwise run it
268/// twice.
269///
270/// Every call in here is async-signal-safe — `tcsetattr` and `write` are on
271/// POSIX's list, and `OnceLock::get` is one atomic load — because [`bail`] runs
272/// it from a signal handler. `io::stdout()` is the thing that would not do:
273/// taking its lock in a handler that interrupted the thread already holding it
274/// is a deadlock at exactly the moment the user wants their terminal back.
275fn restore() {
276    if let Some(t) = ORIG.get() {
277        // SAFETY: `t` borrows the `termios` `enter` filled with `tcgetattr` and
278        // nothing has written it since — `OnceLock` hands out no `&mut` after
279        // `set`. Reached from the `bail` handler, and `tcsetattr` is on POSIX's
280        // async-signal-safe list, so interrupting a `tcsetattr` with this one is
281        // defined.
282        unsafe { libc::tcsetattr(0, libc::TCSANOW, t) };
283    }
284    const OFF: &[u8] = b"\x1b[?25h\x1b[?1049l";
285    // SAFETY: `OFF` is a `'static` slice, so its pointer is valid for the
286    // `OFF.len()` bytes claimed for as long as the program runs, and `write`
287    // only reads them. Async-signal-safe, which is why this is not `stdout`.
288    unsafe { libc::write(1, OFF.as_ptr().cast(), OFF.len()) };
289}
290
291/// Install `h` for `sig`, without `SA_RESTART`.
292///
293/// The flag is omitted because it buys nothing here, not because [`poll_in`]
294/// needs it: `poll(2)` is on signal(7)'s list of calls the kernel never
295/// restarts whatever the flag says, and the `read(2)` in the same loop runs
296/// under VMIN 0 / VTIME 0, so it returns immediately and is never sitting in a
297/// restartable wait either. What makes a resize wake `poll_in` is installing a
298/// handler at all — SIGWINCH's default disposition is to discard the signal,
299/// and a discarded signal interrupts nothing.
300fn on_signal(sig: libc::c_int, h: unsafe extern "C" fn(libc::c_int)) {
301    // SAFETY: `sigaction` is POD, and all-zero is its "no flags, SIG_DFL"
302    // value — the one the two writes below then replace.
303    let mut sa: libc::sigaction = unsafe { std::mem::zeroed() };
304    sa.sa_sigaction = h as usize;
305    // SAFETY: `sa` is initialised and outlives the call, which copies it;
306    // `sigemptyset` writes only `sa_mask`. The load-bearing part is `sa_flags`,
307    // left at zero by the `zeroed` above: with SA_SIGINFO clear the kernel calls
308    // `sa_sigaction` with the single `c_int` that `h`'s type declares, so
309    // setting that flag without widening `h`'s signature is what would break
310    // this. A null `oldact` means "do not report the previous disposition",
311    // which `sigaction(2)` permits. Both handlers this is ever called with —
312    // [`winch`] and [`bail`] — are async-signal-safe.
313    unsafe {
314        libc::sigemptyset(&mut sa.sa_mask);
315        libc::sigaction(sig, &sa, std::ptr::null_mut());
316    }
317}
318
319/// A resize. Nothing to do in the handler — the delivery itself is the message,
320/// and it arrives as an `EINTR` in [`poll_in`].
321unsafe extern "C" fn winch(_: libc::c_int) {}
322
323/// Give the terminal back, then die.
324///
325/// Installing a handler removed the default disposition, so returning would
326/// resume a process the user asked to end; `_exit` rather than `exit` because
327/// the latter runs atexit handlers that are not async-signal-safe, and 128+n is
328/// the status a shell reports for a signal death.
329unsafe extern "C" fn bail(sig: libc::c_int) {
330    restore();
331    // SAFETY: `_exit` dereferences nothing and never returns. It is
332    // async-signal-safe, which is the entire reason it is here rather than
333    // `exit` — see the doc above.
334    unsafe { libc::_exit(128 + sig) };
335}
336
337/// The `termios` calls' contract: `0`, or `-1` and the reason is in `errno`.
338///
339/// Read here rather than at the call site because nothing runs in between —
340/// `rc` is already a value by the time this is entered, and Rust code cannot
341/// touch `errno` — so the read is as prompt as an inline one, and the two call
342/// sites in [`enter`](Term::enter) stop being four lines each.
343fn ok_or_errno(rc: libc::c_int) -> io::Result<()> {
344    match rc {
345        0 => Ok(()),
346        _ => Err(io::Error::last_os_error()),
347    }
348}
349
350fn poll_in(timeout_ms: i32) -> io::Result<bool> {
351    let mut p = libc::pollfd {
352        fd: 0,
353        events: libc::POLLIN,
354        revents: 0,
355    };
356    // SAFETY: `&mut p` points at exactly the one initialised `pollfd` the count
357    // of `1` claims, it lives across the call, and `poll` writes only `revents`.
358    let n = unsafe { libc::poll(&mut p, 1, timeout_ms) };
359    if n >= 0 {
360        return Ok(n > 0);
361    }
362    let e = io::Error::last_os_error();
363    // A resize interrupts the poll, which is the only reason `enter` installs a
364    // SIGWINCH handler at all — the default disposition discards it and this
365    // arm would be unreachable. Reporting "nothing to read" sends the caller
366    // back around the draw loop, which re-reads the size — which is exactly the
367    // handling a resize wants, so it is not retried here.
368    match e.kind() {
369        io::ErrorKind::Interrupted => Ok(false),
370        _ => Err(e),
371    }
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
375pub enum Key {
376    Char(char),
377    Ctrl(char),
378    Up,
379    Down,
380    Left,
381    Right,
382    Enter,
383    Esc,
384    Tab,
385    BackTab,
386    Backspace,
387    Home,
388    End,
389    PageUp,
390    PageDown,
391}
392
393pub const RESET: &str = "\x1b[0m";
394pub const BOLD: &str = "\x1b[1m";
395pub const DIM: &str = "\x1b[2m";
396pub const REV: &str = "\x1b[7m";
397pub const RED: &str = "\x1b[31m";
398pub const GREEN: &str = "\x1b[32m";
399pub const YELLOW: &str = "\x1b[33m";
400pub const BLUE: &str = "\x1b[34m";
401pub const MAGENTA: &str = "\x1b[35m";
402pub const CYAN: &str = "\x1b[36m";
403
404/// One line of the frame, built left to right with its visible width tracked
405/// separately from its bytes.
406///
407/// Styling is inline ANSI, so `buf.len()` says nothing about how wide the line
408/// renders; every truncation and every pad has to go through `width`. That is
409/// the entire reason this type exists rather than a `String`.
410pub struct Row {
411    buf: String,
412    width: usize,
413    max: usize,
414}
415
416impl Row {
417    pub fn new(max: usize) -> Row {
418        Row {
419            buf: String::with_capacity(max + 32),
420            width: 0,
421            max,
422        }
423    }
424
425    pub fn left(&self) -> usize {
426        self.max.saturating_sub(self.width)
427    }
428
429    /// Move the right edge, so a right-aligned field can reserve its space
430    /// before the left-hand side is written and be clipped away by it.
431    pub fn cap(&mut self, max: usize) -> &mut Row {
432        self.max = max;
433        self
434    }
435
436    /// Append text, truncated to what is left of the line.
437    ///
438    /// ponytail: one column per `char`. A CJK log body renders one cell wide per
439    /// character here and will run past the right edge; the fix is a
440    /// `unicode-width` table, which is a crate, and the payload this reads is
441    /// attribute keys and metric names. Revisit if a user shows up with a
442    /// wide-character body.
443    pub fn put(&mut self, style: &str, s: &str) -> &mut Row {
444        let left = self.left();
445        if left == 0 {
446            return self;
447        }
448        if !style.is_empty() {
449            self.buf.push_str(style);
450        }
451        let mut n = 0;
452        for c in s.chars() {
453            if n == left {
454                break;
455            }
456            // A raw control byte in a log body would move the cursor.
457            self.buf.push(if (c as u32) < 0x20 { '·' } else { c });
458            n += 1;
459        }
460        if !style.is_empty() {
461            self.buf.push_str(RESET);
462        }
463        self.width += n;
464        self
465    }
466
467    pub fn plain(&mut self, s: &str) -> &mut Row {
468        self.put("", s)
469    }
470
471    /// Append an already-rendered line of known visible `width`, byte for byte.
472    ///
473    /// [`put`](Row::put) would rewrite its ESC bytes to `·` and count each one
474    /// as a column, which is right for a log body and wrong for a line that came
475    /// out of another `Row`. Nothing here can recover the width from the bytes,
476    /// so the caller states it — pass a line finished with [`done`](Row::done),
477    /// which pads to exactly the width it was built for.
478    pub fn raw(&mut self, s: &str, width: usize) -> &mut Row {
479        self.buf.push_str(s);
480        self.width += width;
481        self
482    }
483
484    /// Pad with spaces until the cursor sits at `col`. Never truncates: a
485    /// column that overflowed its slot pushes the next one right rather than
486    /// losing it.
487    pub fn pad_to(&mut self, col: usize) -> &mut Row {
488        while self.width < col.min(self.max) {
489            self.buf.push(' ');
490            self.width += 1;
491        }
492        self
493    }
494
495    /// Repeat `c` `n` times, clipped to the line.
496    pub fn repeat(&mut self, style: &str, c: char, n: usize) -> &mut Row {
497        let n = n.min(self.left());
498        if n == 0 {
499            return self;
500        }
501        if !style.is_empty() {
502            self.buf.push_str(style);
503        }
504        for _ in 0..n {
505            self.buf.push(c);
506        }
507        if !style.is_empty() {
508            self.buf.push_str(RESET);
509        }
510        self.width += n;
511        self
512    }
513
514    /// Finish the line, padded to full width so a selected row's reverse-video
515    /// background reaches the right edge.
516    pub fn fill(mut self, style: &str) -> String {
517        if !style.is_empty() {
518            // Re-open the style over the padding only; the text has already
519            // closed its own.
520            self.buf.push_str(style);
521        }
522        while self.width < self.max {
523            self.buf.push(' ');
524            self.width += 1;
525        }
526        self.buf.push_str(RESET);
527        self.buf
528    }
529
530    pub fn done(self) -> String {
531        self.fill("")
532    }
533}
534
535#[cfg(test)]
536pub(crate) mod tests {
537    use super::*;
538
539    /// Width accounting has to ignore the escape sequences, or every styled
540    /// line silently truncates early.
541    #[test]
542    fn styling_does_not_count_against_the_width() {
543        let mut r = Row::new(10);
544        r.put(RED, "abc").plain("de");
545        assert_eq!(r.left(), 5);
546        let s = r.done();
547        assert!(s.contains(RED));
548        // 10 visible columns, whatever the byte length.
549        let visible: String = strip(&s);
550        assert_eq!(visible, "abcde     ");
551    }
552
553    #[test]
554    fn text_is_clipped_at_the_edge_and_control_bytes_are_defanged() {
555        let mut r = Row::new(6);
556        r.plain("a\nb").plain("xxxxxxxx");
557        assert_eq!(strip(&r.done()), "a·bxxx");
558    }
559
560    #[test]
561    fn pad_to_never_moves_backwards() {
562        let mut r = Row::new(12);
563        r.plain("overlong").pad_to(4).plain("|");
564        assert_eq!(strip(&r.done()), "overlong|   ");
565    }
566
567    /// The sequences the app actually binds, including the ones that arrive
568    /// split across two reads.
569    #[test]
570    fn escape_sequences_decode_to_keys() {
571        let mut pending: Vec<u8> = Vec::new();
572        let mut keys = |bytes: &[u8]| {
573            pending.extend_from_slice(bytes);
574            let mut out = Vec::new();
575            while let Parsed::Key(k) = parse(&mut pending) {
576                out.push(k);
577            }
578            out
579        };
580        assert_eq!(keys(b"\x1b[A\x1b[B"), vec![Key::Up, Key::Down]);
581        assert_eq!(keys(b"\x1b[5~\x1b[6~"), vec![Key::PageUp, Key::PageDown]);
582        assert_eq!(keys(b"\x1bOD"), vec![Key::Left]);
583        assert_eq!(
584            keys(b"jk\r\x7f"),
585            vec![Key::Char('j'), Key::Char('k'), Key::Enter, Key::Backspace]
586        );
587        assert_eq!(keys(b"\x03"), vec![Key::Ctrl('c')]);
588        // Split mid-sequence: nothing until the rest lands. Both prefixes, since
589        // CSI and SS3 have separate "could still grow" arms.
590        assert_eq!(keys(b"\x1b["), vec![]);
591        assert_eq!(keys(b"C"), vec![Key::Right]);
592        assert_eq!(keys(b"\x1bO"), vec![]);
593        assert_eq!(keys(b"A"), vec![Key::Up]);
594        // Multi-byte UTF-8, likewise, at every lead-byte width — the width is
595        // read off the lead byte, so a wrong row in that table eats the next key.
596        assert_eq!(keys(&[0xc3]), vec![]);
597        assert_eq!(keys(&[0xa9]), vec![Key::Char('é')]);
598        assert_eq!(keys(&[0xe2, 0x82]), vec![]);
599        assert_eq!(keys(&[0xac]), vec![Key::Char('€')]);
600        assert_eq!(keys(&[0xf0, 0x9f, 0x98]), vec![]);
601        assert_eq!(keys(&[0x80]), vec![Key::Char('😀')]);
602    }
603
604    /// Every sequence in the `csi` table, because terminals disagree about which
605    /// one they send for the same physical key and the table exists to absorb
606    /// that. An unrecognised one is `Esc`, not a dropped byte: the app treats
607    /// `Esc` as "cancel", which is the safe reading of a key we do not know.
608    #[test]
609    fn one_key_arrives_in_as_many_spellings_as_there_are_terminals() {
610        // `None` is [`Parsed::Need`]: what is there could still grow into a
611        // longer sequence, so no key comes out of it yet.
612        fn key(bytes: &[u8]) -> Option<Key> {
613            match parse(&mut bytes.to_vec()) {
614                Parsed::Key(k) => Some(k),
615                Parsed::Need => None,
616            }
617        }
618        // A CSI with no final byte yet is the "could still grow" answer, not a
619        // key: the table below would otherwise be reading half a sequence.
620        assert_eq!(key(b"\x1b[1"), None);
621        for (bytes, want) in [
622            (&b"\x1b[H"[..], Some(Key::Home)),
623            (b"\x1b[1~", Some(Key::Home)),
624            (b"\x1b[7~", Some(Key::Home)),
625            (b"\x1b[F", Some(Key::End)),
626            (b"\x1b[4~", Some(Key::End)),
627            (b"\x1b[8~", Some(Key::End)),
628            (b"\x1b[Z", Some(Key::BackTab)),
629            (b"\x1b[C", Some(Key::Right)),
630            (b"\x1bOA", Some(Key::Up)),
631            // Not in the table, and not worth guessing at.
632            (b"\x1b[200~", Some(Key::Esc)),
633            (b"\t", Some(Key::Tab)),
634            (b"\x08", Some(Key::Backspace)),
635            (b"\n", Some(Key::Enter)),
636            // Nothing at all is not a key either: `key` is called in a loop that
637            // polls again, and a `Parsed::Key` here would be one nobody pressed.
638            (b"", None),
639            // Half of a CSI and half of an SS3, which is how they arrive when
640            // the read lands mid-sequence.
641            (b"\x1b[", None),
642            (b"\x1bO", None),
643        ] {
644            assert_eq!(key(bytes), want, "{bytes:?}");
645        }
646
647        // Alt+x: the modifier is dropped and the key kept, so the next call sees
648        // a bare `x` rather than both bytes being swallowed.
649        let mut b = b"\x1bx".to_vec();
650        assert!(matches!(parse(&mut b), Parsed::Need));
651        assert_eq!(key(&b), Some(Key::Char('x')));
652
653        // What `key` falls back to when a sequence never completes: the first
654        // byte for what it is, then resynchronise. A lone ESC is the case that
655        // matters — it is how the filter box gets cancelled.
656        for (byte, want) in [
657            (0x1b, Key::Esc),
658            (0x03, Key::Ctrl('c')),
659            (b'q', Key::Char('q')),
660        ] {
661            let mut b = vec![byte, b'!'];
662            assert_eq!(take_one(&mut b), want);
663            assert_eq!(b, b"!");
664        }
665    }
666
667    /// The three things `Row` does that `put` does not: reserve space from the
668    /// right, re-emit an already-rendered line without re-counting its escapes,
669    /// and pad under a style so a selected row's background reaches the edge.
670    #[test]
671    fn a_row_composes_out_of_other_rows_without_recounting_their_escapes() {
672        // A right-aligned field reserves its space by moving the right edge in,
673        // writing the left side, then letting it back out.
674        let mut r = Row::new(20);
675        r.plain("left").cap(20).pad_to(12).plain("right");
676        assert_eq!(strip(&r.done()), "left        right   ");
677
678        // `raw` takes the caller's word for the width. `put` would have counted
679        // the 4 escape bytes of RED as 4 columns and rewritten them to `·`.
680        let inner = {
681            let mut i = Row::new(5);
682            i.put(RED, "ab");
683            i.done()
684        };
685        let mut outer = Row::new(10);
686        outer.raw(&inner, 5).plain("xy");
687        let s = outer.done();
688        assert!(s.contains(RED), "the inner styling survived byte for byte");
689        assert_eq!(strip(&s), "ab   xy   ");
690
691        // `repeat` clips like `put` does, and a zero-width repeat writes nothing
692        // at all — not an empty style pair, which would still be bytes.
693        let mut r = Row::new(4);
694        r.repeat(DIM, '-', 99);
695        assert_eq!(strip(&r.done()), "----");
696        let mut r = Row::new(0);
697        r.repeat(DIM, '-', 3).put(RED, "x");
698        assert_eq!(r.done(), RESET, "nothing fits, so nothing is written");
699
700        // `fill` re-opens the style over the padding: the highlight on a selected
701        // row has to reach the right edge, not stop where the text does.
702        let mut r = Row::new(6);
703        r.plain("ab");
704        let s = r.fill(REV);
705        assert!(s.ends_with(&format!("{REV}    {RESET}")), "{s:?}");
706    }
707
708    /// The branch that fires in anger: `mira mira` in a pipeline, in CI, or
709    /// under a process supervisor. The rest of the syscall half runs on a real
710    /// pty in [`the_terminal_half_runs_against_a_real_pty`].
711    #[test]
712    fn the_tui_refuses_a_stdin_that_is_not_a_terminal() {
713        let e = Term::enter()
714            .err()
715            .expect("cargo test does not run on a tty");
716        assert_eq!(e.kind(), io::ErrorKind::Unsupported);
717        assert!(e.to_string().contains("needs stdin and stdout on a tty"));
718    }
719
720    /// The other half of the same refusal: if a `termios` call fails anyway,
721    /// `enter` reports why instead of pressing on with a terminal it has half
722    /// configured. Provoked on a pipe, since `enter` turns away everything that
723    /// is not a tty before it reaches either call.
724    #[test]
725    fn a_failed_termios_call_carries_the_os_error() {
726        let mut fds = [-1; 2];
727        // SAFETY: `pipe` writes exactly the two `c_int`s this array holds.
728        assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
729        // SAFETY: all-zero is a valid `termios`, as in `enter` — and here both
730        // calls fail without writing it at all.
731        let mut t: libc::termios = unsafe { std::mem::zeroed() };
732        // SAFETY: `fds[0]` is open until the `close` below, and both calls get
733        // a live, correctly typed `termios`.
734        let get = ok_or_errno(unsafe { libc::tcgetattr(fds[0], &mut t) });
735        // SAFETY: as above; `tcsetattr` only reads it.
736        let set = ok_or_errno(unsafe { libc::tcsetattr(fds[0], libc::TCSANOW, &t) });
737        // SAFETY: both fds were opened by the `pipe` above and are owned by
738        // nothing else, so this is the only close of either.
739        unsafe {
740            libc::close(fds[0]);
741            libc::close(fds[1]);
742        }
743        for r in [get, set] {
744            // ENOTTY, not a generic "it did not work": the message the user
745            // sees is the difference between a redirect and a broken terminal.
746            assert_eq!(r.unwrap_err().raw_os_error(), Some(libc::ENOTTY));
747        }
748    }
749
750    /// The name of the test below, as `--exact` wants it.
751    const ON_POLL: &str = "term::tests::a_poll_that_fails_for_anything_but_a_signal_is_an_error";
752
753    /// EINTR is a resize and means "nothing to read"; every other `poll` error
754    /// has to come back as one, or the draw loop spins on a syscall that is
755    /// failing every time and never says so.
756    ///
757    /// `poll_in` polls fd 0 with nfds 1, which nothing outside the process can
758    /// make fail — except that both Linux and macOS reject an nfds above
759    /// RLIMIT_NOFILE with EINVAL, and the soft limit can be dropped to zero.
760    /// That is process-wide and a process that can open nothing is no use to
761    /// the rest of the suite, so it happens in a re-exec of this one test.
762    #[test]
763    fn a_poll_that_fails_for_anything_but_a_signal_is_an_error() {
764        if std::env::var_os("MIRA_POLL_CHILD").is_none() {
765            let out = std::process::Command::new(std::env::current_exe().unwrap())
766                .args(["--exact", ON_POLL, "--nocapture"])
767                .env("MIRA_POLL_CHILD", "1")
768                .output()
769                .unwrap();
770            let err = String::from_utf8_lossy(&out.stderr);
771            assert!(out.status.success(), "{err}");
772            return;
773        }
774        // SAFETY: `rlimit` is two integers, and `getrlimit` writes exactly the
775        // one live struct it is handed.
776        let mut orig: libc::rlimit = unsafe { std::mem::zeroed() };
777        // SAFETY: as above.
778        let read = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut orig) };
779        assert_eq!(read, 0, "getrlimit: {}", io::Error::last_os_error());
780        let none = libc::rlimit {
781            rlim_cur: 0,
782            rlim_max: orig.rlim_max,
783        };
784        // SAFETY: `setrlimit` only reads the struct, for the call's duration.
785        assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &none) }, 0);
786        let got = poll_in(0);
787        // Put it back before anything — the assertion's own panic machinery
788        // included — needs a descriptor. Lowering the soft limit is reversible
789        // precisely because the hard limit above was left alone.
790        // SAFETY: as above.
791        assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &orig) }, 0);
792        assert_eq!(got.unwrap_err().raw_os_error(), Some(libc::EINVAL));
793    }
794
795    fn strip(s: &str) -> String {
796        let mut out = String::new();
797        let mut it = s.chars();
798        while let Some(c) = it.next() {
799            if c == '\x1b' {
800                for c in it.by_ref() {
801                    if c.is_ascii_alphabetic() {
802                        break;
803                    }
804                }
805            } else {
806                out.push(c);
807            }
808        }
809        out
810    }
811
812    /// The name of the test below, as `--exact` wants it.
813    const SELF: &str = "term::tests::the_terminal_half_runs_against_a_real_pty";
814
815    /// Everything in this file that talks to a terminal, against a terminal.
816    ///
817    /// `enter`, `size`, `draw`, `key`, `poll_in` and `restore` all address fd 0
818    /// and fd 1 directly, so the only honest way to run them is on a process
819    /// whose fd 0 and fd 1 are a tty — and it must not be *this* process, whose
820    /// stdin belongs to the test runner and whose termios the runner needs back.
821    #[test]
822    fn the_terminal_half_runs_against_a_real_pty() {
823        if std::env::var_os("MIRA_PTY_CHILD").is_some() {
824            return on_the_pty();
825        }
826        // Destructured rather than held as a `Pty`: the assertions below want
827        // the names in their format strings, and a format argument on its own
828        // line is a region that only a failing run ever executes.
829        let Pty {
830            screen,
831            err,
832            stalled,
833            trailing,
834        } = drive(
835            SELF,
836            &[],
837            &[
838                ("mira-pty-ready", b"\x1b[B"),
839                ("mira-pty-down", b"x"),
840                ("mira-pty-x", b"\x1b"),
841            ],
842            // The restore sequence is waited for, not snapshotted. The child's
843            // last write and the child's exit are not ordered with each other
844            // from over here: the wait returns on the exit, and the drain thread
845            // can still be one `read` behind on the master. Reading the buffer
846            // right then is a race that a Linux runner loses often enough to be
847            // seen.
848            "\x1b[?25h\x1b[?1049l",
849        );
850        // The child signs off by panicking on purpose, so any *other* panic is a
851        // failed assertion and this is where it gets read out.
852        assert!(
853            stalled.is_none() && err.contains("mira-pty-done"),
854            "child stalled at {stalled:?}: {err}\nscreen:\n{screen:?}"
855        );
856        // The frame reached the terminal, cursor-homed and erased behind itself.
857        assert!(screen.contains("\x1b[H"), "{screen:?}");
858        assert!(screen.contains("mira-pty-ready\x1b[K\r\n"), "{screen:?}");
859        // ...and the panic hook handed the terminal back: cursor on, alternate
860        // screen off. Without it the user's shell is left in raw mode.
861        assert!(trailing, "{screen:?}");
862    }
863
864    /// What [`drive`] saw: everything the child wrote to the pty, everything it
865    /// wrote to stderr, the marker it stopped answering at if it stopped at one,
866    /// and whether the trailing marker ever arrived.
867    pub(crate) struct Pty {
868        pub screen: String,
869        pub err: String,
870        pub stalled: Option<&'static str>,
871        pub trailing: bool,
872    }
873
874    /// Run one test of this binary on the far side of a real pty.
875    ///
876    /// Open a pty, re-exec this same test binary against the slave side, and
877    /// drive it from the master. The child is the same instrumented binary, so
878    /// its coverage counts; `cargo llvm-cov` merges the profraw it writes.
879    ///
880    /// This is CLAUDE.md's `script -q /dev/null` recipe with the pty opened in
881    /// process, which is what makes it a test rather than a thing to run by hand.
882    /// [`crate::tui`] drives its own event loop through here for the same reason
883    /// this module does: a loop that calls `Term::enter` needs somewhere to enter.
884    pub(crate) fn drive(
885        test: &str,
886        env: &[(&str, &std::ffi::OsStr)],
887        script: &[(&'static str, &'static [u8])],
888        trailing: &str,
889    ) -> Pty {
890        use std::os::fd::FromRawFd;
891        use std::process::{Command, Stdio};
892        use std::sync::{Arc, Mutex};
893
894        // SAFETY: flags by value, no pointer argument.
895        let master = unsafe { libc::posix_openpt(libc::O_RDWR | libc::O_NOCTTY) };
896        assert!(master >= 0, "{}", io::Error::last_os_error());
897        // SAFETY: `master` is the fd `posix_openpt` just returned, proved not
898        // -1 by the assert above and closed by nothing until `from_raw_fd`
899        // below; both calls take it by value.
900        assert_eq!(unsafe { libc::grantpt(master) }, 0);
901        // SAFETY: as above.
902        assert_eq!(unsafe { libc::unlockpt(master) }, 0);
903        // SAFETY: `ptsname` returns a NUL-terminated string in a static buffer,
904        // non-null because `master` is a pty master — `grantpt` returned 0 on
905        // it one line up, which it does not for anything else. The buffer is
906        // clobbered by the next `ptsname` on any thread, so the `CStr` is copied
907        // into an owned `String` in this same expression and never held.
908        let slave_path = unsafe { std::ffi::CStr::from_ptr(libc::ptsname(master)) }
909            .to_str()
910            .unwrap()
911            .to_owned();
912
913        let slave = std::fs::OpenOptions::new()
914            .read(true)
915            .write(true)
916            .open(&slave_path)
917            .unwrap();
918        // A size the child can assert on, so `size` is checked against something
919        // it cannot have made up. Set on the slave: macOS refuses TIOCSWINSZ on
920        // a master with no slave open yet.
921        let ws = libc::winsize {
922            ws_row: 40,
923            ws_col: 120,
924            ws_xpixel: 0,
925            ws_ypixel: 0,
926        };
927        let fd = std::os::fd::AsRawFd::as_raw_fd(&slave);
928        // SAFETY: TIOCSWINSZ is the request whose variadic argument is
929        // `*const winsize`, which is what `&ws` is, and `fd` is borrowed from
930        // `slave`, still open here — it is dropped after the spawn.
931        let sized = unsafe { libc::ioctl(fd, libc::TIOCSWINSZ as _, &ws) };
932        assert_eq!(sized, 0, "TIOCSWINSZ: {}", io::Error::last_os_error());
933        let child = Command::new(std::env::current_exe().unwrap())
934            .args(["--exact", test, "--nocapture"])
935            .env("MIRA_PTY_CHILD", "1")
936            .envs(env.iter().copied())
937            .stdin(Stdio::from(slave.try_clone().unwrap()))
938            .stdout(Stdio::from(slave.try_clone().unwrap()))
939            // Not the pty: the child's failures have to come back over a channel
940            // the child cannot also be scribbling frames onto.
941            .stderr(Stdio::piped())
942            .spawn()
943            .unwrap();
944        drop(slave);
945
946        // SAFETY: `from_raw_fd` takes ownership, and this is the only owner
947        // `master` ever gets — it is still open (nothing has closed it since
948        // `posix_openpt`), and the child holds dups of the *slave*, not this fd.
949        // `rd` and its `try_clone` are now what will close it.
950        let mut rd = unsafe { std::fs::File::from_raw_fd(master) };
951        let mut wr = rd.try_clone().unwrap();
952        let screen = Arc::new(Mutex::new(String::new()));
953        let sink = screen.clone();
954        // Drained on a thread: a child that fills the pty buffer while this side
955        // is blocked writing to it is a deadlock, not a slow test.
956        std::thread::spawn(move || {
957            let mut buf = [0u8; 4096];
958            while let Ok(n) = rd.read(&mut buf) {
959                if n == 0 {
960                    break;
961                }
962                sink.lock()
963                    .unwrap()
964                    .push_str(&String::from_utf8_lossy(&buf[..n]));
965            }
966        });
967
968        let arrived = |marker: &str| {
969            (0..600).any(|_| {
970                if screen.lock().unwrap().contains(marker) {
971                    return true;
972                }
973                std::thread::sleep(std::time::Duration::from_millis(10));
974                false
975            })
976        };
977
978        // Every keystroke waits for the marker the child draws once it has
979        // consumed the previous one. Not politeness: a lone Esc only resolves as
980        // Esc because the 20ms grace period finds no byte behind it, so an Esc
981        // written while `x` is still in the buffer arrives as Alt+x.
982        let mut child = child;
983        let stalled = script
984            .iter()
985            .find(|(marker, keys)| !arrived(marker) || wr.write_all(keys).is_err())
986            .map(|(marker, _)| *marker);
987        // Otherwise it sits in `poll_in(-1)` for the rest of the afternoon. One
988        // expression rather than an `if`, so the healthy run — the only one CI
989        // ever takes — still executes the cleanup line.
990        let _ = stalled.map(|_| child.kill());
991
992        let out = child.wait_with_output().unwrap();
993        let trailing = arrived(trailing);
994        Pty {
995            screen: screen.lock().unwrap().clone(),
996            err: String::from_utf8_lossy(&out.stderr).into_owned(),
997            stalled,
998            trailing,
999        }
1000    }
1001
1002    /// The child of the test above. Panics are the failure channel: they land on
1003    /// the piped stderr and the exit status carries them back.
1004    fn on_the_pty() {
1005        let mut t = Term::enter().expect("stdin and stdout are the pty slave");
1006        assert_eq!(t.size(), (120, 40), "the size the parent set");
1007
1008        // A tty nobody sized — a bare `posix_openpt`, `script -q /dev/null` on
1009        // some runners — answers TIOCGWINSZ with zeros rather than failing, and
1010        // a frame laid out for zero columns is a blank screen. 80x24 instead.
1011        let sizes = [(0, 0), (40, 120)].map(|(ws_row, ws_col)| libc::winsize {
1012            ws_row,
1013            ws_col,
1014            ws_xpixel: 0,
1015            ws_ypixel: 0,
1016        });
1017        for (ws, want) in sizes.iter().zip([(80, 24), (120, 40)]) {
1018            // SAFETY: TIOCSWINSZ's variadic argument is `*const winsize`, which
1019            // is what `ws` is, and fd 1 is the pty slave — the same fd `size`
1020            // reads back through. Setting it back afterwards is what leaves the
1021            // rest of this function at the size the parent asked for.
1022            let rc = unsafe { libc::ioctl(1, libc::TIOCSWINSZ as _, ws) };
1023            assert_eq!(rc, 0, "TIOCSWINSZ: {}", io::Error::last_os_error());
1024            assert_eq!(t.size(), want, "{} columns", ws.ws_col);
1025        }
1026
1027        // Before the first marker, so the parent provably has not typed yet: the
1028        // timeout expires and says so, rather than returning a key nobody
1029        // pressed. After a marker it would be a race the parent usually wins.
1030        assert_eq!(t.key(30).unwrap(), None, "empty poll");
1031
1032        // A resize has to break an *infinite* poll, or the frame is never
1033        // repainted at the new size. Aimed at this exact thread: plain `kill`
1034        // is free to hand the signal to the test harness's thread, which is not
1035        // the one parked in `poll`.
1036        struct Tid(libc::pthread_t);
1037        // Handing a thread handle to the thread that will signal with it is the
1038        // only reason this type exists.
1039        // SAFETY: `pthread_t` is a pointer on macOS, which is the only reason
1040        // `Tid` is not `Send` already. The receiving thread does one thing with
1041        // it — `pthread_kill`, which POSIX requires to be callable from any
1042        // thread — and the handle cannot dangle, because this thread is parked
1043        // in the `t.key(-1)` below until the signal it sends arrives. Delete
1044        // that call and the id can outlive its thread.
1045        unsafe impl Send for Tid {}
1046        // SAFETY: `pthread_self` reads no memory and cannot fail.
1047        let me = Tid(unsafe { libc::pthread_self() });
1048        std::thread::spawn(move || {
1049            std::thread::sleep(std::time::Duration::from_millis(30));
1050            // SAFETY: `me.0` names the thread that is blocked in `poll` waiting
1051            // for this — see the `unsafe impl` above for why it is still alive.
1052            // SIGWINCH has a handler by now (`Term::enter` installed it), so
1053            // delivery interrupts that poll instead of killing the process.
1054            unsafe { libc::pthread_kill(me.0, libc::SIGWINCH) };
1055        });
1056        assert_eq!(t.key(-1).unwrap(), None, "a resize interrupts the poll");
1057
1058        // SIGTERM and SIGHUP are inspected rather than raised: they end the
1059        // process, and a child that dies of a signal writes no coverage profile
1060        // and reports nothing back. The failure that actually happened — no
1061        // handler at all, so a `kill` left the shell in raw mode — is visible
1062        // from the disposition. `SA_RESTART` is not asserted on: see
1063        // [`on_signal`] for why the flag has no bearing on either syscall in
1064        // this loop, and the wake-up above for the behaviour that does matter.
1065        for sig in [libc::SIGWINCH, libc::SIGTERM, libc::SIGHUP] {
1066            // SAFETY: POD and all-zero is a valid `sigaction`, as in
1067            // [`on_signal`]; here it is only a destination.
1068            let mut sa: libc::sigaction = unsafe { std::mem::zeroed() };
1069            // SAFETY: a null `act` means "report the disposition without
1070            // changing it", so the only pointer that has to be good is `&mut
1071            // sa`, a live and correctly typed `sigaction`.
1072            let got = unsafe { libc::sigaction(sig, std::ptr::null(), &mut sa) };
1073            assert_eq!(got, 0, "reading the disposition of {sig}");
1074            assert!(
1075                sa.sa_sigaction != libc::SIG_DFL && sa.sa_sigaction != libc::SIG_IGN,
1076                "signal {sig} has no handler"
1077            );
1078        }
1079
1080        // Each marker is both a frame to assert on and the parent's cue to send
1081        // the next key. See the write loop above for why they interleave.
1082        t.draw(&["mira-pty-ready".into(), "second row".into()])
1083            .unwrap();
1084        assert_eq!(t.key(-1).unwrap(), Some(Key::Down));
1085        t.draw(&["mira-pty-down".into()]).unwrap();
1086        assert_eq!(t.key(-1).unwrap(), Some(Key::Char('x')));
1087        t.draw(&["mira-pty-x".into()]).unwrap();
1088        // The lone Esc, which only resolves because the grace period ran out.
1089        assert_eq!(t.key(-1).unwrap(), Some(Key::Esc));
1090
1091        // Two shapes a real terminal will not produce on demand, injected last
1092        // because both of them break the pty this test is standing on.
1093        //
1094        // A window with no size: `size` reads it back off a *successful* ioctl,
1095        // and zero columns is what a detached or not-yet-sized tty answers.
1096        // Every column computation in `tui.rs` divides by that number.
1097        let zero = libc::winsize {
1098            ws_row: 0,
1099            ws_col: 0,
1100            ws_xpixel: 0,
1101            ws_ypixel: 0,
1102        };
1103        // SAFETY: as the parent's TIOCSWINSZ above — `*const winsize` is the
1104        // argument this request takes, and fd 1 is this process's pty slave.
1105        let sized = unsafe { libc::ioctl(1, libc::TIOCSWINSZ as _, &zero) };
1106        assert_eq!(sized, 0, "TIOCSWINSZ 0x0: {}", io::Error::last_os_error());
1107        assert_eq!(t.size(), (80, 24), "no size is 80x24, not 0x0");
1108
1109        // EOF on stdin, which `read` reports as zero bytes on a `poll` that says
1110        // there is something there. Reported as ^C because that is the only
1111        // answer that ends the draw loop — anything else spins on a descriptor
1112        // that is ready forever.
1113        let devnull = std::fs::File::open("/dev/null").unwrap();
1114        let null_fd = std::os::fd::AsRawFd::as_raw_fd(&devnull);
1115        // SAFETY: both arguments are open descriptors this process owns. `dup2`
1116        // closes fd 0 and duplicates `null_fd` onto it; nothing reads fd 0
1117        // after this but the `key` below.
1118        assert_eq!(unsafe { libc::dup2(null_fd, 0) }, 0);
1119        assert_eq!(t.key(0).unwrap(), Some(Key::Ctrl('c')), "EOF is a quit");
1120
1121        // A panic, on purpose, as the last act: the promise `enter` makes by
1122        // installing a hook is that a crash still gives you your terminal back,
1123        // and the only way to check it is to crash. The parent looks for this
1124        // exact message, so a real assertion failure above still reads as one.
1125        panic!("mira-pty-done");
1126    }
1127}