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.
The CLI's own --help calls it "built for coding agents", but `capture` handed
back the daemon's raw PTY bytes, which is the least readable thing it emits,
and every verb panicked when its reader hung up.
`capture --plain` replays those bytes through a terminal grid instead of
stripping escapes from them, using the same alacritty_terminal rev the GUI
renders panes with. The difference is not cosmetic: only the grid knows that a
break at the pane's width was a wrap rather than a newline, that a CR meant
"overwrite this line" rather than "end it", and which cell a wide char shares
with its spacer. A regex gets the easy 90% and then invents the rest — on one
real pane it turned 1193 lines into 2806.
The size each segment needs comes for free: the daemon already sends
DaemonMsg::Size right before every Snapshot, and the CLI was discarding it.
Panes here measure 249 and 86 columns, so the hardcoded 120 would have wrapped
both in the wrong places. Observing still resizes nothing.
The pipe fix is two mechanisms with one contract. On Unix SIGPIPE goes back to
its default disposition, which covers every write site at once and ends the
process the way it ends `cat` (141). Windows has no such signal, so stdio::out
recognizes the hung-up write and leaves quietly. Before this, 16 of 19 verbs
printed a panic and a backtrace note for `tty7 ls | head -1`; `run` instead
reported it as a failure with exit 1.
Also adds skills/tty7, the Claude skill for driving this CLI. It shipped with a
Python ANSI stripper, which is what prompted --plain; the script is gone.
alacritty_terminal moves to [workspace.dependencies] so the GUI and the CLI
cannot drift onto two revs of the fork.
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>
a_slow_stream_outlives_its_idle_timeout proved that the idle window
resets per message by having a thread sleep 60ms between sends and
trusting that to stay under a 150ms timeout. That is a 90ms margin
against the OS scheduler, and the darwin CI job lost the bet.
It also tested the wrong thing: whether recv_timeout fires after the
deadline is the standard library's contract, not ours. What is ours is
that the loop restarts the window on every message rather than budgeting
the whole stream.
Make the one blocking call injectable and script it. Both timing tests
now feed drain_git_stream a fixed sequence -- no threads, no sleeps, no
wall clock -- and the pair runs in microseconds. The queue-budget tests
keep their real channel, which is what they are about. Rename the second
test to say which property it holds.
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.
Listing WSL distros shells out to wsl.exe with no time limit, so a WSL
service that is starting up, updating, or simply wedged blocks the whole
shell probe -- and with it the menu of shells the window offers. The
Windows CI job hit the slow end of this often enough to flake.
Add proc::output_within, which spawns the child, drains both pipes on
their own threads so a large write cannot deadlock the reaper, and kills
the child once the deadline passes. Give the distro listing three seconds
of it; a machine that cannot answer by then simply reports no distros,
which is what an unreachable WSL already produced.
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.
Both socket_path_for and socket_path_in live behind cfg(unix) — Windows
serves the control channel over a named pipe. The test referencing them
broke the Windows lib-test build. Gate the test, and narrow the
socket_path_in re-export to test builds so it stops warning as unused.
`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.
Manual testing found `tty7 run`, `send`, `capture`, `procs` and `split` broken
against any normally-installed server — the CLI's entire hot path. Only the
control verbs worked.
Two endpoints, two rules. The pane socket came from the config dir; the control
socket ignored it and sat in $XDG_RUNTIME_DIR/tty7 or ~/.local/share/tty7 —
under the same basename, `daemon.sock`. So they were told apart by directory
alone, and the CLI, handed one path in TTY7_SOCKET, reconstructed the other with
with_file_name: on the default layout that returns the input unchanged. Pane
verbs dialed the control socket and the daemon hung up on them. A --config-dir
server was worse: it published the *default* control socket to the shells it
spawned, so a CLI inside an isolated instance drove a different server.
The e2e suite passed throughout because its harness set TTY7_CONTROL_SOCK
explicitly, placing both endpoints in one directory under different names — a
layout production never produces. It had removed the bug's precondition.
Now: the control socket is derived from the config dir like the pane socket
(control.sock beside daemon.sock, mirroring Windows' control.port/daemon.port,
with -control on the hashed fallback so the two cannot collide), and panes are
handed TTY7_CONFIG_DIR instead of a socket path. A CLI inherits it, so
ControlClient::connect and PaneClient::local resolve the same two sockets the
server opened, through the same functions. No second derivation to disagree.
remote_link's remote_control_socket was a third copy of the old rule, used to
locate a remote server's endpoint before connecting; it follows the config dir
too, and the env probe now reads $TTY7_CONFIG_DIR.
Drops the CLI's server-lifecycle guard: stop/start already follow the config dir
through transport::connect and --config-dir, so there is no longer a mismatch to
refuse. The e2e case that covered only `status` over a lone variable now also
runs a pane verb — the asymmetry it missed is exactly what broke.
Note: this moves the control socket for existing installs. A running pre-change
daemon will not be found at the new path, which is the honest outcome — its
control dialect is v3 against this build's v4, so reaching it only produced a
version error anyway.
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.
Builds before the tty7/tty7-app split installed the GUI as tty7.exe. Upgrading
only adds tty7-app.exe, so the old binary stays on disk — and a taskbar pin,
which Inno cannot rewrite the way it rewrites [Icons] shortcuts, still points at
it. The user keeps launching the previous version from their pinned icon,
against the same daemon endpoint as the new one.
[InstallDelete] runs after PrepareToInstall has stopped the daemon and released
the file lock, and before the new files land. A fresh install has nothing to
remove.
An interrupted `tty7 run` leaves its pane running with nothing referencing it:
no workspace holds it, every listing walks the tree, and the orphan sweep only
logs. `pane ls --all` reads the server's registry instead and marks what nothing
holds; `pane close` falls back to hanging a pane up directly when no workspace
can route a PaneClose, so an orphan is stoppable.
--quiet silenced failures as well as successes, leaving a bare exit code and
nothing to debug; it now suppresses only output on success, and covers --json
too. `run` exited through a path that skipped the report entirely, so
`run --json` printed nothing at all; it now carries its report, with
exit_code_known distinguishing the command's own 1 from the stand-in 1.
The server lifecycle verbs can only drive the default endpoint — spawn::stop
dials transport::connect() — while every other verb follows $TTY7_SOCKET. They
now refuse when that names a different endpoint rather than acting on a
different server than `tty7 status` reports on.
Also: tables pad by display width, so a CJK name or path no longer skews every
column after it; --h/--v become --horizontal/--vertical with the short forms
kept as aliases; the verbs that are not implemented say so in --help instead of
only at runtime; capture's help admits it decodes as lossy UTF-8.
On macOS, setsockopt against a socket whose peer has already closed fails with
EINVAL. The daemon answers a bad request by writing one Error frame and hanging
up at once, so PaneSession's `set_recv_timeout(...)?` would fail before the
refusal was ever read — turning "no such pane 42", already sitting in the
buffer, into "Invalid argument".
Bounding the reply wait is an optimisation, not a correctness requirement, so it
is now best effort in both attach/observe and spawn. Nothing can hang as a
result: a closed peer returns EOF immediately, and a live peer is exactly the
case where setsockopt succeeds.
This is what made client_lib's reattach test red.
run_stream polled pane.controls(epoch) every 200ms behind a read timeout, so
every attached pane woke its thread five times a second just to notice a
handover that may never come. The writer already learns of the handover the
instant it happens — its channel closes — so it now shuts the connection's read
side down on its way out, and the reader goes back to a plain blocking read.
SshManager::routes() reported a link as disconnected whenever its slot's
try_lock failed, which is precisely when the link is in use. The CLI's
-m <machine> refuses to route over a link it is told is down, so an actively
used connection would intermittently fail. Busy now reads as connected, matching
how SshConnection::is_alive resolves the same contention.
Also: the exit-code probe takes the child lock with try_lock, since Drop holds
it across a blocking wait(), and its window drops from 2s to 500ms — it only
needs to cover the race between pty EOF and the child becoming reapable, and
everything past that is a pane that looks frozen to every client. Observer
budget now covers status traffic and the initial replay, not just output.
Uptime is anchored where the control listener opens so a GUI-hosted server
does not report itself as freshly started.
* 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>
ClientMsg::SendInput (kind 55) writes to a pane's PTY without touching the
controlling subscriber, its epoch, or the size, answered by DaemonMsg::InputAck
(kind 51) or an Error for a missing or exited pane; PaneClient::send_input
wraps it. Both stay within protocol 5.
run_stream now polls its half of the socket and epoch-checks against the pane
before forwarding Input or Resize, so a controller displaced by a preempting
Attach stops writing into the shell and has its connection shut down instead of
half-open forwarding forever.
Each observer meters its queued Output through its own OutputGate; one that
lets 8 MiB pile up is pruned rather than growing daemon memory, while the
controller and the PTY never wait on it.
The pidfile reap guard accepts any legitimate daemon exe name (current exe,
tty7-app, tty7-server, tty7; .exe optional, case-insensitive on Windows), and
the agent-hooks console fast path matches tty7-server.exe and tty7.exe next to
tty7-app.exe.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
- workspace: tty7-cli joins default-members, so a bare root cargo test runs it
- run --keep files the pane into its workspace via TabCreate (and refuses to
keep a pane no workspace would list); --ws help says what it really does
- server start|stop|restart|logs refuse -m instead of silently acting locally
- server start kills the spawned process when it never opens its endpoints
- -m over a down link is refused instead of redialing with auto auth
- capture help tells the truth: raw ANSI bytes, last ring segment by default
- a missed exit-code probe exits 1 with a stderr note, not a fabricated code
- TTY7_SOCKET is honored: control dials it, the pane endpoint is its sibling
- attach's success JSON says attached, not detached_from
- e2e daemons ride a KILL_ON_JOB_CLOSE Job Object on Windows, so a hard-killed
harness cannot leak servers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
StubBackend is gone; RealBackend lazily opens a ControlClient for control
ops and a PaneClient for pane ops. Backend::spawn_shell now returns the
daemon-assigned pane id, so every creating verb (new, tab new, split)
spawns first and seeds the tree op with the real id — client-side pane-id
allocation is deleted. Live end to end: ls, ws/tab/pane verbs, new, send
(attach-input-detach), capture (observer replay; --scrollback = whole
ring, default = the ring's last segment), procs, run (streams output,
passes the child's exit code through, --keep leaves the pane), events
(human lines or NDJSON), agents, status, machine ls, and doctor's server
half (reachability, dialect, status, links). tty7 server start|stop|
restart|logs manage a sibling/PATH/TTY7_SERVER_EXE tty7-server. -m routes
both channels over the local server's link, resolved against Routes by
key or bare host; jump/proxy-chained keys are refused with the reason.
Interactive attach stays stubbed for the next slice. A harness-free e2e
suite drives the compiled tty7.exe against an isolated real server.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
The daemon now waits its pane child and puts the real exit code on the
Exited frame (and replays it to late subscribers of a dead pane) — the
prerequisite for tty7 run's code passthrough. The client library gains
PaneClient::observe (read-only replay+stream), PaneClient::procs, and
PaneClient::routed for reaching a remote machine's pane daemon over the
local server's ROUTE frame, mirroring ControlClient::routed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
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
ControlClient wraps the control-socket machinery behind connect /
connect_at / routed, adds a channel-backed events API on top of the
existing EventSink, and keeps request deadlines and blob replies.
PaneClient speaks the pane socket: one-shot list/version/kill plus
spawn/attach sessions that stream DaemonMsg and split into input and
output halves. transport grows connect_endpoint_at so a client can
reach a server by explicit endpoint file instead of the global config
dir. Integration tests drive both clients against a spawned
tty7-server --daemon in an isolated config/data dir on every platform.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
The thin console CLI from docs/cli-design.md, transport-free for now:
- clap grammar for the full documented surface: ws/tab/pane/machine/server
nouns, the hot-path top-level verbs (ls/attach/run/new/split/send/capture/
procs/agents/events/status/doctor), global -m/--json/-q, `run -- <cmd...>`,
and bare `tty7 [path]` parsing as the GUI launcher stub.
- tmux-style addressing (%42 pane, @7 machine-wide tab ordinal or @<uuid>,
workspace by name / id / unique id prefix) with implicit-context fallback
to TTY7_PANE / TTY7_WS and a "not inside a tty7 shell" error naming the fix.
- Backend trait as the integration seam: control() speaks real
tty7_core::daemon::control ControlRequest/ReplyOk values, plus declared
pane-side entry points (spawn_shell/send_input/capture/procs/attach_pane/
run/events). StubBackend fails loudly until the transport client lands;
MockBackend asserts the exact request shapes every structural verb builds.
- Plain aligned tables and trees for ls/tree/pane ls, one JSON object per
command under --json. Exit codes: 0 ok, 1 failed, 2 usage (clap default).
agents/status/machine ls stay stubbed: they need ControlRequest::AgentStates/
Status/Routes, which another slice is adding; ws stop and server start/stop
likewise wait on their mechanisms rather than inventing protocol.
Build/test this package alone (cargo build -p tty7-cli): its bin is named
tty7 and collides with the GUI bin until that one is renamed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JPaaZVK7rfQPKyrymzsYv
The package name and every display name ("tty7" in menus, tray, .desktop
Name, CFBundleName, installer AppName, shortcuts) stay as they were; only
the executable file is now tty7-app / tty7-app.exe, per docs/cli-design.md.
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>
`an_install_reports_both_transfers_to_completion` and
`every_report_carries_the_host` built their `Installer` by hand — `with_version`
and `with_timeouts`, no `with_dialect`. So they ran at
`RemoteProtocol::of_this_build()` while `FakeRemote` answered the `--protocol`
probe with the fixture's fixed `c3p4`, and passed only because the real
`CONTROL_VERSION` currently *is* 3.
Found by bumping `CONTROL_VERSION` to 4 locally: both tests fail with
`DialectMismatch { wanted: c4p4, spoke: c3p4 }` — an error about a fake's
plumbing, raised in two tests that are about *progress reporting*, at the one
moment somebody is busy changing the wire and least able to spare the
attention. The fixture's own comment says the dialect is a fixed literal
"rather than `RemoteProtocol::of_this_build`", which is exactly right and was
exactly what these two bypassed.
Both now go through `installer()`, whose `release` parameter is a
`&dyn AssetFetcher` so that a chunked fetcher — the only reason either test
hand-rolled the builder — is no longer a reason to leave the helper. The
helper's doc says what forgetting it costs, since that is the failure mode a
reader needs and the compiler cannot give.
No behaviour change, and the same 644 tests before and after.
Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.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>