authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 11:30:27-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 15:51:05-08:00
logf1cd76c17af2fa9496729ef08056ae69e1f27742
tree07667147cacd92bd25766c607d58750e3238ad96
parent31aa32b83bb234ef7f2bdad6a898d06ea1d0552e

Resolve terminal keybindings on server before sending config

- Move default keybinding resolution/override merging into `server.getConfig` - Return server-resolved keybindings to the web client instead of resolving in UI - Update docs and tests to reflect server-side defaults, overrides, and truncation behavior

6 files changed, 108 insertions(+), 134 deletions(-)

KEYBINDINGS.md+11-2
......@@ -4,6 +4,15 @@ T3 Code reads keybindings from:
44
55- `~/.t3/keybindings.json`
66
7Schema source of truth:
8
9- `packages/contracts/src/keybindings.ts`
10- `packages/contracts/src/server.ts`
11
12Server-side default resolution/merging:
13
14- `apps/server/src/wsServer.ts`
15
716The file must be a JSON array of rules:
817
918```json
......@@ -71,13 +80,13 @@ Unknown condition keys evaluate to `false`.
7180
7281## Defaults
7382
74Built-in defaults:
83Built-in defaults are resolved on the server and sent to the web client via `server.getConfig`:
7584
7685- `terminal.toggle`: `mod+j`
7786- `terminal.split`: `mod+d` when `terminalFocus`
7887- `terminal.new`: `mod+shift+d` when `terminalFocus`
7988
80If you define any rules for a command, the default rule for that command is removed.
89If you define any rules for a command, the default rule for that command is removed on the server.
8190
8291## Precedence
8392
apps/server/src/wsServer.test.ts+20-4
......@@ -309,7 +309,11 @@ describe("WebSocket Server", () => {
309309 expect(response.error).toBeUndefined();
310310 expect(response.result).toEqual({
311311 cwd: "/my/workspace",
312 keybindings: [],
312 keybindings: [
313 { key: "mod+j", command: "terminal.toggle" },
314 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
315 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
316 ],
313317 });
314318 });
315319
......@@ -379,7 +383,11 @@ describe("WebSocket Server", () => {
379383 expect(response.error).toBeUndefined();
380384 expect(response.result).toEqual({
381385 cwd: "/my/workspace",
382 keybindings: [{ key: "mod+j", command: "terminal.toggle" }],
386 keybindings: [
387 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
388 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
389 { key: "mod+j", command: "terminal.toggle" },
390 ],
383391 });
384392 expect(
385393 warnSpy.mock.calls.some(([message]) =>
......@@ -414,7 +422,11 @@ describe("WebSocket Server", () => {
414422 expect(response.error).toBeUndefined();
415423 expect(response.result).toEqual({
416424 cwd: "/my/workspace",
417 keybindings: [],
425 keybindings: [
426 { key: "mod+j", command: "terminal.toggle" },
427 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
428 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
429 ],
418430 });
419431 expect(
420432 warnSpy.mock.calls.some(([message]) =>
......@@ -445,7 +457,11 @@ describe("WebSocket Server", () => {
445457 expect(response.error).toBeUndefined();
446458 expect(response.result).toEqual({
447459 cwd: "/my/workspace",
448 keybindings: [],
460 keybindings: [
461 { key: "mod+j", command: "terminal.toggle" },
462 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
463 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
464 ],
449465 });
450466 expect(
451467 warnSpy.mock.calls.some(([message]) =>
apps/server/src/wsServer.ts+42-11
......@@ -70,6 +70,31 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined {
7070 return undefined;
7171}
7272
73const DEFAULT_KEYBINDINGS: KeybindingsConfig = [
74 { key: "mod+j", command: "terminal.toggle" },
75 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
76 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
77];
78
79function mergeWithDefaultKeybindings(custom: KeybindingsConfig): KeybindingsConfig {
80 if (custom.length === 0) {
81 return [...DEFAULT_KEYBINDINGS];
82 }
83
84 const overriddenCommands = new Set(custom.map((binding) => binding.command));
85 const retainedDefaults = DEFAULT_KEYBINDINGS.filter(
86 (binding) => !overriddenCommands.has(binding.command),
87 );
88 const merged = [...retainedDefaults, ...custom];
89
90 if (merged.length <= 256) {
91 return merged;
92 }
93
94 // Keep the latest rules when the config exceeds max size; later rules have higher precedence.
95 return merged.slice(-256);
96}
97
7398function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): KeybindingsConfig {
7499 const configPath = path.join(os.homedir(), ".t3", "keybindings.json");
75100 try {
......@@ -79,7 +104,7 @@ function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): Keybind
79104 logger.warn("ignoring keybindings config with unsupported format; expected array", {
80105 path: configPath,
81106 });
82 return [];
107 return [...DEFAULT_KEYBINDINGS];
83108 }
84109
85110 const sanitized: KeybindingsConfig = [];
......@@ -88,13 +113,6 @@ function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): Keybind
88113 const result = keybindingRuleSchema.safeParse(entry);
89114 if (result.success) {
90115 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 }
98116 continue;
99117 }
100118 invalidEntries += 1;
......@@ -106,17 +124,30 @@ function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): Keybind
106124 totalEntries: parsed.length,
107125 });
108126 }
109 return sanitized;
127 const mergedBeforeCap = [
128 ...DEFAULT_KEYBINDINGS.filter(
129 (binding) => !new Set(sanitized.map((entry) => entry.command)).has(binding.command),
130 ),
131 ...sanitized,
132 ];
133 const merged = mergeWithDefaultKeybindings(sanitized);
134 if (mergedBeforeCap.length > 256) {
135 logger.warn("truncating merged keybindings config to max entries", {
136 path: configPath,
137 maxEntries: 256,
138 });
139 }
140 return merged;
110141 } catch (error) {
111142 if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
112 return [];
143 return [...DEFAULT_KEYBINDINGS];
113144 }
114145 logger.warn("ignoring malformed keybindings config", {
115146 path: configPath,
116147 error: error instanceof Error ? error.message : String(error),
117148 });
118149 }
119 return [];
150 return [...DEFAULT_KEYBINDINGS];
120151}
121152
122153export function createServer(options: ServerOptions) {
apps/web/src/components/ChatView.tsx+5-6
......@@ -52,11 +52,10 @@ import type { ChatImageAttachment } from "../types";
5252import BranchToolbar from "./BranchToolbar";
5353import GitActionsControl from "./GitActionsControl";
5454import {
55 DEFAULT_TERMINAL_KEYBINDINGS,
5655 isTerminalNewShortcut,
5756 isTerminalSplitShortcut,
5857 isTerminalToggleShortcut,
59 resolveTerminalKeybindings,
58 type ResolvedTerminalKeybindings,
6059 shortcutLabelForCommand,
6160} from "../keybindings";
6261import ChatMarkdown from "./ChatMarkdown";
......@@ -152,7 +151,7 @@ export default function ChatView() {
152151 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>({});
153152 const [nowTick, setNowTick] = useState(() => Date.now());
154153 const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0);
155 const [terminalKeybindings, setTerminalKeybindings] = useState(DEFAULT_TERMINAL_KEYBINDINGS);
154 const [terminalKeybindings, setTerminalKeybindings] = useState<ResolvedTerminalKeybindings>([]);
156155 const messagesScrollRef = useRef<HTMLDivElement>(null);
157156 const messagesEndRef = useRef<HTMLDivElement>(null);
158157 const shouldAutoScrollRef = useRef(true);
......@@ -282,7 +281,7 @@ export default function ChatView() {
282281
283282 useEffect(() => {
284283 if (!api || !api.server || typeof api.server.getConfig !== "function") {
285 setTerminalKeybindings(DEFAULT_TERMINAL_KEYBINDINGS);
284 setTerminalKeybindings([]);
286285 return;
287286 }
288287
......@@ -292,11 +291,11 @@ export default function ChatView() {
292291 .getConfig()
293292 .then((config) => {
294293 if (disposed) return;
295 setTerminalKeybindings(resolveTerminalKeybindings(config.keybindings));
294 setTerminalKeybindings(config.keybindings);
296295 })
297296 .catch(() => {
298297 if (disposed) return;
299 setTerminalKeybindings(DEFAULT_TERMINAL_KEYBINDINGS);
298 setTerminalKeybindings([]);
300299 });
301300
302301 return () => {
apps/web/src/keybindings.test.ts+26-64
......@@ -1,13 +1,12 @@
11import { describe, expect, it } from "vitest";
22
3import type { KeybindingsConfig } from "@t3tools/contracts";
34import {
4 DEFAULT_TERMINAL_KEYBINDINGS,
55 formatShortcutLabel,
66 isTerminalClearShortcut,
77 isTerminalNewShortcut,
88 isTerminalSplitShortcut,
99 isTerminalToggleShortcut,
10 resolveTerminalKeybindings,
1110 shortcutLabelForCommand,
1211 type ShortcutEventLike,
1312} from "./keybindings";
......@@ -23,65 +22,55 @@ function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {
2322 };
2423}
2524
25const DEFAULT_BINDINGS: KeybindingsConfig = [
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
2631describe("isTerminalToggleShortcut", () => {
2732 it("matches Cmd+J on macOS", () => {
28 expect(
29 isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
30 platform: "MacIntel",
31 }),
32 ).toBe(true);
33 expect(isTerminalToggleShortcut(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel" })).toBe(true);
3334 });
3435
3536 it("matches Ctrl+J on non-macOS", () => {
36 expect(
37 isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
38 platform: "Win32",
39 }),
40 ).toBe(true);
37 expect(isTerminalToggleShortcut(event({ ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Win32" })).toBe(true);
4138 });
4239});
4340
4441describe("split/new terminal shortcuts", () => {
4542 it("requires terminalFocus for default split/new bindings", () => {
4643 expect(
47 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
44 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
4845 platform: "MacIntel",
4946 context: { terminalFocus: false },
5047 }),
5148 ).toBe(false);
5249 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 ),
50 isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
51 platform: "Linux",
52 context: { terminalFocus: false },
53 }),
6154 ).toBe(false);
6255 });
6356
6457 it("matches split/new when terminalFocus is true", () => {
6558 expect(
66 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_TERMINAL_KEYBINDINGS, {
59 isTerminalSplitShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, {
6760 platform: "MacIntel",
6861 context: { terminalFocus: true },
6962 }),
7063 ).toBe(true);
7164 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 ),
65 isTerminalNewShortcut(event({ key: "d", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
66 platform: "Linux",
67 context: { terminalFocus: true },
68 }),
8069 ).toBe(true);
8170 });
8271
83 it("supports configured overrides with when expressions", () => {
84 const keybindings = resolveTerminalKeybindings([
72 it("supports when expressions", () => {
73 const keybindings: KeybindingsConfig = [
8574 {
8675 key: "mod+\\",
8776 command: "terminal.split",
......@@ -92,7 +81,8 @@ describe("split/new terminal shortcuts", () => {
9281 command: "terminal.new",
9382 when: "terminalOpen && !terminalFocus",
9483 },
95 ]);
84 { key: "mod+j", command: "terminal.toggle" },
85 ];
9686 expect(
9787 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {
9888 platform: "Win32",
......@@ -114,41 +104,13 @@ describe("split/new terminal shortcuts", () => {
114104 });
115105});
116106
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
144107describe("shortcutLabelForCommand", () => {
145108 it("returns the most recent binding label", () => {
146 const resolved = resolveTerminalKeybindings([
109 const bindings: KeybindingsConfig = [
147110 { key: "mod+\\", command: "terminal.split", when: "terminalFocus" },
148111 { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" },
149 ]);
150
151 expect(shortcutLabelForCommand(resolved, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\");
112 ];
113 expect(shortcutLabelForCommand(bindings, "terminal.split", "Linux")).toBe("Ctrl+Shift+\\");
152114 });
153115});
154116
apps/web/src/keybindings.ts+4-47
......@@ -1,4 +1,4 @@
1import type { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/contracts";
1import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts";
22
33export interface ShortcutEventLike {
44 key: string;
......@@ -14,15 +14,8 @@ export interface ShortcutMatchContext {
1414 [key: string]: boolean;
1515}
1616
17export type ResolvedTerminalKeybinding = KeybindingRule;
1817export type ResolvedTerminalKeybindings = KeybindingsConfig;
1918
20export const DEFAULT_TERMINAL_KEYBINDINGS: ResolvedTerminalKeybindings = [
21 { key: "mod+j", command: "terminal.toggle" },
22 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
23 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
24];
25
2619interface ParsedShortcut {
2720 key: string;
2821 metaKey: boolean;
......@@ -306,21 +299,6 @@ function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatc
306299 };
307300}
308301
309function normalizeConfiguredKeybinding(
310 keybinding: KeybindingRule,
311): ResolvedTerminalKeybinding | null {
312 const key = keybinding.key.trim();
313 if (key.length === 0) return null;
314 if (!parseShortcutValue(key)) return null;
315
316 const when = keybinding.when?.trim();
317 return {
318 key,
319 command: keybinding.command,
320 ...(when ? { when } : {}),
321 };
322}
323
324302function matchesCommandShortcut(
325303 event: ShortcutEventLike,
326304 keybindings: ResolvedTerminalKeybindings,
......@@ -340,27 +318,6 @@ function matchesCommandShortcut(
340318 return false;
341319}
342320
343export function resolveTerminalKeybindings(
344 input: KeybindingsConfig | null | undefined,
345): ResolvedTerminalKeybindings {
346 if (!input || input.length === 0) {
347 return [...DEFAULT_TERMINAL_KEYBINDINGS];
348 }
349
350 const normalizedInput = input
351 .map((binding) => normalizeConfiguredKeybinding(binding))
352 .filter((binding): binding is ResolvedTerminalKeybinding => binding !== null);
353 if (normalizedInput.length === 0) {
354 return [...DEFAULT_TERMINAL_KEYBINDINGS];
355 }
356
357 const overriddenCommands = new Set(normalizedInput.map((binding) => binding.command));
358 const retainedDefaults = DEFAULT_TERMINAL_KEYBINDINGS.filter(
359 (binding) => !overriddenCommands.has(binding.command),
360 );
361 return [...retainedDefaults, ...normalizedInput];
362}
363
364321function formatShortcutKeyLabel(key: string): string {
365322 if (key === " ") return "Space";
366323 if (key.length === 1) return key.toUpperCase();
......@@ -413,7 +370,7 @@ export function shortcutLabelForCommand(
413370
414371export function isTerminalToggleShortcut(
415372 event: ShortcutEventLike,
416 keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS,
373 keybindings: ResolvedTerminalKeybindings,
417374 options?: ShortcutMatchOptions,
418375): boolean {
419376 return matchesCommandShortcut(event, keybindings, "terminal.toggle", options);
......@@ -421,7 +378,7 @@ export function isTerminalToggleShortcut(
421378
422379export function isTerminalSplitShortcut(
423380 event: ShortcutEventLike,
424 keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS,
381 keybindings: ResolvedTerminalKeybindings,
425382 options?: ShortcutMatchOptions,
426383): boolean {
427384 return matchesCommandShortcut(event, keybindings, "terminal.split", options);
......@@ -429,7 +386,7 @@ export function isTerminalSplitShortcut(
429386
430387export function isTerminalNewShortcut(
431388 event: ShortcutEventLike,
432 keybindings: ResolvedTerminalKeybindings = DEFAULT_TERMINAL_KEYBINDINGS,
389 keybindings: ResolvedTerminalKeybindings,
433390 options?: ShortcutMatchOptions,
434391): boolean {
435392 return matchesCommandShortcut(event, keybindings, "terminal.new", options);