authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-14 21:10:20+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 15:10:20-03:00
log9130d932f4ddb8abab595b7d12553b46a11c7b22
tree9aeccfa9a844542051bfe2a2a5d9181b0e55d028
parent33118d9ab263904e4180b3bf2cbf0a36fe0695e2
signature Signed by PGP key B5690EEEBB952194

feat(web): add composer and PR number shortcuts (#11615)


24 files changed, 694 insertions(+), 49 deletions(-)

apps/server/src/keybindings.ts+1-20
...@@ -45,7 +45,7 @@ import { writeFileStringAtomically } from "./atomicWrite.ts";...@@ -45,7 +45,7 @@ import { writeFileStringAtomically } from "./atomicWrite.ts";
45import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";45import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson";
46import {46import {
47 DEFAULT_KEYBINDINGS,47 DEFAULT_KEYBINDINGS,
48 DEFAULT_RESOLVED_KEYBINDINGS,48 mergeWithDefaultKeybindings,
49 compileResolvedKeybindingRule,49 compileResolvedKeybindingRule,
50 compileResolvedKeybindingsConfig,50 compileResolvedKeybindingsConfig,
51 parseKeybindingShortcut,51 parseKeybindingShortcut,
...@@ -201,25 +201,6 @@ function invalidEntryIssue(index: number, detail: string): ServerConfigIssue {...@@ -201,25 +201,6 @@ function invalidEntryIssue(index: number, detail: string): ServerConfigIssue {
201 };201 };
202}202}
203203
204function 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 * Keybindings - Service tag for keybinding configuration operations.205 * Keybindings - Service tag for keybinding configuration operations.
225 */206 */
apps/web/src/components/BranchToolbar.tsx+47-2
...@@ -8,7 +8,17 @@ import {...@@ -8,7 +8,17 @@ import {
8 HistoryIcon,8 HistoryIcon,
9 ScaleIcon,9 ScaleIcon,
10} from "lucide-react";10} from "lucide-react";
11import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";11import {
12 type Ref,
13 memo,
14 useImperativeHandle,
15 useCallback,
16 useEffect,
17 useLayoutEffect,
18 useMemo,
19 useRef,
20 useState,
21} from "react";
1222
13import { useComposerDraftStore, type DraftId } from "../composerDraftStore";23import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
14import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";24import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
...@@ -25,7 +35,10 @@ import {...@@ -25,7 +35,10 @@ import {
25 resolvePreviousWorktreeSeed,35 resolvePreviousWorktreeSeed,
26 shouldShowEnvironmentIndicator,36 shouldShowEnvironmentIndicator,
27} from "./BranchToolbar.logic";37} from "./BranchToolbar.logic";
28import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector";38import {
39 BranchToolbarBranchSelector,
40 type BranchToolbarBranchSelectorHandle,
41} from "./BranchToolbarBranchSelector";
29import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector";42import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector";
30import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector";43import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector";
31import { Button } from "./ui/button";44import { Button } from "./ui/button";
...@@ -46,7 +59,13 @@ import { measureRestingComposerControls } from "./chat/restingComposerControlsMe...@@ -46,7 +59,13 @@ import { measureRestingComposerControls } from "./chat/restingComposerControlsMe
46import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout";59import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout";
47import { cn } from "~/lib/utils";60import { cn } from "~/lib/utils";
4861
62export interface BranchToolbarHandle {
63 openBranchPicker: () => void;
64 usePreviousWorktree: () => void;
65}
66
49interface BranchToolbarProps {67interface BranchToolbarProps {
68 ref?: Ref<BranchToolbarHandle>;
50 environmentId: EnvironmentId;69 environmentId: EnvironmentId;
51 threadId: ThreadId;70 threadId: ThreadId;
52 showGitControls: boolean;71 showGitControls: boolean;
...@@ -169,6 +188,10 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({...@@ -169,6 +188,10 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({
169 render={<Button variant="ghost" size="xs" />}188 render={<Button variant="ghost" size="xs" />}
170 className="min-w-0 max-w-[48%] flex-initial justify-start font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80"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 data-composer-context-control190 data-composer-context-control
191 data-composer-shortcut={[
192 showEnvironmentPicker && !envLocked ? "composer.host" : "",
193 !envModeLocked ? "composer.workspace" : "",
194 ].join(" ")}
172 >195 >
173 {triggerContent}196 {triggerContent}
174 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />197 <ChevronDownIcon className="size-3 shrink-0 opacity-50" />
...@@ -438,6 +461,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {...@@ -438,6 +461,7 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean {
438}461}
439462
440export const BranchToolbar = memo(function BranchToolbar({463export const BranchToolbar = memo(function BranchToolbar({
464 ref,
441 environmentId,465 environmentId,
442 threadId,466 threadId,
443 showGitControls,467 showGitControls,
...@@ -458,6 +482,7 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -458,6 +482,7 @@ export const BranchToolbar = memo(function BranchToolbar({
458 composerControlsHostRef,482 composerControlsHostRef,
459 contextStripVisible = true,483 contextStripVisible = true,
460}: BranchToolbarProps) {484}: BranchToolbarProps) {
485 const branchSelectorRef = useRef<BranchToolbarBranchSelectorHandle>(null);
461 const threadRef = useMemo(486 const threadRef = useMemo(
462 () => scopeThreadRef(environmentId, threadId),487 () => scopeThreadRef(environmentId, threadId),
463 [environmentId, threadId],488 [environmentId, threadId],
...@@ -518,6 +543,25 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -518,6 +543,25 @@ export const BranchToolbar = memo(function BranchToolbar({
518 });543 });
519 }, [activeProjectRef, draftId, previousWorktreeSeed, setDraftThreadContext, threadRef]);544 }, [activeProjectRef, draftId, previousWorktreeSeed, setDraftThreadContext, threadRef]);
520545
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 const showEnvironmentPicker = Boolean(565 const showEnvironmentPicker = Boolean(
522 availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange,566 availableEnvironments && availableEnvironments.length > 1 && onEnvironmentChange,
523 );567 );
...@@ -618,6 +662,7 @@ export const BranchToolbar = memo(function BranchToolbar({...@@ -618,6 +662,7 @@ export const BranchToolbar = memo(function BranchToolbar({
618662
619 {showGitControls ? (663 {showGitControls ? (
620 <BranchToolbarBranchSelector664 <BranchToolbarBranchSelector
665 ref={branchSelectorRef}
621 className="min-w-0 flex-initial justify-end @3xl/composer-surface:ml-auto"666 className="min-w-0 flex-initial justify-end @3xl/composer-surface:ml-auto"
622 environmentId={environmentId}667 environmentId={environmentId}
623 threadId={threadId}668 threadId={threadId}
apps/web/src/components/BranchToolbarBranchSelector.tsx+19
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15 useDeferredValue,15 useDeferredValue,
16 useEffect,16 useEffect,
17 useId,17 useId,
18 useImperativeHandle,
18 useLayoutEffect,19 useLayoutEffect,
19 useMemo,20 useMemo,
20 useOptimistic,21 useOptimistic,
...@@ -22,6 +23,7 @@ import {...@@ -22,6 +23,7 @@ import {
22 useState,23 useState,
23 useTransition,24 useTransition,
24 type MouseEvent as ReactMouseEvent,25 type MouseEvent as ReactMouseEvent,
26 type Ref,
25} from "react";27} from "react";
2628
27import { useComposerDraftStore, type DraftId } from "../composerDraftStore";29import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
...@@ -72,7 +74,12 @@ import {...@@ -72,7 +74,12 @@ import {
72import { stackedThreadToast, toastManager } from "./ui/toast";74import { stackedThreadToast, toastManager } from "./ui/toast";
73import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";75import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
7476
77export interface BranchToolbarBranchSelectorHandle {
78 open: () => void;
79}
80
75interface BranchToolbarBranchSelectorProps {81interface BranchToolbarBranchSelectorProps {
82 ref?: Ref<BranchToolbarBranchSelectorHandle>;
76 className?: string;83 className?: string;
77 environmentId: EnvironmentId;84 environmentId: EnvironmentId;
78 threadId: ThreadId;85 threadId: ThreadId;
...@@ -92,6 +99,7 @@ function toBranchActionErrorMessage(error: unknown): string {...@@ -92,6 +99,7 @@ function toBranchActionErrorMessage(error: unknown): string {
92}99}
93100
94export function BranchToolbarBranchSelector({101export function BranchToolbarBranchSelector({
102 ref,
95 className,103 className,
96 environmentId,104 environmentId,
97 threadId,105 threadId,
...@@ -535,6 +543,17 @@ export function BranchToolbarBranchSelector({...@@ -535,6 +543,17 @@ export function BranchToolbarBranchSelector({
535 }543 }
536 }, []);544 }, []);
537545
546 useImperativeHandle(
547 ref,
548 () => ({
549 open: () => {
550 if (isInitialBranchesLoadPending || isBranchActionPending) return;
551 handleOpenChange(true);
552 },
553 }),
554 [handleOpenChange, isBranchActionPending, isInitialBranchesLoadPending],
555 );
556
538 const [showTopBranchScrollFade, setShowTopBranchScrollFade] = useState(false);557 const [showTopBranchScrollFade, setShowTopBranchScrollFade] = useState(false);
539 const [showBottomBranchScrollFade, setShowBottomBranchScrollFade] = useState(false);558 const [showBottomBranchScrollFade, setShowBottomBranchScrollFade] = useState(false);
540 const fetchNextBranchPage = useCallback(() => {559 const fetchNextBranchPage = useCallback(() => {
apps/web/src/components/BranchToolbarEnvModeSelector.tsx+1
...@@ -93,6 +93,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe...@@ -93,6 +93,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe
93 size="xs"93 size="xs"
94 className="min-w-0 shrink font-normal text-xs!"94 className="min-w-0 shrink font-normal text-xs!"
95 aria-label="Workspace"95 aria-label="Workspace"
96 data-composer-shortcut="composer.workspace"
96 data-composer-context-control97 data-composer-context-control
97 >98 >
98 {effectiveEnvMode === "worktree" ? (99 {effectiveEnvMode === "worktree" ? (
apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+1
...@@ -95,6 +95,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir...@@ -95,6 +95,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir
95 size="xs"95 size="xs"
96 className="min-w-0 max-w-full font-normal text-xs!"96 className="min-w-0 max-w-full font-normal text-xs!"
97 aria-label="Run on"97 aria-label="Run on"
98 data-composer-shortcut="composer.host"
98 data-composer-context-control99 data-composer-context-control
99 >100 >
100 {autoEnvironmentLabel ? (101 {autoEnvironmentLabel ? (
apps/web/src/components/ChatView.tsx+47-10
...@@ -219,7 +219,7 @@ import {...@@ -219,7 +219,7 @@ import {
219 deriveAgentPanelModel,219 deriveAgentPanelModel,
220 foldSubagentActivities,220 foldSubagentActivities,
221} from "@t3tools/client-runtime/state/subagentRuntime";221} from "@t3tools/client-runtime/state/subagentRuntime";
222import { BranchToolbar } from "./BranchToolbar";222import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar";
223import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";223import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
224import ThreadTerminalDrawer from "./ThreadTerminalDrawer";224import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
225import {225import {
...@@ -1617,6 +1617,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -1617,6 +1617,7 @@ export default function ChatView(props: ChatViewProps) {
1617 const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]);1617 const composerTerminalContextsRef = useRef<TerminalContextDraft[]>([]);
1618 const localComposerRef = useRef<ChatComposerHandle | null>(null);1618 const localComposerRef = useRef<ChatComposerHandle | null>(null);
1619 const composerRef = useComposerHandleContext() ?? localComposerRef;1619 const composerRef = useComposerHandleContext() ?? localComposerRef;
1620 const branchToolbarRef = useRef<BranchToolbarHandle>(null);
1620 const pasteAsTextShortcutUntilRef = useRef(0);1621 const pasteAsTextShortcutUntilRef = useRef(0);
1621 const [restingComposerControlsHost, setRestingComposerControlsHost] =1622 const [restingComposerControlsHost, setRestingComposerControlsHost] =
1622 useState<HTMLDivElement | null>(null);1623 useState<HTMLDivElement | null>(null);
...@@ -6397,6 +6398,17 @@ export default function ChatView(props: ChatViewProps) {...@@ -6397,6 +6398,17 @@ export default function ChatView(props: ChatViewProps) {
6397 terminalUiOpenByThreadRef.current[activeThreadKey] = current;6398 terminalUiOpenByThreadRef.current[activeThreadKey] = current;
6398 }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]);6399 }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]);
63996400
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 useEffect(() => {6412 useEffect(() => {
6401 const handler = (event: globalThis.KeyboardEvent) => {6413 const handler = (event: globalThis.KeyboardEvent) => {
6402 if (preventRepeatedTerminalCloseShortcut(event, keybindings)) {6414 if (preventRepeatedTerminalCloseShortcut(event, keybindings)) {
...@@ -6417,13 +6429,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -6417,13 +6429,7 @@ export default function ChatView(props: ChatViewProps) {
6417 if (event.defaultPrevented && terminalFocusOwner === null) {6429 if (event.defaultPrevented && terminalFocusOwner === null) {
6418 return;6430 return;
6419 }6431 }
6420 const shortcutContext = {6432 const shortcutContext = getShortcutContext();
6421 terminalFocus: terminalFocusOwner !== null,
6422 terminalOpen: Boolean(terminalUiState.terminalOpen),
6423 previewFocus: isPreviewFocused(),
6424 previewOpen: previewPanelOpen,
6425 modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,
6426 };
64276433
6428 if (6434 if (
6429 !shortcutContext.terminalFocus &&6435 !shortcutContext.terminalFocus &&
...@@ -6588,7 +6594,33 @@ export default function ChatView(props: ChatViewProps) {...@@ -6588,7 +6594,33 @@ export default function ChatView(props: ChatViewProps) {
6588 if (command === "modelPicker.toggle") {6594 if (command === "modelPicker.toggle") {
6589 event.preventDefault();6595 event.preventDefault();
6590 event.stopPropagation();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 return;6624 return;
6593 }6625 }
65946626
...@@ -6643,7 +6675,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -6643,7 +6675,7 @@ export default function ChatView(props: ChatViewProps) {
6643 supportsSettlement,6675 supportsSettlement,
6644 confirmAndUnpinThread,6676 confirmAndUnpinThread,
6645 copyActiveThreadReference,6677 copyActiveThreadReference,
6646 previewPanelOpen,6678 getShortcutContext,
6647 toggleRightPanel,6679 toggleRightPanel,
6648 toggleRightPanelMaximized,6680 toggleRightPanelMaximized,
6649 toggleTerminalVisibility,6681 toggleTerminalVisibility,
...@@ -8708,6 +8740,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -8708,6 +8740,10 @@ export default function ChatView(props: ChatViewProps) {
8708 // reader's feet. A link the agent wrote can open any other one here, and that one has to be8740 // reader's feet. A link the agent wrote can open any other one here, and that one has to be
8709 // checkable out like it is anywhere else.8741 // checkable out like it is anywhere else.
8710 <PullRequestDetailPanel8742 <PullRequestDetailPanel
8743 getShortcutContext={getShortcutContext}
8744 shortcutsEnabled={
8745 rightPanelOpen && activeRightPanelSurface?.id === renderedRightPanelSurface.id
8746 }
8711 key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}8747 key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}
8712 environmentId={activeThread.environmentId}8748 environmentId={activeThread.environmentId}
8713 onSelectPullRequest={(reference) => {8749 onSelectPullRequest={(reference) => {
...@@ -9231,6 +9267,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -9231,6 +9267,7 @@ export default function ChatView(props: ChatViewProps) {
9231 {mountComposerContextStrip && (9267 {mountComposerContextStrip && (
9232 <div className="pointer-events-auto">9268 <div className="pointer-events-auto">
9233 <BranchToolbar9269 <BranchToolbar
9270 ref={branchToolbarRef}
9234 environmentId={activeThread.environmentId}9271 environmentId={activeThread.environmentId}
9235 threadId={activeThread.id}9272 threadId={activeThread.id}
9236 showGitControls={isGitRepo}9273 showGitControls={isGitRepo}
apps/web/src/components/chat/ChatComposer.tsx+26-1
...@@ -15,6 +15,7 @@ import {...@@ -15,6 +15,7 @@ import {
15} from "../../questionAttachments";15} from "../../questionAttachments";
16import type {16import type {
17 ApprovalRequestId,17 ApprovalRequestId,
18 KeybindingCommand,
18 AssistantCitation,19 AssistantCitation,
19 ChatFileAttachment,20 ChatFileAttachment,
20 EnvironmentId,21 EnvironmentId,
...@@ -62,7 +63,7 @@ import {...@@ -62,7 +63,7 @@ import {
62 useState,63 useState,
63 useSyncExternalStore,64 useSyncExternalStore,
64} from "react";65} from "react";
65import { createPortal } from "react-dom";66import { createPortal, flushSync } from "react-dom";
66import {67import {
67 clampCollapsedComposerCursor,68 clampCollapsedComposerCursor,
68 type ComposerSubmissionIntent,69 type ComposerSubmissionIntent,
...@@ -1103,6 +1104,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop...@@ -1103,6 +1104,7 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
1103 <TooltipTrigger1104 <TooltipTrigger
1104 render={1105 render={
1105 <ComposerSelectControl1106 <ComposerSelectControl
1107 data-composer-shortcut="composer.mode"
1106 size={size}1108 size={size}
1107 className={size === "xs" ? undefined : "font-medium"}1109 className={size === "xs" ? undefined : "font-medium"}
1108 aria-label="Runtime mode"1110 aria-label="Runtime mode"
...@@ -1231,6 +1233,7 @@ export interface ChatComposerHandle {...@@ -1231,6 +1233,7 @@ export interface ChatComposerHandle {
1231 ) => boolean;1233 ) => boolean;
1232 openModelPicker: () => void;1234 openModelPicker: () => void;
1233 toggleModelPicker: () => void;1235 toggleModelPicker: () => void;
1236 openControl: (command: KeybindingCommand) => void;
1234 isModelPickerOpen: () => boolean;1237 isModelPickerOpen: () => boolean;
1235 compactContext: () => void;1238 compactContext: () => void;
1236 readSnapshot: () => {1239 readSnapshot: () => {
...@@ -5743,6 +5746,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)...@@ -5743,6 +5746,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
5743 openModelPicker();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 compactContext: compactThreadContext,5771 compactContext: compactThreadContext,
5747 isModelPickerOpen: () => isComposerModelPickerOpen,5772 isModelPickerOpen: () => isComposerModelPickerOpen,
5748 readSnapshot: () => {5773 readSnapshot: () => {
apps/web/src/components/chat/CompactComposerControlsMenu.tsx+3
...@@ -40,6 +40,9 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls...@@ -40,6 +40,9 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
40 variant="ghost"40 variant="ghost"
41 className={size === "xs" ? "shrink-0" : "shrink-0 px-2"}41 className={size === "xs" ? "shrink-0" : "shrink-0 px-2"}
42 aria-label="More composer controls"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,6 +8,7 @@ import { describe, expect, it } from "vite-plus/test";
88
9import { deriveProviderInstanceEntries } from "../../providerInstances";9import { deriveProviderInstanceEntries } from "../../providerInstances";
10import {10import {
11 adjacentModelPickerProvider,
11 resolveModelPickerSelectedModel,12 resolveModelPickerSelectedModel,
12 shouldIncludeModelPickerOption,13 shouldIncludeModelPickerOption,
13 shouldOfferModelPickerSetup,14 shouldOfferModelPickerSetup,
...@@ -213,3 +214,74 @@ describe("shouldOfferModelPickerSetup", () => {...@@ -213,3 +214,74 @@ describe("shouldOfferModelPickerSetup", () => {
213 ).toBe(true);214 ).toBe(true);
214 });215 });
215});216});
217
218describe("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,6 +113,34 @@ export function shouldOfferModelPickerSetup(
113 );113 );
114}114}
115115
116export 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
116const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>();144const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>();
117145
118function ModelListSeparator() {146function ModelListSeparator() {
...@@ -687,6 +715,20 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -687,6 +715,20 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
687 platform: navigator.platform,715 platform: navigator.platform,
688 context: modelJumpShortcutContext,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 const jumpIndex = modelPickerJumpIndexFromCommand(command ?? "");732 const jumpIndex = modelPickerJumpIndexFromCommand(command ?? "");
691 if (jumpIndex === null) {733 if (jumpIndex === null) {
692 return;734 return;
...@@ -710,7 +752,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -710,7 +752,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
710 return () => {752 return () => {
711 window.removeEventListener("keydown", onWindowKeyDown, true);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 ]);
714766
715 useLayoutEffect(() => {767 useLayoutEffect(() => {
716 setShowTopScrollFade(false);768 setShowTopScrollFade(false);
...@@ -737,6 +789,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -737,6 +789,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
737 <ModelPickerSidebar789 <ModelPickerSidebar
738 selectedInstanceId={selectedInstanceId}790 selectedInstanceId={selectedInstanceId}
739 onSelectInstance={handleSelectInstance}791 onSelectInstance={handleSelectInstance}
792 onFocusSearch={focusSearchInput}
740 instanceEntries={sidebarInstanceEntries}793 instanceEntries={sidebarInstanceEntries}
741 showFavorites794 showFavorites
742 {...(selectableUnavailableInstanceIds ? { selectableUnavailableInstanceIds } : {})}795 {...(selectableUnavailableInstanceIds ? { selectableUnavailableInstanceIds } : {})}
...@@ -805,6 +858,28 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {...@@ -805,6 +858,28 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
805 value={searchQuery}858 value={searchQuery}
806 onChange={(e) => setSearchQuery(e.target.value)}859 onChange={(e) => setSearchQuery(e.target.value)}
807 onKeyDown={(e) => {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 if (e.key === "Escape") {883 if (e.key === "Escape") {
809 e.preventDefault();884 e.preventDefault();
810 e.stopPropagation();885 e.stopPropagation();
apps/web/src/components/chat/ModelPickerSidebar.tsx+24-6
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { Toolbar } from "@base-ui/react/toolbar";
1import { type ProviderInstanceId } from "@t3tools/contracts";2import { type ProviderInstanceId } from "@t3tools/contracts";
2import { memo, useLayoutEffect, useRef, useState } from "react";3import { memo, useLayoutEffect, useRef, useState } from "react";
3import { SparklesIcon, StarIcon } from "lucide-react";4import { SparklesIcon, StarIcon } from "lucide-react";
...@@ -43,6 +44,7 @@ const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug";...@@ -43,6 +44,7 @@ const PICKER_TOOLTIP_CLASS = "max-w-64 text-balance font-normal leading-snug";
43export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {44export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
44 selectedInstanceId: ProviderInstanceId | "favorites";45 selectedInstanceId: ProviderInstanceId | "favorites";
45 onSelectInstance: (instanceId: ProviderInstanceId | "favorites") => void;46 onSelectInstance: (instanceId: ProviderInstanceId | "favorites") => void;
47 onFocusSearch: () => void;
46 /**48 /**
47 * Instance entries to render as rail buttons. Each entry becomes one icon49 * Instance entries to render as rail buttons. Each entry becomes one icon
48 * keyed by `instanceId`, so the default built-in Codex and a user-authored50 * 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,7 +89,20 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
87 }, [props.instanceEntries, props.selectedInstanceId, showFavorites]);89 }, [props.instanceEntries, props.selectedInstanceId, showFavorites]);
8890
89 return (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 <div className="h-full overflow-y-auto overscroll-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">106 <div className="h-full overflow-y-auto overscroll-contain [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
92 <div ref={sidebarContentRef} className="relative flex min-h-full flex-col gap-1 p-1">107 <div ref={sidebarContentRef} className="relative flex min-h-full flex-col gap-1 p-1">
93 {selectedIndicatorTop !== null ? (108 {selectedIndicatorTop !== null ? (
...@@ -107,16 +122,17 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {...@@ -107,16 +122,17 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
107 <Tooltip>122 <Tooltip>
108 <TooltipTrigger123 <TooltipTrigger
109 render={124 render={
110 <button125 <Toolbar.Button
111 className={cn(126 className={cn(
112 "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",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 onClick={() => handleSelect("favorites")}129 onClick={() => handleSelect("favorites")}
115 type="button"130 type="button"
116 aria-label="Favorites"131 aria-label="Favorites"
132 aria-pressed={props.selectedInstanceId === "favorites"}
117 >133 >
118 <StarIcon className="size-5 fill-current shrink-0" aria-hidden />134 <StarIcon className="size-5 fill-current shrink-0" aria-hidden />
119 </button>135 </Toolbar.Button>
120 }136 }
121 />137 />
122 <TooltipPopup138 <TooltipPopup
...@@ -155,7 +171,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {...@@ -155,7 +171,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
155 : entry.displayName;171 : entry.displayName;
156172
157 const button = (173 const button = (
158 <button174 <Toolbar.Button
159 className={cn(175 className={cn(
160 "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",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 isDisabled && "opacity-50 cursor-not-allowed hover:bg-transparent",177 isDisabled && "opacity-50 cursor-not-allowed hover:bg-transparent",
...@@ -171,6 +187,8 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {...@@ -171,6 +187,8 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
171 setHoveredInstanceId((current) => (current === entry.instanceId ? null : current))187 setHoveredInstanceId((current) => (current === entry.instanceId ? null : current))
172 }188 }
173 disabled={isDisabled}189 disabled={isDisabled}
190 focusableWhenDisabled={!isDisabled}
191 aria-pressed={isSelected}
174 type="button"192 type="button"
175 aria-label={193 aria-label={
176 isUnavailable || isContextDisabled194 isUnavailable || isContextDisabled
...@@ -203,7 +221,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {...@@ -203,7 +221,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
203 <SparklesIcon className="size-2" />221 <SparklesIcon className="size-2" />
204 </span>222 </span>
205 ) : null}223 ) : null}
206 </button>224 </Toolbar.Button>
207 );225 );
208226
209 const trigger = isDisabled ? (227 const trigger = isDisabled ? (
...@@ -234,6 +252,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {...@@ -234,6 +252,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {
234 })}252 })}
235 </div>253 </div>
236 </div>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,6 +618,7 @@ export const TraitsPicker = memo(function TraitsPicker({
618 <MenuTrigger618 <MenuTrigger
619 render={619 render={
620 <ComposerControl620 <ComposerControl
621 data-composer-shortcut={isComposerOwned ? "composer.effort" : undefined}
621 variant={triggerVariant ?? "ghost"}622 variant={triggerVariant ?? "ghost"}
622 size={size}623 size={size}
623 className={cn(624 className={cn(
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+53-2
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";1import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
2import { useAtomValue } from "@effect/atom-react";
2import { usePullRequestStack } from "~/state/usePullRequestStack";3import { usePullRequestStack } from "~/state/usePullRequestStack";
3import { RefreshIcon } from "~/components/ui/refresh-icon";4import { RefreshIcon } from "~/components/ui/refresh-icon";
4import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment";5import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
...@@ -21,6 +22,7 @@ import {...@@ -21,6 +22,7 @@ import {
21 ArrowUpRightIcon,22 ArrowUpRightIcon,
22 BookOpenIcon,23 BookOpenIcon,
23 CircleDotIcon,24 CircleDotIcon,
25 CopyIcon,
24 ChevronDownIcon,26 ChevronDownIcon,
25 ExternalLinkIcon,27 ExternalLinkIcon,
26 FileDiffIcon,28 FileDiffIcon,
...@@ -49,6 +51,7 @@ import {...@@ -49,6 +51,7 @@ import {
49 type MouseEvent as ReactMouseEvent,51 type MouseEvent as ReactMouseEvent,
50 useCallback,52 useCallback,
51 useEffect,53 useEffect,
54 useEffectEvent,
52 useLayoutEffect,55 useLayoutEffect,
53 useMemo,56 useMemo,
54 useRef,57 useRef,
...@@ -57,7 +60,14 @@ import {...@@ -57,7 +60,14 @@ import {
5760
58import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";61import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
59import { useNewThreadHandler } from "~/hooks/useHandleNewThread";62import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
60import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard";63import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
64import { isCommandPaletteOpen } from "~/commandPaletteBus";
65import {
66 resolveShortcutCommand,
67 shortcutLabelForCommand,
68 type ShortcutMatchContext,
69} from "~/keybindings";
70import { primaryServerKeybindingsAtom } from "~/state/server";
61import { useClientSettings } from "~/hooks/useSettings";71import { useClientSettings } from "~/hooks/useSettings";
62import {72import {
63 deriveLogicalProjectKeyFromSettings,73 deriveLogicalProjectKeyFromSettings,
...@@ -104,6 +114,7 @@ import {...@@ -104,6 +114,7 @@ import {
104 MenuRadioGroup,114 MenuRadioGroup,
105 MenuRadioItem,115 MenuRadioItem,
106 MenuSeparator,116 MenuSeparator,
117 MenuShortcut,
107 MenuTrigger,118 MenuTrigger,
108} from "../ui/menu";119} from "../ui/menu";
109import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";120import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
...@@ -457,6 +468,8 @@ function PullRequestBaseFreshnessWarning({...@@ -457,6 +468,8 @@ function PullRequestBaseFreshnessWarning({
457468
458export function PullRequestDetailPanel({469export function PullRequestDetailPanel({
459 environmentId,470 environmentId,
471 shortcutsEnabled,
472 getShortcutContext,
460 threadRef = null,473 threadRef = null,
461 reference: requestedReference,474 reference: requestedReference,
462 listEntry = null,475 listEntry = null,
...@@ -469,6 +482,8 @@ export function PullRequestDetailPanel({...@@ -469,6 +482,8 @@ export function PullRequestDetailPanel({
469 onSelectPullRequest,482 onSelectPullRequest,
470}: {483}: {
471 environmentId: EnvironmentId;484 environmentId: EnvironmentId;
485 shortcutsEnabled: boolean;
486 getShortcutContext: () => ShortcutMatchContext;
472 onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined;487 onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined;
473 /**488 /**
474 * The thread this panel sits beside, if any. Links that are not the pull489 * The thread this panel sits beside, if any. Links that are not the pull
...@@ -707,6 +722,32 @@ export function PullRequestDetailPanel({...@@ -707,6 +722,32 @@ export function PullRequestDetailPanel({
707 },722 },
708 [activity, coreDetail],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 useEffect(() => {751 useEffect(() => {
711 if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod);752 if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod);
712 }, [detail?.autoMergeMethod, pullRequestKey]);753 }, [detail?.autoMergeMethod, pullRequestKey]);
...@@ -2080,9 +2121,19 @@ export function PullRequestDetailPanel({...@@ -2080,9 +2121,19 @@ export function PullRequestDetailPanel({
2080 <ArrowUpRightIcon className="size-3.5" />2121 <ArrowUpRightIcon className="size-3.5" />
2081 {openOnHostLabel(detail.provider)}2122 {openOnHostLabel(detail.provider)}
2082 </MenuItem>2123 </MenuItem>
2083 <MenuItem onClick={() => void writeTextToClipboard(detail.url)}>2124 <MenuItem onClick={() => copyReference(detail.url, "PR link")}>
2084 <LinkIcon className="size-3.5" />2125 <LinkIcon className="size-3.5" />
2085 Copy link2126 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 </MenuItem>2137 </MenuItem>
2087 {detail.state === "open" && can("close") ? (2138 {detail.state === "open" && can("close") ? (
2088 <>2139 <>
apps/web/src/components/settings/KeybindingsSettings.logic.test.ts+26
...@@ -17,6 +17,32 @@ import {...@@ -17,6 +17,32 @@ import {
17} from "./KeybindingsSettings.logic";17} from "./KeybindingsSettings.logic";
1818
19describe("KeybindingsSettings.logic", () => {19describe("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 it("builds searchable rows with readable key and when values", () => {46 it("builds searchable rows with readable key and when values", () => {
21 const rows = buildKeybindingRows(47 const rows = buildKeybindingRows(
22 [48 [
apps/web/src/components/settings/KeybindingsSettings.logic.ts+2
...@@ -209,6 +209,7 @@ export function buildKeybindingRows(...@@ -209,6 +209,7 @@ export function buildKeybindingRows(
209 return rowsWithConflicts.filter((row) => {209 return rowsWithConflicts.filter((row) => {
210 return (210 return (
211 row.command.toLowerCase().includes(normalizedQuery) ||211 row.command.toLowerCase().includes(normalizedQuery) ||
212 commandLabel(row.command).toLowerCase().includes(normalizedQuery) ||
212 row.key.toLowerCase().includes(normalizedQuery) ||213 row.key.toLowerCase().includes(normalizedQuery) ||
213 row.when.toLowerCase().includes(normalizedQuery) ||214 row.when.toLowerCase().includes(normalizedQuery) ||
214 row.source.toLowerCase().includes(normalizedQuery)215 row.source.toLowerCase().includes(normalizedQuery)
...@@ -275,6 +276,7 @@ export function buildKeybindingCommandOptions(...@@ -275,6 +276,7 @@ export function buildKeybindingCommandOptions(
275}276}
276277
277export function commandLabel(command: KeybindingCommand): string {278export function commandLabel(command: KeybindingCommand): string {
279 if (command === "thread.copyReference") return "Thread: Copy PR URL or Thread ID";
278 const raw = String(command);280 const raw = String(command);
279 if (raw.startsWith("script.") && raw.endsWith(".run")) {281 if (raw.startsWith("script.") && raw.endsWith(".run")) {
280 return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`;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,7 +26,7 @@ import {
26 type ServerRemoveKeybindingInput,26 type ServerRemoveKeybindingInput,
27 type ServerUpsertKeybindingInput,27 type ServerUpsertKeybindingInput,
28} from "@t3tools/contracts";28} from "@t3tools/contracts";
29import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";29import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings";
30import {30import {
31 isAtomCommandInterrupted,31 isAtomCommandInterrupted,
32 squashAtomCommandFailure,32 squashAtomCommandFailure,
...@@ -1334,7 +1334,11 @@ export function KeybindingsSettingsPanel() {...@@ -1334,7 +1334,11 @@ export function KeybindingsSettingsPanel() {
1334 // fan out to every connected environment in the selection, so one1334 // fan out to every connected environment in the selection, so one
1335 // shortcut change reaches each machine the user runs T3 Code on.1335 // shortcut change reaches each machine the user runs T3 Code on.
1336 const { environment: primaryEnvironment, connectedEnvironments } = useSettingsScope();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 const keybindingsConfigPath = primaryEnvironment?.serverConfig?.keybindingsConfigPath ?? null;1342 const keybindingsConfigPath = primaryEnvironment?.serverConfig?.keybindingsConfigPath ?? null;
1339 const availableEditors = primaryEnvironment?.serverConfig?.availableEditors ?? [];1343 const availableEditors = primaryEnvironment?.serverConfig?.availableEditors ?? [];
1340 const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {1344 const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, {
apps/web/src/keybindings.test.ts+196
...@@ -1,4 +1,9 @@...@@ -1,4 +1,9 @@
1import { assert, describe, it } from "vite-plus/test";1import { assert, describe, it } from "vite-plus/test";
2import {
3 compileResolvedKeybindingsConfig,
4 DEFAULT_RESOLVED_KEYBINDINGS,
5 mergeWithDefaultKeybindings,
6} from "@t3tools/shared/keybindings";
27
3import {8import {
4 type KeybindingCommand,9 type KeybindingCommand,
...@@ -1057,3 +1062,194 @@ describe("plus key parsing", () => {...@@ -1057,3 +1062,194 @@ describe("plus key parsing", () => {
1057 );1062 );
1058 });1063 });
1059});1064});
1065
1066describe("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,6 +11,7 @@ import {
11import { isMacPlatform } from "./lib/utils";11import { isMacPlatform } from "./lib/utils";
1212
13export interface ShortcutEventLike {13export interface ShortcutEventLike {
14 getModifierState?: (key: "AltGraph") => boolean;
14 type?: string;15 type?: string;
15 code?: string;16 code?: string;
16 key: string;17 key: string;
...@@ -123,6 +124,12 @@ function matchesShortcut(...@@ -123,6 +124,12 @@ function matchesShortcut(
123 shortcut: KeybindingShortcut,124 shortcut: KeybindingShortcut,
124 platform = navigator.platform,125 platform = navigator.platform,
125): boolean {126): boolean {
127 if (
128 !isMacPlatform(platform) &&
129 event.getModifierState?.("AltGraph") &&
130 !/^[a-z0-9]$/i.test(event.key)
131 )
132 return false;
126 if (!matchesShortcutModifiers(event, shortcut, platform)) return false;133 if (!matchesShortcutModifiers(event, shortcut, platform)) return false;
127 return resolveEventKeys(event).has(shortcut.key);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,6 +151,16 @@ import { cn } from "~/lib/utils";
151import { primaryServerKeybindingsAtom } from "~/state/server";151import { primaryServerKeybindingsAtom } from "~/state/server";
152import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";152import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
153153
154function getShortcutContext() {
155 return {
156 terminalFocus: isTerminalFocused(),
157 terminalOpen: false,
158 previewFocus: false,
159 previewOpen: false,
160 modelPickerOpen: false,
161 };
162}
163
154export interface PullRequestsSearch extends PullRequestListPreferences {164export interface PullRequestsSearch extends PullRequestListPreferences {
155 /**165 /**
156 * Narrows the list to one server. Absent means every connected one, which is the default the166 * Narrows the list to one server. Absent means every connected one, which is the default the
...@@ -1925,7 +1935,7 @@ function PullRequestsRouteView() {...@@ -1925,7 +1935,7 @@ function PullRequestsRouteView() {
1925 const onKeyDown = (event: KeyboardEvent) => {1935 const onKeyDown = (event: KeyboardEvent) => {
1926 if (event.defaultPrevented || isCommandPaletteOpen()) return;1936 if (event.defaultPrevented || isCommandPaletteOpen()) return;
1927 const command = resolveShortcutCommand(event, keybindings, {1937 const command = resolveShortcutCommand(event, keybindings, {
1928 context: { terminalFocus: isTerminalFocused() },1938 context: getShortcutContext(),
1929 });1939 });
1930 if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event);1940 if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event);
1931 if (command === "rightPanel.toggle") toggleRightPanelFromShortcut(event);1941 if (command === "rightPanel.toggle") toggleRightPanelFromShortcut(event);
...@@ -1992,6 +2002,8 @@ function PullRequestsRouteView() {...@@ -1992,6 +2002,8 @@ function PullRequestsRouteView() {
1992 pullRequestStatusSeeds={listedPullRequestTabStatuses}2002 pullRequestStatusSeeds={listedPullRequestTabStatuses}
1993 >2003 >
1994 <PullRequestDetailPanel2004 <PullRequestDetailPanel
2005 getShortcutContext={getShortcutContext}
2006 shortcutsEnabled={activePullRequestSurface?.id === renderedPullRequestSurface.id}
1995 key={renderedPullRequestSurface.id}2007 key={renderedPullRequestSurface.id}
1996 environmentId={panelEnvironmentId}2008 environmentId={panelEnvironmentId}
1997 onSelectPullRequest={(reference) => {2009 onSelectPullRequest={(reference) => {
apps/web/src/state/server.ts+3-4
...@@ -10,7 +10,7 @@ import {...@@ -10,7 +10,7 @@ import {
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server";11import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server";
12import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell";12import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell";
13import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";13import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings";
14import * as Option from "effect/Option";14import * as Option from "effect/Option";
15import { AsyncResult, Atom } from "effect/unstable/reactivity";15import { AsyncResult, Atom } from "effect/unstable/reactivity";
1616
...@@ -90,9 +90,8 @@ export const primaryServerProvidersAtom = Atom.make(...@@ -90,9 +90,8 @@ export const primaryServerProvidersAtom = Atom.make(
90 get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS,90 get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS,
91).pipe(Atom.withLabel("web-primary-server-providers"));91).pipe(Atom.withLabel("web-primary-server-providers"));
9292
93export const primaryServerKeybindingsAtom = Atom.make(93export const primaryServerKeybindingsAtom = Atom.make((get): ServerConfig["keybindings"] =>
94 (get): ServerConfig["keybindings"] =>94 mergeWithDefaultKeybindings(get(primaryServerConfigAtom)?.keybindings ?? []),
95 get(primaryServerConfigAtom)?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS,
96).pipe(Atom.withLabel("web-primary-server-keybindings"));95).pipe(Atom.withLabel("web-primary-server-keybindings"));
9796
98export const primaryServerAvailableEditorsAtom = Atom.make(97export const primaryServerAvailableEditorsAtom = Atom.make(
docs/user/keybindings.md+25
...@@ -3,6 +3,31 @@...@@ -3,6 +3,31 @@
3Customize shortcuts in **Settings → Keybindings** on web and desktop. That page3Customize shortcuts in **Settings → Keybindings** on web and desktop. That page
4also lists the command IDs and defaults available in your version.4also lists the command IDs and defaults available in your version.
55
6## Composer controls
7
8Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host.
9Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the
10workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the
11current checkout, a new worktree, and the previous worktree when available.
12Use `mod+shift+l` to reuse the previous worktree directly.
13
14In the model picker, press Left in an empty search field or Shift+Tab to reach
15the provider list. Use Up/Down to move and Enter to choose. Right returns to
16model search. `mod+shift+up` and `mod+shift+down` switch providers directly and clear the
17search. These provider shortcuts can also be changed in Settings.
18
19These shortcuts run inside the focused web or desktop client. `mod` uses Command
20on macOS and Ctrl on Windows and Linux, including GNOME, KDE Plasma, Niri, and
21Hyprland. If a custom desktop shortcut takes the same keys, choose another binding
22in Settings.
23
24## Copy pull request references
25
26With a PR open in the right panel or on the Pull Requests page, use `mod+shift+c`
27to copy its URL and `mod+shift+k` to copy its number with a `#` prefix.
28Both shortcuts can be changed in Settings. Search for “Copy PR URL or Thread ID”
29or “Copy Number”. They copy the selected PR and leave terminal input alone.
30
6## Edit the configuration file31## Edit the configuration file
732
8Keybindings live on the environment's machine, in33Keybindings live on the environment's machine, in
packages/contracts/src/keybindings.test.ts+7
...@@ -122,6 +122,13 @@ it.effect("parses keybinding rules", () =>...@@ -122,6 +122,13 @@ it.effect("parses keybinding rules", () =>
122 });122 });
123 assert.strictEqual(parsedThreadCopyReference.command, "thread.copyReference");123 assert.strictEqual(parsedThreadCopyReference.command, "thread.copyReference");
124124
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 const parsedThreadStop = yield* decode(KeybindingRule, {132 const parsedThreadStop = yield* decode(KeybindingRule, {
126 key: "mod+escape",133 key: "mod+escape",
127 command: "thread.stop",134 command: "thread.stop",
packages/contracts/src/keybindings.ts+9
...@@ -47,6 +47,8 @@ export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number...@@ -47,6 +47,8 @@ export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number
4747
48const MODEL_PICKER_KEYBINDING_COMMANDS = [48const MODEL_PICKER_KEYBINDING_COMMANDS = [
49 "modelPicker.toggle",49 "modelPicker.toggle",
50 "modelPicker.previousProvider",
51 "modelPicker.nextProvider",
50 ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS,52 ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS,
51] as const;53] as const;
52export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number];54export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number];
...@@ -61,6 +63,7 @@ export const STATIC_KEYBINDING_COMMANDS = [...@@ -61,6 +63,7 @@ export const STATIC_KEYBINDING_COMMANDS = [
61 "rightPanel.toggle",63 "rightPanel.toggle",
62 "rightPanel.toggleMaximized",64 "rightPanel.toggleMaximized",
63 "rightPanel.close",65 "rightPanel.close",
66 "pullRequest.copyNumber",
64 "diff.toggle",67 "diff.toggle",
65 "preview.toggle",68 "preview.toggle",
66 "preview.refresh",69 "preview.refresh",
...@@ -73,6 +76,12 @@ export const STATIC_KEYBINDING_COMMANDS = [...@@ -73,6 +76,12 @@ export const STATIC_KEYBINDING_COMMANDS = [
73 "projectSearch.toggle",76 "projectSearch.toggle",
74 "themeEditor.toggle",77 "themeEditor.toggle",
75 "composer.stash",78 "composer.stash",
79 "composer.host",
80 "composer.effort",
81 "composer.mode",
82 "composer.workspace",
83 "composer.previousWorktree",
84 "composer.branch",
76 "chat.new",85 "chat.new",
77 "chat.newLocal",86 "chat.newLocal",
78 "editor.openFavorite",87 "editor.openFavorite",
packages/shared/src/keybindings.ts+29
...@@ -44,6 +44,15 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [...@@ -44,6 +44,15 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
44 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },44 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
45 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },45 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
46 { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" },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 { key: "mod+o", command: "editor.openFavorite" },56 { key: "mod+o", command: "editor.openFavorite" },
48 { key: "mod+shift+[", command: "thread.previous" },57 { key: "mod+shift+[", command: "thread.previous" },
49 { key: "mod+shift+]", command: "thread.next" },58 { key: "mod+shift+]", command: "thread.next" },
...@@ -299,3 +308,23 @@ export function compileResolvedKeybindingsConfig(...@@ -299,3 +308,23 @@ export function compileResolvedKeybindingsConfig(
299}308}
300309
301export const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS);310export const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS);
311
312export 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}