Skip to content

ui: a keyboard registry, a collision gate, and CodeMirror's keymap folded in - #118

Merged
samkeen merged 4 commits into
mainfrom
claude/kb-shortcut-collision-detect-y4kuln
Jul 28, 2026
Merged

ui: a keyboard registry, a collision gate, and CodeMirror's keymap folded in#118
samkeen merged 4 commits into
mainfrom
claude/kb-shortcut-collision-detect-y4kuln

Conversation

@samkeen

@samkeen samkeen commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

A chord used to be spelled twice — a modifier test in main.ts's keydown
handler and a row of display text in shortcuts.ts — with nothing but
discipline keeping them equal. K1 promises every action has a keyboard
path and that the path is findable, so a sheet free to fall behind the
wiring is a promise waiting to break. And because main.ts can't be
imported by node, none of it was testable: the app's whole keyboard
contract sat in the one file the suite can't see.

The chord is now declared once, in ui/src/bindings.ts — id, chord,
scope — and the rest derives. The handler matches with isBound(e, id),
the editor's keymap takes chordFor(id) (the chord syntax is
CodeMirror's, so one spelling serves both), and the ? sheet names ids
rather than spelling chords. shortcuts.test.ts fails on a binding no
row documents, which turns "add the row in the same change" from a
convention into something the suite keeps.

What deliberately stays as code: the order the surfaces get their turn,
and the guard beside each branch. A table rich enough to express "⌘G,
but only while the find bar is open" is a when-clause language, which
is a worse trade at this size than a guard you can read.

conflicts() is the gate — two commands answering to one keystroke in
one scope, run by npm test inside just check and just ci. It knows
that a Mod- chord answers to ⌃X as well as ⌘X, so Ctrl-Tab and
Mod-Tab are seen to collide. Scopes nest (overlay:link under
overlay), which is what keeps two ⏎ bindings from reading as a clash
while still reporting the shadows that are real: the overlay's Tab trap
takes ⌃Tab from the Settings rail, and only branch order saves it.

