diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 1795808bc179..08084a94df28 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -45,7 +45,7 @@ import { writeFileStringAtomically } from "./atomicWrite.ts"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { DEFAULT_KEYBINDINGS, - DEFAULT_RESOLVED_KEYBINDINGS, + mergeWithDefaultKeybindings, compileResolvedKeybindingRule, compileResolvedKeybindingsConfig, parseKeybindingShortcut, @@ -201,25 +201,6 @@ function invalidEntryIssue(index: number, detail: string): ServerConfigIssue { }; } -function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { - if (custom.length === 0) { - return [...DEFAULT_RESOLVED_KEYBINDINGS]; - } - - const overriddenCommands = new Set(custom.map((binding) => binding.command)); - const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( - (binding) => !overriddenCommands.has(binding.command), - ); - const merged = [...retainedDefaults, ...custom]; - - if (merged.length <= MAX_KEYBINDINGS_COUNT) { - return merged; - } - - // Keep the latest rules when the config exceeds max size; later rules have higher precedence. - return merged.slice(-MAX_KEYBINDINGS_COUNT); -} - /** * Keybindings - Service tag for keybinding configuration operations. */ diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 3e3f834658ea..7b7ffe626bc8 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -8,7 +8,17 @@ import { HistoryIcon, ScaleIcon, } from "lucide-react"; -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + type Ref, + memo, + useImperativeHandle, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; @@ -25,7 +35,10 @@ import { resolvePreviousWorktreeSeed, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; -import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; +import { + BranchToolbarBranchSelector, + type BranchToolbarBranchSelectorHandle, +} from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; import { Button } from "./ui/button"; @@ -46,7 +59,13 @@ import { measureRestingComposerControls } from "./chat/restingComposerControlsMe import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; import { cn } from "~/lib/utils"; +export interface BranchToolbarHandle { + openBranchPicker: () => void; + usePreviousWorktree: () => void; +} + interface BranchToolbarProps { + ref?: Ref; environmentId: EnvironmentId; threadId: ThreadId; showGitControls: boolean; @@ -169,6 +188,10 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ render={ + } /> (current === entry.instanceId ? null : current)) } disabled={isDisabled} + focusableWhenDisabled={!isDisabled} + aria-pressed={isSelected} type="button" aria-label={ isUnavailable || isContextDisabled @@ -203,7 +221,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { ) : null} - + ); const trigger = isDisabled ? ( @@ -234,6 +252,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { })} - + ); }); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 47ce59efde52..0a863e0bd446 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -618,6 +618,7 @@ export const TraitsPicker = memo(function TraitsPicker({ ShortcutMatchContext; onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined; /** * The thread this panel sits beside, if any. Links that are not the pull @@ -707,6 +722,32 @@ export function PullRequestDetailPanel({ }, [activity, coreDetail], ); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const { copyToClipboard: copyReference } = useCopyToClipboard({ + target: "pull request reference", + onCopy: (label) => toastManager.add({ type: "success", title: `${label} copied` }), + onError: (error, label) => + toastManager.add({ + type: "error", + title: `Failed to copy ${label}`, + description: error.message, + }), + }); + const copyFromShortcut = useEffectEvent((event: KeyboardEvent) => { + if (!shortcutsEnabled || event.defaultPrevented || isCommandPaletteOpen()) return; + const command = resolveShortcutCommand(event, keybindings, { + context: getShortcutContext(), + }); + if (command !== "pullRequest.copyNumber") return; + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) copyReference(`#${reference.number}`, "PR number"); + }); + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => copyFromShortcut(event); + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, []); useEffect(() => { if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod); }, [detail?.autoMergeMethod, pullRequestKey]); @@ -2080,9 +2121,19 @@ export function PullRequestDetailPanel({ {openOnHostLabel(detail.provider)} - void writeTextToClipboard(detail.url)}> + copyReference(detail.url, "PR link")}> Copy link + + {shortcutLabelForCommand(keybindings, "thread.copyReference")} + + + copyReference(`#${reference.number}`, "PR number")}> + + Copy PR number + + {shortcutLabelForCommand(keybindings, "pullRequest.copyNumber")} + {detail.state === "open" && can("close") ? ( <> diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 41e3cc2ec304..d55b047e6638 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -17,6 +17,32 @@ import { } from "./KeybindingsSettings.logic"; describe("KeybindingsSettings.logic", () => { + it("lists composer, provider, and pull request commands with editable defaults", () => { + const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, ""); + for (const command of [ + "composer.host", + "composer.effort", + "composer.mode", + "composer.workspace", + "composer.branch", + "composer.previousWorktree", + "modelPicker.previousProvider", + "modelPicker.nextProvider", + "thread.copyReference", + "pullRequest.copyNumber", + ]) { + expect(rows.find((row) => row.command === command)).toMatchObject({ + source: "Default", + conflicts: [], + }); + } + }); + it("finds the existing URL shortcut in Settings", () => { + const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, "PR URL"); + expect(rows).toEqual([ + expect.objectContaining({ command: "thread.copyReference", key: "mod+shift+c" }), + ]); + }); it("builds searchable rows with readable key and when values", () => { const rows = buildKeybindingRows( [ diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 65589d1bcd9f..7119d9ee4e5a 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -209,6 +209,7 @@ export function buildKeybindingRows( return rowsWithConflicts.filter((row) => { return ( row.command.toLowerCase().includes(normalizedQuery) || + commandLabel(row.command).toLowerCase().includes(normalizedQuery) || row.key.toLowerCase().includes(normalizedQuery) || row.when.toLowerCase().includes(normalizedQuery) || row.source.toLowerCase().includes(normalizedQuery) @@ -275,6 +276,7 @@ export function buildKeybindingCommandOptions( } export function commandLabel(command: KeybindingCommand): string { + if (command === "thread.copyReference") return "Thread: Copy PR URL or Thread ID"; const raw = String(command); if (raw.startsWith("script.") && raw.endsWith(".run")) { return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`; diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 85e32b1bf850..d02f15939c5e 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -26,7 +26,7 @@ import { type ServerRemoveKeybindingInput, type ServerUpsertKeybindingInput, } from "@t3tools/contracts"; -import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -1334,7 +1334,11 @@ export function KeybindingsSettingsPanel() { // fan out to every connected environment in the selection, so one // shortcut change reaches each machine the user runs T3 Code on. const { environment: primaryEnvironment, connectedEnvironments } = useSettingsScope(); - const keybindings = primaryEnvironment?.serverConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; + const serverKeybindings = primaryEnvironment?.serverConfig?.keybindings; + const keybindings = useMemo( + () => mergeWithDefaultKeybindings(serverKeybindings ?? []), + [serverKeybindings], + ); const keybindingsConfigPath = primaryEnvironment?.serverConfig?.keybindingsConfigPath ?? null; const availableEditors = primaryEnvironment?.serverConfig?.availableEditors ?? []; const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 598e08e59770..1e9e8adc7baf 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1,4 +1,9 @@ import { assert, describe, it } from "vite-plus/test"; +import { + compileResolvedKeybindingsConfig, + DEFAULT_RESOLVED_KEYBINDINGS, + mergeWithDefaultKeybindings, +} from "@t3tools/shared/keybindings"; import { type KeybindingCommand, @@ -1057,3 +1062,194 @@ describe("plus key parsing", () => { ); }); }); + +describe("composer and pull request shortcuts", () => { + it("fills missing number shortcuts without replacing the saved URL binding", () => { + const olderServerBindings = DEFAULT_RESOLVED_KEYBINDINGS.filter( + (binding) => + binding.command !== "pullRequest.copyNumber" && binding.command !== "thread.copyReference", + ); + const bindings = mergeWithDefaultKeybindings([ + ...olderServerBindings, + ...compileResolvedKeybindingsConfig([ + { key: "mod+shift+8", command: "thread.copyReference", when: "!terminalFocus" }, + ]), + ]); + for (const [key, command] of [ + ["k", "pullRequest.copyNumber"], + ["8", "thread.copyReference"], + ["c", null], + ["y", null], + ] as const) { + assert.strictEqual( + resolveShortcutCommand(event({ key, metaKey: true, shiftKey: true }), bindings, { + platform: "MacIntel", + }), + command, + ); + } + }); + + it.each(["terminalOpen", "previewFocus", "previewOpen", "modelPickerOpen"])( + "honors custom PR shortcut conditions for %s", + (condition) => { + const bindings = compileResolvedKeybindingsConfig([ + { key: "mod+shift+k", command: "thread.copyReference", when: condition }, + { key: "mod+shift+k", command: "pullRequest.copyNumber", when: `!${condition}` }, + ]); + const input = event({ key: "k", ctrlKey: true, shiftKey: true }); + for (const enabled of [false, true]) { + assert.strictEqual( + resolveShortcutCommand(input, bindings, { + platform: "Linux", + context: { [condition]: enabled }, + }), + enabled ? "thread.copyReference" : "pullRequest.copyNumber", + ); + } + }, + ); + + const shortcuts = [ + ["h", "composer.host"], + ["e", "composer.effort"], + ["a", "composer.mode"], + ["x", "composer.workspace"], + ["g", "composer.branch"], + ["l", "composer.previousWorktree"], + ["c", "thread.copyReference"], + ["k", "pullRequest.copyNumber"], + ] as const; + + for (const platform of ["MacIntel", "Win32", "Linux"]) { + it.each(shortcuts)( + `resolves %s on ${platform} and leaves terminal input alone`, + (key, command) => { + const input = event({ + key, + shiftKey: true, + metaKey: platform === "MacIntel", + ctrlKey: platform !== "MacIntel", + }); + assert.strictEqual( + resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { + platform, + context: { terminalFocus: false }, + }), + command, + ); + assert.isNull( + resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { + platform, + context: { terminalFocus: true }, + }), + ); + }, + ); + } + + for (const platform of ["MacIntel", "Win32", "Linux"]) { + it.each([ + ["s", "thread.settle"], + ["p", "thread.pin"], + ])(`preserves the existing %s shortcut on ${platform}`, (key, command) => { + assert.strictEqual( + resolveShortcutCommand( + event({ + key, + shiftKey: true, + metaKey: platform === "MacIntel", + ctrlKey: platform !== "MacIntel", + }), + DEFAULT_RESOLVED_KEYBINDINGS, + { platform }, + ), + command, + ); + }); + } + + const altEffortBindings = compileResolvedKeybindingsConfig([ + { key: "mod+alt+e", command: "composer.effort", when: "!terminalFocus" }, + ]); + + it("leaves AltGr text entry alone with a custom Alt binding", () => { + for (const platform of ["Win32", "Linux"]) { + const input = event({ + key: "€", + code: "KeyE", + ctrlKey: true, + altKey: true, + getModifierState: (key) => key === "AltGraph", + }); + assert.isNull(resolveShortcutCommand(input, altEffortBindings, { platform })); + assert.strictEqual( + resolveShortcutCommand({ ...input, getModifierState: () => false }, altEffortBindings, { + platform, + }), + "composer.effort", + ); + } + }); + + it("keeps Firefox modifier reporting usable on Windows and macOS", () => { + const getModifierState = (key: string) => key === "AltGraph"; + assert.strictEqual( + resolveShortcutCommand( + event({ key: "e", ctrlKey: true, altKey: true, getModifierState }), + altEffortBindings, + { platform: "Win32" }, + ), + "composer.effort", + ); + assert.strictEqual( + resolveShortcutCommand( + event({ key: "´", code: "KeyE", metaKey: true, altKey: true, getModifierState }), + altEffortBindings, + { platform: "MacIntel" }, + ), + "composer.effort", + ); + }); + + it.each(shortcuts)("uses a custom binding for %s", (_key, command) => { + const bindings = compileResolvedKeybindingsConfig([ + { key: "mod+shift+y", command, when: "!terminalFocus" }, + ]); + assert.strictEqual( + resolveShortcutCommand( + event({ key: "Y", code: "KeyY", ctrlKey: true, shiftKey: true }), + bindings, + { platform: "Linux" }, + ), + command, + ); + }); + + for (const platform of ["MacIntel", "Win32", "Linux"]) { + it.each([ + ["ArrowUp", "modelPicker.previousProvider"], + ["ArrowDown", "modelPicker.nextProvider"], + ] as const)(`limits %s to the model picker on ${platform}`, (key, command) => { + const input = event({ + key, + shiftKey: true, + metaKey: platform === "MacIntel", + ctrlKey: platform !== "MacIntel", + }); + assert.strictEqual( + resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { + platform, + context: { modelPickerOpen: true }, + }), + command, + ); + assert.isNull( + resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { + platform, + context: { modelPickerOpen: false }, + }), + ); + }); + } +}); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index b9a5b728289e..8683ad3c68a5 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -11,6 +11,7 @@ import { import { isMacPlatform } from "./lib/utils"; export interface ShortcutEventLike { + getModifierState?: (key: "AltGraph") => boolean; type?: string; code?: string; key: string; @@ -123,6 +124,12 @@ function matchesShortcut( shortcut: KeybindingShortcut, platform = navigator.platform, ): boolean { + if ( + !isMacPlatform(platform) && + event.getModifierState?.("AltGraph") && + !/^[a-z0-9]$/i.test(event.key) + ) + return false; if (!matchesShortcutModifiers(event, shortcut, platform)) return false; return resolveEventKeys(event).has(shortcut.key); } diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 973c406f6900..642740f30323 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -151,6 +151,16 @@ import { cn } from "~/lib/utils"; import { primaryServerKeybindingsAtom } from "~/state/server"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; +function getShortcutContext() { + return { + terminalFocus: isTerminalFocused(), + terminalOpen: false, + previewFocus: false, + previewOpen: false, + modelPickerOpen: false, + }; +} + export interface PullRequestsSearch extends PullRequestListPreferences { /** * Narrows the list to one server. Absent means every connected one, which is the default the @@ -1925,7 +1935,7 @@ function PullRequestsRouteView() { const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || isCommandPaletteOpen()) return; const command = resolveShortcutCommand(event, keybindings, { - context: { terminalFocus: isTerminalFocused() }, + context: getShortcutContext(), }); if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event); if (command === "rightPanel.toggle") toggleRightPanelFromShortcut(event); @@ -1992,6 +2002,8 @@ function PullRequestsRouteView() { pullRequestStatusSeeds={listedPullRequestTabStatuses} > { diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 11fd2e7dc0f9..f4161dfb454b 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -10,7 +10,7 @@ import { } from "@t3tools/contracts"; import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; -import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -90,9 +90,8 @@ export const primaryServerProvidersAtom = Atom.make( get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS, ).pipe(Atom.withLabel("web-primary-server-providers")); -export const primaryServerKeybindingsAtom = Atom.make( - (get): ServerConfig["keybindings"] => - get(primaryServerConfigAtom)?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS, +export const primaryServerKeybindingsAtom = Atom.make((get): ServerConfig["keybindings"] => + mergeWithDefaultKeybindings(get(primaryServerConfigAtom)?.keybindings ?? []), ).pipe(Atom.withLabel("web-primary-server-keybindings")); export const primaryServerAvailableEditorsAtom = Atom.make( diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 4e46e97a3a56..556525910531 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -3,6 +3,31 @@ Customize shortcuts in **Settings → Keybindings** on web and desktop. That page also lists the command IDs and defaults available in your version. +## Composer controls + +Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host. +Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the +workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the +current checkout, a new worktree, and the previous worktree when available. +Use `mod+shift+l` to reuse the previous worktree directly. + +In the model picker, press Left in an empty search field or Shift+Tab to reach +the provider list. Use Up/Down to move and Enter to choose. Right returns to +model search. `mod+shift+up` and `mod+shift+down` switch providers directly and clear the +search. These provider shortcuts can also be changed in Settings. + +These shortcuts run inside the focused web or desktop client. `mod` uses Command +on macOS and Ctrl on Windows and Linux, including GNOME, KDE Plasma, Niri, and +Hyprland. If a custom desktop shortcut takes the same keys, choose another binding +in Settings. + +## Copy pull request references + +With a PR open in the right panel or on the Pull Requests page, use `mod+shift+c` +to copy its URL and `mod+shift+k` to copy its number with a `#` prefix. +Both shortcuts can be changed in Settings. Search for “Copy PR URL or Thread ID” +or “Copy Number”. They copy the selected PR and leave terminal input alone. + ## Edit the configuration file Keybindings live on the environment's machine, in diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 27e096cbb2eb..14579c741198 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -122,6 +122,13 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedThreadCopyReference.command, "thread.copyReference"); + const parsedPullRequestCopyNumber = yield* decode(KeybindingRule, { + key: "mod+shift+k", + command: "pullRequest.copyNumber", + when: "!terminalFocus", + }); + assert.strictEqual(parsedPullRequestCopyNumber.command, "pullRequest.copyNumber"); + const parsedThreadStop = yield* decode(KeybindingRule, { key: "mod+escape", command: "thread.stop", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 2be8aa91f7aa..a285d2fcf4ac 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -47,6 +47,8 @@ export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number const MODEL_PICKER_KEYBINDING_COMMANDS = [ "modelPicker.toggle", + "modelPicker.previousProvider", + "modelPicker.nextProvider", ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, ] as const; export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number]; @@ -61,6 +63,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "rightPanel.toggle", "rightPanel.toggleMaximized", "rightPanel.close", + "pullRequest.copyNumber", "diff.toggle", "preview.toggle", "preview.refresh", @@ -73,6 +76,12 @@ export const STATIC_KEYBINDING_COMMANDS = [ "projectSearch.toggle", "themeEditor.toggle", "composer.stash", + "composer.host", + "composer.effort", + "composer.mode", + "composer.workspace", + "composer.previousWorktree", + "composer.branch", "chat.new", "chat.newLocal", "editor.openFavorite", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 1107873c8285..8d73c07f34ab 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -44,6 +44,15 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, + { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" }, + { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" }, + { key: "mod+shift+a", command: "composer.mode", when: "!terminalFocus" }, + { key: "mod+shift+x", command: "composer.workspace", when: "!terminalFocus" }, + { key: "mod+shift+g", command: "composer.branch", when: "!terminalFocus" }, + { key: "mod+shift+l", command: "composer.previousWorktree", when: "!terminalFocus" }, + { key: "mod+shift+k", command: "pullRequest.copyNumber", when: "!terminalFocus" }, + { key: "mod+shift+arrowup", command: "modelPicker.previousProvider", when: "modelPickerOpen" }, + { key: "mod+shift+arrowdown", command: "modelPicker.nextProvider", when: "modelPickerOpen" }, { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, @@ -299,3 +308,23 @@ export function compileResolvedKeybindingsConfig( } export const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); + +export function mergeWithDefaultKeybindings( + custom: ResolvedKeybindingsConfig, +): ResolvedKeybindingsConfig { + if (custom.length === 0) { + return [...DEFAULT_RESOLVED_KEYBINDINGS]; + } + + const overriddenCommands = new Set(custom.map((binding) => binding.command)); + const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( + (binding) => !overriddenCommands.has(binding.command), + ); + const merged = [...retainedDefaults, ...custom]; + + if (merged.length <= MAX_KEYBINDINGS_COUNT) { + return merged; + } + + return merged.slice(-MAX_KEYBINDINGS_COUNT); +}