authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 13:48:16-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 15:51:05-08:00
log370127760ac980eb790f347c172c2b51f6d3c501
tree4088e050c47a260f38a5607a24d5f369c8f407e7
parentdd3babdcad749bd142a755fbc26c39085c85bc1c

Resolve and validate keybindings on server before sending to web

- add server keybinding compiler for shortcuts and `when` AST parsing - switch websocket/server config payload to resolved keybinding rules - update web keybinding logic/types to consume resolved payload directly - add/adjust contract, server, and web tests (including plus-key parsing) and docs

14 files changed, 813 insertions(+), 433 deletions(-)

KEYBINDINGS.md+13-2
......@@ -9,8 +9,9 @@ Schema source of truth:
99- [`packages/contracts/src/keybindings.ts`](packages/contracts/src/keybindings.ts)
1010- [`packages/contracts/src/server.ts`](packages/contracts/src/server.ts)
1111
12Server-side default resolution/merging:
12Server-side parsing/default resolution/merging:
1313
14- [`apps/server/src/keybindings.ts`](apps/server/src/keybindings.ts)
1415- [`apps/server/src/wsServer.ts`](apps/server/src/wsServer.ts)
1516
1617The file must be a JSON array of rules:
......@@ -94,8 +95,18 @@ Built-in defaults are resolved on the server and sent to the web client via `ser
9495
9596If you define any rules for a command, the default rule for that command is removed on the server.
9697
98## Server-Resolved Payload
99
100`~/.t3/keybindings.json` is parsed and compiled on the server. The web client receives resolved rules (including parsed shortcut/when fields) and does not parse raw keybinding strings.
101
102See:
103
104- [`packages/contracts/src/keybindings.ts`](packages/contracts/src/keybindings.ts)
105- [`apps/server/src/keybindings.ts`](apps/server/src/keybindings.ts)
106- [`apps/server/src/wsServer.ts`](apps/server/src/wsServer.ts)
107
97108## Precedence
98109
99110- Rules are evaluated in array order.
100- For a key event, the server/client resolves the last rule where both `key` matches and `when` evaluates to `true`.
111- For a key event, the last rule where both `key` matches and `when` evaluates to `true` wins.
101112- That means precedence is across commands, not only within the same command.
apps/server/src/keybindings.test.ts created+71
......@@ -0,0 +1,71 @@
1import { describe, expect, it } from "vitest";
2
3import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings";
4
5describe("server keybindings", () => {
6 it("parses shortcuts including plus key", () => {
7 expect(parseKeybindingShortcut("mod+j")).toEqual({
8 key: "j",
9 metaKey: false,
10 ctrlKey: false,
11 shiftKey: false,
12 altKey: false,
13 modKey: true,
14 });
15 expect(parseKeybindingShortcut("mod++")).toEqual({
16 key: "+",
17 metaKey: false,
18 ctrlKey: false,
19 shiftKey: false,
20 altKey: false,
21 modKey: true,
22 });
23 });
24
25 it("compiles valid rule with parsed when AST", () => {
26 const compiled = compileResolvedKeybindingRule({
27 key: "mod+d",
28 command: "terminal.split",
29 when: "terminalOpen && !terminalFocus",
30 });
31
32 expect(compiled).toEqual({
33 key: "mod+d",
34 command: "terminal.split",
35 when: "terminalOpen && !terminalFocus",
36 shortcut: {
37 key: "d",
38 metaKey: false,
39 ctrlKey: false,
40 shiftKey: false,
41 altKey: false,
42 modKey: true,
43 },
44 whenAst: {
45 type: "and",
46 left: { type: "identifier", name: "terminalOpen" },
47 right: {
48 type: "not",
49 node: { type: "identifier", name: "terminalFocus" },
50 },
51 },
52 });
53 });
54
55 it("rejects invalid rules", () => {
56 expect(
57 compileResolvedKeybindingRule({
58 key: "mod+shift+d+o",
59 command: "terminal.new",
60 }),
61 ).toBeNull();
62
63 expect(
64 compileResolvedKeybindingRule({
65 key: "mod+d",
66 command: "terminal.split",
67 when: "terminalFocus && (",
68 }),
69 ).toBeNull();
70 });
71});
apps/server/src/keybindings.ts created+244
......@@ -0,0 +1,244 @@
1import type {
2 KeybindingRule,
3 KeybindingsConfig,
4 KeybindingShortcut,
5 KeybindingWhenNode,
6 ResolvedKeybindingRule,
7 ResolvedKeybindingsConfig,
8} from "@t3tools/contracts";
9
10type WhenToken =
11 | { type: "identifier"; value: string }
12 | { type: "not" }
13 | { type: "and" }
14 | { type: "or" }
15 | { type: "lparen" }
16 | { type: "rparen" };
17
18function normalizeKeyToken(token: string): string {
19 if (token === "space") return " ";
20 if (token === "esc") return "escape";
21 return token;
22}
23
24export function parseKeybindingShortcut(value: string): KeybindingShortcut | null {
25 const rawTokens = value.toLowerCase().split("+").map((token) => token.trim());
26 const tokens = [...rawTokens];
27 let trailingEmptyCount = 0;
28 while (tokens[tokens.length - 1] === "") {
29 trailingEmptyCount += 1;
30 tokens.pop();
31 }
32 if (trailingEmptyCount > 0) {
33 tokens.push("+");
34 }
35 if (tokens.some((token) => token.length === 0)) {
36 return null;
37 }
38 if (tokens.length === 0) return null;
39
40 let key: string | null = null;
41 let metaKey = false;
42 let ctrlKey = false;
43 let shiftKey = false;
44 let altKey = false;
45 let modKey = false;
46
47 for (const token of tokens) {
48 switch (token) {
49 case "cmd":
50 case "meta":
51 metaKey = true;
52 break;
53 case "ctrl":
54 case "control":
55 ctrlKey = true;
56 break;
57 case "shift":
58 shiftKey = true;
59 break;
60 case "alt":
61 case "option":
62 altKey = true;
63 break;
64 case "mod":
65 modKey = true;
66 break;
67 default: {
68 if (key !== null) return null;
69 key = normalizeKeyToken(token);
70 }
71 }
72 }
73
74 if (key === null) return null;
75 return {
76 key,
77 metaKey,
78 ctrlKey,
79 shiftKey,
80 altKey,
81 modKey,
82 };
83}
84
85function tokenizeWhenExpression(expression: string): WhenToken[] | null {
86 const tokens: WhenToken[] = [];
87 let index = 0;
88
89 while (index < expression.length) {
90 const current = expression[index];
91 if (!current) break;
92
93 if (/\s/.test(current)) {
94 index += 1;
95 continue;
96 }
97 if (expression.startsWith("&&", index)) {
98 tokens.push({ type: "and" });
99 index += 2;
100 continue;
101 }
102 if (expression.startsWith("||", index)) {
103 tokens.push({ type: "or" });
104 index += 2;
105 continue;
106 }
107 if (current === "!") {
108 tokens.push({ type: "not" });
109 index += 1;
110 continue;
111 }
112 if (current === "(") {
113 tokens.push({ type: "lparen" });
114 index += 1;
115 continue;
116 }
117 if (current === ")") {
118 tokens.push({ type: "rparen" });
119 index += 1;
120 continue;
121 }
122
123 const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index));
124 if (!identifier) {
125 return null;
126 }
127 tokens.push({ type: "identifier", value: identifier[0] });
128 index += identifier[0].length;
129 }
130
131 return tokens;
132}
133
134export function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null {
135 const tokens = tokenizeWhenExpression(expression);
136 if (!tokens || tokens.length === 0) return null;
137 let index = 0;
138
139 const parsePrimary = (): KeybindingWhenNode | null => {
140 const token = tokens[index];
141 if (!token) return null;
142
143 if (token.type === "identifier") {
144 index += 1;
145 return { type: "identifier", name: token.value };
146 }
147
148 if (token.type === "lparen") {
149 index += 1;
150 const expressionNode = parseOr();
151 const closeToken = tokens[index];
152 if (!expressionNode || !closeToken || closeToken.type !== "rparen") {
153 return null;
154 }
155 index += 1;
156 return expressionNode;
157 }
158
159 return null;
160 };
161
162 const parseUnary = (): KeybindingWhenNode | null => {
163 const token = tokens[index];
164 if (token?.type === "not") {
165 index += 1;
166 const node = parseUnary();
167 if (!node) return null;
168 return { type: "not", node };
169 }
170 return parsePrimary();
171 };
172
173 const parseAnd = (): KeybindingWhenNode | null => {
174 let left = parseUnary();
175 if (!left) return null;
176
177 while (tokens[index]?.type === "and") {
178 index += 1;
179 const right = parseUnary();
180 if (!right) return null;
181 left = { type: "and", left, right };
182 }
183
184 return left;
185 };
186
187 const parseOr = (): KeybindingWhenNode | null => {
188 let left = parseAnd();
189 if (!left) return null;
190
191 while (tokens[index]?.type === "or") {
192 index += 1;
193 const right = parseAnd();
194 if (!right) return null;
195 left = { type: "or", left, right };
196 }
197
198 return left;
199 };
200
201 const ast = parseOr();
202 if (!ast || index !== tokens.length) return null;
203 return ast;
204}
205
206export function compileResolvedKeybindingRule(
207 rule: KeybindingRule,
208): ResolvedKeybindingRule | null {
209 const key = rule.key.trim();
210 if (key.length === 0) return null;
211 const shortcut = parseKeybindingShortcut(key);
212 if (!shortcut) return null;
213
214 const when = rule.when?.trim();
215 if (when && when.length > 0) {
216 const whenAst = parseKeybindingWhenExpression(when);
217 if (!whenAst) return null;
218 return {
219 key,
220 command: rule.command,
221 when,
222 shortcut,
223 whenAst,
224 };
225 }
226
227 return {
228 key,
229 command: rule.command,
230 shortcut,
231 };
232}
233
234export function compileResolvedKeybindingsConfig(
235 config: KeybindingsConfig,
236): ResolvedKeybindingsConfig {
237 const compiled: ResolvedKeybindingsConfig = [];
238 for (const rule of config) {
239 const resolved = compileResolvedKeybindingRule(rule);
240 if (!resolved) continue;
241 compiled.push(resolved);
242 }
243 return compiled;
244}
apps/server/src/wsServer.test.ts+29-14
......@@ -11,10 +11,13 @@ import {
1111 DEFAULT_TERMINAL_ID,
1212 WS_CHANNELS,
1313 WS_METHODS,
14 type KeybindingsConfig,
15 type ResolvedKeybindingsConfig,
1416 type WsPush,
1517 type WsResponse,
1618} from "@t3tools/contracts";
1719import { ProjectRegistry } from "./projectRegistry";
20import { compileResolvedKeybindingRule } from "./keybindings";
1821import type {
1922 TerminalClearInput,
2023 TerminalCloseInput,
......@@ -192,6 +195,18 @@ async function sendRequest(ws: WebSocket, method: string, params?: unknown): Pro
192195 }
193196}
194197
198function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {
199 const resolved: ResolvedKeybindingsConfig = [];
200 for (const binding of bindings) {
201 const compiled = compileResolvedKeybindingRule(binding);
202 if (!compiled) {
203 throw new Error(`Unexpected invalid keybinding in test setup: ${binding.command}`);
204 }
205 resolved.push(compiled);
206 }
207 return resolved;
208}
209
195210describe("WebSocket Server", () => {
196211 let server: ReturnType<typeof createServer> | null = null;
197212 const connections: WebSocket[] = [];
......@@ -309,13 +324,13 @@ describe("WebSocket Server", () => {
309324 expect(response.error).toBeUndefined();
310325 expect(response.result).toEqual({
311326 cwd: "/my/workspace",
312 keybindings: [
327 keybindings: compileKeybindings([
313328 { key: "mod+j", command: "terminal.toggle" },
314329 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
315330 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
316331 { key: "mod+shift+o", command: "chat.new" },
317332 { key: "mod+o", command: "editor.openFavorite" },
318 ],
333 ]),
319334 });
320335 });
321336
......@@ -348,13 +363,13 @@ describe("WebSocket Server", () => {
348363 expect(response.error).toBeUndefined();
349364 expect(response.result).toEqual({
350365 cwd: "/my/workspace",
351 keybindings: [
366 keybindings: compileKeybindings([
352367 { key: "mod+shift+o", command: "chat.new" },
353368 { key: "mod+o", command: "editor.openFavorite" },
354369 { key: "cmd+j", command: "terminal.toggle" },
355370 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
356371 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
357 ],
372 ]),
358373 });
359374 });
360375
......@@ -387,13 +402,13 @@ describe("WebSocket Server", () => {
387402 expect(response.error).toBeUndefined();
388403 expect(response.result).toEqual({
389404 cwd: "/my/workspace",
390 keybindings: [
405 keybindings: compileKeybindings([
391406 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
392407 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
393408 { key: "mod+shift+o", command: "chat.new" },
394409 { key: "mod+o", command: "editor.openFavorite" },
395410 { key: "mod+j", command: "terminal.toggle" },
396 ],
411 ]),
397412 });
398413 expect(
399414 warnSpy.mock.calls.some(([message]) =>
......@@ -428,13 +443,13 @@ describe("WebSocket Server", () => {
428443 expect(response.error).toBeUndefined();
429444 expect(response.result).toEqual({
430445 cwd: "/my/workspace",
431 keybindings: [
446 keybindings: compileKeybindings([
432447 { key: "mod+j", command: "terminal.toggle" },
433448 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
434449 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
435450 { key: "mod+shift+o", command: "chat.new" },
436451 { key: "mod+o", command: "editor.openFavorite" },
437 ],
452 ]),
438453 });
439454 expect(
440455 warnSpy.mock.calls.some(([message]) =>
......@@ -468,13 +483,13 @@ describe("WebSocket Server", () => {
468483 expect(firstResponse.error).toBeUndefined();
469484 expect(firstResponse.result).toEqual({
470485 cwd: "/my/workspace",
471 keybindings: [
486 keybindings: compileKeybindings([
472487 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
473488 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
474489 { key: "mod+shift+o", command: "chat.new" },
475490 { key: "mod+o", command: "editor.openFavorite" },
476491 { key: "cmd+j", command: "terminal.toggle" },
477 ],
492 ]),
478493 });
479494
480495 fs.writeFileSync(
......@@ -514,13 +529,13 @@ describe("WebSocket Server", () => {
514529 expect(response.error).toBeUndefined();
515530 expect(response.result).toEqual({
516531 cwd: "/my/workspace",
517 keybindings: [
532 keybindings: compileKeybindings([
518533 { key: "mod+j", command: "terminal.toggle" },
519534 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
520535 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
521536 { key: "mod+shift+o", command: "chat.new" },
522537 { key: "mod+o", command: "editor.openFavorite" },
523 ],
538 ]),
524539 });
525540 expect(
526541 warnSpy.mock.calls.some(([message]) =>
......@@ -551,13 +566,13 @@ describe("WebSocket Server", () => {
551566 expect(response.error).toBeUndefined();
552567 expect(response.result).toEqual({
553568 cwd: "/my/workspace",
554 keybindings: [
569 keybindings: compileKeybindings([
555570 { key: "mod+j", command: "terminal.toggle" },
556571 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
557572 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
558573 { key: "mod+shift+o", command: "chat.new" },
559574 { key: "mod+o", command: "editor.openFavorite" },
560 ],
575 ]),
561576 });
562577 expect(
563578 warnSpy.mock.calls.some(([message]) =>
apps/server/src/wsServer.ts+31-128
......@@ -11,6 +11,7 @@ import {
1111 WS_METHODS,
1212 keybindingRuleSchema,
1313 type KeybindingsConfig,
14 type ResolvedKeybindingsConfig,
1415 type TerminalEvent,
1516 type WsPush,
1617 type WsRequest,
......@@ -33,6 +34,7 @@ import {
3334 removeGitWorktree,
3435} from "./git";
3536import { TerminalManager } from "./terminalManager";
37import { compileResolvedKeybindingRule } from "./keybindings";
3638
3739const MIME_TYPES: Record<string, string> = {
3840 ".html": "text/html; charset=utf-8",
......@@ -78,129 +80,29 @@ const DEFAULT_KEYBINDINGS: KeybindingsConfig = [
7880 { key: "mod+o", command: "editor.openFavorite" },
7981];
8082
81type WhenTokenType = "identifier" | "not" | "and" | "or" | "lparen" | "rparen";
82
83interface WhenToken {
84 type: WhenTokenType;
85}
86
87function tokenizeWhenExpression(expression: string): WhenToken[] | null {
88 const tokens: WhenToken[] = [];
89 let index = 0;
90
91 while (index < expression.length) {
92 const current = expression[index];
93 if (!current) break;
94
95 if (/\s/.test(current)) {
96 index += 1;
97 continue;
98 }
99 if (expression.startsWith("&&", index)) {
100 tokens.push({ type: "and" });
101 index += 2;
102 continue;
103 }
104 if (expression.startsWith("||", index)) {
105 tokens.push({ type: "or" });
106 index += 2;
107 continue;
108 }
109 if (current === "!") {
110 tokens.push({ type: "not" });
111 index += 1;
112 continue;
113 }
114 if (current === "(") {
115 tokens.push({ type: "lparen" });
116 index += 1;
117 continue;
83function compileDefaultKeybindings(): ResolvedKeybindingsConfig {
84 const resolved: ResolvedKeybindingsConfig = [];
85 for (const rule of DEFAULT_KEYBINDINGS) {
86 const compiled = compileResolvedKeybindingRule(rule);
87 if (!compiled) {
88 throw new Error(`Invalid default keybinding: ${rule.command} (${rule.key})`);
11889 }
119 if (current === ")") {
120 tokens.push({ type: "rparen" });
121 index += 1;
122 continue;
123 }
124
125 const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index));
126 if (!identifier) {
127 return null;
128 }
129 tokens.push({ type: "identifier" });
130 index += identifier[0].length;
90 resolved.push(compiled);
13191 }
132
133 return tokens;
92 return resolved;
13493}
13594
136function isValidWhenExpression(when: string): boolean {
137 const tokens = tokenizeWhenExpression(when);
138 if (!tokens || tokens.length === 0) return false;
139 let index = 0;
140
141 const parsePrimary = (): boolean => {
142 const token = tokens[index];
143 if (!token) return false;
144
145 if (token.type === "identifier") {
146 index += 1;
147 return true;
148 }
149
150 if (token.type === "lparen") {
151 index += 1;
152 const parsed = parseOr();
153 const close = tokens[index];
154 if (!parsed || !close || close.type !== "rparen") return false;
155 index += 1;
156 return true;
157 }
158
159 return false;
160 };
161
162 const parseUnary = (): boolean => {
163 const token = tokens[index];
164 if (token?.type === "not") {
165 index += 1;
166 return parseUnary();
167 }
168 return parsePrimary();
169 };
170
171 const parseAnd = (): boolean => {
172 if (!parseUnary()) return false;
173
174 while (tokens[index]?.type === "and") {
175 index += 1;
176 if (!parseUnary()) return false;
177 }
178
179 return true;
180 };
181
182 const parseOr = (): boolean => {
183 if (!parseAnd()) return false;
184
185 while (tokens[index]?.type === "or") {
186 index += 1;
187 if (!parseAnd()) return false;
188 }
189
190 return true;
191 };
192
193 if (!parseOr()) return false;
194 return index === tokens.length;
195}
95const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings();
19696
197function mergeWithDefaultKeybindings(custom: KeybindingsConfig): KeybindingsConfig {
97function mergeWithDefaultKeybindings(
98 custom: ResolvedKeybindingsConfig,
99): ResolvedKeybindingsConfig {
198100 if (custom.length === 0) {
199 return [...DEFAULT_KEYBINDINGS];
101 return [...DEFAULT_RESOLVED_KEYBINDINGS];
200102 }
201103
202104 const overriddenCommands = new Set(custom.map((binding) => binding.command));
203 const retainedDefaults = DEFAULT_KEYBINDINGS.filter(
105 const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter(
204106 (binding) => !overriddenCommands.has(binding.command),
205107 );
206108 const merged = [...retainedDefaults, ...custom];
......@@ -213,7 +115,9 @@ function mergeWithDefaultKeybindings(custom: KeybindingsConfig): KeybindingsConf
213115 return merged.slice(-256);
214116}
215117
216function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): KeybindingsConfig {
118function readKeybindingsConfig(
119 logger: ReturnType<typeof createLogger>,
120): ResolvedKeybindingsConfig {
217121 const configPath = path.join(os.homedir(), ".t3", "keybindings.json");
218122 try {
219123 const raw = fs.readFileSync(configPath, "utf8");
......@@ -222,19 +126,20 @@ function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): Keybind
222126 logger.warn("ignoring keybindings config with unsupported format; expected array", {
223127 path: configPath,
224128 });
225 return [...DEFAULT_KEYBINDINGS];
129 return [...DEFAULT_RESOLVED_KEYBINDINGS];
226130 }
227131
228 const sanitized: KeybindingsConfig = [];
132 const sanitized: ResolvedKeybindingsConfig = [];
229133 let invalidEntries = 0;
230134 for (const entry of parsed) {
231135 const result = keybindingRuleSchema.safeParse(entry);
232136 if (result.success) {
233 if (result.data.when && !isValidWhenExpression(result.data.when)) {
137 const compiled = compileResolvedKeybindingRule(result.data);
138 if (!compiled) {
234139 invalidEntries += 1;
235140 continue;
236141 }
237 sanitized.push(result.data);
142 sanitized.push(compiled);
238143 continue;
239144 }
240145 invalidEntries += 1;
......@@ -246,14 +151,12 @@ function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): Keybind
246151 totalEntries: parsed.length,
247152 });
248153 }
249 const mergedBeforeCap = [
250 ...DEFAULT_KEYBINDINGS.filter(
251 (binding) => !new Set(sanitized.map((entry) => entry.command)).has(binding.command),
252 ),
253 ...sanitized,
254 ];
154 const overriddenCommands = new Set(sanitized.map((entry) => entry.command));
155 const mergedBeforeCapLength =
156 DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command))
157 .length + sanitized.length;
255158 const merged = mergeWithDefaultKeybindings(sanitized);
256 if (mergedBeforeCap.length > 256) {
159 if (mergedBeforeCapLength > 256) {
257160 logger.warn("truncating merged keybindings config to max entries", {
258161 path: configPath,
259162 maxEntries: 256,
......@@ -262,14 +165,14 @@ function readKeybindingsConfig(logger: ReturnType<typeof createLogger>): Keybind
262165 return merged;
263166 } catch (error) {
264167 if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
265 return [...DEFAULT_KEYBINDINGS];
168 return [...DEFAULT_RESOLVED_KEYBINDINGS];
266169 }
267170 logger.warn("ignoring malformed keybindings config", {
268171 path: configPath,
269172 error: error instanceof Error ? error.message : String(error),
270173 });
271174 }
272 return [...DEFAULT_KEYBINDINGS];
175 return [...DEFAULT_RESOLVED_KEYBINDINGS];
273176}
274177
275178export function createServer(options: ServerOptions) {
apps/web/src/components/ChatView.tsx+3-3
......@@ -5,9 +5,9 @@ import {
55 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
66 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
77 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
8 type ResolvedKeybindingsConfig,
89 type ProviderApprovalDecision,
910 type ProviderSendTurnAttachmentInput,
10 type KeybindingsConfig,
1111} from "@t3tools/contracts";
1212import {
1313 type ClipboardEvent,
......@@ -92,7 +92,7 @@ const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
9292const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
9393const IMAGE_ONLY_BOOTSTRAP_PROMPT =
9494 "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]";
95const EMPTY_KEYBINDINGS: KeybindingsConfig = [];
95const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
9696
9797function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
9898 if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50";
......@@ -1520,7 +1520,7 @@ function ReasoningEffortPicker(props: {
15201520 );
15211521}
15221522
1523function OpenInPicker({ keybindings }: { keybindings: KeybindingsConfig }) {
1523function OpenInPicker({ keybindings }: { keybindings: ResolvedKeybindingsConfig }) {
15241524 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
15251525 const stored = localStorage.getItem(LAST_EDITOR_KEY);
15261526 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;
apps/web/src/components/Sidebar.tsx+3-1
......@@ -1,5 +1,6 @@
11import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";
22import { useCallback, useEffect, useMemo, useState } from "react";
3import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
34import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
45import { isElectron } from "../env";
56import { useTheme } from "../hooks/useTheme";
......@@ -19,6 +20,7 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery";
1920import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
2021
2122const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
23const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
2224
2325function formatRelativeTime(iso: string): string {
2426 const diff = Date.now() - new Date(iso).getTime();
......@@ -115,7 +117,7 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null
115117export default function Sidebar() {
116118 const { state, dispatch } = useStore();
117119 const api = useNativeApi();
118 const { data: keybindings = [] } = useQuery({
120 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({
119121 ...serverConfigQueryOptions(api),
120122 select: (config) => config.keybindings,
121123 });
apps/web/src/keybindings.test.ts+250-18
......@@ -1,6 +1,11 @@
11import { assert, describe, it } from "vitest";
22
3import type { KeybindingsConfig } from "@t3tools/contracts";
3import {
4 type KeybindingShortcut,
5 type KeybindingsConfig,
6 type KeybindingWhenNode,
7 type ResolvedKeybindingsConfig,
8} from "@t3tools/contracts";
49import {
510 formatShortcutLabel,
611 isChatNewShortcut,
......@@ -13,6 +18,14 @@ import {
1318 type ShortcutEventLike,
1419} from "./keybindings";
1520
21type WhenToken =
22 | { type: "identifier"; value: string }
23 | { type: "not" }
24 | { type: "and" }
25 | { type: "or" }
26 | { type: "lparen" }
27 | { type: "rparen" };
28
1629function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {
1730 return {
1831 key: "j",
......@@ -24,13 +37,232 @@ function event(overrides: Partial<ShortcutEventLike> = {}): ShortcutEventLike {
2437 };
2538}
2639
27const DEFAULT_BINDINGS: KeybindingsConfig = [
40function parseShortcut(value: string): KeybindingShortcut | null {
41 const rawTokens = value.toLowerCase().split("+").map((token) => token.trim());
42 const tokens = [...rawTokens];
43 let trailingEmptyCount = 0;
44 while (tokens[tokens.length - 1] === "") {
45 trailingEmptyCount += 1;
46 tokens.pop();
47 }
48 if (trailingEmptyCount > 0) {
49 tokens.push("+");
50 }
51 if (tokens.some((token) => token.length === 0)) {
52 return null;
53 }
54 if (tokens.length === 0) return null;
55
56 let key: string | null = null;
57 let metaKey = false;
58 let ctrlKey = false;
59 let shiftKey = false;
60 let altKey = false;
61 let modKey = false;
62
63 for (const token of tokens) {
64 switch (token) {
65 case "cmd":
66 case "meta":
67 metaKey = true;
68 break;
69 case "ctrl":
70 case "control":
71 ctrlKey = true;
72 break;
73 case "shift":
74 shiftKey = true;
75 break;
76 case "alt":
77 case "option":
78 altKey = true;
79 break;
80 case "mod":
81 modKey = true;
82 break;
83 default: {
84 if (key !== null) return null;
85 key = token;
86 }
87 }
88 }
89
90 if (key === null) return null;
91 return {
92 key,
93 metaKey,
94 ctrlKey,
95 shiftKey,
96 altKey,
97 modKey,
98 };
99}
100
101function shortcut(value: string): KeybindingShortcut {
102 const parsed = parseShortcut(value);
103 if (!parsed) {
104 throw new Error(`invalid shortcut in test fixture: ${value}`);
105 }
106 return parsed;
107}
108
109function tokenizeWhenExpression(expression: string): WhenToken[] | null {
110 const tokens: WhenToken[] = [];
111 let index = 0;
112
113 while (index < expression.length) {
114 const current = expression[index];
115 if (!current) break;
116
117 if (/\s/.test(current)) {
118 index += 1;
119 continue;
120 }
121 if (expression.startsWith("&&", index)) {
122 tokens.push({ type: "and" });
123 index += 2;
124 continue;
125 }
126 if (expression.startsWith("||", index)) {
127 tokens.push({ type: "or" });
128 index += 2;
129 continue;
130 }
131 if (current === "!") {
132 tokens.push({ type: "not" });
133 index += 1;
134 continue;
135 }
136 if (current === "(") {
137 tokens.push({ type: "lparen" });
138 index += 1;
139 continue;
140 }
141 if (current === ")") {
142 tokens.push({ type: "rparen" });
143 index += 1;
144 continue;
145 }
146
147 const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index));
148 if (!identifier) return null;
149 tokens.push({ type: "identifier", value: identifier[0] });
150 index += identifier[0].length;
151 }
152
153 return tokens;
154}
155
156function parseWhenExpression(expression: string): KeybindingWhenNode | null {
157 const tokens = tokenizeWhenExpression(expression);
158 if (!tokens || tokens.length === 0) return null;
159 let index = 0;
160
161 const parsePrimary = (): KeybindingWhenNode | null => {
162 const token = tokens[index];
163 if (!token) return null;
164
165 if (token.type === "identifier") {
166 index += 1;
167 return { type: "identifier", name: token.value };
168 }
169
170 if (token.type === "lparen") {
171 index += 1;
172 const expressionNode = parseOr();
173 const closeToken = tokens[index];
174 if (!expressionNode || !closeToken || closeToken.type !== "rparen") {
175 return null;
176 }
177 index += 1;
178 return expressionNode;
179 }
180
181 return null;
182 };
183
184 const parseUnary = (): KeybindingWhenNode | null => {
185 const token = tokens[index];
186 if (token?.type === "not") {
187 index += 1;
188 const node = parseUnary();
189 if (!node) return null;
190 return { type: "not", node };
191 }
192 return parsePrimary();
193 };
194
195 const parseAnd = (): KeybindingWhenNode | null => {
196 let left = parseUnary();
197 if (!left) return null;
198
199 while (tokens[index]?.type === "and") {
200 index += 1;
201 const right = parseUnary();
202 if (!right) return null;
203 left = { type: "and", left, right };
204 }
205
206 return left;
207 };
208
209 const parseOr = (): KeybindingWhenNode | null => {
210 let left = parseAnd();
211 if (!left) return null;
212
213 while (tokens[index]?.type === "or") {
214 index += 1;
215 const right = parseAnd();
216 if (!right) return null;
217 left = { type: "or", left, right };
218 }
219
220 return left;
221 };
222
223 const ast = parseOr();
224 if (!ast || index !== tokens.length) return null;
225 return ast;
226}
227
228function compile(bindings: KeybindingsConfig): ResolvedKeybindingsConfig {
229 const resolved: ResolvedKeybindingsConfig = [];
230 for (const binding of bindings) {
231 const parsedShortcut = parseShortcut(binding.key);
232 if (!parsedShortcut) {
233 throw new Error(`invalid shortcut in test fixture: ${binding.key}`);
234 }
235 const when = binding.when?.trim();
236 if (when && when.length > 0) {
237 const whenAst = parseWhenExpression(when);
238 if (!whenAst) {
239 throw new Error(`invalid when expression in test fixture: ${when}`);
240 }
241 resolved.push({
242 key: binding.key.trim(),
243 command: binding.command,
244 when,
245 shortcut: parsedShortcut,
246 whenAst,
247 });
248 continue;
249 }
250 resolved.push({
251 key: binding.key.trim(),
252 command: binding.command,
253 shortcut: parsedShortcut,
254 });
255 }
256 return resolved;
257}
258
259const DEFAULT_BINDINGS = compile([
28260 { key: "mod+j", command: "terminal.toggle" },
29261 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
30262 { key: "mod+shift+d", command: "terminal.new", when: "terminalFocus" },
31263 { key: "mod+shift+o", command: "chat.new" },
32264 { key: "mod+o", command: "editor.openFavorite" },
33];
265]);
34266
35267describe("isTerminalToggleShortcut", () => {
36268 it("matches Cmd+J on macOS", () => {
......@@ -78,7 +310,7 @@ describe("split/new terminal shortcuts", () => {
78310 });
79311
80312 it("supports when expressions", () => {
81 const keybindings: KeybindingsConfig = [
313 const keybindings = compile([
82314 {
83315 key: "mod+\\",
84316 command: "terminal.split",
......@@ -90,7 +322,7 @@ describe("split/new terminal shortcuts", () => {
90322 when: "terminalOpen && !terminalFocus",
91323 },
92324 { key: "mod+j", command: "terminal.toggle" },
93 ];
325 ]);
94326 assert.isTrue(
95327 isTerminalSplitShortcut(event({ key: "\\", ctrlKey: true }), keybindings, {
96328 platform: "Win32",
......@@ -112,10 +344,10 @@ describe("split/new terminal shortcuts", () => {
112344 });
113345
114346 it("supports when boolean literals", () => {
115 const keybindings: KeybindingsConfig = [
347 const keybindings = compile([
116348 { key: "mod+n", command: "terminal.new", when: "true" },
117349 { key: "mod+m", command: "terminal.new", when: "false" },
118 ];
350 ]);
119351
120352 assert.isTrue(
121353 isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, {
......@@ -132,10 +364,10 @@ describe("split/new terminal shortcuts", () => {
132364
133365describe("shortcutLabelForCommand", () => {
134366 it("returns the most recent binding label", () => {
135 const bindings: KeybindingsConfig = [
367 const bindings = compile([
136368 { key: "mod+\\", command: "terminal.split", when: "terminalFocus" },
137369 { key: "mod+shift+\\", command: "terminal.split", when: "!terminalFocus" },
138 ];
370 ]);
139371 assert.strictEqual(shortcutLabelForCommand(bindings, "terminal.split", "Linux"), "Ctrl+Shift+\\");
140372 });
141373
......@@ -178,10 +410,10 @@ describe("chat/editor shortcuts", () => {
178410
179411describe("cross-command precedence", () => {
180412 it("uses when + order so a later focused rule overrides a global rule", () => {
181 const keybindings: KeybindingsConfig = [
413 const keybindings = compile([
182414 { key: "mod+n", command: "chat.new" },
183415 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
184 ];
416 ]);
185417
186418 assert.isTrue(
187419 isTerminalNewShortcut(event({ key: "n", metaKey: true }), keybindings, {
......@@ -210,10 +442,10 @@ describe("cross-command precedence", () => {
210442 });
211443
212444 it("still lets a later global rule win when both rules match", () => {
213 const keybindings: KeybindingsConfig = [
445 const keybindings = compile([
214446 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
215447 { key: "mod+n", command: "chat.new" },
216 ];
448 ]);
217449
218450 assert.isFalse(
219451 isTerminalNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, {
......@@ -232,16 +464,16 @@ describe("cross-command precedence", () => {
232464
233465describe("formatShortcutLabel", () => {
234466 it("formats labels for macOS", () => {
235 assert.strictEqual(formatShortcutLabel("mod+shift+d", "MacIntel"), "⇧⌘D");
467 assert.strictEqual(formatShortcutLabel(shortcut("mod+shift+d"), "MacIntel"), "⇧⌘D");
236468 });
237469
238470 it("formats labels for non-macOS", () => {
239 assert.strictEqual(formatShortcutLabel("mod+shift+d", "Linux"), "Ctrl+Shift+D");
471 assert.strictEqual(formatShortcutLabel(shortcut("mod+shift+d"), "Linux"), "Ctrl+Shift+D");
240472 });
241473
242474 it("formats labels for plus key", () => {
243 assert.strictEqual(formatShortcutLabel("mod++", "MacIntel"), "⌘+");
244 assert.strictEqual(formatShortcutLabel("mod++", "Linux"), "Ctrl++");
475 assert.strictEqual(formatShortcutLabel(shortcut("mod++"), "MacIntel"), "⌘+");
476 assert.strictEqual(formatShortcutLabel(shortcut("mod++"), "Linux"), "Ctrl++");
245477 });
246478});
247479
......@@ -258,7 +490,7 @@ describe("isTerminalClearShortcut", () => {
258490
259491describe("plus key parsing", () => {
260492 it("matches the plus key shortcut", () => {
261 const plusBindings: KeybindingsConfig = [{ key: "mod++", command: "terminal.toggle" }];
493 const plusBindings = compile([{ key: "mod++", command: "terminal.toggle" }]);
262494 assert.isTrue(
263495 isTerminalToggleShortcut(event({ key: "+", metaKey: true }), plusBindings, {
264496 platform: "MacIntel",
apps/web/src/keybindings.ts+37-265
......@@ -1,5 +1,9 @@
1import type { KeybindingCommand, KeybindingsConfig } from "@t3tools/contracts";
2import { LruCache } from "./lib/lru";
1import {
2 type KeybindingCommand,
3 type KeybindingShortcut,
4 type KeybindingWhenNode,
5 type ResolvedKeybindingsConfig,
6} from "@t3tools/contracts";
37
48export interface ShortcutEventLike {
59 key: string;
......@@ -15,262 +19,55 @@ export interface ShortcutMatchContext {
1519 [key: string]: boolean;
1620}
1721
18export type ResolvedKeybindings = KeybindingsConfig;
19
20interface ParsedShortcut {
21 key: string;
22 metaKey: boolean;
23 ctrlKey: boolean;
24 shiftKey: boolean;
25 altKey: boolean;
26 modKey: boolean;
27}
22export type ResolvedKeybindings = ResolvedKeybindingsConfig;
2823
2924interface ShortcutMatchOptions {
3025 platform?: string;
3126 context?: Partial<ShortcutMatchContext>;
3227}
3328
34type WhenNode =
35 | { type: "identifier"; name: string }
36 | { type: "not"; node: WhenNode }
37 | { type: "and"; left: WhenNode; right: WhenNode }
38 | { type: "or"; left: WhenNode; right: WhenNode };
39
40type WhenToken =
41 | { type: "identifier"; value: string }
42 | { type: "not" }
43 | { type: "and" }
44 | { type: "or" }
45 | { type: "lparen" }
46 | { type: "rparen" };
47
48const WHEN_AST_CACHE = new LruCache<string, WhenNode | null>(512);
49
5029function isMacPlatform(platform: string): boolean {
5130 return /mac|iphone|ipad|ipod/i.test(platform);
5231}
5332
54function normalizeKeyToken(token: string): string {
55 if (token === "space") return " ";
56 if (token === "esc") return "escape";
57 return token;
58}
59
60function parseShortcutValue(value: string): ParsedShortcut | null {
61 const rawTokens = value.toLowerCase().split("+").map((token) => token.trim());
62 const tokens = [...rawTokens];
63 let trailingEmptyCount = 0;
64 while (tokens[tokens.length - 1] === "") {
65 trailingEmptyCount += 1;
66 tokens.pop();
67 }
68 if (trailingEmptyCount > 0) {
69 tokens.push("+");
70 }
71 if (tokens.some((token) => token.length === 0)) {
72 return null;
73 }
74 if (tokens.length === 0) return null;
75
76 let key: string | null = null;
77 let metaKey = false;
78 let ctrlKey = false;
79 let shiftKey = false;
80 let altKey = false;
81 let modKey = false;
82
83 for (const token of tokens) {
84 switch (token) {
85 case "cmd":
86 case "meta":
87 metaKey = true;
88 break;
89 case "ctrl":
90 case "control":
91 ctrlKey = true;
92 break;
93 case "shift":
94 shiftKey = true;
95 break;
96 case "alt":
97 case "option":
98 altKey = true;
99 break;
100 case "mod":
101 modKey = true;
102 break;
103 default: {
104 if (key !== null) {
105 return null;
106 }
107 key = normalizeKeyToken(token);
108 }
109 }
110 }
111
112 if (key === null) return null;
113
114 return {
115 key,
116 metaKey,
117 ctrlKey,
118 shiftKey,
119 altKey,
120 modKey,
121 };
122}
123
12433function normalizeEventKey(key: string): string {
12534 const normalized = key.toLowerCase();
12635 if (normalized === "esc") return "escape";
12736 return normalized;
12837}
12938
130function matchesShortcutValue(
39function matchesShortcut(
13140 event: ShortcutEventLike,
132 shortcutValue: string,
41 shortcut: KeybindingShortcut,
13342 platform = navigator.platform,
13443): boolean {
135 const parsed = parseShortcutValue(shortcutValue);
136 if (!parsed) return false;
137
13844 const key = normalizeEventKey(event.key);
139 if (key !== parsed.key) return false;
45 if (key !== shortcut.key) return false;
14046
14147 const useMetaForMod = isMacPlatform(platform);
142 const expectedMeta = parsed.metaKey || (parsed.modKey && useMetaForMod);
143 const expectedCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod);
48 const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod);
49 const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod);
14450 return (
14551 event.metaKey === expectedMeta &&
14652 event.ctrlKey === expectedCtrl &&
147 event.shiftKey === parsed.shiftKey &&
148 event.altKey === parsed.altKey
53 event.shiftKey === shortcut.shiftKey &&
54 event.altKey === shortcut.altKey
14955 );
15056}
15157
152function tokenizeWhenExpression(expression: string): WhenToken[] | null {
153 const tokens: WhenToken[] = [];
154 let index = 0;
155
156 while (index < expression.length) {
157 const current = expression[index];
158 if (!current) break;
159
160 if (/\s/.test(current)) {
161 index += 1;
162 continue;
163 }
164 if (expression.startsWith("&&", index)) {
165 tokens.push({ type: "and" });
166 index += 2;
167 continue;
168 }
169 if (expression.startsWith("||", index)) {
170 tokens.push({ type: "or" });
171 index += 2;
172 continue;
173 }
174 if (current === "!") {
175 tokens.push({ type: "not" });
176 index += 1;
177 continue;
178 }
179 if (current === "(") {
180 tokens.push({ type: "lparen" });
181 index += 1;
182 continue;
183 }
184 if (current === ")") {
185 tokens.push({ type: "rparen" });
186 index += 1;
187 continue;
188 }
189
190 const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index));
191 if (!identifier) {
192 return null;
193 }
194 tokens.push({ type: "identifier", value: identifier[0] });
195 index += identifier[0].length;
196 }
197
198 return tokens;
58function resolvePlatform(options: ShortcutMatchOptions | undefined): string {
59 return options?.platform ?? navigator.platform;
19960}
20061
201function parseWhenExpression(expression: string): WhenNode | null {
202 const tokens = tokenizeWhenExpression(expression);
203 if (!tokens || tokens.length === 0) return null;
204 let index = 0;
205
206 const parsePrimary = (): WhenNode | null => {
207 const token = tokens[index];
208 if (!token) return null;
209
210 if (token.type === "identifier") {
211 index += 1;
212 return { type: "identifier", name: token.value };
213 }
214
215 if (token.type === "lparen") {
216 index += 1;
217 const expressionNode = parseOr();
218 const closeToken = tokens[index];
219 if (!expressionNode || !closeToken || closeToken.type !== "rparen") {
220 return null;
221 }
222 index += 1;
223 return expressionNode;
224 }
225
226 return null;
227 };
228
229 const parseUnary = (): WhenNode | null => {
230 const token = tokens[index];
231 if (token?.type === "not") {
232 index += 1;
233 const node = parseUnary();
234 if (!node) return null;
235 return { type: "not", node };
236 }
237 return parsePrimary();
238 };
239
240 const parseAnd = (): WhenNode | null => {
241 let left = parseUnary();
242 if (!left) return null;
243
244 while (tokens[index]?.type === "and") {
245 index += 1;
246 const right = parseUnary();
247 if (!right) return null;
248 left = { type: "and", left, right };
249 }
250
251 return left;
252 };
253
254 const parseOr = (): WhenNode | null => {
255 let left = parseAnd();
256 if (!left) return null;
257
258 while (tokens[index]?.type === "or") {
259 index += 1;
260 const right = parseAnd();
261 if (!right) return null;
262 left = { type: "or", left, right };
263 }
264
265 return left;
62function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatchContext {
63 return {
64 terminalFocus: false,
65 terminalOpen: false,
66 ...options?.context,
26667 };
267
268 const ast = parseOr();
269 if (!ast || index !== tokens.length) return null;
270 return ast;
27168}
27269
273function evaluateWhenNode(node: WhenNode, context: ShortcutMatchContext): boolean {
70function evaluateWhenNode(node: KeybindingWhenNode, context: ShortcutMatchContext): boolean {
27471 switch (node.type) {
27572 case "identifier":
27673 if (node.name === "true") return true;
......@@ -285,32 +82,12 @@ function evaluateWhenNode(node: WhenNode, context: ShortcutMatchContext): boolea
28582 }
28683}
28784
288function matchesWhenExpression(when: string | undefined, context: ShortcutMatchContext): boolean {
289 if (!when) return true;
290 const normalized = when.trim();
291 if (normalized.length === 0) return true;
292
293 const cached = WHEN_AST_CACHE.get(normalized);
294 if (cached === undefined) {
295 const parsed = parseWhenExpression(normalized);
296 WHEN_AST_CACHE.set(normalized, parsed);
297 if (!parsed) return false;
298 return evaluateWhenNode(parsed, context);
299 }
300 if (!cached) return false;
301 return evaluateWhenNode(cached, context);
302}
303
304function resolvePlatform(options: ShortcutMatchOptions | undefined): string {
305 return options?.platform ?? navigator.platform;
306}
307
308function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatchContext {
309 return {
310 terminalFocus: false,
311 terminalOpen: false,
312 ...options?.context,
313 };
85function matchesWhenClause(
86 whenAst: ResolvedKeybindings[number]["whenAst"],
87 context: ShortcutMatchContext,
88): boolean {
89 if (!whenAst) return true;
90 return evaluateWhenNode(whenAst, context);
31491}
31592
31693function matchesCommandShortcut(
......@@ -333,8 +110,8 @@ function resolveShortcutCommand(
333110 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
334111 const binding = keybindings[index];
335112 if (!binding) continue;
336 if (!matchesWhenExpression(binding.when, context)) continue;
337 if (!matchesShortcutValue(event, binding.key, platform)) continue;
113 if (!matchesWhenClause(binding.whenAst, context)) continue;
114 if (!matchesShortcut(event, binding.shortcut, platform)) continue;
338115 return binding.command;
339116 }
340117 return null;
......@@ -351,18 +128,13 @@ function formatShortcutKeyLabel(key: string): string {
351128 return key.slice(0, 1).toUpperCase() + key.slice(1);
352129}
353130
354export function formatShortcutLabel(shortcutValue: string, platform = navigator.platform): string {
355 const parsed = parseShortcutValue(shortcutValue);
356 if (!parsed) {
357 return shortcutValue;
358 }
359
360 const keyLabel = formatShortcutKeyLabel(parsed.key);
131export function formatShortcutLabel(shortcut: KeybindingShortcut, platform = navigator.platform): string {
132 const keyLabel = formatShortcutKeyLabel(shortcut.key);
361133 const useMetaForMod = isMacPlatform(platform);
362 const showMeta = parsed.metaKey || (parsed.modKey && useMetaForMod);
363 const showCtrl = parsed.ctrlKey || (parsed.modKey && !useMetaForMod);
364 const showAlt = parsed.altKey;
365 const showShift = parsed.shiftKey;
134 const showMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod);
135 const showCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod);
136 const showAlt = shortcut.altKey;
137 const showShift = shortcut.shiftKey;
366138
367139 if (useMetaForMod) {
368140 return `${showCtrl ? "\u2303" : ""}${showAlt ? "\u2325" : ""}${showShift ? "\u21e7" : ""}${showMeta ? "\u2318" : ""}${keyLabel}`;
......@@ -385,7 +157,7 @@ export function shortcutLabelForCommand(
385157 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
386158 const binding = keybindings[index];
387159 if (!binding || binding.command !== command) continue;
388 return formatShortcutLabel(binding.key, platform);
160 return formatShortcutLabel(binding.shortcut, platform);
389161 }
390162 return null;
391163}
packages/contracts/src/index.ts+1
......@@ -6,6 +6,7 @@ export * from "./provider";
66export * from "./model";
77export * from "./ws";
88export * from "./keybindings";
9export * from "./keybindingsWhen";
910export * from "./server";
1011export * from "./project";
1112export * from "./git";
packages/contracts/src/keybindings.test.ts created+78
......@@ -0,0 +1,78 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 keybindingRuleSchema,
5 keybindingsConfigSchema,
6 resolvedKeybindingRuleSchema,
7 resolvedKeybindingsConfigSchema,
8} from "./keybindings";
9
10describe("keybindings contracts", () => {
11 it("parses keybinding rules", () => {
12 const parsed = keybindingRuleSchema.parse({
13 key: "mod+j",
14 command: "terminal.toggle",
15 });
16 expect(parsed.command).toBe("terminal.toggle");
17 });
18
19 it("rejects invalid command values", () => {
20 expect(() =>
21 keybindingRuleSchema.parse({
22 key: "mod+j",
23 command: "invalid.command" as unknown as "terminal.toggle",
24 }),
25 ).toThrow();
26 });
27
28 it("parses keybindings array payload", () => {
29 const parsed = keybindingsConfigSchema.parse([
30 { key: "mod+j", command: "terminal.toggle" },
31 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
32 ]);
33 expect(parsed).toHaveLength(2);
34 });
35
36 it("parses resolved keybinding rules", () => {
37 const parsed = resolvedKeybindingRuleSchema.parse({
38 key: "mod+d",
39 command: "terminal.split",
40 when: "terminalOpen && !terminalFocus",
41 shortcut: {
42 key: "d",
43 metaKey: false,
44 ctrlKey: false,
45 shiftKey: false,
46 altKey: false,
47 modKey: true,
48 },
49 whenAst: {
50 type: "and",
51 left: { type: "identifier", name: "terminalOpen" },
52 right: {
53 type: "not",
54 node: { type: "identifier", name: "terminalFocus" },
55 },
56 },
57 });
58 expect(parsed.shortcut.key).toBe("d");
59 });
60
61 it("parses resolved keybindings arrays", () => {
62 const parsed = resolvedKeybindingsConfigSchema.parse([
63 {
64 key: "mod+j",
65 command: "terminal.toggle",
66 shortcut: {
67 key: "j",
68 metaKey: false,
69 ctrlKey: false,
70 shiftKey: false,
71 altKey: false,
72 modKey: true,
73 },
74 },
75 ]);
76 expect(parsed).toHaveLength(1);
77 });
78});
packages/contracts/src/keybindings.ts+46
......@@ -1,4 +1,5 @@
11import { z } from "zod";
2import type { KeybindingWhenNode } from "./keybindingsWhen";
23
34export const keybindingCommandSchema = z.enum([
45 "terminal.toggle",
......@@ -19,6 +20,51 @@ export const keybindingRuleSchema = z.object({
1920
2021export const keybindingsConfigSchema = z.array(keybindingRuleSchema).max(256);
2122
23export const keybindingShortcutSchema = z.object({
24 key: z.string().trim().min(1).max(32),
25 metaKey: z.boolean(),
26 ctrlKey: z.boolean(),
27 shiftKey: z.boolean(),
28 altKey: z.boolean(),
29 modKey: z.boolean(),
30});
31
32export const keybindingWhenNodeSchema: z.ZodType<KeybindingWhenNode> = z.lazy(() =>
33 z.union([
34 z.object({
35 type: z.literal("identifier"),
36 name: z.string().min(1),
37 }),
38 z.object({
39 type: z.literal("not"),
40 node: keybindingWhenNodeSchema,
41 }),
42 z.object({
43 type: z.literal("and"),
44 left: keybindingWhenNodeSchema,
45 right: keybindingWhenNodeSchema,
46 }),
47 z.object({
48 type: z.literal("or"),
49 left: keybindingWhenNodeSchema,
50 right: keybindingWhenNodeSchema,
51 }),
52 ]),
53);
54
55export const resolvedKeybindingRuleSchema = z.object({
56 key: keybindingValueSchema,
57 command: keybindingCommandSchema,
58 when: keybindingWhenSchema.optional(),
59 shortcut: keybindingShortcutSchema,
60 whenAst: keybindingWhenNodeSchema.optional(),
61});
62
63export const resolvedKeybindingsConfigSchema = z.array(resolvedKeybindingRuleSchema).max(256);
64
2265export type KeybindingCommand = z.infer<typeof keybindingCommandSchema>;
2366export type KeybindingRule = z.infer<typeof keybindingRuleSchema>;
2467export type KeybindingsConfig = z.infer<typeof keybindingsConfigSchema>;
68export type KeybindingShortcut = z.infer<typeof keybindingShortcutSchema>;
69export type ResolvedKeybindingRule = z.infer<typeof resolvedKeybindingRuleSchema>;
70export type ResolvedKeybindingsConfig = z.infer<typeof resolvedKeybindingsConfigSchema>;
packages/contracts/src/keybindingsWhen.ts created+5
......@@ -0,0 +1,5 @@
1export type KeybindingWhenNode =
2 | { type: "identifier"; name: string }
3 | { type: "not"; node: KeybindingWhenNode }
4 | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode }
5 | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode };
packages/contracts/src/server.ts+2-2
......@@ -1,9 +1,9 @@
11import { z } from "zod";
2import { keybindingsConfigSchema } from "./keybindings";
2import { resolvedKeybindingsConfigSchema } from "./keybindings";
33
44export const serverConfigSchema = z.object({
55 cwd: z.string().min(1),
6 keybindings: keybindingsConfigSchema.default([]),
6 keybindings: resolvedKeybindingsConfigSchema.default([]),
77});
88
99export type ServerConfig = z.infer<typeof serverConfigSchema>;