From 86eba1e2c2a2751fa705ff699d769379229bc4fc Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:30:09 +0800 Subject: [PATCH] feat(cli): make a captured pane readable, and stop panicking on a closed pipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 1 + Cargo.toml | 53 +++--- crates/tty7-cli/Cargo.toml | 8 + crates/tty7-cli/src/backend.rs | 28 ++- crates/tty7-cli/src/backend/real.rs | 37 ++-- crates/tty7-cli/src/cli.rs | 37 +++- crates/tty7-cli/src/commands.rs | 82 ++++++++- crates/tty7-cli/src/main.rs | 12 +- crates/tty7-cli/src/screen.rs | 257 ++++++++++++++++++++++++++++ crates/tty7-cli/src/stdio.rs | 76 ++++++++ crates/tty7-cli/tests/cli_e2e.rs | 108 +++++++++++- skills/tty7/SKILL.md | 240 ++++++++++++++++++++++++++ skills/tty7/references/commands.md | 247 ++++++++++++++++++++++++++ 13 files changed, 1125 insertions(+), 61 deletions(-) create mode 100644 crates/tty7-cli/src/screen.rs create mode 100644 crates/tty7-cli/src/stdio.rs create mode 100644 skills/tty7/SKILL.md create mode 100644 skills/tty7/references/commands.md diff --git a/Cargo.lock b/Cargo.lock index 9c27262b..dfd9967c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9693,6 +9693,7 @@ dependencies = [ name = "tty7-cli" version = "26.7.6" dependencies = [ + "alacritty_terminal", "anyhow", "clap", "libc", diff --git a/Cargo.toml b/Cargo.toml index b8d5908a..d9e56bda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,29 +91,9 @@ image = "0.25" # to gpui's rev so the shared `http_client`/`zed-reqwest` versions stay aligned. reqwest_client = { git = "https://github.com/zed-industries/zed", rev = "1d217ee39d381ac101b7cf49d3d22451ac1093fe" } -# Our fork of Zed's alacritty_terminal fork. Used by the *client* -# (`terminal::remote`) for the VT parser + grid (`Term`/`ansi::Processor`) that -# renders the mirror. The daemon's PTY itself is driven by `portable-pty` below. -# -# The `tty7` branch is Zed's `fcf32fe` (the rev Zed pins) plus two commits, both -# still missing from alacritty master as of 852e971: -# -# 1. `push_keyboard_mode` capped its stack by removing from `title_stack` instead -# of `keyboard_mode_stack` — a copy-paste slip from `push_title` that compiles -# because both are `Vec`s. With `kitty_keyboard` on (see -# `terminal_config_from_user`) every overflowing push silently drops a saved -# title, and once the title stack is empty `Vec::remove(0)` panics — 4097 -# unpopped `CSI > 1 u` pushes, about 20KB of output, kill the reader thread and -# freeze the pane. -# 2. `input` reserves columns per `char`, so an emoji written as base + U+FE0F -# (`❤️`, `🗂️`, `⚠️` — any base whose East Asian Width is Neutral) gets one -# column instead of two and shifts the rest of the line left by one. The fork -# re-scores the sequence with `UnicodeWidthStr` and widens the cell (issue -# #203). -# -# Each patch has a guard test in `src/terminal/remote.rs`; drop this fork once -# both land upstream. -alacritty_terminal = { git = "https://github.com/l0ng-ai/alacritty", rev = "b79e70484b13308ce766a763531ac25a8302c012" } +# See `[workspace.dependencies]`: the pin is shared with tty7-cli, which parses +# `capture` output through the same grid. +alacritty_terminal.workspace = true # Desktop notifications driven by OSC 9 / OSC 777 escape sequences. Cross-platform; # the macOS backend uses the deprecated NSUserNotification (weak — a completion @@ -247,6 +227,33 @@ gpui = { git = "https://github.com/zed-industries/zed", rev = "1d217ee39d381ac10 # package manifest, so they never reach the Windows/macOS builds. gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "1d217ee39d381ac101b7cf49d3d22451ac1093fe", features = ["font-kit", "runtime_shaders"] } +# Our fork of Zed's alacritty_terminal fork: the VT parser + grid +# (`Term`/`ansi::Processor`). Two packages read it and they must agree, hence +# this being a workspace pin rather than one each — the GUI (`terminal::remote`) +# to render the live mirror, and tty7-cli to turn a captured pane back into text +# (`tty7 capture --plain`). The daemon parses nothing; it is a byte pipe, and its +# PTY is driven by `portable-pty`. +# +# The `tty7` branch is Zed's `fcf32fe` (the rev Zed pins) plus two commits, both +# still missing from alacritty master as of 852e971: +# +# 1. `push_keyboard_mode` capped its stack by removing from `title_stack` instead +# of `keyboard_mode_stack` — a copy-paste slip from `push_title` that compiles +# because both are `Vec`s. With `kitty_keyboard` on (see +# `terminal_config_from_user`) every overflowing push silently drops a saved +# title, and once the title stack is empty `Vec::remove(0)` panics — 4097 +# unpopped `CSI > 1 u` pushes, about 20KB of output, kill the reader thread and +# freeze the pane. +# 2. `input` reserves columns per `char`, so an emoji written as base + U+FE0F +# (`❤️`, `🗂️`, `⚠️` — any base whose East Asian Width is Neutral) gets one +# column instead of two and shifts the rest of the line left by one. The fork +# re-scores the sequence with `UnicodeWidthStr` and widens the cell (issue +# #203). +# +# Each patch has a guard test in `src/terminal/remote.rs`; drop this fork once +# both land upstream. +alacritty_terminal = { git = "https://github.com/l0ng-ai/alacritty", rev = "b79e70484b13308ce766a763531ac25a8302c012" } + anyhow = "1" log = "0.4" serde = { version = "1.0.219", features = ["derive"] } diff --git a/crates/tty7-cli/Cargo.toml b/crates/tty7-cli/Cargo.toml index 64031917..72ba1ce5 100644 --- a/crates/tty7-cli/Cargo.toml +++ b/crates/tty7-cli/Cargo.toml @@ -33,6 +33,14 @@ clap = { version = "4", features = ["derive"] } # every column after it out of line. Already in the tree via alacritty. unicode-width = "0.2" +# The VT parser behind `capture --plain`. A pane's replay is a byte stream, and +# recovering the text from it is a terminal's job, not a regex's: only the grid +# knows that a break at column 120 was a wrap rather than a newline, that a CR +# meant "overwrite this line", or which cell a wide char and its spacer share. +# The same crate the GUI renders with, at the same rev, so the two agree about +# what a pane says. +alacritty_terminal.workspace = true + [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/tty7-cli/src/backend.rs b/crates/tty7-cli/src/backend.rs index 51b3b90f..5733cf39 100644 --- a/crates/tty7-cli/src/backend.rs +++ b/crates/tty7-cli/src/backend.rs @@ -1,12 +1,25 @@ use anyhow::Result; use tty7_core::core::session::WorkspaceId; use tty7_core::daemon::control::{ControlEvent, ControlHelloOk, ControlRequest, ReplyOk}; -use tty7_core::daemon::protocol::{PaneInfo, PaneProcs}; +use tty7_core::daemon::protocol::{PaneInfo, PaneProcs, WinSize}; mod real; pub use real::RealBackend; +/// One replayed snapshot together with the size it was recorded at. +/// +/// The daemon's scrollback ring starts a new segment on every resize and sends +/// `Size` immediately before each `Snapshot`, so the two always arrive paired. +/// Keeping them paired is what lets `capture --plain` replay a segment through a +/// grid of the right width — parse 203 columns of output at 120 and every wrap +/// lands in the wrong place. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CaptureSegment { + pub size: WinSize, + pub bytes: Vec, +} + #[derive(Debug, Clone, PartialEq)] pub struct RunSpec { pub workspace: Option, @@ -24,7 +37,8 @@ pub trait Backend { fn send_input(&mut self, pane: u64, bytes: Vec) -> Result<()>; - fn capture(&mut self, pane: u64, scrollback: bool) -> Result; + /// The pane's replay: the newest segment, or every one with `scrollback`. + fn capture(&mut self, pane: u64, scrollback: bool) -> Result>; fn procs(&mut self, pane: u64) -> Result; @@ -56,7 +70,7 @@ pub mod mock { }; use tty7_core::daemon::protocol::{PROTOCOL_VERSION, PaneInfo, PaneProcs}; - use super::{Backend, RunSpec}; + use super::{Backend, CaptureSegment, RunSpec}; pub struct MockBackend { pub machine: Machine, @@ -66,7 +80,7 @@ pub mod mock { pub next_spawn_id: u64, pub sent: Vec<(u64, Vec)>, pub captured: Vec<(u64, bool)>, - pub capture_text: String, + pub capture_segments: Vec, pub procs_calls: Vec, pub procs_reply: PaneProcs, pub registry: Vec, @@ -86,7 +100,7 @@ pub mod mock { next_spawn_id: 6, sent: Vec::new(), captured: Vec::new(), - capture_text: String::new(), + capture_segments: Vec::new(), procs_calls: Vec::new(), procs_reply: PaneProcs::default(), registry: Vec::new(), @@ -141,9 +155,9 @@ pub mod mock { Ok(()) } - fn capture(&mut self, pane: u64, scrollback: bool) -> Result { + fn capture(&mut self, pane: u64, scrollback: bool) -> Result> { self.captured.push((pane, scrollback)); - Ok(self.capture_text.clone()) + Ok(self.capture_segments.clone()) } fn procs(&mut self, pane: u64) -> Result { diff --git a/crates/tty7-cli/src/backend/real.rs b/crates/tty7-cli/src/backend/real.rs index 437ec9d6..b848f43e 100644 --- a/crates/tty7-cli/src/backend/real.rs +++ b/crates/tty7-cli/src/backend/real.rs @@ -1,4 +1,3 @@ -use std::io::Write as _; use std::path::PathBuf; use std::time::Duration; @@ -12,7 +11,7 @@ use tty7_core::daemon::control::{ use tty7_core::daemon::protocol::{DaemonMsg, PaneInfo, PaneProcs, ShellSpec, WinSize}; use tty7_core::daemon::router::RouteTarget; -use super::{Backend, RunSpec}; +use super::{Backend, CaptureSegment, RunSpec}; const SESSION_SIZE: WinSize = WinSize { cols: 120, @@ -146,7 +145,7 @@ impl Backend for RealBackend { Ok(()) } - fn capture(&mut self, pane: u64, scrollback: bool) -> Result { + fn capture(&mut self, pane: u64, scrollback: bool) -> Result> { let mut session = self .pane_client()? .observe(pane, SESSION_SIZE) @@ -156,11 +155,21 @@ impl Backend for RealBackend { // macOS). That must not turn a completed capture into an error — the // replay we already collected is the answer. let _ = session.set_recv_timeout(Some(REPLAY_FIRST_WAIT)); - let mut snapshots: Vec> = Vec::new(); + // The daemon replays each ring segment as `Size` then `Snapshot`, so the + // last size seen is the one the next snapshot was recorded at. Observing + // does not resize anything — the daemon ignores the size we asked with — + // so `SESSION_SIZE` is only the stand-in for a server too old to have + // sent one. + let mut segments: Vec = Vec::new(); + let mut size = SESSION_SIZE; loop { match session.recv() { + Ok(DaemonMsg::Size(seen)) => { + size = seen; + let _ = session.set_recv_timeout(Some(REPLAY_SETTLE)); + } Ok(DaemonMsg::Snapshot(bytes)) => { - snapshots.push(bytes); + segments.push(CaptureSegment { size, bytes }); let _ = session.set_recv_timeout(Some(REPLAY_SETTLE)); } Ok(DaemonMsg::Output(_)) | Ok(DaemonMsg::Exited { .. }) => break, @@ -173,12 +182,11 @@ impl Backend for RealBackend { } } let _ = session.detach(); - let bytes: Vec = if scrollback { - snapshots.concat() - } else { - snapshots.pop().unwrap_or_default() - }; - Ok(String::from_utf8_lossy(&bytes).into_owned()) + if !scrollback { + // Only the newest segment, which is the one holding the screen. + segments.drain(..segments.len().saturating_sub(1)); + } + Ok(segments) } fn procs(&mut self, pane: u64) -> Result { @@ -234,12 +242,13 @@ impl Backend for RealBackend { .running .take() .ok_or_else(|| anyhow!("run_wait without a spawned command"))?; - let mut stdout = std::io::stdout().lock(); let code = loop { match session.recv() { Ok(DaemonMsg::Output(bytes)) | Ok(DaemonMsg::Snapshot(bytes)) => { - stdout.write_all(&bytes)?; - stdout.flush()?; + // Not `stdout.write_all`: a caller who stopped reading + // (`tty7 run -- … | head`) must end the pipeline, not turn + // into an error about the command we were streaming. + crate::stdio::out(&bytes); } Ok(DaemonMsg::Exited { code }) => break code, Ok(_) => {} diff --git a/crates/tty7-cli/src/cli.rs b/crates/tty7-cli/src/cli.rs index 52c10084..22f9ddb3 100644 --- a/crates/tty7-cli/src/cli.rs +++ b/crates/tty7-cli/src/cli.rs @@ -67,8 +67,9 @@ pub enum Command { Send(SendArgs), #[command( - about = "Print a pane's output with its ANSI escapes intact, decoded as UTF-8 \ - (invalid bytes become U+FFFD): the newest scrollback segment by default" + about = "Print a pane's output — as text with `--plain`, otherwise with its ANSI \ + escapes intact, decoded as UTF-8 (invalid bytes become U+FFFD): the \ + newest scrollback segment by default" )] Capture(CaptureArgs), @@ -197,11 +198,18 @@ pub struct CaptureArgs { #[arg( long, - help = "Print the whole scrollback ring, raw ANSI bytes; the ring splits into \ - segments on resize, and without this flag only the last segment is \ - printed (for a never-resized pane the two are identical)" + help = "Print the whole scrollback ring; the ring splits into segments on resize, \ + and without this flag only the last segment is printed (for a \ + never-resized pane the two are identical)" )] pub scrollback: bool, + + #[arg( + long, + help = "Replay the output through a terminal and print the resulting text: no \ + escapes, wrapped lines rejoined, overwrites and cursor moves applied" + )] + pub plain: bool, } #[derive(Debug, Subcommand)] @@ -617,9 +625,28 @@ mod tests { panic!("capture did not parse"); }; assert!(args.scrollback); + assert!(!args.plain, "raw bytes stay the default"); assert_eq!(args.target.as_deref(), Some("%3")); } + #[test] + fn capture_plain_is_its_own_flag_and_composes_with_scrollback() { + // The two answer different questions — how much to replay, and whether + // to replay it through a grid — so neither implies or excludes the other. + let Some(Command::Capture(args)) = parse(&["tty7", "capture", "--plain"]).command else { + panic!("capture did not parse"); + }; + assert!(args.plain && !args.scrollback); + assert!(args.target.is_none(), "the pane comes from $TTY7_PANE"); + + let Some(Command::Capture(args)) = + parse(&["tty7", "capture", "%3", "--plain", "--scrollback"]).command + else { + panic!("capture did not parse"); + }; + assert!(args.plain && args.scrollback); + } + #[test] fn usage_errors_exit_with_code_2() { let err = Cli::try_parse_from(["tty7", "ws", "frobnicate"]).unwrap_err(); diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index 10b59107..386a1c74 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -13,6 +13,7 @@ use crate::cli::{ }; use crate::output; use crate::resolve; +use crate::screen; #[derive(Debug)] pub struct Report { @@ -386,7 +387,16 @@ fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result Result { let pane = address::pane_or_context(args.target.as_deref(), ctx)?; - let text = backend.capture(pane, args.scrollback)?; + let segments = backend.capture(pane, args.scrollback)?; + // Raw is the default and stays byte-for-byte what the daemon stored, joined + // in replay order; `--plain` hands the same bytes to a grid instead. Either + // way `--json` carries whatever was printed, so a caller reads one field. + let text = if args.plain { + screen::render(&segments) + } else { + let bytes: Vec = segments.into_iter().flat_map(|s| s.bytes).collect(); + String::from_utf8_lossy(&bytes).into_owned() + }; report(text.clone(), json!({ "pane": pane, "text": text })) } @@ -588,9 +598,9 @@ fn pane_close(target: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> fn events(json_mode: bool, backend: &mut dyn Backend) -> Result { backend.events(&mut |event| { if json_mode { - println!("{}", serde_json::to_string(&event)?); + crate::stdio::line(&serde_json::to_string(&event)?); } else { - println!("{}", event_line(&event)); + crate::stdio::line(&event_line(&event)); } Ok(()) })?; @@ -743,6 +753,20 @@ mod tests { MockBackend::with_machine(two_workspace_machine()) } + /// A replayed snapshot at a 20-column pane, which is narrow enough that the + /// wrapping tests can wrap without pages of fixture. + fn segment(bytes: &[u8]) -> crate::backend::CaptureSegment { + crate::backend::CaptureSegment { + size: tty7_core::daemon::protocol::WinSize { + cols: 20, + rows: 10, + cell_w: 8, + cell_h: 16, + }, + bytes: bytes.to_vec(), + } + } + fn run_cli(args: &[&str], ctx: &Context, backend: &mut MockBackend) -> Outcome { execute(cli(args), ctx, backend).expect("this command should succeed against the mock") } @@ -1227,7 +1251,7 @@ mod tests { #[test] fn capture_and_procs_are_wired_through_the_backend() { let mut backend = mock(); - backend.capture_text = "$ make\nok\n".into(); + backend.capture_segments = vec![segment(b"$ make\r\nok\r\n")]; let out = run_cli( &["tty7", "capture", "%2", "--scrollback"], &Context::default(), @@ -1236,14 +1260,60 @@ mod tests { assert_eq!(backend.captured, vec![(2, true)]); assert_eq!( human(out), - "$ make\nok\n", - "capture prints the pane verbatim" + "$ make\r\nok\r\n", + "without --plain the pane's bytes are passed through untouched" ); run_cli(&["tty7", "procs", "%1"], &Context::default(), &mut backend); assert_eq!(backend.procs_calls, vec![1]); } + #[test] + fn capture_plain_replays_the_bytes_through_a_grid() { + let mut backend = mock(); + // Coloured, CR-overwritten, and wrapped past the 20-column pane: three + // things the raw form shows verbatim and `--plain` has to resolve. + backend.capture_segments = vec![segment( + b"\x1b[32m$ make\x1b[0m\r\n10%\r100%\r\nabcdefghijklmnopqrstuvwxyz\r\n", + )]; + let raw = human(run_cli( + &["tty7", "capture", "%2"], + &Context::default(), + &mut backend, + )); + assert!( + raw.contains("\x1b[32m"), + "the default keeps escapes: {raw:?}" + ); + + let plain = human(run_cli( + &["tty7", "capture", "%2", "--plain"], + &Context::default(), + &mut backend, + )); + assert_eq!(plain, "$ make\n100%\nabcdefghijklmnopqrstuvwxyz"); + } + + #[test] + fn capture_json_carries_whichever_form_was_asked_for() { + let mut backend = mock(); + backend.capture_segments = vec![segment(b"\x1b[31mred\x1b[0m\r\n")]; + let raw = json_of(run_cli( + &["tty7", "capture", "%2"], + &Context::default(), + &mut backend, + )); + assert_eq!(raw["text"], json!("\u{1b}[31mred\u{1b}[0m\r\n")); + + let plain = json_of(run_cli( + &["tty7", "capture", "%2", "--plain"], + &Context::default(), + &mut backend, + )); + assert_eq!(plain["text"], json!("red")); + assert_eq!(plain["pane"], json!(2)); + } + #[test] fn run_passes_the_command_and_its_exit_code_through() { let mut backend = mock(); diff --git a/crates/tty7-cli/src/main.rs b/crates/tty7-cli/src/main.rs index 3418527d..86051837 100644 --- a/crates/tty7-cli/src/main.rs +++ b/crates/tty7-cli/src/main.rs @@ -4,13 +4,17 @@ mod cli; mod commands; mod output; mod resolve; +mod screen; mod server; +mod stdio; #[cfg(test)] mod testbed; use clap::Parser; fn main() -> std::process::ExitCode { + // Before the first byte goes out, and before clap can print a usage error. + stdio::end_pipelines_quietly(); let cli = cli::Cli::parse(); let json = cli.json; let quiet = cli.quiet; @@ -21,10 +25,8 @@ fn main() -> std::process::ExitCode { // ours. The report rides along anyway: --json must not go silent just // because the verb also carries an exit code. Ok(commands::Outcome::Exit(code, report)) => { + // No flush needed before the exit below: `stdio::out` already did. emit(report, json, quiet); - // process::exit runs no destructors and flushes nothing; stdout is - // a LineWriter, so anything not ending in a newline would be lost. - let _ = std::io::Write::flush(&mut std::io::stdout()); std::process::exit(code) } Ok(commands::Outcome::Report(report)) => { @@ -46,10 +48,10 @@ fn emit(report: commands::Report, json: bool, quiet: bool) { } if json { if !report.json.is_null() { - println!("{}", report.json); + stdio::line(&report.json.to_string()); } } else if !report.human.is_empty() { - print!("{}", ensure_newline(report.human)); + stdio::out(ensure_newline(report.human).as_bytes()); } } diff --git a/crates/tty7-cli/src/screen.rs b/crates/tty7-cli/src/screen.rs new file mode 100644 index 00000000..1fc85383 --- /dev/null +++ b/crates/tty7-cli/src/screen.rs @@ -0,0 +1,257 @@ +//! Turning a captured pane back into text. +//! +//! `tty7 capture` hands back what the daemon stored: the PTY's bytes, escapes +//! and all. Recovering the text from that is a terminal's job, not a regex's. +//! A stripper that deletes `ESC[`-sequences gets the easy 90% and then lies +//! about the rest, because the information it needs was never in the byte +//! stream to begin with — it is in the grid those bytes drive: +//! +//! - **Wrapping.** A shell writing 200 characters into a 203-column pane emits +//! 200 characters and no newline. At 120 columns the same bytes are two +//! display rows of one logical line. Only the grid knows which, via +//! `WRAPLINE`, and `bounds_to_string` honours it — so a wrapped line comes +//! back joined instead of split at an invented newline. +//! - **Overwriting.** `\r` means "back to column 0", and what follows replaces +//! what was there. A stripper can only guess (turning it into a newline, which +//! is why a syntax-highlighting shell used to read as `eecho …echo`). Replayed +//! through a grid, the cell simply holds the last thing written to it. +//! - **Cursor addressing.** `ESC[5;80H` puts text somewhere specific. Delete the +//! escape and the text lands wherever the previous character left off. +//! - **Width.** A wide char owns two cells and its spacer must not become a +//! second character; combining marks belong to the cell they modify. +//! +//! So this parses, using the same crate and rev the GUI renders panes with — +//! meaning `capture --plain` and the window agree about what a pane says. + +use alacritty_terminal::event::VoidListener; +use alacritty_terminal::grid::Dimensions as _; +use alacritty_terminal::index::{Column, Point}; +use alacritty_terminal::term::{Config, Term}; +use alacritty_terminal::vte::ansi::Processor; +use tty7_core::daemon::protocol::WinSize; + +use crate::backend::CaptureSegment; + +/// What `Term` needs to know about the grid it is filling. +struct GridSize { + cols: usize, + rows: usize, +} + +impl GridSize { + fn of(size: WinSize) -> GridSize { + // A zero-sized grid panics inside alacritty's index arithmetic, and a + // server that never sent a size leaves us with whatever the caller + // guessed — so clamp rather than trust. + GridSize { + cols: (size.cols as usize).max(1), + rows: (size.rows as usize).max(1), + } + } +} + +impl alacritty_terminal::grid::Dimensions for GridSize { + fn total_lines(&self) -> usize { + self.rows + } + fn screen_lines(&self) -> usize { + self.rows + } + fn columns(&self) -> usize { + self.cols + } +} + +/// Replay every segment through a grid of its own size and join the results. +/// +/// Each segment gets its own `Term` because a segment exists precisely because +/// the pane was a different size then; feeding them all to one grid would +/// re-wrap the older output at the newest width. +pub fn render(segments: &[CaptureSegment]) -> String { + let mut out = String::new(); + for segment in segments { + let text = render_segment(segment.size, &segment.bytes); + if text.is_empty() { + continue; + } + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + out.push_str(&text); + } + out +} + +fn render_segment(size: WinSize, bytes: &[u8]) -> String { + let size = GridSize::of(size); + // The scrollback the grid keeps is for output this segment pushed off its + // own screen: a segment can be far longer than one screenful, and dropping + // what scrolled away would answer a different question than the raw form + // does. `Config::default()` allows 10k lines, which is the daemon ring's + // order of magnitude. + let mut term = Term::new(Config::default(), &size, VoidListener); + // Spelled out because `Processor` is generic over its sync-timeout policy + // and nothing here pins it; the default is the one the GUI parses with. + let mut parser: Processor = Processor::new(); + parser.advance(&mut term, bytes); + + let start = Point::new(term.topmost_line(), Column(0)); + let end = Point::new(term.bottommost_line(), term.last_column()); + let text = term.bounds_to_string(start, end); + trim_blank_edges(&text) +} + +/// Drop the blank lines a rectangle leaves at either end of the text. +/// +/// A grid is a fixed rectangle, so the rows under the last line of output are +/// real blank cells — 20 of them after a shell prompt, which is padding, not +/// content. The top gets them too: `ESC[2J` scrolls the screen into history, so +/// a TUI that clears before drawing starts its capture with blank history lines. +/// +/// Blank lines *between* output are content and stay. So does the indentation of +/// a line the app positioned — only whole empty rows at the edges go. +fn trim_blank_edges(text: &str) -> String { + let lines: Vec<&str> = text.split('\n').collect(); + let blank = |line: &&str| line.trim().is_empty(); + let Some(first) = lines.iter().position(|l| !blank(l)) else { + return String::new(); + }; + let last = lines + .iter() + .rposition(|l| !blank(l)) + .expect("a non-blank line was just found"); + lines[first..=last].join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn size(cols: u16, rows: u16) -> WinSize { + WinSize { + cols, + rows, + cell_w: 8, + cell_h: 16, + } + } + + fn plain(cols: u16, rows: u16, bytes: &str) -> String { + render(&[CaptureSegment { + size: size(cols, rows), + bytes: bytes.as_bytes().to_vec(), + }]) + } + + #[test] + fn colour_and_cursor_escapes_leave_no_trace() { + let text = plain(40, 5, "\x1b[01;32mhello\x1b[0m \x1b[36mworld\x1b[00m\r\n"); + assert_eq!(text, "hello world"); + } + + #[test] + fn osc_titles_and_shell_integration_marks_are_not_output() { + // The prompt marks a real shell emits around every command. None of it + // is text the user ever saw. + let text = plain( + 40, + 5, + "\x1b]2;thomas@box:/tmp\x07\x1b]133;A\x07$ \x1b]133;B\x07ls\r\n\ + \x1b]133;C\x07a.txt\r\n\x1b]133;D;0\x07", + ); + assert_eq!(text, "$ ls\na.txt"); + } + + #[test] + fn a_wrapped_line_comes_back_as_one_line() { + // 30 characters into a 10-column pane: the terminal wrapped it across + // three rows, but the shell wrote one line and that is what it is. A + // stripper cannot tell this from three real lines. + let text = plain(10, 6, &"x".repeat(30)); + assert_eq!(text, "x".repeat(30)); + assert!(!text.contains('\n'), "a wrap is not a newline: {text:?}"); + } + + #[test] + fn a_real_newline_still_breaks_the_line() { + let text = plain(10, 6, "one\r\ntwo\r\n"); + assert_eq!(text, "one\ntwo"); + } + + #[test] + fn a_carriage_return_overwrites_instead_of_breaking() { + // What a progress bar does. The user saw "100%", never "10%". + let text = plain(20, 4, "10%\r50%\r100%\r\n"); + assert_eq!(text, "100%"); + } + + #[test] + fn a_redrawn_line_reads_as_its_final_state() { + // zsh-syntax-highlighting rewrites the line it is echoing, which is why + // a regex stripper produces "eecho …echo". The grid holds one copy. + let text = plain(30, 4, "echo hi\x1b[7D\x1b[32mecho\x1b[39m\x1b[3C\r\nhi\r\n"); + assert_eq!(text, "echo hi\nhi"); + } + + #[test] + fn cursor_addressing_puts_text_where_it_was_addressed() { + // A TUI drawing at absolute positions. Strip the escapes and the two + // words collide; replay them and they are on different rows. + let text = plain(20, 4, "\x1b[2J\x1b[1;1Htop\x1b[3;5Hdeep"); + assert_eq!(text, "top\n\n deep"); + } + + #[test] + fn wide_characters_keep_one_cell_pair_and_one_character() { + let text = plain(20, 3, "宽宽 ok\r\n"); + assert_eq!(text, "宽宽 ok"); + } + + #[test] + fn combining_marks_stay_with_the_cell_they_modify() { + let text = plain(20, 3, "e\u{0301}cole\r\n"); + assert_eq!(text, "e\u{0301}cole"); + } + + #[test] + fn the_blank_rows_under_the_output_are_dropped_but_gaps_are_kept() { + let text = plain(20, 24, "first\r\n\r\nthird\r\n"); + assert_eq!( + text, "first\n\nthird", + "a blank line between output is content; the 20 rows after it are not" + ); + } + + #[test] + fn an_empty_capture_renders_to_nothing() { + assert_eq!(plain(20, 5, ""), ""); + assert_eq!(render(&[]), ""); + } + + #[test] + fn each_segment_is_replayed_at_its_own_width() { + // The pane was 10 columns wide, then 40. Wrapping the older segment at + // the newer width — or the reverse — would split or join the wrong line. + let text = render(&[ + CaptureSegment { + size: size(10, 4), + bytes: "y".repeat(15).into_bytes(), + }, + CaptureSegment { + size: size(40, 4), + bytes: b"after the resize\r\n".to_vec(), + }, + ]); + assert_eq!(text, format!("{}\nafter the resize", "y".repeat(15))); + } + + #[test] + fn invalid_utf8_does_not_derail_the_parse() { + let text = render(&[CaptureSegment { + size: size(20, 4), + bytes: b"ok \xff\xfe done\r\n".to_vec(), + }]); + assert!(text.starts_with("ok "), "{text:?}"); + assert!(text.ends_with("done"), "{text:?}"); + } +} diff --git a/crates/tty7-cli/src/stdio.rs b/crates/tty7-cli/src/stdio.rs new file mode 100644 index 00000000..d7313025 --- /dev/null +++ b/crates/tty7-cli/src/stdio.rs @@ -0,0 +1,76 @@ +//! 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()); +} diff --git a/crates/tty7-cli/tests/cli_e2e.rs b/crates/tty7-cli/tests/cli_e2e.rs index 4b38267e..2c56c634 100644 --- a/crates/tty7-cli/tests/cli_e2e.rs +++ b/crates/tty7-cli/tests/cli_e2e.rs @@ -1,4 +1,4 @@ -use std::io::BufRead as _; +use std::io::{BufRead as _, Read as _}; use std::path::PathBuf; use std::process::{Child, Command, Output, Stdio}; use std::time::{Duration, Instant}; @@ -47,6 +47,14 @@ fn main() { "events_stream_reports_a_workspace_creation", events_stream_reports_a_workspace_creation, ), + ( + "a_reader_that_hung_up_ends_the_pipeline_quietly", + a_reader_that_hung_up_ends_the_pipeline_quietly, + ), + ( + "capture_plain_returns_text_not_escapes", + capture_plain_returns_text_not_escapes, + ), ]; let mut failed = 0; @@ -460,3 +468,101 @@ fn events_stream_reports_a_workspace_creation(daemon: &Daemon) { "no event line arrived within {SETTLE_WITHIN:?}; saw {seen:?}" ); } + +/// `tty7 … | head -1` must end the way `cat … | head -1` ends. Rust ignores +/// SIGPIPE and `println!` panics on the resulting error, so without the fix +/// this printed a panic and a backtrace note on a correct invocation. +/// +/// Both write paths are covered: `ls` goes through the report emitter, `run` +/// through the loop that streams a child's output. The reader is dropped +/// immediately, long before either has anything to say, so the very first +/// write lands on a pipe with no other end — no need to guess at a buffer size. +fn a_reader_that_hung_up_ends_the_pipeline_quietly(daemon: &Daemon) { + daemon.run_ok(&["ws", "new", "pipews"]); + + let printer = one_shot("echo tty7_e2e_pipe_marker"); + let mut streaming: Vec<&str> = vec!["run", "--"]; + streaming.extend(printer.iter().map(String::as_str)); + + for args in [vec!["ls"], streaming] { + let mut child = daemon + .cli(&args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|e| panic!("could not spawn tty7 {args:?}: {e}")); + drop(child.stdout.take().expect("stdout was piped")); + + let mut stderr = String::new(); + child + .stderr + .take() + .expect("stderr was piped") + .read_to_string(&mut stderr) + .expect("reading tty7's stderr"); + let status = child.wait().expect("waiting for tty7"); + + assert!( + !stderr.contains("panicked"), + "tty7 {args:?} panicked when its reader hung up: {stderr}" + ); + assert!( + !stderr.to_lowercase().contains("broken pipe"), + "a hung-up reader is how a pipeline ends, not something to report: \ + tty7 {args:?} said {stderr}" + ); + // Unix dies of SIGPIPE, so there is no code at all; Windows exits 0. + // Either way it must not be the failure exit, which is what the error + // path used to produce. + assert_ne!( + status.code(), + Some(1), + "tty7 {args:?} treated a hung-up reader as a failure: {stderr}" + ); + } +} + +/// `--plain` against a real pane, end to end through a real daemon. +/// +/// The discriminator is the PTY's own line ending: a terminal ends lines with +/// CRLF, so every raw capture carries `\r`, and a rendered one carries none — +/// that CR was an instruction to the grid, not text. It holds whatever the test +/// machine's shell decorates its prompt with, which a check for escape bytes +/// would not: the isolated daemon's shell prints no colour at all. +/// +/// What the grid *does* with those bytes (wraps, overwrites, cursor moves) is +/// pinned by the unit tests in `screen.rs`, which can craft the byte stream +/// exactly. This one proves the flag reaches them. +fn capture_plain_returns_text_not_escapes(daemon: &Daemon) { + let created = daemon.run_json(&["new", &workdir()]); + let pane = created["pane"].as_u64().expect("new prints the pane id"); + let address = format!("%{pane}"); + + daemon.run_ok(&["send", &address, "echo tty7_e2e_plain_marker", "--enter"]); + + let deadline = Instant::now() + SETTLE_WITHIN; + loop { + let raw = daemon.run_ok(&["capture", &address, "--scrollback"]); + let plain = daemon.run_ok(&["capture", &address, "--scrollback", "--plain"]); + if plain.contains("tty7_e2e_plain_marker") { + assert!( + raw.contains('\r'), + "the default hands back the pane's bytes, CRLF included:\n{raw:?}" + ); + assert!( + !plain.contains('\r'), + "a carriage return is an instruction to the grid, not text:\n{plain:?}" + ); + assert!( + !plain.contains('\u{1b}'), + "an escape survived the grid:\n{plain:?}" + ); + return; + } + assert!( + Instant::now() < deadline, + "the sent text never showed up in the rendered capture; last was:\n{plain}" + ); + std::thread::sleep(Duration::from_millis(200)); + } +} diff --git a/skills/tty7/SKILL.md b/skills/tty7/SKILL.md new file mode 100644 index 00000000..28a6d0de --- /dev/null +++ b/skills/tty7/SKILL.md @@ -0,0 +1,240 @@ +--- +name: tty7 +description: Drive the tty7 terminal workbench from the shell with the `tty7` binary — list workspaces/tabs/panes, split a pane, send keystrokes into one, capture what is on a pane's screen, run a command in a real PTY and pass its exit code through, see which coding agents are running and which ports a pane is listening on. Use this whenever tty7, panes, workspaces, or `%42`/`@7`/"the other pane"/"the other agent" come up; whenever you need to start something long-running or interactive (dev server, REPL, ssh session, `tail -f`, a TUI) that should not sit blocking your Bash tool; whenever a program needs a real terminal to behave the way the user sees it; and whenever you need to look at or report on what is running in some *other* terminal on this machine. Cheap to check: if `$TTY7_PANE` is set you are already inside tty7 and every command here works with no setup. +--- + +# Driving tty7 from the command line + +`tty7` is a thin, non-interactive client of the tty7 server. Every verb returns +and exits; `--json` makes the output machine-readable. The GUI never has to be +running — the server is what owns the panes. + +## First: where are you? + +```bash +tty7 doctor +``` + +One table, and it answers everything you need before doing anything else: +whether a server is reachable, whether the dialect matches, and whether +`TTY7_CONFIG_DIR` / `TTY7_WS` / `TTY7_PANE` are set — i.e. whether you are +running *inside* a tty7 pane. + +Being inside a pane matters for two reasons: the address-taking verbs +(`split`, `send`, `capture`, `procs`) default to `$TTY7_PANE`, and `run --keep` +files its pane into `$TTY7_WS`. Outside a tty7 shell you must name a target +explicitly, and the error will say so rather than guessing. + +If `tty7 doctor` says the server is unreachable, stop and tell the user — do +not run `tty7 server start` on your own initiative. Starting a server they +didn't ask for changes what their GUI attaches to. + +## When to use this instead of the Bash tool + +The Bash tool is right for anything that starts, does its job, and exits. +Reach for tty7 when one of these is true: + +- **It shouldn't block you.** A dev server, a watcher, `tail -f`, a long test + run you want to check on later. Put it in a pane, come back and read it. +- **It's interactive or stateful.** A REPL, `ssh`, a database shell, anything + where you send one thing, read the answer, then send the next. A pane keeps + the session alive between your turns; a Bash call cannot. +- **It needs a real TTY.** Programs that detect a pipe and change behaviour — + colour, progress bars, TUIs, `top`, anything using raw mode. `tty7 run` + gives a genuine PTY at 120×30. +- **The user should be able to watch it.** Anything in a pane shows up in their + tty7 window, live. That is often the whole point. +- **You're being asked about something you didn't start.** "What's running in + that pane?", "why is port 3000 taken?", "what are my agents doing?" — you can + answer those from here without touching anything. + +## Addresses + +| Shape | Means | Stable? | +|---|---|---| +| `%42` | a pane | yes — a pane keeps its id for its whole life | +| `@7` | a tab, numbered across the **whole machine** in tree order | **no** — it shifts whenever a workspace or tab appears or disappears | +| `api` / `76698a44` / a full UUID | a workspace, by name, by unique id prefix, or by id | yes | + +Re-resolve `@N` right before you use it; never cache one across a step that +creates or removes a tab. Pane ids and workspace ids are safe to remember. + +Omitting the address inside a tty7 shell means "this pane" / "this workspace". +An explicit address always wins over the environment. + +## Running a command: two shapes + +### Blocking, with a real exit code + +```bash +tty7 run -- cargo test # streams to your stdout, exits with cargo's code +tty7 run --cwd /path -- make +tty7 run --keep -- cargo build # leaves the pane as a new tab afterwards +``` + +The command's output streams to your stdout as it happens, and `tty7` exits +with the command's own exit code. This is the closest thing to a Bash call — +the difference is the PTY and the fact that the user can see it. + +Two things to know. `--keep` needs a workspace, so it only works inside a tty7 +shell or with `--ws `. And with `--json`, the streamed output comes +first and the JSON object last — the combined stream is *not* parseable as +JSON, so read the last line. + +### Non-blocking: a pane you talk to over time + +This is the one that makes tty7 worth reaching for. Get a pane, send it work, +come back later. + +```bash +PANE=$(tty7 split --v) # or --h; splits $TTY7_PANE, prints "%83" +tty7 send "$PANE" 'npm run dev' --enter +``` + +`split` prints the new pane's address on stdout, which is what you capture into +a variable. Without an axis it is a usage error — `--v` stacks the new pane +below, `--h` puts it to the right. + +Splitting `$TTY7_PANE` changes the user's visible layout, which is usually the +point: they can watch the dev server you started. Say that you did it, and close +the pane when you're done with it. + +If you are *not* inside a tty7 pane there is nothing to split, so make your own +place to work first. `tty7 new --json /path/to/repo` hands you both ids at +once — don't go digging through `ws tree` for the pane: + +```bash +read -r WS PANE < <(tty7 new --json /path/to/repo \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["id"], "%%%d" % d["pane"])') +``` + +`send` types text into the pane exactly as a keyboard would; `--enter` appends +the carriage return. It does not wait and it does not tell you what happened — +reading is a separate step. + +## Reading a pane + +### If you want the screen, use `--plain` + +```bash +tty7 capture %83 --plain +``` + +`capture` hands back what the daemon stored — the pane's bytes, escapes and +all — and `--plain` replays them through a terminal grid and prints the +resulting text instead. Not a stripper: colour and cursor escapes are gone, but +also a line the shell wrapped at column 249 comes back as one line, a progress +bar that rewrote itself with `\r` reads as its final value, and a TUI's screen +lands where it was drawn. Use it whenever a human would want to read the output. + +Two details about what you get back either way: capture returns a *snapshot*, +not a stream — call it again for a newer one. And by default it prints the +newest scrollback segment (the ring splits on resize); `--scrollback` prints the +whole ring, which for a pane that was never resized is the same thing. + +### If you want the result, redirect to a file + +`--plain` gives you the screen, and a screen is a rectangle: whatever scrolled +past the top of a long build log is gone, and the exit code was never on screen +at all. So when what you want is the *answer* rather than the view, have the +shell write it somewhere clean: + +```bash +tty7 send "$PANE" 'cargo test > /tmp/t.log 2>&1; echo $? > /tmp/t.rc' --enter +# ...wait for it to finish (below), then: +cat /tmp/t.rc /tmp/t.log +``` + +Complete output, a real exit code, no terminal in the middle. + +### Knowing when a command has finished + +```bash +tty7 procs %83 +``` + +lists the process tree inside the pane, indented, with `*` on the foreground +process — plus any ports those processes are listening on. When the only entry +left is the depth-0 shell, the command is done. That is a far more reliable +"finished?" signal than grepping the screen, where your sentinel string can get +line-wrapped or echoed twice. + +Poll it on an interval rather than in a tight loop — a few seconds between +checks. In Claude Code, use the Monitor tool with an until-condition instead of +a bare foreground `sleep`. + +The whole shape, end to end: + +```bash +tty7 send "$PANE" 'cargo test > /tmp/t.log 2>&1; echo $? > /tmp/t.rc' --enter +# poll until only the shell is left +until [ "$(tty7 procs "$PANE" --json | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["procs"]))')" = 1 ]; do sleep 3; done +cat /tmp/t.rc /tmp/t.log +tty7 pane close "$PANE" +``` + +The ports half also stands alone: `tty7 procs %62` answers "what is this pane +serving, and on which port" without any guessing. + +## Looking around + +```bash +tty7 ls # every workspace: tabs, panes, who's attached +tty7 ws tree api # one workspace as a tree — tabs, splits, panes, cwds +tty7 pane ls # panes with their workspace, tab, cwd, live flag +tty7 pane ls --all # + orphans: panes the server runs that no workspace holds +tty7 agents # every coding agent on the machine and its status +tty7 status # server pid, uptime, pane count, build, socket +tty7 machine ls # this machine plus any linked remotes +tty7 events # stream server events, one per line, until interrupted +``` + +`tty7 agents` is worth knowing about: it reports each pane running a recognised +coding agent as `running` / `waiting` / `idle`. If you are one of them, you are +in that list too. + +Add `--json` to any of these to parse instead of eyeball. `-q` suppresses +output on success but never suppresses errors. + +## Don't break the user's session + +The panes on this machine are the user's real work, and some of them are other +coding agents mid-task. Treat anything you did not create as read-only: + +- **Never `send` into a pane you didn't open.** Keystrokes into another agent's + pane, or into a shell the user is typing in, land in the middle of whatever + is happening there. Check `tty7 agents` before you touch a pane. +- **Never `pane close` / `tab close` / `ws rm` something you didn't create.** +- **Never `server stop` or `server restart`.** Every pane on the machine dies + with the server, including yours. If the server genuinely seems wedged, say + so and let the user decide. +- **Clean up what you did create.** `tty7 pane close %83` when you're done with + a scratch pane. Note that `ws rm` does *not* kill the panes inside it — they + survive as orphans, visible under `tty7 pane ls --all` with no workspace, and + you have to close them individually. + +## Remote machines + +`-m ` routes any command over a link the local server already holds: + +```bash +tty7 -m devbox ls +tty7 -m devbox run -- cargo test +``` + +The name matches the full link key (`me@devbox:22`) or just the host. The CLI +will not dial a fresh connection — if the link is down, or it's a jump/proxy +chain, it says so and you should hand that back to the user, who can connect it +from the GUI. + +## Not wired up yet + +`ws stop`, `machine connect`, `machine disconnect`, and bare `tty7 ` (GUI +launch) all exit with a message saying they're not implemented. Don't build a +plan around them. + +## Full command reference + +`references/commands.md` has every verb, subcommand and flag in one table, plus +the JSON shape each one emits. Read it when you need a verb that isn't above, +or when you're about to parse `--json` output and want to know the field names. diff --git a/skills/tty7/references/commands.md b/skills/tty7/references/commands.md new file mode 100644 index 00000000..576c40cb --- /dev/null +++ b/skills/tty7/references/commands.md @@ -0,0 +1,247 @@ +# `tty7` command reference + +Every verb, its flags, and the JSON it emits under `--json`. Read the section +you need; the table of contents mirrors the top-level grammar. + +- [Global flags](#global-flags) +- [Environment](#environment) +- [Exit codes](#exit-codes) +- [Top-level verbs](#top-level-verbs) +- [`ws` — workspaces](#ws--workspaces) +- [`tab` — tabs](#tab--tabs) +- [`pane` — panes](#pane--panes) +- [`machine` — remotes](#machine--remotes) +- [`server` — the daemon](#server--the-daemon) +- [Not implemented yet](#not-implemented-yet) + +## Global flags + +Accepted anywhere on the line, before or after the subcommand. + +| Flag | Effect | +|---|---| +| `-m, --machine ` | Route the command to a linked machine over the local server's existing link. Matches the full link key (`me@devbox:22`) or the bare host (`devbox`). Ssh links only; a down link or a jump/proxy chain is refused with a reason rather than dialled fresh. | +| `--json` | One JSON object on stdout instead of the human table. | +| `-q, --quiet` | No output on success. Errors still go to stderr. | + +## Environment + +Set inside every tty7 pane, inherited by anything you launch from one. + +| Variable | Meaning | +|---|---| +| `TTY7_PANE` | This pane's id, e.g. `71` or `%71` (both forms are accepted). The default target of `split`, `send`, `capture`, `procs`, `pane close`. | +| `TTY7_WS` | This pane's workspace id. The default for `run --keep`, `tab new`, `ws tree`. | +| `TTY7_CONFIG_DIR` | The server's config dir. How the CLI finds the right server's sockets — you never pass a socket path. | + +Outside a tty7 shell the address-taking verbs fail with +`not inside a tty7 shell — pass an explicit %pane/@tab/workspace`. + +## Exit codes + +| Code | Meaning | +|---|---| +| 0 | success | +| 1 | the command failed; the reason is one line on stderr, prefixed `tty7:` | +| 2 | usage error (clap) — unknown verb, missing argument, bad type | +| 141 | Unix only: the reader hung up (`| head -1`) and SIGPIPE ended it, exactly as it ends `cat`. Not a failure. Windows reports 0 for the same thing, having no signal to imitate. | +| *other* | only from `tty7 run`, which passes the child's exit code through | + +Builds before this was fixed panic instead of exiting on a hung-up reader: +`tty7 capture %71 | head -1` prints a Rust `failed printing to stdout: Broken +pipe` note and a backtrace hint to stderr. Harmless, and the data you asked for +still arrived — don't read it as the command having failed. On such a build, +redirect to a file and slice the file instead of piping into `head`. + +If `run` cannot learn the child's code it prints a note to stderr and exits 1 +with `"exit_code_known": false` in the JSON — that is how you tell a real 1 +from a stand-in. + +## Top-level verbs + +### `tty7 ls` +Same as `ws ls`. Table: `WORKSPACE NAME TABS PANES ATTACHED`. +JSON: `{"workspaces":[{"id","name","tabs","panes","attached"}]}`. + +### `tty7 run [--keep] [--cwd DIR] [--ws WORKSPACE] -- CMD...` +Spawns a pane running `CMD`, streams its output to stdout, waits, and exits +with its code. The command must come after `--`; anything after `--` belongs to +the child, so `tty7 run -- cargo test --keep` passes `--keep` to cargo. + +- `--keep` leaves the pane alive as a new tab afterwards. It needs a workspace, + so it requires `--ws` or `$TTY7_WS`; without one it is an error, not a + silent fallback. +- `--cwd` sets the working directory. `--ws` also sets the pane's `TTY7_WS`. +- Interrupting `run` can leave the pane behind as an orphan — `pane ls --all`. + +JSON: `{"pane","exit","exit_code_known","kept"}`, printed **after** the streamed +output. The combined stream is not valid JSON; read the last line. + +### `tty7 new [PATH]` +Creates a workspace plus its first tab and shell, at `PATH` if given. Prints +the workspace id. JSON: `{"id","pane"}`. + +### `tty7 split [%PANE] (--v|--h) [--ratio R]` +Alias of `pane split`. Splits `%PANE` (default `$TTY7_PANE`), spawning a shell +in the same cwd. Exactly one axis is required — `--v`/`--vertical` puts the new +pane below, `--h`/`--horizontal` to the right. `--ratio` (default 0.5) is the +share kept by the *existing* pane. Prints `%NN`. JSON: `{"pane"}`. + +### `tty7 send [%PANE] TEXT [--enter]` +Types `TEXT` into the pane as keystrokes; `--enter` appends CR. With one +argument the text is the argument and the pane comes from `$TTY7_PANE` — but a +lone `%42` is rejected as a missing-text error rather than typed. +JSON: `{"pane","sent","enter"}`. + +### `tty7 capture [%PANE] [--plain] [--scrollback]` +The pane's replay. Two independent choices: **how much** — the newest scrollback +segment by default, the whole ring with `--scrollback` (the ring splits into +segments on resize, so for a pane that was never resized the two are identical) +— and **in what form**. + +Without `--plain` you get the stored bytes, ANSI escapes intact, decoded as +UTF-8 (invalid bytes become U+FFFD). That is the faithful form: it is exactly +what the pane emitted. + +With `--plain` those bytes are replayed through a terminal grid — the same +parser and rev the GUI renders panes with — and you get the text that produced. +The difference from stripping the escapes yourself: + +- a line the shell wrapped at the pane's width comes back as **one** line, not + split at an invented newline +- `\r` **overwrites** rather than breaking the line, so a progress bar reads as + its final value and a syntax-highlighting shell doesn't echo as `eecho …echo` +- cursor addressing (`ESC[5;80H`) puts text **where the app put it** +- wide characters keep one character per cell pair; combining marks stay put +- each segment is rendered at **its own** width, which is why the size travels + with it; the empty rows a grid leaves above and below the output are dropped + +Reach for it whenever a human would want to read the output. It is still a +screen, though: what scrolled past the top is gone, and an exit code was never +on screen — redirect to a file when you want the answer rather than the view. + +Either way it is a snapshot, not a stream: it collects the replay the server +sends, settles for ~300 ms, and returns. Call it again for a newer one. +JSON: `{"pane","text"}`, where `text` is whichever form was asked for. + +### `tty7 procs [%PANE]` +The process tree inside the pane, indented by depth, `*` on the foreground +process — then a second table of ports those processes are listening on. +Prints `nothing running in this pane` when both are empty. + +JSON: `{"procs":[{"pid","name","depth","foreground"}],"ports":[{"port","pid","name"}]}`. + +The reliable "is it done?" check: when the only entry is the depth-0 shell, the +foreground command has exited. + +### `tty7 agents` +Every pane running a recognised coding agent. Table: `PANE AGENT STATUS +MESSAGE`, status one of `running` / `waiting` / `idle`. +JSON: `{"agents":[...]}`. + +### `tty7 events` +Streams server events until interrupted, one per line — pane exits, agent +status changes, workspace preemption, layout deltas. `--json` makes it NDJSON. +Blocks forever; run it with a timeout or in the background. + +### `tty7 status` +Same as `server status`: pid, uptime, pane count, dialect versions, build, +socket path. JSON is the `ServerStatus` object itself (`pid`, `uptime_secs`, +`panes`, `control_version`, `protocol_version`, `build`, `socket`). + +### `tty7 doctor` +The install check: the three env vars, whether the server answers, whether its +control/protocol versions match this binary, pid/uptime/panes, and how many +machine links exist. Adds a note when you are not inside a tty7 shell. +JSON: `{"context":{"config_dir","workspace","pane"},"server":{"reachable","dialect_ok","build","status","routes"}}` +— the context fields are booleans, not values. + +## `ws` — workspaces + +A workspace is a named tree of tabs and panes the server keeps alive. Address +one by name, by full id, or by a unique id prefix (the 8-char prefix `tty7 ls` +prints is what you normally use). An ambiguous name or prefix is an error that +lists the candidates. + +| Command | Effect | JSON | +|---|---|---| +| `ws ls` | every workspace | `{"workspaces":[...]}` | +| `ws tree [WORKSPACE]` | one workspace as a tree: tabs, split axes and ratios, panes with cwds | the whole workspace object: `{"id","name","last_active","tabs":[{"id","name","sidebar_group","root",...}]}`, where `root` is the nested split tree | +| `ws new [NAME]` | an empty workspace (no tab, no pane) | `{"id","name"}` | +| `ws rename WORKSPACE NAME` | name or rename | `{"id","name"}` | +| `ws rm WORKSPACE` | delete the workspace | `{"removed"}` | +| `ws attach WORKSPACE` | become its controlling client | `{"attached","took_over_from"}` | +| `ws detach WORKSPACE` | let go without interrupting anything | `{"detached"}` | + +`ws rm` does not kill the panes it held — they keep running as orphans with no +workspace. Find them with `pane ls --all` and close them one by one. + +Prefer `tty7 new ` over `ws new` when you want something usable: `ws new` +leaves you with an empty workspace you then have to populate, while +`tty7 new --json ` hands back `{"id","pane"}` — both addresses in one go. + +The `root` node in `ws tree --json` is externally tagged, so a leaf is +`{"Leaf":{"pane":31}}` and a split is `{"Split":{"axis","ratio","a","b"}}` with +`a`/`b` nested the same way. `d["tabs"][0]["root"]["pane"]` will not work. + +## `tab` — tabs + +`@N` numbers tabs across the **whole machine** in tree order, densely from `@1`. +The numbering shifts whenever any workspace or tab is created or removed, so +resolve it immediately before use. A full tab UUID also works: `@`. + +| Command | Effect | JSON | +|---|---|---| +| `tab ls [WORKSPACE]` | tabs of a workspace | `{"workspace","tabs":[{"ordinal","id","name","panes":[..]}]}` | +| `tab new [WORKSPACE] [--cwd DIR]` | add a tab with a fresh shell | `{"tab","pane"}` | +| `tab close @TAB` | close the tab and every pane in it | `{"closed"}` | +| `tab rename @TAB NAME` | name or rename | `{"tab","name"}` | +| `tab move @TAB INDEX` | reposition within its workspace | `{"tab","to"}` | + +## `pane` — panes + +| Command | Effect | JSON | +|---|---|---| +| `pane ls [WORKSPACE]` | panes with their workspace, tab, cwd, live flag | `{"panes":[...]}` | +| `pane ls --all` | the server's whole pane registry, including orphans no workspace holds | `{"panes":[...],"orphans":N}` | +| `pane split ...` | identical to top-level `split` | `{"pane"}` | +| `pane close [%PANE]` | close the pane; its shell is hung up | `{"closed"}` | + +`--all` is the one that shows leaks. Each entry is +`{"pane","workspace","orphan","owner","title","cwd","live"}`: `owner` is +`tty7-cli` for panes this CLI spawned (a workspace id otherwise), and +`orphan: true` means no workspace holds it. An interrupted `tty7 run` and a +removed workspace both leave orphans here. + +`title` is the pane's current title — usually the running command, so it reads +`claude`, `nvim`, `cargo` — which makes `pane ls --all --json` a quick way to +find "the pane running X" without capturing anything. + +## `machine` — remotes + +`machine ls` lists the local machine plus every link the server holds: +`MACHINE KIND CONNECTED`. JSON: `{"machines":[{"key","kind","connected"}]}`. + +`machine connect` / `machine disconnect` are not implemented — links are +managed from the GUI's connection manager. + +## `server` — the daemon + +| Command | Effect | +|---|---| +| `server status` | same as `tty7 status` | +| `server logs` | tail the server log; prints the path, and says so when logging was never enabled (`TTY7_LOG=info` before the server starts) | +| `server start` | bring up a server on this machine | +| `server stop` | stop it — **every pane on the machine dies** | +| `server restart` | stop, then start — same consequence | + +Do not run `start`, `stop` or `restart` on your own initiative. They change or +destroy what the user's GUI is attached to. + +## Not implemented yet + +These parse and then exit 1 with an explanation: + +- `ws stop` — the control dialect has no workspace-stop request yet +- `machine connect` / `machine disconnect` — use the GUI +- bare `tty7 ` (launch or focus the GUI) — not wired up