import { matchesKey } from "@mariozechner/pi-tui"; import type { AskConfig, AskConfigKeymaps } from "../config/schema.ts"; const DIGIT_SHORTCUT_PATTERN = /^[1-9]$/; const LETTER_PATTERN = /^[a-z]$/; const DIGIT_PATTERN = /^[0-9]$/; const SYMBOL_PATTERN = /^[`\-=[\]\\;'.,/!@#$%^&*()_+|~{}:<>?]$/; const FUNCTION_KEY_PATTERN = /^f([1-9]|1[0-2])$/; const MODIFIER_ORDER = ["ctrl", "shift", "alt", "super"] as const; const KNOWN_SPECIAL_KEYS = new Set([ "esc", "enter", "tab", "space", "backspace", "delete", "insert", "clear", "home", "end", "pageUp", "pageDown", "up", "down", "left", "right", "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12", ] as const); const RESERVED_BINDINGS = new Set([ "1", "2", "3", "4", "5", "6", "7", "8", "9", ]); const CONTEXT_LABELS: Record = { global: "Global", main: "Main flow", editor: "Answer editor", noteEditor: "Note editor", settingsModal: "Settings modal", }; type KeyId = Parameters[1]; type InputKey = KeyId | (string & {}); type ModifierName = (typeof MODIFIER_ORDER)[number]; export type AskKeymapContext = keyof AskConfigKeymaps; export type AskKeymapAction = keyof AskConfigKeymaps[C] & string; export type AskKeyBindingKind = "command" | "affordance"; export type AskKeyBindingId = | `${AskKeymapContext}.${string}` | "numberShortcut" | "fileReference"; export interface AskKeyBinding { contexts: readonly string[]; description: string; id: AskKeyBindingId; keys: readonly InputKey[]; kind: AskKeyBindingKind; label: string; } export type FooterKeymapContext = | "default" | "multi" | "submit" | "input" | "note"; export const DEFAULT_ASK_KEYMAPS: AskConfigKeymaps = { global: { dismiss: ["ctrl+c"], settings: ["?"], }, main: { confirm: ["enter"], cancel: ["esc"], toggle: ["space"], nextTab: ["tab", "right"], previousTab: ["shift+tab", "left"], nextOption: ["down"], previousOption: ["up"], optionNote: ["n"], questionNote: ["shift+n"], }, editor: { submit: ["enter"], close: ["esc"], nextTabWhenEmpty: ["tab", "right"], previousTabWhenEmpty: ["shift+tab", "left"], nextOptionWhenEmpty: ["down"], previousOptionWhenEmpty: ["up"], }, noteEditor: { save: ["enter"], close: ["esc"], nextTabWhenEmpty: ["tab", "right"], previousTabWhenEmpty: ["shift+tab", "left"], nextOptionWhenEmpty: ["down"], previousOptionWhenEmpty: ["up"], }, settingsModal: { close: ["esc", "ctrl+c", "?"], nextOption: ["down"], previousOption: ["up"], toggle: ["enter", "space"], }, }; const DESCRIPTIONS: Record> = { global: { dismiss: "Dismiss the active ask surface", settings: "Open ask settings", }, main: { confirm: "Confirm selection, continue, or submit", cancel: "Cancel flow", toggle: "Toggle selected option", nextTab: "Switch to next tab", previousTab: "Switch to previous tab", nextOption: "Move to next option/action", previousOption: "Move to previous option/action", optionNote: "Edit selected option note", questionNote: "Edit question note", }, editor: { submit: "Submit custom answer", close: "Close editor and preserve draft", nextTabWhenEmpty: "Switch to next tab when editor is empty", previousTabWhenEmpty: "Switch to previous tab when editor is empty", nextOptionWhenEmpty: "Move to next option when editor is empty", previousOptionWhenEmpty: "Move to previous option when editor is empty", }, noteEditor: { save: "Save note", close: "Close note editor", nextTabWhenEmpty: "Switch to next tab when editor is empty", previousTabWhenEmpty: "Switch to previous tab when editor is empty", nextOptionWhenEmpty: "Move to next option when editor is empty", previousOptionWhenEmpty: "Move to previous option when editor is empty", }, settingsModal: { close: "Close settings", nextOption: "Move to next setting", previousOption: "Move to previous setting", toggle: "Toggle selected setting", }, }; const footerHint = ( binding: AskKeyBinding, action: string, label = binding.label ) => `${label} ${action}`; export function formatKeybindingLabel(key: string): string { if (key === "up") { return "↑"; } if (key === "down") { return "↓"; } if (key === "left") { return "←"; } if (key === "right") { return "→"; } return key .split("+") .map((part) => { if (part.length <= 1) { return part.toUpperCase(); } if (part === "pageUp" || part === "pageDown") { return part; } return part.charAt(0).toUpperCase() + part.slice(1); }) .join("+"); } export function formatBindingLabel(keys: readonly string[]): string { return keys.map(formatKeybindingLabel).join(" / "); } export function matchesBinding(data: string, binding: AskKeyBinding): boolean { return binding.keys.some((key) => { if (key.length === 1) { return data === key; } return matchesKey(data, key as KeyId); }); } export function matchesDigitShortcut(data: string): number | null { return DIGIT_SHORTCUT_PATTERN.test(data) ? Number(data) : null; } export function getAskKeyBindings( config: AskConfig ): Record { const entries: [string, AskKeyBinding][] = []; for (const context of Object.keys(config.keymaps) as AskKeymapContext[]) { const actions = config.keymaps[context] as Record< string, readonly string[] >; for (const [action, keys] of Object.entries(actions)) { const id = `${context}.${action}` as AskKeyBindingId; entries.push([ id, { contexts: [CONTEXT_LABELS[context]], description: DESCRIPTIONS[context][action] ?? action, id, keys, kind: "command", label: formatBindingLabel(keys), }, ]); } } entries.push([ "numberShortcut", { contexts: ["Main flow", "Submit tab"], description: "Quick-select option or submit action", id: "numberShortcut", keys: ["1..9"], kind: "command", label: "1..9", }, ]); entries.push([ "fileReference", { contexts: ["Editors"], description: "Reference files while typing answers or notes", id: "fileReference", keys: ["@"], kind: "affordance", label: "@", }, ]); return Object.fromEntries(entries); } export function getAskContextBindings( config: AskConfig, context: C ): Record, AskKeyBinding> { const bindings = getAskKeyBindings(config); const actions = config.keymaps[context] as Record; return Object.fromEntries( Object.keys(actions).map((action) => [ action, bindings[`${context}.${action}`], ]) ) as Record, AskKeyBinding>; } export function getGlobalBindings( config: AskConfig ): Record, AskKeyBinding> { return getAskContextBindings(config, "global"); } export function getAskKeymaps(config: AskConfig): { customizable: readonly AskKeyBinding[]; fixed: readonly AskKeyBinding[]; } { const bindings = getAskKeyBindings(config); return { customizable: Object.keys(bindings) .filter((id) => id !== "numberShortcut" && id !== "fileReference") .map((id) => bindings[id]), fixed: [bindings.numberShortcut, bindings.fileReference], }; } export function renderFooterKeymaps( config: AskConfig, context: FooterKeymapContext ): string { const global = getGlobalBindings(config); const main = getAskContextBindings(config, "main"); const editor = getAskContextBindings(config, "editor"); const noteEditor = getAskContextBindings(config, "noteEditor"); const bindings = getAskKeyBindings(config); const optionNavigationLabel = `${main.previousOption.label}${main.nextOption.label}`.replaceAll( " / ", "" ); const tabNavigationLabel = "⇆"; const noteNavigationLabel = `${main.optionNote.label}/${main.questionNote.label}`; const hintsByContext: Record = { input: [ footerHint(editor.submit, "submit"), footerHint(editor.close, "close"), footerHint(global.settings, "settings"), ], note: [ footerHint(noteEditor.save, "save"), footerHint(noteEditor.close, "close"), footerHint(global.settings, "settings"), ], submit: [ footerHint(bindings.numberShortcut, "hotkeys"), footerHint(main.previousOption, "select", optionNavigationLabel), footerHint(main.confirm, "confirm"), footerHint(main.cancel, "cancel"), footerHint(global.settings, "settings"), ], multi: [ footerHint(main.nextTab, "tab", tabNavigationLabel), footerHint(main.previousOption, "select", optionNavigationLabel), footerHint(main.toggle, "toggle"), footerHint(main.confirm, "continue"), footerHint(main.optionNote, "note", noteNavigationLabel), footerHint(main.cancel, "dismiss"), footerHint(global.settings, "settings"), ], default: [ footerHint(main.nextTab, "tab", tabNavigationLabel), footerHint(main.previousOption, "select", optionNavigationLabel), footerHint(main.confirm, "confirm"), footerHint(main.optionNote, "note", noteNavigationLabel), footerHint(main.cancel, "dismiss"), footerHint(global.settings, "settings"), ], }; return ` ${hintsByContext[context].join(" · ")}`; } export function renderSettingsFooterKeymaps(config: AskConfig): string { const bindings = getAskContextBindings(config, "settingsModal"); return `${bindings.toggle.label} to change · ${bindings.close.label} to close`; } export function normalizeConfiguredKeymaps( keymaps: unknown ): { ok: true; keymaps: AskConfigKeymaps } | { ok: false; error: string } { const candidate = normalizeLegacyFlatKeymaps(keymaps) ?? keymaps; if (!candidate) { return { ok: true, keymaps: cloneKeymaps(DEFAULT_ASK_KEYMAPS) }; } if (!(candidate && typeof candidate === "object")) { return { ok: false, error: "Keymaps must be an object." }; } const normalized = {} as AskConfigKeymaps; for (const context of Object.keys( DEFAULT_ASK_KEYMAPS ) as AskKeymapContext[]) { const rawContext = (candidate as Record)[context]; if (!(rawContext && typeof rawContext === "object")) { return { ok: false, error: `Missing keymap context ${context}.` }; } const contextResult = normalizeContextKeymaps( context, rawContext as Record ); if (!contextResult.ok) { return contextResult; } normalized[context] = contextResult.keymaps as never; } const globalDuplicates = validateCrossContextConflicts(normalized); if (globalDuplicates) { return { ok: false, error: globalDuplicates }; } return { ok: true, keymaps: normalized }; } function normalizeContextKeymaps( context: AskKeymapContext, rawContext: Record ): | { ok: true; keymaps: Record } | { ok: false; error: string } { const defaults = DEFAULT_ASK_KEYMAPS[context] as Record< string, readonly string[] >; const normalized: Record = {}; for (const action of Object.keys(defaults)) { const rawValue = rawContext[action]; if (rawValue === undefined) { return { ok: false, error: `Missing keymap for ${context}.${action}.` }; } const parsed = normalizeBindingList(rawValue); if (!parsed.ok) { return { ok: false, error: `Invalid keymap for ${context}.${action}: ${parsed.error}`, }; } normalized[action] = parsed.keys; } const duplicateError = validateContextBindings(context, normalized); return duplicateError ? { ok: false, error: duplicateError } : { ok: true, keymaps: normalized }; } function normalizeBindingList( rawValue: unknown ): { ok: true; keys: string[] } | { ok: false; error: string } { const rawKeys = typeof rawValue === "string" ? [rawValue] : rawValue; if (!Array.isArray(rawKeys)) { return { ok: false, error: "binding must be a string or array of strings" }; } if (rawKeys.length === 0) { return { ok: false, error: "empty binding list" }; } const keys: string[] = []; for (const rawKey of rawKeys) { if (typeof rawKey !== "string") { return { ok: false, error: "binding list must contain only strings" }; } const parsed = normalizeKeyId(rawKey); if (!parsed.ok) { return parsed; } if (!keys.includes(parsed.keyId)) { keys.push(parsed.keyId); } } return { ok: true, keys }; } function validateContextBindings( context: AskKeymapContext, bindings: Record ): string | undefined { const seen = new Map(); for (const { action, key } of flattenBindings(bindings)) { if (RESERVED_BINDINGS.has(key)) { return `Reserved keymap for ${context}.${action}: ${formatKeybindingLabel(key)}.`; } const existing = seen.get(key); if (existing) { return `Duplicate keymap ${formatKeybindingLabel(key)} for ${context}.${existing} and ${context}.${action}.`; } seen.set(key, action); } return; } function validateCrossContextConflicts( keymaps: AskConfigKeymaps ): string | undefined { const globalSeen = new Map( flattenBindings(keymaps.global).map(({ action, key }) => [ key, `global.${action}`, ]) ); for (const { context, action, key } of flattenNonGlobalBindings(keymaps)) { const existing = globalSeen.get(key); if (existing) { return `Duplicate global keymap ${formatKeybindingLabel(key)} for ${existing} and ${context}.${action}.`; } } return; } function flattenNonGlobalBindings(keymaps: AskConfigKeymaps): Array<{ action: string; context: AskKeymapContext; key: string; }> { return (Object.keys(keymaps) as AskKeymapContext[]) .filter((context) => context !== "global" && context !== "settingsModal") .flatMap((context) => flattenBindings( keymaps[context] as Record ).map((binding) => ({ ...binding, context })) ); } function flattenBindings( bindings: Record ): Array<{ action: string; key: string }> { return Object.entries(bindings).flatMap(([action, keys]) => keys.map((key) => ({ action, key })) ); } export function normalizeLegacyFlatKeymaps( keymaps: unknown ): AskConfigKeymaps | undefined { if (!(keymaps && typeof keymaps === "object")) { return; } if ("main" in keymaps || "global" in keymaps) { return; } const raw = keymaps as Record; const defaults = cloneKeymaps(DEFAULT_ASK_KEYMAPS); return { ...defaults, global: { ...defaults.global, dismiss: coerceLegacyBinding(raw.dismiss, defaults.global.dismiss), }, main: { ...defaults.main, confirm: coerceLegacyBinding(raw.confirm, defaults.main.confirm), cancel: coerceLegacyBinding(raw.cancel, defaults.main.cancel), toggle: coerceLegacyBinding(raw.toggle, defaults.main.toggle), optionNote: coerceLegacyBinding(raw.optionNote, defaults.main.optionNote), questionNote: coerceLegacyBinding( raw.questionNote, defaults.main.questionNote ), }, editor: { ...defaults.editor, submit: coerceLegacyBinding(raw.confirm, defaults.editor.submit), close: coerceLegacyBinding(raw.cancel, defaults.editor.close), }, noteEditor: { ...defaults.noteEditor, save: coerceLegacyBinding(raw.confirm, defaults.noteEditor.save), close: coerceLegacyBinding(raw.cancel, defaults.noteEditor.close), }, }; } function coerceLegacyBinding(value: unknown, fallback: string[]): string[] { return typeof value === "string" ? [value] : fallback; } function normalizeKeyId( rawKey: string ): { ok: true; keyId: string } | { ok: false; error: string } { const normalizedInput = rawKey.trim(); if (!normalizedInput) { return { ok: false, error: "empty binding" }; } const parts = normalizedInput .toLowerCase() .split("+") .map((part) => part.trim()) .filter(Boolean); if (parts.length === 0) { return { ok: false, error: "empty binding" }; } const basePart = parts.at(-1); if (!basePart) { return { ok: false, error: "missing key" }; } const modifiers = new Set(); for (const part of parts.slice(0, -1)) { const modifier = normalizeModifierName(part); if (!modifier) { return { ok: false, error: `unsupported modifier ${part}` }; } modifiers.add(modifier); } const baseKey = normalizeBaseKey(basePart); if (!baseKey) { return { ok: false, error: `unsupported key ${basePart}` }; } const modifierPrefix = MODIFIER_ORDER.filter((modifier) => modifiers.has(modifier) ); return { ok: true, keyId: [...modifierPrefix, baseKey].join("+") }; } function cloneKeymaps(keymaps: AskConfigKeymaps): AskConfigKeymaps { return { global: { dismiss: [...keymaps.global.dismiss], settings: [...keymaps.global.settings], }, main: { confirm: [...keymaps.main.confirm], cancel: [...keymaps.main.cancel], toggle: [...keymaps.main.toggle], nextTab: [...keymaps.main.nextTab], previousTab: [...keymaps.main.previousTab], nextOption: [...keymaps.main.nextOption], previousOption: [...keymaps.main.previousOption], optionNote: [...keymaps.main.optionNote], questionNote: [...keymaps.main.questionNote], }, editor: { submit: [...keymaps.editor.submit], close: [...keymaps.editor.close], nextTabWhenEmpty: [...keymaps.editor.nextTabWhenEmpty], previousTabWhenEmpty: [...keymaps.editor.previousTabWhenEmpty], nextOptionWhenEmpty: [...keymaps.editor.nextOptionWhenEmpty], previousOptionWhenEmpty: [...keymaps.editor.previousOptionWhenEmpty], }, noteEditor: { save: [...keymaps.noteEditor.save], close: [...keymaps.noteEditor.close], nextTabWhenEmpty: [...keymaps.noteEditor.nextTabWhenEmpty], previousTabWhenEmpty: [...keymaps.noteEditor.previousTabWhenEmpty], nextOptionWhenEmpty: [...keymaps.noteEditor.nextOptionWhenEmpty], previousOptionWhenEmpty: [...keymaps.noteEditor.previousOptionWhenEmpty], }, settingsModal: { close: [...keymaps.settingsModal.close], nextOption: [...keymaps.settingsModal.nextOption], previousOption: [...keymaps.settingsModal.previousOption], toggle: [...keymaps.settingsModal.toggle], }, }; } function normalizeModifierName(part: string): ModifierName | undefined { switch (part) { case "ctrl": case "control": case "ctl": return "ctrl"; case "shift": return "shift"; case "alt": case "option": return "alt"; case "super": case "cmd": case "command": case "meta": case "win": case "windows": return "super"; default: return; } } function normalizeBaseKey(part: string): string | undefined { const aliased = normalizeBaseKeyAlias(part); if ( LETTER_PATTERN.test(aliased) || DIGIT_PATTERN.test(aliased) || SYMBOL_PATTERN.test(aliased) ) { return aliased; } if (FUNCTION_KEY_PATTERN.test(aliased)) { return aliased; } if ( KNOWN_SPECIAL_KEYS.has( aliased as typeof KNOWN_SPECIAL_KEYS extends Set ? T : never ) ) { return aliased; } return; } function normalizeBaseKeyAlias(part: string): string { switch (part) { case "escape": return "esc"; case "return": return "enter"; case "spacebar": return "space"; case "del": return "delete"; case "ins": return "insert"; case "pgup": case "pageup": return "pageUp"; case "pgdown": case "pagedown": return "pageDown"; case "arrowup": return "up"; case "arrowdown": return "down"; case "arrowleft": return "left"; case "arrowright": return "right"; default: return part; } }