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