feat(ui): add ask keymap help modal

This commit is contained in:
Erik Kotsiuba
2026-04-28 22:35:43 +02:00
parent 3c26bfa7c9
commit c9930b494f
23 changed files with 529 additions and 72 deletions
+247
View File
@@ -0,0 +1,247 @@
import { Key, matchesKey } from "@mariozechner/pi-tui";
const DIGIT_SHORTCUT_PATTERN = /^[1-9]$/;
type KeyId = Parameters<typeof matchesKey>[1];
type InputKey = KeyId | (string & {});
export type AskKeyBindingKind = "command" | "affordance";
export interface AskKeyBinding {
contexts: readonly string[];
description: string;
id: string;
keys: readonly InputKey[];
kind: AskKeyBindingKind;
label: string;
}
export type FooterKeymapContext =
| "default"
| "multi"
| "submit"
| "input"
| "note";
export const ASK_KEY_BINDINGS = {
help: {
id: "help",
keys: ["?"],
kind: "command",
label: "?",
description: "Show this menu",
contexts: ["Main flow"],
},
cancel: {
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: "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",
label: "1..9",
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}`;
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 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;
}