Files
tty7/crates/tty7-cli/src/backend.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

269 lines
9.5 KiB
Rust

use anyhow::Result;
use tty7_core::core::agent_hooks::{HookAgent, HooksState};
use tty7_core::core::session::WorkspaceId;
use tty7_core::daemon::control::{ControlEvent, ControlHelloOk, ControlRequest, ReplyOk};
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<u8>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RunSpec {
pub workspace: Option<WorkspaceId>,
pub cwd: Option<String>,
pub command: Vec<String>,
pub keep: bool,
}
pub trait Backend {
fn control(&mut self, req: ControlRequest) -> Result<ReplyOk>;
fn hello(&mut self) -> Result<ControlHelloOk>;
fn spawn_shell(&mut self, workspace: WorkspaceId, cwd: Option<String>) -> Result<u64>;
fn send_input(&mut self, pane: u64, bytes: Vec<u8>) -> Result<()>;
/// The pane's replay: the newest segment, or every one with `scrollback`.
fn capture(&mut self, pane: u64, scrollback: bool) -> Result<Vec<CaptureSegment>>;
fn procs(&mut self, pane: u64) -> Result<PaneProcs>;
/// The install state for an agent's status hooks on the machine this
/// backend addresses. Routed machines return `None`: their config belongs
/// to the remote host and must not be guessed from the local filesystem.
fn agent_hooks_state(&mut self, agent: HookAgent) -> Option<HooksState>;
/// Every pane the server is actually running, straight from its registry —
/// including ones no workspace tree references. The machine tree cannot see
/// those, so this is the only way an orphan becomes visible.
fn list_panes(&mut self) -> Result<Vec<PaneInfo>>;
/// Hang up a pane by id alone, with no workspace to route the request
/// through. Orphans have no workspace, so `PaneClose` cannot reach them.
fn kill_pane(&mut self, pane: u64) -> Result<()>;
fn run_spawn(&mut self, spec: RunSpec) -> Result<u64>;
fn run_wait(&mut self) -> Result<Option<i32>>;
/// Type `line` at the pane's shell prompt and follow the pane until its
/// prompt comes back, the deadline passes, or it exits. Refuses — sending
/// nothing — a pane with no shell integration or one that is not at a
/// prompt; see [`ExecEnd`](crate::exec::ExecEnd).
fn exec(
&mut self,
pane: u64,
line: Vec<u8>,
timeout: Option<std::time::Duration>,
) -> Result<crate::exec::ExecRun>;
fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()>;
}
#[cfg(test)]
pub mod mock {
use std::collections::VecDeque;
use anyhow::Result;
use tty7_core::core::agent_hooks::{HookAgent, HooksState};
use tty7_core::core::machine::Machine;
use tty7_core::core::session::WorkspaceId;
use tty7_core::daemon::control::{
CONTROL_VERSION, ControlEvent, ControlHelloOk, ControlRequest, ReplyOk, feature,
};
use tty7_core::daemon::protocol::{PROTOCOL_VERSION, PaneInfo, PaneProcs};
use super::{Backend, CaptureSegment, RunSpec};
use crate::exec::{ExecEnd, ExecRun};
pub struct MockBackend {
pub machine: Machine,
pub replies: VecDeque<ReplyOk>,
pub control_calls: Vec<ControlRequest>,
pub spawned: Vec<(WorkspaceId, Option<String>)>,
pub next_spawn_id: u64,
pub sent: Vec<(u64, Vec<u8>)>,
pub captured: Vec<(u64, bool)>,
pub capture_segments: Vec<CaptureSegment>,
pub procs_calls: Vec<u64>,
pub procs_reply: PaneProcs,
/// Consumed one per call before falling back to `procs_reply`, so a
/// test can script a pane going busy and then quiet again — which is
/// the whole of what `wait --until free` watches for.
pub procs_replies: VecDeque<PaneProcs>,
pub agent_hooks_states: Vec<(HookAgent, HooksState)>,
pub registry: Vec<PaneInfo>,
pub killed: Vec<u64>,
pub kill_failures: Vec<u64>,
pub runs: Vec<RunSpec>,
pub run_exit: Option<i32>,
pub events: Vec<ControlEvent>,
/// Set to make `hello` fail — doctor's unreachable-server branch.
pub unreachable: bool,
pub execs: Vec<(u64, Vec<u8>, Option<std::time::Duration>)>,
pub exec_run: ExecRun,
}
impl Default for MockBackend {
fn default() -> MockBackend {
MockBackend {
machine: Machine::default(),
replies: VecDeque::new(),
control_calls: Vec::new(),
spawned: Vec::new(),
next_spawn_id: 6,
sent: Vec::new(),
captured: Vec::new(),
capture_segments: Vec::new(),
procs_calls: Vec::new(),
procs_reply: PaneProcs::default(),
procs_replies: VecDeque::new(),
agent_hooks_states: Vec::new(),
registry: Vec::new(),
killed: Vec::new(),
kill_failures: Vec::new(),
runs: Vec::new(),
run_exit: Some(0),
events: Vec::new(),
unreachable: false,
execs: Vec::new(),
exec_run: ExecRun {
end: ExecEnd::Finished {
exit: Some(0),
ran: true,
},
output: Vec::new(),
dropped: 0,
size: tty7_core::daemon::protocol::WinSize {
cols: 80,
rows: 24,
cell_w: 8,
cell_h: 16,
},
},
}
}
}
impl MockBackend {
pub fn with_machine(machine: Machine) -> MockBackend {
MockBackend {
machine,
..MockBackend::default()
}
}
}
impl Backend for MockBackend {
fn control(&mut self, req: ControlRequest) -> Result<ReplyOk> {
let is_machine_get = req == ControlRequest::MachineGet;
self.control_calls.push(req);
if is_machine_get {
return Ok(ReplyOk::MachineTree(Box::new(self.machine.clone())));
}
Ok(self.replies.pop_front().unwrap_or(ReplyOk::Unit))
}
fn hello(&mut self) -> Result<ControlHelloOk> {
if self.unreachable {
anyhow::bail!("connect: no server is listening");
}
Ok(ControlHelloOk {
control_version: CONTROL_VERSION,
protocol_version: PROTOCOL_VERSION,
build: "mock".into(),
separator: '\\',
home: "C:\\Users\\mock".into(),
features: vec![feature::CONTROL.into(), feature::MACHINE_TREE.into()],
instance: "mock-instance".into(),
})
}
fn spawn_shell(&mut self, workspace: WorkspaceId, cwd: Option<String>) -> Result<u64> {
self.spawned.push((workspace, cwd));
let id = self.next_spawn_id;
self.next_spawn_id += 1;
Ok(id)
}
fn send_input(&mut self, pane: u64, bytes: Vec<u8>) -> Result<()> {
self.sent.push((pane, bytes));
Ok(())
}
fn capture(&mut self, pane: u64, scrollback: bool) -> Result<Vec<CaptureSegment>> {
self.captured.push((pane, scrollback));
Ok(self.capture_segments.clone())
}
fn procs(&mut self, pane: u64) -> Result<PaneProcs> {
self.procs_calls.push(pane);
Ok(self
.procs_replies
.pop_front()
.unwrap_or_else(|| self.procs_reply.clone()))
}
fn agent_hooks_state(&mut self, agent: HookAgent) -> Option<HooksState> {
self.agent_hooks_states
.iter()
.find_map(|(candidate, state)| (*candidate == agent).then_some(*state))
}
fn list_panes(&mut self) -> Result<Vec<PaneInfo>> {
Ok(self.registry.clone())
}
fn kill_pane(&mut self, pane: u64) -> Result<()> {
self.killed.push(pane);
if self.kill_failures.contains(&pane) {
anyhow::bail!("mock hangup failure for pane %{pane}");
}
Ok(())
}
fn run_spawn(&mut self, spec: RunSpec) -> Result<u64> {
self.runs.push(spec);
let id = self.next_spawn_id;
self.next_spawn_id += 1;
Ok(id)
}
fn run_wait(&mut self) -> Result<Option<i32>> {
Ok(self.run_exit)
}
fn exec(
&mut self,
pane: u64,
line: Vec<u8>,
timeout: Option<std::time::Duration>,
) -> Result<ExecRun> {
self.execs.push((pane, line, timeout));
Ok(self.exec_run.clone())
}
fn events(&mut self, on_event: &mut dyn FnMut(ControlEvent) -> Result<()>) -> Result<()> {
for event in self.events.drain(..) {
on_event(event)?;
}
Ok(())
}
}
}