Commit Graph
1147 Commits
Author SHA1 Message Date
ayamir fc94022ed0 feat(agent): add TraeCode CLI support (#807)
* feat(agent): add TraeCode CLI support

* fix(settings): index TraeCode agent hooks
2026-09-08 16:57:04 +08:00
l0ng-ai ca9bb747e4 fix(ui): let a folded sidebar group hide its active row too (#806)
A fold left the active tab's row on screen, so folding the group you are
working in drew a shut chevron with one row hanging under it and a header
counting rows that were not there — it reads as a list that failed to load,
not as a group you closed.

The exception existed to keep Cmd-T inside a folded group visible, since
`spawn_group` seeds a new tab with the group it came from. That cost is
taken instead: the pane area shows the fresh shell and the header count goes
up, and the row waits for the group to be opened. Auto-unfolding on spawn was
the other option and is worse — it only fires when the repo probe already hit
the cache, so a cold tab parks in Scratch and moves into its group later
without passing through it, and a magic that works half the time is harder to
read than none.

Claude-Session: https://claude.ai/code/session_01XD6R419Hy1CV1CeVZSRBf7
2026-09-08 16:56:57 +08:00
l0ng-ai 4c02ad3881 feat(ui): fold sidebar groups; stop the diff header clipping its controls (#804)
A sidebar group folds shut when its header is clicked, and stays shut across
launches. A search outranks the fold — a row a live query matches shows
whatever its group says — and so does the active tab's own row, which a fold
must never hide: `spawn_group` seeds a new tab with the group it came from, so
otherwise Cmd-T inside a folded group would draw nothing but a header count
going up by one. Folded rows register no rectangle, so a pane cannot be dropped
into a group that is shut, and the header still counts every row the group has.

Separately, the diff overlay's header stops clipping its own controls. Every
element on it but the commit subject was `flex_shrink_0`, and one of those was
a focused file's path, which is unbounded — in a docked column the two things
you can actually click were pushed off the end and clipped mid-word. The path
and the byline now yield first, ahead of the subject, because each has a second
home elsewhere in the overlay and the subject has none; the path yields
head-first so the filename is the last thing to go. `split_path_leaf` moves to
`path_display` with its tests, which is where a path's display spelling lives.
2026-09-08 12:13:03 +08:00
l0ng-ai 644945d137 style(ui): flatten the inline controls and give tooltips a real shortcut slot (#803)
* style(theme): drop the lift under every inline control

Theme::shadow gates exactly one thing -- the shadow_xs an inline control
(button, input, select trigger, checkbox, radio, slider knob) paints under
itself -- and never the drop shadow on a menu, tooltip or popover, which each
draw theirs unconditionally. Left on, every field and button in the window
carried a faint lift that nothing else here has: this chrome separates surfaces
with low-contrast fills and hairlines, so a control sitting a millimetre above
the panel was the one place claiming depth. Panels that really do float keep
their shadow.

Also bumps the gpui-component pin, and records why the switch and slider keep
their accent: both were tried on the neutral ramp the segmented controls use,
and a dark-grey "on" against a light-grey "off" turned out not to be a large
enough step to read while scanning a column of rows.

* style(tooltip): render a chrome tile's chord as a chord

chord_hint pasted a label and its shortcut into one string -- "Hide sidebar
<cmd>B" -- and handed that to Button::tooltip. Inside the card the chord then
wore the label's own size and colour, so the tooltip read as one odd sentence
rather than as a name with a shortcut beside it.

Tooltip already has a key_binding slot that sets a chord apart on the right, a
size down, in muted_foreground. What was missing was a way to hand Button a
built tooltip instead of a string; gpui-component grew tooltip_element for
that. chord_hint becomes chord_tooltip, and key_hint gains a key_stroke sibling
so a caller can reach the Keystroke rather than only its formatted text.

* style(settings): one field width, and a chevron that is not a patch

The right-hand column had three widths, each picked where it was written: text
fields 260, sliders 240, dropdowns 180. Every row still ended on the same right
edge, so on one page the difference read as controls aligned carelessly rather
than as controls of different kinds -- and moving between Appearance and
Terminal, where the mix differs, the column visibly changed width. FIELD_W is
the one number now, at 260, the widest of the three because it is the one with
a requirement behind it: a font name or a shell path has to fit untruncated.

The Program row's shell picker was a ghost button, which fills a rounded
rectangle while its menu is open, sized by hit_target to the 24px
accessibility floor -- exactly the field's inner height, so that fill met the
border top and bottom and looked like a patch stuck over the field's right end.
It now draws with no fill in any state, the way Select draws its own chevron.
Its menu was min_w(200) anchored TopRight on a chevron that sits inside the
field, so it hung off the field's right half with its left edge 110px in from
the field's own; it is now as wide as the field it drops out of.
2026-09-08 08:34:13 +08:00
l0ng-ai d16746a9af fix(deps): restore the lockfile edges #799's merge walked back (#802)
The merge for #799 re-resolved Cargo.lock and pointed twenty consumers at
older copies of dependencies that were already in the tree for other
crates. No `version =` line moved, so the change is invisible to the usual
scan of a lockfile diff, but the graph regressed:

  * 15 crates off `windows-sys 0.61.2` onto `0.60.2`
    (anstyle-query, anstyle-wincon, dirs-sys, errno, miow, muda,
     nu-ansi-term, quinn-udp, rustix, socket2, stacker, tempfile,
     tray-icon, uds_windows, winreg)
  * `winapi-util` off `windows-sys 0.61.2` all the way onto `0.48.0`
  * `gpu-allocator` off `windows 0.62.2` onto `0.58.0`
  * `iana-time-zone` off `windows-core 0.62.2` onto `0.58.0`
  * `dlib` off `libloading 0.8.9` onto `0.7.4`
  * `bindgen` off `itertools 0.13.0` onto `0.11.0`

Nothing in that PR asked for it. Its only dependency change was the gpui
fork rev, and the range it moved over touches one file in
`crates/gpui/src/elements/list.rs` and no manifest, so the resolution was
incidental to the merge rather than required by it.

This points those twenty edges back at the versions they held before, which
is what a fresh resolve picks. Every version already present in the lock
stays present: `windows-sys 0.60.2` is still there for `notify 8.2.0`,
which pins `^0.60.1`, and the older `windows`/`windows-core`/`libloading`/
`itertools` copies still serve their own consumers. So this drops no
duplicate builds; it only stops the newer copies from being compiled
alongside older ones for crates that had already moved on.

Lockfile only. No manifest and no source changes, and `cargo metadata
--locked` accepts the result without wanting to rewrite it.
2026-09-08 08:18:26 +08:00
l0ng-ai bfabe05375 fix(terminal): submit the line the shell is still holding, not an empty one (#800)
Refs #433.

Adopts the held seed at the editor's own doors rather than at submit time, so a recalled history entry, a ctrl-U, a ghost suggestion or a completion is no longer glued to the front of the gap text.

Also closes a paste-provenance hole left by #790: release_hold()/dump_hold() pushed the hold's contents into the typeahead record as plain text, dropping the paste mark, so a paste made during a gap that outlived the hold window came back looking typed and was submitted raw through the shell's binding table.
2026-09-08 00:22:24 +08:00
l0ng-ai cae2aeb74f test(ui): run the window and pane gpui tests on Windows too (#791)
Windows tty7-app tests go 1466 -> 1638 with no assertion weakened.

Also holds the SCM graph idle test's daemon end open: the moved handle closed the socket right after writing Cwd, which on Windows (loopback TcpStream with unread data) is an abortive close, so settle_graph would time out and the test would silently skip every assertion.
2026-09-07 23:55:21 +08:00
l0ng-ai 314ec61efe fix(pane): return to the pane a directional move left (#781)
Refs #738.

Keys the origin memory by the pane a move lands on as well as the direction: the per-direction array meant a two-step walk clobbered the first step, so Left, Left, Right, Right ended in the wrong pane. Entries are pruned on write when either side names a pane the tab no longer holds.
2026-09-07 23:55:10 +08:00
l0ng-ai 6f7712a5ee fix(terminal): keep a ligated run standing over its own cells (#785)
Refs #751.

The PR body's rationale is stale as of #788: build_font now emits calt:0 liga:0 clig:0 and gpui's DirectWrite backend zeroes all three, so ligatures are off by default on Windows and the Calibri office/waffle repro no longer fires with default settings. This is a fix for users who opt ligatures back on, and for any face on any platform that collapses glyph count.

Reconciled onto #783: the fit flag is gone, so seg_budget now takes ink_covers_segment and the shaping moved above the budget.
2026-09-07 23:50:55 +08:00
l0ng-ai e3630eb8a1 fix(keymap): fold a shift-punctuation chord into the key the platform reports (#784)
Refs #750; only half fixed — the US SHIFTED_GLYPHS table means secondary-shift-]/[ stay unpressable on German/French/Nordic layouts. A layout-correct fold needs KeyBinding::load with the real keyboard mapper.

Also runs the control-code guard over the folded spelling, so ctrl-shift-2 no longer installs ctrl-@ (NUL) beside it.
2026-09-07 23:47:33 +08:00
l0ng-ai 92c1ae9f26 feat(ui): make opening a new window a bindable action (#793)
Refs #710; does not close it (the request was a jump list opening a chosen existing workspace).

Registers NewWindow globally as well as on the render root, since with the tray icon on (the default) closing the last window retires to the tray and leaves no window to dispatch it.
2026-09-07 23:45:17 +08:00
l0ng-ai 661a8c2ed2 feat(ui): mark the tab whose pane is zoomed (#782)
Refs #752.
2026-09-07 23:40:03 +08:00
l0ng-ai 1f9663020f test(terminal): cover a pane's replay across a workspace switch (#789)
Refs #711. Does not fix it; #711 stays open.
2026-09-07 23:33:07 +08:00
l0ng-ai 79527ca871 feat(diff): let the pointer take a range of diff lines and copy it (#794)
Refs #721.

Reconciled onto the virtualised row list from #799: the selection is keyed on RowAt { path, RowId } rather than the flat list index, since collapsing a file above the selection re-points flat indices.
2026-09-07 23:33:02 +08:00
l0ng-ai 2de94472ec feat(tabs): name a titleless tab after its working directory (#792)
Refs #740.
2026-09-07 23:28:22 +08:00
l0ng-ai e6686d0d49 fix(switcher): name a machine whose profile is gone (#786)
Refs #485.

Also stops PaneWorkspace::route_header spelling a profile UUID on the restore path.
2026-09-07 23:09:44 +08:00
l0ng-ai eca98a1318 fix(terminal): submit a plain single line as typed, not as a paste (#790)
Refs #660.
2026-09-07 22:58:36 +08:00
l0ng-ai 28530a476a fix(git): key a repository by one spelling of its root (#796) 2026-09-07 22:55:01 +08:00
l0ng-ai 8c1315b7ab feat(ssh): edit the host from the tab that is connected to it (#801)
Refs #438.
2026-09-07 22:48:39 +08:00
l0ng-ai 23919628c0 fix(settings): say what turning off mouse reporting costs (#780) 2026-09-07 22:43:00 +08:00
l0ng-ai 7b4c6c050d fix(terminal): read a pane's paths in its own host's spelling (#795) 2026-09-07 22:42:11 +08:00
l0ng-ai 5ff0567c7d fix(terminal): keep a solo glyph inside its cell when the next one is taken (#783) 2026-09-07 22:36:58 +08:00
l0ng-ai c125803077 feat(daemon): list a Windows pane's listening ports (#787) 2026-09-07 22:31:23 +08:00
bytehelloandbytehello 42424c7737 feat(ui): add a bindable Close Window action (#773) (#778)
26.9.0's tray-retire model made closing the last window the "keep the daemon,
drop the UI process weight" gesture, but that path was reachable only from the
OS red close button: no action, no palette entry, nothing to bind. `⌘W` closes
a pane or tab, `⌘H` hides the window but keeps it in memory, and `⌘Q` stops the
server. There was nothing that closed the window and left the shells running.

`CloseWindow` is that action, with no default key — the slot is left free.
The logic that decided what a window close means (detach the workspace, and
on the last window retire to the tray if an icon is actually up, otherwise
quit) moves out of `on_window_should_close` into `prepare_window_close`, so
the button and the action share one decision instead of two that can drift.
Notably not routed through `close_window_for`: that one recycles the last
window onto a fresh workspace, which is what deleting a workspace wants and
not what closing a window wants.

It reaches the command palette, the Keybindings UI, and the reference table
of actions with no default key. In the palette it sits beside Quit, because
that pair is the whole point of the action: both end the window in front of
you and only one takes your shells with it. Their subtitles now say which —
including Quit's, which had been promising "shells keep running" while
calling `daemon::spawn::stop()`.

Co-authored-by: bytehello <bytehello@users.noreply.github.com>

https://claude.ai/code/session_01LKMZVh6mUBxXAn6v7P6JC6
2026-09-07 22:29:08 +08:00
l0ng-ai d8a0245e75 fix(terminal): name every ligature feature off, not just calt (#788) 2026-09-07 22:26:39 +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 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 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