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`](
2222 { "key": "mod+j", "command": "terminal.toggle" },
2323 { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" },
2424 { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" },
25 { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" },
2526 { "key": "mod+shift+o", "command": "chat.new" },
27 { "key": "mod+shift+n", "command": "chat.newSameGitState" },
2628 { "key": "mod+o", "command": "editor.openFavorite" }
2729]
2830```
......@@ -46,7 +48,9 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged
4648- `terminal.toggle`: open/close terminal drawer
4749- `terminal.split`: split terminal (in focused terminal context by default)
4850- `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)
4952- `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
5054- `editor.openFavorite`: open current project/worktree in the last-used editor
5155
5256### Key Syntax
apps/server/src/keybindings.test.ts+17-1
......@@ -1,6 +1,10 @@
11import { assert, describe, it } from "vitest";
22
3import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings";
3import {
4 DEFAULT_KEYBINDINGS,
5 compileResolvedKeybindingRule,
6 parseKeybindingShortcut,
7} from "./keybindings";
48
59describe("server keybindings", () => {
610 it("parses shortcuts including plus key", () => {
......@@ -66,4 +70,16 @@ describe("server keybindings", () => {
6670 }),
6771 );
6872 });
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 });
6985});
apps/server/src/keybindings.ts+2
......@@ -28,7 +28,9 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
2828 { key: "mod+j", command: "terminal.toggle" },
2929 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
3030 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
31 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },
3132 { key: "mod+shift+o", command: "chat.new" },
33 { key: "mod+shift+n", command: "chat.newSameGitState" },
3234 { key: "mod+o", command: "editor.openFavorite" },
3335];
3436
apps/web/src/components/ChatView.tsx+11
......@@ -52,6 +52,7 @@ import BranchToolbar from "./BranchToolbar";
5252import GitActionsControl from "./GitActionsControl";
5353import {
5454 isOpenFavoriteEditorShortcut,
55 isTerminalCloseShortcut,
5556 isTerminalNewShortcut,
5657 isTerminalSplitShortcut,
5758 isTerminalToggleShortcut,
......@@ -554,6 +555,14 @@ export default function ChatView() {
554555 return;
555556 }
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
557566 if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return;
558567 event.preventDefault();
559568 event.stopPropagation();
......@@ -570,7 +579,9 @@ export default function ChatView() {
570579 return () => window.removeEventListener("keydown", handler);
571580 }, [
572581 activeThread?.terminalOpen,
582 activeThread?.activeTerminalId,
573583 activeThreadId,
584 closeTerminal,
574585 createNewTerminal,
575586 dispatch,
576587 splitTerminal,
apps/web/src/components/Sidebar.tsx+22-7
......@@ -7,7 +7,7 @@ import { useTheme } from "../hooks/useTheme";
77import { DEFAULT_MODEL } from "../model-logic";
88import { derivePendingApprovals } from "../session-logic";
99import { useStore } from "../store";
10import { isChatNewShortcut } from "../keybindings";
10import { isChatNewSameGitStateShortcut, isChatNewShortcut } from "../keybindings";
1111import {
1212 DEFAULT_THREAD_TERMINAL_HEIGHT,
1313 DEFAULT_THREAD_TERMINAL_ID,
......@@ -140,7 +140,13 @@ export default function Sidebar() {
140140 }, [state.threads]);
141141
142142 const handleNewThread = useCallback(
143 (projectId: string) => {
143 (
144 projectId: string,
145 options?: {
146 branch?: string | null;
147 worktreePath?: string | null;
148 },
149 ) => {
144150 dispatch({
145151 type: "ADD_THREAD",
146152 thread: {
......@@ -166,8 +172,8 @@ export default function Sidebar() {
166172 events: [],
167173 error: null,
168174 createdAt: new Date().toISOString(),
169 branch: null,
170 worktreePath: null,
175 branch: options?.branch ?? null,
176 worktreePath: options?.worktreePath ?? null,
171177 },
172178 });
173179 },
......@@ -381,12 +387,21 @@ export default function Sidebar() {
381387
382388 useEffect(() => {
383389 const onWindowKeyDown = (event: KeyboardEvent) => {
384 if (!isChatNewShortcut(event, keybindings)) return;
385
386390 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;
387403 const projectId = activeThread?.projectId ?? state.projects[0]?.id;
388404 if (!projectId) return;
389
390405 event.preventDefault();
391406 handleNewThread(projectId);
392407 };
apps/web/src/keybindings.test.ts+43-2
......@@ -7,10 +7,12 @@ import {
77 type ResolvedKeybindingsConfig,
88} from "@t3tools/contracts";
99import {
10 isChatNewSameGitStateShortcut,
1011 formatShortcutLabel,
1112 isChatNewShortcut,
1213 isOpenFavoriteEditorShortcut,
1314 isTerminalClearShortcut,
15 isTerminalCloseShortcut,
1416 isTerminalNewShortcut,
1517 isTerminalSplitShortcut,
1618 isTerminalToggleShortcut,
......@@ -83,7 +85,13 @@ const DEFAULT_BINDINGS = compile([
8385 command: "terminal.new",
8486 whenAst: whenIdentifier("terminalFocus"),
8587 },
88 {
89 shortcut: modShortcut("w"),
90 command: "terminal.close",
91 whenAst: whenIdentifier("terminalFocus"),
92 },
8693 { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" },
94 { shortcut: modShortcut("n", { shiftKey: true }), command: "chat.newSameGitState" },
8795 { shortcut: modShortcut("o"), command: "editor.openFavorite" },
8896]);
8997
......@@ -101,8 +109,8 @@ describe("isTerminalToggleShortcut", () => {
101109 });
102110});
103111
104describe("split/new terminal shortcuts", () => {
105 it("requires terminalFocus for default split/new bindings", () => {
112describe("split/new/close terminal shortcuts", () => {
113 it("requires terminalFocus for default split/new/close bindings", () => {
106114 assert.isFalse(
107115 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
108116 platform: "MacIntel",
......@@ -115,6 +123,12 @@ describe("split/new terminal shortcuts", () => {
115123 context: { terminalFocus: false },
116124 }),
117125 );
126 assert.isFalse(
127 isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, {
128 platform: "Linux",
129 context: { terminalFocus: false },
130 }),
131 );
118132 });
119133
120134 it("matches split/new when terminalFocus is true", () => {
......@@ -130,6 +144,12 @@ describe("split/new terminal shortcuts", () => {
130144 context: { terminalFocus: true },
131145 }),
132146 );
147 assert.isTrue(
148 isTerminalCloseShortcut(event({ key: "w", ctrlKey: true }), DEFAULT_BINDINGS, {
149 platform: "Linux",
150 context: { terminalFocus: true },
151 }),
152 );
133153 });
134154
135155 it("supports when expressions", () => {
......@@ -225,6 +245,27 @@ describe("chat/editor shortcuts", () => {
225245 );
226246 });
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
228269 it("matches editor.openFavorite shortcut", () => {
229270 assert.isTrue(
230271 isOpenFavoriteEditorShortcut(event({ key: "o", metaKey: true }), DEFAULT_BINDINGS, {
apps/web/src/keybindings.ts+16
......@@ -190,6 +190,14 @@ export function isTerminalNewShortcut(
190190 return matchesCommandShortcut(event, keybindings, "terminal.new", options);
191191}
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
193201export function isChatNewShortcut(
194202 event: ShortcutEventLike,
195203 keybindings: ResolvedKeybindingsConfig,
......@@ -198,6 +206,14 @@ export function isChatNewShortcut(
198206 return matchesCommandShortcut(event, keybindings, "chat.new", options);
199207}
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
201217export function isOpenFavoriteEditorShortcut(
202218 event: ShortcutEventLike,
203219 keybindings: ResolvedKeybindingsConfig,
packages/contracts/src/keybindings.test.ts+6
......@@ -14,6 +14,12 @@ describe("keybindings contracts", () => {
1414 command: "terminal.toggle",
1515 });
1616 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");
1723 });
1824
1925 it("rejects invalid command values", () => {
packages/contracts/src/keybindings.ts+2
......@@ -4,7 +4,9 @@ export const keybindingCommandSchema = z.enum([
44 "terminal.toggle",
55 "terminal.split",
66 "terminal.new",
7 "terminal.close",
78 "chat.new",
9 "chat.newSameGitState",
810 "editor.openFavorite",
911]);
1012