mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
Merge branch 'main' into fix/restart-resync
This commit is contained in:
Generated
+1
@@ -9693,6 +9693,7 @@ dependencies = [
|
||||
name = "tty7-cli"
|
||||
version = "26.7.6"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
"clap",
|
||||
"libc",
|
||||
|
||||
+30
-23
@@ -91,29 +91,9 @@ image = "0.25"
|
||||
# to gpui's rev so the shared `http_client`/`zed-reqwest` versions stay aligned.
|
||||
reqwest_client = { git = "https://github.com/zed-industries/zed", rev = "1d217ee39d381ac101b7cf49d3d22451ac1093fe" }
|
||||
|
||||
# Our fork of Zed's alacritty_terminal fork. Used by the *client*
|
||||
# (`terminal::remote`) for the VT parser + grid (`Term`/`ansi::Processor`) that
|
||||
# renders the mirror. The daemon's PTY itself is driven by `portable-pty` below.
|
||||
#
|
||||
# The `tty7` branch is Zed's `fcf32fe` (the rev Zed pins) plus two commits, both
|
||||
# still missing from alacritty master as of 852e971:
|
||||
#
|
||||
# 1. `push_keyboard_mode` capped its stack by removing from `title_stack` instead
|
||||
# of `keyboard_mode_stack` — a copy-paste slip from `push_title` that compiles
|
||||
# because both are `Vec`s. With `kitty_keyboard` on (see
|
||||
# `terminal_config_from_user`) every overflowing push silently drops a saved
|
||||
# title, and once the title stack is empty `Vec::remove(0)` panics — 4097
|
||||
# unpopped `CSI > 1 u` pushes, about 20KB of output, kill the reader thread and
|
||||
# freeze the pane.
|
||||
# 2. `input` reserves columns per `char`, so an emoji written as base + U+FE0F
|
||||
# (`❤️`, `🗂️`, `⚠️` — any base whose East Asian Width is Neutral) gets one
|
||||
# column instead of two and shifts the rest of the line left by one. The fork
|
||||
# re-scores the sequence with `UnicodeWidthStr` and widens the cell (issue
|
||||
# #203).
|
||||
#
|
||||
# Each patch has a guard test in `src/terminal/remote.rs`; drop this fork once
|
||||
# both land upstream.
|
||||
alacritty_terminal = { git = "https://github.com/l0ng-ai/alacritty", rev = "b79e70484b13308ce766a763531ac25a8302c012" }
|
||||
# See `[workspace.dependencies]`: the pin is shared with tty7-cli, which parses
|
||||
# `capture` output through the same grid.
|
||||
alacritty_terminal.workspace = true
|
||||
|
||||
# Desktop notifications driven by OSC 9 / OSC 777 escape sequences. Cross-platform;
|
||||
# the macOS backend uses the deprecated NSUserNotification (weak — a completion
|
||||
@@ -247,6 +227,33 @@ gpui = { git = "https://github.com/zed-industries/zed", rev = "1d217ee39d381ac10
|
||||
# package manifest, so they never reach the Windows/macOS builds.
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "1d217ee39d381ac101b7cf49d3d22451ac1093fe", features = ["font-kit", "runtime_shaders"] }
|
||||
|
||||
# Our fork of Zed's alacritty_terminal fork: the VT parser + grid
|
||||
# (`Term`/`ansi::Processor`). Two packages read it and they must agree, hence
|
||||
# this being a workspace pin rather than one each — the GUI (`terminal::remote`)
|
||||
# to render the live mirror, and tty7-cli to turn a captured pane back into text
|
||||
# (`tty7 capture --plain`). The daemon parses nothing; it is a byte pipe, and its
|
||||
# PTY is driven by `portable-pty`.
|
||||
#
|
||||
# The `tty7` branch is Zed's `fcf32fe` (the rev Zed pins) plus two commits, both
|
||||
# still missing from alacritty master as of 852e971:
|
||||
#
|
||||
# 1. `push_keyboard_mode` capped its stack by removing from `title_stack` instead
|
||||
# of `keyboard_mode_stack` — a copy-paste slip from `push_title` that compiles
|
||||
# because both are `Vec`s. With `kitty_keyboard` on (see
|
||||
# `terminal_config_from_user`) every overflowing push silently drops a saved
|
||||
# title, and once the title stack is empty `Vec::remove(0)` panics — 4097
|
||||
# unpopped `CSI > 1 u` pushes, about 20KB of output, kill the reader thread and
|
||||
# freeze the pane.
|
||||
# 2. `input` reserves columns per `char`, so an emoji written as base + U+FE0F
|
||||
# (`❤️`, `🗂️`, `⚠️` — any base whose East Asian Width is Neutral) gets one
|
||||
# column instead of two and shifts the rest of the line left by one. The fork
|
||||
# re-scores the sequence with `UnicodeWidthStr` and widens the cell (issue
|
||||
# #203).
|
||||
#
|
||||
# Each patch has a guard test in `src/terminal/remote.rs`; drop this fork once
|
||||
# both land upstream.
|
||||
alacritty_terminal = { git = "https://github.com/l0ng-ai/alacritty", rev = "b79e70484b13308ce766a763531ac25a8302c012" }
|
||||
|
||||
anyhow = "1"
|
||||
log = "0.4"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
|
||||
@@ -33,6 +33,14 @@ clap = { version = "4", features = ["derive"] }
|
||||
# every column after it out of line. Already in the tree via alacritty.
|
||||
unicode-width = "0.2"
|
||||
|
||||
# The VT parser behind `capture --plain`. A pane's replay is a byte stream, and
|
||||
# recovering the text from it is a terminal's job, not a regex's: only the grid
|
||||
# knows that a break at column 120 was a wrap rather than a newline, that a CR
|
||||
# meant "overwrite this line", or which cell a wide char and its spacer share.
|
||||
# The same crate the GUI renders with, at the same rev, so the two agree about
|
||||
# what a pane says.
|
||||
alacritty_terminal.workspace = true
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
use anyhow::Result;
|
||||
use tty7_core::core::session::WorkspaceId;
|
||||
use tty7_core::daemon::control::{ControlEvent, ControlHelloOk, ControlRequest, ReplyOk};
|
||||
use tty7_core::daemon::protocol::{PaneInfo, PaneProcs};
|
||||
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>,
|
||||
@@ -24,7 +37,8 @@ pub trait Backend {
|
||||
|
||||
fn send_input(&mut self, pane: u64, bytes: Vec<u8>) -> Result<()>;
|
||||
|
||||
fn capture(&mut self, pane: u64, scrollback: bool) -> Result<String>;
|
||||
/// 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>;
|
||||
|
||||
@@ -56,7 +70,7 @@ pub mod mock {
|
||||
};
|
||||
use tty7_core::daemon::protocol::{PROTOCOL_VERSION, PaneInfo, PaneProcs};
|
||||
|
||||
use super::{Backend, RunSpec};
|
||||
use super::{Backend, CaptureSegment, RunSpec};
|
||||
|
||||
pub struct MockBackend {
|
||||
pub machine: Machine,
|
||||
@@ -66,7 +80,7 @@ pub mod mock {
|
||||
pub next_spawn_id: u64,
|
||||
pub sent: Vec<(u64, Vec<u8>)>,
|
||||
pub captured: Vec<(u64, bool)>,
|
||||
pub capture_text: String,
|
||||
pub capture_segments: Vec<CaptureSegment>,
|
||||
pub procs_calls: Vec<u64>,
|
||||
pub procs_reply: PaneProcs,
|
||||
pub registry: Vec<PaneInfo>,
|
||||
@@ -86,7 +100,7 @@ pub mod mock {
|
||||
next_spawn_id: 6,
|
||||
sent: Vec::new(),
|
||||
captured: Vec::new(),
|
||||
capture_text: String::new(),
|
||||
capture_segments: Vec::new(),
|
||||
procs_calls: Vec::new(),
|
||||
procs_reply: PaneProcs::default(),
|
||||
registry: Vec::new(),
|
||||
@@ -141,9 +155,9 @@ pub mod mock {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn capture(&mut self, pane: u64, scrollback: bool) -> Result<String> {
|
||||
fn capture(&mut self, pane: u64, scrollback: bool) -> Result<Vec<CaptureSegment>> {
|
||||
self.captured.push((pane, scrollback));
|
||||
Ok(self.capture_text.clone())
|
||||
Ok(self.capture_segments.clone())
|
||||
}
|
||||
|
||||
fn procs(&mut self, pane: u64) -> Result<PaneProcs> {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::io::Write as _;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -12,7 +11,7 @@ use tty7_core::daemon::control::{
|
||||
use tty7_core::daemon::protocol::{DaemonMsg, PaneInfo, PaneProcs, ShellSpec, WinSize};
|
||||
use tty7_core::daemon::router::RouteTarget;
|
||||
|
||||
use super::{Backend, RunSpec};
|
||||
use super::{Backend, CaptureSegment, RunSpec};
|
||||
|
||||
const SESSION_SIZE: WinSize = WinSize {
|
||||
cols: 120,
|
||||
@@ -146,7 +145,7 @@ impl Backend for RealBackend {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn capture(&mut self, pane: u64, scrollback: bool) -> Result<String> {
|
||||
fn capture(&mut self, pane: u64, scrollback: bool) -> Result<Vec<CaptureSegment>> {
|
||||
let mut session = self
|
||||
.pane_client()?
|
||||
.observe(pane, SESSION_SIZE)
|
||||
@@ -156,11 +155,21 @@ impl Backend for RealBackend {
|
||||
// macOS). That must not turn a completed capture into an error — the
|
||||
// replay we already collected is the answer.
|
||||
let _ = session.set_recv_timeout(Some(REPLAY_FIRST_WAIT));
|
||||
let mut snapshots: Vec<Vec<u8>> = Vec::new();
|
||||
// The daemon replays each ring segment as `Size` then `Snapshot`, so the
|
||||
// last size seen is the one the next snapshot was recorded at. Observing
|
||||
// does not resize anything — the daemon ignores the size we asked with —
|
||||
// so `SESSION_SIZE` is only the stand-in for a server too old to have
|
||||
// sent one.
|
||||
let mut segments: Vec<CaptureSegment> = Vec::new();
|
||||
let mut size = SESSION_SIZE;
|
||||
loop {
|
||||
match session.recv() {
|
||||
Ok(DaemonMsg::Size(seen)) => {
|
||||
size = seen;
|
||||
let _ = session.set_recv_timeout(Some(REPLAY_SETTLE));
|
||||
}
|
||||
Ok(DaemonMsg::Snapshot(bytes)) => {
|
||||
snapshots.push(bytes);
|
||||
segments.push(CaptureSegment { size, bytes });
|
||||
let _ = session.set_recv_timeout(Some(REPLAY_SETTLE));
|
||||
}
|
||||
Ok(DaemonMsg::Output(_)) | Ok(DaemonMsg::Exited { .. }) => break,
|
||||
@@ -173,12 +182,11 @@ impl Backend for RealBackend {
|
||||
}
|
||||
}
|
||||
let _ = session.detach();
|
||||
let bytes: Vec<u8> = if scrollback {
|
||||
snapshots.concat()
|
||||
} else {
|
||||
snapshots.pop().unwrap_or_default()
|
||||
};
|
||||
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
||||
if !scrollback {
|
||||
// Only the newest segment, which is the one holding the screen.
|
||||
segments.drain(..segments.len().saturating_sub(1));
|
||||
}
|
||||
Ok(segments)
|
||||
}
|
||||
|
||||
fn procs(&mut self, pane: u64) -> Result<PaneProcs> {
|
||||
@@ -234,12 +242,13 @@ impl Backend for RealBackend {
|
||||
.running
|
||||
.take()
|
||||
.ok_or_else(|| anyhow!("run_wait without a spawned command"))?;
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
let code = loop {
|
||||
match session.recv() {
|
||||
Ok(DaemonMsg::Output(bytes)) | Ok(DaemonMsg::Snapshot(bytes)) => {
|
||||
stdout.write_all(&bytes)?;
|
||||
stdout.flush()?;
|
||||
// Not `stdout.write_all`: a caller who stopped reading
|
||||
// (`tty7 run -- … | head`) must end the pipeline, not turn
|
||||
// into an error about the command we were streaming.
|
||||
crate::stdio::out(&bytes);
|
||||
}
|
||||
Ok(DaemonMsg::Exited { code }) => break code,
|
||||
Ok(_) => {}
|
||||
|
||||
@@ -67,8 +67,9 @@ pub enum Command {
|
||||
Send(SendArgs),
|
||||
|
||||
#[command(
|
||||
about = "Print a pane's output with its ANSI escapes intact, decoded as UTF-8 \
|
||||
(invalid bytes become U+FFFD): the newest scrollback segment by default"
|
||||
about = "Print a pane's output — as text with `--plain`, otherwise with its ANSI \
|
||||
escapes intact, decoded as UTF-8 (invalid bytes become U+FFFD): the \
|
||||
newest scrollback segment by default"
|
||||
)]
|
||||
Capture(CaptureArgs),
|
||||
|
||||
@@ -197,11 +198,18 @@ pub struct CaptureArgs {
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
help = "Print the whole scrollback ring, raw ANSI bytes; the ring splits into \
|
||||
segments on resize, and without this flag only the last segment is \
|
||||
printed (for a never-resized pane the two are identical)"
|
||||
help = "Print the whole scrollback ring; the ring splits into segments on resize, \
|
||||
and without this flag only the last segment is printed (for a \
|
||||
never-resized pane the two are identical)"
|
||||
)]
|
||||
pub scrollback: bool,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
help = "Replay the output through a terminal and print the resulting text: no \
|
||||
escapes, wrapped lines rejoined, overwrites and cursor moves applied"
|
||||
)]
|
||||
pub plain: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
@@ -617,9 +625,28 @@ mod tests {
|
||||
panic!("capture did not parse");
|
||||
};
|
||||
assert!(args.scrollback);
|
||||
assert!(!args.plain, "raw bytes stay the default");
|
||||
assert_eq!(args.target.as_deref(), Some("%3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_plain_is_its_own_flag_and_composes_with_scrollback() {
|
||||
// The two answer different questions — how much to replay, and whether
|
||||
// to replay it through a grid — so neither implies or excludes the other.
|
||||
let Some(Command::Capture(args)) = parse(&["tty7", "capture", "--plain"]).command else {
|
||||
panic!("capture did not parse");
|
||||
};
|
||||
assert!(args.plain && !args.scrollback);
|
||||
assert!(args.target.is_none(), "the pane comes from $TTY7_PANE");
|
||||
|
||||
let Some(Command::Capture(args)) =
|
||||
parse(&["tty7", "capture", "%3", "--plain", "--scrollback"]).command
|
||||
else {
|
||||
panic!("capture did not parse");
|
||||
};
|
||||
assert!(args.plain && args.scrollback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_errors_exit_with_code_2() {
|
||||
let err = Cli::try_parse_from(["tty7", "ws", "frobnicate"]).unwrap_err();
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::cli::{
|
||||
};
|
||||
use crate::output;
|
||||
use crate::resolve;
|
||||
use crate::screen;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Report {
|
||||
@@ -386,7 +387,16 @@ fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outc
|
||||
|
||||
fn capture(args: CaptureArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outcome> {
|
||||
let pane = address::pane_or_context(args.target.as_deref(), ctx)?;
|
||||
let text = backend.capture(pane, args.scrollback)?;
|
||||
let segments = backend.capture(pane, args.scrollback)?;
|
||||
// Raw is the default and stays byte-for-byte what the daemon stored, joined
|
||||
// in replay order; `--plain` hands the same bytes to a grid instead. Either
|
||||
// way `--json` carries whatever was printed, so a caller reads one field.
|
||||
let text = if args.plain {
|
||||
screen::render(&segments)
|
||||
} else {
|
||||
let bytes: Vec<u8> = segments.into_iter().flat_map(|s| s.bytes).collect();
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
};
|
||||
report(text.clone(), json!({ "pane": pane, "text": text }))
|
||||
}
|
||||
|
||||
@@ -588,9 +598,9 @@ fn pane_close(target: Option<&str>, ctx: &Context, backend: &mut dyn Backend) ->
|
||||
fn events(json_mode: bool, backend: &mut dyn Backend) -> Result<Outcome> {
|
||||
backend.events(&mut |event| {
|
||||
if json_mode {
|
||||
println!("{}", serde_json::to_string(&event)?);
|
||||
crate::stdio::line(&serde_json::to_string(&event)?);
|
||||
} else {
|
||||
println!("{}", event_line(&event));
|
||||
crate::stdio::line(&event_line(&event));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -743,6 +753,20 @@ mod tests {
|
||||
MockBackend::with_machine(two_workspace_machine())
|
||||
}
|
||||
|
||||
/// A replayed snapshot at a 20-column pane, which is narrow enough that the
|
||||
/// wrapping tests can wrap without pages of fixture.
|
||||
fn segment(bytes: &[u8]) -> crate::backend::CaptureSegment {
|
||||
crate::backend::CaptureSegment {
|
||||
size: tty7_core::daemon::protocol::WinSize {
|
||||
cols: 20,
|
||||
rows: 10,
|
||||
cell_w: 8,
|
||||
cell_h: 16,
|
||||
},
|
||||
bytes: bytes.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_cli(args: &[&str], ctx: &Context, backend: &mut MockBackend) -> Outcome {
|
||||
execute(cli(args), ctx, backend).expect("this command should succeed against the mock")
|
||||
}
|
||||
@@ -1227,7 +1251,7 @@ mod tests {
|
||||
#[test]
|
||||
fn capture_and_procs_are_wired_through_the_backend() {
|
||||
let mut backend = mock();
|
||||
backend.capture_text = "$ make\nok\n".into();
|
||||
backend.capture_segments = vec![segment(b"$ make\r\nok\r\n")];
|
||||
let out = run_cli(
|
||||
&["tty7", "capture", "%2", "--scrollback"],
|
||||
&Context::default(),
|
||||
@@ -1236,14 +1260,60 @@ mod tests {
|
||||
assert_eq!(backend.captured, vec![(2, true)]);
|
||||
assert_eq!(
|
||||
human(out),
|
||||
"$ make\nok\n",
|
||||
"capture prints the pane verbatim"
|
||||
"$ make\r\nok\r\n",
|
||||
"without --plain the pane's bytes are passed through untouched"
|
||||
);
|
||||
|
||||
run_cli(&["tty7", "procs", "%1"], &Context::default(), &mut backend);
|
||||
assert_eq!(backend.procs_calls, vec![1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_plain_replays_the_bytes_through_a_grid() {
|
||||
let mut backend = mock();
|
||||
// Coloured, CR-overwritten, and wrapped past the 20-column pane: three
|
||||
// things the raw form shows verbatim and `--plain` has to resolve.
|
||||
backend.capture_segments = vec![segment(
|
||||
b"\x1b[32m$ make\x1b[0m\r\n10%\r100%\r\nabcdefghijklmnopqrstuvwxyz\r\n",
|
||||
)];
|
||||
let raw = human(run_cli(
|
||||
&["tty7", "capture", "%2"],
|
||||
&Context::default(),
|
||||
&mut backend,
|
||||
));
|
||||
assert!(
|
||||
raw.contains("\x1b[32m"),
|
||||
"the default keeps escapes: {raw:?}"
|
||||
);
|
||||
|
||||
let plain = human(run_cli(
|
||||
&["tty7", "capture", "%2", "--plain"],
|
||||
&Context::default(),
|
||||
&mut backend,
|
||||
));
|
||||
assert_eq!(plain, "$ make\n100%\nabcdefghijklmnopqrstuvwxyz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_json_carries_whichever_form_was_asked_for() {
|
||||
let mut backend = mock();
|
||||
backend.capture_segments = vec![segment(b"\x1b[31mred\x1b[0m\r\n")];
|
||||
let raw = json_of(run_cli(
|
||||
&["tty7", "capture", "%2"],
|
||||
&Context::default(),
|
||||
&mut backend,
|
||||
));
|
||||
assert_eq!(raw["text"], json!("\u{1b}[31mred\u{1b}[0m\r\n"));
|
||||
|
||||
let plain = json_of(run_cli(
|
||||
&["tty7", "capture", "%2", "--plain"],
|
||||
&Context::default(),
|
||||
&mut backend,
|
||||
));
|
||||
assert_eq!(plain["text"], json!("red"));
|
||||
assert_eq!(plain["pane"], json!(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_passes_the_command_and_its_exit_code_through() {
|
||||
let mut backend = mock();
|
||||
|
||||
@@ -4,13 +4,17 @@ mod cli;
|
||||
mod commands;
|
||||
mod output;
|
||||
mod resolve;
|
||||
mod screen;
|
||||
mod server;
|
||||
mod stdio;
|
||||
#[cfg(test)]
|
||||
mod testbed;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
// Before the first byte goes out, and before clap can print a usage error.
|
||||
stdio::end_pipelines_quietly();
|
||||
let cli = cli::Cli::parse();
|
||||
let json = cli.json;
|
||||
let quiet = cli.quiet;
|
||||
@@ -21,10 +25,8 @@ fn main() -> std::process::ExitCode {
|
||||
// ours. The report rides along anyway: --json must not go silent just
|
||||
// because the verb also carries an exit code.
|
||||
Ok(commands::Outcome::Exit(code, report)) => {
|
||||
// No flush needed before the exit below: `stdio::out` already did.
|
||||
emit(report, json, quiet);
|
||||
// process::exit runs no destructors and flushes nothing; stdout is
|
||||
// a LineWriter, so anything not ending in a newline would be lost.
|
||||
let _ = std::io::Write::flush(&mut std::io::stdout());
|
||||
std::process::exit(code)
|
||||
}
|
||||
Ok(commands::Outcome::Report(report)) => {
|
||||
@@ -46,10 +48,10 @@ fn emit(report: commands::Report, json: bool, quiet: bool) {
|
||||
}
|
||||
if json {
|
||||
if !report.json.is_null() {
|
||||
println!("{}", report.json);
|
||||
stdio::line(&report.json.to_string());
|
||||
}
|
||||
} else if !report.human.is_empty() {
|
||||
print!("{}", ensure_newline(report.human));
|
||||
stdio::out(ensure_newline(report.human).as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Turning a captured pane back into text.
|
||||
//!
|
||||
//! `tty7 capture` hands back what the daemon stored: the PTY's bytes, escapes
|
||||
//! and all. Recovering the text from that is a terminal's job, not a regex's.
|
||||
//! A stripper that deletes `ESC[`-sequences gets the easy 90% and then lies
|
||||
//! about the rest, because the information it needs was never in the byte
|
||||
//! stream to begin with — it is in the grid those bytes drive:
|
||||
//!
|
||||
//! - **Wrapping.** A shell writing 200 characters into a 203-column pane emits
|
||||
//! 200 characters and no newline. At 120 columns the same bytes are two
|
||||
//! display rows of one logical line. Only the grid knows which, via
|
||||
//! `WRAPLINE`, and `bounds_to_string` honours it — so a wrapped line comes
|
||||
//! back joined instead of split at an invented newline.
|
||||
//! - **Overwriting.** `\r` means "back to column 0", and what follows replaces
|
||||
//! what was there. A stripper can only guess (turning it into a newline, which
|
||||
//! is why a syntax-highlighting shell used to read as `eecho …echo`). Replayed
|
||||
//! through a grid, the cell simply holds the last thing written to it.
|
||||
//! - **Cursor addressing.** `ESC[5;80H` puts text somewhere specific. Delete the
|
||||
//! escape and the text lands wherever the previous character left off.
|
||||
//! - **Width.** A wide char owns two cells and its spacer must not become a
|
||||
//! second character; combining marks belong to the cell they modify.
|
||||
//!
|
||||
//! So this parses, using the same crate and rev the GUI renders panes with —
|
||||
//! meaning `capture --plain` and the window agree about what a pane says.
|
||||
|
||||
use alacritty_terminal::event::VoidListener;
|
||||
use alacritty_terminal::grid::Dimensions as _;
|
||||
use alacritty_terminal::index::{Column, Point};
|
||||
use alacritty_terminal::term::{Config, Term};
|
||||
use alacritty_terminal::vte::ansi::Processor;
|
||||
use tty7_core::daemon::protocol::WinSize;
|
||||
|
||||
use crate::backend::CaptureSegment;
|
||||
|
||||
/// What `Term` needs to know about the grid it is filling.
|
||||
struct GridSize {
|
||||
cols: usize,
|
||||
rows: usize,
|
||||
}
|
||||
|
||||
impl GridSize {
|
||||
fn of(size: WinSize) -> GridSize {
|
||||
// A zero-sized grid panics inside alacritty's index arithmetic, and a
|
||||
// server that never sent a size leaves us with whatever the caller
|
||||
// guessed — so clamp rather than trust.
|
||||
GridSize {
|
||||
cols: (size.cols as usize).max(1),
|
||||
rows: (size.rows as usize).max(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl alacritty_terminal::grid::Dimensions for GridSize {
|
||||
fn total_lines(&self) -> usize {
|
||||
self.rows
|
||||
}
|
||||
fn screen_lines(&self) -> usize {
|
||||
self.rows
|
||||
}
|
||||
fn columns(&self) -> usize {
|
||||
self.cols
|
||||
}
|
||||
}
|
||||
|
||||
/// Replay every segment through a grid of its own size and join the results.
|
||||
///
|
||||
/// Each segment gets its own `Term` because a segment exists precisely because
|
||||
/// the pane was a different size then; feeding them all to one grid would
|
||||
/// re-wrap the older output at the newest width.
|
||||
pub fn render(segments: &[CaptureSegment]) -> String {
|
||||
let mut out = String::new();
|
||||
for segment in segments {
|
||||
let text = render_segment(segment.size, &segment.bytes);
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !out.is_empty() && !out.ends_with('\n') {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&text);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn render_segment(size: WinSize, bytes: &[u8]) -> String {
|
||||
let size = GridSize::of(size);
|
||||
// The scrollback the grid keeps is for output this segment pushed off its
|
||||
// own screen: a segment can be far longer than one screenful, and dropping
|
||||
// what scrolled away would answer a different question than the raw form
|
||||
// does. `Config::default()` allows 10k lines, which is the daemon ring's
|
||||
// order of magnitude.
|
||||
let mut term = Term::new(Config::default(), &size, VoidListener);
|
||||
// Spelled out because `Processor` is generic over its sync-timeout policy
|
||||
// and nothing here pins it; the default is the one the GUI parses with.
|
||||
let mut parser: Processor = Processor::new();
|
||||
parser.advance(&mut term, bytes);
|
||||
|
||||
let start = Point::new(term.topmost_line(), Column(0));
|
||||
let end = Point::new(term.bottommost_line(), term.last_column());
|
||||
let text = term.bounds_to_string(start, end);
|
||||
trim_blank_edges(&text)
|
||||
}
|
||||
|
||||
/// Drop the blank lines a rectangle leaves at either end of the text.
|
||||
///
|
||||
/// A grid is a fixed rectangle, so the rows under the last line of output are
|
||||
/// real blank cells — 20 of them after a shell prompt, which is padding, not
|
||||
/// content. The top gets them too: `ESC[2J` scrolls the screen into history, so
|
||||
/// a TUI that clears before drawing starts its capture with blank history lines.
|
||||
///
|
||||
/// Blank lines *between* output are content and stay. So does the indentation of
|
||||
/// a line the app positioned — only whole empty rows at the edges go.
|
||||
fn trim_blank_edges(text: &str) -> String {
|
||||
let lines: Vec<&str> = text.split('\n').collect();
|
||||
let blank = |line: &&str| line.trim().is_empty();
|
||||
let Some(first) = lines.iter().position(|l| !blank(l)) else {
|
||||
return String::new();
|
||||
};
|
||||
let last = lines
|
||||
.iter()
|
||||
.rposition(|l| !blank(l))
|
||||
.expect("a non-blank line was just found");
|
||||
lines[first..=last].join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn size(cols: u16, rows: u16) -> WinSize {
|
||||
WinSize {
|
||||
cols,
|
||||
rows,
|
||||
cell_w: 8,
|
||||
cell_h: 16,
|
||||
}
|
||||
}
|
||||
|
||||
fn plain(cols: u16, rows: u16, bytes: &str) -> String {
|
||||
render(&[CaptureSegment {
|
||||
size: size(cols, rows),
|
||||
bytes: bytes.as_bytes().to_vec(),
|
||||
}])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_and_cursor_escapes_leave_no_trace() {
|
||||
let text = plain(40, 5, "\x1b[01;32mhello\x1b[0m \x1b[36mworld\x1b[00m\r\n");
|
||||
assert_eq!(text, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_titles_and_shell_integration_marks_are_not_output() {
|
||||
// The prompt marks a real shell emits around every command. None of it
|
||||
// is text the user ever saw.
|
||||
let text = plain(
|
||||
40,
|
||||
5,
|
||||
"\x1b]2;thomas@box:/tmp\x07\x1b]133;A\x07$ \x1b]133;B\x07ls\r\n\
|
||||
\x1b]133;C\x07a.txt\r\n\x1b]133;D;0\x07",
|
||||
);
|
||||
assert_eq!(text, "$ ls\na.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrapped_line_comes_back_as_one_line() {
|
||||
// 30 characters into a 10-column pane: the terminal wrapped it across
|
||||
// three rows, but the shell wrote one line and that is what it is. A
|
||||
// stripper cannot tell this from three real lines.
|
||||
let text = plain(10, 6, &"x".repeat(30));
|
||||
assert_eq!(text, "x".repeat(30));
|
||||
assert!(!text.contains('\n'), "a wrap is not a newline: {text:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_newline_still_breaks_the_line() {
|
||||
let text = plain(10, 6, "one\r\ntwo\r\n");
|
||||
assert_eq!(text, "one\ntwo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_carriage_return_overwrites_instead_of_breaking() {
|
||||
// What a progress bar does. The user saw "100%", never "10%".
|
||||
let text = plain(20, 4, "10%\r50%\r100%\r\n");
|
||||
assert_eq!(text, "100%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_redrawn_line_reads_as_its_final_state() {
|
||||
// zsh-syntax-highlighting rewrites the line it is echoing, which is why
|
||||
// a regex stripper produces "eecho …echo". The grid holds one copy.
|
||||
let text = plain(30, 4, "echo hi\x1b[7D\x1b[32mecho\x1b[39m\x1b[3C\r\nhi\r\n");
|
||||
assert_eq!(text, "echo hi\nhi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_addressing_puts_text_where_it_was_addressed() {
|
||||
// A TUI drawing at absolute positions. Strip the escapes and the two
|
||||
// words collide; replay them and they are on different rows.
|
||||
let text = plain(20, 4, "\x1b[2J\x1b[1;1Htop\x1b[3;5Hdeep");
|
||||
assert_eq!(text, "top\n\n deep");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_characters_keep_one_cell_pair_and_one_character() {
|
||||
let text = plain(20, 3, "宽宽 ok\r\n");
|
||||
assert_eq!(text, "宽宽 ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combining_marks_stay_with_the_cell_they_modify() {
|
||||
let text = plain(20, 3, "e\u{0301}cole\r\n");
|
||||
assert_eq!(text, "e\u{0301}cole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_blank_rows_under_the_output_are_dropped_but_gaps_are_kept() {
|
||||
let text = plain(20, 24, "first\r\n\r\nthird\r\n");
|
||||
assert_eq!(
|
||||
text, "first\n\nthird",
|
||||
"a blank line between output is content; the 20 rows after it are not"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_capture_renders_to_nothing() {
|
||||
assert_eq!(plain(20, 5, ""), "");
|
||||
assert_eq!(render(&[]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_segment_is_replayed_at_its_own_width() {
|
||||
// The pane was 10 columns wide, then 40. Wrapping the older segment at
|
||||
// the newer width — or the reverse — would split or join the wrong line.
|
||||
let text = render(&[
|
||||
CaptureSegment {
|
||||
size: size(10, 4),
|
||||
bytes: "y".repeat(15).into_bytes(),
|
||||
},
|
||||
CaptureSegment {
|
||||
size: size(40, 4),
|
||||
bytes: b"after the resize\r\n".to_vec(),
|
||||
},
|
||||
]);
|
||||
assert_eq!(text, format!("{}\nafter the resize", "y".repeat(15)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_utf8_does_not_derail_the_parse() {
|
||||
let text = render(&[CaptureSegment {
|
||||
size: size(20, 4),
|
||||
bytes: b"ok \xff\xfe done\r\n".to_vec(),
|
||||
}]);
|
||||
assert!(text.starts_with("ok "), "{text:?}");
|
||||
assert!(text.ends_with("done"), "{text:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Stdout for a command that lives inside pipelines.
|
||||
//!
|
||||
//! A reader that hangs up early — `| head -1`, `| grep -q`, PowerShell's
|
||||
//! `Select-Object -First` — is how a pipeline ends, not a failure. Rust makes
|
||||
//! that awkward twice over: it ignores SIGPIPE at startup so the doomed write
|
||||
//! comes back as an `io::Error` instead, and `println!` turns that error into a
|
||||
//! panic. `tty7 capture %1 | head -1` therefore printed a panic and a backtrace
|
||||
//! note where `cat` would have exited without a word — noise on stderr for a
|
||||
//! correct invocation, from a CLI whose whole point is being driven by scripts
|
||||
//! and agents.
|
||||
//!
|
||||
//! Two mechanisms, one contract:
|
||||
//!
|
||||
//! - On Unix [`end_pipelines_quietly`] hands the job back to the kernel. That
|
||||
//! covers every write site at once, including ones added later, and gives
|
||||
//! callers the ending they already know from `cat` (shells report 141).
|
||||
//! - Windows has no SIGPIPE — the write fails with `ERROR_NO_DATA` instead — so
|
||||
//! [`out`] is the stand-in that recognizes the hang-up and leaves quietly.
|
||||
//!
|
||||
//! Which is why every stdout write in this binary goes through [`out`] or
|
||||
//! [`line`]: the `print!` family has no way to express "the reader left".
|
||||
|
||||
use std::io::Write as _;
|
||||
|
||||
/// Exit code once the reader is gone. Unix rarely gets here — SIGPIPE has
|
||||
/// already killed the process, and shells render that as 141 — so this is
|
||||
/// Windows's answer, and Windows has no signal convention to imitate. Success
|
||||
/// is the honest report: everything the reader asked for did reach it.
|
||||
const READER_GONE: i32 = 0;
|
||||
|
||||
/// Let a hung-up pipe end this process the way it ends `cat`.
|
||||
///
|
||||
/// Called once, before anything is written, so no output can be lost to it.
|
||||
#[cfg(unix)]
|
||||
pub fn end_pipelines_quietly() {
|
||||
// SAFETY: setting a signal disposition is safe from single-threaded startup
|
||||
// code, and SIG_DFL is the disposition every other process starts with —
|
||||
// Rust's runtime is the thing that changed it.
|
||||
unsafe {
|
||||
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
|
||||
}
|
||||
}
|
||||
|
||||
/// No-op: Windows has no SIGPIPE, so [`out`] carries the whole contract there.
|
||||
#[cfg(not(unix))]
|
||||
pub fn end_pipelines_quietly() {}
|
||||
|
||||
/// Write to stdout, leaving quietly if the reader has hung up.
|
||||
///
|
||||
/// Exiting from this depth is deliberate — it is what the signal does on Unix,
|
||||
/// and it keeps every call site a plain statement. A `run` cut short this way
|
||||
/// leaves its pane behind, same as any other interrupted `run`.
|
||||
pub fn out(bytes: &[u8]) {
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
// Flush here rather than at exit: `std::process::exit` runs no destructors,
|
||||
// and stdout is a LineWriter, so anything not ending in a newline would be
|
||||
// dropped on the floor.
|
||||
if let Err(e) = stdout.write_all(bytes).and_then(|()| stdout.flush()) {
|
||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
std::process::exit(READER_GONE);
|
||||
}
|
||||
// A stdout that failed for any other reason (full disk, closed fd) is
|
||||
// worth saying out loud — the caller is missing output either way, and
|
||||
// silence would make it look like there was none.
|
||||
eprintln!("tty7: writing to stdout: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// One line and its newline, in a single write.
|
||||
pub fn line(text: &str) {
|
||||
let mut buf = String::with_capacity(text.len() + 1);
|
||||
buf.push_str(text);
|
||||
buf.push('\n');
|
||||
out(buf.as_bytes());
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::io::BufRead as _;
|
||||
use std::io::{BufRead as _, Read as _};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -47,6 +47,14 @@ fn main() {
|
||||
"events_stream_reports_a_workspace_creation",
|
||||
events_stream_reports_a_workspace_creation,
|
||||
),
|
||||
(
|
||||
"a_reader_that_hung_up_ends_the_pipeline_quietly",
|
||||
a_reader_that_hung_up_ends_the_pipeline_quietly,
|
||||
),
|
||||
(
|
||||
"capture_plain_returns_text_not_escapes",
|
||||
capture_plain_returns_text_not_escapes,
|
||||
),
|
||||
];
|
||||
|
||||
let mut failed = 0;
|
||||
@@ -460,3 +468,101 @@ fn events_stream_reports_a_workspace_creation(daemon: &Daemon) {
|
||||
"no event line arrived within {SETTLE_WITHIN:?}; saw {seen:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `tty7 … | head -1` must end the way `cat … | head -1` ends. Rust ignores
|
||||
/// SIGPIPE and `println!` panics on the resulting error, so without the fix
|
||||
/// this printed a panic and a backtrace note on a correct invocation.
|
||||
///
|
||||
/// Both write paths are covered: `ls` goes through the report emitter, `run`
|
||||
/// through the loop that streams a child's output. The reader is dropped
|
||||
/// immediately, long before either has anything to say, so the very first
|
||||
/// write lands on a pipe with no other end — no need to guess at a buffer size.
|
||||
fn a_reader_that_hung_up_ends_the_pipeline_quietly(daemon: &Daemon) {
|
||||
daemon.run_ok(&["ws", "new", "pipews"]);
|
||||
|
||||
let printer = one_shot("echo tty7_e2e_pipe_marker");
|
||||
let mut streaming: Vec<&str> = vec!["run", "--"];
|
||||
streaming.extend(printer.iter().map(String::as_str));
|
||||
|
||||
for args in [vec!["ls"], streaming] {
|
||||
let mut child = daemon
|
||||
.cli(&args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap_or_else(|e| panic!("could not spawn tty7 {args:?}: {e}"));
|
||||
drop(child.stdout.take().expect("stdout was piped"));
|
||||
|
||||
let mut stderr = String::new();
|
||||
child
|
||||
.stderr
|
||||
.take()
|
||||
.expect("stderr was piped")
|
||||
.read_to_string(&mut stderr)
|
||||
.expect("reading tty7's stderr");
|
||||
let status = child.wait().expect("waiting for tty7");
|
||||
|
||||
assert!(
|
||||
!stderr.contains("panicked"),
|
||||
"tty7 {args:?} panicked when its reader hung up: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.to_lowercase().contains("broken pipe"),
|
||||
"a hung-up reader is how a pipeline ends, not something to report: \
|
||||
tty7 {args:?} said {stderr}"
|
||||
);
|
||||
// Unix dies of SIGPIPE, so there is no code at all; Windows exits 0.
|
||||
// Either way it must not be the failure exit, which is what the error
|
||||
// path used to produce.
|
||||
assert_ne!(
|
||||
status.code(),
|
||||
Some(1),
|
||||
"tty7 {args:?} treated a hung-up reader as a failure: {stderr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `--plain` against a real pane, end to end through a real daemon.
|
||||
///
|
||||
/// The discriminator is the PTY's own line ending: a terminal ends lines with
|
||||
/// CRLF, so every raw capture carries `\r`, and a rendered one carries none —
|
||||
/// that CR was an instruction to the grid, not text. It holds whatever the test
|
||||
/// machine's shell decorates its prompt with, which a check for escape bytes
|
||||
/// would not: the isolated daemon's shell prints no colour at all.
|
||||
///
|
||||
/// What the grid *does* with those bytes (wraps, overwrites, cursor moves) is
|
||||
/// pinned by the unit tests in `screen.rs`, which can craft the byte stream
|
||||
/// exactly. This one proves the flag reaches them.
|
||||
fn capture_plain_returns_text_not_escapes(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}");
|
||||
|
||||
daemon.run_ok(&["send", &address, "echo tty7_e2e_plain_marker", "--enter"]);
|
||||
|
||||
let deadline = Instant::now() + SETTLE_WITHIN;
|
||||
loop {
|
||||
let raw = daemon.run_ok(&["capture", &address, "--scrollback"]);
|
||||
let plain = daemon.run_ok(&["capture", &address, "--scrollback", "--plain"]);
|
||||
if plain.contains("tty7_e2e_plain_marker") {
|
||||
assert!(
|
||||
raw.contains('\r'),
|
||||
"the default hands back the pane's bytes, CRLF included:\n{raw:?}"
|
||||
);
|
||||
assert!(
|
||||
!plain.contains('\r'),
|
||||
"a carriage return is an instruction to the grid, not text:\n{plain:?}"
|
||||
);
|
||||
assert!(
|
||||
!plain.contains('\u{1b}'),
|
||||
"an escape survived the grid:\n{plain:?}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"the sent text never showed up in the rendered capture; last was:\n{plain}"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
---
|
||||
name: tty7
|
||||
description: Drive the tty7 terminal workbench from the shell with the `tty7` binary — list workspaces/tabs/panes, split a pane, send keystrokes into one, capture what is on a pane's screen, run a command in a real PTY and pass its exit code through, see which coding agents are running and which ports a pane is listening on. Use this whenever tty7, panes, workspaces, or `%42`/`@7`/"the other pane"/"the other agent" come up; whenever you need to start something long-running or interactive (dev server, REPL, ssh session, `tail -f`, a TUI) that should not sit blocking your Bash tool; whenever a program needs a real terminal to behave the way the user sees it; and whenever you need to look at or report on what is running in some *other* terminal on this machine. Cheap to check: if `$TTY7_PANE` is set you are already inside tty7 and every command here works with no setup.
|
||||
---
|
||||
|
||||
# Driving tty7 from the command line
|
||||
|
||||
`tty7` is a thin, non-interactive client of the tty7 server. Every verb returns
|
||||
and exits; `--json` makes the output machine-readable. The GUI never has to be
|
||||
running — the server is what owns the panes.
|
||||
|
||||
## First: where are you?
|
||||
|
||||
```bash
|
||||
tty7 doctor
|
||||
```
|
||||
|
||||
One table, and it answers everything you need before doing anything else:
|
||||
whether a server is reachable, whether the dialect matches, and whether
|
||||
`TTY7_CONFIG_DIR` / `TTY7_WS` / `TTY7_PANE` are set — i.e. whether you are
|
||||
running *inside* a tty7 pane.
|
||||
|
||||
Being inside a pane matters for two reasons: the address-taking verbs
|
||||
(`split`, `send`, `capture`, `procs`) default to `$TTY7_PANE`, and `run --keep`
|
||||
files its pane into `$TTY7_WS`. Outside a tty7 shell you must name a target
|
||||
explicitly, and the error will say so rather than guessing.
|
||||
|
||||
If `tty7 doctor` says the server is unreachable, stop and tell the user — do
|
||||
not run `tty7 server start` on your own initiative. Starting a server they
|
||||
didn't ask for changes what their GUI attaches to.
|
||||
|
||||
## When to use this instead of the Bash tool
|
||||
|
||||
The Bash tool is right for anything that starts, does its job, and exits.
|
||||
Reach for tty7 when one of these is true:
|
||||
|
||||
- **It shouldn't block you.** A dev server, a watcher, `tail -f`, a long test
|
||||
run you want to check on later. Put it in a pane, come back and read it.
|
||||
- **It's interactive or stateful.** A REPL, `ssh`, a database shell, anything
|
||||
where you send one thing, read the answer, then send the next. A pane keeps
|
||||
the session alive between your turns; a Bash call cannot.
|
||||
- **It needs a real TTY.** Programs that detect a pipe and change behaviour —
|
||||
colour, progress bars, TUIs, `top`, anything using raw mode. `tty7 run`
|
||||
gives a genuine PTY at 120×30.
|
||||
- **The user should be able to watch it.** Anything in a pane shows up in their
|
||||
tty7 window, live. That is often the whole point.
|
||||
- **You're being asked about something you didn't start.** "What's running in
|
||||
that pane?", "why is port 3000 taken?", "what are my agents doing?" — you can
|
||||
answer those from here without touching anything.
|
||||
|
||||
## Addresses
|
||||
|
||||
| Shape | Means | Stable? |
|
||||
|---|---|---|
|
||||
| `%42` | a pane | yes — a pane keeps its id for its whole life |
|
||||
| `@7` | a tab, numbered across the **whole machine** in tree order | **no** — it shifts whenever a workspace or tab appears or disappears |
|
||||
| `api` / `76698a44` / a full UUID | a workspace, by name, by unique id prefix, or by id | yes |
|
||||
|
||||
Re-resolve `@N` right before you use it; never cache one across a step that
|
||||
creates or removes a tab. Pane ids and workspace ids are safe to remember.
|
||||
|
||||
Omitting the address inside a tty7 shell means "this pane" / "this workspace".
|
||||
An explicit address always wins over the environment.
|
||||
|
||||
## Running a command: two shapes
|
||||
|
||||
### Blocking, with a real exit code
|
||||
|
||||
```bash
|
||||
tty7 run -- cargo test # streams to your stdout, exits with cargo's code
|
||||
tty7 run --cwd /path -- make
|
||||
tty7 run --keep -- cargo build # leaves the pane as a new tab afterwards
|
||||
```
|
||||
|
||||
The command's output streams to your stdout as it happens, and `tty7` exits
|
||||
with the command's own exit code. This is the closest thing to a Bash call —
|
||||
the difference is the PTY and the fact that the user can see it.
|
||||
|
||||
Two things to know. `--keep` needs a workspace, so it only works inside a tty7
|
||||
shell or with `--ws <workspace>`. And with `--json`, the streamed output comes
|
||||
first and the JSON object last — the combined stream is *not* parseable as
|
||||
JSON, so read the last line.
|
||||
|
||||
### Non-blocking: a pane you talk to over time
|
||||
|
||||
This is the one that makes tty7 worth reaching for. Get a pane, send it work,
|
||||
come back later.
|
||||
|
||||
```bash
|
||||
PANE=$(tty7 split --v) # or --h; splits $TTY7_PANE, prints "%83"
|
||||
tty7 send "$PANE" 'npm run dev' --enter
|
||||
```
|
||||
|
||||
`split` prints the new pane's address on stdout, which is what you capture into
|
||||
a variable. Without an axis it is a usage error — `--v` stacks the new pane
|
||||
below, `--h` puts it to the right.
|
||||
|
||||
Splitting `$TTY7_PANE` changes the user's visible layout, which is usually the
|
||||
point: they can watch the dev server you started. Say that you did it, and close
|
||||
the pane when you're done with it.
|
||||
|
||||
If you are *not* inside a tty7 pane there is nothing to split, so make your own
|
||||
place to work first. `tty7 new --json /path/to/repo` hands you both ids at
|
||||
once — don't go digging through `ws tree` for the pane:
|
||||
|
||||
```bash
|
||||
read -r WS PANE < <(tty7 new --json /path/to/repo \
|
||||
| python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["id"], "%%%d" % d["pane"])')
|
||||
```
|
||||
|
||||
`send` types text into the pane exactly as a keyboard would; `--enter` appends
|
||||
the carriage return. It does not wait and it does not tell you what happened —
|
||||
reading is a separate step.
|
||||
|
||||
## Reading a pane
|
||||
|
||||
### If you want the screen, use `--plain`
|
||||
|
||||
```bash
|
||||
tty7 capture %83 --plain
|
||||
```
|
||||
|
||||
`capture` hands back what the daemon stored — the pane's bytes, escapes and
|
||||
all — and `--plain` replays them through a terminal grid and prints the
|
||||
resulting text instead. Not a stripper: colour and cursor escapes are gone, but
|
||||
also a line the shell wrapped at column 249 comes back as one line, a progress
|
||||
bar that rewrote itself with `\r` reads as its final value, and a TUI's screen
|
||||
lands where it was drawn. Use it whenever a human would want to read the output.
|
||||
|
||||
Two details about what you get back either way: capture returns a *snapshot*,
|
||||
not a stream — call it again for a newer one. And by default it prints the
|
||||
newest scrollback segment (the ring splits on resize); `--scrollback` prints the
|
||||
whole ring, which for a pane that was never resized is the same thing.
|
||||
|
||||
### If you want the result, redirect to a file
|
||||
|
||||
`--plain` gives you the screen, and a screen is a rectangle: whatever scrolled
|
||||
past the top of a long build log is gone, and the exit code was never on screen
|
||||
at all. So when what you want is the *answer* rather than the view, have the
|
||||
shell write it somewhere clean:
|
||||
|
||||
```bash
|
||||
tty7 send "$PANE" 'cargo test > /tmp/t.log 2>&1; echo $? > /tmp/t.rc' --enter
|
||||
# ...wait for it to finish (below), then:
|
||||
cat /tmp/t.rc /tmp/t.log
|
||||
```
|
||||
|
||||
Complete output, a real exit code, no terminal in the middle.
|
||||
|
||||
### Knowing when a command has finished
|
||||
|
||||
```bash
|
||||
tty7 procs %83
|
||||
```
|
||||
|
||||
lists the process tree inside the pane, indented, with `*` on the foreground
|
||||
process — plus any ports those processes are listening on. When the only entry
|
||||
left is the depth-0 shell, the command is done. That is a far more reliable
|
||||
"finished?" signal than grepping the screen, where your sentinel string can get
|
||||
line-wrapped or echoed twice.
|
||||
|
||||
Poll it on an interval rather than in a tight loop — a few seconds between
|
||||
checks. In Claude Code, use the Monitor tool with an until-condition instead of
|
||||
a bare foreground `sleep`.
|
||||
|
||||
The whole shape, end to end:
|
||||
|
||||
```bash
|
||||
tty7 send "$PANE" 'cargo test > /tmp/t.log 2>&1; echo $? > /tmp/t.rc' --enter
|
||||
# poll until only the shell is left
|
||||
until [ "$(tty7 procs "$PANE" --json | python3 -c 'import json,sys; print(len(json.load(sys.stdin)["procs"]))')" = 1 ]; do sleep 3; done
|
||||
cat /tmp/t.rc /tmp/t.log
|
||||
tty7 pane close "$PANE"
|
||||
```
|
||||
|
||||
The ports half also stands alone: `tty7 procs %62` answers "what is this pane
|
||||
serving, and on which port" without any guessing.
|
||||
|
||||
## Looking around
|
||||
|
||||
```bash
|
||||
tty7 ls # every workspace: tabs, panes, who's attached
|
||||
tty7 ws tree api # one workspace as a tree — tabs, splits, panes, cwds
|
||||
tty7 pane ls # panes with their workspace, tab, cwd, live flag
|
||||
tty7 pane ls --all # + orphans: panes the server runs that no workspace holds
|
||||
tty7 agents # every coding agent on the machine and its status
|
||||
tty7 status # server pid, uptime, pane count, build, socket
|
||||
tty7 machine ls # this machine plus any linked remotes
|
||||
tty7 events # stream server events, one per line, until interrupted
|
||||
```
|
||||
|
||||
`tty7 agents` is worth knowing about: it reports each pane running a recognised
|
||||
coding agent as `running` / `waiting` / `idle`. If you are one of them, you are
|
||||
in that list too.
|
||||
|
||||
Add `--json` to any of these to parse instead of eyeball. `-q` suppresses
|
||||
output on success but never suppresses errors.
|
||||
|
||||
## Don't break the user's session
|
||||
|
||||
The panes on this machine are the user's real work, and some of them are other
|
||||
coding agents mid-task. Treat anything you did not create as read-only:
|
||||
|
||||
- **Never `send` into a pane you didn't open.** Keystrokes into another agent's
|
||||
pane, or into a shell the user is typing in, land in the middle of whatever
|
||||
is happening there. Check `tty7 agents` before you touch a pane.
|
||||
- **Never `pane close` / `tab close` / `ws rm` something you didn't create.**
|
||||
- **Never `server stop` or `server restart`.** Every pane on the machine dies
|
||||
with the server, including yours. If the server genuinely seems wedged, say
|
||||
so and let the user decide.
|
||||
- **Clean up what you did create.** `tty7 pane close %83` when you're done with
|
||||
a scratch pane. Note that `ws rm` does *not* kill the panes inside it — they
|
||||
survive as orphans, visible under `tty7 pane ls --all` with no workspace, and
|
||||
you have to close them individually.
|
||||
|
||||
## Remote machines
|
||||
|
||||
`-m <machine>` routes any command over a link the local server already holds:
|
||||
|
||||
```bash
|
||||
tty7 -m devbox ls
|
||||
tty7 -m devbox run -- cargo test
|
||||
```
|
||||
|
||||
The name matches the full link key (`me@devbox:22`) or just the host. The CLI
|
||||
will not dial a fresh connection — if the link is down, or it's a jump/proxy
|
||||
chain, it says so and you should hand that back to the user, who can connect it
|
||||
from the GUI.
|
||||
|
||||
## Not wired up yet
|
||||
|
||||
`ws stop`, `machine connect`, `machine disconnect`, and bare `tty7 <path>` (GUI
|
||||
launch) all exit with a message saying they're not implemented. Don't build a
|
||||
plan around them.
|
||||
|
||||
## Full command reference
|
||||
|
||||
`references/commands.md` has every verb, subcommand and flag in one table, plus
|
||||
the JSON shape each one emits. Read it when you need a verb that isn't above,
|
||||
or when you're about to parse `--json` output and want to know the field names.
|
||||
@@ -0,0 +1,247 @@
|
||||
# `tty7` command reference
|
||||
|
||||
Every verb, its flags, and the JSON it emits under `--json`. Read the section
|
||||
you need; the table of contents mirrors the top-level grammar.
|
||||
|
||||
- [Global flags](#global-flags)
|
||||
- [Environment](#environment)
|
||||
- [Exit codes](#exit-codes)
|
||||
- [Top-level verbs](#top-level-verbs)
|
||||
- [`ws` — workspaces](#ws--workspaces)
|
||||
- [`tab` — tabs](#tab--tabs)
|
||||
- [`pane` — panes](#pane--panes)
|
||||
- [`machine` — remotes](#machine--remotes)
|
||||
- [`server` — the daemon](#server--the-daemon)
|
||||
- [Not implemented yet](#not-implemented-yet)
|
||||
|
||||
## Global flags
|
||||
|
||||
Accepted anywhere on the line, before or after the subcommand.
|
||||
|
||||
| Flag | Effect |
|
||||
|---|---|
|
||||
| `-m, --machine <MACHINE>` | Route the command to a linked machine over the local server's existing link. Matches the full link key (`me@devbox:22`) or the bare host (`devbox`). Ssh links only; a down link or a jump/proxy chain is refused with a reason rather than dialled fresh. |
|
||||
| `--json` | One JSON object on stdout instead of the human table. |
|
||||
| `-q, --quiet` | No output on success. Errors still go to stderr. |
|
||||
|
||||
## Environment
|
||||
|
||||
Set inside every tty7 pane, inherited by anything you launch from one.
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `TTY7_PANE` | This pane's id, e.g. `71` or `%71` (both forms are accepted). The default target of `split`, `send`, `capture`, `procs`, `pane close`. |
|
||||
| `TTY7_WS` | This pane's workspace id. The default for `run --keep`, `tab new`, `ws tree`. |
|
||||
| `TTY7_CONFIG_DIR` | The server's config dir. How the CLI finds the right server's sockets — you never pass a socket path. |
|
||||
|
||||
Outside a tty7 shell the address-taking verbs fail with
|
||||
`not inside a tty7 shell — pass an explicit %pane/@tab/workspace`.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| 0 | success |
|
||||
| 1 | the command failed; the reason is one line on stderr, prefixed `tty7:` |
|
||||
| 2 | usage error (clap) — unknown verb, missing argument, bad type |
|
||||
| 141 | Unix only: the reader hung up (`| head -1`) and SIGPIPE ended it, exactly as it ends `cat`. Not a failure. Windows reports 0 for the same thing, having no signal to imitate. |
|
||||
| *other* | only from `tty7 run`, which passes the child's exit code through |
|
||||
|
||||
Builds before this was fixed panic instead of exiting on a hung-up reader:
|
||||
`tty7 capture %71 | head -1` prints a Rust `failed printing to stdout: Broken
|
||||
pipe` note and a backtrace hint to stderr. Harmless, and the data you asked for
|
||||
still arrived — don't read it as the command having failed. On such a build,
|
||||
redirect to a file and slice the file instead of piping into `head`.
|
||||
|
||||
If `run` cannot learn the child's code it prints a note to stderr and exits 1
|
||||
with `"exit_code_known": false` in the JSON — that is how you tell a real 1
|
||||
from a stand-in.
|
||||
|
||||
## Top-level verbs
|
||||
|
||||
### `tty7 ls`
|
||||
Same as `ws ls`. Table: `WORKSPACE NAME TABS PANES ATTACHED`.
|
||||
JSON: `{"workspaces":[{"id","name","tabs","panes","attached"}]}`.
|
||||
|
||||
### `tty7 run [--keep] [--cwd DIR] [--ws WORKSPACE] -- CMD...`
|
||||
Spawns a pane running `CMD`, streams its output to stdout, waits, and exits
|
||||
with its code. The command must come after `--`; anything after `--` belongs to
|
||||
the child, so `tty7 run -- cargo test --keep` passes `--keep` to cargo.
|
||||
|
||||
- `--keep` leaves the pane alive as a new tab afterwards. It needs a workspace,
|
||||
so it requires `--ws` or `$TTY7_WS`; without one it is an error, not a
|
||||
silent fallback.
|
||||
- `--cwd` sets the working directory. `--ws` also sets the pane's `TTY7_WS`.
|
||||
- Interrupting `run` can leave the pane behind as an orphan — `pane ls --all`.
|
||||
|
||||
JSON: `{"pane","exit","exit_code_known","kept"}`, printed **after** the streamed
|
||||
output. The combined stream is not valid JSON; read the last line.
|
||||
|
||||
### `tty7 new [PATH]`
|
||||
Creates a workspace plus its first tab and shell, at `PATH` if given. Prints
|
||||
the workspace id. JSON: `{"id","pane"}`.
|
||||
|
||||
### `tty7 split [%PANE] (--v|--h) [--ratio R]`
|
||||
Alias of `pane split`. Splits `%PANE` (default `$TTY7_PANE`), spawning a shell
|
||||
in the same cwd. Exactly one axis is required — `--v`/`--vertical` puts the new
|
||||
pane below, `--h`/`--horizontal` to the right. `--ratio` (default 0.5) is the
|
||||
share kept by the *existing* pane. Prints `%NN`. JSON: `{"pane"}`.
|
||||
|
||||
### `tty7 send [%PANE] TEXT [--enter]`
|
||||
Types `TEXT` into the pane as keystrokes; `--enter` appends CR. With one
|
||||
argument the text is the argument and the pane comes from `$TTY7_PANE` — but a
|
||||
lone `%42` is rejected as a missing-text error rather than typed.
|
||||
JSON: `{"pane","sent","enter"}`.
|
||||
|
||||
### `tty7 capture [%PANE] [--plain] [--scrollback]`
|
||||
The pane's replay. Two 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 never resized the two are identical)
|
||||
— and **in what form**.
|
||||
|
||||
Without `--plain` you get the stored bytes, ANSI escapes intact, decoded as
|
||||
UTF-8 (invalid bytes become U+FFFD). That is the faithful form: it is exactly
|
||||
what the pane emitted.
|
||||
|
||||
With `--plain` those bytes are replayed through a terminal grid — the same
|
||||
parser and rev the GUI renders panes with — and you get the text that produced.
|
||||
The difference from stripping the escapes yourself:
|
||||
|
||||
- a line the shell wrapped at the pane's width comes back as **one** line, not
|
||||
split at an invented newline
|
||||
- `\r` **overwrites** rather than breaking the line, so a progress bar reads as
|
||||
its final value and a syntax-highlighting shell doesn't echo as `eecho …echo`
|
||||
- cursor addressing (`ESC[5;80H`) puts text **where the app put it**
|
||||
- wide characters keep one character per cell pair; combining marks stay put
|
||||
- each segment is rendered at **its own** width, which is why the size travels
|
||||
with it; the empty rows a grid leaves above and below the output are dropped
|
||||
|
||||
Reach for it whenever a human would want to read the output. It is still a
|
||||
screen, though: what scrolled past the top is gone, and an exit code was never
|
||||
on screen — redirect to a file when you want the answer rather than the view.
|
||||
|
||||
Either way it is a snapshot, not a stream: it collects the replay the server
|
||||
sends, settles for ~300 ms, and returns. Call it again for a newer one.
|
||||
JSON: `{"pane","text"}`, where `text` is whichever form was asked for.
|
||||
|
||||
### `tty7 procs [%PANE]`
|
||||
The process tree inside the pane, indented by depth, `*` on the foreground
|
||||
process — then a second table of ports those processes are listening on.
|
||||
Prints `nothing running in this pane` when both are empty.
|
||||
|
||||
JSON: `{"procs":[{"pid","name","depth","foreground"}],"ports":[{"port","pid","name"}]}`.
|
||||
|
||||
The reliable "is it done?" check: when the only entry is the depth-0 shell, the
|
||||
foreground command has exited.
|
||||
|
||||
### `tty7 agents`
|
||||
Every pane running a recognised coding agent. Table: `PANE AGENT STATUS
|
||||
MESSAGE`, status one of `running` / `waiting` / `idle`.
|
||||
JSON: `{"agents":[...]}`.
|
||||
|
||||
### `tty7 events`
|
||||
Streams server events until interrupted, one per line — pane exits, agent
|
||||
status changes, workspace preemption, layout deltas. `--json` makes it NDJSON.
|
||||
Blocks forever; run it with a timeout or in the background.
|
||||
|
||||
### `tty7 status`
|
||||
Same as `server status`: pid, uptime, pane count, dialect versions, build,
|
||||
socket path. JSON is the `ServerStatus` object itself (`pid`, `uptime_secs`,
|
||||
`panes`, `control_version`, `protocol_version`, `build`, `socket`).
|
||||
|
||||
### `tty7 doctor`
|
||||
The install check: the three env vars, whether the server answers, whether its
|
||||
control/protocol versions match this binary, pid/uptime/panes, and how many
|
||||
machine links exist. Adds a note when you are not inside a tty7 shell.
|
||||
JSON: `{"context":{"config_dir","workspace","pane"},"server":{"reachable","dialect_ok","build","status","routes"}}`
|
||||
— the context fields are booleans, not values.
|
||||
|
||||
## `ws` — workspaces
|
||||
|
||||
A workspace is a named tree of tabs and panes the server keeps alive. Address
|
||||
one by name, by full id, or by a unique id prefix (the 8-char prefix `tty7 ls`
|
||||
prints is what you normally use). An ambiguous name or prefix is an error that
|
||||
lists the candidates.
|
||||
|
||||
| Command | Effect | JSON |
|
||||
|---|---|---|
|
||||
| `ws ls` | every workspace | `{"workspaces":[...]}` |
|
||||
| `ws tree [WORKSPACE]` | one workspace as a tree: tabs, split axes and ratios, panes with cwds | the whole workspace object: `{"id","name","last_active","tabs":[{"id","name","sidebar_group","root",...}]}`, where `root` is the nested split tree |
|
||||
| `ws new [NAME]` | an empty workspace (no tab, no pane) | `{"id","name"}` |
|
||||
| `ws rename WORKSPACE NAME` | name or rename | `{"id","name"}` |
|
||||
| `ws rm WORKSPACE` | delete the workspace | `{"removed"}` |
|
||||
| `ws attach WORKSPACE` | become its controlling client | `{"attached","took_over_from"}` |
|
||||
| `ws detach WORKSPACE` | let go without interrupting anything | `{"detached"}` |
|
||||
|
||||
`ws rm` does not kill the panes it held — they keep running as orphans with no
|
||||
workspace. Find them with `pane ls --all` and close them one by one.
|
||||
|
||||
Prefer `tty7 new <path>` over `ws new` when you want something usable: `ws new`
|
||||
leaves you with an empty workspace you then have to populate, while
|
||||
`tty7 new --json <path>` hands back `{"id","pane"}` — both addresses in one go.
|
||||
|
||||
The `root` node in `ws tree --json` is externally tagged, so a leaf is
|
||||
`{"Leaf":{"pane":31}}` and a split is `{"Split":{"axis","ratio","a","b"}}` with
|
||||
`a`/`b` nested the same way. `d["tabs"][0]["root"]["pane"]` will not work.
|
||||
|
||||
## `tab` — tabs
|
||||
|
||||
`@N` numbers tabs across the **whole machine** in tree order, densely from `@1`.
|
||||
The numbering shifts whenever any workspace or tab is created or removed, so
|
||||
resolve it immediately before use. A full tab UUID also works: `@<uuid>`.
|
||||
|
||||
| Command | Effect | JSON |
|
||||
|---|---|---|
|
||||
| `tab ls [WORKSPACE]` | tabs of a workspace | `{"workspace","tabs":[{"ordinal","id","name","panes":[..]}]}` |
|
||||
| `tab new [WORKSPACE] [--cwd DIR]` | add a tab with a fresh shell | `{"tab","pane"}` |
|
||||
| `tab close @TAB` | close the tab and every pane in it | `{"closed"}` |
|
||||
| `tab rename @TAB NAME` | name or rename | `{"tab","name"}` |
|
||||
| `tab move @TAB INDEX` | reposition within its workspace | `{"tab","to"}` |
|
||||
|
||||
## `pane` — panes
|
||||
|
||||
| Command | Effect | JSON |
|
||||
|---|---|---|
|
||||
| `pane ls [WORKSPACE]` | panes with their workspace, tab, cwd, live flag | `{"panes":[...]}` |
|
||||
| `pane ls --all` | the server's whole pane registry, including orphans no workspace holds | `{"panes":[...],"orphans":N}` |
|
||||
| `pane split ...` | identical to top-level `split` | `{"pane"}` |
|
||||
| `pane close [%PANE]` | close the pane; its shell is hung up | `{"closed"}` |
|
||||
|
||||
`--all` is the one that shows leaks. Each entry is
|
||||
`{"pane","workspace","orphan","owner","title","cwd","live"}`: `owner` is
|
||||
`tty7-cli` for panes this CLI spawned (a workspace id otherwise), and
|
||||
`orphan: true` means no workspace holds it. An interrupted `tty7 run` and a
|
||||
removed workspace both leave orphans here.
|
||||
|
||||
`title` is the pane's current title — usually the running command, so it reads
|
||||
`claude`, `nvim`, `cargo` — which makes `pane ls --all --json` a quick way to
|
||||
find "the pane running X" without capturing anything.
|
||||
|
||||
## `machine` — remotes
|
||||
|
||||
`machine ls` lists the local machine plus every link the server holds:
|
||||
`MACHINE KIND CONNECTED`. JSON: `{"machines":[{"key","kind","connected"}]}`.
|
||||
|
||||
`machine connect` / `machine disconnect` are not implemented — links are
|
||||
managed from the GUI's connection manager.
|
||||
|
||||
## `server` — the daemon
|
||||
|
||||
| Command | Effect |
|
||||
|---|---|
|
||||
| `server status` | same as `tty7 status` |
|
||||
| `server logs` | tail the server log; prints the path, and says so when logging was never enabled (`TTY7_LOG=info` before the server starts) |
|
||||
| `server start` | bring up a server on this machine |
|
||||
| `server stop` | stop it — **every pane on the machine dies** |
|
||||
| `server restart` | stop, then start — same consequence |
|
||||
|
||||
Do not run `start`, `stop` or `restart` on your own initiative. They change or
|
||||
destroy what the user's GUI is attached to.
|
||||
|
||||
## Not implemented yet
|
||||
|
||||
These parse and then exit 1 with an explanation:
|
||||
|
||||
- `ws stop` — the control dialect has no workspace-stop request yet
|
||||
- `machine connect` / `machine disconnect` — use the GUI
|
||||
- bare `tty7 <path>` (launch or focus the GUI) — not wired up
|
||||
Reference in New Issue
Block a user