mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
fix/install-test-dialect
495
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a39eebfe1c |
fix(install): every fixture installer gets the fixture's dialect
`an_install_reports_both_transfers_to_completion` and
`every_report_carries_the_host` built their `Installer` by hand — `with_version`
and `with_timeouts`, no `with_dialect`. So they ran at
`RemoteProtocol::of_this_build()` while `FakeRemote` answered the `--protocol`
probe with the fixture's fixed `c3p4`, and passed only because the real
`CONTROL_VERSION` currently *is* 3.
Found by bumping `CONTROL_VERSION` to 4 locally: both tests fail with
`DialectMismatch { wanted: c4p4, spoke: c3p4 }` — an error about a fake's
plumbing, raised in two tests that are about *progress reporting*, at the one
moment somebody is busy changing the wire and least able to spare the
attention. The fixture's own comment says the dialect is a fixed literal
"rather than `RemoteProtocol::of_this_build`", which is exactly right and was
exactly what these two bypassed.
Both now go through `installer()`, whose `release` parameter is a
`&dyn AssetFetcher` so that a chunked fetcher — the only reason either test
hand-rolled the builder — is no longer a reason to leave the helper. The
helper's doc says what forgetting it costs, since that is the failure mode a
reader needs and the compiler cannot give.
No behaviour change, and the same 644 tests before and after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
2a4b5c7f68 |
fix(release): name the server assets for whoever downloads them, not for cargo (#265)
`tty7-server-x86_64-unknown-linux-musl` was never a name anyone chose. Both
workflows staged the file as `tty7-server-${{ matrix.target }}`, so the build
triple went straight into a published filename — and the triple's *vendor*
field, for a Linux target with no particular vendor, is the literal word
`unknown`. It has been sitting on the releases page reading like a failed
lookup.
Of the triple's four fields only two say anything to whoever downloads this:
the architecture, which is what `asset_for_uname` picks by, and `musl`, which
is why one file runs on any distribution. So:
tty7-server-x86_64-unknown-linux-musl → tty7-server-linux-x86_64-musl
tty7-server-aarch64-unknown-linux-musl → tty7-server-linux-aarch64-musl
`<os>-<arch>` in that order because that is what the GUI assets in the same
release already use (`tty7-<version>-linux-x86_64.tar.gz`). One release should
be one naming scheme; it was two.
The triple stays everywhere it really is a build target — `cargo zigbuild
--target`, the `target/<triple>/release` path, the rust-cache key, ci.yml's
matrix. The workflows now carry both: `target` for the build, `asset` for the
filename, deliberately not the same string.
This name is a contract with more than the release step, and all of it moves
together:
- `install::asset::{ASSET_X86_64, ASSET_AARCH64}`, which is what the client
appends to a release URL.
- `bundle-windows.ps1`, which stages the musl binary for WSL. `wsl.rs` looks
for `<dir>/<asset name>` with nothing translating, so the *filename* is as
much a contract as the `server/` directory is — now said out loud in both
places, along with the consequence for `TTY7_BUNDLED_SERVER_DIR`: a
cross-compile has to be copied to the asset name, not left as `tty7-server`.
- The GUI's install prompt fixture, the checksum manifest fixtures, and the
`MissingBundled` assertions.
Nothing globs the old shape: `gh release upload dist/*`, `checksums.txt`'s
`find`, and the installer's `server\*` are all name-agnostic.
A new test pins both names as literals — the module header already says this
naming is "a *literal* contract with the release workflow", and asserting the
consts against themselves asserted nothing. It also fails on the substring
`unknown`, since that word only ever arrived here by way of `matrix.target`,
and checks neither name contains the other, which is what
`checksums::expected_digest` says out loud that it relies on.
Compatibility: a stable client asks its own frozen tag, which keeps whichever
name it shipped with, so every released client keeps working. The rolling
`nightly` tag is replaced each night and its prune step drops assets the run
did not upload — so an *already installed* nightly client 404s on the server
download until it updates itself. Accepted deliberately; the next release is
what has to be right.
Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
66fcc95e2e |
fix(install): name remote servers by dialect, and prove the name before publishing (#264)
* fix(install): name remote servers by dialect, and prove the name before publishing A remote tty7-server was installed as `tty7-server-<version>`, and one stat of that path decided whether anything got installed at all. The version string is not an identity: every build between releases carries the same one, so a client speaking control v3 found the released v2 binary sitting at the exact path it looks for, skipped the install, adopted a server it cannot talk to, and died in the handshake with no way out. `check_running_build` compared the same string and so never even probed. Name the file after the two numbers that actually decide compatibility — `tty7-server-c<control>p<protocol>` — and after uploading, ask the binary what it speaks and publish it only if the answer matches the name it is about to get. That turns "the filename is the dialect" from a convention into a fact a stale bundle or an older release tag cannot break, and surfaces an architecture mismatch at install time rather than as `Exec format error` inside a later daemon launch. The keep-or-restart prompt offered "Keep Sessions" for a state where keeping means the connect fails a moment later; it now offers Cancel. A handshake that does fail gets a Restart Server button, gated on that one error so no other connect failure can reach a destructive action that would not fix it. * fix(remote): offer "Restart Server" only where there is one, and keep a torn-down pane's resume record `RemoteTarget::is_ssh` answers the question the verb depends on, in one place rather than re-spelled at each call site: a WSL distribution's server is started by this client and a `LocalStdio` machine is a child process per connection, so neither has a long-lived daemon to stop and start — which is exactly why `router::restart_server` refuses them. The UI that offers the action and the router that carries it out can no longer disagree about who has one. "Restart Daemon…" now asks the window which daemon serves it. Run from a remote window it used to end every local session in every other window and leave the machine in front of the user untouched — a destructive button that did nothing its label promised. A pane torn down with its coding agent no longer publishes an observation. The kill hangs up the whole process group, so the agent dies before the PTY EOFs and a poll firing on the bytes still in flight sees nothing recognizable; publishing that cleared the record's agent and session id, and the reopened workspace came back to a bare shell instead of the conversation. The same answer about a pane nobody is tearing down still means the agent exited on its own. * fix(remote): review follow-ups on dialect-named servers Six findings from a review pass over the two commits. One commit because they are all corrections to the same change. - The progress bar the `Restarting` phase was added for never appeared for the flow that needs it most. The switcher only reads a machine's phase while *this* window is connecting or showing an error, and "Restart Server…" in the machine's `⋯` menu is deliberately offered whatever the link is doing — so restarting a connected or offline machine recorded a phase nobody read, and the click had no visible effect for the length of two timeouts. A `Restarting` phase now counts on its own: it is only ever recorded by an action a user asked for, it ends every session on that machine including the ones other windows are showing, and it is the one flow that transfers nothing and so has no other way to say the click landed. The header says "restarting…" rather than borrowing "installing…", which described bytes that are not moving. - "Restart Server" on the error card silently did nothing when the machine could no longer be addressed: `replace_remote_server` logged the failure and returned, past a prompt that had just promised the server would be replaced. It reports it, like every other failure on that path. - `is_ssh` gates an action that ends every session on a machine, and spelled the three yes-variants as a `matches!` — so a new `RemoteTarget` would inherit "not SSH" by falling off the end of the pattern, which is the opposite of what its own test claims. An exhaustive `match` makes the compiler ask. - The mismatch prompt's detail explains its buttons by name and the buttons were written out again at the prompt. `Keep Sessions` was one of them until this branch removed it, which is exactly the drift worth preventing twice: `MISMATCH_ANSWERS` is named once beside the detail, and a test asserts the detail explains every answer offered. - `unique_temp`'s pid is private to a process, not to a client: two clients on different machines can share a pid. Left alone, because the new `--protocol` check stands behind it — interleaved bytes cannot answer with our dialect, so they are refused rather than published — but said, along with the fact that two installs inside one process share the path too and are the locks' job, not this function's. - Ten doc sites still said the installed file is named after the version, including the module table's step 2, `client_version`'s "the server that matches me", `Installer::run`'s postcondition and `ensure_remote_server`'s returned path. Also corrected the claim that WSL and `LocalStdio` have no daemon to restart: WSL's is started by this client, which is why "stop it and reconnect" is the whole verb — the reason the router refuses them, not the absence of a daemon. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Co-authored-by: thomas <thomas@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
33eedad90a |
fix(ui): the routed-auth test no longer hangs, and no longer loses its prompt (#263)
* fix(ui): the routed-auth test can no longer hang the whole suite `a_routed_auth_prompt_carries_the_machine_that_raised_it` waited for its own prompt in an unbounded spin loop. `AUTH_MAILBOX` is process-global and `pump_auth_sheets` drains every entry in one pass, so any gpui test in this binary that drives a tick can take that prompt first — and unbounded, the loop then spins until GitHub's six-hour job limit. This is the hang CI has been paying for, named twice and on two platforms: 2h50m inside this test on x86_64-unknown-linux-gnu (run 30517182773), and again on windows-msvc (run 30526538997). It is *not* the cfg(windows) transport accepts bounded in #261; those were a separate latent six-hour hang. The loop now has a ten-second deadline and an assertion that says what an empty mailbox means and whether the responder thread had finished. Note what that does and does not buy: a stolen prompt becomes a fast, self-explaining failure instead of a six-hour outage, but the theft itself is still possible, and curing it means deciding what that process-global mailbox should be under test — a design call, not something to settle inside a CI fix. ci.yml keeps only a comment where a post-mortem step used to be, because the step was worthless twice over. It cannot work: GitHub kills the step's process tree when `timeout-minutes` trips, before the next step runs, so on run 30526538997 the dump printed two headers and nothing between them. And it is not needed: libtest already prints "<test> has been running for over 60 seconds", which was in every hung run all along. The obstacle was only ever that a job's log cannot be fetched while the job is in progress — which the `Test` timeout fixes by making the step fail. * fix(ui): a test waiting on the auth mailbox is no longer raced by a tick The previous commit made the flake loud instead of fatal; this stops it happening. CI proved the mechanism on the very next run: the new assertion fired on windows-msvc with "no routed prompt arrived within 10s ... Responder thread finished: false", 731 other tests passing, the whole suite done in 12.39s instead of hanging for six hours. `AUTH_MAILBOX` is process-global and `pump_auth_sheets` takes every entry in one pass. That is right for the app — one tick, one mailbox — and wrong in a test binary, where a test waiting for the prompt it just caused shares that mailbox with every gpui test that drives a tick. The tick drains a prompt it has no idea was spoken for, and the waiting test never sees it. `MAILBOX_TURN` arbitrates: a test that needs its own prompt back claims it for the exchange, and the drain yields while it is held. Both the static and the claim in `pump_auth_sheets` are `#[cfg(test)]`, so a release build is byte-for -byte what it was — there is one app, one tick, and nothing to arbitrate. The compromise is visible and deliberate: test-only synchronisation inside a production function. The alternative that needs no such thing is to stop the mailbox being process-global — dependency-injected per app — which is a larger change to a path this defect does not otherwise justify touching. No deadlock: the claim is the first thing `pump_auth_sheets` does, before it locks the mailbox, so the two locks are only ever taken in one order. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
4f37fa8eb8 |
fix(transport): bound the Windows test hang — no unbounded loopback accept, and CI timeouts (#261)
* ci: bound the Windows test hang and stop superseded runs holding slots
The Windows `Test` step intermittently hangs — roughly one run in ten, on
any branch, while the same commit passes on a re-run. `cargo test` has no
timeout of its own, so every occurrence ran to GitHub's six-hour job limit:
three times in one day a 75-second step held a runner slot for hours and
reported nothing about which test was stuck. One of them took the runner
down with it ("the hosted runner lost communication with the server"), and
while those zombies held slots an unrelated PR's macOS job queued for two
and a half hours.
Three changes, none of which fix the hang itself:
- `timeout-minutes` on the `Test` step (20) and `Build` step (30), plus a
60-minute job backstop. The honest budget is ~75s warm and ~3.5 min when
the step also compiles the test targets, so a trip means a hang.
- A Windows-only post-mortem step on failure that dumps the process table.
libtest names a test when it *finishes*, so the hung one is the name
missing from a truncated list; the surviving test binary names its crate
and test target instead.
- `concurrency` with `cancel-in-progress` for pull requests, so a
superseded run stops competing for the shared concurrent-job budget.
Pushes to main are exempt: each commit's run is the record of whether
that commit was green.
The hang's cause is still unknown and cannot be reproduced off a Windows
runner. This makes it report in 20 minutes instead of costing six hours.
* fix(transport): no Windows test may block forever on a loopback accept
The three `cfg(windows)` tests in the transport's test module held five
unbounded `listener.accept().unwrap()` calls, each paired with a client
thread that `unwrap()`s its `connect`. When such a thread panics — a
transient loopback refusal on a loaded runner is enough — nothing is left
to wake the accept, and nothing is left to feed the handshake read after
it. The test does not fail; the whole test binary stops.
That is the shape of the hang CI has been paying for: Windows-only (these
tests are `cfg(windows)`, so no developer's macOS run executes them),
intermittent, and mute — libtest names a test only once it *finishes*, so
no log ever said which one was stuck.
`accept_within` polls a non-blocking listener against a ten-second
deadline, then restores blocking mode and puts a read timeout on the
accepted socket. Winsock hands an accepted socket the listener's blocking
mode, so clearing it on the returned stream is a real step, not a no-op.
Verified on the host target, where the logic is identical std code: a real
client is still accepted and its handshake read still works, and a client
that never arrives fails in 10.0s instead of never.
Whether this is the exact hang CI hit is unproven — it cannot be
reproduced off a Windows runner, and `aws-lc-sys` will not even build for
the Windows target on a mac. It is the only Windows-only cluster of
unbounded network waits in the tree, and it matches every observed
symptom. Either way the six-hour failure mode is gone from here.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
|
||
|
|
587887a55a | fix(ui): a failed WSL probe keeps the distributions it already found | ||
|
|
1eacbdcfca | fix(wsl): wait for the daemon to answer instead of guessing a second | ||
|
|
14ca31fb91 | docs(core): retire the M8 notes on a WSL target that now exists | ||
|
|
02d7482b68 | docs(changelog): reference the WSL workspaces PR | ||
|
|
30e3f5be53 |
feat(remote): offer this computer's WSL distributions as machines
The WSL transport, installer, router target and protocol kind have all been in place since remote workspaces landed, but nothing constructed a `RemoteTarget::Wsl` outside tests — `available_hosts` returned saved SSH profiles, `~/.ssh/config` aliases and a dev-only `--stdio` row, so the one machine that needs no configuration at all was the one you could not pick. Every installed distribution is now a row in the switcher, between the saved profiles and the config aliases: installing one is as deliberate as writing a profile, but the list is discovered rather than written, so it does not outrank the machines named by hand. The label is the exact string `wsl -d` takes — that string is also the connection key — and the endpoint column says `WSL`, which is what makes searching for `wsl` find them. `core::shells::wsl_distros` is promoted out of `cfg(test)` and answers empty off Windows, so the new-tab dropdown and the switcher enumerate distributions through one function and cannot disagree about which exist. It spawns `wsl.exe`, and `available_hosts` runs inside the switcher's render, so the list is a global filled by a backgrounded, TTL'd `sweep_wsl` — the shape `terminal::pane_liveness` already uses for the machine answers drawn two rows above these. Swept at startup and on every open, so `wsl --install` is visible after reopening the panel rather than after a restart. `spec_for`'s WSL arm said "not available in this build yet"; it now says there is no SSH connection to describe, which is what the `Err` has always meant to the routed paths that read the target instead. And the band is "Other Machines" rather than "Other SSH Hosts", since a distribution reached by spawning `wsl.exe` is sitting in it. |
||
|
|
b6577d10a6 |
fix(wsl): send a pane to the remote's pane socket
A remote `tty7-server` listens twice and the two dialects are not interchangeable; which socket a routed stream lands on is decided by `--pane` on the bridge command. The SSH path adds it via `RouteChannel::bridge_command`, and `LocalStdio` adds it in the client's `PaneWorkspace::route_header` because its argv is run verbatim — but `RemoteLink::wsl` always built `[binary, "--stdio"]` and dropped `setup.channel` on the floor. So a WSL pane reached the control socket, wrote its `Spawn`, and was answered with nothing: `routed connection closed after 56 up / 0 down bytes`. The workspace connected, the window opened, and the pane inside it said it could not reach the machine. `PaneWorkspace::route_header` already documents this exact failure shape; WSL was the transport that never got wired to it. WSL is the one transport that builds its own argv, so the choice belongs where the argv is built. `wsl_shell` routes its override through `bridge_command`, the same rewrite SSH applies to its shell command, so an escape hatch cannot silently lose the dialect. The argv is now a pure `wsl_link_argv`, because the difference between the two is one flag that cannot be checked anywhere a distribution is required. |
||
|
|
a45830ae80 |
fix(wsl): hold the launch open until the daemon has detached
WSL reaps what an interop session started the moment that session's `wsl.exe` exits, and `setsid` does not make the child safe the instant it runs. The launch line backgrounds `tty7-server --daemon` and returns in milliseconds, so the sequence was: `sh` exits, `wsl.exe` exits, WSL tears the session down, and the daemon died before it could bind its socket. What that looked like was `ensure_daemon`'s "started but nothing was answering on the control socket after 15s", over a binary that had just been installed correctly — no other symptom, and nothing in the launch line to blame. Reproduced against Ubuntu-24.04 by running the launch line alone: with nothing after it the daemon is gone; with as little as `sleep 0.3` after it the daemon lives and serves. `nohup`, `setsid --fork` and a double-forked subshell all die the same way, so the wait is the fix rather than a different spelling of the detach. Deliberately not a wait *for the socket*: `ensure_daemon` only reaches the launch when the socket did not answer, and the ordinary reason for that is a socket file a dead daemon left behind (`wsl --shutdown` is routine), so a `-S` test would pass on the stale file and change nothing. The SSH path needs none of this, so the shared `launch_command` is untouched and the wait lives in the WSL ops. |
||
|
|
f6fa34a016 |
feat(ui): make the sidebar diff preview optional and bound its cost on large working trees (#247)
* feat(diff): make the sidebar diff preview optional and bound its cost Clicking a sidebar row's `+N −N` opens the working-tree diff overlay. On a big tree that could stall the window, and not everyone wants an in-app diff viewer in the first place. Two halves, matching the report. The setting: `sidebar_diff_preview` (Settings → Window & Tabs, on by default, persisted in `config.json`). Off, the branch and the counts stay exactly where they are and read exactly the same; they lose only the pointer cursor and the `toggle_diff_overlay` handler, so the press falls through to ordinary tab activation. Both come off one value — `diff_click_cwd` — so they cannot get out of step. The performance work. All five of the reporter's hypotheses held up against v26.7.6, and each fix is measured on a 300-file / 90 000-line / 4.5 MB diff (release, macOS arm64): 1. The full diff was buffered before parsing — `git_status::git` uses `Command::output()`. Now streamed line by line through the new `git_status::git_lines` into an incremental `DiffParser`: peak transient buffer 4 552 060 bytes → 50 bytes, at ~1.7× the parse CPU (3.97 ms → 6.76 ms) on the background thread, where it never touches a frame. 2. The snapshot was deep-cloned per holder inside `this.update`, i.e. on the UI thread. Now shared behind `Arc`: 2.41 ms → 11 ns per holder. 3. The element tree is not virtualized — confirmed, not cured. Rendering is not being redesigned here; instead the element count is bounded (see 4) and `MAX_RENDERED_FILES` caps the cards built at all, with a "… and N more" line for the tail. 4. Auto-collapse was per file, and counted only +/− while the rendered body also has context lines. Added `AUTO_COLLAPSE_TOTAL_LINES` over *retained* lines: sixty forty-line files, none individually large, went from 2400 side-by-side rows to zero, under a summary saying the diff is too large to render efficiently and pointing at expanding individual files or `git diff`. 5. The Changes panel probed independently and kept its own snapshot. Both now go through `spawn_shared_diff_probe`, which dedupes by cwd and installs one `Arc` into every watcher; opening the overlay while the panel already shows that repo now paints from the panel's snapshot instead of re-probing. Plus a repo-wide retention budget (`MAX_TOTAL_LINES`, `MAX_FILES_WITH_HUNKS`): 90 000 lines / 6.2 MiB of line text → 20 000 / 1.2 MiB. The `+N −N` totals deliberately escape every cap — they are compared against `--numstat` to detect staleness, so a capped total would disagree forever and re-probe in a loop. Small diffs are untouched: a forty-file, twelve-lines-each tree is not oversized and still opens expanded, asserted directly. Not verified: anything requiring the GUI. No frame timings, no visual check of the oversized banner or the settings row, and `AUTO_COLLAPSE_TOTAL_LINES` is a judgement call anchored on row count rather than a measured frame budget. Refs #239. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(diff): bound the untracked list and stop a moving default flipping toggles Five findings from the review of the previous commit, all confirmed against the source before acting. The untracked list escaped every bound the previous commit added. `git ls-files --others` reports the whole tree of anything not yet ignored, so a fresh clone before `node_modules` / `target` / `.venv` reach `.gitignore` answers with tens of thousands of paths — read through the buffering helper, retained without a cap, ignored by `oversized()`, and rendered one non-virtualized row each. That reaches the overlay without going through the diff at all, which is why the diff budget never saw it. It is now streamed through `git_lines`, capped at `MAX_UNTRACKED`, counted toward the oversized threshold, and rendered at most `MAX_RENDERED_FILES` rows with a "… and N more" tail. The reported count stays the true total via `untracked_total` — same split the diff side already makes between what is retained and what is counted, because a count that shrank with the budget would read as files having disappeared. The overlay's expand state was an inversion set — "files flipped away from their default" — which was correct while the default was per-file and stable. The repo-wide `collapse_all` moves the default for every file at once, so any refresh crossing the oversized threshold inverted every explicit choice simultaneously: the two files the user had opened snapped shut and the rest sprang open. It now stores absolute intent (`HashMap<String, bool>`), answered before the default is even computed, so nothing about the snapshot can reach it. Covered by a test that asserts an explicitly opened and an explicitly closed file both survive a transition in either direction while an untouched file follows the default. The Changes panel dropped freshly landed snapshots. `install_diff_snapshot` only wrote `right_panel.diff` when the panel was the one waiting, so a probe the overlay started was discarded even when the panel sat on that exact repo — the overlay rendered the new snapshot while the panel kept the old one, same window, same repo. The wait (`diff_pending`) and the data (`diff_cwd`) are now claimed separately: with probes deduped per cwd there is at most one in flight, so there is no out-of-order overwrite to guard against. The oversized banner reported `retained_lines()` as "diff lines", which after the budget fires is what was kept, not what changed — it read "20000 diff lines" directly under a header showing the exact +90000/-0. It now states loaded-of-total and names which cap ate the difference, and lists each axis that tripped the threshold so a big untracked list never reads as a claim that the diff is big. And the Changes panel deep-copied every untracked path String on every frame, on the UI thread, for two `len()`/`is_empty()` reads — the same cost class the `Arc` switch removed from the probe path. 995 tests pass, fmt clean, no new clippy warnings. The app was not built, launched, or driven; visual acceptance is the owner's. Refs #239. * no-mistakes(review): cap Changes panel rows and fix oversized banner truncation notice * no-mistakes(review): drive banner per-file truncation off parser flags * no-mistakes(document): correct changelog cost count and overlay-trigger docs * fix(diff): reconcile the diff-overlay work with the host-aware git refactor The rebase onto main lands this change on top of the remote-workspace work (#235, #242), which moved the git helpers into `tty7-core` and made every read take the pane's `Host`. Adapting rather than papering over: - `sidebar_diff_preview` moves to the core `Config`, where the struct now lives. - The diff and untracked reads go through `git_status::git(host, cwd, ..)`. - The shared probe, its in-flight set, and `install_diff_snapshot` key on (`HostId`, `PathBuf`) — the same path on two machines is two work trees — as does the Changes panel's `diff_pending`. - `diff_click_cwd` became generic over what identifies a repo, so the setting gate did not need to learn about hosts. - Main's newer card rounding reads `truncated`, which is an `Option` here now. The streaming diff read is deliberately absent at this commit: `Host::git` is buffered, so it is restored on top of a streaming host API in the next one. Refs #239. * feat(host): stream git reads whose size scales with the work tree `Host::git` returns a fully-buffered `Output`, and the remote implementation round-trips one over the wire. That is the right shape for the reads tty7 does constantly — `rev-parse`, `symbolic-ref`, `--numstat` — all of which answer in bytes. It is the wrong shape for `git diff HEAD`, whose output scales with the work tree rather than with anything the UI can show: this repository's own `git log -p -n 400` is 8.3 MB, and the diff overlay keeps a small fraction of it. So `Host` grows a second entry point rather than changing the first. `git_lines` delivers the same invocation a line at a time, and its **default implementation buffers** — every host gets it for free, nothing that works today changes shape, and it stays inside the "every git read funnels through the host" invariant instead of becoming a way around it. Overriding it is an optimisation, never a behaviour change: a test asserts the streamed and buffered reads yield identical lines. The local host reads straight off the pipe. The remote host adds `ControlRequest::GitStream`, answered with `ControlEvent::GitChunk` pushes and a terminating `GitEnd` carrying the exit status — the shape `WatchOpen` already proved out. Two protocol details worth the reader's attention: The **client** picks the stream id, which is why the reply carries none. Ids only need to be unique within a connection and a connection has one client, so choosing it client-side lets the receiver be registered *before* the request goes out. A server-assigned id arrives in the reply, leaving a window where a chunk that overtook it reaches a client with no entry for that id and is dropped under the unknown-id rule — silently losing the front of the diff. `WatchOpen` needs a whole deferred-start mechanism to close that window; this does not. The feature is **advertised and checked**, not assumed. A server predating `GitStream` cannot decode the variant, and an undecodable frame ends the connection — so sending it blind would not degrade, it would disconnect. Servers advertise `git-stream`; a client that does not see it uses the buffered `Git`, which is the path every remote pane used before this existed. Covered by a test against a peer advertising only `control` and `host-rpc`. Chunks carry newline-terminated line data batched to ~64 KiB, not verbatim slices of stdout: the server reads through `git_lines` itself, so line content survives exactly while `\r\n` and a missing final terminator are normalised away. The only consumer is line-oriented. Framing per batch rather than per line is what keeps a 90 000-line diff from becoming 90 000 frames. Re-measured on the rebased code, against real git output, release build (the previous figure was taken before the host refactor and no longer holds): buffered: 8 269 409 bytes resident, read 817 ms + parse 12 ms streamed: peak transient chunk 64 KiB, read+parse 557 ms Lower peak memory *and* faster end to end — parsing now overlaps with git producing output instead of waiting for all of it. The earlier synthetic measurement showed streaming costing ~1.7x CPU; that was an artifact of reading a warm page-cached file, where there was nothing to overlap with. The app was not built, launched, or driven; visual acceptance is the owner's. Refs #239. * fix(host): make unsubscribing a watch take effect at the drop, not after it CI's Windows job failed `watch_drop_unsubscribes`: an event for a file created *after* the subscription was dropped still reached a consumer holding a clone of the receiver. Tearing the watcher down is not instantaneous. The OS backend runs its own thread, and on Windows a `ReadDirectoryChangesW` completion can fire during teardown, reach the event closure while `raw_tx` is still alive, and be forwarded by a coalescer that has not yet noticed the disconnect. So "dropped" meant "stops delivering shortly", which is not what the subscription promises — and for a remote host it is the difference between releasing a server-side watch and leaking one. The handle now closes the delivery channel in its own `Drop`, before any of that unwinds. Batches already queued stay readable — `close` stops sends, not receives — which is the one thing a consumer racing its own drop may legitimately still see, and exactly what the conformance test allows for. Not this branch's bug: the change here is to git reads, not watches. But main is flaky in the same family — it failed the sibling `watch_coalesces_within_window` eleven hours ago and was hardened for that one — so this fixes the cause rather than loosening the test. Refs #239. * refactor(host): make the streaming git read part of the protocol Remote workspaces have never shipped a release, so there is no deployed server to negotiate with. The `git-stream` feature flag, the `has_feature` check and the buffered fallback behind it were all guarding against a peer that cannot exist — dead code that would have to be maintained, and read by the next person as evidence that older servers are out there. `ControlRequest::GitStream` is simply part of the control protocol now. Buffered `Host::git` stays exactly as it was, for the many reads that answer in bytes and have no reason to stream. The remote test that proved the fallback becomes one that proves the stream: the peer serves `GitStream`, splits a line across two chunks, and the client reassembles it — the case the reassembly exists for. Refs #239. * no-mistakes(review): fix remote git-stream deadlock, chunk encoding and stray docs * no-mistakes(review): stop git-stream batch growing after a send failure * no-mistakes(document): note git-stream protocol delta in remote-workspace contract doc * fix(host): bound a git stream's wait, its lines, and its concurrency Three ways the streaming git read could still hold or hang more than it should, all found reviewing #239's implementation. A stream is answered by pushes, so neither of the failure paths the rest of the client relies on covers it: the request deadline was satisfied by the immediate `Unit` reply, and keepalive watches the link, which stays up while a server-side git wedges on a network filesystem. The reader parked forever, on one of a small pool of blocking threads, and the diff probe it belonged to never released its per-repo claim — so that repository's overlay and Changes panel were stuck on "Loading…" for the life of the process. `git_lines` now waits `GIT_STREAM_IDLE_TIMEOUT` between chunks. Between, not across: a slow-but-alive read must be allowed to take as long as it takes, which is why a total deadline would be the wrong instrument. Draining moved to `drain_git_stream` so all three exits are reachable from a test without waiting out two minutes. "Incremental" bounded the number of allocations but not the size of any one of them: a line is only complete at its newline, so a work tree with a minified bundle rebuilt the whole-output peak inside `LineSplitter`, on both ends of a remote link and in the server's outgoing batch. Lines are now capped at `MAX_LINE`, and what is cut says so in the line itself rather than silently shortening a rendered diff. That also bounds the server batch, which makes `GIT_STREAM_CHUNK_MAX` a frame backstop rather than the only thing standing between a bundle and a 32 MiB payload. Finally, `GitStream` is the one request that spawns a thread outside the bounded worker pool, so nothing counted them. `MAX_CONCURRENT_GIT_STREAMS` per connection now does, with the slot returned by a guard so a refusal, a failed spawn and a panicking read all give it back — a leaked slot would be a permanent refusal, not a transient one. * fix(diff): stop the overlay re-walking the tree per frame, and re-probe a folded-in refresh Two things the shared-probe work left on the render path. The overlay asks six whole-snapshot questions while building its element tree — oversized, totals, retained lines, budget fired, per-file cap fired, untracked count — and each accessor walked `files` on its own, `oversized` walking the hunks too. `files` is deliberately uncapped (only hunks are), so that was six walks over a list whose length is the size of the working tree, on the UI thread, on exactly the tree this module exists to keep responsive. `DiffSnapshot::stats` answers all six in one pass and the per-question accessors are gone, so nothing can drift from it. Computed rather than stored, because the snapshot is built by hand with `..Default::default()` throughout the tests and a cached count would read as zero for every one of them. Deduping probes per repository is what makes one `git diff` answer every watcher, but a probe describes the tree as it was when it *started*. A refresh triggered after that — a command finished, an agent turn ended — folded into the running probe and was answered with a snapshot already known to be stale, with nothing left to trigger another look: the overlay's own re-check is gated on `loading`, which the landing clears, and the `GitStatusCache` change that would have re-armed it has been spent. A folded-in request is now remembered and re-issued when that probe lands. It converges rather than loops, because a quiet tree never sets the flag. * fix(diff): bound the stream queue, and stop two thresholds answering the wrong question Review follow-ups on the sidebar-diff branch. Five findings, four of them about a bound that was claimed but not held. The remote streaming read bounded both ends and not the middle. The reader thread serves the whole connection, so it cannot wait on a slow consumer — parking it there stalls every other reply and the keepalive with it — and an unbounded queue was the price. That reassembles the whole diff in a channel, which is the peak the buffered read was replaced to avoid, one container further along. The queue is now bounded instead of back-pressured: each chunk is charged to the stream's arrears, the drainer credits them back, and a stream 32 MiB behind is cut loose with an error rather than served. Real back-pressure would need credit-based flow control in the dialect; this is not that, and says so. `oversized` counted untracked paths on its file axis, and collapsing every file body removes no untracked rows — that section has no bodies to fold. A tree with an un-ignored node_modules and three edited files hid the three cheap things, kept the expensive one, and told the reader their working tree was too large to render. The untracked list is bounded where it is built: MAX_UNTRACKED on retention, MAX_RENDERED_FILES on rows. AUTO_COLLAPSE_TOTAL_LINES counted the context lines git prints around every hunk — four to six retained per line actually changed — against a threshold set as if it were reading `+N -N`. It fired on trees whose header said 400. 8000, compared against the 20000 the parser stops retaining at, since collapsing everything is the heavier of the two interventions and should not arrive first by much. A probe that could not run produced an empty file list, which renders as "Working tree clean" — a claim about the repository, made because a read timed out. Newly reachable, too: a stream can be refused or go silent where a buffered read could only arrive or error. DiffSnapshot::read_failed keeps the two apart. Also: StreamStop's doc comment had been glued onto StreamSlot, leaving the enum undocumented and the guard described twice; the overlay header asked totals() beside stats() rather than through it; and the watch-teardown fix riding along on this branch was in neither the PR body nor the CHANGELOG. Tests: the queue budget both ways (a stream that outruns it is cut loose, a larger one that is drained is not), the untracked axis, an ordinary context-heavy afternoon sized to fail against the old threshold and pass against the new, and empty-because-broken against empty-because-clean. Each was checked to fail against the behaviour it replaces. 1603 pass, 0 fail; fmt clean; no new clippy warnings in the touched files. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.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>
|
||
|
|
183b5e4950 |
fix(terminal): detect links across wrapped and hard-newline rows (#258)
* fix(terminal): detect links across wrapped and hard-newline rows A URL that spans terminal rows was truncated at the first row edge, so Cmd-hover underline and Cmd-click only saw the first line. Resolve links over the joined logical line instead: soft-wrapped rows were already stitched, and a new hard-wrap bridge joins rows a program split with a literal newline when the row is full to the edge with a link char that continues into the next row. Double-click smart-select keeps its word boundaries and never bridges. HoveredLink now spans grid points across rows and the underline paints every covered cell. * fix(terminal): never bridge a hard newline into a URL authority The hard-wrap bridge joins two rows when the first is filled to the right edge with a link char and the next opens with one. A hard newline carries no signal about whether the producer split a URL, so a *complete* URL ending exactly at the right edge is bridged onto the next row's first token (`.../a` + `README.md` resolves as `.../aREADME.md`). That false positive is accepted: the head of a genuinely split URL is itself a valid URL, so there is no reliable test to tell the two apart, and the address bar shows the mistake. The same accident promoting the *second* row to the authority is not acceptable. `https://good.com` + `@evil.com/x` parses as userinfo per RFC 3986, so the real host becomes `evil.com` while the hover underline still reads `good.com` — a phishing hop wearing a trusted label. Refuse to bridge into a `@`. Soft wraps are unaffected: there the terminal folded one logical line, so the continuation is certain and a userinfo URL must still resolve whole. Also drop a dead `c != ' '` guard (`is_url_char(' ')` is already false) and apply rustfmt, which CI enforces as a required check. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
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> |
||
|
|
7194236985 |
fix(terminal): prevent fullwidth punctuation overlap and speed OSC mark scanning (#250)
* fix(terminal): stop wide glyphs overlapping after fullwidth punctuation gpui's apply_force_width_to_layout tells a base glyph from a zero-advance combining mark by whether the shaped x advanced past half the forced width, and CJK fullwidth punctuation fails that test (U+FF08 advances ~0.47 em against a 0.6 em half-slot). In a batched wide run the glyph after such a character was classified as a mark and painted on top of it. Shape each wide glyph on its own line instead: the first glyph of a line is unconditionally a base, so the heuristic never misfires. * perf(terminal): intern wide-segment strings via char_string Each wide glyph now shapes alone, so its text is a single-char string — reuse the char_string memo instead of allocating a fresh String per cell per frame. The interned SharedString is also what keys gpui's line layout cache, so a CJK-dense repaint allocates nothing. * perf(terminal): skip MarkScanner's Text state ahead with SIMD memchr The scanner runs over every batch the client receives, and ordinary output — where the only byte that matters is ESC — dominates each one. Skip to the next ESC with memchr instead of stepping per byte, exactly as tty7-core's OscTokenizer already does: measured on an 8 MB batch of plausible output, 1.6 GB/s became 8.3 GB/s. Declare memchr for the root crate — it left with the OSC tokenizer's move down to tty7-core, and this is the first use since. * fix(terminal): advance segment_row past each wide glyph The unbatching change dropped the `col += 2` along with the batching loop it lived in, so the wide-glyph arm pushed its segment and looped on the same column forever, growing `segs` until allocation failed — the 6 GiB abort on the Windows CI runner, and a machine-freezing memory climb under a local `cargo test`. --------- Co-authored-by: lizhi <lizhi20@xiaomi.com> Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Co-authored-by: l0ng-ai <ysdpk123@gmail.com> |
||
|
|
148cb2f82a |
fix(linux): set window icon and application ID (#254)
Set app_id "tty7" on every platform (WM_CLASS on X11, desktop-entry match on Wayland) and attach a 256px _NET_WM_ICON to X11 windows. macOS and Windows already get their icons from the bundle / exe resource. |
||
|
|
f660ddb1cc |
fix(ui): safe delete prompt default, platform-aware reveal labels, and searchable agent settings (#255)
* fix(ui): make Cancel the default button on the file-tree delete confirmation
The file tree's delete prompt was the only destructive prompt in tty7 with
the destructive action first. On macOS (NSAlert) and Windows (TaskDialog)
the first button is the Return-key default, so pressing Return deleted -
including recursive folder deletion. Linux uses gpui's fallback renderer,
which is click-only, so the swap only reorders the buttons there.
Safe option first, matching every other destructive prompt; the literal
"Cancel" is what gpui maps to PromptButton::Cancel and the Escape key.
* fix(ui): stop saying "Finder" on Linux and Windows
The file-tree context menu and the SFTP job tooltip hardcoded
Finder-flavoured labels; only the right panel's Info row was
platform-conditional. Extract that conditional into
right_panel::reveal_label() and use it at all three sites, so the action
reads "Reveal in Finder" on macOS and "Open Folder" elsewhere.
Side effect of sharing the helper: the SFTP tooltip's "Show in Finder"
becomes "Reveal in Finder" on macOS, converging a third name for the
same action.
* fix(settings): index the Grok Build agent row and re-align drifted index titles
The Grok Build agent renders a settings row but had no search-index
entry, so the row was unreachable by search. The other five agent
entries carried mechanism suffixes ("Claude Code hooks", "Pi
extension") that no longer match the rendered rows, which are titled by
HookAgent::display_name(); the index said "Option acts as Meta" where
the row says "Option (⌥) acts as Meta".
Align the titles with the rows, keep the mechanism words as search
keywords, and extend the index tests: the pinned-title list gains the
Option row, and a new test derives the Agents entries from
HookAgent::ALL so adding or renaming an agent without updating the index
fails the suite.
* no-mistakes(review): add plain search keywords for Option-acts-as-Meta entry
* no-mistakes(document): document copy fixes in changelog and apply rustfmt
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
|
||
|
|
6f842c3007 |
fix(file-tree): stop a watcher event repainting a window with nothing to draw (#249)
* fix(file-tree): stop a watcher event repainting a window with nothing to draw
Issue #243 made two claims. The flicker was fixed independently on main by
|
||
|
|
9c00648875 |
fix(ui): keep the window-drag arm alive across a repaint, and make every header draggable (#252)
* fix(ui): make every header draggable, and keep the arm alive across a repaint (#221)
Two changes to the same code, which is why they land together.
Five rows that stand in for the title bar — the tab rail's top zone, the
settings page's top strip, the detail panel's top zone, and the code and
diff overlays' headers — armed their drag with an `Rc<Cell<bool>>`
allocated inside the render function. A redraw between the press and the
first drag event handed the next frame's listeners a fresh, zeroed cell
while the press had written to the old one, so the whole hold was dead
until you released and tried again.
The press itself schedules that redraw: these rows carry `on_double_click`,
and gpui calls `window.refresh()` on mouse-down for any element with a
click listener. So a drag only survived if the first move beat the next
vsync — 16ms at 60Hz, 8ms on ProMotion. A mouse press physically nudges the
pointer and often won that race; a trackpad press is a finger pushing down
without translating, and almost never did. That is the trackpad-vs-mouse
split the issue reports. The terminal's cursor blink (a 530ms `cx.notify()`
loop) disarms it on its own even with no press at all.
`window_move_gesture` now holds the flag in `window.use_keyed_state`, which
survives frames — where gpui-component's own `TitleBar` has always kept it,
and why the ordinary caption strip was never affected. Keyed rather than
`use_state` because one builder serves several call sites and `use_state`'s
`CodeLocation` id would collide when two of these rows are on screen at
once (the rail's top zone plus an overlay header is a real combination).
A longer-lived flag has to be cleared explicitly, so releasing outside the
row disarms too; with a per-frame cell the frame boundary did that for free.
Nothing else about these rows changes — same hit boxes, same geometry, same
`WindowControlArea::Drag`, same double-click.
Grabbing the window by a header is a property of the whole app, not a
per-surface feature, so a user never has to learn which rows are draggable.
Written down beside `window_move_gesture`, along with the two things it
takes beyond arming the gesture: non-controls inside a header take no hit
box (the rule #202 set for the "duo" mark, so the drag falls through them),
and a header whose contents *do* take hit boxes by design needs a floor on
its flexible spacer.
- `panel_title` — the detail panel's section header, shared by Info,
Outline, Changes, Files and the remote Files browser — is draggable now.
Its one un-`occlude()`d control (SFTP's refresh tile) gains the wrapper
every control on a drag row needs, or Windows' HTCAPTION eats its clicks.
- The horizontal tab strip keeps a bare 80px slice of caption. Its spacer
was a `flex_1` with no minimum, so it collapsed to exactly 0px once the
chips saturated the row (~7-8 tabs on a 1440px window), leaving only three
6px gaps and a hairline above and below the chips to grab — the "the
region that works seems very small" half of the report. The chip row's
fixed-chrome reserve is corrected to match: a stale flat 100px, sized when
the corner held a 30px "+" and a 30px "⋯", becomes the ~137px the corner
actually occupies plus the handle. Chips reach their minimum width and
truncate a tab or two sooner, and the window is always grabbable.
- The rail's top-zone spacer gains the same floor.
`ui::app::window_drag_tests` drives the real `title_bar_drag` row through
gpui's test platform, where `start_window_move` is `unimplemented!()` and a
panic is therefore a reliable "the window would have moved" detector. It
pins the invariant (press → repaint → move still drags), that a press alone
does not, that a release disarms, and that two rows on screen keep separate
arms. A control test keeps the old per-frame-cell pattern alongside and
asserts it still loses the drag to the identical event sequence — without
it, the invariant test could pass for the wrong reason.
* no-mistakes(review): occlude resize handles; correct chip-reserve arithmetic
* no-mistakes(document): reorder changelog sections; record non-draggable header exclusions
* no-mistakes(document): make panel grab-handle docs version-neutral and platform-accurate
* no-mistakes(document): make workspace_head panel-width doc version-neutral
* docs(changelog): re-file Unreleased entries after the rebase onto main
The rebase onto
|
||
|
|
12d8cf549b |
fix(ci): read the AppImage version from [workspace.package]
The crate split left the root manifest leading with `version.workspace = true`, so `grep -m1 '^version'` returned that line verbatim and the sed fell through unchanged. Every AppImage since was named `tty7-version.workspace = true-linux-x86_64.AppImage`, which broke the nightly publish job at `sha256sum -c` — the space-split name resolved to three missing files. bundle-linux.sh, bundle-macos.sh and bundle-windows.ps1 were already anchored on `= "`; this was the one that got missed, and the only one without a guard to catch the miss. |
||
|
|
64403cf860 |
feat(terminal): expose the prompt editor's soft newline as a rebindable InsertNewline action (#246)
* feat(keymap): make the prompt editor's soft newline a bindable action
Shift+Enter and Opt/Alt+Enter have inserted a literal newline into the
command editor since the multi-line prompt editor landed in
|
||
|
|
1f09a62568 |
fix(ui): give corner-filling children their own radius (#236) (#244)
* fix(ui): stop filled children squaring off rounded corners The cursor-shape toggles (Block / Bar / Underline) reported in #236 look rough because the selected segment's fill covers the whole corner of the track it caps, and its outer edge is a hard, unantialiased vertical cut. The track's own border arc is drawn correctly and antialiased — it just floats *inside* that square, so the corner reads as a stair-step. The controls were relying on `overflow_hidden` to shape their end segments' fills to the track's rounding. It cannot do that. `gpui::ContentMask` is a bare axis-aligned `Bounds`; `Style::overflow_mask` builds it from the element's bounds shrunk by the border widths and drops `corner_radii` entirely, and every shader applies it as a hard `clip_distances < 0` discard. So the mask only ever cuts a square, and it never antialiases the cut. A container's own corners come from somewhere else — the quad shader's SDF, `saturate(0.5 - distance)` — which is why a plain rounded card renders smooth while anything with a filled child in its corner does not. That divergence is the whole bug, and the reporter's screenshot shows both halves of it: the corner with the selected fill is square, the corner without one is a clean arc. The fill has to carry the radius itself, so it goes down the SDF path too. It sits one border-width inside the track, so the concentric radius is `outer - border`. `ui::rounding` states that rule once, with the constants and the corner-assignment helpers, and unit-tests the invariants (inset is strictly tighter than the outer radius, clamps at zero, only the end segments cap the track). Applied to every place a child paints a fill into a rounded corner: * the segmented controls (the reported one, plus the others `segmented` serves), * the −/value/+ steppers' hover fills — those glyph boxes also had to be pinned to the track's content height, because a padded auto-height box measures 31px against a 22px content box and its rounded corner would land 4½px outside the visible strip, * the theme picker's flush-mounted previews, * the diff overlay's card headers and the row that closes a card. Not reproducible locally: this is a rendering-geometry defect, not a platform one, but it is most visible at a device pixel ratio of 1, where the clip's hard edge is a whole physical pixel. Verified by reading the gpui mask/shader source and the reporter's screenshot pixel by pixel, and by the geometry tests; the on-screen result is left for visual acceptance. Refs #236 * no-mistakes(review): round diff card header when body is empty * no-mistakes(document): point Unreleased changelog link at v26.7.6 * no-mistakes(document): untrack AGENTS.md per gitignore dev-tool convention --------- 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> |
||
|
|
a88a8b6515 |
test(host): bound watch coalescing by elapsed windows, not a constant
The burst case asserted at most 4 batches for 50 writes, which really asserts the machine finishes 50 writes inside 400ms. A loaded Linux runner spread the burst over ~1.4s, so the coalescer correctly emitted one batch per 100ms window and the test went red on main. Measure how long the burst actually took and allow ceil(burst / WATCH_COALESCE_WINDOW) + 2 batches. A host that stops coalescing still fails: dropping the window to 0ms turns the case red. |
||
|
|
9246340c43 |
Merge pull request #242 from l0ng-ai/feat/ssh-settings-redesign
refactor(ssh): rebuild Settings → SSH as a host library |
||
|
|
c3cfeb5daa | Merge remote-tracking branch 'origin/main' into feat/ssh-settings-redesign | ||
|
|
ed4dbee8e3 |
Merge pull request #235 from l0ng-ai/feat/remote-workspace
feat(remote): remote workspaces |
||
|
|
816a45cc17 |
fix(ci): build the unrepresentable-path case on Windows too
The `to_frame` guard reached for a Latin-1 filename through `os::unix::ffi`, in a test module that is not Unix-only. It now asks each platform for a path it accepts and `str` cannot hold — Latin-1 bytes on Unix, an unpaired surrogate on Windows — which compiles there and, more to the point, actually asserts the refusal on both. |
||
|
|
6f9324727c |
Merge remote-tracking branch 'origin/main' into feat/remote-workspace
The Pi extension conflicted: main gave it the session-id forwarding doc, this branch had already moved it from `current_exe` to the target machine's binary. Both kept. `a_detached_result_lands_after_its_view_is_dropped` now pumps to a deadline instead of parking once — a `Host` call runs on `HostOps`' own pool rather than gpui's executor, so there is nothing for `run_until_parked` to wait on until the answer has already crossed back. |
||
|
|
54cf9f2a8f |
fix(ui): keep blocking host work off the UI thread and off gpui's pool
Five findings from review, all about where blocking work runs and what a stale handle is still pointing at. - `live_pane_count` ran a routed `List` — an SSH handshake, and on a WSL route as far as installing the server — straight from the Stop/Delete action handler. That is `guard_off_ui`'s debug abort in a dev build and a frozen window in a release one. It is now split into a UI-thread read and a background count, with the prompt raised through the window handle afterwards. - `teardown_workspace_forwards` blocked the UI thread on a daemon reply that waits for the SSH server to acknowledge `cancel_tcpip_forward`. On a machine that has gone unreachable — exactly when someone reaches for Stop Workspace — it never came. Backgrounded, and `on_workspace` now sets a read timeout so the thread is not parked forever either. - The file tree's and editor's watch subscriptions had no record of which host opened them. A reconnect inserts a fresh `RemoteHost` under the same `HostId`, so `set_dirs` failed on a dead `ControlClient`, was warned and dropped, and nothing opened a new one: after the first reconnect the tree stopped seeing remote changes for the life of the window, and the editor's external-change detection — what stops a save clobbering someone else's edit — was silently off. Both now compare the host by pointer and reopen when it differs. - Closing a remote window that was empty *because its machine could not be reached* deleted the workspace: its `RemoteRef`, cached layout and geometry, while its panes were still running over there. Only a machine that answered licenses dropping the entry. - `HostOps` ran blocking calls on gpui's background executor, which on Linux is a fixed pool with no blocking tier. Four stalled host calls on a four-core client took every worker, including the one the reconnect needed to clear the stall. They now run on their own elastic pool. |
||
|
|
4486ee6849 |
fix(control): spawn on backlog, and hold a watch until its id is out
Two scheduling bugs on the server side of a connection. The pool asked `idle == 0` before spawning, but `idle` counts a worker from before it parks until after it has re-acquired the lock on its way out — so through the whole wake-up window a worker already handed a job still looked free, and the second `notify_one` in that window went to a thread that had left the wait set. A client pipelining k+1 frames onto k parked workers left the last one queued behind a `git status`. The rule is now "more queued than parked", which counts both sides of the window and cancels it out. `WatchOpen` started its forwarder before the reply carrying the watch id was written, and both go through the same sink. A directory that changed in that instant could push a batch the client dropped — it files the id only once `call` returns — and since the tree relists only on a watch event, that change stayed invisible. The forwarder is now parked and started by `finish`, and dropped outright when no reply went out, so a cancelled `WatchOpen` no longer leaves an OS watch behind either. |
||
|
|
0ec8e050ad |
fix(workspaces): make a handover atomic and stop two stores clobbering one file
The takeover moves two things — the `WorkspaceStore`'s record and the server's `AttachRegistry` handle — and each was internally locked, which is not the same as the pair moving together. Two clients attaching one workspace at the same instant could each win a different table, after which the store named a session the registry had already evicted and no `detach` could clear it: the workspace reported a takeover against a client that had disconnected hours ago. Both moves now happen under one handover lock, dropped before the displaced client is written to so a peer that has stopped reading still cannot hold up the next attach. `WorkspaceDelete` had the same split with no race needed at all: the store dropped its attachment and the registry kept its handle, so the next client to attach that id evicted a session nobody displaced — and, that entry being dedicated, closed its whole link. Two `tty7-server --stdio` sessions arriving while no daemon was up each served in-process, each with its own store over the one file. `persist` writes the whole document, so the second to save silently dropped the first's changes, and their separate registries made takeover a no-op between them. The probe path now starts the daemon and bridges to it — the rule `bridge_panes` already follows one dialect over — and the store re-reads when the file has moved underneath it, which covers the cases where two writers are deliberate. `MAX_RECORD_BYTES` and `MAX_WORKSPACES` did not bound their product: seventeen maximal records put the array past `MAX_FRAME`, after which every `WorkspaceList` was unencodable and every client showed an empty list. The document is now bounded at the save, and only when growing, so an over-large file can still be deleted back under the limit. |
||
|
|
ba5b29818d |
fix(control): stop a reply that cannot be encoded from reading as a hang
Six findings from review, all in the same seam — what happens when a message cannot go on the wire, and who is allowed to conclude the link is dead from that. - `Sink`/`ControlClient::send` encode before writing, so 'this message cannot be serialized' and 'this link failed' are distinguishable. Only the second can have put bytes out, and only the second is grounds for marking the connection dead — the client used to go `Reconnecting` over an oversize `WriteFile` the server never saw. - `Conn::finish` answers an unencodable reply with the error instead of dropping it. Dropping left the client waiting out the request's whole deadline for a reply that was never coming. - `Search` drops the hits whose paths are not UTF-8 rather than letting one Latin-1 filename make the whole reply unencodable. `SearchHit` is the only `PathBuf` on this wire; serde refuses such a path outright. - The control socket is bound under a tightened umask. `bind` creates the node at `0777 & ~umask` and the `chmod` was a window — under `umask 002` a group-connectable one, onto unauthenticated `ReadFile`. - Binding no longer re-permissions a directory it did not create. With `$TTY7_CONTROL_SOCK` or the hashed fallback the parent can be `/tmp`, and 0700 there locks every other user out of it. - A blob is filed under the `pending` lock, so a caller timing out in the gap cannot leave a whole file's contents in the side table for the life of the connection. |
||
|
|
6d70939824 |
feat(agents): make Pi a first-class agent with icon and resume (#240)
* feat(agents): make Pi a first-class agent, not a fallback one
Pi panes drew the generic robot glyph every unbranded agent shares, so a
Pi tab was indistinguishable from an Aider or Qwen one in the sidebar,
the tab chip and the tray menu (#225). Auditing the rest of the registry
turned up two more places Pi was on a default rather than handled.
The avatar. Repo practice, from the most recent addition (
|
||
|
|
ff76c01ac7 |
fix(ci): pick mtime nanoseconds a Windows SystemTime can hold
A Windows `SystemTime` is a FILETIME, whose tick is 100ns, so `UNIX_EPOCH + Duration::new(_, 123_456_789)` came back as `123_456_700` and the assertion failed on a rounding this conversion never saw. Every nanosecond figure in the case is now a multiple of 100, which still exercises the full nanos field. |
||
|
|
26f3a73f58 |
fix(ci): gate the tty7-server test suites that need --stdio on Unix
`--stdio` is refused on Windows by design, and the control socket it probes for is Unix-domain, so `stdio_conformance` and `workspace_store` join `remote_router`/`routed_pane` in carrying a file-level `cfg(unix)`. `cli.rs` keeps its argument-handling cases everywhere — `--version`, `--help`, `agent-hook` and the usage error say nothing about transports — and gates only the bridge and probe cases, which spawn a `--stdio` child or stand up a listener. |
||
|
|
0fba41a0e5 |
fix(ci): keep the control-server tests off Windows
Every test in `host::server`'s test module talks over a `UnixStream` pair, so the module needs the same `unix` gate the `tty7-server` integration tests already carry — without it the Windows leg of `build & test` fails to compile `tty7-core`'s lib test with 19 errors. The three pool tests are plain threads and channels, so they move to their own module rather than being gated away from a platform they work on. |
||
|
|
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()`.
|
||
|
|
f0cfb47b06 |
refactor(ssh): rebuild Settings → SSH as a host library
The section was a field editor wearing a list's clothes: the widest column on the page said "Select a profile to edit" and nothing else, the global security toggles hung underneath whichever host happened to be open, and port forwards had to be typed as `L bind:port target:port` into a text area that taught the syntax nowhere and silently dropped any line it couldn't parse. The master column now leads with a title and a filter — past a dozen hosts, finding one is the job — with Add and the `~/.ssh/config` import demoted to icon affordances beside it. Hosts bucket by group into collapsible sections; a collapsed header keeps showing how many of its hosts are connected, and collapsing the group holding the selection hands the detail pane back to Defaults rather than stranding a selected row nobody can see. A live query force-expands every group. `Defaults` is now a pinned row with its own page, so "every host starts from these and can override one" is something the list's shape says. Each per-host override names the value it currently inherits instead of claiming a mechanism exists. A selected host leads with its own name, address, jump chain and live status, then Save (disabled with nothing to write) and Connect. Save leaves the form open — with the list permanently beside it, closing back to an empty pane read as the selection being thrown away. Port forwards are one row of inputs per rule, and a rule that can't connect is flagged in place rather than dropped on save. With nothing selected the pane offers quick connect and, when `~/.ssh/config` holds aliases tty7 hasn't linked, an offer to link them. |
||
|
|
c469e10312 |
Merge remote-tracking branch 'origin/main' into feat/remote-workspace
# Conflicts: # Cargo.lock # Cargo.toml # src/core/config.rs # src/ui/pane.rs |
||
|
|
5f77c40f96 |
feat(agents): install agent hooks onto the machine that runs the agent
Hook installation was written for one machine — this one — and a remote workspace runs its agents on the far side of the connection, where none of that is true: a different `$HOME`, a different filesystem separator, and a `tty7` binary at a path this client published rather than the one it is running from. `HookTarget` is that machine, borrowed for the length of one background task: `local` resolves our own home and binary, `remote` takes the home a handshake reported and the `tty7-server-<version>` this client installed there. Every path the installer builds now goes through it, via `Host::join` rather than `PathBuf::join` — a Windows client installing onto a Linux box was writing `/home/me\.claude`. The three things that are only true locally (our own environment variables, atomic writes, running the `codex` CLI) are gated on `is_local` instead of assumed. Settings grows a machine picker so the page states which one it is acting on. Also in this commit, three unrelated UI fixes: - The settings sidebar's search placeholder sat 6px right of every nav label under it — a `small` (14px) magnifier where the rows use 16, a 4px gap where they use 8, and an `Input` that adds `input_px` (12px at the default size) whether or not it draws a box. All three corrected, so the placeholder starts on the rows' 32px text column. - The "'X' is still running — reopen it from the workspace menu" toast is gone, along with the `workspace_detach_hint_seen` flag that existed only to show it once. Detaching a workspace is what ⌘W has always done here and the Window menu already lists what came off screen; a one-time lecture on top of that is noise. Old configs carrying the key still load — `Config` doesn't deny unknown fields. |
||
|
|
8239b298a9 |
revert(panel): put the detail panel's tab tiles back at chrome scale
|
||
|
|
9a34576877 |
fix(theme): split the selection ladder into a resting and a cursor rung
Folding three signed-off values into one `SELECTED` target is what made the chrome shout. The old ladder had `hover` 0.09, `sidebar_sel` 0.12 and `list_active` 0.17; the ratio rewrite kept only the loudest of them and pointed every selected fill in the app at it. On the Light theme the rail's selected row went from #E2E2E2 to #C0C0C0 — a silver slab at twice the perceived step it had ever had — and it took the switch tracks, the input grounds, the plain buttons' active state and every chrome tile's *hover* (which paints the sidebar's selected grey by design) with it. The two jobs were never the same: - A resting selection — a rail row, a lit toggle, a switch track — sits there all session next to unselected siblings. It stays quiet and leans on the text channel, which is what `Surface` carries both for. - A cursor — the one menu row under the pointer, the palette's keyboard row — is transient and alone on its surface, and the eye is already tracking it. It gets the loud rung. So `SELECTED` drops to 1.30:1 and a new `CURSOR` takes 1.70:1, each anchored to the Dracula value it was signed off at (`mix(bg, fg, 0.12)` and `0.17` respectively). Dracula lands within 1.02:1 of both old greys, and every other theme is pulled onto the same two perceived steps instead of the same one. `PRESSED` follows `SELECTED` down to 1.55:1 — it is "one step past selected", not a fixed distance from the surface. Only `accent` (gpui-component's name for a menu row highlight) and `list_active` move to the new rung; everything else was already asking for a resting selection and now gets one. The per-surface ladder test drops its floors to match and gains a cursor-vs-selected assertion, so the two rungs can't quietly re-merge. |
||
|
|
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. |
||
|
|
1996f2ae12 | chore(release): v26.7.6 v26.7.6 | ||
|
|
540255e6a1 |
refactor(panel): give the detail panel's top zone back to the panel
The panel's title-bar-height top zone carried seven controls belonging to three different layers: four panel tabs, the panel toggle, the "..." menu, and the workspace chip. All seven at chrome scale, in a column the user can drag down to MIN_WIDTH. The row wants 268px there — it overflows by 68px before anything else goes wrong. The layers are the real defect; the crowding is a symptom. Switching to Files is "what does this panel show", the workspace chip is "which machine am I on", and they sat side by side, same size, same weight. Three changes, none of which touch the window layout — both columns stay full-height and the corner controls stay where they are: - The workspace chip moves to the head of the rail, as a full row with the workspace's name rather than a monogram in a corner. The rail below it enumerates that workspace's tabs, so this is where the name belongs; it also stops a window-scoped control from competing for the panel's width. Note it is *not* folded into the repo group headers under it — those are repositories, and one workspace holds several. - The panel's tab tiles drop to TILE_SIZE_SM. That constant exists for tiles inside a panel, which is exactly what these are, and the one size step separates them from the window chrome sharing their row without spending a divider on it. - On macOS panel_title draws nothing. The tile row above it already says which tab you are on, and so does the content — a file tree is Files, a diff is Changes. It cost a whole row: tiles, then a title, then a search box, before one line of content. Changes' file count moves into that tab's tooltip, where it stays readable without switching tabs. Outline's count does not survive: it needs the active leaf, which needs a &Window right_panel_tabs has no reason to take, and its list is one click away. A tab passing `trailing` still gets the row; none currently do. Off macOS nothing changes: the panel hangs below a spanning title bar there, and panel_title is still that panel's tab switcher. The top zone now needs ~164px against a 200px minimum. |
||
|
|
6888fd2bc8 |
Merge pull request #238 from l0ng-ai/chore/dedupe-resvg-component
chore: finish the resvg dedupe — bump gpui-component to 0.47 |
||
|
|
c2da286a7f |
docs(changelog): reference the dedupe PR number
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |