Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions KEYBINDINGS.md
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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:
Expand Down
79 changes: 79 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -2541,6 +2618,8 @@ export default function ChatView(props: ChatViewProps) {
activeThreadId,
closeTerminal,
createNewTerminal,
cycleTerminal,
focusSplit,
setTerminalOpen,
runProjectScript,
splitTerminal,
Expand Down
134 changes: 134 additions & 0 deletions apps/web/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -261,6 +281,120 @@ describe("split/new/close terminal shortcuts", () => {
});
});

describe("terminal.next / terminal.previous", () => {
const tabEvent = (overrides: Partial<ShortcutEventLike> = {}): 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([
Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/terminalNavigation.test.ts
Original file line number Diff line number Diff line change
@@ -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"));
});
});
22 changes: 22 additions & 0 deletions apps/web/src/terminalNavigation.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 4 additions & 0 deletions packages/contracts/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/shared/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
{ 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" },
Expand Down
Loading