feat(activity): a boundary for the window you were not watching, and one that lasts - #27
Merged
HarryCordewener merged 16 commits intoAug 11, 2026
Merged
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
…dwell floor Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
Beside the terminal-absence bar rather than replacing it: AWAY is about the reader, NEW is about the window, and a reader who meets both in one client should be able to tell them apart without counting the lines. No duration on this one. The terminal bar's span is measured from the last input before the reader vanished — approximate, but an instant they were part of. This boundary is made when a line lands in a window nobody is watching, so a span on it would be timing the output rather than the absence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
Plain text in, matching lines out: nothing here knows about panes, markup or windows, which is what keeps it in Core and what makes a match mean what it looks like. A colour tag mid-word must not split a match, and nobody should be able to search for #ff0000 and find every red line. Five decisions, each pinned: case ignored in both modes (HistorySearch already is, and (?-i) is the way back, which is why there is no third toggle); one match per line, the first, with offsets so a row can show why it is listed; an empty query matches nothing, unlike history, because a pane buffer is thousands of lines and "everything, oldest first" is the pane you are already looking at; an invalid pattern is a state, because a regex is unparseable most of the time it is being typed; and a match timeout, because this runs on the UI thread on every keystroke over every line of every window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
…erminal A window accrues a boundary from the first line that lands while it is not caught up — visible *and* at its live tail, the rule the unread badge already answers to — so a badge showing a count always has a bar under it saying where the count begins. The badge said 3 and nothing said which 3; that was the report. Recorded forwards, unlike the terminal boundary beside it, which has to be reconstructed from the input before the last because focus-out is unobservable. Where both exist the older wins: a reader who typed after lines landed in a window they could not see has a terminal boundary at the end of that buffer, saying they missed nothing, and the window's own boundary knows better. The reveal is only for *arriving* at a window. Coming back to the tail by ⌃End is an explicit "take me to the live tail", and a client that answered it by scrolling somewhere else would move the reader off the place they just asked for — CtrlEndGoesBackToFollowingTheLiveTail is the test that says so. The pane is re-pinned after the insert instead, because a whole-buffer re-feed leaves the offset a frame behind and the newest line would blink off the screen. Nothing accrues before the constructor finishes: until the workspace is laid out "not visible" means "no pane built yet", and the restore replay pours a previous session through the same seam into windows already under a restore bar. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
MarkupText.Plain is what a markup line actually puts on the screen, and PaneLine holds it beside the markup, computed once at append. On demand would restrip every line of every window on every keystroke of a query, and an incremental surface is the whole point. Plain rather than markup because a match has to mean what it looks like: a world may change colour mid-word, and matching the markup would find neither half — the same defect a URL split by a colour change has, one layer down — while a query for #ff0000 must not find every red line. Chrome rows carry no plain text, so a search cannot find its own boundary bars. Plain and VisibleLength are pinned against each other over a table of inputs: they share a protect-then-strip shape, and a divergence would put a match's offsets in a different coordinate system from the width renderers measure with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
Two conditions were not enough. On a shallow absence the pane is already at its live tail when the reader returns, so the very next keystroke retired the bar a second or two after it appeared — the bar going before it could be read. The third is a floor in time, because that is the unit the complaint was in: a raised input count would be an hour on a quiet character and three seconds on a busy one. It is a floor and not a timer — nothing fires on its own, so the bar goes on the first of the existing checks after the floor has passed, and a client sitting untouched keeps its bar. F7 ▸ ACTIVITY, default 30, and 0 restores the previous behaviour exactly. New defaulted field, so no schema bump and no migration. Measured off the app's existing TimeProvider, so the tests move the clock instead of racing it. AwayDividerTests takes the floor at zero: those tests are about where the bar is drawn and what removes it, and several turn on "one keystroke is all it takes". Each suite asserts one rule rather than both at once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
…t says The HistorySearchPrompt/HistorySurface split verbatim: the rules and the wording are exactly the part a headless test can pin. ⌥E and ⌥A both re-search from the top, because each changes what the list is and a pointer kept at row 12 of a different result set points at nothing the reader chose. The header states the bound it searched — '12 found · 4,812 lines held' — because ⌃F sees the pane buffer and not a session's whole history, and a reader who cannot find an old line should be able to see why rather than concluding the search is broken. The window column is drawn only when every window is searched; with one window it would be the same word on every row. The footer names only keys that work, pinned by a test that presses every one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
Adds the `activity-bar` view — three lines land in the main window while Chat is in front of it, and picking main back lands on the NEW bar with those three under it. Separate from `away` because the two are separate facts with separate wording, and this is the one that happens many times an hour. Also corrects the snapshot recipe, which cost this change real time: it said `dotnet build SharpMUTerm.slnx`, which builds *Debug*, while `--no-build` runs the *Release* output. The render then comes from a binary predating your change and a new view comes out byte-identical to the default frame — indistinguishable from a feature that does not work. The trap hides whenever you have just run the suites, because those refresh Release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
The chord, the scope and the landing. ⏎ activates the window the line is really in — through Activate, the one activation path, so the pane, the tab and the session move together rather than a pane being scrolled behind the reader's back — and marks the line with a bar, fourth of the boundary bars, which points rather than repaints: the line is worth having because it is the game's own text in the game's own colours. Two things measured rather than chosen. ⌥⇧G is *not* bound: kitty writes it as CSI 103;4u, a kitty-keyboard-protocol sequence AnsiInputParser drops, so it could never arrive — ⌥⇧1's story one letter over. And Escape does not clear the bar: a claimed Escape does not set _escapeAt, and TryAltEnter pairs an unclaimed one with a following Enter to make Alt+⏎, so binding it would break the newline chord for as long as a bar was on screen. ⌥G removes the bar *before* re-running the search. The bar is itself a row, so a search run around it returns indices in a buffer about to lose one and every hit below it lands a row early — which is what the test caught. Two kinds of inserted chrome now, so the index bookkeeping every buffer mark depends on is one pair of methods (InsertChromeRow/RemoveChromeRow) rather than written out at each site: the freeze point, the pending boundary, the activity bar and the search bar all move together or none of them do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
Four views, because four things about ⌃F are only visible in a frame: a plain query with its hits marked, the same query read as a pattern (the header is the only place either state is said — compose/compose-literal's reasoning), the widened scope where the window column appears, and what ⏎ leaves behind. The last is over a split, so the pane is narrower than the terminal: that is the geometry that catches a landing scrolled to the wrong row. The demo scene now loads with _watching off. It pours a spawn window's whole history in before the first frame, so every line counted as missed and any frame that later made such a window visible carried an activity bar reporting the client's own setup as news — which is exactly what the first cut of the landed frame showed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
HarryCordewener
force-pushed
the
feat/window-activity-boundary
branch
from
August 11, 2026 21:00
037ae39 to
7659a87
Compare
The reported defect was "highlight colours don't seem to actually work". They work alone — `Highlight_RecoloursMatchedRegion` has always passed — and they were destroyed by the rule's *own* rewrite. `Process` applied the highlight to the matched region and then, four lines later, replaced the whole line with `StyledLine.FromText(text, TextStyle.Default)`: no colour, no attributes, no left rule. That combination is not exotic, it is what a channel rule looks like — route it, tidy it to `» $1`, colour it — and it is the shape of the demo configuration's own headline rule. The F2 screen badged such a rule `H` and painted both swatches, so the client promised a highlight it then threw away, and the only way to find one was to discover that deleting the rewrite brought it back. The rewrite now runs first and the highlight covers the whole of what it produced. It cannot cover the match's own offsets, because after a rewrite those address a string that no longer exists; the rewritten text is the rule's product in its entirety, so colouring all of it is the only reading that means anything. Without a rewrite nothing moves: the highlight covers the match and only the match, as it always has. A *later* rule's rewrite still replaces an earlier rule's highlighted text, and that is correct rather than the same bug one rule over — those characters are gone. Pinned, so the ordering fix is not later generalised into re-colouring text the first rule never saw. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
…ly to a spawn "Triggers should not be restricted to only be able to go to spawns." They were, and the restriction was one method deep: `Workspace.RouteSpawn` was the only destination resolver a matched rule had, and it computes `SpawnWindowId(sessionKey, target)` and registers a new `WindowKind.Spawn` window when nothing answers to that id. There is no branch in it that can reach a window that already exists under any other name, so a route naming a window on the screen opened a second one beside it wearing the same label, and the one the user was looking at stayed empty. `Workspace.RouteLine` is the resolver now: a window the target already names wins, and creating a capture pane is what happens when nothing does. `FindRouteTarget` is the same lookup without the side effects, so the shell can still tell "this line opened a pane" from "this line went to one that was already there" without routing twice. What a target may reach is deliberately narrower than "any window with that title": this session's own windows, the windows nobody owns, and another character's *main* window — one alt's channel collected into the pane you actually read. It is not another session's spawn or auxiliary window, because two characters running one capture rule get a pane each and a bare title lookup would collapse them back into one, which is the exact defect `SpawnWindowId` was given an owner to fix. Only a placed window is a destination: appending to a window no pane holds is indistinguishable from the rule not firing. And finding never creates, which is the property that bounds a capture-derived target — that arm can only ever land in a window the user already has. Two consequences in the shell. `OwnerLabel` is stamped on this session's own capture panes only; writing our name onto a window somebody else owns would rename their pane after whoever last routed a line into it. And the F2 `route` field now suggests the workspace's own windows as well as the other rules' targets — while the list was the rules' targets alone, the one place a user reads what a route may say could not name any window they had open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
Review catch. The title lookup already required a *placed* window, and the renamed-spawn fallback beside it did not — so a spawn window whose pane the user closed could be returned as a destination and the channel written into a buffer nothing draws. That is reachable rather than theoretical: the registry outlives the layout, and a restored workspace registers windows a saved layout no longer places. It also contradicted RouteLine's own doc comment, which says a closed window is passed over and the line goes somewhere visible. Both halves, because guarding only the fallback moves the same defect one call deeper: RouteSpawn returned a registered-but-unplaced window untouched, since it only placed one it had just created. It now places on the way past whether or not the window is new — which is what lets FindRouteTarget decline a closed window and fall through, reopening the pane under the same id with its history in it. The regression test was checked against the unfixed code and fails there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
feat(search): ⌃F searches the output across the panes
fix(triggers): route to a window that already exists, and keep a highlight through a rewrite
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Second of a three-PR stack — based on
feat/find-chords(#26), review that one first.Design:
docs/superpowers/specs/2026-08-11-pane-search-and-activity-design.md, part 3.The boundary generalises
Today the client marks where you left off only when you tab away from the terminal. Look away from
a window instead — switch tabs, switch characters, scroll a pane back — and it counts the lines on
a badge and marks nothing. The badge said 3 and nothing on screen said which 3.
A window now accrues from the first line that lands while it is not
Workspace.IsCaughtUp— visibleand at its live tail. That is deliberately the same rule the unread badge answers to, so a badge
showing a count always has a bar under it saying where the count begins.
It is recorded forwards, which the terminal boundary beside it cannot be: a terminal reports
focus-in and this client cannot observe focus-out at all, so that one is reconstructed from the input
before the last. Where a window has both, the older wins — a reader who typed after lines landed
in a window they could not see has a terminal boundary at the end of that buffer, claiming they missed
nothing.
Two words, not one bar with two meanings:
AWAYis about the reader,NEWis about the window. Thewindow bar carries no duration, because its boundary is made when a line arrives rather than at a
moment the reader was part of — a span on it would be timing the output.
The bar lasts
Retirement gains a third conjunct: a dwell floor, F7 ▸ ACTIVITY, default 30 s,
0restoring the oldbehaviour exactly. Two were not enough — on a shallow absence the pane is already at its live tail, so
the next keystroke took the bar a second or two after it appeared. Time and not keystrokes because
that is the unit the complaint was in. It is a floor, not a timer: nothing fires on its own, so an
untouched client keeps its bar. New defaulted field, so no schema bump and no migration.
Two things worth a reviewer's eye
and scrolling somewhere else in answer to it moves the reader off the place they just asked for.
CtrlEndGoesBackToFollowingTheLiveTailcaught exactly that. The pane is re-pinned after the insertinstead, because a whole-buffer re-feed leaves the offset a frame behind.
_watching). Until the workspace is laid out,"not visible" means "no pane built yet" — and
RestorePreviousSessionpours a previous run's linesthrough the same seam into windows that already sit under a restore bar.
Verification
dotnet build -c Release SharpMUTerm.slnxclean and warning-free; five suites green (2,664 tests).New
activity-barsnapshot view rendered and looked at.The snapshot recipe in CLAUDE.md is also corrected: it said
dotnet build SharpMUTerm.slnx, whichbuilds Debug, while
--no-buildruns Release. A new view then renders byte-identical to thedefault frame from a stale binary, which is indistinguishable from a feature that does not work.
🤖 Generated with Claude Code
https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN