mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user