Skip to content
Source
Sign inSign up
Open History to see the last commit

keybindings.ts

410 lines12.7 KB

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