mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 16:02:24 +00:00
The CLI's own --help calls it "built for coding agents", but `capture` handed back the daemon's raw PTY bytes, which is the least readable thing it emits, and every verb panicked when its reader hung up. `capture --plain` replays those bytes through a terminal grid instead of stripping escapes from them, using the same alacritty_terminal rev the GUI renders panes with. The difference is not cosmetic: only the grid knows that a break at the pane's width was a wrap rather than a newline, that a CR meant "overwrite this line" rather than "end it", and which cell a wide char shares with its spacer. A regex gets the easy 90% and then invents the rest — on one real pane it turned 1193 lines into 2806. The size each segment needs comes for free: the daemon already sends DaemonMsg::Size right before every Snapshot, and the CLI was discarding it. Panes here measure 249 and 86 columns, so the hardcoded 120 would have wrapped both in the wrong places. Observing still resizes nothing. The pipe fix is two mechanisms with one contract. On Unix SIGPIPE goes back to its default disposition, which covers every write site at once and ends the process the way it ends `cat` (141). Windows has no such signal, so stdio::out recognizes the hung-up write and leaves quietly. Before this, 16 of 19 verbs printed a panic and a backtrace note for `tty7 ls | head -1`; `run` instead reported it as a failure with exit 1. Also adds skills/tty7, the Claude skill for driving this CLI. It shipped with a Python ANSI stripper, which is what prompted --plain; the script is gone. alacritty_terminal moves to [workspace.dependencies] so the GUI and the CLI cannot drift onto two revs of the fork.
77 lines
3.4 KiB
Rust
77 lines
3.4 KiB
Rust
//! Stdout for a command that lives inside pipelines.
|
|
//!
|
|
//! A reader that hangs up early — `| head -1`, `| grep -q`, PowerShell's
|
|
//! `Select-Object -First` — is how a pipeline ends, not a failure. Rust makes
|
|
//! that awkward twice over: it ignores SIGPIPE at startup so the doomed write
|
|
//! comes back as an `io::Error` instead, and `println!` turns that error into a
|
|
//! panic. `tty7 capture %1 | head -1` therefore printed a panic and a backtrace
|
|
//! note where `cat` would have exited without a word — noise on stderr for a
|
|
//! correct invocation, from a CLI whose whole point is being driven by scripts
|
|
//! and agents.
|
|
//!
|
|
//! Two mechanisms, one contract:
|
|
//!
|
|
//! - On Unix [`end_pipelines_quietly`] hands the job back to the kernel. That
|
|
//! covers every write site at once, including ones added later, and gives
|
|
//! callers the ending they already know from `cat` (shells report 141).
|
|
//! - Windows has no SIGPIPE — the write fails with `ERROR_NO_DATA` instead — so
|
|
//! [`out`] is the stand-in that recognizes the hang-up and leaves quietly.
|
|
//!
|
|
//! Which is why every stdout write in this binary goes through [`out`] or
|
|
//! [`line`]: the `print!` family has no way to express "the reader left".
|
|
|
|
use std::io::Write as _;
|
|
|
|
/// Exit code once the reader is gone. Unix rarely gets here — SIGPIPE has
|
|
/// already killed the process, and shells render that as 141 — so this is
|
|
/// Windows's answer, and Windows has no signal convention to imitate. Success
|
|
/// is the honest report: everything the reader asked for did reach it.
|
|
const READER_GONE: i32 = 0;
|
|
|
|
/// Let a hung-up pipe end this process the way it ends `cat`.
|
|
///
|
|
/// Called once, before anything is written, so no output can be lost to it.
|
|
#[cfg(unix)]
|
|
pub fn end_pipelines_quietly() {
|
|
// SAFETY: setting a signal disposition is safe from single-threaded startup
|
|
// code, and SIG_DFL is the disposition every other process starts with —
|
|
// Rust's runtime is the thing that changed it.
|
|
unsafe {
|
|
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
|
|
}
|
|
}
|
|
|
|
/// No-op: Windows has no SIGPIPE, so [`out`] carries the whole contract there.
|
|
#[cfg(not(unix))]
|
|
pub fn end_pipelines_quietly() {}
|
|
|
|
/// Write to stdout, leaving quietly if the reader has hung up.
|
|
///
|
|
/// Exiting from this depth is deliberate — it is what the signal does on Unix,
|
|
/// and it keeps every call site a plain statement. A `run` cut short this way
|
|
/// leaves its pane behind, same as any other interrupted `run`.
|
|
pub fn out(bytes: &[u8]) {
|
|
let mut stdout = std::io::stdout().lock();
|
|
// Flush here rather than at exit: `std::process::exit` runs no destructors,
|
|
// and stdout is a LineWriter, so anything not ending in a newline would be
|
|
// dropped on the floor.
|
|
if let Err(e) = stdout.write_all(bytes).and_then(|()| stdout.flush()) {
|
|
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
|
std::process::exit(READER_GONE);
|
|
}
|
|
// A stdout that failed for any other reason (full disk, closed fd) is
|
|
// worth saying out loud — the caller is missing output either way, and
|
|
// silence would make it look like there was none.
|
|
eprintln!("tty7: writing to stdout: {e}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
/// One line and its newline, in a single write.
|
|
pub fn line(text: &str) {
|
|
let mut buf = String::with_capacity(text.len() + 1);
|
|
buf.push_str(text);
|
|
buf.push('\n');
|
|
out(buf.as_bytes());
|
|
}
|