mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-25 00:02:29 +00:00
v26.9.1
807
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
31ce382226 |
revert: drop projects as a declared sidebar layer (#769)
Reverts
|
||
|
|
e98586bdbd |
fix(remote): keep the reason a remote server failed to start (#774) (#777)
A remote workspace could sit in a loop nobody could get out of: every reconnect failed with "started but nothing was answering on the control socket after 15s", the strip showed a copy bar frozen at 100%, and no button was offered. The daemon is the root of it. When its control listener would not open it logged one line and kept running — and a running daemon holds the single-server lock, so every later --daemon stood down at once and every client probe failed, forever. Whether something else is serving cannot be read off the errno: bind_control_socket clears the leftovers it can, but a path it cannot clear comes back AddrInUse in the same words a live server does. Ask by connecting, and exit when nothing answers. The reason was thrown away twice over: the daemon's stdout and stderr went to /dev/null, and the readiness probe kept only out.success(). Both are kept now — output and exit status land beside the binary, stamped with the launch's own nonce so a restart never reads the outgoing daemon's status as the incoming one's. A start that has already failed no longer waits out the full timeout. The UI half: an automatic reconnect never retired its install progress, and a leftover entry draws an install in flight instead of the failure and its button. And a long error stretched the status card to 1978px in a 1440px window, taking the retry button off the screen with it. Closes part of #774. The Vim :wq cursor and the btop re-attach items in that issue are not touched. Claude-Session: https://claude.ai/code/session_015q6HRem76HYy33T39bp34c |
||
|
|
cebd871cb4 |
feat(sidebar): add projects as a declared layer beside the derived groups (#769)
* feat(sidebar): add projects as a declared layer beside the derived groups The sidebar's repo groups are derived: a group's identity is a path recomputed every frame from a leaf's cwd, it appears when a tab lands in it and vanishes with its last tab. That layer cannot carry a name of its own, cannot be created before a tab is opened in it, and orphans anything keyed to it when a directory is renamed or moved. Add a Project as a real entity on the workspace — an id, an optional name, a root — and an optional reference to one on each tab. Nothing probes it: a tab joins a project only by an explicit action, and a tab that leaves one lands back in the group the probe would have put it in, so declaration and inference never disagree and no third membership state is needed. The derived grouping, the cwd probe, the write-back and the SidebarGrouping config are untouched; the only difference is the tab list they are fed. A server that predates the projects feature ignores the new array and serves today's sidebar. Closes #756 * fix(sidebar): stop a searched row claiming a chord it does not own A live search deliberately ignores a folded heading — the query is asking about tabs — so the rail draws rows the chord order has taken out. The badge was read from a `Vec<usize>` that started at zero, so every one of those rows claimed ⌘1 while ⌘1 opened something else. It is `Option<usize>` now, built by `badge_positions` off the same order `activate_visual` walks, and a row the order left out wears no badge at all. Also in the rail: the block loop reads "declared" off the section key rather than the position it happens to sit at, and an unreachable `continue` for a folded empty derived block is gone — `sidebar_sections` never makes one. Projects: - `MAX_PROJECTS` is held on the window side too. The machine refuses past it and a refusal resynchronizes, which would re-push the project this window kept and be refused again. Checked before the folder panel opens, so a full workspace says so before asking for a folder rather than after. - `set_project_root` keeps the one-project-per-directory rule `declare_project` holds on the way in; pointing one project at another's folder reached the two-headers-that-mean-the-same-thing state by the back door. - Opening a rename box over one already on another project commits it instead of dropping it with its subscription, which threw the typing away. Sync: - Project reordering moves after `retire_projects`. `to` indexes the machine's whole list, so a project on its way out pushed the survivors along and spelled a move for one already in place. - `adopt_projects` reports whether it changed anything and the callers repaint when it did; it was mutating the window's list with nothing to notify. - `migrate_panes` gets its doc comment back — `reconcile_projects` had been inserted between it and the comment describing it. Dead `L10nKey::ProjectNew` removed: translated four times, used nowhere. * fix(control): move the dialect to v8 for the project verbs `CONTROL_VERSION`'s own doc says to move it whenever a variant is added to `ControlRequest`, `ReplyOk` or `ControlEvent`, and says why the feature strings are not a substitute: they cover what a peer can safely ignore — a field added to a message it already decodes — while a variant it has never heard of fails to decode and takes the whole link down with it. The project verbs shipped behind a `projects` feature string instead. That gates what a client *sends*, so a v7 server never saw a verb it could not read, but nothing gates what a server *pushes*: a v7 client meeting a v8 server that had grown a project would take the `ProjectCreated` delta, fail to decode the frame, and lose the link — `read_until_closed` calls `fail_all` on any decode error. Only the number can turn that pairing away at the handshake. So the number moves and the feature goes. It was redundant even for the direction it did cover: `MACHINE_TREE` and `PROJECTS` were pushed under the same `services.machine.is_some()`, so within one build they were always equal and only a cross-version pairing could tell them apart — which is exactly what v8 now refuses at the handshake. Keeping both would be two mechanisms for one job, and the weaker one silently covering half the problem. Removed with it: `is_project_op` and the `pump` filter it fed. Disk compatibility is a separate axis and is untouched — `Workspace::projects` and `Tab::project` keep their `serde(default)`, and the test that reads a tree written before either still passes. Remote workspaces need their `tty7-server` pushed before they will connect. That is the dialect-refusal path v7 was minted to make reachable: the parked strip and its Update Server button. Also: the two sidebar `+` buttons now fade in on their own heading's hover rather than the whole rail's, so a control appears where the pointer is. |
||
|
|
feeb6897ff |
fix(theme): give a pane divider its own, lighter weight (#771)
One hairline value served every line in the app: the outline that closes a menu, tooltip or card floating over other content, the rule under a header, and the seam where the sidebar meets the terminal. Those are not the same job. The first two are the only thing saying where an edge is; the last runs between two panes that already carry their own fills, so painting it at full weight makes a workspace read as boxes bolted together instead of one surface. Split the derivation into two tiers off the same blend. `border` keeps the 1.5:1 floor for outlines and in-pane rules; `divider` takes 1.2:1 and feeds `sidebar_border`, which is already used at exactly the six pane seams that want it — the tab sidebar, the right panel, the document column, and the two workspace edges in `app.rs`. On the default light theme that moves the seam from #c8c8c8 to #dfdfdf and leaves every popover outline where it was. `right_panel`'s rule under the tab row goes back to `border`: same fill above and below, so the line is carrying the separation alone. Worth stating because the code hid it: the `mix(bg, fg, 0.16)` seed clears neither floor in any builtin theme, so both values are decided entirely by the constants. Lowering the seed changes nothing — that is now in the comment, and `DIVIDER_FLOOR` is the knob if the light tier turns out too faint. Claude-Session: https://claude.ai/code/session_01GAjHNse9BDu5jSCjU5QTKe |
||
|
|
2584efa28e |
fix(terminal): recall the last matching command on ↑ and Ctrl+P (#768)
* fix(terminal): recall the last matching command on ↑ and Ctrl+P The prompt editor walked history in file-load order and ignored the prefix on the line, so the first press showed whatever had been concatenated last — often an old tty7 record, or a command that had nothing to do with what was already typed. Keep the prefix from when navigation started, the way zsh's up-line-or-beginning-search does, and order merged history files by timestamp. * fix(terminal): search history on the text left of the cursor up-line-or-beginning-search matches on $BUFFER[1,CURSOR], not on the whole line, so Ctrl+A followed by UP has to walk every entry rather than filter on text the user is about to type in front of. Keep the search prefix and the line stashed for DOWN in separate fields: restoring what was typed still needs the part sitting right of the cursor. Also cover the borrowed-mtime path, which had no test: untimestamped bash lines must take the file mtime, keep a real timestamp when they have one, and survive an unreadable mtime untouched. Claude-Session: https://claude.ai/code/session_01GAjHNse9BDu5jSCjU5QTKe --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
e231b16fb3 |
feat(ssh): allow remote image clipboard writes (#766)
* feat(ssh): allow remote image clipboard writes * fix(ssh): keep a profile's clipboard grant across a re-attach A native ssh pane's OSC 5522 permission is decided by the spec that dialled the host, and the daemon is the only side that holds it. A window reopening onto a pane that outlived it attaches by pane id, has no spec to read, and sends `allow_remote_clipboard_write: false` — which the daemon took as the new answer and the pane's own view took as a refusal. Both sides then said no, so the first restart after switching the permission on turned every copy into an `EPERM` with the switch still reading "on". Pin the spec's answer in the pane and route both attach and detach through one decision point, so a pane that carries a spec keeps that spec's answer whatever an attaching client claims, and a pane without one — everything on a remote `tty7-server` — is exactly as permitted as its controller says. On the client side, refuse only what the pane can see is forbidden and leave the verdict to the daemon otherwise. Also: release a failed transfer's buffered bytes instead of parking up to `MAX_CLIPBOARD_BYTES` per pane until the next request, and answer the capability probe with the permission actually in force rather than a constant that always reads as "off". --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
0b6c332618 |
feat(settings): add interface font family configuration in appearance typography (#761)
* feat(settings): add interface font family configuration in appearance typography * fix(settings): let the interface font go back to the system face Three things the new **Interface font family** row spelled once and needed twice. `apply_theme` only wrote `Theme.font_family` when the setting was `Some`, and `Theme::change` never puts it back — it rewrites the field only when a theme config names a face, and none of ours does. So picking a font worked, and picking **Default** back saved `None`, redrew every window in the font the user had just cleared, and only came true at the next launch: a setting that looked like it had applied instantly and had not. The face is now assigned in both directions, against the stock value read once before anything overrode it. The dropdown's first row borrowed the bold/italic label, "Default (match primary)" — which promises the *terminal's* primary family. The interface falls back to the system UI font instead, so the row said the chrome would come out in Hack while the description beside it said the opposite. It gets its own label in all three locales. `ui_font_family` was also the one key in `config.json` that disappeared when unset; every other optional key is written as `null`. Dropped the `skip_serializing_if` so the file still lists it, and documented the key in the two tables that enumerate the typography settings. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
a2b5ae56d9 |
fix(panel): stop a conversation row offering a jump the pane cannot make (#759)
A turn's row is a link back into the scrollback, and `scroll_to_agent_turn` refuses two cases: a turn with no anchor, and a pane sitting on the alternate screen with no scrollback behind it. The panel only checked the first. So a conversation recorded under the classic renderer kept its anchors, the user switched the agent into a full-screen renderer — Claude Code's `/tui fullscreen` — and every row went on drawing a pointer and a hover fill while swallowing the click in silence. Both conditions now live in one predicate the panel and the view agree on, and a row that goes nowhere says why on hover: grey text reads as "less important" long before it reads as "not a link". Claude-Session: https://claude.ai/code/session_01A8Hiu4o14SkF5bpoPiV7Ko |
||
|
|
82f235df93 |
fix(terminal): read live focus for the agent unread badge (#758)
A pane takes the window's focus while it is being built, and a pane whose leaf is not in the element tree never receives the blur that goes with losing it again. The cached `focused` flag it left behind therefore says the reader is watching a pane nobody is looking at, and a turn that finishes there never raises its unread badge. Read the window's focus handle where the badge is decided, the way the cursor paint and blink paths already do, and drop the cached flag: with the badge moved off it, nothing read it any more. |
||
|
|
ed14b561ab |
feat(remote): auto-relink dead workspace panes and name their tabs (#757)
* feat(remote): auto-relink dead workspace panes and name their tabs A workspace pane whose stream died while its machine's control link stayed up was invisible to the reconnect supervisor: the tab sat on 'tty7 — disconnected' until the workspace was reopened by hand. - The link supervisor's pump now sweeps for such panes and asks for them back on the existing per-workspace backoff (1s doubling to 30s). A refusal — the machine says the pane is gone — is final for that pane; transient failures keep the clock running. - open_relink waits for the daemon's verdict on the Attach instead of handing an unclassifiable stream to the reader; refusals are typed (AttachRefused) so the retry loop can tell them from transport trouble. - PaneWorkspace carries the workspace's display name, and the pane adopts it as its default title, so a dead link reads 'hummingbot — disconnected' instead of the bare app name (the workspace-pane half of #438). - The reader's teardown logs which way the link died (EOF / read error / protocol error); until now all three were indistinguishable afterwards. Claude-Session: https://claude.ai/code/session_016s4fehNxNDXfJ1AfeTNo6y * fix(remote): keep two relink paths from dialling the same pane at once The daemon keeps one subscriber per pane: a second `Attach` for a pane_id kicks the first off. After a machine-level reconnect, `relink_panes` dials every pane and can sit up to fifteen seconds waiting for the far end's verdict — and the pump's own sweep, which runs every 250 ms and still reads those panes as dead, fired a second `Attach` for each of them. Panes are now claimed for the duration of an attempt. Both askers set the claim before dialling and release it when the attempt reports back, so a pane in flight asks for nothing; the workspace's retry clock likewise survives a sweep that finds no dead panes only because a batch holds them. Claude-Session: https://claude.ai/code/session_016s4fehNxNDXfJ1AfeTNo6y |
||
|
|
4140501e80 |
fix(terminal): keep split-pane cursors focus-correct (#736)
1. Gate blink ticks on each pane's live GPUI focus handle. 2. Render inactive prompt cursors as steady hollow blocks. 3. Cover split focus and caret decisions with portable regressions. |
||
|
|
d4b8e331b9 |
fix(sidebar): activate the row whose counts were clicked before opening its diff (#706) (#729)
* fix(sidebar): activate the row whose counts were clicked before opening its diff (#706) Each sidebar row's `+N −M` opens that row's diff. The counts sit inside the row and swallow the press so the row does not double-act, but the row's `on_click` is the only thing that activates a tab, so the click never switched tabs — and `open_diff_overlay` writes to `self.active`. Click the counts of an inactive tab B while A is showing and B's repository, branch and diff landed in A's document area, with A's `overlay_top` flipped and its own overlay state overwritten by B's path. Later reads keyed on the active tab carried that state on as A's. The handler now activates its own row first, so the tab on screen, the tab the overlay is stored on and the repository shown are one tab. On the row already active it still toggles, so a second click on the same counts closes what the first opened; on any other row it opens rather than toggles, since switching to a tab to see its diff must not close the diff that tab already had up when it happened to be the same one. That decision is a small pure function with a test, beside `diff_click_cwd`, which is the same shape. Reported with the trace and the fix by @IhpEcVns in #706. * fix(sidebar): drop the branch whose arms were the same call counts_click_toggles gated toggle_diff_overlay against open_diff_overlay, but the first forwards to the second with exactly those arguments — the toggle lives inside open_diff_overlay, keyed on host/cwd/source/focus and was_front. Both arms did the same thing, so the helper, its doc and its test described behaviour the code did not have. The fix for #706 is the activate() the handler was missing; that stays. Clicking an inactive row's counts still toggles against that tab once it is active, which is what shipped before and what ships now. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
adb3feb52a |
fix(tree-sync): let a window arriving at a workspace speak for nothing in it (#716) (#728)
* fix(tree-sync): let a window arriving at a workspace speak for nothing in it (#716) A remote client connected to a machine holding nineteen panes and came back to one tab. The workspace kept its id and its shells kept running — `pane ls --all` listed them live, owned by the workspace, held by no tab — but its tab tree was gone, and every GUI on that machine lost the layout at once. The tabs were closed by the window that arrived. `switch_workspace` claims the workspace, then hands `adopt_workspace` an empty session to put up while the real one is pulled — and `adopt_workspace` saves what it put up. That save syncs: a window showing no tabs at all, against whatever the last visit to that workspace left in the tree-sync map. Left Primed and informed, the diff runs at `SyncScope::Full`, where every mirror tab the window is not showing is a tab the user closed. It queued nineteen `TabClose`s and pumped them before `hydrate_window_with_tabs` on the next line had ordered the pull that would have populated the window. `tab_close` removes the tab and the pane records under it and returns the orphaned ids for the caller to hang up, which the CLI does and the GUI does not — hence shells still running under no tab. The workspace is now forgotten on the way in as well as on the way out. An unprimed state has no mirror to diff against, so the empty session goes up, is saved, and closes nothing; the pull lands, the rebuild puts the real tabs up, and `settle_rebuild` hands back the licence to a window that has actually seen what it is speaking for. That the licence outlived the arrival was the whole vulnerability, and it is what the test holds: the closes it authorises are queued and pumped inside `adopt_workspace`, and the hydrate on the next line clears the queue, so the ops are gone by the time a test can look at them either way. This is the local half. A remote client also renames on arrival and the reported workspace came back under the other machine's user name, which `settle_chosen_name` will fire at whatever workspace the window landed on when a parked name differs from the machine's — it cannot tell a name it created a workspace with from one it adopted. Left alone here; it loses a name, not a layout. Reported by xAlisher in #716, with the daemon state that identified it. * fix(test): gate the arrival test on unix, like the harness it uses harness_with_pane is #[cfg(unix)], so the new test broke the Windows build. Its sibling above already carries the same gate. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
ec95edee63 |
fix(terminal): give an overflowing emoji room instead of shaving it flat (#707)
* fix(terminal): give an overflowing emoji room instead of shaving it flat (#697) * fix(terminal): do not lend a blank cell that draws a rule of its own has_room_after let a segment borrow the next cell whenever it was blank with no background and no selection. A blank carrying an underline, a strikethrough or a link hover is none of those, but it becomes a Run of its own and is painted after the segment beside it -- so an emoji that leaned into it had that stroke drawn straight across its face. Reuse the existing draws_on_blanks predicate, which is already what segment_row uses to decide such a blank is worth painting. |
||
|
|
d7284a49b0 |
fix(switcher): keep an orphan pane's Close button on screen (#712)
* fix(switcher): keep an orphan pane's Close button on screen The owner an orphan carries is a WorkspaceId, printed whole: 36 characters of UUID that say nothing to a reader and, in a flex row whose text child never shrank, pushed the Close button clean past the edge of the card. The row named a live pane and offered no way to stop it. Name the workspace when this machine still has one, fall back to the 8-char prefix PANE WS OWNER CWD LIVE %665 76698a44 - /Users/thomas/repo/025/dex-arb-hunter yes %711 927fd6b8 - /Users/thomas/repo/kalo/kalopilot yes %725 927fd6b8 - /Users/thomas/repo/025/hermes-deploy yes %642 927fd6b8 - /Users/thomas/repo/kalo/kalopilot yes %726 927fd6b8 - /Users/thomas/repo/025/hermes-deploy yes %671 927fd6b8 - /Users/thomas yes %716 76698a44 - /Users/thomas/repo/025/tty7 yes %703 76698a44 - /Users/thomas/repo/025/tty7/.claude/worktrees/input-bar-width yes %623 76698a44 - /Users/thomas/repo/025/delta yes %719 927fd6b8 - /Users/thomas/repo/kalo/kalopilot yes %720 76698a44 - /Users/thomas/repo/025/tty7 yes %632 76698a44 - /Users/thomas/repo/025/hummingbot yes %727 76698a44 - /Users/thomas/repo/025/tty7 yes %617 927fd6b8 - /Users/thomas/repo/kalo/data-ai-service/.claude/worktrees/feat+comment-insight-5day-cache-impl yes %645 927fd6b8 - /Users/thomas/repo/025/telegram-cli yes %718 76698a44 - /Users/thomas/repo/025/hummingbot yes %728 76698a44 - /Users/thomas/repo/025/tty7 yes %627 76698a44 - /Users/thomas/repo/025/claude-statusline yes %630 76698a44 - /Users/thomas/repo/025/dex-arb-hunter yes %651 76698a44 - /Users/thomas/repo/025/CloddsBot yes %668 927fd6b8 - /Users/thomas/repo/kalo/kalopilot yes %721 927fd6b8 - /Users/thomas/repo/025/deepagents yes %699 76698a44 - /Users/thomas/repo/025/tty7/.claude/worktrees/audit-fixes yes %661 927fd6b8 - /Users/thomas/repo/kalo/kalopilot yes %590 76698a44 - /Users/thomas yes %724 927fd6b8 - /Users/thomas/repo/025/hermes-deploy yes %717 76698a44 - /Users/thomas/repo/025 yes %643 927fd6b8 - /Users/thomas/repo/kalo/kalopilot yes %723 927fd6b8 - /Users/thomas/repo/025/hermes-deploy yes %713 927fd6b8 - /Users/thomas/repo/kalo/kalopilot yes %722 927fd6b8 - /Users/thomas/repo/kalo/kalopilot yes prints, and let the text shrink (flex_1 + min_w_0 + truncate) while the button holds its width. * docs(switcher): put the owner-label rationale on the function it explains |
||
|
|
b4e7bf2e95 |
fix(updater): read the designated requirement off the stream it is on (#708) (#717)
`codesign -d -r-` writes the requirement to stdout and puts only the `-d`
display header (`Executable=…`) on stderr. `signing_requirement` searched
stderr, so the `designated => ` prefix could never match and every in-app
update on macOS ended at "codesign did not report a designated
requirement" — every build, every channel, with nothing a user could do
but download the app again by hand.
Verified against codesign rather than reasoned about:
$ codesign -d -r- /bin/ls
stdout: designated => identifier "com.apple.ls" and anchor apple
stderr: Executable=/bin/ls
Both streams are read now, stdout first. Which half goes where is
codesign's own business and has moved before; a requirement printed
anywhere in the output is the requirement, and the updater has no reason
to be the stricter party about where it appeared.
The parse is split out of the process call, which is the part that
matters for it staying fixed. Fused to `Command::output`, it could only
run against a real signed bundle, so nothing in a test suite ever
executed it — that is why a total failure of the macOS update path
shipped and stayed. `/bin/ls` is the bundle it was missing: Apple-signed,
on every macOS, and it answers `-d -r-` with a requirement of its own, so
the stream split is now asserted against the tool instead of against our
belief about it.
Both tests were run against the old stderr-only parse; both fail there.
|
||
|
|
74bb98697d |
Keep a stalled remote link off the UI thread (#709)
* fix(terminal): keep a stalled remote link off the UI thread A pane's writing half was a blocking socket with no write timeout, written to synchronously from gpui event handlers. When the far end stopped draining — a congested remote workspace, where the router's copy_bidirectional stops reading our half — the send buffer filled and write(2) parked in the kernel. One UI thread draws every window, so that was every window frozen until the link recovered. macOS gives a unix stream 8K, which is about 1400 keystrokes: a single paste. Move the socket onto a sender thread. write/resize/respond_auth/Detach now encode a frame, push it onto a bounded queue and return; the sender writes with the lock released and is welcome to park for as long as the far end makes it. A second handle on the socket is kept for shutdown, which returns at once even while another thread is parked in write(2) — the only way teardown can break that state. The backlog is bounded at 4 MiB. Reaching it is a dead link rather than a slow one, and is reported through the same path — and once — as an outright refused write. Refusals are now met on the sender thread, so a pane learns of one a moment after the keystroke rather than during it. Teardown gives what is queued 50ms to go out before cutting the socket: on a draining link the sender is idle and Detach leaves in microseconds, and on a stalled one it never leaves at all, which closing a pane must not wait to find out. * fix(terminal): a big paste is a paste, and a retired link keeps its own tongue Review follow-ups on the pane-writer queue. The "said it once" flag lived on the pane and was cleared on relink, but the retiring sender still held the same `Arc`. A doomed write completing after the reset spent the new link's one chance to speak, and the next real refusal went unreported. The flag belongs to a link, not a pane, so `LinkWriter::new` now mints its own. A frame can be over the whole backlog bound on its own — `paste` sends the clipboard as one `Input` — and refusing it marked a perfectly healthy pane gone. An oversized frame onto an empty queue now goes through and lifts the bound by its own size while it is outstanding, so what queues behind it is still held to four megabytes. Also: `close` is idempotent, so the teardown that calls it twice does not spend two grace periods; a sender that has given up closes the queue behind it rather than letting keystrokes pile to the bound it will never drain; and the #673 note that was dropped in the move is back. The backlog test passed with 27K of margin against a send buffer that is 8K on macOS but 212K on Linux, where the sender discounts what it got onto the wire — it queues twice the bound now. |
||
|
|
975e3edf9b |
Fix Windows path quoting, wire up Checkout to…, bound the Spawn reply (#705)
* fix(windows,scm,daemon): quote paths per shell, wire Checkout to, bound Spawn Five fixes from a whole-codebase audit, in one sweep because they share the paths they touch. Path quoting had two implementations. file_tree::shell_quote_for wrapped the path in quotes and picked the right ones per shell (#593); view::shell_escape_path escaped with backslashes, which is POSIX-only and collides head-on with the Windows path separator, so a dropped file, a pasted path, a staged image path and an accepted completion candidate all lost their separators there. completion::complete_path stripped the same backslashes back off before looking a path up, so inline path completion could never resolve a directory on Windows either. Both now go through one core::shell_quote module, and shell_word_start tracks quoting across the word so a second Tab still finds the word it just inserted. "Checkout to..." was registered, listed in the palette, bindable, and handled by an empty match arm — invoking it did nothing at all. It now opens an inline input row in the SCM panel, the twin of the existing "create branch" one. RemoteTerminal's Spawn read the daemon's reply with no deadline, while Attach in the same file and PaneSession::spawn_over in core both bound theirs. A daemon caught mid-restart accepts the connection and never serves it, and the local route spawns synchronously on the UI thread, so the silence froze the window on "new tab". Two Windows papercuts: client_hostname spawned a console program from a GUI process (a visible console flash) where COMPUTERNAME already has the answer, and completion generators were a silent no-op with no way to tell "produced nothing" from "never ran". Three duplicated implementations merged: proc_name existed twice in the daemon with a different fallback in each, the GUI's control link was the one client socket that skipped transport::tune, and fps.rs and perf.rs were the same windowed meter copied twice. * refactor(completion): stop declaring spec fields nothing reads The Fig spec structs mirrored seven keys the completer never looks at, each held up by its own #[allow(dead_code)]. Serde ignores unknown fields by default, so dropping the declarations parses the same specs and drops the attributes with them. * refactor(daemon): delete the loopback-forward management pipeline Two protocol messages, their kind codes, encode and decode arms, two daemon dispatch arms, two wire structs and two GUI client wrappers all existed to reach SshManager::list_loopback_forwards and close_loopback_forward, which were hardcoded to Vec::new() and false. Nothing called the client wrappers either. The kind codes are left as holes rather than renumbered, the way 13 already is, so the wire format is unchanged for every other message. known-hosts management looks like the same shape but is not: its backend parses the real file, fingerprints keys and rewrites through a 0600 temp file. That one keeps its client half and gains a comment saying it is an interface waiting for a screen. * test(ssh): cover the host-key policy table and both proxy handshakes The host-key decision is lifted out of check_server_key into host_key_action, so what to do about Known/Unknown/Changed/ ChangedAlgorithm/Revoked can be read and tested without a server, a broker or a known_hosts file. Eight tests pin it, including the two subtleties the comments already claimed: verify_host_keys=false still rejects a revoked key, and a new algorithm asks the unknown-host prompt rather than a new variant older peers cannot decode. socks5_connect and http_connect are split into connect + handshake, the handshake generic over the stream, so nine tests drive them from an in-memory duplex: length-prefix framing, the variable-length bound address, auth refusal, reply codes, and the header terminator. * test(cli,daemon): cover server binary resolution and the procargs parser server_exe is split into environment lookup and resolve_server_exe, the latter taking its three sources and an is_exe predicate so seven tests can pin the precedence without touching the filesystem. Holding the sibling to is_file rather than exists fixes a directory named tty7-server shadowing the real binary on PATH. parse_macos_procargs gets six tests over the KERN_PROCARGS2 layout: exec-path skipping, however many bytes of alignment padding follow it, argc bounding argv so the environment stays out, truncation, and a short buffer. * test(ui): cover the host-op pool decisions and the local reconnect schedule The pool's retire condition moves into should_retire with the reason named: a worker must not retire on the timeout alone, because submit counted it as idle and so did not spawn a replacement for the job that landed meanwhile. LocalLink::tick's schedule moves into due(), taking the clock and the link's state as arguments. The first attempt going out immediately, the backoff only applying from the second, and a pending deadline not being pushed further out by later ticks are now pinned. The identical scheduler in remote_workspace had TestAppContext coverage; this one, which every launch depends on, had none. * fix(completion): unquote across the whole word, not just its first character The round-trip test caught two things the first cut got wrong. A quote can open partway into a word — quote_for_shell emits ~/'My Documents' so the shell still expands the tilde — and a single-quoted body is literal all through, so unescaping backslashes inside one took the separators out of 'C:\Users\me'. Scanning with a quote state handles both, and makes the '\'' seam fall out of the state changes rather than needing a case of its own. The GPUI test for accepting a candidate follows the insertion from backslash escaping to quoting. * fix(windows): unbreak the Windows build and quote for PowerShell's own dialect `Instant` was moved behind `#[cfg(unix)]` while the generator cache still uses it unconditionally, so the Windows target stopped compiling. The quoting module treated every shell but cmd.exe as POSIX, including PowerShell. PowerShell does not join a quoted string to the bare word beside it, so the `'\''` seam is not a seam there — `C:\Users\O'Brien` came out as three tokens, and the completion un-quoter turned the apostrophe back into a backslash. Quoting is now a three-way dialect (cmd / PowerShell / POSIX) chosen once and threaded through completion in place of the escapes flag. * test(file-tree): name the shell where the quoting rule is the POSIX one `shell_quote_for(_, None)` answers from the platform, so an assertion about the `'\''` seam has to say which shell it means or it fails on Windows, where the unnamed shell is PowerShell. |
||
|
|
46759b8a01 |
fix(input-bar): read column widths from unicode-width, not a hand-rolled table (#704)
* fix(input-bar): read column widths from unicode-width, not a hand-rolled table The input bar scored every character against a hand-written list of code-point ranges. Anything the list missed counted as one plain column, so `🀄`, `⌚` and every combining mark pulled the rest of the row a column left, and clicks, wrapping and the caret all landed off by that much (#701). The grid gets its widths from `unicode-width` by way of `alacritty_terminal`, so read the same table. Zero-width characters then need a cell to ride in: group each base with the marks that follow it, so the shaper sees one run and composes `é` instead of setting `e` and its accent side by side. An emoji presentation sequence is re-scored as a string the way the grid re-scores it, so `❤️` is two columns in the bar as well. A ZWJ sequence stays two cells on purpose — that is what the grid makes of it, and composing it here would put the bar a column off from where the text lands. * fix(input-bar): derive click and wrap geometry from the cells the bar draws `input_cells` re-scores an emoji presentation sequence to two columns and hands a stranded combining mark a column of its own, but `input_char_positions` kept walking the text character by character — so `❤️` was drawn two columns wide and counted as one. Everything geometric read the short count: a click on `X` in `❤️X` selected past it, wrapping broke a column early, and vertical caret motion aimed at the wrong column. Walk the same cells instead. Only the base of a cell carries the width, so a click still lands on the base rather than a mark riding on it, and the riders sit at the column the caret takes after the cell. A cell now also tints as a unit when a selection covers any character in it — it is one glyph, so half-highlighting it drew a mark unselected next to its selected base. |
||
|
|
07e3b26434 |
feat(agent): outline a coding agent's conversation, and jump back to a turn (#703)
* feat(agent): outline a coding agent's conversation, and jump back to a turn The hooks tty7 installs into Claude Code already announce every turn over the pty as an OSC 777, and the daemon reads those for the pane's status dot. The same bytes reach the client, where they are worth something else: the byte offset a `prompt-submit` lands on is a *position in the stream*, so advancing the emulator to exactly there and reading the cursor gives the scrollback row that turn began on. That is an outline of the conversation, and a way back into it — which is the one thing a long agent session in a terminal has never had. The Info panel grows a CONVERSATION section: one row per turn, the prompt's first line as its label, a dot that says whether the turn is still running. Clicking a row scrolls the pane so that turn's prompt is the top line. Not a fourth right-panel tab. `RightPanelTab` says out loud why there is no room for one at 260px, and a fourth variant would drop anyone who rolled back to an older build onto Info. This is a fact about the pane, like its shell and its cwd, so it sits with them. The hook is a subprocess writing to the controlling tty while the agent's own renderer writes to it too. Claude Code repaints in place with ink, so the cursor when the hook's bytes land is wherever the last repaint left it — inside the live region, a few rows from where the prompt's echo comes to rest. And once the scrollback limit starts discarding lines, every anchor slides by the discard count at once. So the anchor is a hint, and the prompt's own text is the correction: at click time (by which point it has long been drawn) the row is looked for around the anchor, exact match first — the row that *is* `> hi`, marker stripped — and only then by containment, which keeps its length floor because `hi` appears inside half the rows of any answer. The row that is found is written back, so a second click does not search again and cannot land somewhere else. Claude Code keeps a JSONL transcript, and reading it would give the assistant's side too. It would also only work for Claude, only when the agent runs on this machine, and only for a path this process may read. An OSC comes back through the pty from wherever the agent actually runs — over ssh, in a container, in a remote workspace — with no file access and no per-agent format. What is lost is the assistant's text; what is kept is every host tty7 supports. - `OscTokenizer::feed_at` reports each payload's end offset. The client already tokenized OSC 777 on every batch to keep agent events out of desktop notifications, so the scan is free; only a real event now costs a cut, which is what #404 was right to object to about the old per-command mark scanner. - `Cut` is a two-variant enum again (cursor repair, agent turn). Two ascending runs concatenated are not one, so a batch carrying both kinds is sorted — and only such a batch pays for it. - A replayed ring is cut the same way, so reattaching to a pane rebuilds the outline from its own history rather than losing it with the old client. - Turn anchors are dropped where image placements are: `clear_scrollback`, and the grid reset in `adopt_relink`. - A turn that began on the alt screen is listed but not clickable — there is no scrollback behind it to return to. - A turn announced twice is one turn. Hooks are not guaranteed to fire once, and what makes it the same turn is that the one before it never ended: a real repeat can only come after an answer, and an answer brings a `stop`. - The hook forwards the prompt's first line, clamped to 200 characters. The tokenizer *abandons* a payload past 8 KiB rather than truncating it, so a pasted file would otherwise cost the whole event; and a needle spanning a line break matches no single row. No protocol change: the prompt rides in the OSC the hook already sent, and an older client ignores the field. * refactor(panel): drop the Info panel's agent row It said `Claude Code · working` behind a status dot — the same name and the same dot the tab chip and its sidebar row were already wearing, restated two panels away from either of them. The CONVERSATION section that now sits under it says what the agent is doing in a form the row never could: which turns there were, which one is still running, and a way back to each. `InfoValue::Agent` and `status_pip` went with it — the dot was the row's only caller — and `PanelAgent` / `PanelAgentIdle` with those. The remaining three status labels stay: the tray menu still names them. `Tab::agent_row` stays too. `agent_status` is that pair's status and the tab strip's badge reads it, which is the one-leaf rule #543 put there. |
||
|
|
8a950a343b |
Say what this platform does, not what macOS does (#700)
* fix(i18n): say what this platform does, not what macOS does Four pieces of user-facing wording described macOS as if it were the only platform they were read on, in all three languages at once — each translation had faithfully carried the English text's assumption across. - Copy on select claimed "no ⌘C needed" everywhere. Off macOS the binding is Ctrl+Shift+C, so the sentence named a key that copies nothing. - The blur switch was labelled "(macOS)" on a row Linux also renders and also honors. Windows gets the backdrop picker instead, so the label was wrong for every reader it had. It now says which compositors deliver it, because gpui's X11 backend does no blur at all and Wayland only does when the compositor offers a blur manager. - X11 forwarding named XQuartz as the only prerequisite anyone could have; Windows needs an X server of its own and Linux needs nothing. - The Explorer verbs were string literals, so a Chinese or Japanese install got English context-menu entries for the life of the install. The Explorer labels are the one string in the product that outlives the process that wrote it: Explorer reads them from the registry, not from tty7. Registration now sets the locale before building the entries (that process returns before the GUI path's set_locale ever runs), and a language change in Settings restates them. Only keys that already exist are rewritten — offering the menu is the installer's checkbox and declining it is the user's, and changing a language must never be what puts the verbs back. * docs(settings): the blur description no longer says what this comment quotes * fix(config): restate the Explorer verbs when a hand-edited language changes |
||
|
|
51b0fe64b9 |
fix(linux): stop the compositor framing a window that draws its own title bar (#679) (#683)
* fix(linux): stop the compositor framing a window that draws its own title bar (#679) tty7 paints its own title bar through gpui-component's TitleBar, and the WindowOptions it opens with say as much (appears_transparent) — but say nothing about decorations. gpui reads a missing window_decorations as WindowDecorations::Server and, on Wayland, sends zxdg_toplevel_decoration_v1.set_mode(server_side) for the toplevel, so a compositor that honours it draws a second title bar and border around the one the app already has. window_options() now asks for WindowDecorations::Client, which is what Zed defaults to (its window_decorations setting, overridable by ZED_WINDOW_DECORATIONS). Nothing new is painted for it. gpui-component's Root already wraps the window in window_border() — bordered defaults to true and tty7's root never turns it off — which under Decorations::Client draws the 1px frame, the 12px shadow, the resize hit bands and the right-click window menu, and tells gpui its inset through set_client_inset; under Decorations::Server it degrades to a plain div. The request was the only piece missing. The field is set without a cfg, unlike the icon beside it: the icon is gated because the PNG behind it is only decoded on Linux, while this is a plain enum that costs nothing elsewhere. request_decorations is an empty default on the PlatformWindow trait that neither the macOS nor the Windows backend overrides, so both keep answering Decorations::Server and the window there is unchanged. X11 turns the request into _MOTIF_WM_HINTS and falls back to server-side on its own when no compositor is running, so a bare X session still gets a window-manager frame — and a reparenting WM under a compositor, which today is told in the same hints to decorate, gets the same fix as Wayland. Client-side decorations bring one follow-on that Zed hit too (ca9cee85e1, "linux: Fix non-maximized Zed windows growing larger across sessions", #22301), and the two Linux backends want opposite answers to it. The bounds tty7 remembers go back in through WindowOptions::window_bounds, which every backend reads as the outer rectangle. On Wayland under client decorations the outer rectangle is the surface, shadow included, and the compositor's first sized configure adds the inset back onto whatever was asked for (compute_outer_size) — so saving outer and reopening at it grew the window by twice the shadow per launch, and saving inner pre-deflates by exactly what the configure re-inflates. X11 never re-inflates: it creates the window at the requested rectangle verbatim, and its inner_window_bounds also shifts the origin by the inset, so saving inner there would shrink the window and walk it down-right by the shadow on every launch wherever the request is honoured (a compositor plus _GTK_FRAME_EXTENTS — GNOME on Xorg, Plasma X11). A window_bounds_to_remember helper therefore saves inner on Wayland and outer everywhere else, told apart by cx.compositor_name(); macOS and Windows report no inset, so the two are the same there. WindowState round-trips unchanged. The one place that hardcoded the window's corner follows the frame: the pane-to-tab-strip drop band was a rectangle from (0, 0) to the title bar's height, which under client decorations is the shadow strip plus the top of the bar, missing its lower third. It now starts at window_paddings(window), which is zero under server decorations, so nothing moves off CSD. A test pins the request: window_options() must answer Some(Client), and its title bar must be the transparent one the request stands in for. Not verified here, with no Linux session to run in: that the reporter's compositor honours the mode switch (the protocol lets it refuse), how the 12px shadow reads against the shipped themes, the edges of a maximized or tiled window, where gpui-component drops the padding on the tiled sides, and one quit-and-relaunch on X11 under a compositor to see the remembered size hold. The app.rs change is untestable in principle: gpui's TestWindow overrides neither inner_window_bounds nor the decorations, so inner and outer are one rectangle in every test. FreeBSD runs the same backends with gpui-component's shadow at zero; unexercised. * fix(linux): remember the inner window bounds on X11 too, not just Wayland The bounds tty7 remembers were saved as the *outer* rectangle everywhere but Wayland, on the reading that X11 creates its window at the requested rectangle verbatim and never puts the shadow back on. That reading is wrong, and it reintroduces on X11 exactly the bug the split was written to avoid on Wayland. gpui only turns client-side decorations on for X11 when a compositor is present *and* the window manager advertises _GTK_FRAME_EXTENTS (client_side_decorations_supported in x11/client.rs). A window manager that advertises that atom is one that honours it — it keeps the visible frame put and treats the extents as shadow outside it — so a window reopened at its outer rectangle comes back one shadow larger on each side, every launch. That is what Zed measured: ca9cee85e1 ("linux: Fix non-maximized Zed windows growing larger across sessions", #22301), the commit this code cites, took all of its before/after numbers on X11 (+20px per session) and fixed both backends with a single unconditional inner_window_bounds(). Zed still reads it unconditionally today, at the rev pinned here. So drop the compositor_name() branch and save the inner rectangle on every platform, as Zed does. It is a no-op wherever there is no inset to strip: inner_window_bounds defaults to window_bounds on the PlatformWindow trait and neither the macOS nor the Windows backend nor gpui's TestWindow overrides it, and on X11 without a compositor window_decorations() answers Server, so the window border never calls set_client_inset and last_insets stays [0, 0, 0, 0]. Also lift the tab strip's drop band out of the render path into strip_band(), so the padding arithmetic can be tested without a window: the viewport measures the whole surface, shadow included, so the band loses one padding at each end rather than one twice over or none at all. It clamps at zero now — a surface narrower than its own shadow is only reachable mid-resize, but a negative width would hand Bounds::contains a rectangle that is inside out. Three tests: the band is unmoved when the frame reports no padding (macOS, Windows, a bare X session), it reaches the far edge of the frame rather than of the surface when it does, and it collapses instead of inverting. window_bounds_to_remember stays untested on purpose — TestWindow makes inner and outer the same rectangle, so any assertion about it would only restate the call. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
2cdc26f357 |
Wire hooks, resume and detection for Kimi Code CLI (#694)
* feat(agents): wire hooks, resume and detection for Kimi Code Kimi Code CLI takes its hooks as [[hooks]] entries in the same config.toml that holds the user's providers and models, so this adds a third install strategy — a format-preserving TOML merge on toml_edit — beside the JSON map merge and the owned files. Like Qwen it reports permission requests first-class, so it gets no Notification hook. Resume rides `kimi --session <id>`; fork stays unwired, Kimi documents none. Closes #693 Signed-off-by: Austin Spraggins <spragginsdesigns@gmail.com> * fix(agents): harden the Kimi Code TOML hook merge and its resume flags The TOML merge strategy the Kimi wiring introduces round-trips a shared config.toml cleanly, but three gaps sat behind it. `hooks_state` counted only the marked entries that still named an event, so a hand-edit that dropped the key off one of nine entries left the remaining eight matching the roster exactly and the file reported Installed with a broken entry in it. Every marked entry now counts, which is what the JSON merge already did and what `refresh_hooks` needs to see. A `hooks = []` spelled as an empty inline array made install fail outright -- toml_edit keeps an empty array and an array of tables apart, but the two say the same thing and neither carries any configuration. It is now promoted rather than refused. Every other wrong-shaped `hooks` key -- a string, a table, a non-empty inline array -- still refuses with the file left byte-for-byte alone. `Stop` is not the only way a Kimi turn ends: its own event reference says `Stop` does not fire on interrupts and `Interrupt` fires instead, and a turn that dies on an error reports `StopFailure`. Without those two an Esc or a failed turn left the pane on "working" for good and `tty7 wait` could only ever time out. Both are observation-only events and report the same end of turn `Stop` does. On resume, `--agent` and `--agent-file` join the stale flags: Kimi rejects either next to `--session` at startup, and resuming rebinds the session agent by itself, so replaying them turned a working resume into a launch error. Tests cover the wrong-shaped `hooks` keys, a config.toml that does not parse on both install and uninstall, a file that does not exist yet, a second install being byte-for-byte the first, mangled and surplus marked entries, an uninstall threading between the user's own entries and the tables after them, and the `--session=<id>`, bare `--session`, `--continue` and `--agent` spellings on the resume path. --------- Signed-off-by: Austin Spraggins <spragginsdesigns@gmail.com> Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
e82a460794 |
fix(windows): normalize path separators before reveal and copy (#680)
* fix(windows): normalize path separators before reveal and copy
Open-folder (reveal_path) and copy-to-clipboard hand raw paths to gpui.
On Windows, mixed-separator paths (a forward-slash prefix joined with
backslash entries) reach reveal_path through two routes:
- the shell's PWD — OSC 7 from Git Bash / MSYS bash reports `/`, and
that string survives `Path::ancestors()` when the file tree walks up
to find `.git`, so the file-tree root keeps the forward slashes
while `read_dir` entries underneath it come back native (backslash);
- `git rev-parse --show-toplevel` from Git for Windows (MSYS2), which
always prints `/` regardless of the calling shell. The SCM panel's
`scm_repo_root` and the worktree creation in tty7-core both use it,
so the root they hand downstream is `/`-prefixed and joins against
backslash-joined entries to form `D:/code/tty7\skills`.
Windows' IShellFolder::ParseDisplayName rejects that with E_INVALIDARG
(0x80070057); reveal_path swallows the error (it only logs), so "open
folder" silently does nothing. The same mixed-separator paths also make
copy-to-clipboard produce strings the user has to retype before a shell
will accept them.
Add a native_separators helper in path_display and apply it to every
reveal_path call (file tree, scm panel, right-panel info cwd, sftp
downloads) and to every path copied to the clipboard.
* fix(windows): rewrite separators losslessly, and only for local paths
Review follow-ups on the reveal/copy separator fix.
`native_separators` went through `to_string_lossy`, so any path holding an
unpaired surrogate — legal in an NTFS name, not representable in a Rust
`str` — came back with `U+FFFD` in place of it, naming a different file.
Since `reveal_path` only logs its failures, that reads to the user as the
same silent no-op the fix is here to remove. It now maps over the path's
own UTF-16 code units and rebuilds with `OsString::from_wide`; `/` and `\`
are ASCII, so a unit equal to either is that character and never half of a
surrogate pair. Still `Cow::Borrowed` when there is no `/` to rewrite.
The three clipboard sites re-spelled remote paths too. File-tree "Copy
path" and the SCM panel's sat outside the locality guard their Reveal
neighbours sit behind, and the Info panel's cwd copied `effective_cwd`
while its Reveal checked `local_cwd` — so a Windows window onto a remote
Linux host copied `/home/u/src` as `\home\u\src`, which names nothing on
either machine. Each now shares one locality check with its Reveal.
Both "Copy working directory" entry points were missed entirely: the
app-menu action and the tab context menu each spelled the path their own
way. They now share `tab_cwd_text`, which applies the same rule.
Adds a Windows test that a lone surrogate survives the rewrite.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
|
||
|
|
010457132f |
fix(terminal): shape a regional-indicator pair as the one flag it is (#686) (#691)
A flag such as 🇨🇳 is two Regional Indicator symbols, U+1F1E8 U+1F1F3.
Each is width 1 to unicode-width, so the grid gives each its own column
and no spacer: the pair already sits in exactly the two columns a flag
occupies. But `segment_row` sent each one to `Solo`, and a `Solo` is its
own `shape_line` call. The shaper never saw the two together, so it had
no chance to form the flag ligature, and each half came out as the
letter-in-a-box glyph an emoji face draws for a lone indicator. A `Solo`
also clips to two cells so a fallback face's advance has room, and that
box is two cells wide, so each half spilled into the next column as well.
Join a Regional Indicator and the one after it into a single two-cell
`Cluster`, the move
|
||
|
|
958d8b7442 |
feat(window): dock the code panel and the diff overlay beside the terminal (#625) (#685)
* feat(window): dock the code panel and the diff overlay beside the terminal (#625) Opening a file covered the workspace. The terminal underneath kept running and was neither visible nor typeable, so reading a file while an agent talked was a toggle loop: open it, close it to read the reply, open it again. The Files tree already docks; the two surfaces you go to *from* it did not. They dock now, as a flex sibling of the terminal column rather than a narrower overlay — that distinction is the feature. `set_grid_size` is driven by the terminal element's laid-out bounds, so a column takes width away from the grid and the PTY reflows into what is left; a card painted over half the workspace would have left the grid full width with half of it hidden. `overlay_top` stops ordering a pair and starts choosing between them: a column has one child, and two `flex_1` siblings would split it and fight. Fill mode keeps the old vector, the old opaque paint and the old platform hoist untouched, so nothing about today's overlay changes for anyone who picks it. - Half the terminal column by default; drag the divider, double-click it to cycle a third / half / two thirds, or use the palette commands. Two thirds deliberately runs past the half-window cap the side panels obey — only the terminal's floor binds it. - `DOCUMENT_MIN_W` joins the width budget: both side panels reserve it the way they already reserve each other, and the column is derived from the *live* sidebar and panel widths rather than their floors, so a panel someone dragged wider is width the terminal keeps. - A window too narrow to seat both fills for that frame. The fallback is derived at render time and never stored, so widening re-docks on the next frame with nothing to undo. - Fill or dock is per tab, on the header's context menu. Reading a long file over the whole window in one tab while an agent keeps half of another is the normal case, and one global switch made each of those flip the other. A tab that has not been told reads `document_layout` from the config, which is what a fresh tab starts as — and which the menu therefore does not write, since every untold tab is reading it. - Everywhere but macOS the title bar spans the workspace, which left a bar's height of nothing above the column. The header is drawn into it, and behaves like the title bar it now sits in. With the detail panel closed the column reaches the window's right edge, so the header stops short of the trailing chrome through a width the tab strip's own reservation shares. - The docked headers drop the traffic-light inset they never had to clear, and the diff header's branch name becomes the thing that yields so the view toggle and the close tile survive a column's width. New in `config.json`: `document_ratio`, and `document_layout` for what a fresh tab starts as. Four new actions, bindable and unbound by default. * fix(window): hold the docked column to widths the strip and the file agree on Three defects in the document column, each with a guard test that fails without its fix. The tab strip did not know a column had taken width off it. On macOS the strip lives inside the terminal column and sizes itself to the window less the detail panel, so a docked document left it 340 points wider than the column it sits in and the chips ran on under the column — the same overrun the panel's own reservation was added for. Everywhere else the strip spans the workspace and the column's hoisted header is drawn over its trailing end with no fill of its own, so a chip left under it showed through the file name and stayed clickable through it. The column's width now comes off `strip_w` on macOS and off `corner_w` elsewhere, which is where the panel's already goes. The divider wrote widths the file would not keep. `Config::sanitize` holds `document_ratio` to 0.2..=0.8; the drag clamped in pixels only, so a column pushed against either edge of a wide window was saved outside that band and reopened somewhere else — on a 2560-point body, 232 points from where it was dropped. The band is a pair of shared constants now and the drag clamps to it, the way the font size and its stepper were made to agree in #550. The palette named the config's layout rather than the tab's. Fill is per tab, so a tab told to fill was still offered "Document: Fill Window" — a row that named the state it was already in and did the opposite. It reads the active tab through `ChromeState` now. Also: `document_layout`'s doc comment still described the global switch an earlier draft had, three lines after the field became a per-tab default. |
||
|
|
7bcb91d8af |
fix(input): give the PTY back the Ctrl chords tty7 was eating (#684)
* fix(input): give the PTY back the Ctrl chords tty7 was eating Follow-up to #682, which handed Ctrl+V to a full-screen program but left three neighbouring holes of the same shape: a key the terminal answers without the keymap ever seeing it. The C0 table was half a table. `input.rs` mapped the alphabet, `[ \ ]` and Ctrl+2, and nothing else — so `Ctrl-^` (Ctrl+6, vim's alternate file), `Ctrl-_` (readline's undo, typed as Ctrl+/ or Ctrl+Shift+-) and Ctrl+3..8 produced no bytes at all. They were not mis-encoded, they were silent: gpui filters control characters out of `key_char` on all three backends, so the text fallback had nothing to offer either. The table is now the VT-220 one, each digit beside the punctuation that shares its key, because every platform hands Ctrl+Shift+6 over as `^` with the Shift already spent. Ctrl+/ is xterm's addition rather than VT-220's and is spelled out with the reason. The twenty-six letters fold to `& 0x1f`. `on_key_down` swallowed plain Ctrl+1..9 off macOS with a bare `return`, left over from when tabs lived on ctrl-digits — they have been on Alt+1..9 for a long time, so nothing claimed those chords and the block only deleted keys. It also sat before `keystroke_to_bytes`, so not even the kitty protocol got through it. Gone. Ctrl+V is now a binding. `AlternatePaste` carries `ctrl-v` off macOS in a `Terminal && !alt_screen` context, and the pane declares `alt_screen` whenever a full-screen program owns the grid, so the behaviour #682 settled on is unchanged — paste at a prompt, SYN inside vim — while the keymap can finally express it, the Keybindings page lists it, and the user gets a say: `"AlternatePaste": ""` hands Ctrl+V to the shell everywhere, including readline's `quoted-insert`, and `"PasteText": "ctrl-v"` pastes on every screen the way Windows Terminal does. That cohort is real — Warp keeps Ctrl+V pasting on Windows on purpose, as a removable binding, for exactly this reason. The hardcoded arm in `handle_cmd_shortcut` now answers Cmd+V alone, which is macOS's only paste chord and carries no control code to lose. Last, the rule about control codes is one function instead of an assertion buried in a test. `steals_a_control_code` plus a commented `control_code_binding_allowed` back both the defaults test and a new runtime warning, so a hand-edited config.json that takes EOF away from every shell says so in the log. It warns rather than refuses: a chord the user asked for by name is theirs to spend, the way the tmux preset spends Ctrl+B. The invariant that still fails a build is that no *default* spends one silently. Tests: `cargo test --bin tty7-app` 1360 passed, 1 known flake (`a_routed_auth_prompt_carries_the_machine_that_raised_it`, green on a rerun and on a clean tree). New: the whole VT-220 table asserted byte by byte, with Ctrl+- held out; `ctrl_6_reaches_the_pty_as_rs`, `ctrl_v_pastes_at_a_prompt` and `ctrl_v_reaches_a_full_screen_program_as_syn` drive the real keymap through `simulate_keystrokes` rather than calling into the view; the keymap tests cover both escape hatches and the context that withholds the binding. #682's two `handle_cmd_shortcut` tests are replaced by those three, which assert the same behaviour at the layer that now decides it; its end-to-end SYN test stands unchanged. The gpui tests are unix-only, so CI is what runs them. * fix(input): ask the grid, not the last frame, before Ctrl+V pastes `AlternatePaste` carries `Terminal && !alt_screen`, but gpui matches a keystroke against the frame it last painted, so the context outlives the switch: a full-screen program that took the screen after that paint is still "at a prompt" as far as the keymap is concerned, and the clipboard lands in it. In vim's normal mode that runs as commands. The action now re-reads the terminal mode and propagates instead, which hands the chord to `on_key_down` and encodes it as the SYN the program is waiting for. Also: - the two escape-hatch assertions in `paste_ships_both_terminal_chords_off_macos_and_retires_together` built a one-entry binding table instead of the default one, so both passed without the hatch working — an emptied `AlternatePaste` cannot dispatch anything when it is the only entry in the table. They now apply the config line on top of the whole default table, and the `PasteText: ctrl-v` case checks both screens; - the keyboard-shortcuts page claimed every other Ctrl chord reaches the program, which Ctrl+Tab and the Windows/Linux font-size chords do not; - `steals_a_control_code` documents `@` and the backtick, which are in the set it walks but were not in the list beside it. |
||
|
|
f44b667639 |
fix(restart): fail a silent Attach, and hold the tabs a rebuild could not put up (#673) (#681)
A restart on nightly 26.8.4 came back with every restored coding-agent pane locked: Ctrl-Z printed its suspended message and never returned to a shell, Ctrl-C did nothing, no keystroke reached anything (#673). Its sibling — a restart after an upgrade that came back to an empty workspace (#672) — was mostly closed by #554 and #579; what is left of it is closed here too, because both are the same mistake, a restart's rebuild reporting a success it did not have. The locked panes are an `Attach` the client took on trust. `attach_reply_prefix` reads far enough into the daemon's reply to tell an `Error` frame from a replay, and a read that timed out with nothing in the buffer fell through to the success branch: silence was read as "a quiet pane". But a quiet pane is never silent. `attach_subscriber` replays the pane's ring before the daemon reads a byte of our input, the ring always holds a segment (`ReplayRing::new` starts with one and every path that empties it puts one back), and every daemon build there has been queues a `Size` and then a `Snapshot` first — a pane that has printed nothing still answers with its geometry. So an `Attach` that produced no bytes in the whole wait is one nobody is serving: a daemon still mid-restart, or a socket some process holds open and will never read. Taken for an attach, it made `spawn_shell_terminal_in` report `restored = true`, the flag that skips the fresh spawn, the restored-screen banner and the agent's `--resume`; and `write` threw every encode error away, so the keystrokes, Ctrl-C and Ctrl-Z all went into that socket and vanished. Zero bytes is now the failure it is, and the caller falls through to the path it already had for a pane that is gone — a fresh shell under the old screen, with the resume typed. Nothing changes on the wire. That silence has a second reading, though, and only one of the two is safe to act on. A daemon merely slow to serve — an execve handoff keeps the listener and its backlog across the exec, and a fresh daemon adopts its panes and seeds ids before it takes an Attach — would have served the connection a moment later, and a fresh pane spawned over that live one carries its history across (`history::carry` is written for a dead pane) and starts the agent's resume against a session the old process still holds. So a silent local Attach is confirmed before it is acted on: the client asks the daemon `Version` on a fresh connection, which a daemon answers before it touches any state. Answered, the daemon is up and serving and the attach socket is one it will never serve — the verdict stands. Unanswered too, nobody is serving yet; there is no third path from a synchronous UI-thread call, so the attach still fails, but the error and the log line say which silence it was rather than claiming the pane is gone, since that is the line someone reads while diagnosing an orphaned shell. Only local routes probe: a remote attach already waits fifteen seconds and a second routed connection is a second bridge process. The two-second local budget is unchanged — only a silent connection ever pays it, and N silent panes hold the window still for N of them. `write` also stops swallowing the link refusing input. The first refusal is logged once from the writing side, and unless the reader was retired for a relink the pane is marked exited by the reader's own signal — `exited_flag`, then the `Exit` event — since it is the same socket, only found dead from the writing side first; the reader still raises its own when it gets there, and the handler is idempotent. A retired link stays quiet, for the reason the retired reader does. This is hardening for a closed link, not the cure for #673 — a socket held open and never read accepts writes into its buffer, and nothing here fires; the attach change is what keeps that pane from existing. The tabs that did not come back are the rebuild's licence outrunning what it rebuilt. `tabs_from_session` drops any tab none of whose panes would start; `settle_hydration` then marked the window `informed` as long as *some* tab rebuilt, while the mirror it had just installed still listed every tab the machine holds. The next `sync_window` ran at `SyncScope::Full`, and `diff` at that scope emits `TabClose` for every mirror tab not in `desired` — which the dropped tabs were not, and `held` did not cover them: it only covers tabs on screen whose panes cannot be represented. A partial rebuild deleted from the machine exactly the tabs it had failed to rebuild, panes and all. They are held now, rather than the licence withheld. `settle_rebuild` records the wanted ids the window is not showing (`not_rebuilt`), and `sync_window` carries them into `held`, whose contract in `diff` is already "mirror tabs the window cannot speak for — close nothing, and do not reorder around them". Withholding the licence would have been the smaller change, and it is what the none-rebuilt case does, but it takes `TabClose` away from the whole window for as long as the failure stands, and a failure can stand across every restart (a tab whose shell is no longer on the machine): every close the user made in the meantime would come back on the next rebuild. Holding only the tabs that failed leaves the window speaking for the ones it did put up. The set is rewritten by the next rebuild and pruned against the mirror on every sync, so a tab the machine lets go of stops being held. The none-rebuilt guard is unchanged: a window that put nothing up still does not speak for the workspace at all. Two things about the held set said out loud. It reads the count of tabs the tree asked for, not the ids it found: `tree_id` is not serialized, so a session that reached this path from disk would name no ids, and "no ids" must not read as "no tabs wanted" — that would hand the licence to a window that rebuilt nothing, which is #672 again. And holding has a cost with no retry: `diff` stops before its reorder pass and the active-tab op whenever anything is held, and nothing rewrites the set but the next rebuild — a re-prime and an `IfEmpty` hydrate on a populated window never get there — so a tab that fails to rebuild holds the window's tab order and active tab off the machine until the next restart. That state was already reachable, since a pane whose remote spawn failed stays connecting for the same span, held the same way; this widens a standing hole rather than opening one, and a retry, or a way to close a held tab from the window, is separate work. |
||
|
|
3c95995e82 |
fix(input): hand Ctrl+V to a full-screen program on the alternate screen (#677) (#682)
In vim or neovim on Windows and Linux, Ctrl+V pasted the clipboard where the editor expected blockwise Visual mode. Windows Terminal (with its ctrl+v binding removed), WezTerm and Alacritty all send the key; macOS was never affected, since Cmd+V is the paste chord there. Ctrl+V was not a keybinding at all. `on_key_down` hands plain Ctrl+C, V and X to `handle_cmd_shortcut` off macOS, and of the three the "v" arm was the only unconditional one: Ctrl+C copies with a selection and otherwise falls through to SIGINT, Ctrl+X falls through outside the editor, but Ctrl+V always consumed, so SYN never reached the PTY -- `input.rs` had the byte, unreachably -- and an empty clipboard turned the key into nothing at all. #270 set the rule that off macOS ctrl-<letter> belongs to the terminal and anything sitting on one must fall through; Ctrl+V was the exception that had escaped it. The arm is now contextual like its neighbours. On the alternate screen it falls through, and `keystroke_to_bytes` sends 0x16, or the CSI u form when the program has the kitty protocol on; off it Ctrl+V pastes exactly as before, and Cmd+V on macOS is untouched. The alternate screen is the gate rather than `input_active` because the editor is inactive whenever shell integration is missing or the prompt editor is off, and gating on that would take paste away from every such user; a program that has switched screens is precisely the case reported. Inside such a program paste is Ctrl+Shift+V, Shift+Insert or the right-click menu, all of which still stage a clipboard image for an agent. The same block did not exclude Shift, so Ctrl+Shift+C/V/X reached the hardcoded path whenever the keymap had nothing on them -- exactly the state rebinding Paste leaves behind, which #271 promised would retire Ctrl+Shift+V, but it went on pasting behind the user's back. Only unshifted chords enter the block now; the shifted ones are the keymap's alone. The right-click menu advertised Ctrl+C, Ctrl+X and Ctrl+V off macOS as though they were the bindings, next to a Select All row that already showed its hint on macOS only. The three rows take the same treatment, which is also what the command palette does. Three view tests pin the split -- Ctrl+V falls through on the alternate screen while Cmd+V still pastes there, Ctrl+V pastes off it, and a key down on the alternate screen arrives at the PTY as SYN and nothing else -- and the keymap's paste test now asserts that no default claims ctrl-v in the Terminal context. The shortcuts reference notes where plain Ctrl+V pastes and where it is the program's. Fixes #677. |
||
|
|
ef333bf055 |
feat(terminal): make the wheel-zoom modifier configurable (#676)
Cmd-scroll zoomed the font with no way to move it or switch it off, so a thumb left on Cmd resized the terminal mid-scroll (#668). The modifier is now a setting: the platform modifier by default, or Ctrl, Alt, or none. Stored as the choice rather than the resolved key, so one config file still means the same thing on a Mac and on a Linux box. Settings -> Terminal -> Mouse carries the picker; off macOS Ctrl and the platform modifier are the same key, so it shows one cell for them. |
||
|
|
8b5aeb0077 |
Wire hooks, resume and fork for the CLI agents that support them (#666)
* feat(agents): hook, resume and fork support for nine more CLI agents Hooks go from 7 agents to 11. Gemini, Droid and Qwen merge into their own settings.json the way Claude and Codex already do; Goose gets an owned file under the Open Plugins layout it implements. Qwen is the only one of them with a first-class PermissionRequest event, so it needs none of the notification sniffing the others do -- and deliberately gets no Notification hook at all, since that event fires for non-blocking alerts too and would strand a pane on "waiting". Resume goes from 10 agents to 17, fork from 5 to 9. Amp's `threads fork` is a real subcommand that is simply missing from `amp threads --help`. Four detection and replay bugs turned up while checking each CLI: - `python3 -m antigravity`, the documented way to trigger Python's own easter egg, was detected as a coding agent. The `antigravity` binary is the IDE's launcher shim anyway, in the shape of VS Code's `code`, not the terminal agent -- that one is `agy`. - Amp lost every launch flag on resume. It names a thread with a positional argument, so the stale-flag list had nothing to drop and the generic bare-token check rejected the whole tail along with it. - Gemini could be handed a command line it refuses to start from: `--session-id` and `--session-file` are mutually exclusive with `--resume` and were never stripped. - Cursor's `--continue` was not stripped either, leaving it to collide with the injected `--resume <id>`. Brand colours for Aider, Goose, Droid, Vibe, Qwen and Antigravity now come from first-party sources -- logo SVG fills and site CSS variables -- rather than approximations. Qwen ships its real mark instead of the generic bot glyph. Hooks stay unwired for Aider (no lifecycle mechanism exists at all), Cursor (its usable events gate permissions, and tty7's silent hook would read as a failed check and auto-allow the command), Auggie (its command field takes only script paths, needing generated wrappers, and the constraint could not be verified without a billed run), and for Hermes, Amp, Vibe and Antigravity, whose event sets are too thin to report a blocked turn. * fix(agents): strip every session-naming alias before replaying launch flags Goose spells --session-id also as --id, --name as -n, and keeps a legacy --path, all in one exclusive clap group; Qwen rejects --session-id next to --resume; Vibe shortens --continue to -c. Any of these surviving a replay broke the regenerated resume command. Qwen's --no-chat-recording also persists nothing, so it now opts the pane out of resume and fork like Auggie's --dont-save-session. The Qwen icon gains the 24x24 width/height every other agent mark carries. |
||
|
|
9c2869a25f |
Trim the app's long-winded copy, add four dark themes (#663)
* refactor(i18n): drop the About page shell primer and trim the long copy The About page carried a "How shells work" section explaining that shells live in a background server. Nothing linked to it and the Updates and Server sections below already say what happens to those shells, so it was a paragraph of prose the page did not need. Remove it, its search index entry, and its three L10nKeys. Then cut the padding out of 48 strings across settings rows, dialogs and notices. Two patterns accounted for most of it: the restart-server dialogs stated "your shells keep running" up to four times each in different words, and the config.json failure notices packed three subordinate clauses into every sentence. Nothing is dropped but repetition and clauses the reader can infer — every consequence a dialog asks the user to weigh is still spelled out. en, zh and ja stay in sync. * feat(themes): add Catppuccin Mocha, Gruvbox Dark, Nord and Tokyo Night Four more dark built-ins, taking the set from nine to thirteen. The docs table and description are updated to match. * fix(themes): give Catppuccin Mocha its rosewater caret, refresh a stale builtin count |
||
|
|
89e4ae833d |
fix(terminal): stop hidden panes from repainting the whole window (#670)
* fix(terminal): stop hidden panes from repainting the whole window Every pane's PTY pump ended a batch with an unconditional window.refresh(), and a pane in a background tab still resolves to its window there — so any hidden pane producing output pinned the visible tab at full frame rate. With 30 tabs, 29 of them chatty in the background, the window repainted at a steady 60 calls/s and the GUI process sat at ~45% CPU with nothing visible changing. Dropping the refresh is not enough: the chrome reads every pane entity while the window draws, so gpui tracks them all and a hidden pane's notify() dirties the window anyway. The pump's Wakeup notify is now gated on a per-pane displayed flag — an Arc<AtomicBool> outside the entity map, declared each frame by the root render (active tab true, everything else false). Flags default to displayed, so a path that never declares can only cost extra repaints, never a frozen grid. Low-frequency events (title, exit) keep notifying unconditionally so tab chips stay fresh. Same load after the change: ~20 renders/s driven only by the visible pane, ~520 background wakeups/s suppressed, and an idle window with 30 quiet tabs sits at a few renders/s. * test(terminal): pin the output gate's semantics; scope the registry per app The displayed registry moves from a process-wide static into a gpui Global. Entity ids are only unique within one App, and parallel gpui tests each mint their own App with colliding id sequences — through a static, one test's frame declarations could flip another test's pane flags. The shipped binary runs exactly one App, so behavior there is unchanged. Three tests now hold the gate to its contract: the active tab's panes count as displayed and a tab switch hands the frame loop over; a pane nobody declared (and an id nobody registered) errs toward displayed, because the failure direction that matters is a visible pane that stops repainting; and a released pane's flag does not outlive it. Also restores touch_active_tab's doc comment, which the previous commit had accidentally fused onto declare_displayed_panes. |
||
|
|
9f34cd3501 |
fix(editor): stop scrolled-out text painting over the line numbers
Bump the gpui-component fork to 070d1a2, which clips the editor's scrolling content to the right of the gutter. Text, selections, indent guides and the cursor all paint from a bounds origin that horizontal scrolling has already shifted left, so scrolled-out content kept painting under the line-number column; the only thing hiding it was the gutter quad painted afterwards, which works only while `editor.gutter.background` is opaque. `apply_theme` clears that key to transparent so the panel can sit on a gradient or image window background without a seam, which is exactly the case the upstream code does not cover. Note that dependency in the theme, so the next person to touch it knows the transparent gutter is not free. |
||
|
|
95305d50dd |
fix(sidebar): give the tab rows a width that resolves
The rows, their group blocks and the scroll area all ask for `w_full`, and a percentage is only a width while some box above it has a real one. The column inside the rail declared `size_full`, which is another percentage: on the passes that size that column from its content there was nothing for any of them to resolve against, so every row fell back to hugging the longest tab name and the active row's capsule stopped well short of the rail's edge. Hand that column real pixels instead. The rail is `w(px(width))` and layout is border-box, so its content is one pixel narrower because of the right border. With a definite width there, the whole chain below resolves — which also makes the same trick on `workspace_head` redundant, though it is left in place as a harmless explicit width. `w_full` on the scroll area itself is the second half: a stretched width sizes it the same but not definitely, and the rows inside need a definite one to be a percentage of. |
||
|
|
0295a98915 |
feat(sftp): open remote text files in the built-in editor
A click on a file in the SSH Files panel used to start a download; the only way to change a remote file was download, edit, re-upload. Now a click opens it in the built-in editor and Cmd-S saves straight back over the pane's own SFTP channel, matching what the Files panel already does locally and over a remote workspace. - protocol: SftpOp::ReadFile/WriteFile and SftpOpResult::File, bytes as base64; the reply carries the body plus the stat it was read under - daemon: ReadFile enforces the caller's size ceiling before and during the read; WriteFile rewrites in place (truncate, not temp-and-rename) so the file keeps its mode and ownership - SftpHost: a Host over the pane's SFTP route, so the editor's existing open/save path works unchanged; git/search/watch honestly Unsupported - editor: an open buffer holds the host it was read from, and save/reload/dedup/watch key on (host, path) instead of the active host - panel: single click opens (dirs navigate, text files edit), the same gesture as the local tree; binary or oversized files get the local tree's toast, and Download moves to the context menu Review follow-ups, in this PR: the SFTP host stays out of HostRegistry, which means "a machine this window has a link to" and is swept as such — filing the pane's channel there made Cmd-S return silently once a workspace deletion took it back out. The cursor-jump lookup, the status bar's path, and the SCM panel's repository all key on the buffer's own host now. Closes #656. |
||
|
|
7b0660bd42 |
fix(sidebar): give the workspace head a width that always resolves (#662)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
6e193c404f |
fix(editor): kill paths one component at a time on ctrl-w (#658) (#659)
The built-in command editor intercepts ctrl-w before the shell sees it, and its whitespace-only word boundaries killed a whole path in one stroke. fish binds ctrl-w to backward-kill-path-component, so users coming from kitty or Terminal.app expect /usr/local/bin to go one segment at a time. Mirror fish's path-component word motion: at most one run per character class, separators (slash, equals, quotes, ...) end a kill next to whitespace on their own. alt-backspace keeps the coarse whitespace-delimited kill, matching fish's split between the two chords. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
ccd21fe97d |
fix(windows): keep a restored screen out of ConPTY's viewport, and stop Restart Server crashing the window (#657)
* fix(restore): keep a restored screen out of ConPTY's viewport On Windows a restored pane came back with its shell drawing in the wrong place: the prompt stopped responding where it stood and the restored text filled with fragments of whatever was being typed. A ConPTY does not hand the terminal a stream, it hands it a rendering of a screen buffer conhost owns, addressed absolutely and counted from that buffer's top-left, which starts blank with the cursor at (0,0). PSReadLine redraws the line being typed as `ESC[6;20H ... ESC[6;26H` on every keystroke, and conhost frames what it paints the same way. Those row numbers are only right if the client's viewport is conhost's buffer, row for row. Restored output is output conhost never produced and knows nothing about. Left on screen it shifts every row conhost names, so the first repaint of the input line lands on the old text. Nothing the client can do fixes it afterwards: the offset is not constant, and it would have to be unpicked from every absolute address in the stream. So the restore preamble now ends by scrolling the restored screen out of the way. `ESC[2J` on the primary screen scrolls the viewport into history rather than erasing it, so the screen the daemon restored is one scroll up rather than gone, and `ESC[H` leaves the cursor where a fresh ConPTY expects to find it. Unix keeps the old behaviour: a shell there positions itself relatively, so the restored screen can stay where it can be seen. * fix(restart): stop Restart Server taking the window with it Clicking Restart Server made the whole app disappear, with a double-lease panic in the crash log: cannot read Tty7App while it is already being updated. The work that puts the window back together after the restart ran inside `update_in` on this window's own entity, and it ends by rebuilding every local window from the machine tree. The first thing that rebuild asks each window is which tabs it is showing, which it reads back out of the window registry — so the first window it reaches for is the one the closure already holds leased, and gpui answers a double lease by panicking, which on the main thread is the process. Split into `settle_after_restart`: the window's own state first, then the resync outside the lease, then the focus. The resync still runs either way the restart went, because a refused handoff leaves the daemon serving the panes this window already dropped (#554). --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
ac3c95a647 |
feat(update): install verified Linux AppImage releases in app (#306) (#652)
The last platform from #306: a Linux install running as an AppImage can now download, verify, and apply a release from inside the app, through the same tty7-updater helper the macOS (#309) and Windows (#330) paths use. Tarball and distro installs are deliberately untouched — they keep the named-package hint and the release page, because replacing a file a package manager may own is not this code's call to make. The installed artifact is one file, the path $APPIMAGE names, so the install is the simplest of the three platforms: stage the download beside the image (two renames only stay atomic on one filesystem), verify, swap, relaunch, and restore the preserved previous image if the new one does not survive its launch grace. What is Linux-shaped about it is the mount: the image the GUI runs from is FUSE-mounted by the AppImage runtime and torn down when the app exits, which is the moment the installer starts working — so the GUI copies the helper out of the mount into staging and runs the copy, the way the Windows path runs a private copy because Setup replaces the installed one. The daemon is left running throughout, as on macOS: nothing on Linux locks a running executable's file, and the panes it serves are the reason the update restarts only the GUI. The swap also carries the installed image's own mode onto its replacement, so a 0700 image stays private and the download's missing execute bit never reaches the installation. Verification holds the issue's requirements with what an unsigned ELF can offer: the bytes must match the release's checksums.txt, the file must actually be a type-2 AppImage — a mis-published asset fails with a name instead of at launch — and the image must state the version it claims. That statement is new: bundle-appimage.sh stamps X-AppImage-Version into the desktop entry, and the updater reads it back with one --appimage-extract, answered by the runtime before any application code and without FUSE. The same pass requires the new image to bundle its own tty7-updater, because an image without one would install fine and then be the last version that ever could. release.yml and nightly.yml now build the updater on the Linux leg and bundle it into the AppImage, and both check the packaged image for the same facts the updater checks on a user's machine — helper present, version stamped — so a packaging mistake fails the workflow instead of the update. The first release carrying this can only bootstrap: images already installed predate the helper and keep the manual hint, so the first complete in-app update is the release after it. |
||
|
|
9fc0f331e8 |
feat(tabs): drag a tab in as a pane, and a pane out as a tab (#651)
* feat(tabs): drag a tab in as a pane, and a pane out as a tab A tab dragged by its chip or its sidebar row can be dropped over the panes to become one of them, and a pane dragged by its grip can be dropped on the strip or the sidebar to become a tab of its own. Both carry the panes across as they are: nothing is spawned and nothing is killed, so a shell mid-command, an SSH session or an agent mid-turn keeps running. The landing is read the way a pane drag's already is, minus the middle: an arriving tab has nothing here to trade places with, so a pane's core means "split it the way it is longest". A tab that was itself split arrives with its own shape intact and takes one share of the row or column it joined. A pane on its way out is offered a caret between two tabs, and the last pane in a tab is offered nothing, being a tab of its own already. Picking a tab up no longer switches to it: the strip and the sidebar now activate on the click rather than on the press. Without that the merge cannot be expressed at all — pressing the tab to drag it would put it on screen, leaving no other tab to drop it into. Two things in the machine tree had to follow: * Panes that change tabs are told as PaneMove, one at a time, rather than as a tab closing and another being rebuilt around them. * The tabs the machine already has are reconciled before new ones are created, so a pane leaving for a tab of its own is given up by the old tab before the new one asks to register it. The machine refuses a pane that is in two tabs at once, and the refusal desynced the window. Closes #621 * test(tree-sync): a tab grafted above a whole layout still converges * fix(tabs): keep a click on the close button from switching tabs Switching on the release rather than the press means every click inside a chip or a sidebar row now reaches the row itself, and gpui-component's `Button` does not stop propagation on a click it handled. So one click on a tab's close button ran `close_tab(i)` and then `activate(i)` — with `i` by then naming whichever tab had slid into that slot, which moved the active tab somewhere nobody asked for. A click into the rename field did the same: it switched away from the tab whose name was being typed, and took the focus out of the field with it. Both now hold the click where they handled it, the way they already held the press. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
3ef644d267 |
fix(scm): keep the sync tile on the branch row at any panel width (#650)
* fix(scm): keep the sync tile on the branch row at any panel width The branch row's flex constraints were set on the Button, but `dropdown_menu_with_anchor` hands that Button to a `Popover`, which wraps it in a plain div and never applies the trigger style it was given (`trigger_style` is stored and never read). The constraints landed inside a box that still measured its own content, so at the panel's 216px floor a 24-character branch name overflowed the row and pushed the sync tile out of the panel entirely, with no way to reach it. Carry `flex_1` on a wrapper instead, and truncate the name against the width it is actually given rather than against a character budget that was guessing at that width. The `elide_middle` ceiling is gone: stacked on top of a real truncation it produced two ellipses in a row (`fix/new-tab-……`) and threw away the tail it existed to keep. Notes and chips move into one `overflow_hidden` group that is allowed to shrink, so the order of who gives way is explicit: branch name first, badges second, the tile never. Also stop offering "Publish Branch" from a HEAD that cannot publish. A detached or unborn HEAD has no upstream by definition, so the token fired there unconditionally — the widest thing on the row, naming the one operation the tile beside it already refuses (#545), and on its own enough to push that tile off a 216px panel. * fix(scm): drop the branch row's note box when it holds nothing The row lays the notes and chips out in one shrinkable box so the sync tile keeps its place. An empty box is still a flex item, so the row's 6px gap was spent on either side of nothing: on the quiet branch that is most of what anyone looks at, the caret sat 12px off the tile instead of 6px. Build the notes first and only add the box when there is something in it. Also drop two comment citations of #549, which is about palette commands that no-op silently and has nothing to do with this row. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
05de7ae33a |
fix(new-tab): keep the SSH menu inside a menu's shape (#649)
* fix(new-tab): keep the SSH menu inside a menu's shape The saved-host rows carried names and endpoints long enough to drag the panel out to the 500px ceiling PopupMenu falls back to, and the row that meant to elide was clipped mid glyph instead. The menu now stops at 360px, and a row that runs out of room cuts the endpoint first — the name is what the reader is picking by, so it keeps whatever is left rather than being squeezed to "..". The height ceiling moves up to fit the shape everyone actually sees — nine shells, both headings, six hosts and the two closing rows — so the default menu arrives whole instead of scrolled with "Local" cut off above, and is capped again against the window so a short one never gets a menu taller than itself. The rule above the split hint goes: a separator divides two lists of things to pick, and the hint is a footnote about the list it follows. Bumps gpui-component, where a scrollable PopupMenu painted a scrollbar whether or not it overflowed, custom rows could not elide, and labels had no padding of their own. * fix(new-tab): measure the menu ceiling off the viewport, and elide nameless hosts `window_bounds()` answers how a window should be reopened after it is closed, so a fullscreen macOS window reports the bounds it would restore to rather than the screen it currently fills. A terminal spends much of its life fullscreen, where that reading capped the menu at 80% of a window nobody is looking at — putting back the scrollbar and the cut-off `Local` this branch is here to remove. `viewport_size()` is what every other window-relative size in the app already measures against. A host saved on its address alone is *named* `user@host:port` and carries no note, so it took the plain-item path — bare text with nothing to elide against, on the longest string in the menu and the row least able to cut it. Every host row is a custom element now, and `menu_row` drops its right half when the note is empty rather than holding the gap open with a zero-width child. Also drops 17 unrelated dependency downgrades that rode along with the `gpui-component` bump. The lockfile moves only the three `source` lines it meant to; `cargo check --locked` accepts it. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
6031570798 |
feat(new-tab): reach saved SSH hosts from the New Tab button (#647)
Adds a saved-SSH-hosts section to the New Tab menu on both the tab strip and the sidebar, ordered by frecency, with a row that opens the full host palette. Holding the modifier opens the host in a split instead of a tab. The menu scrolls once the rows outgrow the popup, long host names and their endpoints truncate rather than overflow, a host with no name of its own draws its endpoint once instead of twice, and the rows are built when the menu opens rather than on every painted frame. |
||
|
|
cf6df5b469 |
fix(ssh): cover the whole window with the password prompt's scrim, and name the machine in a failed reconnect (#645)
Hoists the SSH password prompt's overlay from the body area to the window root so its scrim covers the title bar, tab strip and side panels, and aligns its top offset with the switcher card. Replaces the raw target string with the resolved machine label on the reconnect banner and on the connecting pane, so a profile-backed machine no longer shows a bare config UUID in "Connecting to …" or "Could not reach …". |
||
|
|
2e6103cf19 |
Retire to the tray on window close; cold start no longer stalls on stale daemon files (#639)
* feat(ui,daemon): retire to the tray on window close and make cold start immune to stale daemon files Two problems shared a root: the daemon outlived every window, and nothing could stop it gracefully. Window lifecycle: - Closing the last window retires the app to the tray instead of quitting (QuitMode::Explicit), so the daemon stays reachable. The tray restores the most recent workspace, and Quit — after the confirmation that protects running shells — stops the daemon. Every explicit exit path (tray, palette, keybinding) now stops the server; no exit leaves an orphaned daemon behind a dead icon. - A pathless launch (double-click) hands off to the registered GUI via GuiOpen(None) and exits, instead of starting a second process with a second tray icon. - The tray subsystem initializes once per process; reopening a window no longer creates a duplicate icon. Cold-start robustness: - Liveness connects are bounded to 500 ms, the version handshake times out in 1 s, and an unresponsive daemon is reaped by its recorded pid instead of polled for a 6 s graceful stop. - A dead recorded pid skips the TCP probes entirely — the GUI's ensure_running, the new daemon's endpoint check, and the control-listener occupancy check (which could also misread a reused port as a live control server and refuse to boot). Stale cleanup now also removes the leftover control.port. * fix(daemon,gui): skip the GuiOpen handoff probe when the recorded daemon is dead * fix(lifecycle): keep the stale-endpoint cleanup, and do not retire into a tray that is not there Three gaps in the tray-persist and cold-start work. `ensure_running` moved the refused-connect branch under the new liveness check, so a connect that fails while `recorded_daemon_is_dead` says "not dead" now skips the reap and the stale-endpoint removal entirely. The pidfile answers "not dead" to two cases it has no evidence about: it is missing (the daemon died between `transport::bind`, which writes daemon.port, and `pidfile::write_current`), or it records a pid the OS has since reused. Both then leave daemon.port on disk and the spawn poll pays the OS's refusal delay on it — the cost this path was rewritten to avoid. Restore the branch, and split the rule into `recorded_daemon_is_dead_with` so a test can state that a missing pidfile is not evidence of death, without an env var every parallel test would inherit. The tray's windowless Quit stopped the server without a prompt, reasoning that the confirmation is about the panes behind a window. It is not: it says "anything still running in your shells is terminated", and retiring to the tray is precisely what leaves those shells running with no window. Bring the window back and deliver the action to it, so the confirmation appears; only when no window can be opened does the bare stop remain, with a warning. `show_tray_icon` is a request, not an outcome. `Backend::create` can fail for a whole run — a Linux session with no StatusNotifier host is the ordinary case — and after MAX_ATTEMPTS the loop gives up and logs. Retiring on the config alone then leaves a process with no window and no icon: not reachable, and still holding the daemon. Gate the retirement on an icon actually being up. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
5c284799aa |
fix(tabs): stop a command that is over in a blink from flashing across the tab
The tab title follows the terminal's OSC title, and nearly every prompt framework sets that to the command it is about to run and puts the old title back at the next prompt. For anything that finishes in a blink both edges arrive within a few frames, so the label showed the command and snapped straight back — a flicker that reads as a rendering glitch rather than as information. Hold a new title for 400ms before the tab adopts it. A title that reverts inside the wait matches what the tab already shows and drops the pending one, so a short command never reaches the label at all; one still running when the wait elapses names the tab as before, 400ms later. A second title arriving mid-wait rides the wait already in flight instead of restarting it. Restarting is what would let a program that rewrites its own title faster than the wait — a download reporting progress — put the tab's next update off for as long as it ran. Child exit clears the pending title and writes its own immediately: a title still waiting its turn would otherwise land on top of "(process exited)" a moment later. |
||
|
|
3d6528737a |
fix(settings): give the page back its scroll range, and hold the bar off the window corner
The centring added in #631 turned the settings content box into a flex column, and that cost the page most of its scroll range: the box is an item of the scroll pane, which is itself a flex column, so its height came out of a negotiation with the pane rather than from the rows it stacks. `content_size` is just that box's laid-out bounds, so the range ended a screen short of the last row — dragging to the bottom still left content cut off. `flex_shrink_0` does not help; the height is agreed, not squeezed. Centre with `mx_auto` on the column instead and leave the box a block, which reports the full height it stacks. While there, hold the content scrollbar 12px clear of the top and bottom. Every other list this bar serves sits in a bordered panel where running the full height is right; this pane is the window, and a bar drawn to the last pixel lands on the rounded corner. New `with_inset_vertical_scrollbar` takes the inset, and the existing `with_vertical_scrollbar` keeps its behaviour for the other twelve call sites. |
||
|
|
f08d8c2764 |
fix(remote): stop the server on machines that have no /proc, and show the install on the strip (#627)
* fix(remote): stop the server on machines that have no /proc, and show the install on the strip Restarting the remote server timed out after ten seconds on every Mac and BSD, with the old daemon still running and the new binary already sitting next to it, unlaunched. Both the probe that finds the running `tty7-server-*` and the command that terminates it walked `/proc/[0-9]*` and read each `exe` symlink. There is no `/proc` there. Two things then went wrong at once. zsh is the login shell on macOS, and it aborts the whole command line when a glob matches nothing, so even the trailing `true` never ran; and `cycle_daemon` discards the result of the terminate, so a command that killed nothing was indistinguishable from one that worked. `daemon_is_serving` then answered yes until the deadline. Guard the glob behind `[ -d /proc ]` — unreached, it is never expanded, so zsh has nothing to abort on — and fall back to `ps`, whose `comm` is the full path on the BSDs. It cannot be the only branch: Linux truncates `comm` to 15 characters, one short of `tty7-server-c7p5`, which is why `/proc` stays the first choice where it exists. `check_running_build` reads the same probe and was equally blind on those machines; it can see now. Separately, the install progress bar only ever existed inside the switcher. Pressing Update Server from a parked workspace with no switcher open froze the window for the length of the download and then produced a modal, with nothing in between. The strip draws it too now — caption and bar from the same source the switcher uses, and no button while an install is in flight, since pressing it again would start a second one on top of the first. * fix(remote): say why a stop failed, and stop a leaked install from eating the strip's button Three things the no-/proc fix left standing. `cycle_daemon` still discarded the terminate's result, which is the other half of why a Mac cost a bug report: the command ends in `true`, so anything short of success means the far end never reached the kill at all, and that is exactly what a zsh abort looks like. It is now logged, and named in the timeout error — "the running remote daemon did not stop within 10s" on its own blames a daemon for ignoring a request nobody managed to send it. The strip hides its Update Server button whenever an install is in flight, which is right, but it reads the progress registry with no link state to temper it — unlike the switcher. `finish_connect` bows out before clearing that entry whenever `connect` has moved on in the meantime, and a switcher disconnect or a move to another workspace both do that mid-install. The leftover froze a progress bar on every window pointed at the machine and took away the one button that could have fixed it. Cleared where the attempt actually ends instead, however it ended. The switcher kept its own copy of the progress bar after the caption was shared; it draws the shared one now. Tests: the probe runs for real in every shell on the machine rather than only parsing under `sh -n` — the glob that started this was valid syntax and only fell over when zsh ran it, which no `-n` can see. `ps` is checked on its own where the fallback would actually be taken, since that arm eats its own stderr and a rejected flag would otherwise cost nothing visible. And a stop that fails is asserted to reach the error. `with_shutdown_timeout` exists so that last test does not sit out ten seconds. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
3bc8a764f7 |
fix(theme): stop the code editor painting its gutter and current line in the stock syntax theme's colours (#636)
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |