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:
co-authored by
Claude Fable 4.5 plus-plus
parent
d1ba0c5c3c
commit
682edde844
@@ -127,7 +127,12 @@ export function ModelControl({
|
||||
}
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(null);
|
||||
if (e.key === "Escape") {
|
||||
// Claim the Esc (keyboard wave A): the window-level chain must
|
||||
// not ALSO stop dictation / abort the turn under this menu.
|
||||
e.stopPropagation();
|
||||
setOpen(null);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
@@ -168,6 +173,30 @@ export function ModelControl({
|
||||
[chatId, onSaved],
|
||||
);
|
||||
|
||||
// Shortcut seams (keyboard wave A): ⌘K opens the model menu (the
|
||||
// universal command convention); Shift+Tab cycles the thinking level
|
||||
// up the ladder (wrapping) — the owner's replacement for browser
|
||||
// tab-focus. Both work from any focus, including mid-typing.
|
||||
useEffect(() => {
|
||||
const onOpenPicker = () => {
|
||||
setOpen("model");
|
||||
setQuery("");
|
||||
setError(null);
|
||||
};
|
||||
window.addEventListener("lh:kbd-open-model-picker", onOpenPicker);
|
||||
return () => window.removeEventListener("lh:kbd-open-model-picker", onOpenPicker);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onCycleThinking = () => {
|
||||
if (levels.length === 0) return;
|
||||
const idx = effectiveLevel === undefined ? -1 : levels.indexOf(effectiveLevel);
|
||||
void save({ ...(settings ?? {}), thinkingLevel: levels[(idx + 1) % levels.length] });
|
||||
};
|
||||
window.addEventListener("lh:kbd-cycle-thinking", onCycleThinking);
|
||||
return () => window.removeEventListener("lh:kbd-cycle-thinking", onCycleThinking);
|
||||
}, [levels, effectiveLevel, save, settings]);
|
||||
|
||||
// The filtered, ranked model list.
|
||||
const results = (catalog?.models ?? [])
|
||||
.map((m) => ({ m, s: fuzzyScore(query, m.provider, m.id, m.name) }))
|
||||
|
||||
Reference in New Issue
Block a user