diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index b57c13032c..7ce0b21cae 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -23,6 +23,10 @@ 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": "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" }, @@ -51,12 +55,18 @@ 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.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) - `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: diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6b84aa11ca..24d9db1187 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 focusSplit = 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.focusLeft") { + event.preventDefault(); + event.stopPropagation(); + focusSplit("left"); + return; + } + + if (command === "terminal.focusRight") { + event.preventDefault(); + event.stopPropagation(); + focusSplit("right"); + return; + } + if (command === "diff.toggle") { event.preventDefault(); event.stopPropagation(); @@ -2541,6 +2618,8 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, closeTerminal, createNewTerminal, + cycleTerminal, + focusSplit, setTerminalOpen, runProjectScript, splitTerminal, diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 85c14fa0ab..9538e75cfe 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.focusLeft", + whenAst: whenIdentifier("terminalFocus"), + }, + { + shortcut: modShortcut("arrowright", { altKey: true }), + command: "terminal.focusRight", + whenAst: whenIdentifier("terminalFocus"), + }, { shortcut: modShortcut("d"), command: "diff.toggle", @@ -261,6 +281,120 @@ 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 ctrl+tab and ctrl+shift+tab on Linux/Windows", () => { + assert.strictEqual( + resolveShortcutCommand(tabEvent(), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: true }, + }), + "terminal.next", + ); + assert.strictEqual( + resolveShortcutCommand(tabEvent({ shiftKey: true }), DEFAULT_BINDINGS, { + platform: "Linux", + context: { terminalFocus: true }, + }), + "terminal.previous", + ); + }); +}); + +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 }), + DEFAULT_BINDINGS, + { platform: "MacIntel", context: { terminalFocus: true } }, + ), + "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+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 }), + DEFAULT_BINDINGS, + { platform: "Linux", context: { terminalFocus: true } }, + ), + "terminal.focusRight", + ); + }); + + it("does not fire when terminal is not focused", () => { + assert.isNull( + resolveShortcutCommand( + event({ key: "ArrowLeft", metaKey: true, altKey: true }), + DEFAULT_BINDINGS, + { platform: "MacIntel", context: { terminalFocus: false } }, + ), + ); + assert.isNull( + resolveShortcutCommand( + event({ key: "ArrowRight", 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/terminalNavigation.test.ts b/apps/web/src/terminalNavigation.test.ts new file mode 100644 index 0000000000..828c5bf73b --- /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 0000000000..2b080b8dd9 --- /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 502e564fb8..10e061d58c 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.focusLeft", + "terminal.focusRight", "diff.toggle", "commandPalette.toggle", "chat.new", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 3cc2e91362..0c50f1cc3a 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -23,6 +23,10 @@ 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: "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" },