Owner live-test rounds on waves A/B: - Alt+arrows no longer ALSO walk the question rows (dock ignores alt/meta/ctrl combos; session cycling keeps them exclusively) - question walk clamps at the ends — no wrap (faster to navigate) - plain Tab toggles focus between the thinking field and the answer rows (walked row by default); focus follows the walk while in the rows; Shift+Tab stays the global thinking-level cycle - Esc in the sessions search blurs back to normal (claims the key so the window Esc chain does not fire) - cheat sheet gains the Tab-in-question display entry
190 lines
8.0 KiB
TypeScript
190 lines
8.0 KiB
TypeScript
/**
|
|
* 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"
|
|
| "find"
|
|
| "question-focus"
|
|
| "question-next-row"
|
|
| "question-prev-row"
|
|
| "question-pick-row"
|
|
| "question-submit";
|
|
|
|
/** 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;
|
|
/** Display-only (cheat sheet) — contextual keys a SURFACE owns (the
|
|
* question dock's row machine); never dispatched from the registry,
|
|
* so they can't leak to pages where the surface isn't mounted. */
|
|
displayOnly?: boolean;
|
|
}
|
|
|
|
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" },
|
|
// ⌘F is surface-scoped (owner key map): the graph focuses node search; a
|
|
// chat-scoped transcript search rides the same event when it lands (the
|
|
// graph's listener simply isn't mounted on chat pages).
|
|
{ id: "find", label: "Find (graph: node search)", scope: "graph", binding: "mod+f" },
|
|
{ id: "cheat-sheet", label: "Shortcut cheat sheet", scope: "global", binding: "?" },
|
|
// The question dock's row machine (wave B) — displayOnly: the dock owns
|
|
// the keys while a question is expanded; the registry never dispatches
|
|
// them, so bare arrows stay free everywhere else.
|
|
{ id: "question-focus", label: "Question · focus thinking ↔ rows", scope: "chat", binding: "tab", displayOnly: true },
|
|
{ id: "question-next-row", label: "Question · next row", scope: "chat", binding: "arrowdown", displayOnly: true },
|
|
{ id: "question-prev-row", label: "Question · previous row", scope: "chat", binding: "arrowup", displayOnly: true },
|
|
{ id: "question-pick-row", label: "Question · pick row", scope: "chat", binding: "space", displayOnly: true },
|
|
{ id: "question-submit", label: "Question · submit", scope: "chat", binding: "enter", displayOnly: true },
|
|
];
|
|
|
|
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) {
|
|
if (def.displayOnly) continue; // surface-owned, never dispatched
|
|
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",
|
|
space: "Space",
|
|
};
|
|
|
|
/** 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;
|
|
}
|