feat: keyboard shortcuts wave A — registry, provider, global bindings

The shortcut architecture (owner-approved plan, 2026-09-17):
- src/keyboard.ts: pure action registry (id, label, scope, default
  binding) + lh:keymap localStorage overrides + event normalization.
  The settings milestone renders this same registry as its editor.
- components/keyboard-shortcuts.tsx: the single document-level keydown
  listener (window level, bubble) owning the pieces no surface owns:
  - Tab is DEAD (owner decision): browser tab-focus navigation is
    abandoned; Tab is suppressed everywhere, Shift+Tab cycles the
    thinking level.
  - Esc is a layered dismiss: topmost popover first (popovers
    stopPropagation their own Esc — usePopoverDismiss + ModelControl),
    then dictation stop (lh:kbd-stop-dictation), then abort of an
    in-flight turn (lh:kbd-abort-turn; server-side wake turns are
    honestly non-stoppable — the seam only stops local streams).
  - Global actions dispatch lh:kbd-<action> window events (the
    lh:chat-renamed pattern); surfaces that aren't mounted simply
    don't listen.
- The input guard: inside a typing surface only mod/alt combos (and
  Tab) fire; bare keys pass through to the field.
- Cheat sheet overlay (?) renders from the registry, grouped by scope.

Bindings wired this wave: cmd+d dictation toggle, cmd+k model picker,
shift+tab thinking cycle, cmd+b sidebar, cmd+shift+f sessions search,
alt+up/down + alt+enter session cycling, cmd+g graph view, cmd+n new
session, f2 rename (chat), ? cheat sheet. Wave B adds the question
keyboard machine; wave C the per-surface bindings (find, import).

Verified live in the dev shell (CDP): all of the above via
Input.dispatchKeyEvent.

Co-Authored-By: Claude Fable 4.5 plus-plus <noreply@anthropic.com>
This commit is contained in:
amosblomqvist
2026-09-17 20:47:23 +03:00
co-authored by Claude Fable 4.5 plus-plus
parent d1ba0c5c3c
commit 682edde844
12 changed files with 538 additions and 10 deletions
+24
View File
@@ -98,6 +98,16 @@ export function rmsToMeter(rms: number): number {
*/
let questionTarget: ((text: string) => void) | null = null;
/* Is a dictation run live on this page? The Esc chain (keyboard wave A,
* components/keyboard-shortcuts.tsx) needs a SYNCHRONOUS answer — the
* stop handler can't report back through a CustomEvent. Set at start(),
* cleared in cleanup() (every terminal path runs through cleanup). */
let dictationActiveFlag = false;
export function isDictationActive(): boolean {
return dictationActiveFlag;
}
/** Join a dictated final onto the current text (shared separator rule). */
export function joinDictation(current: string, final: string): string {
return current ? current.replace(/\s+$/, "") + " " + final : final;
@@ -172,6 +182,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
/** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */
const cleanup = useCallback(() => {
genRef.current++;
dictationActiveFlag = false;
flush(); // the deferred transcript appears when the run ends (owner round 2)
if (safetyRef.current) {
clearTimeout(safetyRef.current);
@@ -260,6 +271,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
);
const start = useCallback(async () => {
dictationActiveFlag = true;
setError(null);
setPhase("connecting");
reconnectsRef.current = 0;
@@ -352,6 +364,18 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
// "stopping" presses are ignored — the flush is in flight.
}, [phase, start, stop]);
// Shortcut seams (keyboard wave A): ⌘D toggles from anywhere on the
// page; the Esc chain's stop lands here too. Each page owns ONE hook
// instance, and only the mounted page's listener is attached.
useEffect(() => {
window.addEventListener("lh:kbd-toggle-dictation", toggle);
return () => window.removeEventListener("lh:kbd-toggle-dictation", toggle);
}, [toggle]);
useEffect(() => {
window.addEventListener("lh:kbd-stop-dictation", stop);
return () => window.removeEventListener("lh:kbd-stop-dictation", stop);
}, [stop]);
return { phase, error, levelRef, active: phase !== "idle", toggle, stop };
}