Skip to content

Configurable keybindings and consistent terminal link modifiers - #554

Open
tbrownio wants to merge 7 commits into
mainfrom
configurable-keybindings
Open

Configurable keybindings and consistent terminal link modifiers#554
tbrownio wants to merge 7 commits into
mainfrom
configurable-keybindings

Conversation

@tbrownio

@tbrownio tbrownio commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Description

One global, persistent keybinding model for Pane plus consistent modified-click routing for terminal links. Implements briefs/configurable-keybindings-and-external-link-modifiers.md in these commits:

  1. Shared shortcut catalog + effective-binding dispatch (a9fe0f2c). Every registry-backed command lives in shared/constants/keyboardShortcuts.ts (id, label, category, activation scope, default chord, platform gate, terminal/TUI/webview interception flags). keyboardShortcutOverrides in the global config is sparse (override or null); the hotkey store resolves override | unassigned | default, indexes chord → candidates, and runs neither command on an ambiguous chord. TerminalPanel's hard-coded release list and main's webview forward whitelist are replaced by sets built from the effective catalog (with a parity harness against the deleted lists). Agent launch presets reference catalog ids instead of literal hotkeys.

  2. Shortcuts settings map (92865cf6). Settings → Shortcuts shows a searchable, category-grouped table sourced from the catalog plus current snippets/custom commands: effective + default chord, scope, customized/unassigned/invalid state, per-environment availability. Accessible key recorder (window-capture while armed; Escape cancels without closing Settings; Backspace unassigns; terminal-reserved chords refused; recording a row's default removes the override). Conflicts are validated globally (no platform gate — a Windows host can open a WSL project where Cursor is active) and block Apply naming both owners; Reset deletes the override, Reset all sends {}. Help renders the same map. Raw override maps round-trip untouched (unknown ids / malformed values are preserved on disk and shown as "invalid — using default").

  3. Terminal HTTP(S) link router (5ce8ff4b). Auto-detected URLs, OSC-8 hyperlinks, and git SHA/issue links share one classifier + router: Primary+Shift (⇧⌘ / Ctrl+Shift) opens a validated, credential-free HTTP(S) URL in the session's Browser panel via a single create-or-navigate helper, falling back to the external browser exactly once where no Browser surface exists (Project/main-repo terminals, Pane Chat); Primary opens externally; the macOS Control-click alias applies only to unshifted primary-button activations; Alt and non-primary buttons never qualify; rejected URLs open nothing. Hover text names the available gestures per provider/platform. The browser-panel:navigate double path is removed — BrowserPanel navigates from state alone, with a monotonic navigationNonce so a repeated same-URL request reloads.

  4. Review hardening (322f733c). Every gesture-driven link sink (internal, external, and the unavailable-surface fallback) takes only a validated HTTP(S) URL — rejected targets open nothing; OSC-8 plain click keeps its pre-existing pass-through for compatibility. Alt/non-primary activations are rejected outright. The two Shortcuts Apply buttons block each other while the other draft is unsaved (conflicts are validated across both drafts but persisted separately). HTML previews retitle a reused Browser panel. Follow-ups from pass 2: bare navigation keys and mod+c refused by the recorder; reference rows searchable.

Pre-existing defect fixed on the way

fileLinkProvider and gitLinkProvider indexed the buffer 0-based, but xterm passes a 1-based line to provideLinks (the bundled WebLinks addon does y - 1). They read the row below and emitted ranges one row off, so file-path and git links never matched the pointer. Both now use lineNumber - 1 with ranges on the requested row (unit test pins it).

Plan deltas (vs tmp/configurable-keybindings/plan-2.md / plan-3.md)

  • The router is one module (frontend/src/components/terminal/linkRouting.ts) and the navigation primitive is frontend/src/services/browserPanelNavigation.ts (names differ from the plan; behavior matches).
  • No Playwright case for Pane Chat's Primary+Shift fallback: the mock bridge never initializes the Pane Chat terminal (its CLI loading overlay stays up). The eligibility predicate (canHostSessionBrowser) is unit-tested for Pane Chat, main-repo, and null sessions; the main-repo Playwright case exercises the same fallback path.
  • Created Browser panels are not addPanel()ed locally: panelManager broadcasts panel:created and SessionView inserts the panel into the split layout only when it is not already in the store (the mock now broadcasts the same event).

Type of Change

  • New feature (non-breaking change which adds functionality)
  • Bug fix (non-breaking change which fixes an issue) — link-provider row offset

Checklist

  • My code follows the code style of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation (docs/ADDING_NEW_CLI_TOOLS.md)
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have run pnpm typecheck and pnpm lint locally
  • I have tested the Electron app locally with pnpm electron-dev — manual desktop QA items below remain for the owner

Critical Areas Modified

  • State management/IPC events — config:updated relay for live shortcut propagation; panels:update state writes for Browser navigation

Testing

  • Unit: pnpm --filter frontend test (354), pnpm --filter main test keyboard/config subset (other main suites fail locally on a pre-existing better-sqlite3 NODE_MODULE_VERSION mismatch, unrelated).
  • Playwright (PLAYWRIGHT_PORT=<free> pnpm test): 173 passed. New specs: tests/shortcuts-settings.spec.ts, tests/launch-shortcuts.spec.ts (all three agents × worktree/main-repo views), tests/terminal-links.spec.ts (primary/primary+shift/plain per provider, Browser reuse vs create, main-repo fallback, hover copy on macOS and Linux), plus two cases in tests/settings.spec.ts. Known failures unrelated to this branch: settings.spec.ts "rebaselines a completed host setup" (fails on origin/main, remoteRuntimePresentation.ts crash) and agent-usage.spec.ts:174 (fails identically on the Phase 1 baseline); theme-screenshots.spec.ts "amber-crt: high-contrast" flaked once under the full parallel run and passes 2/2 alone. Note: a stale dev server on the default port 4521 (shared across worktrees with reuseExistingServer) serves another checkout's code — run with a dedicated PLAYWRIGHT_PORT.
  • Review: dual-lane (Claude + Codex) code review on the branch diff, two passes each; all Must-Fix and Should-Fix findings addressed (322f733c, HEAD).

Manual QA still owned by a human (desktop)

  • Remap Claude/Codex/Cursor in Settings, Apply, press the new chord with focus in ordinary UI, a CLI/TUI terminal, and an embedded browser webview; restart Pane and confirm the remap persists.
  • macOS: Control-click a URL → native context menu intact; ⇧⌘-click the same URL twice → one Browser panel, second click reloads, no external window.
  • Pane Chat: ⇧⌘-click a URL → external browser once, no Browser tab.
  • German/AltGr layout: AltGr+Q still types @ in a terminal; Ctrl+\ still delivers SIGQUIT.

https://claude.ai/code/session_012BQcLGZB4EmWoxpTTCWrC9

Tyler Brown added 5 commits August 29, 2026 13:42
…ctive-binding dispatch

Phase 1 of configurable keybindings: one typed catalog of every registry-backed
command, keyboardShortcutOverrides in the global config (sparse: override or
null), a chord->candidates hotkey index that runs neither command on an
ambiguous chord, terminal release and webview forwarding driven by the
configured catalog, a config:updated relay for live propagation, and agent
launch presets referencing catalog ids instead of literal hotkeys.

Claude-Session: https://claude.ai/code/session_016CuGxyYX4yEZ1dfUZZDm36
…eset

Phase 2 of configurable keybindings. Settings → Shortcuts gains a searchable,
category-grouped key-binding table sourced from the shared catalog plus the
current snippet and custom-command bindings: effective and default chords,
activation scope, customized/unassigned/invalid state, and per-environment
availability. An accessible recorder (window-capture while armed, Escape
cancels without closing Settings, Backspace unassigns, terminal-reserved chords
refused, recording a row's default removes the override) writes a sparse draft;
conflicts are validated globally (no platform gate) and block Apply naming both
owners; per-row Reset deletes the override and Reset all sends {}.

Help renders the same map. The raw override map round-trips untouched
(unknown ids and malformed values are preserved on disk and shown as
"invalid — using default"). ConfigManager validates conflicts globally.

Adds Playwright coverage for the settings map, agent-launch remaps in worktree
and main-repo views (mock now records panel creates/updates/activations and
broadcasts panel:created), plus unit coverage for the map builder and glyphs.

Claude-Session: https://claude.ai/code/session_012BQcLGZB4EmWoxpTTCWrC9
… router

Phase 3 of configurable keybindings. Auto-detected URLs, OSC-8 hyperlinks, and
git SHA/issue links share one pure classifier and router: Primary+Shift opens a
validated, credential-free HTTP(S) URL in the session's Browser panel (reusing
the first one or creating one through a single create-or-navigate helper) and
falls back to the external browser exactly once where no Browser surface exists
(Project/main-repo terminals, Pane Chat, no session context); Primary opens
externally; the macOS Control-click alias applies only to unshifted primary
button activations; Alt and non-primary buttons never qualify; rejected URLs on
the Browser branch open nothing. Hover text names the available gestures per
provider and platform.

The browser-panel:navigate event path is removed; BrowserPanel navigates from
panel state alone, with a monotonic navigationNonce so a repeated same-URL
request reloads. The selection popover shows "Open in Browser" only where a
Browser surface exists; HTML previews reuse the same helper.

Also fixes a pre-existing off-by-one in the file and git link providers: xterm
passes a 1-based buffer line to provideLinks, so they read the row below and
emitted ranges one row off, which meant their links never matched the pointer.

Claude-Session: https://claude.ai/code/session_012BQcLGZB4EmWoxpTTCWrC9
- Every gesture-driven link sink (Pane Browser, external, and the
  unavailable-surface fallback) now takes only a validated, credential-free
  HTTP(S) URL; rejected targets open nothing. OSC-8 plain click keeps its
  pre-existing pass-through.
- Alt and non-primary-button activations are classified as rejected so OSC-8
  cannot treat them as plain clicks.
- Shortcuts settings: the key-binding Apply and the snippet Apply are each
  blocked while the other draft is unsaved, since conflicts are validated
  across both drafts but persisted separately.
- HTML previews retitle a reused Browser panel again.
- Drop three unused type exports flagged by Knip.

Claude-Session: https://claude.ai/code/session_012BQcLGZB4EmWoxpTTCWrC9
Recording an unmodified named key (Tab, Enter, Space, arrows, paging…) would
hijack focus navigation and typing app-wide, and mod+c / mod+shift+c is the
terminal's copy shortcut; both are now refused with live status text.

Claude-Session: https://claude.ai/code/session_012BQcLGZB4EmWoxpTTCWrC9
@github-actions

github-actions Bot commented Aug 29, 2026

Copy link
Copy Markdown

React Doctor found 2 new issues in 2 files · 2 warnings · score 69 / 100 (Needs work) · 1 fixed · vs main

2 warnings

src/components/settings/KeyboardShortcutMap.tsx

  • ⚠️ L87 Role used instead of HTML tag prefer-tag-over-role

src/components/settings/categories/ShortcutsSettings.tsx

  • ⚠️ L60 Chained array iterations js-combine-iterations

Reviewed by React Doctor for commit d630469. See inline comments for fixes.

fullWidth
/>
<div className="max-h-[28rem] overflow-y-auto rounded-md border border-border-secondary">
<div role="table" aria-label="Key bindings" className="min-w-full text-sm">

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/prefer-tag-over-role (warning)

Screen reader users get more reliable semantics from <table> than role="table", so use <table> instead.

Fix → Use the matching HTML element when one exists so browsers and assistive tech get native semantics.

Docs

}), [overridesDraft, shortcuts, customCommands, projectEnvironment, platform]);
const conflicted = shortcutMap.conflicts.length > 0;
const snippetConflicts = new Set(
shortcutMap.rows.filter((row) => row.origin === 'snippet' && row.conflicts.length > 0).map((row) => row.id),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/js-combine-iterations (warning)

This loops over your list twice because .filter().map() makes two passes, so do it in one pass with .reduce() or a for...of loop

Fix → Combine .map().filter() style chains into one pass with .reduce() or a for...of loop, so you only loop over the list once

Docs

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