feat(cli): add capture --tail N (#841)

Every "how did the last command end?" query went through `| tail -n 5`,
which ships the whole grid down a pipe to throw most of it away — and on
Windows, where `capture` is just as useful, there is no `tail(1)` to pipe
to. `--tail N` keeps the last N lines of the answer instead.

It is a third independent choice beside `--scrollback` (how much of the
ring) and `--plain` (in what form), and it composes with both. The trim
runs last, after `--plain` has decided what a line is: a shell line the
pane wrapped over three rows is one line to the grid and three to a byte
counter, so `--plain --tail 1` hands back the whole of the last line
rather than its final row. The raw form counts the same way `tail` does,
splitting on the newline and leaving the CR of a CRLF attached to the
line it ended, so the bytes stay the pane's own. A trailing newline
terminates the last line rather than opening an empty one, which is the
difference between `--tail 1` answering the last line and answering
nothing.

`N` must be at least 1: a tail of zero lines would print an empty result
that reads exactly like the blank-pane ambiguity this issue is about, so
it is a usage error (exit 2) instead. `--json` reports the tail in `text`
but leaves `bytes` the size of the whole replay, so the pair still says
"this was trimmed" rather than "this came back short".

Left alone deliberately: the daemon still replays the entire ring on
every observe, so the saving here is the pipe, not the wire. Bounding
what crosses the wire means teaching `ClientMsg::Observe` a limit and
versioning the protocol for it, which is a much larger change than the
papercut warrants — and the default (newest-segment) form has always
received the whole ring and discarded most of it, so this adds no new
cost. The docs say so rather than implying otherwise.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
This commit is contained in:
l0ng-ai
2026-09-10 11:41:32 +08:00
parent 0752110b54
commit 5ac26b5dd9
5 changed files with 209 additions and 6 deletions
+41
View File
@@ -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<u64>,
}
#[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();
+88 -2
View File
@@ -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<Outcome> {
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
+57
View File
@@ -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));
}
}
+10
View File
@@ -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.
<Warning>
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
+13 -4
View File
@@ -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