From 4e5348de18e9d2fdc6357a6ac78f5a3c1d2f59cd Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:39:33 +0200 Subject: [PATCH 1/9] feat(web): add composer keyboard controls --- apps/web/src/components/BranchToolbar.tsx | 40 +++++++++- .../BranchToolbarBranchSelector.tsx | 1 + .../BranchToolbarEnvModeSelector.tsx | 1 + .../BranchToolbarEnvironmentSelector.tsx | 1 + apps/web/src/components/ChatView.tsx | 26 ++++++- apps/web/src/components/chat/ChatComposer.tsx | 27 ++++++- .../chat/CompactComposerControlsMenu.tsx | 3 + .../chat/ModelPickerContent.test.ts | 72 +++++++++++++++++ .../components/chat/ModelPickerContent.tsx | 77 ++++++++++++++++++- .../components/chat/ModelPickerSidebar.tsx | 30 ++++++-- apps/web/src/components/chat/TraitsPicker.tsx | 1 + .../KeybindingsSettings.logic.test.ts | 18 +++++ apps/web/src/keybindings.test.ts | 75 ++++++++++++++++++ docs/user/keybindings.md | 13 ++++ packages/contracts/src/keybindings.ts | 8 ++ packages/shared/src/keybindings.ts | 8 ++ 16 files changed, 390 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 3e3f834658ea..9771efcfa85a 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -8,7 +8,17 @@ import { HistoryIcon, ScaleIcon, } from "lucide-react"; -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + type Ref, + memo, + useImperativeHandle, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; @@ -46,7 +56,12 @@ import { measureRestingComposerControls } from "./chat/restingComposerControlsMe import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; import { cn } from "~/lib/utils"; +export interface BranchToolbarHandle { + usePreviousWorktree: () => void; +} + interface BranchToolbarProps { + ref?: Ref; environmentId: EnvironmentId; threadId: ThreadId; showGitControls: boolean; @@ -169,6 +184,10 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ render={ + } /> (current === entry.instanceId ? null : current)) } disabled={isDisabled} + focusableWhenDisabled={!isDisabled} + aria-pressed={isSelected} type="button" aria-label={ isUnavailable || isContextDisabled @@ -203,7 +221,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { ) : null} - + ); const trigger = isDisabled ? ( @@ -234,6 +252,6 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { })} - + ); }); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 47ce59efde52..0a863e0bd446 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -618,6 +618,7 @@ export const TraitsPicker = memo(function TraitsPicker({ { + it("lists all composer controls and provider navigation with editable defaults", () => { + const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, ""); + for (const command of [ + "composer.host", + "composer.effort", + "composer.mode", + "composer.workspace", + "composer.branch", + "composer.previousWorktree", + "modelPicker.previousProvider", + "modelPicker.nextProvider", + ]) { + expect(rows.find((row) => row.command === command)).toMatchObject({ + source: "Default", + conflicts: [], + }); + } + }); it("builds searchable rows with readable key and when values", () => { const rows = buildKeybindingRows( [ diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 598e08e59770..d2e2755b9455 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1,4 +1,8 @@ import { assert, describe, it } from "vite-plus/test"; +import { + compileResolvedKeybindingsConfig, + DEFAULT_RESOLVED_KEYBINDINGS, +} from "@t3tools/shared/keybindings"; import { type KeybindingCommand, @@ -1057,3 +1061,74 @@ describe("plus key parsing", () => { ); }); }); + +describe("composer control shortcuts", () => { + const shortcuts = [ + ["h", "composer.host", true], + ["e", "composer.effort", false], + ["a", "composer.mode", false], + ["t", "composer.workspace", false], + ["g", "composer.branch", false], + ["p", "composer.previousWorktree", false], + ] as const; + + for (const platform of ["MacIntel", "Linux"]) { + it.each(shortcuts)( + `resolves %s on ${platform} and leaves terminal input alone`, + (key, command, shiftKey) => { + const input = event({ + key, + shiftKey, + altKey: !shiftKey, + metaKey: platform === "MacIntel", + ctrlKey: platform !== "MacIntel", + }); + assert.strictEqual( + resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { + platform, + context: { terminalFocus: false }, + }), + command, + ); + assert.isNull( + resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { + platform, + context: { terminalFocus: true }, + }), + ); + }, + ); + } + + it.each(shortcuts)("uses a custom binding for %s", (_key, command) => { + const bindings = compileResolvedKeybindingsConfig([ + { key: "mod+shift+y", command, when: "!terminalFocus" }, + ]); + assert.strictEqual( + resolveShortcutCommand( + event({ key: "Y", code: "KeyY", ctrlKey: true, shiftKey: true }), + bindings, + { platform: "Linux" }, + ), + command, + ); + }); + + it.each([ + ["ArrowUp", "modelPicker.previousProvider"], + ["ArrowDown", "modelPicker.nextProvider"], + ] as const)("limits %s to the model picker", (key, command) => { + const input = event({ key, altKey: true }); + assert.strictEqual( + resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { + context: { modelPickerOpen: true }, + }), + command, + ); + assert.isNull( + resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { + context: { modelPickerOpen: false }, + }), + ); + }); +}); diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 4e46e97a3a56..1ee54e14e401 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -3,6 +3,19 @@ Customize shortcuts in **Settings → Keybindings** on web and desktop. That page also lists the command IDs and defaults available in your version. +## Composer controls + +Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host. +Use `mod+alt+e` for effort, `mod+alt+a` for access mode, `mod+alt+t` for the +workspace, and `mod+alt+g` for the Git branch. The workspace menu includes the +current checkout, a new worktree, and the previous worktree when available. +Use `mod+alt+p` to reuse the previous worktree directly. + +In the model picker, press Left in an empty search field or Shift+Tab to reach +the provider list. Use Up/Down to move and Enter to choose. Right returns to +model search. `alt+up` and `alt+down` switch providers directly and clear the +search. These provider shortcuts can also be changed in Settings. + ## Edit the configuration file Keybindings live on the environment's machine, in diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 2be8aa91f7aa..dd65be9156af 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -47,6 +47,8 @@ export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number const MODEL_PICKER_KEYBINDING_COMMANDS = [ "modelPicker.toggle", + "modelPicker.previousProvider", + "modelPicker.nextProvider", ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, ] as const; export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number]; @@ -73,6 +75,12 @@ export const STATIC_KEYBINDING_COMMANDS = [ "projectSearch.toggle", "themeEditor.toggle", "composer.stash", + "composer.host", + "composer.effort", + "composer.mode", + "composer.workspace", + "composer.previousWorktree", + "composer.branch", "chat.new", "chat.newLocal", "editor.openFavorite", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 1107873c8285..8b467caafdfc 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -44,6 +44,14 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, + { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" }, + { key: "mod+alt+e", command: "composer.effort", when: "!terminalFocus" }, + { key: "mod+alt+a", command: "composer.mode", when: "!terminalFocus" }, + { key: "mod+alt+t", command: "composer.workspace", when: "!terminalFocus" }, + { key: "mod+alt+g", command: "composer.branch", when: "!terminalFocus" }, + { key: "mod+alt+p", command: "composer.previousWorktree", when: "!terminalFocus" }, + { key: "alt+arrowup", command: "modelPicker.previousProvider", when: "modelPickerOpen" }, + { key: "alt+arrowdown", command: "modelPicker.nextProvider", when: "modelPickerOpen" }, { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, From 06ad13460069ad45e6a4f537bd0e7ceb56946b96 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:25:08 +0200 Subject: [PATCH 2/9] fix(web): avoid desktop and AltGr shortcut conflicts --- apps/web/src/keybindings.test.ts | 45 ++++++++++++++++++++++++++++-- apps/web/src/keybindings.ts | 7 +++++ docs/user/keybindings.md | 7 ++++- packages/shared/src/keybindings.ts | 2 +- 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index d2e2755b9455..ea4c7e6a7463 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1067,12 +1067,12 @@ describe("composer control shortcuts", () => { ["h", "composer.host", true], ["e", "composer.effort", false], ["a", "composer.mode", false], - ["t", "composer.workspace", false], + ["s", "composer.workspace", false], ["g", "composer.branch", false], ["p", "composer.previousWorktree", false], ] as const; - for (const platform of ["MacIntel", "Linux"]) { + for (const platform of ["MacIntel", "Win32", "Linux"]) { it.each(shortcuts)( `resolves %s on ${platform} and leaves terminal input alone`, (key, command, shiftKey) => { @@ -1100,6 +1100,47 @@ describe("composer control shortcuts", () => { ); } + it("leaves AltGr text entry alone on Windows and Linux", () => { + for (const platform of ["Win32", "Linux"]) { + const input = event({ + key: "€", + code: "KeyE", + ctrlKey: true, + altKey: true, + getModifierState: (key) => key === "AltGraph", + }); + assert.isNull(resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { platform })); + assert.strictEqual( + resolveShortcutCommand( + { ...input, getModifierState: () => false }, + DEFAULT_RESOLVED_KEYBINDINGS, + { platform }, + ), + "composer.effort", + ); + } + }); + + it("keeps Firefox modifier reporting usable on Windows and macOS", () => { + const getModifierState = (key: string) => key === "AltGraph"; + assert.strictEqual( + resolveShortcutCommand( + event({ key: "e", ctrlKey: true, altKey: true, getModifierState }), + DEFAULT_RESOLVED_KEYBINDINGS, + { platform: "Win32" }, + ), + "composer.effort", + ); + assert.strictEqual( + resolveShortcutCommand( + event({ key: "´", code: "KeyE", metaKey: true, altKey: true, getModifierState }), + DEFAULT_RESOLVED_KEYBINDINGS, + { platform: "MacIntel" }, + ), + "composer.effort", + ); + }); + it.each(shortcuts)("uses a custom binding for %s", (_key, command) => { const bindings = compileResolvedKeybindingsConfig([ { key: "mod+shift+y", command, when: "!terminalFocus" }, diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index b9a5b728289e..8683ad3c68a5 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -11,6 +11,7 @@ import { import { isMacPlatform } from "./lib/utils"; export interface ShortcutEventLike { + getModifierState?: (key: "AltGraph") => boolean; type?: string; code?: string; key: string; @@ -123,6 +124,12 @@ function matchesShortcut( shortcut: KeybindingShortcut, platform = navigator.platform, ): boolean { + if ( + !isMacPlatform(platform) && + event.getModifierState?.("AltGraph") && + !/^[a-z0-9]$/i.test(event.key) + ) + return false; if (!matchesShortcutModifiers(event, shortcut, platform)) return false; return resolveEventKeys(event).has(shortcut.key); } diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 1ee54e14e401..d12d4eb77ad9 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -6,7 +6,7 @@ also lists the command IDs and defaults available in your version. ## Composer controls Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host. -Use `mod+alt+e` for effort, `mod+alt+a` for access mode, `mod+alt+t` for the +Use `mod+alt+e` for effort, `mod+alt+a` for access mode, `mod+alt+s` for the workspace, and `mod+alt+g` for the Git branch. The workspace menu includes the current checkout, a new worktree, and the previous worktree when available. Use `mod+alt+p` to reuse the previous worktree directly. @@ -16,6 +16,11 @@ the provider list. Use Up/Down to move and Enter to choose. Right returns to model search. `alt+up` and `alt+down` switch providers directly and clear the search. These provider shortcuts can also be changed in Settings. +These shortcuts run inside the focused web or desktop client. `mod` uses Command +on macOS and Ctrl on Windows and Linux, including GNOME, KDE Plasma, Niri, and +Hyprland. If a custom desktop shortcut takes the same keys, choose another binding +in Settings. + ## Edit the configuration file Keybindings live on the environment's machine, in diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 8b467caafdfc..2fa47693b04f 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -47,7 +47,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" }, { key: "mod+alt+e", command: "composer.effort", when: "!terminalFocus" }, { key: "mod+alt+a", command: "composer.mode", when: "!terminalFocus" }, - { key: "mod+alt+t", command: "composer.workspace", when: "!terminalFocus" }, + { key: "mod+alt+s", command: "composer.workspace", when: "!terminalFocus" }, { key: "mod+alt+g", command: "composer.branch", when: "!terminalFocus" }, { key: "mod+alt+p", command: "composer.previousWorktree", when: "!terminalFocus" }, { key: "alt+arrowup", command: "modelPicker.previousProvider", when: "modelPickerOpen" }, From e2e19c36828e25a470a8699a28e029fdd87223af Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sun, 13 Sep 2026 21:11:18 +0200 Subject: [PATCH 3/9] fix(web): use consistent composer shortcut modifiers --- apps/web/src/keybindings.test.ts | 101 +++++++++++++++++++---------- docs/user/keybindings.md | 8 +-- packages/shared/src/keybindings.ts | 14 ++-- 3 files changed, 77 insertions(+), 46 deletions(-) diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index ea4c7e6a7463..5dd7cabe8744 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1064,22 +1064,21 @@ describe("plus key parsing", () => { describe("composer control shortcuts", () => { const shortcuts = [ - ["h", "composer.host", true], - ["e", "composer.effort", false], - ["a", "composer.mode", false], - ["s", "composer.workspace", false], - ["g", "composer.branch", false], - ["p", "composer.previousWorktree", false], + ["h", "composer.host"], + ["e", "composer.effort"], + ["a", "composer.mode"], + ["x", "composer.workspace"], + ["g", "composer.branch"], + ["l", "composer.previousWorktree"], ] as const; for (const platform of ["MacIntel", "Win32", "Linux"]) { it.each(shortcuts)( `resolves %s on ${platform} and leaves terminal input alone`, - (key, command, shiftKey) => { + (key, command) => { const input = event({ key, - shiftKey, - altKey: !shiftKey, + shiftKey: true, metaKey: platform === "MacIntel", ctrlKey: platform !== "MacIntel", }); @@ -1100,7 +1099,32 @@ describe("composer control shortcuts", () => { ); } - it("leaves AltGr text entry alone on Windows and Linux", () => { + for (const platform of ["MacIntel", "Win32", "Linux"]) { + it.each([ + ["s", "thread.settle"], + ["p", "thread.pin"], + ])(`preserves the existing %s shortcut on ${platform}`, (key, command) => { + assert.strictEqual( + resolveShortcutCommand( + event({ + key, + shiftKey: true, + metaKey: platform === "MacIntel", + ctrlKey: platform !== "MacIntel", + }), + DEFAULT_RESOLVED_KEYBINDINGS, + { platform }, + ), + command, + ); + }); + } + + const altEffortBindings = compileResolvedKeybindingsConfig([ + { key: "mod+alt+e", command: "composer.effort", when: "!terminalFocus" }, + ]); + + it("leaves AltGr text entry alone with a custom Alt binding", () => { for (const platform of ["Win32", "Linux"]) { const input = event({ key: "€", @@ -1109,13 +1133,11 @@ describe("composer control shortcuts", () => { altKey: true, getModifierState: (key) => key === "AltGraph", }); - assert.isNull(resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { platform })); + assert.isNull(resolveShortcutCommand(input, altEffortBindings, { platform })); assert.strictEqual( - resolveShortcutCommand( - { ...input, getModifierState: () => false }, - DEFAULT_RESOLVED_KEYBINDINGS, - { platform }, - ), + resolveShortcutCommand({ ...input, getModifierState: () => false }, altEffortBindings, { + platform, + }), "composer.effort", ); } @@ -1126,7 +1148,7 @@ describe("composer control shortcuts", () => { assert.strictEqual( resolveShortcutCommand( event({ key: "e", ctrlKey: true, altKey: true, getModifierState }), - DEFAULT_RESOLVED_KEYBINDINGS, + altEffortBindings, { platform: "Win32" }, ), "composer.effort", @@ -1134,7 +1156,7 @@ describe("composer control shortcuts", () => { assert.strictEqual( resolveShortcutCommand( event({ key: "´", code: "KeyE", metaKey: true, altKey: true, getModifierState }), - DEFAULT_RESOLVED_KEYBINDINGS, + altEffortBindings, { platform: "MacIntel" }, ), "composer.effort", @@ -1155,21 +1177,30 @@ describe("composer control shortcuts", () => { ); }); - it.each([ - ["ArrowUp", "modelPicker.previousProvider"], - ["ArrowDown", "modelPicker.nextProvider"], - ] as const)("limits %s to the model picker", (key, command) => { - const input = event({ key, altKey: true }); - assert.strictEqual( - resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { - context: { modelPickerOpen: true }, - }), - command, - ); - assert.isNull( - resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { - context: { modelPickerOpen: false }, - }), - ); - }); + for (const platform of ["MacIntel", "Win32", "Linux"]) { + it.each([ + ["ArrowUp", "modelPicker.previousProvider"], + ["ArrowDown", "modelPicker.nextProvider"], + ] as const)(`limits %s to the model picker on ${platform}`, (key, command) => { + const input = event({ + key, + shiftKey: true, + metaKey: platform === "MacIntel", + ctrlKey: platform !== "MacIntel", + }); + assert.strictEqual( + resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { + platform, + context: { modelPickerOpen: true }, + }), + command, + ); + assert.isNull( + resolveShortcutCommand(input, DEFAULT_RESOLVED_KEYBINDINGS, { + platform, + context: { modelPickerOpen: false }, + }), + ); + }); + } }); diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index d12d4eb77ad9..94fc3480d6d4 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -6,14 +6,14 @@ also lists the command IDs and defaults available in your version. ## Composer controls Use `mod+shift+m` to choose a model and `mod+shift+h` to choose a host. -Use `mod+alt+e` for effort, `mod+alt+a` for access mode, `mod+alt+s` for the -workspace, and `mod+alt+g` for the Git branch. The workspace menu includes the +Use `mod+shift+e` for effort, `mod+shift+a` for access mode, `mod+shift+x` for the +workspace, and `mod+shift+g` for the Git branch. The workspace menu includes the current checkout, a new worktree, and the previous worktree when available. -Use `mod+alt+p` to reuse the previous worktree directly. +Use `mod+shift+l` to reuse the previous worktree directly. In the model picker, press Left in an empty search field or Shift+Tab to reach the provider list. Use Up/Down to move and Enter to choose. Right returns to -model search. `alt+up` and `alt+down` switch providers directly and clear the +model search. `mod+shift+up` and `mod+shift+down` switch providers directly and clear the search. These provider shortcuts can also be changed in Settings. These shortcuts run inside the focused web or desktop client. `mod` uses Command diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 2fa47693b04f..433f5c738b8d 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -45,13 +45,13 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" }, - { key: "mod+alt+e", command: "composer.effort", when: "!terminalFocus" }, - { key: "mod+alt+a", command: "composer.mode", when: "!terminalFocus" }, - { key: "mod+alt+s", command: "composer.workspace", when: "!terminalFocus" }, - { key: "mod+alt+g", command: "composer.branch", when: "!terminalFocus" }, - { key: "mod+alt+p", command: "composer.previousWorktree", when: "!terminalFocus" }, - { key: "alt+arrowup", command: "modelPicker.previousProvider", when: "modelPickerOpen" }, - { key: "alt+arrowdown", command: "modelPicker.nextProvider", when: "modelPickerOpen" }, + { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" }, + { key: "mod+shift+a", command: "composer.mode", when: "!terminalFocus" }, + { key: "mod+shift+x", command: "composer.workspace", when: "!terminalFocus" }, + { key: "mod+shift+g", command: "composer.branch", when: "!terminalFocus" }, + { key: "mod+shift+l", command: "composer.previousWorktree", when: "!terminalFocus" }, + { key: "mod+shift+arrowup", command: "modelPicker.previousProvider", when: "modelPickerOpen" }, + { key: "mod+shift+arrowdown", command: "modelPicker.nextProvider", when: "modelPickerOpen" }, { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, From ad0e7d0bef914123466926c0ef35593598950dde Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:25:05 +0200 Subject: [PATCH 4/9] fix(web): open branch picker directly from its shortcut --- apps/web/src/components/BranchToolbar.tsx | 9 ++++++++- .../BranchToolbarBranchSelector.tsx | 20 ++++++++++++++++++- apps/web/src/components/ChatView.tsx | 10 ++++++++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 9771efcfa85a..7b7ffe626bc8 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -35,7 +35,10 @@ import { resolvePreviousWorktreeSeed, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; -import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; +import { + BranchToolbarBranchSelector, + type BranchToolbarBranchSelectorHandle, +} from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; import { Button } from "./ui/button"; @@ -57,6 +60,7 @@ import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayo import { cn } from "~/lib/utils"; export interface BranchToolbarHandle { + openBranchPicker: () => void; usePreviousWorktree: () => void; } @@ -478,6 +482,7 @@ export const BranchToolbar = memo(function BranchToolbar({ composerControlsHostRef, contextStripVisible = true, }: BranchToolbarProps) { + const branchSelectorRef = useRef(null); const threadRef = useMemo( () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], @@ -541,6 +546,7 @@ export const BranchToolbar = memo(function BranchToolbar({ useImperativeHandle( ref, () => ({ + openBranchPicker: () => branchSelectorRef.current?.open(), usePreviousWorktree: () => { if (!showGitControls || !canUsePreviousWorktree || !previousWorktreeSeed) return; onUsePreviousWorktree(); @@ -656,6 +662,7 @@ export const BranchToolbar = memo(function BranchToolbar({ {showGitControls ? ( void; +} + interface BranchToolbarBranchSelectorProps { + ref?: Ref; className?: string; environmentId: EnvironmentId; threadId: ThreadId; @@ -92,6 +99,7 @@ function toBranchActionErrorMessage(error: unknown): string { } export function BranchToolbarBranchSelector({ + ref, className, environmentId, threadId, @@ -535,6 +543,17 @@ export function BranchToolbarBranchSelector({ } }, []); + useImperativeHandle( + ref, + () => ({ + open: () => { + if (isInitialBranchesLoadPending || isBranchActionPending) return; + handleOpenChange(true); + }, + }), + [handleOpenChange, isBranchActionPending, isInitialBranchesLoadPending], + ); + const [showTopBranchScrollFade, setShowTopBranchScrollFade] = useState(false); const [showBottomBranchScrollFade, setShowBottomBranchScrollFade] = useState(false); const fetchNextBranchPage = useCallback(() => { @@ -777,7 +796,6 @@ export function BranchToolbarBranchSelector({ // momentary 0.97 shrink would drag the open popup ~3px sideways. className="min-w-0 max-w-full font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80 active:scale-100" disabled={isInitialBranchesLoadPending || isBranchActionPending} - data-composer-shortcut="composer.branch" > Date: Sun, 13 Sep 2026 23:30:24 +0200 Subject: [PATCH 5/9] feat(web): add shortcuts to copy PR references --- apps/web/src/components/ChatView.tsx | 3 + .../pullRequest/PullRequestDetailPanel.tsx | 55 ++++++++++++++++++- .../KeybindingsSettings.logic.test.ts | 4 +- apps/web/src/keybindings.test.ts | 4 +- apps/web/src/routes/_chat.pull-requests.tsx | 1 + docs/user/keybindings.md | 7 +++ packages/contracts/src/keybindings.test.ts | 9 +++ packages/contracts/src/keybindings.ts | 2 + packages/shared/src/keybindings.ts | 2 + 9 files changed, 83 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7481b94fa3f7..43d6677746a4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8536,6 +8536,9 @@ export default function ChatView(props: ChatViewProps) { // reader's feet. A link the agent wrote can open any other one here, and that one has to be // checkable out like it is anywhere else. { diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index dbd2eb255c4e..6a52b4dcbc62 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,4 +1,5 @@ import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { useAtomValue } from "@effect/atom-react"; import { usePullRequestStack } from "~/state/usePullRequestStack"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; @@ -49,6 +50,7 @@ import { type MouseEvent as ReactMouseEvent, useCallback, useEffect, + useEffectEvent, useLayoutEffect, useMemo, useRef, @@ -57,7 +59,11 @@ import { import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; -import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { isCommandPaletteOpen } from "~/commandPaletteBus"; +import { resolveShortcutCommand, shortcutLabelForCommand } from "~/keybindings"; +import { isTerminalFocused } from "~/lib/terminalFocus"; +import { primaryServerKeybindingsAtom } from "~/state/server"; import { useClientSettings } from "~/hooks/useSettings"; import { deriveLogicalProjectKeyFromSettings, @@ -104,6 +110,7 @@ import { MenuRadioGroup, MenuRadioItem, MenuSeparator, + MenuShortcut, MenuTrigger, } from "../ui/menu"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; @@ -457,6 +464,7 @@ function PullRequestBaseFreshnessWarning({ export function PullRequestDetailPanel({ environmentId, + shortcutsEnabled, threadRef = null, reference: requestedReference, listEntry = null, @@ -469,6 +477,7 @@ export function PullRequestDetailPanel({ onSelectPullRequest, }: { environmentId: EnvironmentId; + shortcutsEnabled: boolean; onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined; /** * The thread this panel sits beside, if any. Links that are not the pull @@ -707,6 +716,39 @@ export function PullRequestDetailPanel({ }, [activity, coreDetail], ); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const { copyToClipboard: copyReference } = useCopyToClipboard({ + target: "pull request reference", + onCopy: (label) => toastManager.add({ type: "success", title: `${label} copied` }), + onError: (error, label) => + toastManager.add({ + type: "error", + title: `Failed to copy ${label}`, + description: error.message, + }), + }); + const copyFromShortcut = useEffectEvent((event: KeyboardEvent) => { + if (!shortcutsEnabled || event.defaultPrevented || isCommandPaletteOpen()) return; + const command = resolveShortcutCommand(event, keybindings, { + context: { terminalFocus: isTerminalFocused() }, + }); + const value = + command === "pullRequest.copyUrl" + ? (detail?.url ?? matchingListEntry?.url) + : command === "pullRequest.copyNumber" + ? String(reference.number) + : null; + if (!value) return; + event.preventDefault(); + event.stopPropagation(); + if (!event.repeat) + copyReference(value, command === "pullRequest.copyNumber" ? "PR number" : "PR link"); + }); + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => copyFromShortcut(event); + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, []); useEffect(() => { if (detail?.autoMergeMethod !== undefined) setMergeMethod(detail.autoMergeMethod); }, [detail?.autoMergeMethod, pullRequestKey]); @@ -2080,9 +2122,18 @@ export function PullRequestDetailPanel({ {openOnHostLabel(detail.provider)} - void writeTextToClipboard(detail.url)}> + copyReference(detail.url, "PR link")}> Copy link + + {shortcutLabelForCommand(keybindings, "pullRequest.copyUrl")} + + + copyReference(String(reference.number), "PR number")}> + Copy PR number + + {shortcutLabelForCommand(keybindings, "pullRequest.copyNumber")} + {detail.state === "open" && can("close") ? ( <> diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 937b8c4da4fe..555f84fbc1ac 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -17,7 +17,7 @@ import { } from "./KeybindingsSettings.logic"; describe("KeybindingsSettings.logic", () => { - it("lists all composer controls and provider navigation with editable defaults", () => { + it("lists composer, provider, and pull request commands with editable defaults", () => { const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, ""); for (const command of [ "composer.host", @@ -28,6 +28,8 @@ describe("KeybindingsSettings.logic", () => { "composer.previousWorktree", "modelPicker.previousProvider", "modelPicker.nextProvider", + "pullRequest.copyUrl", + "pullRequest.copyNumber", ]) { expect(rows.find((row) => row.command === command)).toMatchObject({ source: "Default", diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 5dd7cabe8744..8fe5c6689b4c 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1062,7 +1062,7 @@ describe("plus key parsing", () => { }); }); -describe("composer control shortcuts", () => { +describe("composer and pull request shortcuts", () => { const shortcuts = [ ["h", "composer.host"], ["e", "composer.effort"], @@ -1070,6 +1070,8 @@ describe("composer control shortcuts", () => { ["x", "composer.workspace"], ["g", "composer.branch"], ["l", "composer.previousWorktree"], + ["k", "pullRequest.copyUrl"], + ["y", "pullRequest.copyNumber"], ] as const; for (const platform of ["MacIntel", "Win32", "Linux"]) { diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 973c406f6900..8c73f1ca854e 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1992,6 +1992,7 @@ function PullRequestsRouteView() { pullRequestStatusSeeds={listedPullRequestTabStatuses} > { diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 94fc3480d6d4..149393e24fa9 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -21,6 +21,13 @@ on macOS and Ctrl on Windows and Linux, including GNOME, KDE Plasma, Niri, and Hyprland. If a custom desktop shortcut takes the same keys, choose another binding in Settings. +## Copy pull request references + +With a PR open in the right panel or on the Pull Requests page, use `mod+shift+k` +to copy its URL and `mod+shift+y` to copy its number without a `#` prefix. +Both shortcuts can be changed in Settings. They copy the selected PR and leave +terminal input alone. + ## Edit the configuration file Keybindings live on the environment's machine, in diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 27e096cbb2eb..956fc940135f 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -122,6 +122,15 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedThreadCopyReference.command, "thread.copyReference"); + for (const command of ["pullRequest.copyUrl", "pullRequest.copyNumber"]) { + const parsed = yield* decode(KeybindingRule, { + key: "mod+shift+k", + command, + when: "!terminalFocus", + }); + assert.strictEqual(parsed.command, command); + } + const parsedThreadStop = yield* decode(KeybindingRule, { key: "mod+escape", command: "thread.stop", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index dd65be9156af..4e9c60039514 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -63,6 +63,8 @@ export const STATIC_KEYBINDING_COMMANDS = [ "rightPanel.toggle", "rightPanel.toggleMaximized", "rightPanel.close", + "pullRequest.copyUrl", + "pullRequest.copyNumber", "diff.toggle", "preview.toggle", "preview.refresh", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 433f5c738b8d..d84c35a4fd6a 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -50,6 +50,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+x", command: "composer.workspace", when: "!terminalFocus" }, { key: "mod+shift+g", command: "composer.branch", when: "!terminalFocus" }, { key: "mod+shift+l", command: "composer.previousWorktree", when: "!terminalFocus" }, + { key: "mod+shift+k", command: "pullRequest.copyUrl", when: "!terminalFocus" }, + { key: "mod+shift+y", command: "pullRequest.copyNumber", when: "!terminalFocus" }, { key: "mod+shift+arrowup", command: "modelPicker.previousProvider", when: "modelPickerOpen" }, { key: "mod+shift+arrowdown", command: "modelPicker.nextProvider", when: "modelPickerOpen" }, { key: "mod+o", command: "editor.openFavorite" }, From 2a890f00e2d1fdfd58187d28d18f020da21eb5f1 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:02:58 +0200 Subject: [PATCH 6/9] fix(web): honor PR shortcut conditions --- apps/web/src/components/ChatView.tsx | 22 ++++++++++++------- .../pullRequest/PullRequestDetailPanel.tsx | 11 +++++++--- apps/web/src/keybindings.test.ts | 20 +++++++++++++++++ apps/web/src/routes/_chat.pull-requests.tsx | 13 ++++++++++- 4 files changed, 54 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 43d6677746a4..fe38d96a1fb0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6268,6 +6268,17 @@ export default function ChatView(props: ChatViewProps) { terminalUiOpenByThreadRef.current[activeThreadKey] = current; }, [activeThreadKey, focusComposer, terminalUiState.terminalOpen]); + const getShortcutContext = useCallback( + () => ({ + terminalFocus: getTerminalFocusOwner() !== null, + terminalOpen: Boolean(terminalUiState.terminalOpen), + previewFocus: isPreviewFocused(), + previewOpen: previewPanelOpen, + modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, + }), + [composerRef, previewPanelOpen, terminalUiState.terminalOpen], + ); + useEffect(() => { const handler = (event: globalThis.KeyboardEvent) => { if (preventRepeatedTerminalCloseShortcut(event, keybindings)) { @@ -6288,13 +6299,7 @@ export default function ChatView(props: ChatViewProps) { if (event.defaultPrevented && terminalFocusOwner === null) { return; } - const shortcutContext = { - terminalFocus: terminalFocusOwner !== null, - terminalOpen: Boolean(terminalUiState.terminalOpen), - previewFocus: isPreviewFocused(), - previewOpen: previewPanelOpen, - modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, - }; + const shortcutContext = getShortcutContext(); if ( !shortcutContext.terminalFocus && @@ -6540,7 +6545,7 @@ export default function ChatView(props: ChatViewProps) { supportsSettlement, confirmAndUnpinThread, copyActiveThreadReference, - previewPanelOpen, + getShortcutContext, toggleRightPanel, toggleRightPanelMaximized, toggleTerminalVisibility, @@ -8536,6 +8541,7 @@ export default function ChatView(props: ChatViewProps) { // reader's feet. A link the agent wrote can open any other one here, and that one has to be // checkable out like it is anywhere else. ShortcutMatchContext; onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined; /** * The thread this panel sits beside, if any. Links that are not the pull @@ -730,7 +735,7 @@ export function PullRequestDetailPanel({ const copyFromShortcut = useEffectEvent((event: KeyboardEvent) => { if (!shortcutsEnabled || event.defaultPrevented || isCommandPaletteOpen()) return; const command = resolveShortcutCommand(event, keybindings, { - context: { terminalFocus: isTerminalFocused() }, + context: getShortcutContext(), }); const value = command === "pullRequest.copyUrl" diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 8fe5c6689b4c..fc1ddb87a435 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1063,6 +1063,26 @@ describe("plus key parsing", () => { }); describe("composer and pull request shortcuts", () => { + it.each(["terminalOpen", "previewFocus", "previewOpen", "modelPickerOpen"])( + "honors custom PR shortcut conditions for %s", + (condition) => { + const bindings = compileResolvedKeybindingsConfig([ + { key: "mod+shift+k", command: "pullRequest.copyUrl", when: condition }, + { key: "mod+shift+k", command: "pullRequest.copyNumber", when: `!${condition}` }, + ]); + const input = event({ key: "k", ctrlKey: true, shiftKey: true }); + for (const enabled of [false, true]) { + assert.strictEqual( + resolveShortcutCommand(input, bindings, { + platform: "Linux", + context: { [condition]: enabled }, + }), + enabled ? "pullRequest.copyUrl" : "pullRequest.copyNumber", + ); + } + }, + ); + const shortcuts = [ ["h", "composer.host"], ["e", "composer.effort"], diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 8c73f1ca854e..642740f30323 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -151,6 +151,16 @@ import { cn } from "~/lib/utils"; import { primaryServerKeybindingsAtom } from "~/state/server"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; +function getShortcutContext() { + return { + terminalFocus: isTerminalFocused(), + terminalOpen: false, + previewFocus: false, + previewOpen: false, + modelPickerOpen: false, + }; +} + export interface PullRequestsSearch extends PullRequestListPreferences { /** * Narrows the list to one server. Absent means every connected one, which is the default the @@ -1925,7 +1935,7 @@ function PullRequestsRouteView() { const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented || isCommandPaletteOpen()) return; const command = resolveShortcutCommand(event, keybindings, { - context: { terminalFocus: isTerminalFocused() }, + context: getShortcutContext(), }); if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event); if (command === "rightPanel.toggle") toggleRightPanelFromShortcut(event); @@ -1992,6 +2002,7 @@ function PullRequestsRouteView() { pullRequestStatusSeeds={listedPullRequestTabStatuses} > Date: Mon, 14 Sep 2026 00:48:44 +0200 Subject: [PATCH 7/9] fix(web): retain new shortcuts with older servers --- apps/server/src/keybindings.ts | 21 +-------- .../settings/KeybindingsSettings.tsx | 8 +++- apps/web/src/keybindings.test.ts | 43 +++++++++++++++++++ apps/web/src/state/server.ts | 7 ++- packages/shared/src/keybindings.ts | 20 +++++++++ 5 files changed, 73 insertions(+), 26 deletions(-) diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 1795808bc179..08084a94df28 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -45,7 +45,7 @@ import { writeFileStringAtomically } from "./atomicWrite.ts"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { DEFAULT_KEYBINDINGS, - DEFAULT_RESOLVED_KEYBINDINGS, + mergeWithDefaultKeybindings, compileResolvedKeybindingRule, compileResolvedKeybindingsConfig, parseKeybindingShortcut, @@ -201,25 +201,6 @@ function invalidEntryIssue(index: number, detail: string): ServerConfigIssue { }; } -function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { - if (custom.length === 0) { - return [...DEFAULT_RESOLVED_KEYBINDINGS]; - } - - const overriddenCommands = new Set(custom.map((binding) => binding.command)); - const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( - (binding) => !overriddenCommands.has(binding.command), - ); - const merged = [...retainedDefaults, ...custom]; - - if (merged.length <= MAX_KEYBINDINGS_COUNT) { - return merged; - } - - // Keep the latest rules when the config exceeds max size; later rules have higher precedence. - return merged.slice(-MAX_KEYBINDINGS_COUNT); -} - /** * Keybindings - Service tag for keybinding configuration operations. */ diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 85e32b1bf850..d02f15939c5e 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -26,7 +26,7 @@ import { type ServerRemoveKeybindingInput, type ServerUpsertKeybindingInput, } from "@t3tools/contracts"; -import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -1334,7 +1334,11 @@ export function KeybindingsSettingsPanel() { // fan out to every connected environment in the selection, so one // shortcut change reaches each machine the user runs T3 Code on. const { environment: primaryEnvironment, connectedEnvironments } = useSettingsScope(); - const keybindings = primaryEnvironment?.serverConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; + const serverKeybindings = primaryEnvironment?.serverConfig?.keybindings; + const keybindings = useMemo( + () => mergeWithDefaultKeybindings(serverKeybindings ?? []), + [serverKeybindings], + ); const keybindingsConfigPath = primaryEnvironment?.serverConfig?.keybindingsConfigPath ?? null; const availableEditors = primaryEnvironment?.serverConfig?.availableEditors ?? []; const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index fc1ddb87a435..63b4c319f68a 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "vite-plus/test"; import { compileResolvedKeybindingsConfig, DEFAULT_RESOLVED_KEYBINDINGS, + mergeWithDefaultKeybindings, } from "@t3tools/shared/keybindings"; import { @@ -1063,6 +1064,48 @@ describe("plus key parsing", () => { }); describe("composer and pull request shortcuts", () => { + it("fills missing server commands without replacing saved bindings", () => { + const olderServerBindings = DEFAULT_RESOLVED_KEYBINDINGS.filter( + (binding) => !binding.command.startsWith("pullRequest.copy"), + ); + const input = event({ key: "k", metaKey: true, shiftKey: true }); + assert.strictEqual( + resolveShortcutCommand(input, olderServerBindings, { platform: "MacIntel" }), + null, + ); + const bindings = mergeWithDefaultKeybindings(olderServerBindings); + assert.strictEqual( + resolveShortcutCommand(input, bindings, { platform: "MacIntel" }), + "pullRequest.copyUrl", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "y", metaKey: true, shiftKey: true }), bindings, { + platform: "MacIntel", + }), + "pullRequest.copyNumber", + ); + const remapped = mergeWithDefaultKeybindings( + compileResolvedKeybindingsConfig([ + { key: "mod+shift+8", command: "pullRequest.copyUrl", when: "terminalOpen" }, + { key: "mod+shift+y", command: "composer.effort" }, + ]), + ); + assert.strictEqual(resolveShortcutCommand(input, remapped, { platform: "MacIntel" }), null); + assert.strictEqual( + resolveShortcutCommand(event({ key: "8", ctrlKey: true, shiftKey: true }), remapped, { + platform: "Linux", + context: { terminalOpen: true }, + }), + "pullRequest.copyUrl", + ); + assert.strictEqual( + resolveShortcutCommand(event({ key: "y", ctrlKey: true, shiftKey: true }), remapped, { + platform: "Linux", + }), + "composer.effort", + ); + }); + it.each(["terminalOpen", "previewFocus", "previewOpen", "modelPickerOpen"])( "honors custom PR shortcut conditions for %s", (condition) => { diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 11fd2e7dc0f9..f4161dfb454b 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -10,7 +10,7 @@ import { } from "@t3tools/contracts"; import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; -import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { mergeWithDefaultKeybindings } from "@t3tools/shared/keybindings"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -90,9 +90,8 @@ export const primaryServerProvidersAtom = Atom.make( get(primaryServerConfigAtom)?.providers ?? EMPTY_SERVER_PROVIDERS, ).pipe(Atom.withLabel("web-primary-server-providers")); -export const primaryServerKeybindingsAtom = Atom.make( - (get): ServerConfig["keybindings"] => - get(primaryServerConfigAtom)?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS, +export const primaryServerKeybindingsAtom = Atom.make((get): ServerConfig["keybindings"] => + mergeWithDefaultKeybindings(get(primaryServerConfigAtom)?.keybindings ?? []), ).pipe(Atom.withLabel("web-primary-server-keybindings")); export const primaryServerAvailableEditorsAtom = Atom.make( diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index d84c35a4fd6a..3e6d4f92f6e5 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -309,3 +309,23 @@ export function compileResolvedKeybindingsConfig( } export const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); + +export function mergeWithDefaultKeybindings( + custom: ResolvedKeybindingsConfig, +): ResolvedKeybindingsConfig { + if (custom.length === 0) { + return [...DEFAULT_RESOLVED_KEYBINDINGS]; + } + + const overriddenCommands = new Set(custom.map((binding) => binding.command)); + const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( + (binding) => !overriddenCommands.has(binding.command), + ); + const merged = [...retainedDefaults, ...custom]; + + if (merged.length <= MAX_KEYBINDINGS_COUNT) { + return merged; + } + + return merged.slice(-MAX_KEYBINDINGS_COUNT); +} From a48a1b0b76d041d3f5c645ca6ae193879d6b4654 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:49:08 +0200 Subject: [PATCH 8/9] fix(web): copy PR numbers with hash prefix --- .../src/components/pullRequest/PullRequestDetailPanel.tsx | 6 ++++-- docs/user/keybindings.md | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index d28ac6eb2738..c5002bbe9ce7 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -22,6 +22,7 @@ import { ArrowUpRightIcon, BookOpenIcon, CircleDotIcon, + CopyIcon, ChevronDownIcon, ExternalLinkIcon, FileDiffIcon, @@ -741,7 +742,7 @@ export function PullRequestDetailPanel({ command === "pullRequest.copyUrl" ? (detail?.url ?? matchingListEntry?.url) : command === "pullRequest.copyNumber" - ? String(reference.number) + ? `#${reference.number}` : null; if (!value) return; event.preventDefault(); @@ -2134,7 +2135,8 @@ export function PullRequestDetailPanel({ {shortcutLabelForCommand(keybindings, "pullRequest.copyUrl")} - copyReference(String(reference.number), "PR number")}> + copyReference(`#${reference.number}`, "PR number")}> + Copy PR number {shortcutLabelForCommand(keybindings, "pullRequest.copyNumber")} diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 149393e24fa9..9a0afdbdb393 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -24,7 +24,7 @@ in Settings. ## Copy pull request references With a PR open in the right panel or on the Pull Requests page, use `mod+shift+k` -to copy its URL and `mod+shift+y` to copy its number without a `#` prefix. +to copy its URL and `mod+shift+y` to copy its number with a `#` prefix. Both shortcuts can be changed in Settings. They copy the selected PR and leave terminal input alone. From ef116f002b5a556ed3c9cd621fce67bcb3af8409 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:29:43 +0200 Subject: [PATCH 9/9] fix(web): preserve the existing PR URL shortcut --- .../pullRequest/PullRequestDetailPanel.tsx | 13 +--- .../KeybindingsSettings.logic.test.ts | 8 ++- .../settings/KeybindingsSettings.logic.ts | 2 + apps/web/src/keybindings.test.ts | 66 +++++++------------ docs/user/keybindings.md | 8 +-- packages/contracts/src/keybindings.test.ts | 14 ++-- packages/contracts/src/keybindings.ts | 1 - packages/shared/src/keybindings.ts | 3 +- 8 files changed, 48 insertions(+), 67 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index c5002bbe9ce7..cbbfaf1f33fa 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -738,17 +738,10 @@ export function PullRequestDetailPanel({ const command = resolveShortcutCommand(event, keybindings, { context: getShortcutContext(), }); - const value = - command === "pullRequest.copyUrl" - ? (detail?.url ?? matchingListEntry?.url) - : command === "pullRequest.copyNumber" - ? `#${reference.number}` - : null; - if (!value) return; + if (command !== "pullRequest.copyNumber") return; event.preventDefault(); event.stopPropagation(); - if (!event.repeat) - copyReference(value, command === "pullRequest.copyNumber" ? "PR number" : "PR link"); + if (!event.repeat) copyReference(`#${reference.number}`, "PR number"); }); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => copyFromShortcut(event); @@ -2132,7 +2125,7 @@ export function PullRequestDetailPanel({ Copy link - {shortcutLabelForCommand(keybindings, "pullRequest.copyUrl")} + {shortcutLabelForCommand(keybindings, "thread.copyReference")} copyReference(`#${reference.number}`, "PR number")}> diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 555f84fbc1ac..d55b047e6638 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -28,7 +28,7 @@ describe("KeybindingsSettings.logic", () => { "composer.previousWorktree", "modelPicker.previousProvider", "modelPicker.nextProvider", - "pullRequest.copyUrl", + "thread.copyReference", "pullRequest.copyNumber", ]) { expect(rows.find((row) => row.command === command)).toMatchObject({ @@ -37,6 +37,12 @@ describe("KeybindingsSettings.logic", () => { }); } }); + it("finds the existing URL shortcut in Settings", () => { + const rows = buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, "PR URL"); + expect(rows).toEqual([ + expect.objectContaining({ command: "thread.copyReference", key: "mod+shift+c" }), + ]); + }); it("builds searchable rows with readable key and when values", () => { const rows = buildKeybindingRows( [ diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index 65589d1bcd9f..7119d9ee4e5a 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -209,6 +209,7 @@ export function buildKeybindingRows( return rowsWithConflicts.filter((row) => { return ( row.command.toLowerCase().includes(normalizedQuery) || + commandLabel(row.command).toLowerCase().includes(normalizedQuery) || row.key.toLowerCase().includes(normalizedQuery) || row.when.toLowerCase().includes(normalizedQuery) || row.source.toLowerCase().includes(normalizedQuery) @@ -275,6 +276,7 @@ export function buildKeybindingCommandOptions( } export function commandLabel(command: KeybindingCommand): string { + if (command === "thread.copyReference") return "Thread: Copy PR URL or Thread ID"; const raw = String(command); if (raw.startsWith("script.") && raw.endsWith(".run")) { return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`; diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 63b4c319f68a..1e9e8adc7baf 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -1064,53 +1064,37 @@ describe("plus key parsing", () => { }); describe("composer and pull request shortcuts", () => { - it("fills missing server commands without replacing saved bindings", () => { + it("fills missing number shortcuts without replacing the saved URL binding", () => { const olderServerBindings = DEFAULT_RESOLVED_KEYBINDINGS.filter( - (binding) => !binding.command.startsWith("pullRequest.copy"), + (binding) => + binding.command !== "pullRequest.copyNumber" && binding.command !== "thread.copyReference", ); - const input = event({ key: "k", metaKey: true, shiftKey: true }); - assert.strictEqual( - resolveShortcutCommand(input, olderServerBindings, { platform: "MacIntel" }), - null, - ); - const bindings = mergeWithDefaultKeybindings(olderServerBindings); - assert.strictEqual( - resolveShortcutCommand(input, bindings, { platform: "MacIntel" }), - "pullRequest.copyUrl", - ); - assert.strictEqual( - resolveShortcutCommand(event({ key: "y", metaKey: true, shiftKey: true }), bindings, { - platform: "MacIntel", - }), - "pullRequest.copyNumber", - ); - const remapped = mergeWithDefaultKeybindings( - compileResolvedKeybindingsConfig([ - { key: "mod+shift+8", command: "pullRequest.copyUrl", when: "terminalOpen" }, - { key: "mod+shift+y", command: "composer.effort" }, + const bindings = mergeWithDefaultKeybindings([ + ...olderServerBindings, + ...compileResolvedKeybindingsConfig([ + { key: "mod+shift+8", command: "thread.copyReference", when: "!terminalFocus" }, ]), - ); - assert.strictEqual(resolveShortcutCommand(input, remapped, { platform: "MacIntel" }), null); - assert.strictEqual( - resolveShortcutCommand(event({ key: "8", ctrlKey: true, shiftKey: true }), remapped, { - platform: "Linux", - context: { terminalOpen: true }, - }), - "pullRequest.copyUrl", - ); - assert.strictEqual( - resolveShortcutCommand(event({ key: "y", ctrlKey: true, shiftKey: true }), remapped, { - platform: "Linux", - }), - "composer.effort", - ); + ]); + for (const [key, command] of [ + ["k", "pullRequest.copyNumber"], + ["8", "thread.copyReference"], + ["c", null], + ["y", null], + ] as const) { + assert.strictEqual( + resolveShortcutCommand(event({ key, metaKey: true, shiftKey: true }), bindings, { + platform: "MacIntel", + }), + command, + ); + } }); it.each(["terminalOpen", "previewFocus", "previewOpen", "modelPickerOpen"])( "honors custom PR shortcut conditions for %s", (condition) => { const bindings = compileResolvedKeybindingsConfig([ - { key: "mod+shift+k", command: "pullRequest.copyUrl", when: condition }, + { key: "mod+shift+k", command: "thread.copyReference", when: condition }, { key: "mod+shift+k", command: "pullRequest.copyNumber", when: `!${condition}` }, ]); const input = event({ key: "k", ctrlKey: true, shiftKey: true }); @@ -1120,7 +1104,7 @@ describe("composer and pull request shortcuts", () => { platform: "Linux", context: { [condition]: enabled }, }), - enabled ? "pullRequest.copyUrl" : "pullRequest.copyNumber", + enabled ? "thread.copyReference" : "pullRequest.copyNumber", ); } }, @@ -1133,8 +1117,8 @@ describe("composer and pull request shortcuts", () => { ["x", "composer.workspace"], ["g", "composer.branch"], ["l", "composer.previousWorktree"], - ["k", "pullRequest.copyUrl"], - ["y", "pullRequest.copyNumber"], + ["c", "thread.copyReference"], + ["k", "pullRequest.copyNumber"], ] as const; for (const platform of ["MacIntel", "Win32", "Linux"]) { diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 9a0afdbdb393..556525910531 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -23,10 +23,10 @@ in Settings. ## Copy pull request references -With a PR open in the right panel or on the Pull Requests page, use `mod+shift+k` -to copy its URL and `mod+shift+y` to copy its number with a `#` prefix. -Both shortcuts can be changed in Settings. They copy the selected PR and leave -terminal input alone. +With a PR open in the right panel or on the Pull Requests page, use `mod+shift+c` +to copy its URL and `mod+shift+k` to copy its number with a `#` prefix. +Both shortcuts can be changed in Settings. Search for “Copy PR URL or Thread ID” +or “Copy Number”. They copy the selected PR and leave terminal input alone. ## Edit the configuration file diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 956fc940135f..14579c741198 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -122,14 +122,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedThreadCopyReference.command, "thread.copyReference"); - for (const command of ["pullRequest.copyUrl", "pullRequest.copyNumber"]) { - const parsed = yield* decode(KeybindingRule, { - key: "mod+shift+k", - command, - when: "!terminalFocus", - }); - assert.strictEqual(parsed.command, command); - } + const parsedPullRequestCopyNumber = yield* decode(KeybindingRule, { + key: "mod+shift+k", + command: "pullRequest.copyNumber", + when: "!terminalFocus", + }); + assert.strictEqual(parsedPullRequestCopyNumber.command, "pullRequest.copyNumber"); const parsedThreadStop = yield* decode(KeybindingRule, { key: "mod+escape", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 4e9c60039514..a285d2fcf4ac 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -63,7 +63,6 @@ export const STATIC_KEYBINDING_COMMANDS = [ "rightPanel.toggle", "rightPanel.toggleMaximized", "rightPanel.close", - "pullRequest.copyUrl", "pullRequest.copyNumber", "diff.toggle", "preview.toggle", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 3e6d4f92f6e5..8d73c07f34ab 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -50,8 +50,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+shift+x", command: "composer.workspace", when: "!terminalFocus" }, { key: "mod+shift+g", command: "composer.branch", when: "!terminalFocus" }, { key: "mod+shift+l", command: "composer.previousWorktree", when: "!terminalFocus" }, - { key: "mod+shift+k", command: "pullRequest.copyUrl", when: "!terminalFocus" }, - { key: "mod+shift+y", command: "pullRequest.copyNumber", when: "!terminalFocus" }, + { key: "mod+shift+k", command: "pullRequest.copyNumber", when: "!terminalFocus" }, { key: "mod+shift+arrowup", command: "modelPicker.previousProvider", when: "modelPickerOpen" }, { key: "mod+shift+arrowdown", command: "modelPicker.nextProvider", when: "modelPickerOpen" }, { key: "mod+o", command: "editor.openFavorite" },