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 3390ffca..2617e3b0 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], [data-overlay-surface]")); + 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/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/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 && ( +
{ 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/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx b/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx index 46622462..f8da426e 100644 --- a/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx +++ b/src/renderer/views/MainView/parts/RightPanel/parts/BrowserPanel/BrowserPanel.tsx @@ -91,6 +91,7 @@ export function BrowserPanel(props: { visible: boolean }) { }; return (
({ SearchSettings: () =>
Search
, })); +vi.mock("./parts/ShortcutsSettings", () => ({ + ShortcutsSettings: () =>
Shortcuts
, +})); + vi.mock("./parts/UsageSettings", () => ({ UsageSettings: () =>
Usage
, })); @@ -276,7 +280,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 cb5c05de..03d16612 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..a578c59f --- /dev/null +++ b/src/renderer/views/SettingsOverlay/parts/ShortcutsSettings.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from "react"; +import { Search } from "lucide-react"; +import { readBridge } from "@/renderer/bridge"; +import { Input, LightballTabs, type LightballTab } from "@/renderer/components/common"; +import { useKeybindingStore } from "@/renderer/commands/keybindingStore"; +import { + buildShortcutRows, + countRowsForContext, + labelForContext, + SHORTCUT_CONTEXTS, + type ShortcutContext, + type ShortcutRow, +} from "@/renderer/commands/shortcutCatalog"; +import { buildCommandRegistry } from "@/renderer/commands/registry"; +import { SettingsPage } from "./SettingsForm"; + +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 + )} +
+
+ ); +} 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" 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",