Open History to see the last commit
1import {
2 STATIC_KEYBINDING_COMMANDS,
3 type KeybindingCommand,
4 type KeybindingShortcut,
5 type KeybindingWhenNode,
6 type ResolvedKeybindingRule,
7 type ResolvedKeybindingsConfig,
8} from "@t3tools/contracts";
9import {
10 DEFAULT_RESOLVED_KEYBINDINGS,
11 parseKeybindingShortcut,
12 parseKeybindingWhenExpression,
13 US_SHIFTED_TO_UNSHIFTED_KEYS,
14} from "@t3tools/shared/keybindings";
15
16import { shortcutConflictKey, shortcutKeyFromEvent } from "../../keybindings";
17import { isMacPlatform } from "../../lib/utils";
18import { METRIC_OPTIONS, WINDOW_OPTIONS } from "../usage/usageShortcuts";
19
20// Rank every usage command; mixing rank and alphabetical comparisons makes order input-dependent.
21const usageCommandOrder = new Map<KeybindingCommand, number>(
22 [
23 "usage.open" as const,
24 ...[...METRIC_OPTIONS, ...WINDOW_OPTIONS].map((option) => option.command),
25 ].map((command, index) => [command, index]),
26);
27
28const firstUsageCommand = METRIC_OPTIONS[0].command;
29
30function compareCommands(
31 left: KeybindingCommand,
32 right: KeybindingCommand,
33 key: (command: KeybindingCommand) => string,
34): number {
35 const leftRank = usageCommandOrder.get(left);
36 const rightRank = usageCommandOrder.get(right);
37 if (leftRank !== undefined && rightRank !== undefined) return leftRank - rightRank;
38 return key(leftRank === undefined ? left : firstUsageCommand).localeCompare(
39 key(rightRank === undefined ? right : firstUsageCommand),
40 );
41}
42
43export type KeybindingSource = "Default" | "Custom" | "Project";
44
45export interface KeybindingRow {
46 readonly id: string;
47 readonly command: KeybindingCommand;
48 readonly key: string;
49 readonly when: string;
50 readonly source: KeybindingSource;
51 readonly defaultKey: string | null;
52 readonly defaultWhen: string;
53 readonly binding: ResolvedKeybindingRule;
54 readonly conflicts: ReadonlyArray<string>;
55}
56
57export type WhenVariableOption = string;
58export type KeybindingCommandOption = KeybindingCommand;
59
60const CORE_WHEN_VARIABLES = [
61 "terminalFocus",
62 "terminalOpen",
63 "previewOpen",
64 "isWeb",
65 "isDesktop",
66 "true",
67 "false",
68] as const;
69
70const DEFAULT_WHEN_VARIABLES = new Set<string>(CORE_WHEN_VARIABLES);
71for (const binding of DEFAULT_RESOLVED_KEYBINDINGS) {
72 collectWhenIdentifiersFromNode(binding.whenAst, DEFAULT_WHEN_VARIABLES);
73}
74
75export const DEFAULT_WHEN_VARIABLE =
76 [...DEFAULT_WHEN_VARIABLES].find(
77 (identifier) => identifier !== "true" && identifier !== "false",
78 ) ?? "terminalFocus";
79const KNOWN_WHEN_VARIABLES = new Set(DEFAULT_WHEN_VARIABLES);
80
81export function shortcutToKeybindingInput(shortcut: KeybindingShortcut): string {
82 const parts: string[] = [];
83 if (shortcut.modKey) parts.push("mod");
84 if (shortcut.metaKey) parts.push("meta");
85 if (shortcut.ctrlKey) parts.push("ctrl");
86 if (shortcut.altKey) parts.push("alt");
87 if (shortcut.shiftKey) parts.push("shift");
88 parts.push(shortcut.key === " " ? "space" : shortcut.key === "escape" ? "esc" : shortcut.key);
89 return parts.join("+");
90}
91
92export function whenAstToExpression(node: KeybindingWhenNode | undefined): string {
93 if (!node) return "";
94 switch (node.type) {
95 case "identifier":
96 return node.name;
97 case "not":
98 return `!${wrapWhenExpression(node.node)}`;
99 case "and":
100 return `${wrapWhenExpression(node.left)} && ${wrapWhenExpression(node.right)}`;
101 case "or":
102 return `${wrapWhenExpression(node.left)} || ${wrapWhenExpression(node.right)}`;
103 }
104}
105
106export function whenNodeRemoveLabel(node: KeybindingWhenNode, depth: number): string {
107 if (depth === 0) return "Clear all conditions";
108 if (node.type === "identifier" || (node.type === "not" && node.node.type === "identifier")) {
109 return "Remove condition";
110 }
111 return "Remove group and its conditions";
112}
113
114function wrapWhenExpression(node: KeybindingWhenNode): string {
115 if (node.type === "identifier" || node.type === "not") return whenAstToExpression(node);
116 return `(${whenAstToExpression(node)})`;
117}
118
119export function parseWhenExpressionDraft(
120 expression: string,
121): { ok: true; value: KeybindingWhenNode | undefined } | { ok: false; message: string } {
122 const trimmed = expression.trim();
123 if (trimmed.length === 0) return { ok: true, value: undefined };
124
125 const ast = parseKeybindingWhenExpression(trimmed);
126 if (!ast) {
127 return {
128 ok: false,
129 message: "Use variables with !, &&, ||, and parentheses.",
130 };
131 }
132
133 return { ok: true, value: ast };
134}
135
136function sourceForBinding(binding: ResolvedKeybindingRule): KeybindingSource {
137 if (String(binding.command).startsWith("script.")) {
138 return "Project";
139 }
140
141 const bindingKey = shortcutToKeybindingInput(binding.shortcut);
142 const bindingWhen = whenAstToExpression(binding.whenAst);
143 const isDefault = DEFAULT_RESOLVED_KEYBINDINGS.some(
144 (entry) =>
145 entry.command === binding.command &&
146 shortcutToKeybindingInput(entry.shortcut) === bindingKey &&
147 whenAstToExpression(entry.whenAst) === bindingWhen,
148 );
149
150 return isDefault ? "Default" : "Custom";
151}
152
153function defaultBindingForBinding(
154 binding: ResolvedKeybindingRule,
155): ResolvedKeybindingRule | undefined {
156 const bindingKey = shortcutToKeybindingInput(binding.shortcut);
157 const bindingWhen = whenAstToExpression(binding.whenAst);
158
159 return (
160 DEFAULT_RESOLVED_KEYBINDINGS.find(
161 (entry) =>
162 entry.command === binding.command &&
163 shortcutToKeybindingInput(entry.shortcut) === bindingKey &&
164 whenAstToExpression(entry.whenAst) === bindingWhen,
165 ) ??
166 DEFAULT_RESOLVED_KEYBINDINGS.find(
167 (entry) =>
168 entry.command === binding.command && whenAstToExpression(entry.whenAst) === bindingWhen,
169 ) ??
170 DEFAULT_RESOLVED_KEYBINDINGS.find((entry) => entry.command === binding.command)
171 );
172}
173
174function keybindingRowId(command: KeybindingCommand, key: string, when: string): string {
175 return `${command}\u0000${key}\u0000${when}`;
176}
177
178function conflictsWithWhen(leftWhen: string, rightWhen: string): boolean {
179 return leftWhen.length === 0 || rightWhen.length === 0 || leftWhen === rightWhen;
180}
181
182export function keybindingConflictLabels(
183 rows: ReadonlyArray<KeybindingRow>,
184 input: { readonly rowId: string; readonly key: string; readonly when: string },
185 platform = navigator.platform,
186): ReadonlyArray<string> {
187 if (input.key.trim().length === 0) return [];
188 const shortcut = parseKeybindingShortcut(input.key);
189 if (!shortcut) return [];
190 // Compare resolved chords so mod+k and ctrl+k collide where mod is Ctrl.
191 const conflictKey = shortcutConflictKey(shortcut, platform);
192 const conflicts: Array<string> = [];
193 for (const candidate of rows) {
194 if (
195 candidate.id !== input.rowId &&
196 shortcutConflictKey(candidate.binding.shortcut, platform) === conflictKey &&
197 conflictsWithWhen(candidate.when, input.when)
198 ) {
199 conflicts.push(commandLabel(candidate.command));
200 }
201 }
202 return [...new Set(conflicts)].toSorted();
203}
204
205export interface KeybindingGroup {
206 readonly id: string;
207 readonly title: string;
208 readonly rows: ReadonlyArray<KeybindingRow>;
209}
210
211const KEYBINDING_GROUPS = [
212 {
213 id: "navigation",
214 title: "Navigation",
215 prefixes: ["sidebar", "rightPanel", "commandPalette", "filePicker", "projectSearch", "editor"],
216 },
217 { id: "threads", title: "Threads", prefixes: ["thread", "chat", "pullRequest"] },
218 { id: "composer", title: "Composer", prefixes: ["composer", "modelPicker"] },
219 { id: "terminal", title: "Terminal", prefixes: ["terminal"] },
220 { id: "preview", title: "Preview & diff", prefixes: ["preview", "diff"] },
221 { id: "appearance", title: "Appearance", prefixes: ["theme", "appearance", "themeEditor"] },
222 { id: "scripts", title: "Project scripts", prefixes: ["script"] },
223] as const;
224const OTHER_KEYBINDING_GROUP = { id: "other", title: "Other" } as const;
225
226function keybindingGroupFor(command: KeybindingCommand): { id: string; title: string } {
227 const prefix = String(command).split(".")[0] ?? "";
228 return (
229 KEYBINDING_GROUPS.find((group) => (group.prefixes as ReadonlyArray<string>).includes(prefix)) ??
230 OTHER_KEYBINDING_GROUP
231 );
232}
233
234export function groupKeybindingRows(
235 rows: ReadonlyArray<KeybindingRow>,
236): ReadonlyArray<KeybindingGroup> {
237 const rowsByGroup = new Map<string, Array<KeybindingRow>>();
238 for (const row of rows) {
239 const group = keybindingGroupFor(row.command);
240 const bucket = rowsByGroup.get(group.id);
241 if (bucket) bucket.push(row);
242 else rowsByGroup.set(group.id, [row]);
243 }
244 return [...KEYBINDING_GROUPS, OTHER_KEYBINDING_GROUP].flatMap((group) => {
245 const groupRows = rowsByGroup.get(group.id);
246 return groupRows ? [{ id: group.id, title: group.title, rows: groupRows }] : [];
247 });
248}
249
250export function buildKeybindingRows(
251 keybindings: ResolvedKeybindingsConfig,
252 platform = navigator.platform,
253): ReadonlyArray<KeybindingRow> {
254 const rowsById = new Map<string, KeybindingRow>();
255 for (const binding of keybindings) {
256 const defaultBinding = defaultBindingForBinding(binding);
257 const key = shortcutToKeybindingInput(binding.shortcut);
258 const when = whenAstToExpression(binding.whenAst);
259 const id = keybindingRowId(binding.command, key, when);
260 rowsById.set(id, {
261 id,
262 command: binding.command,
263 key,
264 when,
265 source: sourceForBinding(binding),
266 defaultKey: defaultBinding ? shortcutToKeybindingInput(defaultBinding.shortcut) : null,
267 defaultWhen: whenAstToExpression(defaultBinding?.whenAst),
268 binding,
269 conflicts: [],
270 });
271 }
272 const rows = [...rowsById.values()];
273
274 const rowsWithConflicts = rows.map((row) => {
275 const conflicts = keybindingConflictLabels(
276 rows,
277 {
278 rowId: row.id,
279 key: row.key,
280 when: row.when,
281 },
282 platform,
283 );
284 return conflicts.length > 0
285 ? Object.assign({}, row, { conflicts: [...new Set(conflicts)].toSorted() })
286 : row;
287 });
288
289 rowsWithConflicts.sort((left, right) => {
290 const commandCompare = compareCommands(left.command, right.command, (command) => command);
291 if (commandCompare !== 0) return commandCompare;
292 return left.key.localeCompare(right.key);
293 });
294
295 return rowsWithConflicts;
296}
297
298export function filterKeybindingRows(
299 rows: ReadonlyArray<KeybindingRow>,
300 query: string,
301): ReadonlyArray<KeybindingRow> {
302 const normalizedQuery = query.trim().toLowerCase();
303 if (normalizedQuery.length === 0) return rows;
304 return rows.filter((row) => {
305 return (
306 row.command.toLowerCase().includes(normalizedQuery) ||
307 commandLabel(row.command).toLowerCase().includes(normalizedQuery) ||
308 row.key.toLowerCase().includes(normalizedQuery) ||
309 row.when.toLowerCase().includes(normalizedQuery) ||
310 row.source.toLowerCase().includes(normalizedQuery)
311 );
312 });
313}
314
315function collectWhenIdentifiersFromNode(
316 node: KeybindingWhenNode | undefined,
317 identifiers: Set<string>,
318): void {
319 if (!node) return;
320 switch (node.type) {
321 case "identifier":
322 identifiers.add(node.name);
323 return;
324 case "not":
325 collectWhenIdentifiersFromNode(node.node, identifiers);
326 return;
327 case "and":
328 case "or":
329 collectWhenIdentifiersFromNode(node.left, identifiers);
330 collectWhenIdentifiersFromNode(node.right, identifiers);
331 return;
332 }
333}
334
335export function isKnownWhenVariable(identifier: string): boolean {
336 return KNOWN_WHEN_VARIABLES.has(identifier);
337}
338
339export function unknownWhenVariables(node: KeybindingWhenNode | undefined): ReadonlyArray<string> {
340 const identifiers = new Set<string>();
341 collectWhenIdentifiersFromNode(node, identifiers);
342 return [...identifiers].filter((identifier) => !isKnownWhenVariable(identifier)).toSorted();
343}
344
345export function buildWhenVariableOptions(): ReadonlyArray<WhenVariableOption> {
346 return [...KNOWN_WHEN_VARIABLES].toSorted((left, right) => {
347 const leftCoreIndex = CORE_WHEN_VARIABLES.indexOf(left as (typeof CORE_WHEN_VARIABLES)[number]);
348 const rightCoreIndex = CORE_WHEN_VARIABLES.indexOf(
349 right as (typeof CORE_WHEN_VARIABLES)[number],
350 );
351 if (leftCoreIndex !== -1 || rightCoreIndex !== -1) {
352 return (
353 (leftCoreIndex === -1 ? Number.MAX_SAFE_INTEGER : leftCoreIndex) -
354 (rightCoreIndex === -1 ? Number.MAX_SAFE_INTEGER : rightCoreIndex)
355 );
356 }
357 return left.localeCompare(right);
358 });
359}
360
361export function buildKeybindingCommandOptions(
362 keybindings: ResolvedKeybindingsConfig,
363): ReadonlyArray<KeybindingCommandOption> {
364 const commands = new Set<KeybindingCommand>(STATIC_KEYBINDING_COMMANDS);
365 for (const binding of keybindings) {
366 commands.add(binding.command);
367 }
368 return [...commands].toSorted((left, right) => compareCommands(left, right, commandLabel));
369}
370
371export function commandLabel(command: KeybindingCommand): string {
372 if (command === "workspace.focusNext") return "Focus Next Workspace Area";
373 if (command === "workspace.focusPrevious") return "Focus Previous Workspace Area";
374 if (command === "composer.sendAlternate") return "Composer: Opposite Queue or Steer Action";
375 if (command === "composer.sendBackground") return "Composer: Start in Background";
376 if (command === "composer.sendAndNewThread") return "Composer: Send and Start New Thread";
377 if (command === "composer.dictation") return "Composer: Start or Finish Dictation";
378 if (command === "thread.steerQueuedMessage") return "Queue: Send First Queued Message";
379 if (command === "thread.editQueuedMessage") return "Queue: Edit Last Queued Message";
380 if (command === "thread.copyReference") return "Pull Request: Copy Link or Thread";
381 const usageMetric = METRIC_OPTIONS.find((option) => option.command === command);
382 if (usageMetric) return `Usage: ${usageMetric.label}`;
383 const usagePeriod = WINDOW_OPTIONS.find((option) => option.command === command);
384 if (usagePeriod) return `Usage: Period: ${usagePeriod.label}`;
385 if (command === "view.reopenClosed") return "Reopen Closed Tab";
386 if (command === "preview.focusUrl") return "Preview: Focus URL";
387 if (command === "preview.find") return "Preview: Find on Page";
388 const raw = String(command);
389 if (raw.startsWith("script.") && raw.endsWith(".run")) {
390 return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`;
391 }
392 return raw.split(".").map(titleCaseCommandSegment).join(": ");
393}
394
395function titleCaseCommandSegment(segment: string): string {
396 const words: Array<string> = [];
397 for (const part of segment.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[-_\s]+/)) {
398 if (part.length > 0) {
399 words.push(part.slice(0, 1).toUpperCase() + part.slice(1));
400 }
401 }
402 return words.join(" ");
403}
404
405function recordedLayoutShortcutKey(
406 event: Pick<KeyboardEvent, "key" | "code" | "shiftKey" | "altKey">,
407): string {
408 const physicalKey = shortcutKeyFromEvent(event);
409 const layoutKey = event.key.toLowerCase();
410 if (event.altKey || layoutKey.length !== 1) return physicalKey;
411 // Normalize only US shifted glyphs at their US position. Other layouts can
412 // produce the same glyph without Shift or at a different position.
413 const unshiftedKey = event.shiftKey ? US_SHIFTED_TO_UNSHIFTED_KEYS.get(layoutKey) : undefined;
414 return unshiftedKey === physicalKey ? physicalKey : layoutKey;
415}
416
417function normalizeShortcutKeyToken(key: string): string | null {
418 const normalized = key.toLowerCase();
419 if (
420 normalized === "meta" ||
421 normalized === "control" ||
422 normalized === "ctrl" ||
423 normalized === "shift" ||
424 normalized === "alt" ||
425 normalized === "option"
426 ) {
427 return null;
428 }
429 if (normalized === " ") return "space";
430 if (normalized === "escape") return "esc";
431 if (normalized === "arrowup") return "arrowup";
432 if (normalized === "arrowdown") return "arrowdown";
433 if (normalized === "arrowleft") return "arrowleft";
434 if (normalized === "arrowright") return "arrowright";
435 if (normalized.length === 1) return normalized;
436 if (/^f\d{1,2}$/.test(normalized)) return normalized;
437 if (normalized === "enter" || normalized === "tab" || normalized === "backspace") {
438 return normalized;
439 }
440 if (normalized === "delete" || normalized === "home" || normalized === "end") {
441 return normalized;
442 }
443 if (normalized === "pageup" || normalized === "pagedown") return normalized;
444 return null;
445}
446
447export function keybindingFromKeyboardEvent(
448 event: Pick<KeyboardEvent, "key" | "code" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey">,
449 platform: string,
450 options?: {
451 readonly layoutCharacter?: boolean;
452 },
453): string | null {
454 const keyToken = normalizeShortcutKeyToken(
455 options?.layoutCharacter ? recordedLayoutShortcutKey(event) : shortcutKeyFromEvent(event),
456 );
457 if (!keyToken) return null;
458
459 const parts: string[] = [];
460 if (isMacPlatform(platform)) {
461 if (event.metaKey) parts.push("mod");
462 if (event.ctrlKey) parts.push("ctrl");
463 } else {
464 if (event.ctrlKey) parts.push("mod");
465 if (event.metaKey) parts.push("meta");
466 }
467 if (event.altKey) parts.push("alt");
468 if (event.shiftKey) parts.push("shift");
469 parts.push(keyToken);
470 return parts.join("+");
471}