authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 11:11:26-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 15:51:05-08:00
log645b037c9e994ca408bec4ab658db2966eac7a25
tree864fdb76b35b891748fa756b9518c6c41492c6c8
parent7fc53dadc7d66483cdeac70f233059490785c9e2

Add configurable terminal keybindings loaded from ~/.t3

- extend `server.getConfig` to return sanitized keybinding rules from `~/.t3/keybindings.json` - apply resolved bindings in the web app for toggle/split/new terminal shortcuts and labels - add contracts/docs and tests for config parsing, validation, and shortcut behavior

11 files changed, 991 insertions(+), 43 deletions(-)

KEYBINDINGS.md created+85
......@@ -0,0 +1,85 @@
1# Keybindings
2
3T3 Code reads keybindings from:
4
5- `~/.t3/keybindings.json`
6
7The file must be a JSON array of rules:
8
9```json
10[
11 { "key": "mod+j", "command": "terminal.toggle" },
12 { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" },
13 { "key": "mod+shift+d", "command": "terminal.new", "when": "terminalFocus" }
14]
15```
16
17## Rule Shape
18
19Each entry supports:
20
21- `key` (required): shortcut string, like `mod+j`, `ctrl+k`, `cmd+shift+d`
22- `command` (required): action ID
23- `when` (optional): boolean expression controlling when the shortcut is active
24
25Invalid rules are ignored. Invalid config files are ignored. Warnings are logged by the server.
26
27## Available Commands
28
29- `terminal.toggle`: open/close terminal drawer
30- `terminal.split`: split terminal (in focused terminal context by default)
31- `terminal.new`: create new terminal (in focused terminal context by default)
32
33## Key Syntax
34
35Supported modifiers:
36
37- `mod` (`cmd` on macOS, `ctrl` on non-macOS)
38- `cmd` / `meta`
39- `ctrl` / `control`
40- `shift`
41- `alt` / `option`
42
43Examples:
44
45- `mod+j`
46- `mod+shift+d`
47- `ctrl+l`
48- `cmd+k`
49
50## `when` Conditions
51
52Currently available context keys:
53
54- `terminalFocus`
55- `terminalOpen`
56
57Supported operators:
58
59- `!` (not)
60- `&&` (and)
61- `||` (or)
62- parentheses: `(` `)`
63
64Examples:
65
66- `"when": "terminalFocus"`
67- `"when": "terminalOpen && !terminalFocus"`
68- `"when": "terminalFocus || terminalOpen"`
69
70Unknown condition keys evaluate to `false`.
71
72## Defaults
73
74Built-in defaults:
75
76- `terminal.toggle`: `mod+j`
77- `terminal.split`: `mod+d` when `terminalFocus`
78- `terminal.new`: `mod+shift+d` when `terminalFocus`
79
80If you define any rules for a command, the default rule for that command is removed.
81
82## Precedence
83
84- Rules are evaluated in array order.
85- Later matching rules win over earlier matching rules for the same command.
apps/server/src/wsServer.test.ts+145-1
......@@ -307,7 +307,151 @@ describe("WebSocket Server", () => {
307307
308308 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
309309 expect(response.error).toBeUndefined();
310 expect(response.result).toEqual({ cwd: "/my/workspace" });
310 expect(response.result).toEqual({
311 cwd: "/my/workspace",
312 keybindings: [],
313 });
314 });
315
316 it("reads keybindings from ~/.t3/keybindings.json", async () => {
317 const fakeHome = makeTempDir("t3code-home-");
318 const configDir = path.join(fakeHome, ".t3");
319 fs.mkdirSync(configDir, { recursive: true });
320 fs.writeFileSync(
321 path.join(configDir, "keybindings.json"),
322 JSON.stringify([
323 { key: "cmd+j", command: "terminal.toggle" },
324 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
325 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
326 ]),
327 "utf8",
328 );
329 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
330
331 server = createTestServer({ cwd: "/my/workspace" });
332 await server.start();
333 const addr = server.httpServer.address();
334 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
335
336 const ws = await connectWs(port);
337 connections.push(ws);
338
339 await waitForMessage(ws);
340
341 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
342 expect(response.error).toBeUndefined();
343 expect(response.result).toEqual({
344 cwd: "/my/workspace",
345 keybindings: [
346 { key: "cmd+j", command: "terminal.toggle" },
347 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
348 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
349 ],
350 });
351 });
352
353 it("warns and ignores invalid keybinding entries", async () => {
354 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
355 const fakeHome = makeTempDir("t3code-home-invalid-entry-");
356 const configDir = path.join(fakeHome, ".t3");
357 fs.mkdirSync(configDir, { recursive: true });
358 fs.writeFileSync(
359 path.join(configDir, "keybindings.json"),
360 JSON.stringify([
361 { key: "mod+j", command: "terminal.toggle" },
362 { key: "mod+z", command: "invalid.command", when: "terminalFocus" },
363 ]),
364 "utf8",
365 );
366 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
367
368 server = createTestServer({ cwd: "/my/workspace" });
369 await server.start();
370 const addr = server.httpServer.address();
371 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
372
373 const ws = await connectWs(port);
374 connections.push(ws);
375
376 await waitForMessage(ws);
377
378 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
379 expect(response.error).toBeUndefined();
380 expect(response.result).toEqual({
381 cwd: "/my/workspace",
382 keybindings: [{ key: "mod+j", command: "terminal.toggle" }],
383 });
384 expect(
385 warnSpy.mock.calls.some(([message]) =>
386 String(message).includes("ignoring invalid keybinding entries"),
387 ),
388 ).toBe(true);
389 });
390
391 it("warns and ignores unsupported keybindings config format", async () => {
392 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
393 const fakeHome = makeTempDir("t3code-home-unsupported-format-");
394 const configDir = path.join(fakeHome, ".t3");
395 fs.mkdirSync(configDir, { recursive: true });
396 fs.writeFileSync(
397 path.join(configDir, "keybindings.json"),
398 JSON.stringify({ "terminal.toggle": "mod+j" }),
399 "utf8",
400 );
401 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
402
403 server = createTestServer({ cwd: "/my/workspace" });
404 await server.start();
405 const addr = server.httpServer.address();
406 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
407
408 const ws = await connectWs(port);
409 connections.push(ws);
410
411 await waitForMessage(ws);
412
413 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
414 expect(response.error).toBeUndefined();
415 expect(response.result).toEqual({
416 cwd: "/my/workspace",
417 keybindings: [],
418 });
419 expect(
420 warnSpy.mock.calls.some(([message]) =>
421 String(message).includes("unsupported format; expected array"),
422 ),
423 ).toBe(true);
424 });
425
426 it("warns and ignores malformed keybindings config files", async () => {
427 const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
428 const fakeHome = makeTempDir("t3code-home-invalid-");
429 const configDir = path.join(fakeHome, ".t3");
430 fs.mkdirSync(configDir, { recursive: true });
431 fs.writeFileSync(path.join(configDir, "keybindings.json"), "{not-json", "utf8");
432 vi.spyOn(os, "homedir").mockReturnValue(fakeHome);
433
434 server = createTestServer({ cwd: "/my/workspace" });
435 await server.start();
436 const addr = server.httpServer.address();
437 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
438
439 const ws = await connectWs(port);
440 connections.push(ws);
441
442 await waitForMessage(ws);
443
444 const response = await sendRequest(ws, WS_METHODS.serverGetConfig);
445 expect(response.error).toBeUndefined();
446 expect(response.result).toEqual({
447 cwd: "/my/workspace",
448 keybindings: [],
449 });
450 expect(
451 warnSpy.mock.calls.some(([message]) =>
452 String(message).includes("ignoring malformed keybindings config"),
453 ),
454 ).toBe(true);
311455 });
312456
313457 it("returns error for unknown methods", async () => {
apps/server/src/wsServer.ts+55-1
......@@ -9,6 +9,8 @@ import {
99 EDITORS,
1010 WS_CHANNELS,
1111 WS_METHODS,
12 keybindingRuleSchema,
13 type KeybindingsConfig,
1214 type TerminalEvent,
1315 type WsPush,
1416 type WsRequest,
......@@ -68,6 +70,55 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined {
6870 return undefined;
6971}
7072
73function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): KeybindingsConfig {
74 const configPath = path.join(os.homedir(), ".t3", "keybindings.json");
75 try {
76 const raw = fs.readFileSync(configPath, "utf8");
77 const parsed = JSON.parse(raw) as unknown;
78 if (!Array.isArray(parsed)) {
79 logger.warn("ignoring keybindings config with unsupported format; expected array", {
80 path: configPath,
81 });
82 return [];
83 }
84
85 const sanitized: KeybindingsConfig = [];
86 let invalidEntries = 0;
87 for (const entry of parsed) {
88 const result = keybindingRuleSchema.safeParse(entry);
89 if (result.success) {
90 sanitized.push(result.data);
91 if (sanitized.length >= 256) {
92 logger.warn("truncating keybindings config to max entries", {
93 path: configPath,
94 maxEntries: 256,
95 });
96 break;
97 }
98 continue;
99 }
100 invalidEntries += 1;
101 }
102 if (invalidEntries > 0) {
103 logger.warn("ignoring invalid keybinding entries", {
104 path: configPath,
105 invalidEntries,
106 totalEntries: parsed.length,
107 });
108 }
109 return sanitized;
110 } catch (error) {
111 if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
112 return [];
113 }
114 logger.warn("ignoring malformed keybindings config", {
115 path: configPath,
116 error: error instanceof Error ? error.message : String(error),
117 });
118 }
119 return [];
120}
121
71122export function createServer(options: ServerOptions) {
72123 const {
73124 port,
......@@ -410,7 +461,10 @@ export function createServer(options: ServerOptions) {
410461 return undefined;
411462
412463 case WS_METHODS.serverGetConfig:
413 return { cwd };
464 return {
465 cwd,
466 keybindings: readKeybindingsConfig(logger),
467 };
414468
415469 default:
416470 throw new Error(`Unknown method: ${request.method}`);
apps/web/src/components/ChatView.tsx+101-8
......@@ -1,10 +1,12 @@
11import {
2 EDITORS,
3 type EditorId,
4 ModelSlug,
25 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
36 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
47 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
5 type ProviderSendTurnAttachmentInput,
68 type ProviderApprovalDecision,
7 ModelSlug,
9 type ProviderSendTurnAttachmentInput,
810} from "@t3tools/contracts";
911import {
1012 type ClipboardEvent,
......@@ -25,7 +27,6 @@ import {
2527 gitCreateWorktreeMutationOptions,
2628} from "~/lib/gitReactQuery";
2729
28import { EDITORS, type EditorId } from "@t3tools/contracts";
2930import { isElectron } from "../env";
3031import { buildBootstrapInput } from "../historyBootstrap";
3132import {
......@@ -50,7 +51,14 @@ import { useStore } from "../store";
5051import type { ChatImageAttachment } from "../types";
5152import BranchToolbar from "./BranchToolbar";
5253import GitActionsControl from "./GitActionsControl";
53import { isTerminalToggleShortcut } from "../terminal-shortcuts";
54import {
55 DEFAULT_TERMINAL_KEYBINDINGS,
56 isTerminalNewShortcut,
57 isTerminalSplitShortcut,
58 isTerminalToggleShortcut,
59 resolveTerminalKeybindings,
60 shortcutLabelForCommand,
61} from "../terminal-shortcuts";
5462import ChatMarkdown from "./ChatMarkdown";
5563import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
5664import { useNativeApi } from "../hooks/useNativeApi";
......@@ -144,6 +152,7 @@ export default function ChatView() {
144152 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
145153 const [nowTick, setNowTick] = useState(() => Date.now());
146154 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
155 const [terminalKeybindings, setTerminalKeybindings] = useState(DEFAULT_TERMINAL_KEYBINDINGS);
147156 const messagesScrollRef = useRef<HTMLDivElement>(null);
148157 const messagesEndRef = useRef<HTMLDivElement>(null);
149158 const shouldAutoScrollRef = useRef(true);
......@@ -256,12 +265,45 @@ export default function ChatView() {
256265 const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd));
257266 // Default true while loading to avoid toolbar flicker.
258267 const isGitRepo = branchesQuery.data?.isRepo ?? true;
268 const splitTerminalShortcutLabel = useMemo(
269 () => shortcutLabelForCommand(terminalKeybindings, "terminal.split"),
270 [terminalKeybindings],
271 );
272 const newTerminalShortcutLabel = useMemo(
273 () => shortcutLabelForCommand(terminalKeybindings, "terminal.new"),
274 [terminalKeybindings],
275 );
259276
260277 const envLocked = Boolean(
261278 activeThread &&
262279 (activeThread.messages.length > 0 ||
263280 (activeThread.session !== null && activeThread.session.status !== "closed")),
264281 );
282
283 useEffect(() => {
284 if (!api || !api.server || typeof api.server.getConfig !== "function") {
285 setTerminalKeybindings(DEFAULT_TERMINAL_KEYBINDINGS);
286 return;
287 }
288
289 let disposed = false;
290
291 void api.server
292 .getConfig()
293 .then((config) => {
294 if (disposed) return;
295 setTerminalKeybindings(resolveTerminalKeybindings(config.keybindings));
296 })
297 .catch(() => {
298 if (disposed) return;
299 setTerminalKeybindings(DEFAULT_TERMINAL_KEYBINDINGS);
300 });
301
302 return () => {
303 disposed = true;
304 };
305 }, [api]);
306
265307 const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => {
266308 for (const image of images) {
267309 if (!image.previewUrl) continue;
......@@ -490,15 +532,64 @@ export default function ChatView() {
490532 }, [activeThread?.terminalOpen, activeThreadId, focusComposer]);
491533
492534 useEffect(() => {
535 const isTerminalFocused = (): boolean => {
536 const activeElement = document.activeElement;
537 if (!(activeElement instanceof HTMLElement)) return false;
538 if (activeElement.classList.contains("xterm-helper-textarea")) return true;
539 return activeElement.closest(".thread-terminal-drawer .xterm") !== null;
540 };
541
493542 const handler = (event: globalThis.KeyboardEvent) => {
494 if (!activeThreadId) return;
495 if (!isTerminalToggleShortcut(event)) return;
543 if (!activeThreadId || event.defaultPrevented) return;
544 const shortcutContext = {
545 terminalFocus: isTerminalFocused(),
546 terminalOpen: Boolean(activeThread?.terminalOpen),
547 };
548
549 if (isTerminalToggleShortcut(event, terminalKeybindings, { context: shortcutContext })) {
550 event.preventDefault();
551 event.stopPropagation();
552 toggleTerminalVisibility();
553 return;
554 }
555
556 if (isTerminalSplitShortcut(event, terminalKeybindings, { context: shortcutContext })) {
557 event.preventDefault();
558 event.stopPropagation();
559 if (!activeThread?.terminalOpen) {
560 dispatch({
561 type: "SET_THREAD_TERMINAL_OPEN",
562 threadId: activeThreadId,
563 open: true,
564 });
565 }
566 splitTerminal();
567 return;
568 }
569
570 if (!isTerminalNewShortcut(event, terminalKeybindings, { context: shortcutContext })) return;
496571 event.preventDefault();
497 toggleTerminalVisibility();
572 event.stopPropagation();
573 if (!activeThread?.terminalOpen) {
574 dispatch({
575 type: "SET_THREAD_TERMINAL_OPEN",
576 threadId: activeThreadId,
577 open: true,
578 });
579 }
580 createNewTerminal();
498581 };
499582 window.addEventListener("keydown", handler);
500583 return () => window.removeEventListener("keydown", handler);
501 }, [activeThreadId, toggleTerminalVisibility]);
584 }, [
585 activeThread?.terminalOpen,
586 activeThreadId,
587 createNewTerminal,
588 dispatch,
589 splitTerminal,
590 terminalKeybindings,
591 toggleTerminalVisibility,
592 ]);
502593
503594 const setThreadError = (threadId: string | null, error: string | null) => {
504595 if (!threadId) return;
......@@ -1350,6 +1441,8 @@ export default function ChatView() {
13501441 focusRequestId={terminalFocusRequestId}
13511442 onSplitTerminal={splitTerminal}
13521443 onNewTerminal={createNewTerminal}
1444 splitShortcutLabel={splitTerminalShortcutLabel ?? undefined}
1445 newShortcutLabel={newTerminalShortcutLabel ?? undefined}
13531446 onActiveTerminalChange={activateTerminal}
13541447 onCloseTerminal={closeTerminal}
13551448 onHeightChange={(height) =>
apps/web/src/components/ThreadTerminalDrawer.tsx+14-4
......@@ -416,6 +416,8 @@ interface ThreadTerminalDrawerProps {
416416 focusRequestId: number;
417417 onSplitTerminal: () => void;
418418 onNewTerminal: () => void;
419 splitShortcutLabel?: string | undefined;
420 newShortcutLabel?: string | undefined;
419421 onActiveTerminalChange: (terminalId: string) => void;
420422 onCloseTerminal: (terminalId: string) => void;
421423 onHeightChange: (height: number) => void;
......@@ -468,6 +470,8 @@ export default function ThreadTerminalDrawer({
468470 focusRequestId,
469471 onSplitTerminal,
470472 onNewTerminal,
473 splitShortcutLabel,
474 newShortcutLabel,
471475 onActiveTerminalChange,
472476 onCloseTerminal,
473477 onHeightChange,
......@@ -694,7 +698,7 @@ export default function ThreadTerminalDrawer({
694698 <TerminalActionButton
695699 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
696700 onClick={onSplitTerminal}
697 label="Split Terminal"
701 label={splitShortcutLabel ? `Split Terminal (${splitShortcutLabel})` : "Split Terminal"}
698702 >
699703 <SquareSplitHorizontal className="size-3.25" />
700704 </TerminalActionButton>
......@@ -702,7 +706,7 @@ export default function ThreadTerminalDrawer({
702706 <TerminalActionButton
703707 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
704708 onClick={onNewTerminal}
705 label="New Terminal"
709 label={newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"}
706710 >
707711 <Plus className="size-3.25" />
708712 </TerminalActionButton>
......@@ -779,14 +783,20 @@ export default function ThreadTerminalDrawer({
779783 <TerminalActionButton
780784 className="inline-flex h-full items-center px-1 text-foreground/90 transition-colors hover:bg-accent/70"
781785 onClick={onSplitTerminal}
782 label="Split Terminal"
786 label={
787 splitShortcutLabel
788 ? `Split Terminal (${splitShortcutLabel})`
789 : "Split Terminal"
790 }
783791 >
784792 <SquareSplitHorizontal className="size-3.25" />
785793 </TerminalActionButton>
786794 <TerminalActionButton
787795 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
788796 onClick={onNewTerminal}
789 label="New Terminal"
797 label={
798 newShortcutLabel ? `New Terminal (${newShortcutLabel})` : "New Terminal"
799 }
790800 >
791801 <Plus className="size-3.25" />
792802 </TerminalActionButton>
apps/web/src/terminal-shortcuts.test.ts+133-22
......@@ -1,8 +1,14 @@
11import { describe, expect, it } from "vitest";
22
33import {
4 DEFAULT_TERMINAL_KEYBINDINGS,
5 formatShortcutLabel,
46 isTerminalClearShortcut,
7 isTerminalNewShortcut,
8 isTerminalSplitShortcut,
59 isTerminalToggleShortcut,
10 resolveTerminalKeybindings,
11 shortcutLabelForCommand,
612 type ShortcutEventLike,
713} from "./terminal-shortcuts";
814
......@@ -19,22 +25,140 @@ function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {
1925
2026describe("isTerminalToggleShortcut", () => {
2127 it("matches Cmd+J on macOS", () => {
22 expect(isTerminalToggleShortcut(event({ metaKey: true }), "MacIntel")).toBe(true);
28 expect(
29 isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
30 platform: "MacIntel",
31 }),
32 ).toBe(true);
2333 });
2434
2535 it("matches Ctrl+J on non-macOS", () => {
26 expect(isTerminalToggleShortcut(event({ ctrlKey: true }), "Win32")).toBe(true);
36 expect(
37 isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
38 platform: "Win32",
39 }),
40 ).toBe(true);
2741 });
42});
2843
29 it("rejects wrong modifiers", () => {
30 expect(isTerminalToggleShortcut(event({ ctrlKey: true, shiftKey: true }), "Win32")).toBe(false);
31 expect(isTerminalToggleShortcut(event({ metaKey: true, altKey: true }), "MacIntel")).toBe(
32 false,
33 );
44describe("split/new terminal shortcuts", () => {
45 it("requires terminalFocus for default split/new bindings", () => {
46 expect(
47 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
48 platform: "MacIntel",
49 context: { terminalFocus: false },
50 }),
51 ).toBe(false);
52 expect(
53 isTerminalNewShortcut(
54 event({ key: "d", ctrlKey: true, shiftKey: true }),
55 DEFAULT_TERMINAL_KEYBINDINGS,
56 {
57 platform: "Linux",
58 context: { terminalFocus: false },
59 },
60 ),
61 ).toBe(false);
3462 });
3563
36 it("rejects non-j keys", () => {
37 expect(isTerminalToggleShortcut(event({ key: "k", ctrlKey: true }), "Linux")).toBe(false);
64 it("matches split/new when terminalFocus is true", () => {
65 expect(
66 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
67 platform: "MacIntel",
68 context: { terminalFocus: true },
69 }),
70 ).toBe(true);
71 expect(
72 isTerminalNewShortcut(
73 event({ key: "d", ctrlKey: true, shiftKey: true }),
74 DEFAULT_TERMINAL_KEYBINDINGS,
75 {
76 platform: "Linux",
77 context: { terminalFocus: true },
78 },
79 ),
80 ).toBe(true);
81 });
82
83 it("supports configured overrides with when expressions", () => {
84 const keybindings = resolveTerminalKeybindings([
85 {
86 key: "mod+\\",
87 command: "terminal.split",
88 when: "terminalOpen && !terminalFocus",
89 },
90 {
91 key: "mod+shift+n",
92 command: "terminal.new",
93 when: "terminalOpen && !terminalFocus",
94 },
95 ]);
96 expect(
97 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {
98 platform: "Win32",
99 context: { terminalOpen: true, terminalFocus: false },
100 }),
101 ).toBe(true);
102 expect(
103 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {
104 platform: "Win32",
105 context: { terminalOpen: false, terminalFocus: false },
106 }),
107 ).toBe(false);
108 expect(
109 isTerminalNewShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), keybindings, {
110 platform: "Win32",
111 context: { terminalOpen: true, terminalFocus: false },
112 }),
113 ).toBe(true);
114 });
115});
116
117describe("resolveTerminalKeybindings", () => {
118 it("uses defaults when config is empty", () => {
119 expect(resolveTerminalKeybindings(undefined)).toEqual(DEFAULT_TERMINAL_KEYBINDINGS);
120 });
121
122 it("replaces defaults for commands configured by user", () => {
123 const resolved = resolveTerminalKeybindings([
124 { key: "mod+\\", command: "terminal.split", when: "terminalFocus" },
125 ]);
126
127 expect(resolved).toEqual([
128 { key: "mod+j", command: "terminal.toggle" },
129 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
130 { key: "mod+\\", command: "terminal.split", when: "terminalFocus" },
131 ]);
132 });
133
134 it("ignores invalid configured values", () => {
135 expect(
136 resolveTerminalKeybindings([
137 { key: " ", command: "terminal.toggle" },
138 { key: "mod+d+f", command: "terminal.split" },
139 ]),
140 ).toEqual(DEFAULT_TERMINAL_KEYBINDINGS);
141 });
142});
143
144describe("shortcutLabelForCommand", () => {
145 it("returns the most recent binding label", () => {
146 const resolved = resolveTerminalKeybindings([
147 { key: "mod+\\", command: "terminal.split", when: "terminalFocus" },
148 { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" },
149 ]);
150
151 expect(shortcutLabelForCommand(resolved, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\");
152 });
153});
154
155describe("formatShortcutLabel", () => {
156 it("formats labels for macOS", () => {
157 expect(formatShortcutLabel("mod+shift+d", "MacIntel")).toBe("⇧⌘D");
158 });
159
160 it("formats labels for non-macOS", () => {
161 expect(formatShortcutLabel("mod+shift+d", "Linux")).toBe("Ctrl+Shift+D");
38162 });
39163});
40164
......@@ -47,17 +171,4 @@ describe("isTerminalClearShortcut", () => {
47171 it("matches Cmd+K on macOS", () => {
48172 expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "MacIntel")).toBe(true);
49173 });
50
51 it("rejects Cmd+K on non-macOS", () => {
52 expect(isTerminalClearShortcut(event({ key: "k", metaKey: true }), "Win32")).toBe(false);
53 });
54
55 it("rejects wrong modifiers", () => {
56 expect(
57 isTerminalClearShortcut(event({ key: "l", ctrlKey: true, shiftKey: true }), "Linux"),
58 ).toBe(false);
59 expect(
60 isTerminalClearShortcut(event({ key: "k", metaKey: true, altKey: true }), "MacIntel"),
61 ).toBe(false);
62 });
63174});
apps/web/src/terminal-shortcuts.ts+422-7
......@@ -1,3 +1,5 @@
1import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts";
2
13export interface ShortcutEventLike {
24 key: string;
35 metaKey: boolean;
......@@ -6,23 +8,436 @@ export interface ShortcutEventLike {
68 altKey: boolean;
79}
810
11export interface ShortcutMatchContext {
12 terminalFocus: boolean;
13 terminalOpen: boolean;
14 [key: string]: boolean;
15}
16
17export interface ResolvedTerminalKeybinding {
18 key: string;
19 command: KeybindingCommand;
20 when?: string | undefined;
21}
22
23export type ResolvedTerminalKeybindings = ResolvedTerminalKeybinding[];
24
25export const DEFAULT_TERMINAL_KEYBINDINGS: ResolvedTerminalKeybindings = [
26 { key: "mod+j", command: "terminal.toggle" },
27 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
28 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
29];
30
31interface ParsedShortcut {
32 key: string;
33 metaKey: boolean;
34 ctrlKey: boolean;
35 shiftKey: boolean;
36 altKey: boolean;
37 modKey: boolean;
38}
39
40interface ShortcutMatchOptions {
41 platform?: string;
42 context?: Partial<ShortcutMatchContext>;
43}
44
45type WhenNode =
46 | { type: "identifier"; name: string }
47 | { type: "not"; node: WhenNode }
48 | { type: "and"; left: WhenNode; right: WhenNode }
49 | { type: "or"; left: WhenNode; right: WhenNode };
50
51type WhenToken =
52 | { type: "identifier"; value: string }
53 | { type: "not" }
54 | { type: "and" }
55 | { type: "or" }
56 | { type: "lparen" }
57 | { type: "rparen" };
58
59const WHEN_AST_CACHE = new Map<string, WhenNode | null>();
60
961function isMacPlatform(platform: string): boolean {
1062 return /mac|iphone|ipad|ipod/i.test(platform);
1163}
1264
13export function isTerminalToggleShortcut(
65function normalizeKeyToken(token: string): string {
66 if (token === "space") return " ";
67 if (token === "esc") return "escape";
68 return token;
69}
70
71function parseShortcutValue(value: string): ParsedShortcut | null {
72 const tokens = value
73 .toLowerCase()
74 .split("+")
75 .map((token) => token.trim())
76 .filter((token) => token.length > 0);
77 if (tokens.length === 0) return null;
78
79 let key: string | null = null;
80 let metaKey = false;
81 let ctrlKey = false;
82 let shiftKey = false;
83 let altKey = false;
84 let modKey = false;
85
86 for (const token of tokens) {
87 switch (token) {
88 case "cmd":
89 case "meta":
90 metaKey = true;
91 break;
92 case "ctrl":
93 case "control":
94 ctrlKey = true;
95 break;
96 case "shift":
97 shiftKey = true;
98 break;
99 case "alt":
100 case "option":
101 altKey = true;
102 break;
103 case "mod":
104 modKey = true;
105 break;
106 default: {
107 if (key !== null) {
108 return null;
109 }
110 key = normalizeKeyToken(token);
111 }
112 }
113 }
114
115 if (key === null) return null;
116
117 return {
118 key,
119 metaKey,
120 ctrlKey,
121 shiftKey,
122 altKey,
123 modKey,
124 };
125}
126
127function normalizeEventKey(key: string): string {
128 const normalized = key.toLowerCase();
129 if (normalized === "esc") return "escape";
130 return normalized;
131}
132
133function matchesShortcutValue(
14134 event: ShortcutEventLike,
135 shortcutValue: string,
15136 platform = navigator.platform,
16137): boolean {
17 const key = event.key.toLowerCase();
18 if (key !== "j") return false;
19 if (event.shiftKey || event.altKey) return false;
138 const parsed = parseShortcutValue(shortcutValue);
139 if (!parsed) return false;
140
141 const key = normalizeEventKey(event.key);
142 if (key !== parsed.key) return false;
143
144 const useMetaForMod = isMacPlatform(platform);
145 const expectedMeta = parsed.metaKey || (parsed.modKey && useMetaForMod);
146 const expectedCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod);
147 return (
148 event.metaKey === expectedMeta &&
149 event.ctrlKey === expectedCtrl &&
150 event.shiftKey === parsed.shiftKey &&
151 event.altKey === parsed.altKey
152 );
153}
154
155function tokenizeWhenExpression(expression: string): WhenToken[] | null {
156 const tokens: WhenToken[] = [];
157 let index = 0;
20158
21 if (isMacPlatform(platform)) {
22 return event.metaKey && !event.ctrlKey;
159 while (index < expression.length) {
160 const current = expression[index];
161 if (!current) break;
162
163 if (/\s/.test(current)) {
164 index += 1;
165 continue;
166 }
167 if (expression.startsWith("&&", index)) {
168 tokens.push({ type: "and" });
169 index += 2;
170 continue;
171 }
172 if (expression.startsWith("||", index)) {
173 tokens.push({ type: "or" });
174 index += 2;
175 continue;
176 }
177 if (current === "!") {
178 tokens.push({ type: "not" });
179 index += 1;
180 continue;
181 }
182 if (current === "(") {
183 tokens.push({ type: "lparen" });
184 index += 1;
185 continue;
186 }
187 if (current === ")") {
188 tokens.push({ type: "rparen" });
189 index += 1;
190 continue;
191 }
192
193 const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index));
194 if (!identifier) {
195 return null;
196 }
197 tokens.push({ type: "identifier", value: identifier[0] });
198 index += identifier[0].length;
23199 }
24200
25 return event.ctrlKey && !event.metaKey;
201 return tokens;
202}
203
204function parseWhenExpression(expression: string): WhenNode | null {
205 const tokens = tokenizeWhenExpression(expression);
206 if (!tokens || tokens.length === 0) return null;
207 let index = 0;
208
209 const parsePrimary = (): WhenNode | null => {
210 const token = tokens[index];
211 if (!token) return null;
212
213 if (token.type === "identifier") {
214 index += 1;
215 return { type: "identifier", name: token.value };
216 }
217
218 if (token.type === "lparen") {
219 index += 1;
220 const expressionNode = parseOr();
221 const closeToken = tokens[index];
222 if (!expressionNode || !closeToken || closeToken.type !== "rparen") {
223 return null;
224 }
225 index += 1;
226 return expressionNode;
227 }
228
229 return null;
230 };
231
232 const parseUnary = (): WhenNode | null => {
233 const token = tokens[index];
234 if (token?.type === "not") {
235 index += 1;
236 const node = parseUnary();
237 if (!node) return null;
238 return { type: "not", node };
239 }
240 return parsePrimary();
241 };
242
243 const parseAnd = (): WhenNode | null => {
244 let left = parseUnary();
245 if (!left) return null;
246
247 while (tokens[index]?.type === "and") {
248 index += 1;
249 const right = parseUnary();
250 if (!right) return null;
251 left = { type: "and", left, right };
252 }
253
254 return left;
255 };
256
257 const parseOr = (): WhenNode | null => {
258 let left = parseAnd();
259 if (!left) return null;
260
261 while (tokens[index]?.type === "or") {
262 index += 1;
263 const right = parseAnd();
264 if (!right) return null;
265 left = { type: "or", left, right };
266 }
267
268 return left;
269 };
270
271 const ast = parseOr();
272 if (!ast || index !== tokens.length) return null;
273 return ast;
274}
275
276function evaluateWhenNode(node: WhenNode, context: ShortcutMatchContext): boolean {
277 switch (node.type) {
278 case "identifier":
279 return Boolean(context[node.name]);
280 case "not":
281 return !evaluateWhenNode(node.node, context);
282 case "and":
283 return evaluateWhenNode(node.left, context) && evaluateWhenNode(node.right, context);
284 case "or":
285 return evaluateWhenNode(node.left, context) || evaluateWhenNode(node.right, context);
286 }
287}
288
289function matchesWhenExpression(when: string | undefined, context: ShortcutMatchContext): boolean {
290 if (!when) return true;
291 const normalized = when.trim();
292 if (normalized.length === 0) return true;
293
294 if (!WHEN_AST_CACHE.has(normalized)) {
295 WHEN_AST_CACHE.set(normalized, parseWhenExpression(normalized));
296 }
297 const ast = WHEN_AST_CACHE.get(normalized);
298 if (!ast) return false;
299 return evaluateWhenNode(ast, context);
300}
301
302function resolvePlatform(options: ShortcutMatchOptions | undefined): string {
303 return options?.platform ?? navigator.platform;
304}
305
306function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatchContext {
307 return {
308 terminalFocus: false,
309 terminalOpen: false,
310 ...options?.context,
311 };
312}
313
314function normalizeConfiguredKeybinding(
315 keybinding: KeybindingsConfig[number],
316): ResolvedTerminalKeybinding | null {
317 const key = keybinding.key.trim();
318 if (key.length === 0) return null;
319 if (!parseShortcutValue(key)) return null;
320
321 const when = keybinding.when?.trim();
322 return {
323 key,
324 command: keybinding.command,
325 ...(when ? { when } : {}),
326 };
327}
328
329function matchesCommandShortcut(
330 event: ShortcutEventLike,
331 keybindings: ResolvedTerminalKeybindings,
332 command: KeybindingCommand,
333 options?: ShortcutMatchOptions,
334): boolean {
335 const platform = resolvePlatform(options);
336 const context = resolveContext(options);
337
338 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
339 const binding = keybindings[index];
340 if (!binding || binding.command !== command) continue;
341 if (!matchesWhenExpression(binding.when, context)) continue;
342 if (!matchesShortcutValue(event, binding.key, platform)) continue;
343 return true;
344 }
345 return false;
346}
347
348export function resolveTerminalKeybindings(
349 input: KeybindingsConfig | null | undefined,
350): ResolvedTerminalKeybindings {
351 if (!input || input.length === 0) {
352 return [...DEFAULT_TERMINAL_KEYBINDINGS];
353 }
354
355 const normalizedInput = input
356 .map((binding) => normalizeConfiguredKeybinding(binding))
357 .filter((binding): binding is ResolvedTerminalKeybinding => binding !== null);
358 if (normalizedInput.length === 0) {
359 return [...DEFAULT_TERMINAL_KEYBINDINGS];
360 }
361
362 const overriddenCommands = new Set(normalizedInput.map((binding) => binding.command));
363 const retainedDefaults = DEFAULT_TERMINAL_KEYBINDINGS.filter(
364 (binding) => !overriddenCommands.has(binding.command),
365 );
366 return [...retainedDefaults, ...normalizedInput];
367}
368
369function formatShortcutKeyLabel(key: string): string {
370 if (key === " ") return "Space";
371 if (key.length === 1) return key.toUpperCase();
372 if (key === "escape") return "Esc";
373 if (key === "arrowup") return "Up";
374 if (key === "arrowdown") return "Down";
375 if (key === "arrowleft") return "Left";
376 if (key === "arrowright") return "Right";
377 return key.slice(0, 1).toUpperCase() + key.slice(1);
378}
379
380export function formatShortcutLabel(shortcutValue: string, platform = navigator.platform): string {
381 const parsed = parseShortcutValue(shortcutValue);
382 if (!parsed) {
383 return shortcutValue;
384 }
385
386 const keyLabel = formatShortcutKeyLabel(parsed.key);
387 const useMetaForMod = isMacPlatform(platform);
388 const showMeta = parsed.metaKey || (parsed.modKey && useMetaForMod);
389 const showCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod);
390 const showAlt = parsed.altKey;
391 const showShift = parsed.shiftKey;
392
393 if (useMetaForMod) {
394 return `${showCtrl ? "\u2303" : ""}${showAlt ? "\u2325" : ""}${showShift ? "\u21e7" : ""}${showMeta ? "\u2318" : ""}${keyLabel}`;
395 }
396
397 const parts: string[] = [];
398 if (showCtrl) parts.push("Ctrl");
399 if (showAlt) parts.push("Alt");
400 if (showShift) parts.push("Shift");
401 if (showMeta) parts.push("Meta");
402 parts.push(keyLabel);
403 return parts.join("+");
404}
405
406export function shortcutLabelForCommand(
407 keybindings: ResolvedTerminalKeybindings,
408 command: KeybindingCommand,
409 platform = navigator.platform,
410): string | null {
411 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
412 const binding = keybindings[index];
413 if (!binding || binding.command !== command) continue;
414 return formatShortcutLabel(binding.key, platform);
415 }
416 return null;
417}
418
419export function isTerminalToggleShortcut(
420 event: ShortcutEventLike,
421 keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS,
422 options?: ShortcutMatchOptions,
423): boolean {
424 return matchesCommandShortcut(event, keybindings, "terminal.toggle", options);
425}
426
427export function isTerminalSplitShortcut(
428 event: ShortcutEventLike,
429 keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS,
430 options?: ShortcutMatchOptions,
431): boolean {
432 return matchesCommandShortcut(event, keybindings, "terminal.split", options);
433}
434
435export function isTerminalNewShortcut(
436 event: ShortcutEventLike,
437 keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS,
438 options?: ShortcutMatchOptions,
439): boolean {
440 return matchesCommandShortcut(event, keybindings, "terminal.new", options);
26441}
27442
28443export function isTerminalClearShortcut(
apps/web/src/wsNativeApi.ts+3
......@@ -139,6 +139,9 @@ export function createWsNativeApi(): NativeApi {
139139 return showContextMenuFallback(items, position);
140140 },
141141 },
142 server: {
143 getConfig: () => transport.request(WS_METHODS.serverGetConfig),
144 },
142145 };
143146
144147 instance = { api, transport };
packages/contracts/src/index.ts+1
......@@ -5,5 +5,6 @@ export * from "./terminal";
55export * from "./provider";
66export * from "./model";
77export * from "./ws";
8export * from "./server";
89export * from "./project";
910export * from "./git";
packages/contracts/src/ipc.ts+4
......@@ -31,6 +31,7 @@ import type {
3131 ProjectListResult,
3232 ProjectRemoveInput,
3333} from "./project";
34import type { ServerConfig } from "./server";
3435import type {
3536 TerminalClearInput,
3637 TerminalCloseInput,
......@@ -113,4 +114,7 @@ export interface NativeApi {
113114 position?: { x: number; y: number },
114115 ) => Promise<T | null>;
115116 };
117 server: {
118 getConfig: () => Promise<ServerConfig>;
119 };
116120}
packages/contracts/src/server.ts created+28
......@@ -0,0 +1,28 @@
1import { z } from "zod";
2
3export const keybindingCommandSchema = z.enum([
4 "terminal.toggle",
5 "terminal.split",
6 "terminal.new",
7]);
8
9const keybindingValueSchema = z.string().trim().min(1).max(64);
10const keybindingWhenSchema = z.string().trim().min(1).max(256);
11
12export const keybindingRuleSchema = z.object({
13 key: keybindingValueSchema,
14 command: keybindingCommandSchema,
15 when: keybindingWhenSchema.optional(),
16});
17
18export const keybindingsConfigSchema = z.array(keybindingRuleSchema).max(256);
19
20export const serverConfigSchema = z.object({
21 cwd: z.string().min(1),
22 keybindings: keybindingsConfigSchema.default([]),
23});
24
25export type KeybindingCommand = z.infer<typeof keybindingCommandSchema>;
26export type KeybindingRule = z.infer<typeof keybindingRuleSchema>;
27export type KeybindingsConfig = z.infer<typeof keybindingsConfigSchema>;
28export type ServerConfig = z.infer<typeof serverConfigSchema>;