1import {
2 type KeybindingRule,
3 type KeybindingShortcut,
4 type KeybindingWhenNode,
5 MAX_KEYBINDINGS_COUNT,
6 MAX_WHEN_EXPRESSION_DEPTH,
7 MODEL_PICKER_JUMP_KEYBINDING_COMMANDS,
8 type ResolvedKeybindingRule,
9 type ResolvedKeybindingsConfig,
10 THREAD_JUMP_KEYBINDING_COMMANDS,
11} from "@t3tools/contracts";
12
13export interface ShortcutEventLike {
14 getModifierState?: (key: "AltGraph") => boolean;
15 type?: string;
16 code?: string;
17 key: string;
18 repeat?: boolean;
19 metaKey: boolean;
20 ctrlKey: boolean;
21 shiftKey: boolean;
22 altKey: boolean;
23}
24
25export interface ShortcutModifierStateLike {
26 metaKey: boolean;
27 ctrlKey: boolean;
28 shiftKey: boolean;
29 altKey: boolean;
30}
31
32export const EVENT_CODE_SHORTCUT_KEYS: Readonly<Record<string, string>> = {
33 Backquote: "`",
34 Backslash: "\\",
35 BracketLeft: "[",
36 BracketRight: "]",
37 Comma: ",",
38 Digit0: "0",
39 Digit1: "1",
40 Digit2: "2",
41 Digit3: "3",
42 Digit4: "4",
43 Digit5: "5",
44 Digit6: "6",
45 Digit7: "7",
46 Digit8: "8",
47 Digit9: "9",
48 Equal: "=",
49 Minus: "-",
50 Period: ".",
51 Quote: "'",
52 Semicolon: ";",
53 Slash: "/",
54};
55
56export function isMacPlatform(platform: string): boolean {
57 return /mac|iphone|ipad|ipod/i.test(platform);
58}
59
60export function normalizeEventKey(key: string): string {
61 const normalized = key.toLowerCase();
62 if (normalized === "esc") return "escape";
63 return normalized;
64}
65
66export function shortcutKeyFromEvent(event: Pick<ShortcutEventLike, "key" | "code">): string {
67 const layoutKey = normalizeEventKey(event.key);
68 if (/^[a-z]$/.test(layoutKey)) return layoutKey;
69 const physicalKey = event.code ? EVENT_CODE_SHORTCUT_KEYS[event.code] : undefined;
70 return physicalKey ?? layoutKey;
71}
72
73export function resolveEventKeys(event: ShortcutEventLike): Set<string> {
74 const layoutKey = normalizeEventKey(event.key);
75 const keys = new Set([layoutKey]);
76 const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1];
77 if (letterCode && !/^[a-z]$/.test(layoutKey)) {
78 keys.add(letterCode.toLowerCase());
79 }
80 keys.add(shortcutKeyFromEvent(event));
81 return keys;
82}
83
84export function matchesKeybindingShortcutModifiers(
85 event: ShortcutModifierStateLike,
86 shortcut: KeybindingShortcut,
87 platform: string,
88): boolean {
89 const useMetaForMod = isMacPlatform(platform);
90 const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod);
91 const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod);
92 return (
93 event.metaKey === expectedMeta &&
94 event.ctrlKey === expectedCtrl &&
95 event.shiftKey === shortcut.shiftKey &&
96 event.altKey === shortcut.altKey
97 );
98}
99
100export function matchesKeybindingShortcut(
101 event: ShortcutEventLike,
102 shortcut: KeybindingShortcut,
103 platform: string,
104): boolean {
105 if (
106 !isMacPlatform(platform) &&
107 event.getModifierState?.("AltGraph") &&
108 !/^[a-z0-9]$/i.test(event.key)
109 )
110 return false;
111 if (!matchesKeybindingShortcutModifiers(event, shortcut, platform)) return false;
112 return resolveEventKeys(event).has(shortcut.key);
113}
114
115type WhenToken =
116 | { type: "identifier"; value: string }
117 | { type: "not" }
118 | { type: "and" }
119 | { type: "or" }
120 | { type: "lparen" }
121 | { type: "rparen" };
122
123export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
124 { key: "mod+b", command: "sidebar.toggle" },
125 { key: "mod+[", command: "navigation.back", when: "!terminalFocus" },
126 { key: "mod+]", command: "navigation.forward", when: "!terminalFocus" },
127 { key: "mod+j", command: "terminal.toggle" },
128 { key: "mod+alt+b", command: "rightPanel.toggle" },
129 { key: "mod+shift+b", command: "threadPanel.toggle", when: "!terminalFocus" },
130 { key: "mod+t", command: "rightPanel.new", when: "rightPanelOpen && !terminalFocus" },
131 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
132 { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" },
133 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
134 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },
135 { key: "mod+w", command: "rightPanel.close", when: "!terminalFocus" },
136 { key: "mod+shift+t", command: "view.reopenClosed" },
137 { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
138 { key: "mod+shift+j", command: "preview.toggle" },
139 { key: "mod+r", command: "preview.refresh", when: "previewFocus" },
140 { key: "mod+l", command: "preview.focusUrl", when: "previewFocus" },
141 { key: "mod+=", command: "preview.zoomIn", when: "previewFocus" },
142 { key: "mod++", command: "preview.zoomIn", when: "previewFocus" },
143 { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" },
144 { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" },
145 { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },
146 { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" },
147 { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" },
148 { key: "mod+u", command: "usage.open", when: "!terminalFocus" },
149 { key: "mod+alt+a", command: "theme.select", when: "!terminalFocus" },
150 { key: "mod+alt+shift+a", command: "appearance.cycle", when: "!terminalFocus" },
151 { key: "mod+alt+shift+t", command: "themeEditor.toggle" },
152 { key: "mod+s", command: "composer.stash", when: "!terminalFocus" },
153 { key: "mod+shift+enter", command: "thread.steerQueuedMessage", when: "!terminalFocus" },
154 { key: "alt+arrowup", command: "thread.editQueuedMessage", when: "composerFocus" },
155 { key: "mod+enter", command: "composer.sendAlternate", when: "composerFocus && turnRunning" },
156 {
157 key: "mod+enter",
158 command: "composer.sendBackground",
159 when: "composerFocus && draftThreadRoute",
160 },
161 {
162 key: "mod+alt+enter",
163 command: "composer.sendBackground",
164 when: "composerFocus && draftThreadRoute",
165 },
166 {
167 key: "mod+alt+enter",
168 command: "composer.sendAndNewThread",
169 when: "composerFocus && !draftThreadRoute",
170 },
171 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },
172 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
173 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
174 { key: "mod+alt+n", command: "chat.newWithoutProject", when: "!terminalFocus" },
175 { key: "mod+f", command: "chat.find", when: "!terminalFocus && !previewFocus" },
176 { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" },
177 { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" },
178 { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" },
179 { key: "mod+shift+a", command: "composer.mode", when: "!terminalFocus" },
180 { key: "mod+shift+x", command: "composer.workspace", when: "!terminalFocus" },
181 { key: "mod+shift+g", command: "composer.branch", when: "!terminalFocus" },
182 { key: "mod+shift+l", command: "composer.previousWorktree", when: "!terminalFocus" },
183 { key: "mod+shift+k", command: "pullRequest.copyNumber", when: "!terminalFocus" },
184 { key: "mod+shift+arrowup", command: "modelPicker.previousProvider", when: "modelPickerOpen" },
185 { key: "mod+shift+arrowdown", command: "modelPicker.nextProvider", when: "modelPickerOpen" },
186 { key: "mod+o", command: "editor.openFavorite" },
187 { key: "mod+shift+[", command: "thread.previous" },
188 { key: "mod+shift+]", command: "thread.next" },
189 { key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" },
190 { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" },
191 { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" },
192 { key: "mod+z", command: "thread.undo", when: "!terminalFocus && !editableFocus" },
193 ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({
194 key: `mod+${index + 1}`,
195 command,
196 when: "!modelPickerOpen && isDesktop",
197 })),
198 ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({
199 key: `mod+${index + 1}`,
200 command,
201 when: "modelPickerOpen && isDesktop",
202 })),
203 { key: "c", command: "usage.cost", when: "usagePageOpen" },
204 { key: "t", command: "usage.tokens", when: "usagePageOpen" },
205 { key: "mod+shift+1", command: "usage.period.day", when: "usagePageOpen" },
206 { key: "mod+shift+2", command: "usage.period.week", when: "usagePageOpen" },
207 { key: "mod+shift+3", command: "usage.period.month", when: "usagePageOpen" },
208 { key: "mod+shift+4", command: "usage.period.quarter", when: "usagePageOpen" },
209];
210
211function normalizeKeyToken(token: string): string {
212 if (token === "space") return " ";
213 if (token === "esc") return "escape";
214 return token;
215}
216
217export function parseKeybindingShortcut(value: string): KeybindingShortcut | null {
218 const rawTokens = value
219 .toLowerCase()
220 .split("+")
221 .map((token) => token.trim());
222 const tokens = [...rawTokens];
223 let trailingEmptyCount = 0;
224 while (tokens[tokens.length - 1] === "") {
225 trailingEmptyCount += 1;
226 tokens.pop();
227 }
228 if (trailingEmptyCount > 0) {
229 tokens.push("+");
230 }
231 if (tokens.some((token) => token.length === 0)) {
232 return null;
233 }
234 if (tokens.length === 0) return null;
235
236 let key: string | null = null;
237 let metaKey = false;
238 let ctrlKey = false;
239 let shiftKey = false;
240 let altKey = false;
241 let modKey = false;
242
243 for (const token of tokens) {
244 switch (token) {
245 case "cmd":
246 case "meta":
247 metaKey = true;
248 break;
249 case "ctrl":
250 case "control":
251 ctrlKey = true;
252 break;
253 case "shift":
254 shiftKey = true;
255 break;
256 case "alt":
257 case "option":
258 altKey = true;
259 break;
260 case "mod":
261 modKey = true;
262 break;
263 default: {
264 if (key !== null) return null;
265 key = normalizeKeyToken(token);
266 }
267 }
268 }
269
270 if (key === null) return null;
271 return {
272 key,
273 metaKey,
274 ctrlKey,
275 shiftKey,
276 altKey,
277 modKey,
278 };
279}
280
281function tokenizeWhenExpression(expression: string): WhenToken[] | null {
282 const tokens: WhenToken[] = [];
283 let index = 0;
284
285 while (index < expression.length) {
286 const current = expression[index];
287 if (!current) break;
288
289 if (/\s/.test(current)) {
290 index += 1;
291 continue;
292 }
293 if (expression.startsWith("&&", index)) {
294 tokens.push({ type: "and" });
295 index += 2;
296 continue;
297 }
298 if (expression.startsWith("||", index)) {
299 tokens.push({ type: "or" });
300 index += 2;
301 continue;
302 }
303 if (current === "!") {
304 tokens.push({ type: "not" });
305 index += 1;
306 continue;
307 }
308 if (current === "(") {
309 tokens.push({ type: "lparen" });
310 index += 1;
311 continue;
312 }
313 if (current === ")") {
314 tokens.push({ type: "rparen" });
315 index += 1;
316 continue;
317 }
318
319 const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index));
320 if (!identifier) {
321 return null;
322 }
323 tokens.push({ type: "identifier", value: identifier[0] });
324 index += identifier[0].length;
325 }
326
327 return tokens;
328}
329
330export function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null {
331 const tokens = tokenizeWhenExpression(expression);
332 if (!tokens || tokens.length === 0) return null;
333 let index = 0;
334
335 const parsePrimary = (depth: number): KeybindingWhenNode | null => {
336 if (depth > MAX_WHEN_EXPRESSION_DEPTH) {
337 return null;
338 }
339 const token = tokens[index];
340 if (!token) return null;
341
342 if (token.type === "identifier") {
343 index += 1;
344 return { type: "identifier", name: token.value };
345 }
346
347 if (token.type === "lparen") {
348 index += 1;
349 const expressionNode = parseOr(depth + 1);
350 const closeToken = tokens[index];
351 if (!expressionNode || !closeToken || closeToken.type !== "rparen") {
352 return null;
353 }
354 index += 1;
355 return expressionNode;
356 }
357
358 return null;
359 };
360
361 const parseUnary = (depth: number): KeybindingWhenNode | null => {
362 let notCount = 0;
363 while (tokens[index]?.type === "not") {
364 index += 1;
365 notCount += 1;
366 if (notCount > MAX_WHEN_EXPRESSION_DEPTH) {
367 return null;
368 }
369 }
370
371 let node = parsePrimary(depth);
372 if (!node) return null;
373
374 while (notCount > 0) {
375 node = { type: "not", node };
376 notCount -= 1;
377 }
378
379 return node;
380 };
381
382 const parseAnd = (depth: number): KeybindingWhenNode | null => {
383 let left = parseUnary(depth);
384 if (!left) return null;
385
386 while (tokens[index]?.type === "and") {
387 index += 1;
388 const right = parseUnary(depth);
389 if (!right) return null;
390 left = { type: "and", left, right };
391 }
392
393 return left;
394 };
395
396 const parseOr = (depth: number): KeybindingWhenNode | null => {
397 let left = parseAnd(depth);
398 if (!left) return null;
399
400 while (tokens[index]?.type === "or") {
401 index += 1;
402 const right = parseAnd(depth);
403 if (!right) return null;
404 left = { type: "or", left, right };
405 }
406
407 return left;
408 };
409
410 const ast = parseOr(0);
411 if (!ast || index !== tokens.length) return null;
412 return ast;
413}
414
415export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null {
416 const shortcut = parseKeybindingShortcut(rule.key);
417 if (!shortcut) return null;
418
419 if (rule.when !== undefined) {
420 const whenAst = parseKeybindingWhenExpression(rule.when);
421 if (!whenAst) return null;
422 return {
423 command: rule.command,
424 shortcut,
425 whenAst,
426 };
427 }
428
429 return {
430 command: rule.command,
431 shortcut,
432 };
433}
434
435export function compileResolvedKeybindingsConfig(
436 config: ReadonlyArray<KeybindingRule>,
437): ResolvedKeybindingsConfig {
438 const compiled: ResolvedKeybindingRule[] = [];
439 for (const rule of config) {
440 const result = compileResolvedKeybindingRule(rule);
441 if (result) {
442 compiled.push(result);
443 }
444 }
445 return compiled.slice(-MAX_KEYBINDINGS_COUNT);
446}
447
448export const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS);
449
450export function mergeWithDefaultKeybindings(
451 custom: ResolvedKeybindingsConfig,
452): ResolvedKeybindingsConfig {
453 if (custom.length === 0) {
454 return [...DEFAULT_RESOLVED_KEYBINDINGS];
455 }
456
457 const overriddenCommands = new Set(custom.map((binding) => binding.command));
458 const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter(
459 (binding) => !overriddenCommands.has(binding.command),
460 );
461 const merged = [...retainedDefaults, ...custom];
462
463 if (merged.length <= MAX_KEYBINDINGS_COUNT) {
464 return merged;
465 }
466
467 return merged.slice(-MAX_KEYBINDINGS_COUNT);
468}