Commit Graph
1193 Commits
Author SHA1 Message Date
hhdebb bb7078cfd0 fix(sidebar): let an agent's status dot sit outside its disc (#846)
`status_dot` places itself with negative offsets so that it overhangs the
avatar's edge — that overhang is what makes it read as a badge sitting on
the disc rather than a notch bitten out of it. But it is added as a child
of the element carrying `rounded_full`, so everything past the circle is
clipped along the arc and the badge comes back as a crescent.

Give the wrapper the positioning and move the disc down a level. The
radius then only ever clips the disc's own fill and mark, and the dot
becomes a sibling drawn after it, which settles the paint order too.

No test: the change is structural — which element carries the radius —
and the tests here cover the pure decisions behind the avatar
(`needs_edge`, `agent_status_label`) rather than its element tree.
2026-09-10 11:18:45 +08:00
l0ng-ai 2f500a8b5c fix(sidebar): put a row's cwd on the title's line, not under it (#851)
Outside a repo a sidebar row grew a second line for its working
directory, so a group of plain shells was a column of two-line rows
describing paths that mostly agree. The path now rides on the title's
own line: it takes what it needs up to half the line and the title
keeps the rest, the same split a group header makes with the branch
beside its heading. A row keeps its second line only for a branch.

Claude-Session: https://claude.ai/code/session_01VuYUPiDEhQX6aQ4WQZbEGn
2026-09-10 08:46:52 +08:00
l0ng-ai 894af9de3c fix(sidebar): lift a lone row's branch onto its group header too (#836)
A group whose rows share a branch already prints it once on the header
instead of once per row, but the rule asked for at least two rows. A
one-tab group was left with a bare heading over a two-line row, a shape
no other group in the column has, and the branch down there described
the same repo the heading above it already named.

Claude-Session: https://claude.ai/code/session_01E4EPKzHg1fm9HMmHkUYpER
2026-09-10 08:16:07 +08:00
l0ng-ai 3cf226f809 fix(sidebar): stop a long branch from eating a group's name
A header that carried its group's shared branch handed the overflow to
the name and the branch by flex shrink, which splits it in proportion to
what each item asked for — so the longer string took the smaller cut.
Next to `fix/rpc-proxy-and-error-classification` the heading came out as
`DEL…` while the branch still showed thirty characters, which is exactly
backwards: the name is what the group is, the branch only what it
happens to be sitting on.

The header now measures itself against its real chrome the way a row
already does. The branch takes what it wants up to half the line, the
name keeps the rest above a 40px floor, and each is elided into its own
share — the branch keeping both ends, so two branches off one prefix
still read apart. `header_name_avail` holds the split and has three
tests; the counts measurement a row and a header both need moved into
`counts_width`.

Claude-Session: https://claude.ai/code/session_01E4EPKzHg1fm9HMmHkUYpER
2026-09-09 22:13:33 +08:00
l0ng-ai d23bfb9ab9 revert(sidebar): paint the agent disc solid again
The resting tint read as a disabled tab, not as a quieter one: a column
of 16% discs looked like a list of agents that had been switched off,
and the brand hue is how the eye tells a Claude row from a Codex row
before it reads either title.

The disc goes back to a solid fill of the agent's brand on every row,
lit or not, with the mark in the agent's own ink. `tab_avatar` loses the
`lit` argument and the three call sites stop threading selection into
it; `legible_on`, which only existed to ink a glyph on a tinted disc,
goes with it. The rest of the sidebar hierarchy work stays.

Claude-Session: https://claude.ai/code/session_01E4EPKzHg1fm9HMmHkUYpER
2026-09-09 21:57:39 +08:00
l0ng-ai 47af66ff52 fix(chrome): keep the strip's two tiles while the detail panel is open
With the panel open those tiles stand in the band above it, over the
panel's own header — and that header's tab tiles are painted whenever the
panel is, so a band that grew two buttons under the pointer read as a
glitch beside them. macOS already struck this bargain when it moved the
tiles into the panel's title bar: once the panel is open they are part of
its chrome, not part of the strip's. Windows kept them hover-gated.

Position is unchanged; only the visibility gate is.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-09 20:30:09 +08:00
l0ng-ai 400ac032c8 Merge pull request #833 from l0ng-ai/feat/right-panel-chrome
feat(panel): keep the panel's chrome tiles and underline the current tab
2026-09-09 19:33:20 +08:00
l0ng-ai 4c593ef4a4 fix(panel): let the tab underline reach the rule that closes the row
The bar is pinned to the bottom of the tile's own box, which only meets
the hairline when that box is as tall as the row. On macOS it is; in
`panel_title`, where Windows and Linux draw the same tiles, the wrapper
around them is only as tall as a glyph, so the bar floated a few pixels
above the line. Give that wrapper the row's height.

Keep `occlude` on the tile rather than on the full-height frame the bar
hangs off: over the whole row it would take the few pixels above and
below each glyph out of the title bar's drag region and hand them to
nothing.

Claude-Session: https://claude.ai/code/session_01VuYUPiDEhQX6aQ4WQZbEGn
2026-09-09 19:26:35 +08:00
l0ng-ai 20d3427030 feat(panel): keep the panel's chrome tiles and underline the current tab
The right panel's own title bar hid its two trailing tiles until the
pointer entered it, while the three tab tiles beside them were always
drawn — a row that grew two buttons on hover. Paint them with the rest.

Rule the row off from the content with the lighter of the two hairline
tiers, the one the panel's left edge already uses, and leave 8px under it
so the first row of content is not sitting on the line. The other
platforms draw this line on the title row, which keeps that distance with
its own text.

Say which tab is current with a bar under the glyph rather than with a
fill. The fill was the same grey the hover state paints, so the lit tab
and the tile under the pointer read as the same thing.

Claude-Session: https://claude.ai/code/session_01VuYUPiDEhQX6aQ4WQZbEGn
2026-09-09 19:18:21 +08:00
l0ng-ai 6c7863ac1f Merge pull request #829 from l0ng-ai/refactor/drop-conversation-outline
refactor(panel): drop the agent conversation outline
2026-09-09 18:40:22 +08:00
l0ng-ai 139ce81bf2 refactor(panel): drop the agent conversation outline
The Info panel's CONVERSATION section, and the jump back into the
scrollback behind it, are gone: the row list, the anchors the client
kept for it, and the scanner that cut a batch of pty output at every
agent event.

What is left is what the outline rode on rather than owned. The hooks
still send their OSC 777, the daemon still reads it for the tab's status
dot, and `AgentEvent::prompt` still parses — nothing else read the rows.

Output batches now split for one reason (the parked-cursor repair), so
the two-scanner merge and its sort go with the section, and a replayed
snapshot parses in a single pass again.

Claude-Session: https://claude.ai/code/session_01E4EPKzHg1fm9HMmHkUYpER
2026-09-09 18:13:47 +08:00
l0ng-ai 4af01cc5b6 Merge pull request #823 from l0ng-ai/feat/sidebar-hierarchy
feat(sidebar): text hierarchy and state-driven colour
2026-09-09 18:11:02 +08:00
l0ng-ai 615acfc15d docs(sidebar): keep diff_click_cwd's doc comment on diff_click_cwd
git_click was inserted between the function and its doc block, so the
block explaining why the setting is one setting ended up on the wrong
function. Move the helper above it.

Claude-Session: https://claude.ai/code/session_01Ak6Lnzf3EGErJZmG51Rxbo
2026-09-09 17:56:07 +08:00
l0ng-ai c57913fad4 feat(switcher): same hierarchy for the workspace switcher
The switcher card had the sidebar's problem in a popover: the loudest
marks were the filled `this window` / `open` / `active` pills — states
dressed as buttons — and the full-ink diff counts, while the keyboard
cursor sat on the `pressed` rung one step under `cursor`, close enough
to a hovered row that the focused column was anybody's guess.

- Badges become words in the caption ink; only "taken over" keeps its
  warning colour.
- The picked row takes the `cursor` rung the ladder set aside for a
  palette's keyboard row.
- The three `muted_foreground.opacity(0.7)` inks (≈2.6:1) are gone; the
  second line is subordinate by size, not by a grey under the floor.
- A workspace row's second line keeps its timestamp: the path is what
  truncates, so a narrow row no longer ends in a dangling `·`.
- Diff counts use `resting_ink` on the popover fill, as the sidebar does.

Claude-Session: https://claude.ai/code/session_01Ak6Lnzf3EGErJZmG51Rxbo
2026-09-09 17:47:03 +08:00
l0ng-ai 305535cde3 feat(sidebar): give the rail a text hierarchy and put colour on state
Every line in the sidebar sat at the same 4.5:1 grey — tab title, branch
line, group header, search placeholder — so the only things that stood
out were twelve identical brand-orange agent discs and twelve copies of
the same `+94 −26`, neither of which says which tab matters.

- Titles rise to a 7:1 floor (AAA) with a cap that keeps the selected
  label its `TEXT_STEP`; captions stay at `TEXT_RESTING`. A new test pins
  the step between them.
- The sidebar gets its own selection ladder (`SIDEBAR_SELECTED` 1.50);
  the window's signed-off 1.30 rung is untouched and its Dracula pin now
  checks the constant rather than the rail's fill.
- Agent avatars rest as a 16% brand tint with the mark in brand ink and
  light up solid only on the active tab or an agent that is waiting.
- Diff counts render in `resting_ink`: same hue, blended toward the
  caption, walked back to the 4.5 floor where needed.
- A group whose rows all share one branch and diff says so once on its
  header; rows with no status yet do not vote, so ⌘T no longer flips the
  group twice while the poll comes back.
- Group headers get 12px above, the count shows only when folded, and
  the two sub-floor `.opacity()` captions are gone. The workspace
  switcher name reads at the headers' weight instead of inheriting the
  title ink.

Claude-Session: https://claude.ai/code/session_01Ak6Lnzf3EGErJZmG51Rxbo
2026-09-09 17:41:49 +08:00
l0ng-ai 6649cccbc7 Merge pull request #822 from l0ng-ai/fix/notification-poll
fix(notify): stop polling Notification Center from the UI thread
2026-09-09 17:35:45 +08:00
l0ng-ai 990873f762 fix(notify): scope notification identifiers to the process
Include the pid in the `tty7-pane-<pid>-<leaf>-<seq>` identifier so a
banner left over from a previous run (or a concurrent instance) is
ignored instead of revealing an unrelated pane.

Also drop the dead `com.apple.Terminal` fallback in the delegate
installer (mac-notification-sys completes its Once even on failure, so
the second `set_application` never ran), collapse the macOS cfg arms so
test builds compile the production path, and correct stale comments.

Claude-Session: https://claude.ai/code/session_01VuYUPiDEhQX6aQ4WQZbEGn
2026-09-09 17:08:09 +08:00
ayamirandl0ng-ai 59dbe83913 feat(macos): add default terminal integration (#818)
* feat(macos): add default terminal integration

* fix(macos): route external opens through the layout pull

Five holes in the LaunchServices path, all on the way from a URL to a tab.

The `ssh:` arm handed the raw URL back to `parse_quick_connect`, which
reads a bare `user@host:port` typed into Quick Connect. Everything a URL
carries past the authority landed in the wrong field: `ssh://h:2200/`
parsed its port as `2200/` and was dropped on the floor, `ssh://h/srv`
became the host `h/srv`, and the percent escapes `url` was added for were
never decoded. Read the authority off the parsed URL instead.

`x-man-page://3/printf` is Apple's sectioned form, and taking the host as
the page name ran `man 3`, which asks the user what page they wanted.
Section and page are now both carried.

A window that is pulling its layout is one `Adopt::IfEmpty` will not adopt
into, so a tab inserted while the pull is out comes back as the whole
workspace — the failure `then_open` already exists to avoid. Both the
script/man path and the SSH path inserted straight into a freshly restored
window, so `then_open` becomes a list of parked requests and carries a
command or an SSH link as well as a folder. A cold `ssh://` link also went
through `open_at` directly, claiming a fresh workspace and leaving the
restored one detached and unannounced; it takes the shared restore now.

`new_tab_running` wrote the command whether or not a tab opened, so a
failed spawn typed a script path and a newline into whatever pane was
focused before — a shell mid-line, or an agent.

Left alone deliberately: an `ssh://` link still connects without a
confirmation, which is a product call rather than a defect.

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

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-09-09 17:05:58 +08:00
l0ng-ai c9ec23d090 fix(notify): stop polling Notification Center from the UI thread
macOS notifications went through mac-notification-sys with wait_for_click so
a click could reveal the pane. That crate notices a click by parking the
sending thread and adding, per outstanding notification, a repeating 0.5 s
timer on the main run loop that calls deliveredNotifications — a synchronous
XPC round trip. A banner nobody clicks stays in Notification Center, so its
timer never goes away. Sampled with nine outstanding: a fifth of the UI
thread inside that XPC, every window juddering, one more timer per agent
turn.

Drive NSUserNotificationCenter directly with a delegate of our own: the click
arrives through didActivateNotification, the pane rides in the identifier,
and nothing runs on the main thread until the user clicks. notify-rust's
show is no longer called on macOS, since it is that crate and would replace
the delegate; only set_application stays, to name a bare binary.

Claude-Session: https://claude.ai/code/session_01VuYUPiDEhQX6aQ4WQZbEGn
2026-09-09 16:50:42 +08:00
l0ng-ai e632c0f81b Merge pull request #821 from l0ng-ai/feat/link-detection
feat(links): read a path out of the prose glued around it
2026-09-09 16:49:08 +08:00
l0ng-ai e743005321 fix(links): green the Windows test and the host boundary
The traceback test located the path by its first `/`. A Windows temp
path keeps the forward slash it was built with, so the search landed
three quarters of the way along the path and the expected span was 63
columns off. Look for the whole path instead. The detection itself was
right all along; only the expectation was wrong, which is why the
Windows job was already red before the review fixes landed.

The executable check reads the local filesystem from `ui::`, which the
host boundary forbids on sight. It is only reachable once
`host_id.is_local()` has answered, so it goes on the allowlist with
that as its reason.

Claude-Session: https://claude.ai/code/session_01NE3M5Q94Jyxmj5Rdm9bcg4
2026-09-09 16:33:42 +08:00
l0ng-ai 262a166a8d fix(links): five holes review found in the new path detection
The second column of a wide character is written as a space, and the
blank-cell shortcut read that as an empty cell. `logical_line_at` hands
a click there back to the character that owns it, so the underline was
going out on every other column of a path spelled in CJK or emoji. Read
a spacer as part of the glyph it belongs to.

Handing a file the built-in editor cannot read to the desktop is how a
click opens a PNG. On macOS it is also how a click *runs* a program:
`open` on a Mach-O binary launches it, and a build's output is full of
paths to programs. A file the execute bit is set on keeps the words it
had before.

`explorer /select,<path>` went through `Command::arg`, which quotes the
whole argument the moment the path holds a space. Explorer answers a
quoted switch by opening Documents and reporting success, so "Show in
Folder" silently showed the wrong folder. Write that command line by
hand instead, with the switch bare and the path quoted behind it.

The right-click menu resolved a path with no regard for the switch that
decides whether a path underlines at all, so a pane with link detection
turned off still offered to open files.

Finally, the `label:` left cut peeled anything after a colon, so
`branch:main` was probed as `main` and resolved against any directory
of that name. Require what follows to be written like a path too.

Claude-Session: https://claude.ai/code/session_01NE3M5Q94Jyxmj5Rdm9bcg4
2026-09-09 16:16:21 +08:00
l0ng-ai 0ab089c0b3 test(links): pin the wrap contract for file paths
Wrapped paths were only covered for URLs, so nothing said whether the
seam was invisible to file detection too. It is, in every shape the
grid can produce: either side of the seam, the two cells the seam
itself falls on, a wide-character path, and one that takes three rows.
A view-level test pins the other half, that the span handed to the
element reaches across both rows rather than stopping at the edge.

The companion test says what is deliberately not stitched: a newline
that arrives before the text reached the right edge is two lines, not
one wrapped line, and joining those would invent paths out of unrelated
output.

Claude-Session: https://claude.ai/code/session_01NE3M5Q94Jyxmj5Rdm9bcg4
2026-09-09 15:52:33 +08:00
l0ng-ai 6f3cb5c074 feat(links): read a path out of the prose glued around it
File detection used to take the whitespace-delimited token under the
cursor, peel a bracket off each end and hope. Everything a build tool
writes onto a path defeated that: `--file=src/main.rs`, `note:src/x.rs`,
a diff's `a/`, `ls -F`'s `src@`, a tree glyph with no space behind it.

Replace it with a short ordered ladder of readings. Left cuts name the
prefixes that actually occur and stack against each other; right cuts
trim sentence punctuation, balanced-aware so `report(1).pdf` survives.
Location parsing grows two spellings beyond `:10:2` — `app.ts(10,2)` and
`main.rs#L10` — and both keep the whole token on the ladder as well,
since `backup(1)` names a file on any machine that has downloaded
something twice. At most eight readings per token, so a hover costs a
handful of probes rather than one per substring pair.

A path that carries no line number of its own now gets one read from
beside it: `File "handlers.py", line 214` is where clicking a path is
worth the most, and landing on the file but not the line was most of the
way to useless. `bash` and `make` spell it the same way. Directories are
left out, having no line to land on.

Hovering no longer needs the modifier. A resolved link underlines at 45%
of the text's colour as soon as the pointer reaches it, and only turns
solid with a hand cursor once the modifier is down — promising a hand
cursor over a link a plain click will not follow teaches people to stop
trusting the underline. A full-screen application keeps its window to
itself unless the modifier says otherwise. Two guards pay for the extra
looking: the answer is memoised per cell, and a blank cell never lifts a
logical line out of the grid.

Right-clicking a path now opens a menu about that path — open, show in
the file manager, copy path — resolved at mouse-down, because the popup
is built a turn later with no pointer left to ask about. Showing in the
file manager is disabled for a file on another machine, which has no
folder here to show.

Finally, a file the built-in editor cannot read is handed to the desktop
instead of refused. A click on a PNG meant "open this", not "tell me it
is a PNG".

Claude-Session: https://claude.ai/code/session_01NE3M5Q94Jyxmj5Rdm9bcg4
2026-09-09 15:45:36 +08:00
l0ng-ai 554aa9224d Merge pull request #819 from l0ng-ai/fix/prompt-mark-reprobe
Notice a pane came home from ssh without waiting for output
2026-09-09 11:32:11 +08:00
l0ng-ai ab5752e27b fix(daemon): notice a pane came home from ssh without waiting for output
The foreground probe that clears a pane's remote context only runs when
the reader thread has bytes in hand. The prompt a shell draws after a
command is the last output a pane produces until the user types again, so
an `ssh` that exited inside the poll interval left the pane reporting
itself as remote indefinitely — nothing came along to probe on.

Everything keyed off that context stayed on the far end. Most visibly the
history scope: ↑ read the remote list, which for a host with no history
of its own is empty, so ↑ appeared dead until some unrelated output
arrived. Pressing Enter looked like it unblocked the pane because an
empty command is the cheapest way to make output.

A prompt mark that survives the foreground suppression is the shell
saying the command it ran is over, so the foreground has just gone back
to being the shell itself. Probe right then instead of waiting out the
interval.

Switching history scopes also dropped the list it was leaving, and the
reload that refills it is a background task, so ↑ had a second window of
recalling nothing. Park each scope's list instead, capped at four, and
step back into one instantly.

Claude-Session: https://claude.ai/code/session_01Mnerr8RZ23Nd4cxyfeqxiu
2026-09-09 11:10:27 +08:00
l0ng-ai ce1db2d867 test(terminal): keep typing at a pane whose shell is not reading yet
`a_pane_re_attached_after_a_switch_gets_its_screen_back` and
`the_later_of_two_racing_attaches_keeps_the_screen_and_the_seat` both
failed on the Windows runner with a grid that was still completely empty
after 15s — not even a prompt had been printed, so the first command was
typed at a shell that had not started reading.

`DaemonPane::spawn` returning means the pty exists, not that the shell
behind it is up: on a loaded runner it can be seconds behind, and on
Windows the ConPTY has not necessarily connected the child to its input
pipe at all, so bytes written in that window reach nobody and nothing
ever echoes. On unix the same bytes simply wait in the pty buffer, which
is why this only ever showed on Windows.

The first command of each test now goes through `type_until_echoed`,
which retypes it every 2s for up to 30s. Retyping is safe for everything
these tests assert: a line that did land and was merely slow runs twice,
and every assertion is a `contains`. The later commands keep their
single `write_input` — by then the shell has echoed once, which is proof
it is reading.

Verified on macOS: all six replay cases pass in 0.75s, so the retry
costs nothing when the first write lands. The Windows failure is
intermittent and could not be reproduced locally.

Claude-Session: https://claude.ai/code/session_01Mnerr8RZ23Nd4cxyfeqxiu
2026-09-09 00:01:07 +08:00
l0ng-ai 9ef3b90651 Merge pull request #812 from l0ng-ai/feat/hover-chrome
feat(chrome): show the window's buttons only under the pointer
2026-09-08 23:49:35 +08:00
l0ng-ai bd60f8c40f feat(chrome): show the window's buttons only under the pointer
The new-tab, sidebar, right-panel and app-menu tiles were on screen at
all times, so a window resting at the edge of the eye carried four
buttons nobody was reaching for. Each group now paints only while the
pointer is over the bar it belongs to: the sidebar's two follow the
rail, the trailing two follow the strip they sit in, and the collapsed
rail's pair follows the strip as well. The window mark beside them stays
put — it identifies the window rather than doing anything, and a window
that loses its mark when nobody points at it reads as a different
window.

The tiles keep their place in the layout and only lose their paint, so
revealing a group never shifts what is beside it.

The reveal is a hover flag written by a transparent sheet laid over each
region as its last child, not `group_hover` on the region itself. Group
hover asks whether the group's hitbox is the one under the pointer, and
gpui's hit test stops at the first occluding element on the way down —
tab chips and the tiles themselves all occlude, so the region stopped
counting as hovered the instant the pointer reached the button it was
revealing, and the button vanished from under the cursor. Dropping
`occlude()` from the tiles is not the way out either: they sit on the
title bar's drag area, and Windows resolves WM_NCHITTEST against the
whole hit list, so an unoccluded tile becomes draggable chrome and stops
taking clicks. Painted last, the sheet's own hitbox sits in front of
everything in the region and blocks nothing, because it is not opaque.

Claude-Session: https://claude.ai/code/session_01Mnerr8RZ23Nd4cxyfeqxiu
2026-09-08 23:27:19 +08:00
l0ng-ai 38af00fa51 Merge pull request #813 from l0ng-ai/fix/reap-seat-record
fix(daemon): let the reap wait for a seat that is coming back
2026-09-08 23:27:02 +08:00
l0ng-ai a077d47051 fix(daemon): let the reap wait for a seat that is coming back
`reap_stranded_clears_a_seat_holder_with_no_pidfile` failed on macOS CI
often enough to red a PR that touched nothing near it: after a confirmed
reap the lock file still named the dead holder.

A seat is not free the same instant its holder is confirmed dead. The
kernel releases the lock while tearing the process down, and any
descriptor a `fork` left behind holds it a moment longer — BSD `flock`
counts an inherited descriptor as another reference to the one lock
rather than a second lock, which this module already records on the
claim side (`a_reference_a_forking_neighbour_left_behind_does_not_lose_the_seat`,
and the retry loop `claim_within` exists for). `clear_record_if_free`
had no such patience: one `EWOULDBLOCK` ended the attempt, and nothing
ever revisits the file, so the dead pid stayed in it for good — where a
later pre-recording build holding the seat would make that number, by
then possibly reused, read as the holder.

It now retries for 500ms. The rule it enforces is unchanged: the record
is truncated only while the kernel says the seat is free, so a live
holder that outlasts the grace still keeps its record.

The guard was measured both ways — with the grace at zero
`clearing_the_record_waits_out_a_seat_that_is_about_to_come_back` fails
with exactly the assertion CI reported.

Claude-Session: https://claude.ai/code/session_01Mnerr8RZ23Nd4cxyfeqxiu
2026-09-08 23:12:10 +08:00
l0ng-ai ea7543490a Merge pull request #811 from l0ng-ai/feat/remote-ports
feat(ports): detect and forward what a remote pane is serving
2026-09-08 21:56:23 +08:00
l0ng-ai aafd26de72 fix(ports): restore three doc comments and name the right machine
Three items were inserted between an existing doc comment and the item it
described, so `bind_failed`, `action_strip` and one test each lost their
documentation to the newcomer above them — and the peer-procs test carried
two `#[test]` attributes as a result.

The Ports panel's fallback said "This machine's tty7-server is too old",
which reads as the local one; the server that cannot answer is the far
side's.

Claude-Session: https://claude.ai/code/session_01TPXrptp2rCGKjXaz4xE3Lq
2026-09-08 21:40:18 +08:00
l0ng-ai 03c3081a6e feat(ports): detect and forward what a remote pane is serving
A remote workspace's ports were never listed. The pane lives in the
peer's registry and QueryProcs asks this machine's daemon, which has
never heard of it, so the answer was an empty list — indistinguishable
on screen from a pane serving nothing. Add a control request so the peer
answers instead, gated on a feature so an older server says "I cannot
tell you" rather than "nothing is listening".

With the ports visible, the forward becomes something the user should
not have to think about: a port opens on a click, and a new one is
forwarded unasked, at the same number where that number is free here.
The watch runs with the panel shut, which is when a port appearing is
most worth saying something about.

Ports and Forwards were two sections that never mentioned each other; a
row is now a port, and the forward is where that row says it comes out.
Adding one by hand asks for one number instead of five fields, with the
rest of the ssh -L grammar one disclosure away.

Claude-Session: https://claude.ai/code/session_01TPXrptp2rCGKjXaz4xE3Lq
2026-09-08 21:01:50 +08:00
l0ng-ai 9842fddd4c Merge pull request #810 from l0ng-ai/feat/sidebar-custom-groups
feat(ui): custom sidebar groups a tab can be put in by hand
2026-09-08 17:44:41 +08:00
l0ng-ai 7df06538f5 Merge pull request #809 from l0ng-ai/fix/agent-icon-tint
fix(ui): give an agent's mark one colour, not one per draw site
2026-09-08 17:29:30 +08:00
l0ng-ai b063f93431 Merge origin/main into feat/sidebar-custom-groups
main's #806 changed the fold test while this branch changed
toggle_sidebar_group to take Option<&GroupKey>; the textual merge left
one call site on the old signature. Fixed here.

Claude-Session: https://claude.ai/code/session_01MS7VnqvGRtNTrJG9zxtz51
2026-09-08 17:29:10 +08:00
l0ng-ai 5c2a02f064 fix(ui): give an agent's mark one colour, not one per draw site
Two places draw an agent's mark and each decided its colour on its own:
the tab strip took a `TraeCode` branch added in #807, and the tray icon
recoloured every glyph white regardless. So TraeCode came out green on a
tab and white in the tray — one agent with two faces.

The colour now lives on `CLIAgent::icon_rgb`, next to `accent_rgb`, and
both sites read it. White for every mark that is a silhouette on its
brand colour; TraeCode is the one whose mark carries the colour itself.

`the_tray_draws_each_mark_in_the_agents_own_colour` walks `CLIAgent::ALL`
and asserts the tray's solid pixels contain the agent's own mark colour,
so a future agent whose mark is not white cannot be added to one draw
site alone.
2026-09-08 17:03:41 +08:00
l0ng-ai d72a0db420 Merge pull request #808 from l0ng-ai/fix/notice-stacking-and-form-keyboard
ui: notice stacking and form keyboard · terminal: unblock the frame · switcher: mouse can finish a Ctrl+Tab
2026-09-08 16:57:10 +08:00
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 177eff5d77 fix(switcher): let the pointer finish a Ctrl+Tab gesture the keyboard started
Ctrl+Tab raises the panel and holds it up until Ctrl comes back up, which
commits whatever is highlighted. That is right while the gesture is a keyboard
one, and wrong the moment the user reaches for the mouse: letting go of Ctrl
over the workspace list slammed the panel shut and picked a tab, so switching
workspaces by hand — the thing the pointer was on its way to do — was
unreachable.

The panel now tracks where the pointer is, on the card at all and on the tab
column specifically. A release with the pointer parked on the card but off the
tab column drops the hold and leaves the panel up for the mouse to finish in;
over the tab column it still commits, because that is the ordinary gesture.
Both flags come from hover listeners, so they mean nothing until the mouse has
actually moved since the panel came up, which is exactly the distinction
wanted.

Two macOS consequences of holding Ctrl, fixed with it. A held Ctrl turns every
click into a right click, so reaching for the search box mid-gesture popped
Cut/Copy/Paste instead of placing a caret — the rows already dodged this by
dropping their own menus while the gesture is on, and the box has no menu worth
keeping either (Cmd+V still pastes). And a tab row picked with the mouse
arrives on the right button, so nothing between the row and the window may
swallow that press first.

Guarded by three tests that put the pointer on a computed point of the card
rather than a hard-coded pixel: release over the workspace list keeps the panel
up and picks nothing, release over the tab column still commits, and a
Ctrl+click on a tab row mid-gesture picks the row under the pointer rather than
the one the keyboard had reached.
2026-09-08 16:33:41 +08:00
l0ng-ai d69d8710d7 perf(terminal): never queue the frame behind the grid lock
One UI thread paints every pane in every window, and the thread holding the
grid lock is the pane's own reader part-way through feeding a batch of output
into the emulator. Waiting for it wired one pane's write speed to the frame
rate of the whole window — the read-side twin of #709, which was this same
thread parked in `write(2)` for a stalled link.

`build_grid` now takes the lock with `try_lock_unfair` and returns `None` when
it cannot have it, so that frame paints the one before it; nobody can see a
frame of lag. Unfair rather than queued on purpose: a painter that queued would
make the reader wait for a frame it is not going to get anyway, and skipping
the queue is safe precisely because it never waits. Two frames still block,
because neither has anything to fall back on — the first frame a pane ever
paints, and the frame after a resize, whose previous grid is the wrong shape.

The cells therefore have to survive a refused frame, so the shared `GRID_BUF`
thread_local is gone and each pane owns its own `grid_buf` and `grid_snap` on
the view. What made the buffer reusable was always that it is the previous
frame *of this pane*; one shared scratch buffer could not be that once a frame
could decline to rebuild it. The `buf.clear()`/`resize` moves below the lock
for the same reason.

`key_context` and `any_selection` read frame-cached copies of the terminal mode
and the selection flag, refreshed by one `try_lock` at the top of `render`
rather than one per caller. gpui matches keystrokes against the context the
last painted frame published, so the mode was already a frame-old reading even
when it locked. Anything with a real decision to make still asks the terminal
itself — `alternate_paste` is the chord that cannot be a frame late.
`sync_scrollbar` stops blocking too: the scrollbar is a picture of where the
grid is, not worth parking a window to refresh a thumb.

Guarded by `a_frame_that_cannot_have_the_grid_leaves_the_previous_one_alone`,
which reproduces "the reader has it" with no second thread and no timing —
`try_lock` fails against a lock this thread already holds.
2026-09-08 16:33:29 +08:00
l0ng-ai 15ce97d7a7 fix(ui): keep a custom group out of the workspace subject path
`subject_path_of` picks the group most of a workspace's tabs are in and hands
it back as a path. `display_name_of` then titles the window after its last
component. A custom group is a name, not a path, so a workspace grouped by
hand would have been titled `custom:work` — and one grouped as `work/urgent`
would have been chopped to `urgent`.

Only repo roots are counted now. A workspace grouped entirely by hand falls
through to a pane's cwd, which is a real path and is what the window was
titled after before any of this existed.

Claude-Session: https://claude.ai/code/session_01MS7VnqvGRtNTrJG9zxtz51
2026-09-08 16:13:18 +08:00
l0ng-ai 3133f6d0bc feat(ui): drag a tab into a custom sidebar group
A custom group you could not drag into was a box that looked like it took
drops and did not. Dragging a tab onto one now moves it there.

The reorder surface stays split one-per-group. Merging it into a single
sidebar-wide surface would have to answer "which slots may this land on",
clamp the target through the groups that refuse it, and correct the animation
for the header heights it crosses — for an insertion point that cross-group
dragging does not need. Moving between groups is filing, not sorting: land in
the group, then reorder within it if the position matters.

So this rides beside the reorder rather than through it, the way a pane
dropped on the sidebar already does. Each frame the pointer is inside a custom
group's block, that group is offered; the offer is cleared with the rest of
the drag's pending state, so letting go anywhere else drops on nothing. On
landing, a group outranks any order the drag recorded on its way out of the
one it came from — the pointer left, so the shuffle it caused before leaving
is not what was asked for.

Only custom groups register a rectangle. A repo group's membership is decided
by its tabs' cwds, so "put this tab in tty7" is a request the sidebar has no
honest way to honour, and the same goes for Scratch. Rather than let a drag
die silently over them, a lifted tab fades every block that cannot take it and
tints the one under the pointer. Two kinds of group look alike while nothing
is moving; this is where the difference becomes visible without a word of
explanation.

The tab is carried by id. A drag lasts several frames, and a tab closing in
that time — another window on the workspace, a shell exiting — would shift
every index after it and land the drop on a bystander.

Claude-Session: https://claude.ai/code/session_01MS7VnqvGRtNTrJG9zxtz51
2026-09-08 16:11:02 +08:00
l0ng-ai 16a07a6cbb feat(ui): let a tab be put in a sidebar group by hand
Groups were derived and nothing else: the sidebar read a tab's cwd and filed
it under the repo it found. Tabs that belong together for a reason the cwd
cannot see — a few ssh sessions, three forks of one project, the two panes an
investigation is spread across — had no way to sit together.

A tab can now be moved into a named group from its context menu, and the group
is remembered. What makes this safe is the previous commit: a stated group is
never recomputed, so the probe no longer drags a hand-placed tab home again.

Laid out flat rather than behind a "Move to Group ▸" submenu. Custom groups are
maintained by hand and there are never many, so a submenu would cost a second
click to reveal two or three items — and `PopupMenu::submenu` needs a `&mut
Context` that `tab_context_menu`, which reads the app entity, cannot hold at
the same time.

Three things the feature does not work without, so they ship with it:

- "Group Automatically" clears the stated group. A stated group locks the probe
  out of that tab, so without a way back a tab could be moved into a group and
  never follow its cwd again — and nothing on screen says that is what just
  happened.
- `spawn_group` inherits a custom group. ⌘T inside a folded one would otherwise
  draw nothing but the header's count going up by one, which is exactly the
  symptom #804 fixed for repo groups.
- Renaming rewrites every tab in the group in one pass, and carries the fold
  state to the new key. A group is only the set of tabs that name it, so a
  half-applied rename would not leave a half-renamed group — it would leave two.

Renaming is on the header's own context menu rather than a double click: the
first click of a double folds the group, so the name would be edited on a box
that had just shut. A repo group gets no rename — it is named after its root,
and renaming it could only lie about where its tabs are.

Custom headers carry an asterisk. It is the only thing separating them on sight
from derived ones, which matters because the two behave differently under `cd`
and because a custom group may be named after a real repo.

Dragging a tab between groups is not in this commit; the reorder surface is
still split one-per-group.

Claude-Session: https://claude.ai/code/session_01MS7VnqvGRtNTrJG9zxtz51
2026-09-08 16:01:52 +08:00
l0ng-ai 176a39e455 fix(ui): stack the floating notices; give the forms back keyboard and focus
Four things a user can walk into today, all of them an interaction the app
starts and does not finish.

The remote input notice and the ssh status strip were written out a builder
call at a time in two files, identical down to the padding and differing only
in the border colour. Each also placed itself: both `absolute` at `bottom_4`,
centred, and both children of the same container in `body_area` with nothing
arbitrating between them. A remote workspace whose ssh link had also dropped
drew them on top of each other. The shell moves to `ui::notice`, the notices
stop placing themselves, and the anchor is a column, so a second one stacks.

The managed port-forward form had no keyboard contract at all: zero
`on_key_down`, zero input subscriptions. No Return, no Escape, and it opened
cold, so adding a rule meant clicking into Bind first and committing with the
mouse. Every sibling form in the app has all three.

The four sftp edit forms took the focus into a box they owned and dropped the
box without handing the focus back — `sftp_cancel_edit` took no `Window` at
all, so it could not have. Naming a folder and pressing Escape left the caret
on an element that had stopped rendering and the next keystroke went nowhere
until you clicked. `ssh_prompt` asserts in a comment and a test that every
overlay in the app hands focus back on the way out; these were the
counterexample. Fixed on all three paths that take the form down: cancel, a
rename to the name it already had, and a successful op coming back from the
far side.

`Override` on the changed-host-key sheet — the one control in the product that
can accept a key that no longer matches, which is what a man-in-the-middle
looks like — carried no colour at all. `.danger()` had zero call sites across
the whole tree. It is disabled until "yes" is typed, so it greys until armed
and then goes red: the emphasis arrives exactly when the button does. Button
order and `.primary()` are deliberately left alone; `.primary()` means "the
recommended action" on both host-key sheets, and on this one that is Abort.
2026-09-08 15:53:51 +08:00
l0ng-ai e7e5520411 refactor(ui): give a sidebar group a stated kind, not just a path
A sidebar group was a bare `Option<PathBuf>` the sidebar recomputed from the
tab's cwd every frame. That is right for a group the sidebar derived and wrong
for one a user states by hand: the probe overwrote whatever it found, so a
hand-placed tab would be dragged back to its repo on the very next frame.

`GroupKey` names which of the two a key is. `Repo` stays the probe's to change;
`Custom` is never recomputed. One enum rather than a path plus a `pinned` flag,
because the flag and the path are then free to disagree — and because a custom
group's name is not a path at all, which is the second half of this: it must
skip `group_names`, whose job is to lengthen repo roots until they stop
colliding. Run a name through it and `work/urgent` prints as `urgent`.

The flat spelling the protocol, the session file and the fold list all need
marks a custom group with a `custom:` prefix. No absolute path can collide with
it, and every group written before this decodes as a `Repo` — so old sessions
and old daemons keep working untouched.

Under `SidebarGrouping::None` a custom group hides like everything else. It is
tempting to exempt it, but `sidebar_sections` gives Scratch a header as soon as
any keyed group exists, so exempting one would draw two headers for a user who
asked for none. The key stays on the tab; switching grouping back on brings it
straight back.

No custom group can be created yet — this is the data layer alone.

Claude-Session: https://claude.ai/code/session_01MS7VnqvGRtNTrJG9zxtz51
2026-09-08 15:52:16 +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