1import {
2 type KeybindingCommand,
3 type KeybindingShortcut,
4 type KeybindingWhenNode,
5 MODEL_PICKER_JUMP_KEYBINDING_COMMANDS,
6 type ResolvedKeybindingsConfig,
7 THREAD_JUMP_KEYBINDING_COMMANDS,
8 type ModelPickerJumpKeybindingCommand,
9 type ThreadJumpKeybindingCommand,
10} from "@t3tools/contracts";
11import {
12 isMacPlatform,
13 matchesKeybindingShortcut,
14 matchesKeybindingShortcutModifiers,
15 normalizeEventKey,
16 resolveEventKeys,
17 type ShortcutEventLike,
18 type ShortcutModifierStateLike,
19} from "@t3tools/shared/keybindings";
20import { isElectron } from "./env";
21import { projectScriptIdFromCommand } from "./projectScripts";
22
23export type { ShortcutEventLike, ShortcutModifierStateLike } from "@t3tools/shared/keybindings";
24export { shortcutKeyFromEvent } from "@t3tools/shared/keybindings";
25
26export interface ShortcutMatchContext {
27 terminalFocus: boolean;
28 terminalOpen: boolean;
29 previewFocus: boolean;
30 previewOpen: boolean;
31 isWeb: boolean;
32 isDesktop: boolean;
33 /** A text field, textarea, select or rich-text editor owns the keyboard.
34 Optional: only chords that collide with native editing consult it. */
35 editableFocus?: boolean;
36 [key: string]: boolean;
37}
38
39interface ShortcutMatchOptions {
40 platform?: string;
41 context?: Partial<ShortcutMatchContext>;
42}
43
44interface ResolvedShortcutLabelOptions extends ShortcutMatchOptions {
45 platform?: string;
46}
47
48const TERMINAL_WORD_BACKWARD = "\u001bb";
49const TERMINAL_WORD_FORWARD = "\u001bf";
50const TERMINAL_LINE_START = "\u0001";
51const TERMINAL_LINE_END = "\u0005";
52const TERMINAL_DELETE_TO_LINE_START = "\u0015";
53function resolvePlatform(options: ShortcutMatchOptions | undefined): string {
54 return options?.platform ?? navigator.platform;
55}
56
57function resolveContext(options: ShortcutMatchOptions | undefined): ShortcutMatchContext {
58 return {
59 terminalFocus: false,
60 terminalOpen: false,
61 previewFocus: false,
62 previewOpen: false,
63 isWeb: !isElectron,
64 isDesktop: isElectron,
65 editableFocus: false,
66 ...options?.context,
67 };
68}
69
70function evaluateWhenNode(node: KeybindingWhenNode, context: ShortcutMatchContext): boolean {
71 switch (node.type) {
72 case "identifier":
73 if (node.name === "true") return true;
74 if (node.name === "false") return false;
75 return Boolean(context[node.name]);
76 case "not":
77 return !evaluateWhenNode(node.node, context);
78 case "and":
79 return evaluateWhenNode(node.left, context) && evaluateWhenNode(node.right, context);
80 case "or":
81 return evaluateWhenNode(node.left, context) || evaluateWhenNode(node.right, context);
82 }
83}
84
85function matchesWhenClause(
86 whenAst: KeybindingWhenNode | undefined,
87 context: ShortcutMatchContext,
88): boolean {
89 if (!whenAst) return true;
90 return evaluateWhenNode(whenAst, context);
91}
92
93export function shortcutConflictKey(
94 shortcut: KeybindingShortcut,
95 platform = navigator.platform,
96): string {
97 const useMetaForMod = isMacPlatform(platform);
98 const metaKey = shortcut.metaKey || (shortcut.modKey && useMetaForMod);
99 const ctrlKey = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod);
100 return [
101 shortcut.key,
102 metaKey ? "meta" : "",
103 ctrlKey ? "ctrl" : "",
104 shortcut.shiftKey ? "shift" : "",
105 shortcut.altKey ? "alt" : "",
106 ].join("|");
107}
108
109export function effectiveShortcutsForCommand(
110 keybindings: ResolvedKeybindingsConfig,
111 command: KeybindingCommand,
112 options?: ShortcutMatchOptions,
113): KeybindingShortcut[] {
114 const platform = resolvePlatform(options);
115 const context = resolveContext(options);
116 const claimedShortcuts = new Set<string>();
117 const effective: KeybindingShortcut[] = [];
118
119 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
120 const binding = keybindings[index];
121 if (!binding) continue;
122 if (!matchesWhenClause(binding.whenAst, context)) continue;
123
124 const conflictKey = shortcutConflictKey(binding.shortcut, platform);
125 if (claimedShortcuts.has(conflictKey)) {
126 continue;
127 }
128
129 claimedShortcuts.add(conflictKey);
130 if (binding.command === command) {
131 effective.push(binding.shortcut);
132 }
133 }
134
135 return effective;
136}
137
138function findEffectiveShortcutForCommand(
139 keybindings: ResolvedKeybindingsConfig,
140 command: KeybindingCommand,
141 options?: ShortcutMatchOptions,
142): KeybindingShortcut | null {
143 return effectiveShortcutsForCommand(keybindings, command, options)[0] ?? null;
144}
145
146function matchesCommandShortcut(
147 event: ShortcutEventLike,
148 keybindings: ResolvedKeybindingsConfig,
149 command: KeybindingCommand,
150 options?: ShortcutMatchOptions,
151): boolean {
152 return resolveShortcutCommand(event, keybindings, options) === command;
153}
154
155export function resolveShortcutCommand(
156 event: ShortcutEventLike,
157 keybindings: ResolvedKeybindingsConfig,
158 options?: ShortcutMatchOptions,
159): KeybindingCommand | null {
160 const platform = resolvePlatform(options);
161 const context = resolveContext(options);
162
163 for (let index = keybindings.length - 1; index >= 0; index -= 1) {
164 const binding = keybindings[index];
165 if (!binding) continue;
166 if (!matchesWhenClause(binding.whenAst, context)) continue;
167 if (!matchesKeybindingShortcut(event, binding.shortcut, platform)) continue;
168 return binding.command;
169 }
170 return null;
171}
172
173/** App shortcuts use the primary environment; script shortcuts belong to the active project. */
174export function resolveChatShortcutCommand(
175 event: ShortcutEventLike,
176 primaryKeybindings: ResolvedKeybindingsConfig,
177 activeKeybindings: ResolvedKeybindingsConfig,
178 options?: ShortcutMatchOptions,
179): KeybindingCommand | null {
180 const primaryCommand = resolveShortcutCommand(event, primaryKeybindings, options);
181 if (primaryCommand !== null && projectScriptIdFromCommand(primaryCommand) === null) {
182 return primaryCommand;
183 }
184
185 const activeCommand = resolveShortcutCommand(event, activeKeybindings, options);
186 return activeCommand !== null && projectScriptIdFromCommand(activeCommand) !== null
187 ? activeCommand
188 : null;
189}
190
191export function formatShortcutKeyLabel(key: string): string {
192 if (key === " ") return "Space";
193 if (key.length === 1) return key.toUpperCase();
194 if (key === "escape") return "Esc";
195 if (key === "arrowup") return "Up";
196 if (key === "arrowdown") return "Down";
197 if (key === "arrowleft") return "Left";
198 if (key === "arrowright") return "Right";
199 return key.slice(0, 1).toUpperCase() + key.slice(1);
200}
201
202export function formatShortcutLabel(
203 shortcut: KeybindingShortcut,
204 platform = navigator.platform,
205): string {
206 const keyLabel = formatShortcutKeyLabel(shortcut.key);
207 const useMetaForMod = isMacPlatform(platform);
208 const showMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod);
209 const showCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod);
210 const showAlt = shortcut.altKey;
211 const showShift = shortcut.shiftKey;
212
213 if (useMetaForMod) {
214 return `${showCtrl ? "\u2303" : ""}${showAlt ? "\u2325" : ""}${showShift ? "\u21e7" : ""}${showMeta ? "\u2318" : ""}${keyLabel}`;
215 }
216
217 const parts: string[] = [];
218 if (showCtrl) parts.push("Ctrl");
219 if (showAlt) parts.push("Alt");
220 if (showShift) parts.push("Shift");
221 if (showMeta) parts.push("Meta");
222 parts.push(keyLabel);
223 return parts.join("+");
224}
225
226export function shortcutLabelForCommand(
227 keybindings: ResolvedKeybindingsConfig,
228 command: KeybindingCommand | null,
229 options?: string | ResolvedShortcutLabelOptions,
230): string | null {
231 if (command === null) return null;
232 const resolvedOptions =
233 typeof options === "string"
234 ? ({ platform: options } satisfies ResolvedShortcutLabelOptions)
235 : options;
236 const platform = resolvePlatform(resolvedOptions);
237 const shortcut = findEffectiveShortcutForCommand(keybindings, command, resolvedOptions);
238 return shortcut ? formatShortcutLabel(shortcut, platform) : null;
239}
240
241export function threadJumpCommandForIndex(index: number): ThreadJumpKeybindingCommand | null {
242 return THREAD_JUMP_KEYBINDING_COMMANDS[index] ?? null;
243}
244
245export function threadJumpIndexFromCommand(command: string): number | null {
246 const index = THREAD_JUMP_KEYBINDING_COMMANDS.indexOf(command as ThreadJumpKeybindingCommand);
247 return index === -1 ? null : index;
248}
249
250export function threadTraversalDirectionFromCommand(
251 command: string | null,
252): "previous" | "next" | null {
253 if (command === "thread.previous") return "previous";
254 if (command === "thread.next") return "next";
255 return null;
256}
257
258export function shouldShowThreadJumpHintsForModifiers(
259 modifiers: ShortcutModifierStateLike,
260 keybindings: ResolvedKeybindingsConfig,
261 options?: ShortcutMatchOptions,
262): boolean {
263 // The embedded terminal owns keystrokes while it has focus: the Ghostty
264 // surface encodes the keydown and can write the pressed key into the shell
265 // before our window-level shortcut handling ever runs, regardless of any
266 // configured `when` clause on the jump command. Advertising jump hints
267 // here would promise a shortcut that instead types into the terminal, so
268 // never show them while the terminal is focused.
269 if (resolveContext(options).terminalFocus) {
270 return false;
271 }
272
273 const platform = resolvePlatform(options);
274
275 for (const command of THREAD_JUMP_KEYBINDING_COMMANDS) {
276 const shortcut = findEffectiveShortcutForCommand(keybindings, command, options);
277 if (!shortcut) continue;
278 if (matchesKeybindingShortcutModifiers(modifiers, shortcut, platform)) {
279 return true;
280 }
281 }
282
283 return false;
284}
285
286export function modelPickerJumpCommandForIndex(
287 index: number,
288): ModelPickerJumpKeybindingCommand | null {
289 return MODEL_PICKER_JUMP_KEYBINDING_COMMANDS[index] ?? null;
290}
291
292export function modelPickerJumpIndexFromCommand(command: string): number | null {
293 const index = MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.indexOf(
294 command as ModelPickerJumpKeybindingCommand,
295 );
296 return index === -1 ? null : index;
297}
298
299export function isTerminalToggleShortcut(
300 event: ShortcutEventLike,
301 keybindings: ResolvedKeybindingsConfig,
302 options?: ShortcutMatchOptions,
303): boolean {
304 return matchesCommandShortcut(event, keybindings, "terminal.toggle", options);
305}
306
307export function isTerminalSplitShortcut(
308 event: ShortcutEventLike,
309 keybindings: ResolvedKeybindingsConfig,
310 options?: ShortcutMatchOptions,
311): boolean {
312 return matchesCommandShortcut(event, keybindings, "terminal.split", options);
313}
314
315export function isTerminalSplitVerticalShortcut(
316 event: ShortcutEventLike,
317 keybindings: ResolvedKeybindingsConfig,
318 options?: ShortcutMatchOptions,
319): boolean {
320 return matchesCommandShortcut(event, keybindings, "terminal.splitVertical", options);
321}
322
323export function isTerminalNewShortcut(
324 event: ShortcutEventLike,
325 keybindings: ResolvedKeybindingsConfig,
326 options?: ShortcutMatchOptions,
327): boolean {
328 return matchesCommandShortcut(event, keybindings, "terminal.new", options);
329}
330
331export function isTerminalCloseShortcut(
332 event: ShortcutEventLike,
333 keybindings: ResolvedKeybindingsConfig,
334 options?: ShortcutMatchOptions,
335): boolean {
336 return matchesCommandShortcut(event, keybindings, "terminal.close", options);
337}
338
339export function isDiffToggleShortcut(
340 event: ShortcutEventLike,
341 keybindings: ResolvedKeybindingsConfig,
342 options?: ShortcutMatchOptions,
343): boolean {
344 return matchesCommandShortcut(event, keybindings, "diff.toggle", options);
345}
346
347export function isOpenFavoriteEditorShortcut(
348 event: ShortcutEventLike,
349 keybindings: ResolvedKeybindingsConfig,
350 options?: ShortcutMatchOptions,
351): boolean {
352 return (
353 event.repeat !== true &&
354 matchesCommandShortcut(event, keybindings, "editor.openFavorite", options)
355 );
356}
357
358/**
359 * Whether the keypress is the rich-text bold chord (Mod+B without extra
360 * modifiers). Tiptap binds the same chord, so app shortcuts captured ahead
361 * of the editor must yield when the rich-text composer is focused.
362 */
363export function isRichTextBoldShortcut(event: ShortcutEventLike): boolean {
364 if (event.type !== undefined && event.type !== "keydown") {
365 return false;
366 }
367 return (
368 resolveEventKeys(event).has("b") &&
369 (event.metaKey || event.ctrlKey) &&
370 !event.altKey &&
371 !event.shiftKey
372 );
373}
374
375export function isTerminalClearShortcut(
376 event: ShortcutEventLike,
377 platform = navigator.platform,
378): boolean {
379 if (event.type !== undefined && event.type !== "keydown") {
380 return false;
381 }
382
383 const key = event.key.toLowerCase();
384
385 if (key === "l" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
386 return true;
387 }
388
389 return (
390 isMacPlatform(platform) &&
391 key === "k" &&
392 event.metaKey &&
393 !event.ctrlKey &&
394 !event.altKey &&
395 !event.shiftKey
396 );
397}
398
399export function terminalDeleteShortcutData(
400 event: ShortcutEventLike,
401 platform = navigator.platform,
402): string | null {
403 if (event.type !== undefined && event.type !== "keydown") {
404 return null;
405 }
406
407 if (!isMacPlatform(platform)) {
408 return null;
409 }
410
411 const key = normalizeEventKey(event.key);
412 if (key !== "backspace") {
413 return null;
414 }
415
416 return event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey
417 ? TERMINAL_DELETE_TO_LINE_START
418 : null;
419}
420
421export function terminalNavigationShortcutData(
422 event: ShortcutEventLike,
423 platform = navigator.platform,
424): string | null {
425 if (event.type !== undefined && event.type !== "keydown") {
426 return null;
427 }
428
429 if (event.shiftKey) return null;
430
431 const key = normalizeEventKey(event.key);
432 if (key !== "arrowleft" && key !== "arrowright") {
433 return null;
434 }
435
436 const moveWord = key === "arrowleft" ? TERMINAL_WORD_BACKWARD : TERMINAL_WORD_FORWARD;
437 const moveLine = key === "arrowleft" ? TERMINAL_LINE_START : TERMINAL_LINE_END;
438
439 if (isMacPlatform(platform)) {
440 if (event.altKey && !event.metaKey && !event.ctrlKey) {
441 return moveWord;
442 }
443 if (event.metaKey && !event.altKey && !event.ctrlKey) {
444 return moveLine;
445 }
446 return null;
447 }
448
449 if (event.ctrlKey && !event.metaKey && !event.altKey) {
450 return moveWord;
451 }
452
453 if (event.altKey && !event.metaKey && !event.ctrlKey) {
454 return moveWord;
455 }
456
457 return null;
458}