- Conflicting bindings detected:{" "}
- {conflicts.map((c) => `${c.actions.join(" / ")} (${formatChordDisplay(c.chord)})`).join("; ")}
-
- )}
-
- {SHORTCUT_SECTIONS.map((s) => (
-
-
- {s.section}
-
-
- {s.items.map((item) => (
-
- {item.label}
-
- {formatChordDisplay(bindings[item.action])}
-
-
- ))}
-
-
- ))}
+
+
+
+ Keyboard shortcuts
+
+ {conflicts.length > 0 && (
+
+ Conflicting bindings detected:{" "}
+ {conflicts.map((c) => `${c.actions.join(" / ")} (${formatChordDisplay(c.chord)})`).join("; ")}
-
-
+ )}
+
+
+ No shortcuts found.
+ {SHORTCUT_SECTIONS.map((section) => (
+
+ {section.items.map((item) => (
+ {}}
+ >
+ {item.label}
+
+
+ ))}
+
+ ))}
+
+
);
}
diff --git a/ui/src/lib/kiwiKeybindings.test.ts b/ui/src/lib/kiwiKeybindings.test.ts
index ec8f4c94..378d88ee 100644
--- a/ui/src/lib/kiwiKeybindings.test.ts
+++ b/ui/src/lib/kiwiKeybindings.test.ts
@@ -4,9 +4,14 @@ import {
buildChordIndex,
eventMatchesChord,
formatChordDisplay,
+ isMacPlatform,
+ isShortcutBlockedTarget,
+ isStandaloneHelpKey,
+ isTypingTarget,
matchBoundAction,
mergeKeybindings,
normalizeChord,
+ shouldDispatchKeybinding,
} from "./kiwiKeybindings";
describe("normalizeChord", () => {
@@ -101,3 +106,69 @@ describe("formatChordDisplay", () => {
expect(formatChordDisplay("mod+k")).toMatch(/K/i);
});
});
+
+describe("isTypingTarget", () => {
+ it("detects input elements", () => {
+ const input = { tagName: "INPUT", isContentEditable: false } as HTMLElement;
+ const button = { tagName: "BUTTON", isContentEditable: false } as HTMLElement;
+ expect(isTypingTarget(input)).toBe(true);
+ expect(isTypingTarget(button)).toBe(false);
+ });
+});
+
+describe("isShortcutBlockedTarget", () => {
+ it("blocks CodeMirror and ProseMirror editor surfaces", () => {
+ const cmChild = {
+ tagName: "DIV",
+ isContentEditable: false,
+ closest: (sel: string) => (sel.includes("cm-editor") ? {} : null),
+ } as unknown as HTMLElement;
+ const proseChild = {
+ tagName: "P",
+ isContentEditable: false,
+ closest: (sel: string) => (sel.includes("ProseMirror") ? {} : null),
+ } as unknown as HTMLElement;
+ expect(isShortcutBlockedTarget(cmChild)).toBe(true);
+ expect(isShortcutBlockedTarget(proseChild)).toBe(true);
+ });
+});
+
+describe("isStandaloneHelpKey", () => {
+ it("matches bare question mark without modifiers", () => {
+ const help = {
+ key: "?",
+ metaKey: false,
+ ctrlKey: false,
+ altKey: false,
+ shiftKey: true,
+ } as KeyboardEvent;
+ const modHelp = { ...help, metaKey: true } as KeyboardEvent;
+ expect(isStandaloneHelpKey(help)).toBe(true);
+ expect(isStandaloneHelpKey(modHelp)).toBe(false);
+ });
+});
+
+describe("shouldDispatchKeybinding", () => {
+ it("allows help and close overlay while typing", () => {
+ const input = { tagName: "INPUT", isContentEditable: false } as HTMLElement;
+ expect(shouldDispatchKeybinding("shortcuts_help", input, { editing: true, splitEditing: false })).toBe(true);
+ expect(shouldDispatchKeybinding("close_overlay", input, { editing: true, splitEditing: false })).toBe(true);
+ });
+
+ it("blocks navigation shortcuts in inputs and while editing", () => {
+ const input = { tagName: "INPUT", isContentEditable: false } as HTMLElement;
+ const body = { tagName: "BODY", isContentEditable: false, closest: () => null } as unknown as HTMLElement;
+ expect(shouldDispatchKeybinding("search", input, { editing: false, splitEditing: false })).toBe(false);
+ expect(shouldDispatchKeybinding("search", body, { editing: true, splitEditing: false })).toBe(false);
+ expect(shouldDispatchKeybinding("search", body, { editing: false, splitEditing: false })).toBe(true);
+ });
+});
+
+describe("isMacPlatform", () => {
+ it("detects macOS from platform string", () => {
+ const original = navigator.platform;
+ Object.defineProperty(navigator, "platform", { value: "MacIntel", configurable: true });
+ expect(isMacPlatform()).toBe(true);
+ Object.defineProperty(navigator, "platform", { value: original, configurable: true });
+ });
+});
diff --git a/ui/src/lib/kiwiKeybindings.ts b/ui/src/lib/kiwiKeybindings.ts
index 6c6bc94a..d116311e 100644
--- a/ui/src/lib/kiwiKeybindings.ts
+++ b/ui/src/lib/kiwiKeybindings.ts
@@ -129,8 +129,15 @@ export function eventMatchesChord(e: KeyboardEvent, chord: string): boolean {
return eventKey === parsed.key;
}
+export function isMacPlatform(): boolean {
+ if (typeof navigator === "undefined") return false;
+ const ua = navigator as Navigator & { userAgentData?: { platform?: string } };
+ const platform = ua.userAgentData?.platform ?? navigator.platform;
+ return /mac/i.test(platform);
+}
+
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");
@@ -168,6 +175,46 @@ export type ShortcutSection = {
items: { action: KeybindingAction; label: string }[];
};
+export function isTypingTarget(target: EventTarget | null): boolean {
+ if (!target || typeof target !== "object") return false;
+ const el = target as HTMLElement;
+ if (el.isContentEditable) return true;
+ const tag = el.tagName ?? "";
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
+}
+
+const SHORTCUT_BLOCKED_SELECTOR = ".cm-editor, .ProseMirror, [cmdk-input-wrapper]";
+
+export function isShortcutBlockedTarget(target: EventTarget | null): boolean {
+ if (isTypingTarget(target)) return true;
+ const el = target as HTMLElement | null;
+ if (el && typeof el.closest === "function" && el.closest(SHORTCUT_BLOCKED_SELECTOR)) return true;
+ return false;
+}
+
+/** Bare `?` (no modifiers) opens the cheat sheet when not typing in an editor/input. */
+export function isStandaloneHelpKey(e: KeyboardEvent): boolean {
+ if (e.metaKey || e.ctrlKey || e.altKey) return false;
+ const key = e.key.length === 1 ? e.key.toLowerCase() : e.key.toLowerCase();
+ return key === "?";
+}
+
+export function shouldDispatchKeybinding(
+ action: KeybindingAction,
+ target: EventTarget | null,
+ ctx: { editing: boolean; splitEditing: boolean },
+): boolean {
+ if (action === "close_overlay" || action === "shortcuts_help") return true;
+ if (action === "save" || action === "toggle_mode") return true;
+ if (isShortcutBlockedTarget(target)) return false;
+ if (ctx.editing || ctx.splitEditing) return false;
+ return true;
+}
+
+export function shortcutSearchValue(section: string, label: string, chord: string): string {
+ return `${label} ${section} ${formatChordDisplay(chord)} ${chord}`;
+}
+
export const SHORTCUT_SECTIONS: ShortcutSection[] = [
{
section: "Navigation",