Files
tty7/crates/tty7-cli/src/exec.rs
T
l0ng-ai 02d8611e33 feat(cli): add exec, and send --stdin/--from-file/--paste (#951)
`tty7 exec %N -- CMD` types a line at an existing pane's shell prompt,
follows the shell integration's OSC 133 marks to the command's end, prints
what it printed (rendered to text by default, `--raw` for the bytes) and
exits with its exit code. `--timeout` exits 124 and leaves the command
running. A pane with no prompt marks, or one not at a prompt, is refused
before anything is typed.

`tty7 send` can take its text from `--stdin` or `--from-file`, sent byte
for byte and never echoed in --json, so a secret stays out of `ps` and
shell history. `--paste` frames the text the way the GUI's paste does:
bracketed when the pane has mode 2004 on, unframed otherwise, reported in
--json. The framing moves into tty7-core (`core::paste`) so the GUI's
clipboard paste, the agent prompt and the CLI share one construction, and
the daemon now reports each pane's bracketed-paste mode in PaneContext.

Closes #839
Closes #838
2026-09-25 16:32:19 +08:00

306 lines
12 KiB
Rust

//! Finding one command's output and exit code in a pane's byte stream.
//!
//! `tty7 exec` types a line at a shell's prompt and has to know two things the
//! pty alone never says: where the command's output starts and ends, and how it
//! exited. The shell integration says both, in OSC 133 marks: `C` right before
//! the command runs, `D;<code>` the moment it is over, `A` when the next prompt
//! opens. The daemon already reads those marks — it is what sends `Prompt` —
//! but only as a state, not as a position in the bytes, so the positions are
//! found here, in the same stream the daemon read them from.
//!
//! Which of the two gets the last word is deliberate. The byte marks say
//! *where*; the daemon's `Prompt` report says *when*, because it is the one
//! that has already set aside marks relayed by a program running in the
//! foreground (a nested shell, an `ssh`) — a `D` in the bytes is only the end
//! once the daemon agrees the shell is back at its prompt.
use tty7_core::core::osc::OscTokenizer;
use tty7_core::daemon::protocol::WinSize;
/// Output kept past this is dropped from the front: the end of a long build is
/// the part worth reading, and a command that prints without bound must not
/// take the CLI's memory with it. The pane's own scrollback is the same size.
pub const KEEP_OUTPUT: usize = 8 * 1024 * 1024;
const MARK_D: &[u8] = b"\x1b]133;D";
const MARK_A: &[u8] = b"\x1b]133;A";
/// How an `exec` ended.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExecEnd {
/// The pane's shell has never sent a prompt mark, so nothing would ever
/// say the command is over. Nothing was sent.
NoIntegration,
/// The pane is not at a prompt — something already runs there. Nothing
/// was sent.
Busy,
/// The shell is back at its prompt. `ran` is false when it came back
/// without running anything — a syntax error, most likely — in which case
/// there is no exit code to report and the output is what the shell said
/// about the line.
Finished { exit: Option<i32>, ran: bool },
/// The deadline passed first. `started` says whether the shell ever
/// began the command: when it did not, the line is most likely sitting on
/// a continuation prompt (an unclosed quote) rather than running.
TimedOut { started: bool },
/// The pane went away before its shell came back to a prompt.
PaneExited(Option<i32>),
}
/// What an `exec` saw: how it ended, and the output that belongs to it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecRun {
pub end: ExecEnd,
pub output: Vec<u8>,
/// Bytes of output dropped from the front to stay under [`KEEP_OUTPUT`].
pub dropped: usize,
/// The pane's size, which is what `--plain` replays the output at.
pub size: WinSize,
}
/// The pane's output from the moment the line was sent, and where the marks
/// that bound the command fell in it.
pub struct Transcript {
tok: OscTokenizer,
bytes: Vec<u8>,
/// Absolute offset of `bytes[0]` — everything before it was dropped.
base: usize,
/// One past the command's `C` mark: where its output starts.
started: Option<usize>,
/// Where the newest `D` after `started` begins: where the output ends.
ended: Option<usize>,
/// Where an `A` that arrived with no `C` before it begins — the shell drew
/// a fresh prompt without running the line.
reprompted: Option<usize>,
exit_mark: Option<i32>,
}
/// What the daemon's `Prompt` report made of the pane.
pub struct Finished {
pub exit: Option<i32>,
pub ran: bool,
}
impl Default for Transcript {
fn default() -> Transcript {
Transcript::new()
}
}
impl Transcript {
pub fn new() -> Transcript {
Transcript {
tok: OscTokenizer::new(&[b"133"]),
bytes: Vec::new(),
base: 0,
started: None,
ended: None,
reprompted: None,
exit_mark: None,
}
}
/// Take the next chunk of the pane's output.
pub fn output(&mut self, chunk: &[u8]) {
let at = self.base + self.bytes.len();
self.bytes.extend_from_slice(chunk);
let (bytes, base) = (&self.bytes, self.base);
let (mut started, mut ended, mut reprompted, mut exit_mark) =
(self.started, self.ended, self.reprompted, self.exit_mark);
self.tok.feed_at(chunk, |end, payload| {
let end = at + end;
// Where the sequence that just ended began: the newest copy of its
// introducer before its terminator. Payloads carry no ESC, so the
// search cannot land inside a different sequence.
let begin = |intro: &[u8]| {
let upto = end - base;
bytes[..upto]
.windows(intro.len())
.rposition(|w| w == intro)
.map_or(end, |at| base + at)
};
match payload.get(4) {
// Only the first: a command that prints a `C` of its own must
// not move the start of its own output.
Some(b'C') if started.is_none() => started = Some(end),
Some(b'D') if started.is_some() => {
ended = Some(begin(MARK_D));
exit_mark = payload
.strip_prefix(b"133;D;")
.and_then(|c| std::str::from_utf8(c).ok())
.and_then(|c| c.trim().parse().ok());
}
Some(b'A') if started.is_none() && reprompted.is_none() => {
reprompted = Some(begin(MARK_A));
}
_ => {}
}
});
(self.started, self.ended, self.reprompted, self.exit_mark) =
(started, ended, reprompted, exit_mark);
}
/// The daemon's report that the pane's shell is (or is not) at a prompt.
/// `Some` once the command is over.
pub fn prompt(&self, at_prompt: bool, last_exit: Option<i32>) -> Option<Finished> {
if !at_prompt {
return None;
}
if self.started.is_some() {
// The byte mark and the report are the same `D`, read twice; the
// report is preferred only because the daemon parsed it first.
return self.ended.map(|_| Finished {
exit: last_exit.or(self.exit_mark),
ran: true,
});
}
self.reprompted.map(|_| Finished {
exit: None,
ran: false,
})
}
/// Whether the shell has begun running the command.
pub fn started(&self) -> bool {
self.started.is_some()
}
/// The output that belongs to the command, as far as it has got, and how
/// much of it was dropped from the front to keep it bounded.
pub fn take(self) -> (Vec<u8>, usize) {
let all = self.base + self.bytes.len();
let (from, to) = match (self.started, self.ended, self.reprompted) {
(Some(start), Some(end), _) => (start, end),
(Some(start), None, _) => (start, all),
// Never ran: what the shell printed about the line, up to the new
// prompt — the echo of the line itself and whatever error it drew.
(None, _, Some(end)) => (0, end),
(None, _, None) => (0, all),
};
let to = to.clamp(from, all);
let kept = from.max(self.base).max(to.saturating_sub(KEEP_OUTPUT));
(
self.bytes[kept - self.base..to - self.base].to_vec(),
kept - from,
)
}
/// Bound the memory held: once twice [`KEEP_OUTPUT`] has piled up, drop
/// all but the last [`KEEP_OUTPUT`]. Only ever what [`take`](Self::take)
/// would not return anyway, which keeps the newest that much.
pub fn compact(&mut self) {
let len = self.bytes.len();
if len <= KEEP_OUTPUT * 2 {
return;
}
let drop = len - KEEP_OUTPUT;
self.bytes.drain(..drop);
self.base += drop;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// What zsh with tty7's integration prints for one `echo hi` typed at its
/// prompt: the echo of the line, `C` with the line, the output, `D` with
/// the exit code, then the next prompt.
const ONE_COMMAND: &[u8] = b"echo hi\r\n\x1b]133;C;echo hi\x07hi\r\n\x1b]133;D;0\x07\
\x1b]7;file://host/tmp\x07\x1b]133;A\x07% \x1b]133;B\x07";
#[test]
fn the_output_is_what_lies_between_c_and_d() {
let mut t = Transcript::new();
t.output(ONE_COMMAND);
let done = t.prompt(true, Some(0)).expect("D then a prompt is the end");
assert_eq!((done.exit, done.ran), (Some(0), true));
assert_eq!(t.take(), (b"hi\r\n".to_vec(), 0));
}
#[test]
fn marks_split_across_reads_still_bound_the_output() {
// The pty hands the stream over in whatever chunks it likes, including
// through the middle of a mark.
for split in 1..ONE_COMMAND.len() {
let mut t = Transcript::new();
t.output(&ONE_COMMAND[..split]);
t.output(&ONE_COMMAND[split..]);
assert!(t.prompt(true, Some(0)).is_some(), "split at {split}");
assert_eq!(t.take().0, b"hi\r\n".to_vec(), "split at {split}");
}
}
#[test]
fn a_prompt_report_before_the_command_has_ended_is_not_the_end() {
let mut t = Transcript::new();
// The shell has only echoed the line; a prompt redraw (a `B` in PS1
// repainted by a transient-prompt theme) reports "at a prompt" too.
t.output(b"make\r\n\x1b]133;B\x07");
assert!(t.prompt(true, None).is_none());
// Running: the daemon reports "not at a prompt".
t.output(b"\x1b]133;C;make\x07building\r\n");
assert!(t.prompt(false, None).is_none());
// A `D` the daemon did not believe — relayed by something running in
// the foreground, reported as "not at a prompt" — ends nothing.
t.output(b"\x1b]133;D;7\x07more\r\n");
assert!(t.prompt(false, Some(7)).is_none());
t.output(b"done\r\n\x1b]133;D;2\x07\x1b]133;A\x07$ ");
let done = t.prompt(true, Some(2)).expect("the real end");
assert_eq!(done.exit, Some(2));
assert_eq!(
t.take().0,
b"building\r\n\x1b]133;D;7\x07more\r\ndone\r\n".to_vec(),
"the output runs to the D the shell's own prompt followed"
);
}
#[test]
fn a_prompt_with_no_command_before_it_means_the_line_never_ran() {
let mut t = Transcript::new();
t.output(b"echo (\r\nzsh: parse error near `('\r\n\x1b]133;A\x07% ");
let done = t.prompt(true, None).expect("the shell is back");
assert_eq!((done.exit, done.ran), (None, false));
assert_eq!(
t.take().0,
b"echo (\r\nzsh: parse error near `('\r\n".to_vec(),
"what the shell said about the line is the only output there is"
);
}
#[test]
fn the_exit_code_falls_back_to_the_mark_itself() {
let mut t = Transcript::new();
t.output(b"\x1b]133;C\x07\x1b]133;D;5\x1b\\");
let done = t
.prompt(true, None)
.expect("ST terminates a mark as well as BEL");
assert_eq!(done.exit, Some(5));
assert!(t.take().0.is_empty());
}
#[test]
fn a_timeout_keeps_what_the_command_has_printed_so_far() {
let mut t = Transcript::new();
t.output(b"sleep 9\r\n\x1b]133;C;sleep 9\x07tick\r\n");
assert_eq!(t.take().0, b"tick\r\n".to_vec());
}
#[test]
fn output_past_the_bound_is_dropped_from_the_front() {
let mut t = Transcript::new();
t.output(b"\x1b]133;C\x07");
let chunk = vec![b'x'; KEEP_OUTPUT];
for _ in 0..3 {
t.output(&chunk);
t.compact();
}
t.output(b"end\x1b]133;D;0\x07");
assert!(t.prompt(true, Some(0)).is_some());
let (out, dropped) = t.take();
assert_eq!(out.len(), KEEP_OUTPUT);
assert!(out.ends_with(b"end"));
assert_eq!(out.len() + dropped, KEEP_OUTPUT * 3 + 3);
}
}