Commit Graph
891 Commits
Author SHA1 Message Date
l0ng-ai 47eca37192 Merge pull request #854 from l0ng-ai/fix/843-tab-last-focused-pane
fix(ui): remember the focused pane as focus arrives, not at switch time (#843)
2026-09-10 14:44:20 +08:00
l0ng-ai 5c387e2ba4 Merge pull request #830 from l0ng-ai/fix/731-port-probe-honesty
fix(procinfo): stop the listening-port probe failing silently (#731)
2026-09-10 14:34:54 +08:00
l0ng-ai 104268ba81 Merge pull request #828 from l0ng-ai/fix/774-pty-source-and-replayed-modes
fix(terminal): a per-pty parked-cursor repair, and modes restored on re-attach (#774)
2026-09-10 14:34:00 +08:00
l0ng-ai cf75802baf Merge pull request #835 from l0ng-ai/fix/function-keys-834
fix(terminal): encode the function keys and stop swallowing them (#834)
2026-09-10 14:27:50 +08:00
l0ng-ai 2ec515c206 Merge pull request #831 from l0ng-ai/fix/716-remaining-three
fix: the three remaining items of #716 — the misdirected rename, the missing adopt verb, the absent tree backup
2026-09-10 14:19:07 +08:00
l0ng-ai 0381c0270c Merge remote-tracking branch 'origin/main' into review-828
`main` dropped the agent-turn cuts from the reader's `flush_batch!` and
added `foreground_command` to `replay_state`; both are kept, with the
per-pty `repair_cursor` flag replacing `REPAIR_PARKED_CURSOR` in the
simplified cut loop and the mode restore ahead of the new signature.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:13:53 +08:00
l0ng-ai eb419128ca fix(ui): carry a tab's focus memory onto the pane its pending slot became
Watching connecting slots is what lets a pane focused while it is still
coming up be recorded, but the record names the *pending* slot, and that
id dies the moment `land_pane` swaps the slot for the pane that came up
in it. The memory then names an entity no tab holds, `focus_target`
falls through `leaf_matching_or_first`, and the tab comes back to its
first leaf — the bug this branch is about, one landing later.

Nothing else writes the answer down in that window. `land_pane`
re-focuses the pane it built only when the pending slot still held
focus, and with focus off the panes the switch-away sample has nothing
to read either. So: split a pane, let focus wander off the panes while
the new one is still connecting, switch away and back, and you land in
the pane you did not ask for.

`replace_leaf_in` does the swap and carries the memory with it, and
`respawn_native_ssh_in_place` — the other place a live pane is
substituted for a dead one — uses it instead of repeating the walk.

Also pins the wiring this branch changed. The tests here drove
`remember_leaf_in` directly and never the subscription that calls it, so
deleting the one line that records a focus arrival left all four green.
`test_window::harness` makes the round trip reachable — real panes, real
gpui focus, a real `activate` there and back — and both new tests fail
without the code they are about.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:05:02 +08:00
l0ng-ai e7145eabd8 fix(terminal): give the kitty protocol its own F3 and F13..F24
The shared table sent F3 as `SS3 R` / `CSI 1;<mods>R` to a client that had
negotiated the kitty keyboard protocol. Kitty's first version allowed that
letter form and then removed it: `CSI 1;2R` is also a Cursor Position Report
for row 1, column 2, so a client cannot tell Shift+F3 from an answer to its
own DSR. The protocol's table gives F3 as `CSI 13~` alone -- the VT220 `kf3`
-- and alacritty special-cases exactly this. Only the kitty path moves;
everyone reading terminfo still gets the `kf3=\EOR` we advertise.

F13..F24 were left unencodable on both paths for a terminfo reason that holds
only on the legacy one: `kf13` onwards are already the modified F1..F8, so
there is nothing to send. Kitty has no such clash -- it names them in the
private use area, `CSI 57376 u` for F13 -- so a client that asked for the
protocol now gets them, and the legacy path still sends nothing.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 14:00:41 +08:00
l0ng-ai aa82dfade0 Merge pull request #827 from l0ng-ai/fix/ssh-repeating-password-prompt-820
fix(ssh): stop the password prompt coming back after a reconnect (#820)
2026-09-10 13:58:52 +08:00
l0ng-ai a249379bf8 fix(machine): format the three items and stop reading the tree per persist
`rustfmt` is a required check and was red on three hunks of this branch:
`owner_of`'s signature, the `--cwd` argv in `tab_new`'s adopt test, and the
two `tab_create` calls in `the_document_being_replaced_is_kept_beside_it`.

`keep_a_generation` also read the whole document before asking whether it
was going to keep anything. It runs on every persist — pane facts alone
flush every couple of seconds — and answers "too soon" on almost all of
them, so that was a full read of `machine.json` per write to produce one
copy every five minutes. The spacing check moves ahead of the read; the
`NotFound` arm still covers the machine that has never written a tree.

And a line continuation was missing from an assertion message in
`a_typed_name_waits_for_the_create_rather_than_racing_it`, so the failure
would have printed eighteen spaces mid-sentence.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
2026-09-10 13:57:54 +08:00
l0ng-ai 2805478e44 Merge pull request #832 from l0ng-ai/fix/711-replayed-prompt-scrubs-the-alt-screen
fix(daemon): stop a replay claiming a prompt a running program contradicts (#711)
2026-09-10 13:55:31 +08:00
l0ng-ai 87e9f4edd2 fix(switcher): spend a dismissing click on the dismissal
The switcher's scrim covers the whole window, the tile that opens it
included. Its mouse-down closed the switcher and then carried on down to
whatever sat beneath — for that tile, straight into `toggle_switcher`,
which reopened what had just closed. Clicking it a second time looked
like it did nothing.

Stop the press at the scrim. A click that dismisses is spent on the
dismissal and reaches nothing underneath it, so the tile toggles and no
control fires by accident on the way out.

Claude-Session: https://claude.ai/code/session_01XLMiHJR7RXvAGsR8S7jkHa
2026-09-10 11:33:47 +08:00
l0ng-ai bd48a14260 fix(chrome): answer a hover on the workspace tile
The tile's only hover state was a fill the palette derives one step off
the surface, which on the rail is barely a change at all — and the name,
the monogram and the chevron each pinned `muted_foreground`, so the
button's own hover ink never reached them. The one control at the top of
the column said nothing when the pointer landed on it.

Move the ink and the hover onto the row holding the three children and
let them inherit, the way a group header does: the text steps up to full
strength. The fill stays, it is simply no longer the whole signal.

Claude-Session: https://claude.ai/code/session_01XLMiHJR7RXvAGsR8S7jkHa
2026-09-10 11:33:47 +08:00
l0ng-ai 8a4f08e368 fix(ui): remember the focused pane as focus arrives, not at switch time (#843)
A tab's `last_focused` was written by one sample taken on the way out of
`activate`, and that sample asks which leaf holds focus at that instant.
By then focus is routinely somewhere else: the switcher's own search
input, a palette that just closed, the tab strip, the file tree
`activate` focuses itself, or a pane restored and never clicked. With no
leaf focused the write was skipped, the field kept a stale pane or the
`None` it was born with, and `focus_target` fell through
`leaf_matching_or_first` to the tab's first leaf. Two panes, click the
right one, switch away and back, land in the left one.

`watch_pane_focus` already subscribed to every pane's focus-in — it
existed to repaint the chrome that marks the focused pane — so the
record now rides on that callback instead of a new one. Focus-in is the
only moment that knows the answer without having to guess when to look,
and the subscription's cost and lifetime were already paid for. Which
tab gets the write is asked of the layout rather than assumed to be the
active one, so a pane dragged into another tab is remembered by the tab
holding it now; a leaf no tab holds is recorded nowhere. Connecting
slots are watched too, since a pane can be focused while it is still
coming up.

`remember_active_pane` stays. It never writes a wrong answer, only
sometimes none, and its callers want the field settled at a named moment
— before a pane is detached, before a tab is torn down — while the
layout can still answer.

Deliberately left alone: a restored tab still opens on its first leaf
until someone touches a pane. That is a different hole — persistence,
not a focus race — and closing it means giving a leaf an identity that
survives a restart. `SessionTab` has none, and an ordinal into the tree
is not one either, because `session_to_pane` drops leaves that cannot be
respawned and shifts every index after them. The report's third
suggestion, preferring a most-recently-active leaf recorded elsewhere,
was checked and dropped: `last_used` exists on `Tab` for the switcher's
MRU column and there is no per-leaf equivalent anywhere. No timestamp
was invented to create one, and with focus-in authoritative there is
nothing left for it to break a tie about.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-10 11:28:04 +08:00
hhdebb f812b5e76d fix(tabs): stop an untouched rename box from naming the tab (#849)
The box opens holding the tab's label as rendered, so it is never empty,
and `commit_rename` fires on `Blur` as readily as on Enter. Opening the
box and clicking away therefore stored that label as the tab's name — one
the user never typed, and one that means something different from the
title it was copied from: a name stops following the pane, so the tab
froze on whatever it happened to say at that moment.

Read the box against what it was seeded with. `rename_outcome` answers in
three states rather than two, so the one that has to keep working keeps
working: an emptied box still clears the name, which is the only way to
give a tab back to its pane.
2026-09-10 11:21:19 +08:00
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 e320f70fb0 fix(terminal): encode the function keys and stop swallowing them (#834)
F1 through F12 produced no bytes at all. `functional_key` in
`src/terminal/input.rs` is the one place a named key becomes an escape
sequence, and it knew the cursor and editing keys and nothing else; the
`key_char` fallback below it cannot help, because a function key arrives
with no character, and `text_key_code` gives up the moment a key name is
longer than one character. Both encoders funnel through that function,
so the kitty path was equally blank. This was never a Windows or a
PowerShell problem — it was every platform, every shell — but it shows
up first on Windows because PSReadLine keeps CharacterSearch on F3,
HistorySearch on F8 and ClearHistory on Alt+F7, so the missing keys are
part of the shell's ordinary editing surface rather than something
exotic.

The table is `xterm-256color`'s, since that is what we advertise in
`$TERM`: `kf1`..`kf4` are the SS3 letters, `kf5`..`kf12` the numbered
tilde forms whose numbering starts at 15 and skips both 16 and 22.
Modified, they take the same `CSI 1;<mods>` and `CSI <n>;<mods>` shapes
the cursor keys already use, which reproduces `kf13` onwards exactly —
`kf13` is Shift+F1, `kf25` is Ctrl+F1. DECCKM is deliberately not
consulted: unlike `kcuu1`, `kf1` is SS3 under both `smkx` and `rmkx`.

It stops at F12 on purpose. In the entry we advertise there is no
capability left for a thirteenth key — `kf13` and up are already the
modified forms of F1..F8 — so sending the VT220 `\E[25~` for a physical
F13 would hand ncurses a sequence its own table reads back as Shift+F1.
Silence is the more honest answer, and F13+ keys are close to
nonexistent on the keyboards this bug was filed from.

Encoding alone would not have reached the shell, because two things
upstream were eating the keys first. The inline prompt editor had no arm
for a named key it does not bind, so F8 fell out of the bottom of
`handle_editor_key` and died on a `cx.notify()`; it now takes the same
route an unknown Ctrl chord takes, handing the line to the shell before
sending the key, which is what PSReadLine's history and character
searches need in order to act on it. And Find Next / Previous sit on F3
and Shift+F3 off macOS, where gpui matches the binding before the pane's
key handler ever runs; with no find bar open there is no match to step
to, so those listeners now give the keystroke back the way `EditorSave`
gives back Ctrl+S.

F11 keeps fullscreen. It is the chord Windows Terminal, GNOME Terminal
and konsole all use, no shell binds it, the modified forms still reach
the PTY, and it is one line of config away from being retired — but it
is now a stated choice with a test standing on it rather than an
accident. The reporter's second clause is explained rather than fixed:
`prompt_editor` is a real gate, and turning it off could not help while
the bytes did not exist, which is exactly what they saw.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 22:36:17 +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 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 b90b903527 fix(daemon): stop a replay claiming a prompt a running program contradicts (#711)
A pane that came back from a workspace switch came back empty. All that
was left was the shell banner and the `claude --resume … --fork-session`
line the pane was born with; the agent above it was still running and
still painting, but only in fragments, into an otherwise blank screen.
Switching tabs did nothing. Resizing the window fixed it, and only for
the tab that had focus, which had to be repeated tab by tab.

Nothing is lost on the wire. The replay arrives whole — every segment,
every byte — and is applied. What happens is that the client throws the
result away three frames later.

`replay_state` closes an attach by reporting the pane's shell state, and
a client that hears `active && at_prompt` scrubs the TUI modes it finds
in its grid, the alternate screen first (`stale_mode_resets`). For a
live report that is sound: a shell that is prompting cannot have a
full-screen program underneath it, so a `?1049h` still set in the grid
is residue from one that died without its `?1049l` — an `ssh` dropped
mid-`vim` — and sending the `?1049l` is the repair.

For a replayed report it is not sound. The alternate screen the scrub
finds there is the one the ring has just rebuilt, and `?1049l` does not
undo a stale mode: it swaps a live screen away and puts the primary
screen back in its place, which for an agent pane is precisely the
banner and the launch command. The program never learns any of this. It
goes on sending differential updates — only the cells it believes
changed — into a grid that no longer holds what those updates are
differences from, which is why the status line came back as fragments
with the middle blank. And a resize is the one thing that repairs it,
because a resize is the one thing that reaches the child: new geometry,
`SIGWINCH`, a full repaint. A tab switch reports geometry the daemon
already has, so it repairs nothing.

The stale claim comes from `st.shell.at_prompt`, which is only ever as
fresh as the last OSC 133 mark the pane produced. A shell that printed
its prompt (`133;B`) and then handed the terminal to a program that
sends no `133;C` of its own leaves that flag set for as long as the
program runs. The live path already declines to believe such a mark: the
pane reader drops `at_prompt` from any prompt mark that arrives while a
foreground command owns the pty. The replay was the one place that
re-asserted the stored value with no check at all.

It now asks the pty the same question, once, before it takes the state
lock, and reports a prompt only when nothing but the shell owns the
terminal. That keeps the scrub's purpose intact — when the shell really
is prompting there is no foreground command, the report goes out
unchanged, and a genuinely stranded alternate screen still heals on
reattach — while removing the case where the report contradicts a
program that is still there.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:20:14 +08:00
l0ng-ai 425bf32e01 fix(tree-sync): spend a typed workspace name only on the create it rode with (#716)
A name typed into the create form cannot be sent as a rename: the workspace does
not exist on the machine yet, so the rename is answered `NotFound` and dropped,
and the create that follows names the workspace whatever `fresh_workspace_name`
rolled. That is #618, and the fix was to park the name on the window and have
the pull spend it — offering it to the create, and, if the machine came back
saying something else, sending it afterwards as the rename it had become. #604
then wired that answer straight to the switcher chip.

The trouble is that "the machine came back saying something else" is also what
walking into somebody else's workspace looks like. `settle_chosen_name` had two
facts to work with — a parked string and the name the machine answered with —
and neither of them says which workspace the name was meant for, or whether
anything was created at all. So it fired the rename at whatever workspace the
window had landed on. In #716 that was a workspace already holding nineteen
panes on another machine, and it came back named after the arriving client's
login. The report reads as one failure; it was two, and this is the second.

The parked name now carries the workspace it was typed for, and the pull now
says how the workspace got there. `pull_or_create` and `pull_workspace` answer
`Arrival::Created` when a create ran and `Arrival::Adopted` when the tree simply
had the workspace already, and the name is spent only when both agree: same
workspace, and a create to ride along with. Losing the create race still counts
as `Created` — a create did run for that workspace a moment ago, it just was not
this one, and its rolled codename is precisely what #618 exists to beat.

A name that does not match is left parked rather than dropped. Opening a
workspace runs two pulls at once, `start_prime`'s and `hydrate`'s, and only one
of them creates; taking the name on the adopting one would let the loser of that
race swallow it before the winner could spend it, which is the regression this
is trying not to reintroduce. Nothing leaks: `forget` drops the whole window
state when the window leaves the workspace.

`a_workspace_the_machine_already_had_still_takes_the_typed_name` is reshaped
rather than deleted. What it was really pinning is the create that answers under
another name, and that is still asserted, under a name that says so. The half it
asserted wrongly — that an adopted workspace takes the name too — is now pinned
the other way, once through `finish_prime` and once through `settle_hydration`,
which is the path the report actually came in through.

Left alone: the daemon still executes a `WorkspaceRename` from any client
without asking whether that client has ever pulled the tree. Like the tab-tree
half of #716, this fix is client-side, and an older build can still do it to a
machine running current main.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:17:34 +08:00
l0ng-ai 29312d5bb8 fix(procinfo): stop the listening-port probe failing silently (#731)
The Ports section says nothing when a pane has no listeners, and it said
exactly the same thing when the code that looks for listeners never ran.
#731 is a report from inside that gap: a Go service started with `go run
main.go` on macOS serves requests, and tty7 shows no port.

The reported shape itself holds up. `snapshot()` walks the whole descendant
tree from the pane's shell, `go run`'s compiled binary sits at depth 2, and
the `lsof` invocation is the one that finds it. What did not hold up is
everything around that.

The walk stopped at 64 processes, and it is depth-first over children in
ascending pid order. A shell whose earlier children brought a crowd — a
build, a container runtime, an agent's worker pool — could spend the whole
budget before the traversal reached the newest child, and the newest child,
highest pid and visited last, is precisely the server someone started ten
seconds ago. The walk now runs to a far larger bound and the probe is asked
about all of it; only the list handed to the panel is cut back to 64 rows.

Every way the probe can fail arrived as the same empty vector. `lsof`
missing from the daemon's PATH read as "nothing is listening" — and the
daemon's PATH is not the shell's, while macOS keeps `lsof` in /usr/sbin,
the sort of entry a hand-written `export PATH=...` drops. So did a probe
that hung: `Command::output` has no deadline and this runs on the thread
answering `QueryProcs`, so one `lsof` wedged on a dead mount takes the whole
pane's process list with it, permanently. The probe now falls back to the
absolute paths, is bounded at three seconds, and says which of those
happened.

A server under `sudo` is visible as a process and invisible as a socket:
`lsof` running as this user cannot read another user's fds. The walk now
carries each process's effective uid, and a tree holding someone else's
process says so rather than claiming the pane is quiet.

`PaneProcs` grew a `probe` verdict, `serde(default)` so an older
`tty7-server` at the far end of a remote workspace still parses and its
silence still reads as a complete answer. The panel spends it on the one
muted line where it used to write "None", and `tty7 procs` — the command
the issue asks reporters to run — prints a note under the empty PORTS
table. No banner and no new colour: an honest empty state, not a warning.

Deliberately left alone: `lsof`'s exit status. It returns 1 for a pid it
could not locate, and a pane's tree loses processes between the walk and the
probe as a matter of course, so reading that as a broken probe would put a
doubt on screen every time a command finished. A non-zero exit that also
found nothing gets a debug log line and no more. The Windows path is
untouched beyond its new return type — `GetExtendedTcpTable` has no tool to
be missing and no subprocess to hang.

I could not reproduce #731, and none of these is proven to be the
reporter's bug. Each is a way the panel could be silently wrong, and the
verdict is what will make the next report say which one.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:13:51 +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 cd1353fd98 fix(ssh): stop the password prompt coming back after a reconnect (#820)
Three things had to line up for a password sheet that keeps reappearing
after the connection has already succeeded, and cannot be closed. Two of
them are here; the third was the reason closing it never helped.

The connection cache keys one slot per host, and that slot's mutex is
what makes a reconnect ask for a password once rather than once per pane
— everyone dialling the same host queues on it, and all but the first
find the connection the winner left behind. Eviction removed the map
entry outright. A dropped TCP connection kills every pane riding it at
the same instant, so all of them reach the dead-reuse branch in
`run_session` together: the first removed the entry, its `open_connection`
inserted a fresh mutex, and the pane a moment behind it removed *that*
one — the mutex a handshake was already holding — and inserted another.
Each pane ended up alone on a mutex of its own, ran its own handshake and
raised its own prompt. Answer one and the link comes up; the rest are
still queued behind it. Eviction now empties the slot instead of
replacing it, and leaves a slot somebody is dialling on completely alone.
The entry outliving its connection is what the map already looked like
everywhere else, and `routes()` has always reported such a slot as
disconnected rather than omitting it.

The second is what happened on close. A declined prompt failed only the
method that raised it, and `authenticate` walked on to the next one — but
`password` and `keyboard-interactive` are one question asked two ways, and
a server offering both wants the same secret either way. Closing the
password sheet put a keyboard-interactive sheet up asking for the same
password. Nobody declines a *method*, so a declined prompt now ends the
attempt, with a reason distinct enough for a caller to recognise.

That caller is the workspace supervisor, which reconnects on a clock and
treated the refusal as a transient failure: it dialled again a second
later, and every thirty seconds after that, for the rest of the session.
It now suspends the machine instead. The strip already says why and
offers Retry, which is the user asking to be asked again.

A key passphrase is left alone deliberately. Closing that sheet declines
one key, and the methods still to come ask a different question — someone
who cannot remember a passphrase is usually closing it precisely to be
asked for a password instead. So is the fact that a password is only kept
across reconnects when "Remember" was ticked: that is a choice about
storing a secret, not a bug.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:09:32 +08:00
l0ng-ai 237b2a14c4 fix(daemon): restore a pane's terminal modes on re-attach (#774)
A pane's screen comes back on re-attach out of the replay ring, and the ring is
a window: eight megabytes wide, dropped from the front as it fills. That is the
right shape for text, which is only worth what is still on screen, and the
wrong one for modes. A full-screen program announces itself exactly once —
`btop` sends `?1049h` and its mouse-reporting modes when it starts and then
does nothing but refresh — so a long enough run of refreshes pushes the only
copy of that announcement out of the front of the ring. What the client
replays is then a screenful of alternate-buffer frames with nothing left to say
they belong on the alternate buffer: it paints them onto its primary screen
with reporting off, and `wheel_route`, which reads exactly those modes, sends
the wheel to the scrollback of a screen that has none. That is the "a screen
that should not scroll starts scrolling" in the report.

`replay_state` already refuses to rely on the ring for anything that matters —
cwd, prompt state, the remote context, the agent, the exit — because all of
those are facts about the pane rather than bytes on it. The modes are the same
kind of fact and were the exception, so the daemon now folds the bytes it hands
the ring into a small tracker (`core::term_modes`) and `replay_state` re-sends
what is still on. Tracked are the modes that decide input routing or which
buffer is on screen: the alternate screen in its three spellings, the mouse
reporting level and its encodings, alternate scroll, DECCKM, focus reporting
and bracketed paste. They are replayed in the order the application set them,
because the emulator treats the reporting modes as a level and not as
independent bits, so the last one set has to be last here too.

The frame goes *ahead* of the ring rather than after it. That way the replayed
frames are painted into the buffer they were drawn for, and re-entering an
alternate screen the ring turns out to still carry is a no-op in the emulator,
so a prefix and a ring that both carry the mode cannot fight. Where the ring
does still carry a toggle it wins on its own terms, since the fold runs over
every byte the pane ever wrote and therefore agrees with the ring's last word
on any mode the ring still mentions.

Cursor visibility (`?25`) and autowrap (`?7`) are deliberately not tracked. Any
frame of a running TUI repaints them within milliseconds, whereas restoring a
stale `?25l` would leave a shell with an invisible cursor — a worse failure
than the one being fixed, and one the existing `restore_preamble` already goes
out of its way to avoid. A daemon handoff starts the fold empty rather than
carrying it, so a pane adopted across a daemon restart is no worse off than it
is today; the ring it carries is all it ever had.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:09:15 +08:00
l0ng-ai a6e1271697 fix(terminal): decide the parked-cursor repair per pty, not per build (#774)
The repair that puts back the cursor a conhost repaint parks was gated on
`const REPAIR_PARKED_CURSOR: bool = cfg!(windows)` — a compile-time answer
about the platform the client was built for, when the question it is standing
in for is what kind of pty this pane is attached to. Those are not the same
question, and a Windows client's panes are not all alike. A local shell is a
ConPTY and so is `wsl.exe` or an `ssh` client running inside one, because they
are ordinary programs in the same conhost-rendered buffer. A pane on a remote
`tty7-server` is not: a remote workspace only installs onto Linux or macOS
(`asset_for_uname` refuses everything else), so its pty is raw. Nor is a
native-SSH pane, which the local daemon dials but opens no pty for — it bridges
an ssh channel straight through.

The repair is written for what conhost does: it brackets a frame in `?25l` …
`?25h` and, on the frames where it did not paint the cursor, commits the show
wherever the last erase left it. Applied to a raw pty it is not a correction
but a corruption, because there the application owns the cursor and may
deliberately end a repaint on the text it just wrote. Vim opens its `:` command
line exactly that way and then echoes the following keystrokes as bare bytes,
so moving the cursor back to where the repaint hid it drops the `wq` two rows
up, onto the line being edited. That is #430 again, on the path the Mac fix
never covered: a Windows client talking to a Linux host.

So the decision moves to a per-pane `PtySource`, resolved at runtime from the
route the pane was spawned or attached on and carried to the reader as a shared
flag. `RemoteContext` is a second source of the answer, because the route
cannot supply it: a native-SSH pane is spawned and attached through the local
daemon like any other, so a window reopening onto an existing one by pane id
has nothing else to go on. Learning `RemoteKind::NativeSsh` turns the repair
off for good — one way, since the far end of an ssh channel never becomes a
local pty later, and since an `ssh` *command* (`RemoteKind::Ssh`) is a program
inside a ConPTY and must keep the repair. A relink reuses the pane's existing
answer rather than recomputing one, for the same reason: the pty on the far
side is the pty it always was.

Nothing about when a cursor counts as parked changed — `ParkedCursorScanner`
and `ParkedCursorRepair` are untouched, and the Windows behaviour they were
written for is unchanged for every pane that really is on a ConPTY. The tests
that covered them move out of the `cfg(unix)` module and onto the socket-pair
harness that already runs on both platforms, so both answers are now driven
directly instead of one of them being whatever the host happened to compile:
a conhost frame on a ConPTY still parks, the same frame off a raw pty does not,
vim's `:wq!` lands on the command line, and a native-SSH context turns the
repair off mid-stream.

Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
2026-09-09 18:07:58 +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 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 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 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 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 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