import { type KeybindingRule, type KeybindingShortcut, type KeybindingWhenNode, MAX_KEYBINDINGS_COUNT, MAX_WHEN_EXPRESSION_DEPTH, MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, type ResolvedKeybindingRule, type ResolvedKeybindingsConfig, THREAD_JUMP_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; export interface ShortcutEventLike { getModifierState?: (key: "AltGraph") => boolean; type?: string; code?: string; key: string; repeat?: boolean; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean; } export interface ShortcutModifierStateLike { metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean; } export const EVENT_CODE_SHORTCUT_KEYS: Readonly> = { Backquote: "`", Backslash: "\\", BracketLeft: "[", BracketRight: "]", Comma: ",", Digit0: "0", Digit1: "1", Digit2: "2", Digit3: "3", Digit4: "4", Digit5: "5", Digit6: "6", Digit7: "7", Digit8: "8", Digit9: "9", Equal: "=", Minus: "-", Period: ".", Quote: "'", Semicolon: ";", Slash: "/", }; export const US_SHIFTED_TO_UNSHIFTED_KEYS: ReadonlyMap = new Map([ ["!", "1"], ["@", "2"], ["#", "3"], ["$", "4"], ["%", "5"], ["^", "6"], ["&", "7"], ["*", "8"], ["(", "9"], [")", "0"], ["~", "`"], ["_", "-"], ["+", "="], ["{", "["], ["}", "]"], ["|", "\\"], [":", ";"], ['"', "'"], ["<", ","], [">", "."], ["?", "/"], ]); export function isMacPlatform(platform: string): boolean { return /mac|iphone|ipad|ipod/i.test(platform); } export function normalizeEventKey(key: string): string { const normalized = key.toLowerCase(); if (normalized === "esc") return "escape"; return normalized; } export function shortcutKeyFromEvent(event: Pick): string { const layoutKey = normalizeEventKey(event.key); if (/^[a-z]$/.test(layoutKey)) return layoutKey; const physicalKey = event.code ? EVENT_CODE_SHORTCUT_KEYS[event.code] : undefined; return physicalKey ?? layoutKey; } export function resolveEventKeys(event: ShortcutEventLike): Set { const layoutKey = normalizeEventKey(event.key); const keys = new Set([layoutKey]); const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1]; if (letterCode && !/^[a-z]$/.test(layoutKey)) { keys.add(letterCode.toLowerCase()); } keys.add(shortcutKeyFromEvent(event)); return keys; } export function matchesKeybindingShortcutModifiers( event: ShortcutModifierStateLike, shortcut: KeybindingShortcut, platform: string, ): boolean { const useMetaForMod = isMacPlatform(platform); const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod); const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod); return ( event.metaKey === expectedMeta && event.ctrlKey === expectedCtrl && event.shiftKey === shortcut.shiftKey && event.altKey === shortcut.altKey ); } export function matchesKeybindingShortcut( event: ShortcutEventLike, shortcut: KeybindingShortcut, platform: string, ): boolean { if ( !isMacPlatform(platform) && event.getModifierState?.("AltGraph") && !/^[a-z0-9]$/i.test(event.key) ) return false; if (!matchesKeybindingShortcutModifiers(event, shortcut, platform)) return false; return resolveEventKeys(event).has(shortcut.key); } type WhenToken = | { type: "identifier"; value: string } | { type: "not" } | { type: "and" } | { type: "or" } | { type: "lparen" } | { type: "rparen" }; export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+b", command: "sidebar.toggle" }, { key: "f6", command: "workspace.focusNext" }, { key: "shift+f6", command: "workspace.focusPrevious" }, // Some browsers keep plain F6 for their own toolbars; mod+F6 reaches the page in all of them. { key: "mod+f6", command: "workspace.focusNext" }, { key: "mod+shift+f6", command: "workspace.focusPrevious" }, { key: "mod+[", command: "navigation.back", when: "!terminalFocus" }, { key: "mod+]", command: "navigation.forward", when: "!terminalFocus" }, { key: "mod+j", command: "terminal.toggle" }, { key: "mod+alt+b", command: "rightPanel.toggle" }, { key: "mod+shift+b", command: "threadPanel.toggle", when: "!terminalFocus" }, { key: "mod+t", command: "rightPanel.new", when: "rightPanelOpen && !terminalFocus" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" }, { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, { key: "mod+w", command: "terminal.close", when: "terminalFocus" }, { key: "mod+w", command: "rightPanel.close", when: "!terminalFocus" }, { key: "mod+shift+t", command: "view.reopenClosed" }, { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, { key: "mod+shift+j", command: "preview.toggle" }, { key: "mod+r", command: "preview.refresh", when: "previewFocus" }, { key: "mod+l", command: "preview.focusUrl", when: "previewFocus" }, { key: "mod+=", command: "preview.zoomIn", when: "previewFocus" }, { key: "mod++", command: "preview.zoomIn", when: "previewFocus" }, { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, { key: "mod+f", command: "preview.find", when: "previewFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" }, { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, { key: "mod+u", command: "usage.open", when: "!terminalFocus" }, { key: "mod+alt+a", command: "theme.select", when: "!terminalFocus" }, { key: "mod+alt+shift+a", command: "appearance.cycle", when: "!terminalFocus" }, { key: "mod+alt+shift+t", command: "themeEditor.toggle" }, { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, { key: "mod+shift+enter", command: "thread.steerQueuedMessage", when: "!terminalFocus" }, { key: "alt+arrowup", command: "thread.editQueuedMessage", when: "composerFocus" }, { key: "mod+enter", command: "composer.sendAlternate", when: "composerFocus && turnRunning" }, { key: "mod+enter", command: "composer.sendBackground", when: "composerFocus && draftThreadRoute", }, { key: "mod+alt+enter", command: "composer.sendBackground", when: "composerFocus && draftThreadRoute", }, { key: "mod+alt+enter", command: "composer.sendAndNewThread", when: "composerFocus && !draftThreadRoute", }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" }, { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" }, { key: "mod+alt+n", command: "chat.newWithoutProject", when: "!terminalFocus" }, { key: "mod+f", command: "chat.find", when: "!terminalFocus && !previewFocus" }, { key: "mod+shift+m", command: "modelPicker.toggle", when: "!terminalFocus" }, { key: "mod+shift+h", command: "composer.host", when: "!terminalFocus" }, { key: "mod+shift+e", command: "composer.effort", when: "!terminalFocus" }, { key: "mod+shift+a", command: "composer.mode", when: "!terminalFocus" }, { key: "mod+shift+x", command: "composer.workspace", when: "!terminalFocus" }, { key: "mod+shift+g", command: "composer.branch", when: "!terminalFocus" }, { key: "mod+shift+l", command: "composer.previousWorktree", when: "!terminalFocus" }, { key: "mod+shift+k", command: "pullRequest.copyNumber", when: "!terminalFocus" }, { key: "mod+shift+arrowup", command: "modelPicker.previousProvider", when: "modelPickerOpen" }, { key: "mod+shift+arrowdown", command: "modelPicker.nextProvider", when: "modelPickerOpen" }, { key: "mod+o", command: "editor.openFavorite" }, { key: "mod+shift+[", command: "thread.previous" }, { key: "mod+shift+]", command: "thread.next" }, { key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" }, { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" }, { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" }, { key: "mod+z", command: "thread.undo", when: "!terminalFocus && !editableFocus" }, ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ key: `mod+${index + 1}`, command, when: "!modelPickerOpen && isDesktop", })), ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ key: `mod+${index + 1}`, command, when: "modelPickerOpen && isDesktop", })), { key: "c", command: "usage.cost", when: "usagePageOpen" }, { key: "t", command: "usage.tokens", when: "usagePageOpen" }, { key: "l", command: "usage.limits", when: "usagePageOpen" }, { key: "mod+shift+1", command: "usage.period.day", when: "usagePageOpen" }, { key: "mod+shift+2", command: "usage.period.week", when: "usagePageOpen" }, { key: "mod+shift+3", command: "usage.period.month", when: "usagePageOpen" }, { key: "mod+shift+4", command: "usage.period.quarter", when: "usagePageOpen" }, ]; function normalizeKeyToken(token: string): string { if (token === "space") return " "; if (token === "esc") return "escape"; return token; } export function parseKeybindingShortcut(value: string): KeybindingShortcut | null { const rawTokens = value .toLowerCase() .split("+") .map((token) => token.trim()); const tokens = [...rawTokens]; let trailingEmptyCount = 0; while (tokens[tokens.length - 1] === "") { trailingEmptyCount += 1; tokens.pop(); } if (trailingEmptyCount > 0) { tokens.push("+"); } if (tokens.some((token) => token.length === 0)) { return null; } if (tokens.length === 0) return null; let key: string | null = null; let metaKey = false; let ctrlKey = false; let shiftKey = false; let altKey = false; let modKey = false; for (const token of tokens) { switch (token) { case "cmd": case "meta": metaKey = true; break; case "ctrl": case "control": ctrlKey = true; break; case "shift": shiftKey = true; break; case "alt": case "option": altKey = true; break; case "mod": modKey = true; break; default: { if (key !== null) return null; key = normalizeKeyToken(token); } } } if (key === null) return null; return { key, metaKey, ctrlKey, shiftKey, altKey, modKey, }; } function tokenizeWhenExpression(expression: string): WhenToken[] | null { const tokens: WhenToken[] = []; let index = 0; while (index < expression.length) { const current = expression[index]; if (!current) break; if (/\s/.test(current)) { index += 1; continue; } if (expression.startsWith("&&", index)) { tokens.push({ type: "and" }); index += 2; continue; } if (expression.startsWith("||", index)) { tokens.push({ type: "or" }); index += 2; continue; } if (current === "!") { tokens.push({ type: "not" }); index += 1; continue; } if (current === "(") { tokens.push({ type: "lparen" }); index += 1; continue; } if (current === ")") { tokens.push({ type: "rparen" }); index += 1; continue; } const identifier = /^[A-Za-z_][A-Za-z0-9_.-]*/.exec(expression.slice(index)); if (!identifier) { return null; } tokens.push({ type: "identifier", value: identifier[0] }); index += identifier[0].length; } return tokens; } export function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { const tokens = tokenizeWhenExpression(expression); if (!tokens || tokens.length === 0) return null; let index = 0; const parsePrimary = (depth: number): KeybindingWhenNode | null => { if (depth > MAX_WHEN_EXPRESSION_DEPTH) { return null; } const token = tokens[index]; if (!token) return null; if (token.type === "identifier") { index += 1; return { type: "identifier", name: token.value }; } if (token.type === "lparen") { index += 1; const expressionNode = parseOr(depth + 1); const closeToken = tokens[index]; if (!expressionNode || !closeToken || closeToken.type !== "rparen") { return null; } index += 1; return expressionNode; } return null; }; const parseUnary = (depth: number): KeybindingWhenNode | null => { let notCount = 0; while (tokens[index]?.type === "not") { index += 1; notCount += 1; if (notCount > MAX_WHEN_EXPRESSION_DEPTH) { return null; } } let node = parsePrimary(depth); if (!node) return null; while (notCount > 0) { node = { type: "not", node }; notCount -= 1; } return node; }; const parseAnd = (depth: number): KeybindingWhenNode | null => { let left = parseUnary(depth); if (!left) return null; while (tokens[index]?.type === "and") { index += 1; const right = parseUnary(depth); if (!right) return null; left = { type: "and", left, right }; } return left; }; const parseOr = (depth: number): KeybindingWhenNode | null => { let left = parseAnd(depth); if (!left) return null; while (tokens[index]?.type === "or") { index += 1; const right = parseAnd(depth); if (!right) return null; left = { type: "or", left, right }; } return left; }; const ast = parseOr(0); if (!ast || index !== tokens.length) return null; return ast; } export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null { const shortcut = parseKeybindingShortcut(rule.key); if (!shortcut) return null; if (rule.when !== undefined) { const whenAst = parseKeybindingWhenExpression(rule.when); if (!whenAst) return null; return { command: rule.command, shortcut, whenAst, }; } return { command: rule.command, shortcut, }; } export function compileResolvedKeybindingsConfig( config: ReadonlyArray, ): ResolvedKeybindingsConfig { const compiled: ResolvedKeybindingRule[] = []; for (const rule of config) { const result = compileResolvedKeybindingRule(rule); if (result) { compiled.push(result); } } return compiled.slice(-MAX_KEYBINDINGS_COUNT); } export const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); export function mergeWithDefaultKeybindings( custom: ResolvedKeybindingsConfig, ): ResolvedKeybindingsConfig { if (custom.length === 0) { return [...DEFAULT_RESOLVED_KEYBINDINGS]; } const overriddenCommands = new Set(custom.map((binding) => binding.command)); const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( (binding) => !overriddenCommands.has(binding.command), ); const merged = [...retainedDefaults, ...custom]; if (merged.length <= MAX_KEYBINDINGS_COUNT) { return merged; } return merged.slice(-MAX_KEYBINDINGS_COUNT); }