feat(keys): ⌥F freezes, ⌥↑/⌥↓ recall, and ⌃F is free - #26
Conversation
⌃F becomes search and freeze moves to ⌥F; ⌥↑/⌥↓ recall history without the caret-row precondition; the activity boundary generalises from "you left the terminal" to "you left this window" and gains a dwell floor. Every chord was driven at a raw-mode reader with `kitten @ send-key` before it was spent — ⌃↑/⌃↓, one of the two the request offered, was never available. 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
⌃F means find to everyone who has used a computer, and the search surface in the third PR of this stack takes it. Freeze keeps its letter and changes its modifier — ⌥F is `ESC f`, measured at a raw reader with `kitten @ send-key`, the same way ⌥D and ⌥R were. No ⌃F alias is left behind: the ⌃D rule, that a second key for one action is either a secret or a duplicate row on every surface listing chords. The label a *frozen* reader is looking at moves with the chord — a bar naming a key that no longer thaws the pane would be the worst place to leave a stale one. The chord test reads the frame rather than FrozenScrollbackOf: that viewport is created on first freeze and kept for the life of the window, so it answers non-null for ever after the first ⌥F and cannot say whether a pane is frozen now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
The bare arrows recall only where the caret has nowhere further to go, which stops being a rule you can hold in your head once the command line grows to a second row. They are unchanged — the Alt pair is added beside them and skips the caret test. ⌃↑/⌃↓ was the other chord offered and was never available: the terminal writes ESC [ 1;5 A for it and this client already spends that on pane selection and on the ladder onto the second command line. ESC [ 1;3 A is Alt, is free, and was measured at a raw reader before it was spent. Matched on exact modifiers, so ⌥⇧↑ still reaches the pane resize. Also corrects four stale chords in the design README's key list — ⌃Tab (cannot arrive: a terminal writes 0x09), ⌃R as reconnect (it is the history surface), ⌃L as a logging toggle (it is the newline chord) and Shift+⏎ (collapses onto a bare Enter). MacroKeys.Verdict was already right about all four. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (36)
WalkthroughThis PR adds pane output search, activity-boundary tracking, and search/activity bars. It moves freeze to ChangesTUI search and activity behavior
Workspace routing and trigger output
Design and verification documentation
Estimated code review effort: 5 (Critical) | ~100 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/SharpMUTerm.Tui/SharpMUTermApp.cs`:
- Around line 4728-4735: Correct the comment above the ConsoleKey.F freeze
handler to state that freeze is now ⌥F rather than ⌃F, matching the equivalent
wording in MacroKeys.cs; preserve the existing delivery explanation and
ToggleFreeze behavior.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 9e0dfa04-cbec-4bff-a8c6-89da0256baf0
📒 Files selected for processing (12)
CLAUDE.mddocs/design/README.mddocs/superpowers/plans/2026-08-11-find-chords.mddocs/superpowers/specs/2026-08-11-pane-search-and-activity-design.mdsrc/SharpMUTerm.Tui/FreezeBarRenderer.cssrc/SharpMUTerm.Tui/MacroKeys.cssrc/SharpMUTerm.Tui/SharpMUTermApp.cstests/SharpMUTerm.Tui.Tests/FreezeBarRendererTests.cstests/SharpMUTerm.Tui.Tests/FreezeChordTests.cstests/SharpMUTerm.Tui.Tests/HistoryChordTests.cstests/SharpMUTerm.Tui.Tests/OutputScrollbackTests.cstests/SharpMUTerm.Tui.Tests/TimestampGutterTests.cs
Review catch, and a self-inflicted one: the blanket ⌃F → ⌥F rewrite that moved the chord across this file also rewrote the words "It was ⌃F", leaving a comment that read "It was ⌥F, and moved" — the chord it moved *to*, which says nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
…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
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
feat(activity): a boundary for the window you were not watching, and one that lasts
First of a three-PR stack (
feat/find-chords→feat/window-activity-boundary→feat/pane-search).Design:
docs/superpowers/specs/2026-08-11-pane-search-and-activity-design.md.Freeze moves to
⌥F.⌃Fmeans find to everyone who has used a computer, and the searchsurface in PR 3 takes it. Freeze keeps its letter and changes its modifier — the smallest move that
frees the chord. No
⌃Falias is left behind (the⌃Drule: a second key for one action is either asecret or a duplicate row on every surface that lists chords). The
❄ FROZEN ⌥Flabel a frozenreader is actually looking at moves with it, as do the docs.
History gets its own chord,
⌥↑/⌥↓. The bare arrows still recall at the edges and areunchanged; the Alt pair skips the caret test, so it works on a draft tall enough to have another row.
That was the report. Matched on exact modifiers, so
⌥⇧↑still reaches the pane resize.Every chord was measured at a raw-mode reader with
kitten @ send-keybefore it was spent:⌥Fis
ESC f,⌥↑/⌥↓areESC [ 1;3 A/B.⌃↑/⌃↓— the alternative the request offered —arrives as
ESC [ 1;5 Aand was already spent twice, on pane selection and on the ladder onto thesecond command line.
Also swept up: four stale chords in the design README's key list (
⌃Tab, which cannot arrive atall;
⌃Ras reconnect;⌃Las a logging toggle;Shift+⏎).MacroKeys.Verdictwas already rightabout all four.
Between this PR and PR 3,
⌃Fis claimed by nobody and F4 correctly reports that a macro on itfires. That is the honest intermediate state and needs no stub.
Verification:
dotnet build SharpMUTerm.slnxclean and warning-free; all five suites green(2,653 tests). The
freezesnapshot frame rendersFROZEN ⌥F.🤖 Generated with Claude Code
https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN
Summary by CodeRabbit
New Features
Ctrl+F, including regex mode, focused/all-window scope, result navigation, and match markers.Alt+F.Alt+UpandAlt+Downwhile preserving bare-arrow caret behavior.Bug Fixes
Documentation