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

keybindings.ts

488 lines14.7 KB

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