Merge branch 'main' into fix/creds-selfheal

`feat(ssh): probe ~/.ssh default identity keys (#507)` refactored the same
function #486 changes, so the passphrase self-heal is re-expressed in the new
shape rather than bolted back on.

Main split the decode step out of `try_identity_file` into
`load_identity(contents, raw_path, source, cached) -> IdentityLoad`, keyed on a
new `KeySource`: an explicit key's failures are said aloud and may ask for a
passphrase, a discovered `~/.ssh` default's are silent, because russh can only
try an encrypted key by signing and a prompt for a key nobody asked for is a
prompt the server may not even want.

#486's retry now lives in that matrix. `IdentityLoad::NeedsPassphrase` carries
a `rejected` flag, and an explicit key whose *cached* passphrase does not open
the file returns `NeedsPassphrase { rejected: true }` instead of `Unusable` —
one prompt path serves both ways of getting there, the typed answer still gets
its attempt, and a typed answer that fails is the hard failure it always was,
via `round.unusable`. A discovered key keeps skipping in silence whether its
cached passphrase is missing or stale: the self-heal must not turn one stale
`~/.ssh` keychain entry into a sheet on every connection, so only explicit keys
drive it. `no_discovered_key_ever_asks_for_a_passphrase` pins that seam.
This commit is contained in:
l0ng-ai
2026-08-11 21:26:27 +08:00
73 changed files with 8029 additions and 652 deletions
+22 -2
View File
@@ -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"
+70 -13
View File
@@ -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 `<name>.tty7-upload-<hex>` 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.
+9 -2
View File
@@ -17,6 +17,10 @@
<sub>English · [简体中文](README.zh-CN.md)</sub>
<br />
<img src="assets/hero.webp" alt="tty7 with a tab sidebar of agent sessions across several repos, running Claude Code" width="900" />
</div>
## 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:
+8 -1
View File
@@ -17,6 +17,10 @@
<sub>[English](README.md) · 简体中文</sub>
<br />
<img src="assets/hero.webp" alt="tty7 侧边栏列出多个仓库的 agent 会话,右侧运行 Claude Code" width="900" />
</div>
## 为什么
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

+9 -1
View File
@@ -90,6 +90,10 @@ pub mod mock {
pub capture_segments: Vec<CaptureSegment>,
pub procs_calls: Vec<u64>,
pub procs_reply: PaneProcs,
/// Consumed one per call before falling back to `procs_reply`, so a
/// test can script a pane going busy and then quiet again — which is
/// the whole of what `wait --until free` watches for.
pub procs_replies: VecDeque<PaneProcs>,
pub agent_hooks_states: Vec<(HookAgent, HooksState)>,
pub registry: Vec<PaneInfo>,
pub killed: Vec<u64>,
@@ -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<PaneProcs> {
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<HooksState> {
+114 -14
View File
@@ -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<String>,
#[arg(value_name = "TEXT")]
pub second: Option<String>,
#[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<crate::keys::Key>,
}
/// 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<WaitState>,
@@ -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<String>,
#[arg(value_name = "%PANE", help = "Panes to close; defaults to $TTY7_PANE")]
targets: Vec<String>,
// `pane ls --all` has been able to *show* the panes an interrupted
// `run` leaves behind for a while, and the only way to act on that was
// to read ids off the table and close them one at a time. The CLI
// creates these; it should be able to clear them.
#[arg(
long,
conflicts_with = "targets",
help = "Close every pane no workspace holds — what an interrupted `run` \
leaves behind. Lists them; pass --json for the ids"
)]
orphans: bool,
},
}
@@ -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]
+781 -48
View File
@@ -86,8 +86,8 @@ pub fn execute(cli: Cli, ctx: &Context, backend: &mut dyn Backend) -> Result<Out
Some(Command::Tab(TabCmd::Rename { tab, name })) => 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<Outcome> {
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::<Vec<_>>(),
}),
)
}
@@ -734,27 +769,112 @@ fn pane_ls_all(backend: &mut dyn Backend) -> Result<Outcome> {
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 %<id>` stops one\n"
"\n{orphans} pane(s) held by no workspace — `tty7 pane close %<id>` 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<Outcome> {
let pane = address::pane_or_context(target, ctx)?;
fn pane_close(
targets: &[String],
orphans: bool,
ctx: &Context,
backend: &mut dyn Backend,
) -> Result<Outcome> {
// 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::<Result<Vec<_>>>()?
};
// 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::<Vec<_>>()
.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<Vec<u64>> {
let held: Vec<u64> = 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<Outcome> {
@@ -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<Outcome> {
use std::time::{Duration, Instant};
@@ -821,7 +949,11 @@ fn wait(args: WaitArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outc
.timeout
.and_then(|t| Instant::now().checked_add(Duration::from_secs(t)));
let interval = Duration::from_millis(args.interval);
let watch_free = args.until.contains(&WaitState::Free);
let mut baseline: Option<Cursor> = 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<Outc
AgentStatus::Waiting => 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<Outc
// position we arrived at is last turn's answer until the agent moves.
let cursor: Cursor = entry.as_ref().map(|e| (e.state.status, e.state.activity));
let baseline = *baseline.get_or_insert(cursor);
let changed = cursor != baseline;
let mut changed = cursor != baseline;
// `free` is a fact about the process tree, not the agent ladder, so it
// is asked separately, only when requested, and only once the ladder
// has failed to answer. A state the caller listed is their answer:
// overwriting a real `waiting` with a process-tree fact would strand a
// pane whose depth-0 process *is* the agent (see `pane_is_free`), where
// the tree reads free for the whole turn.
if watch_free && current != WaitState::Exit && !args.until.contains(&current) {
if pane_is_free(backend, pane)? {
current = WaitState::Free;
changed = seen_busy;
} else {
seen_busy = true;
}
}
let mut matched = args.until.contains(&current) && (changed || !args.changed);
polls += 1;
@@ -898,17 +1047,43 @@ fn wait(args: WaitArgs, ctx: &Context, backend: &mut dyn Backend) -> Result<Outc
human.push_str(&format!("{msg}"));
}
if !changed {
human.push_str(" (unchanged since the wait began)");
human.push_str(match current {
WaitState::Free => " (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<Outc
}
}
/// Whether the pane is back to its bare shell — nothing running in front of it.
///
/// Depth, not count: the pane's own shell sits at depth 0 and everything it
/// launched hangs below, so "nothing deeper than the shell" holds however many
/// shells the pane ended up with, and does not have to guess at process names.
/// It is also the portable question — Windows has no foreground process group
/// to ask about, so `ProcEntry::foreground` is never true there.
///
/// What it cannot see, both by construction: a pane whose depth-0 process *is*
/// the command — which is what `tty7 run` spawns — reads free for as long as it
/// runs, and a backgrounded job keeps a pane busy after the foreground command
/// is long gone. An empty tree is "we could not see in" rather than "free":
/// answering free there would be the same false success `no-agent` exists to
/// remove.
fn pane_is_free(backend: &mut dyn Backend, pane: u64) -> Result<bool> {
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<bool> {
@@ -1089,6 +1283,7 @@ fn doctor(ctx: &Context, backend: &mut dyn Backend) -> Result<Outcome> {
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<Outcome> {
"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<Outcome> {
"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}");
}
}
+295
View File
@@ -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<u8>,
}
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-<char> (Ctrl), M-<char> (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<Key, String> {
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<u8, String> {
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<u8> {
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-<char>") && help.contains("M-<char>"),
"{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}");
}
}
+1
View File
@@ -3,6 +3,7 @@ mod backend;
mod cli;
mod commands;
mod gui;
mod keys;
mod output;
mod resolve;
mod screen;
+239 -2
View File
@@ -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<String> = 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<String> = 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<String> {
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<String> {
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<PathBuf> {
#[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<Vec<String>> {
let guids = registry_user_subkeys(LXSS)?;
let names: Vec<String> = 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<u32> {
use windows_sys::Win32::System::Registry::{HKEY_CURRENT_USER, RRF_RT_REG_DWORD, RegGetValueW};
fn wide(s: &str) -> Vec<u16> {
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::<u32>() 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<Vec<String>> {
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<u16> = 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<Vec<String>> {
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<String> {
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()
}
+67 -11
View File
@@ -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<String> {
#[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<String> {
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<String> {
["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");
+244
View File
@@ -565,10 +565,100 @@ fn install_lock(distro: &str) -> Arc<Mutex<()>> {
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<Vec<(String, Proved)>> = 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<super::MismatchedRemoteDaemon>,
}
fn remembered(distro: &str) -> Option<Proved> {
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<String> {
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<String> {
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<String> {
""
},
);
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");
+51 -19
View File
@@ -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<String> {
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();
+582 -102
View File
@@ -439,10 +439,36 @@ async fn try_publickeys(
broker: &Arc<PromptBroker>,
) -> Outcome {
let mut last: Option<MethodSet> = 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<String> = 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<String> {
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<String>,
explicit: &[String],
canon: impl Fn(&str) -> Option<String>,
) -> Vec<String> {
let mut seen: std::collections::HashSet<String> =
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<String>,
/// File keys the server rejected, same spelling.
rejected_files: Vec<String>,
/// 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<String>,
/// Transport-level errors after a key was decoded.
errors: Vec<String>,
}
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<String> = 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<PromptBroker>,
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<String> {
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<ClientHandler>, spec: &NativeSshSpec) -> Outcome {
async fn try_agent(
handle: &mut Handle<ClientHandler>,
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<ClientHandler>, 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<S>(
handle: &mut Handle<ClientHandler>,
spec: &NativeSshSpec,
mut agent: AgentClient<S>,
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<MethodSet> = 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<HashAlg> {
}
}
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<String> {
std::env::var("HOME").ok().filter(|h| !h.is_empty())
}
#[cfg(not(unix))]
fn home_dir() -> Option<String> {
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"
);
}
}
+190
View File
@@ -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.
<Warning>
`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.
</Warning>
<Note>
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.
</Note>
### 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
<Warning>
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.
</Warning>
- **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).
+107
View File
@@ -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.
<Frame caption="Placeholder — screenshot: a sidebar of agent sessions across several repos, each with a brand avatar and status dot">
<img src="/images/placeholder.svg" alt="Agent sessions in the tty7 sidebar" />
</Frame>
## 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:
<CardGroup cols={2}>
<Card title="Brand avatars" icon="id-badge">
The tab chip and sidebar row show which agent runs where, so ten tabs stay
legible.
</Card>
<Card title="Git context" icon="code-branch">
The branch and working-tree diff on the row, refreshed as the agent works.
</Card>
<Card title="Session resume" icon="rotate-left">
A pane lost to a reboot relaunches the conversation, carrying its original
flags. [More →](/agents/sessions)
</Card>
<Card title="Context feed" icon="paper-plane">
Palette commands that hand the current selection or the repo's `git diff` to
the running agent as a prompt.
</Card>
</CardGroup>
## 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
<CardGroup cols={3}>
<Card title="Status and notifications" icon="circle-dot" href="/agents/status">
Hooks, status dots, the tray icon.
</Card>
<Card title="Sessions" icon="code-branch" href="/agents/sessions">
Resume, fork, and copying a session id.
</Card>
<Card title="Orchestration" icon="diagram-project" href="/agents/orchestration">
One agent driving another with `tty7 wait`.
</Card>
</CardGroup>
+60
View File
@@ -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`.
<Note>
Resume needs the agent's hooks installed, since the session id comes from
them. [Installing hooks →](/agents/status)
</Note>
## 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 <id> --fork-session` |
| Codex | `codex fork <id>` |
| Grok | `grok --resume <id> --fork-session` |
| OpenCode | `opencode --session <id> --fork` |
| Oh My Pi | `omp --fork <id>` |
It is the agent's own fork command, run in a new pane — nothing is copied by
tty7 itself.
<Warning>
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.
</Warning>
## 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.
+88
View File
@@ -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.
<Note>
The hooks only do anything inside tty7. Running the same agent in another
terminal is unaffected.
</Note>
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 |
<Frame caption="Placeholder — screenshot: three sidebar rows, one working, one waiting, one done">
<img src="/images/placeholder.svg" alt="Agent status dots" />
</Frame>
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.
+84
View File
@@ -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.
<Note>
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)
</Note>
## 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
<Warning>
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.
</Warning>
- **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).
+147
View File
@@ -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.
<Note>
Everything after `--` belongs to the child: `tty7 run -- cargo test --keep`
passes `--keep` to cargo, not to tty7.
</Note>
### 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.
<Warning>
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
```
</Warning>
## 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)
<Card title="Full command reference" icon="book" href="/cli/reference">
Every verb, flag, and JSON shape.
</Card>
+321
View File
@@ -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 <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-<char>` (Ctrl, e.g. `C-c`), `M-<char>` (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 (503,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"}` |
<Warning>
`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.
</Warning>
Prefer `tty7 new <path>` 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: `@<uuid>`.
| 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.
<Warning>
`--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.
</Warning>
`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 |
<Warning>
Do not run `start`, `stop`, or `restart` on someone else's behalf. They change
or destroy what the user's GUI is attached to.
</Warning>
## 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
+84
View File
@@ -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 (1224) |
| **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
<Info>
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.
</Info>
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.
+94
View File
@@ -0,0 +1,94 @@
---
title: "Keybindings"
description: "Rebinding anything, chord sequences, and the tmux preset."
---
**Settings → Keybindings** (<kbd>⌘ ,</kbd>) 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 — <kbd>⌃ B</kbd> then <kbd>X</kbd> |
| <kbd>Esc</kbd> | Cancel |
| <kbd>⌫</kbd> | 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.
<Frame caption="Placeholder — screenshot: the Keybindings page mid-capture, showing “Press keys…”">
<img src="/images/placeholder.svg" alt="Rebinding a shortcut" />
</Frame>
## 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` | <kbd>⌘</kbd> on macOS, <kbd>Ctrl</kbd> 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 — <kbd>⌃ B</kbd> by default, changeable in the **Prefix** field beside
it.
| | |
|---|---|
| <kbd>⌃ B</kbd> <kbd>C</kbd> · <kbd>X</kbd> | New tab · close tab |
| <kbd>⌃ B</kbd> <kbd>%</kbd> · <kbd>"</kbd> | Split right · split down |
| <kbd>⌃ B</kbd> <kbd>←→↑↓</kbd> | Move focus |
| <kbd>⌃ B</kbd> <kbd>⌃ ←→↑↓</kbd> | Resize the pane |
| <kbd>⌃ B</kbd> <kbd>O</kbd> · <kbd>;</kbd> | Next pane · previous pane |
| <kbd>⌃ B</kbd> <kbd>&#123;</kbd> · <kbd>&#125;</kbd> | Swap with the previous · next pane |
| <kbd>⌃ B</kbd> <kbd>Z</kbd> | Zoom the pane |
| <kbd>⌃ B</kbd> <kbd>N</kbd> · <kbd>P</kbd> | Next tab · previous tab |
| <kbd>⌃ B</kbd> <kbd>1</kbd>…<kbd>9</kbd> | Jump to a tab |
Two details that make it livable:
- A **bare prefix** reaches the shell after about a second, so <kbd>⌃ B</kbd>
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
| | |
|---|---|
| <kbd>⇧ ⏎</kbd> · <kbd>⌥ ⏎</kbd> | Insert a newline at the prompt instead of submitting (`InsertNewline`) |
| <kbd>⌘ ⇧ ⏎</kbd> | Zoom the focused pane |
| <kbd>⌘ ⇧ E</kbd> | Toggle the code panel |
| <kbd>⌘ ⇧ R</kbd> | Restart the SSH session in this pane |
| <kbd>⌘ ⇧ O</kbd> | Workspace switcher |
| <kbd>⌘ ⇧ N</kbd> | New workspace |
+85
View File
@@ -0,0 +1,85 @@
---
title: "Settings"
description: "What lives in each section, and how the settings file works."
---
<kbd>⌘ ,</kbd> 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.
<Frame caption="Placeholder — screenshot: the Settings window with the section list on the left">
<img src="/images/placeholder.svg" alt="tty7 Settings" />
</Frame>
## The eight sections
<CardGroup cols={2}>
<Card title="Appearance" icon="palette" href="/customization/themes">
Theme, sync with system, typography, cursor, transparency, language.
</Card>
<Card title="Terminal" icon="terminal">
Shell and start directory, scrollback and scrolling, mouse, bell, per-pane
history.
</Card>
<Card title="Input" icon="keyboard">
Prompt features, selection & clipboard, keyboard (Option as Meta), links.
</Card>
<Card title="SSH" icon="server" href="/remote/ssh">
Hosts, defaults, security, and every per-profile field.
</Card>
<Card title="Agents" icon="robot" href="/agents/status">
Hook installation per agent and per machine, and the CLI on PATH.
</Card>
<Card title="Window & Tabs" icon="window-maximize">
Startup window, tab bar position and grouping, notifications, tray icon.
</Card>
<Card title="Keybindings" icon="command" href="/customization/keybindings">
Every shortcut, the tmux preset, the prefix.
</Card>
<Card title="About" icon="circle-info" href="/reference/updates">
Version, update channel, and the updater.
</Card>
</CardGroup>
## 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.
<Note>
A UTF-8 BOM at the start of the file is tolerated, which matters if you edited
it in a Windows editor.
</Note>
## 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" }
```
+113
View File
@@ -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 |
<Frame caption="Placeholder — screenshot: the theme picker with light and dark sections">
<img src="/images/placeholder.svg" alt="The tty7 theme picker" />
</Frame>
## 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.21.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.
<Note>
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.
</Note>
+142
View File
@@ -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"
}
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

-158
View File
@@ -1,158 +0,0 @@
# Features
<sub>English · [简体中文](features.zh-CN.md)</sub>
## Input
- **Ghost suggestions** — your history completes the whole line as you type; <kbd>→</kbd> 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** — <kbd>⌃ R</kbd> shows what you ran, where, and whether it failed; turn it off (Settings → Input → Prompt, or `history_search` in `config.json`) and <kbd>⌃ R</kbd> 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. <kbd>⇧ ⏎</kbd> · <kbd>⌥ ⏎</kbd> insert a newline instead of submitting (rebindable as `InsertNewline`); a plain <kbd>⏎</kbd> 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** <kbd>⌘ P</kbd> · scrollback search <kbd>⌘ F</kbd>
- **⌘/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 <id>`, `claude --resume <id> --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 <kbd>⌘</kbd> as
<kbd>Ctrl</kbd>. The essentials:
| | |
|---|---|
| <kbd>⌘ T</kbd> · <kbd>⌘ W</kbd> · <kbd>⌘ ⇧ T</kbd> | new tab · close tab · reopen closed tab |
| <kbd>⌘ 1</kbd>…<kbd>⌘ 9</kbd> | jump to tab 19 |
| <kbd>⌃ ⇥</kbd> · <kbd>⌃ ⇧ ⇥</kbd> | hold to walk the switcher forwards · backwards; it commits when you let go |
| <kbd>⌘ D</kbd> · <kbd>⌘ ⇧ D</kbd> | split right · split down |
| <kbd>⌘ ]</kbd> · <kbd>⌘ [</kbd> | next pane · previous pane |
| <kbd>⌘ ⌥ ←→↑↓</kbd> | focus the pane in that direction |
| <kbd>⌘ ⏎</kbd> · <kbd>⌘ ⇧ ⏎</kbd> | toggle fullscreen · zoom pane |
| <kbd>⌘ K</kbd> | clear scrollback |
| <kbd>⌘ P</kbd> | command palette |
| <kbd>⌘ F</kbd> | search the scrollback |
| <kbd>⌃ R</kbd> | fuzzy-search shell history |
| <kbd>⌘ +</kbd> · <kbd>⌘ </kbd> · <kbd>⌘ 0</kbd> | font size up · down · reset |
| <kbd>⌘</kbd> + wheel | zoom the font by scrolling over a terminal |
**Settings → Keybindings** (<kbd>⌘ ,</kbd>) lists every shortcut. Click one,
press the new keys (<kbd>Esc</kbd> cancels, <kbd>Backspace</kbd> 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 <kbd>⌃ B</kbd>):
<kbd>⌃ B</kbd> <kbd>C</kbd> opens a tab, <kbd>⌃ B</kbd> <kbd>%</kbd> splits,
<kbd>⌃ B</kbd> 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.
-150
View File
@@ -1,150 +0,0 @@
# 功能
<sub>[English](features.md) · 简体中文</sub>
## 输入
- **影子建议** —— 边打字边用你的历史补全整条命令,<kbd>→</kbd> 接受
- **带说明的 Tab 补全** —— 每个 flag、每个子命令都带说明,覆盖约 100 个常用命令;tty7 没有候选时 Tab 自动交给 shell 自己的补全,整个功能也可关闭(设置 → 输入 → 提示符,或 `config.json` 里的 `tab_completion`
- **语法高亮** —— 边打边亮,什么都不用装
- **模糊历史搜索** —— <kbd>⌃ R</kbd> 看到每条命令在哪跑的、什么时候、有没有失败;关掉它(设置 → 输入 → 提示符,或 `config.json` 里的 `history_search`)后 <kbd>⌃ R</kbd> 直接交给 shell,你绑的 fzf / percol 照常可用
- **历史开箱即用** —— 你已有的 shell 历史直接生效,并跨会话延续
- **行编辑** —— 点击定位光标、鼠标选区、词级移动、撤销
- **多行编辑** —— 折行和多行命令原地编辑;网格自动上移,光标始终可见。<kbd>⇧ ⏎</kbd> · <kbd>⌥ ⏎</kbd> 插入换行而不提交(可改绑,动作名 `InsertNewline`),单独按 <kbd>⏎</kbd> 提交整个缓冲区
## 窗口
- **标签页与分屏** —— 永远开在当前目录
- **拖动重排分屏** —— 鼠标移到某个 pane 上,它顶边中间会浮出一个小抓手;拖着它在布局里走,就能把这个 pane 挪到标签页内的别处。落在某个 pane 的某一侧=插到它旁边:那一侧要是朝着同一排的邻居,就并入那一排、和它们等分;要是横着切过这一排(没有排可并),才是把那个 pane 一分为二、自己占住那一半。落在它正中=两个 pane 互换位置;继续推到某个 pane 朝着窗口那一侧的外缘(不是朝着另一个 pane 的那侧)=变成贴着窗口某一边、跨满整行或整列的一条,宽度按那条轴上已有的份数均分 —— 2×2 里的一个 pane 一次拖动就能变成通高的第三列(各占三分之一),而不是独占半屏。拖动过程中落点会高亮,且只有当这一放确实会改变布局时才会亮
- **侧栏按仓库分组** —— 左侧标签栏按 git 仓库分组、每组一个标题行,不在仓库里的标签归入末尾的 *草稿* 组;切分支、仓库内 `cd` 都不会挪动行(`config.json``sidebar_grouping`:默认 `repo``none` 恢复扁平列表)
- **命令面板** <kbd>⌘ P</kbd> · scrollback 搜索 <kbd>⌘ F</kbd>
- **⌘ 点击打开链接** · 桌面通知 · 划选即复制(可选,设置 → 输入 → 选择与剪贴板)
- **智能双击选中** —— 双击直接选中整条 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 EmojiWindows 用 Microsoft YaHei / Segoe UI EmojiLinux 用 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 agentClaude Code、Codex、Gemini CLI、
Aider、Amp、OpenCode 等共 18 个)并在其外围加功能 —— 绝不包裹或替代 agent 本身。
- **品牌头像** —— 标签 chip / 侧栏行显示每个 pane 跑的是哪个 agent;自定义包装命令可通过 `config.json``agent_commands` 映射
- **状态点** —— 工作中(蓝)/ 等你输入(琥珀)/ 完成(绿),由 agent 自己上报的 OSC 事件驱动;在 设置 → Agents 一键装好对应 hooksClaude 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 <id>``claude --resume <id> --fork-session`OpenCode、Grok Build 和 Oh My Pi 同样支持),把当前对话分叉成一个独立会话;原会话原封不动,两边各自往下走。在 pane 上右键可选择分屏位置,在标签 / 侧栏行上右键则直接开新标签。需要先装好该 agent 的 hooksfork 认的是 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 上,把 <kbd>⌘</kbd> 读作
<kbd>Ctrl</kbd>。最常用的几个:
| | |
|---|---|
| <kbd>⌘ T</kbd> · <kbd>⌘ W</kbd> · <kbd>⌘ ⇧ T</kbd> | 新建标签页 · 关闭标签页 · 恢复关闭的标签页 |
| <kbd>⌘ 1</kbd>…<kbd>⌘ 9</kbd> | 跳到第 19 个标签页 |
| <kbd>⌃ ⇥</kbd> · <kbd>⌃ ⇧ ⇥</kbd> | 按住不放在切换面板里向后 · 向前走,松手即切换 |
| <kbd>⌘ D</kbd> · <kbd>⌘ ⇧ D</kbd> | 向右分屏 · 向下分屏 |
| <kbd>⌘ ]</kbd> · <kbd>⌘ [</kbd> | 下一个窗格 · 上一个窗格 |
| <kbd>⌘ ⌥ ←→↑↓</kbd> | 按方向切换焦点窗格 |
| <kbd>⌘ ⏎</kbd> · <kbd>⌘ ⇧ ⏎</kbd> | 切换全屏 · 缩放窗格 |
| <kbd>⌘ K</kbd> | 清除 scrollback |
| <kbd>⌘ P</kbd> | 命令面板 |
| <kbd>⌘ F</kbd> | 搜索 scrollback |
| <kbd>⌃ R</kbd> | 模糊搜索 shell 历史 |
| <kbd>⌘ +</kbd> · <kbd>⌘ </kbd> · <kbd>⌘ 0</kbd> | 字号增大 · 减小 · 重置 |
| <kbd>⌘</kbd> + 滚轮 | 在终端上滚动缩放字号,演示时随手放大 |
**设置 → 按键绑定**<kbd>⌘ ,</kbd>)列出全部快捷键。点一行、按下新键即可
<kbd>Esc</kbd> 取消,<kbd>Backspace</kbd> 恢复默认),改完立即生效。窗格缩放与
交换默认不绑定键 —— 在这里绑定,或从命令面板执行。
**tmux 预设** —— 把窗格/标签页操作映射到前缀键(默认 <kbd>⌃ B</kbd>):
<kbd>⌃ B</kbd> <kbd>C</kbd> 新建标签页,<kbd>⌃ B</kbd> <kbd>%</kbd> 分屏,
<kbd>⌃ B</kbd> 接方向键切换焦点。单独按前缀键会在短暂延迟后送达 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、脚本和开发者工作流的输出稳定可预测。
+108
View File
@@ -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 <kbd>⌘ ⇧ O</kbd> 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.
<Frame caption="Placeholder — screenshot: the workspace switcher, workspaces on the left, that workspace's tabs on the right">
<img src="/images/placeholder.svg" alt="The tty7 workspace switcher" />
</Frame>
## 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.
<Warning>
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.
</Warning>
### 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 | ✗ | ✓ | ✓ | ✓ <sup>1</sup> |
| A supported agent session resumes | ✗ | ✓ | ✓ | ✓ |
<sub><sup>1</sup> A capped tail of each pane, restored once. See
[session restore](/reference/troubleshooting#panes-came-back-empty).</sub>
## 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.
<Card title="Drive it from a script" icon="terminal" href="/cli/overview">
Those ids are the whole interface. The CLI page starts there.
</Card>
+126
View File
@@ -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 <kbd>⌘ ,</kbd> (<kbd>Ctrl ,</kbd> on Windows and Linux), or
from the command palette (<kbd>⌘ P</kbd> → *Settings*).
<Frame caption="Placeholder — screenshot: the Settings window, Appearance section">
<img src="/images/placeholder.svg" alt="tty7 Settings" />
</Frame>
## 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), <kbd>⌥ B</kbd> types `∫`, which is what macOS has always
done. On, it sends the escape chord shells expect, so <kbd>⌥ B</kbd> moves back
a word and <kbd>⌥ ⌫</kbd> 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.
<Note>
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)
</Note>
## 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, <kbd>⌃ B</kbd> by default. <kbd>⌃ B</kbd> <kbd>C</kbd> opens a tab,
<kbd>⌃ B</kbd> <kbd>%</kbd> splits, <kbd>⌃ B</kbd> 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
<CardGroup cols={2}>
<Card title="Core concepts" icon="cube" href="/getting-started/concepts">
Workspaces, tabs, panes, and the server that owns them.
</Card>
<Card title="The prompt" icon="terminal" href="/terminal/prompt">
Suggestions, completion, and history search — the part you touch most.
</Card>
</CardGroup>
+151
View File
@@ -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.
<Tabs>
<Tab title="macOS">
Download the DMG that matches your Mac and drag **tty7** into Applications.
| Mac | File |
|---|---|
| Apple silicon (M1 and later) | `tty7-<version>-macos-arm64.dmg` |
| Intel | `tty7-<version>-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.
<Note>
Builds are produced on macOS 14 and macOS 15. macOS 14 (Sonoma) or later
is the tested range.
</Note>
</Tab>
<Tab title="Windows">
Two shapes, both x86-64:
| File | Use it when |
|---|---|
| `tty7-<version>-windows-x86_64-setup.exe` | You want a normal install with Start-menu entries and an uninstaller. |
| `tty7-<version>-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
```
<Note>
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).
</Note>
</Tab>
<Tab title="Linux">
| File | Use it when |
|---|---|
| `tty7-<version>-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-<version>-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
```
</Tab>
</Tabs>
## 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**.
<Tip>
Inside a tty7 pane the CLI works regardless of PATH, because panes inherit the
app's environment.
</Tip>
## 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`.
<Tabs>
<Tab title="macOS / Windows">
```bash
git clone https://github.com/l0ng-ai/tty7
cd tty7
cargo build --release
```
</Tab>
<Tab title="Linux">
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
```
</Tab>
</Tabs>
<Warning>
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).
</Warning>
## Uninstalling
<AccordionGroup>
<Accordion title="macOS">
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.
</Accordion>
<Accordion title="Windows">
Use **Add or remove programs**. The uninstaller removes the PATH entry and
any Explorer context-menu keys it added. Settings live in
`%APPDATA%\tty7`.
</Accordion>
<Accordion title="Linux">
Delete the AppImage or the unpacked directory. Settings live in
`~/.config/tty7`.
</Accordion>
</AccordionGroup>
+48
View File
@@ -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; <kbd>Esc</kbd> closes it.
<Frame caption="Placeholder — screenshot: the diff overlay, side-by-side, several files">
<img src="/images/placeholder.svg" alt="The tty7 diff overlay" />
</Frame>
## 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.
+101
View File
@@ -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) (<kbd>⌘ J</kbd>)
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.
<Frame caption="Placeholder — screenshot: the Source Control panel with staged and unstaged groups and a commit box">
<img src="/images/placeholder.svg" alt="The tty7 source control panel" />
</Frame>
## 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 |
<kbd>⌘ ⏎</kbd> 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.
<Warning>
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.
</Warning>
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.
+56
View File
@@ -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.
<Frame caption="Placeholder — screenshot: the New Worktree Tab dialog">
<img src="/images/placeholder.svg" alt="Creating a worktree" />
</Frame>
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:
```
<repo>/.tty7/worktrees/<name>
```
`<repo>/.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.
<Tip>
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.
</Tip>
Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 675" width="1200" height="675" role="img" aria-label="Screenshot placeholder">
<rect width="1200" height="675" rx="12" fill="#0f1113"/>
<rect x="1" y="1" width="1198" height="673" rx="12" fill="none" stroke="#2a2e33" stroke-width="2"/>
<g fill="#3FDD8C" opacity="0.9">
<circle cx="44" cy="40" r="7"/>
<circle cx="68" cy="40" r="7" opacity="0.45"/>
<circle cx="92" cy="40" r="7" opacity="0.25"/>
</g>
<line x1="0" y1="76" x2="1200" y2="76" stroke="#2a2e33" stroke-width="2"/>
<text x="600" y="340" text-anchor="middle" fill="#5c646d" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="34">screenshot coming soon</text>
<text x="600" y="386" text-anchor="middle" fill="#3a4046" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="20">tty7 docs</text>
</svg>

After

Width:  |  Height:  |  Size: 881 B

+105
View File
@@ -0,0 +1,105 @@
---
title: "tty7"
sidebarTitle: "Introduction"
description: "A terminal workbench: persistent sessions, remote work, agents."
mode: "wide"
---
<Frame>
<img src="/images/hero.webp" alt="tty7 showing a sidebar of agent sessions across several repositories" />
</Frame>
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
<CardGroup cols={2}>
<Card title="Sessions that outlive the app" icon="power-off" href="/getting-started/concepts">
A background server owns your shells, not the window. Quit tty7 and your
builds keep building. No tmux to learn or configure.
</Card>
<Card title="Editor-grade input" icon="keyboard" href="/terminal/prompt">
Ghost suggestions from your history, tab completion that explains each flag,
syntax highlighting, click-to-place-caret, real multi-line editing.
</Card>
<Card title="Agent-aware" icon="robot" href="/agents/overview">
18 coding CLIs are recognised on sight. Per-pane status dots, notifications
when one needs you, git context, and session resume after a reboot.
</Card>
<Card title="Remote work that feels local" icon="server" href="/remote/workspaces">
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.
</Card>
<Card title="Git where you are looking" icon="code-branch" href="/git/source-control">
Branch and diff counts on every sidebar row, a source control panel, a diff
overlay, and worktrees in one dialog.
</Card>
<Card title="Scriptable" icon="terminal" href="/cli/overview">
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.
</Card>
</CardGroup>
## Start here
<Steps>
<Step title="Install it">
Native builds for macOS, Windows, and Linux.
[Installation →](/getting-started/installation)
</Step>
<Step title="Set it up">
Five minutes of settings that pay for themselves.
[First launch →](/getting-started/first-launch)
</Step>
<Step title="Learn the three words">
Workspace, tab, pane — and the server underneath them.
[Core concepts →](/getting-started/concepts)
</Step>
</Steps>
## 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` <sub>(lower is better)</sub> | **95 ms** | 239 ms | 179 ms | 185 ms |
| [DOOM-fire](https://github.com/const-void/DOOM-fire-zig) frame rate <sub>(higher is better)</sub> | **888 fps** | 485 fps | 552 fps | 617 fps |
| Cold-launch memory | 116 MB <sup>1</sup> | 105 MB | 128 MB | 130 MB |
<sub><sup>1</sup> GUI 105 MB plus the persistent server at 11 MB.</sub>
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
<CardGroup cols={3}>
<Card title="Discord" icon="discord" href="https://discord.gg/s3dethqz2V">
Ask a question, show what you built.
</Card>
<Card title="Issues" icon="github" href="https://github.com/l0ng-ai/tty7/issues">
Bugs and feature requests.
</Card>
<Card title="Changelog" icon="list" href="https://github.com/l0ng-ai/tty7/blob/main/CHANGELOG.md">
Everything that shipped, release by release.
</Card>
</CardGroup>
+6
View File
@@ -0,0 +1,6 @@
<svg width="120" height="120" viewBox="8 6 80 80" xmlns="http://www.w3.org/2000/svg">
<!-- "Duo" mark on transparent — same geometry as app-icon.svg, no tile. -->
<rect x="28" y="16" width="58" height="46" rx="12" fill="#3FDD8C" opacity="0.8"/>
<rect x="10" y="30" width="58" height="46" rx="12" fill="#17171A"/>
<path d="M24 42 L35 53 L24 64" fill="none" stroke="#ECEAE4" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 456 B

+183
View File
@@ -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 (4256). |
| `line_height` | number | `1.4` | Multiple of the font size (0.54). |
| `ui_font_size` | number | `16` | The interface's root size in px (1224). |
[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.21.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 (1002000). |
| `sidebar_collapsed` | bool | `false` | |
| `right_panel_visible` | bool | `false` | |
| `right_panel_width` | number | `260` | Pixels (1002000). |
| `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 (100100,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.110. |
| `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 <kbd>⇥</kbd>. Off hands the key to the shell. |
| `history_search` | bool | `true` | tty7's fuzzy history on <kbd>⌃ R</kbd>. 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` | <kbd>⌥</kbd>+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 (13600). |
## 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 ~2530 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.
<Note>
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.
</Note>
+103
View File
@@ -0,0 +1,103 @@
---
title: "Keyboard shortcuts"
description: "Every default binding, plus the action names for rebinding."
---
**Settings → Keybindings** (<kbd>⌘ ,</kbd>) 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 | <kbd>⌘ T</kbd> | <kbd>Ctrl ⇧ T</kbd> |
| Close Pane / Tab | <kbd>⌘ W</kbd> | <kbd>Ctrl ⇧ W</kbd> |
| Reopen Closed Tab | <kbd>⌘ ⇧ T</kbd> | <kbd>Alt ⇧ T</kbd> |
| Next Tab · Previous Tab | <kbd>⌃ ⇥</kbd> · <kbd>⌃ ⇧ ⇥</kbd> | same |
| Go to Tab 19 | <kbd>⌘ 1</kbd>…<kbd>⌘ 9</kbd> | <kbd>Alt 1</kbd>…<kbd>Alt 9</kbd> |
| New Workspace | <kbd>⌘ ⇧ N</kbd> | <kbd>Ctrl ⇧ N</kbd> |
| Switch Workspace | <kbd>⌘ ⇧ O</kbd> | <kbd>Ctrl ⇧ O</kbd> |
## Panes
| Action | macOS | Windows / Linux |
|---|---|---|
| Split Right | <kbd>⌘ D</kbd> | <kbd>Ctrl ⇧ D</kbd> |
| Split Down | <kbd>⌘ ⇧ D</kbd> | <kbd>Ctrl Alt ⇧ D</kbd> |
| Next Pane · Previous Pane | <kbd>⌘ ]</kbd> · <kbd>⌘ [</kbd> | <kbd>Ctrl ⇧ ]</kbd> · <kbd>Ctrl ⇧ [</kbd> |
| Focus Pane Left / Right / Up / Down | <kbd>⌘ ⌥ ←→↑↓</kbd> | <kbd>Alt ←→↑↓</kbd> |
| Zoom Pane | <kbd>⌘ ⇧ ⏎</kbd> | <kbd>Ctrl ⇧ ⏎</kbd> |
| Enter Full Screen | <kbd>⌘ ⏎</kbd> | <kbd>F11</kbd> |
## View
| Action | macOS | Windows / Linux |
|---|---|---|
| Command Palette | <kbd>⌘ P</kbd> | <kbd>Ctrl ⇧ P</kbd> |
| Toggle Left Sidebar | <kbd>⌘ B</kbd> | <kbd>Ctrl ⇧ B</kbd> |
| Toggle Right Panel | <kbd>⌘ J</kbd> | <kbd>Ctrl ⇧ J</kbd> |
| Toggle Code Panel | <kbd>⌘ ⇧ E</kbd> | <kbd>Ctrl ⇧ E</kbd> |
| Font Size Up · Down · Reset | <kbd>⌘ +</kbd> · <kbd>⌘ </kbd> · <kbd>⌘ 0</kbd> | <kbd>Ctrl +</kbd> · <kbd>Ctrl </kbd> · <kbd>Ctrl 0</kbd> |
| Zoom the font | <kbd>⌘</kbd> + wheel | <kbd>Ctrl</kbd> + wheel |
## Terminal
| Action | macOS | Windows / Linux |
|---|---|---|
| Find in Terminal | <kbd>⌘ F</kbd> | <kbd>Ctrl ⇧ F</kbd> |
| Find Next · Previous | <kbd>⌘ G</kbd> · <kbd>⌘ ⇧ G</kbd> | <kbd>F3</kbd> · <kbd>⇧ F3</kbd> |
| Clear Scrollback | <kbd>⌘ K</kbd> | <kbd>Ctrl ⇧ K</kbd> |
| Copy · Paste | <kbd>⌘ C</kbd> · <kbd>⌘ V</kbd> | <kbd>Ctrl ⇧ C</kbd> · <kbd>Ctrl ⇧ V</kbd> · <kbd>⇧ Insert</kbd> |
| Insert Newline (at the prompt) | <kbd>⇧ ⏎</kbd> · <kbd>⌥ ⏎</kbd> | same |
| Fuzzy history search | <kbd>⌃ R</kbd> | same |
| Accept ghost suggestion | <kbd>→</kbd> | same |
| Completion menu | <kbd>⇥</kbd> | same |
## Git and SSH
| Action | macOS | Windows / Linux |
|---|---|---|
| Commit (caret in the message box) | <kbd>⌘ ⏎</kbd> | <kbd>Ctrl ⏎</kbd> |
| Save (in the editor) | <kbd>⌘ S</kbd> | <kbd>Ctrl S</kbd> |
| Restart SSH Session | <kbd>⌘ ⇧ R</kbd> | <kbd>Ctrl ⇧ R</kbd> |
## Application
| Action | macOS | Windows / Linux |
|---|---|---|
| Settings | <kbd>⌘ ,</kbd> | <kbd>Ctrl ,</kbd> |
| Keyboard Shortcuts | <kbd>⌘ /</kbd> | — |
| Hide tty7 · Hide Others · Minimize | <kbd>⌘ H</kbd> · <kbd>⌘ ⌥ H</kbd> · <kbd>⌘ M</kbd> | — |
| Quit | <kbd>⌘ Q</kbd> | <kbd>Ctrl ⇧ Q</kbd> |
## 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 <kbd>⌘</kbd> on macOS and <kbd>Ctrl</kbd> elsewhere. A space
separates the steps of a chord.
[More →](/customization/keybindings)
+60
View File
@@ -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:
<CardGroup cols={2}>
<Card title="Devices" icon="camera">
Camera · microphone · Bluetooth · location · motion
</Card>
<Card title="Personal data" icon="address-book">
Contacts · calendars · reminders · photo library
</Card>
<Card title="System" icon="gear">
Local network · Apple Events · speech recognition · system administration
</Card>
</CardGroup>
<Warning>
**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**.
</Warning>
### 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 | `<config>/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. |
+73
View File
@@ -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.
<Note>
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.
</Note>
## 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 <kbd>⌃ R</kbd>'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**.
+137
View File
@@ -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 &lt;build&gt;"
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 &lt;machine&gt;"* | The SSH link dropped. Reconnect from the switcher. |
`tty7 -m <machine>` never dials a fresh connection by design — it uses a link
the local server already holds. Connect from the GUI first.
## <kbd>⇥</kbd> or <kbd>⌃ R</kbd> 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.
## <kbd>⌥ B</kbd> 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 <kbd>⇧</kbd> 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
<CardGroup cols={2}>
<Card title="Discord" icon="discord" href="https://discord.gg/s3dethqz2V">
Ask — someone has probably hit it.
</Card>
<Card title="Report an issue" icon="github" href="https://github.com/l0ng-ai/tty7/issues/new">
Include `tty7 doctor` output and your platform.
</Card>
</CardGroup>
+97
View File
@@ -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
<Steps>
<Step title="Check">
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**.
</Step>
<Step title="Download and verify, in the background">
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 2530 MB.
(`auto_download_updates: false`)
</Step>
<Step title="Update and relaunch">
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.
</Step>
</Steps>
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.
<Note>
A stable release outranks every dated build of its core version, which is how
switching back to Stable *graduates* rather than downgrades.
</Note>
## Platform notes
<AccordionGroup>
<Accordion title="macOS">
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.
</Accordion>
<Accordion title="Windows">
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.
</Accordion>
<Accordion title="Linux">
AppImage and tarball installs are replaced by downloading the new file.
</Accordion>
</AccordionGroup>
## "The background server is still running &lt;build&gt;"
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.
+61
View File
@@ -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.
<Frame caption="Placeholder — screenshot: the Forwards panel with a local and a dynamic rule">
<img src="/images/placeholder.svg" alt="The forwards panel" />
</Frame>
## The one-click shortcut
<kbd>⌘</kbd>-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**
<Note>
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.
</Note>
+48
View File
@@ -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.
<Frame caption="Placeholder — screenshot: the SFTP panel over a pane, with a transfer in progress">
<img src="/images/placeholder.svg" alt="The tty7 SFTP panel" />
</Frame>
## 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.
<Tip>
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.
</Tip>
+116
View File
@@ -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.
<Frame caption="Placeholder — screenshot: an SSH connection sheet asking for a key passphrase inside a pane">
<img src="/images/placeholder.svg" alt="Connecting over SSH in tty7" />
</Frame>
## Four ways to connect
<AccordionGroup>
<Accordion title="QuickConnect — type an address">
Open the palette (<kbd>⌘ P</kbd>) and type an address. IPv6 works with
brackets.
```
me@devbox
me@devbox:2222
me@[2001:db8::1]:22
```
</Accordion>
<Accordion title="A saved profile">
Profiles live in **Settings → SSH → Hosts**. Start typing the name in the
palette, or open the *SSH: Manage Profiles…* command.
</Accordion>
<Accordion title="An alias from ~/.ssh/config">
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.
<Note>
`Match`, `canonicalize*`, and GSSAPI directives are not supported, and
there is no fallback to the system `ssh` when one appears.
</Note>
</Accordion>
<Accordion title="A remote workspace">
The same connection can host whole workspaces on the far machine rather than
a single shell. [Remote workspaces →](/remote/workspaces)
</Accordion>
</AccordionGroup>
## 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
<kbd>⌘ ⇧ R</kbd> — 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
+114
View File
@@ -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.
<Frame caption="Placeholder — screenshot: a remote workspace open, sidebar showing remote repos, with the machine name in the strip">
<img src="/images/placeholder.svg" alt="A remote workspace in tty7" />
</Frame>
## Connecting
<Steps>
<Step title="Open the switcher">
<kbd>⌘ ⇧ O</kbd>. Machines are listed alongside your local workspaces —
*This Computer* first, then every saved SSH profile and, on Windows, every
WSL distribution.
</Step>
<Step title="Pick a machine">
tty7 connects over the same SSH stack as everything else, so profiles,
keychain credentials, and jump hosts all apply.
</Step>
<Step title="Approve the server install, once">
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.
</Step>
<Step title="Open a workspace">
From then on the machine's workspaces are in the switcher, and a new one
opens like a local one.
</Step>
</Steps>
## What gets installed
| | |
|---|---|
| **What** | A single static `tty7-server` binary |
| **Where** | `~/.local/share/tty7/bin/tty7-server-c<control>p<protocol>` |
| **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:
<AccordionGroup>
<Accordion title="Update tty7's server on “devbox”?">
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.
</Accordion>
<Accordion title="Restart tty7's server on “devbox”?">
Same consequence, deliberately: every shell on that machine ends. Workspaces
and layouts are kept and come back with fresh shells.
</Accordion>
</AccordionGroup>
<Warning>
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.
</Warning>
## 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)
+59
View File
@@ -0,0 +1,59 @@
---
title: "History"
description: "Fuzzy history search, and whether each pane gets its own."
---
## Fuzzy search with <kbd>⌃ R</kbd>
<kbd>⌃ R</kbd> 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.
<Frame caption="Placeholder — screenshot: ⌃R open, matching rows showing relative time and a failed exit">
<img src="/images/placeholder.svg" alt="Fuzzy history search" />
</Frame>
<kbd>⏎</kbd> puts the command on the prompt. <kbd>⌘ ⏎</kbd> puts it there and
runs it. <kbd>Esc</kbd> closes without touching it.
### Handing <kbd>⌃ R</kbd> back
If you already have an fzf, percol, atuin or McFly binding you like, turn off
**Settings → Input → Prompt → History search** (`history_search: false`).
<kbd>⌃ R</kbd> 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 <kbd>↑</kbd> key. Off by default,
because someone who has not asked for it would experience the change as their
history mysteriously forgetting the other window.
+51
View File
@@ -0,0 +1,51 @@
---
title: "Links"
description: "Opening URLs, files, and localhost ports straight out of the terminal."
---
Hold <kbd>⌘</kbd> (<kbd>Ctrl</kbd> 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
<kbd>⌘</kbd>-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.
<Tip>
For a forward you want to keep, set one up properly instead —
[port forwarding](/remote/port-forwarding).
</Tip>
+75
View File
@@ -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.110). |
| **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. |
<kbd>⌘</kbd> 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 <kbd>⇧</kbd> to keep a gesture local and select text instead. |
<Tip>
If selecting text inside `vim` or `less` grabs the app's own selection instead
of yours, hold <kbd>⇧</kbd> while you drag.
</Tip>
## Font size
| | |
|---|---|
| <kbd>⌘ +</kbd> · <kbd>⌘ </kbd> | Bigger · smaller |
| <kbd>⌘ 0</kbd> | Back to the configured size |
| <kbd>⌘</kbd> + 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)
+130
View File
@@ -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.
<Frame caption="Placeholder — video: typing a command with a ghost suggestion, accepting it, then opening the completion menu">
<img src="/images/placeholder.svg" alt="The tty7 prompt" />
</Frame>
## Ghost suggestions
As you type, the rest of the line is filled in from your history, greyed out
ahead of the cursor.
| | |
|---|---|
| <kbd>→</kbd> | 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
<kbd>⇥</kbd> 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
<Frame caption="Placeholder — screenshot: the completion menu on `git c`, each subcommand with its description">
<img src="/images/placeholder.svg" alt="Explained tab completion" />
</Frame>
When tty7 has nothing useful to offer, the <kbd>⇥</kbd> falls through to your
shell's own completion, so a carefully configured zsh setup is not lost.
To hand <kbd>⇥</kbd> 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.
<Tip>
On macOS, turn on **Settings → Input → Keyboard → Option (⌥) acts as Meta** if
you want <kbd>⌥ B</kbd> / <kbd>⌥ F</kbd> to move by word instead of typing
`∫` and `ƒ`.
</Tip>
## 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 <kbd>⌃</kbd>,
<kbd>⌘</kbd>, <kbd>fn</kbd>, or <kbd>⌥</kbd> 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.
<Note>
On macOS with **Option (⌥) acts as Meta** turned on, <kbd>⌥</kbd> chords
bypass the IME entirely, so <kbd>⌥ B</kbd> reaches your shell as meta-b
instead of being eaten as a dead key.
</Note>
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.
| | |
|---|---|
| <kbd>⇧ ⏎</kbd> · <kbd>⌥ ⏎</kbd> | Insert a newline instead of submitting |
| <kbd>⏎</kbd> | 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** | <kbd>⇥</kbd> → your shell's completion |
| **Settings → Input → Prompt → History search** | <kbd>⌃ R</kbd> → your shell's reverse-i-search, or your fzf binding |
+62
View File
@@ -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 |
| <kbd>⇧</kbd>-click | Extend the current selection to where you clicked |
| <kbd>⌘ A</kbd> *(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 | <kbd>⌘ C</kbd> | <kbd>Ctrl ⇧ C</kbd> |
| Paste | <kbd>⌘ V</kbd> | <kbd>Ctrl ⇧ V</kbd> · <kbd>⇧ Insert</kbd> |
Two settings shape what lands where, both under **Settings → Input → Selection
& clipboard**:
<CardGroup cols={2}>
<Card title="Copy on select" icon="clipboard">
Selecting with the mouse copies immediately, no <kbd>⌘ C</kbd>. Off by
default.
</Card>
<Card title="Trim trailing spaces on copy" icon="scissors">
Strips trailing whitespace from every copied line — useful when copying out
of a TUI that pads to the pane width. Off by default.
</Card>
</CardGroup>
<Note>
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.
</Note>
+59
View File
@@ -0,0 +1,59 @@
---
title: "Command palette"
description: "One key for every action in the app — including the ones with no shortcut."
---
<kbd>⌘ P</kbd> opens the command palette. Type to filter, <kbd>↑</kbd>
<kbd>↓</kbd> to move, <kbd>⏎</kbd> 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.
<Frame caption="Placeholder — screenshot: the command palette open over a terminal, grouped results">
<img src="/images/placeholder.svg" alt="The tty7 command palette" />
</Frame>
## 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)
+50
View File
@@ -0,0 +1,50 @@
---
title: "Search"
description: "Finding text in the scrollback, and everything else the search boxes cover."
---
## In the terminal
<kbd>⌘ F</kbd> opens the find bar over the focused pane and searches its whole
scrollback, not just the visible screen.
| | |
|---|---|
| <kbd>⏎</kbd> · <kbd>⌘ G</kbd> | Next match |
| <kbd>⇧ ⏎</kbd> · <kbd>⌘ ⇧ G</kbd> | Previous match |
| <kbd>Esc</kbd> | 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 <kbd>Ctrl ⇧ F</kbd> to open,
<kbd>F3</kbd> and <kbd>⇧ F3</kbd> to step.
<Frame caption="Placeholder — screenshot: the find bar with matches highlighted in the scrollback">
<img src="/images/placeholder.svg" alt="Searching the scrollback" />
</Frame>
<Tip>
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.
</Tip>
## 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 |
|---|---|
| <kbd>⌘ P</kbd> | Commands — and SSH addresses you type in full |
| <kbd>⌘ ⇧ O</kbd> | Workspaces, tabs, and machines |
| <kbd>⌃ R</kbd> | 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 |
+67
View File
@@ -0,0 +1,67 @@
---
title: "The side panel"
description: "Info, Source Control, and Files — plus the built-in editor."
---
<kbd>⌘ J</kbd> 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.
<Frame caption="Placeholder — screenshot: the side panel showing the Info tab beside a terminal">
<img src="/images/placeholder.svg" alt="The tty7 side panel" />
</Frame>
## 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
<kbd>⌘ ⇧ E</kbd> 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.
| | |
|---|---|
| <kbd>⌘ S</kbd> | Save |
| <kbd>Esc</kbd> | 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.
+80
View File
@@ -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.
<kbd>⌘ B</kbd> shows and hides it. Drag its right edge to resize.
<Frame caption="Placeholder — screenshot: the sidebar with two repo groups, agent avatars and status dots, branch and +N M counts">
<img src="/images/placeholder.svg" alt="The tty7 tab sidebar" />
</Frame>
## 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
<CardGroup cols={2}>
<Card title="Who is running there" icon="robot">
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.
</Card>
<Card title="Where it is" icon="code-branch">
The pane's git branch, refreshed on `cd` and whenever a command finishes.
</Card>
<Card title="What changed" icon="plus-minus">
The working-tree diff as `+N M`. Click the counts to open the
[diff overlay](/git/diffs).
</Card>
<Card title="Whether you have looked" icon="circle-dot">
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.
</Card>
</CardGroup>
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
<kbd>⌘ ⇧ O</kbd> 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, <kbd>⇥</kbd> to cross into the tab column, <kbd>⏎</kbd> 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.
+100
View File
@@ -0,0 +1,100 @@
---
title: "Tabs and splits"
description: "Opening, arranging, and rearranging the panes in a tab."
---
<Note>
Keys are written in macOS notation. On Windows and Linux, read <kbd>⌘</kbd> as
<kbd>Ctrl ⇧</kbd> for most window actions — the exact chords are on the
[keyboard shortcuts](/reference/keyboard-shortcuts) page.
</Note>
## Tabs
| | |
|---|---|
| <kbd>⌘ T</kbd> | New tab |
| <kbd>⌘ W</kbd> | Close the tab (or the focused pane, if the tab has more than one) |
| <kbd>⌘ ⇧ T</kbd> | Reopen the tab you just closed |
| <kbd>⌘ 1</kbd> … <kbd>⌘ 9</kbd> | Jump to tab 19 |
| <kbd>⌃ ⇥</kbd> · <kbd>⌃ ⇧ ⇥</kbd> | 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
| | |
|---|---|
| <kbd>⌘ D</kbd> | Split right |
| <kbd>⌘ ⇧ D</kbd> | Split down |
| <kbd>⌘ ]</kbd> · <kbd>⌘ [</kbd> | Next pane · previous pane |
| <kbd>⌘ ⌥ ← → ↑ ↓</kbd> | Focus the pane in that direction |
| <kbd>⌘ ⇧ ⏎</kbd> | Zoom the focused pane to fill the tab |
| <kbd>⌘ ⏎</kbd> | 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.
<Frame caption="Placeholder — video: dragging a pane by its grip, with the landing highlighted">
<img src="/images/placeholder.svg" alt="Dragging a pane to a new position" />
</Frame>
Where you drop it decides what happens:
<AccordionGroup>
<Accordion title="On a pane's side">
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.
</Accordion>
<Accordion title="On a pane's middle">
The two panes **trade places**.
</Accordion>
<Accordion title="Past a pane's outer side">
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.
</Accordion>
</AccordionGroup>
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.
<kbd>⌘ B</kbd> toggles the sidebar; <kbd>⌘ J</kbd> toggles the
[side panel](/window/side-panel) on the right.
+156 -35
View File
@@ -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 <workspace>`. And with `--json`, the streamed output comes
first and the JSON object last — the combined stream is *not* parseable as
JSON, so read the last line.
Three things to know. `--keep` needs a workspace, so it only works inside a tty7
shell or with `--ws <workspace>`. 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-<char>` for Ctrl and `M-<char>` 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 <path>` (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
+110 -24
View File
@@ -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-<char>` (Ctrl: `C-c`, `C-d`, `C-z`, also `C-@ C-[ C-\ C-] C-^ C-_ C-?`), `M-<char>` (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 (503,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 <path>` 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 <path>` (launch or focus the GUI) — not wired up
+438 -4
View File
@@ -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::<crate::ui::presets::ActiveBackground>() {
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<f32>) -> 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];
+1
View File
@@ -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;
+12 -5
View File
@@ -2130,11 +2130,18 @@ fn connect_routed(route: &PaneRoute) -> anyhow::Result<Stream> {
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()))?;
+274
View File
@@ -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<Cell<GridScroll>>,
/// The `display_offset` the bar wants and the pane has not applied yet.
pending: Rc<Cell<Option<usize>>>,
}
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<usize> {
self.pending.take()
}
#[cfg(test)]
fn snapshot(&self) -> GridScroll {
self.grid.get()
}
}
impl ScrollbarHandle for TerminalScrollHandle {
fn offset(&self) -> Point<Pixels> {
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<Pixels>) {
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<Pixels> {
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"
);
}
}
+155
View File
@@ -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<SearchState>,
pub cursor_visible: bool,
pub focused: bool,
@@ -229,6 +234,19 @@ pub struct TerminalView {
editor_drag_word: Option<(usize, usize)>,
editor_goal_col: Option<usize>,
pub(super) hovered_link: Option<HoveredLink>,
/// 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<gpui::Subscription>,
}
@@ -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<RemoteContext> {
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<crate::terminal::remote::EventProxy>,
row: usize,
@@ -5245,6 +5326,7 @@ impl Drop for TerminalView {
impl Render for TerminalView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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);
+27 -16
View File
@@ -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<TerminalView>),
@@ -831,24 +840,18 @@ impl Pane<PaneSlot> {
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<PaneSlot> {
.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(
+6
View File
@@ -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;
}
+7 -1
View File
@@ -191,7 +191,13 @@ fn build_spec_inner(
let mut key_passphrases: HashMap<String, String> = 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;
};
+1 -5
View File
@@ -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()
+5 -3
View File
@@ -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)))
}
+392 -36
View File
@@ -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<Adopt>,
/// 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::<TreeSync>()
.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::<TreeSync>()
.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<Machine> {
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::<TreeSync>()
.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::<TreeSync>()
.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::<TreeSync>()
.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::<TreeSync>()
.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::<TreeSync>()
.windows
.entry(ws)
.or_default()
.sync = SyncPhase::Unprimed {
dirty: false,
priming: true,
};
};
let attempts =
|cx: &mut App| cx.default_global::<TreeSync>().windows[&ws].rehydrate_attempts;
unprimed(cx);
let epoch = cx.default_global::<TreeSync>().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::<TreeSync>()
.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::<TreeSync>()
.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::<TreeSync>().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| {