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 parseKeybindingWhenExpression,
12} from "@t3tools/shared/keybindings";
13
14import { shortcutKeyFromEvent } from "../../keybindings";
15import { isMacPlatform } from "../../lib/utils";
16import { METRIC_OPTIONS, WINDOW_OPTIONS } from "../usage/usageShortcuts";
17
18// Every usage.* command needs a rank. An unranked one falls back to the
19// alphabetical compare, which makes the comparator inconsistent and the order
20// depend on the input order.
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
30/**
31 * Orders commands by `key`, except Usage page commands, which sort as one
32 * block in page order where the first of them would sort. A total order, so
33 * adding a binding elsewhere cannot reshuffle the Usage rows.
34 */
35function compareCommands(
36 left: KeybindingCommand,
37 right: KeybindingCommand,
38 key: (command: KeybindingCommand) => string,
39): number {
40 const leftRank = usageCommandOrder.get(left);
41 const rightRank = usageCommandOrder.get(right);
42 if (leftRank !== undefined && rightRank !== undefined) return leftRank - rightRank;
43 return key(leftRank === undefined ? left : firstUsageCommand).localeCompare(
44 key(rightRank === undefined ? right : firstUsageCommand),
45 );
46}
47
48export type KeybindingSource = "Default" | "Custom" | "Project";
49
50export interface KeybindingRow {
51 readonly id: string;
52 readonly command: KeybindingCommand;
53 readonly key: string;
54 readonly when: string;
55 readonly source: KeybindingSource;
56 readonly defaultKey: string | null;
57 readonly defaultWhen: string;
58 readonly binding: ResolvedKeybindingRule;
59 readonly conflicts: ReadonlyArray<string>;
60}
61
62export type WhenVariableOption = string;
63export type KeybindingCommandOption = KeybindingCommand;
64
65const CORE_WHEN_VARIABLES = [
66 "terminalFocus",
67 "terminalOpen",
68 "isWeb",
69 "isDesktop",
70 "true",
71 "false",
72] as const;
73
74const DEFAULT_WHEN_VARIABLES = new Set<string>(CORE_WHEN_VARIABLES);
75for (const binding of DEFAULT_RESOLVED_KEYBINDINGS) {
76 collectWhenIdentifiersFromNode(binding.whenAst, DEFAULT_WHEN_VARIABLES);
77}
78
79export const DEFAULT_WHEN_VARIABLE =
80 [...DEFAULT_WHEN_VARIABLES].find(
81 (identifier) => identifier !== "true" && identifier !== "false",
82 ) ?? "terminalFocus";
83const KNOWN_WHEN_VARIABLES = new Set(DEFAULT_WHEN_VARIABLES);
84
85export function shortcutToKeybindingInput(shortcut: KeybindingShortcut): string {
86 const parts: string[] = [];
87 if (shortcut.modKey) parts.push("mod");
88 if (shortcut.metaKey) parts.push("meta");
89 if (shortcut.ctrlKey) parts.push("ctrl");
90 if (shortcut.altKey) parts.push("alt");
91 if (shortcut.shiftKey) parts.push("shift");
92 parts.push(shortcut.key === " " ? "space" : shortcut.key === "escape" ? "esc" : shortcut.key);
93 return parts.join("+");
94}
95
96export function whenAstToExpression(node: KeybindingWhenNode | undefined): string {
97 if (!node) return "";
98 switch (node.type) {
99 case "identifier":
100 return node.name;
101 case "not":
102 return `!${wrapWhenExpression(node.node)}`;
103 case "and":
104 return `${wrapWhenExpression(node.left)} && ${wrapWhenExpression(node.right)}`;
105 case "or":
106 return `${wrapWhenExpression(node.left)} || ${wrapWhenExpression(node.right)}`;
107 }
108}
109
110export function whenNodeRemoveLabel(node: KeybindingWhenNode, depth: number): string {
111 if (depth === 0) return "Clear all conditions";
112 if (node.type === "identifier" || (node.type === "not" && node.node.type === "identifier")) {
113 return "Remove condition";
114 }
115 return "Remove group and its conditions";
116}
117
118function wrapWhenExpression(node: KeybindingWhenNode): string {
119 if (node.type === "identifier" || node.type === "not") return whenAstToExpression(node);
120 return `(${whenAstToExpression(node)})`;
121}
122
123export function parseWhenExpressionDraft(
124 expression: string,
125): { ok: true; value: KeybindingWhenNode | undefined } | { ok: false; message: string } {
126 const trimmed = expression.trim();
127 if (trimmed.length === 0) return { ok: true, value: undefined };
128
129 const ast = parseKeybindingWhenExpression(trimmed);
130 if (!ast) {
131 return {
132 ok: false,
133 message: "Use variables with !, &&, ||, and parentheses.",
134 };
135 }
136
137 return { ok: true, value: ast };
138}
139
140function sourceForBinding(binding: ResolvedKeybindingRule): KeybindingSource {
141 if (String(binding.command).startsWith("script.")) {
142 return "Project";
143 }
144
145 const bindingKey = shortcutToKeybindingInput(binding.shortcut);
146 const bindingWhen = whenAstToExpression(binding.whenAst);
147 const isDefault = DEFAULT_RESOLVED_KEYBINDINGS.some(
148 (entry) =>
149 entry.command === binding.command &&
150 shortcutToKeybindingInput(entry.shortcut) === bindingKey &&
151 whenAstToExpression(entry.whenAst) === bindingWhen,
152 );
153
154 return isDefault ? "Default" : "Custom";
155}
156
157function defaultBindingForBinding(
158 binding: ResolvedKeybindingRule,
159): ResolvedKeybindingRule | undefined {
160 const bindingKey = shortcutToKeybindingInput(binding.shortcut);
161 const bindingWhen = whenAstToExpression(binding.whenAst);
162
163 return (
164 DEFAULT_RESOLVED_KEYBINDINGS.find(
165 (entry) =>
166 entry.command === binding.command &&
167 shortcutToKeybindingInput(entry.shortcut) === bindingKey &&
168 whenAstToExpression(entry.whenAst) === bindingWhen,
169 ) ??
170 DEFAULT_RESOLVED_KEYBINDINGS.find(
171 (entry) =>
172 entry.command === binding.command && whenAstToExpression(entry.whenAst) === bindingWhen,
173 ) ??
174 DEFAULT_RESOLVED_KEYBINDINGS.find((entry) => entry.command === binding.command)
175 );
176}
177
178function keybindingRowId(command: KeybindingCommand, key: string, when: string): string {
179 return `${command}\u0000${key}\u0000${when}`;
180}
181
182function conflictsWithWhen(leftWhen: string, rightWhen: string): boolean {
183 return leftWhen.length === 0 || rightWhen.length === 0 || leftWhen === rightWhen;
184}
185
186export function keybindingConflictLabels(
187 rows: ReadonlyArray<KeybindingRow>,
188 input: { readonly rowId: string; readonly key: string; readonly when: string },
189): ReadonlyArray<string> {
190 if (input.key.trim().length === 0) return [];
191 const conflicts: Array<string> = [];
192 for (const candidate of rows) {
193 if (
194 candidate.id !== input.rowId &&
195 candidate.key === input.key &&
196 conflictsWithWhen(candidate.when, input.when)
197 ) {
198 conflicts.push(commandLabel(candidate.command));
199 }
200 }
201 return [...new Set(conflicts)].toSorted();
202}
203
204export interface KeybindingGroup {
205 readonly id: string;
206 readonly title: string;
207 readonly rows: ReadonlyArray<KeybindingRow>;
208}
209
210/** Page sections in display order; a command joins the first group listing its prefix. */
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
234/** Splits sorted rows into the page's sections, dropping sections with no rows. */
235export function groupKeybindingRows(
236 rows: ReadonlyArray<KeybindingRow>,
237): ReadonlyArray<KeybindingGroup> {
238 const rowsByGroup = new Map<string, Array<KeybindingRow>>();
239 for (const row of rows) {
240 const group = keybindingGroupFor(row.command);
241 const bucket = rowsByGroup.get(group.id);
242 if (bucket) bucket.push(row);
243 else rowsByGroup.set(group.id, [row]);
244 }
245 return [...KEYBINDING_GROUPS, OTHER_KEYBINDING_GROUP].flatMap((group) => {
246 const groupRows = rowsByGroup.get(group.id);
247 return groupRows ? [{ id: group.id, title: group.title, rows: groupRows }] : [];
248 });
249}
250
251export function buildKeybindingRows(
252 keybindings: ResolvedKeybindingsConfig,
253 query: string,
254): ReadonlyArray<KeybindingRow> {
255 const normalizedQuery = query.trim().toLowerCase();
256 const rows = keybindings.flatMap((binding, index) => {
257 if (binding.command === "usage.limits") return [];
258
259 const defaultBinding = defaultBindingForBinding(binding);
260 const key = shortcutToKeybindingInput(binding.shortcut);
261 const when = whenAstToExpression(binding.whenAst);
262 return [
263 {
264 id: `${keybindingRowId(binding.command, key, when)}\u0000${index}`,
265 command: binding.command,
266 key,
267 when,
268 source: sourceForBinding(binding),
269 defaultKey: defaultBinding ? shortcutToKeybindingInput(defaultBinding.shortcut) : null,
270 defaultWhen: whenAstToExpression(defaultBinding?.whenAst),
271 binding,
272 conflicts: [],
273 } satisfies KeybindingRow,
274 ];
275 });
276
277 const rowsWithConflicts = rows.map((row) => {
278 const conflicts = keybindingConflictLabels(rows, {
279 rowId: row.id,
280 key: row.key,
281 when: row.when,
282 });
283 return conflicts.length > 0
284 ? Object.assign({}, row, { conflicts: [...new Set(conflicts)].toSorted() })
285 : row;
286 });
287
288 rowsWithConflicts.sort((left, right) => {
289 const commandCompare = compareCommands(left.command, right.command, (command) => command);
290 if (commandCompare !== 0) return commandCompare;
291 return left.key.localeCompare(right.key);
292 });
293
294 if (normalizedQuery.length === 0) {
295 return rowsWithConflicts;
296 }
297
298 return rowsWithConflicts.filter((row) => {
299 return (
300 row.command.toLowerCase().includes(normalizedQuery) ||
301 commandLabel(row.command).toLowerCase().includes(normalizedQuery) ||
302 row.key.toLowerCase().includes(normalizedQuery) ||
303 row.when.toLowerCase().includes(normalizedQuery) ||
304 row.source.toLowerCase().includes(normalizedQuery)
305 );
306 });
307}
308
309function collectWhenIdentifiersFromNode(
310 node: KeybindingWhenNode | undefined,
311 identifiers: Set<string>,
312): void {
313 if (!node) return;
314 switch (node.type) {
315 case "identifier":
316 identifiers.add(node.name);
317 return;
318 case "not":
319 collectWhenIdentifiersFromNode(node.node, identifiers);
320 return;
321 case "and":
322 case "or":
323 collectWhenIdentifiersFromNode(node.left, identifiers);
324 collectWhenIdentifiersFromNode(node.right, identifiers);
325 return;
326 }
327}
328
329export function isKnownWhenVariable(identifier: string): boolean {
330 return KNOWN_WHEN_VARIABLES.has(identifier);
331}
332
333export function unknownWhenVariables(node: KeybindingWhenNode | undefined): ReadonlyArray<string> {
334 const identifiers = new Set<string>();
335 collectWhenIdentifiersFromNode(node, identifiers);
336 return [...identifiers].filter((identifier) => !isKnownWhenVariable(identifier)).toSorted();
337}
338
339export function buildWhenVariableOptions(): ReadonlyArray<WhenVariableOption> {
340 return [...KNOWN_WHEN_VARIABLES].toSorted((left, right) => {
341 const leftCoreIndex = CORE_WHEN_VARIABLES.indexOf(left as (typeof CORE_WHEN_VARIABLES)[number]);
342 const rightCoreIndex = CORE_WHEN_VARIABLES.indexOf(
343 right as (typeof CORE_WHEN_VARIABLES)[number],
344 );
345 if (leftCoreIndex !== -1 || rightCoreIndex !== -1) {
346 return (
347 (leftCoreIndex === -1 ? Number.MAX_SAFE_INTEGER : leftCoreIndex) -
348 (rightCoreIndex === -1 ? Number.MAX_SAFE_INTEGER : rightCoreIndex)
349 );
350 }
351 return left.localeCompare(right);
352 });
353}
354
355export function buildKeybindingCommandOptions(
356 keybindings: ResolvedKeybindingsConfig,
357): ReadonlyArray<KeybindingCommandOption> {
358 const commands = new Set<KeybindingCommand>(
359 STATIC_KEYBINDING_COMMANDS.filter((command) => command !== "usage.limits"),
360 );
361 for (const binding of keybindings) {
362 if (binding.command !== "usage.limits") commands.add(binding.command);
363 }
364 return [...commands].toSorted((left, right) => compareCommands(left, right, commandLabel));
365}
366
367export function commandLabel(command: KeybindingCommand): string {
368 if (command === "composer.sendAlternate") return "Composer: Opposite Queue or Steer Action";
369 if (command === "composer.sendBackground") return "Composer: Start in Background";
370 if (command === "composer.sendAndNewThread") return "Composer: Send and Start New Thread";
371 if (command === "thread.steerQueuedMessage") return "Queue: Send First Queued Message as Steer";
372 if (command === "thread.editQueuedMessage") return "Queue: Edit Last Queued Message";
373 if (command === "thread.copyReference") return "Pull Request: Copy Link or Thread ID";
374 const usageMetric = METRIC_OPTIONS.find((option) => option.command === command);
375 if (usageMetric) return `Usage: ${usageMetric.label}`;
376 const usagePeriod = WINDOW_OPTIONS.find((option) => option.command === command);
377 if (usagePeriod) return `Usage: Period: ${usagePeriod.label}`;
378 if (command === "view.reopenClosed") return "Reopen Closed Tab";
379 if (command === "preview.focusUrl") return "Preview: Focus URL";
380 const raw = String(command);
381 if (raw.startsWith("script.") && raw.endsWith(".run")) {
382 return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`;
383 }
384 return raw.split(".").map(titleCaseCommandSegment).join(": ");
385}
386
387function titleCaseCommandSegment(segment: string): string {
388 const words: Array<string> = [];
389 for (const part of segment.replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[-_\s]+/)) {
390 if (part.length > 0) {
391 words.push(part.slice(0, 1).toUpperCase() + part.slice(1));
392 }
393 }
394 return words.join(" ");
395}
396
397function normalizeShortcutKeyToken(key: string): string | null {
398 const normalized = key.toLowerCase();
399 if (
400 normalized === "meta" ||
401 normalized === "control" ||
402 normalized === "ctrl" ||
403 normalized === "shift" ||
404 normalized === "alt" ||
405 normalized === "option"
406 ) {
407 return null;
408 }
409 if (normalized === " ") return "space";
410 if (normalized === "escape") return "esc";
411 if (normalized === "arrowup") return "arrowup";
412 if (normalized === "arrowdown") return "arrowdown";
413 if (normalized === "arrowleft") return "arrowleft";
414 if (normalized === "arrowright") return "arrowright";
415 if (normalized.length === 1) return normalized;
416 if (/^f\d{1,2}$/.test(normalized)) return normalized;
417 if (normalized === "enter" || normalized === "tab" || normalized === "backspace") {
418 return normalized;
419 }
420 if (normalized === "delete" || normalized === "home" || normalized === "end") {
421 return normalized;
422 }
423 if (normalized === "pageup" || normalized === "pagedown") return normalized;
424 return null;
425}
426
427/** Turns a keydown into a binding such as `mod+shift+k` or `tab`. Null for modifier-only presses. */
428export function keybindingFromKeyboardEvent(
429 event: Pick<KeyboardEvent, "key" | "code" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey">,
430 platform: string,
431): string | null {
432 const keyToken = normalizeShortcutKeyToken(shortcutKeyFromEvent(event));
433 if (!keyToken) return null;
434
435 const parts: string[] = [];
436 if (isMacPlatform(platform)) {
437 if (event.metaKey) parts.push("mod");
438 if (event.ctrlKey) parts.push("ctrl");
439 } else {
440 if (event.ctrlKey) parts.push("mod");
441 if (event.metaKey) parts.push("meta");
442 }
443 if (event.altKey) parts.push("alt");
444 if (event.shiftKey) parts.push("shift");
445 parts.push(keyToken);
446 return parts.join("+");
447}