diff --git a/.github/scripts/bundle-macos.sh b/.github/scripts/bundle-macos.sh index b2566676..632a3c3a 100755 --- a/.github/scripts/bundle-macos.sh +++ b/.github/scripts/bundle-macos.sh @@ -214,9 +214,29 @@ DMG="dist/tty7-${VERSION}-macos-${ARCH}.dmg" STAGE="dist/dmg-stage" rm -rf "$STAGE" mkdir "$STAGE" -cp -R "$APP" "$STAGE/" +# `mv`, not `cp -R`: this is the peak, and a second full copy of the bundle is +# the most expensive thing on the volume that nobody needs. Nothing reads +# dist/tty7.app after this point — the zip above is what the updater ships and +# what nightly.yml verifies (it extracts that, not this), and release.yml only +# knows about tty7.app as an intermediate to keep out of the upload globs. +mv "$APP" "$STAGE/" ln -s /Applications "$STAGE/Applications" -hdiutil create -volname "tty7" -srcfolder "$STAGE" -ov -format UDZO "$DMG" +# Size the image explicitly. Left to itself, `-srcfolder` measures the bytes it +# is about to copy and asks for about that much, which does not cover what the +# filesystem spends carrying them — so the copy runs the *volume* out of room +# partway through and hdiutil reports "No space left on device". The path in +# that message is under /Volumes/tty7, not on the host: three nightlies died +# here on 2026-08-10 with 105 GiB free on the runner. It is a threshold, not a +# cliff — the x86_64 binaries are the larger pair and crossed it first, while +# arm64 went on building fine just underneath. +# +# Doubling the content and adding 64 MiB is far more slack than the shortfall +# needs, and it is close to free: the image is compressed on the way out, so +# measured against a stage of this shape, 127 MiB of empty volume cost 672 KiB +# in the published DMG. +STAGE_KB="$(du -sk "$STAGE" | awk '{print $1}')" +hdiutil create -volname "tty7" -srcfolder "$STAGE" -ov -format UDZO \ + -size "$(( STAGE_KB * 2 + 65536 ))k" "$DMG" rm -rf "$STAGE" if [[ -n "$SIGN_ID" && -n "${APPLE_CERTIFICATE:-}" ]]; then codesign --force --timestamp --sign "$SIGN_ID" "$DMG" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0deb0bcf..a6f4997b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,8 +63,65 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 A pane's shell is recorded alongside, so a git bash pane no longer comes back as PowerShell. +- **`tty7 wait` can wait for a command, not just an agent** — a new `free` + state ends the wait when the pane's foreground command has exited and the + pane is back to its bare shell, which is what a `cargo test` running in a + pane has instead of an agent status. With `--changed` it means "something ran + and then finished", the shape you want on the line after a `send`. It costs a + second request per poll and so is only checked when you name it — and only + when none of the agent states you named answered first, so `waiting,done,free` + on a pane of unknown kind cannot lose you a `waiting`. + +- **`tty7 send --key` presses keys instead of typing characters** — `C-c` to + stop a runaway build, `escape` to close a TUI, `up`/`down`/`enter` to answer + a permission prompt that takes no text. Repeatable for a sequence, composable + with `TEXT`, and delivered as separate events 200 ms apart so a raw-mode TUI + reads a sequence as a sequence rather than as a paste. An unknown key name is + a usage error raised before anything is written. + +- **`tty7 pane close` takes several panes, and `--orphans` clears the lot** — + `pane ls --all` has been able to *show* the panes an interrupted `run` leaves + behind; there was no way to act on that except by reading ids off the table + one at a time. A pane that cannot be closed no longer abandons the rest of + the batch. + +- **`tty7 doctor` reports where the agent status hooks stand** — it has claimed + to check hooks in its own `--help` for a while without doing so. Missing or + outdated hooks are why an agent can look frozen in `tty7 agents` and why + `tty7 wait` on it only ever times out, so the check belongs in the verb + people run when something is not working. + +- **SSH probes the `~/.ssh` default identity keys** — a connection with no + identity file of its own used to offer the server nothing unless an agent + was running, which on Windows is the common case (the OpenSSH + Authentication Agent service is off by default), and then reported "no + public key was accepted" when no key had ever been sent. `id_ed25519`, + `id_ecdsa` and `id_rsa` are now offered after the connection's own files + and before the agent, OpenSSH-style, deduplicated against the explicit list + by canonical path so one key spelled two ways is offered once — every offer + spends one of the server's `MaxAuthTries`. A discovered key that is + encrypted is used only when its passphrase is already in the OS keychain: + russh has no offer-without-signing probe, so asking would spend a prompt on + a key the server may not even want. A key named in the profile still asks, + as before. The failure text now separates the two situations the old line + papered over — keys the server rejected are named, and a round that offered + nothing says where it looked. (#484) + +### Changed + +- **`tty7 pane close --json` now reports `{"closed": [ids]}`** rather than a + single `{"closed": id}`, because the verb takes more than one pane. A batch + that could not close everything exits 1 with `{"closed": […], "failed": […]}` + and the complaint on stderr, so a retry knows what is left. + ### Fixed +- **`tty7 wait` no longer calls a busy shell `idle`** — a pane with nothing + reporting agent status was reported as `idle`, so `tty7 wait %3 --until idle` + returned success immediately, `matched: true`, about a pane that was midway + through a build. Those panes now report `no-agent`, which is both true and + the signal to use `--until free` instead; a wait that times out there says so. + - **An SFTP upload no longer sits in the browser under its temporary name** — an upload is written as `.tty7-upload-` and renamed into place at the end, and the browser listed the directory the moment the transfer @@ -162,19 +219,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 cost is one aggregate request per tick — the same one `tty7 agents` makes once. (by @yetone in #248) -- **An orchestration skill for Claude Code** — a switch under - **Settings → Agents** installs `~/.claude/skills/tty7-orchestration`, a - skill teaching a *primary* agent the whole delegation loop: open a worker - pane, send it one bounded task, `wait` on it, answer what it asks, collect - the result, close the pane. A skill rather than a global instruction on - purpose — an earlier cut appended this to `~/.claude/CLAUDE.md`, which - taxed every session's context window and, worse, encouraged *every* agent - to go orchestrate its neighbours. As a skill, only its one-line - description rides in context until something explicitly reaches for it, - and worker agents never inherit orchestration authority. The file carries - an ownership marker: uninstall removes a file tty7 wrote and refuses to - touch one it didn't, so a hand-written skill that happens to share the - directory name survives. (#248) +- **An agent-facing skill for the CLI** — the repo carries + [`skills/tty7`](skills/tty7/SKILL.md), which teaches a coding agent the + verbs above: work out which pane it is sitting in, split one, send a task + into another, capture what came back, run a command in a real PTY and pass + its exit code through. You install it yourself, with + `npx skills add l0ng-ai/tty7` — tty7 writes nothing into `~/.claude` for + it, and there is no switch in Settings that does. + + A skill rather than a global instruction, on purpose: an earlier cut + appended this guidance to `~/.claude/CLAUDE.md`, which taxed every + session's context window and, worse, encouraged *every* agent to go drive + its neighbours. As a skill, only its one-line description rides in context + until something explicitly reaches for it. - **Smooth scrolling for wheel mice** — a notch now eases into place over a handful of frames instead of jumping the whole distance at once. diff --git a/README.md b/README.md index 9675d34d..0ba8a115 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ English · [简体中文](README.zh-CN.md) +
+ +tty7 with a tab sidebar of agent sessions across several repos, running Claude Code + ## Why @@ -50,8 +54,11 @@ Native builds for each platform on [**Releases**](https://github.com/l0ng-ai/tty | **CLI + Skills** | bundled `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · pane/workspace control · real PTY commands · output, process, port, and agent status | | **SSH** | native russh stack: profiles with keychain secrets · SFTP panel · port forwarding · jump hosts · one-time, unprivileged `tty7-server` install | -Terminal and keybinding reference: [docs/features.md](docs/features.md). The agent-facing CLI -interface is documented in [skills/tty7/SKILL.md](skills/tty7/SKILL.md). +Full documentation lives in [**`docs/`**](docs/) — +[keyboard shortcuts](docs/reference/keyboard-shortcuts.mdx) · +[config.json](docs/reference/configuration.mdx) · +[CLI reference](docs/cli/reference.mdx). The agent-facing CLI interface is also +documented in [skills/tty7/SKILL.md](skills/tty7/SKILL.md). Install the skill with: diff --git a/README.zh-CN.md b/README.zh-CN.md index 069ec5ef..56a5ab7a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -17,6 +17,10 @@ [English](README.md) · 简体中文 +
+ +tty7 侧边栏列出多个仓库的 agent 会话,右侧运行 Claude Code + ## 为什么 @@ -50,7 +54,10 @@ | **CLI + Skills** | 安装包自带 `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · pane/工作区控制 · 真实 PTY 命令 · 输出、进程、端口和 agent 状态 | | **SSH** | 原生 russh 栈:profile 凭据进 keychain · SFTP 面板 · 端口转发 · 跳板机 · 一次无 sudo 安装 `tty7-server` | -终端和快捷键参考:[docs/features.zh-CN.md](docs/features.zh-CN.md)。面向 agent 的 CLI 接口见 +完整文档在 [**`docs/`**](docs/)(英文)—— +[快捷键](docs/reference/keyboard-shortcuts.mdx) · +[config.json](docs/reference/configuration.mdx) · +[CLI 参考](docs/cli/reference.mdx)。面向 agent 的 CLI 接口另见 [skills/tty7/SKILL.md](skills/tty7/SKILL.md)。 通过以下命令安装 skill: diff --git a/assets/hero.webp b/assets/hero.webp new file mode 100644 index 00000000..416a5a41 Binary files /dev/null and b/assets/hero.webp differ diff --git a/crates/tty7-cli/src/backend.rs b/crates/tty7-cli/src/backend.rs index 52ed5347..40c20efa 100644 --- a/crates/tty7-cli/src/backend.rs +++ b/crates/tty7-cli/src/backend.rs @@ -90,6 +90,10 @@ pub mod mock { pub capture_segments: Vec, pub procs_calls: Vec, pub procs_reply: PaneProcs, + /// Consumed one per call before falling back to `procs_reply`, so a + /// test can script a pane going busy and then quiet again — which is + /// the whole of what `wait --until free` watches for. + pub procs_replies: VecDeque, pub agent_hooks_states: Vec<(HookAgent, HooksState)>, pub registry: Vec, pub killed: Vec, @@ -112,6 +116,7 @@ pub mod mock { capture_segments: Vec::new(), procs_calls: Vec::new(), procs_reply: PaneProcs::default(), + procs_replies: VecDeque::new(), agent_hooks_states: Vec::new(), registry: Vec::new(), killed: Vec::new(), @@ -173,7 +178,10 @@ pub mod mock { fn procs(&mut self, pane: u64) -> Result { self.procs_calls.push(pane); - Ok(self.procs_reply.clone()) + Ok(self + .procs_replies + .pop_front() + .unwrap_or_else(|| self.procs_reply.clone())) } fn agent_hooks_state(&mut self, agent: HookAgent) -> Option { diff --git a/crates/tty7-cli/src/cli.rs b/crates/tty7-cli/src/cli.rs index 79bd3c7b..ce246cbf 100644 --- a/crates/tty7-cli/src/cli.rs +++ b/crates/tty7-cli/src/cli.rs @@ -68,7 +68,13 @@ pub enum Command { #[command(about = "Split a pane (= tty7 pane split)")] Split(SplitArgs), - #[command(about = "Type text into a pane")] + // 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( @@ -100,7 +106,8 @@ pub enum Command { Status, #[command( - about = "Check this install: socket, dialect, config, versions, hooks, links, context" + about = "Check this install: socket, dialect, config, versions, agent hooks, links, \ + context" )] Doctor, @@ -190,24 +197,51 @@ pub struct SplitArgs { #[derive(Debug, Args)] pub struct SendArgs { #[arg(value_name = "%PANE|TEXT")] - pub first: String, + pub first: Option, #[arg(value_name = "TEXT")] pub second: Option, #[arg(long, help = "Press Enter after the text")] 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, } -/// One resting place a `wait` can end on. `Exit` is pane-level (the child -/// died or the pane is gone), the rest are the agent-status ladder the -/// server maintains from hook events. +/// 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, } @@ -221,6 +255,8 @@ impl WaitState { WaitState::Working => "working", WaitState::Waiting => "waiting", WaitState::Done => "done", + WaitState::NoAgent => "no-agent", + WaitState::Free => "free", WaitState::Exit => "exit", } } @@ -236,12 +272,14 @@ pub struct WaitArgs { // 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" + help = "States that end the wait; `free` waits for a plain command to finish" )] pub until: Vec, @@ -259,10 +297,17 @@ pub struct WaitArgs { // 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 (use this after `send`)" + moved into after the wait began; with `free`, wait until something \ + has actually run (use this after `send`)" )] pub changed: bool, @@ -412,10 +457,22 @@ pub enum PaneCmd { #[command(about = "Split a pane in two")] Split(SplitArgs), - #[command(about = "Close a pane; its shell is hung up")] + #[command(about = "Close panes; their shells are hung up")] Close { - #[arg(value_name = "%PANE")] - target: Option, + #[arg(value_name = "%PANE", help = "Panes to close; defaults to $TTY7_PANE")] + targets: Vec, + + // `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, }, } @@ -593,7 +650,7 @@ mod tests { let Some(Command::Send(args)) = cli.command else { panic!("send did not parse"); }; - assert_eq!(args.first, "%42"); + assert_eq!(args.first.as_deref(), Some("%42")); assert_eq!(args.second.as_deref(), Some("make -j8")); assert!(args.enter); @@ -601,10 +658,39 @@ mod tests { let Some(Command::Send(args)) = cli.command else { panic!("send did not parse"); }; - assert_eq!(args.first, "make -j8"); + 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!( @@ -676,8 +762,22 @@ mod tests { )); assert!(matches!( parse(&["tty7", "pane", "close", "%9"]).command, - Some(Command::Pane(PaneCmd::Close { target: Some(t) })) if t == "%9" + 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] diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index 8daa3486..1ea301a0 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -86,8 +86,8 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result tab_rename(&tab, name, backend), Some(Command::Tab(TabCmd::Move { tab, index })) => tab_move(&tab, index, backend), Some(Command::Pane(PaneCmd::Ls { ws, all })) => pane_ls(ws.as_deref(), all, backend), - Some(Command::Pane(PaneCmd::Close { target })) => { - pane_close(target.as_deref(), ctx, backend) + Some(Command::Pane(PaneCmd::Close { targets, orphans })) => { + pane_close(&targets, orphans, ctx, backend) } Some(Command::Events) => events(json_mode, backend), Some(Command::Agents) => agents(backend), @@ -482,29 +482,64 @@ fn pane_split(args: SplitArgs, ctx: &Context, backend: &mut dyn Backend) -> Resu } fn send(args: SendArgs, ctx: &Context, backend: &mut dyn Backend) -> Result { - const ENTER_GAP: Duration = Duration::from_millis(200); + const KEY_GAP: Duration = Duration::from_millis(200); - let (target, text) = match &args.second { - Some(text) => (Some(args.first.as_str()), text.as_str()), - None => { - if args.first.starts_with('%') && address::parse_pane(&args.first).is_ok() { - bail!("send needs TEXT after the pane address"); + // Three shapes reach here, and only the address is ever ambiguous: + // `send %3 "text"`, `send "text"` (this pane), and — new with --key — + // `send %3 --key C-c`, where there is no text at all and the lone + // positional is therefore an address rather than the missing-text error it + // has to stay in every other case. + let (target, text) = match (&args.first, &args.second) { + (Some(first), Some(text)) => (Some(first.as_str()), Some(text.as_str())), + (Some(first), None) if first.starts_with('%') && address::parse_pane(first).is_ok() => { + if args.keys.is_empty() { + bail!("send needs TEXT after the pane address, or a --key to press"); } - (None, args.first.as_str()) + (Some(first.as_str()), None) + } + (Some(first), None) => (None, Some(first.as_str())), + (None, _) => { + if args.keys.is_empty() { + bail!("send needs TEXT to type or a --key to press"); + } + (None, None) } }; + let pane = address::pane_or_context(target, ctx)?; - backend.send_input(pane, text.as_bytes().to_vec())?; + let mut already_wrote = false; + if let Some(text) = text { + backend.send_input(pane, text.as_bytes().to_vec())?; + already_wrote = true; + } + // `--enter` is the same thing as `--key enter`, and predates it. Keeping it + // as sugar rather than deprecating it: it reads better for the overwhelming + // case, which is typing one command and running it. Going through the same + // parser leaves one definition of what Enter puts on the wire. + let mut pressed = args.keys.clone(); if args.enter { + pressed.push(crate::keys::parse("enter").expect("enter is in the vocabulary")); + } + for key in &pressed { // Raw-mode TUIs detect a fast stream as pasted input and intentionally - // absorb Enter as a newline. Keep the public one-shot command, but let - // the text leave that burst window before delivering the key itself. - std::thread::sleep(ENTER_GAP); - backend.send_input(pane, vec![b'\r'])?; + // absorb Enter as a newline — and a menu being driven by arrow keys has + // the same problem. Let each keystroke leave the burst window on its + // own, which is what makes a sequence land as a sequence. Nothing + // precedes the first write, though, so an interrupt stays immediate. + if already_wrote { + std::thread::sleep(KEY_GAP); + } + backend.send_input(pane, key.bytes.clone())?; + already_wrote = true; } report( "", - json!({ "pane": pane, "sent": text, "enter": args.enter }), + json!({ + "pane": pane, + "sent": text.unwrap_or_default(), + "enter": args.enter, + "keys": pressed.iter().map(|k| k.name.as_str()).collect::>(), + }), ) } @@ -734,27 +769,112 @@ fn pane_ls_all(backend: &mut dyn Backend) -> Result { let mut human = output::registry_table(&running, &|pane| holder(pane).map(|ws| ws.to_string())); if orphans > 0 { human.push_str(&format!( - "\n{orphans} pane(s) held by no workspace — `tty7 pane close %` stops one\n" + "\n{orphans} pane(s) held by no workspace — `tty7 pane close %` stops one, \ + `tty7 pane close --orphans` stops all of them\n" )); } report(human, json!({ "panes": panes, "orphans": orphans })) } -fn pane_close(target: Option<&str>, ctx: &Context, backend: &mut dyn Backend) -> Result { - let pane = address::pane_or_context(target, ctx)?; +fn pane_close( + targets: &[String], + orphans: bool, + ctx: &Context, + backend: &mut dyn Backend, +) -> Result { + // One tree read for the whole batch: it resolves the orphan set and then + // every pane's owning workspace. let machine = fetch_machine(backend)?; - match resolve::workspace_of_pane(&machine, pane) { - Ok(ws) => { - let workspace = ws.id; - let reply = backend.control(ControlRequest::PaneClose { workspace, pane })?; - hang_up_removed_panes("PaneClose", reply, backend)?; + let panes = if orphans { + let found = orphan_panes(&machine, backend)?; + if found.is_empty() { + return report("no orphan panes\n", json!({ "closed": [] })); + } + found + } else if targets.is_empty() { + vec![address::pane_or_context(None, ctx)?] + } else { + targets + .iter() + .map(|t| address::pane_or_context(Some(t), ctx)) + .collect::>>()? + }; + + // Every pane is attempted even if an earlier one fails — a reaper that + // stops at the first error leaves the rest of the leak in place, which is + // the state the caller was trying to fix. + let mut closed = Vec::new(); + let mut failures = Vec::new(); + for pane in panes { + let outcome = match resolve::workspace_of_pane(&machine, pane) { + Ok(ws) => { + let workspace = ws.id; + match backend.control(ControlRequest::PaneClose { workspace, pane }) { + Ok(reply) => hang_up_removed_panes("PaneClose", reply, backend), + Err(e) => Err(e), + } + } + // No workspace holds it, so PaneClose has nothing to route through. + // Hang it up directly instead of refusing — this is exactly the + // orphan `pane ls --all` points the user at. + Err(_) => backend.kill_pane(pane), + }; + match outcome { + Ok(()) => closed.push(pane), + Err(e) => failures.push(format!("%{pane}: {e:#}")), } - // No workspace holds it, so PaneClose has nothing to route through. - // Hang it up directly instead of refusing — this is exactly the orphan - // `pane ls --all` just pointed the user at. - Err(_) => backend.kill_pane(pane)?, } - report("", json!({ "closed": pane })) + + if !failures.is_empty() { + // Structured even here, for the reason `wait` is: the caller was + // cleaning up, and what they need next is which panes are still theirs + // to deal with — an anyhow error would leave `--json` holding prose. + // The complaint goes to stderr all the same, so `-q` still reports it + // and the exit code is not the only thing that says so. + eprintln!( + "tty7: closed {} pane(s); {} could not be closed — {}", + closed.len(), + failures.len(), + failures.join("; ") + ); + return Ok(Outcome::Exit( + 1, + Report { + human: String::new(), + json: json!({ "closed": closed, "failed": failures }), + }, + )); + } + let human = match closed.as_slice() { + // The single-pane case is the overwhelming one and has always been + // silent on success; only a batch is worth narrating. + [_] => String::new(), + many => format!( + "closed {} panes: {}\n", + many.len(), + many.iter() + .map(|p| format!("%{p}")) + .collect::>() + .join(" ") + ), + }; + report(human, json!({ "closed": closed })) +} + +/// The panes the daemon is running that no workspace's tab tree references. +fn orphan_panes(machine: &Machine, backend: &mut dyn Backend) -> Result> { + let held: Vec = machine + .workspaces + .iter() + .flat_map(|ws| ws.tabs.iter()) + .flat_map(|tab| tab.root.pane_ids()) + .collect(); + Ok(backend + .list_panes()? + .iter() + .map(|info| info.pane_id) + .filter(|pane| !held.contains(pane)) + .collect()) } fn events(json_mode: bool, backend: &mut dyn Backend) -> Result { @@ -789,15 +909,23 @@ fn event_line(event: &ControlEvent) -> String { } } -/// The one verb that *blocks*: poll until the watched pane's agent reaches a -/// requested state, then report it. This is what turns the CLI into an -/// orchestration tool — "wake me when my peer agent needs input, or finishes -/// its turn" — without the screen-scraping a tmux-based agent team resorts to. +/// The one verb that *blocks*: poll until the watched pane reaches a requested +/// state, then report it. This is what turns the CLI into an orchestration tool +/// — "wake me when my peer agent needs input, or finishes its turn" — without +/// the screen-scraping a tmux-based agent team resorts to. /// -/// A poll of `AgentStates` rather than an `events` subscription on purpose: a -/// one-shot, stateless question composes into scripts (`tty7 wait %3 && -/// tty7 capture %3 --plain`), survives a server restart mid-wait, and needs no -/// cursor management. At the default 500ms interval the cost is one aggregate +/// Two kinds of pane can be waited on, and they are watched differently. An +/// agent pane has a status the server keeps from hook events; a pane merely +/// running a command has none, and for it the question is whether the +/// foreground command has exited — `free`, read off the process tree. Keeping +/// both here rather than in two verbs means a caller that does not know which +/// kind it has can ask for `waiting,done,free,exit` and get an answer either +/// way. +/// +/// A poll rather than an `events` subscription on purpose: a one-shot, +/// stateless question composes into scripts (`tty7 wait %3 && tty7 capture %3 +/// --plain`), survives a server restart mid-wait, and needs no cursor +/// management. At the default 500ms interval an agent wait costs one aggregate /// control request per tick — the same request `tty7 agents` makes once. fn wait(args: WaitArgs, ctx: &Context, backend: &mut dyn Backend) -> Result { use std::time::{Duration, Instant}; @@ -821,7 +949,11 @@ fn wait(args: WaitArgs, ctx: &Context, backend: &mut dyn Backend) -> Result = None; + // Sticky: has the pane been seen running something since the wait began? + // This is `--changed`'s edge for `free` — see the flag's own comment. + let mut seen_busy = false; let mut polls: u32 = 0; loop { let states = match backend.control(ControlRequest::AgentStates)? { @@ -836,11 +968,13 @@ fn wait(args: WaitArgs, ctx: &Context, backend: &mut dyn Backend) -> Result WaitState::Waiting, AgentStatus::Done => WaitState::Done, }, - // No agent state for the pane: an agentless-but-live pane reads - // as idle; a dead or vanished one as exit. The machine tree is - // only fetched on this branch — while an agent is reporting, its - // state alone answers the question. - None if pane_is_live(backend, pane)? => WaitState::Idle, + // No agent state for the pane: a live one is agentless — a plain + // shell, or an agent whose hooks never got installed — and a dead + // or vanished one has exited. Reporting `idle` here (as this once + // did) made `--until idle` answer "finished" about a pane that was + // midway through a build. The machine tree is only fetched on this + // branch — while an agent is reporting, its state alone answers. + None if pane_is_live(backend, pane)? => WaitState::NoAgent, None => WaitState::Exit, }; @@ -848,7 +982,22 @@ fn wait(args: WaitArgs, ctx: &Context, backend: &mut dyn Backend) -> Result Result " (already free — nothing ran while we watched)", + _ => " (unchanged since the wait began)", + }); } return report(human, json); } if deadline.is_some_and(|d| Instant::now() >= d) { // 124 = the `timeout(1)` convention: "gave up", distinct from // both success and error, so orchestration scripts can branch. + let mut human = format!("pane %{pane}: still {} — timed out", current.name()); + // A wait for agent states that never move is the shape of both + // "there is no agent here" and "the agent's hooks are missing", + // and neither is visible from a timeout alone. Say which door to + // try rather than leaving the caller to poll harder. + if current == WaitState::NoAgent { + human.push_str( + "\nnothing is reporting agent status in this pane — for a plain command \ + wait `--until free`, and for an agent check `tty7 agents` for a missing \ + status hook", + ); + } + // `--changed` needs to have *seen* the pane busy, and a command + // that starts and finishes inside one interval never is. That + // looks exactly like "the command never ran", so say both, rather + // than let a finished command read as a timeout. + if current == WaitState::Free && !seen_busy { + human.push_str( + "\nnothing was ever seen running here — either the command never started, \ + or it finished inside one --interval. Poll faster (--interval 100) or drop \ + --changed", + ); + } return Ok(Outcome::Exit( 124, Report { - human: format!("pane %{pane}: still {} — timed out", current.name()), + human, json: json!({ "pane": pane, "status": current.name(), "timed_out": true }), }, )); @@ -923,6 +1098,25 @@ fn wait(args: WaitArgs, ctx: &Context, backend: &mut dyn Backend) -> Result Result { + let procs = backend.procs(pane)?.procs; + Ok(!procs.is_empty() && procs.iter().all(|p| p.depth == 0)) +} + /// Whether the daemon still has a live pane behind this id. Absent from the /// tree counts as dead: a closed pane is as gone as an exited one. fn pane_is_live(backend: &mut dyn Backend, pane: u64) -> Result { @@ -1089,6 +1283,7 @@ fn doctor(ctx: &Context, backend: &mut dyn Backend) -> Result { vec![address::ENV_PANE.to_string(), mark(&ctx.pane)], ]; let mut server = json!({ "reachable": false }); + let mut hooks: Vec<(HookAgent, HooksState)> = Vec::new(); match backend.hello() { Ok(hello) => { let dialect_ok = hello.control_version == CONTROL_VERSION @@ -1127,6 +1322,13 @@ fn doctor(ctx: &Context, backend: &mut dyn Backend) -> Result { "machine links".to_string(), format!("{} known, {connected} connected", routes.len()), ]); + // Without hooks an agent reports no status, which means `tty7 + // agents` shows it standing still and `tty7 wait` never wakes. That + // failure looks like a hang rather than a missing install, so the + // check that explains it belongs in the verb people run when + // something is not working. + hooks = hook_survey(backend); + rows.push(vec!["agent hooks".to_string(), hooks_summary(&hooks)]); server = json!({ "reachable": true, "dialect_ok": dialect_ok, @@ -1154,10 +1356,76 @@ fn doctor(ctx: &Context, backend: &mut dyn Backend) -> Result { "pane": ctx.pane.is_some(), }, "server": server, + "hooks": hooks_json(&hooks), }), ) } +/// Where every installable status hook stands on this machine. +/// +/// Agents whose state cannot be read at all are left out rather than guessed +/// at: the backend answers `None` both for a `-m` run (hooks are a local +/// install, and this is a local check) and when the app itself cannot be found, +/// and neither is the same as "not installed". +fn hook_survey(backend: &mut dyn Backend) -> Vec<(HookAgent, HooksState)> { + HookAgent::ALL + .into_iter() + .filter_map(|agent| Some((agent, backend.agent_hooks_state(agent)?))) + .collect() +} + +fn hooks_summary(hooks: &[(HookAgent, HooksState)]) -> String { + if hooks.is_empty() { + return "unknown — hooks are a local install, and this check could not read them".into(); + } + let named = |want: HooksState| -> Vec<&'static str> { + hooks + .iter() + .filter(|(_, state)| *state == want) + .map(|(agent, _)| agent.display_name()) + .collect() + }; + let installed = named(HooksState::Installed); + let outdated = named(HooksState::Outdated); + + // The current ones are named because that is the answer to "can I delegate + // to this agent"; the missing ones are a count, since listing every agent + // tty7 knows about would bury it. "Up to date" rather than "installed": + // an outdated hook *is* installed, and saying "none installed" next to six + // outdated ones reads as a contradiction. + let mut summary = if installed.is_empty() { + "none up to date".to_string() + } else { + format!("{} up to date", installed.join(", ")) + }; + if !outdated.is_empty() { + summary.push_str(&format!("; {} OUTDATED", outdated.join(", "))); + } + let missing = hooks.len() - installed.len() - outdated.len(); + if missing > 0 { + summary.push_str(&format!("; {missing} not installed")); + } + if installed.is_empty() || !outdated.is_empty() { + summary.push_str(" (Settings → Agents)"); + } + summary +} + +fn hooks_json(hooks: &[(HookAgent, HooksState)]) -> Value { + let slugs = |want: HooksState| -> Vec<&'static str> { + hooks + .iter() + .filter(|(_, state)| *state == want) + .map(|(agent, _)| agent.slug()) + .collect() + }; + json!({ + "installed": slugs(HooksState::Installed), + "outdated": slugs(HooksState::Outdated), + "not_installed": slugs(HooksState::NotInstalled), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -1740,6 +2008,98 @@ mod tests { ); } + /// The CLI is what creates orphans, so it should be able to clear them. + /// `--orphans` closes exactly the panes the registry holds and the tab + /// trees do not — panes that *are* held must survive it untouched. + #[test] + fn pane_close_orphans_reaps_only_what_no_workspace_holds() { + let mut backend = mock(); + // %1 and %3 live in the tree (see two_workspace_machine); %77 and %78 + // are what interrupted `run`s left behind. + backend.registry = vec![ + pane_info(1, None), + pane_info(3, None), + pane_info(77, Some("tty7-cli")), + pane_info(78, Some("tty7-cli")), + ]; + + let json = json_of(run_cli( + &["tty7", "pane", "close", "--orphans"], + &Context::default(), + &mut backend, + )); + assert_eq!(json["closed"], serde_json::json!([77, 78])); + assert_eq!(backend.killed, vec![77, 78]); + assert!( + !backend + .control_calls + .iter() + .any(|c| matches!(c, ControlRequest::PaneClose { .. })), + "orphans have no workspace to route a PaneClose through" + ); + + // Nothing to reap is a success with an empty list, not an error: a + // cleanup step that fails when the machine is already clean is one a + // script has to guard, and every script would then guard it the same way. + let mut backend = mock(); + backend.registry = vec![pane_info(1, None), pane_info(3, None)]; + let json = json_of(run_cli( + &["tty7", "pane", "close", "--orphans"], + &Context::default(), + &mut backend, + )); + assert_eq!(json["closed"], serde_json::json!([])); + assert!(backend.killed.is_empty()); + } + + /// A batch keeps going after a failure. Stopping at the first one would + /// leave the rest of the leak exactly where it was — while still reporting + /// the failure, because a half-done cleanup that claims success is worse. + /// + /// Reported as an exit code carrying a report, not as an error: the caller + /// was cleaning up, and the useful answer is which panes are still theirs + /// to deal with. An anyhow error would leave `--json` with prose. + #[test] + fn pane_close_reports_failures_without_abandoning_the_batch() { + let mut backend = mock(); + backend.registry = vec![ + pane_info(77, None), + pane_info(78, None), + pane_info(79, None), + ]; + backend.kill_failures = vec![78]; + + let out = execute( + cli(&["tty7", "pane", "close", "--orphans"]), + &Context::default(), + &mut backend, + ) + .expect("a partial cleanup is an exit code, not an error"); + let Outcome::Exit(1, r) = out else { + panic!("a pane that could not be closed has to be reported"); + }; + assert_eq!( + r.json["closed"], + serde_json::json!([77, 79]), + "the survivors of the batch are what a retry needs: {}", + r.json + ); + assert!( + r.json["failed"] + .as_array() + .expect("the failures are a list") + .iter() + .any(|f| f.as_str().is_some_and(|f| f.contains("%78"))), + "{}", + r.json + ); + assert_eq!( + backend.killed, + vec![77, 78, 79], + "the panes after the failure still had to be attempted" + ); + } + #[test] fn send_reaches_the_pane_socket_seam_not_the_control_socket() { let mut backend = mock(); @@ -1763,6 +2123,66 @@ mod tests { assert_eq!(backend.sent, vec![(3, b"echo hi".to_vec())]); } + /// The keystrokes text cannot express. Each goes out as its own write, in + /// the order given, because a pane reads them as separate key events — + /// which is what walking a menu and then confirming it requires. + #[test] + fn send_key_presses_keys_in_order() { + let mut backend = mock(); + run_cli( + &["tty7", "send", "%1", "--key", "down", "--key", "enter"], + &Context::default(), + &mut backend, + ); + assert_eq!( + backend.sent, + vec![(1, b"\x1b[B".to_vec()), (1, b"\r".to_vec())] + ); + + // Text and keys compose: type the answer, then press the key that + // submits it in whatever the pane is showing. + let mut backend = mock(); + run_cli( + &["tty7", "send", "%1", "y", "--key", "enter"], + &Context::default(), + &mut backend, + ); + assert_eq!(backend.sent, vec![(1, b"y".to_vec()), (1, b"\r".to_vec())]); + + // Interrupting takes no text at all — the case that made TEXT optional. + let mut backend = mock(); + let json = json_of(run_cli( + &["tty7", "send", "%1", "--key", "C-c"], + &Context::default(), + &mut backend, + )); + assert_eq!(backend.sent, vec![(1, vec![0x03])]); + assert_eq!(json["keys"], serde_json::json!(["c-c"])); + assert_eq!(json["sent"], "", "nothing was typed"); + } + + /// A lone address still has to be the missing-text error it always was — + /// otherwise `tty7 send %42` would silently do nothing at all. + #[test] + fn send_still_refuses_a_bare_address_when_there_is_nothing_to_press() { + let mut backend = mock(); + let err = execute( + cli(&["tty7", "send", "%1"]), + &Context::default(), + &mut backend, + ) + .expect_err("a bare address sends nothing and must say so"); + assert!(err.to_string().contains("needs TEXT"), "{err}"); + assert!(backend.sent.is_empty()); + + // And outside a tty7 shell, with neither text nor keys, the complaint + // is about the missing input rather than the missing pane. + let mut backend = mock(); + let err = execute(cli(&["tty7", "send"]), &Context::default(), &mut backend) + .expect_err("send with no arguments has nothing to do"); + assert!(err.to_string().contains("--key"), "{err}"); + } + #[test] fn send_outside_a_shell_without_an_address_names_the_fix() { let mut backend = mock(); @@ -2143,6 +2563,39 @@ mod tests { agent_state_at(pane_id, status, 0) } + /// A pane sitting at its prompt: the shell, and nothing in front of it. + fn idle_procs() -> tty7_core::daemon::protocol::PaneProcs { + tty7_core::daemon::protocol::PaneProcs { + procs: vec![proc_entry(100, "zsh", 0, true)], + ports: Vec::new(), + } + } + + /// The same pane with a command running in it. + fn busy_procs() -> tty7_core::daemon::protocol::PaneProcs { + tty7_core::daemon::protocol::PaneProcs { + procs: vec![ + proc_entry(100, "zsh", 0, false), + proc_entry(101, "cargo", 1, true), + ], + ports: Vec::new(), + } + } + + fn proc_entry( + pid: u32, + name: &str, + depth: u8, + foreground: bool, + ) -> tty7_core::daemon::protocol::ProcEntry { + tty7_core::daemon::protocol::ProcEntry { + pid, + name: name.into(), + depth, + foreground, + } + } + fn agent_state_at( pane_id: u64, status: tty7_core::core::cli_agent::AgentStatus, @@ -2312,18 +2765,18 @@ mod tests { } /// Panes without an agent state fall back to the machine tree: live means - /// idle, dead-or-gone means exit — which ends every wait, but only counts - /// as *matched* when the caller listed it. + /// `no-agent`, dead-or-gone means exit — which ends every wait, but only + /// counts as *matched* when the caller listed it. #[test] fn wait_reads_agentless_panes_from_the_tree() { let mut backend = mock(); backend.replies.push_back(ReplyOk::AgentStates(Vec::new())); let out = run_cli( - &["tty7", "wait", "%3", "--until", "idle"], + &["tty7", "wait", "%3", "--until", "no-agent"], &Context::default(), &mut backend, ); - assert_eq!(json_of(out)["status"], "idle"); + assert_eq!(json_of(out)["status"], "no-agent"); // Pane 9 exists nowhere: "exit", matched by the default until-set. let mut backend = mock(); @@ -2353,6 +2806,230 @@ mod tests { assert!(r.human.contains("exited"), "{}", r.human); } + /// The trap this state exists to close. A pane with nothing reporting used + /// to answer `idle`, so `--until idle` returned success — instantly, with + /// `matched: true` — about a shell that was midway through a build. The + /// caller then read a half-finished screen and believed it. + #[test] + fn wait_does_not_call_a_busy_shell_idle() { + let mut backend = mock(); + backend.replies.push_back(ReplyOk::AgentStates(Vec::new())); + backend.procs_reply = busy_procs(); + + let out = execute( + cli(&["tty7", "wait", "%3", "--until", "idle", "--timeout", "0"]), + &Context::default(), + &mut backend, + ) + .expect("a timeout is an exit code, not an error"); + let Outcome::Exit(124, r) = out else { + panic!("a pane with no agent must not satisfy --until idle"); + }; + assert_eq!(r.json["status"], "no-agent"); + assert!( + r.human.contains("--until free"), + "the timeout should point at the flag that answers this question: {}", + r.human + ); + } + + /// `free` is the missing half of the verb: an agent pane has a status to + /// wait on, a pane merely running a command has only its process tree. + /// Nothing below the depth-0 shell means the foreground command exited. + #[test] + fn wait_free_ends_when_the_foreground_command_exits() { + let mut backend = mock(); + for _ in 0..3 { + backend.replies.push_back(ReplyOk::AgentStates(Vec::new())); + } + // Busy, busy, then back to the bare shell. + backend.procs_replies.push_back(busy_procs()); + backend.procs_replies.push_back(busy_procs()); + backend.procs_replies.push_back(idle_procs()); + + let json = json_of(run_cli( + &["tty7", "wait", "%3", "--until", "free", "--interval", "50"], + &Context::default(), + &mut backend, + )); + assert_eq!(json["status"], "free"); + assert_eq!(json["matched"], true); + assert_eq!(json["stale"], false, "we watched the command finish"); + assert_eq!( + backend.procs_calls.len(), + 3, + "one process-tree read per poll, and only because `free` was asked for" + ); + } + + /// The process tree is level-triggered like the agent ladder, but a shell + /// that goes free → busy → free lands back where it started, so a baseline + /// comparison would miss it. `--changed` therefore means "something ran + /// while I watched" here — which is what a caller wants right after `send`. + #[test] + fn wait_changed_free_waits_for_something_to_actually_run() { + // Already free and it stays that way: the command has not started yet, + // so answering "free" would report the shell we sent the work *to*. + let mut backend = mock(); + for _ in 0..2 { + backend.replies.push_back(ReplyOk::AgentStates(Vec::new())); + } + backend.procs_reply = idle_procs(); + let out = execute( + cli(&[ + "tty7", + "wait", + "%3", + "--until", + "free", + "--changed", + "--timeout", + "0", + ]), + &Context::default(), + &mut backend, + ) + .expect("a timeout is an exit code, not an error"); + assert!( + matches!(out, Outcome::Exit(124, _)), + "a pane that was free all along has not run anything" + ); + + // Free → busy → free is the real shape, and it must wake. + let mut backend = mock(); + for _ in 0..3 { + backend.replies.push_back(ReplyOk::AgentStates(Vec::new())); + } + backend.procs_replies.push_back(idle_procs()); + backend.procs_replies.push_back(busy_procs()); + backend.procs_replies.push_back(idle_procs()); + let json = json_of(run_cli( + &[ + "tty7", + "wait", + "%3", + "--until", + "free", + "--changed", + "--interval", + "50", + ], + &Context::default(), + &mut backend, + )); + assert_eq!(json["status"], "free"); + assert_eq!(json["matched"], true); + assert_eq!(json["stale"], false); + } + + /// A command that starts and finishes between two polls is never *seen* + /// busy, which is indistinguishable from one that never ran — so the + /// timeout has to name both doors instead of letting a finished command + /// read as "still going". + #[test] + fn wait_changed_free_says_why_it_saw_nothing_run() { + let mut backend = mock(); + for _ in 0..2 { + backend.replies.push_back(ReplyOk::AgentStates(Vec::new())); + } + backend.procs_reply = idle_procs(); + + let out = execute( + cli(&[ + "tty7", + "wait", + "%3", + "--until", + "free", + "--changed", + "--timeout", + "0", + ]), + &Context::default(), + &mut backend, + ) + .expect("a timeout is an exit code, not an error"); + let Outcome::Exit(124, r) = out else { + panic!("a pane that was free all along has not run anything"); + }; + assert!( + r.human.contains("--interval") && r.human.contains("--changed"), + "the timeout should name the two ways out: {}", + r.human + ); + } + + /// `free` answers for a pane the agent ladder cannot, so it must not answer + /// *over* it. A pane whose depth-0 process is the agent itself reads free + /// for its whole turn; letting that outrank a `waiting` the caller asked + /// for would strand exactly the delegation loop the verb exists for. + #[test] + fn wait_free_does_not_overrule_a_state_the_caller_asked_for() { + use tty7_core::core::cli_agent::AgentStatus; + let mut backend = mock(); + backend + .replies + .push_back(ReplyOk::AgentStates(vec![agent_state( + 3, + AgentStatus::Waiting, + )])); + // The agent is the pane's only process, so the tree reads "free". + backend.procs_reply = idle_procs(); + + let json = json_of(run_cli( + &["tty7", "wait", "%3", "--until", "waiting,free"], + &Context::default(), + &mut backend, + )); + assert_eq!(json["status"], "waiting", "the ladder answered first"); + assert_eq!(json["matched"], true); + assert!( + backend.procs_calls.is_empty(), + "and the process tree was never asked" + ); + } + + /// An unreadable process tree is not an idle one. Answering `free` on an + /// empty reply would be the same false success `no-agent` was added to + /// remove, one layer down. + #[test] + fn wait_free_does_not_read_an_empty_process_tree_as_finished() { + let mut backend = mock(); + backend.replies.push_back(ReplyOk::AgentStates(Vec::new())); + backend.procs_reply = tty7_core::daemon::protocol::PaneProcs::default(); + + let out = execute( + cli(&["tty7", "wait", "%3", "--until", "free", "--timeout", "0"]), + &Context::default(), + &mut backend, + ) + .expect("a timeout is an exit code, not an error"); + assert!( + matches!(out, Outcome::Exit(124, _)), + "nothing was seen, so nothing can be claimed" + ); + } + + /// Watching `free` must not cost anything for callers who did not ask: + /// the process tree is a second round trip per poll on top of the agent + /// snapshot, and the default wait is for agents. + #[test] + fn wait_only_reads_the_process_tree_when_free_is_asked_for() { + use tty7_core::core::cli_agent::AgentStatus; + let mut backend = mock(); + backend + .replies + .push_back(ReplyOk::AgentStates(vec![agent_state( + 3, + AgentStatus::Waiting, + )])); + run_cli(&["tty7", "wait", "%3"], &Context::default(), &mut backend); + assert!( + backend.procs_calls.is_empty(), + "the default until-set names no pane-level state" + ); + } + /// A `--timeout` that runs out exits 124 — the `timeout(1)` convention — /// so scripts can branch on "not yet" separately from "broken". #[test] @@ -2567,4 +3244,60 @@ mod tests { let out = human(run_cli(&["tty7", "doctor"], &ctx, &mut doctor_backend())); assert!(out.contains("set (/cfg/tty7)"), "{out}"); } + + /// Missing hooks are the reason a perfectly healthy-looking agent never + /// reports and `tty7 wait` sits there until it times out. `doctor` is the + /// verb people run when something is not working, so it is where that has + /// to be visible — and it long claimed to check hooks without doing so. + #[test] + fn doctor_reports_where_the_agent_status_hooks_stand() { + use tty7_core::core::agent_hooks::HookAgent; + + let mut backend = doctor_backend(); + // The real backend answers for every agent it knows how to install + // hooks for, so the mock does too — the interesting part is that the + // three states are told apart, not that a lookup can come back empty. + backend.agent_hooks_states = HookAgent::ALL + .into_iter() + .map(|agent| match agent { + HookAgent::Claude => (agent, HooksState::Installed), + HookAgent::Codex => (agent, HooksState::Outdated), + other => (other, HooksState::NotInstalled), + }) + .collect(); + let out = run_cli(&["tty7", "doctor"], &Context::default(), &mut backend); + let Outcome::Report(r) = out else { + panic!("doctor reports"); + }; + assert!(r.human.contains("agent hooks"), "{}", r.human); + assert!( + r.human.contains("OUTDATED"), + "an outdated hook is the quiet failure worth shouting about: {}", + r.human + ); + assert!( + r.human.contains("Settings → Agents"), + "say where the fix is: {}", + r.human + ); + assert_eq!(r.json["hooks"]["installed"], serde_json::json!(["claude"])); + assert_eq!(r.json["hooks"]["outdated"], serde_json::json!(["codex"])); + assert_eq!( + r.json["hooks"]["not_installed"] + .as_array() + .expect("the rest are reported as a list, not omitted") + .len(), + HookAgent::ALL.len() - 2 + ); + + // A backend that cannot read hook state at all — a `-m` run, where the + // hooks live on the other machine — says so rather than reporting a + // machine-wide gap that is not there. + let out = human(run_cli( + &["tty7", "doctor"], + &Context::default(), + &mut doctor_backend(), + )); + assert!(out.contains("unknown"), "{out}"); + } } diff --git a/crates/tty7-cli/src/keys.rs b/crates/tty7-cli/src/keys.rs new file mode 100644 index 00000000..055af2b7 --- /dev/null +++ b/crates/tty7-cli/src/keys.rs @@ -0,0 +1,295 @@ +//! Key names for `tty7 send --key`. +//! +//! `send` types text, and text is enough right up until the pane is showing +//! something that text cannot answer: a permission prompt whose options are +//! chosen with the arrow keys, a TUI to be dismissed with Escape, a build to be +//! interrupted with Ctrl-C. Those are keystrokes, not characters — the caller +//! would otherwise have to know that Ctrl-C is byte 0x03 and that Up is +//! `ESC [ A`, and write them into a shell string without a typo. +//! +//! The vocabulary is deliberately the orchestration subset rather than every +//! key a terminal can encode: the modifier-plus-function-key combinations live +//! in a much larger table (and a mode-dependent one), and nothing here needs +//! them. What is missing can still be sent as literal text. + +use std::fmt; + +/// A parsed key: the bytes to write, plus the spelling the caller used so +/// errors and `--json` can name it back to them. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Key { + pub name: String, + pub bytes: Vec, +} + +impl fmt::Display for Key { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.name) + } +} + +/// The named keys, in the order `--help` should list them. +/// +/// Cursor keys are written in their CSI ("normal") form rather than the SS3 +/// form a terminal in application-cursor mode sends. Both are widely accepted +/// by readline and by TUI toolkits, and CSI is what a pane emits until an +/// application asks for the other — so it is the form that is right when we +/// cannot know which mode the pane is in. +const NAMED: &[(&str, &[u8])] = &[ + ("enter", b"\r"), + ("escape", b"\x1b"), + ("tab", b"\t"), + ("backtab", b"\x1b[Z"), + ("space", b" "), + ("backspace", b"\x7f"), + ("delete", b"\x1b[3~"), + ("up", b"\x1b[A"), + ("down", b"\x1b[B"), + ("right", b"\x1b[C"), + ("left", b"\x1b[D"), + ("home", b"\x1b[H"), + ("end", b"\x1b[F"), + ("pageup", b"\x1b[5~"), + ("pagedown", b"\x1b[6~"), +]; + +/// Spellings that mean one of the above. Keeping these as aliases rather than +/// entries of their own keeps the `--help` list short while accepting the name +/// whichever terminal's documentation the caller learned it from. +const ALIASES: &[(&str, &str)] = &[ + ("return", "enter"), + ("cr", "enter"), + ("esc", "escape"), + ("del", "delete"), + ("bs", "backspace"), + ("shift-tab", "backtab"), + ("pgup", "pageup"), + ("pgdn", "pagedown"), + ("pgdown", "pagedown"), +]; + +/// Every spelling `--key` accepts, in one line: what an unknown name is +/// answered with, and half of what `tty7 send --help` prints. +pub fn vocabulary() -> String { + let named: Vec<&str> = NAMED.iter().map(|(name, _)| *name).collect(); + format!("{}, C- (Ctrl), M- (Alt)", named.join(", ")) +} + +/// `tty7 send --help`. Assembled from the tables above so that adding a key or +/// an alias cannot leave the help text describing the vocabulary of an older +/// build — the drift nobody notices until a caller is told a key exists and it +/// does not, or the reverse. +pub fn send_long_help() -> String { + let aliases: Vec<&str> = ALIASES.iter().map(|(from, _)| *from).collect(); + format!( + "Types TEXT into the pane exactly as a keyboard would.\n\n\ + --key sends a keystroke rather than characters, which is what a pane wants once \ + something is already running in it: answering a prompt that only takes arrow keys, \ + closing a TUI with escape, stopping a build with C-c. Repeat it for a sequence.\n\n\ + Keys: {}. Aliases: {}.", + vocabulary(), + aliases.join(", ") + ) +} + +/// clap's `value_parser` for `--key`, so an unknown name is a usage error +/// caught before a single byte reaches the pane — sending half a key sequence +/// and then failing would leave the pane in a state nobody asked for. +pub fn parse(spelling: &str) -> Result { + let trimmed = spelling.trim(); + let folded = trimmed.to_ascii_lowercase(); + let canonical = ALIASES + .iter() + .find_map(|(from, to)| (*from == folded).then_some(*to)) + .unwrap_or(folded.as_str()); + + if let Some((_, bytes)) = NAMED.iter().find(|(name, _)| *name == canonical) { + return Ok(Key { + name: canonical.to_string(), + bytes: bytes.to_vec(), + }); + } + // Ctrl reads the folded spelling: the C0 rule clears the top three bits, so + // C-c and C-C are the same byte and always were. + if let Some(rest) = strip_modifier(canonical, &["c-", "ctrl-", "control-"]) { + return control(rest).map(|byte| Key { + name: format!("c-{rest}"), + bytes: vec![byte], + }); + } + // Alt is a prefixed ESC — the encoding every Unix terminal has used for it + // since long before there was a modifier-reporting protocol to do better. + // Which means the character rides through as itself, so unlike Ctrl this + // one has to read the spelling as written: M-X is not M-x. + // Stripped from `folded` rather than `canonical`: only that one is the + // caller's own spelling with the case knocked out of it, which is what + // makes the tail recoverable from `trimmed` by length. + if let Some(rest) = + strip_modifier(&folded, &["m-", "alt-", "meta-"]).map(|rest| as_written(trimmed, rest)) + { + let mut chars = rest.chars(); + return match (chars.next(), chars.next()) { + (Some(ch), None) => { + let mut bytes = vec![0x1b]; + let mut buf = [0u8; 4]; + bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes()); + Ok(Key { + name: format!("m-{rest}"), + bytes, + }) + } + _ => Err(format!( + "'{spelling}' is not a key — Alt takes a single character, as in M-x" + )), + }; + } + Err(format!( + "'{spelling}' is not a key. Known keys: {}. Anything else can be sent as text.", + vocabulary() + )) +} + +fn strip_modifier<'a>(name: &'a str, prefixes: &[&str]) -> Option<&'a str> { + prefixes + .iter() + .find_map(|prefix| name.strip_prefix(prefix)) + .filter(|rest| !rest.is_empty()) +} + +/// The same tail of the spelling the caller wrote, before it was folded. +/// +/// Safe to index by length: `to_ascii_lowercase` is byte-for-byte, and every +/// modifier prefix is ASCII, so a suffix of the folded form is a suffix of the +/// original at the same offset and on the same char boundary. +fn as_written<'a>(original: &'a str, folded_rest: &str) -> &'a str { + &original[original.len() - folded_rest.len()..] +} + +/// The C0 control byte a Ctrl-chord produces. This is the ASCII table's own +/// rule — clear the top three bits — which is why the range runs past the +/// letters and into `[ \ ] ^ _`, and why Ctrl-? is the odd one out at 0x7f. +fn control(rest: &str) -> Result { + let mut chars = rest.chars(); + let (Some(ch), None) = (chars.next(), chars.next()) else { + return Err(format!( + "'{rest}' is not a Ctrl chord — it takes a single character, as in C-c" + )); + }; + match ch { + 'a'..='z' => Ok(ch as u8 - b'a' + 1), + '@' => Ok(0), + '[' => Ok(0x1b), + '\\' => Ok(0x1c), + ']' => Ok(0x1d), + '^' => Ok(0x1e), + '_' => Ok(0x1f), + '?' => Ok(0x7f), + _ => Err(format!( + "Ctrl-{ch} is not a control character — Ctrl takes a-z or one of @ [ \\ ] ^ _ ?" + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bytes(spelling: &str) -> Vec { + parse(spelling) + .unwrap_or_else(|e| panic!("{spelling} should parse: {e}")) + .bytes + } + + #[test] + fn the_keys_an_orchestrator_actually_reaches_for() { + // Interrupting a runaway command, and answering a prompt that only + // takes keystrokes: the two things text alone cannot do. + assert_eq!(bytes("C-c"), vec![0x03]); + assert_eq!(bytes("escape"), vec![0x1b]); + assert_eq!(bytes("up"), b"\x1b[A".to_vec()); + assert_eq!(bytes("down"), b"\x1b[B".to_vec()); + assert_eq!(bytes("enter"), b"\r".to_vec()); + assert_eq!(bytes("tab"), b"\t".to_vec()); + } + + #[test] + fn spelling_is_forgiving_but_the_bytes_are_not() { + // Case and the common aliases all land on the same key, because the + // caller learned the name from whichever terminal they came from. + for spelling in ["enter", "Enter", "ENTER", "return", "CR"] { + assert_eq!(bytes(spelling), b"\r".to_vec(), "{spelling}"); + } + for spelling in ["C-c", "c-c", "ctrl-c", "Control-C"] { + assert_eq!(bytes(spelling), vec![0x03], "{spelling}"); + } + assert_eq!(bytes("shift-tab"), b"\x1b[Z".to_vec()); + assert_eq!(bytes("pgdn"), b"\x1b[6~".to_vec()); + } + + #[test] + fn the_control_range_follows_the_ascii_rule_not_a_lookup_table() { + assert_eq!(bytes("C-a"), vec![0x01]); + assert_eq!(bytes("C-d"), vec![0x04], "end of input"); + assert_eq!(bytes("C-l"), vec![0x0c], "clear"); + assert_eq!(bytes("C-z"), vec![0x1a], "suspend"); + assert_eq!(bytes("C-["), vec![0x1b], "the same byte as Escape"); + assert_eq!(bytes("C-?"), vec![0x7f], "the one that is not 0x00..0x1f"); + } + + #[test] + fn alt_is_a_prefixed_escape() { + assert_eq!(bytes("M-x"), vec![0x1b, b'x']); + assert_eq!(bytes("alt-b"), vec![0x1b, b'b']); + } + + /// Ctrl can be folded and Alt cannot: the ASCII rule throws the case away + /// either way for a control byte, while Alt carries the character through + /// as itself, so `M-X` and `M-x` are two different keys and must stay so. + #[test] + fn alt_keeps_the_case_the_caller_wrote() { + assert_eq!(bytes("M-X"), vec![0x1b, b'X']); + assert_eq!(bytes("Meta-X"), vec![0x1b, b'X']); + assert_eq!(bytes("M-x"), vec![0x1b, b'x']); + assert_eq!(parse("M-X").unwrap().name, "m-X"); + // Non-ASCII rides through as its own UTF-8, and the prefix arithmetic + // must not land mid-character doing it. + assert_eq!(bytes("M-ä"), vec![0x1b, 0xc3, 0xa4]); + } + + /// The help text is generated from the tables, so it cannot describe a + /// vocabulary the parser does not have. This is the assertion that the + /// generating is real rather than a second copy that happens to agree. + #[test] + fn the_help_text_lists_every_key_the_parser_takes() { + let help = send_long_help(); + for (name, _) in NAMED { + assert!(help.contains(name), "`{name}` is missing from --help"); + } + for (alias, _) in ALIASES { + assert!(help.contains(alias), "`{alias}` is missing from --help"); + } + assert!( + help.contains("C-") && help.contains("M-"), + "{help}" + ); + } + + /// An unknown name has to fail before anything is written: a key sequence + /// half-delivered into a live pane is worse than one not delivered at all. + /// So the error names the vocabulary rather than just refusing. + #[test] + fn an_unknown_key_is_refused_with_the_list() { + let err = parse("f7").expect_err("f7 is outside the vocabulary"); + assert!(err.contains("not a key"), "{err}"); + assert!( + err.contains("escape"), + "the error should list what is known: {err}" + ); + + let err = parse("C-cc").expect_err("a Ctrl chord takes one character"); + assert!(err.contains("single character"), "{err}"); + + let err = parse("C-1").expect_err("Ctrl-1 is not a control character"); + assert!(err.contains("a-z"), "{err}"); + } +} diff --git a/crates/tty7-cli/src/main.rs b/crates/tty7-cli/src/main.rs index efb2ad90..b4ef3f1b 100644 --- a/crates/tty7-cli/src/main.rs +++ b/crates/tty7-cli/src/main.rs @@ -3,6 +3,7 @@ mod backend; mod cli; mod commands; mod gui; +mod keys; mod output; mod resolve; mod screen; diff --git a/crates/tty7-core/src/core/shells.rs b/crates/tty7-core/src/core/shells.rs index 8fa55afb..32ffba12 100644 --- a/crates/tty7-core/src/core/shells.rs +++ b/crates/tty7-core/src/core/shells.rs @@ -463,19 +463,103 @@ mod wsl_tests { "registry default {default:?} not in {installed:?}" ); } + + /// Windows Terminal drops `Modern = 1` distros from this very key, because + /// they hand it a profile fragment separately and it would otherwise list + /// them twice. Copying that filter here would hide the ordinary distro on + /// an up-to-date machine — on the box this was written on, the only one. + #[test] + fn a_modern_distro_is_still_offered() { + let installed = super::wsl_distros(); + if installed.is_empty() { + eprintln!("skipping: no WSL distributions installed"); + return; + } + let modern: Vec = super::registry_user_subkeys(super::LXSS) + .unwrap_or_default() + .iter() + .filter(|guid| { + super::registry_user_dword(&format!(r"{}\{guid}", super::LXSS), "Modern") == Some(1) + }) + .filter_map(|guid| { + super::registry_user_string(&format!(r"{}\{guid}", super::LXSS), "DistributionName") + }) + .filter(|name| super::worth_offering(name)) + .collect(); + if modern.is_empty() { + eprintln!("skipping: no modern WSL distributions installed"); + return; + } + for name in &modern { + assert!( + installed.contains(name), + "modern distro {name:?} was dropped from {installed:?}" + ); + } + } + + #[test] + fn listing_the_distros_does_not_wait_on_the_wsl_service() { + // Only the registry answer is meant to be fast. When there is none the + // fallback to `wsl.exe` is doing exactly what it exists for, and timing + // it would fail this test on every machine without WSL installed. + if super::registered_wsl_distros().is_none() { + eprintln!("skipping: the registry has no distro list to read"); + return; + } + let started = std::time::Instant::now(); + let _ = super::wsl_distros(); + let elapsed = started.elapsed(); + assert!( + elapsed < std::time::Duration::from_millis(500), + "the listing went to `wsl.exe` after all: {elapsed:?}" + ); + } + + /// The list is what the shell menu offers, so a distro that cannot open a + /// pane must not be on it: `wsl -l -q`, which this replaced, only ever + /// listed installed ones. + #[test] + fn a_distro_that_is_not_installed_is_not_offered() { + let Some(guids) = super::registry_user_subkeys(super::LXSS) else { + eprintln!("skipping: the registry has no distro list to read"); + return; + }; + let half_installed: Vec = guids + .iter() + .map(|guid| format!(r"{}\{guid}", super::LXSS)) + .filter(|key| super::registry_user_dword(key, "State").is_some_and(|state| state != 1)) + .filter_map(|key| super::registry_user_string(&key, "DistributionName")) + .collect(); + if half_installed.is_empty() { + eprintln!("skipping: every registered distro finished installing"); + return; + } + let offered = super::wsl_distros(); + for name in &half_installed { + assert!( + !offered.contains(name), + "unfinished distro {name:?} was offered in {offered:?}" + ); + } + } } pub fn wsl_distros() -> Vec { wsl_distros_probed().unwrap_or_default() } +/// Where `wsl.exe` registers what is installed: one subkey per distro, named by +/// GUID, carrying `DistributionName` and `State`. +#[cfg(windows)] +const LXSS: &str = r"Software\Microsoft\Windows\CurrentVersion\Lxss"; + /// The distro `wsl.exe` launches when no `--distribution` is given, read from /// the registry (`Lxss\DefaultDistribution` names the per-distro key that /// carries `DistributionName`). The registry rather than `wsl -l`: this runs /// on the pane-spawn path, where a microsecond read beats a subprocess. #[cfg(windows)] pub fn default_wsl_distro() -> Option { - const LXSS: &str = r"Software\Microsoft\Windows\CurrentVersion\Lxss"; let guid = registry_user_string(LXSS, "DefaultDistribution")?; let name = registry_user_string(&format!(r"{LXSS}\{guid}"), "DistributionName")?; (!name.is_empty()).then_some(name) @@ -568,8 +652,161 @@ fn find_git_bash() -> Option { #[cfg(windows)] const WSL_LIST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); +/// Distros that exist to carry a container runtime, not to be typed into. +#[cfg_attr(unix, allow(dead_code))] +const NOT_FOR_TYPING: [&str; 2] = ["docker-desktop", "rancher-desktop"]; + +#[cfg_attr(unix, allow(dead_code))] +fn worth_offering(name: &str) -> bool { + !name.is_empty() && !NOT_FOR_TYPING.iter().any(|hidden| name.starts_with(hidden)) +} + +/// The installed distros, from `Lxss` — the same registry key `wsl.exe` itself +/// registers them in, and the one `default_wsl_distro` above already reads. +/// +/// Not `wsl -l -q`, because that has to reach the WSL service, and reaching the +/// WSL service is exactly the part that can be slow: issue #454 was a machine +/// where it took 3.3s, past the timeout below, so the list came back empty +/// every time and no distro was ever offered in the shell menu. A registry read +/// is microseconds and cannot hang, because nothing is listening on it. +/// +/// Windows Terminal made this same move in 2021 (microsoft/terminal#10967) for +/// the same reason, but skips distros whose key carries `Modern = 1`. That is a +/// deduplication rule specific to Terminal — modern distros ship it a profile +/// fragment of their own, so reading both would list them twice. Nothing ships +/// tty7 anything, so we take them all; skipping them here would hide the most +/// ordinary distro on an up-to-date machine. +/// +/// `State` we do read, the way Terminal does: a distro is only offered while it +/// says 1, "installed". An install that was interrupted — `wsl --install` shut +/// down halfway, a failed `--import`, one being uninstalled right now — leaves +/// the key behind with a name and some other state, and `wsl -l -q` (which this +/// replaced) never listed those. Offering one puts a distro in the shell menu +/// that can only open a pane that dies of a WSL registration error. +/// +/// `None` means "could not tell", never "there is nothing": the caller falls +/// back to `wsl.exe` on it, and a caller further up keeps the last good list. +#[cfg(windows)] +fn registered_wsl_distros() -> Option> { + let guids = registry_user_subkeys(LXSS)?; + let names: Vec = guids + .iter() + .map(|guid| format!(r"{LXSS}\{guid}")) + // A key with no `State` at all is taken at its word: the absent value + // is not evidence of a broken install, and inventing one would be how + // this hides a working distro. + .filter(|key| registry_user_dword(key, "State").unwrap_or(INSTALLED) == INSTALLED) + .filter_map(|key| registry_user_string(&key, "DistributionName")) + .filter(|name| worth_offering(name)) + .collect(); + + // Subkeys but nothing to show for them is not an answer either: every name + // unreadable has the shape of a permissions problem, not of a machine with + // no distros on it — that machine has an empty `Lxss`, and says so. + if names.is_empty() && !guids.is_empty() { + return None; + } + Some(names) +} + +/// `State` of a distro that finished installing and has not started leaving. +#[cfg(windows)] +const INSTALLED: u32 = 1; + +#[cfg(windows)] +fn registry_user_dword(subkey: &str, value: &str) -> Option { + use windows_sys::Win32::System::Registry::{HKEY_CURRENT_USER, RRF_RT_REG_DWORD, RegGetValueW}; + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } + let (subkey, value) = (wide(subkey), wide(value)); + let mut data: u32 = 0; + let mut size = std::mem::size_of::() as u32; + // SAFETY: both names are NUL-terminated and owned here, and `data` is a + // live u32 exactly `size` bytes long, which is what a DWORD read writes. + let rc = unsafe { + RegGetValueW( + HKEY_CURRENT_USER, + subkey.as_ptr(), + value.as_ptr(), + RRF_RT_REG_DWORD, + std::ptr::null_mut(), + (&raw mut data).cast(), + &mut size, + ) + }; + (rc == 0).then_some(data) +} + +/// The names of a key's subkeys, or `None` if they could not all be read. +/// +/// All or nothing on purpose. The list this feeds is what the shell menu offers, +/// and a caller that cannot tell a short list from a complete one would quietly +/// drop distros: the walk is by index, so a key that changes underneath it — +/// `wsl --unregister` running right now, a Store install rewriting `Lxss` — +/// ends early, and reporting that as the answer is worse than admitting it. +#[cfg(windows)] +fn registry_user_subkeys(subkey: &str) -> Option> { + use windows_sys::Win32::Foundation::ERROR_NO_MORE_ITEMS; + use windows_sys::Win32::System::Registry::{ + HKEY, HKEY_CURRENT_USER, KEY_READ, RegCloseKey, RegEnumKeyExW, RegOpenKeyExW, + }; + + let subkey: Vec = subkey.encode_utf16().chain(std::iter::once(0)).collect(); + let mut key: HKEY = std::ptr::null_mut(); + // SAFETY: `subkey` is NUL-terminated and owned here; `key` is written only + // on success and closed on every path out below. + if unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut key) } != 0 { + return None; + } + + let mut names = Vec::new(); + // A registry key name is at most 255 characters, plus the terminator. + let mut buf = [0u16; 256]; + let mut ended_with = None; + for index in 0.. { + let mut len = buf.len() as u32; + // SAFETY: `buf` really is `len` units long, and every pointer that is + // not wanted is null, which this call documents as "do not report it". + let rc = unsafe { + RegEnumKeyExW( + key, + index, + buf.as_mut_ptr(), + &mut len, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + if rc != 0 { + ended_with = Some(rc); + break; + } + names.push(String::from_utf16_lossy(&buf[..len as usize])); + } + + // SAFETY: `key` was opened above and is not used after this. + unsafe { RegCloseKey(key) }; + // There is one honest way for the walk to end. Anything else — the key + // deleted underneath it, a name that would not fit — leaves a list that is + // short by an unknown amount, which nobody downstream can tell from a real + // one, so say nothing instead. + (ended_with == Some(ERROR_NO_MORE_ITEMS)).then_some(names) +} + #[cfg(windows)] fn list_wsl_distros() -> Option> { + if let Some(registered) = registered_wsl_distros() { + return Some(registered); + } + + // The registry would not answer — no `Lxss` key at all, or a walk of it that + // ended somewhere other than the end. Either way this is not a "there are no + // distros" to pass on, so ask the slow way rather than claim there is nothing. + log::debug!("{LXSS} gave no usable answer; falling back to `wsl -l -q`"); let mut cmd = std::process::Command::new("wsl.exe"); cmd.args(["-l", "-q"]); let output = match crate::core::proc::output_within( @@ -597,7 +834,7 @@ fn parse_wsl_list(bytes: &[u8]) -> Vec { let text = String::from_utf16_lossy(&units); text.lines() .map(|l| l.trim_matches(|c: char| c.is_whitespace() || c == '\u{feff}' || c == '\0')) - .filter(|l| !l.is_empty() && !l.starts_with("docker-desktop")) + .filter(|l| worth_offering(l)) .map(str::to_string) .collect() } diff --git a/crates/tty7-core/src/core/ssh_profile.rs b/crates/tty7-core/src/core/ssh_profile.rs index 8f5db096..8fcbfdad 100644 --- a/crates/tty7-core/src/core/ssh_profile.rs +++ b/crates/tty7-core/src/core/ssh_profile.rs @@ -288,27 +288,64 @@ pub fn expand_identity_placeholders(path: &str, host: &str, user: &str) -> Strin expand_tilde(&out) } -pub fn expand_tilde(path: &str) -> String { - let home = || { - #[cfg(windows)] - let var = "USERPROFILE"; - #[cfg(not(windows))] - let var = "HOME"; - std::env::var(var).ok().filter(|h| !h.is_empty()) - }; +/// The platform home directory: `%USERPROFILE%` on Windows, `$HOME` elsewhere. +fn home_dir() -> Option { + #[cfg(windows)] + let var = "USERPROFILE"; + #[cfg(not(windows))] + let var = "HOME"; + std::env::var(var).ok().filter(|h| !h.is_empty()) +} + +fn expand_tilde_with(path: &str, home: Option<&str>) -> String { if let Some(rest) = path.strip_prefix("~/") { - if let Some(home) = home() { + if let Some(home) = home { let sep = if home.ends_with('/') { "" } else { "/" }; return format!("{home}{sep}{rest}"); } } else if path == "~" { - if let Some(home) = home() { - return home; + if let Some(home) = home { + return home.to_string(); } } path.to_string() } +pub fn expand_tilde(path: &str) -> String { + expand_tilde_with(path, home_dir().as_deref()) +} + +/// The private keys publickey auth probes when a connection carries no usable +/// `IdentityFile` of its own — OpenSSH's default-identity behaviour (issue +/// #484). Without it, "no profile key + no agent" offers the server zero keys, +/// which on Windows is the common case (the OpenSSH Authentication Agent +/// service is disabled by default there). +/// +/// Both the GUI (`ui::ssh_connect`, preloading cached passphrases) and the +/// daemon (`daemon::ssh::auth`, offering the keys) must see the *same* list: +/// `NativeSshSpec::key_passphrases` is keyed on these exact strings, so the +/// two sides share this one definition rather than formatting their own. +/// +/// The list stays short on purpose: every offered key spends one of the +/// server's `MaxAuthTries` (default 6), shared with explicit keys and agent +/// identities. `id_dsa` is long deprecated, `id_xmss`/`id_*_sk` are beyond +/// what russh can sign with, so the three software keys cover what exists in +/// practice — ed25519 first as the modern default. +pub fn default_identity_candidates() -> Vec { + let Some(home) = home_dir() else { + return Vec::new(); + }; + default_identity_candidates_in(&home) +} + +/// The pure core, home injected so tests never touch the environment. +fn default_identity_candidates_in(home: &str) -> Vec { + ["id_ed25519", "id_ecdsa", "id_rsa"] + .into_iter() + .map(|name| expand_tilde_with(&format!("~/.ssh/{name}"), Some(home))) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -459,6 +496,25 @@ mod tests { assert_eq!(expand_tilde("/abs/path"), "/abs/path"); } + #[test] + fn default_identity_candidates_are_ordered_and_home_relative() { + assert_eq!( + default_identity_candidates_in("/home/me"), + vec![ + "/home/me/.ssh/id_ed25519".to_string(), + "/home/me/.ssh/id_ecdsa".to_string(), + "/home/me/.ssh/id_rsa".to_string() + ] + ); + // A trailing separator must not double up, and the strings must be + // exactly what an explicit `~/.ssh/...` entry expands to, because + // `key_passphrases` is keyed on them. + assert_eq!( + default_identity_candidates_in("/home/me/"), + default_identity_candidates_in("/home/me") + ); + } + #[test] fn profile_expanded_identity_files_uses_own_host_user() { let mut p = SshProfile::new("x"); diff --git a/crates/tty7-core/src/daemon/install/wsl.rs b/crates/tty7-core/src/daemon/install/wsl.rs index 9570272b..32bf96f1 100644 --- a/crates/tty7-core/src/daemon/install/wsl.rs +++ b/crates/tty7-core/src/daemon/install/wsl.rs @@ -565,10 +565,100 @@ fn install_lock(distro: &str) -> Arc> { lock } +/// Where a distro's server was last proved to be, so the next pane can skip the +/// proving. +/// +/// `Installer::run` costs five serial `wsl.exe` round trips — `uname`, `$HOME`, +/// a stat, a liveness probe, and a look at what is running. That is a fine price +/// to pay once for a distro, and an absurd one to pay per pane: issue #454 was a +/// machine where one round trip took 3.3s, so opening a second tab on a distro +/// that was already connected cost half a minute to re-learn what the first tab +/// had just learned. +/// +/// Nothing here expires on a timer, because the answer barely rots: +/// +/// - The binary does not move. A tty7 upgrade renames it, but a new build is a +/// new process and this map lives only in memory, so it starts empty. +/// - The distro shutting down does not invalidate it either. `wsl.exe` starts a +/// stopped distro on demand, and the bridge (`tty7-server --stdio --pane`) +/// starts its own daemon if none is listening — so the one thing that really +/// does stop being true, "a daemon is running in there", is repaired a layer +/// below us without anyone asking. +/// +/// What is left is a path that could stop existing: the distro reinstalled, the +/// binary deleted by hand. Only the bridge discovers that, and only once it is +/// running — so the router forgets the distro when a bridge dies without ever +/// answering, and the pane after that proves it again. See `forget_wsl_server`. +static READY: Mutex> = Mutex::new(Vec::new()); + +#[derive(Clone)] +struct Proved { + binary: String, + /// The build mismatch the probe found, if it found one. + /// + /// Kept because the warning is produced inside `Installer::run`, and the + /// whole point of the note is that `run` does not happen again: without + /// this, only the first pane of the daemon's lifetime would ever hear that + /// a different build is serving the distro, and every window opened after + /// it — including a whole new GUI session, since the daemon outlives one — + /// would attach in silence. + mismatch: Option, +} + +fn remembered(distro: &str) -> Option { + READY + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .iter() + .find(|(d, _)| d == distro) + .map(|(_, proved)| proved.clone()) +} + +fn remember(distro: &str, proved: Proved) { + let mut ready = READY + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match ready.iter_mut().find(|(d, _)| d == distro) { + Some((_, known)) => *known = proved, + None => ready.push((distro.to_string(), proved)), + } +} + +/// Where this distro's server was last proved to be, or `None` if the next pane +/// would have to go and ask. A hint for callers deciding whether a failure is +/// worth re-proving; the answer itself comes from `ensure_wsl_server`. +pub fn remembered_wsl_server(distro: &str) -> Option { + remembered(distro).map(|proved| proved.binary) +} + +/// Drop what we thought we knew about a distro, so the next `ensure_wsl_server` +/// proves it again the long way. +pub fn forget_wsl_server(distro: &str) { + READY + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .retain(|(d, _)| d != distro); +} + pub fn ensure_wsl_server(distro: &str) -> io::Result { validate_distro(distro)?; + + // Under the lock even when the answer is only going to be read, because the + // note names a file that `replace_wsl_server` is in the business of moving: + // reading it outside would let a pane spawn the very binary a replace is + // deleting. The lock is uncontended except during an install, and waiting + // for an install to finish is what a pane wants to do anyway. let lock = install_lock(distro); let _held = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(proved) = remembered(distro) { + // Re-file the warning rather than re-run the probe that found it: this + // route's sink is a fresh one, and the client on the other end of it + // has not heard about the mismatch yet. + if let Some(mismatch) = proved.mismatch { + super::record_remote_mismatches(vec![mismatch]); + } + return Ok(proved.binary); + } let ops = WslRemoteOps::new(distro); let source = BundledServerBinary::discover(); @@ -595,6 +685,13 @@ pub fn ensure_wsl_server(distro: &str) -> io::Result { "" }, ); + remember( + distro, + Proved { + binary: report.paths.binary.clone(), + mismatch: report.mismatch, + }, + ); Ok(report.paths.binary) } @@ -605,6 +702,11 @@ pub fn restart_wsl_daemon(distro: &str) -> io::Result<()> { let confirm = install_confirm(); let lock = install_lock(distro); let _held = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + // Both of these deliberately change what is running in there, which is the + // one thing the remembered answer is a claim about. Forget it first: if the + // restart fails halfway, the next pane must go and look rather than trust a + // note written before the upheaval. + forget_wsl_server(distro); Installer::with_source(&ops, &source, confirm.as_ref(), host_label(distro)).restart_daemon()?; Ok(()) } @@ -619,6 +721,7 @@ pub fn replace_wsl_server(distro: &str) -> io::Result<()> { let confirm = install_confirm(); let lock = install_lock(distro); let _held = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + forget_wsl_server(distro); Installer::with_source(&ops, &source, confirm.as_ref(), host_label(distro)).replace()?; Ok(()) } @@ -1561,6 +1664,147 @@ mod tests { assert!(a2.try_lock().is_ok()); } + /// Names no real distribution can have, so these tests never touch one and + /// never collide with each other when the suite runs in parallel. + fn nowhere(test: &str) -> String { + format!("tty7-no-such-distro-{test}") + } + + fn note(binary: &str) -> Proved { + Proved { + binary: binary.to_string(), + mismatch: None, + } + } + + #[test] + fn a_remembered_distro_is_answered_without_asking_wsl_anything() { + let distro = nowhere("remembered"); + let binary = "/home/me/.local/share/tty7/bin/tty7-server-c5p5"; + remember(&distro, note(binary)); + + // There is no such distribution, so a probe could only have failed: + // getting the path back at all is what proves none ran. + let answered = ensure_wsl_server(&distro).expect("the note is the answer"); + assert_eq!(answered, binary); + + forget_wsl_server(&distro); + } + + #[test] + fn forgetting_sends_the_next_caller_back_to_the_distribution() { + let distro = nowhere("forgotten"); + remember(&distro, note("/somewhere/tty7-server")); + assert!(remembered_wsl_server(&distro).is_some()); + + forget_wsl_server(&distro); + assert_eq!(remembered_wsl_server(&distro), None); + assert!( + ensure_wsl_server(&distro).is_err(), + "a forgotten distro must be proved again, not assumed" + ); + } + + #[test] + fn what_is_remembered_is_per_distro_and_replaceable() { + let (a, b) = (nowhere("map-a"), nowhere("map-b")); + remember(&a, note("/a/tty7-server")); + remember(&b, note("/b/tty7-server")); + assert_eq!(remembered_wsl_server(&a).as_deref(), Some("/a/tty7-server")); + + forget_wsl_server(&a); + assert_eq!(remembered_wsl_server(&a), None); + assert_eq!( + remembered_wsl_server(&b).as_deref(), + Some("/b/tty7-server"), + "forgetting one distro must not forget another" + ); + + remember(&b, note("/b/tty7-server-newer")); + assert_eq!( + remembered_wsl_server(&b).as_deref(), + Some("/b/tty7-server-newer"), + "a later answer replaces the earlier one" + ); + forget_wsl_server(&b); + } + + #[test] + fn a_restart_forgets_first_so_a_failed_one_leaves_no_stale_note() { + let distro = nowhere("restart"); + remember(&distro, note("/x/tty7-server")); + + // This cannot succeed — there is no such distribution — which is the + // point: the note must be gone even though the work after it failed. + let _ = restart_wsl_daemon(&distro); + assert_eq!(remembered_wsl_server(&distro), None); + } + + #[test] + fn a_remembered_answer_waits_for_an_install_to_let_go_of_the_binary() { + let distro = nowhere("locked"); + remember(&distro, note("/x/tty7-server")); + + // Stand in for a `replace_wsl_server` in progress: it holds this lock + // while it moves the very file the note names. + let lock = install_lock(&distro); + let held = lock.lock().expect("a lock nobody else has"); + + let (tx, rx) = std::sync::mpsc::channel(); + let asking = { + let distro = distro.clone(); + std::thread::spawn(move || tx.send(ensure_wsl_server(&distro))) + }; + assert!( + rx.recv_timeout(Duration::from_millis(250)).is_err(), + "the note was handed out while a replace was under way" + ); + + drop(held); + let answered = rx + .recv_timeout(Duration::from_secs(5)) + .expect("answered once the install let go") + .expect("the note is the answer"); + assert_eq!(answered, "/x/tty7-server"); + let _ = asking.join(); + + forget_wsl_server(&distro); + } + + #[test] + fn a_remembered_mismatch_is_told_to_every_later_pane() { + let distro = nowhere("mismatch"); + let entry = crate::daemon::install::MismatchedRemoteDaemon { + host: host_label(&distro), + running_version: Some("0.0.1".to_string()), + running_exe: Some("/x/tty7-server-someone-elses".to_string()), + wanted_version: "9.9.9".to_string(), + }; + remember( + &distro, + Proved { + binary: "/x/tty7-server".to_string(), + mismatch: Some(entry.clone()), + }, + ); + + // A later pane is a fresh route with a fresh sink, and the client on + // the other end of it has never been told. + let sink = Arc::new(Mutex::new(Vec::new())); + let answered = + crate::daemon::install::with_mismatch_sink(sink.clone(), || ensure_wsl_server(&distro)) + .expect("the note is the answer"); + + assert_eq!(answered, "/x/tty7-server"); + assert_eq!( + &*sink.lock().expect("the sink"), + &[entry], + "the warning stopped at the first pane" + ); + + forget_wsl_server(&distro); + } + #[test] fn ensure_refuses_an_unusable_distro_name_before_spawning_anything() { let err = ensure_wsl_server("--shutdown").expect_err("refused"); diff --git a/crates/tty7-core/src/daemon/router.rs b/crates/tty7-core/src/daemon/router.rs index b414e8af..12d0bf13 100644 --- a/crates/tty7-core/src/daemon/router.rs +++ b/crates/tty7-core/src/daemon/router.rs @@ -605,7 +605,25 @@ async fn drive(local: Stream, header: &RouteHeader) -> io::Result<()> { if !leftover.is_empty() { tokio::io::AsyncWriteExt::write_all(&mut *link, &leftover).await?; } - let (to_remote, to_local) = tokio::io::copy_bidirectional(&mut local, &mut *link).await?; + let copied = tokio::io::copy_bidirectional(&mut local, &mut *link).await; + // A bridge that never sent a byte never ran. This is where a stale note is + // actually found out: `wsl.exe` spawns quite happily with a server path + // that no longer exists inside the distro — the distro was reinstalled, the + // directory was cleaned out — and only fails once it is the shell trying to + // exec it. Forget the distro, so the pane after this one proves it again + // rather than repeating a failure that would otherwise outlive every window + // and last until tty7 itself restarts. + if let RouteTarget::Wsl { distro } = &header.target + && header.server_command.is_none() + && !copied + .as_ref() + .is_ok_and(|(_, from_remote)| *from_remote > 0) + { + log::info!("wsl:{distro}: the bridge closed without answering; proving it again next time"); + crate::daemon::install::wsl::forget_wsl_server(distro); + } + + let (to_remote, to_local) = copied?; log::debug!("routed connection closed after {to_remote} up / {to_local} down bytes"); drop(conn); Ok(()) @@ -742,6 +760,15 @@ async fn restart_server( } } +/// Prove (or recall) where this distro's server is, off the reactor — the probe +/// is a chain of blocking `wsl.exe` calls the first time round. +async fn ensure_wsl_server(distro: &str, setup: &RouteSetup) -> anyhow::Result { + let distro = distro.to_string(); + Ok(setup + .blocking(move || crate::daemon::install::wsl::ensure_wsl_server(&distro)) + .await??) +} + async fn open_link( header: &RouteHeader, setup: &RouteSetup, @@ -754,25 +781,30 @@ async fn open_link( Ok((link, Some(conn))) } RouteTarget::Wsl { distro } => { - let resolved = match header.server_command { - Some(_) => None, - None => { - let distro = distro.clone(); - Some( - setup - .blocking(move || { - crate::daemon::install::wsl::ensure_wsl_server(&distro) - }) - .await??, - ) + if let Some(command) = header.server_command.as_deref() { + let link = RemoteLink::wsl_shell(distro, command, setup.channel)?; + return Ok((link, None)); + } + + let from_memory = crate::daemon::install::wsl::remembered_wsl_server(distro).is_some(); + let binary = ensure_wsl_server(distro, setup).await?; + match RemoteLink::wsl(distro, &binary, setup.channel) { + Ok(link) => Ok((link, None)), + // Only worth a second look when the path came from memory: one + // proved a moment ago will prove the same, and re-proving it + // just doubles the wait before the error reaches the user. + Err(stale) if from_memory => { + log::info!( + "wsl:{distro}: the remembered server would not start ({stale}); \ + looking again" + ); + crate::daemon::install::wsl::forget_wsl_server(distro); + let binary = ensure_wsl_server(distro, setup).await?; + let link = RemoteLink::wsl(distro, &binary, setup.channel)?; + Ok((link, None)) } - }; - let link = match (header.server_command.as_deref(), resolved.as_deref()) { - (Some(command), _) => RemoteLink::wsl_shell(distro, command, setup.channel)?, - (None, Some(binary)) => RemoteLink::wsl(distro, binary, setup.channel)?, - (None, None) => unreachable!("resolved is Some whenever there is no override"), - }; - Ok((link, None)) + Err(e) => Err(e.into()), + } } RouteTarget::LocalStdio { program, args } => { let args: Vec<&str> = args.iter().map(String::as_str).collect(); diff --git a/crates/tty7-core/src/daemon/ssh/auth.rs b/crates/tty7-core/src/daemon/ssh/auth.rs index 2db296c3..91622639 100644 --- a/crates/tty7-core/src/daemon/ssh/auth.rs +++ b/crates/tty7-core/src/daemon/ssh/auth.rs @@ -439,10 +439,36 @@ async fn try_publickeys( broker: &Arc, ) -> Outcome { let mut last: Option = None; + let mut round = KeyRound::default(); if spec.auth_mode != SshAuthMode::Agent { - for path in &spec.identity_files { - match try_identity_file(handle, spec, broker, path).await { + // OpenSSH parity (#484): the `~/.ssh` default identities are appended + // after the explicit ones (there is no `IdentitiesOnly` yet), and + // deduped against them by canonical path — the explicit list may spell + // the same key with different separators or casing, and every offer + // spends one of the server's MaxAuthTries. Dedup compares the *expanded* + // explicit paths, the same ones `try_identity_file` opens: a spec entry + // still carrying `~` or `%h` names a real file, and comparing it raw + // would fail to canonicalize and offer that key a second time. + let explicit: Vec = spec + .identity_files + .iter() + .map(|p| { + crate::core::ssh_profile::expand_identity_placeholders(p, &spec.host, &spec.user) + }) + .collect(); + let discovered = dedup_candidates( + crate::core::ssh_profile::default_identity_candidates(), + &explicit, + canonical_key, + ); + let files = spec + .identity_files + .iter() + .map(|p| (p.clone(), KeySource::Explicit)) + .chain(discovered.into_iter().map(|p| (p, KeySource::Discovered))); + for (path, source) in files { + match try_identity_file(handle, spec, broker, &path, source, &mut round).await { Outcome::Authenticated => return Outcome::Authenticated, Outcome::Failed { remaining_methods, .. @@ -457,7 +483,7 @@ async fn try_publickeys( } if spec.auth_mode != SshAuthMode::PublicKey { - match try_agent(handle, spec).await { + match try_agent(handle, spec, &mut round).await { Outcome::Authenticated => return Outcome::Authenticated, Outcome::Failed { remaining_methods, .. @@ -472,7 +498,204 @@ async fn try_publickeys( Outcome::Failed { remaining_methods: last, - reason: Some("no public key was accepted".to_string()), + reason: Some(round.reason(spec.auth_mode)), + } +} + +/// Where an identity file came from. Provenance decides failure behaviour: +/// an explicit key is the user's own choice, so its failures are said aloud +/// and its encrypted form may ask for a passphrase; a discovered `~/.ssh` +/// default is none of the user's doing, so every failure of one is silent +/// (#484). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KeySource { + Explicit, + Discovered, +} + +/// Canonical path for dedup: the same key reached via `~`, an absolute path, +/// or different separator/casing spellings must be offered once, not twice — +/// each offer spends one of the server's MaxAuthTries. Files that cannot be +/// canonicalized (missing) never enter the set; the read step skips them. +fn canonical_key(path: &str) -> Option { + std::fs::canonicalize(path) + .ok() + .map(|p| p.to_string_lossy().into_owned()) +} + +/// Drop default candidates an explicit entry already names, comparing by +/// canonical path. Pure apart from the injected canonicalizer, so tests never +/// touch the filesystem. +fn dedup_candidates( + candidates: Vec, + explicit: &[String], + canon: impl Fn(&str) -> Option, +) -> Vec { + let mut seen: std::collections::HashSet = + explicit.iter().filter_map(|p| canon(p)).collect(); + let mut out = Vec::new(); + for candidate in candidates { + match canon(&candidate) { + Some(key) if seen.contains(&key) => {} + Some(key) => { + seen.insert(key); + out.push(candidate); + } + // Not canonicalizable means not readable; the read step skips it. + None => out.push(candidate), + } + } + out +} + +/// What one publickey round learned, kept so the final error can distinguish +/// the two situations "no public key was accepted" used to paper over +/// (#484): nothing local could be offered at all, or keys went to the server +/// and it refused every one. +#[derive(Default)] +struct KeyRound { + /// File keys actually sent to the server, by their configured path. + offered_files: Vec, + /// File keys the server rejected, same spelling. + rejected_files: Vec, + /// Whether an agent answered, and how many of its identities were + /// sent / rejected. + agent_available: bool, + agent_offered: usize, + agent_rejected: usize, + /// Explicit files that could not be read or decoded, with the reason. + /// (Discovered candidates fail silently, so they never land here.) + unusable: Vec, + /// Transport-level errors after a key was decoded. + errors: Vec, +} + +impl KeyRound { + fn reason(&self, mode: SshAuthMode) -> String { + if !self.rejected_files.is_empty() || self.agent_rejected > 0 { + let mut what = self.rejected_files.clone(); + if self.agent_rejected > 0 { + what.push(format!( + "{} agent {}", + self.agent_rejected, + if self.agent_rejected == 1 { + "identity" + } else { + "identities" + } + )); + } + return format!("server rejected public key(s): {}", what.join(", ")); + } + if self.offered_files.is_empty() && self.agent_offered == 0 { + let mut looked: Vec = Vec::new(); + if mode != SshAuthMode::Agent { + looked.push("identity files".to_string()); + looked.push("~/.ssh default keys".to_string()); + } + if mode != SshAuthMode::PublicKey { + looked.push(if self.agent_available { + "the SSH agent".to_string() + } else { + "the SSH agent (unavailable)".to_string() + }); + } + let mut msg = format!( + "no usable private key was found (checked: {})", + looked.join(", ") + ); + if !self.unusable.is_empty() { + msg.push_str(&format!("; {}", self.unusable.join("; "))); + } + return msg; + } + // Keys were offered and none was rejected or accepted: the transport + // broke, and the last error says where. + if let Some(e) = self.errors.last() { + return e.clone(); + } + "no public key was accepted".to_string() + } +} + +/// Decode-time policy for one identity file, split from the network so the +/// source × encryption matrix stays unit-testable. The asymmetry is the +/// point (#484 review): russh has no offer-without-signature probe, so +/// trying an encrypted key means signing — i.e. prompting *before* the server +/// has shown any interest in that key. An explicit key earns that prompt; a +/// discovered default never does — not with no cached passphrase, and not with +/// a cached one that turned out to be wrong (#486), which for an explicit key +/// reopens the prompt but here would mean a sheet per stale `~/.ssh` entry on +/// every connection. +enum IdentityLoad { + Ready(russh::keys::PrivateKey), + /// Not worth an offer: a `.pub`, an undecodable file, or a discovered + /// candidate that is encrypted with no cached passphrase. + Skip, + /// An explicit key the user should hear about. + Unusable(String), + /// Explicit and encrypted, and no passphrase to hand opened it — ask the + /// user. `rejected` says a cached passphrase was tried first and refused, + /// which the sheet has to admit to before asking again (#486); without one + /// this is simply the first time anybody has been asked. + NeedsPassphrase { + rejected: bool, + }, +} + +fn load_identity( + contents: &str, + raw_path: &str, + source: KeySource, + cached: Option<&str>, +) -> IdentityLoad { + if PublicKey::from_openssh(contents.trim()).is_ok() { + // A `.pub` handed in as the identity file is never an offer. Worth a + // line in the log when the user named it themselves — pointing + // IdentityFile at the public half is a common slip, and the round is + // otherwise silent about it. + if source == KeySource::Explicit { + log::warn!("identity file {raw_path} is a public key; skipping"); + } + return IdentityLoad::Skip; + } + match russh::keys::decode_secret_key(contents, None) { + Ok(key) => IdentityLoad::Ready(key), + Err(russh::keys::Error::KeyIsEncrypted) => match cached { + Some(passphrase) => match russh::keys::decode_secret_key(contents, Some(passphrase)) { + Ok(key) => IdentityLoad::Ready(key), + Err(e) => { + log::warn!("the stored passphrase did not decrypt {raw_path}: {e}"); + match source { + // Ending the attempt here is what locked an explicit + // key out for good once a wrong passphrase reached the + // keychain: no prompt, and no way to correct it from + // inside the app (#486). The secret is simply wrong, so + // ask — and say that is why. + KeySource::Explicit => IdentityLoad::NeedsPassphrase { rejected: true }, + // A stale cached passphrase for a key the user never + // configured: skip, don't shout — and above all do not + // prompt. #484's rule holds whatever the reason the + // passphrase failed; nobody asked for this key, so it + // must never be the thing that puts a sheet on screen. + KeySource::Discovered => IdentityLoad::Skip, + } + } + }, + None => match source { + KeySource::Explicit => IdentityLoad::NeedsPassphrase { rejected: false }, + KeySource::Discovered => IdentityLoad::Skip, + }, + }, + Err(e) => { + log::warn!("could not read identity file {raw_path}: {e}"); + match source { + KeySource::Explicit => { + IdentityLoad::Unusable(format!("could not read identity file {raw_path}: {e}")) + } + KeySource::Discovered => IdentityLoad::Skip, + } + } } } @@ -481,103 +704,131 @@ async fn try_identity_file( spec: &NativeSshSpec, broker: &Arc, raw_path: &str, + source: KeySource, + round: &mut KeyRound, ) -> Outcome { - let path = expand_identity_path(raw_path, &spec.host, &spec.user); + let path = + crate::core::ssh_profile::expand_identity_placeholders(raw_path, &spec.host, &spec.user); let contents = match std::fs::read_to_string(&path) { Ok(c) => c, - Err(e) => return failed(format!("cannot read identity file {path}: {e}")), + Err(e) => { + return match source { + KeySource::Explicit => { + round + .unusable + .push(format!("cannot read identity file {raw_path}: {e}")); + Outcome::Failed { + remaining_methods: None, + reason: None, + } + } + // A default candidate that is not there is the normal case, + // not a failure. + KeySource::Discovered => Outcome::Skipped, + }; + } }; - if PublicKey::from_openssh(contents.trim()).is_ok() { - log::warn!("identity file {path} is a public key; skipping"); - return Outcome::Skipped; - } - - let key = match russh::keys::decode_secret_key(&contents, None) { - Ok(k) => k, - Err(russh::keys::Error::KeyIsEncrypted) => { - // A passphrase the connection carried in from the keychain gets - // one silent attempt. If it does not open the file it is simply - // the wrong secret, and the only way forward is to ask — which is - // what this used to refuse to do: a passphrase saved by mistake - // ended every later connection here, with no prompt and no way to - // correct it from inside the app. - let stored = stored_passphrase(spec, raw_path); - let unlocked = match &stored { - Some(p) => match russh::keys::decode_secret_key(&contents, Some(p)) { - Ok(k) => Some(k), - Err(e) => { - log::warn!("the stored passphrase did not decrypt {path}: {e}"); - None - } - }, - None => None, + let key = match load_identity( + &contents, + raw_path, + source, + stored_passphrase(spec, raw_path), + ) { + IdentityLoad::Ready(k) => k, + IdentityLoad::Skip => return Outcome::Skipped, + IdentityLoad::Unusable(reason) => { + round.unusable.push(reason); + return Outcome::Failed { + remaining_methods: None, + reason: None, }; - match unlocked { - Some(k) => k, - None => { - let resp = broker - .prompt(AuthPromptKind::KeyPassphrase { - key_path: raw_path.to_string(), - comment: String::new(), - rejected: stored.is_some(), - }) - .await; - let typed = match resp { - AuthResponse::Secret(p) => p, - _ => return Outcome::Skipped, + } + // One prompt serves both ways of arriving here — no passphrase to try, + // or one that was tried and refused. `rejected` is the only difference, + // and it only changes what the sheet says (#486). + IdentityLoad::NeedsPassphrase { rejected } => { + let resp = broker + .prompt(AuthPromptKind::KeyPassphrase { + key_path: raw_path.to_string(), + comment: String::new(), + rejected, + }) + .await; + let AuthResponse::Secret(passphrase) = resp else { + return Outcome::Skipped; + }; + // The user just typed this one, so a failure here is not stale + // state to heal — it is the answer being wrong, and saying so + // beats asking again forever. + match russh::keys::decode_secret_key(&contents, Some(&passphrase)) { + Ok(k) => k, + Err(e) => { + log::warn!("could not decrypt identity file {path}: {e}"); + round + .unusable + .push(format!("could not decrypt identity file {raw_path}")); + return Outcome::Failed { + remaining_methods: None, + reason: None, }; - // The user just typed this one, so a failure here is not - // stale state to heal — it is the answer being wrong, and - // saying so beats silently asking again. - match russh::keys::decode_secret_key(&contents, Some(&typed)) { - Ok(k) => k, - Err(e) => { - log::warn!("could not decrypt identity file {path}: {e}"); - return failed(format!("could not decrypt identity file {path}")); - } - } } } } - Err(e) => { - log::warn!("could not read identity file {path}: {e}"); - return failed(format!("could not read identity file {path}")); - } }; + round.offered_files.push(raw_path.to_string()); let hash_alg = rsa_hash_alg(&key.algorithm()); let pk = PrivateKeyWithHashAlg::new(Arc::new(key), hash_alg); match handle.authenticate_publickey(&spec.user, pk).await { Ok(AuthResult::Success) => Outcome::Authenticated, Ok(AuthResult::Failure { remaining_methods, .. - }) => Outcome::Failed { - remaining_methods: Some(remaining_methods), - reason: Some(format!("server rejected key {raw_path}")), - }, - Err(e) => failed(format!("public-key auth error: {e}")), + }) => { + round.rejected_files.push(raw_path.to_string()); + Outcome::Failed { + remaining_methods: Some(remaining_methods), + reason: None, + } + } + Err(e) => { + round + .errors + .push(format!("public-key auth error with {raw_path}: {e}")); + Outcome::Failed { + remaining_methods: None, + reason: None, + } + } } } /// The passphrase this connection already carries for `raw_path`, if any. /// /// The map is keyed by the identity path exactly as the spec lists it — the -/// same string the prompt names and the GUI files the keychain entry under — -/// so the lookup uses the raw path, not the one `expand_identity_path` built -/// for the filesystem. -fn stored_passphrase(spec: &NativeSshSpec, raw_path: &str) -> Option { - spec.key_passphrases.as_ref()?.get(raw_path).cloned() +/// same string the prompt names, the GUI files the keychain entry under, and +/// `default_identity_candidates` spells a discovered key with — so the lookup +/// uses the raw path, not the one `expand_identity_placeholders` built for the +/// filesystem. +fn stored_passphrase<'a>(spec: &'a NativeSshSpec, raw_path: &str) -> Option<&'a str> { + spec.key_passphrases + .as_ref()? + .get(raw_path) + .map(String::as_str) } -async fn try_agent(handle: &mut Handle, spec: &NativeSshSpec) -> Outcome { +async fn try_agent( + handle: &mut Handle, + spec: &NativeSshSpec, + round: &mut KeyRound, +) -> Outcome { #[cfg(unix)] { let agent = match AgentClient::connect_env().await { Ok(a) => a, Err(_) => return Outcome::Skipped, }; - try_agent_identities(handle, spec, agent).await + try_agent_identities(handle, spec, agent, round).await } #[cfg(windows)] { @@ -587,7 +838,7 @@ async fn try_agent(handle: &mut Handle, spec: &NativeSshSpec) -> Ok(a) => a, Err(_) => return Outcome::Skipped, }; - try_agent_identities(handle, spec, agent).await + try_agent_identities(handle, spec, agent, round).await } } @@ -595,6 +846,7 @@ async fn try_agent_identities( handle: &mut Handle, spec: &NativeSshSpec, mut agent: AgentClient, + round: &mut KeyRound, ) -> Outcome where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send, @@ -603,12 +855,14 @@ where Ok(ids) => ids, Err(_) => return Outcome::Skipped, }; + round.agent_available = true; let mut last: Option = None; for identity in identities { let pubkey: PublicKey = match &identity { AgentIdentity::PublicKey { key, .. } => key.clone(), AgentIdentity::Certificate { .. } => continue, }; + round.agent_offered += 1; let hash_alg = rsa_hash_alg(&pubkey.algorithm()); match handle .authenticate_publickey_with(&spec.user, pubkey, hash_alg, &mut agent) @@ -617,13 +871,16 @@ where Ok(AuthResult::Success) => return Outcome::Authenticated, Ok(AuthResult::Failure { remaining_methods, .. - }) => last = Some(remaining_methods), + }) => { + round.agent_rejected += 1; + last = Some(remaining_methods); + } Err(_) => continue, } } Outcome::Failed { remaining_methods: last, - reason: Some("no agent key was accepted".to_string()), + reason: None, } } @@ -874,26 +1131,6 @@ fn rsa_hash_alg(algorithm: &Algorithm) -> Option { } } -fn expand_identity_path(path: &str, host: &str, user: &str) -> String { - let substituted = path.replace("%h", host).replace("%r", user); - if let Some(rest) = substituted.strip_prefix("~/") { - if let Some(home) = home_dir() { - return format!("{home}/{rest}"); - } - } - substituted -} - -#[cfg(unix)] -fn home_dir() -> Option { - std::env::var("HOME").ok().filter(|h| !h.is_empty()) -} - -#[cfg(not(unix))] -fn home_dir() -> Option { - std::env::var("USERPROFILE").ok().filter(|h| !h.is_empty()) -} - #[cfg(test)] mod tests { use super::*; @@ -923,12 +1160,6 @@ mod tests { assert!(!msg.ends_with(' '), "{msg}"); } - #[test] - fn identity_path_expands_tokens_and_tilde() { - let p = expand_identity_path("/keys/%r@%h/id", "example.com", "deploy"); - assert_eq!(p, "/keys/deploy@example.com/id"); - } - #[test] fn method_order_restricts_by_mode() { assert_eq!( @@ -994,10 +1225,7 @@ mod tests { // tilde and all, and `try_identity_file` has to look it up under the // same string rather than under the filesystem path it expanded to. let spec = spec_with(r#","key_passphrases":{"~/.ssh/id_ed25519":"pp"}"#); - assert_eq!( - stored_passphrase(&spec, "~/.ssh/id_ed25519").as_deref(), - Some("pp") - ); + assert_eq!(stored_passphrase(&spec, "~/.ssh/id_ed25519"), Some("pp")); assert_eq!(stored_passphrase(&spec, "/home/u/.ssh/id_ed25519"), None); assert_eq!(stored_passphrase(&spec_with(""), "~/.ssh/id_ed25519"), None); } @@ -1048,4 +1276,256 @@ mod tests { ); assert_eq!(rsa_hash_alg(&Algorithm::Ed25519), None); } + + #[test] + fn default_candidates_dedup_against_explicit_by_canonical_path() { + // The fake canonicalizer collapses spelling differences; two strings + // with the same canonical form are one file, and the explicit entry + // wins the offer slot. + let canon = |p: &str| Some(p.replace("//", "/")); + let out = dedup_candidates( + vec![ + "/home/me/.ssh/id_ed25519".to_string(), + "/home/me/.ssh/id_ecdsa".to_string(), + "/home/me/.ssh/id_rsa".to_string(), + ], + &["/home/me//.ssh/id_rsa".to_string()], + canon, + ); + assert_eq!( + out, + vec![ + "/home/me/.ssh/id_ed25519".to_string(), + "/home/me/.ssh/id_ecdsa".to_string() + ] + ); + } + + #[test] + fn candidates_that_do_not_canonicalize_pass_through() { + // A missing default is the normal case; the read step skips it, so + // dedup must not drop it here either. + let out = dedup_candidates(vec!["/missing/id_ed25519".to_string()], &[], |_| None); + assert_eq!(out, vec!["/missing/id_ed25519".to_string()]); + } + + const PASSPHRASE: &str = "correct horse battery staple"; + + /// The throwaway ed25519 key these tests offer, built here rather than + /// pasted in as a PEM blob: a private key sitting in the tree is a + /// secret-scanner hit whatever its provenance, and a scanner that has to + /// be overridden to stay green is one nobody reads. The seed is fixed, so + /// the bytes are the same on every run, and this key exists nowhere but + /// these assertions. + fn fixture_key() -> russh::keys::PrivateKey { + russh::keys::PrivateKey::from(russh::keys::ssh_key::private::Ed25519Keypair::from_seed( + &[7u8; 32], + )) + } + + fn plain_key() -> String { + fixture_key() + .to_openssh(russh::keys::ssh_key::LineEnding::LF) + .expect("encode the fixture key") + .to_string() + } + + /// The same key under `PASSPHRASE`. `encrypt_with` takes the KDF and + /// checkint rather than an RNG, which is what keeps this crate free of a + /// rand dependency it otherwise has no use for; the low bcrypt round count + /// is a test's, not a real key's. + fn encrypted_key() -> String { + fixture_key() + .encrypt_with( + russh::keys::ssh_key::Cipher::Aes256Ctr, + russh::keys::ssh_key::Kdf::Bcrypt { + salt: vec![9u8; 16], + rounds: 4, + }, + 0, + PASSPHRASE, + ) + .expect("encrypt the fixture key") + .to_openssh(russh::keys::ssh_key::LineEnding::LF) + .expect("encode the encrypted fixture key") + .to_string() + } + + #[test] + fn load_identity_ready_for_plain_key_either_source() { + for source in [KeySource::Explicit, KeySource::Discovered] { + assert!( + matches!( + load_identity(&plain_key(), "k", source, None), + IdentityLoad::Ready(_) + ), + "plain key must load for {source:?}" + ); + } + } + + #[test] + fn load_identity_skips_public_key_content() { + let public = fixture_key() + .public_key() + .to_openssh() + .expect("encode the fixture public key"); + for source in [KeySource::Explicit, KeySource::Discovered] { + assert!( + matches!( + load_identity(&public, "k", source, None), + IdentityLoad::Skip + ), + "a .pub is never an offer" + ); + } + } + + #[test] + fn load_identity_garbage_is_loud_for_explicit_quiet_for_discovered() { + assert!(matches!( + load_identity("not a key", "k", KeySource::Explicit, None), + IdentityLoad::Unusable(_) + )); + assert!(matches!( + load_identity("not a key", "k", KeySource::Discovered, None), + IdentityLoad::Skip + )); + } + + #[test] + fn load_identity_encrypted_prompts_only_for_explicit() { + // The whole policy (#484): russh can only try an encrypted key by + // signing, so a discovered one with no cached passphrase is skipped + // rather than spending a prompt on a key the server may not want. + assert!(matches!( + load_identity(&encrypted_key(), "k", KeySource::Explicit, None), + IdentityLoad::NeedsPassphrase { rejected: false } + )); + assert!(matches!( + load_identity(&encrypted_key(), "k", KeySource::Discovered, None), + IdentityLoad::Skip + )); + } + + #[test] + fn load_identity_encrypted_uses_a_cached_passphrase_for_either_source() { + for source in [KeySource::Explicit, KeySource::Discovered] { + assert!( + matches!( + load_identity(&encrypted_key(), "k", source, Some(PASSPHRASE)), + IdentityLoad::Ready(_) + ), + "cached passphrase must unlock for {source:?}" + ); + } + } + + #[test] + fn load_identity_wrong_cached_passphrase_asks_again_only_for_explicit() { + // #486 inside #484's matrix. A wrong stored passphrase used to be the + // end of an explicit key: `Unusable`, so "could not decrypt identity + // file" with no way to correct the secret from inside the app. It now + // reopens the prompt, flagged so the sheet can say the saved one was + // refused. + assert!(matches!( + load_identity(&encrypted_key(), "k", KeySource::Explicit, Some("wrong")), + IdentityLoad::NeedsPassphrase { rejected: true } + )); + // The discovered half is the one that must not move: a `~/.ssh` default + // nobody configured stays silent whether its cached passphrase is + // absent or stale, so a stale entry cannot turn every connection into a + // prompt for a key the user never asked to use. + assert!(matches!( + load_identity(&encrypted_key(), "k", KeySource::Discovered, Some("wrong")), + IdentityLoad::Skip + )); + } + + #[test] + fn no_discovered_key_ever_asks_for_a_passphrase() { + // The seam where #484 and #486 meet: the self-heal reopens a prompt on + // a refused passphrase, and the probe hands this function keys the user + // never named. Whatever a discovered candidate's state, it must never + // be the thing that puts a sheet on screen — several of them would + // otherwise queue up a prompt storm on every connection. + for cached in [None, Some("wrong"), Some(PASSPHRASE)] { + assert!( + !matches!( + load_identity(&encrypted_key(), "k", KeySource::Discovered, cached), + IdentityLoad::NeedsPassphrase { .. } + ), + "a discovered key must not prompt (cached: {cached:?})" + ); + } + } + + #[test] + fn reason_names_the_keys_the_server_rejected() { + let mut round = KeyRound::default(); + round.offered_files = vec!["/home/me/.ssh/id_ed25519".to_string()]; + round.rejected_files = round.offered_files.clone(); + let msg = round.reason(SshAuthMode::Auto); + assert_eq!( + msg, + "server rejected public key(s): /home/me/.ssh/id_ed25519" + ); + + round.agent_offered = 2; + round.agent_rejected = 2; + let msg = round.reason(SshAuthMode::Auto); + assert_eq!( + msg, + "server rejected public key(s): /home/me/.ssh/id_ed25519, 2 agent identities" + ); + } + + #[test] + fn reason_for_nothing_offered_says_where_it_looked() { + let round = KeyRound::default(); + let msg = round.reason(SshAuthMode::Auto); + assert!(msg.contains("no usable private key was found"), "{msg}"); + assert!(msg.contains("~/.ssh default keys"), "{msg}"); + assert!(msg.contains("agent (unavailable)"), "{msg}"); + + // An agent that answered but held nothing is "checked", not + // "unavailable". + let mut round = KeyRound::default(); + round.agent_available = true; + let msg = round.reason(SshAuthMode::Auto); + assert!(msg.contains("the SSH agent"), "{msg}"); + assert!(!msg.contains("unavailable"), "{msg}"); + + // Pinned modes name only what they would have used. + let msg = KeyRound::default().reason(SshAuthMode::Agent); + assert!(!msg.contains("default keys"), "{msg}"); + let msg = KeyRound::default().reason(SshAuthMode::PublicKey); + assert!(!msg.contains("agent"), "{msg}"); + } + + #[test] + fn reason_appends_unusable_explicit_files() { + let mut round = KeyRound::default(); + round + .unusable + .push("cannot read identity file /bad/key: denied".to_string()); + let msg = round.reason(SshAuthMode::PublicKey); + assert!( + msg.contains("cannot read identity file /bad/key: denied"), + "{msg}" + ); + } + + #[test] + fn reason_falls_back_to_the_transport_error_after_an_offer() { + let mut round = KeyRound::default(); + round.offered_files = vec!["/home/me/.ssh/id_ed25519".to_string()]; + round.errors.push( + "public-key auth error with /home/me/.ssh/id_ed25519: connection lost".to_string(), + ); + assert_eq!( + round.reason(SshAuthMode::Auto), + "public-key auth error with /home/me/.ssh/id_ed25519: connection lost" + ); + } } diff --git a/docs/agents/orchestration.mdx b/docs/agents/orchestration.mdx new file mode 100644 index 00000000..7c830378 --- /dev/null +++ b/docs/agents/orchestration.mdx @@ -0,0 +1,190 @@ +--- +title: "Orchestrating agents" +description: "One agent opening a pane for another, waiting on it, and reading the result." +--- + +Once an agent's status is a thing a program can ask about, one agent can run +another. tty7 gives that loop a primitive instead of leaving it to screen +scraping. + +## The loop + +```bash +# 1. give the worker a pane +PANE=$(tty7 split --v) + +# 2. hand it a task +tty7 send "$PANE" 'claude -p "add tests for the parser"' --enter + +# 3. sleep until it needs you or finishes +tty7 wait "$PANE" --until waiting,done --changed --timeout 600 + +# 4. read what happened +tty7 capture "$PANE" --plain + +# 5. clean up +tty7 pane close "$PANE" +``` + +That is the whole shape. The interesting step is the third. + +## `tty7 wait` + +```bash +tty7 wait [%PANE] [--until STATE,…] [--changed] [--timeout SECS] [--interval MS] +``` + +Blocks until the pane reaches one of the states you named. + +| Flag | Default | | +|---|---|---| +| `--until` | `waiting,done,exit` | Which states end the wait — see below | +| `--changed` | off | Ignore the state the pane was *already* in — only wake on one it moved into after the wait began | +| `--timeout` | none | Give up after this many seconds, exiting 124 | +| `--interval` | 500 ms | How often to poll | + +Exit codes are made for scripts: + +| Code | Meaning | +|---|---| +| `0` | A state you asked for was reached | +| `124` | Timed out — the `timeout(1)` convention, so "not yet" is distinguishable from "broken" | +| `1` | The worker died first; the JSON says `"status": "exit"` | + +The reply carries the agent's own message and its native session id, so a +wake-up is directly actionable. + +### The states + +Four of them are the agent's own [status](/agents/status). The other three are +about the pane, because not everything worth waiting on is an agent: + +| State | Means | +|---|---| +| `idle` `working` `waiting` `done` | What the agent's hooks last reported | +| `no-agent` | Nothing reports status in this pane — a plain shell, or an agent whose hooks are missing | +| `free` | The foreground command has exited; the pane is back to its bare shell | +| `exit` | The pane is gone. Ends every wait, whether you asked for it or not | + +### Waiting on a command instead of an agent + +An agent says when it is done. A `cargo test` does not — so for a plain pane the +question is whether anything is still running in front of the shell, which is +what `free` answers: + +```bash +tty7 send "$PANE" 'cargo test > /tmp/t.log 2>&1; echo $? > /tmp/t.rc' --enter +tty7 wait "$PANE" --until free --changed --timeout 900 +cat /tmp/t.rc /tmp/t.log +``` + +`free` costs one extra request per poll, so it is only checked when you name it — +and only once the agent ladder has not already answered, so pairing it with +`waiting,done` never costs you a state you asked for. + + + `free` is read off the process tree, which has two blind spots. A pane whose + own root process *is* the command — what `tty7 run` spawns — looks free the + whole time it runs; wait on `tty7 run` itself instead, it already blocks. And + a backgrounded job (`… &`) keeps the pane busy after the foreground command + has finished. + + + + A pane with no agent reports `no-agent`, **not** `idle`. That distinction is + why `--until idle` cannot be used to mean "the command finished" — `idle` is a + thing an agent says about itself, and a busy shell never says it. + + +### Why `--changed` matters + +The status the server keeps is a **level, not an event**. `done` stands until +the next turn begins; `waiting` stands until the agent moves again. + +So a `wait` issued immediately after a `send` can answer with the *previous* +turn's state, before the worker has even read the input. `--changed` refuses the +state the pane was already in, which is what every round after the first needs. +Without it, the JSON's `stale` flag tells you whether that happened. + +`free` has the same problem and a different fix: a shell that goes free → busy → +free ends up where it started, so there is no new state to compare against. +There `--changed` means "something ran while I was watching", which is exactly +what you want in the line after a `send`. + +That does mean a command which starts *and* finishes between two polls is never +seen running, and the wait sits there until it times out. If the thing you are +waiting on can be that quick, poll faster (`--interval 100`) or drop `--changed` +and let a sentinel file carry the answer. The timeout says as much when it +happens. + +## Answering a prompt + +A worker that stops at `waiting` is usually showing something that keystrokes, +not text, are the answer to — a permission prompt driven by the arrow keys, a +menu, a TUI to be dismissed. `send --key` presses keys: + +```bash +tty7 wait "$PANE" --until waiting --changed # it needs something +tty7 capture "$PANE" --plain | tail -20 # see what it is asking +tty7 send "$PANE" --key down --key enter # answer it +tty7 send "$PANE" --key C-c # or stop it altogether +``` + +Keys are delivered as separate events 200 ms apart, so a raw-mode TUI reads a +sequence as a sequence rather than as a paste. The +[full vocabulary](/cli/reference#tty7-send-pane-text-enter-key-key) is in the +reference. + +## When an agent never moves + +If `tty7 wait` times out and `tty7 agents` shows a status that never changes, +the likely cause is that the agent's status hooks are not installed or are out +of date — the agent is working fine, it just has no way to say so. `tty7 doctor` +reports where every agent's hooks stand, and `tty7 agents` names the specific +one when it can see the gap. + +## Watching everything at once + +```bash +tty7 agents # every agent on the machine: pane, agent, status, message +tty7 agents --json # the same, parseable +``` + +If you are an agent yourself, you are in that list too. + +## Teaching an agent to do this + +tty7 installs nothing into `~/.claude` for it — no switch in **Settings → +Agents** writes a skill, and none ever will. What the agent needs to know ships +in the repository instead, as a skill you install yourself: + +```bash +npx skills add l0ng-ai/tty7 +``` + +That covers the pane-driving half — where it is, how to open a pane, send into +one, read one back, and the rules below — see [the agent +skill](/cli/agent-skill). The `wait` step is documented on this page. + +A skill rather than a global instruction, on purpose: only its one-line +description rides in context until something reaches for it, so an agent that +never touches another pane pays nothing for it. + +## Rules of the road + + + The panes on a machine are somebody's real work, and some of them are other + agents mid-task. Treat anything you did not create as read-only. + + +- **Never `send` into a pane you did not open.** Check `tty7 agents` first. +- **Never close a pane, tab, or workspace you did not create.** +- **Never `server stop` or `server restart`.** Every pane on the machine dies + with it. +- **Clean up what you did create** — `tty7 pane close %83` when you are done. + An interrupted `run` leaves its pane behind; `tty7 pane ls --all` shows those, + and `tty7 pane close --orphans` clears them. That last one is a human's + broom, not an agent's: it closes every orphan on the machine, including ones + somebody else abandoned mid-command. + +The full agent-facing contract is in [the skill](/cli/agent-skill). diff --git a/docs/agents/overview.mdx b/docs/agents/overview.mdx new file mode 100644 index 00000000..903c94a6 --- /dev/null +++ b/docs/agents/overview.mdx @@ -0,0 +1,107 @@ +--- +title: "Coding agents" +description: "What tty7 does around Claude Code, Codex, and 16 others — without ever wrapping them." +--- + +tty7 recognises coding agents running in a pane and builds around them. It does +not wrap them, proxy them, or replace their interface: the agent you start is +the agent you get, running in a normal PTY, with its own UI. tty7 adds the +things a terminal is in a position to add — who is running where, what they +need, and what changed. + + + Agent sessions in the tty7 sidebar + + +## Which agents + +Eighteen CLIs are recognised on sight, by the command running in the pane: + +| Agent | Command | +|---|---| +| Claude Code | `claude`, `claude-code` | +| Codex | `codex`, `codex-cli` | +| Gemini | `gemini`, `gemini-cli` | +| Copilot | `copilot` | +| Cursor | `cursor-agent` | +| Amp | `amp` | +| OpenCode | `opencode` | +| Aider | `aider`, `aider-chat` | +| Goose | `goose` | +| Droid | `droid` | +| Grok | `grok` | +| Qwen Code | `qwen`, `qwen-code` | +| Auggie | `auggie` | +| Hermes | `hermes` | +| Vibe | `vibe`, `vibe-acp` | +| Antigravity | `agy`, `antigravity` | +| Pi | `pi` | +| Oh My Pi | `omp` | + +Detection sees through the usual disguises: a full path, a `.cmd` or `.exe` on +Windows, leading environment assignments, and an interpreter in front +(`node .../claude/cli.js`). + +### Your own wrapper + +If you launch agents through a wrapper script, map its name to an agent in +`config.json`: + +```json +{ + "agent_commands": { + "cc": "claude", + "work": "codex" + } +} +``` + +The key is your command's name; the value is one of the slugs above (`claude`, +`codex`, `gemini`, `aider`, `amp`, `opencode`, `copilot`, `cursor`, `goose`, +`droid`, `pi`, `auggie`, `hermes`, `vibe`, `antigravity`, `grok`, `qwen`, +`omp`). + +## What you get for free + +Just by running an agent in a pane: + + + + The tab chip and sidebar row show which agent runs where, so ten tabs stay + legible. + + + The branch and working-tree diff on the row, refreshed as the agent works. + + + A pane lost to a reboot relaunches the conversation, carrying its original + flags. [More →](/agents/sessions) + + + Palette commands that hand the current selection or the repo's `git diff` to + the running agent as a prompt. + + + +## What needs a hook + +Live status — **working**, **needs your input**, **done** — comes from the agent +itself, over a channel tty7 installs into that agent's configuration. It powers +the status dots, the notifications, the tray icon, and `tty7 wait`. + +Installing takes one click per agent under **Settings → Agents**. +[Status and notifications →](/agents/status) + +## Where to go next + + + + Hooks, status dots, the tray icon. + + + Resume, fork, and copying a session id. + + + One agent driving another with `tty7 wait`. + + diff --git a/docs/agents/sessions.mdx b/docs/agents/sessions.mdx new file mode 100644 index 00000000..bc8f685b --- /dev/null +++ b/docs/agents/sessions.mdx @@ -0,0 +1,60 @@ +--- +title: "Agent sessions" +description: "Resuming a conversation after a reboot, forking a live one, and getting at the session id." +--- + +Coding agents keep their own conversation history, addressed by a session id. +Because tty7's hooks learn that id, it can do three things with it. + +## Resume after a restart + +When the server goes away — a reboot, a crash, a deliberate restart — the shells +go with it. Panes that were running an agent relaunch the conversation on +restore instead of coming back to a bare prompt: + +```bash +claude --dangerously-skip-permissions --resume 8f3c… +``` + +The original launch flags are replayed, so the pane comes back the way you +started it, not the way the defaults would. + +Supported for Claude Code, Codex, Gemini, OpenCode, Amp, Cursor, Copilot, Grok, +Pi, and Oh My Pi. Turn it off with `restore_agent_sessions: false`. + + + Resume needs the agent's hooks installed, since the session id comes from + them. [Installing hooks →](/agents/status) + + +## Fork a live session + +Forking branches a running conversation into a second, independent one. The +original keeps going untouched; both continue separately from the same history. + +Right-click a **pane** to fork into a split — the menu offers a placement — +or right-click the **tab or sidebar row** to open the fork in a new tab. + +| Agent | What tty7 runs | +|---|---| +| Claude Code | `claude --resume --fork-session` | +| Codex | `codex fork ` | +| Grok | `grok --resume --fork-session` | +| OpenCode | `opencode --session --fork` | +| Oh My Pi | `omp --fork ` | + +It is the agent's own fork command, run in a new pane — nothing is copied by +tty7 itself. + + + A fork duplicates the whole transcript in the agent's session store, so + forking repeatedly costs real disk. A pane on a + [remote machine](/remote/workspaces) cannot fork, because the command would + run against the local agent. + + +## Copy the session id + +**Copy Session ID** — in the tab's right-click menu, beside *Copy Working +Directory*, and in the command palette — puts the agent's native id on the +clipboard. Paste it into `codex resume`, a bug report, or another tool. diff --git a/docs/agents/status.mdx b/docs/agents/status.mdx new file mode 100644 index 00000000..70eab33c --- /dev/null +++ b/docs/agents/status.mdx @@ -0,0 +1,88 @@ +--- +title: "Status and notifications" +description: "Installing the hooks, reading the dots, and being told when an agent needs you." +--- + +An agent working for two minutes and an agent that stopped ninety seconds ago +waiting for permission look identical from outside. tty7 fixes that by letting +the agent say which one it is. + +## Installing the hooks + +**Settings → Agents** lists every agent that can report status, with an +**Install** button beside each: + +| Agent | | +|---|---| +| Claude Code · Codex · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi | Hooks available | +| Gemini · Aider · Amp · Cursor · Goose · Droid · Auggie · Hermes · Vibe · Antigravity · Qwen Code | Detected and labelled, but no status channel yet | + +Installing writes into that agent's own configuration directory. Once installed +the row grows a second **Uninstall** button beside the first, which itself +becomes **Reinstall** — or **Update**, against an **Outdated** state, when tty7 +ships a newer hook. + + + The hooks only do anything inside tty7. Running the same agent in another + terminal is unaffected. + + +Hooks are installed per machine. Once a second +[machine](/remote/workspaces) is linked, a row of chips appears above the table +to pick which one you are looking at, and the table then shows that machine's +agents — so a dev box gets its hooks installed the same way, from the same +screen. + +## The status dot + +Every tab chip and sidebar row carries a dot: + +| Dot | Meaning | +|---|---| +| 🔵 **Blue** | Working | +| 🟠 **Amber** | Needs your input — a permission prompt, a question | +| 🟢 **Green** | Done with this turn | + + + Agent status dots + + +The same three states are what `tty7 agents` reports as `working` / `waiting` / +`done`, and what [`tty7 wait`](/agents/orchestration) blocks on. A fourth state, +`idle`, carries no dot — it is an agent that has not started a turn. + +## Notifications + +Two, both following your **Settings → Window & Tabs → Notifications** policy: + +- **"needs your permission…"** the moment an agent blocks on you +- **"finished after 42s"** at the end of a turn + +Which means that by default — *When unfocused* — you are told the instant you +are the bottleneck, and left alone while you are watching. + +## The tray icon + +tty7 keeps a status item in the system tray (menu bar on macOS). It flips to an +attention state the moment *any* agent anywhere needs input, so you can see it +without the window in front of you. + +Its menu lists every agent pane with its brand avatar and status dot — click one +to reveal it — and also holds the notification policy switch and **Quit and Stop +Server…**. + +Turn it off with **Settings → Window & Tabs → Show tray icon** +(`show_tray_icon: false`). + +## Sending an agent some context + +Two command-palette entries hand what is in front of you to the agent running in +the pane, as a ready-made prompt: + +| Command | Sends | +|---|---| +| **Agent: Send Selection** | The current terminal selection | +| **Agent: Send Git Diff for Review** | The repository's `git diff` | + +If nothing recognisable is running, tty7 says *"No running coding agent found"* +rather than typing into your shell. diff --git a/docs/cli/agent-skill.mdx b/docs/cli/agent-skill.mdx new file mode 100644 index 00000000..3fa48456 --- /dev/null +++ b/docs/cli/agent-skill.mdx @@ -0,0 +1,84 @@ +--- +title: "The agent skill" +description: "Teaching a coding agent to use tty7 properly — including when not to." +--- + +The CLI is only half of the story. An agent has to know *when* reaching for a +pane beats running a command, and — more importantly — which panes it must not +touch. That is what the skill is for. + +## Installing it + +```bash +npx skills add l0ng-ai/tty7 +``` + +The source lives at +[`skills/tty7/`](https://github.com/l0ng-ai/tty7/tree/main/skills/tty7) in the +repository: a `SKILL.md` and a full command reference. + + + This is the only skill tty7 has — nothing in **Settings → Agents** installs + one for you. Using the CLI to run *other* agents — a worker pane, `tty7 wait`, + collecting the result — is covered separately. + [Orchestration →](/agents/orchestration) + + +## What it teaches + +### When to use a pane instead of a plain command + +The Bash-style tool an agent already has is right for anything that starts, does +its job, and exits. A pane is right when: + +- **It should not block.** A dev server, a watcher, `tail -f`, a long test run. +- **It is interactive or stateful.** A REPL, `ssh`, a database shell — anything + where you send, read, then send again. A pane keeps the session alive between + turns; a one-shot call cannot. +- **It needs a real TTY.** Programs that detect a pipe and change behaviour — + colour, progress bars, TUIs, `top`, raw mode. +- **The user should be able to watch.** Anything in a pane shows up live in + their window. That is often the whole point. +- **You are being asked about something you did not start.** "What's running in + that pane?", "why is port 3000 taken?", "what are my agents doing?" + +### The safety rules + + + The panes on this machine are the user's real work, and some of them are other + coding agents mid-task. Anything the agent did not create is read-only. + + +- **Never `send` into a pane you did not open.** Keystrokes land in the middle + of whatever is happening there. Check `tty7 agents` first. +- **Never close a pane, tab, or workspace you did not create.** +- **Never `server stop` or `server restart`.** Every pane on the machine dies + with the server, including yours. +- **Never `tty7 server start` on your own initiative** when `doctor` says the + server is unreachable — starting one the user did not ask for changes what + their GUI attaches to. Tell them instead. +- **Clean up what you did create.** `tty7 pane close %83` when the scratch pane + is done with. + +### The reliable idioms + +Rather than screen-scraping, the skill points agents at the primitives that +actually answer the question: + +```bash +# is it finished? — when only the depth-0 shell is left, yes +tty7 procs %83 --json + +# the answer, not the view +tty7 send "$PANE" 'cargo test > /tmp/t.log 2>&1; echo $? > /tmp/t.rc' --enter + +# wake up exactly when the other agent needs something +tty7 wait %3 --until waiting,done --changed --timeout 600 +``` + +## For humans writing their own tooling + +The same material is worth reading even if you are not an agent — it is the +shortest description of how to use tty7 as a job runner. Start with the +[CLI overview](/cli/overview), then the +[command reference](/cli/reference). diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx new file mode 100644 index 00000000..0754bc8b --- /dev/null +++ b/docs/cli/overview.mdx @@ -0,0 +1,147 @@ +--- +title: "The tty7 command" +description: "Driving the workbench from a script, a Makefile, or another agent." +--- + +`tty7` is a thin, non-interactive client of the tty7 server. Every verb runs and +exits; `--json` makes the output machine-readable. **The GUI does not have to be +running** — the server is what owns the panes. + +It ships inside every installer and is put on PATH at launch, so it works from +any terminal on the machine. [Installation →](/getting-started/installation#the-tty7-command) + +## Start with `doctor` + +```bash +tty7 doctor +``` + +One table that answers everything you need before doing anything else: whether a +server is reachable, whether its wire dialect matches this binary, and whether +`TTY7_CONFIG_DIR` / `TTY7_WS` / `TTY7_PANE` are set — that is, whether you are +running *inside* a tty7 pane. + +Being inside a pane matters because the address-taking verbs (`split`, `send`, +`capture`, `procs`, `wait`, `pane close`) default to `$TTY7_PANE`, and +`run --keep` files its pane into `$TTY7_WS`. Outside one you must name a target, +and the error says so rather than guessing. + +## 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 any workspace or tab appears or disappears | +| `api` · `76698a44` · a full UUID | A workspace, by name, unique id prefix, or id | Yes | + +Re-resolve `@N` immediately before using it. Pane and workspace ids are safe to +remember. + +## Two ways to run something + +### Blocking, with a real exit code + +```bash +tty7 run -- cargo test # streams to stdout, exits with cargo's code +tty7 run --cwd /path -- make +tty7 run --keep -- cargo build # leaves the pane behind as a new tab +``` + +The closest thing to running the command yourself — the difference is that it +gets a real PTY (so colour, progress bars, and TUIs behave), and that you can +watch it happen in the window. + + + Everything after `--` belongs to the child: `tty7 run -- cargo test --keep` + passes `--keep` to cargo, not to tty7. + + +### Non-blocking: a pane you talk to over time + +This is the one worth reaching for. Get a pane, give it work, come back. + +```bash +PANE=$(tty7 split --v) # or --h; prints "%83" +tty7 send "$PANE" 'npm run dev' --enter +# ... later +tty7 capture "$PANE" --plain +tty7 pane close "$PANE" +``` + +If you are not inside a tty7 pane there is nothing to split, so make your own +place to work: + +```bash +tty7 new --json /path/to/repo # {"id": "...", "pane": 83} +``` + +## Reading a pane + +```bash +tty7 capture %83 --plain +``` + +`capture` returns what the server stored. Without `--plain` that is the raw +bytes, escapes and all. With `--plain` those bytes are replayed through a real +terminal grid and you get the text that produced — which is not the same as +stripping escapes yourself: + +- A line the shell wrapped at the pane width comes back as **one** line +- A progress bar that rewrote itself with `\r` reads as its **final** value +- Cursor addressing puts text **where the program put it**, so a TUI's screen + lands where it was drawn + +Use `--plain` whenever a human would want to read the output. + + + A screen is a rectangle. Whatever scrolled off the top is gone, and an exit + code was never on it. When you want the *answer* rather than the *view*, have + the shell write it somewhere clean: + + ```bash + tty7 send "$PANE" 'cargo test > /tmp/t.log 2>&1; echo $? > /tmp/t.rc' --enter + ``` + + +## Knowing when something finished + +```bash +tty7 procs %83 +``` + +The process tree inside the pane, indented by depth, with `*` on the foreground +process — plus the ports those processes are listening on. **When the only entry +left is the depth-0 shell, the command is done.** That is far more reliable than +grepping the screen for a sentinel that can wrap or echo twice. + +For agents specifically, use [`tty7 wait`](/agents/orchestration) instead of +polling. + +## Looking around + +```bash +tty7 ls # every workspace: tabs, panes, who's attached +tty7 ws tree api # one workspace as a tree +tty7 pane ls --all # every pane, including orphans no workspace holds +tty7 agents # every coding agent 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 until interrupted +``` + +`--json` on any of them, `-q` to suppress success output (errors still print). + +## Remote machines + +```bash +tty7 -m devbox ls +tty7 -m devbox run -- cargo test +``` + +`-m` routes over a link the local server already holds. It will not dial a fresh +connection — connect from the GUI first. +[Remote workspaces →](/remote/workspaces) + + + Every verb, flag, and JSON shape. + diff --git a/docs/cli/reference.mdx b/docs/cli/reference.mdx new file mode 100644 index 00000000..58dfcf5f --- /dev/null +++ b/docs/cli/reference.mdx @@ -0,0 +1,321 @@ +--- +title: "Command reference" +description: "Every verb, its flags, and the JSON it emits under --json." +--- + +## Global flags + +Accepted anywhere on the line, before or after the subcommand. + +| Flag | Effect | +|---|---| +| `-m, --machine ` | Route 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 launched from one. + +| Variable | Meaning | +|---|---| +| `TTY7_PANE` | This pane's id, e.g. `71` or `%71` (both accepted). Default target of `split`, `send`, `capture`, `procs`, `wait`, `pane close`. | +| `TTY7_WS` | This pane's workspace id. Default for `run --keep`, `tab new`, `ws tree`. | +| `TTY7_CONFIG_DIR` | The server's config dir — how the CLI finds the right server. You never pass a socket path. | + +Outside a tty7 shell, 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; one line on stderr, prefixed `tty7:` | +| `2` | Usage error — unknown verb, missing argument, bad type | +| `124` | `tty7 wait` timed out (the `timeout(1)` convention) | +| `141` | Unix only: the reader hung up — piping into `head -1`, say — 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 | + +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 [PATH]` + +No subcommand means the GUI. A running window is asked to come forward and open +a tab at `PATH`; if none is registered, the app is launched instead. +JSON: `{"path","delivered","launched"}` — `delivered` says an existing window +took it, `launched` that a new process was started. + +Without `PATH` it just activates the app. `-m` is refused: this verb drives the +GUI on *this* machine. + +### `tty7 ls` + +Same as `ws ls`. Table: `WORKSPACE NAME TABS PANES ATTACHED`. +JSON: `{"workspaces":[{"id","name","tabs","panes","attached"}]}`. + +`ATTACHED` names the host holding the workspace — a GUI window, or another +client — and is `-` when nobody is. + +### `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 `--`. + +- `--keep` leaves the pane alive as a new tab afterwards. 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 — see + `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] [--open]` + +Creates a workspace plus its first tab and shell, at `PATH` if given. Prints the +workspace id. JSON: `{"id","pane","opened"}`. + +`--open` also puts a window on it, if a GUI is running on this machine. Without +it the workspace still appears in the switcher; it just waits to be opened. + +### `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] [--key KEY]…` + +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, unless a +`--key` gives it something to do. + +`--key` presses a key instead of typing characters, which is what a pane wants +once something is already running in it: answering a prompt that only takes +arrow keys, closing a TUI with `escape`, stopping a build with `C-c`. Repeat it +for a sequence, and it composes with `TEXT` — the text goes first. + +| | | +|---|---| +| Named | `enter` `escape` `tab` `backtab` `space` `backspace` `delete` `up` `down` `right` `left` `home` `end` `pageup` `pagedown` | +| Chords | `C-` (Ctrl, e.g. `C-c`), `M-` (Alt) | +| Aliases | `return` `cr` `esc` `del` `bs` `shift-tab` `pgup` `pgdn` | + +Names are case-insensitive, and an unknown one is a usage error (exit `2`) +raised before anything is sent — half a key sequence in a live pane is worse +than none. Each keystroke is delivered as its own event, 200 ms apart, so a +raw-mode TUI reads a sequence as a sequence rather than as a paste. + +JSON: `{"pane","sent","enter","keys"}`. + +### `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. + +**In what form** — without `--plain`, the stored bytes with ANSI escapes intact, +decoded as UTF-8 (invalid bytes become U+FFFD). With `--plain`, those bytes +replayed through a terminal grid and printed as the text they produced. + +Either way it is a snapshot, not a stream: it collects the replay, settles for +~300 ms, and returns. Call it again for a newer one. +JSON: `{"pane","text"}`. + +### `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"}]}`. + +### `tty7 agents` + +Every pane running a recognised coding agent. Table: +`PANE AGENT STATUS MESSAGE`, status one of `idle` / `working` / `waiting` / +`done`. JSON: `{"agents":[...]}`, plus a `"diagnostics"` array when an agent's +status hook is missing or out of date — that is why an agent can be listed with +a status that never moves. + +### `tty7 wait [%PANE] [--until STATE,…] [--changed] [--timeout SECS] [--interval MS]` + +Blocks until the pane reaches one of the named states. + +| Flag | Default | | +|---|---|---| +| `--until` | `waiting,done,exit` | See the state table below | +| `--changed` | off | Only wake on a state the pane moved into *after* the wait began | +| `--timeout` | none | Give up after N seconds, exiting `124` | +| `--interval` | `500` | Poll interval in ms (50–3,600,000) | + +The states come from two places. Four are the agent's own status, as reported +by its [hooks](/agents/status); the last three are facts about the pane: + +| State | Means | +|---|---| +| `idle` `working` `waiting` `done` | The agent's status | +| `no-agent` | Nothing is reporting status here — a plain shell, or an agent whose hooks are not installed | +| `free` | The foreground command has exited; the pane is back to its bare shell | +| `exit` | The pane itself is gone. Ends every wait whether it was asked for or not | + +`free` is how you wait for a **command** rather than an agent, and it is the +one state that costs a second request per poll — so it is only checked when you +name it, and only when none of the agent states you asked for already matched. +With `--changed` it means "something ran and then finished", which is what you +want directly after a `send`; a command quick enough to finish inside one +`--interval` is never seen running, and the timeout says so. + +The reply carries the agent's message and native session id. The JSON's `stale` +flag says whether the answer might belong to the previous turn. +[Orchestration →](/agents/orchestration) + +### `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 environment variables, whether the server answers, +whether its control and protocol versions match this binary, pid/uptime/panes, +how many machine links exist, and where each agent's +[status hooks](/agents/status) stand. Adds a note when you are not inside a +tty7 shell. + +The hooks row is the one that explains a mystery: without them an agent reports +nothing, so `tty7 agents` shows it standing still and `tty7 wait` sits there +until it times out. Outdated hooks fail the same quiet way. Hooks are a local +install, so under `-m` the row reads `unknown`. + +JSON: `{"context":{"config_dir","workspace","pane"},"server":{"reachable","dialect_ok","build","status","routes"},"hooks":{"installed","outdated","not_installed"}}` +— the context fields are booleans, not values, and each `hooks` field is a list +of agent slugs. + +## `ws` — workspaces + +Address a workspace by name, by full id, or by a unique id prefix (the 8-char +prefix `tty7 ls` prints). 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","active_tab","tabs":[{"id","name","sidebar_group","root",…}]}` | +| `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 ` over `ws new` when you want something usable: `ws new` +leaves an empty workspace you then have to populate, while `tty7 new --json` +hands back both ids at once. + +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. + +## `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: `@`. + +| Command | Effect | JSON | +|---|---|---| +| `tab ls [WORKSPACE]` | Tabs of a workspace | `{"workspace","tabs":[{"ordinal","id","name","label","agent","group","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"}` | + +`GROUP` is the heading the GUI's sidebar files the tab under, shown by its last +segment. Read-only from here: with the default repo grouping the GUI recomputes +it from the tab's working directory. + +`label` falls back through the best evidence available — the name if someone set +one, else the agent running there, else the last segment of the cwd, else the +foreground process. `name` stays literal, so a script can tell a real name from +a stand-in. + +## `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 | `{"panes":[…],"orphans":N}` | +| `pane split …` | Identical to top-level `split` | `{"pane"}` | +| `pane close [%PANE…]` | Close panes; their shells are hung up | `{"closed":[…]}` | +| `pane close --orphans` | Close every pane no workspace holds | `{"closed":[…]}` | + +`--all` is the one that shows leaks. Each entry is +`{"pane","workspace","orphan","owner","title","cwd","live"}`: `owner` is the id +of the workspace that owns the pane, and `orphan: true` means no workspace holds +it. An interrupted `run` and a removed workspace both leave orphans here. + +`--orphans` is the reaper for exactly those. It closes what `pane ls --all` +lists as orphaned and nothing else — panes a workspace holds are untouched — +and reports an empty list rather than an error when there is nothing to clean +up, so a script does not have to guard it. A pane that cannot be closed does +not abandon the rest of the batch: the rest are still attempted, the complaint +goes to stderr, and the verb exits 1 with `{"closed":[…],"failed":[…]}` — the +list a retry needs. + + + `--orphans` closes every orphan on the machine, and an orphan can still be + doing real work — an interrupted `run` leaves the command running. Look at + `pane ls --all` first. + + +`title` is usually the running command — `claude`, `nvim`, `cargo` — which makes +`pane ls --all --json` a quick way to find "the pane running X". + +## `machine` — remotes + +`machine ls` lists the local machine plus every link the server holds: +`MACHINE KIND CONNECTED`. JSON: `{"machines":[{"key","kind","connected"}]}`. + +## `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 someone else's behalf. 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's connection manager diff --git a/docs/customization/fonts.mdx b/docs/customization/fonts.mdx new file mode 100644 index 00000000..29777c6d --- /dev/null +++ b/docs/customization/fonts.mdx @@ -0,0 +1,84 @@ +--- +title: "Fonts" +description: "The bundled default, fallback chains, ligatures, and why CJK needs a word." +--- + +**Settings → Appearance → Typography** covers the everyday choices; the rest is +`config.json`. + +| Setting | Default | | +|---|---|---| +| **Font family** | Hack | Picked from fonts installed on your system | +| **Font size** | 15 px | The terminal grid | +| **Interface font size** | 16 px | Everything outside the grid (12–24) | +| **Line height** | 1.4 | A multiple of the font size | +| **Bold font** / **Italic font** | — | Distinct faces, when you want them | +| **Font ligatures** | off | Contextual alternates stay off unless you ask | + +## Hack is bundled + +The default font ships inside the binary. It renders identically on every +machine without relying on a system install, so a fresh laptop looks like the +one you set up last year. + +## Fallbacks + +`font_family` is the primary face; `font_fallbacks` is an ordered list tried in +turn for anything the primary lacks. + +```json +{ + "font_family": "JetBrains Mono", + "font_fallbacks": ["Maple Mono NF CN", "PingFang SC", "Apple Color Emoji"] +} +``` + +Leave `font_fallbacks` out and you get the platform's default chain: + +| | Default fallbacks, in order | +|---|---| +| **macOS** | Menlo · Hasklug Nerd Font Mono · Maple Mono NF CN · PingFang SC · Apple Color Emoji | +| **Windows** | Maple Mono NF CN · Cascadia Mono · Microsoft YaHei · Segoe UI Emoji | +| **Linux** | Maple Mono NF CN · DejaVu Sans Mono · Noto Sans CJK SC · Noto Color Emoji | + +Each ends in faces the host OS actually ships, and those stock names are +appended to whatever list you write yourself — so a `config.json` copied from +another platform still resolves. + +## OpenType features + +`font_features` passes tags straight through to the shaper: + +```json +{ + "font_features": { "calt": true, "liga": 1, "ss01": true, "zero": false } +} +``` + +A tag must be four alphanumeric characters; `true`/`false` map to `1`/`0`. +Anything malformed is skipped with a log line rather than failing the whole +config. + +## CJK and the two-column grid + + + A cell is one advance of the primary face, and a wide (CJK) character is + pinned to exactly two of them. A CJK fallback sits flush in its slot only if + its ideographs advance **twice** the primary's Latin advance. + + +Bundled Hack advances 0.60205em, so a two-column slot is 1.2041em — while every +stock CJK face (Microsoft YaHei, PingFang SC, Noto Sans CJK) advances 1.0em. +Those glyphs get left-aligned in the slot, leaving a ~0.2em gap on the right of +every character. + +[Maple Mono NF CN](https://github.com/subframe7536/maple-font) is in every +platform's default chain for exactly this reason: 0.6em Latin, 1.2em CJK, an +exact two-cell fit against Hack. It leads the chain on Windows and Linux, and on +macOS sits behind Menlo and Hasklug, which cover Latin and Nerd Font glyphs +first. It is referenced by name only, never bundled (~20 MB per weight) — +install it and tty7 picks it up with no config change. + +If you want CJK set *tight* rather than merely even, change the **primary** face +instead. One that advances 0.5em — Sarasa Mono SC, say — makes two columns +exactly 1.0em. diff --git a/docs/customization/keybindings.mdx b/docs/customization/keybindings.mdx new file mode 100644 index 00000000..84386b0c --- /dev/null +++ b/docs/customization/keybindings.mdx @@ -0,0 +1,94 @@ +--- +title: "Keybindings" +description: "Rebinding anything, chord sequences, and the tmux preset." +--- + +**Settings → Keybindings** (⌘ ,) lists every shortcut in the app, +grouped the same way the command palette is. + +## Rebinding + +Click a shortcut and press the new keys. It saves after a brief pause. + +| | | +|---|---| +| Press keys | Set the binding | +| Press more keys | Chain a sequence — ⌃ B then X | +| Esc | Cancel | +| | Remove the last key — or, pressed first, reset the shortcut to its default | + +**Restore all defaults** at the bottom undoes every rebinding at once. There is +no undo for that one. + + + Rebinding a shortcut + + +## Actions with no default key + +Some actions ship deliberately unbound, because there is no obvious key left to +take: pane resize and swap, workspace selection, most git commands, SFTP, and +the panel tabs. They are all in the command palette, and all bindable here. + +## Editing `config.json` instead + +```json +{ + "keybindings": { + "SplitRight": "cmd-d", + "ResizePaneLeft": "ctrl-alt-left", + "ToggleSftp": "cmd-shift-u" + } +} +``` + +The syntax is modifiers joined by `-`, then the key. Chords are separated by a +space. + +| Token | Means | +|---|---| +| `secondary` | on macOS, Ctrl elsewhere | +| `cmd` · `ctrl` · `alt` · `shift` | Literal modifiers | +| `ctrl-b n` | A two-key sequence | + +An unknown action name or an invalid keystroke is skipped with a warning in the +log rather than breaking the rest of your bindings. + +The full action list is on the [keyboard shortcuts](/reference/keyboard-shortcuts) +page. + +## The tmux preset + +**Settings → Keybindings → Preset → tmux** remaps pane and tab actions onto a +prefix — ⌃ B by default, changeable in the **Prefix** field beside +it. + +| | | +|---|---| +| ⌃ B C · X | New tab · close tab | +| ⌃ B % · " | Split right · split down | +| ⌃ B ←→↑↓ | Move focus | +| ⌃ B ⌃ ←→↑↓ | Resize the pane | +| ⌃ B O · ; | Next pane · previous pane | +| ⌃ B { · } | Swap with the previous · next pane | +| ⌃ B Z | Zoom the pane | +| ⌃ B N · P | Next tab · previous tab | +| ⌃ B 19 | Jump to a tab | + +Two details that make it livable: + +- A **bare prefix** reaches the shell after about a second, so ⌃ B + still works as "back one character" when you meant it. +- **Prefix plus an unbound key** is passed straight through to the terminal, so + a tmux binding you did not remap still lands in whatever is running. + +## Some non-obvious defaults + +| | | +|---|---| +| ⇧ ⏎ · ⌥ ⏎ | Insert a newline at the prompt instead of submitting (`InsertNewline`) | +| ⌘ ⇧ ⏎ | Zoom the focused pane | +| ⌘ ⇧ E | Toggle the code panel | +| ⌘ ⇧ R | Restart the SSH session in this pane | +| ⌘ ⇧ O | Workspace switcher | +| ⌘ ⇧ N | New workspace | diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx new file mode 100644 index 00000000..b93b5526 --- /dev/null +++ b/docs/customization/settings.mdx @@ -0,0 +1,85 @@ +--- +title: "Settings" +description: "What lives in each section, and how the settings file works." +--- + +⌘ , opens Settings. There is a search box at the top that matches +setting names *and* keywords, which is usually faster than remembering which +section something is in. + + + tty7 Settings + + +## The eight sections + + + + Theme, sync with system, typography, cursor, transparency, language. + + + Shell and start directory, scrollback and scrolling, mouse, bell, per-pane + history. + + + Prompt features, selection & clipboard, keyboard (Option as Meta), links. + + + Hosts, defaults, security, and every per-profile field. + + + Hook installation per agent and per machine, and the CLI on PATH. + + + Startup window, tab bar position and grouping, notifications, tray icon. + + + Every shortcut, the tmux preset, the prefix. + + + Version, update channel, and the updater. + + + +## The settings file + +Everything the Settings window writes goes to one file: + +| | | +|---|---| +| macOS / Linux | `~/.config/tty7/config.json` | +| Windows | `%APPDATA%\tty7\config.json` | + +Set `TTY7_CONFIG_DIR` to point the whole directory — config, themes, state — +somewhere else. + +You can edit the file by hand; a handful of options exist only there. See the +[configuration reference](/reference/configuration) for every key, its type, and +its default. + +### How it handles mistakes + +The file is written atomically, and read forgivingly: + +- **A missing key** means the default — you only have to write what you change. +- **An out-of-range number** is clamped into its band, not rejected. +- **An unrecognised enum value** falls back to the default with a log line, + rather than failing the whole file. +- **An unparseable file** is not overwritten. tty7 starts on defaults, keeps a + copy at `config.json.corrupt`, and says so in the log. + + + A UTF-8 BOM at the start of the file is tolerated, which matters if you edited + it in a Windows editor. + + +## Language + +**Settings → Appearance → Language** switches the interface between English, +简体中文, and 日本語. The choice is explicit — the system language is never +inferred — and CLI output stays English regardless, so agent and script +integrations keep a stable surface. + +```json +{ "gui_language": "zh-CN" } +``` diff --git a/docs/customization/themes.mdx b/docs/customization/themes.mdx new file mode 100644 index 00000000..06774279 --- /dev/null +++ b/docs/customization/themes.mdx @@ -0,0 +1,113 @@ +--- +title: "Themes" +description: "Nine built-ins, your own YAML themes, iTerm2 imports, and a colour editor." +--- + +**Settings → Appearance → Theme** — or **Change Theme…** in the command +palette — opens the theme picker. + +## Built in + +| Light | Dark | +|---|---| +| Light *(default)* · One Light · Catppuccin Latte · Rosé Pine Dawn | Dark · Dracula · Harbor · One Dark Pro · Rosé Pine | + + + The tty7 theme picker + + +## Following the system + +Turn on **Sync with system** and pick a theme for each appearance. tty7 follows +the OS live — no restart, no reload. + +```json +{ + "theme_follow_system": true, + "theme_preset_light": "one_light", + "theme_preset_dark": "dracula" +} +``` + +## Legible bright colours + +Some palettes put a bright ANSI colour so close to their own background that it +disappears. **Legible bright colors** (on by default) brightens or darkens those +just enough to be readable. Turn it off with `theme_legible_palette: false` if +you want the palette exactly as authored. + +## Transparency + +**Settings → Appearance → Transparency**: + +| | | +|---|---| +| **Opacity** | 0.2–1.0, applied to every theme. *Follow theme* hands the decision back to the theme's own `opacity`. | +| **Blur** | Blurs whatever is behind a translucent window (macOS). | +| **Background material** | Windows only: *Auto*, *Blur*, *Mica*, *Mica Alt*, *Acrylic*, *Off*. Only the presets your Windows build supports are listed. | + +## Writing your own + +**Open themes folder** in Settings takes you to: + +| | | +|---|---| +| macOS / Linux | `~/.config/tty7/themes/` | +| Windows | `%APPDATA%\tty7\themes\` | + +Drop a `.yaml` file in and it appears in the picker. The file name is the +theme's id; `name` is what is shown. + +```yaml +name: "Midnight" +background: "#0d1117" +foreground: "#c9d1d9" +accent: "#3fdd8c" +cursor: "#3fdd8c" +selection: "#264f78" +opacity: 0.95 +blur: true +ansi: + normal: ["#484f58", "#ff7b72", "#3fb950", "#d29922", "#58a6ff", "#bc8cff", "#39c5cf", "#b1bac4"] + bright: ["#6e7681", "#ffa198", "#56d364", "#e3b341", "#79c0ff", "#d2a8ff", "#56d4dd", "#f0f6fc"] +``` + +Everything except `background`, `foreground`, `accent`, and `ansi` is optional. + +### Gradients and images + +`background` also takes two colours: + +```yaml +background: { top: "#0d1117", bottom: "#161b22" } +# or +background: { left: "#0d1117", right: "#161b22" } +``` + +And a theme can carry an image behind the terminal: + +```yaml +background_image: + path: "/Users/me/Pictures/wall.jpg" + opacity: 0.25 +``` + +The Settings panel has a picker for both, so you rarely have to write this by +hand. + +## Editing in the app + +Select a built-in theme and hit **Duplicate to edit** — built-ins are read-only, +so the editor works on your copy. From there you get every colour, including the +sixteen ANSI slots, plus the background image controls. Changes are written back +to your themes folder as YAML. + +## Importing from iTerm2 + +Drop an `.itermcolors` file into the themes folder and tty7 reads it directly — +no conversion step. + + + A theme file tty7 could not load is listed in Settings under **Not loaded from + the themes folder**, with the reason, rather than silently ignored. + diff --git a/docs/docs.json b/docs/docs.json new file mode 100644 index 00000000..762af9e8 --- /dev/null +++ b/docs/docs.json @@ -0,0 +1,142 @@ +{ + "$schema": "https://mintlify.com/docs.json", + "theme": "mint", + "name": "tty7", + "description": "A terminal workbench: persistent sessions, remote work, agents.", + "colors": { + "primary": "#0FA968", + "light": "#3FDD8C", + "dark": "#0FA968" + }, + "favicon": "/favicon.ico", + "logo": { + "light": "/logo/logo.svg", + "dark": "/logo/logo.svg", + "href": "https://github.com/l0ng-ai/tty7" + }, + "navigation": { + "tabs": [ + { + "tab": "Documentation", + "groups": [ + { + "group": "Getting started", + "pages": [ + "index", + "getting-started/installation", + "getting-started/first-launch", + "getting-started/concepts" + ] + }, + { + "group": "The window", + "pages": [ + "window/tabs-and-splits", + "window/sidebar", + "window/command-palette", + "window/search", + "window/side-panel" + ] + }, + { + "group": "The terminal", + "pages": [ + "terminal/prompt", + "terminal/history", + "terminal/selection-and-clipboard", + "terminal/links", + "terminal/mouse-and-scrolling" + ] + }, + { + "group": "Coding agents", + "pages": [ + "agents/overview", + "agents/status", + "agents/sessions", + "agents/orchestration" + ] + }, + { + "group": "Remote work", + "pages": [ + "remote/ssh", + "remote/sftp", + "remote/port-forwarding", + "remote/workspaces" + ] + }, + { + "group": "Git", + "pages": [ + "git/source-control", + "git/diffs", + "git/worktrees" + ] + }, + { + "group": "Customization", + "pages": [ + "customization/settings", + "customization/themes", + "customization/fonts", + "customization/keybindings" + ] + } + ] + }, + { + "tab": "CLI", + "groups": [ + { + "group": "The tty7 command", + "pages": [ + "cli/overview", + "cli/reference", + "cli/agent-skill" + ] + } + ] + }, + { + "tab": "Reference", + "groups": [ + { + "group": "Reference", + "pages": [ + "reference/configuration", + "reference/keyboard-shortcuts", + "reference/shell-integration", + "reference/updates", + "reference/privacy", + "reference/troubleshooting" + ] + } + ] + } + ] + }, + "navbar": { + "links": [ + { + "label": "GitHub", + "href": "https://github.com/l0ng-ai/tty7" + }, + { + "label": "Discord", + "href": "https://discord.gg/s3dethqz2V" + } + ], + "primary": { + "type": "button", + "label": "Download", + "href": "https://github.com/l0ng-ai/tty7/releases" + } + }, + "footer": { + "socials": { + "github": "https://github.com/l0ng-ai/tty7", + "discord": "https://discord.gg/s3dethqz2V" + } + } +} diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 00000000..f4cbfc6d Binary files /dev/null and b/docs/favicon.ico differ diff --git a/docs/features.md b/docs/features.md deleted file mode 100644 index 7310b805..00000000 --- a/docs/features.md +++ /dev/null @@ -1,158 +0,0 @@ -# Features - -English · [简体中文](features.zh-CN.md) - -## Input - -- **Ghost suggestions** — your history completes the whole line as you type; to accept -- **Explained tab completion** — every flag and subcommand with its description, for ~100 common commands; when tty7 has nothing to offer the Tab falls through to your shell's own completion, and the whole feature can be turned off (Settings → Input → Prompt, or `tab_completion` in `config.json`) -- **Syntax highlighting** — as you type, nothing to install -- **Fuzzy history search** — ⌃ R shows what you ran, where, and whether it failed; turn it off (Settings → Input → Prompt, or `history_search` in `config.json`) and ⌃ R goes to your shell instead, so an fzf / percol binding keeps working -- **History from day one** — your existing shell history works as-is and carries across sessions -- **Line editing** — click to place the caret, mouse selection, word motion, undo -- **Multi-line editing** — wrapped and multi-line commands edit in place; the grid shifts to keep the caret visible. ⇧ ⏎ · ⌥ ⏎ insert a newline instead of submitting (rebindable as `InsertNewline`); a plain submits the whole buffer - -## In the window - -- **Tabs & splits** — always open in the current directory -- **Rearrange splits by dragging** — hover a pane and a small grip appears along its top edge; drag it over the layout to put the pane somewhere else in the tab. Dropping on a pane's side goes in beside it — taking an equal share of the row or column it joins, or splitting that pane in half when the side faces across the layout rather than along it — dropping on its middle trades the two panes' places, and carrying it past a pane's outer side — the one facing the window rather than another pane — makes it a full-width or full-height band beside everything else, sized to an even share of what that side already holds — so a pane in the middle of a 2×2 becomes a full-height third column in one drag. The landing lights up while you drag, and only ever lights up when the drop would really change the layout -- **Repo-grouped sidebar** — the left tab sidebar groups rows under a header per git repository, non-repo tabs in a trailing *Scratch* section; branch switches and in-repo `cd`s never move a row (`sidebar_grouping` in `config.json`: `repo` default, `none` for a flat list) -- **Command palette** ⌘ P · scrollback search ⌘ F -- **⌘/Ctrl-click links** (⌘ on macOS, Ctrl on Windows/Linux) · desktop notifications · copy on select (opt-in, Settings → Input → Selection & clipboard) -- **Smart double-click selection** — double-click grabs the whole URL, file path, bracket/quote pair, or dictionary-segmented CJK word under the cursor; Shift-click extends a selection (toggle in Settings → Input → Selection & clipboard; word separators via `word_separators` in `config.json`) -- **Nine themes, plus your own** — YAML seed themes with solid, gradient, or image backgrounds; iTerm2 `.itermcolors` import; in-app color editor with a background-image picker -- **Sync with system** — Settings → Appearance; pick separate light and dark themes and tty7 follows the OS appearance live (`theme_follow_system`, `theme_preset_light` / `theme_preset_dark` in `config.json`) -- **Window opacity & blur** — Settings → Appearance → Transparency; applies to every theme, *Follow theme* returns to the theme's own `opacity` / `blur` -- **CJK / IME input** -- **Windows Explorer menu** — the installer offers *Add “Open in tty7” to the folder context menu* as a setup task, off by default, and the uninstaller always takes it back out. Writing shell verbs is an install-time decision, so there is no runtime setting; a portable-zip install can do it itself with `tty7-app.exe --register-explorer-menu` (or `--unregister-explorer-menu`). Either way the keys land under `HKCU`, so only your own Windows account is affected - -## Fonts - -- **Hack is bundled** — it ships inside the binary, so the default renders identically everywhere without relying on a system install -- **Primary + ordered fallbacks** — `font_family` and `font_fallbacks` in `config.json`; optional `font_family_bold` / `font_family_italic` for distinct faces, and `font_features` to pass OpenType features through (contextual ligatures stay off unless you ask for them) -- **Platform-aware defaults** — the fallback list names faces the host OS actually ships (PingFang SC / Apple Color Emoji on macOS, Microsoft YaHei / Segoe UI Emoji on Windows, Noto on Linux). Those stock names are appended to a hand-written list too, so a `config.json` written on another platform still resolves - -### CJK and the two-column grid - -A cell is one advance of the primary face, and a wide (CJK) character is pinned -to exactly two of them. A CJK fallback therefore sits flush in its slot only if -its ideographs advance **twice** the primary's Latin advance. - -Bundled Hack advances 0.60205em, so a two-column slot is 1.2041em — while every -stock CJK face (Microsoft YaHei, PingFang SC, Noto Sans CJK) advances 1.0em. -Those glyphs get left-aligned in the slot and the leftover ~0.2em lands as a gap -on the right of every character. - -[Maple Mono NF CN](https://github.com/subframe7536/maple-font) is tried first on -every platform for exactly this reason — 0.6em Latin, 1.2em CJK, an exact -two-cell fit against Hack. It is referenced by name only, never bundled (~20MB -per weight): install it and tty7 picks it up with no config change. - -For CJK set *tight* rather than merely even, change the primary face instead — -one that advances 0.5em (Sarasa Mono SC, say) makes two columns exactly 1.0em. - -## Coding agents - -tty7 recognizes third-party coding agents running in a pane (Claude Code, -Codex, Gemini CLI, Aider, Amp, OpenCode, and 12 more) and adds around them — -it never wraps or replaces the agent. - -- **Brand avatars** — the tab chip / sidebar row shows which agent runs where; custom wrappers map in via `agent_commands` in `config.json` -- **Status dot** — working (blue) / needs your input (amber) / done (green), driven by agent-reported events over an OSC channel; Settings → Agents installs the hooks that feed it (Claude Code, Codex, Copilot CLI, OpenCode, Pi, Grok Build, Oh My Pi) -- **Notifications** — "needs your permission…" the moment an agent blocks on you, and "finished after Ns" per turn, honoring your notification policy -- **Branch at a glance** — each sidebar row shows its pane's git branch and working-tree diff (`+N −M`), refreshed on `cd` and when a command finishes; clicking the counts opens the diff overlay, and turning that off (Settings → Window & Tabs, or `sidebar_diff_preview: false` in `config.json`) keeps the readout while making it non-clickable -- **Session resume** — panes lost to a reboot re-launch their agent conversation on restore, carrying the original launch flags (`claude --dangerously-skip-permissions --resume …`) (`restore_agent_sessions`, on by default) -- **Fork session** — branch a live agent conversation into a second, independent one by shelling the agent's own fork command (`codex fork `, `claude --resume --fork-session`, also OpenCode, Grok Build, and Oh My Pi); the original is untouched and both continue separately. Right-click a pane to pick a split placement, or right-click the tab / sidebar row to open the fork in a new tab. Needs the agent's hooks installed, since the fork targets the session id they report; a remote pane can't fork, because the command would run against the local agent — and note a fork copies the whole transcript, so repeated forking costs real disk in the agent's own session store -- **Copy Session ID** — put the agent's native session id on the clipboard, beside *Copy Working Directory*, for pasting into `codex resume`, a bug report, or another tool -- **Context feed** — palette commands send the current selection or the repo's `git diff` to the running agent as a ready-made prompt -- **Tray icon** — a system tray / menu bar item that flips to an attention state the moment any agent needs your input; its menu lists every agent pane (brand avatar + status dot, click to reveal), switches the notification policy, and offers *Quit and Stop Server…* alongside the plain session-keeping quit (`show_tray_icon`, on by default) -- **`tty7 wait`** — the CLI's orchestration primitive: block until a pane's agent needs input or finishes its turn (`tty7 wait %3 --until waiting,done --changed --timeout 600`, exit 124 on timeout), so one agent can sleep until its peer blocks on a permission prompt instead of screen-scraping — then `tty7 capture %3 --plain` to read the result. The agent status is a level, not an event, so `--changed` ignores the state the pane was already in when the wait began; without it, the JSON's `stale` flag says whether the answer might belong to the previous turn -- **`tty7` on PATH** — the CLI ships inside every installer and is put on PATH at launch, so a script or a coding agent can drive tty7 from any terminal. Inside a tty7 pane it works regardless, since panes inherit the app's environment. On Unix it is a symlink into whichever of `/opt/homebrew/bin`, `/usr/local/bin`, `~/.local/bin`, `~/bin`, `~/.cargo/bin` your PATH already covers; on Windows the install directory is appended to your user PATH, and the uninstaller takes it back out. A `tty7` you installed yourself is left alone, never replaced. Off via Settings → Agents or `install_cli_on_path: false` in `config.json` - -## SSH - -A native Rust SSH stack (russh) is the **only** path — profiles, credentials, -and SFTP without shelling out to `ssh`. There is no system-ssh compat mode. - -- **QuickConnect** — type `user@host[:port]` in the palette and connect; IPv6 `[::1]:port` supported -- **Saved profiles** — full connection config with passwords / passphrases in the OS keychain, never on disk -- **`~/.ssh/config` aliases** — type one to connect (resolved natively — common fields, best-effort — over russh), or import them as profiles in Settings -- **GUI auth** — in-pane sheets for password, key passphrase, 2FA, and host-key confirmation (new vs. changed) -- **Built-in SFTP** — a slide-in file panel: browse, upload / download, rename / delete / chmod, drag to Finder -- **Port forwarding** — Local / Remote / Dynamic, preconfigured or added live, plus ⌘/Ctrl-click `localhost:PORT` to auto-forward -- **Jump hosts & proxies** — multi-hop via profile references or `ProxyJump`, ProxyCommand, SOCKS5 / HTTP - -| Entry point | Connects via | -|---|---| -| Saved profiles · QuickConnect · typed `user@host[:port]` | Native russh — SFTP · keychain · GUI auth · L/R/D forwards | -| `~/.ssh/config` aliases | Resolved natively, then russh (`Match`/canonicalize/GSSAPI unsupported — no fallback) | - -## Keybindings - -Keys are shown in macOS notation — on Windows and Linux, read as -Ctrl. The essentials: - -| | | -|---|---| -| ⌘ T · ⌘ W · ⌘ ⇧ T | new tab · close tab · reopen closed tab | -| ⌘ 1⌘ 9 | jump to tab 1–9 | -| ⌃ ⇥ · ⌃ ⇧ ⇥ | hold to walk the switcher forwards · backwards; it commits when you let go | -| ⌘ D · ⌘ ⇧ D | split right · split down | -| ⌘ ] · ⌘ [ | next pane · previous pane | -| ⌘ ⌥ ←→↑↓ | focus the pane in that direction | -| ⌘ ⏎ · ⌘ ⇧ ⏎ | toggle fullscreen · zoom pane | -| ⌘ K | clear scrollback | -| ⌘ P | command palette | -| ⌘ F | search the scrollback | -| ⌃ R | fuzzy-search shell history | -| ⌘ + · ⌘ − · ⌘ 0 | font size up · down · reset | -| + wheel | zoom the font by scrolling over a terminal | - -**Settings → Keybindings** (⌘ ,) lists every shortcut. Click one, -press the new keys (Esc cancels, Backspace resets to -default), and it takes effect immediately. Pane resize and swap have no default -keys — bind them here or run them from the command palette. - -**tmux preset** — remaps pane/tab actions onto a prefix (default ⌃ B): -⌃ B C opens a tab, ⌃ B % splits, -⌃ B then an arrow moves focus. A bare prefix reaches the shell after -a brief pause; `prefix` + an unbound key passes straight through. - -## Performance notes - -- The PTY is read at device speed and parsed in large batches, off the render path -- Hot paths are lock-free — a big `cat` never waits on drawing -- The server buffers up to 16 MiB ahead of the window before backpressure applies - -## macOS privacy - -Panes are forked from the bundled executable, so macOS attributes a program's -request for a protected resource to tty7.app. tty7 declares the matching TCC -usage strings (camera, microphone, contacts, calendar, reminders, photos, -location, local network, Bluetooth, speech recognition, Apple Events, system -administration) so that program gets the normal one-time prompt instead of -being denied outright with no prompt at all. - -Not covered by usage strings: - -- **Full Disk Access** — Apple defines no usage-string key for it. Reaching - `~/Library/Mail`, `~/Library/Messages`, `~/Library/Safari` or - `~/Library/Containers` needs a manual grant in System Settings. - -Declaring a usage string is not the same as holding the permission: tty7.app -itself is granted none of these resources. Every prompt you see belongs to -whatever you ran in the pane, and you can revoke it under Privacy & Security. - -## Localization - -The GUI ships English, Simplified Chinese and Japanese strings. Pick one in -Settings → Appearance → Language, or in `config.json`: - -```json -{ "gui_language": "zh-CN" } -``` - -`en`, `zh-CN` and `ja-JP` are the only accepted values; anything else falls back -to `en`. -The choice is explicit — the system language is never inferred. CLI output stays -English so agent and script integrations keep a stable, predictable surface. diff --git a/docs/features.zh-CN.md b/docs/features.zh-CN.md deleted file mode 100644 index abeda9e7..00000000 --- a/docs/features.zh-CN.md +++ /dev/null @@ -1,150 +0,0 @@ -# 功能 - -[English](features.md) · 简体中文 - -## 输入 - -- **影子建议** —— 边打字边用你的历史补全整条命令, 接受 -- **带说明的 Tab 补全** —— 每个 flag、每个子命令都带说明,覆盖约 100 个常用命令;tty7 没有候选时 Tab 自动交给 shell 自己的补全,整个功能也可关闭(设置 → 输入 → 提示符,或 `config.json` 里的 `tab_completion`) -- **语法高亮** —— 边打边亮,什么都不用装 -- **模糊历史搜索** —— ⌃ R 看到每条命令在哪跑的、什么时候、有没有失败;关掉它(设置 → 输入 → 提示符,或 `config.json` 里的 `history_search`)后 ⌃ R 直接交给 shell,你绑的 fzf / percol 照常可用 -- **历史开箱即用** —— 你已有的 shell 历史直接生效,并跨会话延续 -- **行编辑** —— 点击定位光标、鼠标选区、词级移动、撤销 -- **多行编辑** —— 折行和多行命令原地编辑;网格自动上移,光标始终可见。⇧ ⏎ · ⌥ ⏎ 插入换行而不提交(可改绑,动作名 `InsertNewline`),单独按 提交整个缓冲区 - -## 窗口 - -- **标签页与分屏** —— 永远开在当前目录 -- **拖动重排分屏** —— 鼠标移到某个 pane 上,它顶边中间会浮出一个小抓手;拖着它在布局里走,就能把这个 pane 挪到标签页内的别处。落在某个 pane 的某一侧=插到它旁边:那一侧要是朝着同一排的邻居,就并入那一排、和它们等分;要是横着切过这一排(没有排可并),才是把那个 pane 一分为二、自己占住那一半。落在它正中=两个 pane 互换位置;继续推到某个 pane 朝着窗口那一侧的外缘(不是朝着另一个 pane 的那侧)=变成贴着窗口某一边、跨满整行或整列的一条,宽度按那条轴上已有的份数均分 —— 2×2 里的一个 pane 一次拖动就能变成通高的第三列(各占三分之一),而不是独占半屏。拖动过程中落点会高亮,且只有当这一放确实会改变布局时才会亮 -- **侧栏按仓库分组** —— 左侧标签栏按 git 仓库分组、每组一个标题行,不在仓库里的标签归入末尾的 *草稿* 组;切分支、仓库内 `cd` 都不会挪动行(`config.json` 的 `sidebar_grouping`:默认 `repo`,`none` 恢复扁平列表) -- **命令面板** ⌘ P · scrollback 搜索 ⌘ F -- **⌘ 点击打开链接** · 桌面通知 · 划选即复制(可选,设置 → 输入 → 选择与剪贴板) -- **智能双击选中** —— 双击直接选中整条 URL、文件路径、括号/引号对,中文按词典分词出词;Shift 点击扩展选区(设置 → 输入 → 选择与剪贴板可开关;分隔符用 `config.json` 的 `word_separators` 配置) -- **9 套主题,也能自定义** — YAML 种子主题,背景支持纯色、渐变或图片;可导入 iTerm2 `.itermcolors`;应用内颜色编辑器带背景图选择 -- **跟随系统外观** — 设置 → 外观;分别选好浅色和深色主题,tty7 随系统深浅模式实时切换(`config.json` 中的 `theme_follow_system`、`theme_preset_light` / `theme_preset_dark`) -- **窗口透明与模糊** — 设置 → 外观 → 透明度;对所有主题生效,*跟随主题* 恢复主题自带的 `opacity` / `blur` -- **CJK / 输入法输入** -- **Windows 资源管理器右键菜单** —— 安装程序提供 *Add “Open in tty7” to the folder context menu* 这个安装任务,默认不勾选,卸载时一律移除。写 shell verb 是安装期的决定,所以没有运行时开关;用 portable zip 的话可以自己执行 `tty7-app.exe --register-explorer-menu`(或 `--unregister-explorer-menu`)。两种方式写入的键都在 `HKCU` 下,只影响你自己的 Windows 账户 - -## 字体 - -- **内置 Hack** —— 打包进二进制,默认配置在各平台渲染完全一致,不依赖系统安装 -- **主字体 + 有序 fallback** —— `config.json` 里的 `font_family` 和 `font_fallbacks`;可选 `font_family_bold` / `font_family_italic` 指定独立字面,`font_features` 透传 OpenType 特性(上下文连字默认关闭) -- **默认列表按平台分支** —— fallback 只写宿主系统真正自带的字体(macOS 用 PingFang SC / Apple Color Emoji,Windows 用 Microsoft YaHei / Segoe UI Emoji,Linux 用 Noto)。这些名字也会追加到你手写的列表后面,所以在别的平台写出来的 `config.json` 一样能落地 - -### 中文与两列网格 - -一个格子等于主字体的一个 advance,宽字符(CJK)被钉死在正好两格上。所以中文 -fallback 只有在**汉字 advance 等于主字体西文 advance 的两倍**时,才能严丝合缝地 -填满自己的槽。 - -内置 Hack 的 advance 是 0.60205em,两格就是 1.2041em —— 而系统自带的中文字体 -(Microsoft YaHei、PingFang SC、Noto Sans CJK)全都是 1.0em。这些字形在槽里左 -对齐,多出来的约 0.2em 就变成每个字右边的一道空隙。 - -[Maple Mono NF CN](https://github.com/subframe7536/maple-font) 在所有平台都排在 -第一位正是因为这个 —— 西文 0.6em、中文 1.2em,对上 Hack 正好两格。它只按名字引 -用,不打包(每字重约 20MB):装上即生效,不用改配置。 - -想让中文排得**紧**而不只是均匀,要换的是主字体:选一个 advance 为 0.5em 的 -(比如 Sarasa Mono SC 更纱黑体等宽),两格就正好 1.0em。 - -## Coding agent - -tty7 能识别 pane 里跑着的第三方 coding agent(Claude Code、Codex、Gemini CLI、 -Aider、Amp、OpenCode 等共 18 个)并在其外围加功能 —— 绝不包裹或替代 agent 本身。 - -- **品牌头像** —— 标签 chip / 侧栏行显示每个 pane 跑的是哪个 agent;自定义包装命令可通过 `config.json` 的 `agent_commands` 映射 -- **状态点** —— 工作中(蓝)/ 等你输入(琥珀)/ 完成(绿),由 agent 自己上报的 OSC 事件驱动;在 设置 → Agents 一键装好对应 hooks(Claude Code、Codex、Copilot CLI、OpenCode、Pi、Grok Build、Oh My Pi) -- **通知** —— agent 卡在等你批准的那一刻弹 "needs your permission…",每轮结束弹 "finished after Ns",遵循你的通知策略 -- **一眼看分支** —— 侧栏每行显示该 pane 的 git 分支和工作区改动(`+N −M`),`cd` 或命令跑完时自动刷新;点改动数字会打开 diff 浮层,关掉它(设置 → 窗口与标签页,或 `config.json` 的 `sidebar_diff_preview: false`)分支和数字照常显示,只是不再可点 -- **会话恢复** —— 重启后无法重连的 pane 会自动续上 agent 对话,并带上原始启动 flags(`claude --dangerously-skip-permissions --resume …`;`restore_agent_sessions`,默认开启) -- **Fork 会话** —— 直接调 agent 自己的 fork 命令(`codex fork `、`claude --resume --fork-session`,OpenCode、Grok Build 和 Oh My Pi 同样支持),把当前对话分叉成一个独立会话;原会话原封不动,两边各自往下走。在 pane 上右键可选择分屏位置,在标签 / 侧栏行上右键则直接开新标签。需要先装好该 agent 的 hooks(fork 认的是 hooks 上报的 session id);远程 pane 不能 fork,因为命令会跑在本机的 agent 上;另外 fork 会整份复制对话历史,反复 fork 会在 agent 自己的会话目录里占掉不少磁盘 -- **复制会话 ID** —— 把 agent 的原生 session id 复制到剪贴板,就在 *复制工作目录* 旁边,方便粘进 `codex resume`、bug 报告或别的工具 -- **上下文回填** —— 面板命令把当前选区或仓库 `git diff` 打包成 prompt 直接喂给正在跑的 agent -- **托盘图标** —— 系统托盘 / 菜单栏常驻图标,任何 agent 等你输入时立即切换为提醒态;菜单列出所有 agent pane(品牌头像 + 状态点,点击直达)、可切换通知策略,并在保留会话的普通退出之外提供 *退出并停止服务器…*(`show_tray_icon`,默认开启) -- **`tty7 wait`** —— CLI 的编排原语:阻塞到某个 pane 的 agent 等待输入或完成一轮(`tty7 wait %3 --until waiting,done --changed --timeout 600`,超时退出码 124),让一个 agent 睡到同伴卡在权限确认的那一刻,而不是抓屏猜——然后 `tty7 capture %3 --plain` 收结果。agent 状态是电平不是边沿,所以 `--changed` 会忽略 wait 开始时 pane 本来就处在的那个状态;不加它的话,JSON 里的 `stale` 标记会告诉你这个答案是不是上一轮留下的 -- **`tty7` 上 PATH** —— CLI 随每个安装包一起发布,启动时自动放到 PATH 上,脚本和 coding agent 在任何终端里都能驱动 tty7。tty7 自己的 pane 里则一定可用,因为 pane 继承 app 的环境。Unix 上是往 `/opt/homebrew/bin`、`/usr/local/bin`、`~/.local/bin`、`~/bin`、`~/.cargo/bin` 中你 PATH 已经覆盖的那个目录里放一个软链;Windows 上是把安装目录追加到用户 PATH,卸载时再摘掉。你自己装的 `tty7` 一律保持原样,不会被覆盖。关掉:设置 → Agents,或 `config.json` 里 `install_cli_on_path: false` - -## SSH - -**唯一**路径就是原生 Rust SSH 栈(russh)—— profile、凭据、SFTP 全部内置, -不 shell 出 `ssh`,也没有系统 ssh 兼容模式。 - -- **QuickConnect** —— 面板里打 `user@host[:port]` 回车即连;支持 IPv6 `[::1]:port` -- **保存 profile** —— 完整连接配置,密码 / passphrase 进 OS keychain,不落盘 -- **`~/.ssh/config` alias** —— 直接输入 alias 即连(原生解析常用字段,尽力而为,走 russh),也可在设置页一键导入为 profile -- **GUI 认证** —— pane 内 sheet 输入密码、私钥 passphrase、2FA,并确认主机密钥(新主机 vs 已变更) -- **内置 SFTP** —— 滑入式文件面板:浏览、上传 / 下载、重命名 / 删除 / chmod,可拖进 Finder -- **端口转发** —— Local / Remote / Dynamic,预配置或运行时增删,外加 ⌘ 点击 `localhost:PORT` 一键转发 -- **跳板与代理** —— 经 profile 引用或 `ProxyJump` 多跳、ProxyCommand、SOCKS5 / HTTP - -| 入口 | 连接方式 | -|---|---| -| 保存 profile · QuickConnect · 输入 `user@host[:port]` | 原生 russh —— SFTP · keychain · GUI 认证 · L/R/D 转发 | -| `~/.ssh/config` alias | 原生解析后走 russh(`Match`/canonicalize/GSSAPI 不支持,且无回退) | - -## 快捷键 - -下表按 macOS 记法书写 —— 在 Windows 和 Linux 上,把 读作 -Ctrl。最常用的几个: - -| | | -|---|---| -| ⌘ T · ⌘ W · ⌘ ⇧ T | 新建标签页 · 关闭标签页 · 恢复关闭的标签页 | -| ⌘ 1⌘ 9 | 跳到第 1–9 个标签页 | -| ⌃ ⇥ · ⌃ ⇧ ⇥ | 按住不放在切换面板里向后 · 向前走,松手即切换 | -| ⌘ D · ⌘ ⇧ D | 向右分屏 · 向下分屏 | -| ⌘ ] · ⌘ [ | 下一个窗格 · 上一个窗格 | -| ⌘ ⌥ ←→↑↓ | 按方向切换焦点窗格 | -| ⌘ ⏎ · ⌘ ⇧ ⏎ | 切换全屏 · 缩放窗格 | -| ⌘ K | 清除 scrollback | -| ⌘ P | 命令面板 | -| ⌘ F | 搜索 scrollback | -| ⌃ R | 模糊搜索 shell 历史 | -| ⌘ + · ⌘ − · ⌘ 0 | 字号增大 · 减小 · 重置 | -| + 滚轮 | 在终端上滚动缩放字号,演示时随手放大 | - -**设置 → 按键绑定**(⌘ ,)列出全部快捷键。点一行、按下新键即可 -(Esc 取消,Backspace 恢复默认),改完立即生效。窗格缩放与 -交换默认不绑定键 —— 在这里绑定,或从命令面板执行。 - -**tmux 预设** —— 把窗格/标签页操作映射到前缀键(默认 ⌃ B): -⌃ B C 新建标签页,⌃ B % 分屏, -⌃ B 接方向键切换焦点。单独按前缀键会在短暂延迟后送达 shell, -`前缀` + 未绑定的键原样透传给终端。 - -## 性能说明 - -- 以设备速度读取 PTY,在渲染路径之外成批解析 -- 热路径全程无锁 —— 再大的 `cat` 也不会阻塞在渲染上 -- 触发背压前,服务器最多可领先窗口缓冲 16 MiB - -## macOS 隐私 - -窗格是从 app bundle 里的可执行文件 fork 出来的,所以程序申请受保护资源时, -macOS 会把这次请求算到 tty7.app 头上。tty7 声明了对应的 TCC usage strings -(摄像头、麦克风、通讯录、日历、提醒、照片、定位、本地网络、蓝牙、语音识别、 -Apple Events、系统管理),这样程序才能正常弹出一次性授权窗口,而不是连弹窗都 -没有就被直接拒绝。 - -不受 usage strings 覆盖的: - -- **完全磁盘访问** —— 苹果没有为它定义 usage-string 键。要读写 - `~/Library/Mail`、`~/Library/Messages`、`~/Library/Safari` 或 - `~/Library/Containers`,需要在「系统设置」中手动授权。 - -声明 usage string 不等于持有权限:tty7.app 自己一项都没有拿到。你看到的每个 -授权弹窗都属于你在窗格里运行的那个程序,也可以在「隐私与安全性」中撤销。 - -## 本地化 - -GUI 目前提供英文、简体中文和日文三套文案。在「设置 → 外观 → 语言」中选择,或直接改 -`config.json`: - -```json -{ "gui_language": "zh-CN" } -``` - -只接受 `en`、`zh-CN` 和 `ja-JP` 三个值,其它值一律回落到 `en`。语言必须显式指定,不会 -去猜系统语言。CLI 输出保持英文,保证 agent、脚本和开发者工作流的输出稳定可预测。 diff --git a/docs/getting-started/concepts.mdx b/docs/getting-started/concepts.mdx new file mode 100644 index 00000000..1f6bb2ec --- /dev/null +++ b/docs/getting-started/concepts.mdx @@ -0,0 +1,108 @@ +--- +title: "Core concepts" +description: "Workspaces, tabs, panes — and the background server that owns them all." +--- + +Four words explain most of tty7. Three of them you can see; the fourth is the +reason the other three survive a reboot. + +## Pane + +A **pane** is one terminal: one shell (or one program) attached to one PTY. It +is the only thing in tty7 that actually runs something. + +Panes have stable ids — `%42` — for their whole life. That id is what the +[CLI](/cli/overview) addresses, and what `$TTY7_PANE` holds inside the pane +itself. + +## Tab + +A **tab** is a layout of panes. One pane to start with; split it and the tab +holds two, arranged in rows and columns you can drag around. + +Tabs appear in the sidebar (or the top strip, if you move it there). A tab's +label is the best evidence tty7 has: a name you set, else the coding agent +running in it, else the last segment of its working directory. + +## Workspace + +A **workspace** is a named set of tabs — a project, usually. One window shows +one workspace at a time, and ⌘ ⇧ O opens the switcher to move between +them or open a second window on another one. + +Workspaces are how tty7 keeps ten repositories from becoming forty +indistinguishable tabs. They also travel: a workspace on a remote machine is +still a workspace, opened from the same switcher. + + + The tty7 workspace switcher + + +## The server + +Here is the part that matters. **The window does not own your shells — a +background server does.** + +Quitting tty7 closes the window and leaves that server running. Your build keeps +building, your agent keeps working, your SSH session stays up. Open tty7 again +and it reattaches to exactly what was there. + +This is also why: + +- **`tty7` works from any terminal.** The CLI talks to the same server. The GUI + does not have to be running at all. +- **A crash is not a catastrophe.** Panes come back showing what was on them: + a capped tail of each pane's output is kept on disk and handed to the pane + that reopens on its id. +- **Stopping is explicit.** *Quit and Stop Server…* in the tray menu is the only + ordinary way to end everything, and it warns you first. + + + Restarting the server ends every process in every pane on that machine — + shells, agents, and SSH sessions alike. Layouts are kept and come back with + fresh shells. Never do it on someone else's behalf without asking. + + +### What survives what + +| | Close a tab | Quit tty7 | Stop the server | Reboot | +|---|:--:|:--:|:--:|:--:| +| The shell keeps running | ✗ | ✓ | ✗ | ✗ | +| The layout comes back | ✗ | ✓ | ✓ | ✓ | +| What was on screen comes back | ✗ | ✓ | ✓ | ✓ 1 | +| A supported agent session resumes | ✗ | ✓ | ✓ | ✓ | + +1 A capped tail of each pane, restored once. See +[session restore](/reference/troubleshooting#panes-came-back-empty). + +## Machines + +Everything above exists per **machine**. Your laptop is one; a dev box you +connect to over SSH is another, with its own server, its own workspaces, and its +own panes. + +The switcher lists them together, and the CLI reaches them with `-m`: + +```bash +tty7 -m devbox ls +``` + +Remote panes run on the remote machine — the files, the repository, the git +data, and the process tree are all over there. +[Remote workspaces →](/remote/workspaces) + +## The three environment variables + +Every pane exports these, and anything you launch from one inherits them: + +| Variable | What it holds | +|---|---| +| `TTY7_PANE` | This pane's id — the default target of `tty7 split`, `send`, `capture`, `procs`. | +| `TTY7_WS` | This pane's workspace id. | +| `TTY7_CONFIG_DIR` | The config directory, which is how the CLI finds the right server. | + +`echo $TTY7_PANE` is the fastest way to tell whether you are inside tty7 at all. + + + Those ids are the whole interface. The CLI page starts there. + diff --git a/docs/getting-started/first-launch.mdx b/docs/getting-started/first-launch.mdx new file mode 100644 index 00000000..cb83ceb7 --- /dev/null +++ b/docs/getting-started/first-launch.mdx @@ -0,0 +1,126 @@ +--- +title: "First launch" +description: "The handful of settings worth changing before you start working." +--- + +Open tty7 and you get a window with one tab and one shell, and a tab sidebar +down the left. Everything below is optional — but these are the settings people +end up changing anyway, so they are worth five minutes now. + +Open Settings with ⌘ , (Ctrl , on Windows and Linux), or +from the command palette (⌘ P → *Settings*). + + + tty7 Settings + + +## 1. Pick a theme + +**Settings → Appearance → Theme.** Nine themes ship built in — Light, One Light, +Catppuccin Latte, Rosé Pine Dawn, Dark, Dracula, Harbor, One Dark Pro, and +Rosé Pine. The default is **Light**. + +Turn on **Sync with system** to pick a light theme and a dark theme separately; +tty7 then follows the OS appearance live. + +Transparency lives on the same page, under **Transparency** — opacity applies to +every theme, and *Follow theme* hands the decision back to the theme's own +setting. On Windows there is also a **Background material** picker (Mica, +Acrylic, and friends). + +[More about themes →](/customization/themes) + +## 2. Choose your shell + +**Settings → Terminal → Shell.** Leave **Program** empty to use the platform +default. Otherwise it takes an executable name on PATH or an absolute path +(`zsh`, `fish`, `pwsh`, `nu`, `/opt/homebrew/bin/bash`), plus space-separated +**Arguments** — `-l` for a login shell, say. + +**Start in** decides what a *fresh* shell opens in: tty7's launch directory +(the default), your home folder, or a fixed path. New tabs and splits keep +inheriting the active pane's directory either way. + +## 3. macOS only: decide what Option does + +**Settings → Input → Keyboard → Option (⌥) acts as Meta.** + +Off (the default), ⌥ B types `∫`, which is what macOS has always +done. On, it sends the escape chord shells expect, so ⌥ B moves back +a word and ⌥ ⌫ deletes one. Turn it on if you live in readline; +leave it off if you type accented characters. + +## 4. If you use coding agents, install the hooks + +**Settings → Agents.** tty7 detects 18 coding CLIs by process name on its own — +you get brand avatars and tab labels for free. The *status dots*, the "needs +your permission" notifications, and `tty7 wait` all need one more thing: a small +hook the agent calls to report what it is doing. + +Click **Install** next to Claude Code, Codex, Copilot CLI, OpenCode, Pi, Grok +Build, or Oh My Pi. It writes into that agent's own config directory and can be +removed from the same row. + +[More about agents →](/agents/status) + +## 5. Know what Quit does + +Plain **Quit** closes the window and leaves the background server running. +Your shells, builds, and agent turns keep going, and reopening tty7 reattaches +to them. + +To actually stop everything, use **Quit and Stop Server…** from the tray icon's +menu. It says so plainly before it does it: anything still running in your +shells is terminated, while your tabs and layout are kept and reopen with fresh +shells. + + + This is why there is no tmux in the picture. The persistence is not a feature + of your shell setup — it belongs to the server underneath. + [Core concepts →](/getting-started/concepts) + + +## 6. Tune the notifications + +**Settings → Window & Tabs → Notifications.** By default tty7 posts a desktop +notification when a foreground command that ran longer than 10 seconds +finishes — but only while the window is unfocused. Set **Notify on command +finish** to *Never* or *Always*, and move the threshold if 10 seconds is the +wrong number for your work. + +Agent notifications ("needs your permission…", "finished after 42s") follow the +same policy. + +## 7. Coming from tmux? + +**Settings → Keybindings → Preset → tmux** remaps pane and tab actions onto a +prefix, ⌃ B by default. ⌃ B C opens a tab, +⌃ B % splits, ⌃ B then an arrow moves focus. + +A bare prefix reaches the shell after about a second, and prefix plus an unbound +key passes straight through — so a tmux binding you did not remap still lands in +whatever is running. + +[More about keybindings →](/customization/keybindings) + +## Where things live + +| | macOS / Linux | Windows | +|---|---|---| +| Settings file | `~/.config/tty7/config.json` | `%APPDATA%\tty7\config.json` | +| Custom themes | `~/.config/tty7/themes/` | `%APPDATA%\tty7\themes\` | + +Everything in the Settings window writes to `config.json`, and you can edit it +by hand instead — see the [configuration reference](/reference/configuration). +Set `TTY7_CONFIG_DIR` to move the whole directory somewhere else. + +## Next + + + + Workspaces, tabs, panes, and the server that owns them. + + + Suggestions, completion, and history search — the part you touch most. + + diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx new file mode 100644 index 00000000..4f5f3e59 --- /dev/null +++ b/docs/getting-started/installation.mdx @@ -0,0 +1,151 @@ +--- +title: "Installation" +description: "Native builds for macOS, Windows, and Linux — plus building from source." +--- + +Every release publishes native builds on +[**GitHub Releases**](https://github.com/l0ng-ai/tty7/releases). There is no +runtime to install first: fonts are embedded in the binary, and the Linux +AppImage bundles its own X11/Wayland/font libraries. + + + + Download the DMG that matches your Mac and drag **tty7** into Applications. + + | Mac | File | + |---|---| + | Apple silicon (M1 and later) | `tty7--macos-arm64.dmg` | + | Intel | `tty7--macos-x86_64.dmg` | + + Builds are signed with a Developer ID certificate and notarized by Apple, so + Gatekeeper opens them without a right-click dance. + + + Builds are produced on macOS 14 and macOS 15. macOS 14 (Sonoma) or later + is the tested range. + + + + + Two shapes, both x86-64: + + | File | Use it when | + |---|---| + | `tty7--windows-x86_64-setup.exe` | You want a normal install with Start-menu entries and an uninstaller. | + | `tty7--windows-x86_64.zip` | You want it portable — unzip anywhere and run `tty7-app.exe`. | + + The installer offers one optional setup task, off by default: **Add "Open in + tty7" to the folder context menu**. It writes shell verbs under `HKCU`, so + only your own Windows account is affected, and the uninstaller always takes + them back out. + + A portable install can add or remove the same entries itself: + + ```powershell + tty7-app.exe --register-explorer-menu + tty7-app.exe --unregister-explorer-menu + ``` + + + The Windows package also carries a Linux `tty7-server` binary so a WSL + distro can be served locally instead of downloading one. See + [Remote workspaces](/remote/workspaces). + + + + + | File | Use it when | + |---|---| + | `tty7--linux-x86_64.AppImage` | Almost always. `chmod +x` and run — the X11, Wayland, xkb, and font libraries are bundled, so it works on Fedora, Arch, Debian and friends, not just Ubuntu. | + | `tty7--linux-x86_64.tar.gz` | You would rather unpack the plain binary and place it yourself. | + + ```bash + chmod +x tty7-*-linux-x86_64.AppImage + ./tty7-*-linux-x86_64.AppImage + ``` + + + +## The `tty7` command + +Every installer ships the `tty7` CLI beside the app, and the app puts it on your +PATH the first time it launches. That is what lets a script — or a coding agent +in some other terminal — open panes and read them back. + +- **On Unix** it is a symlink into whichever of `/opt/homebrew/bin`, + `/usr/local/bin`, `~/.local/bin`, `~/bin`, or `~/.cargo/bin` your PATH already + covers. +- **On Windows** the install directory is appended to your user PATH, and the + uninstaller removes it again. + +A `tty7` you installed yourself — a `cargo install` build, a package manager's +copy — is never replaced. To turn the whole thing off, uncheck **Settings → +Agents → Install the tty7 command on PATH**. + + + Inside a tty7 pane the CLI works regardless of PATH, because panes inherit the + app's environment. + + +## Updating + +tty7 checks for updates every six hours and can update itself: **Settings → +About → Check now**, then **Update and relaunch**. Releases are downloaded and +verified in the background so applying one is just a restart. + +Pick **Stable** or **Nightly** under **Settings → About → Update channel**. See +[Updates and channels](/reference/updates) for what each feed publishes and how +switching behaves. + +## Building from source + +You need a stable Rust toolchain. The build is a plain `cargo build`; the app +binary is `tty7-app`. + + + + ```bash + git clone https://github.com/l0ng-ai/tty7 + cd tty7 + cargo build --release + ``` + + + gpui resolves its X11/Wayland/font backends through `pkg-config` at build + time, so the development packages have to be present: + + ```bash + sudo apt-get install -y pkg-config cmake clang \ + libxkbcommon-dev libxkbcommon-x11-dev \ + libfontconfig1-dev libfreetype6-dev \ + libwayland-dev libx11-dev libxcb1-dev \ + libzstd-dev libssl-dev libkrb5-dev + cargo build --release + ``` + + + + + A source build does not update itself, and it will not replace an installed + copy's server. If you run both, see + [Troubleshooting](/reference/troubleshooting). + + +## Uninstalling + + + + Quit tty7 (use **Quit and Stop Server** from the tray menu so the background + server stops too), then drag the app to the Trash. Your settings live in + `~/.config/tty7` and are left alone; delete that folder to remove them. + + + Use **Add or remove programs**. The uninstaller removes the PATH entry and + any Explorer context-menu keys it added. Settings live in + `%APPDATA%\tty7`. + + + Delete the AppImage or the unpacked directory. Settings live in + `~/.config/tty7`. + + diff --git a/docs/git/diffs.mdx b/docs/git/diffs.mdx new file mode 100644 index 00000000..836d825a --- /dev/null +++ b/docs/git/diffs.mdx @@ -0,0 +1,48 @@ +--- +title: "Diffs" +description: "The diff overlay: side-by-side or unified, from the sidebar or the panel." +--- + +## Opening one + +| From | How | +|---|---| +| The sidebar | Click a row's `+N −M` counts | +| Source Control | **Open Changes** on a file, or click the row | +| History | Click a file inside a commit's detail view | + +The overlay covers the window; Esc closes it. + + + The tty7 diff overlay + + +## Side-by-side or unified + +**Toggle Unified / Side-by-Side Diff** in the command palette switches between +the two. The choice is global — one setting for every diff, the same call VS +Code's `diffEditor.renderSideBySide` makes — and persists as `diff_view` in +`config.json`. + +## What it shows + +- Every changed file, with its status and `+N −M` +- **Untracked files** as a preview of their contents, up to 4 MB — past that the + card says the read failed rather than showing a silently cut-off file +- A commit's files, when the diff came from the history + +Two limits keep a huge diff from becoming a huge wait: + +| Limit | Value | What happens | +|---|---|---| +| Files rendered | 300 | *"… and N more changed files — run git diff in the terminal to see them."* | +| Lines before auto-collapse | 400 per file | Big files start collapsed; expand the ones you care about | + +Both are stated in the overlay when they apply — nothing is dropped silently. + +## Turning the sidebar shortcut off + +If you would rather the sidebar's counts not be clickable, turn off **Settings → +Window & Tabs → Open diff preview from sidebar counts** +(`sidebar_diff_preview: false`). The branch and counts stay on the row; they +just stop opening the overlay. diff --git a/docs/git/source-control.mdx b/docs/git/source-control.mdx new file mode 100644 index 00000000..b0c0b4ef --- /dev/null +++ b/docs/git/source-control.mdx @@ -0,0 +1,101 @@ +--- +title: "Source control" +description: "Stage, commit, branch, and push from the panel beside your terminal." +--- + +The **Source Control** tab of the [side panel](/window/side-panel) (⌘ J) +is a full git client for whichever repository the focused pane is in. It follows +the pane: `cd` into another repository and the panel switches with you. + + + The tty7 source control panel + + +## Changes + +Files are grouped by what git thinks of them: + +| Group | | +|---|---| +| **Merge Changes** | Conflicts, with *Resolve Conflict* and *Mark as Resolved* | +| **Staged Changes** | What the next commit will contain | +| **Changes** | Modified but not staged | +| **Untracked** | New files | + +Each row has **Stage Changes**, **Unstage Changes**, **Discard Changes**, and +**Open Changes** — which opens the [diff](/git/diffs). Group-level *Stage All*, +*Unstage All*, and *Discard All* sit on the headers, and the destructive ones +confirm first. + +## Committing + +Write the message in the box at the top and pick a commit action: + +| | | +|---|---| +| **Commit** | Commit what is staged | +| **Commit All** | Stage everything, then commit | +| **Commit (Amend)** | Replace the last commit — confirms first, because anyone who already has it has to reconcile | +| **Commit & Push** | Commit, then push | +| **Commit & Sync** | Commit, then pull and push | + +⌘ ⏎ commits while the caret is in the message box. **Stash All** is +there too. + +## Branches and remotes + +The branch name at the top of the panel is a dropdown. It holds: + +| | | +|---|---| +| **The branch list** | Click one to check it out. Past a dozen branches the list scrolls instead of growing past the window | +| **Create Branch…** | From here, or from any commit in the history | +| **Fetch** · **Pull** · **Push** | Individually | +| **Switch Repository** | Only when the window has panes in more than one repo | + +Beside it, the sync button pulls then pushes — and relabels itself **Publish +Branch** when the branch has no upstream yet. + +The command palette carries the verbs under **Git** — *Git: Commit*, *Stage +All*, *Unstage All*, *Discard All*, *Create Branch*, *Sync*, *Push*, *Pull*, +*Fetch* — so those are bindable. Checking out is a pick rather than a verb, so +it lives only in the dropdown. + + + Checking out does not stash for you. A dirty tree that would be clobbered + makes git refuse the checkout, and tty7 shows you git's own refusal as a + notification rather than working around it. + + +When a repository is mid-operation — merging, rebasing, cherry-picking, +reverting, bisecting, applying — the panel says so instead of pretending +everything is normal. + +## History + +The *History* section header opens the commit graph: branches drawn as lanes, a +filter box, **Current Branch** or **All Branches**, and *Load more* at the +bottom. + +**Git: Toggle Commit History** does the same from the keyboard. It ships with no +default key — bind one under **Settings → Keyboard Shortcuts**. + +Click a commit for its detail view — message, parents, and the files it touched, +each openable as a diff. From a commit's menu: + +| | | +|---|---| +| **Checkout Commit** · **Create Branch Here…** | Move to it | +| **Cherry Pick** · **Revert Commit** | Apply or undo it here | +| **Reset (Soft / Mixed / Hard)** | Move the branch to it — Hard confirms, since commits after it fall off the branch and uncommitted changes are discarded | +| **Copy Commit SHA** | | + +The history section starts collapsed and remembers whether you opened it +(`scm_graph_expanded`). + +## In the sidebar + +You do not have to open the panel to know where you stand: every +[sidebar row](/window/sidebar) carries its pane's branch and a `+N −M` count of +the working tree, refreshed on `cd` and when a command finishes. Clicking the +counts opens the diff overlay. diff --git a/docs/git/worktrees.mdx b/docs/git/worktrees.mdx new file mode 100644 index 00000000..74d33edb --- /dev/null +++ b/docs/git/worktrees.mdx @@ -0,0 +1,56 @@ +--- +title: "Worktrees" +description: "An isolated checkout on a fresh branch, in one dialog and one tab." +--- + +Running two agents on the same repository at once means they fight over the +working tree. A git worktree is the fix, and tty7 makes it a single dialog. + +## Creating one + +**New Worktree Tab…** — in the command palette, the tab's right-click menu, and +the application menu — asks three things: + +| Field | Default | +|---|---| +| **Worktree Name** | A fresh name that does not collide with an existing branch or directory | +| **New Branch** | The same name, editable | +| **Start From** | The branch you are currently on | + +Each field opens on a suggestion you can accept or type straight over. + + + Creating a worktree + + +Confirm and tty7 creates the worktree, opens a tab in it, and starts a shell +there. The [sidebar](/window/sidebar) files it under the same repository group as +its parent, on its own branch. + +## Where they go + +Worktrees land inside the repository, under: + +``` +/.tty7/worktrees/ +``` + +`/.tty7/.gitignore` is created with `*` in it the first time, so the +directory never shows up as an untracked mess in your own repository. + +## Removing one + +Closing a worktree tab offers to remove the worktree with it: + +- **Clean tree** — *Remove Worktree* or *Keep*. +- **Dirty tree** — the dialog says so, and removing requires the explicit + *Discard Changes & Remove*. + +Nothing is removed silently, and *Keep* leaves the worktree on disk for `git +worktree list` to find later. + + + Pair this with [agent sessions](/agents/sessions): a worktree per agent means + two Claude Codes can work on the same repository without stepping on each + other's files. + diff --git a/docs/images/hero.webp b/docs/images/hero.webp new file mode 100644 index 00000000..416a5a41 Binary files /dev/null and b/docs/images/hero.webp differ diff --git a/docs/images/placeholder.svg b/docs/images/placeholder.svg new file mode 100644 index 00000000..38ee5e64 --- /dev/null +++ b/docs/images/placeholder.svg @@ -0,0 +1,12 @@ + + + + + + + + + + screenshot coming soon + tty7 docs + diff --git a/docs/index.mdx b/docs/index.mdx new file mode 100644 index 00000000..d70cc602 --- /dev/null +++ b/docs/index.mdx @@ -0,0 +1,105 @@ +--- +title: "tty7" +sidebarTitle: "Introduction" +description: "A terminal workbench: persistent sessions, remote work, agents." +mode: "wide" +--- + + + tty7 showing a sidebar of agent sessions across several repositories + + +tty7 is a terminal you can leave running. Close the window, reboot the machine, +walk to a different laptop — the shells you started are still there, and so are +the coding agents you left working in them. + +It is written in Rust, renders on the GPU through Zed's +[gpui](https://github.com/zed-industries/zed), and parses VT with Alacritty's +terminal core. In practice that means roughly twice the throughput of Alacritty, +Ghostty, or Kitty on a big `cat`, and a frame rate that does not fall over when +something floods the screen. + +## What makes it different + + + + A background server owns your shells, not the window. Quit tty7 and your + builds keep building. No tmux to learn or configure. + + + Ghost suggestions from your history, tab completion that explains each flag, + syntax highlighting, click-to-place-caret, real multi-line editing. + + + 18 coding CLIs are recognised on sight. Per-pane status dots, notifications + when one needs you, git context, and session resume after a reboot. + + + A native Rust SSH stack with profiles, SFTP, and port forwarding — plus + remote workspaces where files, repos, and panes all stay on the far machine. + + + Branch and diff counts on every sidebar row, a source control panel, a diff + overlay, and worktrees in one dialog. + + + A bundled `tty7` CLI that opens panes, sends keys, reads screens, and blocks + until an agent needs you — so scripts and agents can drive the workbench. + + + +## Start here + + + + Native builds for macOS, Windows, and Linux. + [Installation →](/getting-started/installation) + + + Five minutes of settings that pay for themselves. + [First launch →](/getting-started/first-launch) + + + Workspace, tab, pane — and the server underneath them. + [Core concepts →](/getting-started/concepts) + + + +## How fast, exactly + +Same machine, same day, same 155×40 grid — Apple M1 Pro, macOS 26.3.1, +five-run averages. + +| | **tty7** | Alacritty | Ghostty | Kitty | +|---|---:|---:|---:|---:| +| Plaintext I/O — 11 MB `cat` (lower is better) | **95 ms** | 239 ms | 179 ms | 185 ms | +| [DOOM-fire](https://github.com/const-void/DOOM-fire-zig) frame rate (higher is better) | **888 fps** | 485 fps | 552 fps | 617 fps | +| Cold-launch memory | 116 MB 1 | 105 MB | 128 MB | 130 MB | + +1 GUI 105 MB plus the persistent server at 11 MB. + +The methodology and a one-command reproduction live in +[`scripts/bench/`](https://github.com/l0ng-ai/tty7/tree/main/scripts/bench). + +Three decisions account for most of it: + +- **The PTY is read at device speed** and parsed in large batches, off the + render path — so drawing never throttles reading. +- **The hot paths are lock-free.** A big `cat` never waits on the renderer. +- **The server buffers up to 16 MiB** ahead of the window before backpressure + applies, which is enough that a flood finishes writing while the window is + still catching up. + +## Getting help + + + + Ask a question, show what you built. + + + Bugs and feature requests. + + + Everything that shipped, release by release. + + diff --git a/docs/logo/logo.svg b/docs/logo/logo.svg new file mode 100644 index 00000000..0ec488f2 --- /dev/null +++ b/docs/logo/logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/docs/reference/configuration.mdx b/docs/reference/configuration.mdx new file mode 100644 index 00000000..81851282 --- /dev/null +++ b/docs/reference/configuration.mdx @@ -0,0 +1,183 @@ +--- +title: "config.json" +description: "Every key tty7 reads, its type, and its default." +--- + +| | | +|---|---| +| macOS / Linux | `~/.config/tty7/config.json` | +| Windows | `%APPDATA%\tty7\config.json` | +| Override the whole directory | `TTY7_CONFIG_DIR` | + +Every key is optional — a missing one means its default, so you only write what +you change. Out-of-range numbers are clamped rather than rejected, and an +unrecognised enum value falls back to the default with a log line instead of +failing the file. + +```json +{ + "font_family": "JetBrains Mono", + "font_size": 14, + "theme_follow_system": true, + "theme_preset_light": "one_light", + "theme_preset_dark": "dracula", + "macos_option_as_alt": true, + "scrollback_limit": 50000 +} +``` + +## Typography + +| Key | Type | Default | | +|---|---|---|---| +| `font_family` | string | `"Hack"` | Primary face. Hack is bundled. | +| `font_fallbacks` | string[] | platform list | Ordered fallbacks. Stock platform faces are appended to whatever you write. | +| `font_family_bold` | string | — | A distinct bold face. | +| `font_family_italic` | string | — | A distinct italic face. | +| `font_features` | object | — | OpenType tags, e.g. `{"calt": true, "liga": 1}`. Four alphanumeric characters per tag. | +| `font_size` | number | `15` | Terminal text size in px (4–256). | +| `line_height` | number | `1.4` | Multiple of the font size (0.5–4). | +| `ui_font_size` | number | `16` | The interface's root size in px (12–24). | + +[More about fonts →](/customization/fonts) + +## Theme and window + +| Key | Type | Default | | +|---|---|---|---| +| `theme_preset` | string | `"light"` | Active theme id. | +| `theme_follow_system` | bool | `false` | Follow the OS appearance. | +| `theme_preset_light` | string | `"light"` | Used when following the system. | +| `theme_preset_dark` | string | `"dark"` | Used when following the system. | +| `theme_legible_palette` | bool | `true` | Brighten or darken bright ANSI colours that would be unreadable on the background. | +| `window_opacity` | number | — | 0.2–1.0. Unset means "follow the theme". | +| `window_blur` | bool | — | Blur behind a translucent window (macOS). Unset means "follow the theme". | +| `window_backdrop` | enum | `"auto"` | Windows only: `auto`, `blur`, `mica`, `mica-alt`, `acrylic`, `off`. | +| `dim_inactive_panes` | bool | `true` | Dim panes that are not focused. | +| `startup_mode` | enum | `"normal"` | `normal`, `maximized`, `fullscreen`. | +| `remember_window_size` | bool | `true` | Reopen at the last size and position. | +| `restore_session` | bool | `true` | Reopen the last window's tabs, splits, and directories. | +| `gui_language` | enum | `"en"` | `en`, `zh-CN`, `ja-JP`. Anything else falls back to `en`. | + +Built-in theme ids: `light`, `one_light`, `catppuccin_latte`, `rose_pine_dawn`, +`dark`, `dracula`, `harbor`, `one_dark_pro`, `rose_pine`. Your own themes take +their id from the file name. [More about themes →](/customization/themes) + +## Tabs, sidebar, panels + +| Key | Type | Default | | +|---|---|---|---| +| `tab_bar_position` | enum | `"left"` | `left` (sidebar) or `top` (strip). | +| `new_tab_position` | enum | `"after-current"` | Or `end`. | +| `sidebar_grouping` | enum | `"repo"` | Or `none` for a flat list. | +| `sidebar_diff_preview` | bool | `true` | Clicking a row's `+N −M` opens the diff overlay. | +| `sidebar_width` | number | `220` | Pixels (100–2000). | +| `sidebar_collapsed` | bool | `false` | | +| `right_panel_visible` | bool | `false` | | +| `right_panel_width` | number | `260` | Pixels (100–2000). | +| `right_panel_tab` | enum | `"info"` | `info`, `changes`, `files`. | +| `diff_view` | enum | `"split"` | Or `unified`. Global, not per file. | +| `scm_graph_expanded` | bool | `false` | Whether the history section starts open. | +| `show_tray_icon` | bool | `true` | The tray / menu bar status item. | + +## Terminal + +| Key | Type | Default | | +|---|---|---|---| +| `shell` | object | — | `{"program": "fish", "args": ["-l"]}`. Unset uses the platform default. | +| `working_directory` | object | `{"strategy":"inherit"}` | `strategy` is `inherit`, `home`, or `custom`; `path` is used when custom. | +| `env` | object | `{}` | Extra environment variables for every pane. | +| `scrollback_limit` | number | `10000` | Lines per pane (100–100,000). New panes only. | +| `cursor_style` | enum | `"block"` | `block`, `bar`, `underline`. | +| `cursor_blink` | bool | `true` | | +| `bell` | enum | `"visual"` | `none`, `visual`, `audible`, `both`. | +| `per_pane_history` | bool | `false` | Give each pane its own shell history file. | + +## Mouse and scrolling + +| Key | Type | Default | | +|---|---|---|---| +| `mouse_scroll_multiplier` | number | `1.0` | 0.1–10. | +| `smooth_scroll` | bool | `true` | Ease each wheel notch. Trackpads unaffected. | +| `mouse_reporting` | bool | `true` | Let full-screen apps handle clicks and scrolling. | +| `mouse_hide_while_typing` | bool | `true` | | +| `focus_follows_mouse` | bool | `false` | | + +## Input and clipboard + +| Key | Type | Default | | +|---|---|---|---| +| `tab_completion` | bool | `true` | tty7's completion menu on . Off hands the key to the shell. | +| `history_search` | bool | `true` | tty7's fuzzy history on ⌃ R. Off hands the key to the shell. | +| `smart_select` | bool | `true` | Double-click grabs URLs, paths, bracket pairs, CJK words. | +| `word_separators` | string | see below | Characters that end a word. Used when smart selection is off. | +| `copy_on_select` | bool | `false` | | +| `clipboard_trim_trailing_spaces` | bool | `false` | | +| `macos_option_as_alt` | bool | `false` | +key sends the escape chord instead of typing a special character. | +| `keybindings` | object | `{}` | `{"SplitRight": "cmd-d"}`. [Syntax →](/customization/keybindings) | +| `keybinding_preset` | string | `"default"` | Or `"tmux"`. | +| `prefix` | string | `"ctrl-b"` | The tmux preset's prefix. | + +The default `word_separators` are a comma, a box-drawing bar, a backtick, a +pipe, a colon, both quote characters, a space, the six bracket characters, the +angle brackets, and a tab: + +```json +{ "word_separators": ",│`|:\"' ()[]{}<>\t" } +``` + +## Links + +| Key | Type | Default | | +|---|---|---|---| +| `link_url` | bool | `true` | Underline and open URLs on ⌘/Ctrl-click. | +| `link_file_command` | string | — | Command for file links. `{path}`, `{line}`, `{column}` are substituted; a flag whose value is missing is dropped. | +| `ssh_loopback_forward` | bool | `false` | Open `localhost:PORT` links through a temporary forward when the pane is in SSH. | + +## Notifications + +| Key | Type | Default | | +|---|---|---|---| +| `notify_on_command_finish` | enum | `"unfocused"` | `never`, `unfocused`, `always`. | +| `notify_threshold_secs` | number | `10` | How long a command must run to qualify (1–3600). | + +## Agents + +| Key | Type | Default | | +|---|---|---|---| +| `agent_commands` | object | `{}` | Map a wrapper command to an agent slug: `{"cc": "claude"}`. | +| `restore_agent_sessions` | bool | `true` | Relaunch an agent conversation when a lost pane is restored. | +| `install_cli_on_path` | bool | `true` | Put the bundled `tty7` command on PATH at launch. | + +[Agent slugs →](/agents/overview#your-own-wrapper) + +## SSH + +| Key | Type | Default | | +|---|---|---|---| +| `ssh_profiles` | array | `[]` | Managed from **Settings → SSH**. Secrets live in the OS keychain, never here. | +| `verify_host_keys` | bool | `true` | | +| `ssh_warn_on_close` | bool | `false` | Confirm before closing a live connection. | + +## Updates and network + +| Key | Type | Default | | +|---|---|---|---| +| `check_for_updates` | bool | `true` | | +| `update_channel` | enum | `"stable"` | Or `nightly`. | +| `auto_download_updates` | bool | `true` | Fetch and verify in the background so installing is a restart. Packages are ~25–30 MB and a check happens every six hours. | +| `http_proxy` | string | — | For tty7's *own* traffic only — update checks, downloads, remote-server installs. `http://…` or `socks5://…`. Programs in a pane are unaffected. | + +[Updates →](/reference/updates) + +## Keys tty7 manages itself + +`ssh_profile_frecency` and `command_frecency` record how often and how recently +you use a profile or command, so the pickers can rank them. They are written by +the app; there is no reason to edit them. + + + If the file cannot be parsed, tty7 starts on defaults, keeps your original at + `config.json.corrupt`, and logs the reason. It never silently overwrites what + you wrote. + diff --git a/docs/reference/keyboard-shortcuts.mdx b/docs/reference/keyboard-shortcuts.mdx new file mode 100644 index 00000000..495e2445 --- /dev/null +++ b/docs/reference/keyboard-shortcuts.mdx @@ -0,0 +1,103 @@ +--- +title: "Keyboard shortcuts" +description: "Every default binding, plus the action names for rebinding." +--- + +**Settings → Keybindings** (⌘ ,) is the live version of this page — +it shows what *your* copy is bound to. This is the shipped default. + +## Tabs and workspaces + +| Action | macOS | Windows / Linux | +|---|---|---| +| New Tab | ⌘ T | Ctrl ⇧ T | +| Close Pane / Tab | ⌘ W | Ctrl ⇧ W | +| Reopen Closed Tab | ⌘ ⇧ T | Alt ⇧ T | +| Next Tab · Previous Tab | ⌃ ⇥ · ⌃ ⇧ ⇥ | same | +| Go to Tab 1–9 | ⌘ 1⌘ 9 | Alt 1Alt 9 | +| New Workspace | ⌘ ⇧ N | Ctrl ⇧ N | +| Switch Workspace | ⌘ ⇧ O | Ctrl ⇧ O | + +## Panes + +| Action | macOS | Windows / Linux | +|---|---|---| +| Split Right | ⌘ D | Ctrl ⇧ D | +| Split Down | ⌘ ⇧ D | Ctrl Alt ⇧ D | +| Next Pane · Previous Pane | ⌘ ] · ⌘ [ | Ctrl ⇧ ] · Ctrl ⇧ [ | +| Focus Pane Left / Right / Up / Down | ⌘ ⌥ ←→↑↓ | Alt ←→↑↓ | +| Zoom Pane | ⌘ ⇧ ⏎ | Ctrl ⇧ ⏎ | +| Enter Full Screen | ⌘ ⏎ | F11 | + +## View + +| Action | macOS | Windows / Linux | +|---|---|---| +| Command Palette | ⌘ P | Ctrl ⇧ P | +| Toggle Left Sidebar | ⌘ B | Ctrl ⇧ B | +| Toggle Right Panel | ⌘ J | Ctrl ⇧ J | +| Toggle Code Panel | ⌘ ⇧ E | Ctrl ⇧ E | +| Font Size Up · Down · Reset | ⌘ + · ⌘ − · ⌘ 0 | Ctrl + · Ctrl − · Ctrl 0 | +| Zoom the font | + wheel | Ctrl + wheel | + +## Terminal + +| Action | macOS | Windows / Linux | +|---|---|---| +| Find in Terminal | ⌘ F | Ctrl ⇧ F | +| Find Next · Previous | ⌘ G · ⌘ ⇧ G | F3 · ⇧ F3 | +| Clear Scrollback | ⌘ K | Ctrl ⇧ K | +| Copy · Paste | ⌘ C · ⌘ V | Ctrl ⇧ C · Ctrl ⇧ V · ⇧ Insert | +| Insert Newline (at the prompt) | ⇧ ⏎ · ⌥ ⏎ | same | +| Fuzzy history search | ⌃ R | same | +| Accept ghost suggestion | | same | +| Completion menu | | same | + +## Git and SSH + +| Action | macOS | Windows / Linux | +|---|---|---| +| Commit (caret in the message box) | ⌘ ⏎ | Ctrl ⏎ | +| Save (in the editor) | ⌘ S | Ctrl S | +| Restart SSH Session | ⌘ ⇧ R | Ctrl ⇧ R | + +## Application + +| Action | macOS | Windows / Linux | +|---|---|---| +| Settings | ⌘ , | Ctrl , | +| Keyboard Shortcuts | ⌘ / | — | +| Hide tty7 · Hide Others · Minimize | ⌘ H · ⌘ ⌥ H · ⌘ M | — | +| Quit | ⌘ Q | Ctrl ⇧ Q | + +## Actions with no default key + +All of these are in the command palette, and all are bindable under **Settings → +Keybindings**: + +| Group | Actions | +|---|---| +| Tabs | `RenameTab` · `NewWorktreeTab` · `CloseOtherTabs` · `CloseTabsToTheRight` · `CopyWorkingDirectory` · `MarkTabUnread` · `ToggleTabSidebar` | +| Panes | `ResizePaneLeft/Right/Up/Down` · `SwapPaneNext` · `SwapPanePrev` | +| Workspaces | `SelectWorkspace1`…`SelectWorkspace9` · `RenameWorkspace` · `StopWorkspace` · `DeleteWorkspace` | +| Agents | `ForkAgentSession` (+ `Right` / `Left` / `Down` / `Up`) · `CopyAgentSessionId` | +| Git | `ScmStageAll` · `ScmUnstageAll` · `ScmDiscardAll` · `ScmCommitAmend` · `ScmRefresh` · `ScmSync` · `ScmPush` · `ScmPull` · `ScmFetch` · `ScmCheckoutBranch` · `ScmCreateBranch` · `ScmToggleGraph` · `ToggleDiffViewMode` | +| Panels | `ShowRightPanelInfo` · `ShowRightPanelChanges` · `ShowRightPanelFiles` | +| SSH | `ToggleSftp` · `ShowSshForwards` · `OpenSshProfiles` | +| Application | `About` · `CheckForUpdates` · `OpenDocumentation` · `OpenDiscord` · `ReportIssue` · `ShowAll` · `ZoomWindow` | + +## Rebinding syntax + +```json +{ + "keybindings": { + "ResizePaneLeft": "ctrl-alt-left", + "ToggleSftp": "secondary-shift-u", + "ScmSync": "ctrl-b s" + } +} +``` + +`secondary` means on macOS and Ctrl elsewhere. A space +separates the steps of a chord. +[More →](/customization/keybindings) diff --git a/docs/reference/privacy.mdx b/docs/reference/privacy.mdx new file mode 100644 index 00000000..9b405502 --- /dev/null +++ b/docs/reference/privacy.mdx @@ -0,0 +1,60 @@ +--- +title: "Privacy and permissions" +description: "What macOS asks you, why, and what tty7 itself holds." +--- + +## Why macOS asks tty7 for permission + +Panes are forked from tty7's own bundled executable, so when a program you run +asks macOS for a protected resource, macOS attributes the request to **tty7.app** +— not to the program. + +If tty7 declared no usage strings, that request would be **denied outright with +no prompt at all**, and the program would look broken for no visible reason. + +So tty7 declares the matching usage strings, and you get the normal one-time +prompt: + + + + Camera · microphone · Bluetooth · location · motion + + + Contacts · calendars · reminders · photo library + + + Local network · Apple Events · speech recognition · system administration + + + + + **Declaring a usage string is not the same as holding the permission.** + tty7.app itself is granted none of these. Every prompt you see belongs to + whatever you ran in the pane, and you can revoke it under **System Settings → + Privacy & Security**. + + +### Full Disk Access + +Apple defines no usage-string key for it. Reaching `~/Library/Mail`, +`~/Library/Messages`, `~/Library/Safari`, or `~/Library/Containers` needs a +manual grant in **System Settings → Privacy & Security → Full Disk Access**. + +## What leaves your machine + +| | | +|---|---| +| **Update checks** | A request to the GitHub releases API every six hours, plus the download when you accept one. Turn it off with `check_for_updates: false`. | +| **Remote server installs** | Downloading a `tty7-server` binary for a machine you connected to — or, for WSL, copying the one already bundled with your install. | +| **Everything else** | Nothing. There is no telemetry, no analytics, and no account. | + +Both of the above honour `http_proxy`. [Updates →](/reference/updates#proxies) + +## What is stored, and where + +| | | +|---|---| +| Settings, themes, window state | `~/.config/tty7/` (`%APPDATA%\tty7\` on Windows) | +| SSH passwords and key passphrases | The **OS keychain** — never `config.json`, never plain text on disk | +| Pane scrollback tails | `/scrollback/*.bin`, mode `0600` on Unix and behind the config directory's ACL on Windows. 256 KiB per pane, kept only until something can no longer ask for it: closing a pane deletes its file at once, a restore consumes it, and a periodic pass collects the rest. | +| Shell history | Your shell's own file, exactly as before — unless you turned on per-pane history, which merges back into it. | diff --git a/docs/reference/shell-integration.mdx b/docs/reference/shell-integration.mdx new file mode 100644 index 00000000..3f77bfbf --- /dev/null +++ b/docs/reference/shell-integration.mdx @@ -0,0 +1,73 @@ +--- +title: "Shell integration" +description: "What tty7 injects into your shell, and what it buys you." +--- + +A terminal that only sees bytes cannot tell a prompt from output, or a finished +command from a hung one. tty7's shell integration closes that gap: the shell +reports where prompts begin, what was submitted, what it exited with, and where +it is. + +**You do not install it.** It is injected when the pane's shell starts, and +removes itself from the equation if you run the same shell elsewhere. + +## Which shells + +| Shell | How it is injected | +|---|---| +| **zsh** | A throwaway `ZDOTDIR` whose files source yours first, then tty7's. Your `TTY7_USER_ZDOTDIR` is preserved. | +| **bash** | An rcfile that sources your own first. | +| **fish** | A `-C` init command. | +| **PowerShell** | An encoded init command that wraps your existing `prompt` function and PSReadLine's line reader. | +| **WSL** *(Windows)* | The distro's shell is bootstrapped with the same scripts. | +| **Remote panes** | The same three POSIX shells, bootstrapped over the SSH connection. Toggle per profile with **Settings → SSH → Session → Shell integration**. | + +`TTY7_SHELL_INTEGRATION` is set once it is active, and guards against a second +injection when shells nest. + + + A shell launched with custom arguments is left alone for bash, PowerShell, and + WSL, because tty7's injection would conflict with the flags you chose. + + +## What it reports + +| Signal | Sequence | Used for | +|---|---|---| +| Prompt begins / input begins | `OSC 133;A`, `133;B` | The [prompt layer](/terminal/prompt): suggestions, completion, multi-line editing | +| Command submitted | `OSC 133;C` | Knowing a command is running; agent detection on Windows, where ConPTY exposes no foreground process group | +| Command finished, with exit code | `OSC 133;D` | The "finished after 42s" notification, failure marks in [history](/terminal/history) | +| Working directory | `OSC 7` | New tabs and splits opening in the right place, the sidebar's repo grouping, the git branch readout | +| Editing mode (vi / emacs) | `OSC 133;V` | Matching tty7's key handling to your shell's mode | +| Window title | `OSC 0` | Tab labels. Only PowerShell is given this — zsh, bash, and fish already set a title of their own, and tty7 reads whatever they emit | + +## What turns off without it + +Run a shell tty7 does not integrate with, and everything below still works — +it just falls back to less precise sources: + +- Ghost suggestions, the completion menu, and ⌃ R's fuzzy history +- "Command finished" notifications and the failure marks in history search +- Exact working-directory tracking (tty7 falls back to inspecting the process) + +Panes, splits, scrollback, search, SSH, and the CLI are unaffected. + +## Per-pane history + +When `per_pane_history` is on, the integration is also what makes it work. It +runs *after* your own rc file — which is the only reason it can: `$HISTFILE` is +yours to set, wherever you like, and nothing outside the shell knew where it +pointed until then. + +The sequence is: seed the pane's private file from your real history so it does +not start blank, record how much was seeded, repoint `$HISTFILE`, and merge +everything past that mark back when the pane closes. + +[More about history →](/terminal/history#one-history-or-one-per-pane) + +## Remote shells + +For a remote workspace or an SSH pane, the same scripts are sent over the +connection at login, so a remote pane reports its cwd, exit codes, and prompt +marks exactly like a local one. Turn it off for a particular host under that +profile's **Advanced → Session**. diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx new file mode 100644 index 00000000..e06c3746 --- /dev/null +++ b/docs/reference/troubleshooting.mdx @@ -0,0 +1,137 @@ +--- +title: "Troubleshooting" +description: "The things that go wrong, and what they actually mean." +--- + +## Start here + +```bash +tty7 doctor +``` + +One table: whether the server is reachable, whether its wire dialect matches +your binary, the three environment variables, pid/uptime/panes, and how many +machine links exist. Most of what follows is a specific answer this gives you. + +## `tty7: command not found` + +The CLI is put on PATH the first time the app launches. If it is missing: + +- Check **Settings → Agents → Install the tty7 command on PATH** is on. +- On Unix it symlinks into whichever of `/opt/homebrew/bin`, `/usr/local/bin`, + `~/.local/bin`, `~/bin`, `~/.cargo/bin` your PATH already covers — if none of + those are on your PATH, add one. +- On Windows the install directory is appended to your user PATH, which needs a + new shell to take effect. +- A `tty7` you installed yourself is never replaced, so an old one earlier in + PATH will win. + +Inside a tty7 pane it works regardless, since panes inherit the app's +environment. + +## The server is unreachable + +`tty7 doctor` says so, and the GUI cannot open panes. + +Start it with `tty7 server start`. If you are an agent or a script, +**do not** — tell the user instead. Starting a server they did not ask for +changes what their GUI attaches to. + +For logs: + +```bash +TTY7_LOG=info # must be set before the server starts +tty7 server logs +``` + +## Panes came back empty + +A crash, a `kill -9`, or a reboot takes the shells with it — that part is +unavoidable. The *screens* should come back: tty7 keeps a capped tail of each +pane's output (256 KiB) and hands it to the pane that reopens on that id. + +It is consumed once. If a pane was restored, then closed, then reopened, the +second time there is nothing left to restore — that is by design, not a bug. + +## "The background server is still running <build>" + +tty7 updated in place, so the app is new and your panes are still served by the +previous build. Restarting the server picks up the new one and **ends every +process in every pane**. There is no hurry — do it when your panes are idle. +[Updates →](/reference/updates) + +## A remote machine will not connect + +| Message | What it means | +|---|---| +| *"running an old tty7 server that this copy cannot talk to"* | The server there predates your client's protocol. Let tty7 update it — this ends every session on that machine. | +| *"running a newer tty7 server than this copy"* | Update tty7 here instead, or replace the server there. | +| *"answered, but not as a tty7 server"* | Something else is listening, or the binary is not what tty7 expects. | +| *"tty7 no longer has a way to reach <machine>"* | The SSH link dropped. Reconnect from the switcher. | + +`tty7 -m ` never dials a fresh connection by design — it uses a link +the local server already holds. Connect from the GUI first. + +## or ⌃ R is not doing what I expect + +Both are switches, and turning one off hands the key straight back to your +shell: + +- **Settings → Input → Prompt → Tab completion** +- **Settings → Input → Prompt → History search** + +If they do nothing at all in a particular pane, the shell there probably has no +[shell integration](/reference/shell-integration) — nushell, elvish, xonsh and +friends run fine but do not get the prompt layer. + +## ⌥ B types `∫` instead of moving a word + +That is macOS's default. Turn on **Settings → Input → Keyboard → Option (⌥) acts +as Meta**. + +## CJK characters have a gap on the right + +Your CJK fallback advances 1.0em while the primary face advances 0.60205em, so +the glyph does not fill its two-column slot. Install +[Maple Mono NF CN](https://github.com/subframe7536/maple-font) — it is already +first in the fallback chain and fits Hack exactly — or change the primary face. +[The full explanation →](/customization/fonts#cjk-and-the-two-column-grid) + +## A theme in my themes folder is not showing up + +Settings lists it under **Not loaded from the themes folder**, with the reason. +Usually a missing required key: `background`, `foreground`, `accent`, and `ansi` +(with eight `normal` and eight `bright` entries) are all mandatory. + +## My `config.json` edits did nothing + +If the file cannot be parsed, tty7 starts on defaults and keeps your original at +`config.json.corrupt` — check for that file. Otherwise: + +- An out-of-range number is **clamped**, not applied literally. +- An unrecognised enum value falls back to the default with a log line. +- `scrollback_limit` applies to **new** panes only. +- An unknown action name in `keybindings` is skipped with a warning. + +## Selecting text inside vim / less selects the app's own thing + +Hold while dragging to keep the gesture local, or turn off +**Settings → Terminal → Mouse → Report mouse to apps**. + +## `tty7 capture … | head -1` printed a Rust panic + +An old build's behaviour when the reader hangs up. The data you asked for still +arrived. On such a build, redirect to a file and slice the file instead of +piping into `head`. Current builds exit `141` on Unix, which is exactly what +`cat` does. + +## Still stuck + + + + Ask — someone has probably hit it. + + + Include `tty7 doctor` output and your platform. + + diff --git a/docs/reference/updates.mdx b/docs/reference/updates.mdx new file mode 100644 index 00000000..fc877fda --- /dev/null +++ b/docs/reference/updates.mdx @@ -0,0 +1,97 @@ +--- +title: "Updates" +description: "How tty7 updates itself, and what the two channels mean." +--- + +**Settings → About** is where everything lives: the version you are on, the +channel you follow, and the button that installs what is waiting. + +## How it works + + + + tty7 checks at launch and every six hours after that. Turn it off entirely + with `check_for_updates: false`, or check on demand with **Check now**. + + + A found release is fetched and verified before you are asked to do anything + — which turns "spend five minutes downloading" into "press restart". + Nothing is ever *installed* without an explicit choice; the staged package + waits in Settings. + + Turn this off on a metered connection: the packages run 25–30 MB. + (`auto_download_updates: false`) + + + A dedicated `tty7-updater` helper verifies the release checksum, the bundle + version, and — on macOS — the code-signing requirement, before replacing the + installation. If the relaunch fails, it puts the previous copy back. + + + +Declining an update defers it rather than retiring it; it comes back later. + +## Channels + +**Settings → About → Update channel.** + +| | | +|---|---| +| **Stable** *(default)* | Published releases. Reads `/releases/latest`, which excludes prereleases. | +| **Nightly** | Rebuilt from the latest code every night — newer, but not release-tested. Reads the rolling `nightly` tag. | + +The channel is a property of your **installation**, not something inferred from +how version numbers sort. Neither feed can hand the other an update, so a +Nightly is never walked back onto Stable by an update it did not ask for, and an +installation only changes channel when you change it. + +Switching channels invalidates what the old feed produced: the staged package, +the deferred prompt, and any transfer still in flight. + + + A stable release outranks every dated build of its core version, which is how + switching back to Stable *graduates* rather than downgrades. + + +## Platform notes + + + + The new GUI reuses a running local server when its wire protocol is + compatible, so your shells survive the update. An incompatible server keeps + its shells too and raises an explicit keep-or-restart prompt. + + + Windows cannot replace a running daemon's image, so the install path stops + the service first — and the dialog says so before you agree. + + An all-users `C:\Program Files` install cannot be updated in place and keeps + the release-page fallback instead. Running Setup as the signed-in user would + either install a second copy beside the real one, or put a bare UAC prompt + in front of someone whose GUI just vanished. + + + AppImage and tarball installs are replaced by downloading the new file. + + + +## "The background server is still running <build>" + +If Settings tells you this, tty7 was updated in place: the app is the new build, +but your panes are still served by the previous one. Restarting the server picks +up the new one — **and ends every process running in your panes**, shells, +agents, and SSH sessions alike. + +There is no hurry. Pick a moment when your panes are idle. + +## Proxies + +Update checks and downloads resolve a proxy from, in order: + +1. `http_proxy` in `config.json` — `http://127.0.0.1:7890` or + `socks5://127.0.0.1:1080` +2. The platform system proxy (Windows registry, macOS `SCDynamicStore`) +3. `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` + +Programs running in a pane are deliberately unaffected — they inherit their +proxy from their own environment, as in any other terminal. diff --git a/docs/remote/port-forwarding.mdx b/docs/remote/port-forwarding.mdx new file mode 100644 index 00000000..99e1b1aa --- /dev/null +++ b/docs/remote/port-forwarding.mdx @@ -0,0 +1,61 @@ +--- +title: "Port forwarding" +description: "Local, remote, and dynamic forwards — preconfigured or added mid-session." +--- + +## The three kinds + +| | What it does | +|---|---| +| **Local** (`L`) | A port on this machine reaches a service on the remote side | +| **Remote** (`R`) | A port on the remote machine reaches a service here | +| **Dynamic** (`D`) | A SOCKS proxy on this machine, routed through the connection | + +## Adding one to a profile + +**Settings → SSH →** a profile **→ Port forwarding → + Add rule**. Rules saved +here open with the connection, every time. + +A Local or Remote rule needs a listen port and a target; a Dynamic rule needs +only the listen port. An incomplete rule tells you so rather than being saved +half-configured. + +Each rule takes an optional description — *"what it's for"* — because six months +later `8080 → 3000` explains nothing. + +## Adding one mid-session + +*SSH: Port Forwarding* in the command palette opens the **Forwards** panel for +the current connection. Add a rule there and it starts immediately; remove it +and it stops. These live only as long as the session unless you save them into +the profile. + + + The forwards panel + + +## The one-click shortcut + +-clicking a `localhost:PORT` link inside an SSH pane can open a +temporary forward for exactly that port and then open the browser — turn on +**Settings → Terminal → Links → Forward SSH loopback links**. + +That is the right tool for "let me look at this dev server once". For something +you use every day, put it in the profile. + +## Jump hosts and proxies + +Multi-hop connections are configured per profile: + +- **Jump host** — point at another saved profile, or use a `ProxyJump` chain +- **ProxyCommand** — an arbitrary transport command, with `%h`, `%p`, `%r` + substituted +- **SOCKS5 proxy** / **HTTP proxy** — `host:port`, under **Advanced → Proxies** + + + These proxy settings are for reaching the SSH server. tty7's *own* network + traffic — update checks, release downloads, remote-server installs — uses + `http_proxy` in `config.json`, the system proxy, or the `HTTP_PROXY` family. + Programs running in a pane are unaffected either way; they inherit their proxy + from their own environment, as in any terminal. + diff --git a/docs/remote/sftp.mdx b/docs/remote/sftp.mdx new file mode 100644 index 00000000..b7963868 --- /dev/null +++ b/docs/remote/sftp.mdx @@ -0,0 +1,48 @@ +--- +title: "SFTP" +description: "A file browser for the machine on the other end of the connection." +--- + +While a pane is in an SSH session, *SSH: Remote Files* (command palette) slides +a file panel in over it. It is a real SFTP client on the same connection — no +second login, no second password. + + + The tty7 SFTP panel + + +## Browsing + +The panel opens on the remote home directory. **Go to Shell Directory** in the +overflow menu jumps it to wherever the pane's shell currently is, which is +usually where you actually want to be. + +Right-click a row for **Open**, **Follow Symlink**, **Rename**, **chmod…**, and +delete. The overflow menu adds **New Folder**, **New File**, **Upload…**, and +**Refresh**. + +## Transferring + +| Direction | How | +|---|---| +| Download | Drag a file out of the panel into Finder or Explorer | +| Upload | **Upload…**, or drag files into the panel | + +Uploads are written under a temporary name and renamed into place at the end, so +a half-finished file never looks like a finished one. A name that is already +taken asks before replacing. + +**Transfer History** in the overflow menu shows every transfer with its +progress, and lets you cancel one in flight. The panel header summarises what is +happening — *"2 transferring · 64%"*. + +## Permissions + +**chmod…** takes an octal mode (`755`, `600`). The current mode is shown in the +row's editor before you change it. + + + For files in a [remote workspace](/remote/workspaces) you can often skip SFTP + entirely — the Files panel reads and writes across the link directly, and the + built-in editor saves back to the remote machine. + diff --git a/docs/remote/ssh.mdx b/docs/remote/ssh.mdx new file mode 100644 index 00000000..fe632340 --- /dev/null +++ b/docs/remote/ssh.mdx @@ -0,0 +1,116 @@ +--- +title: "SSH" +description: "A native Rust SSH stack: quick connects, saved profiles, keychain credentials, jump hosts." +--- + +tty7 speaks SSH itself, over [russh](https://github.com/Eugeny/russh). It never +shells out to the `ssh` binary, and there is no compatibility mode that does. + +That is what makes the rest possible: credentials in the OS keychain, +[SFTP](/remote/sftp) in a side panel, [port forwards](/remote/port-forwarding) +you can add mid-session, and authentication prompts drawn as sheets in the pane +instead of a password echoing into your shell. + + + Connecting over SSH in tty7 + + +## Four ways to connect + + + + Open the palette (⌘ P) and type an address. IPv6 works with + brackets. + + ``` + me@devbox + me@devbox:2222 + me@[2001:db8::1]:22 + ``` + + + + Profiles live in **Settings → SSH → Hosts**. Start typing the name in the + palette, or open the *SSH: Manage Profiles…* command. + + + + Type an alias you already have and tty7 resolves it natively — common fields, + best effort — then connects over russh. **Settings → SSH → Import from + ~/.ssh/config** turns aliases into real profiles. + + + `Match`, `canonicalize*`, and GSSAPI directives are not supported, and + there is no fallback to the system `ssh` when one appears. + + + + + The same connection can host whole workspaces on the far machine rather than + a single shell. [Remote workspaces →](/remote/workspaces) + + + +## Profiles + +**Settings → SSH → Hosts** holds the full connection config. The basics: + +| Field | | +|---|---| +| **Name** | A label for this connection | +| **Host** | Hostname or IP | +| **User** | Login user — blank resolves at connect time | +| **Auth** | *Auto* (tries every applicable method), *GSSAPI*, *Password*, *Key*, *Agent*, or *2FA* | +| **Jump host** | Another profile, or a `ProxyJump` chain | +| **Port forwarding** | Rules opened with the connection | + +**Defaults** at the top of the list is inherited by every host, so a setting you +want everywhere is set once. + +Passwords and key passphrases go in the **OS keychain**, never in +`config.json` and never on disk in plain text. **Forget Password** in a +profile's menu removes the stored one. + +### Advanced + +Behind **Advanced** on a profile, grouped: + +| Group | Fields | +|---|---| +| **Authentication** | Identity files (one path per line, `%h`/`%r` expand), agent forwarding | +| **Proxies** | ProxyCommand (`%h`/`%p`/`%r` substituted), SOCKS5 proxy, HTTP proxy | +| **Algorithms** | KEX algorithms, ciphers, MACs, host-key algorithms, compression | +| **Connection** | Keepalive interval and count, connect timeout, X11 forwarding | +| **Session** | Shell integration, login scripts, skip banner | + +Everything blank means "the library default", so you only fill in what you +actually need to override. + +## Authentication prompts + +Password, key passphrase, and 2FA prompts appear as sheets inside the pane, with +a **Remember (keychain)** option where it makes sense. + +## Host keys + +Host keys are verified against `known_hosts` by default. A first connection asks +you to confirm the fingerprint; a **changed** key is a much louder prompt that +makes you type `yes` to override, because that is what a changed key deserves. + +**Settings → SSH → Security → Verify host keys** turns verification off +entirely. It is on for a reason. + +Also under Security: **Warn before closing** a live connection, off by default. + +## Reconnecting + +⌘ ⇧ R — or *SSH: Reconnect* in the palette — restarts the session in +the current pane. Useful after a laptop sleeps or a network changes. + +## What is not supported + +- No fallback to the system `ssh` binary +- No `Match` or `canonicalize*` directives from `~/.ssh/config` +- No GSSAPI *directives* from `~/.ssh/config`. Kerberos `gssapi-with-mic` itself + is supported — pick **GSSAPI** in a profile's Auth field — it is just not + something the config-file resolution path reads diff --git a/docs/remote/workspaces.mdx b/docs/remote/workspaces.mdx new file mode 100644 index 00000000..ff24780d --- /dev/null +++ b/docs/remote/workspaces.mdx @@ -0,0 +1,114 @@ +--- +title: "Remote workspaces" +description: "Whole workspaces hosted on another machine — files, repos, panes, and git all stay over there." +--- + +An SSH pane runs one shell on a remote machine. A **remote workspace** goes +further: tty7 runs a server on the far machine, and the whole workbench points +at it. Tabs, splits, the file tree, the git panel, the diff overlay, the process +list — all of it is the remote machine's, rendered here. + +Nothing is synced or copied. The repository stays where it is. + + + A remote workspace in tty7 + + +## Connecting + + + + ⌘ ⇧ O. Machines are listed alongside your local workspaces — + *This Computer* first, then every saved SSH profile and, on Windows, every + WSL distribution. + + + tty7 connects over the same SSH stack as everything else, so profiles, + keychain credentials, and jump hosts all apply. + + + The first connection asks: + + > tty7 will write its server binary to *devbox* so this machine can host + > workspaces there. Nothing else on *devbox* is touched, and no sudo is + > used. + + It shows the exact path, version, size, source, and SHA-256 before you + agree. Later upgrades on that machine install silently. + + + From then on the machine's workspaces are in the switcher, and a new one + opens like a local one. + + + +## What gets installed + +| | | +|---|---| +| **What** | A single static `tty7-server` binary | +| **Where** | `~/.local/share/tty7/bin/tty7-server-cp` | +| **Privileges** | None. No sudo, nothing outside your home directory | +| **Hosts** | Linux, x86-64 or aarch64 | + +The binary is named after the wire dialect it speaks, so a client and a server +that disagree never quietly half-work — tty7 installs the matching one instead. + +On Windows, a WSL distribution is handed the Linux server the installer already +shipped, so a WSL workspace needs no network access at all. + +## Reattaching + +Remote workspaces are the point at which persistence pays off twice: the panes +survive on the remote machine whether or not your laptop is awake, and you can +reattach from a different client entirely. + +A strip along the top of the window says what the connection is doing — +*connecting*, *reconnecting (attempt 3)*, *disconnected*, or *taken over by +someone else*. Reconnection is automatic; a workspace another client has claimed +says so by name rather than fighting over it. + +## Keeping the server current + +Two dialogs you may meet: + + + + The machine is serving sessions from a build whose protocol this client + cannot speak. tty7 has already installed a matching server, but the one + already running is the one your sessions are on. **Update Server** replaces + it and **ends every session it is hosting** — including ones this window is + not showing. Cancel leaves the machine exactly as it is. + + + Same consequence, deliberately: every shell on that machine ends. Workspaces + and layouts are kept and come back with fresh shells. + + + + + Both of these end other people's work if the machine is shared. tty7 spells + out what will happen before either one runs — read it. + + +## What a remote pane cannot do + +- **Fork an agent session.** The fork command would run against the *local* + agent, so tty7 does not offer it. +- **Move very large files through the Files panel.** Drag-and-drop across the + link is capped at what one control frame can carry; past that the panel tells + you to use [SFTP](/remote/sftp). + +## From the CLI + +```bash +tty7 machine ls # this machine plus every link the server holds +tty7 -m devbox ls # route any command to a linked machine +tty7 -m devbox run -- cargo test +``` + +`-m` matches the full link key (`me@devbox:22`) or just the host. It uses a link +the local server *already* holds — it will not dial a fresh connection, and it +says so rather than guessing. Connect from the switcher first. + +[CLI overview →](/cli/overview) diff --git a/docs/terminal/history.mdx b/docs/terminal/history.mdx new file mode 100644 index 00000000..c3e875ec --- /dev/null +++ b/docs/terminal/history.mdx @@ -0,0 +1,59 @@ +--- +title: "History" +description: "Fuzzy history search, and whether each pane gets its own." +--- + +## Fuzzy search with ⌃ R + +⌃ R opens a fuzzy search over what you have actually run. Type any +fragment — the letters do not have to be adjacent — and the list narrows. + +Each row carries context the plain shell version throws away: + +- **when** you last ran it, as a relative time +- **whether it failed**, from the exit code + +A command appears once, however many times you have run it — repeats collapse +into their most recent occurrence. Ranking mixes frequency with recency, and +commands you ran in the *current* directory are pushed up, so the thing you want +is usually the thing you last did here. + + + Fuzzy history search + + + puts the command on the prompt. ⌘ ⏎ puts it there and +runs it. Esc closes without touching it. + +### Handing ⌃ R back + +If you already have an fzf, percol, atuin or McFly binding you like, turn off +**Settings → Input → Prompt → History search** (`history_search: false`). +⌃ R then goes to the shell, and whatever you bound there keeps +working. + +## Where the history comes from + +Your existing shell history file, as-is. Nothing is imported or converted, and a +history written outside tty7 shows up immediately. + +The per-row extras — when you last ran it, whether it failed — come from a small +file tty7 keeps alongside it, filled in as you run things. A command tty7 has +never seen still appears; it just arrives without a timestamp or an exit code. + +## One history, or one per pane + +By default every pane shares your shell's history file, which is what a terminal +has always done: a command typed in one pane is available in the next. + +**Settings → Input → Prompt → Give each pane its own shell history** +(`per_pane_history: true`) changes that. Each pane gets a private history file: + +- **seeded** from your real history when the pane opens, so it is not blank +- **merged back** into your real history when the pane closes, so nothing typed + is lost + +Useful when several agents or several tasks are running side by side and you do +not want their commands interleaved in your key. Off by default, +because someone who has not asked for it would experience the change as their +history mysteriously forgetting the other window. diff --git a/docs/terminal/links.mdx b/docs/terminal/links.mdx new file mode 100644 index 00000000..5d937729 --- /dev/null +++ b/docs/terminal/links.mdx @@ -0,0 +1,51 @@ +--- +title: "Links" +description: "Opening URLs, files, and localhost ports straight out of the terminal." +--- + +Hold (Ctrl on Windows and Linux) and links under the +pointer underline; click to open one. + +## URLs + +Anything that looks like a URL is detected, including one the shell wrapped +across two lines — tty7 stitches it back together before opening it. + +Turn detection off with **Settings → Terminal → Links → Detect URLs** +(`link_url: false`). + +## Files + +A file path in the output — a compiler error, a test failure, a `grep -n` hit — +opens in your default application for that file type. + +To send it somewhere specific instead, set **Settings → Terminal → Links → Open +files with**. The command runs with placeholders substituted: + +``` +code --goto {path}:{line}:{column} +zed {path}:{line} +herdr edit {path} --line={line} +``` + +`{path}`, `{line}`, and `{column}` are filled in from the link. A flag whose +value is not available is dropped rather than passed empty — so +`--line={line}` simply disappears when the link had no line number. Leave the +field blank to go back to the default application. + +The same setting is `link_file_command` in `config.json`. + +## localhost ports + +-clicking `localhost:3000` opens it in your browser, which is only +useful if the server is on this machine. + +When the pane is inside an SSH session it usually is not. Turn on **Settings → +Terminal → Links → Forward SSH loopback links** (`ssh_loopback_forward: true`) +and tty7 opens a temporary port forward through that connection first, so the +link reaches the server on the remote machine. + + + For a forward you want to keep, set one up properly instead — + [port forwarding](/remote/port-forwarding). + diff --git a/docs/terminal/mouse-and-scrolling.mdx b/docs/terminal/mouse-and-scrolling.mdx new file mode 100644 index 00000000..cd77a3a7 --- /dev/null +++ b/docs/terminal/mouse-and-scrolling.mdx @@ -0,0 +1,75 @@ +--- +title: "Mouse, scrolling, and the bell" +description: "How the pointer, the wheel, and ^G behave — and how to change each." +--- + +## Scrolling + +The wheel and trackpad scroll the pane's scrollback. All of it lives under +**Settings → Terminal → Scrolling**: + +| Setting | Default | What it does | +|---|---|---| +| **Scrollback** | 10,000 lines | How much history each pane keeps, up to 100,000. Applies to new panes. | +| **Scroll speed** | 1.0 | A multiplier on wheel scrolling (0.1–10). | +| **Smooth scrolling** | On | Eases each wheel notch into place over a few frames instead of jumping the whole way. Trackpads scroll continuously already and are unaffected. | + + plus the wheel zooms the font instead of scrolling. + +A scrollbar appears down the right edge of the pane as soon as the view moves, +and fades out once it stops — the same bar the sidebar and every list in the app +use. Drag its thumb to travel the whole scrollback at once, or click the track +to jump. A pane sitting at the live edge stays bare, however much output is +running through it. + +## The pointer + +Under **Settings → Terminal → Mouse**: + +| Setting | Default | What it does | +|---|---|---| +| **Focus follows mouse** | Off | Hovering a pane focuses it without a click. | +| **Hide mouse while typing** | On | The pointer disappears as you type and returns on the next move. | +| **Report mouse to apps** | On | Full-screen programs — vim, tmux, `htop` — get clicks and scroll events themselves. Hold to keep a gesture local and select text instead. | + + + If selecting text inside `vim` or `less` grabs the app's own selection instead + of yours, hold while you drag. + + +## Font size + +| | | +|---|---| +| ⌘ + · ⌘ − | Bigger · smaller | +| ⌘ 0 | Back to the configured size | +| + wheel | Zoom by scrolling over a terminal | + +The base size is **Settings → Appearance → Typography → Font size**, 15 px by +default. The rest of the interface has its own size — **Interface font size**, +16 px, adjustable from 12 to 24 — so you can scale the chrome without touching +the terminal grid, or the other way round. + +## The bell + +**Settings → Terminal → Bell → Terminal bell** decides what `^G` does: + +| Mode | Behaviour | +|---|---| +| **Off** | Nothing | +| **Visual** *(default)* | A brief flash | +| **Audible** | The system sound | +| **Both** | Flash and sound | + +## Command-finished notifications + +**Settings → Window & Tabs → Notifications** posts a desktop notification when a +foreground command finishes: + +- **Notify on command finish** — *Never*, *When unfocused* (default), or + *Always* +- **Notify threshold** — how long a command has to run to qualify, 10 seconds by + default + +Coding agents use the same policy for their own notifications. +[Agent status →](/agents/status) diff --git a/docs/terminal/prompt.mdx b/docs/terminal/prompt.mdx new file mode 100644 index 00000000..5e71f2d4 --- /dev/null +++ b/docs/terminal/prompt.mdx @@ -0,0 +1,130 @@ +--- +title: "The prompt" +description: "Ghost suggestions, tab completion that explains itself, syntax highlighting, and real multi-line editing." +--- + +tty7 puts an editor at the shell prompt. Nothing to install, no plugin to source +— the moment a supported shell starts in a pane, the prompt behaves like this. + + + The tty7 prompt + + +## Ghost suggestions + +As you type, the rest of the line is filled in from your history, greyed out +ahead of the cursor. + +| | | +|---|---| +| | Accept the whole suggestion | +| Keep typing | The suggestion narrows | +| Anything that does not match | It disappears | + +Your existing shell history is what feeds it — there is no separate database to +build up first, and it carries across sessions and reboots. + +## Tab completion, with descriptions + + opens a completion menu that knows what it is offering: + +- **Commands** from your PATH and your shell's builtins +- **Files and directories**, with `cd`, `pushd`, `popd`, and `rmdir` offering + directories only +- **Flags and subcommands** with their descriptions, for about 100 common + commands — `git`, `cargo`, `docker`, `kubectl`, `npm`, `brew` and the rest +- **Values** where a flag only takes certain ones + + + Explained tab completion + + +When tty7 has nothing useful to offer, the falls through to your +shell's own completion, so a carefully configured zsh setup is not lost. + +To hand back to the shell entirely, turn off **Settings → Input → +Prompt → Tab completion** (`tab_completion` in `config.json`). + +## Syntax highlighting + +The line you are typing is coloured as you type it: the command, its flags, its +arguments, paths, quoted strings, operators, comments. It is a fast tokenizer, +not a shell parser — it never changes what gets run. + +## Line editing + +The prompt behaves like a text field, because it is one: + +- **Click to place the caret** anywhere in the line +- **Select with the mouse**, drag to extend +- **Word motion** and word delete +- **Undo** + +Everything readline does still works — this sits on top, it does not replace it. + + + On macOS, turn on **Settings → Input → Keyboard → Option (⌥) acts as Meta** if + you want ⌥ B / ⌥ F to move by word instead of typing + `∫` and `ƒ`. + + +## Typing with an IME + +Pinyin, Kana, Hangul and the rest work in a pane the way they do in a text +field: the composition is drawn in place at the cursor and only the committed +text reaches the program. + +Two rules decide who gets a keystroke: + +- **A plain printable key goes to the IME.** A key held with , + , fn, or does not — those are chords, not + characters. +- **A program that asks for every key gets every key.** When something turns on + the kitty keyboard protocol's report-all-keys mode, the IME steps aside so the + program sees raw input. + + + On macOS with **Option (⌥) acts as Meta** turned on, chords + bypass the IME entirely, so ⌥ B reaches your shell as meta-b + instead of being eaten as a dead key. + + +Rendering CJK well is a separate question — see +[fonts and the two-column grid](/customization/fonts#cjk-and-the-two-column-grid). + +## Multi-line commands + +A command that wraps, or one you deliberately break across lines, edits in +place. The grid shifts to keep the caret visible instead of scrolling the whole +screen away. + +| | | +|---|---| +| ⇧ ⏎ · ⌥ ⏎ | Insert a newline instead of submitting | +| | Submit the whole buffer, however many lines it is | + +The newline key is rebindable as `InsertNewline` under **Settings → +Keybindings**. + +## Which shells + +The prompt features arrive through tty7's shell integration, which is injected +automatically — nothing to add to your rc file — for **zsh**, **bash**, +**fish**, **PowerShell**, and **WSL**. Other shells (nushell, elvish, xonsh, and +the rest) run perfectly well in a pane; they simply do not get the prompt layer. + +The integration is also what reports the working directory, the exit code of +each command, and where prompts begin — which is what the sidebar's branch +readout, the "command finished" notification, and `tty7 procs` are built on. + +[How shell integration works →](/reference/shell-integration) + +## Turning it off + +Both prompt features are switches, and turning one off hands its key straight +back to the shell: + +| Setting | Key it releases | +|---|---| +| **Settings → Input → Prompt → Tab completion** | → your shell's completion | +| **Settings → Input → Prompt → History search** | ⌃ R → your shell's reverse-i-search, or your fzf binding | diff --git a/docs/terminal/selection-and-clipboard.mdx b/docs/terminal/selection-and-clipboard.mdx new file mode 100644 index 00000000..30856770 --- /dev/null +++ b/docs/terminal/selection-and-clipboard.mdx @@ -0,0 +1,62 @@ +--- +title: "Selection and clipboard" +description: "Smart double-click, copy on select, and the settings around them." +--- + +## Selecting + +| | | +|---|---| +| Drag | Select a range | +| Double-click | Select the thing under the cursor — see below | +| Triple-click | Select the line | +| -click | Extend the current selection to where you clicked | +| ⌘ A *(macOS)* | Select all — also in the right-click menu and the command palette on every platform | + +### Smart double-click + +A double-click does not just grab a word bounded by spaces. It works out what +you are pointing at: + +| Under the cursor | What you get | +|---|---| +| A URL | The whole URL — including one the shell wrapped across two lines | +| A file path | The whole path | +| An email address | The whole address | +| A bracket or quote | The matching pair, and everything between them | +| CJK text | The word, segmented by dictionary rather than by character | + +Turn it off with **Settings → Input → Selection & clipboard → Smart selection**. +With it off, double-click falls back to plain word selection using the +`word_separators` list from `config.json` — by default: + +``` +,│`|:"' ()[]{}<>⇥ +``` + +## Copying and pasting + +| | macOS | Windows / Linux | +|---|---|---| +| Copy | ⌘ C | Ctrl ⇧ C | +| Paste | ⌘ V | Ctrl ⇧ V · ⇧ Insert | + +Two settings shape what lands where, both under **Settings → Input → Selection +& clipboard**: + + + + Selecting with the mouse copies immediately, no ⌘ C. Off by + default. + + + Strips trailing whitespace from every copied line — useful when copying out + of a TUI that pads to the pane width. Off by default. + + + + + Pasting multiple lines sends them as typed input, exactly as any terminal + does. If the shell supports bracketed paste it will treat the block as one + paste rather than running each line. + diff --git a/docs/window/command-palette.mdx b/docs/window/command-palette.mdx new file mode 100644 index 00000000..ffff9b38 --- /dev/null +++ b/docs/window/command-palette.mdx @@ -0,0 +1,59 @@ +--- +title: "Command palette" +description: "One key for every action in the app — including the ones with no shortcut." +--- + +⌘ P opens the command palette. Type to filter, + to move, to run. + +Every action tty7 can perform is in here, whether or not it has a keybinding — +which makes it the fastest way to reach the ones that deliberately ship unbound, +like pane resize and swap. + + + The tty7 command palette + + +## What is in it + +Results are grouped, and the groups are the map of the app: + +| Group | Examples | +|---|---| +| **Tabs & Panes** | New Tab · New Worktree Tab… · Split Right · Zoom Pane · Focus Pane Left · Resize Pane Up · Swap Pane Next · Reopen Closed Tab · Copy Working Directory · Fork Session | +| **Workspaces** | New Workspace · Switch Workspace… · Rename Workspace… · Stop Workspace… · Delete Workspace… | +| **View** | Show/Hide Left Sidebar · Show/Hide Right Panel · Show Code Panel · Tab Bar: Move to Top · Right Panel: Info / Changes / Files · Change Theme… · Enter Full Screen · Toggle Unified / Side-by-Side Diff | +| **Git** | Commit · Stage All Changes · Unstage All · Discard All · Create Branch… · Sync · Push · Pull · Fetch | +| **Terminal** | Clear Scrollback · Find in Terminal… · Find Next / Previous · Copy · Cut · Paste · Select All | +| **SSH** | Add Connection… · Manage Profiles… · Reconnect · Remote Files · Port Forwarding | +| **Agents** | Send Selection · Send Git Diff for Review · Copy Session ID | +| **Application** | Settings… · Keyboard Shortcuts · Check for Updates… · Documentation · Join the Discord · Report an Issue… · Restart Server… | + +Entries that do something destructive say so under the name — *Delete +Workspace…* is subtitled "ends its shells and forgets the layout", *Restart +Server…* is "ends every running shell; layout is kept". + +## Connecting from the palette + +Type an SSH address and the palette offers to connect to it: + +``` +me@devbox +me@devbox:2222 +[::1]:22 +``` + +Saved profiles show up the same way — start typing the name. A host that only +exists in `~/.ssh/config` does not: import it into a profile first, or reach it +from the workspace switcher. [SSH →](/remote/ssh) + +## Sending context to an agent + +Two palette commands hand what is in front of you to the coding agent running in +the pane, as a ready-made prompt: + +- **Agent: Send Selection** — the current selection. +- **Agent: Send Git Diff for Review** — the repository's `git diff`. + +If no agent is running, tty7 says so rather than typing into your shell. +[Agents →](/agents/overview) diff --git a/docs/window/search.mdx b/docs/window/search.mdx new file mode 100644 index 00000000..ad4d4f81 --- /dev/null +++ b/docs/window/search.mdx @@ -0,0 +1,50 @@ +--- +title: "Search" +description: "Finding text in the scrollback, and everything else the search boxes cover." +--- + +## In the terminal + +⌘ F opens the find bar over the focused pane and searches its whole +scrollback, not just the visible screen. + +| | | +|---|---| +| · ⌘ G | Next match | +| ⇧ ⏎ · ⌘ ⇧ G | Previous match | +| Esc | Close the find bar | + +Two toggles sit in the bar: + +- **Match case** — off by default, so a lowercase query matches anything. +- **Use regular expression** — the query becomes a regex. An invalid pattern is + shown as an error rather than silently matching nothing. + +Matches are highlighted in place and the view scrolls to each one as you step +through. On Windows and Linux the shortcuts are Ctrl ⇧ F to open, +F3 and ⇧ F3 to step. + + + Searching the scrollback + + + + How much there is to search is **Settings → Terminal → Scrolling → + Scrollback** — 10,000 lines per pane by default, up to 100,000. The change + applies to new panes. + + +## Everywhere else + +tty7 leans on the same pattern in a lot of places. All of them are +type-to-filter, no button to press: + +| Where | What it searches | +|---|---| +| ⌘ P | Commands — and SSH addresses you type in full | +| ⌘ ⇧ O | Workspaces, tabs, and machines | +| ⌃ R | Your shell history, fuzzily — [see History](/terminal/history) | +| Settings search box | Every setting, by name and by keyword | +| Files panel | Files under the workspace root | +| Theme picker | Built-in and custom themes | +| Font picker | Fonts installed on your system | diff --git a/docs/window/side-panel.mdx b/docs/window/side-panel.mdx new file mode 100644 index 00000000..d2f4258c --- /dev/null +++ b/docs/window/side-panel.mdx @@ -0,0 +1,67 @@ +--- +title: "The side panel" +description: "Info, Source Control, and Files — plus the built-in editor." +--- + +⌘ J opens a panel on the right of the window with three tabs. It is +hidden by default; whichever tab you leave it on is where it opens next time. + + + The tty7 side panel + + +## Info + +Everything tty7 knows about the focused pane, in one column: + +| Section | What it shows | +|---|---| +| **Session** | working directory, shell, SSH connection, git branch, `+N −M` changes, and the coding agent with its status (idle / working / waiting / done) | +| **Processes** | the process tree inside the pane, with the foreground process marked | +| **Ports** | every port those processes are listening on | + +On a local pane, the working directory row has **Reveal in Finder** / **Open +Folder** beside it. +The ports section is the quickest answer to "what is this pane serving, and +where" — the same data `tty7 procs` prints. + +## Source Control + +The git panel for the focused pane's repository, in four groups — **Merge +Changes**, **Staged Changes**, **Changes**, **Untracked**. Write a message, +commit, and push without leaving the window. + +[Source control →](/git/source-control) + +## Files + +A file tree rooted at the pane's working directory, with git status decorations +on every row and a search box at the top. + +- **Click a file** to open it in the built-in editor. +- **Drag a file out** to Finder or Explorer to copy it there. +- **Drag files in** from the desktop to copy them into the folder under the + cursor. A folder row takes them itself, a file row stands in for the folder + holding it, and the empty space below the tree means the top of it. Folders + come in whole, the executable bit survives, and a name that is already taken + is asked about rather than replaced. + +Both directions work over a [remote workspace](/remote/workspaces) too, reading +on one machine and writing on the other, up to the size one control frame can +carry — past that the panel tells you to use [SFTP](/remote/sftp). + +## The editor + +⌘ ⇧ E toggles the code panel; clicking a file in the Files tab opens +it there. It is a real editor — syntax highlighting, line and column readout, +wrap toggle, and a Markdown preview — meant for the edit you would otherwise +have opened `vim` for. + +| | | +|---|---| +| ⌘ S | Save | +| Esc | Back to the terminal | + +Files are watched on disk: a change underneath you is picked up, and closing +with unsaved edits asks before discarding them. Files over 4 MB and anything +that looks binary are refused with a note rather than opened badly. diff --git a/docs/window/sidebar.mdx b/docs/window/sidebar.mdx new file mode 100644 index 00000000..ef9236e8 --- /dev/null +++ b/docs/window/sidebar.mdx @@ -0,0 +1,80 @@ +--- +title: "The sidebar" +description: "Tabs grouped by repository, with branch, diff counts, and agent status on every row." +--- + +The left sidebar is tty7's tab bar, and it is the default because a vertical row +has room for things a horizontal chip does not: the repository a tab belongs to, +the branch it is on, how much has changed there, and what a coding agent in it +is doing. + +⌘ B shows and hides it. Drag its right edge to resize. + + + The tty7 tab sidebar + + +## Grouped by repository + +Rows sit under a header per git repository, with everything else collected in a +trailing **Scratch** section. The grouping follows the tab's working directory, +not its history — switching branches or `cd`-ing around inside a repository +never moves a row out from under its header. + +**Settings → Window & Tabs → Sidebar grouping** switches between *By repo* (the +default) and *Flat*. + +## What a row tells you + + + + A brand avatar when a coding agent is in the tab, plus a status dot — + blue for working, amber for needs-your-input, green for done. + + + The pane's git branch, refreshed on `cd` and whenever a command finishes. + + + The working-tree diff as `+N −M`. Click the counts to open the + [diff overlay](/git/diffs). + + + An unread marker on tabs where a coding agent finished its turn while you + were elsewhere. Agent tabs also carry *Mark as Unread* in the right-click + menu, once there is a finished turn to mark. + + + +If you would rather the counts not be clickable, turn off **Settings → Window & +Tabs → Open diff preview from sidebar counts**. The branch and the numbers stay; +they simply stop opening the overlay. + +## Rearranging + +Drag a row to reorder it within its group, or drag a whole group header to move +the group. A row cannot be dragged into a different group: with the default repo +grouping a tab's group comes from its working directory, so `cd` is what moves +it. + +## Naming + +Almost no tab has a name of its own, so the sidebar falls back: + +1. a name you set (right-click → **Rename Tab…**) +2. the title the shell is reporting — the running command, usually +3. **Shell 3**, numbered by position, when there is no title at all + +`tty7 tab ls` answers the same question with more evidence, because a script has +no screen to look at. Its `label` falls back through the name, then the coding +agent running in the tab ("Claude Code"), then the last segment of the working +directory, then the foreground process — while `name` stays literal, so a script +can tell a real name from a stand-in. + +## The switcher + +⌘ ⇧ O opens the workspace switcher: every workspace on every machine +you are connected to on the left, that workspace's tabs on the right. Type to +filter both, to cross into the tab column, to open. + +From here you can also rename a workspace, open one in a new window, stop one, +or connect to a machine you have a profile for. diff --git a/docs/window/tabs-and-splits.mdx b/docs/window/tabs-and-splits.mdx new file mode 100644 index 00000000..e0095085 --- /dev/null +++ b/docs/window/tabs-and-splits.mdx @@ -0,0 +1,100 @@ +--- +title: "Tabs and splits" +description: "Opening, arranging, and rearranging the panes in a tab." +--- + + + Keys are written in macOS notation. On Windows and Linux, read as + Ctrl ⇧ for most window actions — the exact chords are on the + [keyboard shortcuts](/reference/keyboard-shortcuts) page. + + +## Tabs + +| | | +|---|---| +| ⌘ T | New tab | +| ⌘ W | Close the tab (or the focused pane, if the tab has more than one) | +| ⌘ ⇧ T | Reopen the tab you just closed | +| ⌘ 1⌘ 9 | Jump to tab 1–9 | +| ⌃ ⇥ · ⌃ ⇧ ⇥ | Hold to walk the switcher forwards or backwards; it commits when you let go | + +A new tab always opens in the current pane's directory. Where it lands in the +list is **Settings → Window & Tabs → New tab position** — *After current* by +default, or *At end*. + +Right-click a tab for the rest: rename, split right or down, a new worktree tab, +copy the working directory, copy the session id, close · close others · close to +the right, and — when a coding agent is running there — mark unread and fork its +session. + +## Splits + +| | | +|---|---| +| ⌘ D | Split right | +| ⌘ ⇧ D | Split down | +| ⌘ ] · ⌘ [ | Next pane · previous pane | +| ⌘ ⌥ ← → ↑ ↓ | Focus the pane in that direction | +| ⌘ ⇧ ⏎ | Zoom the focused pane to fill the tab | +| ⌘ ⏎ | Fullscreen the window | + +A split inherits the current pane's working directory, so splitting inside a +repository keeps you in the repository. + +Resizing and swapping panes have no default keys — bind them under **Settings → +Keybindings**, or run them from the command palette (*Resize Pane Left*, *Swap +Pane Next*, and friends). + +Inactive panes are dimmed slightly so the focused one is obvious. Turn that off +with **Settings → Appearance → Dim inactive panes**. + +## Rearranging by dragging + +Hover a pane and a small grip appears along its top edge. Drag it to move that +pane somewhere else in the tab. + + + Dragging a pane to a new position + + +Where you drop it decides what happens: + + + + The pane goes in **beside** that one. If it is facing a neighbour in the + same row or column, it joins that row and takes an equal share of it. Only + when it faces across the layout — where there is no row to join — does it + split that pane in half. + + + The two panes **trade places**. + + + The band beyond the outermost edge — the side facing the window rather than + another pane — makes the dragged pane a full-width or full-height band + beside everything else, sized to an even share of what that side already + holds. A pane in the middle of a 2×2 becomes a full-height *third* column in + one drag, rather than taking half the window. + + + +The landing lights up while you drag, and only ever lights up when the drop +would actually change the layout. + +## Closing something that is busy + +Closing a pane or tab with a command still running asks first, and says what is +running: *"cargo is still running. Closing ends it."* When a coding agent is +mid-turn it says that instead — *"Claude Code is still working. Closing ends its +turn."* + +## Where the tabs live + +**Settings → Window & Tabs → Tab bar position** puts tabs in a vertical sidebar +on the left (the default) or a horizontal strip on top. The sidebar has room for +things a strip does not — git branch, diff counts, agent status — so most of +[its own page](/window/sidebar) is about that. + +⌘ B toggles the sidebar; ⌘ J toggles the +[side panel](/window/side-panel) on the right. diff --git a/skills/tty7/SKILL.md b/skills/tty7/SKILL.md index 8a12f68b..1835fb33 100644 --- a/skills/tty7/SKILL.md +++ b/skills/tty7/SKILL.md @@ -1,7 +1,7 @@ --- 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. + Drive the tty7 terminal workbench from the shell with the `tty7` binary — list workspaces/tabs/panes, split a pane, send text or keystrokes into one, capture what is on a pane's screen, run a command in a real PTY and pass its exit code through, block until a pane finishes or needs input, 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 want to hand work to another agent and collect the result ("get Claude/Codex to do X", "派个活", "let another agent handle this", running several agents in parallel); 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 @@ -17,14 +17,18 @@ 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. +whether a server is reachable, whether the dialect matches, whether each agent's +status hooks are installed, 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. +(`split`, `send`, `capture`, `procs`, `wait`, `pane close`) 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. + +The hooks row matters if you intend to delegate to another agent: without them +an agent reports no status, so `tty7 wait` on it will only ever time out. 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 @@ -48,6 +52,9 @@ Reach for tty7 when one of these is true: - **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. +- **Someone else should do the work.** Another coding agent can run in a pane, + and you can wait on it and read its answer. See [Handing work to another + agent](#handing-work-to-another-agent). ## Addresses @@ -77,10 +84,11 @@ 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 `. 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. +Three things to know. `--keep` needs a workspace, so it only works inside a tty7 +shell or with `--ws `. 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. And the pane is 120 columns wide with no way to change it, +so output that assumes a wider terminal wraps. ### Non-blocking: a pane you talk to over time @@ -111,7 +119,11 @@ read -r WS PANE < <(tty7 new --json /path/to/repo \ `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 is a separate step, and waiting is `tty7 wait`. + +For keystrokes rather than characters — Ctrl-C, Escape, the arrow keys — use +`--key` (see [Answering a prompt](#answering-a-prompt)). Typing `^C` as text +does nothing; it arrives as two characters. ## Reading a pane @@ -150,32 +162,134 @@ Complete output, a real exit code, no terminal in the middle. ### Knowing when a command has finished +Don't poll the screen and don't write your own loop — block on it: + ```bash -tty7 procs %83 +tty7 wait "$PANE" --until free --changed --timeout 900 ``` -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`. +`free` means the foreground command has exited and the pane is back to its bare +shell. `--changed` adds "and something actually ran while I watched", which is +what you want on the line right after a `send`: without it, a command that has +not started yet leaves the pane looking finished. 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 +tty7 wait "$PANE" --until free --changed --timeout 900 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. +Exit codes are built for this: `0` means a state you asked for was reached, +`124` means the timeout ran out (the `timeout(1)` convention, so "not yet" is +distinguishable from "broken"), `1` means the pane died first. + +One trap in `--changed`: a command that finishes inside a single poll (500ms by +default) is never *seen* running, so the wait keeps going until it times out. +For something that quick, `--interval 100`, or drop `--changed` and read the +`.rc` file. The timeout message says so when it happens. + +If you want the process tree itself — "what is running in there", "which port is +this pane serving" — that is `tty7 procs %83`: indented by depth, `*` on the +foreground process, then the ports those processes are listening on. + +## Handing work to another agent + +Everything above also works when the thing in the pane is a coding agent, and +that is where this stops being a terminal wrapper and starts being useful. An +agent reports its own status, so you can wait on *it* rather than on its +process tree: + +```bash +PANE=$(tty7 split --v) +tty7 send "$PANE" 'claude -p "add tests for the parser"' --enter +tty7 wait "$PANE" --until waiting,done --changed --timeout 900 +tty7 capture "$PANE" --plain | tail -40 +tty7 pane close "$PANE" +``` + +Five steps: give it a pane, hand it the task, sleep until it needs you or +finishes, read what happened, clean up. The third is the one worth +understanding. + +### What the states mean + +| State | The pane is | +|---|---| +| `working` | mid-turn | +| `waiting` | **stopped, needing you** — a permission prompt, a question | +| `done` | finished its turn | +| `idle` | an agent that has not started a turn | +| `free` | no agent: the foreground command exited (see above) | +| `no-agent` | nothing reports status here — a plain shell, or hooks not installed | +| `exit` | the pane is gone; ends every wait whether you asked for it or not | + +`--until waiting,done,exit` is the default because those are the three that mean +"your turn again". Note that `idle` is something an agent says about *itself* — +a pane running a build is `no-agent`, never `idle`, so `--until idle` is never +the way to ask "is the command finished". That is `free`. + +Mixing the two is safe: `--until waiting,done,free` covers a pane whose kind you +don't know, because `free` is only consulted when none of the agent states you +named matched first. + +### `--changed` is not optional in a loop + +The status is a **level, not an event**: `done` stands until the next turn +begins. So a `wait` issued right after a `send` will happily answer with *last* +turn's `done` before the worker has even read the input, and you will read a +stale screen and think it failed. `--changed` refuses the state the pane was +already in. Every round after the first needs it; the JSON's `stale` flag tells +you when it mattered. + +### Answering a prompt + +A worker that stops at `waiting` is usually showing something that text cannot +answer — a permission prompt driven by arrow keys, a menu, a TUI. Look first, +then press keys: + +```bash +tty7 capture "$PANE" --plain | tail -20 # what is it asking? +tty7 send "$PANE" --key down --key enter # answer it +tty7 send "$PANE" --key C-c # or stop it +``` + +`--key` takes `enter escape tab backtab space backspace delete up down right +left home end pageup pagedown`, plus `C-` for Ctrl and `M-` for +Alt. Repeat it for a sequence; text and keys compose, text first. This is also +how you interrupt a runaway command in a pane you own — `--key C-c` — which +plain `send` cannot express. + +### Running several at once + +Panes are independent, so fan out and then collect: + +```bash +for task in parser lexer codegen; do + P=$(tty7 split --v) + tty7 send "$P" "claude -p 'add tests for the $task'" --enter + echo "$P" >> /tmp/workers +done +while read -r P; do + tty7 wait "$P" --until done,exit --changed --timeout 1800 || echo "$P did not finish" + tty7 capture "$P" --plain | tail -40 + tty7 pane close "$P" +done < /tmp/workers +``` + +Splitting repeatedly makes the user's window very busy; `tty7 new` gives each +worker its own workspace instead if you would rather not. + +### When a worker never moves + +A `wait` that times out while `tty7 agents` shows a status that never changes +almost always means the agent's status hooks are missing or out of date — the +worker is fine, it just has no way to say so. `tty7 agents` names the agent when +it can see the gap, and `tty7 doctor` reports where every agent's hooks stand. +Hooks are installed from the GUI's **Settings → Agents**; tell the user rather +than trying to install them yourself. ## Looking around @@ -191,8 +305,11 @@ tty7 events # stream server events, one per line, until interrupt ``` `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. +coding agent as `idle` / `working` / `waiting` / `done`, with the agent's own +message beside it. If you are one of them, you are in that list too. It also +prints a diagnostic — `diagnostics` in the JSON — when it can see an agent +running whose status hooks are missing or outdated, which is the explanation for +any agent that appears frozen. Add `--json` to any of these to parse instead of eyeball. `-q` suppresses output on success but never suppresses errors. @@ -204,15 +321,20 @@ 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. + is happening there. Check `tty7 agents` before you touch a pane. This goes + double for `--key`: a stray `C-c` kills somebody's work. - **Never `pane close` / `tab close` / `ws rm` something you didn't create.** +- **Never `pane close --orphans`.** It closes every abandoned pane on the + machine, and an abandoned pane can still be running a real command. It is the + user's broom; point them at it, don't swing it. - **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. + a scratch pane; it takes several ids at once. `ws rm` hangs up the panes the + workspace held, so removing a scratch workspace is enough on its own. What + does leak is an interrupted `tty7 run` — that pane keeps running with nothing + referencing it, and shows up under `tty7 pane ls --all`. ## Remote machines @@ -230,9 +352,8 @@ from the GUI. ## Not wired up yet -`ws stop`, `machine connect`, `machine disconnect`, and bare `tty7 ` (GUI -launch) all exit with a message saying they're not implemented. Don't build a -plan around them. +`ws stop`, `machine connect` and `machine disconnect` exit with a message saying +they're not implemented. Don't build a plan around them. ## Full command reference diff --git a/skills/tty7/references/commands.md b/skills/tty7/references/commands.md index 828694b2..4f3c3a55 100644 --- a/skills/tty7/references/commands.md +++ b/skills/tty7/references/commands.md @@ -30,7 +30,7 @@ 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_PANE` | This pane's id, e.g. `71` or `%71` (both forms are accepted). The default target of `split`, `send`, `capture`, `procs`, `wait`, `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. | @@ -44,21 +44,25 @@ Outside a tty7 shell the address-taking verbs fail with | 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 | +| 124 | `tty7 wait` gave up — the `timeout(1)` convention, so "not yet" is distinguishable from "broken" | | 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 [PATH]` +No subcommand means the GUI. A running window is asked to come forward and open +a tab at `PATH`; if none is registered, the app is launched instead. Without +`PATH` it just activates the app. `-m` is refused — this verb drives the GUI on +*this* machine. JSON: `{"path","delivered","launched"}`. + +A word in this position that does not name a path is treated as a mistyped verb +and refused, rather than silently opening a window. + ### `tty7 ls` Same as `ws ls`. Table: `WORKSPACE NAME TABS PANES ATTACHED`. JSON: `{"workspaces":[{"id","name","tabs","panes","attached"}]}`. @@ -95,11 +99,28 @@ in the same cwd. Exactly one axis is required — `--v`/`--vertical` puts the ne 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]` +### `tty7 send [%PANE] [TEXT] [--enter] [--key KEY]…` 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"}`. +lone `%42` is rejected as a missing-text error rather than typed, unless a +`--key` gives it something to do. +JSON: `{"pane","sent","enter","keys"}`. + +`--key` presses a key instead of typing characters — the arrow keys a +permission prompt wants, the `escape` that closes a TUI, the `C-c` that stops a +build. Repeatable, delivered in order, and composable with `TEXT` (text first). + +| | | +|---|---| +| Named | `enter` `escape` `tab` `backtab` `space` `backspace` `delete` `up` `down` `right` `left` `home` `end` `pageup` `pagedown` | +| Chords | `C-` (Ctrl: `C-c`, `C-d`, `C-z`, also `C-@ C-[ C-\ C-] C-^ C-_ C-?`), `M-` (Alt = prefixed ESC) | +| Aliases | `return` `cr` `esc` `del` `bs` `shift-tab` `pgup` `pgdn` | + +Case-insensitive. An unknown name is a usage error (exit 2) raised before +anything is written, so a bad key never lands half a sequence in a live pane. +Each keystroke goes out as its own event 200 ms after the last, which is what +keeps a raw-mode TUI from reading the sequence as a paste; the first write is +not delayed, so an interrupt is immediate. ### `tty7 capture [%PANE] [--plain] [--scrollback]` The pane's replay. Two independent choices: **how much** — the newest scrollback @@ -139,13 +160,60 @@ 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. +Nothing below the depth-0 shell means the foreground command has exited — but +you rarely need to check that by hand, because that is exactly what +`tty7 wait --until free` blocks on. ### `tty7 agents` Every pane running a recognised coding agent. Table: `PANE AGENT STATUS -MESSAGE`, status one of `running` / `waiting` / `idle`. -JSON: `{"agents":[...]}`. +MESSAGE`, status one of `idle` / `working` / `waiting` / `done`. +JSON: `{"agents":[...]}`, plus `"diagnostics"` when an agent is running whose +status hooks are missing or outdated — the reason an agent can sit in one status +forever. Each diagnostic is +`{"kind":"agent_status_hooks_unavailable","agent","hooks_state","action"}`. + +### `tty7 wait [%PANE] [--until STATE,…] [--changed] [--timeout SECS] [--interval MS]` +Blocks until the pane reaches one of the named states. The orchestration +primitive: `tty7 wait %3 && tty7 capture %3 --plain`. + +| Flag | Default | | +|---|---|---| +| `--until` | `waiting,done,exit` | Comma-separated; see the states below | +| `--changed` | off | Only wake on a state the pane moved into *after* the wait began | +| `--timeout` | none | Give up after N seconds, exiting 124 | +| `--interval` | 500 | Poll interval in ms (50–3,600,000) | + +| State | Means | +|---|---| +| `idle` `working` `waiting` `done` | The agent's own status, from its hooks | +| `no-agent` | Nothing reports status here — a plain shell, or hooks not installed | +| `free` | The foreground command has exited; the pane is back to its bare shell | +| `exit` | The pane is gone. Ends every wait whether asked for or not | + +JSON: `{"pane","status","matched","stale","activity","message","session_id"}`. +`stale: true` means the pane was already in that state when the wait began, so +the answer may belong to a previous turn — which is what `--changed` refuses. + +Exit 0 = a requested state was reached; 124 = timed out; 1 = the pane exited +without reaching it (the JSON still comes, with `"matched": false`). + +Notes that decide whether a loop works: + +- **`idle` is not "the command finished".** It is something an *agent* says + about itself. A pane running a build has no agent and reports `no-agent`. + Use `free` for commands. +- **`free` costs a second request per poll**, so it is only checked when named, + and only if none of the agent states you asked for matched first — pairing + `waiting,done,free` never loses you a `waiting`. +- **`--changed` means something different for `free`**: a shell goes free → + busy → free and ends where it started, so there is no new state to compare + against. There it means "something ran while I watched" — exactly what you + want on the line after a `send`. A command fast enough to finish inside one + `--interval` is never seen running, so it times out instead; use + `--interval 100` for those, or drop `--changed` and read a sentinel file. +- **`free` reads the process tree**, so a pane whose root process is the command + itself (a `tty7 run` pane) looks free while it runs, and a backgrounded job + keeps a pane busy after the foreground command is gone. ### `tty7 events` Streams server events until interrupted, one per line — pane exits, agent @@ -159,10 +227,17 @@ socket path. JSON is the `ServerStatus` object itself (`pid`, `uptime_secs`, ### `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. +control/protocol versions match this binary, pid/uptime/panes, how many machine +links exist, and where each agent's status hooks stand. Adds a note when you are +not inside a tty7 shell. +JSON: `{"context":{"config_dir","workspace","pane"},"server":{"reachable","dialect_ok","build","status","routes"},"hooks":{"installed","outdated","not_installed"}}` +— the context fields are booleans, not values; each `hooks` field is a list of +agent slugs. + +The hooks row is what explains an agent that never moves: without hooks it +reports no status, so `tty7 agents` shows it frozen and `tty7 wait` only ever +times out. Hooks are a local install, so under `-m` the row reads `unknown` +rather than claiming a gap it cannot see. ## `ws` — workspaces @@ -181,8 +256,9 @@ lists the candidates. | `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. +`ws rm` hangs up the panes the workspace held, so removing a scratch workspace +is enough on its own. What does leak is an interrupted `tty7 run`: that pane +keeps running with nothing referencing it. `pane ls --all` finds those. Prefer `tty7 new ` over `ws new` when you want something usable: `ws new` leaves you with an empty workspace you then have to populate, while @@ -225,13 +301,24 @@ still tell a real name from a stand-in. | `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"}` | +| `pane close [%PANE…]` | close panes; their shells are hung up | `{"closed":[...]}` | +| `pane close --orphans` | close every pane no workspace holds | `{"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. +`orphan: true` means no workspace holds it. An interrupted `tty7 run` is what +leaves them. + +`close` takes several ids at once and keeps going after a failure: the rest are +still attempted, and it exits 1 with `{"closed":[...],"failed":[...]}` so you +know what is left. `--orphans` closes exactly what `pane ls --all` marks +orphaned, and reports an empty list instead of an error when there is nothing +to do. + +**`--orphans` is the user's broom, not yours.** It closes every abandoned pane +on the machine, and an abandoned pane may still be running someone's command. +Point the user at it; don't run it on your own initiative. `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 @@ -264,4 +351,3 @@ 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 ` (launch or focus the GUI) — not wired up diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 97e26f7e..845010d5 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -232,6 +232,21 @@ fn active_selection_bg(cx: &gpui::App) -> Rgb { } } +/// Blends a finished cell's colours toward `under` for a dimmed pane. The +/// blend has to happen on the *cell*, not on the palette: truecolour cells +/// (the direct `38;2;…`/`48;2;…` SGR a prompt like starship emits for its +/// segments) carry their own `Spec` colour that a palette-level dim would +/// never see, leaving a truecolour prompt at full brightness while indexed +/// content around it dims. +fn dim_cell(mut rc: RenderCell, dim: f32, under: Rgba) -> RenderCell { + rc.fg = blend_toward(rc.fg, dim, under); + rc.bg = blend_toward(rc.bg, dim, under); + if let Some(u) = rc.underline_color.as_mut() { + *u = blend_toward(*u, dim, under); + } + rc +} + /// What a search match is washed with. /// /// The theme's accent, not the terminal palette's selection colour. A hit and a @@ -259,6 +274,104 @@ struct PaintColors { bg_rgb: Rgb, } +/// The under-colour a dimmed pane blends its content toward: the window +/// background as it actually sits in the frame, i.e. the active preset fill +/// premultiplied by the window's own opacity. Dimming by blending every colour +/// toward this value (instead of alpha-multiplying each primitive) keeps the +/// composite of stacked layers — a powerline separator path over its segment +/// quad, text over a tint — exactly as dimmed as any single layer, so the +/// decorations of a prompt stay continuous when the pane goes inactive. +/// +/// The fill comes from the active preset rather than the flat +/// `theme().background` token because the workspace root paints the preset +/// (see `theme::workspace_background`): with a gradient or wallpaper preset +/// the actual backdrop is not the theme token, and blending toward the wrong +/// colour would leave a tinted slab inside the dimmed pane. Solid fills match +/// exactly; gradients are approximated by their midpoint stop; a wallpaper +/// image rides over the fill at low opacity, so the fill stays the best +/// available target. Cells whose background is not painted (the default +/// terminal background) intentionally keep showing the un-dimmed window +/// background through them, so the backdrop material stays visible. +/// +/// Two trade-offs follow from painting opaque, pre-blended colours instead of +/// the old element-opacity style. On a translucent window the desktop no +/// longer shows through a dimmed pane's painted cells — the old style left +/// them at alpha `dim` and let the backdrop contribute; the new style paints +/// them opaque, blended toward `fill × window_opacity`, which ignores the +/// backdrop's own contribution. And only the terminal element's cells are +/// dimmed: the search bar, completion menu and integration notice render +/// outside the grid and stay at full brightness, where the old style faded +/// the whole `TerminalView` — the same "the fading is worn by what it holds" +/// rule the drag grip follows. +fn dim_under(cx: &gpui::App) -> Rgba { + // Mirror `workspace_background`: the active preset fill when a preset is + // installed, the theme token otherwise. The alpha is the window opacity + // in both arms, so the premultiply is uniform. + match cx.try_global::() { + Some(bg) => fill_under(&bg.fill, bg.opacity), + None => theme_under(cx.theme().background), + } +} + +/// The premultiplied under-colour for an active preset's fill at the window's +/// own opacity. Solid fills are exact; gradients are approximated by their +/// midpoint stop (a wallpaper image rides over the fill at low opacity, so +/// the fill stays the best available target). +fn fill_under(fill: &crate::ui::presets::Fill, opacity: Option) -> Rgba { + let packed = match fill { + crate::ui::presets::Fill::Solid(c) => *c, + crate::ui::presets::Fill::Vertical { top, bottom } => { + crate::ui::presets::mix(*top, *bottom, 0.5) + } + crate::ui::presets::Fill::Horizontal { left, right } => { + crate::ui::presets::mix(*left, *right, 0.5) + } + }; + premultiplied(packed, opacity.unwrap_or(1.)) +} + +/// The premultiplied under-colour for the theme background token, used when +/// no preset is installed — the `None` arm of `workspace_background`. +fn theme_under(bg: Hsla) -> Rgba { + let bg = Rgba::from(bg); + Rgba { + r: bg.r * bg.a, + g: bg.g * bg.a, + b: bg.b * bg.a, + a: 1., + } +} + +/// The premultiplied under-colour for a packed fill colour at the window's +/// own opacity — exactly the colour a `Solid` workspace fill paints over the +/// OS backdrop, ignoring the backdrop's own contribution. +fn premultiplied(packed: u32, opacity: f32) -> Rgba { + Rgba { + r: ((packed >> 16) & 0xff) as f32 / 255. * opacity, + g: ((packed >> 8) & 0xff) as f32 / 255. * opacity, + b: (packed & 0xff) as f32 / 255. * opacity, + a: 1., + } +} + +/// Blends `c` toward `under` in RGB space, keeping `c`'s own alpha. `under` +/// is passed pre-converted to `Rgba` because it is a frame constant: every +/// cell blends fg, bg and an optional underline colour against it, and the +/// HSL↔RGB round trip would otherwise run once per colour per cell. Linear +/// in the composited result: painting `blend_toward(a)` over `blend_toward(b)` +/// equals `blend_toward(a over b)`, which is exactly what a uniform opacity +/// of `dim` over the window background would produce. +fn blend_toward(c: Hsla, dim: f32, under: Rgba) -> Hsla { + let c = Rgba::from(c); + Rgba { + r: dim * c.r + (1. - dim) * under.r, + g: dim * c.g + (1. - dim) * under.g, + b: dim * c.b + (1. - dim) * under.b, + a: c.a, + } + .into() +} + impl PaintColors { fn resolve(theme: &gpui_component::Theme, cx: &gpui::App) -> Self { let default_fg = theme.foreground; @@ -300,6 +413,28 @@ impl PaintColors { bg_rgb, } } + + /// Blends every colour toward `under` for a dimmed pane. The alpha of each + /// colour is left alone — the blend happens in RGB, so translucent tints + /// (selection, matches) composite over the already-blended cell colours + /// exactly as dimmed as opaque content does. + /// + /// `fg_rgb`/`bg_rgb` are deliberately left raw: they only feed `resolve` + /// for the named foreground/background cells, and those cells get blended + /// by `dim_cell` like every other cell — blending them here as well would + /// dim the named colours twice. + fn dimmed(&self, dim: f32, under: Rgba) -> Self { + Self { + default_fg: blend_toward(self.default_fg, dim, under), + default_bg: blend_toward(self.default_bg, dim, under), + caret: blend_toward(self.caret, dim, under), + selection_bg: blend_toward(self.selection_bg, dim, under), + match_bg: blend_toward(self.match_bg, dim, under), + current_match_bg: blend_toward(self.current_match_bg, dim, under), + fg_rgb: self.fg_rgb, + bg_rgb: self.bg_rgb, + } + } } fn paint_backgrounds(window: &mut Window, geom: &CellGeom, buf: &[RenderCell]) { @@ -1102,6 +1237,8 @@ impl TerminalElement { cols: usize, want_sliver: bool, cx: &App, + dim: f32, + under: Rgba, ) -> GridSnapshot { buf.clear(); buf.resize(rows * cols, RenderCell::default()); @@ -1154,26 +1291,34 @@ impl TerminalElement { } let rc = snapshot_cell(cell.cell, cell.point, &palette, colors, selection.as_ref()); any_selected |= rc.selected; - buf[row as usize * cols + col] = rc; + buf[row as usize * cols + col] = if dim < 1. { + dim_cell(rc, dim, under) + } else { + rc + }; } if want_sliver && (display_offset as usize) < term.grid().history_size() { let line = AlacLine(-display_offset - 1); let mut row_buf = vec![RenderCell::default(); cols]; - for (col, rc) in row_buf + for (col, slot) in row_buf .iter_mut() .enumerate() .take(term.columns().min(cols)) { let point = AlacPoint::new(line, AlacColumn(col)); - *rc = snapshot_cell( + let mut rc = snapshot_cell( &term.grid()[line][AlacColumn(col)], point, &palette, colors, selection.as_ref(), ); + if dim < 1. { + rc = dim_cell(rc, dim, under); + } any_selected |= rc.selected; + *slot = rc; } sliver = Some(row_buf); } @@ -1528,9 +1673,34 @@ impl Element for TerminalElement { let cursor_visible = self.view.read(cx).cursor_visible; let bell_flash = self.view.read(cx).bell_flash; let editor_active = self.view.read(cx).input_active(); + // The pane leaf stores its per-frame dim here (see `TerminalView::dim`). + // Blending the palette and paint colours toward the window background is + // what actually dims the pane; the pane no longer wraps the terminal in + // an element-opacity style, whose per-primitive alpha multiplication + // would leave stacked decorations with a seam against the segments + // below them. + let dim = self.view.read(cx).dim.clamp(0., 1.); + let (colors, dim, under) = if dim < 1. { + let under = dim_under(cx); + (colors.dimmed(dim, under), dim, under) + } else { + // `under` is only read while `dim < 1.` (per-cell in the grid and + // over bitmaps), so a default keeps the common rest frame from + // paying for the preset lookup and colour math. + (colors, 1., Rgba::default()) + }; let mut buf = GRID_BUF.with(|b| std::mem::take(&mut *b.borrow_mut())); - let snap = self.build_grid(&colors, &mut buf, geom.rows, geom.cols, frac > 0., cx); + let snap = self.build_grid( + &colors, + &mut buf, + geom.rows, + geom.cols, + frac > 0., + cx, + dim, + under, + ); let cursor = snap.cursor; let sliver = snap.sliver.as_ref(); @@ -1660,6 +1830,14 @@ impl Element for TerminalElement { size: size(geom.cell_width * span_cols, geom.line_height * span_rows), }; let _ = window.paint_image(bounds, Corners::default(), img.data.clone(), 0, false); + // A bitmap is a single layer, so its dim cannot come from the + // pre-blended palette; blend the image itself toward the under + // the same way the surrounding cells are blended. + if dim < 1. { + let mut c: Hsla = under.into(); + c.a = 1. - dim; + window.paint_quad(fill(bounds, c)); + } } // Evict superseded / deleted frames from the sprite atlas. Without // this a browser re-transmitting at 60fps would leak one GPU tile per @@ -1732,6 +1910,16 @@ impl Element for TerminalElement { if let Some(start) = fps_start { super::fps::record(start.elapsed()); } + + // The dim is a per-frame hand-off from the pane leaf to this paint + // (see `TerminalView::set_dim`): the pane writes it while rendering, + // this paint reads it above. Reset it here so the hand-off is + // structural — any frame that ends without a render site having set + // the dim paints at full brightness next, instead of carrying a stale + // value. A render site that forgets to set it (a maximized pane has + // no chrome to set it) therefore degrades to a single stale frame at + // worst, never a permanently dimmed terminal. + self.view.update(cx, |v, _cx| v.set_dim(1.)); } } @@ -2699,6 +2887,252 @@ mod tests { ); } + #[test] + fn blend_toward_mixes_in_rgb_space_and_keeps_alpha() { + let under = Rgba { + r: 16. / 255., + g: 18. / 255., + b: 19. / 255., + a: 1., + }; + let red = to_hsla(Rgb { + r: 218, + g: 98, + b: 125, + }); + let half = Rgba::from(blend_toward(red, 0.5, under)); + assert!((half.r - 0.5 * (218. / 255.) - 0.5 * (16. / 255.)).abs() < 1e-3); + assert!((half.g - 0.5 * (98. / 255.) - 0.5 * (18. / 255.)).abs() < 1e-3); + assert!((half.b - 0.5 * (125. / 255.) - 0.5 * (19. / 255.)).abs() < 1e-3); + assert_eq!(half.a, 1.0, "an opaque colour stays opaque"); + + // A translucent tint keeps its own alpha: only the rgb is blended. + let tint = Hsla { a: 0.24, ..red }; + let dimmed = Rgba::from(blend_toward(tint, 0.55, under)); + assert!((dimmed.a - 0.24).abs() < 1e-3, "alpha is untouched"); + assert!( + (dimmed.r - 0.55 * (218. / 255.) - 0.45 * (16. / 255.)).abs() < 1e-3, + "rgb still blends toward the under" + ); + } + + #[test] + fn dimming_a_stacked_fill_stays_continuous_with_its_segment() { + // The reported bug: a powerline separator (fill) drawn over its + // segment (bg) dimmed unevenly because the old pane element-opacity + // alpha-multiplied each layer, so the separator kept the segment's own + // dim visible through its (1 - dim) and landed with a seam against + // the segment (brighter on this palette). Blending every colour + // toward the under first keeps the composite exactly as dimmed as any + // single layer. + let under = Rgba { + r: 16. / 255., + g: 18. / 255., + b: 19. / 255., + a: 1., + }; + let segment = to_hsla(Rgb { + r: 218, + g: 98, + b: 125, + }); + let fill = to_hsla(Rgb { + r: 154, + g: 52, + b: 142, + }); + let dim = 0.55; + + let over = |top: Rgba, bottom: Rgba| Rgba { + r: top.r * top.a + bottom.r * (1. - top.a), + g: top.g * top.a + bottom.g * (1. - top.a), + b: top.b * top.a + bottom.b * (1. - top.a), + a: top.a + bottom.a * (1. - top.a), + }; + + // New rendering: both layers are pre-blended and stay opaque, so the + // fill over the segment composites to exactly the dimmed fill — the + // same value the segment itself renders as, with no seam between them. + let dimmed_segment = Rgba::from(blend_toward(segment, dim, under)); + let dimmed_fill = Rgba::from(blend_toward(fill, dim, under)); + let new_composite = over(dimmed_fill, dimmed_segment); + assert!((new_composite.r - dimmed_fill.r).abs() < 1e-6); + assert!((new_composite.g - dimmed_fill.g).abs() < 1e-6); + assert!((new_composite.b - dimmed_fill.b).abs() < 1e-6); + + // Old rendering: the pane opacity turned both layers translucent, so + // the fill showed the already-dimmed segment underneath and painted + // with a seam against the segment next to it. + let old_fill = Rgba { + a: dim, + ..Rgba::from(fill) + }; + let old_segment = Rgba { + a: dim, + ..Rgba::from(segment) + }; + let old_composite = over(old_fill, old_segment); + assert!( + old_composite.r > new_composite.r + 0.05, + "on this palette the old alpha-multiplied separator was visibly brighter than the segment" + ); + } + + #[test] + fn dim_cell_blends_truecolor_cells_toward_the_under() { + // Starship's prompt paints its segments with direct 38;2;/48;2; SGR + // colours that a palette-level dim would never see; the pane dim must + // reach those cells too, or the whole prompt would stay at full + // brightness while indexed content around it dims. + let under = Rgba { + r: 16. / 255., + g: 18. / 255., + b: 19. / 255., + a: 1., + }; + let mut cell = RenderCell::default(); + cell.c = 'x'; + cell.fg = to_hsla(Rgb { + r: 218, + g: 98, + b: 125, + }); + cell.bg = to_hsla(Rgb { + r: 154, + g: 52, + b: 142, + }); + cell.draw_bg = true; + + let dimmed = dim_cell(cell, 0.55, under); + assert_eq!(dimmed.c, 'x', "the character itself survives"); + assert!(dimmed.draw_bg, "the explicit background flag survives"); + let fg = Rgba::from(dimmed.fg); + assert!((fg.r - (0.55 * 218. + 0.45 * 16.) / 255.).abs() < 1e-3); + assert!((fg.g - (0.55 * 98. + 0.45 * 18.) / 255.).abs() < 1e-3); + assert!((fg.b - (0.55 * 125. + 0.45 * 19.) / 255.).abs() < 1e-3); + let bg = Rgba::from(dimmed.bg); + assert!((bg.r - (0.55 * 154. + 0.45 * 16.) / 255.).abs() < 1e-3); + assert_eq!(fg.a, 1.0, "opaque cell colours stay opaque"); + + // A DIM-flagged cell keeps its reduced alpha through the blend. + let mut dim_flag = RenderCell::default(); + dim_flag.fg = to_hsla(Rgb { + r: 218, + g: 98, + b: 125, + }); + dim_flag.fg.a = DIM_OPACITY; + let dimmed_flag = dim_cell(dim_flag, 0.55, under); + assert!( + (dimmed_flag.fg.a - DIM_OPACITY).abs() < 1e-3, + "the SGR dim alpha survives the pane dim" + ); + } + + #[test] + fn paint_colors_dimmed_keeps_translucent_tint_alphas() { + let colors = caret_colors(); + let under = Rgba { + r: 16. / 255., + g: 18. / 255., + b: 19. / 255., + a: 1., + }; + let dimmed = colors.dimmed(0.55, under); + assert!((dimmed.caret.a - colors.caret.a).abs() < 1e-3); + assert!( + (dimmed.selection_bg.a - colors.selection_bg.a).abs() < 1e-3, + "translucent overlays keep their own alpha so they still tint" + ); + assert_ne!(dimmed.default_fg, colors.default_fg); + // The named foreground/background rgb feed `resolve` for default cells, + // which `dim_cell` blends — pre-blending them here would double-dim. + assert_eq!(dimmed.fg_rgb, colors.fg_rgb); + assert_eq!(dimmed.bg_rgb, colors.bg_rgb); + } + + #[test] + fn premultiplied_under_scales_rgb_by_the_window_opacity_and_stays_opaque() { + // A dimmed pane blends toward the colour the workspace actually paints + // behind it: the fill scaled by the window's own opacity (premultiplied + // alpha), never toward a colour that includes the OS backdrop. + let u = premultiplied(0xda_62_7d, 0.82); + assert_eq!(u.a, 1.0, "the under must stay an opaque paint colour"); + assert!((u.r - 0.82 * (218. / 255.)).abs() < 1e-6); + assert!((u.g - 0.82 * (98. / 255.)).abs() < 1e-6); + assert!((u.b - 0.82 * (125. / 255.)).abs() < 1e-6); + // A fully opaque window keeps the fill untouched. + let opaque = premultiplied(0xda_62_7d, 1.0); + assert!((opaque.r - 218. / 255.).abs() < 1e-6); + assert!((opaque.g - 98. / 255.).abs() < 1e-6); + assert!((opaque.b - 125. / 255.).abs() < 1e-6); + } + + #[test] + fn fill_under_maps_preset_fills_to_their_premultiplied_under() { + use crate::ui::presets::Fill; + + // A solid fill is exactly the colour the workspace paints behind the + // terminal, scaled by the window's own opacity. + let solid = fill_under(&Fill::Solid(0xda_62_7d), Some(0.82)); + assert!((solid.r - 0.82 * (218. / 255.)).abs() < 1e-6); + assert!((solid.g - 0.82 * (98. / 255.)).abs() < 1e-6); + assert!((solid.b - 0.82 * (125. / 255.)).abs() < 1e-6); + assert_eq!(solid.a, 1.0); + + // A gradient is approximated by its midpoint stop (`mix` rounds each + // channel, so 0x00…ff lands on 128/255 rather than exactly 0.5). + let vertical = fill_under( + &Fill::Vertical { + top: 0x00_00_00, + bottom: 0xff_ff_ff, + }, + None, + ); + assert!((vertical.r - 128. / 255.).abs() < 1e-6); + assert_eq!(vertical.r, vertical.g); + assert_eq!(vertical.r, vertical.b); + let horizontal = fill_under( + &Fill::Horizontal { + left: 0xff_00_00, + right: 0x00_00_ff, + }, + None, + ); + assert!((horizontal.r - 128. / 255.).abs() < 1e-6); + assert!((horizontal.b - 128. / 255.).abs() < 1e-6); + + // No explicit opacity means the fill is used at full strength. + let full = fill_under(&Fill::Solid(0xda_62_7d), None); + assert!((full.r - 218. / 255.).abs() < 1e-6); + } + + #[test] + fn theme_under_premultiplies_by_the_theme_background_alpha() { + // The no-preset fallback mirrors `theme().background`, whose alpha is + // the window's own opacity. + let bg = to_hsla(Rgb { + r: 218, + g: 98, + b: 125, + }); + let mut bg = bg; + bg.a = 0.82; + let under = theme_under(bg); + assert_eq!(under.a, 1.0); + assert!((under.r - 0.82 * (218. / 255.)).abs() < 1e-6); + assert!((under.g - 0.82 * (98. / 255.)).abs() < 1e-6); + assert!((under.b - 0.82 * (125. / 255.)).abs() < 1e-6); + // An opaque theme token is used untouched. + let opaque = theme_under(to_hsla(Rgb { + r: 218, + g: 98, + b: 125, + })); + assert!((opaque.r - 218. / 255.).abs() < 1e-6); + } + #[test] fn underline_flag_bits_map_to_their_variants() { let palette = [Rgb { r: 0, g: 0, b: 0 }; 256]; diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index cdc79d9d..29b63685 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -19,6 +19,7 @@ pub(crate) mod pane_liveness; pub(crate) mod parked_cursor; mod remote; mod reverse_search; +pub(crate) mod scrollbar; pub mod search; mod signature; mod size; diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index eee2e509..4077ca3f 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -2130,11 +2130,18 @@ fn connect_routed(route: &PaneRoute) -> anyhow::Result { tty7_core::host::guard_off_ui(); - if let crate::daemon::router::RouteTarget::Wsl { distro } = &header.target { - crate::daemon::install::wsl::ensure_wsl_server(distro) - .map_err(|e| anyhow::anyhow!("prepare tty7-server in WSL `{distro}`: {e}"))?; - } - + // No `ensure_wsl_server` here on purpose. The daemon runs exactly the same + // probe inside `router::open_link` before it opens the link, so asking from + // this side too bought nothing and cost a second full round of `wsl.exe` + // invocations — five of them, serially, on every single pane. On a machine + // where a `wsl.exe` round trip is slow (issue #454 measured 3.3s) that + // duplicate was half of the wait before a new tab could take a key. + // + // Nothing is lost by dropping it: the returned path was discarded, the + // failure is reported just as well through the route ack below, and the + // first-install consent question still reaches this process — the daemon + // runs its probe under `RouteSetup::blocking`, which installs the relay + // that turns the question into a frame on this very connection. let mut stream = connect()?; let ack = crate::daemon::router::negotiate(&mut stream, header) .map_err(|e| anyhow::anyhow!("route this pane to {}: {e}", header.describe()))?; diff --git a/src/terminal/scrollbar.rs b/src/terminal/scrollbar.rs new file mode 100644 index 00000000..bba1dd05 --- /dev/null +++ b/src/terminal/scrollbar.rs @@ -0,0 +1,274 @@ +//! The scrollback bar down the right edge of a terminal pane (issue #432). +//! +//! A pane's scroll position does not live in a [`gpui::ScrollHandle`]: it is +//! alacritty's `display_offset`, counted in rows of scrollback rather than in +//! pixels of laid-out content. This handle translates between the two, so the +//! pane can hand the grid to the same [`gpui_component::scroll::Scrollbar`] the +//! sidebar and every list in the app already draw, and get their behaviour for +//! free — a thumb that appears while the view moves and fades out once it +//! stops. +//! +//! The bar never touches the terminal. [`set_offset`](ScrollbarHandle::set_offset) +//! only records the row it wants; `TerminalView::sync_scrollbar` applies that on +//! the next render and reports back where the grid actually ended up. + +use std::cell::Cell; +use std::rc::Rc; + +use gpui::{Pixels, Point, Size, point, px, size}; +use gpui_component::scroll::ScrollbarHandle; + +/// Where the grid stood the last time the pane reported in. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct GridScroll { + /// Rows of scrollback behind the viewport. + pub(crate) history: usize, + /// How many of those rows the viewport has been scrolled back over. Zero is + /// the live edge. + pub(crate) display_offset: usize, + /// Rows the viewport shows. + pub(crate) screen_lines: usize, + /// The height of one row, in logical pixels. + pub(crate) line_height: f32, +} + +impl GridScroll { + /// A row is never zero pixels tall; a zero here would only ever be the + /// default this starts life with, one render before the first layout. + fn line_height(&self) -> f32 { + self.line_height.max(1.) + } + + /// Rows that have scrolled off the top of the viewport. + fn above(&self) -> usize { + self.history.saturating_sub(self.display_offset) + } +} + +/// The pane's end of the scrollbar: a snapshot of the grid the bar reads, and a +/// row the bar asks for. +#[derive(Clone, Default)] +pub(crate) struct TerminalScrollHandle { + grid: Rc>, + /// The `display_offset` the bar wants and the pane has not applied yet. + pending: Rc>>, +} + +impl TerminalScrollHandle { + /// Report where the grid stands now. + /// + /// Scrollback piling up at the live edge is deliberately *not* reported: + /// the bar shows itself whenever the offset it reads has changed since the + /// last frame, so a pane printing a build log — its viewport pinned to the + /// bottom, its history growing under it — would hold the thumb on screen + /// for as long as the output ran. Freezing that one case keeps the bar to + /// what it is for: saying where you are once you have gone looking. Every + /// other change is taken as it comes, including the history *shrinking*, + /// which is a cleared scrollback and not growth at all. + pub(crate) fn sync(&self, live: GridScroll) { + let snap = self.grid.get(); + let pinned_growth = live.display_offset == 0 + && snap.display_offset == 0 + && live.history >= snap.history + && live.screen_lines == snap.screen_lines + && live.line_height == snap.line_height; + if !pinned_growth { + self.grid.set(live); + } + } + + /// The row the bar was dragged to, if it was dragged since the last render. + pub(crate) fn take_pending(&self) -> Option { + self.pending.take() + } + + #[cfg(test)] + fn snapshot(&self) -> GridScroll { + self.grid.get() + } +} + +impl ScrollbarHandle for TerminalScrollHandle { + fn offset(&self) -> Point { + let grid = self.grid.get(); + // Scroll offsets run negative as the content moves up past the top of + // the viewport, which is what the rows above it have done. + point(px(0.), px(-(grid.above() as f32) * grid.line_height())) + } + + fn set_offset(&self, offset: Point) { + let grid = self.grid.get(); + let above = (-offset.y.as_f32() / grid.line_height()) + .round() + .clamp(0., grid.history as f32) as usize; + let target = grid.history - above; + if target == grid.display_offset { + return; + } + // Move the snapshot with the thumb rather than waiting for the pane to + // confirm: the bar reads the offset back on the very next mouse move to + // decide where the thumb sits, and a snapshot still showing the old row + // would drag it back under the cursor. + self.grid.set(GridScroll { + display_offset: target, + ..grid + }); + self.pending.set(Some(target)); + } + + fn content_size(&self) -> Size { + let grid = self.grid.get(); + // Width is never read for a vertical-only bar, and the pane has no + // horizontal scroll to describe. + size( + px(0.), + px((grid.history + grid.screen_lines) as f32 * grid.line_height()), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn grid(history: usize, display_offset: usize) -> GridScroll { + GridScroll { + history, + display_offset, + screen_lines: 24, + line_height: 10., + } + } + + #[test] + fn the_thumb_sits_at_the_bottom_at_the_live_edge_and_at_the_top_of_the_scrollback() { + let handle = TerminalScrollHandle::default(); + + handle.sync(grid(100, 0)); + assert_eq!(handle.offset().y, px(-1000.)); + assert_eq!(handle.content_size().height, px(1240.)); + + handle.sync(grid(100, 100)); + assert_eq!( + handle.offset().y, + px(0.), + "scrolled all the way back is the top of the content" + ); + } + + #[test] + fn dragging_the_thumb_asks_the_pane_for_a_row() { + let handle = TerminalScrollHandle::default(); + handle.sync(grid(100, 0)); + + handle.set_offset(point(px(0.), px(-250.))); + assert_eq!( + handle.take_pending(), + Some(75), + "25 rows down from the top of a 100-row scrollback" + ); + assert_eq!( + handle.offset().y, + px(-250.), + "and the thumb stays where the drag put it until the pane renders" + ); + assert_eq!( + handle.take_pending(), + None, + "asked for once, not every frame" + ); + } + + #[test] + fn a_drag_past_either_end_lands_on_it() { + let handle = TerminalScrollHandle::default(); + handle.sync(grid(100, 50)); + + handle.set_offset(point(px(0.), px(400.))); + assert_eq!( + handle.take_pending(), + Some(100), + "no further back than the top" + ); + + handle.sync(grid(100, 50)); + handle.set_offset(point(px(0.), px(-9000.))); + assert_eq!( + handle.take_pending(), + Some(0), + "no further forward than the live edge" + ); + } + + #[test] + fn a_drag_that_lands_on_the_row_it_started_on_asks_for_nothing() { + let handle = TerminalScrollHandle::default(); + handle.sync(grid(100, 40)); + + // Half a row's worth of travel, which rounds back to where it was. + handle.set_offset(point(px(0.), px(-604.))); + assert_eq!(handle.take_pending(), None); + } + + #[test] + fn output_at_the_live_edge_does_not_move_the_bar() { + let handle = TerminalScrollHandle::default(); + handle.sync(grid(100, 0)); + let before = handle.offset(); + + // A screenful of new output, all of it pushing history under a viewport + // that is already at the bottom. + handle.sync(grid(124, 0)); + assert_eq!( + handle.offset(), + before, + "a streaming pane would otherwise hold the thumb on screen the whole time" + ); + assert_eq!(handle.snapshot().history, 100); + } + + #[test] + fn everything_other_than_growth_at_the_live_edge_is_reported() { + let handle = TerminalScrollHandle::default(); + + handle.sync(grid(100, 0)); + handle.sync(grid(100, 3)); + assert_eq!(handle.snapshot().display_offset, 3, "the viewport moved"); + + handle.sync(grid(140, 5)); + assert_eq!( + handle.snapshot().history, + 140, + "output arriving while scrolled back moves the rows under the thumb" + ); + + handle.sync(grid(140, 0)); + handle.sync(grid(0, 0)); + assert_eq!( + handle.snapshot().history, + 0, + "a cleared scrollback is not growth, and leaves nothing to scroll" + ); + + handle.sync(grid(0, 0)); + handle.sync(GridScroll { + screen_lines: 40, + ..grid(0, 0) + }); + assert_eq!( + handle.snapshot().screen_lines, + 40, + "a resized pane changes how much of the content is on screen" + ); + + handle.sync(GridScroll { + line_height: 18., + ..grid(0, 0) + }); + assert_eq!( + handle.snapshot().line_height, + 18., + "and so does a font-size change" + ); + } +} diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 96fbf192..401124ee 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -10,6 +10,7 @@ use gpui::{ }; use gpui_component::kbd::Kbd; use gpui_component::menu::{ContextMenuExt, PopupMenuItem}; +use gpui_component::scroll::Scrollbar; use gpui_component::{ActiveTheme as _, Icon, IconName, WindowExt as _, h_flex}; use super::TermSize; @@ -20,6 +21,7 @@ use super::highlight::{self, TokenKind}; use super::hold::{GapHold, Verdict}; use super::remote::RemoteTerminal; use super::reverse_search::{self, ReverseSearch}; +use super::scrollbar::{GridScroll, TerminalScrollHandle}; use super::search::{LinkTarget, SearchState}; use super::typeahead::{RawInput, Typeahead}; use crate::core::actions::{ @@ -171,6 +173,9 @@ pub struct TerminalView { /// to the other. zoom_debt: f32, pub(super) scroll_frac: f32, + /// The scrollback bar's end of the grid: where it thinks the viewport is, + /// and where it has asked for it to go. See [`super::scrollbar`]. + pub(super) scroll_handle: TerminalScrollHandle, pub search: Option, pub cursor_visible: bool, pub focused: bool, @@ -229,6 +234,19 @@ pub struct TerminalView { editor_drag_word: Option<(usize, usize)>, editor_goal_col: Option, pub(super) hovered_link: Option, + /// How opaque the pane wants this terminal painted this frame: 1.0 at + /// rest, [`crate::ui::pane::INACTIVE_DIM`] for an unfocused pane in a + /// split, [`crate::ui::pane::LIFTED_DIM`] while the pane is being + /// dragged. The pane leaf computes it and stores it here, because the + /// dim is applied by blending the terminal's own colours toward the + /// window background instead of the pane's element opacity — an opacity + /// style would alpha-multiply every quad and path separately, so a + /// powerline triangle stacked over a segment quad would show the + /// already-dimmed segment through its own (1-dim) alpha and land with a + /// visible seam where it meets the segment. The terminal element resets + /// the field to 1.0 at the end of every paint, so the value never + /// outlives the frame it was written for. + pub(super) dim: f32, _focus_subs: Vec, } @@ -1120,9 +1138,11 @@ impl TerminalView { scroll_debt: 0., zoom_debt: 0., scroll_frac: 0., + scroll_handle: TerminalScrollHandle::default(), search: None, cursor_visible: true, focused: true, + dim: 1., search_focused: false, search_case_sensitive: false, search_regex: false, @@ -1215,6 +1235,17 @@ impl TerminalView { self.terminal.foreground_cwd() } + /// Sets how opaque the pane wants this terminal painted; the pane leaf + /// calls this every frame while rendering, and the terminal element + /// blends its colours toward the window background during paint (see + /// [`Self::dim`] for why that beats an element-opacity style). The + /// element resets the value to 1.0 at the end of every paint, so a + /// render site that forgets to set it gets full brightness next frame — + /// never a stale dim. + pub(crate) fn set_dim(&mut self, dim: f32) { + self.dim = dim; + } + pub fn remote_context(&self) -> Option { self.terminal.remote_context() } @@ -4495,6 +4526,56 @@ impl TerminalView { false } + /// Settle up with the scrollback bar for this frame: move the viewport + /// where a drag asked for, then tell the bar where the grid ended up. + /// + /// Both halves belong here rather than in the handle, so the bar — which + /// runs from a mouse handler, with no pane to call into — never reaches + /// into the terminal behind the pane's back. + fn sync_scrollbar(&mut self) { + if let Some(target) = self.scroll_handle.take_pending() { + // Whatever the wheel had in flight was heading somewhere else. + self.cancel_scroll_anim(); + let mut term = self.terminal.term.lock(); + let delta = target as i32 - term.grid().display_offset() as i32; + if delta != 0 { + term.scroll_display(Scroll::Delta(delta)); + } + drop(term); + // A sub-line remainder left over from a smooth wheel scroll would + // paint the grid shifted off the row the thumb just picked. + self.scroll_frac = 0.; + } + let term = self.terminal.term.lock(); + let grid = GridScroll { + history: term.grid().history_size(), + display_offset: term.grid().display_offset(), + screen_lines: term.screen_lines(), + line_height: self.line_height.as_f32(), + }; + drop(term); + self.scroll_handle.sync(grid); + } + + /// The scrollback bar, laid down the right edge of the grid. + /// + /// The track is inset to the rows themselves — [`GRID_PAD_Y`] is padding + /// the grid never scrolls through, and counting it would leave the thumb + /// short of the ends by that much. + fn render_scrollbar(&self) -> impl IntoElement + use<> { + div() + .absolute() + .top(px(GRID_PAD_Y)) + .left_0() + .right_0() + .h(self.line_height * self.terminal.size().rows as f32) + // No `scrollbar_show` override: the bar takes `cx.theme()`'s, which + // `apply_theme` pins to `Scrolling` for every list in the app. A + // pane disagreeing with the sidebar about when a scrollbar is worth + // showing would be the odd one out. + .child(Scrollbar::vertical(&self.scroll_handle).id("terminal-scrollbar")) + } + fn grid_line( term: &alacritty_terminal::Term, row: usize, @@ -5245,6 +5326,7 @@ impl Drop for TerminalView { impl Render for TerminalView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.sync_typeahead_owner(); + self.sync_scrollbar(); if self.shell_owns_prompt() { if let Some((_net, bytes)) = self.hold.release() { self.terminal.write(bytes); @@ -5352,6 +5434,7 @@ impl Render for TerminalView { this.tab_pressed(false, cx); })) .child(TerminalElement::new(entity)) + .child(self.render_scrollbar()) .children(search_bar) .children(input_bar) .children(completion_menu) @@ -7717,6 +7800,78 @@ mod gpui_tests { .unwrap(); } + #[gpui::test] + fn the_scrollbar_moves_the_viewport_and_follows_it_back(cx: &mut TestAppContext) { + use gpui_component::scroll::ScrollbarHandle as _; + + let (window, mut daemon) = harness(cx); + + // Overflow the 24-row viewport so there is a scrollback to scroll. + let mut out = Vec::new(); + for i in 0..60 { + out.extend_from_slice(format!("line {i}\r\n").as_bytes()); + } + DaemonMsg::Output(out).encode(&mut daemon).unwrap(); + // Wait for the reader to go quiet, not just to start: a scrollback + // still filling underneath would move every row this test names. + let mut settled = 0; + for _ in 0..200 { + let now = window + .update(cx, |view, _, _| { + view.terminal.term.lock().grid().history_size() + }) + .unwrap(); + if now > 0 && now == settled { + break; + } + settled = now; + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + window + .update(cx, |view, _, _| { + view.sync_scrollbar(); + let history = view.terminal.term.lock().grid().history_size(); + assert!(history > 0, "the test needs a scrollback to scroll"); + let row = view.line_height.as_f32(); + assert_eq!( + view.scroll_handle.offset().y, + px(-(history as f32) * row), + "at the live edge the whole scrollback sits above the viewport" + ); + + // Drag the thumb a third of the way up its track. The bar only + // records the row; the pane applies it on its next render. + view.scroll_frac = 0.5; + view.scroll_handle + .set_offset(point(px(0.), px(-(history as f32) * row / 3.))); + assert_eq!( + view.terminal.term.lock().grid().display_offset(), + 0, + "the bar does not reach into the terminal itself" + ); + + view.sync_scrollbar(); + let offset = view.terminal.term.lock().grid().display_offset(); + assert_eq!( + offset, + history - (history as f32 / 3.).round() as usize, + "the viewport lands on the row the thumb was dropped on" + ); + assert_eq!( + view.scroll_frac, 0., + "a sub-line remainder left over from the wheel would paint \ + the grid off the row the thumb picked" + ); + assert_eq!( + view.scroll_handle.offset().y, + px(-((history - offset) as f32) * row), + "and the thumb reports the row the grid actually reached" + ); + }) + .unwrap(); + } + #[gpui::test] fn a_stale_hover_row_does_not_index_the_shrunken_grid(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx); diff --git a/src/ui/pane.rs b/src/ui/pane.rs index ee1fa5cc..30d41686 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -12,6 +12,15 @@ const MIN_RATIO: f32 = 0.1; const MAX_RATIO: f32 = 0.9; const DIVIDER_THICKNESS: f32 = 5.; +/// How opaque a dragged (lifted) pane's terminal paints, blended toward the +/// window background. The terminal reads it through `TerminalView::dim`; kept +/// here so that field's docs can point at the real numbers instead of +/// duplicating them. +pub(crate) const LIFTED_DIM: f32 = 0.45; +/// How opaque an unfocused pane in a split paints while `dim_inactive_panes` +/// is on. +pub(crate) const INACTIVE_DIM: f32 = 0.55; + #[derive(Clone)] pub enum PaneSlot { Ready(Entity), @@ -831,24 +840,18 @@ impl Pane { let focused = v.contains_focused(window, cx); let lifted = chrome.lifted == Some(id); let grip = chrome.rearrangeable && chrome.lifted.is_none(); - // Whatever fading the pane is under is worn by what it holds, - // not by the pane: a grip dimmed along with the pane it belongs - // to would be faintest on exactly the panes being reached for. - let fade = if lifted { - Some(0.45) + // The terminal renders itself at this opacity by blending its + // colours toward the window background (`TerminalView::dim`, + // whose field docs explain why that beats an element-opacity + // style). The connecting screen has no stacked content, so it + // keeps the plain opacity style. + let dim = if lifted { + LIFTED_DIM } else if chrome.dim_inactive && !focused { - Some(0.55) + INACTIVE_DIM } else { - None + 1.0 }; - let content = - div() - .size_full() - .when_some(fade, |d, f| d.opacity(f)) - .map(|d| match v { - PaneSlot::Ready(t) => d.child(t.clone()), - PaneSlot::Connecting(p) => d.child(p.clone()), - }); div() .size_full() .relative() @@ -856,7 +859,15 @@ impl Pane { .when(chrome.rearrangeable, |d| { d.pt(px(crate::ui::pane_drag::HANDLE_STRIP)) }) - .child(content) + .map(|d| match v { + PaneSlot::Ready(t) => { + t.update(cx, |v, _cx| v.set_dim(dim)); + d.child(t.clone()) + } + PaneSlot::Connecting(p) => { + d.when(dim < 1., |d| d.opacity(dim)).child(p.clone()) + } + }) .when(grip, |d| { d.child(crate::ui::pane_drag::reveal_band(id, &chrome.hovered)) .child(crate::ui::pane_drag::handle( diff --git a/src/ui/remote_workspace.rs b/src/ui/remote_workspace.rs index 4e59d511..e64b9a79 100644 --- a/src/ui/remote_workspace.rs +++ b/src/ui/remote_workspace.rs @@ -956,6 +956,12 @@ fn pump_tick(cx: &mut gpui::App) -> bool { changed = true; log::info!("link to {target} is attached"); crate::ui::machine_mirror::MachineMirrors::refresh(cx, host); + // A link this machine's windows never asked for — the switcher + // connected it, or `finish_connect` installed it — comes up + // without any reconnect attempt finishing, so nothing else + // tells the windows on it that their machine can be reached + // now. One of them may be sitting empty owing a pull. + crate::ui::tree_sync::on_link_up(cx, host); } continue; } diff --git a/src/ui/ssh_connect.rs b/src/ui/ssh_connect.rs index 615a2919..f3d2e94c 100644 --- a/src/ui/ssh_connect.rs +++ b/src/ui/ssh_connect.rs @@ -191,7 +191,13 @@ fn build_spec_inner( let mut key_passphrases: HashMap = HashMap::new(); if matches!(profile.auth, AuthMode::Auto | AuthMode::PublicKey) { - for path in &identity_files { + // Explicit files, then the same `~/.ssh` defaults the daemon probes + // (#484): it looks passphrases up by the candidate string, so both + // sides must iterate the one shared list. + for path in identity_files + .iter() + .chain(crate::core::ssh_profile::default_identity_candidates().iter()) + { let Ok(bytes) = std::fs::read(path) else { continue; }; diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index d2093f71..cea70f60 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -1812,11 +1812,7 @@ impl Tty7App { .anchor_scroll(self.switcher_anchor(Column::Left, picked)) .hover(move |r| r.bg(hover)) .child(crate::ui::tab_strip::workspace_avatar( - &row.name, - row.live, - row.current, - ROW_AVATAR, - cx, + &row.name, row.live, ROW_AVATAR, cx, )) .child( v_flex() diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 66cdb206..653d5110 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -535,7 +535,6 @@ pub(crate) const UNKNOWN_DOT: u32 = 0x9AA0A6; pub(crate) fn workspace_avatar( name: &str, live: crate::terminal::pane_liveness::Liveness, - current: bool, size: f32, cx: &App, ) -> impl IntoElement + use<> { @@ -550,6 +549,10 @@ pub(crate) fn workspace_avatar( .next() .map(|c| c.to_uppercase().to_string()) .unwrap_or_else(|| "~".to_string()); + // The disc reads the same on every row, current one included: the rows that + // are the current workspace already say so with a badge, a heavier name and + // a selected background, and dimming the disc on top of that only pushed the + // monogram under the liveness dot beside it, which is never dimmed. div() .relative() .flex_shrink_0() @@ -565,8 +568,7 @@ pub(crate) fn workspace_avatar( .text_size(px((size * 0.46).round())) .font_weight(FontWeight::MEDIUM) .text_color(cx.theme().foreground.opacity(0.65)) - .child(initial) - .when(!current, |disc| disc.opacity(0.55)), + .child(initial), ) .children(dot.map(|rgb| Tty7App::status_dot(rgb, 0, size, cx.theme().popover, false))) } diff --git a/src/ui/tree_sync.rs b/src/ui/tree_sync.rs index 05c3a2e6..bef802f0 100644 --- a/src/ui/tree_sync.rs +++ b/src/ui/tree_sync.rs @@ -714,6 +714,19 @@ struct WsState { /// from it until the pull is retried — an empty window diffs into /// "close every tab" and would wipe the layout off the machine. rehydrate: Option, + /// How many pulls in a row this window has owed, which paces the retry. + /// + /// Counts consecutive failures, so it is cleared by anything that ends the + /// run: a pull that lands (`finish_hydration`), a prime that lands + /// (`finish_prime` — the machine answered, which is the whole question), + /// and a debt abandoned rather than paid (`take_rehydrate` dropping a + /// `Replace` the user has overtaken). A machine that hiccups once is then + /// asked again promptly, and one that is really gone is not asked in a + /// loop. + /// + /// Leaving it standing after the run ends is what makes a *first* failure + /// wait the cap: the count would still be carrying an outage that is over. + rehydrate_attempts: u32, /// Whether this window has already been told why it opened empty. /// /// The retry is as quiet as the failure was, so a window whose machine @@ -736,6 +749,7 @@ impl Default for WsState { informed: false, epoch: 0, rehydrate: None, + rehydrate_attempts: 0, said_why_empty: false, } } @@ -819,7 +833,14 @@ fn take_rehydrate(cx: &mut App, client_ws: WorkspaceId, window_is_empty: bool) - .windows .get_mut(&client_ws)?; let adopt = state.rehydrate.take()?; - (window_is_empty || adopt == Adopt::IfEmpty).then_some(adopt) + if !window_is_empty && adopt == Adopt::Replace { + // Abandoned, not paid — but the run of failures is over either way, and + // a count left standing would make the next window's first failure wait + // the cap on an outage that has nothing to do with it. + state.rehydrate_attempts = 0; + return None; + } + Some(adopt) } /// Whether a window with no tabs may delete `client_ws` outright — from the @@ -1070,6 +1091,9 @@ fn finish_prime(cx: &mut App, client_ws: WorkspaceId, epoch: u64, outcome: io::R let landed = match outcome { Ok(mirror) => { state.informed |= mirror.tabs.is_empty(); + // The machine answered, which is the only thing the retry was + // waiting to find out, so the next failure starts its backoff over. + state.rehydrate_attempts = 0; let landed = (mirror.tabs.clone(), mirror.active); state.sync = SyncPhase::Primed(mirror); landed @@ -1252,7 +1276,7 @@ enum Adopt { fn hydrate(cx: &mut App, client_ws: WorkspaceId, adopt: Adopt) { let host = WorkspaceStore::host_of(cx, client_ws); let machine_ws = tree_workspace_id(cx, client_ws); - let epoch = { + let (epoch, failures) = { let state = cx .default_global::() .windows @@ -1266,22 +1290,29 @@ fn hydrate(cx: &mut App, client_ws: WorkspaceId, adopt: Adopt) { state.epoch += 1; // This attempt takes over the debt; it re-records it if it fails too. state.rehydrate = None; - state.epoch + (state.epoch, state.rehydrate_attempts) }; + // How many times in a row this window has already failed, which is what + // decides whether another failure is news or the same news again. + let level = hydration_log_level(failures, log::Level::Warn); cx.spawn(async move |cx| { let deadline = std::time::Instant::now() + HYDRATE_LINK_DEADLINE; let client = loop { match cx.update(|cx| tree_control_for(cx, host)) { TreeLink::Ready(client) => break Some(client), TreeLink::Unserved => { - log::warn!( + log::log!( + level, "workspace {client_ws}: its machine's server does not serve the \ machine tree; opening empty" ); break None; } TreeLink::Down if std::time::Instant::now() > deadline => { - log::warn!("workspace {client_ws}: no link to its machine; opening empty"); + log::log!( + level, + "workspace {client_ws}: no link to its machine; opening empty" + ); break None; } TreeLink::Down => cx.background_executor().timer(HYDRATE_LINK_POLL).await, @@ -1334,12 +1365,23 @@ fn say_why_the_window_is_empty(cx: &mut App, client_ws: WorkspaceId) { }); } -/// Records that a hydration failed and still owes `client_ws` its layout. +/// Records that a hydration failed and still owes `client_ws` its layout, and +/// arms the retry that pays it back. /// /// Nothing else recovers on its own: the window stays empty, and without this /// the next `sync_window` would push that emptiness to the machine as "close -/// every tab". Instead the pull is retried the next time the window syncs — -/// which is what a reconnect does through `on_link_up`. +/// every tab". The debt is settled by the next sync of this window — a +/// reconnect drives one through `on_link_up`, an edit in the window drives one +/// through `save_session`, and [`arm_rehydrate_retry`] drives one when neither +/// happens. +/// +/// That last driver is the load-bearing one. A pull can fail with the link +/// perfectly healthy — a `MachineGet` that overran its ten seconds on a slow +/// link, or a create that lost its race with `start_prime` — and then no link +/// ever comes back up to notice, and an empty window has nothing to edit. The +/// window sat empty until the app was restarted, with every tab and every +/// shell still on the machine: "tty7 lost my session" for a request that +/// needed asking twice. /// /// Returns whether the debt was taken on. A superseded attempt gets `false`: /// a newer hydration owns the window now, and this one speaks for nothing. @@ -1354,45 +1396,162 @@ fn owe_rehydration(cx: &mut App, client_ws: WorkspaceId, epoch: u64, adopt: Adop *priming = false; } state.rehydrate = Some(adopt); - log::info!("workspace {client_ws}: will pull its layout again once its machine answers"); + state.rehydrate_attempts = state.rehydrate_attempts.saturating_add(1); + let attempts = state.rehydrate_attempts; + log::log!( + // Once settled this line says the same thing every thirty seconds until + // the window closes, which is a fact about the machine and not an event. + hydration_log_level(attempts, log::Level::Info), + "workspace {client_ws}: will pull its layout again once its machine answers \ + (attempt {attempts})" + ); + arm_rehydrate_retry(cx, client_ws, epoch, attempts); true } +/// Whether the debt this retry was armed for is still the window's own. +/// +/// A newer epoch means another hydration took the window over while the +/// backoff ran, and this retry speaks for nothing. +fn still_owed(cx: &App, client_ws: WorkspaceId, epoch: u64) -> bool { + cx.try_global::() + .and_then(|t| t.windows.get(&client_ws)) + .is_some_and(|s| s.rehydrate.is_some() && s.epoch == epoch) +} + +/// The attempt from which the backoff no longer grows. +/// +/// Also the point where a window stops being a fresh failure and becomes a +/// standing one, which is what [`hydration_log_level`] keys off. +const REHYDRATE_SETTLED: u32 = 5; +const REHYDRATE_BACKOFF_CAP: std::time::Duration = std::time::Duration::from_secs(30); + +/// The first retry is soon enough to look instant to someone watching an empty +/// window; the backoff is what keeps a machine that is really unreachable from +/// being asked on a loop for as long as its window stays open. +fn rehydrate_backoff(attempts: u32) -> std::time::Duration { + std::time::Duration::from_secs(2u64.saturating_pow(attempts.min(REHYDRATE_SETTLED))) + .min(REHYDRATE_BACKOFF_CAP) +} + +/// Steps `fresh` down to `debug` once this window's failures have stopped being +/// events and become a standing condition. +/// +/// The first few are news: something that was working stopped. Once the backoff +/// has settled at its cap the window is in a steady state — a machine that is +/// simply not there — and the retry will go on failing every thirty seconds for +/// as long as the window stays open. Repeating that at full volume buries +/// whatever else is in the log. The retry stays exactly as persistent either +/// way; only the volume drops. +fn hydration_log_level(attempts: u32, fresh: log::Level) -> log::Level { + if attempts >= REHYDRATE_SETTLED { + log::Level::Debug + } else { + fresh + } +} + +/// Asks `client_ws` to sync once the backoff is up, if it still owes a pull. +/// +/// Deliberately routed through `sync_window` rather than straight into +/// `hydrate`: that is where the rules about *whether* a window may still adopt +/// the machine's layout live — a preempted workspace stays out of it, and a +/// `Replace` is dropped once the user has filled the window in themselves. +fn arm_rehydrate_retry(cx: &mut App, client_ws: WorkspaceId, epoch: u64, attempts: u32) { + let delay = rehydrate_backoff(attempts); + cx.spawn(async move |cx| { + cx.background_executor().timer(delay).await; + let _ = cx.update(|cx| { + if !still_owed(cx, client_ws, epoch) { + return; + } + // No window left to fill, so asking its machine now would be work + // for nobody. Closing a window drops its whole `WsState` through + // `forget`, debt and all, so `still_owed` above normally answers + // first; this covers the window that is on its way out and has + // already dropped its app. + let Some(app) = crate::ui::windows::WindowRegistry::app_for(cx, client_ws) + .and_then(|app| app.upgrade()) + else { + return; + }; + app.update(cx, |app, cx| sync_window(app, cx)); + }); + }) + .detach(); +} + fn pull_workspace( client: &ControlClient, machine_ws: WorkspaceId, ) -> io::Result<(Machine, WsMirror, Session)> { - let machine: Machine = match client.call(ControlRequest::MachineGet)? { - ReplyOk::MachineTree(m) => *m, - other => return Err(io::Error::other(format!("MachineGet answered {other:?}"))), + let machine = match layout_of(machine_get(client)?, machine_ws) { + Ok(pulled) => return Ok(pulled), + Err(machine) => machine, }; - match machine.workspaces.iter().find(|w| w.id == machine_ws) { - Some(ws) => { - let mirror = WsMirror { - tabs: ws.tabs.clone(), - active: ws.active_tab, - }; - let session = session_from_tree(ws, &machine.panes); - Ok((machine, mirror, session)) - } - None => { - // The whole tree is already in hand, so the taken names can be read - // straight off it rather than passed down from the main thread. - let taken: Vec<&str> = machine - .workspaces - .iter() - .filter_map(|w| w.name.as_deref()) - .collect(); - let name = tty7_core::core::codename::unique(|n| taken.contains(&n)); - client.call(ControlRequest::WorkspaceCreate { - name: Some(name), - workspace: Some(machine_ws), - })?; - Ok((machine, WsMirror::default(), Session::default())) + // The whole tree is already in hand, so the taken names can be read + // straight off it rather than passed down from the main thread. + let taken: Vec<&str> = machine + .workspaces + .iter() + .filter_map(|w| w.name.as_deref()) + .collect(); + let name = tty7_core::core::codename::unique(|n| taken.contains(&n)); + match client.call(ControlRequest::WorkspaceCreate { + name: Some(name), + workspace: Some(machine_ws), + }) { + Ok(_) => Ok((machine, WsMirror::default(), Session::default())), + // Losing this create is not a failed hydration. Opening a remote + // workspace runs two pulls at once — this one and `start_prime`'s — + // and both create when the tree they read did not hold it yet, so the + // loser is told it already exists. The workspace the create was for is + // on the machine either way, and it may already hold tabs: read the + // tree again and hydrate from what is really there. Treating this as a + // failure left the window empty over a workspace that was fine. + // + // Any refusal is worth the second look, not just "already exists": what + // matters is whether the workspace is there now, and the tree answers + // that better than the error text does. If it still is not there, the + // create's own refusal is the honest error to report — the reread + // happened on its behalf and has nothing of its own to say. + Err(refused) => { + log::debug!( + "workspace {machine_ws} could not be created ({refused}); reading the tree \ + again in case something else created it first" + ); + match machine_get(client) { + Ok(machine) => layout_of(machine, machine_ws).map_err(|_| refused), + Err(_) => Err(refused), + } } } } +fn machine_get(client: &ControlClient) -> io::Result { + match client.call(ControlRequest::MachineGet)? { + ReplyOk::MachineTree(m) => Ok(*m), + other => Err(io::Error::other(format!("MachineGet answered {other:?}"))), + } +} + +/// This workspace's layout as `machine` has it, or the tree handed back +/// untouched when the machine does not hold the workspace at all. +fn layout_of( + machine: Machine, + machine_ws: WorkspaceId, +) -> Result<(Machine, WsMirror, Session), Machine> { + let Some(ws) = machine.workspaces.iter().find(|w| w.id == machine_ws) else { + return Err(machine); + }; + let mirror = WsMirror { + tabs: ws.tabs.clone(), + active: ws.active_tab, + }; + let session = session_from_tree(ws, &machine.panes); + Ok((machine, mirror, session)) +} + fn finish_hydration( cx: &mut App, client_ws: WorkspaceId, @@ -1412,7 +1571,15 @@ fn finish_hydration( let (machine, mirror, session) = match outcome { Ok(pulled) => pulled, Err(e) => { - log::warn!("could not hydrate workspace {client_ws} from its machine: {e}"); + let failures = cx + .default_global::() + .windows + .get(&client_ws) + .map_or(0, |s| s.rehydrate_attempts); + log::log!( + hydration_log_level(failures, log::Level::Warn), + "could not hydrate workspace {client_ws} from its machine: {e}" + ); let _ = owe_rehydration(cx, client_ws, epoch, adopt); return; } @@ -1427,6 +1594,8 @@ fn finish_hydration( let dirty = matches!(state.sync, SyncPhase::Unprimed { dirty: true, .. }); state.informed |= machine_was_empty; state.sync = SyncPhase::Primed(mirror); + // The machine answered, so the next failure starts its backoff over. + state.rehydrate_attempts = 0; // The machine answered, so the explanation has been overtaken by events // and a later outage deserves its own. state.said_why_empty = false; @@ -2150,6 +2319,193 @@ mod tests { }); } + /// The debt an owed pull records is worth nothing without something that + /// pays it. A pull can fail with the link up and healthy — a `MachineGet` + /// past its deadline on a slow link, a create that lost its race — and + /// then no reconnect ever happens to notice, and an empty window has no + /// edit in it to drive a sync. The window sat there empty, with every tab + /// still on the machine, until the app was restarted. + #[gpui::test] + fn an_owed_pull_is_retried_until_it_is_paid_or_superseded(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + let ws = WorkspaceId::new(); + let epoch = cx + .default_global::() + .windows + .entry(ws) + .or_default() + .epoch; + owe_rehydration(cx, ws, epoch, Adopt::IfEmpty); + assert!( + still_owed(cx, ws, epoch), + "the retry armed for this debt must still recognise it" + ); + + // Paid: the pull landed, so the retry that is still in flight has + // to stand down rather than replay the machine over the window. + cx.default_global::() + .windows + .get_mut(&ws) + .expect("owed above") + .rehydrate = None; + assert!(!still_owed(cx, ws, epoch)); + + // Superseded: a newer hydration owns the window now. + let state = cx + .default_global::() + .windows + .get_mut(&ws) + .expect("owed above"); + state.rehydrate = Some(Adopt::IfEmpty); + state.epoch += 1; + assert!(!still_owed(cx, ws, epoch)); + assert!(still_owed(cx, ws, epoch + 1)); + }); + } + + #[test] + fn the_retry_backs_off_and_settles_at_a_cap() { + let secs = |n| rehydrate_backoff(n).as_secs(); + assert_eq!(secs(1), 2, "the first retry is prompt: a window is empty"); + assert!( + secs(1) < secs(2) && secs(2) < secs(3), + "a machine that keeps refusing must be asked less often, not more" + ); + assert_eq!(secs(REHYDRATE_SETTLED), 30); + assert_eq!( + secs(50), + 30, + "a window left open on an unreachable machine settles at the cap" + ); + } + + /// Once the backoff stops growing the same failure repeats every thirty + /// seconds for as long as the window stays open. Reporting each one at full + /// volume turns one unreachable machine into a log nobody can read past. + #[test] + fn a_standing_failure_stops_shouting_once_the_backoff_settles() { + assert_eq!( + hydration_log_level(1, log::Level::Warn), + log::Level::Warn, + "the first failures are news and must stay news" + ); + assert_eq!( + hydration_log_level(REHYDRATE_SETTLED, log::Level::Warn), + log::Level::Debug + ); + assert_eq!( + hydration_log_level(REHYDRATE_SETTLED, log::Level::Info), + log::Level::Debug, + "the step down is to debug from wherever it started, not to warn" + ); + } + + /// The count paces the retry, so it has to mean "failures in a row". Left + /// standing after the run ends, it makes the next *first* failure wait the + /// cap on an outage that was already over. + #[gpui::test] + fn the_backoff_count_ends_with_the_run_of_failures(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + let _ = tty7_core::core::config::set_config_dir( + std::env::temp_dir().join(format!("tty7-backoff-count-{}", std::process::id())), + ); + let view = crate::core::session::WindowView::default(); + let ws = view.id; + WorkspaceStore::install_for_test( + cx, + crate::core::session::WindowViews { + views: vec![view], + active: Some(ws), + }, + ); + let unprimed = |cx: &mut App| { + cx.default_global::() + .windows + .entry(ws) + .or_default() + .sync = SyncPhase::Unprimed { + dirty: false, + priming: true, + }; + }; + let attempts = + |cx: &mut App| cx.default_global::().windows[&ws].rehydrate_attempts; + + unprimed(cx); + let epoch = cx.default_global::().windows[&ws].epoch; + for expected in 1..=3 { + unprimed(cx); + owe_rehydration(cx, ws, epoch, Adopt::IfEmpty); + assert_eq!( + attempts(cx), + expected, + "each failure in the run paces the next" + ); + } + + // The machine answered. Whatever it was, it is over. + unprimed(cx); + finish_prime(cx, ws, epoch, Ok(WsMirror::default())); + assert_eq!( + attempts(cx), + 0, + "a prime landing is the machine answering, which is the whole question" + ); + + // Abandoned rather than paid: the user filled the window in + // themselves, so the `Replace` is dropped — and the run is over too. + { + let state = cx + .default_global::() + .windows + .get_mut(&ws) + .unwrap(); + state.rehydrate = Some(Adopt::Replace); + state.rehydrate_attempts = 4; + } + assert!(take_rehydrate(cx, ws, false).is_none()); + assert_eq!( + attempts(cx), + 0, + "a debt nobody owes any more cannot go on pacing the next one" + ); + }); + } + + /// The retry fires on a timer, so the window it was armed for can be gone + /// by the time it runs. It has to notice and stand down — and leave the + /// debt where it is, because a window that is not there is not one that + /// has been paid. + #[gpui::test] + async fn a_retry_that_finds_no_window_stands_down(cx: &mut gpui::TestAppContext) { + let ws = cx.update(|cx| { + crate::ui::windows::WindowRegistry::init(cx); + let ws = WorkspaceId::new(); + let epoch = cx + .default_global::() + .windows + .entry(ws) + .or_default() + .epoch; + owe_rehydration(cx, ws, epoch, Adopt::IfEmpty); + ws + }); + + // Well past the first backoff: the armed retry really runs, rather than + // the test ending while it is still asleep. + cx.executor().advance_clock(rehydrate_backoff(1) * 2); + cx.executor().run_until_parked(); + + cx.update(|cx| { + assert!( + cx.default_global::().windows[&ws] + .rehydrate + .is_some(), + "the debt outlives a retry that found nothing to pay it into" + ); + }); + } + #[gpui::test] fn a_window_that_filled_up_while_owed_keeps_what_it_has(cx: &mut gpui::TestAppContext) { cx.update(|cx| {