About had grown three sections that change system state and that nobody
looks for under "About": a PATH install, a registry write, and a daemon
restart. Two of them move out.
The `tty7` CLI goes to Agents. That page already describes tty7 <-> agent
integration in one direction (hooks reporting session status); the CLI is
the other direction, and its own description leads with "so scripts and
coding agents can drive tty7". The Loading and Unavailable arms there no
longer return early, since the CLI toggle is about this GUI's own host
rather than whichever machine the hook rows describe.
The Windows Explorer context menu goes to the installer, which is where
VS Code and Git for Windows put theirs: writing shell verbs is an
install-time decision, not a runtime preference. A task checkbox drives
new `--register-explorer-menu` / `--unregister-explorer-menu` flags, so
the key layout stays in core::explorer_context_menu instead of being
copied into the .iss. `status()` existed only to paint the settings UI
and goes with it. The uninstaller unregisters unconditionally: an install
that registered once and was later upgraded without the box ticked still
holds keys that would otherwise point at a deleted exe.
Server restart stays — it is about the app itself.
Also fixes localization the About section had skipped: eight hardcoded
English strings in the update block now have keys, and the orphaned
SettingsCheckUpdatesDesc key (which still claimed "tty7 never updates
itself", contradicted by the macOS in-app updater) is reused for a
one-line description in place of a 60-word account of the updater's
internals.
Finally, terminology in the Chinese UI. hook, agent, worktree, diff and
fork are read and spoken in English by Chinese developers, so translating
them lost more than it gained. Scrollback was worse than a style
question: 回滚 means rollback, the opposite direction. 窗格 for pane is
kept — that one is standard.
* fix(terminal): eliminate seams between Powerline separators
* fix(terminal): skip the separator cover quad when the glyph is dim
The cover quad and the anti-aliased path overlap on the closing edge's
device pixel. With an opaque foreground that is a no-op, but a DIM cell
carries fg.a = 0.66, so the two compositing passes push that one column
to 1 - 0.34^2 = 0.884 alpha and tint the neighboring cell's background.
Emit the quad only for opaque separators.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(i18n): correct Chinese terminology and wording
The worst one collided two different concepts: a git worktree was
translated as 工作区, the same word tty7 uses for a workspace. "Remove
Worktree" therefore read as "delete this workspace" in a destructive
confirmation, and New Worktree Tab had three different names across the
menu bar, the palette, and its own dialog. A working tree is already 工作树
everywhere else, so worktree joins it and workspace keeps 工作区 to itself.
Also:
- Forget password means "clear the stored password", not "I forgot my
password" — 忘记密码 reads as password recovery.
- The SSH auth mode Agent (ssh-agent) was 代理, the same word as the proxy
fields right next to it.
- "The server holding your shells" became 保存 (stores), which is not what
the server does with them.
- Focus follows mouse had subject and object swapped.
- "over a week ago" lost its "ago".
- The shell help said to clear "Program" while the field above it is
labelled 程序; the sidebar-grouping help said "Scratch" while the header
itself reads 草稿.
- "Off closes straight away" was ambiguous about what closes.
- Mark Tab as Unread lost the tab in the palette.
- An SSH profile is a saved host, not a file on disk: 配置文件 → 主机配置.
- Punctuation: 帐户 → 账户, a halfwidth comma in Ln/Col, and em dashes
inside a sentence are now —— throughout instead of a spaced —.
* fix(i18n): close remaining zh terminology gaps
Review follow-up to the terminology pass on this branch.
- Finish the profile -> 主机配置 rename. The About blurb still said 配置文件,
and the Hosts search keywords still only matched the old term, so searching
settings for the words the UI now shows found nothing. Settings search is a
plain substring match over the whole keyword blob, so 主机配置 joins
配置文件 there and both still reach the section.
- Quote UI labels with the “” the file already uses for “显示更多选项”,
rather than the 「」 that had been introduced in two strings.
- 一周多以前 -> 一周多前, matching its four siblings: 刚刚, 分钟前, 小时前,
天前.
- 标记标签页为未读 -> 将标签页标记为未读.
- The remove-worktree dialog said 未提交的更改 while the changes panel, the
diff overlay and the palette all call git changes 变更. Its confirm button
(放弃更改并删除) is rendered from the same prompt, so the two moved together
and the dialog stays internally consistent.
The New Worktree Tab labels are left alone: 新X in the menu bar and 新建X in
the palette is the split the file already makes for New Workspace.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(windows): advertise terminal background to TUI apps
* refactor(windows): keep the background hint out of config.json
The daemon needs to know whether the window is light or dark when it
spawns a Windows pane, because ConPTY drops the child's OSC 11 query
before tty7's emulator can answer it. It was reading that from
`Config::theme` — a field nothing had written since it went dead — which
meant the GUI had to rewrite the user's `config.json` every time the
effective preset changed sides.
Move the hint to `appearance.json`, beside `machine.json` in the data
dir, and leave `Config::theme` exactly as it was. It is derived state:
written by the process that paints the window, read by the process that
has to describe it, and of no interest to the user. A file of its own
rather than a field on `Machine`, because the machine tree is owned by
the daemon and flushed on a timer, so a second writer would clobber the
workspaces and panes it had not seen. Absent, unreadable, and unparsable
all read as light — what the default preset is — so a daemon that starts
before the GUI has ever applied a theme describes the default window
instead of guessing.
Also silence the `unused variable` warning the hint parameter raised on
every non-Windows build, where the `COLORFGBG` block it feeds is
compiled out.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Accept a visible inline history suggestion while preserving end-of-line behavior when no suggestion is shown.
Refs l0ng-ai/tty7#315
Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
* feat(ui): add GUI localization for en and zh-Hans
* feat(ui): localize search placeholders and relative time
* feat(ui): localize palette, switcher, and sftp strings
* feat(ui): localize home shortcut labels
* feat(ui): localize tray, ssh prompt, and editor strings
* feat(ui): add plural/select i18n helpers and localize sftp/settings labels
* feat(ui): localize settings search, forwards panel, and file tree
* feat(ui): localize code editor and right panel
* feat(ui): localize stop/delete workspace confirmations with plural support
* feat(ui): localize diff overlay with plural-aware summary
* feat(ui): localize pending pane, worktree prompt, and home time strings
* feat(ui): localize app menus, tray, tab strip/sidebar, and remote status strings
* feat(ui): localize switcher, file_tree, machine_mirror fallback strings
* feat(ui): localize ssh prompts, theme presets, host error wrapper, and finish remote strings
* feat(ui): localize command palette strings
* feat(ui): localize app.rs notifications, prompts, placeholders, and parse errors
* feat(ui): localize remaining theme, switcher, settings, and sftp strings
* style: cargo fmt
* feat(ui): add language selector to settings
* fix(ui): refresh locales across windows
* refactor(ui): make GUI language selection explicit
* fix(ui): localize Explorer settings after merge
* fix(ui): keep persisted theme names out of the GUI locale
A theme's name is data, not chrome: it is written into the theme YAML and
matched back with `trim_end_matches(" (custom)")`. Translating it meant a
Chinese GUI forked "Nord" into "Nord(自定义)", the next fork stacked a second
suffix on it, and the name stayed Chinese after switching back to English. The
derived-name fallback had the same problem. Both are English again.
Also in this pass:
- Give each test thread its own locale override. The locale is process-wide and
tests run in parallel, so the two tests that switched to zh-CN could flip the
language out from under another thread's English assertions.
- Rebuild the menu bar when gui_language changes in config.json, the way the
in-app picker already does — otherwise the menus kept the old language.
- Document the values the setting actually accepts. The docs still described
`auto` and `zh-Hans`, which sanitize() resets to `en`.
- Put the English words back into the Chinese search keywords for the language
setting; the other 58 keyword sets keep them.
- Drop the unused is_zh_hans helper.
---------
Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* add CLI support for opening directories in new tabs
f
* feat(windows): add optional windows explorer context menus
f
* fix(gui): restore missing windows and reject lossy paths
* fix(windows): harden explorer menu registration and native path handling
* fix(cli): preserve native GUI paths on Windows
---------
Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Three defects in the inline completion menu, all of which produced something
wrong rather than merely unhelpful.
A candidate was inserted into the command line verbatim. A directory named
`My Documents` completed to `cd My Documents/`, which the shell resplits into
two arguments and the command breaks. `shell_escape_path` already existed for
drag-and-drop paths; completion never reached for it. `escape_candidate` wraps
it and keeps a leading `~/` unescaped, since that prefix is the user's own text
and escaping it would stop the home expansion it was typed for.
The same escape decides whether a common-prefix step is safe to write. The
prefix shared by `My Documents` and `My Music` is `My ` — writing it raw both
breaks the line and puts a space inside the open word, which closes the menu on
the next keystroke and leaves the user worse off than before the Tab. A prefix
that needs escaping now steps through the candidates instead.
A menu fed only by generators stayed armed forever when nothing matched.
`git ckout<Tab>` matches no subcommand, but git's alias generator is in flight,
so the session opens empty and waits — and the callback that would have closed
it returned early on an empty result, so the menu never learned the generator
was done. An armed empty menu swallows every later Tab instead of handing the
line to the shell. Sessions now count their generators, and the last one to
answer closes a menu that still has nothing in it.
Command completion scanned this machine's PATH in a remote pane. The remote
isolation added in 08ca3a3 covered paths and generators but deliberately left
command completion running, which was right for the builtins half and wrong for
the PATH half: `system_prof<Tab>` over SSH to Linux offered macOS's
`system_profiler`. Worse, it failed inconsistently — with no local match the
position falls through to the remote's own compsys and answers correctly, so
the bug only appeared when this machine happened to have a match. Builtins are
true on any POSIX shell and still go out; the PATH scan is now local-only.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
$SHELL is a snapshot the session inherits at login, so chsh never moves
it -- a GUI launch keeps reporting the shell that was current when the
user logged in, and goes on doing so until they log out. The window's
shell menu marked the wrong entry "default" for that whole stretch.
Read the passwd entry instead, via getpwuid_r -- the reentrant form,
since getpwuid returns a pointer into a shared static another thread's
lookup can overwrite. $SHELL stays as the fallback for the rare case
where the lookup fails. The three callers that each reached for the
variable on their own -- the default-name lookup, the PATH enrichment
that runs the login shell at startup, and the shell-integration kind
probe -- now share the one function.
Same commit fixes who wins a name in the menu. Candidates were login
shell, then /etc/shells, then $PATH, and dedupe keeps the first -- so
on a machine with a Homebrew bash, /etc/shells listing /bin/bash first
handed the entry to macOS's 3.2 from 2007, old enough that
bash-completion 2.x will not load against it. Probe $PATH before
/etc/shells and widen the probe list to the POSIX shells, so the menu's
"bash" is the binary typing bash would reach; /etc/shells still catches
anything installed off $PATH.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
The taught loop started its worker with `claude -p`, but headless print
mode never stops to ask, so the `waiting` state steps 3-4 are built on
could not arrive. Step 2 now launches interactively.
Every wait after a send passes `--changed`, with the reason spelled out:
without it the loop re-reads the state it just walked in on. The exit-1
"worker died" branch is documented too, and both guards are asserted in
the skill-content test.
Also gives the install round-trip test a Drop guard, so a panic cannot
leave `CLAUDE_CONFIG_DIR` set for whatever runs next in the process.
`installed()` was called from inside `render_settings_agents`, so the
Agents page did a `read_to_string` every frame it was on screen. The hook
rows it was modelled on cache into `SettingsState` precisely to avoid
that; the skill's presence now does the same, read when the page opens
and after a change.
The switch also swallowed its outcome into `log::warn!`. The one error a
user actually hits — uninstall refusing a `tty7-orchestration` file tty7
did not write — left the switch springing back with no explanation. The
result now lands in `orchestration_skill_note` beside the switch, the way
`agent_hooks_note` does, and the install/uninstall itself runs off the UI
thread.
* feat(cli): `tty7 wait` + the agent-coordination note
The two pieces of the original session-CLI PR that main's own CLI
doesn't cover, rebuilt as a minimal delta against it.
`tty7 wait %N --until waiting,done --timeout 600` blocks until a pane's
agent reaches a requested state — the orchestration primitive that lets
one agent sleep until its peer blocks on a permission prompt or
finishes a turn, instead of screen-scraping. A poll of `AgentStates`
rather than an `events` subscription on purpose: a one-shot stateless
question composes into scripts, survives a server restart mid-wait, and
needs no cursor management. Agentless-but-live panes read as idle via
the machine tree; a dead or vanished pane reads as exit, which ends
every wait (matched only when asked for). Timeout exits 124, the
`timeout(1)` convention.
The coordination note is discovery for the whole CLI: a marked,
idempotent block describing the verbs, installed into
~/.claude/CLAUDE.md (always; CLAUDE_CONFIG_DIR honored) and
~/.codex/AGENTS.md (only when ~/.codex exists). A one-time "Let your
agents coordinate?" prompt fires the first time a pane detects a coding
agent; a Settings → Agents switch drives the same install/remove, with
state read from the files themselves. Uninstall strips exactly the
marked block; an unterminated block is left alone rather than truncated
at a guess.
* feat(agents): replace the global note with an orchestration skill
Per review: global instructions tax every session's context and hand
every agent — workers included — the ambient authority to orchestrate
its neighbours. The common shape is primary → workers: one agent owns
decomposition, dispatch, waiting and aggregation; workers just do
bounded tasks.
A Claude Code skill fits that exactly. `core::orchestration_skill`
installs ~/.claude/skills/tty7-orchestration/SKILL.md — only its
one-line description rides in context until the user or the primary
agent explicitly invokes it, and workers never see it. The body can
therefore afford the full delegation loop (tab new → send → wait →
answer-or-capture → pane close) instead of a token-starved cheat
sheet.
The file is wholly tty7-owned: install is a plain overwrite (also the
version-refresh path), and uninstall keys on an ownership marker so a
user's hand-written skill under the same name is refused, not deleted.
Gone with the global note: the first-agent-detected prompt, its config
flag, and the CLAUDE.md/AGENTS.md writers — the Settings → Agents
switch now drives the skill install instead.
---------
Co-authored-by: l0ng-ai <ysdpk123@gmail.com>
Replace the unbounded decode channel with a bounded, latest-frame inbox so full-window browser frames cannot queue faster than they decode. Keep deletes ordered with in-flight work and discard superseded frames before decoding them.
Only retire images that reached the sprite atlas, and evict remaining atlas entries when a pane closes. This keeps hidden terminal-browser tabs and repeated pane lifecycles from retaining one decoded frame per repaint.
Cursor-hiding TUIs (Kimi CLI, Ink apps) draw their caret as a reverse-video
cell and leave the real cursor wherever the frame's last write ended — for
Kimi that is the input box's right border, and the IME candidate list was
stranded there. When the cursor is hidden and its row holds exactly one
caret-sized inverse run, snap the IME anchor (and the marked-text preview)
to that run; rendering is untouched.
The gpui side (bumped here) now also answers IMR_QUERYCHARPOSITION — the
query the Windows 11 Microsoft Pinyin IME uses instead of CANDIDATEFORM —
and re-anchors the candidate window on every WM_IME_COMPOSITION.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pane_title_of() picked any non-empty PaneRecord.title, but an idle
terminal's foreground process is just the shell itself (zsh, bash, ...).
That made almost every idle-shell workspace show up as "zsh" in the
sidebar instead of the far more useful cwd/repo-derived name, which
defeats the "cwd as final fallback" intent of this change.
Skip bare shell process names when picking a pane title, so the
process-name fallback only kicks in for genuinely distinctive
foreground processes (nvim, an agent, ...).
Restarting the local server clears the window's tabs and then pulls the
layout back from the machine tree. The pull went out on the control link
we held, which pointed at the server we had just killed: `is_connected`
only flips once the reader sees EOF, so for a moment the dead link still
classifies as Ready. The call on it failed, `finish_hydration` logged a
warning and gave up, and the window stayed empty on the home page.
Drop the local link before resyncing so the pull waits for the new
server instead of racing a dead socket, and record a failed hydration as
a debt the next sync retries. The debt also stops the empty window from
diffing into "close every tab" and wiping the layout off the machine
once the link is back; a `Replace` retry is abandoned if the user has
filled the window in the meantime.
Follow-up on the review of #277. Seven fixes, no change to what the feature
is for.
An AppImage copy is now claimed with a marker file instead of being inferred
from "am I an AppImage right now". Keying off the runtime meant that a user
who moved from the AppImage to the tarball hit their own copy, read it as
somebody else's binary, and never got another install for as long as that file
sat there.
The Windows uninstaller takes {app} back out of HKCU\Environment. Nothing did
before: the entry is written by the app at runtime, so Inno never knew it
existed and every uninstall grew the user's PATH by one dead entry. Unix has no
equivalent hook and still leaves its symlink behind; that is now stated in the
module docs rather than left to be discovered.
An occupied candidate directory no longer ends the scan, and every platform now
reports whether the install actually wins the lookup. `Occupied` on
/opt/homebrew/bin used to mean giving up while ~/.local/bin sat free, and
Windows — which appends to PATH and so never collides — reported `Installed`
even when an existing tty7 earlier on PATH kept beating it. A new
`InstalledShadowed` names the winner.
`cargo run --release` no longer repoints the developer's real tty7 at a build
tree. `cfg!(debug_assertions)` only covered the debug half of that.
The Windows registry PATH is read, matched, and written as UTF-16 throughout.
It went through `to_string_lossy` before, so a value the registry holds but
Rust cannot represent as a String would have been written back with U+FFFD in
place of its characters — the exact PATH corruption the surrounding code is
careful to avoid.
Two tests mutated $HOME and $PATH while the rest of the binary's tests ran
beside them, and src/ui/home.rs mutates $HOME too. `candidate_dirs` takes home
as a parameter, `place` takes its mode, and the PATH-joining and registry-
joining rules are pure functions — so no test in this module touches the
environment any more. 5 tests become 11, and the Windows joining logic is
covered on every platform.
Also: the config flag reaches Settings → About and both features docs instead
of being config.json-only, startup reads config.json once instead of twice, and
the CLI's strip failure warns like its sibling instead of being swallowed.
The `tty7` CLI was built by every release run and thrown away: all four
bundle scripts copied only `tty7-app`, and the upload glob covers `dist/`,
which the CLI never reached. Nothing put it on PATH either, so the
agent-facing half of the product was unreachable from a shipped install.
Bundle it on all four platforms, and have the GUI link it up itself rather
than hiding the step behind a menu item most people never find.
The install has two halves. The environment half prepends the CLI's
directory to this process's PATH before the daemon is spawned, so every
pane inherits it — that alone makes `tty7` work where agents actually run,
writes nothing to disk, and behaves the same everywhere. The on-disk half
symlinks into a directory already on PATH (Unix) or appends to
HKCU\Environment (Windows), and is allowed to fail.
Candidate directories are a fixed list intersected with PATH, not the first
writable entry on it: pyenv/rbenv/asdf/mise shim directories sit at the
front of PATH on many machines and are writable, and anything dropped there
is deleted on the next rehash — silently, days later.
Debug builds get the environment half only. `target/debug` holds a `tty7`
too, so otherwise a `cargo run` would repoint the developer's real `tty7`
at a debug binary, and each isolated dev-verify instance would rewrite the
PATH of the machine it is meant to stay away from.
The Windows CI job flakes on a_local_window_lists_this_computers_shells:
the local shell probe runs off-thread and, on Windows, spawns wsl.exe to
list distros. With the whole test binary running in parallel on a slow
runner that can take longer than the five seconds pump_until allowed, so
the poll gave up on a probe that was merely late.
Raise the deadline to thirty seconds, matching the other polling tests in
this repo. A probe that does land still returns immediately, so a healthy
run costs nothing extra.
`tty7 tree` answered "launching the GUI is not wired up yet (would open tree)".
Bare `tty7 [PATH]` opens the GUI, so any word clap has no subcommand for lands
in that positional — every typo came back as an offer to open a directory named
after it.
A word with no separator, no leading ./~, and no matching file on disk is a
mistyped verb, and is now reported as one. Real paths still reach the launcher
and fail there for the honest reason.
Also: `ws`'s help said "the named session trees", missed by the session -> shell
pass, and long_about still advertised $TTY7_SOCKET, which no longer exists —
it is $TTY7_CONFIG_DIR now.
spawn_once hard-coded `workspace: None` — the field was added to make the call
compile when the protocol grew it, and never wired up. Every pane the GUI opens
therefore reached its shell without $TTY7_WS, and the GUI is how panes are
normally opened: `tty7 ws tree`, `tab ls` and `run --keep` all refused inside
one, and `doctor` reported the workspace as missing.
The id was already there — `owner` carries it and is passed straight through.
It is taken separately rather than reused after the filter below it: `owner` is
also gated on FEATURE_PANE_OWNER, while the workspace field rides the c4p5 spawn
kind and needs no feature probe. Local routes only, matching `owner`, since a
remote server keeps its own machine tree and this id names a workspace in ours.
Extracted as spawn_workspace so a test pins it; passing None again would be
invisible otherwise.
Two conflicts, both where main's graphics work and this branch's observer work
touched the same lines.
daemon/protocol.rs: both sides appended frame kinds. INPUT_ACK (51) and
IMAGE/DELETE_IMAGE (60/61) do not collide; both kept.
daemon/pane.rs: main taught the reader to forward a chunk as an ordered
GraphicsFrame sequence instead of one Output, so an image lands at the cursor
cell the sender drew it at. This branch had lifted the same send into
fan_out_output, which also feeds read-only observers and holds each to its
budget. fan_out_output now takes the frame sequence: the no-graphics fast path
still sends one Output, and Image frames reach observers as well, gated on their
own length. A Delete selector rides `notify`, which is ungated but still drops
an observer that has stopped draining — matching the drain accounting in
server.rs.
An observer is a read-only mirror of the pane, so it sees images for the same
reason it sees text.
Adds kitty graphics protocol support: a daemon-side APC tokenizer lifts image transmissions out of the PTY stream before the replay ring, forwards them out-of-band as compact binary frames interleaved in stream order, and the client decodes off-thread with newest-wins coalescing per image id. Local panes take the file/shm fast path; remote panes keep pixels compressed in-tunnel. Cell size is now reported to children in device pixels so pixel-aware senders render at native resolution.
Closes#213.
* fix(input): stop a Tab on a detached remote pane from eating the line
Tab reaches the view through the SendTab/SendBackTab actions, which bypass
the read-only guard in on_key_down. On a remote workspace whose link is not
attached, that let a failed completion run handoff_line_to_shell: it cleared
the editor's line and wrote it to a writer nobody was reading, so the command
vanished, the editor stood down for the rest of the prompt cycle, and every
later keystroke was swallowed by the guard the Tab had skipped.
Guard tab_pressed, handoff_line_to_shell and submit_command with
accepts_input, so a pane that cannot reach its shell keeps the line instead
of handing it to nowhere. A late SFTP listing now also checks that the editor
still owns the line before acting on it - that round trip can take seconds.
A listing that fails still hands the line to the shell, same as locally.
* fix(input): name the reason a detached link turns a Tab away
Fold the link check into a `link_inactive_reason` so the Tab path logs why
it did nothing, and let `remote_path_results` consult the link as well as
the editor instead of leaning on `handoff_line_to_shell` to catch it.
Reword the failed-listing warning: with the drop guard in place the line
does not always reach the shell.
Dispatch the Tab in the disconnected-pane test through the real `SendTab`
action, since the bug was that the action skips `on_key_down`, and add a
test that a Tab on a detached pane never starts an SFTP listing — the one
thing only the Tab-path guard prevents.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
The workspace switcher's row menu now uses the same verbs as every
other entry point (Stop/Delete Workspace), running processes are
shells everywhere, SSH links are connections, and the background
process is the server in all user-facing text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
Server-side gaps for the tty7 CLI (docs/cli-design.md, the additive tier):
- Pane multi-subscriber: the single controlling subscriber keeps its
preemption semantics, and a pane now also carries N read-only observers.
A new pane-protocol frame `Observe { pane_id, size }` (kind 54) joins a
connection as an observer: it gets the Snapshot replay, then
Output/Exited/Size, and its Input/Resize is answered with an Error frame
instead of reaching the shell. Observers never displace the GUI, never
defer the dead-pane reap, and are pruned when their connection goes away.
- Context env injection: every locally spawned shell now carries
TTY7_PANE (its pane id), TTY7_WS (the workspace named in Spawn), and
TTY7_SOCKET (the control endpoint path), next to the existing TTY7
marker. ClientMsg::Spawn gains an optional `workspace` field, carried
by the SPAWN_OWNED frame with serde defaults.
- Aggregate queries: ControlRequest::{AgentStates, Routes, Status} with
ReplyOk::{AgentStates, Routes, Status}. AgentStates snapshots each
pane's live agent session state through a new Services::panes directory
wired from the daemon's registry; Routes lists the SshManager's held
connections with key/kind/liveness; Status reports pid, uptime from a
process-start instant, pane count, both dialect versions, build, and
the control socket path.
- Dialect bump: CONTROL_VERSION 3 -> 4, PROTOCOL_VERSION 4 -> 5,
following the convention set by bed22d8 and 1792bb8/a4972d3 where every
additive variant bumped the strict-equality handshake versions (and
with them the tty7-server-c{control}p{protocol} install name).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
Off macOS every GUI terminal teaches the same chords -- GNOME Terminal,
Konsole, Windows Terminal and WezTerm all copy on Ctrl+Shift+C and paste on
Ctrl+Shift+V -- but tty7 answered neither. Both are now defaults, alongside
the unchanged Ctrl+C/Ctrl+V.
Shift+Insert was installed as a hardcoded binding in init(), which made it
invisible: Settings could not show it, record over it, or warn when another
chord collided with it, and a user's own shift-insert binding silently lost
to it in the Terminal context. Copy and Paste are now rows in the default
table like any other action. Shift+Insert rides along as Paste's second
chord through the same mechanism that gives InsertNewline its Alt+Enter --
generalized from a one-action special case into a table -- so rebinding
Paste retires both defaults together instead of leaving Shift+Insert
pasting behind the user's back.
The control-code guard also tightens: Ctrl+2..8 are NUL, ESC, FS, GS, RS,
US and DEL in xterm-style terminals, and Ctrl+` is NUL, so a future default
cannot land on them.
The changelog now also spells out the word-motion trade Alt+arrows made:
Alt+left/right focus panes (Windows Terminal's default), and word movement
in the prompt editor is Ctrl+left/right, with both Shift variants selecting
by word.
Refs #269
Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(input): stop the Windows/Linux keymap and CRLF pastes from breaking the shell
Two problems a Windows user hit within an hour of installing tty7, both from
code that only ever had macOS in mind.
A bracketed paste forwarded the clipboard byte for byte. Text copied on Windows
carries \r\n, and vim counts CR and LF as two line breaks, so pasting code into
it left a blank line under every line. Fold \r\n to \n inside the paste markers
so a Windows clipboard produces exactly what the same paste already produced on
Linux and macOS. The non-bracketed path keeps converting to CR: with no paste
mode to tell text from typing, a line break has to arrive as the byte Return
sends.
`secondary` means Cmd on macOS and Ctrl everywhere else, and the default keymap
was carried over from macOS unchanged, which put window actions on top of
terminal control codes. Ctrl+D could not send EOF, Ctrl+[ could not send ESC,
and Ctrl+W, Ctrl+K, Ctrl+P, Ctrl+J, Ctrl+T, Ctrl+Q and Ctrl+S were all taken.
These bindings carry no context, so they match before the terminal sees the key
-- apply_readline_ctrl already sends EOF for ^D, it was just unreachable.
Off macOS, ctrl-<letter> and ctrl-[ ] \ space now belong to the terminal and
window actions move to ctrl-shift-*, the convention GNOME Terminal, Konsole,
Windows Terminal and WezTerm share. Tabs move to Alt+1-9 and pane focus to
Alt+arrow, since Ctrl+Alt+arrow is workspace switching under GNOME. Ctrl+S
keeps saving in the code panel but propagates when the editor is unfocused, so
a shell still gets XOFF. Shift+Insert pastes. macOS is untouched, and a user's
own rebindings still win over the defaults.
A test walks the default table and rejects any chord that lands on a control
code, so the next binding added cannot quietly bring this back; a second one
rejects two actions claiming the same chord, which is how the reshuffle caught
NewTab colliding with ReopenClosedTab.
Refs #269
* fix(history): keep one history store per machine, not one per install
tty7 owns ^R at the prompt and shows its own fuzzy menu, but `history.rs` had
no notion of where a command had run: `load()` read one file and `append()`
wrote one file, so an ssh session was offered the commands typed on the local
machine. That is worse than an empty menu -- the entries look plausible until
one of them runs against the wrong box.
History is now scoped. The local store keeps its existing path, so nobody loses
what they have; each remote gets its own file under `history.d/`, named after
the target with a hash appended so a label that is not a filename ("me@box:/srv")
cannot escape the directory or collide with another host.
A pane picks its scope from the innermost remote it can see -- the ssh target
reported by shell integration, else its host id -- and follows it when that
changes, clearing the menu and reloading in the background rather than showing
the previous machine's entries while it waits. A reload that lands after the
pane has moved on again is dropped.
On a remote workspace, where a Host impl exists, the far end's own
~/.zsh_history and ~/.bash_history are read through the same channel git status
and file listings already use, so the first ^R on a freshly connected box has
something in it. Bare ssh has no Host to read through and falls back to what
tty7 recorded itself for that target, which is still the right machine's
commands.
Refs #269
* fix(test): the control-code guard must admit Ctrl+S, which falls through
The guard forbade every bare ctrl-letter default, but EditorSave staying on
Ctrl+S is the design: its handler propagates whenever the editor does not
have focus, so the terminal still receives XOFF. The guard's real invariant
is "no default may swallow a control code" -- an action that falls through
does not swallow. EditorSave is exempted by name, with the condition for
earning a spot written next to the list.
The test is vacuous on macOS (secondary parses to Cmd), which is why the
contradiction only surfaced on the Linux runner.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(history): never seed an inner ssh target from the workspace host's files
remote_shell_history_sources gated only on "scope is remote and host is
remote", so a pane that ssh'ed onward from a remote workspace -- scoped to
the inner target -- was seeded from the workspace host's ~/.zsh_history:
another machine's commands in the menu, the exact confusion scoping exists
to prevent. The Host can only reach the workspace machine's home, so seeding
is now reserved for the host's own scope; a pane with a remote_context falls
back to what tty7 recorded for that target, exactly like bare ssh from a
local pane.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Removed all Rust comments -- line, block, and doc -- from the 139 tracked
.rs files with `uncomment` 3.5.1. It parses each file with tree-sitter
instead of matching text, so comment-like content inside string literals
is left alone: the JavaScript plugin source embedded in agent_hooks.rs
raw strings keeps its own `//` lines.
Left alone: Cargo.toml comments and the shell scripts under scripts/.
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
* fix(session): come back to the last workspace closed, not the home page
Launch only ever restored a workspace that still had a window at quit, so
closing them one by one and relaunching came up on the empty home page —
with no hint that four workspaces were sitting there detached.
Closing a window here is a detach: its panes keep running in the daemon,
which makes that workspace every bit as much "where you left off" as one
that still had a window on screen. `workspace_to_restore` now falls back
to the most recently active workspace of any kind, and since
`close_window` touches it on the way out, that is the one closed last. An
open workspace still outranks a more recently touched detached one, so a
background agent cannot steal the restore from the window that was
actually on screen. Deleting a workspace still drops it from the file —
that is the one gesture meaning "done with this".
`None` therefore means a genuine first run only, which retires the
`FreshStart::HomePage` launch path along with the enum that threaded it
through `windows::open` and `Tty7App::for_workspace`.
* fix(remote): reopening a remote workspace starts the supervisor again
The supervisor stops — and clears every MachineLink with it — as soon as
no open workspace is on a remote machine, which closing the last remote
window does. The connection itself stays: a closed window is a detach,
so HostLinks keeps the socket for whatever opens next.
Reopening that workspace then found a live connection with no link
behind it. `reopen_remote_at_startup` read the surviving HostLinks entry
as "another window got there first" and returned before starting the
pump, so nothing ever put a MachineLink back and `status_of` answered
Disconnected for good: a "Not connected to <machine>" strip and a dead
keyboard over panes that were visibly still running on the far side.
An existing HostLinks entry is not a reason to skip the supervisor. It
answers "is there a socket"; what the window renders from is `machines`.
Both now go through RemoteLinks::supervise, which is a no-op for a local
workspace and an unconditional ensure_running for a remote one —
idempotent, so a machine already supervised costs a flag check, and the
first tick over a live socket marks it Attached without opening a second
SSH session.
Fixes the same hole on the switcher's path, which never told the
supervisor anything at all: picking a remote workspace from it goes
through `switch_workspace`, not the launch path.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
`tty7-server-x86_64-unknown-linux-musl` was never a name anyone chose. Both
workflows staged the file as `tty7-server-${{ matrix.target }}`, so the build
triple went straight into a published filename — and the triple's *vendor*
field, for a Linux target with no particular vendor, is the literal word
`unknown`. It has been sitting on the releases page reading like a failed
lookup.
Of the triple's four fields only two say anything to whoever downloads this:
the architecture, which is what `asset_for_uname` picks by, and `musl`, which
is why one file runs on any distribution. So:
tty7-server-x86_64-unknown-linux-musl → tty7-server-linux-x86_64-musl
tty7-server-aarch64-unknown-linux-musl → tty7-server-linux-aarch64-musl
`<os>-<arch>` in that order because that is what the GUI assets in the same
release already use (`tty7-<version>-linux-x86_64.tar.gz`). One release should
be one naming scheme; it was two.
The triple stays everywhere it really is a build target — `cargo zigbuild
--target`, the `target/<triple>/release` path, the rust-cache key, ci.yml's
matrix. The workflows now carry both: `target` for the build, `asset` for the
filename, deliberately not the same string.
This name is a contract with more than the release step, and all of it moves
together:
- `install::asset::{ASSET_X86_64, ASSET_AARCH64}`, which is what the client
appends to a release URL.
- `bundle-windows.ps1`, which stages the musl binary for WSL. `wsl.rs` looks
for `<dir>/<asset name>` with nothing translating, so the *filename* is as
much a contract as the `server/` directory is — now said out loud in both
places, along with the consequence for `TTY7_BUNDLED_SERVER_DIR`: a
cross-compile has to be copied to the asset name, not left as `tty7-server`.
- The GUI's install prompt fixture, the checksum manifest fixtures, and the
`MissingBundled` assertions.
Nothing globs the old shape: `gh release upload dist/*`, `checksums.txt`'s
`find`, and the installer's `server\*` are all name-agnostic.
A new test pins both names as literals — the module header already says this
naming is "a *literal* contract with the release workflow", and asserting the
consts against themselves asserted nothing. It also fails on the substring
`unknown`, since that word only ever arrived here by way of `matrix.target`,
and checks neither name contains the other, which is what
`checksums::expected_digest` says out loud that it relies on.
Compatibility: a stable client asks its own frozen tag, which keeps whichever
name it shipped with, so every released client keeps working. The rolling
`nightly` tag is replaced each night and its prune step drops assets the run
did not upload — so an *already installed* nightly client 404s on the server
download until it updates itself. Accepted deliberately; the next release is
what has to be right.
Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(install): name remote servers by dialect, and prove the name before publishing
A remote tty7-server was installed as `tty7-server-<version>`, and one stat of
that path decided whether anything got installed at all. The version string is
not an identity: every build between releases carries the same one, so a client
speaking control v3 found the released v2 binary sitting at the exact path it
looks for, skipped the install, adopted a server it cannot talk to, and died in
the handshake with no way out. `check_running_build` compared the same string
and so never even probed.
Name the file after the two numbers that actually decide compatibility —
`tty7-server-c<control>p<protocol>` — and after uploading, ask the binary what
it speaks and publish it only if the answer matches the name it is about to get.
That turns "the filename is the dialect" from a convention into a fact a stale
bundle or an older release tag cannot break, and surfaces an architecture
mismatch at install time rather than as `Exec format error` inside a later
daemon launch.
The keep-or-restart prompt offered "Keep Sessions" for a state where keeping
means the connect fails a moment later; it now offers Cancel. A handshake that
does fail gets a Restart Server button, gated on that one error so no other
connect failure can reach a destructive action that would not fix it.
* fix(remote): offer "Restart Server" only where there is one, and keep a torn-down pane's resume record
`RemoteTarget::is_ssh` answers the question the verb depends on, in one place
rather than re-spelled at each call site: a WSL distribution's server is started
by this client and a `LocalStdio` machine is a child process per connection, so
neither has a long-lived daemon to stop and start — which is exactly why
`router::restart_server` refuses them. The UI that offers the action and the
router that carries it out can no longer disagree about who has one.
"Restart Daemon…" now asks the window which daemon serves it. Run from a remote
window it used to end every local session in every other window and leave the
machine in front of the user untouched — a destructive button that did nothing
its label promised.
A pane torn down with its coding agent no longer publishes an observation. The
kill hangs up the whole process group, so the agent dies before the PTY EOFs and
a poll firing on the bytes still in flight sees nothing recognizable; publishing
that cleared the record's agent and session id, and the reopened workspace came
back to a bare shell instead of the conversation. The same answer about a pane
nobody is tearing down still means the agent exited on its own.
* fix(remote): review follow-ups on dialect-named servers
Six findings from a review pass over the two commits. One commit
because they are all corrections to the same change.
- The progress bar the `Restarting` phase was added for never appeared
for the flow that needs it most. The switcher only reads a machine's
phase while *this* window is connecting or showing an error, and
"Restart Server…" in the machine's `⋯` menu is deliberately offered
whatever the link is doing — so restarting a connected or offline
machine recorded a phase nobody read, and the click had no visible
effect for the length of two timeouts. A `Restarting` phase now
counts on its own: it is only ever recorded by an action a user
asked for, it ends every session on that machine including the ones
other windows are showing, and it is the one flow that transfers
nothing and so has no other way to say the click landed. The header
says "restarting…" rather than borrowing "installing…", which
described bytes that are not moving.
- "Restart Server" on the error card silently did nothing when the
machine could no longer be addressed: `replace_remote_server` logged
the failure and returned, past a prompt that had just promised the
server would be replaced. It reports it, like every other failure on
that path.
- `is_ssh` gates an action that ends every session on a machine, and
spelled the three yes-variants as a `matches!` — so a new
`RemoteTarget` would inherit "not SSH" by falling off the end of the
pattern, which is the opposite of what its own test claims. An
exhaustive `match` makes the compiler ask.
- The mismatch prompt's detail explains its buttons by name and the
buttons were written out again at the prompt. `Keep Sessions` was
one of them until this branch removed it, which is exactly the drift
worth preventing twice: `MISMATCH_ANSWERS` is named once beside the
detail, and a test asserts the detail explains every answer offered.
- `unique_temp`'s pid is private to a process, not to a client: two
clients on different machines can share a pid. Left alone, because
the new `--protocol` check stands behind it — interleaved bytes
cannot answer with our dialect, so they are refused rather than
published — but said, along with the fact that two installs inside
one process share the path too and are the locks' job, not this
function's.
- Ten doc sites still said the installed file is named after the
version, including the module table's step 2, `client_version`'s
"the server that matches me", `Installer::run`'s postcondition and
`ensure_remote_server`'s returned path. Also corrected the claim
that WSL and `LocalStdio` have no daemon to restart: WSL's is
started by this client, which is why "stop it and reconnect" is the
whole verb — the reason the router refuses them, not the absence of
a daemon.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(ui): the routed-auth test can no longer hang the whole suite
`a_routed_auth_prompt_carries_the_machine_that_raised_it` waited for its own
prompt in an unbounded spin loop. `AUTH_MAILBOX` is process-global and
`pump_auth_sheets` drains every entry in one pass, so any gpui test in this
binary that drives a tick can take that prompt first — and unbounded, the loop
then spins until GitHub's six-hour job limit.
This is the hang CI has been paying for, named twice and on two platforms:
2h50m inside this test on x86_64-unknown-linux-gnu (run 30517182773), and again
on windows-msvc (run 30526538997). It is *not* the cfg(windows) transport
accepts bounded in #261; those were a separate latent six-hour hang.
The loop now has a ten-second deadline and an assertion that says what an empty
mailbox means and whether the responder thread had finished. Note what that does
and does not buy: a stolen prompt becomes a fast, self-explaining failure instead
of a six-hour outage, but the theft itself is still possible, and curing it means
deciding what that process-global mailbox should be under test — a design call,
not something to settle inside a CI fix.
ci.yml keeps only a comment where a post-mortem step used to be, because the
step was worthless twice over. It cannot work: GitHub kills the step's process
tree when `timeout-minutes` trips, before the next step runs, so on run
30526538997 the dump printed two headers and nothing between them. And it is not
needed: libtest already prints "<test> has been running for over 60 seconds",
which was in every hung run all along. The obstacle was only ever that a job's
log cannot be fetched while the job is in progress — which the `Test` timeout
fixes by making the step fail.
* fix(ui): a test waiting on the auth mailbox is no longer raced by a tick
The previous commit made the flake loud instead of fatal; this stops it
happening. CI proved the mechanism on the very next run: the new assertion
fired on windows-msvc with "no routed prompt arrived within 10s ... Responder
thread finished: false", 731 other tests passing, the whole suite done in
12.39s instead of hanging for six hours.
`AUTH_MAILBOX` is process-global and `pump_auth_sheets` takes every entry in
one pass. That is right for the app — one tick, one mailbox — and wrong in a
test binary, where a test waiting for the prompt it just caused shares that
mailbox with every gpui test that drives a tick. The tick drains a prompt it
has no idea was spoken for, and the waiting test never sees it.
`MAILBOX_TURN` arbitrates: a test that needs its own prompt back claims it for
the exchange, and the drain yields while it is held. Both the static and the
claim in `pump_auth_sheets` are `#[cfg(test)]`, so a release build is byte-for
-byte what it was — there is one app, one tick, and nothing to arbitrate.
The compromise is visible and deliberate: test-only synchronisation inside a
production function. The alternative that needs no such thing is to stop the
mailbox being process-global — dependency-injected per app — which is a larger
change to a path this defect does not otherwise justify touching.
No deadlock: the claim is the first thing `pump_auth_sheets` does, before it
locks the mailbox, so the two locks are only ever taken in one order.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>