Commit Graph
1126 Commits
Author SHA1 Message Date
l0ng-ai 5ae63e2141 docs(diff): say what happens to the drag context the list is handed
`Drag` is read off the overlay once and moved into the item builder, not
cloned per row; the derive it never used said otherwise.
2026-09-07 22:41:53 +08:00
l0ng-ai ae43c67f40 Merge origin/main into feat/copy-from-diff-view
#799 landed first and rebuilt the overlay as one virtualised `gpui::list`
of flat rows, deleting the card, the hunk nesting and the three row
builders this branch had hung the drag on. The selection is re-applied
onto that model rather than rebuilt beside it.

**Where a row is.** `DiffRow::Split`/`Unified` now carry a `RowAt` — the
file's path and the `RowId { hunk, row }` the range is keyed on. The
list index is deliberately *not* that key: `resync_list` splices only
the rows between a shared prefix and suffix, so collapsing a file above
the selection moves every index below it, and a range keyed on those
would go on naming the same slots while the code under them changed.
`RowAt::path` is a `SharedString` so one rides on every line of a patch
for a refcount bump.

**Where the selection is read.** `diff_rows_list` clones it into a
`Drag` once a frame and the list's item builder reads that. It stays out
of `RowsKey` on purpose: a selection changes what a row looks like, not
which rows there are, so a step of a drag notifies, re-renders the rows
on screen, and never re-flattens the patch — `gpui::list` calls its item
builder for every visible row on each layout pass, so the highlight is
current without the key knowing anything about it.

**Where the menu is.** `diff_file_card` was the old host and is gone.
Copy Selected Lines now hangs on the selected rows themselves. The
overlay root was the other candidate and is not usable: the header
already carries `document_header_menu`, and gpui-component's context
menu opens on a bubbling right-press without stopping propagation, so
two of them over one click would open two popups.

The padding-cell fix from 87dc26f0..f7fd1d43 is carried across: a blank
half of a split row takes the same press and move listeners as any other
cell, and the selection colour when the range covers it.

Also drops `HunkRows::len`/`is_empty`, which only the deleted card body
called.

