mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
* feat(cli): restart the server in place by default, keeping sessions `tty7 server restart` used to be stop + start, killing every pane, while the GUI's Restart Server hands the daemon off to a new image via execve and keeps everything running. Same verb, opposite side effects. The CLI now probes the daemon for the handoff feature and asks it to exec the tty7-server binary in place: same pid, same ptys, sessions survive. Success is judged by the version endpoint answering with a new per-process instance id, not by build strings, since the CLI and server binaries can be on different versions. A refused or stalled handoff leaves the daemon untouched and reports an error suggesting `--hard` instead of silently killing sessions. The stop + start path remains for `--hard` and for daemons that cannot exec themselves (Windows, pre-handoff builds). * fix(cli): leave a slow handoff's seat holder alive, and let a hard restart say sessions ended After a taken handoff, the poll timing out does not mean the daemon died: the singleton lock survives the exec, so a held seat is the new image still coming up with every session aboard. Falling back to start() there would grant it one second of grace and then reap it — bail with the seat still held instead, and only start over a genuinely free seat. The stop-and-start fallback (--hard, Windows, pre-handoff builds) now reports that sessions ended instead of relaying start()'s plain report, since the default restart's promise is sessions kept.
878 lines
29 KiB
Rust
878 lines
29 KiB
Rust
use clap::{ArgGroup, Args, Parser, Subcommand};
|
|
|
|
#[derive(Debug, Parser)]
|
|
#[command(
|
|
name = "tty7",
|
|
version,
|
|
about = "A terminal workbench, from the command line",
|
|
long_about = "One name, three artifacts: this CLI, the tty7 GUI (tty7-app), and\n\
|
|
tty7-server.\n\n\
|
|
The GUI and this CLI are both clients of the server on each machine —\n\
|
|
neither needs the other to be running. This CLI is built for coding\n\
|
|
agents: every verb is non-interactive, and --json makes the output\n\
|
|
machine-readable.\n\n\
|
|
An agent reaches the server through this binary, not through a wire\n\
|
|
protocol of its own. Inside a tty7 shell, $TTY7_CONFIG_DIR points at\n\
|
|
the server, and $TTY7_PANE / $TTY7_WS let the address-taking verbs run\n\
|
|
with no address given."
|
|
)]
|
|
pub struct Cli {
|
|
#[arg(
|
|
short = 'm',
|
|
long,
|
|
global = true,
|
|
value_name = "MACHINE",
|
|
help = "Route to that machine over the local server's existing link"
|
|
)]
|
|
pub machine: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
global = true,
|
|
help = "Machine-readable output, one JSON object per command"
|
|
)]
|
|
pub json: bool,
|
|
|
|
#[arg(short, long, global = true, help = "Quiet mode: no output on success")]
|
|
pub quiet: bool,
|
|
|
|
#[arg(
|
|
value_name = "PATH",
|
|
help = "Launch or activate the GUI, opening a new tab at PATH if given"
|
|
)]
|
|
pub path: Option<std::path::PathBuf>,
|
|
|
|
#[command(subcommand)]
|
|
pub command: Option<Command>,
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
pub enum Command {
|
|
#[command(about = "This machine on one screen (= tty7 ws ls)")]
|
|
Ls,
|
|
|
|
#[command(about = "Start a pane running a command, stream it, pass through the exit code")]
|
|
Run(RunArgs),
|
|
|
|
#[command(about = "Create a workspace and its first tab, print the id")]
|
|
New {
|
|
#[arg(value_name = "PATH")]
|
|
path: Option<String>,
|
|
#[arg(
|
|
long,
|
|
help = "Also open a window on it, if a GUI is running on this machine"
|
|
)]
|
|
open: bool,
|
|
},
|
|
|
|
#[command(about = "Split a pane (= tty7 pane split)")]
|
|
Split(SplitArgs),
|
|
|
|
// The key list is built from the table it is a list *of*, rather than
|
|
// written out here: a hand-copied vocabulary drifts the first time a key
|
|
// is added, and this is the text a caller reaches for to learn the names.
|
|
#[command(
|
|
about = "Type text into a pane, or send it keystrokes with --key",
|
|
long_about = crate::keys::send_long_help()
|
|
)]
|
|
Send(SendArgs),
|
|
|
|
#[command(
|
|
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),
|
|
|
|
#[command(about = "Processes and listening ports inside a pane")]
|
|
Procs {
|
|
#[arg(value_name = "%PANE")]
|
|
target: Option<String>,
|
|
},
|
|
|
|
#[command(about = "Every agent on this machine: running, waiting for a reply, idle")]
|
|
Agents,
|
|
|
|
#[command(
|
|
about = "Block until a pane's agent needs input, finishes its turn, or the pane \
|
|
exits — the orchestration primitive: `tty7 wait %3 && tty7 capture %3 --plain`"
|
|
)]
|
|
Wait(WaitArgs),
|
|
|
|
#[command(about = "Stream server events, one per line (NDJSON with --json)")]
|
|
Events,
|
|
|
|
#[command(about = "Server health at a glance (= tty7 server status)")]
|
|
Status,
|
|
|
|
#[command(
|
|
about = "Check this install: socket, dialect, config, versions, agent hooks, links, \
|
|
context"
|
|
)]
|
|
Doctor,
|
|
|
|
#[command(
|
|
subcommand,
|
|
about = "Workspaces: the named trees of tabs and panes the server keeps alive"
|
|
)]
|
|
Ws(WsCmd),
|
|
|
|
#[command(subcommand, about = "Tabs within a workspace (@N addresses)")]
|
|
Tab(TabCmd),
|
|
|
|
#[command(subcommand, about = "Panes: the terminals themselves (%N addresses)")]
|
|
Pane(PaneCmd),
|
|
|
|
#[command(
|
|
subcommand,
|
|
about = "Machines: this one plus every remote the server has a link to"
|
|
)]
|
|
Machine(MachineCmd),
|
|
|
|
#[command(subcommand, about = "The tty7-server process on this machine")]
|
|
Server(ServerCmd),
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
pub struct RunArgs {
|
|
#[arg(long, help = "Keep the pane after the command exits")]
|
|
pub keep: bool,
|
|
|
|
#[arg(long, value_name = "DIR", help = "Working directory for the command")]
|
|
pub cwd: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
value_name = "WORKSPACE",
|
|
help = "Sets the pane's TTY7_WS and, with --keep, the workspace the kept pane is \
|
|
filed into; defaults to $TTY7_WS inside a tty7 shell"
|
|
)]
|
|
pub ws: Option<String>,
|
|
|
|
#[arg(
|
|
last = true,
|
|
required = true,
|
|
value_name = "CMD",
|
|
help = "The command, after `--`: tty7 run -- cargo test"
|
|
)]
|
|
pub cmd: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
#[command(group = ArgGroup::new("axis").required(true))]
|
|
pub struct SplitArgs {
|
|
#[arg(
|
|
value_name = "%PANE",
|
|
help = "Pane to split; defaults to $TTY7_PANE inside a tty7 shell"
|
|
)]
|
|
pub target: Option<String>,
|
|
|
|
// Spelled out, with the one-letter forms kept as aliases: `--h` alone reads
|
|
// like a typo for `-h`, and scripts written against it keep working.
|
|
#[arg(
|
|
long = "horizontal",
|
|
visible_alias = "h",
|
|
group = "axis",
|
|
help = "Side by side: the new pane goes to the right"
|
|
)]
|
|
pub horizontal: bool,
|
|
|
|
#[arg(
|
|
long = "vertical",
|
|
visible_alias = "v",
|
|
group = "axis",
|
|
help = "Stacked: the new pane goes below"
|
|
)]
|
|
pub vertical: bool,
|
|
|
|
#[arg(
|
|
long,
|
|
default_value_t = 0.5,
|
|
value_name = "RATIO",
|
|
help = "Share kept by the existing pane"
|
|
)]
|
|
pub ratio: f32,
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
pub struct SendArgs {
|
|
#[arg(value_name = "%PANE|TEXT")]
|
|
pub first: Option<String>,
|
|
|
|
#[arg(value_name = "TEXT")]
|
|
pub second: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
help = "Press Enter after the text, or on its own when there is none \
|
|
(= --key enter)"
|
|
)]
|
|
pub enter: bool,
|
|
|
|
// Text covers "type this command"; it cannot express the keystrokes a pane
|
|
// asks for once something is already running — the arrow keys a permission
|
|
// prompt is answered with, the Escape that closes a TUI, the Ctrl-C that
|
|
// stops a runaway build. Repeatable, and delivered in the order given.
|
|
#[arg(
|
|
long = "key",
|
|
value_name = "KEY",
|
|
value_parser = crate::keys::parse,
|
|
help = "Send a keystroke instead of text; repeat for a sequence \
|
|
(C-c, escape, up, enter, …). See `tty7 send --help`"
|
|
)]
|
|
pub keys: Vec<crate::keys::Key>,
|
|
}
|
|
|
|
/// One resting place a `wait` can end on. Three ontologies meet here, which is
|
|
/// why the list is longer than the agent ladder: `Idle`/`Working`/`Waiting`/
|
|
/// `Done` are the agent status the server keeps from hook events, `NoAgent`
|
|
/// and `Free` describe the pane itself, and `Exit` is the pane being gone.
|
|
///
|
|
/// `NoAgent` exists because the alternative was worse: a pane with nothing
|
|
/// reporting used to read as `idle`, so `--until idle` answered "yes, done"
|
|
/// about a shell that was midway through a build. Saying "no agent is
|
|
/// reporting here" is both true and the thing a caller needs in order to
|
|
/// switch to `Free`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
|
|
pub enum WaitState {
|
|
Idle,
|
|
Working,
|
|
Waiting,
|
|
Done,
|
|
/// Nothing is reporting agent status in this pane — a plain shell, or an
|
|
/// agent whose hooks are not installed. Watch `Free` for those.
|
|
#[value(name = "no-agent")]
|
|
NoAgent,
|
|
/// The pane is back to its bare shell: the foreground command has exited.
|
|
/// Costs one extra request per poll, so it is only checked when asked for.
|
|
Free,
|
|
Exit,
|
|
}
|
|
|
|
impl WaitState {
|
|
/// The wire spelling: what `--until` accepts and what `--json` reports.
|
|
/// Written out rather than derived from the variant name so renaming a
|
|
/// variant cannot silently rewrite the JSON contract.
|
|
pub fn name(self) -> &'static str {
|
|
match self {
|
|
WaitState::Idle => "idle",
|
|
WaitState::Working => "working",
|
|
WaitState::Waiting => "waiting",
|
|
WaitState::Done => "done",
|
|
WaitState::NoAgent => "no-agent",
|
|
WaitState::Free => "free",
|
|
WaitState::Exit => "exit",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
pub struct WaitArgs {
|
|
#[arg(
|
|
value_name = "%PANE",
|
|
help = "Pane to watch; defaults to $TTY7_PANE inside a tty7 shell"
|
|
)]
|
|
pub target: Option<String>,
|
|
|
|
// The default is the two states worth waking for plus the one nobody can
|
|
// wait past: "my peer needs input", "my peer finished", "my peer died".
|
|
// `free` is deliberately not in it — it is the answer for a pane running a
|
|
// command rather than an agent, and it costs a second request per poll.
|
|
#[arg(
|
|
long,
|
|
value_name = "STATE,…",
|
|
value_delimiter = ',',
|
|
default_values = ["waiting", "done", "exit"],
|
|
help = "States that end the wait; `free` waits for a plain command to finish"
|
|
)]
|
|
pub until: Vec<WaitState>,
|
|
|
|
#[arg(
|
|
long,
|
|
value_name = "SECS",
|
|
help = "Give up after this many seconds, with exit code 124 (the `timeout(1)` \
|
|
convention, so scripts can tell \"not yet\" from \"broken\")"
|
|
)]
|
|
pub timeout: Option<u64>,
|
|
|
|
// The agent status the server keeps is a level, not an edge: `done` stands
|
|
// until the next turn starts, `waiting` until the agent moves again. So a
|
|
// wait issued right after a `send` would answer with last turn's status
|
|
// before the agent has even read the input. `--changed` refuses the state
|
|
// the pane was already in, which is what a delegation loop wants on every
|
|
// round after the first.
|
|
//
|
|
// `free` is level-triggered the same way and needs the same guard, but a
|
|
// shell that goes free → busy → free returns to the state it started in,
|
|
// so comparing against a baseline would miss it. There the rule is instead
|
|
// "we watched something run": `free` only counts once the pane has been
|
|
// seen busy, which is exactly "the command I just sent has finished".
|
|
#[arg(
|
|
long,
|
|
help = "Ignore the state the pane was already in — only wake on a state it \
|
|
moved into after the wait began; with `free`, wait until something \
|
|
has actually run (use this after `send`)"
|
|
)]
|
|
pub changed: bool,
|
|
|
|
#[arg(
|
|
long,
|
|
value_name = "MS",
|
|
default_value_t = 500,
|
|
value_parser = clap::value_parser!(u64).range(50..=3_600_000),
|
|
help = "Poll every this many milliseconds"
|
|
)]
|
|
pub interval: u64,
|
|
}
|
|
|
|
#[derive(Debug, Args)]
|
|
pub struct CaptureArgs {
|
|
#[arg(
|
|
value_name = "%PANE",
|
|
help = "Pane to read; defaults to $TTY7_PANE inside a tty7 shell"
|
|
)]
|
|
pub target: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
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)]
|
|
pub enum WsCmd {
|
|
#[command(about = "Every workspace on this machine")]
|
|
Ls,
|
|
|
|
#[command(about = "One workspace as a tree: tabs, splits, panes")]
|
|
Tree {
|
|
#[arg(value_name = "WORKSPACE")]
|
|
ws: Option<String>,
|
|
},
|
|
|
|
#[command(about = "Create an empty workspace")]
|
|
New {
|
|
#[arg(value_name = "NAME")]
|
|
name: Option<String>,
|
|
},
|
|
|
|
#[command(about = "Name or rename a workspace")]
|
|
Rename {
|
|
#[arg(value_name = "WORKSPACE")]
|
|
ws: String,
|
|
#[arg(value_name = "NAME")]
|
|
name: String,
|
|
},
|
|
|
|
#[command(about = "End the shells, keep the layout (not implemented yet)")]
|
|
Stop {
|
|
#[arg(value_name = "WORKSPACE")]
|
|
ws: String,
|
|
},
|
|
|
|
#[command(about = "Delete the workspace and hang up its panes")]
|
|
Rm {
|
|
#[arg(value_name = "WORKSPACE")]
|
|
ws: String,
|
|
},
|
|
|
|
#[command(about = "Become the workspace's controlling client")]
|
|
Attach {
|
|
#[arg(value_name = "WORKSPACE")]
|
|
ws: String,
|
|
},
|
|
|
|
#[command(about = "Let go without interrupting anything")]
|
|
Detach {
|
|
#[arg(value_name = "WORKSPACE")]
|
|
ws: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
pub enum TabCmd {
|
|
#[command(about = "Tabs of a workspace")]
|
|
Ls {
|
|
#[arg(value_name = "WORKSPACE")]
|
|
ws: Option<String>,
|
|
},
|
|
|
|
#[command(about = "Add a tab with a fresh shell")]
|
|
New {
|
|
#[arg(value_name = "WORKSPACE")]
|
|
ws: Option<String>,
|
|
#[arg(
|
|
long,
|
|
value_name = "DIR",
|
|
help = "Working directory for the tab's shell"
|
|
)]
|
|
cwd: Option<String>,
|
|
},
|
|
|
|
#[command(about = "Close a tab and every pane in it")]
|
|
Close {
|
|
#[arg(value_name = "@TAB")]
|
|
tab: String,
|
|
},
|
|
|
|
#[command(about = "Name or rename a tab")]
|
|
Rename {
|
|
#[arg(value_name = "@TAB")]
|
|
tab: String,
|
|
#[arg(value_name = "NAME")]
|
|
name: String,
|
|
},
|
|
|
|
#[command(about = "Move a tab to another position in its workspace")]
|
|
Move {
|
|
#[arg(value_name = "@TAB")]
|
|
tab: String,
|
|
#[arg(value_name = "INDEX")]
|
|
index: u64,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
pub enum PaneCmd {
|
|
#[command(about = "Panes of a workspace, or of the whole machine")]
|
|
Ls {
|
|
#[arg(value_name = "WORKSPACE")]
|
|
ws: Option<String>,
|
|
|
|
#[arg(
|
|
long,
|
|
conflicts_with = "ws",
|
|
help = "Every pane the server runs, including ones no workspace holds \
|
|
(an interrupted `run` leaves those behind)"
|
|
)]
|
|
all: bool,
|
|
},
|
|
|
|
#[command(about = "Split a pane in two")]
|
|
Split(SplitArgs),
|
|
|
|
#[command(about = "Close panes; their shells are hung up")]
|
|
Close {
|
|
#[arg(value_name = "%PANE", help = "Panes to close; defaults to $TTY7_PANE")]
|
|
targets: Vec<String>,
|
|
|
|
// `pane ls --all` has been able to *show* the panes an interrupted
|
|
// `run` leaves behind for a while, and the only way to act on that was
|
|
// to read ids off the table and close them one at a time. The CLI
|
|
// creates these; it should be able to clear them.
|
|
#[arg(
|
|
long,
|
|
conflicts_with = "targets",
|
|
help = "Close every pane no workspace holds — what an interrupted `run` \
|
|
leaves behind. Lists them; pass --json for the ids"
|
|
)]
|
|
orphans: bool,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
pub enum MachineCmd {
|
|
#[command(about = "This machine and every linked remote")]
|
|
Ls,
|
|
|
|
#[command(about = "Open a link to a machine from an SSH profile (not implemented yet)")]
|
|
Connect {
|
|
#[arg(value_name = "PROFILE")]
|
|
profile: String,
|
|
},
|
|
|
|
#[command(about = "Drop the link; the remote server keeps running (not implemented yet)")]
|
|
Disconnect {
|
|
#[arg(value_name = "MACHINE")]
|
|
machine: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Subcommand)]
|
|
pub enum ServerCmd {
|
|
#[command(about = "Start the server on this machine")]
|
|
Start,
|
|
|
|
#[command(about = "Stop the server; sessions end")]
|
|
Stop,
|
|
|
|
#[command(about = "Restart in place; sessions keep running (--hard: stop, then start)")]
|
|
Restart {
|
|
#[arg(long, help = "Stop, then start — every session ends")]
|
|
hard: bool,
|
|
},
|
|
|
|
#[command(about = "Version, uptime, panes, links")]
|
|
Status,
|
|
|
|
#[command(about = "Tail the server log")]
|
|
Logs,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use clap::error::ErrorKind;
|
|
|
|
fn parse(args: &[&str]) -> Cli {
|
|
Cli::try_parse_from(args).expect("this invocation is part of the documented grammar")
|
|
}
|
|
|
|
#[test]
|
|
fn bare_tty7_is_the_gui_launcher() {
|
|
let cli = parse(&["tty7"]);
|
|
assert!(cli.command.is_none(), "no subcommand means launch the GUI");
|
|
assert!(cli.path.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn a_path_that_is_not_a_verb_still_launches_the_gui() {
|
|
let cli = parse(&["tty7", "C:\\Users\\me\\proj"]);
|
|
assert!(cli.command.is_none());
|
|
assert_eq!(
|
|
cli.path.as_deref(),
|
|
Some(std::path::Path::new("C:\\Users\\me\\proj"))
|
|
);
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn a_gui_path_preserves_native_windows_arguments() {
|
|
use std::ffi::OsString;
|
|
use std::os::windows::ffi::OsStringExt as _;
|
|
|
|
let native_path =
|
|
OsString::from_wide(&[b'C' as u16, b':' as u16, b'\\' as u16, 0xD800, b'x' as u16]);
|
|
let cli = Cli::try_parse_from([OsString::from("tty7"), native_path.clone()])
|
|
.expect("PathBuf arguments accept native Windows strings");
|
|
|
|
assert_eq!(cli.path, Some(std::path::PathBuf::from(native_path)));
|
|
}
|
|
|
|
#[test]
|
|
fn every_top_level_verb_parses() {
|
|
assert!(matches!(parse(&["tty7", "ls"]).command, Some(Command::Ls)));
|
|
assert!(matches!(
|
|
parse(&["tty7", "new"]).command,
|
|
Some(Command::New { path: None, .. })
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "new", "C:\\proj"]).command,
|
|
Some(Command::New { path: Some(p), .. }) if p == "C:\\proj"
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "agents"]).command,
|
|
Some(Command::Agents)
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "events"]).command,
|
|
Some(Command::Events)
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "status"]).command,
|
|
Some(Command::Status)
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "doctor"]).command,
|
|
Some(Command::Doctor)
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "procs", "%3"]).command,
|
|
Some(Command::Procs { target: Some(t) }) if t == "%3"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn run_takes_the_command_after_a_double_dash() {
|
|
let cli = parse(&["tty7", "run", "--", "cargo", "test", "--keep"]);
|
|
let Some(Command::Run(args)) = cli.command else {
|
|
panic!("run did not parse");
|
|
};
|
|
assert_eq!(args.cmd, vec!["cargo", "test", "--keep"]);
|
|
assert!(
|
|
!args.keep,
|
|
"--keep after -- belongs to the child, not to tty7"
|
|
);
|
|
|
|
let cli = parse(&["tty7", "run", "--keep", "--cwd", "C:\\proj", "--", "make"]);
|
|
let Some(Command::Run(args)) = cli.command else {
|
|
panic!("run did not parse");
|
|
};
|
|
assert!(args.keep);
|
|
assert_eq!(args.cwd.as_deref(), Some("C:\\proj"));
|
|
assert_eq!(args.cmd, vec!["make"]);
|
|
}
|
|
|
|
#[test]
|
|
fn run_without_a_command_is_a_usage_error() {
|
|
let err = Cli::try_parse_from(["tty7", "run"]).unwrap_err();
|
|
assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument);
|
|
}
|
|
|
|
#[test]
|
|
fn split_requires_exactly_one_axis() {
|
|
let cli = parse(&["tty7", "split", "--v"]);
|
|
let Some(Command::Split(args)) = cli.command else {
|
|
panic!("split did not parse");
|
|
};
|
|
assert!(args.vertical && !args.horizontal);
|
|
assert!(args.target.is_none(), "the pane comes from $TTY7_PANE");
|
|
assert_eq!(args.ratio, 0.5);
|
|
|
|
let cli = parse(&["tty7", "pane", "split", "%42", "--h", "--ratio", "0.3"]);
|
|
let Some(Command::Pane(PaneCmd::Split(args))) = cli.command else {
|
|
panic!("pane split did not parse");
|
|
};
|
|
assert!(args.horizontal);
|
|
assert_eq!(args.target.as_deref(), Some("%42"));
|
|
assert_eq!(args.ratio, 0.3);
|
|
|
|
let err = Cli::try_parse_from(["tty7", "split", "%1"]).unwrap_err();
|
|
assert_eq!(
|
|
err.kind(),
|
|
ErrorKind::MissingRequiredArgument,
|
|
"an axis must be chosen"
|
|
);
|
|
let err = Cli::try_parse_from(["tty7", "split", "--h", "--v"]).unwrap_err();
|
|
assert_eq!(
|
|
err.kind(),
|
|
ErrorKind::ArgumentConflict,
|
|
"the two axes exclude each other"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn send_takes_an_optional_address_then_text() {
|
|
let cli = parse(&["tty7", "send", "%42", "make -j8", "--enter"]);
|
|
let Some(Command::Send(args)) = cli.command else {
|
|
panic!("send did not parse");
|
|
};
|
|
assert_eq!(args.first.as_deref(), Some("%42"));
|
|
assert_eq!(args.second.as_deref(), Some("make -j8"));
|
|
assert!(args.enter);
|
|
|
|
let cli = parse(&["tty7", "send", "make -j8"]);
|
|
let Some(Command::Send(args)) = cli.command else {
|
|
panic!("send did not parse");
|
|
};
|
|
assert_eq!(args.first.as_deref(), Some("make -j8"));
|
|
assert!(args.second.is_none());
|
|
}
|
|
|
|
/// `--key` is the reason TEXT became optional: `send %42 --key C-c` has an
|
|
/// address and no text, which every other shape would read as a mistake.
|
|
#[test]
|
|
fn send_accepts_keys_with_or_without_text() {
|
|
let cli = parse(&["tty7", "send", "%42", "--key", "C-c"]);
|
|
let Some(Command::Send(args)) = cli.command else {
|
|
panic!("send did not parse");
|
|
};
|
|
assert_eq!(args.first.as_deref(), Some("%42"));
|
|
assert!(args.second.is_none());
|
|
assert_eq!(args.keys.len(), 1);
|
|
assert_eq!(args.keys[0].bytes, vec![0x03]);
|
|
|
|
// A sequence keeps the order it was written in — that is the whole
|
|
// point for a menu that has to be walked down and then confirmed.
|
|
let cli = parse(&["tty7", "send", "--key", "down", "--key", "enter"]);
|
|
let Some(Command::Send(args)) = cli.command else {
|
|
panic!("send did not parse");
|
|
};
|
|
assert!(args.first.is_none(), "the pane comes from $TTY7_PANE");
|
|
let names: Vec<&str> = args.keys.iter().map(|k| k.name.as_str()).collect();
|
|
assert_eq!(names, vec!["down", "enter"]);
|
|
|
|
// An unknown key is a usage error, caught before anything is sent —
|
|
// half a key sequence in a live pane is worse than none.
|
|
let err = Cli::try_parse_from(["tty7", "send", "--key", "f7"]).unwrap_err();
|
|
assert_eq!(err.exit_code(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn every_ws_verb_parses() {
|
|
assert!(matches!(
|
|
parse(&["tty7", "ws", "ls"]).command,
|
|
Some(Command::Ws(WsCmd::Ls))
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "ws", "tree", "api"]).command,
|
|
Some(Command::Ws(WsCmd::Tree { ws: Some(w) })) if w == "api"
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "ws", "new", "api"]).command,
|
|
Some(Command::Ws(WsCmd::New { name: Some(n) })) if n == "api"
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "ws", "rename", "api", "web"]).command,
|
|
Some(Command::Ws(WsCmd::Rename { ws, name })) if ws == "api" && name == "web"
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "ws", "stop", "api"]).command,
|
|
Some(Command::Ws(WsCmd::Stop { ws })) if ws == "api"
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "ws", "rm", "api"]).command,
|
|
Some(Command::Ws(WsCmd::Rm { ws })) if ws == "api"
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "ws", "attach", "api"]).command,
|
|
Some(Command::Ws(WsCmd::Attach { ws })) if ws == "api"
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "ws", "detach", "api"]).command,
|
|
Some(Command::Ws(WsCmd::Detach { ws })) if ws == "api"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn every_tab_verb_parses() {
|
|
assert!(matches!(
|
|
parse(&["tty7", "tab", "ls", "api"]).command,
|
|
Some(Command::Tab(TabCmd::Ls { ws: Some(w) })) if w == "api"
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "tab", "new", "api", "--cwd", "C:\\proj"]).command,
|
|
Some(Command::Tab(TabCmd::New { ws: Some(w), cwd: Some(c) })) if w == "api" && c == "C:\\proj"
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "tab", "close", "@7"]).command,
|
|
Some(Command::Tab(TabCmd::Close { tab })) if tab == "@7"
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "tab", "rename", "@7", "build"]).command,
|
|
Some(Command::Tab(TabCmd::Rename { tab, name })) if tab == "@7" && name == "build"
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "tab", "move", "@7", "0"]).command,
|
|
Some(Command::Tab(TabCmd::Move { tab, index: 0 })) if tab == "@7"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn every_pane_verb_parses() {
|
|
assert!(matches!(
|
|
parse(&["tty7", "pane", "ls"]).command,
|
|
Some(Command::Pane(PaneCmd::Ls {
|
|
ws: None,
|
|
all: false
|
|
}))
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "pane", "close", "%9"]).command,
|
|
Some(Command::Pane(PaneCmd::Close { targets, orphans: false })) if targets == ["%9"]
|
|
));
|
|
// Several at once, because a cleanup usually has more than one thing
|
|
// to clean up — and the whole registry with `--orphans`.
|
|
assert!(matches!(
|
|
parse(&["tty7", "pane", "close", "%9", "%10"]).command,
|
|
Some(Command::Pane(PaneCmd::Close { targets, .. })) if targets == ["%9", "%10"]
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "pane", "close", "--orphans"]).command,
|
|
Some(Command::Pane(PaneCmd::Close { targets, orphans: true })) if targets.is_empty()
|
|
));
|
|
// Naming panes *and* asking for every orphan is a contradiction: which
|
|
// set did the caller mean? Refuse rather than pick one.
|
|
let err = Cli::try_parse_from(["tty7", "pane", "close", "%9", "--orphans"]).unwrap_err();
|
|
assert_eq!(err.kind(), ErrorKind::ArgumentConflict);
|
|
}
|
|
|
|
#[test]
|
|
fn every_machine_and_server_verb_parses() {
|
|
assert!(matches!(
|
|
parse(&["tty7", "machine", "ls"]).command,
|
|
Some(Command::Machine(MachineCmd::Ls))
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "machine", "connect", "devbox"]).command,
|
|
Some(Command::Machine(MachineCmd::Connect { profile })) if profile == "devbox"
|
|
));
|
|
assert!(matches!(
|
|
parse(&["tty7", "machine", "disconnect", "devbox"]).command,
|
|
Some(Command::Machine(MachineCmd::Disconnect { machine })) if machine == "devbox"
|
|
));
|
|
for (verb, want) in [
|
|
("start", ServerCmd::Start),
|
|
("stop", ServerCmd::Stop),
|
|
("restart", ServerCmd::Restart { hard: false }),
|
|
("status", ServerCmd::Status),
|
|
("logs", ServerCmd::Logs),
|
|
] {
|
|
let cli = parse(&["tty7", "server", verb]);
|
|
let Some(Command::Server(got)) = cli.command else {
|
|
panic!("server {verb} did not parse");
|
|
};
|
|
assert_eq!(
|
|
std::mem::discriminant(&got),
|
|
std::mem::discriminant(&want),
|
|
"server {verb} parsed as the wrong verb"
|
|
);
|
|
}
|
|
assert!(matches!(
|
|
parse(&["tty7", "server", "restart", "--hard"]).command,
|
|
Some(Command::Server(ServerCmd::Restart { hard: true }))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn global_flags_are_accepted_anywhere() {
|
|
let cli = parse(&["tty7", "-m", "devbox", "ls", "--json"]);
|
|
assert_eq!(cli.machine.as_deref(), Some("devbox"));
|
|
assert!(cli.json);
|
|
assert!(matches!(cli.command, Some(Command::Ls)));
|
|
|
|
let cli = parse(&["tty7", "ws", "ls", "-q", "-m", "devbox"]);
|
|
assert!(cli.quiet);
|
|
assert_eq!(cli.machine.as_deref(), Some("devbox"));
|
|
|
|
let cli = parse(&["tty7", "capture", "%3", "--scrollback", "--json"]);
|
|
assert!(cli.json);
|
|
let Some(Command::Capture(args)) = cli.command else {
|
|
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();
|
|
assert_eq!(
|
|
err.exit_code(),
|
|
2,
|
|
"clap's usage-error exit code is the CLI contract"
|
|
);
|
|
let err = Cli::try_parse_from(["tty7", "tab", "move", "@7", "not-a-number"]).unwrap_err();
|
|
assert_eq!(err.exit_code(), 2);
|
|
}
|
|
}
|