feat: add config-backed ask keymaps

This commit is contained in:
Erik Kotsiuba
2026-04-28 22:35:43 +02:00
parent 5c0d8964fb
commit e8a36b72ef
31 changed files with 2192 additions and 422 deletions
+427 -155
View File
@@ -1,16 +1,98 @@
import { Key, 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_CONFIGURABLE_BINDINGS = new Set([
"?",
"tab",
"shift+tab",
"left",
"right",
"up",
"down",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
]);
const ACTION_LABEL_OVERRIDES: Partial<Record<AskKeyBindingId, string>> = {
nextTab: "Tab / →",
previousTab: "Shift+Tab / ←",
previousOption: "↑ / ↓",
nextOption: "↓",
numberShortcut: "1..9",
};
type KeyId = Parameters<typeof matchesKey>[1];
type InputKey = KeyId | (string & {});
type ConfigurableBindingMetadata = Omit<AskKeyBinding, "keys" | "label"> & {
id: AskConfigurableKeyAction;
};
type FixedBindingMetadata = Omit<AskKeyBinding, "keys" | "label"> & {
id: AskFixedKeyBindingId;
keys: readonly InputKey[];
label?: string;
};
type ModifierName = (typeof MODIFIER_ORDER)[number];
export type AskKeyBindingKind = "command" | "affordance";
export type AskConfigurableKeyAction = keyof AskConfigKeymaps;
export type AskFixedKeyBindingId =
| "settings"
| "nextTab"
| "previousTab"
| "previousOption"
| "nextOption"
| "numberShortcut"
| "fileReference";
export type AskKeyBindingId = AskConfigurableKeyAction | AskFixedKeyBindingId;
export interface AskKeyBinding {
contexts: readonly string[];
description: string;
id: string;
id: AskKeyBindingId;
keys: readonly InputKey[];
kind: AskKeyBindingKind;
label: string;
@@ -23,64 +105,91 @@ export type FooterKeymapContext =
| "input"
| "note";
export const ASK_KEY_BINDINGS = {
help: {
id: "help",
keys: ["?"],
kind: "command",
label: "?",
description: "Show this menu",
contexts: ["Main flow"],
},
cancel: {
export const DEFAULT_ASK_KEYMAPS: AskConfigKeymaps = {
cancel: "esc",
dismiss: "ctrl+c",
toggle: "space",
confirm: "enter",
optionNote: "n",
questionNote: "shift+n",
};
const CONFIGURABLE_BINDING_METADATA: readonly ConfigurableBindingMetadata[] = [
{
id: "cancel",
keys: [Key.escape],
kind: "command",
label: "Esc",
description: "Cancel flow or close/save editor draft",
contexts: ["Main flow", "Editors"],
},
dismiss: {
{
id: "dismiss",
keys: [Key.ctrl("c")],
kind: "command",
label: "Ctrl+C",
description: "Dismiss entire ask flow immediately",
contexts: ["Anywhere"],
},
nextTab: {
{
id: "toggle",
kind: "command",
description: "Toggle selected option",
contexts: ["Questions"],
},
{
id: "confirm",
kind: "command",
description: "Confirm selection, continue, save, or submit",
contexts: ["Main flow", "Editors"],
},
{
id: "optionNote",
kind: "command",
description: "Edit selected option note",
contexts: ["Question tabs"],
},
{
id: "questionNote",
kind: "command",
description: "Edit question note",
contexts: ["Question tabs"],
},
] as const;
const FIXED_BINDING_METADATA: readonly FixedBindingMetadata[] = [
{
id: "settings",
keys: ["?"],
kind: "command",
description: "Open ask settings modal",
contexts: ["Main flow", "Empty editor"],
},
{
id: "nextTab",
keys: [Key.tab, Key.right],
kind: "command",
label: "Tab / →",
description: "Switch to next tab",
contexts: ["Main flow", "Empty editor"],
},
previousTab: {
{
id: "previousTab",
keys: [Key.shift("tab"), Key.left],
kind: "command",
label: "Shift+Tab / ←",
description: "Switch to previous tab",
contexts: ["Main flow", "Empty editor"],
},
previousOption: {
{
id: "previousOption",
keys: [Key.up],
kind: "command",
label: "↑ / ↓",
description: "Move between options/actions",
contexts: ["Main flow", "Empty editor"],
},
nextOption: {
{
id: "nextOption",
keys: [Key.down],
kind: "command",
label: "↓",
description: "Move to next option/action",
contexts: ["Main flow", "Empty editor"],
},
numberShortcut: {
{
id: "numberShortcut",
keys: ["1..9"],
kind: "command",
@@ -88,149 +197,34 @@ export const ASK_KEY_BINDINGS = {
description: "Quick-select option or submit action",
contexts: ["Main flow", "Submit tab"],
},
toggle: {
id: "toggle",
keys: [Key.space],
kind: "command",
label: "Space",
description: "Toggle selected option",
contexts: ["Multi-select questions"],
},
confirm: {
id: "confirm",
keys: [Key.enter],
kind: "command",
label: "Enter",
description: "Confirm selection, continue, save, or submit",
contexts: ["Main flow", "Editors"],
},
optionNote: {
id: "optionNote",
keys: ["n"],
kind: "command",
label: "N",
description: "Edit selected option note",
contexts: ["Question tabs"],
},
questionNote: {
id: "questionNote",
keys: ["N"],
kind: "command",
label: "Shift+N",
description: "Edit question note",
contexts: ["Question tabs"],
},
fileReference: {
{
id: "fileReference",
keys: ["@"],
kind: "affordance",
label: "@",
description: "Reference files while typing answers or notes",
contexts: ["Editors"],
},
} as const satisfies Record<string, AskKeyBinding>;
export const ASK_KEYMAPS: readonly AskKeyBinding[] = [
ASK_KEY_BINDINGS.help,
ASK_KEY_BINDINGS.cancel,
ASK_KEY_BINDINGS.dismiss,
ASK_KEY_BINDINGS.nextTab,
ASK_KEY_BINDINGS.previousTab,
ASK_KEY_BINDINGS.previousOption,
ASK_KEY_BINDINGS.numberShortcut,
ASK_KEY_BINDINGS.toggle,
ASK_KEY_BINDINGS.confirm,
ASK_KEY_BINDINGS.optionNote,
ASK_KEY_BINDINGS.questionNote,
ASK_KEY_BINDINGS.fileReference,
] as const;
const footerHint = (
binding: AskKeyBinding,
action: string,
label = binding.label
): string => `${label} ${action}`;
const optionNavigationLabel = ASK_KEY_BINDINGS.previousOption.label.replaceAll(
" / ",
""
);
const tabNavigationLabel = "⇆";
const noteNavigationLabel = `${ASK_KEY_BINDINGS.optionNote.label}/${ASK_KEY_BINDINGS.questionNote.label}`;
) => `${label} ${action}`;
export const ASK_FOOTER_HINTS_BY_CONTEXT: Record<
FooterKeymapContext,
readonly string[]
> = {
input: [
footerHint(ASK_KEY_BINDINGS.confirm, "submit"),
footerHint(ASK_KEY_BINDINGS.cancel, "close"),
footerHint(ASK_KEY_BINDINGS.help, "help"),
],
note: [
footerHint(ASK_KEY_BINDINGS.confirm, "save"),
footerHint(ASK_KEY_BINDINGS.cancel, "close"),
footerHint(ASK_KEY_BINDINGS.help, "help"),
],
submit: [
footerHint(ASK_KEY_BINDINGS.numberShortcut, "hotkeys"),
footerHint(
ASK_KEY_BINDINGS.previousOption,
"select",
optionNavigationLabel
),
footerHint(ASK_KEY_BINDINGS.confirm, "confirm"),
footerHint(ASK_KEY_BINDINGS.cancel, "cancel"),
footerHint(ASK_KEY_BINDINGS.help, "help"),
],
multi: [
footerHint(ASK_KEY_BINDINGS.nextTab, "tab", tabNavigationLabel),
footerHint(
ASK_KEY_BINDINGS.previousOption,
"select",
optionNavigationLabel
),
footerHint(ASK_KEY_BINDINGS.toggle, "toggle"),
footerHint(ASK_KEY_BINDINGS.confirm, "continue"),
footerHint(ASK_KEY_BINDINGS.optionNote, "note", noteNavigationLabel),
footerHint(ASK_KEY_BINDINGS.cancel, "dismiss"),
footerHint(ASK_KEY_BINDINGS.help, "help"),
],
default: [
footerHint(ASK_KEY_BINDINGS.nextTab, "tab", tabNavigationLabel),
footerHint(
ASK_KEY_BINDINGS.previousOption,
"select",
optionNavigationLabel
),
footerHint(ASK_KEY_BINDINGS.confirm, "confirm"),
footerHint(ASK_KEY_BINDINGS.optionNote, "note", noteNavigationLabel),
footerHint(ASK_KEY_BINDINGS.cancel, "dismiss"),
footerHint(ASK_KEY_BINDINGS.help, "help"),
],
} as const;
export const ASK_HELP_CLOSE_BINDINGS = [
ASK_KEY_BINDINGS.cancel,
ASK_KEY_BINDINGS.dismiss,
ASK_KEY_BINDINGS.help,
] as const;
export const ASK_HELP_CLOSE_HINT = `Press ${ASK_HELP_CLOSE_BINDINGS.map(
(binding) => binding.label
)
.map((label, index, labels) => {
if (index === 0) {
return label;
}
if (index === labels.length - 1) {
return `or ${label}`;
}
return label;
})
.join(", ")} to close.`;
export function renderFooterKeymaps(context: FooterKeymapContext): string {
return ` ${ASK_FOOTER_HINTS_BY_CONTEXT[context].join(" · ")}`;
export function formatKeybindingLabel(key: string): string {
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 matchesBinding(data: string, binding: AskKeyBinding): boolean {
@@ -245,3 +239,281 @@ export function matchesBinding(data: string, binding: AskKeyBinding): boolean {
export function matchesDigitShortcut(data: string): number | null {
return DIGIT_SHORTCUT_PATTERN.test(data) ? Number(data) : null;
}
export function getAskKeyBindings(
config: AskConfig
): Record<AskKeyBindingId, AskKeyBinding> {
const customizable: Record<AskConfigurableKeyAction, AskKeyBinding> = {
cancel: createCustomizableBinding(config, "cancel"),
dismiss: createCustomizableBinding(config, "dismiss"),
toggle: createCustomizableBinding(config, "toggle"),
confirm: createCustomizableBinding(config, "confirm"),
optionNote: createCustomizableBinding(config, "optionNote"),
questionNote: createCustomizableBinding(config, "questionNote"),
};
const fixed = Object.fromEntries(
FIXED_BINDING_METADATA.map((binding) => [
binding.id,
{
...binding,
label:
binding.label ??
ACTION_LABEL_OVERRIDES[binding.id] ??
binding.keys
.map((key) => formatKeybindingLabel(String(key)))
.join(" / "),
},
])
) as Record<AskFixedKeyBindingId, AskKeyBinding>;
return {
...customizable,
...fixed,
};
}
export function getAskKeymaps(config: AskConfig): {
customizable: readonly AskKeyBinding[];
fixed: readonly AskKeyBinding[];
} {
const bindings = getAskKeyBindings(config);
return {
customizable: CONFIGURABLE_BINDING_METADATA.map(({ id }) => bindings[id]),
fixed: FIXED_BINDING_METADATA.map(({ id }) => bindings[id]),
};
}
export function renderFooterKeymaps(
config: AskConfig,
context: FooterKeymapContext
): string {
const bindings = getAskKeyBindings(config);
const optionNavigationLabel = bindings.previousOption.label.replaceAll(
" / ",
""
);
const tabNavigationLabel = "⇆";
const noteNavigationLabel = `${bindings.optionNote.label}/${bindings.questionNote.label}`;
const hintsByContext: Record<FooterKeymapContext, readonly string[]> = {
input: [
footerHint(bindings.confirm, "submit"),
footerHint(bindings.cancel, "close"),
footerHint(bindings.settings, "settings"),
],
note: [
footerHint(bindings.confirm, "save"),
footerHint(bindings.cancel, "close"),
footerHint(bindings.settings, "settings"),
],
submit: [
footerHint(bindings.numberShortcut, "hotkeys"),
footerHint(bindings.previousOption, "select", optionNavigationLabel),
footerHint(bindings.confirm, "confirm"),
footerHint(bindings.cancel, "cancel"),
footerHint(bindings.settings, "settings"),
],
multi: [
footerHint(bindings.nextTab, "tab", tabNavigationLabel),
footerHint(bindings.previousOption, "select", optionNavigationLabel),
footerHint(bindings.toggle, "toggle"),
footerHint(bindings.confirm, "continue"),
footerHint(bindings.optionNote, "note", noteNavigationLabel),
footerHint(bindings.cancel, "dismiss"),
footerHint(bindings.settings, "settings"),
],
default: [
footerHint(bindings.nextTab, "tab", tabNavigationLabel),
footerHint(bindings.previousOption, "select", optionNavigationLabel),
footerHint(bindings.confirm, "confirm"),
footerHint(bindings.optionNote, "note", noteNavigationLabel),
footerHint(bindings.cancel, "dismiss"),
footerHint(bindings.settings, "settings"),
],
};
return ` ${hintsByContext[context].join(" · ")}`;
}
export function normalizeConfiguredKeymaps(
keymaps: Partial<Record<AskConfigurableKeyAction, unknown>> | undefined
): { ok: true; keymaps: AskConfigKeymaps } | { ok: false; error: string } {
if (!keymaps) {
return { ok: true, keymaps: { ...DEFAULT_ASK_KEYMAPS } };
}
const normalized = {} as AskConfigKeymaps;
for (const action of Object.keys(
DEFAULT_ASK_KEYMAPS
) as AskConfigurableKeyAction[]) {
const rawValue = keymaps[action];
if (typeof rawValue !== "string") {
return { ok: false, error: `Missing keymap for ${action}.` };
}
const parsed = normalizeKeyId(rawValue);
if (!parsed.ok) {
return {
ok: false,
error: `Invalid keymap for ${action}: ${parsed.error}`,
};
}
if (RESERVED_CONFIGURABLE_BINDINGS.has(parsed.keyId)) {
return {
ok: false,
error: `Reserved keymap for ${action}: ${formatKeybindingLabel(parsed.keyId)}.`,
};
}
normalized[action] = parsed.keyId;
}
const seen = new Map<string, AskConfigurableKeyAction>();
for (const action of Object.keys(normalized) as AskConfigurableKeyAction[]) {
const key = normalized[action];
const existing = seen.get(key);
if (existing) {
return {
ok: false,
error: `Duplicate keymap ${formatKeybindingLabel(key)} for ${existing} and ${action}.`,
};
}
seen.set(key, action);
}
return { ok: true, keymaps: normalized };
}
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<ModifierName>();
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 createCustomizableBinding(
config: AskConfig,
id: AskConfigurableKeyAction
): AskKeyBinding {
const binding = CONFIGURABLE_BINDING_METADATA.find(
(entry) => entry.id === id
);
if (!binding) {
throw new Error(`Unknown customizable ask key binding: ${id}`);
}
const key = config.keymaps[id];
return {
...binding,
keys: [key],
label: formatKeybindingLabel(key),
};
}
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<infer T> ? 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;
}
}