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}
+3
View File
@@ -53,8 +53,11 @@ export function Composer({
onKeyDown={(e) => {
// Esc ends dictation before anything else Esc might someday mean
// here (the dictate decisions: click-again or Esc to stop).
// stopPropagation claims the Esc (keyboard wave A): the
// window-level chain must not also abort a turn under us.
if (e.key === "Escape" && onEscape) {
e.preventDefault();
e.stopPropagation();
onEscape();
return;
}
+24
View File
@@ -98,6 +98,16 @@ export function rmsToMeter(rms: number): number {
*/
let questionTarget: ((text: string) => void) | null = null;
/* Is a dictation run live on this page? The Esc chain (keyboard wave A,
* components/keyboard-shortcuts.tsx) needs a SYNCHRONOUS answer — the
* stop handler can't report back through a CustomEvent. Set at start(),
* cleared in cleanup() (every terminal path runs through cleanup). */
let dictationActiveFlag = false;
export function isDictationActive(): boolean {
return dictationActiveFlag;
}
/** Join a dictated final onto the current text (shared separator rule). */
export function joinDictation(current: string, final: string): string {
return current ? current.replace(/\s+$/, "") + " " + final : final;
@@ -172,6 +182,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
/** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */
const cleanup = useCallback(() => {
genRef.current++;
dictationActiveFlag = false;
flush(); // the deferred transcript appears when the run ends (owner round 2)
if (safetyRef.current) {
clearTimeout(safetyRef.current);
@@ -260,6 +271,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
);
const start = useCallback(async () => {
dictationActiveFlag = true;
setError(null);
setPhase("connecting");
reconnectsRef.current = 0;
@@ -352,6 +364,18 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
// "stopping" presses are ignored — the flush is in flight.
}, [phase, start, stop]);
// Shortcut seams (keyboard wave A): ⌘D toggles from anywhere on the
// page; the Esc chain's stop lands here too. Each page owns ONE hook
// instance, and only the mounted page's listener is attached.
useEffect(() => {
window.addEventListener("lh:kbd-toggle-dictation", toggle);
return () => window.removeEventListener("lh:kbd-toggle-dictation", toggle);
}, [toggle]);
useEffect(() => {
window.addEventListener("lh:kbd-stop-dictation", stop);
return () => window.removeEventListener("lh:kbd-stop-dictation", stop);
}, [stop]);
return { phase, error, levelRef, active: phase !== "idle", toggle, stop };
}
+4 -1
View File
@@ -103,9 +103,12 @@ export function Home() {
value={topic}
onChange={(e) => setTopic(e.target.value)}
onKeyDown={(e) => {
// Esc ends dictation (the dictate decisions: click-again or Esc).
// Esc ends dictation (the dictate decisions: click-again or
// Esc) — stopPropagation claims it (keyboard wave A) so
// the window chain doesn't also abort a turn.
if (e.key === "Escape" && dictation.active) {
e.preventDefault();
e.stopPropagation();
dictation.stop();
return;
}
+162
View File
@@ -0,0 +1,162 @@
"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>
);
}
+30 -1
View File
@@ -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) }))
+7 -1
View File
@@ -20,7 +20,13 @@ export function usePopoverDismiss(
if (rootRef.current && !rootRef.current.contains(e.target as Node)) close();
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
if (e.key === "Escape") {
// Claim the Esc (keyboard wave A): the window-level dismiss
// chain must not ALSO stop dictation / abort a turn under
// this popover. Popovers are the chain's topmost layer.
e.stopPropagation();
close();
}
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
+8 -2
View File
@@ -443,9 +443,12 @@ function QuestionDockForm({
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
// Esc ends dictation (the dictate decisions: click-again or Esc).
// Esc ends dictation (the dictate decisions: click-again
// or Esc) — stopPropagation claims it (keyboard wave
// A) so the window chain doesn't also abort a turn.
if (e.key === "Escape" && onEscape) {
e.preventDefault();
e.stopPropagation();
onEscape();
return;
}
@@ -465,9 +468,12 @@ function QuestionDockForm({
value={thinking}
onChange={(e) => setThinking(e.target.value)}
onKeyDown={(e) => {
// Esc ends dictation (the dictate decisions: click-again or Esc).
// Esc ends dictation (the dictate decisions: click-again
// or Esc) — stopPropagation claims it (keyboard wave
// A) so the window chain doesn't also abort a turn.
if (e.key === "Escape" && onEscape) {
e.preventDefault();
e.stopPropagation();
onEscape();
return;
}
+92 -4
View File
@@ -1,12 +1,13 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon } from "lucide-react";
import { ActivitySection } from "@/components/activity-dock";
import { LhMark } from "@/components/lh-mark";
import { BrailleSpinner } from "@/components/braille-spinner";
import { KeyboardShortcuts } from "@/components/keyboard-shortcuts";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
@@ -111,6 +112,14 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) {
});
}, [narrow, open]);
// ⌘B (keyboard wave A): the global shortcut dispatches lh:toggle-sidebar;
// the context is the one place that owns open state, so it listens here.
useEffect(() => {
const onToggle = () => toggle();
window.addEventListener("lh:toggle-sidebar", onToggle);
return () => window.removeEventListener("lh:toggle-sidebar", onToggle);
}, [toggle]);
return (
<SidebarContext.Provider value={{ open: open && !narrow, toggle }}>
{/* transitions gated on mount: no slide-in flash on page load */}
@@ -216,18 +225,24 @@ export function SessionRow({
chat,
active,
onOpen,
kbdSelected = false,
}: {
chat: ChatListItem;
active: boolean;
onOpen: () => void;
/** Keyboard-cycle highlight (shortcut wave A: Alt+↑/↓ + Alt+Enter) —
* visually distinct from the active session, which stays filled. */
kbdSelected?: boolean;
}) {
return (
<button
type="button"
onClick={onOpen}
data-testid={kbdSelected ? "session-row-kbd" : undefined}
className={cn(
"relative mb-0.5 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition-colors hover:bg-accent",
active && "bg-accent",
kbdSelected && !active && "bg-accent/60 ring-1 ring-border",
)}
>
<SessionStatus busy={chat.busy} />
@@ -289,6 +304,70 @@ function SessionsSidebar() {
return () => window.removeEventListener("lh:chat-renamed", onRenamed);
}, []);
/* ── Keyboard seams (shortcut wave A, 2026-09-17) ── The sidebar owns
every session-list interaction, so the shortcuts land here: ⌘⇧F
focuses the search field; Alt+↑/↓ cycle the list (opening the sidebar
if it's closed — the keys work from anywhere, like the toggle does);
Alt+Enter opens the selected session. The selection is a row index
into `shown`, so a typed query narrows the cycle to the matches.
Mouse and keyboard coexist: hovering doesn't claim, a click or an
open clears the selection. */
const [kbdIndex, setKbdIndex] = useState<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onFocusSearch = () => {
if (!open) toggle();
// A clipped (overflow-hidden) input still focuses — the sidebar's
// slide-in doesn't gate the keystroke.
requestAnimationFrame(() => searchRef.current?.focus());
};
window.addEventListener("lh:kbd-sessions-search", onFocusSearch);
return () => window.removeEventListener("lh:kbd-sessions-search", onFocusSearch);
}, [open, toggle]);
useEffect(() => {
const cycle = (dir: 1 | -1) => {
setKbdIndex((i) => {
const n = shown?.length ?? 0;
if (n === 0) return null;
if (i === null) return dir === 1 ? 0 : n - 1;
return (i + dir + n) % n;
});
};
const onPrev = () => {
if (!open) toggle();
cycle(-1);
};
const onNext = () => {
if (!open) toggle();
cycle(1);
};
const onOpenSelected = () => {
setKbdIndex((i) => {
const target = i !== null ? shown?.[i] : undefined;
if (target) router.push(`/chat/${target.id}`);
return target ? null : i;
});
};
window.addEventListener("lh:kbd-cycle-session-prev", onPrev);
window.addEventListener("lh:kbd-cycle-session-next", onNext);
window.addEventListener("lh:kbd-open-session", onOpenSelected);
return () => {
window.removeEventListener("lh:kbd-cycle-session-prev", onPrev);
window.removeEventListener("lh:kbd-cycle-session-next", onNext);
window.removeEventListener("lh:kbd-open-session", onOpenSelected);
};
}, [open, toggle, shown, router]);
// Keep the kbd-selected row in view (the rows are the container's only
// buttons, so the index matches).
useEffect(() => {
if (kbdIndex === null) return;
listRef.current?.querySelectorAll("button")[kbdIndex]?.scrollIntoView({ block: "nearest" });
}, [kbdIndex]);
useEffect(() => {
if (!open) return;
void refresh();
@@ -351,6 +430,7 @@ function SessionsSidebar() {
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">Sessions</span>
<span className="relative flex-1">
<Input
ref={searchRef}
data-testid="session-search"
value={query}
onChange={(e) => setQuery(e.target.value)}
@@ -362,7 +442,7 @@ function SessionsSidebar() {
</span>
</div>
<div className="flex-1 overflow-y-auto p-2">
<div ref={listRef} className="flex-1 overflow-y-auto p-2">
{shown === null ? (
<div className="px-2 py-4 text-xs text-muted-foreground">Loading…</div>
) : shown.length === 0 ? (
@@ -370,12 +450,16 @@ function SessionsSidebar() {
{query.trim() ? "No sessions match." : "No sessions yet."}
</div>
) : (
shown.map((c) => (
shown.map((c, i) => (
<SessionRow
key={c.id}
chat={c}
active={pathname === `/chat/${c.id}`}
onOpen={() => router.push(`/chat/${c.id}`)}
kbdSelected={i === kbdIndex}
onOpen={() => {
setKbdIndex(null);
router.push(`/chat/${c.id}`);
}}
/>
))
)}
@@ -398,6 +482,10 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<>
<SessionsSidebar />
<main className="min-w-0 flex-1">{children}</main>
{/* The single document-level keydown listener (keyboard wave A) —
after the content in the tree, but it's window-level so order only
matters for the events it dispatches, never for DOM priority. */}
<KeyboardShortcuts />
</>
);
}