Tests: the drag suite moves to `selection_gpui_tests` and drives the
same cases against the new state, including the upward drag; two new
`diff_list` tests pin the coordinates a row carries — that they name the
patch rather than the list, and that they survive a collapse above them.
2026-09-07 22:40:07 +08:00
dependabot[bot] cb710c4d79 deps: bump async_zip from 0.0.18 to 0.0.19 (#764)
Also bump the version requirement in Cargo.toml, which dependabot left at 0.0.18 and which made every --locked job fail.
2026-09-07 22:23:28 +08:00
l0ng-ai f7fd1d43fd fix(diff): let a drag cross the blank half of a split row
A split row pads whichever side of a change has no line, and that half
was drawn inert: no I-beam, a press that started nothing, and a range
that visibly stopped at the padding and resumed below it. A one-sided
change is the ordinary shape of a diff, so the dead band runs down most
of one column — the drag it swallows is the common one.

The blank half now carries the same press and move listeners as any
other cell and takes the selection colour when the range covers it. What
lands on the clipboard is unchanged: `DiffSelection::text` already reads
past the padding, because the blank half is layout, not a line.

Also drives an upward drag through the real entry points. `range()`
ordering was only asserted in `diff_rows`, where a selection is built by
hand; nothing checked that a drag whose head ends above its anchor
copies the same rows.
2026-09-07 22:18:21 +08:00
l0ng-ai 081e191bb0 perf(diff-overlay): draw the patch as a virtualised row list (#799)
The overlay built its whole patch as a nested element tree on every frame:
a card per file, a header per hunk, six elements per line. gpui notifies the
view on each scroll wheel event, so a few hundred lines of diff rebuilt tens
of thousands of elements tens of times a second, and the window stalled.

Flatten the tree into one row per line in a new `diff_list` module and draw it
with `gpui::list`, which builds only the rows on screen. The rows are rebuilt
only when what they are built from changes, so scrolling no longer re-splits
hunks or re-clones every line, and a change to one file splices just the rows
it touched rather than resetting the list and losing the scroll position.

The key that decides a rebuild takes the snapshot each frame was asked about
even when it matched only by contents. A probe that finds nothing new still
lands a fresh `Arc` over an equal snapshot; a key left pointing at the old one
would go on walking the whole patch to prove the two equal, once per wheel
event, which is the cost the key exists to avoid.

A list counts a row it has not laid out yet as zero tall, which left the
scrollbar reading an 800-line patch as one viewport: its thumb filled the
track, and a drag from top to bottom travelled 248px and stopped. The rows
below the fold are counted at the 19px both views already give a line of a
patch, through `ListState::with_size_hint` — added to the gpui fork for this,
`Cargo.lock` following its `tty7` branch to `ece710e3`.

A card cannot survive that flattening — its rows are separate items now — so
the frame it drew is gone, and with it the grey header bars and hunk bands
that made the overlay the one view in the app still speaking gpui-component's
default container language. The rows take the source control panel's own
measurements instead: 26px, 10px inset, 5px radius, colour only under the
pointer. The title bar's view switch loses its border for the same reason.
2026-09-07 22:13:57 +08:00
l0ng-ai dac70ec4c8 perf(protocol): put a frame's header and payload on the wire in one write (#797)
Refs #713.
2026-09-07 22:11:06 +08:00
dependabot[bot] 6474e25a24 deps: bump the cargo-minor-patch group across 1 directory with 3 updates (#776) 2026-09-07 22:09:19 +08:00
l0ng-ai f6358494b6 test(remote): give each machine in the route-origin tests its own key (#798) 2026-09-07 22:09:07 +08:00
l0ng-ai 20b73adb2a chore(release): v26.9.1 v26.9.1 2026-09-07 21:00:55 +08:00
l0ng-ai 87dc26f061 feat(diff): let the pointer take a range of diff lines and copy it (#721)
The overlay drew its patch as a grid of independent text elements, so
there was no way to get code out of it: no selection, no clipboard, and
nothing to right-click. Reading a diff and then retyping what you read
is not a workflow.

Pressing on a row starts a range, dragging extends it, and Ctrl/Cmd+C
copies it — as does the new Copy Selected Lines item on the card's
context menu. The rows carry an I-beam so the offer is visible before
anyone tries it.

What lands on the clipboard is the file's own text: no +/- marker, no
line numbers, and tabs left as the file wrote them rather than the four
spaces the grid draws. In the split view a drag reads the column it
started in, so the left one gives the code as it was and the right one
as it is; in the unified view a row is a line. A range never spans two
cards, and it is dropped when the view mode flips or a fresh patch
lands, since either one re-cuts the rows underneath it.

Selection is line-granular. Half a line, or a range spanning two files,
is not on offer here.
2026-09-07 18:48:42 +08:00
l0ng-ai 893172f57d fix(control): raise the dialect to v9 rather than walk it back to 7
Reverting the projects layer took CONTROL_VERSION back to 7 with the
verbs it had been raised for. The dialect that number describes is
correct — this build speaks v7's messages again, one for one — but the
number is not, because v8 is already deployed.

A version that moves backwards stops being an identity. A 7 on the wire
would mean "before projects" or "after them" depending on which build
sent it, and the handshake has nothing but the number to tell those
apart: a v8 peer would read our 7 as an older server it may keep
talking to, and every project verb it pushed would fail to decode and
drop the link.

v9 is a number no peer has seen, so a v8 peer is turned away at the
handshake instead. Nothing else changes; every other reference to the
constant is relative to it.

Claude-Session: https://claude.ai/code/session_015q6HRem76HYy33T39bp34c
2026-09-07 11:24:37 +08:00
l0ng-ai 31ce382226 revert: drop projects as a declared sidebar layer (#769)
Reverts cebd871c. The declared layer sat beside the derived repo groups
rather than replacing them, so the sidebar carried two kinds of heading
that look alike and behave differently, plus the verbs to create, rename,
re-root, reorder and delete one. That is more interface than the problem
was worth, and the derived grouping already covers the common case.

Everything the commit touched goes back: the Project entity on the
machine, the per-tab reference, the six control verbs and their layout
deltas, the sidebar rail and tab strip surfaces, the mirror and tree-sync
paths, and the i18n strings.

CONTROL_VERSION drops back to 7 with the verbs it was raised for. A
remote box still serving the v8 build will be turned away at the
handshake until its tty7-server is replaced.

Sessions written while projects existed still load: `projects` and a
tab's `project` are unknown fields now, and serde drops them.

Claude-Session: https://claude.ai/code/session_015q6HRem76HYy33T39bp34c
2026-09-07 11:01:19 +08:00
l0ng-ai be2eb4c086 fix(daemon): clear a stale pane socket before binding it (#779)
A daemon that died without unlinking its Unix socket stopped every later
daemon on that machine from ever starting. The client launched one, it
exited on the bind, the client launched another, forever.

run_with does clear a stale endpoint, but only after a probe it skipped
whenever the recorded daemon was known dead — reasoning that the bind below
would overwrite the file. That is true of a Windows port file, and the whole
of #639, where the skip came from, is Windows ports: it added a connect
timeout so a firewalled stale port fails fast. On Unix the endpoint is a
socket file and bind refuses any path that exists, so the one path that
reached the bind with a leftover still there was the one that skipped its
removal.

What decides the removal is now the single-server seat, not the pidfile.
Holding it means nobody else can be serving this config dir, so anything
still at the endpoint belongs to a process that is gone — safe by
construction, which is the property `singleton` exists to provide. Removing
on one failed connect instead is the race that module was written to retire,
so it is used only where there is no seat, and even there a socket that
answers is refused rather than removed.

Reproduced on a Linux box: with a socket nothing was behind and a pidfile
naming a dead pid, main exits 1 on the bind and this starts and serves.

Claude-Session: https://claude.ai/code/session_015q6HRem76HYy33T39bp34c
2026-09-07 10:38:35 +08:00
l0ng-ai 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
2026-09-06 23:08:49 +08:00
l0ng-ai 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.
2026-09-04 21:19:05 +08:00
l0ng-ai 37be703d5b chore(release): v26.9.0 v26.9.0 2026-09-04 17:42:08 +08:00
l0ng-ai 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
2026-09-04 17:29:17 +08:00
l0ng-ai cd70338486 fix(daemon): let a clean version probe clear the mismatch record (#770)
* fix(daemon): let a clean version probe clear the mismatch record

The restart prompt was armed from a global that only ever accumulated:
`note_daemon_mismatch` could set it, and nothing could take it away
except the window that consumed it. `ensure_running`'s agreeing path
never touched the record at all.

That matters because `ensure_running` is the first thing every
control-link reconnect attempt runs, and a mismatched daemon is one no
connect succeeds against. The link backed off and retried, arming the
prompt again each time round — including in the seconds the user spent
reading the dialog it had already opened. Restarting the daemon then
fixed the daemon and not the record, so the next window built took that
last arming and asked a second time about a server that was already
gone.

Make a probe's verdict settle the record rather than only add to it: a
daemon found to be ours wipes what an earlier probe left. The probe
judgement moves into `judge_probe`, and the handoff's own return
judgement into `judge_handoff_return` / `land_handoff_return`, so both
are testable apart from the sockets it takes to reach them.

Claude-Session: https://claude.ai/code/session_01GAjHNse9BDu5jSCjU5QTKe

* fix(daemon): tell a silent control socket apart from an agreeing one

`control_dialect_refusal` returned `Option<DialectRefusal>`, and `None` meant
both "it answered with our own dialect" and "it never answered at all" — a
connect that failed, the handshake timing out, a peer that hung up before
`HelloOk`. That conflation was harmless while silence only meant "record
nothing". It stopped being harmless when a clean verdict started *clearing* the
mismatch record: a control socket that times out now wipes a refusal the
control link had already met, and the window built next opens with no tabs and
nothing on screen to explain why — the exact state the record exists to
prevent. The function's own doc comment still promised the opposite.

Give the answer three shapes (`DialectAnswer::{Agrees, Refuses, Silent}`) and
carry the distinction through to the verdict (`MismatchVerdict::{Clear, Found,
Unchanged}`). Only `Agrees` clears. Silence leaves the record exactly as it
stands, which is the cheap side of the asymmetry: a stale record costs one
prompt about a daemon that turned out fine and the next probe takes it away,
while a wrongly cleared one costs a window its tabs.

The landing also moves out of `ensure_running` into `land_probe`, so the
logging and the record write are one thing a second caller can reuse.

Claude-Session: https://claude.ai/code/session_01GAjHNse9BDu5jSCjU5QTKe

* fix(daemon): settle the mismatch record on the restart path too

`ensure_running` only settles the record on the branch where a daemon is
already listening and answers. `restart()` is `stop()` + `ensure_running()`, so
by the time it runs the connect is refused, the endpoint is reaped, a daemon is
spawned, and the startup poll loop returns having written `note_local_daemon`
and nothing else. The record still describes the daemon the user just killed.

That is the path a mismatch is most likely to take. A daemon from before
protocol versioning reports no version at all, so `local_daemon_supports`
answers false for the handoff feature and the prompt's Restart takes
`restart()`, not `hand_off()` — the one branch that was given a clear.

Judge the freshly spawned daemon with `judge_probe` and land it with
`land_probe`, the same pair the already-running branch uses. The control
listener is up before the pane endpoint binds, so the dialect can be asked this
early; a daemon that answers neither handshake is still recorded as nothing,
same as before.

Claude-Session: https://claude.ai/code/session_01GAjHNse9BDu5jSCjU5QTKe

* fix(daemon): drop a probe verdict about a daemon that is already gone

A probe is not one instant: it connects, asks the pane endpoint and the control
socket, and only then writes what it found. In between, this build can stop the
daemon, hand it off, or spawn a new one — and now that a verdict *settles* the
record rather than only adding to it, a late one is not a stale read but a
wrong write.

Against a mismatched daemon that is the normal case, not a corner. The control
link retries on a backoff and every retry runs `ensure_running`, so when
`land_handoff_return` clears the record, a probe that connected to the outgoing
image before the exec lands afterwards and re-arms the prompt about the daemon
the user just replaced. The clear was best-effort against its own retry loop.

Stamp each verdict with a counter that moves whenever this build deliberately
changes which process serves — `stop`, `reap_stranded`, `spawn_detached`, and
the handoff at the point the exec is asked for — and drop a landing whose stamp
is stale. Probes against the same daemon are still last-one-wins, which is what
a record of "what is running now" should do.

Claude-Session: https://claude.ai/code/session_01GAjHNse9BDu5jSCjU5QTKe
2026-09-04 15:58:59 +08:00
momoandl0ng-ai 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>
2026-09-03 16:06:58 +08:00
ayamirandl0ng-ai 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>
2026-09-02 14:12:28 +08:00
oldhuandl0ng-ai 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>
2026-09-02 14:07:59 +08:00
l0ng-ai 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
2026-08-28 19:56:50 +08:00
l0ng-ai 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.
2026-08-28 18:19:10 +08:00
l0ng-ai 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
2026-08-28 18:15:54 +08:00
cloudy 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.
2026-08-28 17:39:46 +08:00
webdevandl0ng-ai 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>
2026-08-26 21:35:55 +08:00
webdevandl0ng-ai 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>
2026-08-26 20:51:40 +08:00
webdev c23483ba85 fix(ssh): close every channel tty7 abandons before the server does (#715) (#727)
A remote link worked for a while, then every operation on it failed with
"could not identify the remote machine: could not open a command channel:
Failed to open channel (ConnectFailed)", and Try Again only made it worse.
sshd was refusing the session channel: its stock MaxSessions is ten, and
tty7 had left ten open on the cached connection.

russh closes a channel in exactly one case. When the server sends
CHANNEL_CLOSE first, the session task answers it on arrival. Dropping a
`Channel` sends nothing — the one close-on-drop it has sits behind
`into_stream`, which the remote link and SFTP already ride and which a
command whose output is read with `wait` does not. So a command that ran
and exited cost nothing, and a channel abandoned while the far side was
still running it cost a session for the life of the connection. There
were four ways to abandon one. The installer's `exec` returned early on a
failed exec request, and more to the point was dropped mid-drain by the
timeouts in `run` and `spawn_detached`: a `uname` that hangs or a daemon
launch that does not answer within its budget is what those timeouts are
for, and each one pinned a session. The shell and env probes broke out of
their drain on EOF or at their output limit and dropped the channel. And
`drive_channel`, the pane's own shell, closed only on the pane's Close: a
pane whose reader had gone while the shell still ran broke out of its
loop and left that shell's session held for as long as the cached
connection lived. That last one is the "after some use".

A command now rides a `CommandChannel`, which closes on drop: the `?`
after the open, the normal return and the timeout's cancellation all
queue the CHANNEL_CLOSE for the session task, the way russh's own
close-on-drop does. The runtime it spawns on is taken at construction,
on the runtime by definition, rather than looked up from whichever
thread the drop lands on. The probes ride the same type. `drive_channel`
closes after its loop on every exit; after a close the server sent
first, russh has already taken the channel out of its table and the
redundant EOF and CLOSE put nothing on the wire.

The safety net, for a leak this change did not find: a connection whose
session open comes back ConnectFailed marks itself dead, and `is_alive`
is what the cache consults before handing a connection out again, so the
next Connect — Try Again included — dials afresh instead of retrying a
link that will refuse forever. It is not the fix: a fresh connection to
a leaking client is ten operations from the same wall. The shell probe
also no longer remembers a "no integration" it got from a link that
refused it a channel, which would have kept integration off that host
for the rest of the run.

The install layer is tested against `FakeRemote`, which has no wire, so
none of this was visible. An SSH server now runs in the test process —
russh's server half, accepting every session up to a limit and answering
`exec` as a command that exits or one that hangs — and counts the
channels the client opened and closed. It shows a timed-out command
closing its channel, twelve abandoned commands against a limit of ten
with none refused, a finished command's close answered exactly once, a
gone pane closing the shell behind it, and a refused open retiring the
connection. Each was checked against the old code. What it cannot show
is sshd's own accounting; the reporter did that, with a paramiko script
that exec'd freely while closing each channel and was refused on the
eleventh it left open.

Diagnosis and reproduction by xAlisher.
2026-08-26 20:11:52 +08:00
l0ng-ai ba6760c6f9 fix(restore): carry the pane's title in the snapshot so a restored tab keeps its name (#725)
A tab's title only exists as live terminal state: the OSC that set it was
emitted screens ago, and the on-disk snapshot is capped at 256 KiB, so the
bytes that would restore it are almost always trimmed away. Since #681 made
a silent attach fall through to a fresh spawn, a slow daemon on reopen turns
every such tab into the default "tty7".

Store the pane's last OSC title beside the snapshot's segments (as a
trailing field old readers skip and old files simply lack), and replay it as
a fresh BEL-terminated OSC 0 -- control bytes stripped so a stored title
cannot terminate the sequence early -- before the restore preamble. The new
pane's daemon record inherits the title too, so the switcher and CLI agree.
2026-08-25 16:54:56 +08:00
l0ng-ai 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.
2026-08-25 16:51:00 +08:00
l0ng-ai 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
2026-08-25 16:41:28 +08:00
l0ng-ai 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.
2026-08-25 16:16:08 +08:00
dependabot[bot] 436e9c4320 deps: bump the cargo-minor-patch group with 2 updates (#724)
Bumps the cargo-minor-patch group with 2 updates: [uuid](https://github.com/uuid-rs/uuid) and [ureq](https://github.com/algesten/ureq).


Updates `uuid` from 1.24.0 to 1.24.1
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.24.0...v1.24.1)

Updates `ureq` from 3.3.0 to 3.4.0
- [Changelog](https://github.com/algesten/ureq/blob/main/CHANGELOG.md)
- [Commits](https://github.com/algesten/ureq/compare/3.3.0...3.4.0)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 1.24.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-minor-patch
- dependency-name: ureq
  dependency-version: 3.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-25 16:09:02 +08:00
l0ng-ai 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.
2026-08-21 16:05:16 +08:00
l0ng-ai 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.
2026-08-20 23:33:26 +08:00
l0ng-ai 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.
2026-08-20 22:35:32 +08:00
l0ng-ai 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.
2026-08-20 21:56:34 +08:00
l0ng-ai d8ac3ef454 docs(readme): fix the fork column and restate the feature tables
The support matrix left Fork blank for Droid, Qwen, and Goose, but all
three have a fork command in CLIAgent::fork_label and hooks in
HookAgent::ALL, so the menu entry is reachable. Amp stays blank: it has a
fork command but no hooks, so no session id ever arrives and can_fork
never turns true — which the intro paragraph now states.

Also restores "click places the caret" and IME to the input and window
rows, folds the prose that had crept into the Agent-aware, CLI, and Git
cells back into scannable fragments, and drops the Why lede's repetition
of the three bullets directly beneath it.
2026-08-20 21:49:31 +08:00
l0ng-ai 024d368925 docs(skill): restructure the agent skill around a delegation playbook (#702)
* docs(skill): restructure around a delegation playbook

SKILL.md becomes a slim routing layer: a what-are-you-here-to-do section up
front, the pane/run/wait primitives, and four delegation rules that survive
even when the reference is skipped. Everything specific to running another
agent moves to references/delegation.md, which adds what the old text never
had: per-worker git worktree isolation, a delivery contract collected through
git instead of screen scraping, a launch-verification checklist, a babysit
loop, and a fan-out harvest with short per-worker timeouts so one stuck
worker cannot stall the round. Also replaces the last remaining 'claude -p'
example (the fan-out one #699 missed) and keeps every snippet valid under
both bash 3.2 and zsh.

* docs(skill): un-deadlock the fan-out harvest loop

Fresh read of SKILL.md and references/delegation.md. Every internal anchor
resolves and the two files agree on the primitives; three things did not
hold up:

- The harvest loop passed `--changed`, which cannot work there. `wait`
  compares against the state standing when *that* wait began, so a worker
  that reached `done` while you were waiting on a different one is already
  in `done` when its own turn in the round comes up — refused, every round,
  forever. Each pane runs one turn, so a standing `done` is this turn's;
  drop the flag and note the one thing it was buying (a just-answered
  `waiting` worker needs to leave that state before it is requeued).
- The same loop folded `wait`'s exit 1 into its 124 branch, so a pane that
  died got requeued instead of reported — and requeued at full speed,
  since a dead pane answers immediately. Split the three codes.
- SKILL.md described `--plain` unwrapping "a line the shell wrapped at
  column 249" while two other passages state a pane is 120 columns.
  Say "at the pane's width", as references/commands.md already does.

No typos or grammar slips found. Every bash block in both files parses
under bash 3.2 and zsh.

* docs(skill): two failure modes from the playbook's first live run

Dogfooded the delegation playbook end to end (worker reviewing this very
file). Two failures it hit that the text did not cover:

- A turn aborted by an API error emits no turn boundary, so the status
  stands at 'working' forever and wait sleeps through it. Diagnose from
  the screen's error line; recover by telling the still-alive interactive
  session to continue.
- A short capture tail cuts off the spinner line and shows only the TUI's
  always-present input box, which reads as idle. Tail 15+ lines and read
  for the spinner; 'bottom looks like a prompt' is only evidence on a
  shell pane.
2026-08-20 21:02:11 +08:00
l0ng-ai 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
2026-08-20 20:51:36 +08:00
l0ng-ai 8131ac2f93 Address a tab by the bare id --json prints, and stop the skill sending workers in headless (#699)
* fix(cli): address a tab by the bare id --json hands back

`parse_tab` required the `@` sigil, so the tab id from `tty7 tab new --json`
— the one id a caller is certain of — was the one shape the CLI refused.
`parse_pane` already made `%` optional for exactly this reason (#538); this
aligns tabs with it, keeping the digits-only guard so a leading `+` cannot
read as an ordinal now that the sigil is gone.

* docs(skill): hand a pane worker its interactive mode

The worked example passed the task with `-p`, which draws nothing: the pane
stays blank until the turn ends, `capture --plain` reads back empty, and the
user watching their tty7 window sees a worker that looks hung. Putting a
piped worker in a pane discards the only reason it is in one.

Also documents three things that cost real debugging time: a fresh pane can
swallow the Enter while its shell is still running startup files, `tty7 procs`
reports nothing running for a pane with a live agent in it, and the OSC 777
event stream in a raw `capture` is what actually answers "is it moving".
2026-08-20 18:28:48 +08:00
l0ng-ai 47e25ef854 fix(ci): judge a Mach-O's signature by codesign's exit status (#696)
`codesign -dv` spells its signature line differently per posture:
`Signature=adhoc` for an ad-hoc or linker signature, `Signature size=8968`
for a Developer ID one with a timestamp. The check matched the literal
`Signature=`, which the second spelling does not contain.

While the script only pointed at the standalone tty7-server, which is
ad-hoc signed, that was invisible. #692 pointed it at the bundle's
tty7-app, tty7 and tty7-updater as well, and those are Developer ID
signed whenever the signing secrets are present. Pull requests do not see
the secrets, so every PR run took the ad-hoc branch and passed; the first
build that signed for real — the nightly — failed on all three binaries,
printing `CodeDirectory`, `Signature size=8968` and a Developer ID
`TeamIdentifier` as its proof they carried no signature. The binaries
were signed, notarized and stapled; only the assertion was wrong.

Exit status has no such split: 0 for anything signed, 1 with `code object
is not signed at all` for anything not, verified against all three
postures. The output is still captured so the failure message carries it.
2026-08-20 14:00:40 +08:00
webdevandl0ng-ai 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>
2026-08-20 10:14:49 +08:00
Austin Spragginsandl0ng-ai 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>
2026-08-20 09:51:05 +08:00
Wh1teandl0ng-ai 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>
2026-08-20 09:47:26 +08:00
webdev edfea5b830 ci(macos): assert every Mach-O in the bundle is the arch it ships as (#687) (#692)
A macOS 26 user opened the Apple Silicon build and was told it "contains
Intel parts" (#687). Downloading what is actually published — v26.8.2,
v26.8.3 and the nightly after #605 — and reading every file's Mach-O
header says otherwise: the three binaries under Contents/MacOS are thin
arm64, nothing else in the bundle is Mach-O at all, and tty7-app's load
commands are all /System/Library/Frameworks and /usr/lib. The build is
right today. The likeliest reading of the warning is macOS pinning an
x86_64 program someone ran in a pane on tty7.app as the responsible
process — the same attribution bundle-macos.sh already documents for
TCC — and that belongs on the issue, not in this change.

What does belong here is that nothing would have caught it if the report
had been right. assert-macho.sh knows how to say "this is a 64-bit
Mach-O for <arch>, it links only what macOS ships, and it is signed", and
since #605 it has said it — about the standalone tty7-server asset, and
only that. It has never been pointed at anything inside the .app. A
helper built without --target on an Intel runner, a dylib dragged in
from /opt/homebrew, a universal binary from a toolchain that decided to
be helpful: each would have zipped, notarized and shipped, and the first
check would have been a user's Finder.

So check the bundle, in bundle-macos.sh, where release.yml and
nightly.yml both build it. After the signing block — assert-macho.sh
insists on a signature, and this way one pass covers Developer ID and
adhoc alike — and before the update zip and the DMG, so a bundle that
fails never becomes an artifact, and before the `mv` that dissolves
dist/tty7.app. First the binaries the script staged itself: tty7-app,
tty7 and, when it is packaged, tty7-updater, each through
assert-macho.sh at the full standard the server asset is held to. That
also leaves every shipped binary's load commands in the release log,
which is where the next report of this kind gets answered from. Then a
sweep of every file in the bundle: `file` says which are Mach-O of any
kind, `lipo -archs` names the slices in each, and the answer has to be
exactly the matrix arch. Any other name is the wrong build; two names is
a universal binary, which is what the report described. lipo judges
rather than a parse of `file`'s prose because Apple's `file` and
upstream libmagic word the arch differently and lipo's slice names do
not move. A sweep that finds fewer Mach-Os than the binaries staged
above fails as well, so a changed wording cannot quietly turn it into a
no-op.

On a Developer ID build this runs after notarization, which spends a few
minutes of notary time on a bundle that was never going to ship. Cheap
next to carrying a second copy of the block inside each signing branch.

Deliberately not a fix for what the reporter saw, if it is the
child-process attribution: no check at build time can speak for a binary
the user runs inside a pane. What it guarantees is narrower and worth
having — the bundle named arm64 contains nothing but arm64, and a release
where that stops being true fails on the runner.

Validated with bash -n and shellcheck, and by running the sweep — and
the whole script in its adhoc posture — on Linux against fake bundles
with file, lipo, otool, codesign, ditto and hdiutil stubbed: a clean
bundle passes and packages; a wrong-arch updater, a universal tty7-app,
a stray x86_64 dylib, an arm64e nested bundle and an empty bundle each
fail and name the file, and nothing is zipped after a failure. Not yet
run on a Mac; the next nightly is what answers that.
2026-08-20 09:30:42 +08:00
webdev 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 a7835a0 made for SARA AM: two width-1 codepoints
that own a column each but are not atomic to the shaper. The check sits
ahead of the marks branch so a stray mark on either half (a VS16 on the
first, say) rides along in the cluster text instead of splitting the
pair — split, the other half paints alone as a box again.

`wide_base` stays false. With the ligature there is one glyph at
position zero and the pinning is moot. Without it — a font that lacks
the flag — the shaper returns two glyphs, and `force_width = cell_width`
pins the second into the second column, where it stays visible — the
two legible halves such a setup shows today. `wide_base: true` would
pin it at `2 × cell_width`, past the cluster's two-cell clip, and
swallow half the pair.

The edges fall out of the scan. An indicator in the last column has no
partner on its row and stays `Solo`; two halves of a flag on different
rows cannot be joined, and drawing them apart is the honest answer.
Three in a row pair greedily left to right, which is UAX #29's rule for
them. A style change between the halves keeps the cluster under the
first cell's style, as for SARA AM: a recoloured flag beats two boxes.
Selection, copy, cursor placement and reflow read the alacritty grid,
not `RowSeg`, and are untouched.

A grid-level test feeds 🇨🇳x through the emulator, `snapshot_cell` and
`segment_row`, pinning the premise that each indicator lands in one
plain column. A unicode-width or alacritty bump that changes that fails
there rather than misdrawing quietly.

Out of scope: skin-tone modifiers and ZWJ sequences. Those are a
grid-width problem — alacritty reserves four or six columns for them —
and nothing here touches them.
2026-08-20 08:56:09 +08:00
l0ng-ai 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.
2026-08-19 18:03:51 +08:00
l0ng-ai 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.
2026-08-19 17:38:28 +08:00
webdev 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.
2026-08-19 14:24:12 +08:00