diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 2eef6ac8416..76285d26cca 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -200,6 +200,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); + assert.equal(defaultsByCommand.get("project.add"), "alt+a"); assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 902b7e87773..98265e53f44 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -5,9 +5,36 @@ import { buildThreadActionItems, enumerateCommandPaletteItems, filterCommandPaletteGroups, + shouldHandleCommandPaletteShortcut, type CommandPaletteGroup, } from "./CommandPalette.logic"; +describe("shouldHandleCommandPaletteShortcut", () => { + it("does not capture the add-project shortcut from an editable target", () => { + expect( + shouldHandleCommandPaletteShortcut({ + command: "project.add", + editableTarget: true, + }), + ).toBe(false); + }); + + it("still handles add-project outside editors and the palette toggle everywhere", () => { + expect( + shouldHandleCommandPaletteShortcut({ + command: "project.add", + editableTarget: false, + }), + ).toBe(true); + expect( + shouldHandleCommandPaletteShortcut({ + command: "commandPalette.toggle", + editableTarget: true, + }), + ).toBe(true); + }); +}); + describe("enumerateCommandPaletteItems", () => { it("assigns positional jump shortcuts to the first nine displayed items", () => { const items = Array.from({ length: 10 }, (_, index) => ({ diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index f69c38e1a0f..5b08337ce7e 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -15,6 +15,16 @@ export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-muted-foreground/80"; export const ADDON_ICON_CLASS = "size-4"; +export function shouldHandleCommandPaletteShortcut(input: { + command: KeybindingCommand | null; + editableTarget: boolean; +}): boolean { + if (input.command === "commandPalette.toggle") { + return true; + } + return input.command === "project.add" && !input.editableTarget; +} + export interface CommandPaletteItem { readonly kind: "action" | "submenu"; readonly value: string; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aa7547c8ba6..fbc9bc8cb5c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -102,6 +102,7 @@ import { getCommandPaletteMode, ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, + shouldHandleCommandPaletteShortcut, } from "./CommandPalette.logic"; import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; @@ -408,16 +409,31 @@ export function CommandPalette({ children }: { children: ReactNode }) { terminalOpen, }, }); - if (command !== "commandPalette.toggle") { + const target = + event.target instanceof Element + ? event.target.closest( + 'input, textarea, select, [contenteditable]:not([contenteditable="false"])', + ) + : null; + if ( + !shouldHandleCommandPaletteShortcut({ + command, + editableTarget: target !== null, + }) + ) { return; } event.preventDefault(); event.stopPropagation(); - toggleOpen(); + if (command === "project.add") { + openAddProject(); + } else { + toggleOpen(); + } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, terminalOpen, toggleOpen]); + }, [keybindings, openAddProject, terminalOpen, toggleOpen]); useEffect( () => @@ -1146,6 +1162,35 @@ function OpenCommandPaletteDialog(props: { ]); const actionItems: Array = []; + const addProjectAction: CommandPaletteActionItem = { + kind: "action", + value: "action:add-project", + searchTerms: [ + "add project", + "folder", + "directory", + "browse", + "clone", + "remote", + "repository", + "repo", + "git", + "github", + "gitlab", + "bitbucket", + "azure", + "devops", + "url", + "environment", + ], + title: "Add project", + icon: , + shortcutCommand: "project.add", + keepOpen: true, + run: async () => { + openAddProjectFlow(); + }, + }; if (projects.length > 0) { const activeProjectTitle = @@ -1182,38 +1227,14 @@ function OpenCommandPaletteDialog(props: { title: "New thread in...", icon: , addonIcon: , - groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], + groups: [ + { value: "projects", label: "Projects", items: projectThreadItems }, + { value: "actions", label: "Actions", items: [addProjectAction] }, + ], }); } - actionItems.push({ - kind: "action", - value: "action:add-project", - searchTerms: [ - "add project", - "folder", - "directory", - "browse", - "clone", - "remote", - "repository", - "repo", - "git", - "github", - "gitlab", - "bitbucket", - "azure", - "devops", - "url", - "environment", - ], - title: "Add project", - icon: , - keepOpen: true, - run: async () => { - openAddProjectFlow(); - }, - }); + actionItems.push(addProjectAction); if (wslAddProjectEnvironmentOption) { actionItems.push({ diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index c0d326edd55..90a797506c6 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -125,6 +125,18 @@ const DEFAULT_BINDINGS = compile([ }, { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" }, { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newLocal" }, + { + shortcut: { + key: "a", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: true, + modKey: false, + }, + command: "project.add", + whenAst: whenNot(whenIdentifier("terminalFocus")), + }, { shortcut: modShortcut("o"), command: "editor.openFavorite" }, { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" }, @@ -327,6 +339,7 @@ describe("shortcutLabelForCommand", () => { shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), "⌘K", ); + assert.strictEqual(shortcutLabelForCommand(DEFAULT_BINDINGS, "project.add", "MacIntel"), "⌥A"); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"), "Ctrl+Shift+M", @@ -514,6 +527,23 @@ describe("chat/editor shortcuts", () => { ); }); + it("matches project.add shortcut outside terminal focus", () => { + assert.strictEqual( + resolveShortcutCommand(event({ key: "å", code: "KeyA", altKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + "project.add", + ); + assert.notStrictEqual( + resolveShortcutCommand(event({ key: "å", code: "KeyA", altKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + "project.add", + ); + }); + it("matches diff.toggle shortcut outside terminal focus", () => { assert.isTrue( isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 33ecd38039f..15ddc6d4a65 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -65,6 +65,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedLocal.command, "chat.newLocal"); + const parsedAddProject = yield* decode(KeybindingRule, { + key: "alt+a", + command: "project.add", + }); + assert.strictEqual(parsedAddProject.command, "project.add"); + const parsedModelPickerToggle = yield* decode(KeybindingRule, { key: "mod+shift+m", command: "modelPicker.toggle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index c7cff9943cd..008317f670c 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -65,6 +65,7 @@ const STATIC_KEYBINDING_COMMANDS = [ "commandPalette.toggle", "chat.new", "chat.newLocal", + "project.add", "editor.openFavorite", ...MODEL_PICKER_KEYBINDING_COMMANDS, ...THREAD_KEYBINDING_COMMANDS, diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index b6bdd7b4783..7d8c2470800 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -38,6 +38,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, + { key: "alt+a", command: "project.add", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" },