authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 14:20:50-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 15:51:05-08:00
log6667992753c3995fb591356ce8784624c6d4bd05
tree6d15a4eeeaad60a5367fd428566db66573d7b002
parent41ba1ec7a8965b42a7a7bf48065e5a800d46dc9e

Harden keybinding schemas and align tests with server defaults

- make resolved keybinding/shortcut schemas strict to reject unknown fields - export server `DEFAULT_KEYBINDINGS` as source of truth and reuse it in ws tests - simplify keybinding test fixtures and update docs to reference server defaults

6 files changed, 82 insertions(+), 162 deletions(-)

KEYBINDINGS.md+3-10
......@@ -21,11 +21,8 @@ The file must be a JSON array of rules:
2121
2222```json
2323[
24 { "key": "mod+j", "command": "terminal.toggle" },
25 { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" },
26 { "key": "mod+shift+d", "command": "terminal.new", "when": "terminalFocus" },
27 { "key": "mod+shift+o", "command": "chat.new" },
28 { "key": "mod+o", "command": "editor.openFavorite" }
24 { "key": "mod+g", "command": "terminal.toggle" },
25 { "key": "mod+shift+g", "command": "terminal.new", "when": "terminalFocus" }
2926]
3027```
3128
......@@ -90,11 +87,7 @@ Unknown condition keys evaluate to `false`.
9087
9188Built-in defaults are resolved on the server and sent to the web client via `server.getConfig`:
9289
93- `terminal.toggle`: `mod+j`
94- `terminal.split`: `mod+d` when `terminalFocus`
95- `terminal.new`: `mod+shift+d` when `terminalFocus`
96- `chat.new`: `mod+shift+o`
97- `editor.openFavorite`: `mod+o`
90- Source of truth: [`DEFAULT_KEYBINDINGS` in `apps/server/src/keybindings.ts`](apps/server/src/keybindings.ts)
9891
9992If you define any rules for a command, the default rule for that command is removed on the server.
10093
apps/server/src/keybindings.ts+1-1
......@@ -24,7 +24,7 @@ type WhenToken =
2424 | { type: "lparen" }
2525 | { type: "rparen" };
2626
27const DEFAULT_KEYBINDINGS: KeybindingsConfig = [
27export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
2828 { key: "mod+j", command: "terminal.toggle" },
2929 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
3030 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
apps/server/src/wsServer.test.ts+22-42
......@@ -17,7 +17,7 @@ import {
1717 type WsResponse,
1818} from "@t3tools/contracts";
1919import { ProjectRegistry } from "./projectRegistry";
20import { compileResolvedKeybindingRule } from "./keybindings";
20import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings";
2121import type {
2222 TerminalClearInput,
2323 TerminalCloseInput,
......@@ -207,6 +207,20 @@ function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsCon
207207 return resolved;
208208}
209209
210const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]);
211
212function mergeWithDefaultsForTest(custom: KeybindingsConfig): ResolvedKeybindingsConfig {
213 if (custom.length === 0) {
214 return DEFAULT_RESOLVED_KEYBINDINGS;
215 }
216
217 const overriddenCommands = new Set(custom.map((binding) => binding.command));
218 const retainedDefaults = DEFAULT_KEYBINDINGS.filter(
219 (binding) => !overriddenCommands.has(binding.command),
220 );
221 return compileKeybindings([...retainedDefaults, ...custom].slice(-256));
222}
223
210224describe("WebSocket Server", () => {
211225 let server: ReturnType<typeof createServer> | null = null;
212226 const connections: WebSocket[] = [];
......@@ -324,13 +338,7 @@ describe("WebSocket Server", () => {
324338 expect(response.error).toBeUndefined();
325339 expect(response.result).toEqual({
326340 cwd: "/my/workspace",
327 keybindings: compileKeybindings([
328 { key: "mod+j", command: "terminal.toggle" },
329 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
330 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
331 { key: "mod+shift+o", command: "chat.new" },
332 { key: "mod+o", command: "editor.openFavorite" },
333 ]),
341 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
334342 });
335343 });
336344
......@@ -363,9 +371,7 @@ describe("WebSocket Server", () => {
363371 expect(response.error).toBeUndefined();
364372 expect(response.result).toEqual({
365373 cwd: "/my/workspace",
366 keybindings: compileKeybindings([
367 { key: "mod+shift+o", command: "chat.new" },
368 { key: "mod+o", command: "editor.openFavorite" },
374 keybindings: mergeWithDefaultsForTest([
369375 { key: "cmd+j", command: "terminal.toggle" },
370376 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
371377 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
......@@ -402,11 +408,7 @@ describe("WebSocket Server", () => {
402408 expect(response.error).toBeUndefined();
403409 expect(response.result).toEqual({
404410 cwd: "/my/workspace",
405 keybindings: compileKeybindings([
406 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
407 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
408 { key: "mod+shift+o", command: "chat.new" },
409 { key: "mod+o", command: "editor.openFavorite" },
411 keybindings: mergeWithDefaultsForTest([
410412 { key: "mod+j", command: "terminal.toggle" },
411413 ]),
412414 });
......@@ -443,13 +445,7 @@ describe("WebSocket Server", () => {
443445 expect(response.error).toBeUndefined();
444446 expect(response.result).toEqual({
445447 cwd: "/my/workspace",
446 keybindings: compileKeybindings([
447 { key: "mod+j", command: "terminal.toggle" },
448 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
449 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
450 { key: "mod+shift+o", command: "chat.new" },
451 { key: "mod+o", command: "editor.openFavorite" },
452 ]),
448 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
453449 });
454450 expect(
455451 warnSpy.mock.calls.some(([message]) =>
......@@ -483,11 +479,7 @@ describe("WebSocket Server", () => {
483479 expect(firstResponse.error).toBeUndefined();
484480 expect(firstResponse.result).toEqual({
485481 cwd: "/my/workspace",
486 keybindings: compileKeybindings([
487 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
488 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
489 { key: "mod+shift+o", command: "chat.new" },
490 { key: "mod+o", command: "editor.openFavorite" },
482 keybindings: mergeWithDefaultsForTest([
491483 { key: "cmd+j", command: "terminal.toggle" },
492484 ]),
493485 });
......@@ -529,13 +521,7 @@ describe("WebSocket Server", () => {
529521 expect(response.error).toBeUndefined();
530522 expect(response.result).toEqual({
531523 cwd: "/my/workspace",
532 keybindings: compileKeybindings([
533 { key: "mod+j", command: "terminal.toggle" },
534 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
535 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
536 { key: "mod+shift+o", command: "chat.new" },
537 { key: "mod+o", command: "editor.openFavorite" },
538 ]),
524 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
539525 });
540526 expect(
541527 warnSpy.mock.calls.some(([message]) =>
......@@ -566,13 +552,7 @@ describe("WebSocket Server", () => {
566552 expect(response.error).toBeUndefined();
567553 expect(response.result).toEqual({
568554 cwd: "/my/workspace",
569 keybindings: compileKeybindings([
570 { key: "mod+j", command: "terminal.toggle" },
571 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
572 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
573 { key: "mod+shift+o", command: "chat.new" },
574 { key: "mod+o", command: "editor.openFavorite" },
575 ]),
555 keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
576556 });
577557 expect(
578558 warnSpy.mock.calls.some(([message]) =>
apps/web/src/keybindings.test.ts+37-106
......@@ -29,75 +29,21 @@ function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {
2929 };
3030}
3131
32function parseShortcut(value: string): KeybindingShortcut | null {
33 const rawTokens = value.toLowerCase().split("+").map((token) => token.trim());
34 const tokens = [...rawTokens];
35 let trailingEmptyCount = 0;
36 while (tokens[tokens.length - 1] === "") {
37 trailingEmptyCount += 1;
38 tokens.pop();
39 }
40 if (trailingEmptyCount > 0) {
41 tokens.push("+");
42 }
43 if (tokens.some((token) => token.length === 0)) {
44 return null;
45 }
46 if (tokens.length === 0) return null;
47
48 let key: string | null = null;
49 let metaKey = false;
50 let ctrlKey = false;
51 let shiftKey = false;
52 let altKey = false;
53 let modKey = false;
54
55 for (const token of tokens) {
56 switch (token) {
57 case "cmd":
58 case "meta":
59 metaKey = true;
60 break;
61 case "ctrl":
62 case "control":
63 ctrlKey = true;
64 break;
65 case "shift":
66 shiftKey = true;
67 break;
68 case "alt":
69 case "option":
70 altKey = true;
71 break;
72 case "mod":
73 modKey = true;
74 break;
75 default: {
76 if (key !== null) return null;
77 key = token;
78 }
79 }
80 }
81
82 if (key === null) return null;
32function modShortcut(
33 key: string,
34 overrides: Partial<Omit<KeybindingShortcut, "key">> = {},
35): KeybindingShortcut {
8336 return {
8437 key,
85 metaKey,
86 ctrlKey,
87 shiftKey,
88 altKey,
89 modKey,
38 metaKey: false,
39 ctrlKey: false,
40 shiftKey: false,
41 altKey: false,
42 modKey: true,
43 ...overrides,
9044 };
9145}
9246
93function shortcut(value: string): KeybindingShortcut {
94 const parsed = parseShortcut(value);
95 if (!parsed) {
96 throw new Error(`invalid shortcut in test fixture: ${value}`);
97 }
98 return parsed;
99}
100
10147function whenIdentifier(name: string): KeybindingWhenNode {
10248 return { type: "identifier", name };
10349}
......@@ -111,48 +57,33 @@ function whenAnd(left: KeybindingWhenNode, right: KeybindingWhenNode): Keybindin
11157}
11258
11359interface TestBinding {
114 key: string;
60 shortcut: KeybindingShortcut;
11561 command: KeybindingCommand;
11662 whenAst?: KeybindingWhenNode;
11763}
11864
11965function compile(bindings: TestBinding[]): ResolvedKeybindingsConfig {
120 const resolved: ResolvedKeybindingsConfig = [];
121 for (const binding of bindings) {
122 const parsedShortcut = parseShortcut(binding.key);
123 if (!parsedShortcut) {
124 throw new Error(`invalid shortcut in test fixture: ${binding.key}`);
125 }
126 if (binding.whenAst) {
127 resolved.push({
128 command: binding.command,
129 shortcut: parsedShortcut,
130 whenAst: binding.whenAst,
131 });
132 continue;
133 }
134 resolved.push({
135 command: binding.command,
136 shortcut: parsedShortcut,
137 });
138 }
139 return resolved;
66 return bindings.map((binding) => ({
67 command: binding.command,
68 shortcut: binding.shortcut,
69 ...(binding.whenAst ? { whenAst: binding.whenAst } : {}),
70 }));
14071}
14172
14273const DEFAULT_BINDINGS = compile([
143 { key: "mod+j", command: "terminal.toggle" },
74 { shortcut: modShortcut("j"), command: "terminal.toggle" },
14475 {
145 key: "mod+d",
76 shortcut: modShortcut("d"),
14677 command: "terminal.split",
14778 whenAst: whenIdentifier("terminalFocus"),
14879 },
14980 {
150 key: "mod+shift+d",
81 shortcut: modShortcut("d", { shiftKey: true }),
15182 command: "terminal.new",
15283 whenAst: whenIdentifier("terminalFocus"),
15384 },
154 { key: "mod+shift+o", command: "chat.new" },
155 { key: "mod+o", command: "editor.openFavorite" },
85 { shortcut: modShortcut("o", { shiftKey: true }), command: "chat.new" },
86 { shortcut: modShortcut("o"), command: "editor.openFavorite" },
15687]);
15788
15889describe("isTerminalToggleShortcut", () => {
......@@ -203,16 +134,16 @@ describe("split/new terminal shortcuts", () => {
203134 it("supports when expressions", () => {
204135 const keybindings = compile([
205136 {
206 key: "mod+\\",
137 shortcut: modShortcut("\\"),
207138 command: "terminal.split",
208139 whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))),
209140 },
210141 {
211 key: "mod+shift+n",
142 shortcut: modShortcut("n", { shiftKey: true }),
212143 command: "terminal.new",
213144 whenAst: whenAnd(whenIdentifier("terminalOpen"), whenNot(whenIdentifier("terminalFocus"))),
214145 },
215 { key: "mod+j", command: "terminal.toggle" },
146 { shortcut: modShortcut("j"), command: "terminal.toggle" },
216147 ]);
217148 assert.isTrue(
218149 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {
......@@ -236,8 +167,8 @@ describe("split/new terminal shortcuts", () => {
236167
237168 it("supports when boolean literals", () => {
238169 const keybindings = compile([
239 { key: "mod+n", command: "terminal.new", whenAst: whenIdentifier("true") },
240 { key: "mod+m", command: "terminal.new", whenAst: whenIdentifier("false") },
170 { shortcut: modShortcut("n"), command: "terminal.new", whenAst: whenIdentifier("true") },
171 { shortcut: modShortcut("m"), command: "terminal.new", whenAst: whenIdentifier("false") },
241172 ]);
242173
243174 assert.isTrue(
......@@ -257,12 +188,12 @@ describe("shortcutLabelForCommand", () => {
257188 it("returns the most recent binding label", () => {
258189 const bindings = compile([
259190 {
260 key: "mod+\\",
191 shortcut: modShortcut("\\"),
261192 command: "terminal.split",
262193 whenAst: whenIdentifier("terminalFocus"),
263194 },
264195 {
265 key: "mod+shift+\\",
196 shortcut: modShortcut("\\", { shiftKey: true }),
266197 command: "terminal.split",
267198 whenAst: whenNot(whenIdentifier("terminalFocus")),
268199 },
......@@ -310,9 +241,9 @@ describe("chat/editor shortcuts", () => {
310241describe("cross-command precedence", () => {
311242 it("uses when + order so a later focused rule overrides a global rule", () => {
312243 const keybindings = compile([
313 { key: "mod+n", command: "chat.new" },
244 { shortcut: modShortcut("n"), command: "chat.new" },
314245 {
315 key: "mod+n",
246 shortcut: modShortcut("n"),
316247 command: "terminal.new",
317248 whenAst: whenIdentifier("terminalFocus"),
318249 },
......@@ -347,11 +278,11 @@ describe("cross-command precedence", () => {
347278 it("still lets a later global rule win when both rules match", () => {
348279 const keybindings = compile([
349280 {
350 key: "mod+n",
281 shortcut: modShortcut("n"),
351282 command: "terminal.new",
352283 whenAst: whenIdentifier("terminalFocus"),
353284 },
354 { key: "mod+n", command: "chat.new" },
285 { shortcut: modShortcut("n"), command: "chat.new" },
355286 ]);
356287
357288 assert.isFalse(
......@@ -371,16 +302,16 @@ describe("cross-command precedence", () => {
371302
372303describe("formatShortcutLabel", () => {
373304 it("formats labels for macOS", () => {
374 assert.strictEqual(formatShortcutLabel(shortcut("mod+shift+d"), "MacIntel"), "⇧⌘D");
305 assert.strictEqual(formatShortcutLabel(modShortcut("d", { shiftKey: true }), "MacIntel"), "⇧⌘D");
375306 });
376307
377308 it("formats labels for non-macOS", () => {
378 assert.strictEqual(formatShortcutLabel(shortcut("mod+shift+d"), "Linux"), "Ctrl+Shift+D");
309 assert.strictEqual(formatShortcutLabel(modShortcut("d", { shiftKey: true }), "Linux"), "Ctrl+Shift+D");
379310 });
380311
381312 it("formats labels for plus key", () => {
382 assert.strictEqual(formatShortcutLabel(shortcut("mod++"), "MacIntel"), "⌘+");
383 assert.strictEqual(formatShortcutLabel(shortcut("mod++"), "Linux"), "Ctrl++");
313 assert.strictEqual(formatShortcutLabel(modShortcut("+"), "MacIntel"), "⌘+");
314 assert.strictEqual(formatShortcutLabel(modShortcut("+"), "Linux"), "Ctrl++");
384315 });
385316});
386317
......@@ -397,7 +328,7 @@ describe("isTerminalClearShortcut", () => {
397328
398329describe("plus key parsing", () => {
399330 it("matches the plus key shortcut", () => {
400 const plusBindings = compile([{ key: "mod++", command: "terminal.toggle" }]);
331 const plusBindings = compile([{ shortcut: modShortcut("+"), command: "terminal.toggle" }]);
401332 assert.isTrue(
402333 isTerminalToggleShortcut(event({ key: "+", metaKey: true }), plusBindings, {
403334 platform: "MacIntel",
packages/contracts/src/keybindings.test.ts+17-1
......@@ -61,7 +61,6 @@ describe("keybindings contracts", () => {
6161 it("parses resolved keybindings arrays", () => {
6262 const parsed = resolvedKeybindingsConfigSchema.parse([
6363 {
64 key: "mod+j",
6564 command: "terminal.toggle",
6665 shortcut: {
6766 key: "j",
......@@ -75,4 +74,21 @@ describe("keybindings contracts", () => {
7574 ]);
7675 assert.lengthOf(parsed, 1);
7776 });
77
78 it("rejects unknown fields in resolved keybinding rules", () => {
79 assert.throws(() =>
80 resolvedKeybindingRuleSchema.parse({
81 command: "terminal.toggle",
82 shortcut: {
83 key: "j",
84 metaKey: false,
85 ctrlKey: false,
86 shiftKey: false,
87 altKey: false,
88 modKey: true,
89 },
90 key: "mod+j",
91 }),
92 );
93 });
7894});
packages/contracts/src/keybindings.ts+2-2
......@@ -26,7 +26,7 @@ export const keybindingShortcutSchema = z.object({
2626 shiftKey: z.boolean(),
2727 altKey: z.boolean(),
2828 modKey: z.boolean(),
29});
29}).strict();
3030
3131export type KeybindingWhenNode =
3232 | { type: "identifier"; name: string }
......@@ -61,7 +61,7 @@ export const resolvedKeybindingRuleSchema = z.object({
6161 command: keybindingCommandSchema,
6262 shortcut: keybindingShortcutSchema,
6363 whenAst: keybindingWhenNodeSchema.optional(),
64});
64}).strict();
6565
6666export const resolvedKeybindingsConfigSchema = z.array(resolvedKeybindingRuleSchema).max(256);
6767