import { parseKey } from "@mariozechner/pi-tui"; export type KeyHintSlot = "card" | "footer" | "help"; export type KeyBinding = { keys: string | readonly string[]; action: Action; label: string; slots?: readonly KeyHintSlot[]; }; export type KeyHint = { keys: string[]; action: Action; label: string; }; export type DocketKeymap = { name: string; resolve(input: string): Action | undefined; hints(slot: KeyHintSlot): KeyHint[]; }; /** * Raw terminal escape sequences, matched before any case folding: the final byte carries the * meaning (\u001b[A is Up, \u001b[a is Shift+Up), so lowercasing one loses the key. Both the * CSI form (\u001b[A) and the SS3/application-cursor form (\u001bOA) reach us in practice; * which one a terminal sends depends on its keypad mode, not on anything the user chose. */ const KEY_SEQUENCES: Record = { "\u001b[A": "up", "\u001bOA": "up", "\u001b[B": "down", "\u001bOB": "down", "\u001b[C": "right", "\u001bOC": "right", "\u001b[D": "left", "\u001bOD": "left", "\u001b[H": "home", "\u001bOH": "home", "\u001b[1~": "home", "\u001b[7~": "home", "\u001b[F": "end", "\u001bOF": "end", "\u001b[4~": "end", "\u001b[8~": "end", "\u001b[5~": "pageup", "\u001b[6~": "pagedown", }; const KEY_ALIASES: Record = { "\u001b": "escape", escape: "escape", esc: "escape", "\u0003": "ctrl+c", "ctrl+c": "ctrl+c", "\r": "enter", "\n": "enter", enter: "enter", "\t": "tab", tab: "tab", " ": "space", space: "space", arrowup: "up", up: "up", arrowdown: "down", down: "down", pgup: "pageup", pageup: "pageup", pgdn: "pagedown", pagedown: "pagedown", home: "home", end: "end", arrowleft: "left", left: "left", arrowright: "right", right: "right", "\u0006": "ctrl+f", "ctrl+f": "ctrl+f", "\u0015": "ctrl+u", "ctrl+u": "ctrl+u", "\u0004": "ctrl+d", "ctrl+d": "ctrl+d", }; export function normalizeDocketKey(input: string): string { const sequence = KEY_SEQUENCES[input]; if (sequence) return sequence; // Terminals negotiating the Kitty or modifyOtherKeys protocols encode navigation keys as // sequences no static table can enumerate. pi-tui decodes every dialect it enables, so ask // it first for anything longer than a bare character and normalize the name it returns. const decoded = input.length > 1 ? parseKey(input) : undefined; const candidate = decoded ?? input; const alias = KEY_ALIASES[candidate.toLowerCase()]; if (alias) return alias; if (input.length === 1 && input !== " ") return input; return candidate.toLowerCase(); } function displayKey(key: string): string { const normalized = normalizeDocketKey(key); if (normalized === "escape") return "Esc"; if (normalized === "ctrl+c") return "Ctrl+C"; if (normalized === "ctrl+d") return "Ctrl+D"; if (normalized === "ctrl+f") return "Ctrl+F"; if (normalized === "ctrl+u") return "Ctrl+U"; if (normalized === "space") return "Space"; if (normalized === "enter") return "Enter"; if (normalized === "tab") return "Tab"; if (normalized === "pageup") return "PgUp"; if (normalized === "pagedown") return "PgDn"; return key; } export function defineKeymap(name: string, bindings: readonly KeyBinding[]): DocketKeymap { const actions = new Map(); const hints = new Map[]>(); for (const binding of bindings) { const keys = (typeof binding.keys === "string" ? [binding.keys] : [...binding.keys]).map(normalizeDocketKey); for (const key of keys) { const prior = actions.get(key); if (prior && prior !== binding.action) throw new Error(`Docket keymap "${name}" assigns "${key}" to both "${prior}" and "${binding.action}"`); actions.set(key, binding.action); } for (const slot of binding.slots ?? []) { const entries = hints.get(slot) ?? []; entries.push({ keys: keys.map(displayKey), action: binding.action, label: binding.label }); hints.set(slot, entries); } } return { name, resolve(input: string): Action | undefined { return actions.get(normalizeDocketKey(input)); }, hints(slot: KeyHintSlot): KeyHint[] { return hints.get(slot) ?? []; }, }; } export function formatKeyHints(keymap: DocketKeymap, slot: KeyHintSlot, separator = " ยท "): string { return keymap.hints(slot).map((hint) => `${hint.keys.join("/")} ${hint.label}`).join(separator); } export type ScrollKeyAction = "close" | "down" | "up" | "downFast" | "upFast" | "pageDown" | "pageUp" | "top" | "bottom" | "left" | "right" | "leftmost"; export function createScrollingKeymap(): DocketKeymap { return defineKeymap("scrolling viewer", [ { keys: ["escape", "q", "ctrl+c"], action: "close", label: "close", slots: ["footer"] }, { keys: ["j", "down"], action: "down", label: "down", slots: ["footer"] }, { keys: ["k", "up"], action: "up", label: "up", slots: ["footer"] }, { keys: "J", action: "downFast", label: "down 5" }, { keys: "K", action: "upFast", label: "up 5" }, { keys: [" ", "d", "pagedown", "ctrl+f"], action: "pageDown", label: "page", slots: ["footer"] }, { keys: ["b", "u", "pageup", "ctrl+u"], action: "pageUp", label: "page" }, { keys: "g", action: "top", label: "top", slots: ["footer"] }, { keys: "G", action: "bottom", label: "bottom", slots: ["footer"] }, { keys: ["h", "left"], action: "left", label: "left", slots: ["footer"] }, { keys: ["l", "right"], action: "right", label: "right", slots: ["footer"] }, { keys: "0", action: "leftmost", label: "left" }, ]); } export type DashboardKeyAction = "close" | "down" | "up" | "top" | "bottom" | "next" | "help" | "progress" | "peek" | "open" | "load" | "tell" | "attach" | "stop"; export function createWorkerDashboardKeymap(options: { enterLabel?: "verdict" | "details"; canLoad?: boolean } = {}): DocketKeymap { const bindings: KeyBinding[] = [ { keys: ["escape", "q", "ctrl+c"], action: "close", label: "close", slots: ["footer"] }, { keys: ["j", "down"], action: "down", label: "down", slots: ["footer"] }, { keys: ["k", "up"], action: "up", label: "up", slots: ["footer"] }, { keys: "g", action: "top", label: "top" }, { keys: "G", action: "bottom", label: "bottom" }, { keys: "tab", action: "next", label: "next worker", slots: ["help"] }, { keys: "?", action: "help", label: "more", slots: ["footer"] }, { keys: "t", action: "progress", label: "progress details", slots: ["help"] }, { keys: "p", action: "peek", label: "peek", slots: ["footer"] }, { keys: "r", action: "tell", label: "tell", slots: ["footer"] }, { keys: "enter", action: "open", label: options.enterLabel ?? "verdict/details", slots: ["footer"] }, ...(options.canLoad === false ? [] : [{ keys: "l", action: "load", label: "load", slots: ["footer"] } satisfies KeyBinding]), { keys: "a", action: "attach", label: "direct tmux control", slots: ["help"] }, { keys: "x", action: "stop", label: "stop", slots: ["footer"] }, ]; return defineKeymap("worker dashboard", bindings); } export type PickerKeyAction = "close" | "down" | "up" | "top" | "bottom" | "select" | "preview" | "edit" | "delete" | "switchCheckpoint" | "switchWorker" | "switchDeliverable" | "switch"; export function createPickerKeymap(options: { mode: "resume" | "delete" | "load"; canSwitch?: boolean; canPreview?: boolean; canCheckpoint?: boolean; canWorker?: boolean; canDeliverable?: boolean }): DocketKeymap { const bindings: KeyBinding[] = [ { keys: ["escape", "q", "ctrl+c"], action: "close", label: "close", slots: ["footer"] }, { keys: ["j", "down"], action: "down", label: "move", slots: ["footer"] }, { keys: ["k", "up"], action: "up", label: "move", slots: ["footer"] }, { keys: "g", action: "top", label: "top" }, { keys: "G", action: "bottom", label: "bottom" }, { keys: "enter", action: "select", label: options.mode === "resume" ? "Start session" : options.mode, slots: ["footer"] }, ]; if (options.canPreview) bindings.push({ keys: "p", action: "preview", label: "preview", slots: ["footer"] }); if (options.mode === "resume") { bindings.push({ keys: "e", action: "edit", label: "edit", slots: ["footer"] }); bindings.push({ keys: "d", action: "delete", label: "delete", slots: ["footer"] }); } if (options.mode === "delete") bindings.push({ keys: "d", action: "delete", label: "delete", slots: ["footer"] }); if (options.canSwitch) { bindings.push({ keys: "tab", action: "switch", label: "switch", slots: ["footer"] }); } if (options.canSwitch || options.canCheckpoint) bindings.push({ keys: "1", action: "switchCheckpoint", label: "legacy bundles" }); if (options.canSwitch || options.canWorker) bindings.push({ keys: "2", action: "switchWorker", label: "workers" }); if (options.canDeliverable) bindings.push({ keys: "3", action: "switchDeliverable", label: "deliverables" }); return defineKeymap(`picker:${options.mode}`, bindings); } export type EvidenceBundleKeyAction = "close" | "down" | "up" | "top" | "bottom" | "toggle" | "all" | "none" | "save"; export function createEvidenceBundleKeymap(): DocketKeymap { return defineKeymap("evidence bundle", [ { keys: ["escape", "q", "ctrl+c"], action: "close", label: "cancel", slots: ["footer"] }, { keys: ["j", "down"], action: "down", label: "move", slots: ["footer"] }, { keys: ["k", "up"], action: "up", label: "move", slots: ["footer"] }, { keys: "g", action: "top", label: "top" }, { keys: "G", action: "bottom", label: "bottom" }, { keys: "space", action: "toggle", label: "toggle", slots: ["footer"] }, { keys: "a", action: "all", label: "all", slots: ["footer"] }, { keys: "n", action: "none", label: "none", slots: ["footer"] }, { keys: "enter", action: "save", label: "Save", slots: ["footer"] }, ]); } export type VerdictKeyAction = "close" | "down" | "up" | "top" | "bottom" | "select" | "diff" | "hunk" | "report" | "use" | "save" | "option1" | "option2" | "option3" | "option4" | "option5" | "option6" | "option7" | "option8" | "option9"; export function createVerdictKeymap(options: { hasChangeSet: boolean; optionCount: number; canReport?: boolean; canUse?: boolean; canSave?: boolean }): DocketKeymap { const bindings: KeyBinding[] = [ { keys: ["escape", "q", "ctrl+c"], action: "close", label: "close", slots: ["footer"] }, { keys: ["j", "down"], action: "down", label: "move", slots: ["footer"] }, { keys: ["k", "up"], action: "up", label: "move", slots: ["footer"] }, { keys: "g", action: "top", label: "top" }, { keys: "G", action: "bottom", label: "bottom" }, { keys: "enter", action: "select", label: "select", slots: ["footer"] }, ]; if (options.canReport) { bindings.push({ keys: "r", action: "report", label: "Report", slots: ["footer"] }); } if (options.canUse) bindings.push({ keys: "u", action: "use", label: "Use", slots: ["footer"] }); if (options.canSave ?? options.canUse) bindings.push({ keys: "s", action: "save", label: "Save", slots: ["footer"] }); if (options.hasChangeSet) { bindings.push({ keys: "d", action: "diff", label: "full diff", slots: ["footer"] }); bindings.push({ keys: "h", action: "hunk", label: "Hunk review", slots: ["footer"] }); } for (let index = 1; index <= Math.min(9, options.optionCount); index++) { bindings.push({ keys: String(index), action: `option${index}` as VerdictKeyAction, label: "pick", slots: ["footer"] }); } return defineKeymap("verdict", bindings); }