mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
709243f9604963a4201724e0c0e243ccc6df2cdf
112
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8286317493 |
fix(switcher): make Tab cross to the tab column instead of chasing a button
The panel's footer says Tab is the way across once a query is in the box, and it never was. gpui-component's Root binds `tab` to its focus walker, gpui dispatches bindings *before* key listeners, so `on_switcher_key` never saw the key: Tab put a blue focus ring on the ellipsis button in the panel's own header and left the columns where they were. Tab and Shift-Tab now bind to the panel's own key context, which sits deeper in the dispatch path than Root's — the same shape the terminal already uses to keep Tab for the shell. Crossing over only helps if you can see where the cursor went, and you could not: both columns keep their rows inside one child element, and `ScrollHandle::scroll_to_item` indexes a scroll's *direct* children, so it could only ever find item 0. Walking a long tab list quietly left the selection below the fold. The selected row now wears a `ScrollAnchor`, and stepping the cursor scrolls to it — in either column. |
||
|
|
b77f70310d |
fix(prompts): put the action on the right and give Escape a home
Every confirmation dialog in the app was built as `&[Cancel, Delete]`, and gpui hands answer 0 to the platform first — which NSAlert draws on the *right* and gives Return. So the app shipped 14 dialogs with the buttons mirrored: Delete sat on the left, exactly where a decade of macOS has trained people to expect Cancel, and Cancel sat on the right holding the default. Escape was worse: it did nothing at all, anywhere. gpui only sets the Escape key equivalent on a `PromptButton::cancel`, and every call site passed plain strings, which become `PromptButton::Other`. There was no way to dismiss any of these dialogs from the keyboard except by pressing Return. A shared `ui::confirm_answers(action, keep)` now builds the pair, so the arrangement is decided in one place: | | Before | After | |---|---|---| | Right button (Return) | Cancel | the action | | Left button | the action | Cancel | | Escape | nothing | Cancel | | Space / initial focus | Cancel | Cancel | | `Ok(1)` means | act | — | | `Ok(0)` means | cancel | act | Verified on the file-tree delete, end to end: Escape leaves both files in place, Return removes only the one that was right-clicked. Also checked on the ⌘W busy-pane guard, where Escape and Space keep the tab and Return closes it. Two prompts keep their own shape and say why in a comment: - The daemon-mismatch prompt at launch offers Quit and Restart Server and nothing else. With no answer that leaves things alone there is nothing safe to give Escape, so Quit stays on Return — it loses no sessions, while restarting the server ends every one of them. - The unsaved-editor prompt has three answers. Save keeps answer 0, Cancel is marked so it takes Escape, and Discard sits on the far left where nothing lands by reflex. gpui puts the initial keyboard focus on Discard so it stays reachable without a mouse. |
||
|
|
f45dd34cab |
feat(update): separate Stable and Nightly release channels (#386)
The update channel becomes a property of the installation rather than something derived from how version numbers happen to sort, so a Nightly follows Nightly instead of being walked back onto Stable by an update it never asked for. Stable reads /releases/latest, which excludes prereleases; Nightly reads /releases/tags/nightly. Neither feed can hand the other an update, so an installation only changes channel when the user changes it in Settings. The nightly release cannot state its version in its tag — `nightly` is force-moved every night, so `tag_name` is the literal string. It now publishes nightly.json beside the packages, falling back to parsing asset filenames for builds that predate the manifest. Prereleases are ordered by every numeric identifier in the stamp, and the stamp goes to the minute so two builds in one day are distinguishable; a stable release still outranks every dated build of its core version, which is how switching back to Stable graduates instead of downgrading. Switching channel invalidates what the old feed produced: the staged package, the deferred prompt, and the transfer still in flight, which would otherwise finish and stage a build from the channel the user just left. Settings keeps one action on the update row rather than three — the update dialog covers the rest, but it is a moment rather than a place, and where the package cannot be installed for the user the release page is the whole update path. Skipping a version is retired along with its state, its Settings row, and its localization keys. Also carries the staging work this was branched from: an update is fetched and verified while the prompt is up, so installing it is a restart, and declining one defers it instead of retiring it permanently. |
||
|
|
4a8a4bcbaa |
feat(proxy): macOS system proxy, Windows SOCKS parsing, manual override (#367)
Resolve an HTTP/SOCKS proxy for tty7's own update checks and release downloads, from (in order) a new `http_proxy` config field, the platform system proxy — Windows registry / macOS SCDynamicStore — and the HTTP_PROXY/HTTPS_PROXY/ALL_PROXY environment variables. Programs running in a pane are deliberately unaffected: they inherit their proxy from their own environment, as in any other terminal. Fixes #365. |
||
|
|
0190036a2e |
chore(ui): remove the Outline right panel (#375)
Removes the Outline right panel and all of its wiring: the `RightPanelTab::Outline` variant, the `ShowRightPanelOutline` action and its handler, the tab-strip button, the palette command, the keymap arm, `render_panel_outline`, `TerminalView::{command_marks, scroll_to_mark}`, the `one_line` helper, and four i18n keys.
An existing config value of "outline" falls back to the default Info tab via `de_lenient`, and a user keybinding naming `ShowRightPanelOutline` degrades to a logged warning rather than breaking the keymap.
Also drops what the removal stranded: `Marks::list` is now `cfg(test)` (its last production caller was `command_marks`), and `icons/list.svg` — the Outline button's icon, with no other user — is gone.
Closes #374
|
||
|
|
2fa518a767 |
refactor(settings): rescope the About page (#350)
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. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
603bca171e |
feat(updater): add windows updates and cross-platform nightly support (#330)
* feat(updater): add windows online updates
* feat(updater): support online updates for windows portable zip builds
f
* feat(updater): support online updates for nightly build
* fix(updater): strengthen post-download update verification
* feat(updater): support explicit stable and nightly channel switching
* fix(i18n): localize update settings ui
* fix(settings): prevent slider value labels from wrapping
* feat(updater): drop the nightly channel, refuse all-users Windows installs
Follow-up to the Windows updater work on this branch, applying maintainer
review.
Nightly is a build channel, not an update channel. The updater consults
`/releases/latest` again and nothing else, so it behaves on Windows exactly
as it already does on macOS: a Nightly build is offered the stable release
that supersedes it and graduates out of the prerelease, and no rolling
prerelease can become a source of code that gets executed on a user's
machine. Removed with it: the `UpdateChannel` enum and its version-string
inference, the `tags/nightly` query, the cross-channel version-ordering
bypass, the Settings → About channel row, the rolling-tag
`update-manifest.json` and the i18n keys that only served them.
`parse_version` and `is_update_available` are byte-identical to main again.
Nightly builds are untouched, and still carry tty7-updater plus the macOS
update archive — a Nightly user needs a working helper to reach the stable
release that replaces their build.
An all-users Windows installation is no longer updated in place. Running the
release Setup silently as the signed-in user cannot replace
`C:\Program Files\tty7`: Inno resolves `{autopf}` to `%LocalAppData%\Programs`
and installs a second copy beside the real one, or re-launches itself
elevated and puts a bare UAC prompt for an unsigned executable in `%TEMP%` in
front of a user whose GUI just vanished. tty7 declines both and points at the
release page. Detection reads Inno's own `HKLM` state for the frozen AppId and
independently probes whether the directory accepts writes, so a relocated or
pruned installation is caught too; the decision is a pure function with unit
tests, and it is re-checked before the download as well as during it.
Release and Nightly now verify the Windows packages they just built, mirroring
the macOS update-archive step: the install marker, tty7-updater.exe, the ZIP
layout the updater will accept and the PE versions it will demand. Every fact
the updater checks on the user's machine after downloading is checked here
instead, so a packaging mistake fails the build.
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>
|
||
|
|
618855cf4a |
fix(windows): brand toast notifications with a tty7 AUMID (#340)
* fix(windows): brand toast notifications with a tty7 AUMID (#339) * fix(windows): only write the toast shortcut where it is ours to write The AUMID shortcut was rewritten on every launch, which broke two cases the review caught on a real machine. An elevated install owns `%ProgramData%\...\tty7.lnk`, so writing a per-user copy listed "tty7" twice in the Start Menu and left an orphan pointing at a deleted exe once the uninstaller had removed only its own. And `cargo run` repointed the installed shortcut at `target\debug`, permanently, for anyone who both installs tty7 and builds it. So decide before writing. An all-users shortcut settles the question by itself — branded if the installer stamped our AUMID on it, otherwise we stay on the PowerShell identity, because the alternative is littering a Start Menu we cannot clean up. Otherwise we refresh the single per-user `tty7.lnk` Inno's default install owns anyway, and only when it is not already ours, and never from a cargo build directory. A dev build still brands the process for taskbar grouping, and still gets branded toasts when an install left a stamped shortcut behind — Windows asks that the AUMID be registered, not that it point at the process using it. Reading a shortcut back needs `IShellLinkW::GetPath`, hence the `Win32_Storage_FileSystem` feature; `SLGP_RAWPATH` keeps it from chasing a moved target over the network. Also close the window this opened. The shell indexes a new `.lnk` asynchronously and, for an AUMID it has not seen, `Toast::show()` reports success and drops the toast — measured, it does not return an error. A shortcut we wrote seconds ago is therefore not yet proof of anything, so toasts keep the PowerShell identity for half a minute after we write one: ugly beats invisible. 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> |
||
|
|
b7e08c7e11 |
feat(windows): add optional windows explorer context menus (#310)
* 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> |
||
|
|
a6754b28bc | feat(update): install verified macOS releases in app | ||
|
|
da6df709cd | Remove orchestration skill setting | ||
|
|
c396e85e0c |
docs(skill): launch workers interactively and wait on --changed
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. |
||
|
|
e6bdf44f3c |
feat(cli): session CLI for scripting and agent orchestration (#248)
* 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> |
||
|
|
0c9f4baa3a |
fix(cli): make the PATH install reversible, honest, and safe to migrate
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. |
||
|
|
c275960ceb |
feat(cli): ship the CLI in every installer and put it on PATH at launch
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. |
||
|
|
8c1946d763 |
chore: strip every comment from the Rust sources (#268)
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> |
||
|
|
ac8968643d |
fix: reattach the last workspace closed, and resupervise a reopened remote one (#267)
* 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> |
||
|
|
a4972d32d8 |
feat(core): daemon-owned workspace tree — semantic ops, incremental deltas, thin clients (#260)
* refactor(daemon): share one run_daemon between tty7 and tty7-server
Extract the control-listener-plus-pane-server startup from tty7-server
into tty7_core::daemon::server::run_daemon, and point both binaries at
it. The local daemon now serves the control dialect exactly like a
remote one: one machine = one daemon, whichever binary happens to be
running it.
The bound control socket (and a bind failure) is still reported on
stderr with the historical 'tty7-server:' prefix — a headless server's
log file is off by default, and the remote_router test reads that exact
line back to prove the client derivation and the server bind agree.
* feat(core): daemon-owned machine tree with semantic operations
Add core::machine: the workspace/tab/pane tree a machine's daemon owns
outright, replacing the client-owned-schema model of the opaque record
store. Leaves hold a pane id and nothing else; every fact about a pane
(cwd from OSC 7, title, ssh spec, agent identity) lives once in the
pane registry, which is what makes revival sound: a reopened store
force-clears every live flag, so after a daemon restart the tree itself
says every leaf is awaiting revival — no client-side instance stamps or
id-reuse heuristics required.
Operations (workspace create/rename/delete/touch/set-active-tab, tab
create/close/rename/move/regroup, pane split/close/set-ratio/move/
replace) validate against the held tree, persist atomically, roll back
on a failed write, and broadcast incremental LayoutDelta events with
origin exclusion so a writer never hears its own echo. Persisted to
machine.json beside the old store's file, serde with #[serde(default)]
throughout so the daemon can keep evolving the schema, corrupt files
quarantined instead of overwritten.
* feat(control): machine-tree verbs and incremental Layout deltas
Teach the control dialect the semantic operations the machine tree
serves: MachineGet / WorkspaceTree pulls, WorkspaceCreate / Rename /
Remove / Touch / SetActiveTab, TabCreate / Close / Rename / Move /
SetGroup, and PaneSplit / Close / SetRatio / Move / Replace. Replies
carry the daemon's own tree types (a created workspace or tab comes
back whole; close operations answer the pane ids that left the tree so
the caller can kill their PTYs), and every operation broadcasts a
ControlEvent::Layout delta to every connection but the writer's — the
same origin-exclusion mechanism the record store uses, one delta at a
time instead of whole-record last-writer-wins.
The server advertises a new 'machine-tree' capability bit only when it
actually carries a MachineStore; both daemons now do, alongside the
retired opaque record store, which keeps serving unchanged while
clients migrate. Delta fan-out rides its own bounded queue and
forwarder thread per connection, so a peer that stopped reading stalls
nobody's edit; the drop-on-overflow tradeoff is documented against the
keepalive that reaps such a peer and the full pull every reconnect
starts with.
The request/reply/event enums lose their Eq derive: split ratios are
f32. End-to-end tests drive the shipped tty7-server binary over real
pipes: capability advertisement, tree ops landing in the server's own
file, dead-pane revival across a real process restart, and delta
delivery between two live clients.
* feat(daemon): pane facts flow from the pane server into the machine tree
The tree's pane records are only worth reviving from if they hold what
the machine itself observed, so the pane server now publishes into the
MachineStore the daemon serves: the reader thread reports OSC 7 / probed
cwd changes and the sniffer's agent facts (identity, native session id,
launch argv, coarse status) after each chunk that changed them, and
DeathReporter::report flips the record to live == false however the
death was noticed — that flag is the client-visible 'awaiting revival'
state, and it now comes from the process that owns the PTYs on the very
event, not only from the next restart.
The store rides a process-wide slot (installed by control_services,
same shape as the control event observer) so the three pane-spawn paths
need not thread it through; without one installed, observing is a
no-op, which keeps unit tests and tree-less servers quiet. Facts are
published outside the pane state lock and only on a real change, so the
reader's hot path pays two clones and a compare. AgentFacts.status
tightens from a free string to the existing AgentStatus enum while no
wire client depends on it.
* feat(ui): hold a supervised control link to the local daemon
The GUI now dials this machine's own daemon over the control dialect,
exactly as it does a remote one: one machine, one daemon, one control
link. The link lives in its own global rather than RemoteConnections —
inserting it there would register a wire-backed Host for this machine
(local files and git must keep going through the in-process LocalHost)
and would break the HostId::LOCAL-never-holds-a-control-connection
invariant. No routing either: the daemon's control socket is right
here, so connecting is a Unix connect plus a ControlHello.
Supervised on its own forever loop at the remote pump's cadence,
because that pump deliberately parks when the last remote workspace
closes and a purely local session is the common case. Each turn also
drains the shared control-event queue, so local pushes (Layout deltas,
Preempted) are delivered under HostId::LOCAL even with the remote pump
stopped; the observer install is shared with the remote supervisor so
whichever comes up first, reader threads never find nobody listening.
Reconnects ride the same 1/2/4/…/30s backoff a remote machine gets,
with ensure_running first — the daemon is the GUI's own child, and a
cold start legitimately races its listener.
Unix-only like the control listener it dials; on Windows the loop
compiles to a supervision no-op and the pane path is untouched.
* feat(control): attachment and takeover ride the machine tree too
WorkspaceAttach / WorkspaceDetach (and the hello-names-a-workspace
shorthand) now record their data half on whichever workspace stores the
server carries: the retired record store, the machine tree, or — on a
full daemon while clients migrate — both, since they describe the same
workspace. The behavioural contract is untouched and now survives the
record store's retirement: newcomer always wins, the displaced session
is pushed Preempted (and closed only when its link was dedicated), and
a preempted session's tidy-up detach cannot evict the usurper — the
token check lives in the tree's runtime-only attachment exactly as it
did in the store's. A server carrying neither store answers the same
refusal a store-less server always has.
WorkspaceId gains FromStr (the inverse of its Display) because the
attach verbs predate the typed tree and carry the id as a string. The
end-to-end test drives a takeover on a server serving the tree and no
record store at all, asserting the tree's own attachment record moves
with it.
* fix(core): review hardening for the machine-tree foundation
Findings from a correctness review of the new daemon-owned tree,
applied together:
- A dead pane can no longer be resurrected in the tree by its own last
output. On Windows the exit monitor reports the death while the
reader is still draining ConPTY's buffered bytes, and the death
report is latched; the reader's 'output is proof of life' publish now
asserts liveness only while the pane state still says alive.
- Delta delivery is ordered. Mutations were serialized by the state
lock but delivered after releasing it, so one writer's deltas could
overtake another's and leave every mirroring client on the losing
state with no cue to re-pull. A notify-order mutex now spans each
mutation and its own fan-out; cheap, because subscriber callbacks are
enqueue-only by contract.
- Implicit active-tab changes broadcast. tab_create's activation and
the close paths' heal now emit ActiveTabChanged, so a client applying
deltas never re-implements the server's heal rule; the one
inexpressible case (no tabs) needs no delta because it is a fact,
not surgery.
- The coarse agent status no longer drives disk writes: it flips per
hook event and is display-only, so it is outside the changed-facts
gate and merely rides along when a load-bearing fact changes.
- control_services reports which stores it serves on stderr again —
tty7-server configures no log sink, and 'no machine tree' was
invisible exactly where it matters, on a headless box.
- The local link's first connect attempt is immediate instead of one
backoff step late; the observation-slot test withdraws its store so
it cannot swallow later tests' observations; and locked()'s poison
rationale now says what is actually guaranteed.
* feat(control): let clients mint workspace and tab identities on create
A window names its workspace — in the registry, the view file, and any
operation it queues — before its first round trip completes, and the same
holds for a tab the moment the user opens it. Making the daemon the only
minter would force every client to hold its edits until a reply carried
the real id back. Ids are uuids, so a client-minted one is as unique as a
daemon-minted one; WorkspaceCreate and TabCreate now carry an optional
client id, keep it when it is free, and refuse a duplicate rather than
adopt it. Absent (older callers, tests) the daemon mints as before.
* feat(ui): windows speak semantic tree operations for every structural change
The write path of the client migration: each window now keeps a mirror of
what the daemon's tree holds for its workspace, and save_session — the
funnel every structural change already passes through — diffs the window
against that mirror and sends the recovered operations (TabCreate,
PaneSplit, PaneClose, PaneReplace, TabMove, ratio and label ops) over the
workspace's control link: the LocalLink for this machine, the machine's
RemoteConnections entry otherwise. Consecutive saves differ by exactly one
user action, so the diff recovers that action rather than re-shipping the
layout; changes no single op expresses rebuild the affected tab whole,
matching the delta contract's own granularity.
The mirror advances by running the server's own tree surgery (PaneNode's
split/remove/replace are public now), and any disagreement — a refused op,
a dropped link — resolves by one shared recovery path: drop the queue,
re-pull WorkspaceTree, re-diff. Fresh spawns are invisible until their
pane id lands; land_pane's save is when their create goes out. GUI tabs
carry a client-minted TabId, and a primed mirror re-points tabs it
recognizes by their panes, so a rebuilt window adopts the daemon's tabs
instead of churning them.
Workspace-level facts ride along: focus touches, renames, and deletions
now reach the machine's tree too, and the divider drag finally persists
the ratio it lands on (it previously reached disk only as a passenger on
the next structural change).
session.json is still written in parallel; it retires with the read-path
migration.
* feat(ui): local windows restore by asking the daemon's tree
The read path: opening a known local workspace no longer rebuilds from
session.json synchronously. The window opens empty and a background pull
(MachineGet — the workspace's structure joined with the pane registry,
which is where the revival facts live) rebuilds it the moment the daemon
answers; against the local daemon that is milliseconds, so the empty
state is effectively one frame — the same shape a remote workspace's
connect-driven rebuild has always had.
The lowering from tree to window is the revival decision: a leaf whose
pane record says live re-attaches by id, a dead one lowers to an id-less
leaf carrying the record's cwd, SSH spec and agent resume — the exact
shape that makes the existing builder spawn a successor and type the
agent's --resume. The save that follows diffs the successor against the
mirror and sends PaneReplace, spending the old record; revival needed no
op code of its own.
Restored tabs keep their daemon tab ids (SessionTab grows a never-
persisted tree_id), so the first save addresses the daemon's tabs instead
of churning them. A tree with nothing for the workspace falls back once
to the client's cached layout, whose adoption re-populates the tree
through the ordinary diff — the whole of the best-effort import.
* feat(ui): live windows apply the machine's incremental layout deltas
The pump's event drain now lands ControlEvent::Layout instead of debug-
logging it: each delta advances this client's mirror (by the same
surgery the server ran) and then the live window showing the workspace —
renames, regrouping, moves, active-tab changes and ratio drags in place;
TabCreated by building the tab and attaching its (writer-spawned, so
live) panes; TabRestructured by rebuilding the one tab while reusing the
views of panes the window already shows, because re-attaching a pane
this window holds would steal its own stream. Origin exclusion means
every delta arriving is another client's edit, and applying it to window
and mirror in one step leaves the next local diff with nothing to echo.
A delta that will not apply cleanly — a tab the mirror never heard of, a
drifted window — falls back to re-pulling the workspace and rebuilding
the window from the authoritative tree, the same single recovery path
every other failure already uses.
* feat(daemon): report panes the machine tree no longer references
With the tree now populated by clients' semantic operations, the daemon
can finally see panes nothing references. A periodic sweep reports them —
log-only, deliberately: an unreferenced pane is not proof of a leak (a
native-SSH pane opened inside a remote workspace's window runs in this
daemon while belonging to the other machine's tree), and reclaiming one
wrongly kills a session the user is looking at. The sweep's interval
doubles as a grace period: a pane is reported only after being
unreferenced across two consecutive looks, so an adoption still in
flight is never flagged. Reclamation can be layered on once the log has
shown the false-positive rate is zero.
* feat(ui): remote workspaces read and write the machine tree like local ones
Local and remote are now the same shape end to end. A remote workspace
opens empty unconditionally (connected or not) and is filled by the same
tree hydration a local window uses; the connect supervisor's landing
replaces the opaque-record refresh with it — a blinked link relinks the
pane streams and hydrates whatever opened empty meanwhile, a replaced
server process resyncs the window from the tree, whose force-cleared
live flags are what make every leaf revive. The remote picker lists
workspaces from MachineGet, deriving names from the tree the way a
local workspace derives its own; creating one lets the hydration's
WorkspaceCreate mint it on the machine; the record push, pull, refresh
(WorkspaceChanged) and remote delete paths are gone client-side.
Windows that have not yet seen their machine's tree sync additively: a
window that opened empty ahead of its pull may add tabs but never prunes
ones it has not displayed, so its ignorance can no longer read as 'close
everything' — the diff takes an explicit scope, and only hydration (or a
deliberately authoritative open, like restore-off) grants the full one.
* refactor(core): retire the client-side pane-identity defenses
The machine tree made this whole family unnecessary, so it goes rather
than lingers: daemon_instance stamps (a restarted daemon's tree says
live=false about every pane — a fact, where the stamp was a heuristic),
forget_stale_pane_ids on both layers, dedupe_pane_ids (the daemon
refuses a pane appearing twice in its tree, so there is no duplicate to
mop up client-side), the claim/record instance plumbing, and the
whole-record halves of the storage split (to_remote_json,
apply_remote_json, REMOTE_OWNED_FIELDS, CLIENT_OWNED_FIELDS, and the
store's apply_remote / remote_payload), together with their tests.
forget_pane_ids stays for now: it clears the client's cached copy, which
still serves as the one-time import fallback until the view file slims
down to pure view state.
* refactor(ui): a local daemon restart rebuilds from the tree too
The tree file survives the restart and the fresh daemon force-clears
every pane's live flag, so the resync path already expresses exactly
what the hand-rolled saved-session rebuild did: every leaf revives as a
fresh shell in its recorded cwd with its agent resumed. The pull waits
out the local link reconnecting to the fresh daemon.
* docs(core): drop a stale reference to the retired record verbs
* fix(ui): close the review findings on the tree migration
Review fixes, worst first:
- Pane ids never alias across daemon restarts: the pane registry seeds
its counter past everything the persisted tree references. A fresh
process minting from 1 handed new shells ids that dead leaves still
claimed — the tree marked the wrong pane live, revival stalled forever
on 'already part of this machine's tree', and an attach by the stale
id stole another workspace's stream. Ids are names now, not slots.
- An empty window only licenses WorkspaceRemove once it is *informed*:
a window whose hydration has not answered is empty because it is
waiting, and closing or swapping it mid-pull was deleting populated
trees. Remote workspaces also hydrate regardless of the restore
setting — their panes are running sessions, not a saved layout, and
the restore-off swap used to open them empty-and-authoritative and
close every tab on the machine.
- Tabs whose panes are all still spawning are *held*, not pruned: they
are invisible in the desired tree without being absent, and the Full
diff was closing them (spending the records the landing spawns'
PaneReplace needed) on every remote revival.
- A preempted window stays passive under deltas: applying the usurper's
TabCreated/TabRestructured attached to their fresh panes and stole the
streams they were typing into. The mirror is dropped instead; taking
the workspace back re-pulls it whole.
- Delta TabClosed tracks the active tab by identity (closing a tab to
the left no longer shifts focus and pushes the wrong active tab back).
- The hydrate/resync path drops the op queue like desync does, so ops
computed against an abandoned mirror cannot drain after the snapshot.
- A rebuilt remote tab no longer matches a native-SSH leaf's *local*
pane id against remote ids; delta-applied ratios clamp to the GUI
band; async completions use get_mut so a forgotten window's sync state
is not resurrected.
* feat(ui): a per-machine mirror of each daemon's tree feeds the read surfaces
The switcher, the Window menu, the title bar, the rename seeds, the
stop/delete confirmation and the liveness sweep all answered their
questions (display name, subject path, pane ids, pane count) from the
client's cached copy of the layout. The machine's tree owns the layout
now, so a new per-host MachineMirrors global holds each machine's last
pulled tree — filled by a MachineGet whenever a control link comes up
(and for free off every hydration, which already pulls the whole
machine), advanced by the same Layout delta stream the windows consume,
plus explicit notes for this client's own operations, which origin
exclusion keeps out of that stream.
The readers move over wholesale. A machine not pulled yet reads as
not-knowing rather than a stale guess: pickers show the shared fallback
for a beat (against the local daemon the pull lands within a frame),
and the pane-count prompt says the machine could not be asked instead
of counting against a cache. tree_display_name moves out of the remote
picker into the mirror as display_name_of — it was always the tree
flavour of Workspace::display_name, and now everything shares it.
This is the read-model half of retiring the client's layout cache; the
persistence shrink to pure view state follows on top of it.
* refactor(ui): client persistence shrinks to pure window views
The client file stops carrying layout. session.json's Workspace — id,
name, a whole embedded Session, geometry, open, last_active, host —
becomes WindowView { id, window, open, last_active, host } in a fresh
views.json (no migration by design; an old session.json is simply
ignored, and its panes revive from the machine tree like any daemon
restart). Everything the embedded layout used to answer already moved
to the per-machine mirror, so this deletes the write half:
- WorkspaceStore::claim answers only the id; record shrinks to
record_geometry. claimable_session / record_session — the
reachability-gated layout cache — go entirely, and with them the
one-time empty-tree import in finish_hydration: with no cached copy
there is nothing to import, and the machine answering "no tabs" is
the layout.
- The user-set name is purely the machine's fact now. rename /
rename_locally leave the store; the chip and switcher renames fire
WorkspaceRename directly (tree_sync::rename_workspace), the
WorkspaceRenamed delta needs nothing from the window because the
mirror already applied it, and WorkspaceCreate seeds no name.
- forget_pane_ids / blank_pane_ids and the layout-derived getters
(display_name, dominant_repo, first_cwd, pane_count, pane_ids) are
deleted with their tests — each had grown a mirror-side twin.
- switch_workspace always hydrates: with the tree as the only layout
source, restore-off governs what launch comes back to, not what a
deliberate switcher pick shows.
The retired opaque record store loses its one test that asserted its
file parses as a client Workspaces document — that coupling is the
thing this migration ends, and the store itself is next to go.
* refactor(server): retire the opaque workspace record store
Clients stopped sending WorkspaceList/Get/Put/Delete when the tree
migration landed, so the coexistence scaffolding comes out:
- core::workspace_store is deleted. Attachment and the data-directory
resolution (TTY7_DATA_DIR, XDG fallback chain) move into
core::machine, which was already their only consumer; Attachment
loses its vestigial serde derives (it never crosses disk or wire).
- The control dialect drops the four record verbs, the ReplyOk::Json
payload they answered with, and the WorkspaceChanged event. Their
serde names (and the workspace-store capability bit) are recorded as
burned rather than reserved by any mechanism — the dialect has no
numbered slots to hold, so a comment at each site is the guard, plus
the handshake test asserting the bit never reappears.
- host::server loses Services.workspaces, the verb arms, the
per-connection store subscription and its WorkspaceChanged forwarder,
and the store half of attach/detach/teardown. Attachment data now
lives solely in the tree: a workspace the tree does not list records
no data half (the registry's live handles still move, so takeover
behaviour is unchanged), and it appears the moment the workspace
does. Services::with_workspaces/and_machine collapse into
with_machine; control_services becomes a single match.
- The attach/takeover tests move onto MachineStore wholesale, attaching
to workspaces created in a real tree; the record-store round-trip and
fan-out tests go (tests/machine_tree.rs has carried the tree
equivalents since the verbs landed), and tests/workspace_store.rs is
deleted with the serde_json dev-dependency that existed only for it.
machine.rs gains the two guarantees the old suite held uniquely: an
attachment dies with its workspace structurally, and the default path
resolution ends at the documented file.
- The GUI's dead WorkspaceChanged arm and every stale doc reference go.
* refactor(ui): rename RemoteConnections to HostLinks
Purely mechanical, plus the doc sentences that carry the model: the
table holds one control link per machine, and the local machine is a
machine like any other — its link just lives in its own global
(LocalLink) because it is in-process rather than wire-backed. The old
name framed the table as remote-only plumbing, which the tree
migration made false in spirit: local and remote windows speak the
same operations over whichever link their machine answers on.
* fix(ui): a tree-driven tab rebuild keeps the native-SSH split it cannot name
A native-SSH pane opened inside a remote workspace's window runs in
this client's own daemon and is deliberately absent from the remote
machine's tree (its local id would collide with an unrelated remote
pane). The TabRestructured rebuild therefore had no leaf for it and
dropped its view on the floor: the local session kept running,
invisible from every surface — a true orphan only the daemon's log-only
sweep would ever mention.
The rebuild now sets such leaves aside while harvesting reusable views
and appends each back as a fresh half-and-half split on the right once
the tree's own panes are built. The old split geometry is unknowable
from the delta (the tree never held it), so the appended shape is the
one a split created it in; the next save changes nothing, because the
diff already lowers a remote window without its ssh leaves.
The resync path (a delta that fails to apply, a replaced server) still
rebuilds the whole window from the tree and drops such views — that
path discards every view it has by design, and is left as a known
residual. TerminalView grows a test-only ssh-marked pane constructor so
the kept-split property is pinned by a gpui test.
* docs(core): finish pointing the last session.json references at views.json
* fix(ui): kick every local window's sync when the local link comes up
A window built while the local control link was still dialing parks as
Unprimed { dirty } — start_prime's unreachable arm leaves the retry to
"the reconnect-triggered save", but the local link supervisor never
triggered one. On a first launch (window built before the auto-spawned
daemon binds its socket) nothing else re-enters sync_window until the
next structural change, so quitting before one loses the window's
layout: the machine never heard of it.
Reproduced end-to-end on a scratch daemon: fresh launch, no user
action, quit — the relaunch came up empty. With the link supervisor
calling tree_sync::on_link_up on connect, the same launch syncs the
tree within one pump tick.
* fix(ui): read a deleted workspace's kill list before the removal blanks the mirror
delete_workspace fired WorkspaceRemove first, and fire_workspace_op folds
the removal into the machine mirror synchronously on its way out — so the
kill list stop_workspace_keeping then read off that mirror was always
empty, and 'Delete Workspace' ended zero of the sessions its confirm
prompt promised to end. The kill list is now read before the op fires,
and both destructive paths receive it explicitly so the ordering is a
signature rather than a convention.
* fix(control): bump both dialect versions and gate tree verbs on the machine-tree bit
The tree migration deleted four control verbs and added seventeen, but
CONTROL_VERSION stayed at 2 — two builds that cannot understand each
other's requests would have shaken hands as equals. It is now 3, with
the history entry the file's format asks for.
PROTOCOL_VERSION moves to 4 for the service change underneath: a
pre-tree 'tty7 --daemon' has no control listener at all, so a GUI from
this build silently adopting one connects its control link into the
void forever and every window hydrates from a tree that never answers.
The bump routes that meeting into ensure_running's existing
keep-or-restart prompt.
Clients now also consume the machine-tree capability bit before any
tree traffic: a connected peer without it (a server with no home
directory keeps serving files and panes) classifies as a distinct
'unserved' state that is logged once and skipped, instead of a refused
round trip per operation.
* fix(ui): preempted windows stay passive and take-back rebuilds from the tree
Two halves of the same takeover contract were broken.
A preempted window kept pushing: sync_window had no preemption check, so
a click on the read-only tab strip sent WorkspaceSetActiveTab against
the usurper's session, and the next save Full-diffed the stale layout —
rolling the usurper's edits back wholesale. sync_window now returns
early for a preempted workspace, and preemption itself drops the
window's queue, mirror and 'informed' licence (tree_sync::on_preempted,
shared with the delta path's existing reset).
Take Back never rebuilt: the recovery attach ran the ordinary IfEmpty
hydration, which skips any non-empty window — and a preempted window is
by definition non-empty with the pre-takeover layout. retry_now now
marks the workspace as reclaiming, and finish_attempt rebuilds marked
(or still-preempted) windows via Adopt::Replace, honouring the 'take
back re-pulls whole' promise the delta path documents.
* fix(ui): delta application survives pulls in flight
Three overlap bugs between the incremental delta stream and the full
pulls it has no ordering barrier with:
- A TabCreated straddling a pull was applied by both — the snapshot
already carried the tab, and the delta inserted a second copy into
the machine mirror and the window mirror, and rebuilt a second GUI
tab whose attach stole the pane's single stream from the window
itself. All three application sites now replace by id.
- A delta arriving while a window's prime/hydration was in flight was
applied to the window even though the mirror side skipped it — a
TabCreated landing in a still-empty window made finish_hydration
read 'the user got here first' and skip adopting the tree, leaving
the window with only the concurrently-created tab forever. Window
application is now gated on the mirror being primed; the pull's
snapshot carries the delta's effect.
- A prime answered after a newer cycle (hydration, desync, preemption)
replaced it would install its stale tree over a mirror that had since
advanced, and the next diff would re-emit the rollback as operations.
Every cycle now stamps an epoch, and pulls landing under an old one
are dropped.
* fix(ui): apply ratio deltas in the server's clamp band
set_gui_ratio clamped to 0.1-0.9 while the server accepts 0.05-0.95, so
another client's 0.07 arrived as 0.1 — and the next save's ratio diff
pushed the rewrite back at the machine, silently moving their divider.
* fix(core): machine-store hardening around seeds and unreadable files
- A PaneSeed entered the registry live:true unconditionally. A pane
that died between its spawn and its adopting operation had its death
observation dropped (note_pane_facts ignores panes the tree does not
hold), and nothing ever flipped the record back — the leaf claimed a
live pane forever and revival was never offered. The daemon now
installs a liveness probe on the store (registry-backed), consulted
at registration; without one (tests, clients) the seed is trusted.
- seed_ids_past computed max + 1, which panics a debug daemon at
startup when the persisted tree names u64::MAX. saturating_add parks
the counter at the ceiling instead.
- load_machine quarantined an unparseable file but not an unreadable
one: a read failure logged, started empty, and the first mutation
overwrote the very file that could not be read. Read failures now
quarantine too — by rename, since a copy would need the read
permission that just failed.
Also de-flakes the pre-existing spawn_writer test: the first write into
a freshly-closed socket can succeed before the kernel processes the
close, so the poll loop now keeps the writer fed until a write fails.
* feat(control): announce dropped layout deltas so lagged clients resync
A connection whose per-link delta queue overflowed lost an edit it will
never hear again — the server logged the drop, and the client mirrored
a tree it was no longer looking at until something else happened to
fail. The subscriber callback now flags the connection lagged, and the
layout forwarder sends the new ControlEvent::LayoutResync ahead of the
next delta it delivers (the flag is only ever set with a full queue
behind it, so the announcement never waits on a quiet tree). The client
answers by re-pulling the machine mirror and resyncing every window on
that machine — the same recovery an unappliable delta already uses,
announced instead of stumbled into. WatchOverflow is the precedent.
* fix(ui): a pure native-SSH tab is invisible to the tree, not held forever
Held means 'spawns are landing, wait before ordering' — but a remote
window's tab that is native-SSH through and through can never land: its
panes live in this client's daemon and are deliberately unnameable in
the remote machine's tree. Filing it as held made every diff return
before the ordering and active-tab passes, freezing tab order and
activation sync for the whole window for as long as the tab existed —
and a mixed tab whose last remote pane was closed kept its dead leaf on
the machine for ever, because the held id shielded the daemon tab from
the close.
Such tabs are now classified permanently invisible: not desired, not
held. Ordering resumes, and the mixed tab's daemon twin closes when its
last tree-visible pane goes. Pending leaves (a connecting spawn, an
empty slot) still read as held.
* docs(core): drop the dead instance helper, the stale title field, and two doc lies
- local_daemon_instance() lost its last caller when the client-side
pane-identity defenses were retired; deleted.
- DaemonVersion::instance's doc pointed at Workspace::daemon_instance
(deleted with the record store) and claimed pane ids restart from 1 —
no longer true of a tree-carrying daemon, which seeds its ids past
everything the tree names. Rewritten to describe what the field
actually backs now.
- PaneRecord::title claimed to label panes awaiting revival, but no
code ever wrote it: the pane's title is a live foreground-process
query at PaneInfo time, not state the facts path observes. The field
is deleted (serde-compatible: unknown fields are ignored on read) and
the decision recorded where it lived; revival labels derive from cwd
and agent.
* fix(ui): converge the tree after adopting a delta-created tab
Adopting a TabCreated delta whose pane is dead on arrival attaches
nothing and spawns a fresh pane under a new id — and nothing on the
delta path saved afterwards, so the tree kept the dead leaf: other
clients saw a dead tab, and a relaunch would spawn a second successor
beside the leaked first. Reproduced end-to-end (external client creates
a tab with an unspawned pane; the GUI adopted it and the tree never
learned the successor's id).
One sync_window after a clean apply closes it: free when window and
mirror agree (the diff is empty), and exactly the PaneReplace that
spends the dead record when adoption had to spawn.
* fix(core): review follow-ups on the daemon-owned tree
Nine findings from a review pass over the branch. One commit because
they cross the same files, and splitting them would leave an
intermediate that does not build on Windows.
- A dropped delta announced a LayoutResync and then delivered the
backlog behind it. The queue is FIFO, so everything still in it is
*older* than the gap: the peer re-pulled on the notice and was then
walked back through history it had already left — TabRestructured
restoring the shape a tab used to have, with window and mirror
agreeing on the stale answer so nothing recovered a second time. The
forwarder now drops the superseded queue and sends the resync in its
place.
- Pane facts persisted the whole document, with an fsync, from the PTY
reader thread — once per OSC 7, so once per prompt per pane — while
holding the lock that orders every other client's edits. A shell
looping over directories was a write per iteration. Observations
(pane facts, workspace_touch) now take Persist::Soon: the delta still
goes out at once, the file catches up within FACT_FLUSH_INTERVAL, and
the daemon flushes on the way out. The layout itself is never
deferred.
- An ordinary output chunk paid two AgentFacts clones and a
clone-to-compare for facts it could not have changed. Gated on the
signals that can move one, and the compare no longer clones.
- machine.json was created 0644, naming every workspace's directories,
the SSH user and host of every native-SSH pane, and each agent's
session id. It is written owner-only from the first instant the final
name exists, and a second corruption no longer overwrites the rescue
copy of the first.
- Windows had no control listener, so on the one platform where the
tree is the only layout store, tabs did not come back at all. It now
serves the dialect over the transport its pane socket already uses: a
loopback listener whose port and 256-bit token live in a user-private
control.port beside daemon.port — its own token, not the pane
endpoint's — refusing to rebind over a live one, since binding is
what writes the marker. run_daemon and the GUI's local link are one
code path again.
- Workspace names and paths came only from the machine's mirror, so a
laptop shut since Friday listed every row as "Untitled" with a blank
subtitle, in the picker whose whole job is offering workspaces on
machines that are asleep. WindowView carries the label and subject
the machine last gave, stamped on save and on detach; the tree still
wins whenever it answers.
- liveness_of read "the mirror has not been pulled yet" as Stopped,
which tells the user their sessions are gone on the strength of our
own ignorance. Unknown is what that state is for.
- A WorkspaceRemove that never reached its machine was a debug line,
though the client had already forgotten the workspace. It is now a
warning that says what was left where.
- MachineMirrors::install landed a pull without a repaint; the two tests
the record store's retirement took with it (a closed connection stops
being a subscriber, concurrent connections can all write) are back
against the tree; and CHANGELOG records the migration's one-time
layout loss and the Windows gap this closes.
Suites green: tty7-core 675, tty7 819, tty7-server 9/5/3/3/51, fmt and
clippy clean. The Windows listener is unverified by a compiler here — a
C dependency in the tree blocks cross-checking from macOS — so CI's
Windows job is its first build.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: thomas <thomas@thomass-Mini.lan>
|
||
|
|
bed22d899e |
Keep workspaces whole: remote reopen/restart recovery, and cross-workspace restore guards (#257)
* feat(remote): keep a remote workspace whole across reopens and restarts Reopening a remote workspace — or coming back to one whose `tty7-server` had been replaced — landed on a screen of `tty7 — disconnected` panes with their coding-agent conversations gone. Several independent holes added up to that; this closes them together, and picks up the surrounding work the same session produced. **Telling a restarted server from a blinked link.** `ControlHelloOk` now carries an `instance` minted once per server *process*. Nothing else in the handshake changes across a restart — `build` and both dialect numbers survive it — so a reconnect had no way to know its `pane_id`s were dead. It does now: a different instance rebuilds the window from its layout (same tabs and splits, fresh shells in the saved cwds) instead of re-attaching to a process that is gone. An absent instance means *unknown* and is never read as a restart. **An attach can now fail.** `Attach` has no synchronous reply, so the client returned `Ok` unconditionally and the daemon's `Error` frame was read much later by the reader thread, which has no arm for it — the pane then landed in the *link is down* state instead of falling back to a fresh shell. The client now reads far enough into the reply to classify it on the kind byte (the snapshot behind it can be megabytes) and hands those bytes to the reader thread, so a successful attach loses none of its replay. Local and remote attaches get different waits: the local one is on the UI thread. **The agent session survives to be resumed.** `TerminalView` raises `AgentSessionChanged` when the pane's agent reports a new native session id, so the layout on file catches up instead of waiting for the user to happen to open a tab. A pane that is still connecting now carries its agent through `PendingSpawn` — a save landing in that window used to write `agent: null` over the record — and `land_pane` sends `--resume` when the attach turned out to need a fresh shell. **Ending sessions says so on file.** "End Sessions" kills the panes and then drops their ids from the record, pushing the cleared layout to the machine that owns it (design §10: the remote's copy wins, so a local-only clear would be undone by the next open — the open this exists for). **The new-tab dropdown lists the window's machine.** `Host::shells` and a `Shells` control request (dialect v2) make the "+" menu a property of the machine the window is bound to. A remote window filled from this computer's `/etc/shells` offered `/bin/zsh` on a box whose zsh is elsewhere, and every pick failed to spawn. **An install reports its bytes.** The download and the SFTP upload each report progress, relayed to the client over the routed connection as a `RoutePrompt::InstallProgress`, and painted as a bar under the machine's row in the switcher. ~8 MB across two hops behind the word "connecting…" was indistinguishable from a hang. **The installer compares dialects, not version strings.** `tty7-server --protocol` prints what a binary speaks without starting it, so a connect adopts an already-running server it can talk to rather than prompting about a build difference and uploading 8 MB the machine did not need. **Switcher.** A machine's `⋯` menu holds "New Workspace" (it was a row under every machine, pushing the list a quarter of a card down) and a new "Disconnect", which drops the connection and leaves the windows open and read-only. The suspension lasts exactly as long as that machine has a window on it. Also drops three design/contract docs for the now-shipped remote-workspace work. * fix(session): stop one workspace's panes from being restored into another A restart put a copy of one workspace's seven tabs — cwds, layout and recorded agent sessions — in front of another workspace's own tabs, and auto-resumed every one of those agents a second time: six `claude --resume <id>` pairs running in parallel against the same conversations, one set per window. The record-level corruption that seeded it is still unattributed, but every mechanism that let it propagate, amplify, or go unnoticed is closable, and this closes them. **Panes now know their owner.** `Spawn` can carry the workspace the pane is created for; the daemon stores it immutably and reports it in `List`'s `PaneInfo.owner`. Restore refuses to re-attach a pane another workspace owns (`pane_attachable`) — before this, a saved id landing on somebody else's live pane attached silently, which is how one window could pick up another's shells. The field rides a new `SPAWN_OWNED` frame with a struct payload (the legacy spawn payloads are positional tuples an old daemon cannot grow), gated on a new `pane-owner` feature string: a client only sends it to a daemon that advertises it, so the legacy kinds stay byte-for-byte what old daemons expect. A pane with no recorded owner stays attachable by anyone — that is the pre-field behavior, not a new risk. **Saved pane ids are bound to the daemon process that issued them.** `DaemonVersion` now carries an `instance` minted once per process (the local twin of the control hello's), the GUI caches it at the `ensure_running` handshake, and each local workspace records it as `daemon_instance` beside its layout. Claiming a workspace whose ids came from a different instance blanks them first: daemon pane ids restart from 1, so after a reboot every saved id points at whatever unrelated shell holds the number now, and the aliveness check cannot tell a survivor from a squatter. A blank on either side means "cannot tell" and never trips it. Unlike the duplicate-claim case below, this path keeps the agent resume — the pane is genuinely gone with its daemon, and the fresh shell resuming the conversation is the feature. **A duplicate claim loses its agent resume along with its pane id.** `dedupe_pane_ids` kept the loser's layout *and* its `agent_session_id`, so the blanked leaves took restore's spawn-fresh path and auto-typed `claude --resume` for conversations the winning workspace's panes were still running — the doubling above. The winner keeps the panes and the resume; the loser keeps only cwds. **Cross-workspace saves are caught at the write.** Every terminal view remembers the workspace whose window created it, and `save_session` logs an error naming both ids if a window ever records a pane created for a different workspace — the tripwire for the still-unattributed seed corruption, so a recurrence is caught in the act instead of reconstructed from `session.json` archaeology days later. Wire compatibility both ways: `PaneInfo.owner`, `DaemonVersion.instance` and `Workspace.daemon_instance` are `#[serde(default)]` struct fields (old peers' JSON decodes, new fields are ignored by old readers), and `SPAWN_OWNED` is feature-gated as above. `daemon_instance` is client-owned in the design-§10 storage split — it names the local daemon, and the field-census test pins the classification. * fix(session): resume the agent when a local pane dies mid-restore `session_to_pane` decided whether to send a coding agent's `--resume` from `restore.is_none()` — i.e. from whether the pane looked alive when the restore started. But `alive_panes_on` runs one `List` at the top of the restore, while the attaches happen per leaf afterwards. A pane that exited in between failed its attach, fell back to a fresh shell inside `spawn_shell_terminal_in`, and then landed in the `restore.is_some()` arm: an empty shell with its conversation dropped. `ShellParts.restored` already answers this exactly, and the remote path already reads it in `land_pane`. Carry it onto `TerminalView` so the synchronous local path can read it too, and branch on that instead of re-deriving the answer from a set that may be stale by the time it is used. No behaviour change on the paths that were already correct: a view that was never restoring anything reports `restored: false`, which is the same answer `restore.is_none()` gave them. * fix(remote): check the server instance against the record, not just memory A remote workspace's pane ids were only guarded against server restarts by `RemoteLinks::instances`, an in-memory map. On the first connect after the client starts, every machine is a first sighting, so `server_restarted` answers false — and a `tty7-server` that was replaced while the client was closed sails straight through. Its pane ids restart from 1, so the saved ones now name unrelated shells, and the reconnect attaches to them: the exact id-reuse failure the local side already guards against. `Workspace::daemon_instance` was local-only for the stated reason that a remote server's identity is tracked live per connection. That tracking is correct but not sufficient — it cannot survive the client restart that makes the question worth asking. So the field now means the same thing on both sides: which process minted the pane ids in this record. `WorkspaceStore::serving_instance` picks the local daemon or the far machine's server depending on the workspace, and `finish_attempt` compares it per workspace before deciding to re-attach or rebuild. It stays client-owned: it records what *this* client last saw, so two clients on one remote workspace each keep their own and neither may overwrite the other's. An unreachable machine still records nothing, which is what keeps a good stamp from being erased with `None` — that would disarm the next check. Also in these three files: the §N references to the deleted design docs, cleaned up as part of the sweep in the following commit. * docs: drop the references to the deleted design documents The three documents this branch removed were cited ~280 times: `design §10`, `contract §8`, `§17` and friends in comments, five references by file path in code and manifests, five in CI workflows and one in the release skill. Every one of them now points at nothing. Rewritten rather than merely stripped, because most were not decoration: "design §10 makes the remote's `workspaces.json` the authority" becomes a statement in its own right, and the several that carried a Chinese phrase from the document as their justification say the same thing in English instead. Where the reference was purely parenthetical it is simply gone. Not touched: `PRD §7.1`, `brief §8` and the like, which name documents this branch did not remove and were already external before it, and the `RFC 4648 §10` test-vector citation, which is a real specification. The `host boundary` CI job loses `(§10.6)` from its name. It is not one of the required checks, so branch protection is unaffected. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
9ca3319239 |
feat(agents): fork an agent session and copy its session id (#241)
* feat(agents): fork an agent session, and copy its session id A coding-agent conversation is a single thread: to try a risky direction you either lose the one that got you there, or you don't try it. Every agent tty7 resumes already knows how to branch — `codex fork <id>`, `claude --resume <id> --fork-session`, `opencode --session <id> --fork`, `grok --resume <id> --fork-session` — but nothing in tty7 reached them, so the capability was invisible from the terminal that already knows every pane's session id. Fork is a per-agent capability beside the existing resume table (`CLIAgent::fork_command`), not a Codex special case: it is the same `match self` shape, it reuses the same id validation and the same launch-flag replay, and four installed agents qualify today. Every command was checked against that CLI's own `--help`; agents with no fork tty7 could verify return `None` and are never offered the action, since a guessed flag shape would only ever produce a usage error in the pane. Flag replay needed one correctness fix to survive this. A forked pane's own argv *is* a fork command, so relaunching it would replay the stale subcommand and id (`codex fork <old>` → an old id as a positional prompt) or double the modifier (`--fork-session --fork-session`). `codex fork` now sheds its subcommand exactly as `codex resume` did, and `--fork-session` / `--fork` join their agents' stale session-targeting lists. That also settles restore: a forked pane restores through `resume_command`, which now drops the fork flag — a restart continues the fork rather than branching it again. Placement follows where the user asked from. A pane-level ask is spatial, so the pane right-click menu offers Split Right / Left / Down / Up (pane splits gained a `before` slot for the Left/Up half, which the tree had no way to express). A tab-level ask isn't, so the tab context menu — inherited verbatim by the sidebar rows, which is where the request came from — opens the fork in a new tab with no placement question. The bare action behind the palette, the File menu and Settings → Keybindings takes the tab-level meaning. The three ways a fork can't run all surface rather than no-op: no session id yet (hooks not installed) and a remote pane (the command would shell the *local* agent) render the row disabled instead of hiding it, so the capability stays discoverable, and the action paths that have no row to grey out say so in a notification. Forking mid-turn is allowed but announced — agents fork from the persisted transcript, so the turn in flight is absent from the copy — and the parent is untouched either way. Copy Session ID sits beside Copy Working Directory. Codex has no copy-or-duplicate subcommand, so "copy the session" is the id: paste it into `codex resume`, a bug report, or another tool. Deliberately not built: any reading or writing of an agent's own session files. tty7's exposure stays the public CLI contract plus the hook payload's session id, so a change to Codex's rollout format or its version-numbered SQLite index costs at most a visible shell error. Forked tabs also look exactly like their parent, by decision — "Rename Tab" is the answer. Closes #211 * no-mistakes(review): perf(terminal): compute fork menu enablement at menu-open time * no-mistakes(document): docs: correct fork action surfaces, label, and remote limits * fix(agents): label forking the same for every agent The fork row said "Branch Session" on Claude Code and "Fork Session" everywhere else, on the strength of a source comment claiming "Claude Code calls it branching". It does not. `claude --help` documents the flag as `--fork-session`, described as "When resuming, create a new session ID instead of reusing the original"; the only occurrences of "branch" in its help are an unrelated git-branch review option. The claim came from otty's own UI wording, which I mistook for Claude's vocabulary and then wrote into the source as fact — so the comment goes with the special case rather than being left behind as a false statement about someone else's tool. The split was also inconsistent with itself: Grok takes the identical `--fork-session` flag and was already labelled "Fork Session". Every agent that has the capability calls it forking — `codex fork`, `--fork-session` on Claude Code and Grok, `--fork` on OpenCode — so one wording covers all four. `fork_label` keeps returning `Option<&'static str>`: it is still the UI's single capability gate (`None` = no verified fork command, no row offered), and per-agent wording stays expressible should one ever genuinely diverge. Generated commands are untouched — the existing table test still pins `claude --resume <id> --fork-session` and the other three verbatim. Also drops the two doc sentences that promised the per-agent label, and the stale "Branch Session" mentions left in comments; no occurrence survives anywhere in the tree. * no-mistakes(review): fix(agents): fork the pane the tab menu row named * no-mistakes(document): rewrap fork menu comment after label unification * fix(agents): repoint Pi's token-gate comment after the rebase Rebasing #211 onto #240 moved the session-id token gate out of resume_command and into the shared session_command_flags helper, so Pi's comment pointing at "the token gate above" no longer names anything. Comment only; the gate itself is unchanged. * no-mistakes(document): correct fork placement rationale in menus and changelog * chore: untrack AGENTS.md per gitignore dev-tool convention tty7 keeps agent-memory files out of the repo: `/CLAUDE.md` is already ignored, and on disk it is a symlink to `AGENTS.md`, so tracking the target defeated the convention. Ignore `/AGENTS.md` alongside it and drop the tracked copy; the file stays on disk, where the notes belong. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
c951318f93 |
fix(tests): stop the keybinding tests from wiping the real user config
The gpui keybinding tests in `ui::app` never pinned a config dir, so every
`update_config` they run ends in `Config::save()` — a *full* overwrite — against
the developer's real `~/.config/tty7/config.json`. A plain `cargo test` reset the
whole live config to `Config::default()` plus the shortcut the test recorded;
the user-visible symptom was a rebind reappearing days after it was reset.
Guard it at the type level rather than trusting each test to remember: a
`#[cfg(test)]` `Config::save`/`load` on the GUI newtype panics when the resolved
config dir is the platform's real one. An inherent method wins over the `Deref`
to `CoreConfig`, so under `cargo test` every call site routes through the guard
and otherwise straight to core — no call site opts in. `default_config_dir` is
pub now so the guard compares against it instead of re-deriving the layout.
`set_config_dir` is first-call-wins and process-wide, which means every test has
to pin the *same* path: a test pinning a name of its own silently redirects
whichever test loses the race. Nine hand-copied `tty7-covtest-<pid>` literals now
go through one `pin_test_config_dir()`.
|
||
|
|
68683647ee |
feat(remote): build remote panes off the UI thread, and redraw the switcher
Opening, restoring, closing or listing a remote pane each ran a routed, blocking connection straight from a gpui input callback, freezing the whole window for the length of an SSH setup. Pane tree leaves become a PaneSlot that can be Ready or Connecting, so the slot lands in the layout immediately and the wait happens in it. connect_routed now carries the same debug UI-thread guard the Host trait uses, so the class of bug fails loudly instead of shipping. The workspace switcher is redrawn on one alignment axis with one icon size, a guide rail tying a remote machine's rows to it, and the command palette's card geometry. Launch restores only the workspace the user was last in. |
||
|
|
d534bdbb75 |
fix(remote): keep a remote workspace's layout across reconnects
A remote workspace came back empty every time. The layout was thrown away on both sides of design §10's storage split: `record_session` blanked a remote entry's `session` before anything could push it, and `claimable_session` blanked it again on the way out, so the record pulled from the machine on connect was discarded the moment the window opened from it. Nothing ever called `push_remote_layout` outside workspace creation either, which left the machine's own `workspaces.json` holding a record with no tabs in it. The entry's `session` is now what it was always meant to be — this client's cache of a record the machine owns — and the split is enforced by reachability instead of by erasure: - `claim` / `record` ask `machine_is_connected` first. A connected remote window stores and reopens its layout like any other; an unreachable one opens empty and, the half that matters, leaves the cached layout alone so there is still something to rebuild from. - `save_session` pushes to the machine that owns the record, so the remote's copy tracks every structural change rather than only the workspace's creation. - `finish_attempt` hydrates a window that came up before its machine did. Only an empty one: a window with tabs is one the user is working in. The invariant the erasure was protecting still holds, and holds where it belongs — every leaf routes through `pane_workspace_for`, so a remote workspace's panes attach or spawn over there, and an unreachable machine fails the spawn rather than falling back to a local shell. Also stop matching a native-SSH leaf's `pane_id` against a remote workspace's alive set. That pane lives in this client's daemon however the window is bound, and pane ids are unique only within one daemon, so a collision would have swapped the user's SSH tab for whatever the machine happened to be running under the same number. |
||
|
|
208454e202 |
feat(remote): remote workspaces — a window that is one machine
Split the framework-free half of tty7 into `tty7-core` and add a headless
`tty7-server` built on it, so a workspace's filesystem, git and session state
can live on another machine while the GUI stays where it is.
- `crates/tty7-core`: wire protocol, session daemon, PTY, native SSH engine and
the domain model, with no gpui dependency. Module paths are unchanged.
- `crates/tty7-server`: the same daemon with no GUI attached, linked fully
static against musl and pushed onto the remote box. One dependency, on
purpose — a second one the GUI also needs belongs in core.
- `Host` trait + `HostId`/`HostRegistry`: every fs/git/watch call a workspace
makes goes through the machine it belongs to. `LocalHost` answers on this
box, `RemoteHost` over a routed control connection.
- `ui::host_ops`: the GUI's single door to a `Host`. Host calls block, so all
of them run on the background executor with the result landed on the UI
thread; de-duplication, staleness and error reporting live here rather than
at each call site. Enforced by a CI grep.
- Connect flow: home page → pick a configured SSH host → the machine's own
workspace list → a window bound to one workspace on it. Workspace switcher
groups by machine, this computer included.
- CI: static musl builds of `tty7-server` for x86_64/aarch64 via
cargo-zigbuild, a host-boundary grep, and version stamping factored out of
the nightly workflow. Both new jobs are non-required so branch protection
does not wedge open PRs.
Design and the interface contract it was built to are in
`docs/2026-07-27-remote-workspace-{design,impl-contract}.md`.
|
||
|
|
aad0a49ef7 |
fix(config): don't let a UTF-8 BOM silently reset every setting
Every config-dir file is read by a loader that treats any parse error as "there is no file" and falls back to defaults. serde_json rejects the U+FEFF a BOM puts before the opening brace, so a BOM never surfaced as a broken config — it surfaced as an absent one, and the app booted on defaults with nothing to explain it. Windows makes that easy to hit by accident: PowerShell's `>`, `Out-File` and `Set-Content -Encoding utf8` all write a BOM, so editing config.json from a shell was enough to lose every setting. Strip a leading BOM in the three loaders whose files people hand-edit: config.json, session.json (which dropped every workspace the same way), and themes/*.yaml. read_to_string decodes the marker to one U+FEFF char, so this strips the char, not the three raw bytes — and only the first one, since a second is content the parser should still reject. window.json and update.json are left alone: they are machine-written state a relaunch rebuilds, never hand-edited. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD |
||
|
|
64744fce61 |
test(config): pin that an unknown key can't silently re-enable the prompt
`Config::load` turns any parse error into *defaults*, so a config that fails to deserialize doesn't fall back field-by-field — it comes back with `confirm_window_close: true` and nothing said. The struct has no `deny_unknown_fields` today; this pins that, since the opt-out is exactly the setting whose silent reversal nobody would notice. Also index the Windows/Linux spelling of the chord: the prompt is reached by Ctrl-W off macOS, and search only knew "cmd-w". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD |
||
|
|
88d21d19ed |
Merge remote-tracking branch 'origin/main' into feat/confirm-window-close
# Conflicts: # src/ui/settings.rs |
||
|
|
b3bd61e867 |
feat(settings): let the last-window close confirmation be turned off
Closing the last window pops "Close Window?" every time. The prompt was only ever teaching, not protection — Cmd-Q, the tray's Quit and the palette's Quit all leave without asking, and nothing is lost either way since the panes keep running in the daemon. Once the user knows that, being asked on every quit is friction. Adds `confirm_window_close` (default true, so nothing changes for existing configs) and a Window & Tabs toggle. Off makes the last window close like any other: detach the workspace, quit. |
||
|
|
c31826ea57 |
test(crash): match the panic location without a path separator
`panic!` records the location with the platform's own separator, so the assertion on `src/core/crash.rs` failed the Windows job while the hook itself worked fine there. |
||
|
|
6edfb28200 |
fix(terminal): stop a stale hover cell from indexing a shrunken grid
Hovering a pane records the cell under the pointer so ⌘-hover can underline links. Nothing invalidated that cell when the grid shrank under it — a vertical split, un-maximizing a pane, a smaller window — so the remembered row could name a line the grid no longer has. The next modifier press then walked into it: ModifiersChanged → set_link_modifier → every pane in every tab → link_span_at, which indexed the grid with the stale row. Grid's Index<Line> only debug_asserts the bound, so a release build underflows the storage index and panics on the slice check. That panic happens inside gpui's extern "C" input callback, where it cannot unwind — the process aborts, and the OS crash report keeps the abort rather than the panic. Validate the row against the grid before indexing (one choke point, shared by hover and click), and drop the remembered cell on resize. Two more ways the same class of crash reached users: - `new_terminal` unwrapped a failed daemon spawn with `expect`, so a daemon that died took the app down with the next ⌘D/⌘T instead of reporting the failure. It returns a Result now; split, new tab, worktree tab and reopen-closed-tab surface a notification, and session restore drops the leaves it can't rebuild. - A spawn that fails because nothing is listening on the socket now starts a daemon and retries once, the way a disconnect mid-Spawn already did. `connect()` had to stop interpolating the io::Error into a string for that classification to see it. And so the next one is diagnosable at all: a panic hook writes the message, location and backtrace to `crash.log` in the config dir. The GUI has no logger and no stderr when launched from Finder, and the crash report loses the message to the abort. Fixes #201 |
||
|
|
8bdcaf4290 |
fix(fonts): name a CJK and emoji fallback the host platform actually ships
The default `font_fallbacks` list was macOS-only -- Menlo, Hasklug Nerd Font Mono, Maple Mono NF CN, Apple Color Emoji. Fallbacks resolve by family name against installed fonts, so off macOS the whole chain matched nothing and every glyph the primary lacked was left to the platform's own cascade. Bundled Hack maps 1548 codepoints and zero ideographs, so on Windows that was every Chinese character in every pane, and every emoji. The fall-through is not only a matter of which face you get. `element.rs` pins each wide cell to `2 x cell_width`, and Hack advances 0.60205em, so a two-column slot is 1.2041em -- while every stock CJK face (Microsoft YaHei, PingFang SC, Noto Sans CJK) advances 1.0em. `force_width` left-aligns, so the ideograph hugs the left of its slot and the remaining 0.2em shows up as a gap on the right of every character. Measured on Windows at font_size 15: left bearing 1.49px, right bearing 4.90px. Branch the defaults per platform, keeping Maple Mono NF CN first everywhere -- 0.6em Latin, 1.2em CJK, the one exact two-cell fit against Hack (bearings 3.06px / 3.62px, ink centered). It stays referenced by name only, never bundled, at ~20MB per weight. Changing `Config::default` alone would reach nobody who already has a `config.json`, which is every existing user. So `fallback_chain` appends the platform's stock faces the same way it already pins Hack: a fallback is consulted only after everything ahead of it has missed, so appending can never displace a face the user chose, and the file is never rewritten. Verified by driving two builds against one config naming only absent macOS faces: before, the CJK line differed from an explicit Microsoft YaHei chain by 3571 pixels (the cascade picked something else); after, it is pixel-identical. |
||
|
|
78d86b9949 |
Merge pull request #175 from l0ng-ai/feat/ux-overhaul
feat(ux): rebuild the menu bar, command palette, and Settings IA |
||
|
|
89b6fc5ae5 |
Merge pull request #174 from l0ng-ai/feat/grok-hooks
feat(agents): wire the rich status channel into Grok Build |
||
|
|
4ac7d30178 |
feat(ux): rebuild the menu bar, command palette, and Settings IA
The menu bar shipped four menus in the order App / Shell / Window / View with no Edit menu at all, so Copy and Paste existed nowhere but a right-click, and About / Check for Updates / Hide / Minimize / Help had no home. It now follows the macOS HIG's standard set. The palette listed 47 commands in three competing naming styles, ranked only by catalog order, with no grouping and no way to reach most of what the tab context menu could do. It now has one documented grammar, a scored fuzzy ranker, group bands with a frecency-driven Recent, and the commands it was missing. Settings had a three-row Shell page indistinguishable from Terminal, a seven-group Terminal page that had become a junk drawer, two different groups called "Window", and a search index that had drifted so far from the rendered rows that "opacity" and "completion" returned nothing. Also folds copy / cut / paste / undo onto one code path each, which fixed two real drift bugs: the right-click Paste skipped the image-paste branch that Cmd+V had, and Copy rendered disabled whenever the selection was in the prompt editor rather than the grid. |
||
|
|
eced0af754 |
feat(agents): give Grok its brand avatar
Grok was drawing the generic robot glyph on a slate disc — the fallback picked back when no usable mark was bundled. xAI publishes its symbol only as a ~2:1 landscape lockup that bleeds off its own canvas; traced and fitted to a 24x24 box it is unreadable as a 16px silhouette, which is what the tab chip and sidebar render. So the bundled mark is lobehub/lobe-icons' square transcription (MIT), drawn for exactly this avatar use. Its notice rides in the SVG. The slate accent goes with it. That mid-tone exists for vendors whose monochrome mark is grey or a gradient (Cursor), because a white field vanishes on a light theme; a black field has no such problem — it stays darker than even the darkest theme background and the white mark carries the badge. Grok brands in black, like Codex, so it keeps that. Adds a guard test: every CLIAgent::icon_path must resolve through the asset source. A brand mark means touching two files, and forgetting the registration costs the agent its avatar silently. |
||
|
|
c76ef87d02 |
feat(agents): wire the rich status channel into Grok Build
Grok Build exposes a Claude Code-shaped hook surface, so tty7 can now install into it and give grok panes live session status and resume-after-restart, not just a brand chip. - Owned hook file at ~/.grok/hooks/tty7.json (grok loads every JSON file there; global hooks need no folder-trust grant), so the user's own hooks are never touched. - Read camelCase payload keys: grok's envelope sends sessionId, and without it restore loses the id --resume needs. - Relabel events that arrive through grok's Claude-compat scan of ~/.claude/settings.json, keyed on the GROK_HOOK_EVENT var its hook runner injects — otherwise a grok pane reports Claude Code, and having both integrations installed emits every turn under two identities. - Resume via `grok --resume <id>`, stripping the flags that would fight the injected id (--resume/--load/--continue/--session-id/--fork-session) or relocate the session (--worktree/--worktree-ref). Notification is subscribed with a matcher for elicitation_dialog only. Grok dispatches its permission_prompt notification before the permission system decides, so it fires on essentially every tool call, auto-approved ones included; escalating that to the amber "needs you" state would flash the pane and fire a desktop notification on every tool a turn runs. |
||
|
|
5d814c0d70 | feat(config): default the tab bar to the left sidebar | ||
|
|
efd2213cf9 |
Merge branch 'main' into fix/prompt-ctrl-chords
CHANGELOG: keep both Unreleased sets, with the history-search entry under Added beside the multi-window ones and the Ctrl+J/M fix in its own Fixed section. |
||
|
|
7d7d3ce882 |
fix(windows): start fresh workspaces with a shell, and say where a closed one went
Review follow-ups on top of the multi-window work. - A brand-new workspace came up on the home page with no shell, because `claim` always hands back an (empty) session and the window treated that as "restore this". A first run and `New Workspace` now take the first-run path again and spawn a terminal; the launch that exists to show the workspace picker asks for an empty window explicitly (`FreshStart`). - The close-window prompt promised sessions "will be restored the next time you open tty7", which is no longer what happens — the workspace detaches and waits in the picker. Both it and the one-time detach hint now point at the title bar's workspace menu rather than the macOS Window menu, which does not exist on Windows or Linux. - `ToggleSftp` read the panel state off the config, which is now only what a *new* window starts with; it reads this window's own state. - `SelectWorkspace1..9` were unbindable: registered as actions but absent from the keymap tables. Added with no default chord (⌘1–9 is the tab row's). - `theme_commands`' doc comment had been captured by a function inserted above it, and the Window menu's slot→action mapping was a second copy of the title-bar chip's. - CHANGELOG: drop the ⌘1–9 claim (no such binding ships), and document the chrome tile sizing that rode along with this branch. |
||
|
|
443f04f3c6 |
fix(prompt): stop swallowing ^J, and let ^R go to the shell on request
The local command editor consumed every Ctrl chord at the prompt, matched or not, so two things the shell owns quietly stopped working (#163). ^J and ^M carry accept-line's control codes — Enter by another name — but fell into `apply_readline_ctrl`'s no-op arm, so the keys did nothing at all. Route them through the same path Enter takes, via a shared `accept_line`, so the completion picker and the history menu treat them identically. ^R was recognized, but only ever opened tty7's own history menu, with no way back to a `bindkey`ed widget (fzf, percol). Add `history_search` (default on, Settings → Terminal → Keyboard): with it off, the edited line is handed to the shell and the raw ^R follows it, so whatever is bound there answers. The "shell integration never engaged" notice stays quiet in that case — ^R reaching the PTY is then the point, not a gap. `handoff_tab_to_shell` generalizes to `handoff_line_to_shell(chord)` to carry the ^R handoff; the Tab path is a thin wrapper over it and its behavior is byte-for-byte unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7789dc4c36 |
Merge branch 'main' into feat/multi-window
Conflict in the sidebar's control row: main wrapped both tiles in `occlude()` so Windows' HTCAPTION drag doesn't swallow their clicks, while this branch moved their geometry onto `chrome_tile_sized` / the shared `TILE_*` constants. Kept both — occluded wrappers around helper-sized tiles. |
||
|
|
e41afaf857 |
feat(windows): one window per workspace
tty7 had exactly one window, so `main` opened it inline and every app-wide duty — tray, menus, the quit hook — lived in `Tty7App`'s constructor. This splits those apart: a *workspace* is the persistent identity (tabs, splits, cwds, name) and a *window* is a transient view onto exactly one of them. - `ui::windows` — the app-level window registry and the single place that opens a window. Exactly one window per workspace is enforced there: the daemon gives each pane a single subscriber, so a second window on the same panes would silently steal the first's output. `open` focuses the existing window instead. New windows cascade so one never lands on top of another. - `WorkspaceStore` owns session.json, so windows never race each other as writers. Closing a window *detaches* — panes keep running in the daemon and the entry stays for the picker; `StopWorkspace` kills the sessions and keeps the layout; `DeleteWorkspace` also forgets it. - Window menu lists every workspace with a monogram badge and a liveness dot, ⌘1–9 for the first nine. Same list in the palette; closed ones also appear in a home-page picker with a coarse relative age. - Sidebar collapse and right-panel visibility move onto `Tty7App`, so toggling one window's chrome leaves the others alone; the config value becomes what a new window starts with. Panel *width* stays shared — a width is a preference, not a view state. - Tray, menus, and the quit hook now walk the registry rather than belonging to a single window. Protocol goes to v2: `RemoteKind::Wsl` is a new enum variant, which is not the additive change it looks like — the enums carry no `#[serde(other)]`, so a v1 peer fails the whole decode and drops the pane's connection. The handshake now catches that skew and offers a restart. |
||
|
|
0ebe68a453 |
feat(ssh): fold port forwarding and SFTP into the detail panel
Both SSH tools floated over the terminal: a tunnel icon and an SFTP icon pinned top-right, opening a 460px popover and a bottom dock. They are pane facts, so they now live where the pane's other facts already are. Port forwarding becomes a Forwards band on the Info tab, under Ports — one says what the pane listens on locally, the other what it routes across the connection. Rows take the panel's language: a mono kind letter, the bound port as the same chip a listening port gets, hover to remove, click to edit. The add form is inline, stacked to fit the column. The list re-lists on the Info tab's existing 2s poll, so a forward that dies remotely turns red on its own. SFTP becomes the Files tab's remote mode: the tab follows the detail pane, showing a local repository tree or that machine's filesystem. Same browsing model as before (breadcrumb, filter, `..`-led list, per-row right-click) relaid out for ~260px — the toolbar collapses to refresh plus a `⋯`, and the permissions column moves into the chmod form, which now names the mode it is editing. The header carries the hostname: the tab swaps between two filesystems as the pane changes, and it can rename and delete. Transfers become a footer on the panel column rather than a tray inside SFTP. It sits below every tab, so reading Info doesn't hide a running upload, and stays pane-scoped rather than aggregating every pane, which would quietly make the panel a window-level transfer centre. Opening the browser gained a step: the shell's cwd needs tty7's shell integration on the remote, which a freshly-connected host rarely has, so it fell through to `/`. A new SftpOp::Realpath resolves the login directory instead. Per-pane positions are recorded on arrival, so a first landing at `/` can no longer be remembered as a preference. With nothing floating over the terminal any more, the ⌘F find bar gets its top-right slot back — it used to be suppressed while those icons were up. |
||
|
|
9a4d818b78 |
refactor(editor): drop the LSP client entirely
Opening a `.rs` file in the code panel silently spawned rust-analyzer, which then indexed the whole workspace — hundreds of megabytes of RAM and a busy core — with no setting to turn it off. A terminal emulator should not do that to its user on a click, and rather than add a flag to disable something nobody asked for, the integration goes. Removed: the JSON-RPC client and reader thread (`ui::lsp`), the per-server registry, the completion / hover / definition providers installed on the buffer, document sync (didOpen/didChange/didSave/didClose), diagnostics, Go to Definition (F12), Find References (⇧F12) and its drawer, and the status bar's server indicator. With them go the `lsp-types`, `ropey` and `url` dependencies — all three were used only by this code (they remain in the lock file as transitive deps of gpui-component and gpui, which is expected). Kept, and deliberately so: - **Syntax highlighting**, which is tree-sitter, not LSP: gpui-component's `tree-sitter-languages` feature, `InputState::code_editor(language)` and `language_for_path` are all untouched. It is static, in-process, and costs nothing beyond parsing the open buffer. - ⌘S save, dirty tracking, the external-change watcher and its conflict banner, markdown preview, soft wrap, and open-from-the-file-tree. The module header now records *why* there is no language server, so the next person to reach for one finds the reasoning instead of a gap. Net −975 lines. |
||
|
|
649fdef51e |
Merge branch 'main' into worktree-code-panel
# Conflicts: # src/core/config.rs # src/ui/mod.rs # src/ui/tab_sidebar.rs # src/ui/tab_strip.rs |
||
|
|
403cfd47a1 |
feat(right-panel): docked detail panel with Info, Changes and Files tabs
Add a right-hand detail column showing what the active pane is, not what it prints: session facts plus its process tree and listening ports (daemon-side procinfo, pull-based via QueryProcs), the working-tree diff, and the file tree. Tab row lives in the title bar, body in right_panel. Also record OSC 133 command marks client-side so the panel's Outline can list a pane's commands and scroll back to one, keyed on row text since absolute scrollback indices drift once history fills. |
||
|
|
27c1cdbf3f |
feat(ssh): bootstrap shell integration into native SSH sessions
Native-SSH panes reported no OSC 133, so the inline line editor, exit-code marks and cwd tracking were all inert there — the daemon's OSC sniffer was already wired up for them and simply never received anything. Every existing integration configures a *local* process spawn (ZDOTDIR, a bash --rcfile, fish's -C). An SSH channel offers no spawn to configure, only the string an `exec` request carries, so the remote path recreates those same files on the remote side and execs through them. The integration bodies are reused verbatim rather than forked. The bootstrap can't be shell-agnostic: sshd runs it as `$SHELL -c <string>`, so a POSIX script is parsed by fish and a fish script by zsh. Rather than contort one expression into parsing identically everywhere, spend a probe round-trip (`echo __tty7_shell; echo $SHELL` — no substitution, assignment or grouping, so it is valid in all of them) and then emit the dialect we know we are talking to. The probe is memoized on the connection key, so extra tabs to an open host cost nothing. The probe's negative answer is load-bearing: a remote whose login shell is unrecognized — or that isn't POSIX at all, where `$SHELL` echoes back unexpanded — falls through to the plain shell request it always used. Every arm ends by exec'ing the user's own shell, including the failure paths, so a remote with a read-only $TMPDIR loses the integration and not the session. zsh only gets ZDOTDIR pointed at the throwaway dir once all four redirectors are confirmed written; a half-populated dir would silently cost the user their dotfiles. The dir removes itself on the first precmd, by which point every startup file has been read. Add a per-profile switch, on by default and defaulting to on for profiles saved before it existed, for remotes we *can* integrate but shouldn't. |
||
|
|
8955b1545f |
fix(git-status): refresh the sidebar counts on window focus and tool calls
The sidebar's `+N -N` only refreshed on three rare edges: the pane changing directory, a command ending, and an agent turn ending. Edits made anywhere else produced no signal at all, so the counts sat stale — a long agent turn showed nothing until it finished minutes later, and a file edited in another editor never registered until the user happened to run a command in the pane. Two new triggers close the gap: - Window activation re-probes every pane. Coming back to the window is the only cue we get that the tree moved while the user was elsewhere, and the sidebar lists every tab, so refreshing just the focused pane isn't enough. - An agent's tool completions re-probe mid-turn. `AgentSessionState` gains an `activity` counter because `ToolComplete` is deliberately a status no-op during normal work, leaving status-watchers unable to see it. Both go through a new throttled claim on `GitStatusCache` that drops triggers instead of queueing them, so a busy agent or a window full of panes collapses into one shell-out per repo per 1.5s rather than a `git` storm. Also: fold the probe's two `rev-parse` calls into one (it now asks for toplevel, git-dir and common-dir together), which makes `repo_home` a pure function and unit-testable; and land probe results in the shared cache independently of the pane entity, so a pane closed mid-probe can't wedge the cwd-keyed in-flight claim for every other pane in that directory. |
||
|
|
6753b9697b |
fix(completion): fall through to shell completion, dir-only candidates, opt-out (#136)
Three fixes for tty7's Tab completion: - Tab is no longer swallowed when the engine has no candidates: the locally edited line is handed off to the shell (text shipped raw, cursor walked back, Tab sent) and the local editor suspends until the next prompt cycle, so shell-native completion (compsys, fzf-tab, ...) answers instead. The handoff release keys off a new entered-prompt cycle counter rather than the raw Prompt-frame seq, so same-prompt redraws (PS1-embedded 133;B re-emissions) cannot re-engage the editor while zle still holds the handed-off text. - cd/pushd/popd/rmdir complete directories only in the no-signature path fallback; Fig 'folders' templates narrow signature slots the same way. Symlinks now classify by their target. - New tab_completion config field (default true) plus a Settings -> Terminal -> Keyboard toggle; when off every Tab goes to the shell. |
||
|
|
f61bb39f01 |
Merge pull request #144 from l0ng-ai/feat/agent-resume-launch-flags
feat(agent): carry launch flags onto session resume commands |