Skip to main content

max / shop

Shop --record flag: tee PTY output for testkit capture Adds `Pty::set_recorder(path)` in shop-pty and a `--record <path>` CLI arg in shop's main. When set, every successful read of PTY bytes is teed byte-for-byte to the file before returning. Recorder write errors are swallowed so a debug-capture hiccup can't kill an interactive session. Usage: shop --exec 'yazi ~/Pictures' --record /tmp/yazi.bytes capture-apc yazi ../kitty-graphics-testkit/tests/fixtures/ \\ < /tmp/yazi.bytes Unblocks the kitty-graphics-testkit corpus.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 15:46 UTC
Signed with PGP, not checked
Commit: 6752cceb38021818c3fdf053567b0915d26b0fb9
Parent: 7cc692a
2 files changed, +39 insertions, -3 deletions
@@ -10,8 +10,10 @@
10 10 //! corcovado (mio 0.6) coupling entirely.
11 11
12 12 use std::ffi::CString;
13 - use std::io;
13 + use std::fs::File;
14 + use std::io::{self, Write};
14 15 use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
16 + use std::path::Path;
15 17
16 18 use nix::fcntl::{FcntlArg, OFlag, fcntl};
17 19 use nix::libc;
@@ -44,6 +46,7 @@
44 46 pub struct Pty {
45 47 master: OwnedFd,
46 48 child: Pid,
49 + recorder: Option<File>,
47 50 }
48 51
49 52 impl Pty {
@@ -75,6 +78,7 @@
75 78 Ok(Self {
76 79 master: pair.master,
77 80 child,
81 + recorder: None,
78 82 })
79 83 }
80 84 ForkResult::Child => {
@@ -104,8 +108,30 @@
104 108
105 109 /// Nonblocking read. Returns `Ok(0)` on EOF, `WouldBlock` when there's
106 110 /// nothing to read yet.
111 + ///
112 + /// If a recorder is attached (see [`set_recorder`](Self::set_recorder)),
113 + /// bytes read here are teed to it byte-for-byte before returning.
114 + /// Recorder write errors are swallowed — the terminal must not die
115 + /// because a debug capture failed.
107 116 pub fn read(&self, buf: &mut [u8]) -> io::Result<usize> {
108 - nix::unistd::read(&self.master, buf).map_err(io::Error::from)
117 + let n = nix::unistd::read(&self.master, buf).map_err(io::Error::from)?;
118 + if n > 0 {
119 + if let Some(f) = self.recorder.as_ref() {
120 + let _ = (&*f).write_all(&buf[..n]);
121 + }
122 + }
123 + Ok(n)
124 + }
125 +
126 + /// Tee every subsequent successful [`read`](Self::read) to `path`. The
127 + /// file is created (or truncated if it exists) up front, so a bad path
128 + /// surfaces here rather than mid-session.
129 + ///
130 + /// Meant for capturing the PTY byte stream for feeding into the
131 + /// `kitty-graphics-testkit` corpus. See `capture-apc`.
132 + pub fn set_recorder(&mut self, path: impl AsRef<Path>) -> io::Result<()> {
133 + self.recorder = Some(File::create(path)?);
134 + Ok(())
109 135 }
110 136
111 137 /// Write bytes to the shell (e.g. keystrokes).
@@ -75,6 +75,12 @@
75 75 .iter()
76 76 .position(|a| a == "--exec")
77 77 .and_then(|i| args.get(i + 1).cloned());
78 + // `--record PATH` tees the PTY output byte-for-byte to PATH, for
79 + // feeding into the kitty-graphics-testkit corpus via capture-apc.
80 + let record_path = args
81 + .iter()
82 + .position(|a| a == "--record")
83 + .and_then(|i| args.get(i + 1).cloned());
78 84 let (spawn_cmd, spawn_args): (String, Vec<String>) = match &exec_cmd {
79 85 Some(cmd) => ("/bin/sh".into(), vec!["-c".into(), cmd.clone()]),
80 86 None => (
@@ -85,7 +91,7 @@
85 91 let spawn_args_refs: Vec<&str> = spawn_args.iter().map(String::as_str).collect();
86 92 let cols_initial = grid_cols(INITIAL.0);
87 93 let rows_initial = grid_rows(INITIAL.1);
88 - let pty = Pty::spawn(
94 + let mut pty = Pty::spawn(
89 95 &spawn_cmd,
90 96 &spawn_args_refs,
91 97 PtySize {
@@ -96,6 +102,10 @@
96 102 },
97 103 "xterm-256color",
98 104 )?;
105 + if let Some(path) = &record_path {
106 + pty.set_recorder(path)?;
107 + info!(path = %path, "recording PTY output");
108 + }
99 109 info!(spawn = %spawn_cmd, "spawned child");
100 110
101 111 let conn = Connection::connect_to_env()?;