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
+28 -1
View File
@@ -200,6 +200,28 @@ export function Chat({
}
}, [renaming]);
// ── Keyboard seams (shortcut wave A, 2026-09-17) ──
// F2 opens the title rename (the chat header owns the editing state).
useEffect(() => {
const onRename = () => {
setTitleDraft(titleText);
setRenaming(true);
};
window.addEventListener("lh:kbd-rename-session", onRename);
return () => window.removeEventListener("lh:kbd-rename-session", onRename);
}, [titleText]);
// The Esc chain's last link: abort the turn. Honest scope (the composer's
// Stop has the same rule): only a LOCAL stream has a client request to
// stop — a server-side wake turn aborts nothing (the stop would be a
// no-op lie), so Esc is simply inert while one runs.
useEffect(() => {
const onAbort = () => {
if (localStreamRef.current) stop();
};
window.addEventListener("lh:kbd-abort-turn", onAbort);
return () => window.removeEventListener("lh:kbd-abort-turn", onAbort);
}, [stop]);
// Mount continuation: if the stored transcript ends with a fulfilled tool
// (a job result landed while no client was open), resume the lesson — the
// SDK's sendAutomaticallyWhen only fires inside addToolOutput, so reloads
@@ -670,7 +692,12 @@ export function Chat({
onChange={(e) => setTitleDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void commitRename(titleDraft);
else if (e.key === "Escape") setRenaming(false);
// stopPropagation claims the Esc (keyboard wave A):
// closing rename must not also abort a turn.
else if (e.key === "Escape") {
e.stopPropagation();
setRenaming(false);
}
}}
onBlur={() => void commitRename(titleDraft)}
spellCheck={false}