Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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`.
23 changes: 19 additions & 4 deletions ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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<void>; toggleMode?: () => void } | null>(null);
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -733,6 +740,14 @@ const handleSpaceSwitch = useCallback(() => {
}}
/>
<HostToolbarActions />
<Tooltip>
<TooltipTrigger asChild>
<span>
<KeyboardShortcutsHelpButton onClick={() => setShortcutsOpen(true)} />
</span>
</TooltipTrigger>
<TooltipContent>Keyboard shortcuts (?)</TooltipContent>
</Tooltip>
{!themeLocked && (
<ToolbarButton onClick={toggleTheme} label={theme === "dark" ? "Light mode" : "Dark mode"}>
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
Expand Down Expand Up @@ -963,7 +978,7 @@ const handleSpaceSwitch = useCallback(() => {
<KeyboardShortcuts
open={shortcutsOpen}
onOpenChange={setShortcutsOpen}
bindings={bindings}
sections={shortcutSections}
conflicts={conflicts}
/>
</TooltipProvider>
Expand Down
136 changes: 90 additions & 46 deletions ui/src/components/KeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
@@ -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<KeybindingAction, string>;
sections: ShortcutDisplaySection[];
conflicts?: { chord: string; actions: string[] }[];
};

export function KeyboardShortcuts({ open, onOpenChange, bindings, conflicts = [] }: Props) {
function KbdCombo({ keys }: { keys: string[] }) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Keyboard className="h-4 w-4" />
Keyboard shortcuts
</DialogTitle>
</DialogHeader>
<span className="ml-auto flex items-center gap-1">
{keys.map((key, index) => (
<kbd
key={`${key}-${index}`}
className="px-2 py-0.5 rounded border border-border bg-muted font-mono text-xs text-muted-foreground"
>
{key}
</kbd>
))}
</span>
);
}

export function KeyboardShortcutsHelpButton({
onClick,
className,
}: {
onClick: () => void;
className?: string;
}) {
const mac = isMacPlatform();
const mod = mac ? "⌘" : "Ctrl+";
return (
<button
type="button"
onClick={onClick}
aria-label="Keyboard shortcuts"
title={`Keyboard shortcuts (${mod}/ or ?)`}
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors",
className,
)}
>
<HelpCircle className="h-4 w-4" />
</button>
);
}

export function KeyboardShortcuts({ open, onOpenChange, sections, conflicts = [] }: Props) {
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title="Keyboard shortcuts"
contentClassName="sm:max-w-lg"
commandProps={{ shouldFilter: true }}
>
<CommandInput placeholder="Search shortcuts…" />
<CommandList>
{conflicts.length > 0 && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
Conflicting bindings detected:{" "}
{conflicts.map((c) => `${c.actions.join(" / ")} (${formatChordDisplay(c.chord)})`).join("; ")}
<div className="mx-2 mt-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
Conflicting bindings:{" "}
{conflicts.map((c) => `${c.actions.join(" / ")} (${c.chord})`).join("; ")}
</div>
)}
<div className="space-y-4">
{SHORTCUT_SECTIONS.map((s) => (
<div key={s.section}>
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-2">
{s.section}
</div>
<div className="space-y-1.5">
{s.items.map((item) => (
<div
key={item.action}
className="flex items-center justify-between text-sm"
>
<span>{item.label}</span>
<kbd className="px-2 py-0.5 rounded border border-border bg-muted font-mono text-xs text-muted-foreground">
{formatChordDisplay(bindings[item.action])}
</kbd>
</div>
))}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
<CommandEmpty>No shortcuts found.</CommandEmpty>
{sections.map((section) => (
<CommandGroup key={section.name} heading={section.name}>
{section.items.map((item) => (
<CommandItem
key={`${section.name}-${item.action}`}
value={`${section.name} ${item.label} ${item.keys.join(" ")}`}
className="flex items-center justify-between gap-3"
>
<span className="flex items-center gap-2">
{item.label}
{item.custom && (
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
Custom
</span>
)}
</span>
<KbdCombo keys={item.keys.length > 0 ? item.keys : formatChordParts(item.chord)} />
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
);
}
4 changes: 3 additions & 1 deletion ui/src/components/ui/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,23 @@ interface CommandDialogProps extends React.ComponentProps<typeof Dialog> {
className?: string;
contentClassName?: string;
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
title?: string;
}

const CommandDialog = ({
children,
className,
contentClassName,
commandProps,
title = "Search",
...props
}: CommandDialogProps) => (
<Dialog {...props}>
<DialogContent
className={cn("overflow-hidden p-0 sm:max-w-2xl", contentClassName)}
showCloseButton={false}
>
<DialogTitle className="sr-only">Search</DialogTitle>
<DialogTitle className="sr-only">{title}</DialogTitle>
<Command
{...commandProps}
className={cn(
Expand Down
30 changes: 25 additions & 5 deletions ui/src/hooks/useKeybindings.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import { api } from "../lib/api";
import { useCallback, useEffect, useMemo, useState } from "react";
import { api, onSpaceChange } from "../lib/api";
import {
buildShortcutDisplaySections,
DEFAULT_KEYBINDINGS,
isMacPlatform,
mergeKeybindings,
type KeybindingAction,
type KeybindingsConfig,
Expand All @@ -10,14 +12,32 @@ import {
export function useKeybindings() {
const [config, setConfig] = useState<KeybindingsConfig | null>(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 };
42 changes: 42 additions & 0 deletions ui/src/lib/kiwiKeybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ import { describe, expect, it } from "vitest";
import {
DEFAULT_KEYBINDINGS,
buildChordIndex,
buildShortcutDisplaySections,
eventMatchesChord,
formatChordDisplay,
formatChordParts,
isBareQuestionMark,
isTypingTarget,
matchBoundAction,
mergeKeybindings,
normalizeChord,
Expand Down Expand Up @@ -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 }),
]);
});
});
Loading
Loading