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>
163 lines
5.8 KiB
TypeScript
163 lines
5.8 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Keyboard shortcuts (shortcut wave A, 2026-09-17) — the single document-level
|
|
* keydown listener. Owns the pieces that belong to no surface in particular:
|
|
*
|
|
* - Tab is DEAD: browser tab-focus navigation is abandoned by owner decision
|
|
* (2026-09-17). Tab is suppressed everywhere (preventDefault, no-op) and
|
|
* Shift+Tab cycles the thinking level instead.
|
|
* - Esc is a layered dismiss: topmost popover first (popovers stopPropagation
|
|
* on their own Esc — usePopoverDismiss + ModelControl), then dictation
|
|
* stop, then abort of an in-flight turn.
|
|
* - Global actions dispatch lh:kbd-<action> window events; surfaces that are
|
|
* mounted listen (the lh:chat-renamed pattern). A surface that isn't
|
|
* mounted simply doesn't listen — an F2 on the graph view is a no-op, not
|
|
* an error. Actions live in src/core/keyboard.ts (registry + persisted
|
|
* overrides), the ONE table the settings milestone will render as its
|
|
* editor.
|
|
*
|
|
* Listeners attach at the WINDOW level (bubble's last stop) so every
|
|
* component-level handler — popovers' own Esc, the composer's Esc — gets the
|
|
* event first and can claim it with stopPropagation.
|
|
*/
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import {
|
|
actionForBinding,
|
|
bindingForAction,
|
|
eventBinding,
|
|
formatBinding,
|
|
isTypingSurface,
|
|
KEYBOARD_ACTIONS,
|
|
} from "@/src/core";
|
|
import { isDictationActive } from "@/components/dictation";
|
|
|
|
export function KeyboardShortcuts() {
|
|
const router = useRouter();
|
|
const [cheatOpen, setCheatOpen] = useState(false);
|
|
|
|
// Router + cheat state inside a ref so the keydown listener (attached
|
|
// once) never goes stale.
|
|
const handlersRef = useRef<{
|
|
router: ReturnType<typeof useRouter>;
|
|
setCheatOpen: (v: boolean) => void;
|
|
}>({ router, setCheatOpen });
|
|
handlersRef.current = { router, setCheatOpen };
|
|
|
|
useEffect(() => {
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
// ── Tab is dead (owner, 2026-09-17): preventDefault even when
|
|
// nothing is bound to it — browser tab-focus is abandoned, and
|
|
// the default action runs after dispatch, so suppressing it here
|
|
// works from any focus.
|
|
if (e.key === "Tab") {
|
|
e.preventDefault();
|
|
if (e.shiftKey) window.dispatchEvent(new CustomEvent("lh:kbd-cycle-thinking"));
|
|
return;
|
|
}
|
|
|
|
if (e.key === "Escape") {
|
|
// Our own overlay is the topmost layer.
|
|
if (cheatOpen) {
|
|
setCheatOpen(false);
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
// Popovers stopPropagation on their own Esc, so reaching here
|
|
// means none was open. Dictation first, then abort the turn
|
|
// (a server-side turn has no client request to abort — the
|
|
// abort seam in Chat decides what's honestly stoppable).
|
|
if (isDictationActive()) {
|
|
e.preventDefault();
|
|
window.dispatchEvent(new CustomEvent("lh:kbd-stop-dictation"));
|
|
return;
|
|
}
|
|
window.dispatchEvent(new CustomEvent("lh:kbd-abort-turn"));
|
|
return;
|
|
}
|
|
|
|
// ── Registry dispatch.
|
|
const action = actionForBinding(eventBinding(e));
|
|
if (!action) return;
|
|
|
|
// The input guard: inside a typing surface only mod/alt combos
|
|
// fire (they never type a character). Bare keys — "?" included —
|
|
// pass through so the field keeps them. Tab already returned above.
|
|
if (isTypingSurface(e) && !(e.metaKey || e.ctrlKey || e.altKey)) return;
|
|
|
|
// Dispatch: global commands we own run here; surface commands
|
|
// ride the window event for the mounted surface to catch.
|
|
switch (action) {
|
|
case "new-session":
|
|
handlersRef.current.router.push("/chat/new");
|
|
break;
|
|
case "jump-graph":
|
|
handlersRef.current.router.push("/");
|
|
break;
|
|
case "cheat-sheet":
|
|
handlersRef.current.setCheatOpen(true);
|
|
break;
|
|
default:
|
|
window.dispatchEvent(new CustomEvent(`lh:kbd-${action}`));
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKeyDown);
|
|
return () => window.removeEventListener("keydown", onKeyDown);
|
|
}, [cheatOpen]);
|
|
|
|
return <CheatSheet open={cheatOpen} onClose={() => setCheatOpen(false)} />;
|
|
}
|
|
|
|
/** The ? overlay — the registry rendered as a table (grouped by scope).
|
|
* Until the settings milestone ships an editor, this is how shortcuts
|
|
* stay discoverable. */
|
|
function CheatSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
if (!open) return null;
|
|
const scopes = ["global", "chat", "graph"] as const;
|
|
const scopeLabels: Record<(typeof scopes)[number], string> = {
|
|
global: "Global",
|
|
chat: "Chat",
|
|
graph: "Graph view",
|
|
};
|
|
return (
|
|
<div
|
|
data-testid="cheat-sheet"
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="max-h-[80vh] w-full max-w-[36rem] overflow-y-auto rounded-xl border bg-card p-5 shadow-2xl"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="mb-4 flex items-baseline justify-between">
|
|
<span className="text-sm font-medium text-card-foreground">Keyboard shortcuts</span>
|
|
<span className="text-xs text-muted-foreground">Esc to close</span>
|
|
</div>
|
|
{scopes.map((scope) => {
|
|
const actions = KEYBOARD_ACTIONS.filter((a) => a.scope === scope);
|
|
if (actions.length === 0) return null;
|
|
return (
|
|
<div key={scope} className="mb-4">
|
|
<div className="mb-1.5 text-xs uppercase tracking-widest text-muted-foreground/70">
|
|
{scopeLabels[scope]}
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
{actions.map((a) => (
|
|
<div key={a.id} className="flex items-center justify-between gap-4 text-sm">
|
|
<span className="text-muted-foreground">{a.label}</span>
|
|
<kbd className="rounded-md border border-border bg-background px-1.5 py-0.5 font-mono text-xs text-card-foreground">
|
|
{formatBinding(bindingForAction(a.id))}
|
|
</kbd>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|