authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 22:33:12-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 23:08:37-08:00
logc42d6a6a6489c89d56830cdba7fee752afef5c14
treeb7dee09d52d94618d54d2559af562a6702f088df
parent5242bbbaded1389ba117dae8089f6d7f505bc2a5

Add shortcuts for terminal close and chat with same Git state

- Extend keybinding contracts/defaults with `terminal.close` and `chat.newSameGitState` - Wire web handlers for `mod+w` to close focused terminal and `mod+shift+n` to open a thread preserving branch/worktree - Update docs and keybinding tests across server, web, and contracts

9 files changed, 123 insertions(+), 10 deletions(-)

KEYBINDINGS.md+4
...@@ -22,7 +22,9 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`](...@@ -22,7 +22,9 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`](
22 { "key": "mod+j", "command": "terminal.toggle" },22 { "key": "mod+j", "command": "terminal.toggle" },
23 { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" },23 { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" },
24 { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" },24 { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" },
25 { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" },
25 { "key": "mod+shift+o", "command": "chat.new" },26 { "key": "mod+shift+o", "command": "chat.new" },
27 { "key": "mod+shift+n", "command": "chat.newSameGitState" },
26 { "key": "mod+o", "command": "editor.openFavorite" }28 { "key": "mod+o", "command": "editor.openFavorite" }
27]29]
28```30```
...@@ -46,7 +48,9 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged...@@ -46,7 +48,9 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged
46- `terminal.toggle`: open/close terminal drawer48- `terminal.toggle`: open/close terminal drawer
47- `terminal.split`: split terminal (in focused terminal context by default)49- `terminal.split`: split terminal (in focused terminal context by default)
48- `terminal.new`: create new terminal (in focused terminal context by default)50- `terminal.new`: create new terminal (in focused terminal context by default)
51- `terminal.close`: close/kill the focused terminal (in focused terminal context by default)
49- `chat.new`: create a new chat thread for the active project52- `chat.new`: create a new chat thread for the active project
53- `chat.newSameGitState`: create a new chat thread preserving the active thread's branch/worktree state
50- `editor.openFavorite`: open current project/worktree in the last-used editor54- `editor.openFavorite`: open current project/worktree in the last-used editor
5155
52### Key Syntax56### Key Syntax
apps/server/src/keybindings.test.ts+17-1
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import { assert, describe, it } from "vitest";1import { assert, describe, it } from "vitest";
22
3import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings";3import {
4 DEFAULT_KEYBINDINGS,
5 compileResolvedKeybindingRule,
6 parseKeybindingShortcut,
7} from "./keybindings";
48
5describe("server keybindings", () => {9describe("server keybindings", () => {
6 it("parses shortcuts including plus key", () => {10 it("parses shortcuts including plus key", () => {
...@@ -66,4 +70,16 @@ describe("server keybindings", () => {...@@ -66,4 +70,16 @@ describe("server keybindings", () => {
66 }),70 }),
67 );71 );
68 });72 });
73
74 it("defines defaults for terminal.close and chat.newSameGitState", () => {
75 assert.deepInclude(DEFAULT_KEYBINDINGS, {
76 key: "mod+w",
77 command: "terminal.close",
78 when: "terminalFocus",
79 });
80 assert.deepInclude(DEFAULT_KEYBINDINGS, {
81 key: "mod+shift+n",
82 command: "chat.newSameGitState",
83 });
84 });
69});85});
apps/server/src/keybindings.ts+2
...@@ -28,7 +28,9 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [...@@ -28,7 +28,9 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
28 { key: "mod+j", command: "terminal.toggle" },28 { key: "mod+j", command: "terminal.toggle" },
29 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },29 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
30 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },30 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
31 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },
31 { key: "mod+shift+o", command: "chat.new" },32 { key: "mod+shift+o", command: "chat.new" },
33 { key: "mod+shift+n", command: "chat.newSameGitState" },
32 { key: "mod+o", command: "editor.openFavorite" },34 { key: "mod+o", command: "editor.openFavorite" },
33];35];
3436
apps/web/src/components/ChatView.tsx+11
...@@ -52,6 +52,7 @@ import BranchToolbar from "./BranchToolbar";...@@ -52,6 +52,7 @@ import BranchToolbar from "./BranchToolbar";
52import GitActionsControl from "./GitActionsControl";52import GitActionsControl from "./GitActionsControl";
53import {53import {
54 isOpenFavoriteEditorShortcut,54 isOpenFavoriteEditorShortcut,
55 isTerminalCloseShortcut,
55 isTerminalNewShortcut,56 isTerminalNewShortcut,
56 isTerminalSplitShortcut,57 isTerminalSplitShortcut,
57 isTerminalToggleShortcut,58 isTerminalToggleShortcut,
...@@ -554,6 +555,14 @@ export default function ChatView() {...@@ -554,6 +555,14 @@ export default function ChatView() {
554 return;555 return;
555 }556 }
556557
558 if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) {
559 event.preventDefault();
560 event.stopPropagation();
561 if (!activeThread?.terminalOpen) return;
562 closeTerminal(activeThread.activeTerminalId);
563 return;
564 }
565
557 if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;566 if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
558 event.preventDefault();567 event.preventDefault();
559 event.stopPropagation();568 event.stopPropagation();
...@@ -570,7 +579,9 @@ export default function ChatView() {...@@ -570,7 +579,9 @@ export default function ChatView() {
570 return () => window.removeEventListener("keydown", handler);579 return () => window.removeEventListener("keydown", handler);
571 }, [580 }, [
572 activeThread?.terminalOpen,581 activeThread?.terminalOpen,
582 activeThread?.activeTerminalId,
573 activeThreadId,583 activeThreadId,
584 closeTerminal,
574 createNewTerminal,585 createNewTerminal,
575 dispatch,586 dispatch,
576 splitTerminal,587 splitTerminal,
apps/web/src/components/Sidebar.tsx+22-7
...@@ -7,7 +7,7 @@ import { useTheme } from "../hooks/useTheme";...@@ -7,7 +7,7 @@ import { useTheme } from "../hooks/useTheme";
7import { DEFAULT_MODEL } from "../model-logic";7import { DEFAULT_MODEL } from "../model-logic";
8import { derivePendingApprovals } from "../session-logic";8import { derivePendingApprovals } from "../session-logic";
9import { useStore } from "../store";9import { useStore } from "../store";
10import { isChatNewShortcut } from "../keybindings";10import { isChatNewSameGitStateShortcut, isChatNewShortcut } from "../keybindings";
11import {11import {
12 DEFAULT_THREAD_TERMINAL_HEIGHT,12 DEFAULT_THREAD_TERMINAL_HEIGHT,
13 DEFAULT_THREAD_TERMINAL_ID,13 DEFAULT_THREAD_TERMINAL_ID,
...@@ -140,7 +140,13 @@ export default function Sidebar() {...@@ -140,7 +140,13 @@ export default function Sidebar() {
140 }, [state.threads]);140 }, [state.threads]);
141141
142 const handleNewThread = useCallback(142 const handleNewThread = useCallback(
143 (projectId: string) => {143 (
144 projectId: string,
145 options?: {
146 branch?: string | null;
147 worktreePath?: string | null;
148 },
149 ) => {
144 dispatch({150 dispatch({
145 type: "ADD_THREAD",151 type: "ADD_THREAD",
146 thread: {152 thread: {
...@@ -166,8 +172,8 @@ export default function Sidebar() {...@@ -166,8 +172,8 @@ export default function Sidebar() {
166 events: [],172 events: [],
167 error: null,173 error: null,
168 createdAt: new Date().toISOString(),174 createdAt: new Date().toISOString(),
169 branch: null,175 branch: options?.branch ?? null,
170 worktreePath: null,176 worktreePath: options?.worktreePath ?? null,
171 },177 },
172 });178 });
173 },179 },
...@@ -381,12 +387,21 @@ export default function Sidebar() {...@@ -381,12 +387,21 @@ export default function Sidebar() {
381387
382 useEffect(() => {388 useEffect(() => {
383 const onWindowKeyDown = (event: KeyboardEvent) => {389 const onWindowKeyDown = (event: KeyboardEvent) => {
384 if (!isChatNewShortcut(event, keybindings)) return;
385
386 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);390 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
391 if (isChatNewSameGitStateShortcut(event, keybindings)) {
392 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
393 if (!projectId) return;
394 event.preventDefault();
395 handleNewThread(projectId, {
396 branch: activeThread?.branch ?? null,
397 worktreePath: activeThread?.worktreePath ?? null,
398 });
399 return;
400 }
401
402 if (!isChatNewShortcut(event, keybindings)) return;
387 const projectId = activeThread?.projectId ?? state.projects[0]?.id;403 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
388 if (!projectId) return;404 if (!projectId) return;
389
390 event.preventDefault();405 event.preventDefault();
391 handleNewThread(projectId);406 handleNewThread(projectId);
392 };407 };
apps/web/src/keybindings.test.ts+43-2
...@@ -7,10 +7,12 @@ import {...@@ -7,10 +7,12 @@ import {
7 type ResolvedKeybindingsConfig,7 type ResolvedKeybindingsConfig,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import {9import {
10 isChatNewSameGitStateShortcut,
10 formatShortcutLabel,11 formatShortcutLabel,
11 isChatNewShortcut,12 isChatNewShortcut,
12 isOpenFavoriteEditorShortcut,13 isOpenFavoriteEditorShortcut,
13 isTerminalClearShortcut,14 isTerminalClearShortcut,
15 isTerminalCloseShortcut,
14 isTerminalNewShortcut,16 isTerminalNewShortcut,
15 isTerminalSplitShortcut,17 isTerminalSplitShortcut,
16 isTerminalToggleShortcut,18 isTerminalToggleShortcut,
...@@ -83,7 +85,13 @@ const DEFAULT_BINDINGS = compile([...@@ -83,7 +85,13 @@ const DEFAULT_BINDINGS = compile([
83 command: "terminal.new",85 command: "terminal.new",
84 whenAst: whenIdentifier("terminalFocus"),86 whenAst: whenIdentifier("terminalFocus"),
85 },87 },
88 {
89 shortcut: modShortcut("w"),
90 command: "terminal.close",
91 whenAst: whenIdentifier("terminalFocus"),
92 },
86 { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" },93 { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" },
94 { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newSameGitState" },
87 { shortcut: modShortcut("o"), command: "editor.openFavorite" },95 { shortcut: modShortcut("o"), command: "editor.openFavorite" },
88]);96]);
8997
...@@ -101,8 +109,8 @@ describe("isTerminalToggleShortcut", () => {...@@ -101,8 +109,8 @@ describe("isTerminalToggleShortcut", () => {
101 });109 });
102});110});
103111
104describe("split/new terminal shortcuts", () => {112describe("split/new/close terminal shortcuts", () => {
105 it("requires terminalFocus for default split/new bindings", () => {113 it("requires terminalFocus for default split/new/close bindings", () => {
106 assert.isFalse(114 assert.isFalse(
107 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {115 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
108 platform: "MacIntel",116 platform: "MacIntel",
...@@ -115,6 +123,12 @@ describe("split/new terminal shortcuts", () => {...@@ -115,6 +123,12 @@ describe("split/new terminal shortcuts", () => {
115 context: { terminalFocus: false },123 context: { terminalFocus: false },
116 }),124 }),
117 );125 );
126 assert.isFalse(
127 isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, {
128 platform: "Linux",
129 context: { terminalFocus: false },
130 }),
131 );
118 });132 });
119133
120 it("matches split/new when terminalFocus is true", () => {134 it("matches split/new when terminalFocus is true", () => {
...@@ -130,6 +144,12 @@ describe("split/new terminal shortcuts", () => {...@@ -130,6 +144,12 @@ describe("split/new terminal shortcuts", () => {
130 context: { terminalFocus: true },144 context: { terminalFocus: true },
131 }),145 }),
132 );146 );
147 assert.isTrue(
148 isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, {
149 platform: "Linux",
150 context: { terminalFocus: true },
151 }),
152 );
133 });153 });
134154
135 it("supports when expressions", () => {155 it("supports when expressions", () => {
...@@ -225,6 +245,27 @@ describe("chat/editor shortcuts", () => {...@@ -225,6 +245,27 @@ describe("chat/editor shortcuts", () => {
225 );245 );
226 });246 });
227247
248 it("matches chat.newSameGitState shortcut", () => {
249 assert.isTrue(
250 isChatNewSameGitStateShortcut(
251 event({ key: "n", metaKey: true, shiftKey: true }),
252 DEFAULT_BINDINGS,
253 {
254 platform: "MacIntel",
255 },
256 ),
257 );
258 assert.isTrue(
259 isChatNewSameGitStateShortcut(
260 event({ key: "n", ctrlKey: true, shiftKey: true }),
261 DEFAULT_BINDINGS,
262 {
263 platform: "Linux",
264 },
265 ),
266 );
267 });
268
228 it("matches editor.openFavorite shortcut", () => {269 it("matches editor.openFavorite shortcut", () => {
229 assert.isTrue(270 assert.isTrue(
230 isOpenFavoriteEditorShortcut(event({ key: "o", metaKey: true }), DEFAULT_BINDINGS, {271 isOpenFavoriteEditorShortcut(event({ key: "o", metaKey: true }), DEFAULT_BINDINGS, {
apps/web/src/keybindings.ts+16
...@@ -190,6 +190,14 @@ export function isTerminalNewShortcut(...@@ -190,6 +190,14 @@ export function isTerminalNewShortcut(
190 return matchesCommandShortcut(event, keybindings, "terminal.new", options);190 return matchesCommandShortcut(event, keybindings, "terminal.new", options);
191}191}
192192
193export function isTerminalCloseShortcut(
194 event: ShortcutEventLike,
195 keybindings: ResolvedKeybindingsConfig,
196 options?: ShortcutMatchOptions,
197): boolean {
198 return matchesCommandShortcut(event, keybindings, "terminal.close", options);
199}
200
193export function isChatNewShortcut(201export function isChatNewShortcut(
194 event: ShortcutEventLike,202 event: ShortcutEventLike,
195 keybindings: ResolvedKeybindingsConfig,203 keybindings: ResolvedKeybindingsConfig,
...@@ -198,6 +206,14 @@ export function isChatNewShortcut(...@@ -198,6 +206,14 @@ export function isChatNewShortcut(
198 return matchesCommandShortcut(event, keybindings, "chat.new", options);206 return matchesCommandShortcut(event, keybindings, "chat.new", options);
199}207}
200208
209export function isChatNewSameGitStateShortcut(
210 event: ShortcutEventLike,
211 keybindings: ResolvedKeybindingsConfig,
212 options?: ShortcutMatchOptions,
213): boolean {
214 return matchesCommandShortcut(event, keybindings, "chat.newSameGitState", options);
215}
216
201export function isOpenFavoriteEditorShortcut(217export function isOpenFavoriteEditorShortcut(
202 event: ShortcutEventLike,218 event: ShortcutEventLike,
203 keybindings: ResolvedKeybindingsConfig,219 keybindings: ResolvedKeybindingsConfig,
packages/contracts/src/keybindings.test.ts+6
...@@ -14,6 +14,12 @@ describe("keybindings contracts", () => {...@@ -14,6 +14,12 @@ describe("keybindings contracts", () => {
14 command: "terminal.toggle",14 command: "terminal.toggle",
15 });15 });
16 assert.strictEqual(parsed.command, "terminal.toggle");16 assert.strictEqual(parsed.command, "terminal.toggle");
17
18 const parsedClose = keybindingRuleSchema.parse({
19 key: "mod+w",
20 command: "terminal.close",
21 });
22 assert.strictEqual(parsedClose.command, "terminal.close");
17 });23 });
1824
19 it("rejects invalid command values", () => {25 it("rejects invalid command values", () => {
packages/contracts/src/keybindings.ts+2
...@@ -4,7 +4,9 @@ export const keybindingCommandSchema = z.enum([...@@ -4,7 +4,9 @@ export const keybindingCommandSchema = z.enum([
4 "terminal.toggle",4 "terminal.toggle",
5 "terminal.split",5 "terminal.split",
6 "terminal.new",6 "terminal.new",
7 "terminal.close",
7 "chat.new",8 "chat.new",
9 "chat.newSameGitState",
8 "editor.openFavorite",10 "editor.openFavorite",
9]);11]);
1012