diff --git a/crates/tty7-cli/src/cli.rs b/crates/tty7-cli/src/cli.rs index bc216a6d..1b075367 100644 --- a/crates/tty7-cli/src/cli.rs +++ b/crates/tty7-cli/src/cli.rs @@ -347,6 +347,21 @@ pub struct CaptureArgs { escapes, wrapped lines rejoined, overwrites and cursor moves applied" )] pub plain: bool, + + // "How did the last command end?" is the common question, and answering it + // meant `| tail -n 5` — a pipe that only exists to throw most of the answer + // away, and one more thing a script has to have on PATH (Windows does not). + // The trim is the last thing that happens, after `--plain` has decided what + // a line even is: a wrapped line is one line to the grid and three to a + // byte counter, so trimming earlier would answer a different question than + // the one the flag composes with. + #[arg( + long, + value_name = "N", + value_parser = clap::value_parser!(u64).range(1..), + help = "Keep only the last N lines of the answer, the way `tail -n N` would" + )] + pub tail: Option, } #[derive(Debug, Subcommand)] @@ -863,6 +878,32 @@ mod tests { assert!(args.plain && args.scrollback); } + #[test] + fn capture_tail_takes_a_count_and_refuses_zero() { + let Some(Command::Capture(args)) = parse(&["tty7", "capture", "%3", "--tail", "5"]).command + else { + panic!("capture did not parse"); + }; + assert_eq!(args.tail, Some(5)); + + let Some(Command::Capture(args)) = parse(&["tty7", "capture", "%3"]).command else { + panic!("capture did not parse"); + }; + assert_eq!(args.tail, None, "the whole answer stays the default"); + + // A tail of nothing is a mistake, not a request for an empty string — + // and it would read as a blank pane, which is the very ambiguity #841 + // is about. + for bad in [ + vec!["tty7", "capture", "%3", "--tail", "0"], + vec!["tty7", "capture", "%3", "--tail", "-1"], + vec!["tty7", "capture", "%3", "--tail", "lots"], + ] { + let err = Cli::try_parse_from(&bad).unwrap_err(); + assert_eq!(err.exit_code(), 2, "{bad:?} should be a usage error"); + } + } + #[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 93dc6f3d..572aa5ab 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -622,12 +622,37 @@ fn capture(args: CaptureArgs, ctx: &Context, backend: &mut dyn Backend) -> Resul capture that came back short" ); } + // After the note, not before: a tail is a view of the answer, and whether + // the answer itself was blank is a fact about the pane either way. + let text = match args.tail { + Some(keep) => last_lines(&rendered, keep as usize), + None => rendered, + }; report( - rendered.clone(), - json!({ "pane": pane, "text": rendered, "bytes": replayed }), + text.clone(), + json!({ "pane": pane, "text": text, "bytes": replayed }), ) } +/// The last `keep` lines of `text`, counted the way `tail -n` counts them. +/// +/// A trailing newline terminates the last line rather than opening an empty +/// one, so `tail -n 1` of `"a\nb\n"` is `"b\n"` and not `""`. Splitting on +/// `\n` alone leaves the `\r` of a CRLF attached to the line it ended, which +/// is what the raw form is supposed to hand back byte-for-byte. +fn last_lines(text: &str, keep: usize) -> String { + let (body, trailer) = match text.strip_suffix('\n') { + Some(body) => (body, "\n"), + None => (text, ""), + }; + let start = body + .rmatch_indices('\n') + .nth(keep.saturating_sub(1)) + .map(|(at, _)| at + 1) + .unwrap_or(0); + format!("{}{trailer}", &body[start..]) +} + fn procs(target: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> Result { let pane = address::pane_or_context(target, ctx)?; let procs = backend.procs(pane)?; @@ -2606,6 +2631,67 @@ mod tests { assert_eq!(plain["pane"], json!(2)); } + #[test] + fn capture_tail_keeps_the_last_lines_of_either_form() { + let mut backend = mock(); + let replay = b"one\r\ntwo\r\nthree\r\nfour\r\n"; + backend.capture_segments = vec![segment(replay)]; + + let plain = human(run_cli( + &["tty7", "capture", "%2", "--plain", "--tail", "2"], + &Context::default(), + &mut backend, + )); + assert_eq!(plain, "three\nfour"); + + let raw = human(run_cli( + &["tty7", "capture", "%2", "--tail", "2"], + &Context::default(), + &mut backend, + )); + assert_eq!( + raw, "three\r\nfour\r\n", + "the raw form still hands back the pane's own bytes, CR included" + ); + + // More lines asked for than exist is the whole answer, not an error — + // `tail -n 99` of a three-line file is the file. + let all = human(run_cli( + &["tty7", "capture", "%2", "--plain", "--tail", "99"], + &Context::default(), + &mut backend, + )); + assert_eq!(all, "one\ntwo\nthree\nfour"); + + // And `--json` reports the tail it printed, over the byte count of the + // whole replay: the two together are what say a tail was taken. + let tailed = json_of(run_cli( + &["tty7", "capture", "%2", "--plain", "--tail", "1"], + &Context::default(), + &mut backend, + )); + assert_eq!(tailed["text"], json!("four")); + assert_eq!(tailed["bytes"], json!(replay.len())); + } + + #[test] + fn a_tail_counts_lines_the_way_tail_does() { + // A trailing newline ends the last line rather than opening an empty + // one, which is the difference between `tail -n 1` answering "b" and + // answering nothing at all. + assert_eq!(last_lines("a\nb\n", 1), "b\n"); + assert_eq!(last_lines("a\nb", 1), "b"); + assert_eq!(last_lines("a\nb\n", 2), "a\nb\n"); + assert_eq!(last_lines("a\nb\n", 9), "a\nb\n"); + assert_eq!(last_lines("", 3), ""); + assert_eq!(last_lines("\n", 1), "\n"); + assert_eq!( + last_lines("keep\n\n\n", 2), + "\n\n", + "blank lines are lines; a tail is not a filter" + ); + } + #[test] fn capture_json_counts_the_bytes_the_replay_carried() { // The whole point of the field: `text` is empty in both of these, and diff --git a/crates/tty7-cli/tests/cli_e2e.rs b/crates/tty7-cli/tests/cli_e2e.rs index adf7b2ae..7353a402 100644 --- a/crates/tty7-cli/tests/cli_e2e.rs +++ b/crates/tty7-cli/tests/cli_e2e.rs @@ -79,6 +79,10 @@ fn main() { "capture_still_answers_after_a_resize", capture_still_answers_after_a_resize, ), + ( + "capture_tail_trims_a_real_panes_answer", + capture_tail_trims_a_real_panes_answer, + ), ]; let mut failed = 0; @@ -905,3 +909,56 @@ fn capture_still_answers_after_a_resize(daemon: &Daemon) { let _ = pane.detach(); } + +/// `--tail` against a real pane, whose replay carries a shell prompt, escapes +/// and CRLF rather than the tidy fixtures the unit tests craft. +/// +/// The contract is only "the last N lines of what the command would have +/// printed", so what is pinned is that the tail is a suffix of the whole answer, +/// that it holds the marker the pane printed last, and that it is shorter than +/// what it was cut from — not the exact line count, which depends on how the +/// test machine's shell decorates its prompt. +fn capture_tail_trims_a_real_panes_answer(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}"); + + for n in 1..=6 { + let line = format!("echo tty7_e2e_tail_line_{n}"); + daemon.run_ok(&["send", &address, &line, "--enter"]); + } + + let deadline = Instant::now() + SETTLE_WITHIN; + loop { + let whole = daemon.run_ok(&["capture", &address, "--plain"]); + let tail = daemon.run_ok(&["capture", &address, "--plain", "--tail", "2"]); + let settled = whole.contains("tty7_e2e_tail_line_6") + && tail.contains("tty7_e2e_tail_line_6") + && whole.contains("tty7_e2e_tail_line_1"); + if settled { + assert!( + whole.trim_end().ends_with(tail.trim_end()), + "a tail has to be the end of the answer it was cut from:\ntail: {tail:?}\nwhole: {whole:?}" + ); + assert!( + !tail.contains("tty7_e2e_tail_line_1"), + "two lines cannot still hold the first of six:\n{tail:?}" + ); + // The byte count stays the size of the replay, not of the tail — + // that is what says a tail was taken rather than a short capture. + let json = daemon.run_json(&["capture", &address, "--plain", "--tail", "2"]); + assert!( + json["bytes"] + .as_u64() + .is_some_and(|n| n as usize > tail.len()), + "--tail must not shrink the reported replay size: {json}" + ); + return; + } + assert!( + Instant::now() < deadline, + "the six echoes never settled; last capture was:\n{whole}" + ); + std::thread::sleep(Duration::from_millis(200)); + } +} diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx index 0754bc8b..9a5e09cf 100644 --- a/docs/cli/overview.mdx +++ b/docs/cli/overview.mdx @@ -93,6 +93,16 @@ stripping escapes yourself: Use `--plain` whenever a human would want to read the output. +When all you want is how the last command ended, ask for that much: + +```bash +tty7 capture %83 --plain --tail 5 +``` + +`--tail N` keeps the last N lines of the answer, counted after `--plain` has +resolved the wraps — so no pipe through `tail(1)`, which is one less thing to +have on PATH. + A screen is a rectangle. Whatever scrolled off the top is gone, and an exit code was never on it. When you want the *answer* rather than the *view*, have diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx index 0444f71c..1890fbe2 100644 --- a/docs/cli/reference.mdx +++ b/docs/cli/reference.mdx @@ -130,9 +130,9 @@ raw-mode TUI reads a sequence as a sequence rather than as a paste. JSON: `{"pane","sent","enter","keys"}`. -### `tty7 capture [%PANE] [--plain] [--scrollback]` +### `tty7 capture [%PANE] [--plain] [--scrollback] [--tail N]` -The pane's replay. Two independent choices: +The pane's replay. Three 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 @@ -142,11 +142,20 @@ never resized the two are identical. decoded as UTF-8 (invalid bytes become U+FFFD). With `--plain`, those bytes replayed through a terminal grid and printed as the text they produced. +**How many lines** — all of them by default, the last `N` with `--tail N`, which +answers "how did the last command end?" without a pipe through `tail(1)` (a +program Windows does not have). The trim happens last, after `--plain` has +decided what a line is: a wrapped line is one line to the grid, so `--plain +--tail 1` gives you the whole of the last line and not its final row. `N` must +be at least 1 — a tail of nothing would read as a blank pane. The daemon still +replays the ring in full; the saving is the pipe, not the wire. + Either way it is a snapshot, not a stream: it collects the replay, settles for ~300 ms, and returns. Call it again for a newer one. -JSON: `{"pane","text","bytes"}` — `bytes` is how much the replay carried, counted -before `--plain` rendered it. It is what tells an empty `text` apart: `0` is a +JSON: `{"pane","text","bytes"}` — `text` is what was printed, `--tail` included; +`bytes` is how much the replay carried, counted before `--plain` rendered it or +`--tail` trimmed it. It is what tells an empty `text` apart: `0` is a pane that has printed nothing, and a non-zero count with no text is a screen whose bytes produced nothing visible (a pane that was cleared, say). The same case also prints one line on stderr, so a script that never reads the JSON still