From 682edde84402eeef9d4edfad2c1dd4ae491a36f3 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Thu, 17 Sep 2026 20:47:23 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20keyboard=20shortcuts=20wave=20A=20?= =?UTF-8?q?=E2=80=94=20registry,=20provider,=20global=20bindings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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- 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 --- app/globals.css | 13 +++ components/chat.tsx | 29 +++++- components/composer.tsx | 3 + components/dictation.tsx | 24 +++++ components/home.tsx | 5 +- components/keyboard-shortcuts.tsx | 162 +++++++++++++++++++++++++++++ components/model-picker.tsx | 31 +++++- components/popover-dismiss.ts | 8 +- components/question-dock.tsx | 10 +- components/sidebar.tsx | 96 ++++++++++++++++- src/core/index.ts | 2 + src/core/keyboard.ts | 165 ++++++++++++++++++++++++++++++ 12 files changed, 538 insertions(+), 10 deletions(-) create mode 100644 components/keyboard-shortcuts.tsx create mode 100644 src/core/keyboard.ts diff --git a/app/globals.css b/app/globals.css index f9594c6..55b740c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -338,3 +338,16 @@ body { .lh-electron .lh-closed-toggle { margin-left: 92px; } + +/* ───────────────────────────────────────────────────────────────────────── + Keyboard focus ring (shortcut wave A, 2026-09-17). Browser Tab-focus + navigation is ABANDONED by owner decision (Tab is suppressed at the + window level — see components/keyboard-shortcuts.tsx; Shift+Tab cycles + the thinking level instead), so this ring should almost never appear. + It exists as belt-and-braces: if focus ever lands somewhere via script, + it glows in the app's own accent, not Chromium's default blue. + ───────────────────────────────────────────────────────────────────────── */ +:focus-visible { + outline: 1.5px solid color-mix(in srgb, var(--lh-ink-strong, #c96b4a) 75%, transparent); + outline-offset: 1px; +} diff --git a/components/chat.tsx b/components/chat.tsx index 921b45f..76084e6 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -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} diff --git a/components/composer.tsx b/components/composer.tsx index ed21548..e2cb291 100644 --- a/components/composer.tsx +++ b/components/composer.tsx @@ -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; } diff --git a/components/dictation.tsx b/components/dictation.tsx index fb4d999..755ce81 100644 --- a/components/dictation.tsx +++ b/components/dictation.tsx @@ -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 }; } diff --git a/components/home.tsx b/components/home.tsx index 9005c75..fa82895 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -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; } diff --git a/components/keyboard-shortcuts.tsx b/components/keyboard-shortcuts.tsx new file mode 100644 index 0000000..ea64165 --- /dev/null +++ b/components/keyboard-shortcuts.tsx @@ -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- 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; + 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 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 ( +
+
e.stopPropagation()} + > +
+ Keyboard shortcuts + Esc to close +
+ {scopes.map((scope) => { + const actions = KEYBOARD_ACTIONS.filter((a) => a.scope === scope); + if (actions.length === 0) return null; + return ( +
+
+ {scopeLabels[scope]} +
+
+ {actions.map((a) => ( +
+ {a.label} + + {formatBinding(bindingForAction(a.id))} + +
+ ))} +
+
+ ); + })} +
+
+ ); +} diff --git a/components/model-picker.tsx b/components/model-picker.tsx index 841b198..42625e9 100644 --- a/components/model-picker.tsx +++ b/components/model-picker.tsx @@ -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) })) diff --git a/components/popover-dismiss.ts b/components/popover-dismiss.ts index 105a5aa..fff58e3 100644 --- a/components/popover-dismiss.ts +++ b/components/popover-dismiss.ts @@ -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); diff --git a/components/question-dock.tsx b/components/question-dock.tsx index e179470..3fdb2b6 100644 --- a/components/question-dock.tsx +++ b/components/question-dock.tsx @@ -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; } diff --git a/components/sidebar.tsx b/components/sidebar.tsx index a77a246..e3913e3 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -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 ( {/* 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 (