CI found both new end-to-end cases red on Linux and macOS while green on
Windows. One was a bad assumption in the test; the other was the test
telling me the fix is narrower than the first commit claimed.
The real finding is the resize case. On Unix a resize raises SIGWINCH and
the shell repaints its prompt, so the segment the resize opens is *not*
empty — it holds the repaint. Dropping byte-less segments therefore
leaves the newest non-empty segment being a bare prompt, with the pane's
output still stranded in the segment sealed behind it: on CI the default
form came back as 174 bytes of prompt escapes and no marker. On Windows
nothing answers the resize, the segment stays empty, and the fix reaches
past it to the output — which is why the assertion passed there. It was
asserting an accident of the platform.
So the fix stands but is smaller than "a resize no longer costs you the
pane's output": it makes the zero-byte answer impossible, and that is
all. It cannot do more. Nothing in the byte stream distinguishes a prompt
repaint from output the pane meant, so no client-side rule can tell which
side of the boundary the answer is on. The boundary is the flaw — the
default form's unit is the last resize, an event in the window rather
than in the pane — and moving it means redefining what the default
returns (the last screenful of the ring, say), which would shrink what
every caller with a never-resized pane gets today. Left alone, and said
plainly instead: in `what_was_asked_for`'s doc comment, and in a warning
in the CLI reference telling anyone reading a pane under the GUI to ask
for `--scrollback`.
The test now asserts what the fix actually guarantees on every platform —
the default form answers with bytes rather than with the resize's
placeholder, and it is the end of what `--scrollback` returns, which is
what would catch a fix reaching for the wrong segment. The marker is
pinned against `--scrollback`, the form that promises to hold it.
The `--tail` failure was a race in the test, not in `--tail`: the whole
and the tail were separate calls and the pane advanced between them, so
the tail carried a prompt line the whole capture had not caught up to.
Both cases now read the whole answer on either side of the others and
require the two readings to match before comparing anything, which is
what makes the comparison a statement about the code rather than about
the moment. Both also read `--scrollback` now, so neither depends on
where a segment boundary happens to fall, and neither pins exact pane
content — the macOS runner prints a zsh banner into the pane.
Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
Every "how did the last command end?" query went through `| tail -n 5`,
which ships the whole grid down a pipe to throw most of it away — and on
Windows, where `capture` is just as useful, there is no `tail(1)` to pipe
to. `--tail N` keeps the last N lines of the answer instead.
It is a third independent choice beside `--scrollback` (how much of the
ring) and `--plain` (in what form), and it composes with both. The trim
runs last, after `--plain` has decided what a line is: a shell line the
pane wrapped over three rows is one line to the grid and three to a byte
counter, so `--plain --tail 1` hands back the whole of the last line
rather than its final row. The raw form counts the same way `tail` does,
splitting on the newline and leaving the CR of a CRLF attached to the
line it ended, so the bytes stay the pane's own. A trailing newline
terminates the last line rather than opening an empty one, which is the
difference between `--tail 1` answering the last line and answering
nothing.
`N` must be at least 1: a tail of zero lines would print an empty result
that reads exactly like the blank-pane ambiguity this issue is about, so
it is a usage error (exit 2) instead. `--json` reports the tail in `text`
but leaves `bytes` the size of the whole replay, so the pair still says
"this was trimmed" rather than "this came back short".
Left alone deliberately: the daemon still replays the entire ring on
every observe, so the saving here is the pipe, not the wire. Bounding
what crosses the wire means teaching `ClientMsg::Observe` a limit and
versioning the protocol for it, which is a much larger change than the
papercut warrants — and the default (newest-segment) form has always
received the whole ring and discarded most of it, so this adds no new
cost. The docs say so rather than implying otherwise.
Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
`tty7 capture %n` answered a live pane with zero bytes and exit 0, which
is byte-identical to a pane that had genuinely never printed. The report
guessed at the replay grid. It is not the grid — it is the segment the
grid was handed.
The daemon's replay ring splits on resize: `ReplayRing::resize` seals the
segment holding the output and pushes an empty one at the new geometry,
and `ReplayRing::replay` sends every segment it holds, empty tail
included. `RealBackend::capture` then kept "the newest segment" for the
default (non-`--scrollback`) form, and for any pane resized since it last
printed, the newest segment is that empty placeholder. A pane restored
from disk lands in the same state, because seeding the ring ends in a
resize too.
Measured end to end against a real daemon before the fix: a pane holding
954 bytes of scrollback answered `capture` and `capture --plain` with 0
bytes and exit 0 while `capture --scrollback` returned all 954. The fix
drops byte-less segments before choosing the newest one — they carry
nothing in either form, so `--scrollback` drops them too and both forms
describe the same bytes. The daemon still sends them: its trailing `Size`
is how an attaching client learns the pane's current geometry, and that
is not the CLI's to take away.
What this does not explain is the reporter's other half — that
`--scrollback` came back empty in the same episode. Filtering empty
segments cannot cause that, and neither could I reproduce it: 60 rounds
of `capture` against a pane spewing 4000 escape-laden lines produced no
empty result, and an 8 MiB ring (the cap) replayed whole in ~400 ms, well
inside the 300 ms per-frame settle window that was the other suspect. So
the silent-empty class is left distinguishable rather than declared
closed: `--json` now carries `bytes`, the size of the replay counted
before anything renders or trims it, and the one case where an empty
answer is not an empty replay — bytes in, no text out — says so in a line
on stderr. Zero bytes is a pane that printed nothing; bytes with no text
is a screen whose content did not survive the grid. Deliberately no retry
loop and no warning on a condition that has not been established.
Claude-Session: https://claude.ai/code/session_01UUyWQXzcBAoBzaSX8pc7nU
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
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
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.
`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.
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
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
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
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
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
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
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
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
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
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
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
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
* 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>
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
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
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
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
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
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
`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
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
`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
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
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
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
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.
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
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.
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.
`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