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
@@ -34,5 +34,7 @@ export * from "./ui-message.ts";
|
||||
export * from "./tool-parts.ts";
|
||||
export * from "./prices.ts";
|
||||
export * from "./fuzzy.ts";
|
||||
// keyboard.ts: the shortcut registry + binding normalization (pure, browser-safe).
|
||||
export * from "./keyboard.ts";
|
||||
export * from "./coverage.ts";
|
||||
export * from "./graph-context.ts";
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Keyboard shortcuts (shortcut wave, 2026-09-17) — the single source of truth
|
||||
* for every app action that has a binding. This module is PURE (no React, no
|
||||
* window access at module scope beyond the guarded helpers) so it can render
|
||||
* a cheat sheet, match events, and — at the settings milestone — drive a
|
||||
* user-editable key map without touching any component.
|
||||
*
|
||||
* Design (owner-approved plan):
|
||||
* - Tab is DEAD: browser tab-focus navigation is abandoned entirely. Tab
|
||||
* does nothing; Shift+Tab alone cycles the thinking level.
|
||||
* - Esc is a layered dismiss: topmost popover (each popover stopPropagation's
|
||||
* its own Esc) → dictation stop → abort in-flight turn.
|
||||
* - The key map persists overrides in localStorage (`lh:keymap`, overrides
|
||||
* only) merged over DEFAULT_BINDINGS — the settings wave adds the editor.
|
||||
*
|
||||
* Binding strings are normalized, cross-platform:
|
||||
* "mod+d" — mod = ⌘ on macOS, Ctrl elsewhere (both are accepted live)
|
||||
* "shift+tab", "alt+arrowdown", "mod+shift+f", "?", "f2", "escape"
|
||||
* Order is fixed: mod, alt, shift, then the key.
|
||||
*/
|
||||
|
||||
export type KeyAction =
|
||||
| "toggle-dictation"
|
||||
| "open-model-picker"
|
||||
| "cycle-thinking"
|
||||
| "toggle-sidebar"
|
||||
| "sessions-search"
|
||||
| "cycle-session-prev"
|
||||
| "cycle-session-next"
|
||||
| "open-session"
|
||||
| "jump-graph"
|
||||
| "new-session"
|
||||
| "rename-session"
|
||||
| "cheat-sheet";
|
||||
|
||||
/** Where the cheat sheet groups an action (display only — dispatch is
|
||||
* global; a seam that isn't mounted simply no-ops). */
|
||||
export type KeyScope = "global" | "chat" | "graph";
|
||||
|
||||
export interface KeyActionDef {
|
||||
id: KeyAction;
|
||||
/** Cheat-sheet label. */
|
||||
label: string;
|
||||
scope: KeyScope;
|
||||
binding: string;
|
||||
}
|
||||
|
||||
export const KEYBOARD_ACTIONS: readonly KeyActionDef[] = [
|
||||
{ id: "toggle-dictation", label: "Toggle dictation", scope: "global", binding: "mod+d" },
|
||||
{ id: "open-model-picker", label: "Model selector", scope: "global", binding: "mod+k" },
|
||||
{ id: "cycle-thinking", label: "Cycle thinking level", scope: "global", binding: "shift+tab" },
|
||||
{ id: "toggle-sidebar", label: "Toggle sessions sidebar", scope: "global", binding: "mod+b" },
|
||||
{ id: "sessions-search", label: "Search sessions", scope: "global", binding: "mod+shift+f" },
|
||||
{ id: "cycle-session-prev", label: "Previous session", scope: "global", binding: "alt+arrowup" },
|
||||
{ id: "cycle-session-next", label: "Next session", scope: "global", binding: "alt+arrowdown" },
|
||||
{ id: "open-session", label: "Open selected session", scope: "global", binding: "alt+enter" },
|
||||
{ id: "jump-graph", label: "Graph view", scope: "global", binding: "mod+g" },
|
||||
{ id: "new-session", label: "New session", scope: "global", binding: "mod+n" },
|
||||
{ id: "rename-session", label: "Rename session", scope: "chat", binding: "f2" },
|
||||
{ id: "cheat-sheet", label: "Shortcut cheat sheet", scope: "global", binding: "?" },
|
||||
];
|
||||
|
||||
const STORAGE_KEY = "lh:keymap";
|
||||
|
||||
/** localStorage overrides ({actionId: binding}) — read once per module
|
||||
* instance; the settings editor will write this key. Guarded: SSR and a
|
||||
* corrupt/absent store both fall back to defaults. */
|
||||
function loadOverrides(): Partial<Record<KeyAction, string>> {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as Partial<Record<KeyAction, string>>;
|
||||
if (parsed && typeof parsed === "object") return parsed;
|
||||
} catch {
|
||||
/* corrupt store = defaults */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** The live key map: defaults with user overrides applied. */
|
||||
export function effectiveKeymap(): Map<string, KeyAction> {
|
||||
const overrides = loadOverrides();
|
||||
const map = new Map<string, KeyAction>();
|
||||
for (const def of KEYBOARD_ACTIONS) {
|
||||
const binding = overrides[def.id] ?? def.binding;
|
||||
if (typeof binding === "string" && binding) map.set(binding, def.id);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Which action a normalized binding fires, if any (override-aware). */
|
||||
export function actionForBinding(binding: string): KeyAction | undefined {
|
||||
return effectiveKeymap().get(binding);
|
||||
}
|
||||
|
||||
/** The effective (override-aware) binding for one action — display use. */
|
||||
export function bindingForAction(id: KeyAction): string {
|
||||
const overrides = loadOverrides();
|
||||
const def = KEYBOARD_ACTIONS.find((a) => a.id === id);
|
||||
if (!def) return "";
|
||||
return overrides[id] ?? def.binding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a KeyboardEvent to a canonical binding string. Rules:
|
||||
* - mod matches BOTH meta (⌘) and ctrl — Ctrl+K works on a Mac keyboard too.
|
||||
* - a printable key ("?") bakes its shift in: "shift+/?" collapses to "?".
|
||||
*/
|
||||
export function eventBinding(e: KeyboardEvent): string {
|
||||
const mod = e.metaKey || e.ctrlKey;
|
||||
// A bare printable key bakes its shift into the character ("?" not
|
||||
// "shift+/"), so it returns alone. With a real modifier riding along,
|
||||
// shift is explicit (⌘⇧F = "mod+shift+f"); non-printable keys (Tab,
|
||||
// arrows) always keep their shift (Shift+Tab = "shift+tab").
|
||||
if (e.key.length === 1 && !mod && !e.altKey) return e.key.toLowerCase();
|
||||
const mods: string[] = [];
|
||||
if (mod) mods.push("mod");
|
||||
if (e.altKey) mods.push("alt");
|
||||
if (e.shiftKey) mods.push("shift");
|
||||
return [...mods, e.key.toLowerCase()].join("+");
|
||||
}
|
||||
|
||||
/** True when the event lands in a typing surface (input, textarea, or a
|
||||
* contentEditable). In one, only mod/alt combos (and Tab, which types
|
||||
* nothing) may fire — bare keys belong to the field (typing, caret). */
|
||||
export function isTypingSurface(e: KeyboardEvent): boolean {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return false;
|
||||
const tag = target.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
||||
return target.isContentEditable;
|
||||
}
|
||||
|
||||
/** macOS styling for binding display ("⌘D", "⇧⇥", "⌥↑"). */
|
||||
export const IS_MAC =
|
||||
typeof navigator !== "undefined" && /mac/i.test(navigator.platform || navigator.userAgent);
|
||||
|
||||
const MOD_GLYPH = IS_MAC ? "⌘" : "Ctrl+";
|
||||
const ALT_GLYPH = IS_MAC ? "⌥" : "Alt+";
|
||||
const SHIFT_GLYPH = IS_MAC ? "⇧" : "Shift+";
|
||||
|
||||
const KEY_GLYPH: Record<string, string> = {
|
||||
arrowup: "↑",
|
||||
arrowdown: "↓",
|
||||
arrowleft: "←",
|
||||
arrowright: "→",
|
||||
enter: "↵",
|
||||
tab: "⇥",
|
||||
escape: "Esc",
|
||||
};
|
||||
|
||||
/** Format a binding for display: "mod+shift+f" → "⌘⇧F". */
|
||||
export function formatBinding(binding: string): string {
|
||||
const parts = binding.split("+");
|
||||
const key = parts.pop()!; // last part is always the key
|
||||
const glyphs: string[] = [];
|
||||
for (const part of parts) {
|
||||
if (part === "mod") glyphs.push(MOD_GLYPH);
|
||||
else if (part === "alt") glyphs.push(ALT_GLYPH);
|
||||
else if (part === "shift") glyphs.push(SHIFT_GLYPH);
|
||||
}
|
||||
const keyGlyph = KEY_GLYPH[key] ?? key.toUpperCase();
|
||||
return glyphs.join("") + keyGlyph;
|
||||
}
|
||||
Reference in New Issue
Block a user