editorkeys.ts folds in the other keyboard. CodeMirror ships ~100 stock
bindings B2 never wrote, and three of B2's chords work while editing
only because it happens to leave them alone — an assumption that lived
in a parenthesis ("CodeMirror leaves Mod-e unbound, so the event bubbles
here"). It's a pinned list now. main.ts installs the stock keymaps from
that module, so the set checked is the set that runs.

Two findings from the fold-in, both pinned rather than changed:

  • CodeMirror binds Mod-i (selectParentSyntax). ⌘I stays italic only
    because the format chords are installed ahead of the defaults — load-
    bearing ordering that nothing noticed before.
  • The ⌃-as-⌘ alias puts ten B2 chords on macOS's emacs text bindings
    inside the editor: ⌃F moves the caret and opens the find bar, ⌃E
    goes to end-of-line and leaves edit mode. Pre-existing, and it all
    retires at once if Mod stops accepting ⌃ — worth its own decision.

Behavior deltas, all narrow: chord matching is now strict about ⌥/⇧
where the hand-rolled tests weren't, so ⇧⌘S no longer force-saves and
⌘⏎ no longer commits the link dialog. Escape and Tab keep their
any-modifier leniency explicitly, via an Any- chord — the escape hatch
must not depend on a modifier the user hasn't let go of. In the sheet,
⇧⌃Tab now renders ⌃⇧Tab (Apple's HIG modifier order, applied in one
place rather than by hand), and a new row documents ⏎ committing an open
dialog — a chord that was bound and undocumented, which is exactly what
the coverage check is for.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01A5LV3uA69fYYJBPes6ubF2

Summary by CodeRabbit

  • New Features
    • Centralized keyboard shortcuts so controls, editor commands, and the Keyboard settings sheet stay synchronized.
    • Shortcut labels/tooltips (including toast/chord text) now render from the configured bindings.
    • Added shortcut conflict detection and editor compatibility validation to ensure key handling matches expectations.
  • Bug Fixes
    • Wikilink follow actions on macOS are now limited to ⌘-click (Ctrl-/secondary click no longer triggers following behavior).
  • Tests
    • Expanded automated coverage for binding parsing, collisions/shadowing, editor keymap overlap, and click-follow behavior.
  • Documentation
    • Keyboard shortcut guidance now reflects the registry-projected workflow and the discoverability rules for new bindings.

claude added 3 commits July 27, 2026 22:05
…lded in

A chord used to be spelled twice — a modifier test in main.ts's keydown
handler and a row of display text in shortcuts.ts — with nothing but
discipline keeping them equal. K1 promises every action has a keyboard
path *and* that the path is findable, so a sheet free to fall behind the
wiring is a promise waiting to break. And because main.ts can't be
imported by node, none of it was testable: the app's whole keyboard
contract sat in the one file the suite can't see.

The chord is now declared once, in `ui/src/bindings.ts` — id, chord,
scope — and the rest derives. The handler matches with `isBound(e, id)`,
the editor's keymap takes `chordFor(id)` (the chord syntax is
CodeMirror's, so one spelling serves both), and the `?` sheet names ids
rather than spelling chords. `shortcuts.test.ts` fails on a binding no
row documents, which turns "add the row in the same change" from a
convention into something the suite keeps.

What deliberately stays as code: the order the surfaces get their turn,
and the guard beside each branch. A table rich enough to express "⌘G,
but only while the find bar is open" is a `when`-clause language, which
is a worse trade at this size than a guard you can read.

`conflicts()` is the gate — two commands answering to one keystroke in
one scope, run by `npm test` inside `just check` and `just ci`. It knows
that a `Mod-` chord answers to ⌃X as well as ⌘X, so `Ctrl-Tab` and
`Mod-Tab` are seen to collide. Scopes nest (`overlay:link` under
`overlay`), which is what keeps two ⏎ bindings from reading as a clash
while still reporting the shadows that are real: the overlay's Tab trap
takes ⌃Tab from the Settings rail, and only branch order saves it.

`editorkeys.ts` folds in the other keyboard. CodeMirror ships ~100 stock
bindings B2 never wrote, and three of B2's chords work while editing
only because it happens to leave them alone — an assumption that lived
in a parenthesis ("CodeMirror leaves Mod-e unbound, so the event bubbles
here"). It's a pinned list now. main.ts installs the stock keymaps *from*
that module, so the set checked is the set that runs.

Two findings from the fold-in, both pinned rather than changed:
- CodeMirror binds Mod-i (selectParentSyntax). ⌘I stays italic only
  because the format chords are installed ahead of the defaults — load-
  bearing ordering that nothing noticed before.
- The ⌃-as-⌘ alias puts ten B2 chords on macOS's emacs text bindings
  inside the editor: ⌃F moves the caret *and* opens the find bar, ⌃E
  goes to end-of-line *and* leaves edit mode. Pre-existing, and it all
  retires at once if `Mod` stops accepting ⌃ — worth its own decision.

Behavior deltas, all narrow: chord matching is now strict about ⌥/⇧
where the hand-rolled tests weren't, so ⇧⌘S no longer force-saves and
⌘⏎ no longer commits the link dialog. Escape and Tab keep their
any-modifier leniency explicitly, via an `Any-` chord — the escape hatch
must not depend on a modifier the user hasn't let go of. In the sheet,
⇧⌃Tab now renders ⌃⇧Tab (Apple's HIG modifier order, applied in one
place rather than by hand), and a new row documents ⏎ committing an open
dialog — a chord that was bound and undocumented, which is exactly what
the coverage check is for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5LV3uA69fYYJBPes6ubF2
…ndings

Every handler read `(e.metaKey || e.ctrlKey)`, so each ⌘ chord answered
to ⌃ as well. That's the right reflex on Windows and Linux, where ⌃ is
the platform modifier, and the wrong one on the only platform B2 ships
on. macOS gives ⌃F/⌃B/⌃N/⌃P/⌃A/⌃E emacs meanings in every text field,
and CodeMirror implements the same set so its editor feels native.

A CodeMirror binding calls `preventDefault` but never `stopPropagation`
(0 of the 73 stock bindings opt in), so the keystroke still reached the
document handler and ran a second command. ⌃E moved the caret to
end-of-line *and* left edit mode. ⌃F moved it forward a character *and*
opened the find bar. Also ⌃⇧F, ⌃N, ⌃⇧N and ⌃⇧A; ⌃←/⌃→ were spared only
by nav's unrelated `!state.editing` guard.

`Mod` now means ⌘ and nothing else, and every modifier is compared
literally — which also converges B2's reading of `Mod` with CodeMirror's
(`normalizeKeyName` resolves it to meta on mac). That matters more than
it sounds: the two share this chord syntax because `chordFor` hands
specs straight to `keymap.of`, so the same word meaning two things was a
latent trap, and it was already making editorkeys.ts over-expand
CodeMirror's own chords when normalizing them.

Falling out of the strictness: a chord now has exactly one keystroke
form (`Any-` excepted, which is why forms are still a list), so the
collision checker no longer reasons about aliasing; ⌘⌃X becomes
representable, so parseChord stops rejecting it; nothing binds it.

Two guards, both verified to fail when the alias is put back. In
bindings.test.ts, a property over the whole table — a binding may claim
a ⌃ keystroke only by asking for ⌃ — so it holds for chords not yet
written. In editorkeys.test.ts, the other end: whatever B2 binds, it
never meets CodeMirror on a ⌃ keystroke. The eight real overlaps are
unchanged; the alias set that check used to pin is now empty, and
`isAliasOnly` went with it.

Unaffected and left alone: `livepreview.ts`'s ⌘-click-to-follow, which
takes ⌃-click too. A mouse gesture rather than a chord, and ⌃-click is
macOS's secondary click — the same shape of problem, worth its own look.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5LV3uA69fYYJBPes6ubF2
…a synonym

The live-preview editor's follow-a-link gesture read
`(e.metaKey || e.ctrlKey)`, the mouse's version of the keyboard alias
dropped in the previous commit. It's worse on this side: on macOS
⌃-click *is* the secondary click, so the OS synthesizes a context-menu
gesture from it — ⌃-clicking a wikilink navigated away and right-clicked
at once. ⌘ only now.

The rule is a named exported function rather than an inline test,
because the handler it lives in is buried in a ViewPlugin's
`eventHandlers` where the suite can't reach it, and "restore the Ctrl
branch for cross-platform symmetry" is a plausible-looking edit somebody
will eventually make. bindings.test.ts and editorkeys.test.ts hold this
line for the keyboard; neither can see a mouse handler.

Two changes were needed to get livepreview.ts into the suite at all, and
both are worth having on their own:

- The two widget classes used constructor parameter properties, the one
  TypeScript construct node's `--experimental-strip-types` refuses. That
  made the entire module unimportable by the test runner. Now plain
  field declarations.
- `./render` is now `./render.ts`. Node resolves specifiers literally;
  the extensionless form is what the app-only modules use, and this file
  isn't app-only any more.

Verified by putting the Ctrl branch back and watching the new check fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5LV3uA69fYYJBPes6ubF2
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d077b217-ce83-4418-bb0a-474364475767

📥 Commits

Reviewing files that changed from the base of the PR and between dee34d9 and 1ca4b88.

📒 Files selected for processing (1)
  • ui/src/main.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • ui/src/main.ts

📝 Walkthrough

Walkthrough

The PR centralizes keyboard bindings, derives runtime handlers and shortcut documentation from the registry, validates B2 chords against CodeMirror keymaps, and restricts wikilink following to command-click.

Changes

Centralized keyboard bindings

Layer / File(s) Summary
Binding registry and validation
ui/src/bindings.ts, ui/src/bindings.test.ts
Defines canonical bindings, parsing, matching, scoped collision analysis, shadow detection, and display formatting with comprehensive tests.
Editor keymap integration
ui/src/editorkeys.ts, ui/src/editorkeys.test.ts, ui/src/format.ts, ui/src/format.test.ts, ui/src/main.ts
Models CodeMirror stock keymaps, validates overlaps, and derives editor and formatting chords from the registry.
Runtime shortcut wiring and documentation
ui/src/main.ts, ui/src/shortcuts.ts, ui/src/shortcuts.test.ts, ui/src/anomalies.ts, CLAUDE.md, crates/b2-desktop/CLAUDE.md
Routes UI keyboard handling through binding IDs and derives shortcut labels, anomaly text, and keyboard-sheet entries from registered chords.

Wikilink follow-click behavior

Layer / File(s) Summary
Command-click follow rule
ui/src/livepreview.ts, ui/src/livepreview.test.ts
Adds and tests isFollowClick, restricting wikilink following to command-click while preserving widget behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant KeyboardEvent
  participant main.ts
  participant bindings.ts
  participant UIAction
  KeyboardEvent->>main.ts: keydown event
  main.ts->>bindings.ts: isBound(event, bindingId)
  bindings.ts-->>main.ts: binding match result
  main.ts->>UIAction: dispatch matched action
Loading

Possibly related PRs

Poem

A rabbit hops through bindings bright,
One chord guides each key just right.
Conflicts flee, sheets display,
CodeMirror checks the way.
Command-click makes links take flight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.33% 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 summarizes the main changes: a centralized keyboard registry, collision checking, and CodeMirror keymap integration.
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
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/kb-shortcut-collision-detect-y4kuln

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

🧹 Nitpick comments (1)
crates/b2-desktop/CLAUDE.md (1)

134-137: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Shell titles still spell chords by hand.

The new rule ("never typed out") is only honoured by the find-close button so far; buildShell() in ui/src/main.ts still hardcodes Back (⌘[), Forward (⌘]), ⇧⌘F in the search placeholder/title, Settings (⌘,), and the editor bar's ⌘E (⌘S flushes anytime). Worth converting those to displayKeys([...]) in this PR (or noting them as follow-up) so the doc isn't ahead of the code.

🤖 Prompt for 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.

In `@crates/b2-desktop/CLAUDE.md` around lines 134 - 137, Update buildShell() to
remove all hardcoded keyboard chords from the Back, Forward, search, Settings,
and editor-bar titles/placeholders, generating each shortcut with
displayKeys([id]) using the corresponding action identifiers. Preserve the
existing labels and menu/control placement while ensuring every displayed chord
is derived from the shared key-display helper.
🤖 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 `@ui/src/main.ts`:
- Around line 3092-3094: Wrap the displayKeys(["dismiss"]) result in escapeHtml
before interpolating it into the find-close button’s title attribute. Update
only this projected chord interpolation in the surrounding markup and preserve
the existing title text and displayKeys behavior.

---

Nitpick comments:
In `@crates/b2-desktop/CLAUDE.md`:
- Around line 134-137: Update buildShell() to remove all hardcoded keyboard
chords from the Back, Forward, search, Settings, and editor-bar
titles/placeholders, generating each shortcut with displayKeys([id]) using the
corresponding action identifiers. Preserve the existing labels and menu/control
placement while ensuring every displayed chord is derived from the shared
key-display helper.
🪄 Autofix (Beta)

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: CHILL

Plan: Pro Plus

Run ID: ecfd2484-c142-4162-a944-ad17cb564429

📥 Commits

Reviewing files that changed from the base of the PR and between 50a3e8a and dee34d9.

📒 Files selected for processing (14)
  • CLAUDE.md
  • crates/b2-desktop/CLAUDE.md
  • ui/src/anomalies.ts
  • ui/src/bindings.test.ts
  • ui/src/bindings.ts
  • ui/src/editorkeys.test.ts
  • ui/src/editorkeys.ts
  • ui/src/format.test.ts
  • ui/src/format.ts
  • ui/src/livepreview.test.ts
  • ui/src/livepreview.ts
  • ui/src/main.ts
  • ui/src/shortcuts.test.ts
  • ui/src/shortcuts.ts

Comment thread ui/src/main.ts Outdated
Review catch (PR #118). `displayKeys(["dismiss"])` was interpolated
straight into the shell's `innerHTML`. The value is registry-derived and
reads "Esc" today, so nothing is wrong on the screen — but `displayChord`
renders whatever key the chord names, and `Mod-<` parses perfectly well,
so a future binding over an HTML metacharacter would land in markup raw.

The repo's rule is that every value B2 itself interpolates goes through
`escapeHtml` (invariant E5) precisely so nobody has to re-derive a
value's provenance at each call site. This was the one site in the change
that skipped it: the anomaly badge sets `.title` as a DOM property, the
toast writes `textContent`, and the `?` sheet already escapes both
columns in `shortcutsGridHtml`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5LV3uA69fYYJBPes6ubF2
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.

2 participants