From 310be952ffdb07c61a8d36762d8af77146478ef1 Mon Sep 17 00:00:00 2001 From: Oskar Jansson Date: Sun, 10 May 2026 14:19:51 +0200 Subject: [PATCH 1/7] Add keybindings to cycle terminals and focus split panes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds four new keybinding commands for keyboard-driven terminal navigation: - terminal.next / terminal.previous — cycle terminals with wrap Default: ctrl+tab / ctrl+shift+tab (matches Ghostty, Windows Terminal) Alternate: alt+pagedown / alt+pageup (browser-safe; primary keys are reserved by Chromium/Firefox/Safari and cannot be intercepted in the web app) - terminal.focusLeftPane / terminal.focusRightPane — directional pane focus within the current terminal, no wrap Default: mod+alt+arrowleft / mod+alt+arrowright (matches Ghostty, iTerm2: cmd+alt+arrow on macOS, ctrl+alt+arrow elsewhere) All four are gated on `terminalFocus` so they only fire when focus is inside a terminal and don't hijack ctrl+tab from the chat composer. Pure index helpers (`nextCycleIndex`, `nextDirectionalIndex`) are extracted to apps/web/src/terminalNavigation.ts with unit tests covering wrap, edges, single-element, and out-of-range inputs. Display labels for pageup/pagedown render as PgUp/PgDn. Co-Authored-By: Claude Opus 4.7 --- KEYBINDINGS.md | 12 +++ apps/web/src/components/ChatView.tsx | 79 ++++++++++++++++++ apps/web/src/keybindings.test.ts | 104 ++++++++++++++++++++++++ apps/web/src/keybindings.ts | 2 + apps/web/src/terminalNavigation.test.ts | 53 ++++++++++++ apps/web/src/terminalNavigation.ts | 22 +++++ packages/contracts/src/keybindings.ts | 4 + packages/shared/src/keybindings.ts | 6 ++ 8 files changed, 282 insertions(+) create mode 100644 apps/web/src/terminalNavigation.test.ts create mode 100644 apps/web/src/terminalNavigation.ts diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index b57c13032ce..2a9be1a5ba4 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -23,6 +23,12 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" }, { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" }, { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, + { "key": "ctrl+tab", "command": "terminal.next", "when": "terminalFocus" }, + { "key": "ctrl+shift+tab", "command": "terminal.previous", "when": "terminalFocus" }, + { "key": "alt+pagedown", "command": "terminal.next", "when": "terminalFocus" }, + { "key": "alt+pageup", "command": "terminal.previous", "when": "terminalFocus" }, + { "key": "mod+alt+arrowleft", "command": "terminal.focusLeftPane", "when": "terminalFocus" }, + { "key": "mod+alt+arrowright", "command": "terminal.focusRightPane", "when": "terminalFocus" }, { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" }, { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, @@ -51,6 +57,12 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.split`: split terminal (in focused terminal context by default) - `terminal.new`: create new terminal (in focused terminal context by default) - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) +- `terminal.next`: cycle to the next terminal (wraps around) +- `terminal.previous`: cycle to the previous terminal (wraps around) +- `terminal.focusLeftPane`: focus the pane to the left of the active pane within the current terminal (no wrap) +- `terminal.focusRightPane`: focus the pane to the right of the active pane within the current terminal (no wrap) + +Browser note: `ctrl+tab` and `cmd+alt+arrow` are reserved by most browsers and cannot be intercepted in the web app. Use the alt+pageup/pagedown alternatives, or override these in your custom keybindings — both work reliably in the desktop app and the web app. - `commandPalette.toggle`: open or close the global command palette - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6b84aa11ca6..ec4ac0d968d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -102,6 +102,7 @@ import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { nextCycleIndex, nextDirectionalIndex } from "../terminalNavigation"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { ChevronDownIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; @@ -1813,6 +1814,54 @@ export default function ChatView(props: ChatViewProps) { storeNewTerminal(activeThreadRef, terminalId); setTerminalFocusRequestId((value) => value + 1); }, [activeThreadRef, storeNewTerminal]); + const cycleTerminal = useCallback( + (direction: "next" | "previous") => { + if (!activeThreadRef) return; + const groups = terminalState.terminalGroups; + const currentIndex = groups.findIndex( + (group) => group.id === terminalState.activeTerminalGroupId, + ); + const nextIndex = nextCycleIndex(currentIndex, groups.length, direction); + if (nextIndex === null) return; + const targetTerminalId = groups[nextIndex]?.terminalIds[0]; + if (!targetTerminalId) return; + storeSetActiveTerminal(activeThreadRef, targetTerminalId); + setTerminalFocusRequestId((value) => value + 1); + }, + [ + activeThreadRef, + storeSetActiveTerminal, + terminalState.activeTerminalGroupId, + terminalState.terminalGroups, + ], + ); + const focusPane = useCallback( + (direction: "left" | "right") => { + if (!activeThreadRef) return; + const activeGroup = terminalState.terminalGroups.find( + (group) => group.id === terminalState.activeTerminalGroupId, + ); + if (!activeGroup) return; + const currentIndex = activeGroup.terminalIds.indexOf(terminalState.activeTerminalId); + const nextIndex = nextDirectionalIndex( + currentIndex, + activeGroup.terminalIds.length, + direction, + ); + if (nextIndex === null) return; + const targetTerminalId = activeGroup.terminalIds[nextIndex]; + if (!targetTerminalId) return; + storeSetActiveTerminal(activeThreadRef, targetTerminalId); + setTerminalFocusRequestId((value) => value + 1); + }, + [ + activeThreadRef, + storeSetActiveTerminal, + terminalState.activeTerminalGroupId, + terminalState.activeTerminalId, + terminalState.terminalGroups, + ], + ); const closeTerminal = useCallback( (terminalId: string) => { const api = readEnvironmentApi(environmentId); @@ -2510,6 +2559,34 @@ export default function ChatView(props: ChatViewProps) { return; } + if (command === "terminal.next") { + event.preventDefault(); + event.stopPropagation(); + cycleTerminal("next"); + return; + } + + if (command === "terminal.previous") { + event.preventDefault(); + event.stopPropagation(); + cycleTerminal("previous"); + return; + } + + if (command === "terminal.focusLeftPane") { + event.preventDefault(); + event.stopPropagation(); + focusPane("left"); + return; + } + + if (command === "terminal.focusRightPane") { + event.preventDefault(); + event.stopPropagation(); + focusPane("right"); + return; + } + if (command === "diff.toggle") { event.preventDefault(); event.stopPropagation(); @@ -2541,6 +2618,8 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, closeTerminal, createNewTerminal, + cycleTerminal, + focusPane, setTerminalOpen, runProjectScript, splitTerminal, diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 85c14fa0ab7..1300fc17bed 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -100,6 +100,26 @@ const DEFAULT_BINDINGS = compile([ command: "terminal.close", whenAst: whenIdentifier("terminalFocus"), }, + { + shortcut: { key: "tab", metaKey: false, ctrlKey: true, shiftKey: false, altKey: false, modKey: false }, + command: "terminal.next", + whenAst: whenIdentifier("terminalFocus"), + }, + { + shortcut: { key: "tab", metaKey: false, ctrlKey: true, shiftKey: true, altKey: false, modKey: false }, + command: "terminal.previous", + whenAst: whenIdentifier("terminalFocus"), + }, + { + shortcut: modShortcut("arrowleft", { altKey: true }), + command: "terminal.focusLeftPane", + whenAst: whenIdentifier("terminalFocus"), + }, + { + shortcut: modShortcut("arrowright", { altKey: true }), + command: "terminal.focusRightPane", + whenAst: whenIdentifier("terminalFocus"), + }, { shortcut: modShortcut("d"), command: "diff.toggle", @@ -261,6 +281,90 @@ describe("split/new/close terminal shortcuts", () => { }); }); +describe("terminal.next / terminal.previous", () => { + const tabEvent = (overrides: Partial = {}): ShortcutEventLike => + event({ key: "Tab", ctrlKey: true, ...overrides }); + + it("resolves ctrl+tab to terminal.next when terminal is focused", () => { + assert.strictEqual( + resolveShortcutCommand(tabEvent(), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + "terminal.next", + ); + }); + + it("resolves ctrl+shift+tab to terminal.previous when terminal is focused", () => { + assert.strictEqual( + resolveShortcutCommand(tabEvent({ shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + "terminal.previous", + ); + }); + + it("does not fire ctrl+tab when terminal is not focused", () => { + assert.isNull( + resolveShortcutCommand(tabEvent(), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + ); + assert.isNull( + resolveShortcutCommand(tabEvent({ shiftKey: true }), DEFAULT_BINDINGS, { + platform: "MacIntel", + context: { terminalFocus: false }, + }), + ); + }); + + it("uses the same ctrl+tab binding on Linux/Windows", () => { + assert.strictEqual( + resolveShortcutCommand(tabEvent(), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: true }, + }), + "terminal.next", + ); + }); +}); + +describe("terminal pane focus shortcuts", () => { + it("resolves cmd+alt+left to terminal.focusLeftPane on macOS", () => { + assert.strictEqual( + resolveShortcutCommand( + event({ key: "ArrowLeft", metaKey: true, altKey: true }), + DEFAULT_BINDINGS, + { platform: "MacIntel", context: { terminalFocus: true } }, + ), + "terminal.focusLeftPane", + ); + }); + + it("resolves ctrl+alt+right to terminal.focusRightPane on Linux/Windows", () => { + assert.strictEqual( + resolveShortcutCommand( + event({ key: "ArrowRight", ctrlKey: true, altKey: true }), + DEFAULT_BINDINGS, + { platform: "Linux", context: { terminalFocus: true } }, + ), + "terminal.focusRightPane", + ); + }); + + it("does not fire pane focus shortcuts when terminal is not focused", () => { + assert.isNull( + resolveShortcutCommand( + event({ key: "ArrowLeft", metaKey: true, altKey: true }), + DEFAULT_BINDINGS, + { platform: "MacIntel", context: { terminalFocus: false } }, + ), + ); + }); +}); + describe("shortcutLabelForCommand", () => { it("returns the effective binding label", () => { const bindings = compile([ diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index dbf2450f794..10b340b415e 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -216,6 +216,8 @@ function formatShortcutKeyLabel(key: string): string { if (key === "arrowdown") return "Down"; if (key === "arrowleft") return "Left"; if (key === "arrowright") return "Right"; + if (key === "pageup") return "PgUp"; + if (key === "pagedown") return "PgDn"; return key.slice(0, 1).toUpperCase() + key.slice(1); } diff --git a/apps/web/src/terminalNavigation.test.ts b/apps/web/src/terminalNavigation.test.ts new file mode 100644 index 00000000000..828c5bf73bd --- /dev/null +++ b/apps/web/src/terminalNavigation.test.ts @@ -0,0 +1,53 @@ +import { assert, describe, it } from "vitest"; +import { nextCycleIndex, nextDirectionalIndex } from "./terminalNavigation"; + +describe("nextCycleIndex", () => { + it("returns null when length <= 1", () => { + assert.isNull(nextCycleIndex(0, 0, "next")); + assert.isNull(nextCycleIndex(0, 1, "next")); + assert.isNull(nextCycleIndex(0, 1, "previous")); + }); + + it("advances forward and wraps around", () => { + assert.strictEqual(nextCycleIndex(0, 3, "next"), 1); + assert.strictEqual(nextCycleIndex(1, 3, "next"), 2); + assert.strictEqual(nextCycleIndex(2, 3, "next"), 0); + }); + + it("steps backward and wraps around", () => { + assert.strictEqual(nextCycleIndex(2, 3, "previous"), 1); + assert.strictEqual(nextCycleIndex(1, 3, "previous"), 0); + assert.strictEqual(nextCycleIndex(0, 3, "previous"), 2); + }); + + it("treats out-of-range or negative current index as 0", () => { + assert.strictEqual(nextCycleIndex(-1, 3, "next"), 1); + assert.strictEqual(nextCycleIndex(99, 3, "next"), 1); + assert.strictEqual(nextCycleIndex(-1, 3, "previous"), 2); + }); +}); + +describe("nextDirectionalIndex", () => { + it("returns null when current index is out of range", () => { + assert.isNull(nextDirectionalIndex(-1, 3, "right")); + assert.isNull(nextDirectionalIndex(3, 3, "right")); + assert.isNull(nextDirectionalIndex(99, 3, "left")); + }); + + it("moves left and right within bounds", () => { + assert.strictEqual(nextDirectionalIndex(0, 3, "right"), 1); + assert.strictEqual(nextDirectionalIndex(1, 3, "right"), 2); + assert.strictEqual(nextDirectionalIndex(2, 3, "left"), 1); + assert.strictEqual(nextDirectionalIndex(1, 3, "left"), 0); + }); + + it("returns null at edges (no wrap)", () => { + assert.isNull(nextDirectionalIndex(2, 3, "right")); + assert.isNull(nextDirectionalIndex(0, 3, "left")); + }); + + it("returns null for single-element arrays", () => { + assert.isNull(nextDirectionalIndex(0, 1, "right")); + assert.isNull(nextDirectionalIndex(0, 1, "left")); + }); +}); diff --git a/apps/web/src/terminalNavigation.ts b/apps/web/src/terminalNavigation.ts new file mode 100644 index 00000000000..2b080b8dd93 --- /dev/null +++ b/apps/web/src/terminalNavigation.ts @@ -0,0 +1,22 @@ +export function nextCycleIndex( + currentIndex: number, + length: number, + direction: "next" | "previous", +): number | null { + if (length <= 1) return null; + const startIndex = currentIndex < 0 || currentIndex >= length ? 0 : currentIndex; + const offset = direction === "next" ? 1 : -1; + return (startIndex + offset + length) % length; +} + +export function nextDirectionalIndex( + currentIndex: number, + length: number, + direction: "left" | "right", +): number | null { + if (currentIndex < 0 || currentIndex >= length) return null; + const offset = direction === "right" ? 1 : -1; + const nextIndex = currentIndex + offset; + if (nextIndex < 0 || nextIndex >= length) return null; + return nextIndex; +} diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 502e564fb82..b10e95cdbba 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -52,6 +52,10 @@ const STATIC_KEYBINDING_COMMANDS = [ "terminal.split", "terminal.new", "terminal.close", + "terminal.next", + "terminal.previous", + "terminal.focusLeftPane", + "terminal.focusRightPane", "diff.toggle", "commandPalette.toggle", "chat.new", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 3cc2e913621..a667fede48e 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -23,6 +23,12 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, + { key: "ctrl+tab", command: "terminal.next", when: "terminalFocus" }, + { key: "ctrl+shift+tab", command: "terminal.previous", when: "terminalFocus" }, + { key: "alt+pagedown", command: "terminal.next", when: "terminalFocus" }, + { key: "alt+pageup", command: "terminal.previous", when: "terminalFocus" }, + { key: "mod+alt+arrowleft", command: "terminal.focusLeftPane", when: "terminalFocus" }, + { key: "mod+alt+arrowright", command: "terminal.focusRightPane", when: "terminalFocus" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, From c5bf6f310c3af862b90c4e9670959de4b55d3628 Mon Sep 17 00:00:00 2001 From: Oskar Jansson Date: Sun, 10 May 2026 14:40:22 +0200 Subject: [PATCH 2/7] Rename split-focus commands to drop foreign "pane" term MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit terminal.focusLeftPane -> terminal.focusLeft terminal.focusRightPane -> terminal.focusRight The t3code codebase consistently uses "terminal" (the unit) and "split" (the multi-terminal state); "pane" is not used anywhere else. Aligning with native vocabulary keeps the command namespace consistent and auto-generates cleaner labels in the keybindings settings UI: "Terminal: Focus Left" / "Terminal: Focus Right". Naming is still forward-compatible with vertical splits (#1049) — would add terminal.focusUp / terminal.focusDown without renames. Co-Authored-By: Claude Opus 4.7 --- KEYBINDINGS.md | 8 ++++---- apps/web/src/components/ChatView.tsx | 4 ++-- apps/web/src/keybindings.test.ts | 12 ++++++------ packages/contracts/src/keybindings.ts | 4 ++-- packages/shared/src/keybindings.ts | 4 ++-- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index 2a9be1a5ba4..e6322f7d984 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -27,8 +27,8 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "ctrl+shift+tab", "command": "terminal.previous", "when": "terminalFocus" }, { "key": "alt+pagedown", "command": "terminal.next", "when": "terminalFocus" }, { "key": "alt+pageup", "command": "terminal.previous", "when": "terminalFocus" }, - { "key": "mod+alt+arrowleft", "command": "terminal.focusLeftPane", "when": "terminalFocus" }, - { "key": "mod+alt+arrowright", "command": "terminal.focusRightPane", "when": "terminalFocus" }, + { "key": "mod+alt+arrowleft", "command": "terminal.focusLeft", "when": "terminalFocus" }, + { "key": "mod+alt+arrowright", "command": "terminal.focusRight", "when": "terminalFocus" }, { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" }, { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, @@ -59,8 +59,8 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.close`: close/kill the focused terminal (in focused terminal context by default) - `terminal.next`: cycle to the next terminal (wraps around) - `terminal.previous`: cycle to the previous terminal (wraps around) -- `terminal.focusLeftPane`: focus the pane to the left of the active pane within the current terminal (no wrap) -- `terminal.focusRightPane`: focus the pane to the right of the active pane within the current terminal (no wrap) +- `terminal.focusLeft`: focus the terminal to the left of the active one within the current split (no wrap) +- `terminal.focusRight`: focus the terminal to the right of the active one within the current split (no wrap) Browser note: `ctrl+tab` and `cmd+alt+arrow` are reserved by most browsers and cannot be intercepted in the web app. Use the alt+pageup/pagedown alternatives, or override these in your custom keybindings — both work reliably in the desktop app and the web app. - `commandPalette.toggle`: open or close the global command palette diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ec4ac0d968d..02fbd0ca673 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2573,14 +2573,14 @@ export default function ChatView(props: ChatViewProps) { return; } - if (command === "terminal.focusLeftPane") { + if (command === "terminal.focusLeft") { event.preventDefault(); event.stopPropagation(); focusPane("left"); return; } - if (command === "terminal.focusRightPane") { + if (command === "terminal.focusRight") { event.preventDefault(); event.stopPropagation(); focusPane("right"); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 1300fc17bed..56ca82c1002 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -112,12 +112,12 @@ const DEFAULT_BINDINGS = compile([ }, { shortcut: modShortcut("arrowleft", { altKey: true }), - command: "terminal.focusLeftPane", + command: "terminal.focusLeft", whenAst: whenIdentifier("terminalFocus"), }, { shortcut: modShortcut("arrowright", { altKey: true }), - command: "terminal.focusRightPane", + command: "terminal.focusRight", whenAst: whenIdentifier("terminalFocus"), }, { @@ -332,25 +332,25 @@ describe("terminal.next / terminal.previous", () => { }); describe("terminal pane focus shortcuts", () => { - it("resolves cmd+alt+left to terminal.focusLeftPane on macOS", () => { + it("resolves cmd+alt+left to terminal.focusLeft on macOS", () => { assert.strictEqual( resolveShortcutCommand( event({ key: "ArrowLeft", metaKey: true, altKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", context: { terminalFocus: true } }, ), - "terminal.focusLeftPane", + "terminal.focusLeft", ); }); - it("resolves ctrl+alt+right to terminal.focusRightPane on Linux/Windows", () => { + it("resolves ctrl+alt+right to terminal.focusRight on Linux/Windows", () => { assert.strictEqual( resolveShortcutCommand( event({ key: "ArrowRight", ctrlKey: true, altKey: true }), DEFAULT_BINDINGS, { platform: "Linux", context: { terminalFocus: true } }, ), - "terminal.focusRightPane", + "terminal.focusRight", ); }); diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index b10e95cdbba..10e061d58cf 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -54,8 +54,8 @@ const STATIC_KEYBINDING_COMMANDS = [ "terminal.close", "terminal.next", "terminal.previous", - "terminal.focusLeftPane", - "terminal.focusRightPane", + "terminal.focusLeft", + "terminal.focusRight", "diff.toggle", "commandPalette.toggle", "chat.new", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index a667fede48e..4361b72b33a 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -27,8 +27,8 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "ctrl+shift+tab", command: "terminal.previous", when: "terminalFocus" }, { key: "alt+pagedown", command: "terminal.next", when: "terminalFocus" }, { key: "alt+pageup", command: "terminal.previous", when: "terminalFocus" }, - { key: "mod+alt+arrowleft", command: "terminal.focusLeftPane", when: "terminalFocus" }, - { key: "mod+alt+arrowright", command: "terminal.focusRightPane", when: "terminalFocus" }, + { key: "mod+alt+arrowleft", command: "terminal.focusLeft", when: "terminalFocus" }, + { key: "mod+alt+arrowright", command: "terminal.focusRight", when: "terminalFocus" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, From faf67ceaa64585e5a42a45c6a64beae7f01c2a23 Mon Sep 17 00:00:00 2001 From: Oskar Jansson Date: Sun, 10 May 2026 16:12:57 +0200 Subject: [PATCH 3/7] Drop alt+pageup/pagedown defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These were intended as browser-safe alternates to ctrl+tab, but on macOS laptops without dedicated PageUp/PageDown keys, Fn+Option+Arrow doesn't reliably synthesize Alt+PageUp/Down — the OS layer interferes. Same problem VS Code has with its Ctrl+PageUp/Down terminal navigation. Trying to bake browser support into defaults forces ergonomic compromises. Keep the defaults targeted at the desktop app and direct web-app users to customize via ~/.t3/keybindings.json. Docs updated to reflect this. The PgUp/PgDn display labels remain — users who add custom pageup/ pagedown bindings via the settings UI will still see clean labels. Co-Authored-By: Claude Opus 4.7 --- KEYBINDINGS.md | 4 +--- packages/shared/src/keybindings.ts | 2 -- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index e6322f7d984..a8a6348892b 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -25,8 +25,6 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" }, { "key": "ctrl+tab", "command": "terminal.next", "when": "terminalFocus" }, { "key": "ctrl+shift+tab", "command": "terminal.previous", "when": "terminalFocus" }, - { "key": "alt+pagedown", "command": "terminal.next", "when": "terminalFocus" }, - { "key": "alt+pageup", "command": "terminal.previous", "when": "terminalFocus" }, { "key": "mod+alt+arrowleft", "command": "terminal.focusLeft", "when": "terminalFocus" }, { "key": "mod+alt+arrowright", "command": "terminal.focusRight", "when": "terminalFocus" }, { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" }, @@ -62,7 +60,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.focusLeft`: focus the terminal to the left of the active one within the current split (no wrap) - `terminal.focusRight`: focus the terminal to the right of the active one within the current split (no wrap) -Browser note: `ctrl+tab` and `cmd+alt+arrow` are reserved by most browsers and cannot be intercepted in the web app. Use the alt+pageup/pagedown alternatives, or override these in your custom keybindings — both work reliably in the desktop app and the web app. +Browser note: `ctrl+tab` and `cmd+alt+arrow` are reserved by most browsers and cannot be intercepted in the web app variant — these defaults target the desktop app. If you use the web app, override them in your custom keybindings with shortcuts that work for your browser and OS. - `commandPalette.toggle`: open or close the global command palette - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 4361b72b33a..0c50f1cc3af 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -25,8 +25,6 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "ctrl+tab", command: "terminal.next", when: "terminalFocus" }, { key: "ctrl+shift+tab", command: "terminal.previous", when: "terminalFocus" }, - { key: "alt+pagedown", command: "terminal.next", when: "terminalFocus" }, - { key: "alt+pageup", command: "terminal.previous", when: "terminalFocus" }, { key: "mod+alt+arrowleft", command: "terminal.focusLeft", when: "terminalFocus" }, { key: "mod+alt+arrowright", command: "terminal.focusRight", when: "terminalFocus" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, From 423a1e4ceb3dd5b1640ca67bf3a7b44af3d8300d Mon Sep 17 00:00:00 2001 From: Oskar Jansson Date: Sun, 10 May 2026 19:27:26 +0200 Subject: [PATCH 4/7] Rename focusPane helper to focusSplit The user-facing commands are terminal.focusLeft / terminal.focusRight, and the codebase uses "split" (not "pane") in UI-layer code. Rename the internal callback to match. Co-Authored-By: Claude Opus 4.7 --- apps/web/src/components/ChatView.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 02fbd0ca673..24d9db11876 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1835,7 +1835,7 @@ export default function ChatView(props: ChatViewProps) { terminalState.terminalGroups, ], ); - const focusPane = useCallback( + const focusSplit = useCallback( (direction: "left" | "right") => { if (!activeThreadRef) return; const activeGroup = terminalState.terminalGroups.find( @@ -2576,14 +2576,14 @@ export default function ChatView(props: ChatViewProps) { if (command === "terminal.focusLeft") { event.preventDefault(); event.stopPropagation(); - focusPane("left"); + focusSplit("left"); return; } if (command === "terminal.focusRight") { event.preventDefault(); event.stopPropagation(); - focusPane("right"); + focusSplit("right"); return; } @@ -2619,7 +2619,7 @@ export default function ChatView(props: ChatViewProps) { closeTerminal, createNewTerminal, cycleTerminal, - focusPane, + focusSplit, setTerminalOpen, runProjectScript, splitTerminal, From 8291912866852e8d749e27e593d2f57b7d16442b Mon Sep 17 00:00:00 2001 From: Oskar Jansson Date: Sun, 10 May 2026 19:32:56 +0200 Subject: [PATCH 5/7] Update split-focus tests after rename, broaden coverage Drops "pane" from describe/it labels to match the focusLeft/focusRight command names. Each platform block now exercises both left and right directions, and the terminalFocus guard test covers both. Added a ctrl+shift+tab Linux/Windows assertion for terminal.previous symmetry. Co-Authored-By: Claude Opus 4.7 --- apps/web/src/keybindings.test.ts | 40 ++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 56ca82c1002..9538e75cfe4 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -320,7 +320,7 @@ describe("terminal.next / terminal.previous", () => { ); }); - it("uses the same ctrl+tab binding on Linux/Windows", () => { + it("uses ctrl+tab and ctrl+shift+tab on Linux/Windows", () => { assert.strictEqual( resolveShortcutCommand(tabEvent(), DEFAULT_BINDINGS, { platform: "Linux", @@ -328,11 +328,18 @@ describe("terminal.next / terminal.previous", () => { }), "terminal.next", ); + assert.strictEqual( + resolveShortcutCommand(tabEvent({ shiftKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: true }, + }), + "terminal.previous", + ); }); }); -describe("terminal pane focus shortcuts", () => { - it("resolves cmd+alt+left to terminal.focusLeft on macOS", () => { +describe("terminal split focus shortcuts", () => { + it("resolves cmd+alt+arrow to focusLeft/focusRight on macOS", () => { assert.strictEqual( resolveShortcutCommand( event({ key: "ArrowLeft", metaKey: true, altKey: true }), @@ -341,9 +348,25 @@ describe("terminal pane focus shortcuts", () => { ), "terminal.focusLeft", ); + assert.strictEqual( + resolveShortcutCommand( + event({ key: "ArrowRight", metaKey: true, altKey: true }), + DEFAULT_BINDINGS, + { platform: "MacIntel", context: { terminalFocus: true } }, + ), + "terminal.focusRight", + ); }); - it("resolves ctrl+alt+right to terminal.focusRight on Linux/Windows", () => { + it("resolves ctrl+alt+arrow to focusLeft/focusRight on Linux/Windows", () => { + assert.strictEqual( + resolveShortcutCommand( + event({ key: "ArrowLeft", ctrlKey: true, altKey: true }), + DEFAULT_BINDINGS, + { platform: "Linux", context: { terminalFocus: true } }, + ), + "terminal.focusLeft", + ); assert.strictEqual( resolveShortcutCommand( event({ key: "ArrowRight", ctrlKey: true, altKey: true }), @@ -354,7 +377,7 @@ describe("terminal pane focus shortcuts", () => { ); }); - it("does not fire pane focus shortcuts when terminal is not focused", () => { + it("does not fire when terminal is not focused", () => { assert.isNull( resolveShortcutCommand( event({ key: "ArrowLeft", metaKey: true, altKey: true }), @@ -362,6 +385,13 @@ describe("terminal pane focus shortcuts", () => { { platform: "MacIntel", context: { terminalFocus: false } }, ), ); + assert.isNull( + resolveShortcutCommand( + event({ key: "ArrowRight", metaKey: true, altKey: true }), + DEFAULT_BINDINGS, + { platform: "MacIntel", context: { terminalFocus: false } }, + ), + ); }); }); From 682e86c91d71b94cd6ffe8b205b5e9e4aff5d844 Mon Sep 17 00:00:00 2001 From: Oskar Jansson Date: Sun, 10 May 2026 19:36:49 +0200 Subject: [PATCH 6/7] Drop unrelated PgUp/PgDn label change Out of scope for the terminal navigation feature. No default binding uses these keys after the alt+pageup/pagedown drop. Can be a separate follow-up if needed. Co-Authored-By: Claude Opus 4.7 --- apps/web/src/keybindings.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 10b340b415e..dbf2450f794 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -216,8 +216,6 @@ function formatShortcutKeyLabel(key: string): string { if (key === "arrowdown") return "Down"; if (key === "arrowleft") return "Left"; if (key === "arrowright") return "Right"; - if (key === "pageup") return "PgUp"; - if (key === "pagedown") return "PgDn"; return key.slice(0, 1).toUpperCase() + key.slice(1); } From 4bc68644fe4bcbd0abe5eead130ffe57e6d103e9 Mon Sep 17 00:00:00 2001 From: Oskar Jansson Date: Sun, 10 May 2026 19:38:56 +0200 Subject: [PATCH 7/7] Move browser note out of the commands list in KEYBINDINGS.md The note was inserted mid-list between terminal.focusRight and commandPalette.toggle, breaking the bullet list in rendered Markdown. Moved it after the list ends and dropped the em-dash for plain punctuation. --- KEYBINDINGS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index a8a6348892b..7ce0b21cae1 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -59,14 +59,14 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `terminal.previous`: cycle to the previous terminal (wraps around) - `terminal.focusLeft`: focus the terminal to the left of the active one within the current split (no wrap) - `terminal.focusRight`: focus the terminal to the right of the active one within the current split (no wrap) - -Browser note: `ctrl+tab` and `cmd+alt+arrow` are reserved by most browsers and cannot be intercepted in the web app variant — these defaults target the desktop app. If you use the web app, override them in your custom keybindings with shortcuts that work for your browser and OS. - `commandPalette.toggle`: open or close the global command palette - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) - `editor.openFavorite`: open current project/worktree in the last-used editor - `script.{id}.run`: run a project script by id (for example `script.test.run`) +Browser note: `ctrl+tab` and `cmd+alt+arrow` are reserved by most browsers and cannot be intercepted in the web app variant. These defaults target the desktop app. If you use the web app, override them in your custom keybindings with shortcuts that work for your browser and OS. + ### Key Syntax Supported modifiers: