diff --git a/episodes/agents/cursor-428/2026-06-28-keyboard-shortcut-cheat-sheet.md b/episodes/agents/cursor-428/2026-06-28-keyboard-shortcut-cheat-sheet.md new file mode 100644 index 0000000..dd63390 --- /dev/null +++ b/episodes/agents/cursor-428/2026-06-28-keyboard-shortcut-cheat-sheet.md @@ -0,0 +1,24 @@ +--- +memory_kind: episodic +episode_id: cursor-428-2026-06-28 +title: "Issue #428 keyboard shortcut cheat sheet overlay" +tags: [kiwifs, ui, keyboard-shortcuts, issue-428] +date: 2026-06-28 +--- + +## Summary + +Hands-on takeover after fleet agent delivered then reverted the #428 implementation. Rebuilt the searchable shortcut overlay on top of existing `kiwiKeybindings.ts` infrastructure on a clean branch from `origin/main`. + +## Actions + +1. Diagnosed revert commits 77f9bfe/31c04b8 that removed `keybindings.ts` implementation from overlay workspace. +2. Created `feat/issue-428-keyboard-shortcuts` from `origin/main` (writable tree; overlay mnt read-only). +3. Extended `kiwiKeybindings.ts` with display helpers and custom section builder. +4. Replaced static Dialog with searchable `CommandDialog`, added `HelpCircle` toolbar button, bare `?` trigger. +5. Added regression tests; UI suite 194/194 passing. +6. Committed `908a024`, pushed, opened PR. + +## Outcome + +Feature complete per issue #428 acceptance criteria. Fix doc updated at `pages/fixes/kiwifs-kiwifs/issue-428-keyboard-shortcut-cheat-sheet.md`. diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d9cc252..18be6cb 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -34,7 +34,7 @@ import { KiwiKanban } from "./components/KiwiKanban"; import { KiwiRecentStart } from "./components/KiwiRecentStart"; import { KanbanDragProvider } from "./components/kanban/KanbanDragProvider"; import { NewPageDialog } from "./components/NewPageDialog"; -import { KeyboardShortcuts } from "./components/KeyboardShortcuts"; +import { KeyboardShortcuts, KeyboardShortcutsHelpButton } from "./components/KeyboardShortcuts"; import { dispatchPageChanged, getHostConfig, getToolbarBuiltinViews } from "./lib/hostConfig"; import { filterToolbarViewsByFeatures, @@ -47,7 +47,7 @@ import { usePinnedPages } from "./hooks/usePinnedPages"; import { useKeybindings } from "./hooks/useKeybindings"; import { useUIConfig } from "./hooks/useUIConfig"; import { usePreferences } from "./hooks/usePreferences"; -import { formatChordDisplay, matchBoundAction, type KeybindingAction } from "./lib/kiwiKeybindings"; +import { formatChordDisplay, isBareQuestionMark, isTypingTarget, matchBoundAction, type KeybindingAction } from "./lib/kiwiKeybindings"; import { resolveOverlayDismiss } from "./lib/overlayDismiss"; import { hasDeepLinkPath, resolveDashboardPath, resolveStartPage, shouldApplyStartPage } from "./lib/startPage"; import { formatDocumentTitle } from "./lib/pageTitle"; @@ -179,7 +179,7 @@ export default function App() { const { recent, recordVisit } = useRecentPages(currentSpace); const { starred, toggle: toggleStar, isStarred } = useStarredPages(currentSpace); const { pinned, toggle: togglePin, isPinned } = usePinnedPages(currentSpace); - const { bindings, conflicts } = useKeybindings(); + const { bindings, conflicts, sections: shortcutSections } = useKeybindings(); const { config: uiConfig, loaded: uiConfigLoaded } = useUIConfig(); const resolvedStartPage = resolveStartPage(uiConfig.startPage); const editorRef = useRef<{ save: () => Promise; toggleMode?: () => void } | null>(null); @@ -312,6 +312,13 @@ export default function App() { useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.defaultPrevented) return; + + if (!isTypingTarget(e.target) && isBareQuestionMark(e)) { + e.preventDefault(); + setShortcutsOpen(true); + return; + } + const action = matchBoundAction(e, bindings); if (!action) return; @@ -733,6 +740,14 @@ const handleSpaceSwitch = useCallback(() => { }} /> + + + + setShortcutsOpen(true)} /> + + + Keyboard shortcuts (?) + {!themeLocked && ( {theme === "dark" ? : } @@ -963,7 +978,7 @@ const handleSpaceSwitch = useCallback(() => { diff --git a/ui/src/components/KeyboardShortcuts.tsx b/ui/src/components/KeyboardShortcuts.tsx index 56589ed..f6bf091 100644 --- a/ui/src/components/KeyboardShortcuts.tsx +++ b/ui/src/components/KeyboardShortcuts.tsx @@ -1,62 +1,106 @@ -import { Keyboard } from "lucide-react"; +import { HelpCircle } from "lucide-react"; import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@kw/components/ui/dialog"; + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@kw/components/ui/command"; +import { cn } from "@kw/lib/cn"; import { - formatChordDisplay, - SHORTCUT_SECTIONS, - type KeybindingAction, + formatChordParts, + isMacPlatform, + type ShortcutDisplaySection, } from "../lib/kiwiKeybindings"; type Props = { open: boolean; onOpenChange: (open: boolean) => void; - bindings: Record; + sections: ShortcutDisplaySection[]; conflicts?: { chord: string; actions: string[] }[]; }; -export function KeyboardShortcuts({ open, onOpenChange, bindings, conflicts = [] }: Props) { +function KbdCombo({ keys }: { keys: string[] }) { return ( - - - - - - Keyboard shortcuts - - + + {keys.map((key, index) => ( + + {key} + + ))} + + ); +} + +export function KeyboardShortcutsHelpButton({ + onClick, + className, +}: { + onClick: () => void; + className?: string; +}) { + const mac = isMacPlatform(); + const mod = mac ? "⌘" : "Ctrl+"; + return ( + + ); +} + +export function KeyboardShortcuts({ open, onOpenChange, sections, conflicts = [] }: Props) { + return ( + + + {conflicts.length > 0 && ( -
- Conflicting bindings detected:{" "} - {conflicts.map((c) => `${c.actions.join(" / ")} (${formatChordDisplay(c.chord)})`).join("; ")} +
+ Conflicting bindings:{" "} + {conflicts.map((c) => `${c.actions.join(" / ")} (${c.chord})`).join("; ")}
)} -
- {SHORTCUT_SECTIONS.map((s) => ( -
-
- {s.section} -
-
- {s.items.map((item) => ( -
- {item.label} - - {formatChordDisplay(bindings[item.action])} - -
- ))} -
-
- ))} -
- -
+ No shortcuts found. + {sections.map((section) => ( + + {section.items.map((item) => ( + + + {item.label} + {item.custom && ( + + Custom + + )} + + 0 ? item.keys : formatChordParts(item.chord)} /> + + ))} + + ))} + + ); } diff --git a/ui/src/components/ui/command.tsx b/ui/src/components/ui/command.tsx index 9566fe7..ec982cf 100644 --- a/ui/src/components/ui/command.tsx +++ b/ui/src/components/ui/command.tsx @@ -24,6 +24,7 @@ interface CommandDialogProps extends React.ComponentProps { className?: string; contentClassName?: string; commandProps?: React.ComponentPropsWithoutRef; + title?: string; } const CommandDialog = ({ @@ -31,6 +32,7 @@ const CommandDialog = ({ className, contentClassName, commandProps, + title = "Search", ...props }: CommandDialogProps) => ( @@ -38,7 +40,7 @@ const CommandDialog = ({ className={cn("overflow-hidden p-0 sm:max-w-2xl", contentClassName)} showCloseButton={false} > - Search + {title} (null); - useEffect(() => { - api.getKeybindings().then(setConfig).catch(() => setConfig(null)); + const load = useCallback(async () => { + try { + const data = await api.getKeybindings(); + setConfig(data); + } catch { + setConfig(null); + } }, []); + useEffect(() => { + void load(); + return onSpaceChange(() => { + void load(); + }); + }, [load]); + const bindings = useMemo(() => mergeKeybindings(config), [config]); const conflicts = config?.conflicts ?? []; + const defaults = config?.defaults ?? DEFAULT_KEYBINDINGS; + const mac = isMacPlatform(); + const sections = useMemo( + () => buildShortcutDisplaySections(bindings, defaults, mac), + [bindings, defaults, mac], + ); - return { bindings, conflicts, defaults: config?.defaults ?? DEFAULT_KEYBINDINGS }; + return { bindings, conflicts, defaults, sections }; } export type { KeybindingAction }; diff --git a/ui/src/lib/kiwiKeybindings.test.ts b/ui/src/lib/kiwiKeybindings.test.ts index ec8f4c9..f6d47a9 100644 --- a/ui/src/lib/kiwiKeybindings.test.ts +++ b/ui/src/lib/kiwiKeybindings.test.ts @@ -2,8 +2,12 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_KEYBINDINGS, buildChordIndex, + buildShortcutDisplaySections, eventMatchesChord, formatChordDisplay, + formatChordParts, + isBareQuestionMark, + isTypingTarget, matchBoundAction, mergeKeybindings, normalizeChord, @@ -101,3 +105,41 @@ describe("formatChordDisplay", () => { expect(formatChordDisplay("mod+k")).toMatch(/K/i); }); }); + +describe("formatChordParts", () => { + it("returns separate kbd parts for mac and non-mac", () => { + expect(formatChordParts("mod+shift+b", true)).toEqual(["⌘", "⇧", "B"]); + expect(formatChordParts("mod+k", false)).toEqual(["Ctrl", "K"]); + }); +}); + +describe("isBareQuestionMark", () => { + it("detects unmodified question mark", () => { + const bare = { key: "?", metaKey: false, ctrlKey: false, shiftKey: false, altKey: false } as KeyboardEvent; + const shifted = { key: "?", metaKey: false, ctrlKey: false, shiftKey: true, altKey: false } as KeyboardEvent; + expect(isBareQuestionMark(bare)).toBe(true); + expect(isBareQuestionMark(shifted)).toBe(false); + }); +}); + +describe("isTypingTarget", () => { + it("detects inputs and editor surfaces", () => { + const input = { tagName: "INPUT", isContentEditable: false, closest: () => null } as unknown as EventTarget; + expect(isTypingTarget(input)).toBe(true); + }); +}); + +describe("buildShortcutDisplaySections", () => { + it("includes a custom section for overridden bindings", () => { + const bindings = mergeKeybindings({ + bindings: { search: "mod+j" }, + defaults: DEFAULT_KEYBINDINGS, + conflicts: [], + }); + const sections = buildShortcutDisplaySections(bindings, DEFAULT_KEYBINDINGS, false); + expect(sections.some((s) => s.name === "Navigation")).toBe(true); + expect(sections.find((s) => s.name === "Custom")?.items).toEqual([ + expect.objectContaining({ action: "search", custom: true }), + ]); + }); +}); diff --git a/ui/src/lib/kiwiKeybindings.ts b/ui/src/lib/kiwiKeybindings.ts index 6c6bc94..218ca09 100644 --- a/ui/src/lib/kiwiKeybindings.ts +++ b/ui/src/lib/kiwiKeybindings.ts @@ -129,8 +129,39 @@ export function eventMatchesChord(e: KeyboardEvent, chord: string): boolean { return eventKey === parsed.key; } +export function isMacPlatform(): boolean { + if (typeof navigator === "undefined") return false; + const platform = (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform; + if (platform) return /mac/i.test(platform); + return /Mac|iPhone|iPad|iPod/.test(navigator.platform); +} + +export function formatChordParts(chord: string, mac = isMacPlatform()): string[] { + const parsed = parseChord(chord); + const parts: string[] = []; + if (parsed.mod) parts.push(mac ? "⌘" : "Ctrl"); + if (parsed.alt) parts.push(mac ? "⌥" : "Alt"); + if (parsed.shift) parts.push(mac ? "⇧" : "Shift"); + + switch (parsed.key) { + case "escape": + parts.push("Esc"); + break; + case "/": + parts.push("/"); + break; + case "?": + parts.push("?"); + break; + default: + parts.push(parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key); + } + + return parts; +} + export function formatChordDisplay(chord: string): string { - const isMac = typeof navigator !== "undefined" && navigator.platform.includes("Mac"); + const isMac = isMacPlatform(); const parsed = parseChord(chord); const parts: string[] = []; if (parsed.mod) parts.push(isMac ? "⌘" : "Ctrl"); @@ -149,6 +180,26 @@ export function formatChordDisplay(chord: string): string { return `${parts.join("+")}+${keyLabel}`; } +export function isTypingTarget(target: EventTarget | null): boolean { + if (!target || typeof target !== "object" || !("tagName" in target)) return false; + const el = target as HTMLElement; + const tag = el.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; + if (el.isContentEditable) return true; + if (el.closest(".cm-editor, [contenteditable='true'], [role='textbox']")) return true; + return false; +} + +export function isBareQuestionMark(event: KeyboardEvent): boolean { + return ( + event.key === "?" && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey + ); +} + export function mergeKeybindings(config: KeybindingsConfig | null | undefined): Record { const merged = { ...DEFAULT_KEYBINDINGS }; const source = config?.bindings ?? {}; @@ -219,3 +270,60 @@ export function matchBoundAction( } return null; } + +export type ShortcutDisplayItem = { + action: KeybindingAction; + label: string; + chord: string; + keys: string[]; + custom: boolean; +}; + +export type ShortcutDisplaySection = { + name: string; + items: ShortcutDisplayItem[]; +}; + +const ACTION_LABELS: Record = Object.fromEntries( + SHORTCUT_SECTIONS.flatMap((section) => section.items.map((item) => [item.action, item.label])), +) as Record; + +export function buildShortcutDisplaySections( + bindings: Record, + defaults: Partial> = DEFAULT_KEYBINDINGS, + mac = isMacPlatform(), +): ShortcutDisplaySection[] { + const sections: ShortcutDisplaySection[] = SHORTCUT_SECTIONS.map(({ section, items }) => ({ + name: section, + items: items.map(({ action, label }) => ({ + action, + label, + chord: bindings[action], + keys: formatChordParts(bindings[action], mac), + custom: false, + })), + })); + + const customItems = (Object.keys(DEFAULT_KEYBINDINGS) as KeybindingAction[]) + .filter((action) => { + const def = defaults[action] ?? DEFAULT_KEYBINDINGS[action]; + try { + return normalizeChord(bindings[action]) !== normalizeChord(def); + } catch { + return false; + } + }) + .map((action) => ({ + action, + label: ACTION_LABELS[action] ?? action, + chord: bindings[action], + keys: formatChordParts(bindings[action], mac), + custom: true, + })); + + if (customItems.length > 0) { + sections.push({ name: "Custom", items: customItems }); + } + + return sections; +}