Skip to content

feat(keys): ⌥F freezes, ⌥↑/⌥↓ recall, and ⌃F is free - #26

Merged
HarryCordewener merged 22 commits into
mainfrom
feat/find-chords
Aug 11, 2026
Merged

feat(keys): ⌥F freezes, ⌥↑/⌥↓ recall, and ⌃F is free#26
HarryCordewener merged 22 commits into
mainfrom
feat/find-chords

Conversation

@HarryCordewener

@HarryCordewener HarryCordewener commented Aug 11, 2026

Copy link
Copy Markdown
Member

First of a three-PR stack (feat/find-chordsfeat/window-activity-boundaryfeat/pane-search).
Design: docs/superpowers/specs/2026-08-11-pane-search-and-activity-design.md.

Freeze moves to ⌥F. ⌃F means find to everyone who has used a computer, and the search
surface in PR 3 takes it. Freeze keeps its letter and changes its modifier — the smallest move that
frees the chord. No ⌃F alias is left behind (the ⌃D rule: a second key for one action is either a
secret or a duplicate row on every surface that lists chords). The ❄ FROZEN ⌥F label a frozen
reader 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 are
unchanged; 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-key before it was spent: ⌥F
is ESC f, ⌥↑/⌥↓ are ESC [ 1;3 A/B. ⌃↑/⌃↓ — the alternative the request offered —
arrives as ESC [ 1;5 A and was already spent twice, on pane selection and on the ladder onto the
second command line.

Also swept up: four stale chords in the design README's key list (⌃Tab, which cannot arrive at
all; ⌃R as reconnect; ⌃L as a logging toggle; Shift+⏎). MacroKeys.Verdict was already right
about all four.

Between this PR and PR 3, ⌃F is claimed by nobody and F4 correctly reports that a macro on it
fires. That is the honest intermediate state and needs no stub.

Verification: dotnet build SharpMUTerm.slnx clean and warning-free; all five suites green
(2,653 tests). The freeze snapshot frame renders FROZEN ⌥F.

🤖 Generated with Claude Code

https://claude.ai/code/session_015nuKnWthnELNkrd86q5KWN

Summary by CodeRabbit

  • New Features

    • Search command output with Ctrl+F, including regex mode, focused/all-window scope, result navigation, and match markers.
    • Freeze and resume the focused pane with Alt+F.
    • Recall command history with Alt+Up and Alt+Down while preserving bare-arrow caret behavior.
    • See activity bars for output missed while away, with configurable display duration.
    • Route messages to existing workspace windows when available.
  • Bug Fixes

    • Improved rewritten-text highlighting and trigger routing behavior.
  • Documentation

    • Updated keyboard shortcuts, freeze labels, command mappings, and usage guidance.

HarryCordewener and others added 4 commits August 11, 2026 14:41
⌃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
⌃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
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 72726f3b-6a59-4068-b238-c6b6662dc016

📥 Commits

Reviewing files that changed from the base of the PR and between 9715575 and 361771e.

📒 Files selected for processing (36)
  • CLAUDE.md
  • docs/design/README.md
  • docs/superpowers/plans/2026-08-11-pane-search.md
  • docs/superpowers/plans/2026-08-11-window-activity-boundary.md
  • src/SharpMUTerm.Core/Automation/Trigger.cs
  • src/SharpMUTerm.Core/Automation/TriggerEngine.cs
  • src/SharpMUTerm.Core/Configuration/PreferenceSettings.cs
  • src/SharpMUTerm.Core/Text/OutputSearch.cs
  • src/SharpMUTerm.Core/Workspace/Workspace.cs
  • src/SharpMUTerm.Tui/AwayBarRenderer.cs
  • src/SharpMUTerm.Tui/Glyphs.cs
  • src/SharpMUTerm.Tui/MacroKeys.cs
  • src/SharpMUTerm.Tui/MarkupText.cs
  • src/SharpMUTerm.Tui/OptionsScreenRenderer.cs
  • src/SharpMUTerm.Tui/PaneLine.cs
  • src/SharpMUTerm.Tui/SearchBarRenderer.cs
  • src/SharpMUTerm.Tui/SearchPrompt.cs
  • src/SharpMUTerm.Tui/SearchSurface.cs
  • src/SharpMUTerm.Tui/SharpMUTermApp.cs
  • src/SharpMUTerm.Tui/TriggersScreenRenderer.cs
  • src/SharpMUTerm.Tui/TriggersScreenView.cs
  • tests/SharpMUTerm.Core.Tests/Automation/HighlightRewriteTests.cs
  • tests/SharpMUTerm.Core.Tests/OutputSearchTests.cs
  • tests/SharpMUTerm.Core.Tests/Workspace/RouteToExistingWindowTests.cs
  • tests/SharpMUTerm.Tui.Tests/ActivityBarDwellTests.cs
  • tests/SharpMUTerm.Tui.Tests/AwayBarRendererTests.cs
  • tests/SharpMUTerm.Tui.Tests/AwayDividerTests.cs
  • tests/SharpMUTerm.Tui.Tests/FreezeChordTests.cs
  • tests/SharpMUTerm.Tui.Tests/MarkupTextPlainTests.cs
  • tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs
  • tests/SharpMUTerm.Tui.Tests/SearchBarRendererTests.cs
  • tests/SharpMUTerm.Tui.Tests/SearchEndToEndTests.cs
  • tests/SharpMUTerm.Tui.Tests/SearchPromptTests.cs
  • tests/SharpMUTerm.Tui.Tests/TriggerRouteDestinationTests.cs
  • tests/SharpMUTerm.Tui.Tests/TriggersScreenRendererTests.cs
  • tests/SharpMUTerm.Tui.Tests/WindowActivityBoundaryTests.cs

Walkthrough

This PR adds pane output search, activity-boundary tracking, and search/activity bars. It moves freeze to Alt+F, assigns Ctrl+F to search, adds unconditional Alt+Up/Down history recall, improves window routing and rewrite highlighting, and updates tests and documentation.

Changes

TUI search and activity behavior

Layer / File(s) Summary
Search matching and modal search surface
src/SharpMUTerm.Core/Text/OutputSearch.cs, src/SharpMUTerm.Tui/Search*.cs, src/SharpMUTerm.Tui/PaneLine.cs, tests/SharpMUTerm.Core.Tests/OutputSearchTests.cs, tests/SharpMUTerm.Tui.Tests/Search*Tests.cs
Search supports case-insensitive literal and regex matching, focused or all-window scope, result navigation, errors, scrolling, and result landing. Pane lines retain markup-free text for matching.
Activity boundaries and bars
src/SharpMUTerm.Tui/SharpMUTermApp.cs, src/SharpMUTerm.Tui/AwayBarRenderer.cs, src/SharpMUTerm.Core/Configuration/PreferenceSettings.cs, src/SharpMUTerm.Tui/OptionsScreenRenderer.cs, tests/SharpMUTerm.Tui.Tests/*Activity*, tests/SharpMUTerm.Tui.Tests/Away*
The application records output missed by background or scrolled-away windows. Activity bars use distinct missed-output labels, index-safe insertion, live-tail checks, and configurable dwell timing.
Keyboard chords and freeze behavior
src/SharpMUTerm.Tui/MacroKeys.cs, src/SharpMUTerm.Tui/SharpMUTermApp.cs, src/SharpMUTerm.Tui/FreezeBarRenderer.cs, tests/SharpMUTerm.Tui.Tests/*ChordTests.cs
Ctrl+F opens search. Alt+F toggles freeze. Exact Alt+Up/Down recalls history. Bare arrows retain caret-first and edge-based behavior.

Workspace routing and trigger output

Layer / File(s) Summary
Existing-window routing and rewrite highlighting
src/SharpMUTerm.Core/Workspace/Workspace.cs, src/SharpMUTerm.Core/Automation/Trigger*.cs, src/SharpMUTerm.Tui/SharpMUTermApp.cs, tests/SharpMUTerm.Core.Tests/Workspace/*, tests/SharpMUTerm.Core.Tests/Automation/*, tests/SharpMUTerm.Tui.Tests/Trigger*
Routing selects eligible existing windows before spawning and preserves session ownership. Rewritten trigger output receives full-line styling, while unchanged output keeps match-scoped highlighting.
Route-target UI wiring
src/SharpMUTerm.Tui/TriggersScreen*.cs, tests/SharpMUTerm.Tui.Tests/TriggersScreenRendererTests.cs
Trigger screens rename spawnTargets to routeTargets and pass workspace route suggestions through rendering and editing.

Design and verification documentation

Layer / File(s) Summary
Implementation plans and design references
docs/superpowers/plans/*, docs/superpowers/specs/*, docs/design/README.md, CLAUDE.md
Documentation describes search, activity boundaries, keyboard encodings, routing behavior, snapshot views, build instructions, and the updated shortcut mappings.

Estimated code review effort: 5 (Critical) | ~100 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main keyboard changes: Alt+F freezes, Alt+Up/Down recall history, and Ctrl+F is unclaimed.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between e8b42e8 and abff85e.

📒 Files selected for processing (12)
  • CLAUDE.md
  • docs/design/README.md
  • docs/superpowers/plans/2026-08-11-find-chords.md
  • docs/superpowers/specs/2026-08-11-pane-search-and-activity-design.md
  • src/SharpMUTerm.Tui/FreezeBarRenderer.cs
  • src/SharpMUTerm.Tui/MacroKeys.cs
  • src/SharpMUTerm.Tui/SharpMUTermApp.cs
  • tests/SharpMUTerm.Tui.Tests/FreezeBarRendererTests.cs
  • tests/SharpMUTerm.Tui.Tests/FreezeChordTests.cs
  • tests/SharpMUTerm.Tui.Tests/HistoryChordTests.cs
  • tests/SharpMUTerm.Tui.Tests/OutputScrollbackTests.cs
  • tests/SharpMUTerm.Tui.Tests/TimestampGutterTests.cs

Comment thread src/SharpMUTerm.Tui/SharpMUTermApp.cs Outdated
HarryCordewener and others added 18 commits August 11, 2026 15:59
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
@HarryCordewener
HarryCordewener merged commit e47a370 into main Aug 11, 2026
2 of 3 checks passed
@HarryCordewener
HarryCordewener deleted the feat/find-chords branch August 11, 2026 23:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant