mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
@@ -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
@@ -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
@@ -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(¤t) {
|
||||
if pane_is_free(backend, pane)? {
|
||||
current = WaitState::Free;
|
||||
changed = seen_busy;
|
||||
} else {
|
||||
seen_busy = true;
|
||||
}
|
||||
}
|
||||
|
||||
let mut matched = args.until.contains(¤t) && (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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod backend;
|
||||
mod cli;
|
||||
mod commands;
|
||||
mod gui;
|
||||
mod keys;
|
||||
mod output;
|
||||
mod resolve;
|
||||
mod screen;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
@@ -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 (50–3,600,000) |
|
||||
|
||||
The states come from two places. Four are the agent's own status, as reported
|
||||
by its [hooks](/agents/status); the last three are facts about the pane:
|
||||
|
||||
| State | Means |
|
||||
|---|---|
|
||||
| `idle` `working` `waiting` `done` | The agent's status |
|
||||
| `no-agent` | Nothing is reporting status here — a plain shell, or an agent whose hooks are not installed |
|
||||
| `free` | The foreground command has exited; the pane is back to its bare shell |
|
||||
| `exit` | The pane itself is gone. Ends every wait whether it was asked for or not |
|
||||
|
||||
`free` is how you wait for a **command** rather than an agent, and it is the
|
||||
one state that costs a second request per poll — so it is only checked when you
|
||||
name it, and only when none of the agent states you asked for already matched.
|
||||
With `--changed` it means "something ran and then finished", which is what you
|
||||
want directly after a `send`; a command quick enough to finish inside one
|
||||
`--interval` is never seen running, and the timeout says so.
|
||||
|
||||
The reply carries the agent's message and native session id. The JSON's `stale`
|
||||
flag says whether the answer might belong to the previous turn.
|
||||
[Orchestration →](/agents/orchestration)
|
||||
|
||||
### `tty7 events`
|
||||
|
||||
Streams server events until interrupted, one per line — pane exits, agent status
|
||||
changes, workspace preemption, layout deltas. `--json` makes it NDJSON. Blocks
|
||||
forever; run it with a timeout or in the background.
|
||||
|
||||
### `tty7 status`
|
||||
|
||||
Same as `server status`: pid, uptime, pane count, dialect versions, build,
|
||||
socket path. JSON is the `ServerStatus` object itself (`pid`, `uptime_secs`,
|
||||
`panes`, `control_version`, `protocol_version`, `build`, `socket`).
|
||||
|
||||
### `tty7 doctor`
|
||||
|
||||
The install check: the three environment variables, whether the server answers,
|
||||
whether its control and protocol versions match this binary, pid/uptime/panes,
|
||||
how many machine links exist, and where each agent's
|
||||
[status hooks](/agents/status) stand. Adds a note when you are not inside a
|
||||
tty7 shell.
|
||||
|
||||
The hooks row is the one that explains a mystery: without them an agent reports
|
||||
nothing, so `tty7 agents` shows it standing still and `tty7 wait` sits there
|
||||
until it times out. Outdated hooks fail the same quiet way. Hooks are a local
|
||||
install, so under `-m` the row reads `unknown`.
|
||||
|
||||
JSON: `{"context":{"config_dir","workspace","pane"},"server":{"reachable","dialect_ok","build","status","routes"},"hooks":{"installed","outdated","not_installed"}}`
|
||||
— the context fields are booleans, not values, and each `hooks` field is a list
|
||||
of agent slugs.
|
||||
|
||||
## `ws` — workspaces
|
||||
|
||||
Address a workspace by name, by full id, or by a unique id prefix (the 8-char
|
||||
prefix `tty7 ls` prints). An ambiguous name or prefix is an error that lists the
|
||||
candidates.
|
||||
|
||||
| Command | Effect | JSON |
|
||||
|---|---|---|
|
||||
| `ws ls` | Every workspace | `{"workspaces":[...]}` |
|
||||
| `ws tree [WORKSPACE]` | One workspace as a tree: tabs, split axes and ratios, panes with cwds | The whole workspace object: `{"id","name","last_active","active_tab","tabs":[{"id","name","sidebar_group","root",…}]}` |
|
||||
| `ws new [NAME]` | An empty workspace (no tab, no pane) | `{"id","name"}` |
|
||||
| `ws rename WORKSPACE NAME` | Name or rename | `{"id","name"}` |
|
||||
| `ws rm WORKSPACE` | Delete the workspace | `{"removed"}` |
|
||||
| `ws attach WORKSPACE` | Become its controlling client | `{"attached","took_over_from"}` |
|
||||
| `ws detach WORKSPACE` | Let go without interrupting anything | `{"detached"}` |
|
||||
|
||||
<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
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
title: "Fonts"
|
||||
description: "The bundled default, fallback chains, ligatures, and why CJK needs a word."
|
||||
---
|
||||
|
||||
**Settings → Appearance → Typography** covers the everyday choices; the rest is
|
||||
`config.json`.
|
||||
|
||||
| Setting | Default | |
|
||||
|---|---|---|
|
||||
| **Font family** | Hack | Picked from fonts installed on your system |
|
||||
| **Font size** | 15 px | The terminal grid |
|
||||
| **Interface font size** | 16 px | Everything outside the grid (12–24) |
|
||||
| **Line height** | 1.4 | A multiple of the font size |
|
||||
| **Bold font** / **Italic font** | — | Distinct faces, when you want them |
|
||||
| **Font ligatures** | off | Contextual alternates stay off unless you ask |
|
||||
|
||||
## Hack is bundled
|
||||
|
||||
The default font ships inside the binary. It renders identically on every
|
||||
machine without relying on a system install, so a fresh laptop looks like the
|
||||
one you set up last year.
|
||||
|
||||
## Fallbacks
|
||||
|
||||
`font_family` is the primary face; `font_fallbacks` is an ordered list tried in
|
||||
turn for anything the primary lacks.
|
||||
|
||||
```json
|
||||
{
|
||||
"font_family": "JetBrains Mono",
|
||||
"font_fallbacks": ["Maple Mono NF CN", "PingFang SC", "Apple Color Emoji"]
|
||||
}
|
||||
```
|
||||
|
||||
Leave `font_fallbacks` out and you get the platform's default chain:
|
||||
|
||||
| | Default fallbacks, in order |
|
||||
|---|---|
|
||||
| **macOS** | Menlo · Hasklug Nerd Font Mono · Maple Mono NF CN · PingFang SC · Apple Color Emoji |
|
||||
| **Windows** | Maple Mono NF CN · Cascadia Mono · Microsoft YaHei · Segoe UI Emoji |
|
||||
| **Linux** | Maple Mono NF CN · DejaVu Sans Mono · Noto Sans CJK SC · Noto Color Emoji |
|
||||
|
||||
Each ends in faces the host OS actually ships, and those stock names are
|
||||
appended to whatever list you write yourself — so a `config.json` copied from
|
||||
another platform still resolves.
|
||||
|
||||
## OpenType features
|
||||
|
||||
`font_features` passes tags straight through to the shaper:
|
||||
|
||||
```json
|
||||
{
|
||||
"font_features": { "calt": true, "liga": 1, "ss01": true, "zero": false }
|
||||
}
|
||||
```
|
||||
|
||||
A tag must be four alphanumeric characters; `true`/`false` map to `1`/`0`.
|
||||
Anything malformed is skipped with a log line rather than failing the whole
|
||||
config.
|
||||
|
||||
## CJK and the two-column grid
|
||||
|
||||
<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.
|
||||
@@ -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>{</kbd> · <kbd>}</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 |
|
||||
@@ -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" }
|
||||
```
|
||||
@@ -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.2–1.0, applied to every theme. *Follow theme* hands the decision back to the theme's own `opacity`. |
|
||||
| **Blur** | Blurs whatever is behind a translucent window (macOS). |
|
||||
| **Background material** | Windows only: *Auto*, *Blur*, *Mica*, *Mica Alt*, *Acrylic*, *Off*. Only the presets your Windows build supports are listed. |
|
||||
|
||||
## Writing your own
|
||||
|
||||
**Open themes folder** in Settings takes you to:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| macOS / Linux | `~/.config/tty7/themes/` |
|
||||
| Windows | `%APPDATA%\tty7\themes\` |
|
||||
|
||||
Drop a `.yaml` file in and it appears in the picker. The file name is the
|
||||
theme's id; `name` is what is shown.
|
||||
|
||||
```yaml
|
||||
name: "Midnight"
|
||||
background: "#0d1117"
|
||||
foreground: "#c9d1d9"
|
||||
accent: "#3fdd8c"
|
||||
cursor: "#3fdd8c"
|
||||
selection: "#264f78"
|
||||
opacity: 0.95
|
||||
blur: true
|
||||
ansi:
|
||||
normal: ["#484f58", "#ff7b72", "#3fb950", "#d29922", "#58a6ff", "#bc8cff", "#39c5cf", "#b1bac4"]
|
||||
bright: ["#6e7681", "#ffa198", "#56d364", "#e3b341", "#79c0ff", "#d2a8ff", "#56d4dd", "#f0f6fc"]
|
||||
```
|
||||
|
||||
Everything except `background`, `foreground`, `accent`, and `ansi` is optional.
|
||||
|
||||
### Gradients and images
|
||||
|
||||
`background` also takes two colours:
|
||||
|
||||
```yaml
|
||||
background: { top: "#0d1117", bottom: "#161b22" }
|
||||
# or
|
||||
background: { left: "#0d1117", right: "#161b22" }
|
||||
```
|
||||
|
||||
And a theme can carry an image behind the terminal:
|
||||
|
||||
```yaml
|
||||
background_image:
|
||||
path: "/Users/me/Pictures/wall.jpg"
|
||||
opacity: 0.25
|
||||
```
|
||||
|
||||
The Settings panel has a picker for both, so you rarely have to write this by
|
||||
hand.
|
||||
|
||||
## Editing in the app
|
||||
|
||||
Select a built-in theme and hit **Duplicate to edit** — built-ins are read-only,
|
||||
so the editor works on your copy. From there you get every colour, including the
|
||||
sixteen ANSI slots, plus the background image controls. Changes are written back
|
||||
to your themes folder as YAML.
|
||||
|
||||
## Importing from iTerm2
|
||||
|
||||
Drop an `.itermcolors` file into the themes folder and tty7 reads it directly —
|
||||
no conversion step.
|
||||
|
||||
<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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -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 1–9 |
|
||||
| <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.
|
||||
@@ -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 Emoji,Windows 用 Microsoft YaHei / Segoe UI Emoji,Linux 用 Noto)。这些名字也会追加到你手写的列表后面,所以在别的平台写出来的 `config.json` 一样能落地
|
||||
|
||||
### 中文与两列网格
|
||||
|
||||
一个格子等于主字体的一个 advance,宽字符(CJK)被钉死在正好两格上。所以中文
|
||||
fallback 只有在**汉字 advance 等于主字体西文 advance 的两倍**时,才能严丝合缝地
|
||||
填满自己的槽。
|
||||
|
||||
内置 Hack 的 advance 是 0.60205em,两格就是 1.2041em —— 而系统自带的中文字体
|
||||
(Microsoft YaHei、PingFang SC、Noto Sans CJK)全都是 1.0em。这些字形在槽里左
|
||||
对齐,多出来的约 0.2em 就变成每个字右边的一道空隙。
|
||||
|
||||
[Maple Mono NF CN](https://github.com/subframe7536/maple-font) 在所有平台都排在
|
||||
第一位正是因为这个 —— 西文 0.6em、中文 1.2em,对上 Hack 正好两格。它只按名字引
|
||||
用,不打包(每字重约 20MB):装上即生效,不用改配置。
|
||||
|
||||
想让中文排得**紧**而不只是均匀,要换的是主字体:选一个 advance 为 0.5em 的
|
||||
(比如 Sarasa Mono SC 更纱黑体等宽),两格就正好 1.0em。
|
||||
|
||||
## Coding agent
|
||||
|
||||
tty7 能识别 pane 里跑着的第三方 coding agent(Claude Code、Codex、Gemini CLI、
|
||||
Aider、Amp、OpenCode 等共 18 个)并在其外围加功能 —— 绝不包裹或替代 agent 本身。
|
||||
|
||||
- **品牌头像** —— 标签 chip / 侧栏行显示每个 pane 跑的是哪个 agent;自定义包装命令可通过 `config.json` 的 `agent_commands` 映射
|
||||
- **状态点** —— 工作中(蓝)/ 等你输入(琥珀)/ 完成(绿),由 agent 自己上报的 OSC 事件驱动;在 设置 → Agents 一键装好对应 hooks(Claude Code、Codex、Copilot CLI、OpenCode、Pi、Grok Build、Oh My Pi)
|
||||
- **通知** —— agent 卡在等你批准的那一刻弹 "needs your permission…",每轮结束弹 "finished after Ns",遵循你的通知策略
|
||||
- **一眼看分支** —— 侧栏每行显示该 pane 的 git 分支和工作区改动(`+N −M`),`cd` 或命令跑完时自动刷新;点改动数字会打开 diff 浮层,关掉它(设置 → 窗口与标签页,或 `config.json` 的 `sidebar_diff_preview: false`)分支和数字照常显示,只是不再可点
|
||||
- **会话恢复** —— 重启后无法重连的 pane 会自动续上 agent 对话,并带上原始启动 flags(`claude --dangerously-skip-permissions --resume …`;`restore_agent_sessions`,默认开启)
|
||||
- **Fork 会话** —— 直接调 agent 自己的 fork 命令(`codex fork <id>`、`claude --resume <id> --fork-session`,OpenCode、Grok Build 和 Oh My Pi 同样支持),把当前对话分叉成一个独立会话;原会话原封不动,两边各自往下走。在 pane 上右键可选择分屏位置,在标签 / 侧栏行上右键则直接开新标签。需要先装好该 agent 的 hooks(fork 认的是 hooks 上报的 session id);远程 pane 不能 fork,因为命令会跑在本机的 agent 上;另外 fork 会整份复制对话历史,反复 fork 会在 agent 自己的会话目录里占掉不少磁盘
|
||||
- **复制会话 ID** —— 把 agent 的原生 session id 复制到剪贴板,就在 *复制工作目录* 旁边,方便粘进 `codex resume`、bug 报告或别的工具
|
||||
- **上下文回填** —— 面板命令把当前选区或仓库 `git diff` 打包成 prompt 直接喂给正在跑的 agent
|
||||
- **托盘图标** —— 系统托盘 / 菜单栏常驻图标,任何 agent 等你输入时立即切换为提醒态;菜单列出所有 agent pane(品牌头像 + 状态点,点击直达)、可切换通知策略,并在保留会话的普通退出之外提供 *退出并停止服务器…*(`show_tray_icon`,默认开启)
|
||||
- **`tty7 wait`** —— CLI 的编排原语:阻塞到某个 pane 的 agent 等待输入或完成一轮(`tty7 wait %3 --until waiting,done --changed --timeout 600`,超时退出码 124),让一个 agent 睡到同伴卡在权限确认的那一刻,而不是抓屏猜——然后 `tty7 capture %3 --plain` 收结果。agent 状态是电平不是边沿,所以 `--changed` 会忽略 wait 开始时 pane 本来就处在的那个状态;不加它的话,JSON 里的 `stale` 标记会告诉你这个答案是不是上一轮留下的
|
||||
- **`tty7` 上 PATH** —— CLI 随每个安装包一起发布,启动时自动放到 PATH 上,脚本和 coding agent 在任何终端里都能驱动 tty7。tty7 自己的 pane 里则一定可用,因为 pane 继承 app 的环境。Unix 上是往 `/opt/homebrew/bin`、`/usr/local/bin`、`~/.local/bin`、`~/bin`、`~/.cargo/bin` 中你 PATH 已经覆盖的那个目录里放一个软链;Windows 上是把安装目录追加到用户 PATH,卸载时再摘掉。你自己装的 `tty7` 一律保持原样,不会被覆盖。关掉:设置 → Agents,或 `config.json` 里 `install_cli_on_path: false`
|
||||
|
||||
## SSH
|
||||
|
||||
**唯一**路径就是原生 Rust SSH 栈(russh)—— profile、凭据、SFTP 全部内置,
|
||||
不 shell 出 `ssh`,也没有系统 ssh 兼容模式。
|
||||
|
||||
- **QuickConnect** —— 面板里打 `user@host[:port]` 回车即连;支持 IPv6 `[::1]:port`
|
||||
- **保存 profile** —— 完整连接配置,密码 / passphrase 进 OS keychain,不落盘
|
||||
- **`~/.ssh/config` alias** —— 直接输入 alias 即连(原生解析常用字段,尽力而为,走 russh),也可在设置页一键导入为 profile
|
||||
- **GUI 认证** —— pane 内 sheet 输入密码、私钥 passphrase、2FA,并确认主机密钥(新主机 vs 已变更)
|
||||
- **内置 SFTP** —— 滑入式文件面板:浏览、上传 / 下载、重命名 / 删除 / chmod,可拖进 Finder
|
||||
- **端口转发** —— Local / Remote / Dynamic,预配置或运行时增删,外加 ⌘ 点击 `localhost:PORT` 一键转发
|
||||
- **跳板与代理** —— 经 profile 引用或 `ProxyJump` 多跳、ProxyCommand、SOCKS5 / HTTP
|
||||
|
||||
| 入口 | 连接方式 |
|
||||
|---|---|
|
||||
| 保存 profile · QuickConnect · 输入 `user@host[:port]` | 原生 russh —— SFTP · keychain · GUI 认证 · L/R/D 转发 |
|
||||
| `~/.ssh/config` alias | 原生解析后走 russh(`Match`/canonicalize/GSSAPI 不支持,且无回退) |
|
||||
|
||||
## 快捷键
|
||||
|
||||
下表按 macOS 记法书写 —— 在 Windows 和 Linux 上,把 <kbd>⌘</kbd> 读作
|
||||
<kbd>Ctrl</kbd>。最常用的几个:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| <kbd>⌘ T</kbd> · <kbd>⌘ W</kbd> · <kbd>⌘ ⇧ T</kbd> | 新建标签页 · 关闭标签页 · 恢复关闭的标签页 |
|
||||
| <kbd>⌘ 1</kbd>…<kbd>⌘ 9</kbd> | 跳到第 1–9 个标签页 |
|
||||
| <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、脚本和开发者工作流的输出稳定可预测。
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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
@@ -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>
|
||||
@@ -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 |
@@ -0,0 +1,183 @@
|
||||
---
|
||||
title: "config.json"
|
||||
description: "Every key tty7 reads, its type, and its default."
|
||||
---
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| macOS / Linux | `~/.config/tty7/config.json` |
|
||||
| Windows | `%APPDATA%\tty7\config.json` |
|
||||
| Override the whole directory | `TTY7_CONFIG_DIR` |
|
||||
|
||||
Every key is optional — a missing one means its default, so you only write what
|
||||
you change. Out-of-range numbers are clamped rather than rejected, and an
|
||||
unrecognised enum value falls back to the default with a log line instead of
|
||||
failing the file.
|
||||
|
||||
```json
|
||||
{
|
||||
"font_family": "JetBrains Mono",
|
||||
"font_size": 14,
|
||||
"theme_follow_system": true,
|
||||
"theme_preset_light": "one_light",
|
||||
"theme_preset_dark": "dracula",
|
||||
"macos_option_as_alt": true,
|
||||
"scrollback_limit": 50000
|
||||
}
|
||||
```
|
||||
|
||||
## Typography
|
||||
|
||||
| Key | Type | Default | |
|
||||
|---|---|---|---|
|
||||
| `font_family` | string | `"Hack"` | Primary face. Hack is bundled. |
|
||||
| `font_fallbacks` | string[] | platform list | Ordered fallbacks. Stock platform faces are appended to whatever you write. |
|
||||
| `font_family_bold` | string | — | A distinct bold face. |
|
||||
| `font_family_italic` | string | — | A distinct italic face. |
|
||||
| `font_features` | object | — | OpenType tags, e.g. `{"calt": true, "liga": 1}`. Four alphanumeric characters per tag. |
|
||||
| `font_size` | number | `15` | Terminal text size in px (4–256). |
|
||||
| `line_height` | number | `1.4` | Multiple of the font size (0.5–4). |
|
||||
| `ui_font_size` | number | `16` | The interface's root size in px (12–24). |
|
||||
|
||||
[More about fonts →](/customization/fonts)
|
||||
|
||||
## Theme and window
|
||||
|
||||
| Key | Type | Default | |
|
||||
|---|---|---|---|
|
||||
| `theme_preset` | string | `"light"` | Active theme id. |
|
||||
| `theme_follow_system` | bool | `false` | Follow the OS appearance. |
|
||||
| `theme_preset_light` | string | `"light"` | Used when following the system. |
|
||||
| `theme_preset_dark` | string | `"dark"` | Used when following the system. |
|
||||
| `theme_legible_palette` | bool | `true` | Brighten or darken bright ANSI colours that would be unreadable on the background. |
|
||||
| `window_opacity` | number | — | 0.2–1.0. Unset means "follow the theme". |
|
||||
| `window_blur` | bool | — | Blur behind a translucent window (macOS). Unset means "follow the theme". |
|
||||
| `window_backdrop` | enum | `"auto"` | Windows only: `auto`, `blur`, `mica`, `mica-alt`, `acrylic`, `off`. |
|
||||
| `dim_inactive_panes` | bool | `true` | Dim panes that are not focused. |
|
||||
| `startup_mode` | enum | `"normal"` | `normal`, `maximized`, `fullscreen`. |
|
||||
| `remember_window_size` | bool | `true` | Reopen at the last size and position. |
|
||||
| `restore_session` | bool | `true` | Reopen the last window's tabs, splits, and directories. |
|
||||
| `gui_language` | enum | `"en"` | `en`, `zh-CN`, `ja-JP`. Anything else falls back to `en`. |
|
||||
|
||||
Built-in theme ids: `light`, `one_light`, `catppuccin_latte`, `rose_pine_dawn`,
|
||||
`dark`, `dracula`, `harbor`, `one_dark_pro`, `rose_pine`. Your own themes take
|
||||
their id from the file name. [More about themes →](/customization/themes)
|
||||
|
||||
## Tabs, sidebar, panels
|
||||
|
||||
| Key | Type | Default | |
|
||||
|---|---|---|---|
|
||||
| `tab_bar_position` | enum | `"left"` | `left` (sidebar) or `top` (strip). |
|
||||
| `new_tab_position` | enum | `"after-current"` | Or `end`. |
|
||||
| `sidebar_grouping` | enum | `"repo"` | Or `none` for a flat list. |
|
||||
| `sidebar_diff_preview` | bool | `true` | Clicking a row's `+N −M` opens the diff overlay. |
|
||||
| `sidebar_width` | number | `220` | Pixels (100–2000). |
|
||||
| `sidebar_collapsed` | bool | `false` | |
|
||||
| `right_panel_visible` | bool | `false` | |
|
||||
| `right_panel_width` | number | `260` | Pixels (100–2000). |
|
||||
| `right_panel_tab` | enum | `"info"` | `info`, `changes`, `files`. |
|
||||
| `diff_view` | enum | `"split"` | Or `unified`. Global, not per file. |
|
||||
| `scm_graph_expanded` | bool | `false` | Whether the history section starts open. |
|
||||
| `show_tray_icon` | bool | `true` | The tray / menu bar status item. |
|
||||
|
||||
## Terminal
|
||||
|
||||
| Key | Type | Default | |
|
||||
|---|---|---|---|
|
||||
| `shell` | object | — | `{"program": "fish", "args": ["-l"]}`. Unset uses the platform default. |
|
||||
| `working_directory` | object | `{"strategy":"inherit"}` | `strategy` is `inherit`, `home`, or `custom`; `path` is used when custom. |
|
||||
| `env` | object | `{}` | Extra environment variables for every pane. |
|
||||
| `scrollback_limit` | number | `10000` | Lines per pane (100–100,000). New panes only. |
|
||||
| `cursor_style` | enum | `"block"` | `block`, `bar`, `underline`. |
|
||||
| `cursor_blink` | bool | `true` | |
|
||||
| `bell` | enum | `"visual"` | `none`, `visual`, `audible`, `both`. |
|
||||
| `per_pane_history` | bool | `false` | Give each pane its own shell history file. |
|
||||
|
||||
## Mouse and scrolling
|
||||
|
||||
| Key | Type | Default | |
|
||||
|---|---|---|---|
|
||||
| `mouse_scroll_multiplier` | number | `1.0` | 0.1–10. |
|
||||
| `smooth_scroll` | bool | `true` | Ease each wheel notch. Trackpads unaffected. |
|
||||
| `mouse_reporting` | bool | `true` | Let full-screen apps handle clicks and scrolling. |
|
||||
| `mouse_hide_while_typing` | bool | `true` | |
|
||||
| `focus_follows_mouse` | bool | `false` | |
|
||||
|
||||
## Input and clipboard
|
||||
|
||||
| Key | Type | Default | |
|
||||
|---|---|---|---|
|
||||
| `tab_completion` | bool | `true` | tty7's completion menu on <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 (1–3600). |
|
||||
|
||||
## Agents
|
||||
|
||||
| Key | Type | Default | |
|
||||
|---|---|---|---|
|
||||
| `agent_commands` | object | `{}` | Map a wrapper command to an agent slug: `{"cc": "claude"}`. |
|
||||
| `restore_agent_sessions` | bool | `true` | Relaunch an agent conversation when a lost pane is restored. |
|
||||
| `install_cli_on_path` | bool | `true` | Put the bundled `tty7` command on PATH at launch. |
|
||||
|
||||
[Agent slugs →](/agents/overview#your-own-wrapper)
|
||||
|
||||
## SSH
|
||||
|
||||
| Key | Type | Default | |
|
||||
|---|---|---|---|
|
||||
| `ssh_profiles` | array | `[]` | Managed from **Settings → SSH**. Secrets live in the OS keychain, never here. |
|
||||
| `verify_host_keys` | bool | `true` | |
|
||||
| `ssh_warn_on_close` | bool | `false` | Confirm before closing a live connection. |
|
||||
|
||||
## Updates and network
|
||||
|
||||
| Key | Type | Default | |
|
||||
|---|---|---|---|
|
||||
| `check_for_updates` | bool | `true` | |
|
||||
| `update_channel` | enum | `"stable"` | Or `nightly`. |
|
||||
| `auto_download_updates` | bool | `true` | Fetch and verify in the background so installing is a restart. Packages are ~25–30 MB and a check happens every six hours. |
|
||||
| `http_proxy` | string | — | For tty7's *own* traffic only — update checks, downloads, remote-server installs. `http://…` or `socks5://…`. Programs in a pane are unaffected. |
|
||||
|
||||
[Updates →](/reference/updates)
|
||||
|
||||
## Keys tty7 manages itself
|
||||
|
||||
`ssh_profile_frecency` and `command_frecency` record how often and how recently
|
||||
you use a profile or command, so the pickers can rank them. They are written by
|
||||
the app; there is no reason to edit them.
|
||||
|
||||
<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>
|
||||
@@ -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 1–9 | <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)
|
||||
@@ -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. |
|
||||
@@ -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**.
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: "Troubleshooting"
|
||||
description: "The things that go wrong, and what they actually mean."
|
||||
---
|
||||
|
||||
## Start here
|
||||
|
||||
```bash
|
||||
tty7 doctor
|
||||
```
|
||||
|
||||
One table: whether the server is reachable, whether its wire dialect matches
|
||||
your binary, the three environment variables, pid/uptime/panes, and how many
|
||||
machine links exist. Most of what follows is a specific answer this gives you.
|
||||
|
||||
## `tty7: command not found`
|
||||
|
||||
The CLI is put on PATH the first time the app launches. If it is missing:
|
||||
|
||||
- Check **Settings → Agents → Install the tty7 command on PATH** is on.
|
||||
- On Unix it symlinks into whichever of `/opt/homebrew/bin`, `/usr/local/bin`,
|
||||
`~/.local/bin`, `~/bin`, `~/.cargo/bin` your PATH already covers — if none of
|
||||
those are on your PATH, add one.
|
||||
- On Windows the install directory is appended to your user PATH, which needs a
|
||||
new shell to take effect.
|
||||
- A `tty7` you installed yourself is never replaced, so an old one earlier in
|
||||
PATH will win.
|
||||
|
||||
Inside a tty7 pane it works regardless, since panes inherit the app's
|
||||
environment.
|
||||
|
||||
## The server is unreachable
|
||||
|
||||
`tty7 doctor` says so, and the GUI cannot open panes.
|
||||
|
||||
Start it with `tty7 server start`. If you are an agent or a script,
|
||||
**do not** — tell the user instead. Starting a server they did not ask for
|
||||
changes what their GUI attaches to.
|
||||
|
||||
For logs:
|
||||
|
||||
```bash
|
||||
TTY7_LOG=info # must be set before the server starts
|
||||
tty7 server logs
|
||||
```
|
||||
|
||||
## Panes came back empty
|
||||
|
||||
A crash, a `kill -9`, or a reboot takes the shells with it — that part is
|
||||
unavoidable. The *screens* should come back: tty7 keeps a capped tail of each
|
||||
pane's output (256 KiB) and hands it to the pane that reopens on that id.
|
||||
|
||||
It is consumed once. If a pane was restored, then closed, then reopened, the
|
||||
second time there is nothing left to restore — that is by design, not a bug.
|
||||
|
||||
## "The background server is still running <build>"
|
||||
|
||||
tty7 updated in place, so the app is new and your panes are still served by the
|
||||
previous build. Restarting the server picks up the new one and **ends every
|
||||
process in every pane**. There is no hurry — do it when your panes are idle.
|
||||
[Updates →](/reference/updates)
|
||||
|
||||
## A remote machine will not connect
|
||||
|
||||
| Message | What it means |
|
||||
|---|---|
|
||||
| *"running an old tty7 server that this copy cannot talk to"* | The server there predates your client's protocol. Let tty7 update it — this ends every session on that machine. |
|
||||
| *"running a newer tty7 server than this copy"* | Update tty7 here instead, or replace the server there. |
|
||||
| *"answered, but not as a tty7 server"* | Something else is listening, or the binary is not what tty7 expects. |
|
||||
| *"tty7 no longer has a way to reach <machine>"* | The SSH link dropped. Reconnect from the switcher. |
|
||||
|
||||
`tty7 -m <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>
|
||||
@@ -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 25–30 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 <build>"
|
||||
|
||||
If Settings tells you this, tty7 was updated in place: the app is the new build,
|
||||
but your panes are still served by the previous one. Restarting the server picks
|
||||
up the new one — **and ends every process running in your panes**, shells,
|
||||
agents, and SSH sessions alike.
|
||||
|
||||
There is no hurry. Pick a moment when your panes are idle.
|
||||
|
||||
## Proxies
|
||||
|
||||
Update checks and downloads resolve a proxy from, in order:
|
||||
|
||||
1. `http_proxy` in `config.json` — `http://127.0.0.1:7890` or
|
||||
`socks5://127.0.0.1:1080`
|
||||
2. The platform system proxy (Windows registry, macOS `SCDynamicStore`)
|
||||
3. `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY`
|
||||
|
||||
Programs running in a pane are deliberately unaffected — they inherit their
|
||||
proxy from their own environment, as in any other terminal.
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: "Mouse, scrolling, and the bell"
|
||||
description: "How the pointer, the wheel, and ^G behave — and how to change each."
|
||||
---
|
||||
|
||||
## Scrolling
|
||||
|
||||
The wheel and trackpad scroll the pane's scrollback. All of it lives under
|
||||
**Settings → Terminal → Scrolling**:
|
||||
|
||||
| Setting | Default | What it does |
|
||||
|---|---|---|
|
||||
| **Scrollback** | 10,000 lines | How much history each pane keeps, up to 100,000. Applies to new panes. |
|
||||
| **Scroll speed** | 1.0 | A multiplier on wheel scrolling (0.1–10). |
|
||||
| **Smooth scrolling** | On | Eases each wheel notch into place over a few frames instead of jumping the whole way. Trackpads scroll continuously already and are unaffected. |
|
||||
|
||||
<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)
|
||||
@@ -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 |
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 1–9 |
|
||||
| <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
@@ -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
|
||||
|
||||
|
||||
@@ -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 (50–3,600,000) |
|
||||
|
||||
| State | Means |
|
||||
|---|---|
|
||||
| `idle` `working` `waiting` `done` | The agent's own status, from its hooks |
|
||||
| `no-agent` | Nothing reports status here — a plain shell, or hooks not installed |
|
||||
| `free` | The foreground command has exited; the pane is back to its bare shell |
|
||||
| `exit` | The pane is gone. Ends every wait whether asked for or not |
|
||||
|
||||
JSON: `{"pane","status","matched","stale","activity","message","session_id"}`.
|
||||
`stale: true` means the pane was already in that state when the wait began, so
|
||||
the answer may belong to a previous turn — which is what `--changed` refuses.
|
||||
|
||||
Exit 0 = a requested state was reached; 124 = timed out; 1 = the pane exited
|
||||
without reaching it (the JSON still comes, with `"matched": false`).
|
||||
|
||||
Notes that decide whether a loop works:
|
||||
|
||||
- **`idle` is not "the command finished".** It is something an *agent* says
|
||||
about itself. A pane running a build has no agent and reports `no-agent`.
|
||||
Use `free` for commands.
|
||||
- **`free` costs a second request per poll**, so it is only checked when named,
|
||||
and only if none of the agent states you asked for matched first — pairing
|
||||
`waiting,done,free` never loses you a `waiting`.
|
||||
- **`--changed` means something different for `free`**: a shell goes free →
|
||||
busy → free and ends where it started, so there is no new state to compare
|
||||
against. There it means "something ran while I watched" — exactly what you
|
||||
want on the line after a `send`. A command fast enough to finish inside one
|
||||
`--interval` is never seen running, so it times out instead; use
|
||||
`--interval 100` for those, or drop `--changed` and read a sentinel file.
|
||||
- **`free` reads the process tree**, so a pane whose root process is the command
|
||||
itself (a `tty7 run` pane) looks free while it runs, and a backgrounded job
|
||||
keeps a pane busy after the foreground command is gone.
|
||||
|
||||
### `tty7 events`
|
||||
Streams server events until interrupted, one per line — pane exits, agent
|
||||
@@ -159,10 +227,17 @@ socket path. JSON is the `ServerStatus` object itself (`pid`, `uptime_secs`,
|
||||
|
||||
### `tty7 doctor`
|
||||
The install check: the three env vars, whether the server answers, whether its
|
||||
control/protocol versions match this binary, pid/uptime/panes, and how many
|
||||
machine links exist. Adds a note when you are not inside a tty7 shell.
|
||||
JSON: `{"context":{"config_dir","workspace","pane"},"server":{"reachable","dialect_ok","build","status","routes"}}`
|
||||
— the context fields are booleans, not values.
|
||||
control/protocol versions match this binary, pid/uptime/panes, how many machine
|
||||
links exist, and where each agent's status hooks stand. Adds a note when you are
|
||||
not inside a tty7 shell.
|
||||
JSON: `{"context":{"config_dir","workspace","pane"},"server":{"reachable","dialect_ok","build","status","routes"},"hooks":{"installed","outdated","not_installed"}}`
|
||||
— the context fields are booleans, not values; each `hooks` field is a list of
|
||||
agent slugs.
|
||||
|
||||
The hooks row is what explains an agent that never moves: without hooks it
|
||||
reports no status, so `tty7 agents` shows it frozen and `tty7 wait` only ever
|
||||
times out. Hooks are a local install, so under `-m` the row reads `unknown`
|
||||
rather than claiming a gap it cannot see.
|
||||
|
||||
## `ws` — workspaces
|
||||
|
||||
@@ -181,8 +256,9 @@ lists the candidates.
|
||||
| `ws attach WORKSPACE` | become its controlling client | `{"attached","took_over_from"}` |
|
||||
| `ws detach WORKSPACE` | let go without interrupting anything | `{"detached"}` |
|
||||
|
||||
`ws rm` does not kill the panes it held — they keep running as orphans with no
|
||||
workspace. Find them with `pane ls --all` and close them one by one.
|
||||
`ws rm` hangs up the panes the workspace held, so removing a scratch workspace
|
||||
is enough on its own. What does leak is an interrupted `tty7 run`: that pane
|
||||
keeps running with nothing referencing it. `pane ls --all` finds those.
|
||||
|
||||
Prefer `tty7 new <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
@@ -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];
|
||||
|
||||
@@ -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
@@ -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()))?;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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| {
|
||||
|
||||
Reference in New Issue
Block a user