From edd126efda78c6c00affd7491b53273b14202b25 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Wed, 3 Jun 2026 02:43:08 -0700 Subject: [PATCH 1/2] feat(settings): add shortcuts page and keybinding focus contexts - Add Shortcuts settings section with searchable, context-filtered keybinding list - Wire shortcuts into settings overlay, sidebar, types, and routing tests - Extend command when-context with composer, panel, and browser focus flags - Tag composer, right panel, and browser surfaces with data attributes for focus detection --- src/renderer/commands/registry.ts | 8 + .../components/layout/UnifiedRightPanel.tsx | 5 +- .../components/thread/ThreadComposer.tsx | 2 +- .../parts/BrowserPanel/BrowserPanel.tsx | 1 + .../SettingsOverlay/SettingsOverlay.test.tsx | 6 +- .../views/SettingsOverlay/SettingsOverlay.tsx | 2 + .../SettingsOverlay/parts/SettingsSidebar.tsx | 14 + .../parts/ShortcutsSettings.tsx | 408 ++++++++++++++++++ .../views/SettingsOverlay/parts/types.ts | 1 + 9 files changed, 444 insertions(+), 3 deletions(-) create mode 100644 src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx diff --git a/src/renderer/commands/registry.ts b/src/renderer/commands/registry.ts index 3390ffca..83bfb47c 100644 --- a/src/renderer/commands/registry.ts +++ b/src/renderer/commands/registry.ts @@ -50,14 +50,22 @@ export function buildWhenContext( const inputFocus = isTextInputElement(element); const editorFocus = Boolean(element?.closest(".monaco-editor")); const terminalFocus = Boolean(element?.closest(".xterm")); + const composerFocus = Boolean( + element?.closest("[data-lightcode-composer], .lightcode-composer-shell"), + ); + const panelFocus = Boolean(element?.closest("[data-lightcode-panel]")); + const browserFocus = Boolean(element?.closest("[data-lightcode-browser]")); return { paletteOpen, inputFocus, editorFocus, + composerFocus, editorOpen: Boolean(fileEditor.activePath || fileEditor.rootContext), terminalFocus, terminalOpen: terminal.isOpen, + panelFocus, + browserFocus, hasProject: Boolean(active.project), hasThread: Boolean(active.thread), view: app.view.kind, diff --git a/src/renderer/components/layout/UnifiedRightPanel.tsx b/src/renderer/components/layout/UnifiedRightPanel.tsx index a5a4f0a8..fbda7343 100644 --- a/src/renderer/components/layout/UnifiedRightPanel.tsx +++ b/src/renderer/components/layout/UnifiedRightPanel.tsx @@ -82,7 +82,10 @@ export function UnifiedRightPanel(props: { const dragCtl = "lightcode-overlay-header__controls"; return ( -
+
{projectName && ( +
({ SearchSettings: () =>
Search
, })); +vi.mock("./parts/ShortcutsSettings", () => ({ + ShortcutsSettings: () =>
Shortcuts
, +})); + vi.mock("./parts/ArchivedThreadsSettings", () => ({ ArchivedThreadsSettings: () =>
Archived
, })); @@ -272,7 +276,7 @@ describe("SettingsOverlay", () => { it("routes split general sections from the sidebar", () => { render( undefined} />); - for (const section of ["Appearance", "Terminal", "Threads", "Git"]) { + for (const section of ["Appearance", "Terminal", "Threads", "Git", "Shortcuts"]) { fireEvent.click(screen.getByRole("button", { name: section })); expect(within(screen.getByRole("main")).getByText(section)).toBeInTheDocument(); } diff --git a/src/renderer/views/SettingsOverlay/SettingsOverlay.tsx b/src/renderer/views/SettingsOverlay/SettingsOverlay.tsx index 57919dab..63e8730f 100644 --- a/src/renderer/views/SettingsOverlay/SettingsOverlay.tsx +++ b/src/renderer/views/SettingsOverlay/SettingsOverlay.tsx @@ -18,6 +18,7 @@ import { AISettings } from "./parts/AISettings"; import { AcpRegistrySettings } from "./parts/AcpRegistrySettings"; import { AgentsGeneralSettings } from "./parts/AgentsGeneralSettings"; import { SearchSettings } from "./parts/SearchSettings"; +import { ShortcutsSettings } from "./parts/ShortcutsSettings"; import { TerminalSettings } from "./parts/TerminalSettings"; import { ThreadSettings } from "./parts/ThreadSettings"; import { ArchivedThreadsSettings } from "./parts/ArchivedThreadsSettings"; @@ -37,6 +38,7 @@ const SECTION_VIEWS: Partial ReactNode>> = { notifications: () => , ai: () => , search: () => , + shortcuts: () => , agents: () => , agentsGeneral: () => , browser: () => , diff --git a/src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx b/src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx index 0967e798..4be613a5 100644 --- a/src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx +++ b/src/renderer/views/SettingsOverlay/parts/SettingsSidebar.tsx @@ -10,6 +10,7 @@ import { GitFork, Globe, Info, + Keyboard, Mic, MessageSquare, PanelLeft, @@ -139,6 +140,13 @@ export function SettingsSidebar(props: { isActive={activeSection === "search"} onPress={() => onSectionChange("search")} /> + } + label="Shortcuts" + isActive={activeSection === "shortcuts"} + onPress={() => onSectionChange("shortcuts")} + /> } @@ -315,6 +323,12 @@ export function SettingsSidebar(props: { isActive={activeSection === "search"} onPress={() => onSectionChange("search")} /> + } + label="Shortcuts" + isActive={activeSection === "shortcuts"} + onPress={() => onSectionChange("shortcuts")} + /> } label="Agents" diff --git a/src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx b/src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx new file mode 100644 index 00000000..f86df7a5 --- /dev/null +++ b/src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx @@ -0,0 +1,408 @@ +import { useEffect, useState } from "react"; +import { Search } from "lucide-react"; +import type { KeybindingEntry } from "@/shared/keybindings"; +import { readBridge } from "@/renderer/bridge"; +import { Input, LightballTabs, type LightballTab } from "@/renderer/components/common"; +import { useKeybindingStore } from "@/renderer/commands/keybindingStore"; +import { bindingForPlatform, formatKeybinding } from "@/renderer/commands/keybindingMatcher"; +import { buildCommandRegistry, type AppCommand } from "@/renderer/commands/registry"; +import type { PlatformName } from "@/renderer/commands/keybindingMatcher"; +import { SettingsPage } from "./SettingsForm"; + +const SHORTCUT_CONTEXTS = [ + { id: "all", label: "All" }, + { id: "global", label: "Global" }, + { id: "composer", label: "Composer" }, + { id: "panel", label: "Panel" }, + { id: "editor", label: "Editor" }, + { id: "terminal", label: "Terminal" }, + { id: "browser", label: "Browser" }, + { id: "project", label: "Project" }, + { id: "thread", label: "Thread" }, +] as const; + +type ShortcutContext = (typeof SHORTCUT_CONTEXTS)[number]["id"]; + +interface ShortcutRow { + id: string; + title: string; + description: string; + group: string; + contexts: ShortcutContext[]; + keys: string[]; + searchText: string; +} + +interface BuiltInShortcut { + id: string; + title: string; + description: string; + group: string; + contexts: ShortcutContext[]; + keys: string[]; +} + +const BUILT_IN_SHORTCUTS: BuiltInShortcut[] = [ + { + id: "composer.send", + title: "Send message", + description: "Composer", + group: "Composer", + contexts: ["composer"], + keys: ["Enter"], + }, + { + id: "composer.new-line", + title: "New line", + description: "Composer", + group: "Composer", + contexts: ["composer"], + keys: ["Shift+Enter"], + }, + { + id: "composer.toggle-work-plan", + title: "Toggle Work or Plan", + description: "Composer controls", + group: "Composer", + contexts: ["composer"], + keys: ["Shift+Tab"], + }, + { + id: "composer.cycle-effort", + title: "Cycle reasoning effort", + description: "Composer controls", + group: "Composer", + contexts: ["composer"], + keys: ["Mod+T"], + }, + { + id: "composer.toggle-fast", + title: "Toggle Fast mode", + description: "Composer controls", + group: "Composer", + contexts: ["composer"], + keys: ["Mod+F"], + }, + { + id: "composer.cycle-permission", + title: "Cycle permission mode", + description: "Composer controls", + group: "Composer", + contexts: ["composer"], + keys: ["Mod+P"], + }, + { + id: "composer.open-model-picker", + title: "Open model picker", + description: "Composer controls", + group: "Composer", + contexts: ["composer"], + keys: ["Mod+M"], + }, + { + id: "terminal.copy", + title: "Copy selection", + description: "Terminal", + group: "Terminal", + contexts: ["terminal"], + keys: ["Mod+C"], + }, + { + id: "terminal.paste", + title: "Paste", + description: "Terminal", + group: "Terminal", + contexts: ["terminal"], + keys: ["Mod+V"], + }, + { + id: "browser.reload", + title: "Reload browser page", + description: "Browser", + group: "Browser", + contexts: ["browser", "panel"], + keys: ["Mod+R", "F5"], + }, + { + id: "browser.hard-reload", + title: "Force reload browser page", + description: "Browser", + group: "Browser", + contexts: ["browser", "panel"], + keys: ["Mod+Shift+R"], + }, + { + id: "editor.close-tab", + title: "Close editor tab", + description: "Editor", + group: "Editor", + contexts: ["editor"], + keys: ["Mod+W"], + }, + { + id: "review.submit-comment", + title: "Submit PR comment", + description: "Review composer", + group: "Git", + contexts: ["panel", "project"], + keys: ["Mod+Enter"], + }, +]; + +export function ShortcutsSettings() { + const [query, setQuery] = useState(""); + const [activeContext, setActiveContext] = useState("all"); + const keybindings = useKeybindingStore((state) => state.keybindings); + const loaded = useKeybindingStore((state) => state.loaded); + const loadKeybindings = useKeybindingStore((state) => state.load); + const platform = readBridge().platform; + + useEffect(() => { + if (loaded) return; + void loadKeybindings().catch((error) => { + console.error("[renderer] failed to load keybindings:", error); + }); + }, [loadKeybindings, loaded]); + + const rows = buildShortcutRows(buildCommandRegistry(), keybindings, platform); + const normalizedQuery = query.trim().toLowerCase(); + const visibleRows = rows.filter((row) => { + if (activeContext !== "all" && !row.contexts.includes(activeContext)) return false; + if (!normalizedQuery) return true; + return row.searchText.includes(normalizedQuery); + }); + const tabs: LightballTab[] = SHORTCUT_CONTEXTS.map((context) => ({ + id: context.id, + label: context.label, + trailing: context.id === "all" ? rows.length : countRowsForContext(rows, context.id), + })); + + return ( + +
+ + setQuery(event.target.value)} + /> +
+ + + +
+
+ Command + Keybinding +
+ {visibleRows.length > 0 ? ( + visibleRows.map((row) => ) + ) : ( +
No shortcuts found
+ )} +
+
+ ); +} + +function ShortcutRowView(props: { row: ShortcutRow }) { + const { row } = props; + const contexts = row.contexts.filter((context) => context !== "all"); + return ( +
+
+
{row.title}
+
+ {row.description} + {contexts.map((context) => ( + + {labelForContext(context)} + + ))} +
+
+
+ {row.keys.length > 0 ? ( + row.keys.map((key) => ( + + {key} + + )) + ) : ( + Unassigned + )} +
+
+ ); +} + +function buildShortcutRows( + commands: AppCommand[], + keybindings: readonly KeybindingEntry[], + platform: PlatformName, +): ShortcutRow[] { + const bindingsByCommand = new Map(); + for (const binding of keybindings) { + const existing = bindingsByCommand.get(binding.command); + if (existing) existing.push(binding); + else bindingsByCommand.set(binding.command, [binding]); + } + + const knownCommandIds = new Set(commands.map((command) => command.id)); + const commandRows = commands.map((command) => { + const bindings = bindingsByCommand.get(command.id) ?? []; + const contexts = contextsForCommand(command, bindings); + const keys = formatBindings(bindings, platform); + return rowWithSearchText({ + id: command.id, + title: command.title, + description: command.subtitle ?? command.group, + group: command.group, + contexts, + keys, + }); + }); + + const customRows = keybindings + .filter((binding) => !knownCommandIds.has(binding.command)) + .map((binding) => + rowWithSearchText({ + id: `custom:${binding.command}:${binding.key ?? binding.mac ?? binding.windows ?? binding.linux ?? ""}`, + title: binding.command, + description: "Custom", + group: "Custom", + contexts: contextsForWhen(binding.when, "Custom", binding.command), + keys: formatBindings([binding], platform), + }), + ); + + const builtInRows = BUILT_IN_SHORTCUTS.map((shortcut) => + rowWithSearchText({ + ...shortcut, + keys: shortcut.keys.map((key) => formatKeybinding(key, platform) || key), + }), + ); + + return [...commandRows, ...customRows, ...builtInRows].sort((a, b) => { + const groupCompare = a.group.localeCompare(b.group); + return groupCompare === 0 ? a.title.localeCompare(b.title) : groupCompare; + }); +} + +function rowWithSearchText(row: Omit): ShortcutRow { + return { + ...row, + searchText: [ + row.title, + row.description, + row.group, + row.keys.join(" "), + row.contexts.map(labelForContext).join(" "), + ] + .join(" ") + .toLowerCase(), + }; +} + +function formatBindings(bindings: readonly KeybindingEntry[], platform: PlatformName): string[] { + const formatted = new Set(); + for (const binding of bindings) { + const raw = bindingForPlatform(binding, platform); + const key = formatKeybinding(raw, platform); + if (key) formatted.add(key); + } + return [...formatted]; +} + +function contextsForCommand( + command: AppCommand, + bindings: readonly KeybindingEntry[], +): ShortcutContext[] { + const when = [command.when, ...bindings.map((binding) => binding.when)] + .filter((item): item is string => Boolean(item)) + .join(" "); + return contextsForWhen(when, command.group, command.id); +} + +function contextsForWhen( + when: string | undefined, + group: string, + commandId: string, +): ShortcutContext[] { + const text = `${when ?? ""} ${group} ${commandId}`.toLowerCase(); + const contexts = new Set(); + + if (!when || text.includes("!inputfocus")) contexts.add("global"); + if (hasPositiveToken(text, "composerfocus") || hasPositiveToken(text, "inputfocus")) { + contexts.add("composer"); + } + if ( + hasPositiveToken(text, "panelfocus") || + commandId === "files.open" || + commandId === "git.open" + ) { + contexts.add("panel"); + } + if ( + hasPositiveToken(text, "editorfocus") || + hasPositiveToken(text, "editoropen") || + group === "Editor" + ) { + contexts.add("editor"); + } + if ( + hasPositiveToken(text, "terminalfocus") || + hasPositiveToken(text, "terminalopen") || + group === "Terminal" + ) { + contexts.add("terminal"); + } + if (hasPositiveToken(text, "browserfocus") || group === "Browser") contexts.add("browser"); + if (hasPositiveToken(text, "hasproject") || group === "Project" || group === "Scripts") { + contexts.add("project"); + } + if ( + hasPositiveToken(text, "threadview") || + hasPositiveToken(text, "hasthread") || + group === "Thread" + ) { + contexts.add("thread"); + } + + return contexts.size > 0 ? [...contexts] : ["global"]; +} + +function hasPositiveToken(text: string, token: string): boolean { + const matcher = new RegExp(`(^|[^a-z0-9_.-])${token}([^a-z0-9_.-]|$)`, "g"); + for (const match of text.matchAll(matcher)) { + const prefixLength = match[1]?.length ?? 0; + const tokenStart = (match.index ?? 0) + prefixLength; + const previous = text.slice(0, tokenStart).trimEnd().at(-1); + if (previous !== "!") return true; + } + return false; +} + +function countRowsForContext(rows: readonly ShortcutRow[], context: ShortcutContext): number { + return rows.filter((row) => row.contexts.includes(context)).length; +} + +function labelForContext(context: ShortcutContext): string { + return SHORTCUT_CONTEXTS.find((item) => item.id === context)?.label ?? context; +} diff --git a/src/renderer/views/SettingsOverlay/parts/types.ts b/src/renderer/views/SettingsOverlay/parts/types.ts index 3454b9f5..525d1620 100644 --- a/src/renderer/views/SettingsOverlay/parts/types.ts +++ b/src/renderer/views/SettingsOverlay/parts/types.ts @@ -10,6 +10,7 @@ export type SettingsSection = | "acpRegistry" | "agentsGeneral" | "search" + | "shortcuts" | "agents" | "browser" | "usage" From dbb2550492f8558f78e0c234a47d344996a5001a Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sat, 6 Jun 2026 08:32:00 -0700 Subject: [PATCH 2/2] fix(keybindings): block global shortcuts in focused panels - Extract shortcut catalog from ShortcutsSettings into shared module - Add panel, browser, and composer guards to pane.close and thread.search.open - Resolve panelFocus for overlay surfaces in when context builder - Cover catalog rows, when clauses, and browser F5 reload in tests --- .../commands/defaultKeybindings.test.ts | 37 +++ src/renderer/commands/registry.ts | 2 +- src/renderer/commands/shortcutCatalog.test.ts | 53 +++ src/renderer/commands/shortcutCatalog.ts | 310 ++++++++++++++++++ .../parts/BrowserPanel/BrowserPanel.test.tsx | 6 + .../parts/ShortcutsSettings.tsx | 308 +---------------- src/shared/keybindings.test.ts | 4 + src/shared/keybindings.ts | 4 +- 8 files changed, 422 insertions(+), 302 deletions(-) create mode 100644 src/renderer/commands/shortcutCatalog.test.ts create mode 100644 src/renderer/commands/shortcutCatalog.ts diff --git a/src/renderer/commands/defaultKeybindings.test.ts b/src/renderer/commands/defaultKeybindings.test.ts index 40cbaedd..1436c6d0 100644 --- a/src/renderer/commands/defaultKeybindings.test.ts +++ b/src/renderer/commands/defaultKeybindings.test.ts @@ -48,6 +48,43 @@ describe("default keybindings", () => { inputFocus: true, }), ).toBe(false); + expect( + evaluateWhenClause(bindings["pane.close"]?.when, { + ...idleThreadContext, + panelFocus: true, + }), + ).toBe(false); + expect( + evaluateWhenClause(bindings["pane.close"]?.when, { + ...idleThreadContext, + browserFocus: true, + }), + ).toBe(false); + expect( + evaluateWhenClause(bindings["pane.close"]?.when, { + ...idleThreadContext, + composerFocus: true, + }), + ).toBe(false); + expect(evaluateWhenClause(bindings["thread.search.open"]?.when, idleThreadContext)).toBe(true); + expect( + evaluateWhenClause(bindings["thread.search.open"]?.when, { + ...idleThreadContext, + panelFocus: true, + }), + ).toBe(false); + expect( + evaluateWhenClause(bindings["thread.search.open"]?.when, { + ...idleThreadContext, + browserFocus: true, + }), + ).toBe(false); + expect( + evaluateWhenClause(bindings["thread.search.open"]?.when, { + ...idleThreadContext, + composerFocus: true, + }), + ).toBe(false); expect(evaluateWhenClause(bindings["editor.save"]?.when, { editorFocus: true })).toBe(true); expect(evaluateWhenClause(bindings["editor.save"]?.when, { editorOpen: true })).toBe(false); }); diff --git a/src/renderer/commands/registry.ts b/src/renderer/commands/registry.ts index 83bfb47c..2617e3b0 100644 --- a/src/renderer/commands/registry.ts +++ b/src/renderer/commands/registry.ts @@ -53,7 +53,7 @@ export function buildWhenContext( const composerFocus = Boolean( element?.closest("[data-lightcode-composer], .lightcode-composer-shell"), ); - const panelFocus = Boolean(element?.closest("[data-lightcode-panel]")); + const panelFocus = Boolean(element?.closest("[data-lightcode-panel], [data-overlay-surface]")); const browserFocus = Boolean(element?.closest("[data-lightcode-browser]")); return { diff --git a/src/renderer/commands/shortcutCatalog.test.ts b/src/renderer/commands/shortcutCatalog.test.ts new file mode 100644 index 00000000..8597e963 --- /dev/null +++ b/src/renderer/commands/shortcutCatalog.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_KEYBINDINGS } from "@/shared/keybindings"; +import { buildCommandRegistry } from "./registry"; +import { buildShortcutRows, SHORTCUT_CONTEXTS, type ShortcutContext } from "./shortcutCatalog"; +import { formatKeybinding, type PlatformName } from "./keybindingMatcher"; + +const PLATFORMS: PlatformName[] = ["darwin", "win32", "linux"]; +const CONTEXTS = SHORTCUT_CONTEXTS.map((context) => context.id).filter( + (context): context is Exclude => context !== "all", +); + +describe("shortcut catalog", () => { + it("lists Shift+F5 for browser hard reload", () => { + for (const platform of PLATFORMS) { + const rows = buildShortcutRows( + buildCommandRegistry(), + DEFAULT_KEYBINDINGS.keybindings, + platform, + ); + const row = rows.find((item) => item.id === "browser.hard-reload"); + + expect(row?.keys).toContain(formatKeybinding("Shift+F5", platform)); + } + }); + + it("does not show conflicting shortcuts inside a context", () => { + const conflicts: string[] = []; + + for (const platform of PLATFORMS) { + const rows = buildShortcutRows( + buildCommandRegistry(), + DEFAULT_KEYBINDINGS.keybindings, + platform, + ); + + for (const context of CONTEXTS) { + const seen = new Map(); + for (const row of rows) { + if (!row.contexts.includes(context)) continue; + for (const key of row.keys) { + const previous = seen.get(key); + if (previous) { + conflicts.push(`${platform} ${context} ${key}: ${previous} vs ${row.id}`); + } + seen.set(key, row.id); + } + } + } + } + + expect(conflicts).toEqual([]); + }); +}); diff --git a/src/renderer/commands/shortcutCatalog.ts b/src/renderer/commands/shortcutCatalog.ts new file mode 100644 index 00000000..0d8a6c35 --- /dev/null +++ b/src/renderer/commands/shortcutCatalog.ts @@ -0,0 +1,310 @@ +import type { KeybindingEntry } from "@/shared/keybindings"; +import { bindingForPlatform, formatKeybinding, type PlatformName } from "./keybindingMatcher"; +import type { AppCommand } from "./registry"; + +export const SHORTCUT_CONTEXTS = [ + { id: "all", label: "All" }, + { id: "global", label: "Global" }, + { id: "composer", label: "Composer" }, + { id: "panel", label: "Panel" }, + { id: "editor", label: "Editor" }, + { id: "terminal", label: "Terminal" }, + { id: "browser", label: "Browser" }, + { id: "project", label: "Project" }, + { id: "thread", label: "Thread" }, +] as const; + +export type ShortcutContext = (typeof SHORTCUT_CONTEXTS)[number]["id"]; + +export interface ShortcutRow { + id: string; + title: string; + description: string; + group: string; + contexts: ShortcutContext[]; + keys: string[]; + searchText: string; +} + +interface LocalShortcut { + id: string; + title: string; + description: string; + group: string; + when?: string; + keys: string[]; +} + +export const LOCAL_SHORTCUTS: readonly LocalShortcut[] = [ + { + id: "composer.send", + title: "Send message", + description: "Composer", + group: "Composer", + when: "composerFocus", + keys: ["Enter"], + }, + { + id: "composer.new-line", + title: "New line", + description: "Composer", + group: "Composer", + when: "composerFocus", + keys: ["Shift+Enter"], + }, + { + id: "composer.toggle-work-plan", + title: "Toggle Work or Plan", + description: "Composer controls", + group: "Composer", + when: "composerFocus", + keys: ["Shift+Tab"], + }, + { + id: "composer.cycle-effort", + title: "Cycle reasoning effort", + description: "Composer controls", + group: "Composer", + when: "composerFocus", + keys: ["Mod+T"], + }, + { + id: "composer.toggle-fast", + title: "Toggle Fast mode", + description: "Composer controls", + group: "Composer", + when: "composerFocus", + keys: ["Mod+F"], + }, + { + id: "composer.cycle-permission", + title: "Cycle permission mode", + description: "Composer controls", + group: "Composer", + when: "composerFocus", + keys: ["Mod+P"], + }, + { + id: "composer.open-model-picker", + title: "Open model picker", + description: "Composer controls", + group: "Composer", + when: "composerFocus", + keys: ["Mod+M"], + }, + { + id: "terminal.copy", + title: "Copy selection", + description: "Terminal", + group: "Terminal", + when: "terminalFocus", + keys: ["Mod+C"], + }, + { + id: "terminal.paste", + title: "Paste", + description: "Terminal", + group: "Terminal", + when: "terminalFocus", + keys: ["Mod+V"], + }, + { + id: "browser.reload", + title: "Reload browser page", + description: "Browser", + group: "Browser", + when: "browserFocus", + keys: ["Mod+R", "F5"], + }, + { + id: "browser.hard-reload", + title: "Force reload browser page", + description: "Browser", + group: "Browser", + when: "browserFocus", + keys: ["Mod+Shift+R", "Shift+F5"], + }, + { + id: "editor.close-tab", + title: "Close editor tab", + description: "Editor", + group: "Editor", + when: "editorFocus", + keys: ["Mod+W"], + }, + { + id: "overlay.close", + title: "Close overlay", + description: "Panels and overlays", + group: "Lightcode", + when: "panelFocus", + keys: ["Escape"], + }, + { + id: "git.submit-form", + title: "Submit Git form", + description: "Commit, PR, and review composers", + group: "Git", + when: "panelFocus", + keys: ["Mod+Enter"], + }, +]; + +export function buildShortcutRows( + commands: AppCommand[], + keybindings: readonly KeybindingEntry[], + platform: PlatformName, +): ShortcutRow[] { + const bindingsByCommand = new Map(); + for (const binding of keybindings) { + const existing = bindingsByCommand.get(binding.command); + if (existing) existing.push(binding); + else bindingsByCommand.set(binding.command, [binding]); + } + + const knownCommandIds = new Set(commands.map((command) => command.id)); + const commandRows = commands.map((command) => { + const bindings = bindingsByCommand.get(command.id) ?? []; + const contexts = contextsForCommand(command, bindings); + const keys = formatBindings(bindings, platform); + return rowWithSearchText({ + id: command.id, + title: command.title, + description: command.subtitle ?? command.group, + group: command.group, + contexts, + keys, + }); + }); + + const customRows = keybindings + .filter((binding) => !knownCommandIds.has(binding.command)) + .map((binding) => + rowWithSearchText({ + id: `custom:${binding.command}:${binding.key ?? binding.mac ?? binding.windows ?? binding.linux ?? ""}`, + title: binding.command, + description: "Custom", + group: "Custom", + contexts: contextsForWhen(binding.when, "Custom", binding.command), + keys: formatBindings([binding], platform), + }), + ); + + const localRows = LOCAL_SHORTCUTS.map((shortcut) => + rowWithSearchText({ + ...shortcut, + contexts: contextsForWhen(shortcut.when, shortcut.group, shortcut.id), + keys: shortcut.keys.map((key) => formatKeybinding(key, platform) || key), + }), + ); + + return [...commandRows, ...customRows, ...localRows].sort((a, b) => { + const groupCompare = a.group.localeCompare(b.group); + return groupCompare === 0 ? a.title.localeCompare(b.title) : groupCompare; + }); +} + +export function countRowsForContext( + rows: readonly ShortcutRow[], + context: ShortcutContext, +): number { + return rows.filter((row) => row.contexts.includes(context)).length; +} + +export function labelForContext(context: ShortcutContext): string { + return SHORTCUT_CONTEXTS.find((item) => item.id === context)?.label ?? context; +} + +function rowWithSearchText(row: Omit): ShortcutRow { + return { + ...row, + searchText: [ + row.title, + row.description, + row.group, + row.keys.join(" "), + row.contexts.map(labelForContext).join(" "), + ] + .join(" ") + .toLowerCase(), + }; +} + +function formatBindings(bindings: readonly KeybindingEntry[], platform: PlatformName): string[] { + const formatted = new Set(); + for (const binding of bindings) { + const raw = bindingForPlatform(binding, platform); + const key = formatKeybinding(raw, platform); + if (key) formatted.add(key); + } + return [...formatted]; +} + +function contextsForCommand( + command: AppCommand, + bindings: readonly KeybindingEntry[], +): ShortcutContext[] { + const when = [command.when, ...bindings.map((binding) => binding.when)] + .filter((item): item is string => Boolean(item)) + .join(" "); + return contextsForWhen(when, command.group, command.id); +} + +function contextsForWhen( + when: string | undefined, + group: string, + commandId: string, +): ShortcutContext[] { + const text = `${when ?? ""} ${group} ${commandId}`.toLowerCase(); + const contexts = new Set(); + + if (!when || text.includes("!inputfocus")) contexts.add("global"); + if (hasPositiveToken(text, "composerfocus") || hasPositiveToken(text, "inputfocus")) { + contexts.add("composer"); + } + if ( + hasPositiveToken(text, "panelfocus") || + commandId === "files.open" || + commandId === "git.open" + ) { + contexts.add("panel"); + } + if ( + hasPositiveToken(text, "editorfocus") || + hasPositiveToken(text, "editoropen") || + group === "Editor" + ) { + contexts.add("editor"); + } + if ( + hasPositiveToken(text, "terminalfocus") || + hasPositiveToken(text, "terminalopen") || + group === "Terminal" + ) { + contexts.add("terminal"); + } + if (hasPositiveToken(text, "browserfocus") || group === "Browser") contexts.add("browser"); + if (hasPositiveToken(text, "hasproject") || group === "Project" || group === "Scripts") { + contexts.add("project"); + } + if ( + hasPositiveToken(text, "threadview") || + hasPositiveToken(text, "hasthread") || + group === "Thread" + ) { + contexts.add("thread"); + } + + return contexts.size > 0 ? [...contexts] : ["global"]; +} + +function hasPositiveToken(text: string, token: string): boolean { + const matcher = new RegExp(`(^|[^a-z0-9_.-])${token}([^a-z0-9_.-]|$)`, "g"); + for (const match of text.matchAll(matcher)) { + const prefixLength = match[1]?.length ?? 0; + const tokenStart = (match.index ?? 0) + prefixLength; + const previous = text.slice(0, tokenStart).trimEnd().at(-1); + if (previous !== "!") return true; + } + return false; +} diff --git a/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.test.tsx b/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.test.tsx index 19e5de6e..0581529c 100644 --- a/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.test.tsx +++ b/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.test.tsx @@ -139,5 +139,11 @@ describe("BrowserPanel", () => { fireEvent.keyDown(panel, { key: "R", ctrlKey: true, shiftKey: true }); expect(bridge.browserHardReload).toHaveBeenCalledWith({ tabId: "tab-1" }); + + fireEvent.keyDown(panel, { key: "F5" }); + expect(bridge.browserReload).toHaveBeenCalledTimes(2); + + fireEvent.keyDown(panel, { key: "F5", shiftKey: true }); + expect(bridge.browserHardReload).toHaveBeenCalledTimes(2); }); }); diff --git a/src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx b/src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx index f86df7a5..a578c59f 100644 --- a/src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx +++ b/src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx @@ -1,154 +1,19 @@ import { useEffect, useState } from "react"; import { Search } from "lucide-react"; -import type { KeybindingEntry } from "@/shared/keybindings"; import { readBridge } from "@/renderer/bridge"; import { Input, LightballTabs, type LightballTab } from "@/renderer/components/common"; import { useKeybindingStore } from "@/renderer/commands/keybindingStore"; -import { bindingForPlatform, formatKeybinding } from "@/renderer/commands/keybindingMatcher"; -import { buildCommandRegistry, type AppCommand } from "@/renderer/commands/registry"; -import type { PlatformName } from "@/renderer/commands/keybindingMatcher"; +import { + buildShortcutRows, + countRowsForContext, + labelForContext, + SHORTCUT_CONTEXTS, + type ShortcutContext, + type ShortcutRow, +} from "@/renderer/commands/shortcutCatalog"; +import { buildCommandRegistry } from "@/renderer/commands/registry"; import { SettingsPage } from "./SettingsForm"; -const SHORTCUT_CONTEXTS = [ - { id: "all", label: "All" }, - { id: "global", label: "Global" }, - { id: "composer", label: "Composer" }, - { id: "panel", label: "Panel" }, - { id: "editor", label: "Editor" }, - { id: "terminal", label: "Terminal" }, - { id: "browser", label: "Browser" }, - { id: "project", label: "Project" }, - { id: "thread", label: "Thread" }, -] as const; - -type ShortcutContext = (typeof SHORTCUT_CONTEXTS)[number]["id"]; - -interface ShortcutRow { - id: string; - title: string; - description: string; - group: string; - contexts: ShortcutContext[]; - keys: string[]; - searchText: string; -} - -interface BuiltInShortcut { - id: string; - title: string; - description: string; - group: string; - contexts: ShortcutContext[]; - keys: string[]; -} - -const BUILT_IN_SHORTCUTS: BuiltInShortcut[] = [ - { - id: "composer.send", - title: "Send message", - description: "Composer", - group: "Composer", - contexts: ["composer"], - keys: ["Enter"], - }, - { - id: "composer.new-line", - title: "New line", - description: "Composer", - group: "Composer", - contexts: ["composer"], - keys: ["Shift+Enter"], - }, - { - id: "composer.toggle-work-plan", - title: "Toggle Work or Plan", - description: "Composer controls", - group: "Composer", - contexts: ["composer"], - keys: ["Shift+Tab"], - }, - { - id: "composer.cycle-effort", - title: "Cycle reasoning effort", - description: "Composer controls", - group: "Composer", - contexts: ["composer"], - keys: ["Mod+T"], - }, - { - id: "composer.toggle-fast", - title: "Toggle Fast mode", - description: "Composer controls", - group: "Composer", - contexts: ["composer"], - keys: ["Mod+F"], - }, - { - id: "composer.cycle-permission", - title: "Cycle permission mode", - description: "Composer controls", - group: "Composer", - contexts: ["composer"], - keys: ["Mod+P"], - }, - { - id: "composer.open-model-picker", - title: "Open model picker", - description: "Composer controls", - group: "Composer", - contexts: ["composer"], - keys: ["Mod+M"], - }, - { - id: "terminal.copy", - title: "Copy selection", - description: "Terminal", - group: "Terminal", - contexts: ["terminal"], - keys: ["Mod+C"], - }, - { - id: "terminal.paste", - title: "Paste", - description: "Terminal", - group: "Terminal", - contexts: ["terminal"], - keys: ["Mod+V"], - }, - { - id: "browser.reload", - title: "Reload browser page", - description: "Browser", - group: "Browser", - contexts: ["browser", "panel"], - keys: ["Mod+R", "F5"], - }, - { - id: "browser.hard-reload", - title: "Force reload browser page", - description: "Browser", - group: "Browser", - contexts: ["browser", "panel"], - keys: ["Mod+Shift+R"], - }, - { - id: "editor.close-tab", - title: "Close editor tab", - description: "Editor", - group: "Editor", - contexts: ["editor"], - keys: ["Mod+W"], - }, - { - id: "review.submit-comment", - title: "Submit PR comment", - description: "Review composer", - group: "Git", - contexts: ["panel", "project"], - keys: ["Mod+Enter"], - }, -]; - export function ShortcutsSettings() { const [query, setQuery] = useState(""); const [activeContext, setActiveContext] = useState("all"); @@ -251,158 +116,3 @@ function ShortcutRowView(props: { row: ShortcutRow }) {
); } - -function buildShortcutRows( - commands: AppCommand[], - keybindings: readonly KeybindingEntry[], - platform: PlatformName, -): ShortcutRow[] { - const bindingsByCommand = new Map(); - for (const binding of keybindings) { - const existing = bindingsByCommand.get(binding.command); - if (existing) existing.push(binding); - else bindingsByCommand.set(binding.command, [binding]); - } - - const knownCommandIds = new Set(commands.map((command) => command.id)); - const commandRows = commands.map((command) => { - const bindings = bindingsByCommand.get(command.id) ?? []; - const contexts = contextsForCommand(command, bindings); - const keys = formatBindings(bindings, platform); - return rowWithSearchText({ - id: command.id, - title: command.title, - description: command.subtitle ?? command.group, - group: command.group, - contexts, - keys, - }); - }); - - const customRows = keybindings - .filter((binding) => !knownCommandIds.has(binding.command)) - .map((binding) => - rowWithSearchText({ - id: `custom:${binding.command}:${binding.key ?? binding.mac ?? binding.windows ?? binding.linux ?? ""}`, - title: binding.command, - description: "Custom", - group: "Custom", - contexts: contextsForWhen(binding.when, "Custom", binding.command), - keys: formatBindings([binding], platform), - }), - ); - - const builtInRows = BUILT_IN_SHORTCUTS.map((shortcut) => - rowWithSearchText({ - ...shortcut, - keys: shortcut.keys.map((key) => formatKeybinding(key, platform) || key), - }), - ); - - return [...commandRows, ...customRows, ...builtInRows].sort((a, b) => { - const groupCompare = a.group.localeCompare(b.group); - return groupCompare === 0 ? a.title.localeCompare(b.title) : groupCompare; - }); -} - -function rowWithSearchText(row: Omit): ShortcutRow { - return { - ...row, - searchText: [ - row.title, - row.description, - row.group, - row.keys.join(" "), - row.contexts.map(labelForContext).join(" "), - ] - .join(" ") - .toLowerCase(), - }; -} - -function formatBindings(bindings: readonly KeybindingEntry[], platform: PlatformName): string[] { - const formatted = new Set(); - for (const binding of bindings) { - const raw = bindingForPlatform(binding, platform); - const key = formatKeybinding(raw, platform); - if (key) formatted.add(key); - } - return [...formatted]; -} - -function contextsForCommand( - command: AppCommand, - bindings: readonly KeybindingEntry[], -): ShortcutContext[] { - const when = [command.when, ...bindings.map((binding) => binding.when)] - .filter((item): item is string => Boolean(item)) - .join(" "); - return contextsForWhen(when, command.group, command.id); -} - -function contextsForWhen( - when: string | undefined, - group: string, - commandId: string, -): ShortcutContext[] { - const text = `${when ?? ""} ${group} ${commandId}`.toLowerCase(); - const contexts = new Set(); - - if (!when || text.includes("!inputfocus")) contexts.add("global"); - if (hasPositiveToken(text, "composerfocus") || hasPositiveToken(text, "inputfocus")) { - contexts.add("composer"); - } - if ( - hasPositiveToken(text, "panelfocus") || - commandId === "files.open" || - commandId === "git.open" - ) { - contexts.add("panel"); - } - if ( - hasPositiveToken(text, "editorfocus") || - hasPositiveToken(text, "editoropen") || - group === "Editor" - ) { - contexts.add("editor"); - } - if ( - hasPositiveToken(text, "terminalfocus") || - hasPositiveToken(text, "terminalopen") || - group === "Terminal" - ) { - contexts.add("terminal"); - } - if (hasPositiveToken(text, "browserfocus") || group === "Browser") contexts.add("browser"); - if (hasPositiveToken(text, "hasproject") || group === "Project" || group === "Scripts") { - contexts.add("project"); - } - if ( - hasPositiveToken(text, "threadview") || - hasPositiveToken(text, "hasthread") || - group === "Thread" - ) { - contexts.add("thread"); - } - - return contexts.size > 0 ? [...contexts] : ["global"]; -} - -function hasPositiveToken(text: string, token: string): boolean { - const matcher = new RegExp(`(^|[^a-z0-9_.-])${token}([^a-z0-9_.-]|$)`, "g"); - for (const match of text.matchAll(matcher)) { - const prefixLength = match[1]?.length ?? 0; - const tokenStart = (match.index ?? 0) + prefixLength; - const previous = text.slice(0, tokenStart).trimEnd().at(-1); - if (previous !== "!") return true; - } - return false; -} - -function countRowsForContext(rows: readonly ShortcutRow[], context: ShortcutContext): number { - return rows.filter((row) => row.contexts.includes(context)).length; -} - -function labelForContext(context: ShortcutContext): string { - return SHORTCUT_CONTEXTS.find((item) => item.id === context)?.label ?? context; -} diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index fddbf657..99d21e42 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -9,7 +9,11 @@ describe("DEFAULT_KEYBINDINGS", () => { expect(byCommand["pane.close"]?.when).toContain("!inputFocus"); expect(byCommand["pane.close"]?.when).toContain("!terminalFocus"); + expect(byCommand["pane.close"]?.when).toContain("!panelFocus"); + expect(byCommand["pane.close"]?.when).toContain("!browserFocus"); + expect(byCommand["pane.close"]?.when).toContain("!composerFocus"); expect(byCommand["editor.save"]?.when).toBe("editorFocus"); expect(byCommand["thread.search.open"]?.when).toContain("!inputFocus"); + expect(byCommand["thread.search.open"]?.when).toContain("!panelFocus"); }); }); diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index 8b5cbd5d..3ac15571 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -34,7 +34,7 @@ export const DEFAULT_KEYBINDINGS: KeybindingsFile = { command: "thread.search.open", key: "Ctrl+P", mac: "Meta+P", - when: "!inputFocus && !editorFocus && !terminalFocus", + when: "!inputFocus && !editorFocus && !terminalFocus && !composerFocus && !panelFocus && !browserFocus", }, { command: "terminal.toggle", @@ -45,7 +45,7 @@ export const DEFAULT_KEYBINDINGS: KeybindingsFile = { command: "pane.close", key: "Ctrl+W", mac: "Meta+W", - when: "threadView && !inputFocus && !editorFocus && !terminalFocus", + when: "threadView && !inputFocus && !editorFocus && !terminalFocus && !composerFocus && !panelFocus && !browserFocus", }, { command: "editor.save",