| author | |
| committer | |
| log | 9130d932f4ddb8abab595b7d12553b46a11c7b22 |
| tree | 9aeccfa9a844542051bfe2a2a5d9181b0e55d028 |
| parent | 33118d9ab263904e4180b3bf2cbf0a36fe0695e2 |
| signature | Signed by PGP key B5690EEEBB952194 |
24 files changed, 694 insertions(+), 49 deletions(-)
apps/server/src/keybindings.ts+1-20| ... | ... | @@ -45,7 +45,7 @@ import { writeFileStringAtomically } from "./atomicWrite.ts"; |
| 45 | 45 | import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; |
| 46 | 46 | import { |
| 47 | 47 | DEFAULT_KEYBINDINGS, |
| 48 | DEFAULT_RESOLVED_KEYBINDINGS, | |
| 48 | mergeWithDefaultKeybindings, | |
| 49 | 49 | compileResolvedKeybindingRule, |
| 50 | 50 | compileResolvedKeybindingsConfig, |
| 51 | 51 | parseKeybindingShortcut, |
| ... | ... | @@ -201,25 +201,6 @@ function invalidEntryIssue(index: number, detail: string): ServerConfigIssue { |
| 201 | 201 | }; |
| 202 | 202 | } |
| 203 | 203 | |
| 204 | function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { | |
| 205 | if (custom.length === 0) { | |
| 206 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 207 | } | |
| 208 | ||
| 209 | const overriddenCommands = new Set(custom.map((binding) => binding.command)); | |
| 210 | const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( | |
| 211 | (binding) => !overriddenCommands.has(binding.command), | |
| 212 | ); | |
| 213 | const merged = [...retainedDefaults, ...custom]; | |
| 214 | ||
| 215 | if (merged.length <= MAX_KEYBINDINGS_COUNT) { | |
| 216 | return merged; | |
| 217 | } | |
| 218 | ||
| 219 | // Keep the latest rules when the config exceeds max size; later rules have higher precedence. | |
| 220 | return merged.slice(-MAX_KEYBINDINGS_COUNT); | |
| 221 | } | |
| 222 | ||
| 223 | 204 | /** |
| 224 | 205 | * Keybindings - Service tag for keybinding configuration operations. |
| 225 | 206 | */ |
apps/web/src/components/BranchToolbar.tsx+47-2| ... | ... | @@ -8,7 +8,17 @@ import { |
| 8 | 8 | HistoryIcon, |
| 9 | 9 | ScaleIcon, |
| 10 | 10 | } from "lucide-react"; |
| 11 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 11 | import { | |
| 12 | type Ref, | |
| 13 | memo, | |
| 14 | useImperativeHandle, | |
| 15 | useCallback, | |
| 16 | useEffect, | |
| 17 | useLayoutEffect, | |
| 18 | useMemo, | |
| 19 | useRef, | |
| 20 | useState, | |
| 21 | } from "react"; | |
| 12 | 22 | |
| 13 | 23 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; |
| 14 | 24 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| ... | ... | @@ -25,7 +35,10 @@ import { |
| 25 | 35 | resolvePreviousWorktreeSeed, |
| 26 | 36 | shouldShowEnvironmentIndicator, |
| 27 | 37 | } from "./BranchToolbar.logic"; |
| 28 | import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; | |
| 38 | import { | |
| 39 | BranchToolbarBranchSelector, | |
| 40 | type BranchToolbarBranchSelectorHandle, | |
| 41 | } from "./BranchToolbarBranchSelector"; | |
| 29 | 42 | import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; |
| 30 | 43 | import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; |
| 31 | 44 | import { Button } from "./ui/button"; |
| ... | ... | @@ -46,7 +59,13 @@ import { measureRestingComposerControls } from "./chat/restingComposerControlsMe |
| 46 | 59 | import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; |
| 47 | 60 | import { cn } from "~/lib/utils"; |
| 48 | 61 | |
| 62 | export interface BranchToolbarHandle { | |
| 63 | openBranchPicker: () => void; | |
| 64 | usePreviousWorktree: () => void; | |
| 65 | } | |
| 66 | ||
| 49 | 67 | interface BranchToolbarProps { |
| 68 | ref?: Ref<BranchToolbarHandle>; | |
| 50 | 69 | environmentId: EnvironmentId; |
| 51 | 70 | threadId: ThreadId; |
| 52 | 71 | showGitControls: boolean; |
| ... | ... | @@ -169,6 +188,10 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ |
| 169 | 188 | render={<Button variant="ghost" size="xs" />} |
| 170 | 189 | className="min-w-0 max-w-[48%] flex-initial justify-start font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80" |
| 171 | 190 | data-composer-context-control |
| 191 | data-composer-shortcut={[ | |
| 192 | showEnvironmentPicker && !envLocked ? "composer.host" : "", | |
| 193 | !envModeLocked ? "composer.workspace" : "", | |
| 194 | ].join(" ")} | |
| 172 | 195 | > |
| 173 | 196 | {triggerContent} |
| 174 | 197 | <ChevronDownIcon className="size-3 shrink-0 opacity-50" /> |
| ... | ... | @@ -438,6 +461,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { |
| 438 | 461 | } |
| 439 | 462 | |
| 440 | 463 | export const BranchToolbar = memo(function BranchToolbar({ |
| 464 | ref, | |
| 441 | 465 | environmentId, |
| 442 | 466 | threadId, |
| 443 | 467 | showGitControls, |
| ... | ... | @@ -458,6 +482,7 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 458 | 482 | composerControlsHostRef, |
| 459 | 483 | contextStripVisible = true, |
| 460 | 484 | }: BranchToolbarProps) { |
| 485 | const branchSelectorRef = useRef<BranchToolbarBranchSelectorHandle>(null); | |
| 461 | 486 | const threadRef = useMemo( |
| 462 | 487 | () => scopeThreadRef(environmentId, threadId), |
| 463 | 488 | [environmentId, threadId], |
| ... | ... | @@ -518,6 +543,25 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 518 | 543 | }); |
| 519 | 544 | }, [activeProjectRef, draftId, previousWorktreeSeed, setDraftThreadContext, threadRef]); |
| 520 | 545 | |
| 546 | useImperativeHandle( | |
| 547 | ref, | |
| 548 | () => ({ | |
| 549 | openBranchPicker: () => branchSelectorRef.current?.open(), | |
| 550 | usePreviousWorktree: () => { | |
| 551 | if (!showGitControls || !canUsePreviousWorktree || !previousWorktreeSeed) return; | |
| 552 | onUsePreviousWorktree(); | |
| 553 | onComposerFocusRequest?.(); | |
| 554 | }, | |
| 555 | }), | |
| 556 | [ | |
| 557 | canUsePreviousWorktree, | |
| 558 | onComposerFocusRequest, | |
| 559 | onUsePreviousWorktree, | |
| 560 | previousWorktreeSeed, | |
| 561 | showGitControls, | |
| 562 | ], | |
| 563 | ); | |
| 564 | ||
| 521 | 565 | const showEnvironmentPicker = Boolean( |
| 522 | 566 | availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange, |
| 523 | 567 | ); |
| ... | ... | @@ -618,6 +662,7 @@ export const BranchToolbar = memo(function BranchToolbar({ |
| 618 | 662 | |
| 619 | 663 | {showGitControls ? ( |
| 620 | 664 | <BranchToolbarBranchSelector |
| 665 | ref={branchSelectorRef} | |
| 621 | 666 | className="min-w-0 flex-initial justify-end @3xl/composer-surface:ml-auto" |
| 622 | 667 | environmentId={environmentId} |
| 623 | 668 | threadId={threadId} |
apps/web/src/components/BranchToolbarBranchSelector.tsx+19| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | useDeferredValue, |
| 16 | 16 | useEffect, |
| 17 | 17 | useId, |
| 18 | useImperativeHandle, | |
| 18 | 19 | useLayoutEffect, |
| 19 | 20 | useMemo, |
| 20 | 21 | useOptimistic, |
| ... | ... | @@ -22,6 +23,7 @@ import { |
| 22 | 23 | useState, |
| 23 | 24 | useTransition, |
| 24 | 25 | type MouseEvent as ReactMouseEvent, |
| 26 | type Ref, | |
| 25 | 27 | } from "react"; |
| 26 | 28 | |
| 27 | 29 | import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; |
| ... | ... | @@ -72,7 +74,12 @@ import { |
| 72 | 74 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 73 | 75 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 74 | 76 | |
| 77 | export interface BranchToolbarBranchSelectorHandle { | |
| 78 | open: () => void; | |
| 79 | } | |
| 80 | ||
| 75 | 81 | interface BranchToolbarBranchSelectorProps { |
| 82 | ref?: Ref<BranchToolbarBranchSelectorHandle>; | |
| 76 | 83 | className?: string; |
| 77 | 84 | environmentId: EnvironmentId; |
| 78 | 85 | threadId: ThreadId; |
| ... | ... | @@ -92,6 +99,7 @@ function toBranchActionErrorMessage(error: unknown): string { |
| 92 | 99 | } |
| 93 | 100 | |
| 94 | 101 | export function BranchToolbarBranchSelector({ |
| 102 | ref, | |
| 95 | 103 | className, |
| 96 | 104 | environmentId, |
| 97 | 105 | threadId, |
| ... | ... | @@ -535,6 +543,17 @@ export function BranchToolbarBranchSelector({ |
| 535 | 543 | } |
| 536 | 544 | }, []); |
| 537 | 545 | |
| 546 | useImperativeHandle( | |
| 547 | ref, | |
| 548 | () => ({ | |
| 549 | open: () => { | |
| 550 | if (isInitialBranchesLoadPending || isBranchActionPending) return; | |
| 551 | handleOpenChange(true); | |
| 552 | }, | |
| 553 | }), | |
| 554 | [handleOpenChange, isBranchActionPending, isInitialBranchesLoadPending], | |
| 555 | ); | |
| 556 | ||
| 538 | 557 | const [showTopBranchScrollFade, setShowTopBranchScrollFade] = useState(false); |
| 539 | 558 | const [showBottomBranchScrollFade, setShowBottomBranchScrollFade] = useState(false); |
| 540 | 559 | const fetchNextBranchPage = useCallback(() => { |
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+1| ... | ... | @@ -93,6 +93,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe |
| 93 | 93 | size="xs" |
| 94 | 94 | className="min-w-0 shrink font-normal text-xs!" |
| 95 | 95 | aria-label="Workspace" |
| 96 | data-composer-shortcut="composer.workspace" | |
| 96 | 97 | data-composer-context-control |
| 97 | 98 | > |
| 98 | 99 | {effectiveEnvMode === "worktree" ? ( |
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+1| ... | ... | @@ -95,6 +95,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir |
| 95 | 95 | size="xs" |
| 96 | 96 | className="min-w-0 max-w-full font-normal text-xs!" |
| 97 | 97 | aria-label="Run on" |
| 98 | data-composer-shortcut="composer.host" | |
| 98 | 99 | data-composer-context-control |
| 99 | 100 | > |
| 100 | 101 | {autoEnvironmentLabel ? ( |
apps/web/src/components/ChatView.tsx+47-10| ... | ... | @@ -219,7 +219,7 @@ import { |
| 219 | 219 | deriveAgentPanelModel, |
| 220 | 220 | foldSubagentActivities, |
| 221 | 221 | } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 222 | import { BranchToolbar } from "./BranchToolbar"; | |
| 222 | import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; | |
| 223 | 223 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 224 | 224 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| 225 | 225 | import { |
| ... | ... | @@ -1617,6 +1617,7 @@ export default function ChatView(props: ChatViewProps) { |
| 1617 | 1617 | const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]); |
| 1618 | 1618 | const localComposerRef = useRef<ChatComposerHandle | null>(null); |
| 1619 | 1619 | const composerRef = useComposerHandleContext() ?? localComposerRef; |
| 1620 | const branchToolbarRef = useRef<BranchToolbarHandle>(null); | |
| 1620 | 1621 | const pasteAsTextShortcutUntilRef = useRef(0); |
| 1621 | 1622 | const [restingComposerControlsHost, setRestingComposerControlsHost] = |
| 1622 | 1623 | useState<HTMLDivElement | null>(null); |
| ... | ... | @@ -6397,6 +6398,17 @@ export default function ChatView(props: ChatViewProps) { |
| 6397 | 6398 | terminalUiOpenByThreadRef.current[activeThreadKey] = current; |
| 6398 | 6399 | }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); |
| 6399 | 6400 | |
| 6401 | const getShortcutContext = useCallback( | |
| 6402 | () => ({ | |
| 6403 | terminalFocus: getTerminalFocusOwner() !== null, | |
| 6404 | terminalOpen: Boolean(terminalUiState.terminalOpen), | |
| 6405 | previewFocus: isPreviewFocused(), | |
| 6406 | previewOpen: previewPanelOpen, | |
| 6407 | modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, | |
| 6408 | }), | |
| 6409 | [composerRef, previewPanelOpen, terminalUiState.terminalOpen], | |
| 6410 | ); | |
| 6411 | ||
| 6400 | 6412 | useEffect(() => { |
| 6401 | 6413 | const handler = (event: globalThis.KeyboardEvent) => { |
| 6402 | 6414 | if (preventRepeatedTerminalCloseShortcut(event, keybindings)) { |
| ... | ... | @@ -6417,13 +6429,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6417 | 6429 | if (event.defaultPrevented && terminalFocusOwner === null) { |
| 6418 | 6430 | return; |
| 6419 | 6431 | } |
| 6420 | const shortcutContext = { | |
| 6421 | terminalFocus: terminalFocusOwner !== null, | |
| 6422 | terminalOpen: Boolean(terminalUiState.terminalOpen), | |
| 6423 | previewFocus: isPreviewFocused(), | |
| 6424 | previewOpen: previewPanelOpen, | |
| 6425 | modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, | |
| 6426 | }; | |
| 6432 | const shortcutContext = getShortcutContext(); | |
| 6427 | 6433 | |
| 6428 | 6434 | if ( |
| 6429 | 6435 | !shortcutContext.terminalFocus && |
| ... | ... | @@ -6588,7 +6594,33 @@ export default function ChatView(props: ChatViewProps) { |
| 6588 | 6594 | if (command === "modelPicker.toggle") { |
| 6589 | 6595 | event.preventDefault(); |
| 6590 | 6596 | event.stopPropagation(); |
| 6591 | composerRef.current?.toggleModelPicker(); | |
| 6597 | if (!event.repeat) composerRef.current?.toggleModelPicker(); | |
| 6598 | return; | |
| 6599 | } | |
| 6600 | ||
| 6601 | if ( | |
| 6602 | command === "composer.host" || | |
| 6603 | command === "composer.effort" || | |
| 6604 | command === "composer.mode" || | |
| 6605 | command === "composer.workspace" | |
| 6606 | ) { | |
| 6607 | event.preventDefault(); | |
| 6608 | event.stopPropagation(); | |
| 6609 | if (!event.repeat) composerRef.current?.openControl(command); | |
| 6610 | return; | |
| 6611 | } | |
| 6612 | ||
| 6613 | if (command === "composer.branch") { | |
| 6614 | event.preventDefault(); | |
| 6615 | event.stopPropagation(); | |
| 6616 | if (!event.repeat) branchToolbarRef.current?.openBranchPicker(); | |
| 6617 | return; | |
| 6618 | } | |
| 6619 | ||
| 6620 | if (command === "composer.previousWorktree") { | |
| 6621 | event.preventDefault(); | |
| 6622 | event.stopPropagation(); | |
| 6623 | if (!event.repeat) branchToolbarRef.current?.usePreviousWorktree(); | |
| 6592 | 6624 | return; |
| 6593 | 6625 | } |
| 6594 | 6626 | |
| ... | ... | @@ -6643,7 +6675,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6643 | 6675 | supportsSettlement, |
| 6644 | 6676 | confirmAndUnpinThread, |
| 6645 | 6677 | copyActiveThreadReference, |
| 6646 | previewPanelOpen, | |
| 6678 | getShortcutContext, | |
| 6647 | 6679 | toggleRightPanel, |
| 6648 | 6680 | toggleRightPanelMaximized, |
| 6649 | 6681 | toggleTerminalVisibility, |
| ... | ... | @@ -8708,6 +8740,10 @@ export default function ChatView(props: ChatViewProps) { |
| 8708 | 8740 | // reader's feet. A link the agent wrote can open any other one here, and that one has to be |
| 8709 | 8741 | // checkable out like it is anywhere else. |
| 8710 | 8742 | <PullRequestDetailPanel |
| 8743 | getShortcutContext={getShortcutContext} | |
| 8744 | shortcutsEnabled={ | |
| 8745 | rightPanelOpen && activeRightPanelSurface?.id === renderedRightPanelSurface.id | |
| 8746 | } | |
| 8711 | 8747 | key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`} |
| 8712 | 8748 | environmentId={activeThread.environmentId} |
| 8713 | 8749 | onSelectPullRequest={(reference) => { |
| ... | ... | @@ -9231,6 +9267,7 @@ export default function ChatView(props: ChatViewProps) { |
| 9231 | 9267 | {mountComposerContextStrip && ( |
| 9232 | 9268 | <div className="pointer-events-auto"> |
| 9233 | 9269 | <BranchToolbar |
| 9270 | ref={branchToolbarRef} | |
| 9234 | 9271 | environmentId={activeThread.environmentId} |
| 9235 | 9272 | threadId={activeThread.id} |
| 9236 | 9273 | showGitControls={isGitRepo} |
apps/web/src/components/chat/ChatComposer.tsx+26-1| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | } from "../../questionAttachments"; |
| 16 | 16 | import type { |
| 17 | 17 | ApprovalRequestId, |
| 18 | KeybindingCommand, | |
| 18 | 19 | AssistantCitation, |
| 19 | 20 | ChatFileAttachment, |
| 20 | 21 | EnvironmentId, |
| ... | ... | @@ -62,7 +63,7 @@ import { |
| 62 | 63 | useState, |
| 63 | 64 | useSyncExternalStore, |
| 64 | 65 | } from "react"; |
| 65 | import { createPortal } from "react-dom"; | |
| 66 | import { createPortal, flushSync } from "react-dom"; | |
| 66 | 67 | import { |
| 67 | 68 | clampCollapsedComposerCursor, |
| 68 | 69 | type ComposerSubmissionIntent, |
| ... | ... | @@ -1103,6 +1104,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop |
| 1103 | 1104 | <TooltipTrigger |
| 1104 | 1105 | render={ |
| 1105 | 1106 | <ComposerSelectControl |
| 1107 | data-composer-shortcut="composer.mode" | |
| 1106 | 1108 | size={size} |
| 1107 | 1109 | className={size === "xs" ? undefined : "font-medium"} |
| 1108 | 1110 | aria-label="Runtime mode" |
| ... | ... | @@ -1231,6 +1233,7 @@ export interface ChatComposerHandle { |
| 1231 | 1233 | ) => boolean; |
| 1232 | 1234 | openModelPicker: () => void; |
| 1233 | 1235 | toggleModelPicker: () => void; |
| 1236 | openControl: (command: KeybindingCommand) => void; | |
| 1234 | 1237 | isModelPickerOpen: () => boolean; |
| 1235 | 1238 | compactContext: () => void; |
| 1236 | 1239 | readSnapshot: () => { |
| ... | ... | @@ -5743,6 +5746,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 5743 | 5746 | openModelPicker(); |
| 5744 | 5747 | } |
| 5745 | 5748 | }, |
| 5749 | openControl: (command) => { | |
| 5750 | if (composerBlurFrameRef.current !== null) { | |
| 5751 | window.cancelAnimationFrame(composerBlurFrameRef.current); | |
| 5752 | composerBlurFrameRef.current = null; | |
| 5753 | } | |
| 5754 | flushSync(() => { | |
| 5755 | setIsComposerScrollCollapsed(false); | |
| 5756 | setIsComposerFocused(true); | |
| 5757 | }); | |
| 5758 | const shell = composerFormRef.current?.closest('[data-slot="composer-shell"]'); | |
| 5759 | const trigger = Array.from( | |
| 5760 | shell?.querySelectorAll<HTMLButtonElement>( | |
| 5761 | `button[data-composer-shortcut~="${command}"]:not(:disabled)`, | |
| 5762 | ) ?? [], | |
| 5763 | ).find( | |
| 5764 | (element) => | |
| 5765 | !element.closest("[inert]") && element.checkVisibility({ visibilityProperty: true }), | |
| 5766 | ); | |
| 5767 | if (!trigger) return; | |
| 5768 | trigger.focus({ preventScroll: true }); | |
| 5769 | trigger.click(); | |
| 5770 | }, | |
| 5746 | 5771 | compactContext: compactThreadContext, |
| 5747 | 5772 | isModelPickerOpen: () => isComposerModelPickerOpen, |
| 5748 | 5773 | readSnapshot: () => { |
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+3| ... | ... | @@ -40,6 +40,9 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls |
| 40 | 40 | variant="ghost" |
| 41 | 41 | className={size === "xs" ? "shrink-0" : "shrink-0 px-2"} |
| 42 | 42 | aria-label="More composer controls" |
| 43 | data-composer-shortcut={ | |
| 44 | props.traitsMenuContent ? "composer.mode composer.effort" : "composer.mode" | |
| 45 | } | |
| 43 | 46 | /> |
| 44 | 47 | } |
| 45 | 48 | > |
apps/web/src/components/chat/ModelPickerContent.test.ts+72| ... | ... | @@ -8,6 +8,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 8 | 8 | |
| 9 | 9 | import { deriveProviderInstanceEntries } from "../../providerInstances"; |
| 10 | 10 | import { |
| 11 | adjacentModelPickerProvider, | |
| 11 | 12 | resolveModelPickerSelectedModel, |
| 12 | 13 | shouldIncludeModelPickerOption, |
| 13 | 14 | shouldOfferModelPickerSetup, |
| ... | ... | @@ -213,3 +214,74 @@ describe("shouldOfferModelPickerSetup", () => { |
| 213 | 214 | ).toBe(true); |
| 214 | 215 | }); |
| 215 | 216 | }); |
| 217 | ||
| 218 | describe("adjacentModelPickerProvider", () => { | |
| 219 | const codex = entry("ready", "codex"); | |
| 220 | const claude = entry("ready", "claudeAgent"); | |
| 221 | const unavailable = entry("error"); | |
| 222 | const input = { | |
| 223 | entries: [codex, unavailable, claude], | |
| 224 | disabledInstanceIds: undefined, | |
| 225 | selectableUnavailableInstanceIds: undefined, | |
| 226 | }; | |
| 227 | ||
| 228 | it("wraps through favorites and ready instances, skipping unavailable providers", () => { | |
| 229 | expect( | |
| 230 | adjacentModelPickerProvider({ ...input, selectedInstanceId: codex.instanceId, direction: 1 }), | |
| 231 | ).toBe(claude.instanceId); | |
| 232 | expect( | |
| 233 | adjacentModelPickerProvider({ ...input, selectedInstanceId: "favorites", direction: -1 }), | |
| 234 | ).toBe(claude.instanceId); | |
| 235 | expect( | |
| 236 | adjacentModelPickerProvider({ | |
| 237 | ...input, | |
| 238 | selectedInstanceId: claude.instanceId, | |
| 239 | direction: 1, | |
| 240 | }), | |
| 241 | ).toBe("favorites"); | |
| 242 | }); | |
| 243 | ||
| 244 | it("keeps thread locks and the selected unavailable catalog", () => { | |
| 245 | expect( | |
| 246 | adjacentModelPickerProvider({ | |
| 247 | ...input, | |
| 248 | disabledInstanceIds: new Set([claude.instanceId]), | |
| 249 | selectedInstanceId: codex.instanceId, | |
| 250 | direction: 1, | |
| 251 | }), | |
| 252 | ).toBe("favorites"); | |
| 253 | expect( | |
| 254 | adjacentModelPickerProvider({ | |
| 255 | ...input, | |
| 256 | selectableUnavailableInstanceIds: new Set([unavailable.instanceId]), | |
| 257 | selectedInstanceId: codex.instanceId, | |
| 258 | direction: 1, | |
| 259 | }), | |
| 260 | ).toBe(unavailable.instanceId); | |
| 261 | }); | |
| 262 | ||
| 263 | it("handles an empty catalog and a removed selection in either direction", () => { | |
| 264 | expect( | |
| 265 | adjacentModelPickerProvider({ | |
| 266 | ...input, | |
| 267 | entries: [], | |
| 268 | selectedInstanceId: codex.instanceId, | |
| 269 | direction: -1, | |
| 270 | }), | |
| 271 | ).toBe("favorites"); | |
| 272 | expect( | |
| 273 | adjacentModelPickerProvider({ | |
| 274 | ...input, | |
| 275 | selectedInstanceId: unavailable.instanceId, | |
| 276 | direction: 1, | |
| 277 | }), | |
| 278 | ).toBe("favorites"); | |
| 279 | expect( | |
| 280 | adjacentModelPickerProvider({ | |
| 281 | ...input, | |
| 282 | selectedInstanceId: unavailable.instanceId, | |
| 283 | direction: -1, | |
| 284 | }), | |
| 285 | ).toBe(claude.instanceId); | |
| 286 | }); | |
| 287 | }); |
apps/web/src/components/chat/ModelPickerContent.tsx+76-1| ... | ... | @@ -113,6 +113,34 @@ export function shouldOfferModelPickerSetup( |
| 113 | 113 | ); |
| 114 | 114 | } |
| 115 | 115 | |
| 116 | export function adjacentModelPickerProvider(input: { | |
| 117 | entries: ReadonlyArray<ProviderInstanceEntry>; | |
| 118 | selectedInstanceId: ProviderInstanceId | "favorites"; | |
| 119 | direction: 1 | -1; | |
| 120 | disabledInstanceIds: ReadonlySet<ProviderInstanceId> | undefined; | |
| 121 | selectableUnavailableInstanceIds: ReadonlySet<ProviderInstanceId> | undefined; | |
| 122 | }) { | |
| 123 | const providers: Array<ProviderInstanceId | "favorites"> = [ | |
| 124 | "favorites", | |
| 125 | ...input.entries | |
| 126 | .filter( | |
| 127 | (entry) => | |
| 128 | !input.disabledInstanceIds?.has(entry.instanceId) && | |
| 129 | (isProviderInstancePickerReady(entry) || | |
| 130 | input.selectableUnavailableInstanceIds?.has(entry.instanceId)), | |
| 131 | ) | |
| 132 | .map((entry) => entry.instanceId), | |
| 133 | ]; | |
| 134 | const index = providers.indexOf(input.selectedInstanceId); | |
| 135 | return providers[ | |
| 136 | index < 0 | |
| 137 | ? input.direction === 1 | |
| 138 | ? 0 | |
| 139 | : providers.length - 1 | |
| 140 | : (index + input.direction + providers.length) % providers.length | |
| 141 | ]!; | |
| 142 | } | |
| 143 | ||
| 116 | 144 | const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>(); |
| 117 | 145 | |
| 118 | 146 | function ModelListSeparator() { |
| ... | ... | @@ -687,6 +715,20 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 687 | 715 | platform: navigator.platform, |
| 688 | 716 | context: modelJumpShortcutContext, |
| 689 | 717 | }); |
| 718 | if (command === "modelPicker.previousProvider" || command === "modelPicker.nextProvider") { | |
| 719 | event.preventDefault(); | |
| 720 | event.stopPropagation(); | |
| 721 | const next = adjacentModelPickerProvider({ | |
| 722 | entries: sidebarInstanceEntries, | |
| 723 | selectedInstanceId, | |
| 724 | direction: command === "modelPicker.nextProvider" ? 1 : -1, | |
| 725 | disabledInstanceIds: lockedDisabledInstanceIds, | |
| 726 | selectableUnavailableInstanceIds, | |
| 727 | }); | |
| 728 | setSearchQuery(""); | |
| 729 | handleSelectInstance(next); | |
| 730 | return; | |
| 731 | } | |
| 690 | 732 | const jumpIndex = modelPickerJumpIndexFromCommand(command ?? ""); |
| 691 | 733 | if (jumpIndex === null) { |
| 692 | 734 | return; |
| ... | ... | @@ -710,7 +752,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 710 | 752 | return () => { |
| 711 | 753 | window.removeEventListener("keydown", onWindowKeyDown, true); |
| 712 | 754 | }; |
| 713 | }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]); | |
| 755 | }, [ | |
| 756 | handleModelSelect, | |
| 757 | handleSelectInstance, | |
| 758 | keybindings, | |
| 759 | lockedDisabledInstanceIds, | |
| 760 | modelJumpModelKeys, | |
| 761 | modelJumpShortcutContext, | |
| 762 | selectableUnavailableInstanceIds, | |
| 763 | selectedInstanceId, | |
| 764 | sidebarInstanceEntries, | |
| 765 | ]); | |
| 714 | 766 | |
| 715 | 767 | useLayoutEffect(() => { |
| 716 | 768 | setShowTopScrollFade(false); |
| ... | ... | @@ -737,6 +789,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 737 | 789 | <ModelPickerSidebar |
| 738 | 790 | selectedInstanceId={selectedInstanceId} |
| 739 | 791 | onSelectInstance={handleSelectInstance} |
| 792 | onFocusSearch={focusSearchInput} | |
| 740 | 793 | instanceEntries={sidebarInstanceEntries} |
| 741 | 794 | showFavorites |
| 742 | 795 | {...(selectableUnavailableInstanceIds ? { selectableUnavailableInstanceIds } : {})} |
| ... | ... | @@ -805,6 +858,28 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { |
| 805 | 858 | value={searchQuery} |
| 806 | 859 | onChange={(e) => setSearchQuery(e.target.value)} |
| 807 | 860 | onKeyDown={(e) => { |
| 861 | if ( | |
| 862 | showSidebar && | |
| 863 | !e.altKey && | |
| 864 | !e.ctrlKey && | |
| 865 | !e.metaKey && | |
| 866 | ((e.key === "ArrowLeft" && !e.shiftKey && searchQuery.length === 0) || | |
| 867 | (e.key === "Tab" && e.shiftKey)) | |
| 868 | ) { | |
| 869 | const sidebar = e.currentTarget | |
| 870 | .closest("[data-model-picker-content]") | |
| 871 | ?.querySelector("[data-model-picker-sidebar]"); | |
| 872 | const button = | |
| 873 | sidebar?.querySelector<HTMLButtonElement>( | |
| 874 | 'button[aria-pressed="true"]:not(:disabled)', | |
| 875 | ) ?? sidebar?.querySelector<HTMLButtonElement>("button:not(:disabled)"); | |
| 876 | if (button) { | |
| 877 | e.preventDefault(); | |
| 878 | e.stopPropagation(); | |
| 879 | button.focus(); | |
| 880 | return; | |
| 881 | } | |
| 882 | } | |
| 808 | 883 | if (e.key === "Escape") { |
| 809 | 884 | e.preventDefault(); |
| 810 | 885 | e.stopPropagation(); |
apps/web/src/components/chat/ModelPickerSidebar.tsx+24-6| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { Toolbar } from "@base-ui/react/toolbar"; | |
| 1 | 2 | import { type ProviderInstanceId } from "@t3tools/contracts"; |
| 2 | 3 | import { memo, useLayoutEffect, useRef, useState } from "react"; |
| 3 | 4 | import { SparklesIcon, StarIcon } from "lucide-react"; |
| ... | ... | @@ -43,6 +44,7 @@ const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug"; |
| 43 | 44 | export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 44 | 45 | selectedInstanceId: ProviderInstanceId | "favorites"; |
| 45 | 46 | onSelectInstance: (instanceId: ProviderInstanceId | "favorites") => void; |
| 47 | onFocusSearch: () => void; | |
| 46 | 48 | /** |
| 47 | 49 | * Instance entries to render as rail buttons. Each entry becomes one icon |
| 48 | 50 | * keyed by `instanceId`, so the default built-in Codex and a user-authored |
| ... | ... | @@ -87,7 +89,20 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 87 | 89 | }, [props.instanceEntries, props.selectedInstanceId, showFavorites]); |
| 88 | 90 | |
| 89 | 91 | return ( |
| 90 | <div className="w-11 shrink-0 overflow-hidden bg-muted/30" data-model-picker-sidebar="true"> | |
| 92 | <Toolbar.Root | |
| 93 | className="w-11 shrink-0 overflow-hidden bg-muted/30" | |
| 94 | data-model-picker-sidebar="true" | |
| 95 | aria-label="Providers" | |
| 96 | orientation="vertical" | |
| 97 | onKeyDown={(event) => { | |
| 98 | if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; | |
| 99 | if (event.key === "ArrowRight") { | |
| 100 | event.preventDefault(); | |
| 101 | props.onFocusSearch(); | |
| 102 | return; | |
| 103 | } | |
| 104 | }} | |
| 105 | > | |
| 91 | 106 | <div className="h-full overflow-y-auto overscroll-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> |
| 92 | 107 | <div ref={sidebarContentRef} className="relative flex min-h-full flex-col gap-1 p-1"> |
| 93 | 108 | {selectedIndicatorTop !== null ? ( |
| ... | ... | @@ -107,16 +122,17 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 107 | 122 | <Tooltip> |
| 108 | 123 | <TooltipTrigger |
| 109 | 124 | render={ |
| 110 | <button | |
| 125 | <Toolbar.Button | |
| 111 | 126 | className={cn( |
| 112 | 127 | "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded-md transition-colors hover:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] focus-visible:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] focus-visible:outline-none", |
| 113 | 128 | )} |
| 114 | 129 | onClick={() => handleSelect("favorites")} |
| 115 | 130 | type="button" |
| 116 | 131 | aria-label="Favorites" |
| 132 | aria-pressed={props.selectedInstanceId === "favorites"} | |
| 117 | 133 | > |
| 118 | 134 | <StarIcon className="size-5 fill-current shrink-0" aria-hidden /> |
| 119 | </button> | |
| 135 | </Toolbar.Button> | |
| 120 | 136 | } |
| 121 | 137 | /> |
| 122 | 138 | <TooltipPopup |
| ... | ... | @@ -155,7 +171,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 155 | 171 | : entry.displayName; |
| 156 | 172 | |
| 157 | 173 | const button = ( |
| 158 | <button | |
| 174 | <Toolbar.Button | |
| 159 | 175 | className={cn( |
| 160 | 176 | "relative isolate flex w-full cursor-pointer aspect-square items-center justify-center rounded-md transition-colors hover:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] focus-visible:bg-[color-mix(in_srgb,var(--popover)_90%,var(--contrast-foreground))] focus-visible:outline-none", |
| 161 | 177 | isDisabled && "opacity-50 cursor-not-allowed hover:bg-transparent", |
| ... | ... | @@ -171,6 +187,8 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 171 | 187 | setHoveredInstanceId((current) => (current === entry.instanceId ? null : current)) |
| 172 | 188 | } |
| 173 | 189 | disabled={isDisabled} |
| 190 | focusableWhenDisabled={!isDisabled} | |
| 191 | aria-pressed={isSelected} | |
| 174 | 192 | type="button" |
| 175 | 193 | aria-label={ |
| 176 | 194 | isUnavailable || isContextDisabled |
| ... | ... | @@ -203,7 +221,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 203 | 221 | <SparklesIcon className="size-2" /> |
| 204 | 222 | </span> |
| 205 | 223 | ) : null} |
| 206 | </button> | |
| 224 | </Toolbar.Button> | |
| 207 | 225 | ); |
| 208 | 226 | |
| 209 | 227 | const trigger = isDisabled ? ( |
| ... | ... | @@ -234,6 +252,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { |
| 234 | 252 | })} |
| 235 | 253 | </div> |
| 236 | 254 | </div> |
| 237 | </div> | |
| 255 | </Toolbar.Root> | |
| 238 | 256 | ); |
| 239 | 257 | }); |
apps/web/src/components/chat/TraitsPicker.tsx+1| ... | ... | @@ -618,6 +618,7 @@ export const TraitsPicker = memo(function TraitsPicker({ |
| 618 | 618 | <MenuTrigger |
| 619 | 619 | render={ |
| 620 | 620 | <ComposerControl |
| 621 | data-composer-shortcut={isComposerOwned ? "composer.effort" : undefined} | |
| 621 | 622 | variant={triggerVariant ?? "ghost"} |
| 622 | 623 | size={size} |
| 623 | 624 | className={cn( |
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+53-2| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | 3 | import { usePullRequestStack } from "~/state/usePullRequestStack"; |
| 3 | 4 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 4 | 5 | import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| ... | ... | @@ -21,6 +22,7 @@ import { |
| 21 | 22 | ArrowUpRightIcon, |
| 22 | 23 | BookOpenIcon, |
| 23 | 24 | CircleDotIcon, |
| 25 | CopyIcon, | |
| 24 | 26 | ChevronDownIcon, |
| 25 | 27 | ExternalLinkIcon, |
| 26 | 28 | FileDiffIcon, |
| ... | ... | @@ -49,6 +51,7 @@ import { |
| 49 | 51 | type MouseEvent as ReactMouseEvent, |
| 50 | 52 | useCallback, |
| 51 | 53 | useEffect, |
| 54 | useEffectEvent, | |
| 52 | 55 | useLayoutEffect, |
| 53 | 56 | useMemo, |
| 54 | 57 | useRef, |
| ... | ... | @@ -57,7 +60,14 @@ import { |
| 57 | 60 | |
| 58 | 61 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| 59 | 62 | import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; |
| 60 | import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; | |
| 63 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | |
| 64 | import { isCommandPaletteOpen } from "~/commandPaletteBus"; | |
| 65 | import { | |
| 66 | resolveShortcutCommand, | |
| 67 | shortcutLabelForCommand, | |
| 68 | type ShortcutMatchContext, | |
| 69 | } from "~/keybindings"; | |
| 70 | import { primaryServerKeybindingsAtom } from "~/state/server"; | |
| 61 | 71 | import { useClientSettings } from "~/hooks/useSettings"; |
| 62 | 72 | import { |
| 63 | 73 | deriveLogicalProjectKeyFromSettings, |
| ... | ... | @@ -104,6 +114,7 @@ import { |
| 104 | 114 | MenuRadioGroup, |
| 105 | 115 | MenuRadioItem, |
| 106 | 116 | MenuSeparator, |
| 117 | MenuShortcut, | |
| 107 | 118 | MenuTrigger, |
| 108 | 119 | } from "../ui/menu"; |
| 109 | 120 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| ... | ... | @@ -457,6 +468,8 @@ function PullRequestBaseFreshnessWarning({ |
| 457 | 468 | |
| 458 | 469 | export function PullRequestDetailPanel({ |
| 459 | 470 | environmentId, |
| 471 | shortcutsEnabled, | |
| 472 | getShortcutContext, | |
| 460 | 473 | threadRef = null, |
| 461 | 474 | reference: requestedReference, |
| 462 | 475 | listEntry = null, |
| ... | ... | @@ -469,6 +482,8 @@ export function PullRequestDetailPanel({ |
| 469 | 482 | onSelectPullRequest, |
| 470 | 483 | }: { |
| 471 | 484 | environmentId: EnvironmentId; |
| 485 | shortcutsEnabled: boolean; | |
| 486 | getShortcutContext: () => ShortcutMatchContext; | |
| 472 | 487 | onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined; |
| 473 | 488 | /** |
| 474 | 489 | * The thread this panel sits beside, if any. Links that are not the pull |
| ... | ... | @@ -707,6 +722,32 @@ export function PullRequestDetailPanel({ |
| 707 | 722 | }, |
| 708 | 723 | [activity, coreDetail], |
| 709 | 724 | ); |
| 725 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | |
| 726 | const { copyToClipboard: copyReference } = useCopyToClipboard<string>({ | |
| 727 | target: "pull request reference", | |
| 728 | onCopy: (label) => toastManager.add({ type: "success", title: `${label} copied` }), | |
| 729 | onError: (error, label) => | |
| 730 | toastManager.add({ | |
| 731 | type: "error", | |
| 732 | title: `Failed to copy ${label}`, | |
| 733 | description: error.message, | |
| 734 | }), | |
| 735 | }); | |
| 736 | const copyFromShortcut = useEffectEvent((event: KeyboardEvent) => { | |
| 737 | if (!shortcutsEnabled || event.defaultPrevented || isCommandPaletteOpen()) return; | |
| 738 | const command = resolveShortcutCommand(event, keybindings, { | |
| 739 | context: getShortcutContext(), | |
| 740 | }); | |
| 741 | if (command !== "pullRequest.copyNumber") return; | |
| 742 | event.preventDefault(); | |
| 743 | event.stopPropagation(); | |
| 744 | if (!event.repeat) copyReference(`#${reference.number}`, "PR number"); | |
| 745 | }); | |
| 746 | useEffect(() => { | |
| 747 | const onKeyDown = (event: KeyboardEvent) => copyFromShortcut(event); | |
| 748 | window.addEventListener("keydown", onKeyDown, true); | |
| 749 | return () => window.removeEventListener("keydown", onKeyDown, true); | |
| 750 | }, []); | |
| 710 | 751 | useEffect(() => { |
| 711 | 752 | if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod); |
| 712 | 753 | }, [detail?.autoMergeMethod, pullRequestKey]); |
| ... | ... | @@ -2080,9 +2121,19 @@ export function PullRequestDetailPanel({ |
| 2080 | 2121 | <ArrowUpRightIcon className="size-3.5" /> |
| 2081 | 2122 | {openOnHostLabel(detail.provider)} |
| 2082 | 2123 | </MenuItem> |
| 2083 | <MenuItem onClick={() => void writeTextToClipboard(detail.url)}> | |
| 2124 | <MenuItem onClick={() => copyReference(detail.url, "PR link")}> | |
| 2084 | 2125 | <LinkIcon className="size-3.5" /> |
| 2085 | 2126 | Copy link |
| 2127 | <MenuShortcut> | |
| 2128 | {shortcutLabelForCommand(keybindings, "thread.copyReference")} | |
| 2129 | </MenuShortcut> | |
| 2130 | </MenuItem> | |
| 2131 | <MenuItem onClick={() => copyReference(`#${reference.number}`, "PR number")}> | |
| 2132 | <CopyIcon className="size-3.5" /> | |
| 2133 | Copy PR number | |
| 2134 | <MenuShortcut> | |
| 2135 | {shortcutLabelForCommand(keybindings, "pullRequest.copyNumber")} | |
| 2136 | </MenuShortcut> | |
| 2086 | 2137 | </MenuItem> |
| 2087 | 2138 | {detail.state === "open" && can("close") ? ( |
| 2088 | 2139 | <> |
apps/web/src/components/settings/KeybindingsSettings.logic.test.ts+26| ... | ... | @@ -17,6 +17,32 @@ import { |
| 17 | 17 | } from "./KeybindingsSettings.logic"; |
| 18 | 18 | |
| 19 | 19 | describe("KeybindingsSettings.logic", () => { |
| 20 | it("lists composer, provider, and pull request commands with editable defaults", () => { | |
| 21 | const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, ""); | |
| 22 | for (const command of [ | |
| 23 | "composer.host", | |
| 24 | "composer.effort", | |
| 25 | "composer.mode", | |
| 26 | "composer.workspace", | |
| 27 | "composer.branch", | |
| 28 | "composer.previousWorktree", | |
| 29 | "modelPicker.previousProvider", | |
| 30 | "modelPicker.nextProvider", | |
| 31 | "thread.copyReference", | |
| 32 | "pullRequest.copyNumber", | |
| 33 | ]) { | |
| 34 | expect(rows.find((row) => row.command === command)).toMatchObject({ | |
| 35 | source: "Default", | |
| 36 | conflicts: [], | |
| 37 | }); | |
| 38 | } | |
| 39 | }); | |
| 40 | it("finds the existing URL shortcut in Settings", () => { | |
| 41 | const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, "PR URL"); | |
| 42 | expect(rows).toEqual([ | |
| 43 | expect.objectContaining({ command: "thread.copyReference", key: "mod+shift+c" }), | |
| 44 | ]); | |
| 45 | }); | |
| 20 | 46 | it("builds searchable rows with readable key and when values", () => { |
| 21 | 47 | const rows = buildKeybindingRows( |
| 22 | 48 | [ |
apps/web/src/components/settings/KeybindingsSettings.logic.ts+2| ... | ... | @@ -209,6 +209,7 @@ export function buildKeybindingRows( |
| 209 | 209 | return rowsWithConflicts.filter((row) => { |
| 210 | 210 | return ( |
| 211 | 211 | row.command.toLowerCase().includes(normalizedQuery) || |
| 212 | commandLabel(row.command).toLowerCase().includes(normalizedQuery) || | |
| 212 | 213 | row.key.toLowerCase().includes(normalizedQuery) || |
| 213 | 214 | row.when.toLowerCase().includes(normalizedQuery) || |
| 214 | 215 | row.source.toLowerCase().includes(normalizedQuery) |
| ... | ... | @@ -275,6 +276,7 @@ export function buildKeybindingCommandOptions( |
| 275 | 276 | } |
| 276 | 277 | |
| 277 | 278 | export function commandLabel(command: KeybindingCommand): string { |
| 279 | if (command === "thread.copyReference") return "Thread: Copy PR URL or Thread ID"; | |
| 278 | 280 | const raw = String(command); |
| 279 | 281 | if (raw.startsWith("script.") && raw.endsWith(".run")) { |
| 280 | 282 | return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`; |
apps/web/src/components/settings/KeybindingsSettings.tsx+6-2| ... | ... | @@ -26,7 +26,7 @@ import { |
| 26 | 26 | type ServerRemoveKeybindingInput, |
| 27 | 27 | type ServerUpsertKeybindingInput, |
| 28 | 28 | } from "@t3tools/contracts"; |
| 29 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; | |
| 29 | import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings"; | |
| 30 | 30 | import { |
| 31 | 31 | isAtomCommandInterrupted, |
| 32 | 32 | squashAtomCommandFailure, |
| ... | ... | @@ -1334,7 +1334,11 @@ export function KeybindingsSettingsPanel() { |
| 1334 | 1334 | // fan out to every connected environment in the selection, so one |
| 1335 | 1335 | // shortcut change reaches each machine the user runs T3 Code on. |
| 1336 | 1336 | const { environment: primaryEnvironment, connectedEnvironments } = useSettingsScope(); |
| 1337 | const keybindings = primaryEnvironment?.serverConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; | |
| 1337 | const serverKeybindings = primaryEnvironment?.serverConfig?.keybindings; | |
| 1338 | const keybindings = useMemo( | |
| 1339 | () => mergeWithDefaultKeybindings(serverKeybindings ?? []), | |
| 1340 | [serverKeybindings], | |
| 1341 | ); | |
| 1338 | 1342 | const keybindingsConfigPath = primaryEnvironment?.serverConfig?.keybindingsConfigPath ?? null; |
| 1339 | 1343 | const availableEditors = primaryEnvironment?.serverConfig?.availableEditors ?? []; |
| 1340 | 1344 | const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { |
apps/web/src/keybindings.test.ts+196| ... | ... | @@ -1,4 +1,9 @@ |
| 1 | 1 | import { assert, describe, it } from "vite-plus/test"; |
| 2 | import { | |
| 3 | compileResolvedKeybindingsConfig, | |
| 4 | DEFAULT_RESOLVED_KEYBINDINGS, | |
| 5 | mergeWithDefaultKeybindings, | |
| 6 | } from "@t3tools/shared/keybindings"; | |
| 2 | 7 | |
| 3 | 8 | import { |
| 4 | 9 | type KeybindingCommand, |
| ... | ... | @@ -1057,3 +1062,194 @@ describe("plus key parsing", () => { |
| 1057 | 1062 | ); |
| 1058 | 1063 | }); |
| 1059 | 1064 | }); |
| 1065 | ||
| 1066 | describe("composer and pull request shortcuts", () => { | |
| 1067 | it("fills missing number shortcuts without replacing the saved URL binding", () => { | |
| 1068 | const olderServerBindings = DEFAULT_RESOLVED_KEYBINDINGS.filter( | |
| 1069 | (binding) => | |
| 1070 | binding.command !== "pullRequest.copyNumber" && binding.command !== "thread.copyReference", | |
| 1071 | ); | |
| 1072 | const bindings = mergeWithDefaultKeybindings([ | |
| 1073 | ...olderServerBindings, | |
| 1074 | ...compileResolvedKeybindingsConfig([ | |
| 1075 | { key: "mod+shift+8", command: "thread.copyReference", when: "!terminalFocus" }, | |
| 1076 | ]), | |
| 1077 | ]); | |
| 1078 | for (const [key, command] of [ | |
| 1079 | ["k", "pullRequest.copyNumber"], | |
| 1080 | ["8", "thread.copyReference"], | |
| 1081 | ["c", null], | |
| 1082 | ["y", null], | |
| 1083 | ] as const) { | |
| 1084 | assert.strictEqual( | |
| 1085 | resolveShortcutCommand(event({ key, metaKey: true, shiftKey: true }), bindings, { | |
| 1086 | platform: "MacIntel", | |
| 1087 | }), | |
| 1088 | command, | |
| 1089 | ); | |
| 1090 | } | |
| 1091 | }); | |
| 1092 | ||
| 1093 | it.each(["terminalOpen", "previewFocus", "previewOpen", "modelPickerOpen"])( | |
| 1094 | "honors custom PR shortcut conditions for %s", | |
| 1095 | (condition) => { | |
| 1096 | const bindings = compileResolvedKeybindingsConfig([ | |
| 1097 | { key: "mod+shift+k", command: "thread.copyReference", when: condition }, | |
| 1098 | { key: "mod+shift+k", command: "pullRequest.copyNumber", when: `!${condition}` }, | |
| 1099 | ]); | |
| 1100 | const input = event({ key: "k", ctrlKey: true, shiftKey: true }); | |
| 1101 | for (const enabled of [false, true]) { | |
| 1102 | assert.strictEqual( | |
| 1103 | resolveShortcutCommand(input, bindings, { | |
| 1104 | platform: "Linux", | |
| 1105 | context: { [condition]: enabled }, | |
| 1106 | }), | |
| 1107 | enabled ? "thread.copyReference" : "pullRequest.copyNumber", | |
| 1108 | ); | |
| 1109 | } | |
| 1110 | }, | |
| 1111 | ); | |
| 1112 | ||
| 1113 | const shortcuts = [ | |
| 1114 | ["h", "composer.host"], | |
| 1115 | ["e", "composer.effort"], | |
| 1116 | ["a", "composer.mode"], | |
| 1117 | ["x", "composer.workspace"], | |
| 1118 | ["g", "composer.branch"], | |
| 1119 | ["l", "composer.previousWorktree"], | |
| 1120 | ["c", "thread.copyReference"], | |
| 1121 | ["k", "pullRequest.copyNumber"], | |
| 1122 | ] as const; | |
| 1123 | ||
| 1124 | for (const platform of ["MacIntel", "Win32", "Linux"]) { | |
| 1125 | it.each(shortcuts)( | |
| 1126 | `resolves %s on ${platform} and leaves terminal input alone`, | |
| 1127 | (key, command) => { | |
| 1128 | const input = event({ | |
| 1129 | key, | |
| 1130 | shiftKey: true, | |
| 1131 | metaKey: platform === "MacIntel", | |
| 1132 | ctrlKey: platform !== "MacIntel", | |
| 1133 | }); | |
| 1134 | assert.strictEqual( | |
| 1135 | resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { | |
| 1136 | platform, | |
| 1137 | context: { terminalFocus: false }, | |
| 1138 | }), | |
| 1139 | command, | |
| 1140 | ); | |
| 1141 | assert.isNull( | |
| 1142 | resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { | |
| 1143 | platform, | |
| 1144 | context: { terminalFocus: true }, | |
| 1145 | }), | |
| 1146 | ); | |
| 1147 | }, | |
| 1148 | ); | |
| 1149 | } | |
| 1150 | ||
| 1151 | for (const platform of ["MacIntel", "Win32", "Linux"]) { | |
| 1152 | it.each([ | |
| 1153 | ["s", "thread.settle"], | |
| 1154 | ["p", "thread.pin"], | |
| 1155 | ])(`preserves the existing %s shortcut on ${platform}`, (key, command) => { | |
| 1156 | assert.strictEqual( | |
| 1157 | resolveShortcutCommand( | |
| 1158 | event({ | |
| 1159 | key, | |
| 1160 | shiftKey: true, | |
| 1161 | metaKey: platform === "MacIntel", | |
| 1162 | ctrlKey: platform !== "MacIntel", | |
| 1163 | }), | |
| 1164 | DEFAULT_RESOLVED_KEYBINDINGS, | |
| 1165 | { platform }, | |
| 1166 | ), | |
| 1167 | command, | |
| 1168 | ); | |
| 1169 | }); | |
| 1170 | } | |
| 1171 | ||
| 1172 | const altEffortBindings = compileResolvedKeybindingsConfig([ | |
| 1173 | { key: "mod+alt+e", command: "composer.effort", when: "!terminalFocus" }, | |
| 1174 | ]); | |
| 1175 | ||
| 1176 | it("leaves AltGr text entry alone with a custom Alt binding", () => { | |
| 1177 | for (const platform of ["Win32", "Linux"]) { | |
| 1178 | const input = event({ | |
| 1179 | key: "€", | |
| 1180 | code: "KeyE", | |
| 1181 | ctrlKey: true, | |
| 1182 | altKey: true, | |
| 1183 | getModifierState: (key) => key === "AltGraph", | |
| 1184 | }); | |
| 1185 | assert.isNull(resolveShortcutCommand(input, altEffortBindings, { platform })); | |
| 1186 | assert.strictEqual( | |
| 1187 | resolveShortcutCommand({ ...input, getModifierState: () => false }, altEffortBindings, { | |
| 1188 | platform, | |
| 1189 | }), | |
| 1190 | "composer.effort", | |
| 1191 | ); | |
| 1192 | } | |
| 1193 | }); | |
| 1194 | ||
| 1195 | it("keeps Firefox modifier reporting usable on Windows and macOS", () => { | |
| 1196 | const getModifierState = (key: string) => key === "AltGraph"; | |
| 1197 | assert.strictEqual( | |
| 1198 | resolveShortcutCommand( | |
| 1199 | event({ key: "e", ctrlKey: true, altKey: true, getModifierState }), | |
| 1200 | altEffortBindings, | |
| 1201 | { platform: "Win32" }, | |
| 1202 | ), | |
| 1203 | "composer.effort", | |
| 1204 | ); | |
| 1205 | assert.strictEqual( | |
| 1206 | resolveShortcutCommand( | |
| 1207 | event({ key: "´", code: "KeyE", metaKey: true, altKey: true, getModifierState }), | |
| 1208 | altEffortBindings, | |
| 1209 | { platform: "MacIntel" }, | |
| 1210 | ), | |
| 1211 | "composer.effort", | |
| 1212 | ); | |
| 1213 | }); | |
| 1214 | ||
| 1215 | it.each(shortcuts)("uses a custom binding for %s", (_key, command) => { | |
| 1216 | const bindings = compileResolvedKeybindingsConfig([ | |
| 1217 | { key: "mod+shift+y", command, when: "!terminalFocus" }, | |
| 1218 | ]); | |
| 1219 | assert.strictEqual( | |
| 1220 | resolveShortcutCommand( | |
| 1221 | event({ key: "Y", code: "KeyY", ctrlKey: true, shiftKey: true }), | |
| 1222 | bindings, | |
| 1223 | { platform: "Linux" }, | |
| 1224 | ), | |
| 1225 | command, | |
| 1226 | ); | |
| 1227 | }); | |
| 1228 | ||
| 1229 | for (const platform of ["MacIntel", "Win32", "Linux"]) { | |
| 1230 | it.each([ | |
| 1231 | ["ArrowUp", "modelPicker.previousProvider"], | |
| 1232 | ["ArrowDown", "modelPicker.nextProvider"], | |
| 1233 | ] as const)(`limits %s to the model picker on ${platform}`, (key, command) => { | |
| 1234 | const input = event({ | |
| 1235 | key, | |
| 1236 | shiftKey: true, | |
| 1237 | metaKey: platform === "MacIntel", | |
| 1238 | ctrlKey: platform !== "MacIntel", | |
| 1239 | }); | |
| 1240 | assert.strictEqual( | |
| 1241 | resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { | |
| 1242 | platform, | |
| 1243 | context: { modelPickerOpen: true }, | |
| 1244 | }), | |
| 1245 | command, | |
| 1246 | ); | |
| 1247 | assert.isNull( | |
| 1248 | resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { | |
| 1249 | platform, | |
| 1250 | context: { modelPickerOpen: false }, | |
| 1251 | }), | |
| 1252 | ); | |
| 1253 | }); | |
| 1254 | } | |
| 1255 | }); |
apps/web/src/keybindings.ts+7| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | import { isMacPlatform } from "./lib/utils"; |
| 12 | 12 | |
| 13 | 13 | export interface ShortcutEventLike { |
| 14 | getModifierState?: (key: "AltGraph") => boolean; | |
| 14 | 15 | type?: string; |
| 15 | 16 | code?: string; |
| 16 | 17 | key: string; |
| ... | ... | @@ -123,6 +124,12 @@ function matchesShortcut( |
| 123 | 124 | shortcut: KeybindingShortcut, |
| 124 | 125 | platform = navigator.platform, |
| 125 | 126 | ): boolean { |
| 127 | if ( | |
| 128 | !isMacPlatform(platform) && | |
| 129 | event.getModifierState?.("AltGraph") && | |
| 130 | !/^[a-z0-9]$/i.test(event.key) | |
| 131 | ) | |
| 132 | return false; | |
| 126 | 133 | if (!matchesShortcutModifiers(event, shortcut, platform)) return false; |
| 127 | 134 | return resolveEventKeys(event).has(shortcut.key); |
| 128 | 135 | } |
apps/web/src/routes/_chat.pull-requests.tsx+13-1| ... | ... | @@ -151,6 +151,16 @@ import { cn } from "~/lib/utils"; |
| 151 | 151 | import { primaryServerKeybindingsAtom } from "~/state/server"; |
| 152 | 152 | import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; |
| 153 | 153 | |
| 154 | function getShortcutContext() { | |
| 155 | return { | |
| 156 | terminalFocus: isTerminalFocused(), | |
| 157 | terminalOpen: false, | |
| 158 | previewFocus: false, | |
| 159 | previewOpen: false, | |
| 160 | modelPickerOpen: false, | |
| 161 | }; | |
| 162 | } | |
| 163 | ||
| 154 | 164 | export interface PullRequestsSearch extends PullRequestListPreferences { |
| 155 | 165 | /** |
| 156 | 166 | * Narrows the list to one server. Absent means every connected one, which is the default the |
| ... | ... | @@ -1925,7 +1935,7 @@ function PullRequestsRouteView() { |
| 1925 | 1935 | const onKeyDown = (event: KeyboardEvent) => { |
| 1926 | 1936 | if (event.defaultPrevented || isCommandPaletteOpen()) return; |
| 1927 | 1937 | const command = resolveShortcutCommand(event, keybindings, { |
| 1928 | context: { terminalFocus: isTerminalFocused() }, | |
| 1938 | context: getShortcutContext(), | |
| 1929 | 1939 | }); |
| 1930 | 1940 | if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event); |
| 1931 | 1941 | if (command === "rightPanel.toggle") toggleRightPanelFromShortcut(event); |
| ... | ... | @@ -1992,6 +2002,8 @@ function PullRequestsRouteView() { |
| 1992 | 2002 | pullRequestStatusSeeds={listedPullRequestTabStatuses} |
| 1993 | 2003 | > |
| 1994 | 2004 | <PullRequestDetailPanel |
| 2005 | getShortcutContext={getShortcutContext} | |
| 2006 | shortcutsEnabled={activePullRequestSurface?.id === renderedPullRequestSurface.id} | |
| 1995 | 2007 | key={renderedPullRequestSurface.id} |
| 1996 | 2008 | environmentId={panelEnvironmentId} |
| 1997 | 2009 | onSelectPullRequest={(reference) => { |
apps/web/src/state/server.ts+3-4| ... | ... | @@ -10,7 +10,7 @@ import { |
| 10 | 10 | } from "@t3tools/contracts"; |
| 11 | 11 | import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; |
| 12 | 12 | import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; |
| 13 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; | |
| 13 | import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings"; | |
| 14 | 14 | import * as Option from "effect/Option"; |
| 15 | 15 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 16 | 16 | |
| ... | ... | @@ -90,9 +90,8 @@ export const primaryServerProvidersAtom = Atom.make( |
| 90 | 90 | get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS, |
| 91 | 91 | ).pipe(Atom.withLabel("web-primary-server-providers")); |
| 92 | 92 | |
| 93 | export const primaryServerKeybindingsAtom = Atom.make( | |
| 94 | (get): ServerConfig["keybindings"] => | |
| 95 | get(primaryServerConfigAtom)?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS, | |
| 93 | export const primaryServerKeybindingsAtom = Atom.make((get): ServerConfig["keybindings"] => | |
| 94 | mergeWithDefaultKeybindings(get(primaryServerConfigAtom)?.keybindings ?? []), | |
| 96 | 95 | ).pipe(Atom.withLabel("web-primary-server-keybindings")); |
| 97 | 96 | |
| 98 | 97 | export const primaryServerAvailableEditorsAtom = Atom.make( |
docs/user/keybindings.md+25| ... | ... | @@ -3,6 +3,31 @@ |
| 3 | 3 | Customize shortcuts in **Settings → Keybindings** on web and desktop. That page |
| 4 | 4 | also lists the command IDs and defaults available in your version. |
| 5 | 5 | |
| 6 | ## Composer controls | |
| 7 | ||
| 8 | Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host. | |
| 9 | Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the | |
| 10 | workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the | |
| 11 | current checkout, a new worktree, and the previous worktree when available. | |
| 12 | Use `mod+shift+l` to reuse the previous worktree directly. | |
| 13 | ||
| 14 | In the model picker, press Left in an empty search field or Shift+Tab to reach | |
| 15 | the provider list. Use Up/Down to move and Enter to choose. Right returns to | |
| 16 | model search. `mod+shift+up` and `mod+shift+down` switch providers directly and clear the | |
| 17 | search. These provider shortcuts can also be changed in Settings. | |
| 18 | ||
| 19 | These shortcuts run inside the focused web or desktop client. `mod` uses Command | |
| 20 | on macOS and Ctrl on Windows and Linux, including GNOME, KDE Plasma, Niri, and | |
| 21 | Hyprland. If a custom desktop shortcut takes the same keys, choose another binding | |
| 22 | in Settings. | |
| 23 | ||
| 24 | ## Copy pull request references | |
| 25 | ||
| 26 | With a PR open in the right panel or on the Pull Requests page, use `mod+shift+c` | |
| 27 | to copy its URL and `mod+shift+k` to copy its number with a `#` prefix. | |
| 28 | Both shortcuts can be changed in Settings. Search for “Copy PR URL or Thread ID” | |
| 29 | or “Copy Number”. They copy the selected PR and leave terminal input alone. | |
| 30 | ||
| 6 | 31 | ## Edit the configuration file |
| 7 | 32 | |
| 8 | 33 | Keybindings live on the environment's machine, in |
packages/contracts/src/keybindings.test.ts+7| ... | ... | @@ -122,6 +122,13 @@ it.effect("parses keybinding rules", () => |
| 122 | 122 | }); |
| 123 | 123 | assert.strictEqual(parsedThreadCopyReference.command, "thread.copyReference"); |
| 124 | 124 | |
| 125 | const parsedPullRequestCopyNumber = yield* decode(KeybindingRule, { | |
| 126 | key: "mod+shift+k", | |
| 127 | command: "pullRequest.copyNumber", | |
| 128 | when: "!terminalFocus", | |
| 129 | }); | |
| 130 | assert.strictEqual(parsedPullRequestCopyNumber.command, "pullRequest.copyNumber"); | |
| 131 | ||
| 125 | 132 | const parsedThreadStop = yield* decode(KeybindingRule, { |
| 126 | 133 | key: "mod+escape", |
| 127 | 134 | command: "thread.stop", |
packages/contracts/src/keybindings.ts+9| ... | ... | @@ -47,6 +47,8 @@ export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number |
| 47 | 47 | |
| 48 | 48 | const MODEL_PICKER_KEYBINDING_COMMANDS = [ |
| 49 | 49 | "modelPicker.toggle", |
| 50 | "modelPicker.previousProvider", | |
| 51 | "modelPicker.nextProvider", | |
| 50 | 52 | ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, |
| 51 | 53 | ] as const; |
| 52 | 54 | export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number]; |
| ... | ... | @@ -61,6 +63,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ |
| 61 | 63 | "rightPanel.toggle", |
| 62 | 64 | "rightPanel.toggleMaximized", |
| 63 | 65 | "rightPanel.close", |
| 66 | "pullRequest.copyNumber", | |
| 64 | 67 | "diff.toggle", |
| 65 | 68 | "preview.toggle", |
| 66 | 69 | "preview.refresh", |
| ... | ... | @@ -73,6 +76,12 @@ export const STATIC_KEYBINDING_COMMANDS = [ |
| 73 | 76 | "projectSearch.toggle", |
| 74 | 77 | "themeEditor.toggle", |
| 75 | 78 | "composer.stash", |
| 79 | "composer.host", | |
| 80 | "composer.effort", | |
| 81 | "composer.mode", | |
| 82 | "composer.workspace", | |
| 83 | "composer.previousWorktree", | |
| 84 | "composer.branch", | |
| 76 | 85 | "chat.new", |
| 77 | 86 | "chat.newLocal", |
| 78 | 87 | "editor.openFavorite", |
packages/shared/src/keybindings.ts+29| ... | ... | @@ -44,6 +44,15 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 44 | 44 | { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, |
| 45 | 45 | { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, |
| 46 | 46 | { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, |
| 47 | { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" }, | |
| 48 | { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" }, | |
| 49 | { key: "mod+shift+a", command: "composer.mode", when: "!terminalFocus" }, | |
| 50 | { key: "mod+shift+x", command: "composer.workspace", when: "!terminalFocus" }, | |
| 51 | { key: "mod+shift+g", command: "composer.branch", when: "!terminalFocus" }, | |
| 52 | { key: "mod+shift+l", command: "composer.previousWorktree", when: "!terminalFocus" }, | |
| 53 | { key: "mod+shift+k", command: "pullRequest.copyNumber", when: "!terminalFocus" }, | |
| 54 | { key: "mod+shift+arrowup", command: "modelPicker.previousProvider", when: "modelPickerOpen" }, | |
| 55 | { key: "mod+shift+arrowdown", command: "modelPicker.nextProvider", when: "modelPickerOpen" }, | |
| 47 | 56 | { key: "mod+o", command: "editor.openFavorite" }, |
| 48 | 57 | { key: "mod+shift+[", command: "thread.previous" }, |
| 49 | 58 | { key: "mod+shift+]", command: "thread.next" }, |
| ... | ... | @@ -299,3 +308,23 @@ export function compileResolvedKeybindingsConfig( |
| 299 | 308 | } |
| 300 | 309 | |
| 301 | 310 | export const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); |
| 311 | ||
| 312 | export function mergeWithDefaultKeybindings( | |
| 313 | custom: ResolvedKeybindingsConfig, | |
| 314 | ): ResolvedKeybindingsConfig { | |
| 315 | if (custom.length === 0) { | |
| 316 | return [...DEFAULT_RESOLVED_KEYBINDINGS]; | |
| 317 | } | |
| 318 | ||
| 319 | const overriddenCommands = new Set(custom.map((binding) => binding.command)); | |
| 320 | const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( | |
| 321 | (binding) => !overriddenCommands.has(binding.command), | |
| 322 | ); | |
| 323 | const merged = [...retainedDefaults, ...custom]; | |
| 324 | ||
| 325 | if (merged.length <= MAX_KEYBINDINGS_COUNT) { | |
| 326 | return merged; | |
| 327 | } | |
| 328 | ||
| 329 | return merged.slice(-MAX_KEYBINDINGS_COUNT); | |
| 330 | } |