feat(ui): shortcut registry foundation (#652)

* feat(ui): shortcut registry foundation

Salvages the foundation of #380 by @gjermundgaraba — the original PR sat
6 weeks waiting for review and was closed by the author. This lands the
engine, scope data, and auto-generated marketing docs page, deferring
per-component handler migration to follow-up PRs.

Layout:
- packages/ui/shortcuts/{core,runtime,index}.ts — engine + barrel
- packages/ui/shortcuts/plan-review/* — scopes for plan editor surfaces
- packages/ui/shortcuts/code-review/* — scopes for review editor surfaces
- packages/{editor,review-editor}/shortcuts.ts — per-app surface composition
- apps/marketing/src/{components,lib} — auto-generated docs page

App behavior is unchanged: existing ad-hoc keydown handlers keep running.
The registry is plumbing waiting for migration PRs to wire to it.

Co-authored-by: Gjermund Garaba <gjermund@garaba.net>

* feat(ui): add missing shortcuts; warn on hold-binding gap

Addresses code review on #652.

Adds five live shortcuts that were missing from the registry, so the
auto-generated /docs/reference/keyboard-shortcuts page is no longer
incomplete:

- Mod+P (print plan)            — plan review + annotate
- Mod+B (toggle file tree)      — code review (new "Layout" section)
- Mod+. (toggle review sidebar) — code review
- Mod+Shift+T (toggle demo tour, dev-only) — code review
- Mod+Shift+F (focus PR comments search)   — new code-review/prComments scope

Also whitelists `.` as a valid normalized binding token in the registry
validator (keeps catching typos like Cmd/Ctrl while allowing legitimate
punctuation keys).

Hold-binding gap: the registry's `Alt hold` advertises a shortcut that
`useShortcutScope` cannot dispatch. Today this is harmless because the
existing `useInputMethodSwitch` hook handles it bespoke. Added warning
comments at the binding declaration and next to `useDoubleTapShortcuts`
so future migrators don't fall in. A `useHoldShortcuts` hook is the
proper fix and will land with the App.tsx migration that needs it.

* feat(ui): comprehensive shortcut registry coverage

Two-Sonnet inventory audit found 19 more user-facing shortcuts not in
the registry, on top of the 5 added in the previous commit. Adding all
of them so the auto-generated /docs/reference/keyboard-shortcuts page
ships honest and the in-app help modal (once migrated to read from the
registry) is comprehensive.

Plan / annotate surface (4 added):
- Escape closes image lightbox       (Viewer)
- Mod+Enter saves annotation edit    (AnnotationPanel)
- Escape cancels annotation edit     (AnnotationPanel)
- Enter confirms image name          (ImageAnnotator name field)

Code review surface (15 added):
- v / a in single-file diff mode     (toggle viewed / stage)
- v, a, x, z, c, [, ] in all-files   (new AllFilesDiff scope)
- Mod+Enter / Escape / Tab           (new SuggestionModal scope)
- Mod+Enter / Escape                 (new AI Assistant scope: AITab + AskAIInput)
- Escape closes tour dialog          (new TourDialog scope)

Engine: extended NAMED_TOKENS with `[` and `]` for the all-files
navigation bindings.

Tests updated for the new section ordering. App behavior unchanged —
this is pure declarative metadata. The existing ad-hoc keydown handlers
keep running; the registry is plumbing for the migration follow-up PRs.

* fix(ui): suggestion modal scope advertised non-existent submit shortcut

Self-review caught: SuggestionModal.tsx only has Escape (close) and Tab
(indent) handlers — no Mod+Enter. The Mod+Enter the inventory agent
referenced lives in AnnotationToolbar's suggested-code textarea, which
is already covered by reviewAnnotationToolbarShortcuts.submitComment.

Removed the bogus submit action from the scope. Renamed cancel→close
since the modal preserves suggestion state on Escape (it's not a
discard).

* fix(review): remove broken Cmd+Shift+C copy-diff shortcut

The handler at App.tsx:695 checked `e.key === 'c'` but Shift forces
`e.key` to uppercase `'C'`, so the shortcut never fired. The in-app
help modal also advertised it incorrectly as "Toggle comment mode".

Removing the broken shortcut entirely rather than fixing the case
mismatch — the toolbar's Copy button already does the same thing,
and there's no evidence anyone was relying on the keyboard shortcut.

Three deletions:
- packages/review-editor/App.tsx — handler block
- packages/review-editor/shortcuts.ts — copyDiff registry action
- packages/ui/components/KeyboardShortcuts.tsx — help modal entry

* chore(ui): address review nits

- F3: refresh AGENTS.md scope inventory (added scopes were missing)
- F5: getShortcutPlatform() now delegates to packages/ui/utils/platform
       isMac instead of duplicating the regex+SSR guard
- F7: drop the stand-alone imageAnnotator.confirmName entry; the
       behaviour is folded into the existing save action's hint so the
       marketing docs page no longer renders two Enter rows with
       different descriptions
- F8: remove the unused listenerOptions parameter from useShortcutScope
       (no callers; inline objects in the dep array would have caused
       re-render loops if anyone tried to use it)

TODOs left for the migration follow-up:
- F2: cross-scope arbitration — dispatchShortcutEvent should bail on
       event.defaultPrevented; needs preventDefault to be claimed
       consistently across scopes first
- F6: matchesKeyToken doesn't actually match the Space token; needs a
       special case before any scope binds Space

App behaviour unchanged.

---------

Co-authored-by: Gjermund Garaba <gjermund@garaba.net>
This commit is contained in:
Michael Ramos
2026-05-04 10:15:31 -07:00
committed by GitHub
parent 525c8b05c1
commit 5bc57fc5bd
29 changed files with 1612 additions and 28 deletions
+27 -1
View File
@@ -52,6 +52,13 @@ plannotator/
│ │ │ ├── icons/ # Shared SVG icon components (themeIcons, etc.)
│ │ │ ├── plan-diff/ # PlanDiffBadge, PlanDiffViewer, clean/raw diff views
│ │ │ └── sidebar/ # SidebarContainer, SidebarTabs, VersionBrowser, ArchiveBrowser
│ │ ├── shortcuts/ # Keyboard shortcut registry (see Keyboard Shortcuts section below)
│ │ │ ├── core.ts # Engine: parser, formatter, dispatcher, validator
│ │ │ ├── runtime.ts # Engine: useShortcutScope, useDoubleTapShortcuts hooks
│ │ │ ├── index.ts # Barrel — re-exports engine + scopes from both subfolders
│ │ │ ├── plan-review/ # Scopes for plan-editor surfaces (annotationToolbar, annotationPanel, commentPopover, imageAnnotator, inputMethod, viewer)
│ │ │ └── code-review/ # Scopes for review-editor surfaces (ai, allFilesDiff, annotationToolbar, fileTree, prComments, suggestionModal, tourDialog)
│ │ ├── shortcuts.test.ts # Registry unit tests (parser, dispatcher, validator)
│ │ ├── utils/ # parser.ts, sharing.ts, storage.ts, planSave.ts, agentSwitch.ts, planDiffEngine.ts, planAgentInstructions.ts
│ │ ├── hooks/ # useAnnotationHighlighter.ts, useSharing.ts, usePlanDiff.ts, useSidebar.ts, useLinkedDoc.ts, useAnnotationDraft.ts, useCodeAnnotationDraft.ts, useArchive.ts
│ │ └── types.ts
@@ -60,9 +67,12 @@ plannotator/
│ │ ├── storage.ts # Plan saving, version history, archive listing (node:fs only)
│ │ ├── draft.ts # Annotation draft persistence (node:fs only)
│ │ └── project.ts # Pure string helpers (sanitizeTag, extractRepoName, extractDirName)
│ ├── editor/ # Plan review App.tsx
│ ├── editor/ # Plan review app
│ │ ├── App.tsx # Main plan review app
│ │ └── shortcuts.ts # planReviewSurface + annotateSurface — composes plan-review scopes into per-surface registries
│ └── review-editor/ # Code review UI
│ ├── App.tsx # Main review app
│ ├── shortcuts.ts # codeReviewSurface — composes code-review scopes into the review registry
│ ├── components/ # DiffViewer, FileTree, ReviewSidebar
│ ├── dock/ # Dockview center panel infrastructure
│ ├── demoData.ts # Demo diff for standalone mode
@@ -380,6 +390,20 @@ interface Block {
Text highlighting uses `web-highlighter` library. Code blocks use manual `<mark>` wrapping (web-highlighter can't select inside `<pre>`).
## Keyboard Shortcuts
**Location:** `packages/ui/shortcuts/` (engine + scope data), `packages/editor/shortcuts.ts` and `packages/review-editor/shortcuts.ts` (per-app surfaces).
The shortcut system has three layers:
1. **Engine** (`packages/ui/shortcuts/{core,runtime}.ts`) — parser for declarative bindings (`Mod+Enter`, `Alt Alt` double-tap, `Alt hold`), dispatcher, platform-aware formatter (mac glyphs vs. `Ctrl`), validator, and the `useShortcutScope` / `useDoubleTapShortcuts` React hooks. Truly shared — both apps use it as-is.
2. **Scopes** — `defineShortcutScope({ id, title, shortcuts: { actionId: { bindings, description, section, ... } } })`. One scope per UI surface (annotation toolbar, comment popover, file tree, etc.). Lives in `packages/ui/shortcuts/{plan-review,code-review}/` — **the subfolder names which app's UI the scope serves**. Components/Apps wire handlers to a scope via `useShortcutScope({ scope, handlers: { actionId: () => ... } })`.
3. **Surfaces** (`packages/editor/shortcuts.ts`, `packages/review-editor/shortcuts.ts`) — each app composes its scopes into a `ShortcutSurface` (`planReviewSurface`, `annotateSurface`, `codeReviewSurface`). Surfaces feed both the in-app help modal and the marketing site's auto-generated docs page.
**Convention for adding new shortcuts:** define the action in the relevant scope file under the right subfolder (`plan-review/` or `code-review/`), declare the binding(s) and description, then wire a handler at the call site with `useShortcutScope`. The marketing docs page picks it up automatically at next build. Unit tests in `packages/ui/shortcuts.test.ts` enforce normalized binding tokens (`Mod`, `Shift`, `Alt`, `A-Z`, `1-0`, named keys, `F1`–`F12`) and unique scope ids.
**Marketing docs auto-generation:** `apps/marketing/src/lib/shortcutReference.ts` reads the three surfaces and `apps/marketing/src/components/ShortcutReference.astro` renders them as tables. The `/docs/reference/keyboard-shortcuts` page is special-cased in `apps/marketing/src/pages/docs/[...slug].astro` to render the component instead of the markdown body.
## URL Sharing
**Location:** `packages/ui/utils/sharing.ts`, `packages/ui/hooks/useSharing.ts`
@@ -482,6 +506,8 @@ Running only `build:opencode` will copy stale HTML files.
`apps/marketing/` is the plannotator.ai website — landing page, documentation, and blog. Built with Astro 5 (static output, zero client JS except a theme toggle island). Docs are markdown files in `src/content/docs/`, blog posts in `src/content/blog/`, both using Astro content collections. Tailwind CSS v4 via `@tailwindcss/vite`. Deploys to S3/CloudFront via GitHub Actions on push to main.
The `/docs/reference/keyboard-shortcuts` page is auto-generated from the shortcut registry at build time — see the Keyboard Shortcuts section above. Editing the markdown body has no effect; update the scope files instead.
## Test plugin locally
```
@@ -0,0 +1,37 @@
---
import { shortcutReferenceSurfaces } from '../lib/shortcutReference';
import { formatShortcutBindingsText } from '../../../../packages/ui/shortcuts';
---
<p>Keyboard shortcuts available across the Plannotator review and annotation UIs.</p>
{shortcutReferenceSurfaces.map((surface) => (
<Fragment>
<h2 id={surface.slug}>{surface.title}</h2>
<p>{surface.description}</p>
{surface.sections.map((section) => (
<Fragment>
<h3 id={section.slug}>{section.title}</h3>
<table>
<thead>
<tr>
<th>Shortcut</th>
<th>Action</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{section.shortcuts.map((shortcut) => (
<tr>
<td><code>{formatShortcutBindingsText(shortcut.bindings)}</code></td>
<td>{shortcut.description}</td>
<td>{shortcut.hint ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</Fragment>
))}
</Fragment>
))}
@@ -6,22 +6,14 @@ sidebar:
section: "Reference"
---
Keyboard shortcuts available in the Plannotator plan review, code review, and annotation UIs.
<!--
This page is auto-generated from the shortcut registry at build time.
Editing the body below has no effect — the slug page renders
`apps/marketing/src/components/ShortcutReference.astro` instead.
## Global shortcuts
To change a shortcut, edit the relevant scope file under
`packages/ui/shortcuts/plan-review/` or `packages/ui/shortcuts/code-review/`,
or a per-app surface in `packages/{editor,review-editor}/shortcuts.ts`.
-->
| Shortcut | Context | Action |
|----------|---------|--------|
| `Cmd/Ctrl+Enter` | Plan review (no annotations) | Approve plan |
| `Cmd/Ctrl+Enter` | Plan review (with annotations) | Send feedback |
| `Cmd/Ctrl+Enter` | Code review | Send feedback / Approve |
| `Cmd/Ctrl+Enter` | Annotate mode | Send annotations |
| `Cmd/Ctrl+S` | Any mode (with API) | Quick save to default notes app |
| `Escape` | Annotation toolbar | Close toolbar |
## Notes
- `Cmd/Ctrl+Enter` is blocked when a modal or dialog is open (export, import, confirm dialogs, image annotator)
- `Cmd/Ctrl+Enter` is blocked when typing in an input or textarea
- `Cmd/Ctrl+S` opens the Export modal if no default notes app is configured
- `Escape` in the annotation toolbar closes it without creating an annotation
This page is generated from the shared shortcut registry at build time.
@@ -0,0 +1,21 @@
import { planReviewSurface, annotateSurface } from '../../../../packages/editor/shortcuts';
import { codeReviewSurface } from '../../../../packages/review-editor/shortcuts';
import { listRegistryShortcutSections } from '../../../../packages/ui/shortcuts';
import type { ShortcutSurface } from '../../../../packages/ui/shortcuts';
const slugify = (value: string) => value.toLowerCase().replace(/\s+/g, '-');
const allSurfaces: ShortcutSurface[] = [planReviewSurface, annotateSurface, codeReviewSurface];
export const shortcutReferenceSurfaces = allSurfaces.map((surface) => ({
...surface,
sections: listRegistryShortcutSections(surface.registry).map((section) => ({
...section,
slug: `${surface.slug}-${slugify(section.title)}`,
})),
}));
export const shortcutReferenceHeadings = shortcutReferenceSurfaces.flatMap((surface) => [
{ depth: 2 as const, slug: surface.slug, text: surface.title },
...surface.sections.map((section) => ({ depth: 3 as const, slug: section.slug, text: section.title })),
]);
@@ -1,6 +1,8 @@
---
import { getCollection, render } from 'astro:content';
import Docs from '../../layouts/Docs.astro';
import ShortcutReference from '../../components/ShortcutReference.astro';
import { shortcutReferenceHeadings } from '../../lib/shortcutReference';
export async function getStaticPaths() {
const docs = await getCollection('docs');
@@ -11,7 +13,12 @@ export async function getStaticPaths() {
}
const { doc } = Astro.props;
const { Content, headings } = await render(doc);
const isShortcutReference = doc.id === 'reference/keyboard-shortcuts';
const rendered = isShortcutReference ? null : await render(doc);
const Content = rendered?.Content;
const headings = isShortcutReference
? shortcutReferenceHeadings
: rendered?.headings ?? [];
---
<Docs
title={doc.data.title}
@@ -19,5 +26,5 @@ const { Content, headings } = await render(doc);
headings={headings}
currentId={doc.id}
>
<Content />
{isShortcutReference ? <ShortcutReference /> : Content && <Content />}
</Docs>
+2 -1
View File
@@ -4,7 +4,8 @@
"type": "module",
"exports": {
".": "./App.tsx",
"./styles": "./index.css"
"./styles": "./index.css",
"./shortcuts": "./shortcuts.ts"
},
"dependencies": {
"@plannotator/shared": "workspace:*",
+109
View File
@@ -0,0 +1,109 @@
import {
annotationPanelShortcuts,
annotationToolbarShortcuts,
commentPopoverShortcuts,
createShortcutRegistry,
createShortcutScopeHook,
defineShortcutScope,
imageAnnotatorShortcuts,
inputMethodShortcuts,
viewerShortcuts,
type ShortcutSurface,
} from '@plannotator/ui/shortcuts';
export const planEditorShortcuts = defineShortcutScope({
id: 'plan-editor',
title: 'Plan Editor',
shortcuts: {
submitPlan: {
description: 'Approve / Send feedback',
bindings: ['Mod+Enter'],
section: 'Actions',
hint: 'Approves when there are no annotations and sends feedback when there are.',
displayOrder: 10,
},
submitAnnotations: {
description: 'Send annotations',
bindings: ['Mod+Enter'],
section: 'Actions',
displayOrder: 10,
},
quickSave: {
description: 'Save to notes app',
bindings: ['Mod+S'],
section: 'Actions',
hint: 'Opens Export if no default notes app is configured.',
displayOrder: 20,
},
exitPlanDiff: {
description: 'Close diff view',
bindings: ['Escape'],
section: 'Actions',
hint: 'Available while plan diff is open.',
displayOrder: 30,
},
printPlan: {
description: 'Print',
bindings: ['Mod+P'],
section: 'Actions',
hint: 'Opens the browser print dialog for the current document.',
displayOrder: 40,
},
},
});
export const usePlanEditorShortcuts = createShortcutScopeHook(planEditorShortcuts);
const planReviewEditorSettingsShortcuts = defineShortcutScope({
id: 'plan-review-editor-settings',
title: 'Plan Editor',
shortcuts: {
submitPlan: planEditorShortcuts.shortcuts.submitPlan,
quickSave: planEditorShortcuts.shortcuts.quickSave,
exitPlanDiff: planEditorShortcuts.shortcuts.exitPlanDiff,
printPlan: planEditorShortcuts.shortcuts.printPlan,
},
});
const annotateEditorSettingsShortcuts = defineShortcutScope({
id: 'annotate-editor-settings',
title: 'Annotate Editor',
shortcuts: {
submitAnnotations: planEditorShortcuts.shortcuts.submitAnnotations,
quickSave: planEditorShortcuts.shortcuts.quickSave,
printPlan: planEditorShortcuts.shortcuts.printPlan,
},
});
const sharedPlanSurfaceShortcuts = [
inputMethodShortcuts,
annotationToolbarShortcuts,
viewerShortcuts,
commentPopoverShortcuts,
annotationPanelShortcuts,
imageAnnotatorShortcuts,
] as const;
export const planReviewSettingsShortcutRegistry = createShortcutRegistry([
planReviewEditorSettingsShortcuts,
...sharedPlanSurfaceShortcuts,
] as const);
export const annotateSettingsShortcutRegistry = createShortcutRegistry([
annotateEditorSettingsShortcuts,
...sharedPlanSurfaceShortcuts,
] as const);
export const planReviewSurface: ShortcutSurface = {
slug: 'plan-review',
title: 'Plan review',
description: 'Shortcuts surfaced by the plan review UI.',
registry: planReviewSettingsShortcutRegistry,
};
export const annotateSurface: ShortcutSurface = {
slug: 'annotate-mode',
title: 'Annotate mode',
description: 'Shortcuts surfaced by the standalone annotation UI.',
registry: annotateSettingsShortcutRegistry,
};
-5
View File
@@ -691,11 +691,6 @@ const ReviewApp: React.FC = () => {
clearSearch();
}
}
// Cmd/Ctrl+Shift+C to copy diff
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c') {
e.preventDefault();
handleCopyDiff();
}
// Cmd/Ctrl+B to toggle file tree
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'b' && !isTypingTarget(e.target)) {
e.preventDefault();
+2 -1
View File
@@ -4,7 +4,8 @@
"type": "module",
"exports": {
".": "./App.tsx",
"./styles": "./index.css"
"./styles": "./index.css",
"./shortcuts": "./shortcuts.ts"
},
"dependencies": {
"@pierre/diffs": "^1.1.12",
+113
View File
@@ -0,0 +1,113 @@
import {
createDoubleTapShortcutsHook,
createShortcutRegistry,
createShortcutScopeHook,
defineShortcutScope,
reviewAiShortcuts,
reviewAllFilesDiffShortcuts,
reviewAnnotationToolbarShortcuts,
reviewFileTreeShortcuts,
reviewPrCommentsShortcuts,
reviewSuggestionModalShortcuts,
reviewTourDialogShortcuts,
type ShortcutSurface,
} from '@plannotator/ui/shortcuts';
export const reviewEditorShortcuts = defineShortcutScope({
id: 'review-editor',
title: 'Review Editor',
shortcuts: {
submit: {
description: 'Approve / Send feedback',
bindings: ['Mod+Enter'],
section: 'Actions',
displayOrder: 10,
},
focusSearch: {
description: 'Focus search',
bindings: ['Mod+F'],
section: 'Search',
hint: 'Available when the file tree search bar is shown.',
displayOrder: 10,
},
nextSearchMatch: {
description: 'Next search result',
bindings: ['Enter', 'F3'],
section: 'Search',
displayOrder: 20,
},
prevSearchMatch: {
description: 'Previous search result',
bindings: ['Shift+Enter', 'Shift+F3'],
section: 'Search',
displayOrder: 30,
},
clearSearch: {
description: 'Clear search / close panel',
bindings: ['Escape'],
section: 'Search',
displayOrder: 40,
},
toggleDestination: {
description: 'Toggle review destination',
bindings: ['Alt Alt'],
section: 'Actions',
hint: 'Double-tap to switch between platform and agent in PR review mode.',
displayOrder: 30,
},
toggleFileTree: {
description: 'Toggle file tree',
bindings: ['Mod+B'],
section: 'Layout',
displayOrder: 10,
},
toggleSidebar: {
description: 'Toggle review sidebar',
bindings: ['Mod+.'],
section: 'Layout',
displayOrder: 20,
},
toggleTour: {
description: 'Toggle demo tour dialog',
bindings: ['Mod+Shift+T'],
section: 'Layout',
hint: 'Available in dev builds only.',
displayOrder: 30,
},
toggleViewed: {
description: 'Toggle file viewed',
bindings: ['V'],
section: 'File Actions',
hint: 'Marks the active diff file as viewed (and auto-collapses in all-files view).',
displayOrder: 10,
},
stageFile: {
description: 'Stage current file',
bindings: ['A'],
section: 'File Actions',
hint: 'Available when staging is supported (not in PR review mode).',
displayOrder: 20,
},
},
});
export const useReviewEditorShortcuts = createShortcutScopeHook(reviewEditorShortcuts);
export const useReviewEditorDoubleTap = createDoubleTapShortcutsHook(reviewEditorShortcuts);
export const reviewSettingsShortcutRegistry = createShortcutRegistry([
reviewEditorShortcuts,
reviewFileTreeShortcuts,
reviewAllFilesDiffShortcuts,
reviewAnnotationToolbarShortcuts,
reviewSuggestionModalShortcuts,
reviewAiShortcuts,
reviewPrCommentsShortcuts,
reviewTourDialogShortcuts,
] as const);
export const codeReviewSurface: ShortcutSurface = {
slug: 'code-review',
title: 'Code review',
description: 'Shortcuts surfaced by the code review UI.',
registry: reviewSettingsShortcutRegistry,
};
@@ -116,7 +116,6 @@ const reviewShortcuts: ShortcutSection[] = [
shortcuts: [
{ keys: [modKey, enter], desc: 'Approve / Send feedback' },
{ keys: [altKey, altKey], desc: 'Toggle destination', hint: 'Double-tap to switch between GitHub and Agent in PR review mode' },
{ keys: [modKey, '⇧', 'C'], desc: 'Toggle comment mode' },
{ keys: [modKey, 'B'], desc: 'Toggle file tree' },
{ keys: [modKey, '.'], desc: 'Toggle sidebar' },
{ keys: ['Esc'], desc: 'Collapse sidebar' },
+1
View File
@@ -9,6 +9,7 @@
"./components/plan-diff/*": "./components/plan-diff/*.tsx",
"./utils/*": "./utils/*.ts",
"./hooks/*": "./hooks/*.ts",
"./shortcuts": "./shortcuts/index.ts",
"./config": "./config/index.ts",
"./types": "./types.ts",
"./theme": "./theme.css"
+271
View File
@@ -0,0 +1,271 @@
import { describe, expect, it } from 'bun:test';
import { annotateSettingsShortcutRegistry, planEditorShortcuts, planReviewSettingsShortcutRegistry } from '../editor/shortcuts';
import { reviewSettingsShortcutRegistry } from '../review-editor/shortcuts';
import {
createShortcutRegistry,
defineShortcutScope,
dispatchShortcutEvent,
formatShortcutBindingText,
formatShortcutBindingTokens,
getShortcut,
listRegistryShortcutSections,
matchesKeyName,
matchesShortcutBinding,
parseDoubleTapBinding,
validateShortcutRegistry,
} from './shortcuts';
describe('shortcuts', () => {
it('formats bindings for docs and keycaps', () => {
expect(formatShortcutBindingText('Mod+Enter')).toBe('Cmd/Ctrl+Enter');
expect(formatShortcutBindingText('Alt hold')).toBe('Hold Alt');
expect(formatShortcutBindingText('Alt Alt')).toBe('Double-tap Alt');
expect(formatShortcutBindingText('Alt Alt', 'mac')).toBe('Double-tap Option');
expect(formatShortcutBindingTokens('Mod+Enter', 'mac')).toEqual(['⌘', '⏎']);
expect(formatShortcutBindingTokens('Mod+Enter', 'non-mac')).toEqual(['Ctrl', '↵']);
expect(formatShortcutBindingTokens('Alt Alt', 'mac')).toEqual(['⌥', '×2']);
expect(formatShortcutBindingTokens('Alt Alt', 'non-mac')).toEqual(['Alt', '×2']);
});
it('validates duplicate scope ids and non-normalized tokens', () => {
const duplicateScope = defineShortcutScope({
id: 'dup',
title: 'Duplicate',
shortcuts: {
submit: {
description: 'Submit',
bindings: ['Mod+Enter'],
section: 'Actions',
},
},
});
const badScope = defineShortcutScope({
id: 'bad',
title: 'Bad',
shortcuts: {
broken: {
description: 'Broken',
bindings: ['Cmd+Enter'],
section: 'Actions',
},
missingCopy: {
description: '',
bindings: ['Mod+C'],
section: '',
},
},
});
const errors = validateShortcutRegistry([duplicateScope, duplicateScope, badScope]);
expect(errors).toContain('Duplicate shortcut scope id: dup');
expect(errors.some(error => error.includes('Cmd'))).toBe(true);
expect(errors).toContain('Shortcut bad.missingCopy is missing a section.');
expect(errors).toContain('Shortcut bad.missingCopy is missing a description.');
expect(() => createShortcutRegistry([duplicateScope, duplicateScope])).toThrow();
});
it('lists plan review, annotate, and review sections from assembled registries', () => {
const planReviewSections = listRegistryShortcutSections(planReviewSettingsShortcutRegistry);
const annotateSections = listRegistryShortcutSections(annotateSettingsShortcutRegistry);
const reviewSections = listRegistryShortcutSections(reviewSettingsShortcutRegistry);
expect(planReviewSections.map(section => section.title)).toEqual([
'Actions',
'Input Method',
'Annotations',
'Image Annotator',
]);
expect(annotateSections.map(section => section.title)).toEqual([
'Actions',
'Input Method',
'Annotations',
'Image Annotator',
]);
expect(getShortcut(planReviewSettingsShortcutRegistry, 'plan-review-editor-settings', 'submitPlan')?.description).toBe('Approve / Send feedback');
expect(getShortcut(planReviewSettingsShortcutRegistry, 'plan-review-editor-settings', 'submitAnnotations')).toBeUndefined();
expect(getShortcut(annotateSettingsShortcutRegistry, 'annotate-editor-settings', 'submitAnnotations')?.description).toBe('Send annotations');
expect(getShortcut(annotateSettingsShortcutRegistry, 'annotate-editor-settings', 'submitPlan')).toBeUndefined();
expect(reviewSections.map(section => section.title)).toEqual([
'Actions',
'Search',
'Layout',
'File Actions',
'File Navigation',
'All-Files View',
'Annotations',
'Suggestion Editor',
'AI Assistant',
'PR Comments',
'Tour',
]);
});
it('matches normalized runtime bindings', () => {
const submitEvent = { key: 'Enter', ctrlKey: true, metaKey: false, shiftKey: false, altKey: false, code: 'Enter' } as KeyboardEvent;
const reverseSearchEvent = { key: 'F3', ctrlKey: false, metaKey: false, shiftKey: true, altKey: false, code: 'F3' } as KeyboardEvent;
const typeEvent = { key: 'A', ctrlKey: false, metaKey: false, shiftKey: true, altKey: false, code: 'KeyA' } as KeyboardEvent;
const quickLabelEvent = { key: '3', ctrlKey: false, metaKey: false, shiftKey: false, altKey: true, code: 'Digit3' } as KeyboardEvent;
const macOptionQuickLabelEvent = { key: '£', ctrlKey: false, metaKey: false, shiftKey: false, altKey: true, code: 'Digit3' } as KeyboardEvent;
const wrongEvent = { key: 'Enter', ctrlKey: false, metaKey: false, shiftKey: false, altKey: true, code: 'Enter' } as KeyboardEvent;
expect(matchesShortcutBinding(submitEvent, 'Mod+Enter')).toBe(true);
expect(matchesShortcutBinding(reverseSearchEvent, 'Shift+F3')).toBe(true);
expect(matchesShortcutBinding(typeEvent, 'A-Z')).toBe(true);
expect(matchesShortcutBinding(quickLabelEvent, 'Alt+1-0')).toBe(true);
expect(matchesShortcutBinding(macOptionQuickLabelEvent, 'Alt+1-0')).toBe(true);
expect(matchesShortcutBinding(wrongEvent, 'Mod+Enter')).toBe(false);
});
it('dispatches matching registry actions', () => {
const calls: string[] = [];
const event = { key: 'Enter', ctrlKey: true, metaKey: false, shiftKey: false, altKey: false } as KeyboardEvent;
const handled = dispatchShortcutEvent(planReviewSettingsShortcutRegistry[0], {
submitPlan: () => calls.push('submitPlan'),
quickSave: () => calls.push('quickSave'),
}, event);
expect(handled).toBe(true);
expect(calls).toEqual(['submitPlan']);
});
it('can dispatch annotate submit after plan submit declines the same binding', () => {
const calls: string[] = [];
const event = {
key: 'Enter',
ctrlKey: true,
metaKey: false,
shiftKey: false,
altKey: false,
preventDefault: () => calls.push('preventDefault'),
} as unknown as KeyboardEvent;
const handled = dispatchShortcutEvent(planEditorShortcuts, {
submitPlan: {
when: () => false,
handle: () => calls.push('submitPlan'),
},
submitAnnotations: {
when: () => true,
handle: () => {
event.preventDefault();
calls.push('submitAnnotations');
},
},
}, event);
expect(handled).toBe(true);
expect(calls).toEqual(['preventDefault', 'submitAnnotations']);
});
it('supports guarded handlers and continues after a failed guard', () => {
const guardedScope = defineShortcutScope({
id: 'guarded',
title: 'Guarded',
shortcuts: {
primary: {
description: 'Primary',
bindings: ['Enter'],
section: 'Actions',
preventDefault: true,
},
fallback: {
description: 'Fallback',
bindings: ['Enter'],
section: 'Actions',
preventDefault: true,
},
},
});
const calls: string[] = [];
const event = {
key: 'Enter',
ctrlKey: false,
metaKey: false,
shiftKey: false,
altKey: false,
preventDefault: () => calls.push('preventDefault'),
} as unknown as KeyboardEvent;
const handled = dispatchShortcutEvent(guardedScope, {
primary: {
when: () => false,
handle: () => calls.push('primary'),
},
fallback: {
when: () => true,
handle: () => calls.push('fallback'),
},
}, event);
expect(handled).toBe(true);
expect(calls).toEqual(['preventDefault', 'fallback']);
});
it('parses double-tap bindings', () => {
expect(parseDoubleTapBinding('Alt Alt')).toBe('Alt');
expect(parseDoubleTapBinding('Shift Shift')).toBe('Shift');
expect(parseDoubleTapBinding('Alt hold')).toBeNull();
expect(parseDoubleTapBinding('Mod+Enter')).toBeNull();
expect(parseDoubleTapBinding('Alt Shift')).toBeNull(); // different keys
expect(parseDoubleTapBinding('Alt+Shift Alt+Shift')).toBeNull(); // multi-key groups
});
it('matches key names for sequential binding support', () => {
const altEvent = { key: 'Alt' } as KeyboardEvent;
const shiftEvent = { key: 'Shift' } as KeyboardEvent;
const metaEvent = { key: 'Meta' } as KeyboardEvent;
const ctrlEvent = { key: 'Control' } as KeyboardEvent;
expect(matchesKeyName(altEvent, 'Alt')).toBe(true);
expect(matchesKeyName(altEvent, 'Shift')).toBe(false);
expect(matchesKeyName(shiftEvent, 'Shift')).toBe(true);
expect(matchesKeyName(metaEvent, 'Mod')).toBe(true);
expect(matchesKeyName(ctrlEvent, 'Mod')).toBe(true);
});
it('does not handle or prevent default when a guard fails', () => {
const guardedScope = defineShortcutScope({
id: 'guarded-skip',
title: 'Guarded Skip',
shortcuts: {
save: {
description: 'Save',
bindings: ['Mod+S'],
section: 'Actions',
preventDefault: true,
},
},
});
let preventDefaultCalls = 0;
const event = {
key: 's',
ctrlKey: true,
metaKey: false,
shiftKey: false,
altKey: false,
preventDefault: () => {
preventDefaultCalls += 1;
},
} as unknown as KeyboardEvent;
const handled = dispatchShortcutEvent(guardedScope, {
save: {
when: () => false,
handle: () => {
throw new Error('should not run');
},
},
}, event);
expect(handled).toBe(false);
expect(preventDefaultCalls).toBe(0);
});
});
@@ -0,0 +1,25 @@
import { defineShortcutScope } from '../core';
import { createShortcutScopeHook } from '../runtime';
export const reviewAiShortcuts = defineShortcutScope({
id: 'review-ai',
title: 'AI Assistant',
shortcuts: {
submit: {
description: 'Send message',
bindings: ['Mod+Enter'],
section: 'AI Assistant',
hint: 'Available in the AI tab and the Ask AI inline input.',
displayOrder: 10,
},
cancel: {
description: 'Cancel inline AI input',
bindings: ['Escape'],
section: 'AI Assistant',
hint: 'Available in the Ask AI inline input.',
displayOrder: 20,
},
},
});
export const useReviewAiShortcuts = createShortcutScopeHook(reviewAiShortcuts);
@@ -0,0 +1,46 @@
import { defineShortcutScope } from '../core';
import { createShortcutScopeHook } from '../runtime';
export const reviewAllFilesDiffShortcuts = defineShortcutScope({
id: 'review-all-files-diff',
title: 'All-Files Diff View',
shortcuts: {
toggleCollapse: {
description: 'Collapse / expand current file',
bindings: ['X'],
section: 'All-Files View',
hint: 'Available when the all-files diff panel is active.',
displayOrder: 10,
},
undoCollapse: {
description: 'Undo last collapse',
bindings: ['Z'],
section: 'All-Files View',
hint: 'Reopens the most recently auto-collapsed file (auto-collapse fires when toggling viewed).',
displayOrder: 20,
},
addFileComment: {
description: 'Add file-scoped comment',
bindings: ['C'],
section: 'All-Files View',
hint: 'Opens the file-scoped comment popover for the active file.',
displayOrder: 30,
},
nextFile: {
description: 'Scroll to next expanded file',
bindings: [']'],
section: 'All-Files View',
hint: 'All-files panel only.',
displayOrder: 40,
},
prevFile: {
description: 'Scroll to previous expanded file',
bindings: ['['],
section: 'All-Files View',
hint: 'All-files panel only.',
displayOrder: 50,
},
},
});
export const useReviewAllFilesDiffShortcuts = createShortcutScopeHook(reviewAllFilesDiffShortcuts);
@@ -0,0 +1,30 @@
import { defineShortcutScope } from '../core';
import { createShortcutScopeHook } from '../runtime';
export const reviewAnnotationToolbarShortcuts = defineShortcutScope({
id: 'review-annotation-toolbar',
title: 'Review Annotation Toolbar',
shortcuts: {
submitComment: {
description: 'Submit comment',
bindings: ['Mod+Enter'],
section: 'Annotations',
displayOrder: 10,
},
indentSuggestedCode: {
description: 'Indent suggested code',
bindings: ['Tab'],
section: 'Annotations',
displayOrder: 20,
},
cancel: {
description: 'Close comment editor',
bindings: ['Escape'],
section: 'Annotations',
hint: 'Available while the review comment editor is open.',
displayOrder: 30,
},
},
});
export const useReviewAnnotationToolbarShortcuts = createShortcutScopeHook(reviewAnnotationToolbarShortcuts);
@@ -0,0 +1,35 @@
import { defineShortcutScope } from '../core';
import { createShortcutScopeHook } from '../runtime';
export const reviewFileTreeShortcuts = defineShortcutScope({
id: 'review-file-tree',
title: 'File Navigation',
shortcuts: {
nextFile: {
description: 'Next file',
bindings: ['J', 'ArrowDown'],
section: 'File Navigation',
displayOrder: 10,
},
prevFile: {
description: 'Previous file',
bindings: ['K', 'ArrowUp'],
section: 'File Navigation',
displayOrder: 20,
},
firstFile: {
description: 'First file',
bindings: ['Home'],
section: 'File Navigation',
displayOrder: 30,
},
lastFile: {
description: 'Last file',
bindings: ['End'],
section: 'File Navigation',
displayOrder: 40,
},
},
});
export const useReviewFileTreeShortcuts = createShortcutScopeHook(reviewFileTreeShortcuts);
@@ -0,0 +1,18 @@
import { defineShortcutScope } from '../core';
import { createShortcutScopeHook } from '../runtime';
export const reviewPrCommentsShortcuts = defineShortcutScope({
id: 'review-pr-comments',
title: 'PR Comments',
shortcuts: {
focusSearch: {
description: 'Focus PR comments search',
bindings: ['Mod+Shift+F'],
section: 'PR Comments',
hint: 'Available while the PR Comments tab is open in the review sidebar.',
displayOrder: 10,
},
},
});
export const useReviewPrCommentsShortcuts = createShortcutScopeHook(reviewPrCommentsShortcuts);
@@ -0,0 +1,25 @@
import { defineShortcutScope } from '../core';
import { createShortcutScopeHook } from '../runtime';
export const reviewSuggestionModalShortcuts = defineShortcutScope({
id: 'review-suggestion-modal',
title: 'Suggestion Editor',
shortcuts: {
indent: {
description: 'Indent (insert spaces)',
bindings: ['Tab'],
section: 'Suggestion Editor',
hint: 'Available in the expanded suggestion editor.',
displayOrder: 10,
},
close: {
description: 'Close suggestion editor',
bindings: ['Escape'],
section: 'Suggestion Editor',
hint: 'Closes the modal; suggestion text is preserved on the underlying comment.',
displayOrder: 20,
},
},
});
export const useReviewSuggestionModalShortcuts = createShortcutScopeHook(reviewSuggestionModalShortcuts);
@@ -0,0 +1,18 @@
import { defineShortcutScope } from '../core';
import { createShortcutScopeHook } from '../runtime';
export const reviewTourDialogShortcuts = defineShortcutScope({
id: 'review-tour-dialog',
title: 'Tour Dialog',
shortcuts: {
close: {
description: 'Close tour',
bindings: ['Escape'],
section: 'Tour',
hint: 'Available while the code tour dialog is open.',
displayOrder: 10,
},
},
});
export const useReviewTourDialogShortcuts = createShortcutScopeHook(reviewTourDialogShortcuts);
+399
View File
@@ -0,0 +1,399 @@
import { isMac } from '../utils/platform';
export type ShortcutPlatform = 'mac' | 'non-mac' | 'cross-platform';
export interface ShortcutDefinition {
description: string;
/** Alternative bindings for the same action, shown as “or” in the shortcuts UI. */
bindings: string[];
section: string;
hint?: string;
preventDefault?: boolean;
/** Stable help-menu ordering within a section, without relying on object key order. */
displayOrder?: number;
}
export interface ShortcutScopeDefinition<TAction extends string = string> {
id: string;
title: string;
shortcuts: Record<TAction, ShortcutDefinition>;
}
export interface ShortcutEntry extends ShortcutDefinition {
scopeId: string;
scopeTitle: string;
actionId: string;
}
export interface ShortcutSection {
title: string;
shortcuts: ShortcutEntry[];
}
export type ShortcutRegistry = readonly ShortcutScopeDefinition[];
export interface ShortcutSurface {
slug: string;
title: string;
description: string;
registry: ShortcutRegistry;
}
const NAMED_TOKENS = new Set([
'Mod',
'Shift',
'Alt',
'Enter',
'Escape',
'Tab',
// TODO(migration): `matchesKeyToken` does not currently match `Space` —
// pressing Spacebar produces `event.key === ' '` (length 1), which the
// matcher uppercases to `' '` and then compares to the literal `'Space'`,
// always failing. Add a special case in `matchesKeyToken` (e.g.
// `if (token === 'Space') return event.key === ' ' || event.code === 'Space'`)
// before any scope binds Space.
'Space',
'Backspace',
'Delete',
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'Home',
'End',
'A-Z',
'1-0',
'hold',
// Punctuation keys used as shortcut targets. Add new ones as needed; we
// whitelist explicitly so typos like `Cmd` instead of `Mod` keep failing
// validation.
'.',
'[',
']',
]);
for (let n = 1; n <= 12; n += 1) {
NAMED_TOKENS.add(`F${n}`);
}
const MODIFIER_TOKENS = new Set(['Mod', 'Shift', 'Alt']);
export function defineShortcutScope<TAction extends string>(scope: ShortcutScopeDefinition<TAction>): ShortcutScopeDefinition<TAction> {
return scope;
}
function isSingleLetter(token: string): boolean {
return /^[A-Z]$/.test(token);
}
function isSingleDigit(token: string): boolean {
return /^[0-9]$/.test(token);
}
function getBindingTokens(binding: string): string[] {
return binding.trim().split(/[+\s]+/).filter(Boolean);
}
function getBindingGroups(binding: string): string[][] {
return binding.trim().split(/\s+/).filter(Boolean).map(group => group.split('+').filter(Boolean));
}
/**
* Parse a double-tap binding like `"Alt Alt"` and return the key name.
* Returns null for non-double-tap bindings.
*/
export function parseDoubleTapBinding(binding: string): string | null {
const groups = getBindingGroups(binding);
if (groups.length !== 2) return null;
if (groups[0].length !== 1 || groups[1].length !== 1) return null;
if (groups[0][0] !== groups[1][0]) return null;
if (groups[1][0] === 'hold') return null;
return groups[0][0];
}
/**
* Check if a KeyboardEvent matches a named key token (for sequential/stateful matching).
* Unlike `matchesShortcutBinding`, this matches a single key identity without modifier checks.
*/
export function matchesKeyName(event: KeyboardEvent, keyName: string): boolean {
if (keyName === 'Alt') return event.key === 'Alt';
if (keyName === 'Shift') return event.key === 'Shift';
if (keyName === 'Mod') return event.key === 'Meta' || event.key === 'Control';
return matchesKeyToken(event, keyName);
}
function isNormalizedToken(token: string): boolean {
return NAMED_TOKENS.has(token) || isSingleLetter(token) || isSingleDigit(token);
}
function normalizeShortcutEntry(
scope: ShortcutScopeDefinition,
actionId: string,
shortcut: ShortcutDefinition,
): ShortcutEntry {
return {
...shortcut,
scopeId: scope.id,
scopeTitle: scope.title,
actionId,
};
}
export function listScopeShortcuts(
scope: ShortcutScopeDefinition,
options?: { actionIds?: readonly string[] },
): ShortcutEntry[] {
const allowedActionIds = options?.actionIds ? new Set(options.actionIds) : null;
const shortcuts: ShortcutEntry[] = [];
for (const [actionId, shortcut] of Object.entries(scope.shortcuts)) {
if (allowedActionIds && !allowedActionIds.has(actionId)) continue;
shortcuts.push(normalizeShortcutEntry(scope, actionId, shortcut));
}
return shortcuts;
}
export function validateShortcutRegistry(registry: ShortcutRegistry): string[] {
const errors: string[] = [];
const scopeIds = new Set<string>();
for (const scope of registry) {
if (scopeIds.has(scope.id)) {
errors.push(`Duplicate shortcut scope id: ${scope.id}`);
}
scopeIds.add(scope.id);
for (const [actionId, shortcut] of Object.entries(scope.shortcuts)) {
const id = `${scope.id}.${actionId}`;
if (!shortcut.section.trim()) {
errors.push(`Shortcut ${id} is missing a section.`);
}
if (!shortcut.description.trim()) {
errors.push(`Shortcut ${id} is missing a description.`);
}
if (shortcut.bindings.length === 0) {
errors.push(`Shortcut ${id} must define at least one binding.`);
}
for (const binding of shortcut.bindings) {
if (!binding.trim()) {
errors.push(`Shortcut ${id} contains an empty binding.`);
continue;
}
for (const token of getBindingTokens(binding)) {
if (!isNormalizedToken(token)) {
errors.push(`Shortcut ${id} uses non-normalized token \`${token}\` in binding \`${binding}\`.`);
}
}
}
}
}
return errors;
}
export function createShortcutRegistry<TRegistry extends ShortcutRegistry>(registry: TRegistry): TRegistry {
const errors = validateShortcutRegistry(registry);
if (errors.length > 0) {
throw new Error(`Invalid shortcut registry:\n- ${errors.join('\n- ')}`);
}
return registry;
}
export function mergeShortcutRegistries(...registries: ShortcutRegistry[]): ShortcutRegistry {
return createShortcutRegistry(registries.flat());
}
export function getShortcutScope(registry: ShortcutRegistry, scopeId: string): ShortcutScopeDefinition | undefined {
return registry.find(scope => scope.id === scopeId);
}
export function getShortcut(
registry: ShortcutRegistry,
scopeId: string,
actionId: string,
): ShortcutEntry | undefined {
const scope = getShortcutScope(registry, scopeId);
const shortcut = scope?.shortcuts[actionId];
return scope && shortcut ? normalizeShortcutEntry(scope, actionId, shortcut) : undefined;
}
export function listRegistryShortcuts(registry: ShortcutRegistry): ShortcutEntry[] {
const shortcuts: ShortcutEntry[] = [];
for (const scope of registry) {
shortcuts.push(...listScopeShortcuts(scope));
}
return shortcuts;
}
export function listShortcutSections(shortcuts: readonly ShortcutEntry[]): ShortcutSection[] {
const sections = new Map<string, ShortcutEntry[]>();
for (const shortcut of shortcuts) {
const existing = sections.get(shortcut.section);
if (existing) {
existing.push(shortcut);
} else {
sections.set(shortcut.section, [shortcut]);
}
}
return Array.from(sections.entries()).map(([title, sectionShortcuts]) => ({
title,
shortcuts: [...sectionShortcuts].sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0) || a.description.localeCompare(b.description)),
}));
}
export function listRegistryShortcutSections(registry: ShortcutRegistry): ShortcutSection[] {
return listShortcutSections(listRegistryShortcuts(registry));
}
export function getShortcutPlatform(): Exclude<ShortcutPlatform, 'cross-platform'> {
return isMac ? 'mac' : 'non-mac';
}
function formatKeycapToken(token: string, platform: Exclude<ShortcutPlatform, 'cross-platform'>): string {
if (platform === 'mac') {
if (token === 'Mod') return '⌘';
if (token === 'Alt') return '⌥';
if (token === 'Shift') return '⇧';
if (token === 'Enter') return '⏎';
if (token === 'Escape') return 'Esc';
}
if (platform === 'non-mac') {
if (token === 'Mod') return 'Ctrl';
if (token === 'Enter') return '↵';
if (token === 'Escape') return 'Esc';
}
return token;
}
function formatTextToken(token: string, platform: ShortcutPlatform): string {
if (token === 'Mod') {
if (platform === 'mac') return 'Cmd';
if (platform === 'non-mac') return 'Ctrl';
return 'Cmd/Ctrl';
}
if (token === 'Alt') {
return platform === 'mac' ? 'Option' : 'Alt';
}
if (token === 'Escape') return 'Escape';
if (token === 'hold') return 'hold';
return token;
}
export function formatShortcutBindingTokens(
binding: string,
platform: Exclude<ShortcutPlatform, 'cross-platform'> = getShortcutPlatform(),
): string[] {
const doubleTapKey = parseDoubleTapBinding(binding);
if (doubleTapKey) {
return [formatKeycapToken(doubleTapKey, platform), '×2'];
}
return getBindingTokens(binding).map(token => formatKeycapToken(token, platform));
}
export function formatShortcutBindingText(
binding: string,
platform: ShortcutPlatform = 'cross-platform',
): string {
const groups = getBindingGroups(binding);
if (groups.length === 2 && groups[1].length === 1 && groups[1][0] === 'hold' && groups[0].length === 1) {
return `Hold ${formatTextToken(groups[0][0], platform)}`;
}
const doubleTapKey = parseDoubleTapBinding(binding);
if (doubleTapKey) {
return `Double-tap ${formatTextToken(doubleTapKey, platform)}`;
}
return groups
.map(group => group.map(token => formatTextToken(token, platform)).join('+'))
.join(' then ');
}
export function formatShortcutBindingsText(
bindings: string[],
platform: ShortcutPlatform = 'cross-platform',
): string {
return bindings.map(binding => formatShortcutBindingText(binding, platform)).join(' or ');
}
function getDigitCode(event: KeyboardEvent): string | null {
const code = typeof event.code === 'string' ? event.code : '';
const match = code.match(/^Digit([0-9])$/);
return match ? match[1] : null;
}
export function getShortcutDigit(event: KeyboardEvent): number | null {
const parsed = Number.parseInt(event.key, 10);
if (!Number.isNaN(parsed)) return parsed;
const digitCode = getDigitCode(event);
return digitCode === null ? null : Number.parseInt(digitCode, 10);
}
function matchesKeyToken(event: KeyboardEvent, token: string): boolean {
const key = event.key.length === 1 ? event.key.toUpperCase() : event.key;
const shortcutDigit = getShortcutDigit(event);
if (token === 'A-Z') {
return /^[A-Z]$/.test(key);
}
if (token === '1-0') {
return /^[0-9]$/.test(key) || shortcutDigit !== null;
}
if (isSingleLetter(token)) {
return key === token;
}
if (isSingleDigit(token)) {
return key === token || String(shortcutDigit ?? '') === token;
}
return key === token;
}
export function matchesShortcutBinding(event: KeyboardEvent, binding: string): boolean {
if (binding.includes(' ') || binding.includes('hold')) {
return false;
}
const tokens = binding.split('+').filter(Boolean);
if (tokens.length === 0) return false;
const requiresMod = tokens.includes('Mod');
const requiresShift = tokens.includes('Shift');
const requiresAlt = tokens.includes('Alt');
const keyTokens = tokens.filter(token => !MODIFIER_TOKENS.has(token));
if (keyTokens.length !== 1) return false;
const keyToken = keyTokens[0];
const shiftMatches = requiresShift === event.shiftKey || (!requiresShift && keyToken === 'A-Z' && event.shiftKey);
if (requiresMod !== (event.metaKey || event.ctrlKey)) return false;
if (!shiftMatches) return false;
if (requiresAlt !== event.altKey) return false;
return matchesKeyToken(event, keyToken);
}
export function getMatchingShortcutBindingIndex(event: KeyboardEvent, bindings: string[]): number {
return bindings.findIndex(binding => matchesShortcutBinding(event, binding));
}
+19
View File
@@ -0,0 +1,19 @@
export * from './core';
export * from './runtime';
// plan-review scopes
export { annotationToolbarShortcuts, useAnnotationToolbarShortcuts } from './plan-review/annotationToolbar.shortcuts';
export { annotationPanelShortcuts, useAnnotationPanelShortcuts } from './plan-review/annotationPanel.shortcuts';
export { commentPopoverShortcuts } from './plan-review/commentPopover.shortcuts';
export { imageAnnotatorShortcuts, useImageAnnotatorShortcuts } from './plan-review/imageAnnotator.shortcuts';
export { inputMethodShortcuts } from './plan-review/inputMethod.shortcuts';
export { viewerShortcuts, useViewerShortcuts } from './plan-review/viewer.shortcuts';
// code-review scopes
export { reviewAnnotationToolbarShortcuts, useReviewAnnotationToolbarShortcuts } from './code-review/annotationToolbar.shortcuts';
export { reviewFileTreeShortcuts, useReviewFileTreeShortcuts } from './code-review/fileTree.shortcuts';
export { reviewPrCommentsShortcuts, useReviewPrCommentsShortcuts } from './code-review/prComments.shortcuts';
export { reviewAllFilesDiffShortcuts, useReviewAllFilesDiffShortcuts } from './code-review/allFilesDiff.shortcuts';
export { reviewAiShortcuts, useReviewAiShortcuts } from './code-review/ai.shortcuts';
export { reviewSuggestionModalShortcuts, useReviewSuggestionModalShortcuts } from './code-review/suggestionModal.shortcuts';
export { reviewTourDialogShortcuts, useReviewTourDialogShortcuts } from './code-review/tourDialog.shortcuts';
@@ -0,0 +1,25 @@
import { defineShortcutScope } from '../core';
import { createShortcutScopeHook } from '../runtime';
export const annotationPanelShortcuts = defineShortcutScope({
id: 'annotation-panel',
title: 'Annotation Panel',
shortcuts: {
saveEdit: {
description: 'Save annotation edit',
bindings: ['Mod+Enter'],
section: 'Annotations',
hint: 'Available while editing an annotation in the side panel.',
displayOrder: 50,
},
cancelEdit: {
description: 'Cancel annotation edit',
bindings: ['Escape'],
section: 'Annotations',
hint: 'Available while editing an annotation in the side panel.',
displayOrder: 60,
},
},
});
export const useAnnotationPanelShortcuts = createShortcutScopeHook(annotationPanelShortcuts);
@@ -0,0 +1,39 @@
import { defineShortcutScope } from '../core';
import { createShortcutScopeHook } from '../runtime';
export const annotationToolbarShortcuts = defineShortcutScope({
id: 'annotation-toolbar',
title: 'Annotation Toolbar',
shortcuts: {
typeToComment: {
description: 'Start comment',
bindings: ['A-Z'],
section: 'Annotations',
hint: 'Typing a letter opens the comment editor with that character.',
displayOrder: 10,
},
applyQuickLabel: {
description: 'Apply toolbar label',
bindings: ['Alt+1-0'],
section: 'Annotations',
hint: 'Applies the matching preset label while the annotation toolbar is open.',
displayOrder: 20,
},
applyQuickLabelFromPicker: {
description: 'Apply picker label',
bindings: ['1-0'],
section: 'Annotations',
hint: 'Available while the quick label picker is open.',
displayOrder: 30,
},
close: {
description: 'Close toolbar',
bindings: ['Escape'],
section: 'Annotations',
hint: 'Available while the annotation toolbar is open.',
displayOrder: 40,
},
},
});
export const useAnnotationToolbarShortcuts = createShortcutScopeHook(annotationToolbarShortcuts);
@@ -0,0 +1,21 @@
import { defineShortcutScope } from '../core';
export const commentPopoverShortcuts = defineShortcutScope({
id: 'comment-popover',
title: 'Comment Editor',
shortcuts: {
submit: {
description: 'Submit comment',
bindings: ['Mod+Enter'],
section: 'Annotations',
displayOrder: 30,
},
cancel: {
description: 'Close comment',
bindings: ['Escape'],
section: 'Annotations',
hint: 'Available while the comment editor is open.',
displayOrder: 40,
},
},
});
@@ -0,0 +1,42 @@
import { defineShortcutScope } from '../core';
import { createShortcutScopeHook } from '../runtime';
export const imageAnnotatorShortcuts = defineShortcutScope({
id: 'image-annotator',
title: 'Image Annotator',
shortcuts: {
penTool: {
description: 'Pen tool',
bindings: ['1'],
section: 'Image Annotator',
displayOrder: 10,
},
arrowTool: {
description: 'Arrow tool',
bindings: ['2'],
section: 'Image Annotator',
displayOrder: 20,
},
circleTool: {
description: 'Circle tool',
bindings: ['3'],
section: 'Image Annotator',
displayOrder: 30,
},
undo: {
description: 'Undo',
bindings: ['Mod+Z'],
section: 'Image Annotator',
displayOrder: 40,
},
save: {
description: 'Save and close annotator',
bindings: ['Enter', 'Escape'],
section: 'Image Annotator',
hint: 'When the image name field is focused, Escape blurs it first and Enter confirms the name; both close the annotator otherwise.',
displayOrder: 50,
},
},
});
export const useImageAnnotatorShortcuts = createShortcutScopeHook(imageAnnotatorShortcuts);
@@ -0,0 +1,30 @@
import { defineShortcutScope } from '../core';
// Heads-up for future migrators: these bindings (`Alt hold`, `Alt Alt`) are
// declarative metadata for the help modal and marketing docs. They do NOT
// dispatch through `useShortcutScope`. `Alt Alt` works via
// `useDoubleTapShortcuts`; `Alt hold` has no shared hook yet — it's wired
// today by the bespoke `useInputMethodSwitch` hook in
// `packages/editor/hooks/`. See the comment block above
// `useDoubleTapShortcuts` in `packages/ui/shortcuts/runtime.ts`.
export const inputMethodShortcuts = defineShortcutScope({
id: 'input-method',
title: 'Input Method',
shortcuts: {
temporarySwitch: {
description: 'Temporarily switch input method',
bindings: ['Alt hold'],
section: 'Input Method',
hint: 'Hold Alt to switch between Select and Pinpoint, then release to revert.',
displayOrder: 10,
},
toggleSwitch: {
description: 'Toggle input method',
bindings: ['Alt Alt'],
section: 'Input Method',
hint: 'Double-tap Alt to switch between Select and Pinpoint until you toggle again.',
displayOrder: 20,
},
},
});
@@ -0,0 +1,24 @@
import { defineShortcutScope } from '../core';
import { createShortcutScopeHook } from '../runtime';
export const viewerShortcuts = defineShortcutScope({
id: 'viewer',
title: 'Viewer',
shortcuts: {
copySelection: {
description: 'Copy selected text',
bindings: ['Mod+C'],
section: 'Annotations',
displayOrder: 25,
},
closeLightbox: {
description: 'Close image lightbox',
bindings: ['Escape'],
section: 'Annotations',
hint: 'Available while an image is open in the lightbox.',
displayOrder: 70,
},
},
});
export const useViewerShortcuts = createShortcutScopeHook(viewerShortcuts);
+215
View File
@@ -0,0 +1,215 @@
import { useEffect, useRef } from 'react';
import type { ShortcutDefinition, ShortcutScopeDefinition } from './core';
import { matchesKeyName, matchesShortcutBinding, parseDoubleTapBinding } from './core';
type ShortcutActionId<TScope extends ShortcutScopeDefinition<any>> = keyof TScope['shortcuts'] & string;
export interface ShortcutHandlerConfig {
when?: (event: KeyboardEvent) => boolean;
handle: (event: KeyboardEvent) => void;
}
export type ShortcutHandler = ((event: KeyboardEvent) => void) | ShortcutHandlerConfig;
export type ShortcutHandlers<TScope extends ShortcutScopeDefinition<any>> = Partial<
Record<ShortcutActionId<TScope>, ShortcutHandler>
>;
type ShortcutEventTarget = 'window' | 'document' | EventTarget | null;
export interface UseShortcutScopeOptions<TScope extends ShortcutScopeDefinition<any>> {
scope: TScope;
handlers: ShortcutHandlers<TScope>;
target?: ShortcutEventTarget;
stopOnMatch?: boolean;
}
function normalizeShortcutHandler(handler: ShortcutHandler): ShortcutHandlerConfig {
if (typeof handler === 'function') {
return { handle: handler };
}
return handler;
}
// TODO(migration): no cross-scope arbitration. When two scopes bind the
// same key (e.g. `Escape` in both an outer editor scope and an inner
// dialog scope), both `useShortcutScope` listeners fire on a single
// keypress. Add `if (event.defaultPrevented) return false;` at the top
// of this function once shortcut definitions consistently set
// `preventDefault: true` (or once we flip the default). Until then,
// callers must guard with `when` to prevent double-handling.
export function dispatchShortcutEvent<TScope extends ShortcutScopeDefinition<any>>(
scope: TScope,
handlers: ShortcutHandlers<TScope>,
event: KeyboardEvent,
options?: { stopOnMatch?: boolean },
): boolean {
const stopOnMatch = options?.stopOnMatch ?? true;
let handled = false;
for (const [actionId, shortcut] of Object.entries(scope.shortcuts) as Array<[
ShortcutActionId<TScope>,
ShortcutDefinition,
]>) {
const handler = handlers[actionId];
if (!handler) continue;
if (!shortcut.bindings.some(binding => matchesShortcutBinding(event, binding))) continue;
const { when, handle } = normalizeShortcutHandler(handler);
if (when && !when(event)) continue;
if (shortcut.preventDefault) {
event.preventDefault();
}
handle(event);
handled = true;
if (stopOnMatch) {
return true;
}
}
return handled;
}
function getEventTarget(target: ShortcutEventTarget): EventTarget | null {
if (target === 'window') {
return typeof window === 'undefined' ? null : window;
}
if (target === 'document') {
return typeof document === 'undefined' ? null : document;
}
return target;
}
export function useShortcutScope<TScope extends ShortcutScopeDefinition<any>>({
scope,
handlers,
target = 'window',
stopOnMatch = true,
}: UseShortcutScopeOptions<TScope>) {
const handlersRef = useRef(handlers);
useEffect(() => {
handlersRef.current = handlers;
}, [handlers]);
useEffect(() => {
const eventTarget = getEventTarget(target);
if (!eventTarget || !('addEventListener' in eventTarget)) return;
const handleKeyDown = (event: Event) => {
dispatchShortcutEvent(scope, handlersRef.current, event as KeyboardEvent, { stopOnMatch });
};
eventTarget.addEventListener('keydown', handleKeyDown as EventListener);
return () => {
eventTarget.removeEventListener('keydown', handleKeyDown as EventListener);
};
}, [scope, stopOnMatch, target]);
}
export function createShortcutScopeHook<TScope extends ShortcutScopeDefinition<any>>(scope: TScope) {
return function useScopedShortcutScope(options: Omit<UseShortcutScopeOptions<TScope>, 'scope'>) {
useShortcutScope({ scope, ...options });
};
}
// --- Multi-press shortcut support ---
//
// `useShortcutScope` only dispatches single-press bindings — anything with
// whitespace (e.g. `"Alt Alt"`) or the `hold` token (e.g. `"Alt hold"`)
// short-circuits in `matchesShortcutBinding`. Multi-press bindings need a
// dedicated hook that knows their semantics:
//
// - Double-tap → `useDoubleTapShortcuts` below.
// - Hold → no shared hook yet; wire by hand in the consuming
// component until one is built. The `Alt hold` binding in
// `inputMethodShortcuts` is currently driven by the
// bespoke `useInputMethodSwitch` hook in the plan editor.
//
// TODO: when the App.tsx migration starts touching hold semantics, add a
// `useHoldShortcuts` here paired with a `parseHoldBinding` in core.ts so the
// registry-driven path actually fires.
// --- Double-tap shortcut support ---
export type DoubleTapHandlers<TScope extends ShortcutScopeDefinition<any>> = Partial<
Record<ShortcutActionId<TScope>, ShortcutHandler>
>;
export interface UseDoubleTapShortcutsOptions<TScope extends ShortcutScopeDefinition<any>> {
scope: TScope;
handlers: DoubleTapHandlers<TScope>;
/** Max gap between two key releases to count as a double-tap (default: 300ms). */
window?: number;
}
/**
* Hook for handling double-tap shortcuts (bindings like `"Alt Alt"`).
*
* Double-tap is detected on keyup: if the same key is released twice
* within `window` ms, the handler fires. Regular (keydown) bindings
* in the same scope are ignored — use `useShortcutScope` for those.
*/
export function useDoubleTapShortcuts<TScope extends ShortcutScopeDefinition<any>>({
scope,
handlers,
window: tapWindow = 300,
}: UseDoubleTapShortcutsOptions<TScope>) {
const handlersRef = useRef(handlers);
useEffect(() => { handlersRef.current = handlers; }, [handlers]);
useEffect(() => {
// Pre-parse which actions have double-tap bindings
const doubleTapActions: Array<{ actionId: ShortcutActionId<TScope>; keyName: string }> = [];
for (const [actionId, shortcut] of Object.entries(scope.shortcuts) as Array<[
ShortcutActionId<TScope>,
ShortcutDefinition,
]>) {
for (const binding of shortcut.bindings) {
const keyName = parseDoubleTapBinding(binding);
if (keyName) {
doubleTapActions.push({ actionId, keyName });
}
}
}
if (doubleTapActions.length === 0) return;
// Track last keyup timestamp per key
const lastKeyUp = new Map<string, number>();
const handleKeyUp = (event: KeyboardEvent) => {
for (const { actionId, keyName } of doubleTapActions) {
if (!matchesKeyName(event, keyName)) continue;
const handler = handlersRef.current[actionId];
if (!handler) continue;
const { when, handle } = normalizeShortcutHandler(handler);
if (when && !when(event)) continue;
const now = Date.now();
const prev = lastKeyUp.get(keyName) ?? 0;
if (now - prev < tapWindow) {
handle(event);
lastKeyUp.set(keyName, 0); // reset so triple-tap doesn't re-fire
} else {
lastKeyUp.set(keyName, now);
}
}
};
window.addEventListener('keyup', handleKeyUp);
return () => window.removeEventListener('keyup', handleKeyUp);
}, [scope, tapWindow]);
}
export function createDoubleTapShortcutsHook<TScope extends ShortcutScopeDefinition<any>>(scope: TScope) {
return function useScopedDoubleTap(options: Omit<UseDoubleTapShortcutsOptions<TScope>, 'scope'>) {
useDoubleTapShortcuts({ scope, ...options });
};
}