feat: add context-aware ask keymaps
This commit is contained in:
+429
-231
@@ -1,4 +1,4 @@
|
||||
import { Key, matchesKey } from "@mariozechner/pi-tui";
|
||||
import { matchesKey } from "@mariozechner/pi-tui";
|
||||
import type { AskConfig, AskConfigKeymaps } from "../config/schema.ts";
|
||||
|
||||
const DIGIT_SHORTCUT_PATTERN = /^[1-9]$/;
|
||||
@@ -37,14 +37,7 @@ const KNOWN_SPECIAL_KEYS = new Set([
|
||||
"f11",
|
||||
"f12",
|
||||
] as const);
|
||||
const RESERVED_CONFIGURABLE_BINDINGS = new Set([
|
||||
"?",
|
||||
"tab",
|
||||
"shift+tab",
|
||||
"left",
|
||||
"right",
|
||||
"up",
|
||||
"down",
|
||||
const RESERVED_BINDINGS = new Set([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
@@ -55,39 +48,27 @@ const RESERVED_CONFIGURABLE_BINDINGS = new Set([
|
||||
"8",
|
||||
"9",
|
||||
]);
|
||||
const ACTION_LABEL_OVERRIDES: Partial<Record<AskKeyBindingId, string>> = {
|
||||
nextTab: "Tab / →",
|
||||
previousTab: "Shift+Tab / ←",
|
||||
previousOption: "↑ / ↓",
|
||||
nextOption: "↓",
|
||||
numberShortcut: "1..9",
|
||||
|
||||
const CONTEXT_LABELS: Record<AskKeymapContext, string> = {
|
||||
global: "Global",
|
||||
main: "Main flow",
|
||||
editor: "Answer editor",
|
||||
noteEditor: "Note editor",
|
||||
settingsModal: "Settings modal",
|
||||
};
|
||||
|
||||
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 AskKeymapContext = keyof AskConfigKeymaps;
|
||||
export type AskKeymapAction<C extends AskKeymapContext = AskKeymapContext> =
|
||||
keyof AskConfigKeymaps[C] & string;
|
||||
export type AskKeyBindingKind = "command" | "affordance";
|
||||
export type AskConfigurableKeyAction = keyof AskConfigKeymaps;
|
||||
export type AskFixedKeyBindingId =
|
||||
| "settings"
|
||||
| "nextTab"
|
||||
| "previousTab"
|
||||
| "previousOption"
|
||||
| "nextOption"
|
||||
export type AskKeyBindingId =
|
||||
| `${AskKeymapContext}.${string}`
|
||||
| "numberShortcut"
|
||||
| "fileReference";
|
||||
export type AskKeyBindingId = AskConfigurableKeyAction | AskFixedKeyBindingId;
|
||||
|
||||
export interface AskKeyBinding {
|
||||
contexts: readonly string[];
|
||||
@@ -106,105 +87,84 @@ export type FooterKeymapContext =
|
||||
| "note";
|
||||
|
||||
export const DEFAULT_ASK_KEYMAPS: AskConfigKeymaps = {
|
||||
cancel: "esc",
|
||||
dismiss: "ctrl+c",
|
||||
toggle: "space",
|
||||
confirm: "enter",
|
||||
optionNote: "n",
|
||||
questionNote: "shift+n",
|
||||
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 CONFIGURABLE_BINDING_METADATA: readonly ConfigurableBindingMetadata[] = [
|
||||
{
|
||||
id: "cancel",
|
||||
kind: "command",
|
||||
description: "Cancel flow or close/save editor draft",
|
||||
contexts: ["Main flow", "Editors"],
|
||||
const DESCRIPTIONS: Record<AskKeymapContext, Record<string, string>> = {
|
||||
global: {
|
||||
dismiss: "Dismiss the active ask surface",
|
||||
settings: "Open ask settings",
|
||||
},
|
||||
{
|
||||
id: "dismiss",
|
||||
kind: "command",
|
||||
description: "Dismiss entire ask flow immediately",
|
||||
contexts: ["Anywhere"],
|
||||
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",
|
||||
},
|
||||
{
|
||||
id: "toggle",
|
||||
kind: "command",
|
||||
description: "Toggle selected option",
|
||||
contexts: ["Questions"],
|
||||
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",
|
||||
},
|
||||
{
|
||||
id: "confirm",
|
||||
kind: "command",
|
||||
description: "Confirm selection, continue, save, or submit",
|
||||
contexts: ["Main flow", "Editors"],
|
||||
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",
|
||||
},
|
||||
{
|
||||
id: "optionNote",
|
||||
kind: "command",
|
||||
description: "Edit selected option note",
|
||||
contexts: ["Question tabs"],
|
||||
settingsModal: {
|
||||
close: "Close settings",
|
||||
nextOption: "Move to next setting",
|
||||
previousOption: "Move to previous setting",
|
||||
toggle: "Toggle selected setting",
|
||||
},
|
||||
{
|
||||
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",
|
||||
contexts: ["Main flow", "Empty editor"],
|
||||
},
|
||||
{
|
||||
id: "nextTab",
|
||||
keys: [Key.tab, Key.right],
|
||||
kind: "command",
|
||||
description: "Switch to next tab",
|
||||
contexts: ["Main flow", "Empty editor"],
|
||||
},
|
||||
{
|
||||
id: "previousTab",
|
||||
keys: [Key.shift("tab"), Key.left],
|
||||
kind: "command",
|
||||
description: "Switch to previous tab",
|
||||
contexts: ["Main flow", "Empty editor"],
|
||||
},
|
||||
{
|
||||
id: "previousOption",
|
||||
keys: [Key.up],
|
||||
kind: "command",
|
||||
description: "Move between options/actions",
|
||||
contexts: ["Main flow", "Empty editor"],
|
||||
},
|
||||
{
|
||||
id: "nextOption",
|
||||
keys: [Key.down],
|
||||
kind: "command",
|
||||
description: "Move to next option/action",
|
||||
contexts: ["Main flow", "Empty editor"],
|
||||
},
|
||||
{
|
||||
id: "numberShortcut",
|
||||
keys: ["1..9"],
|
||||
kind: "command",
|
||||
label: "1..9",
|
||||
description: "Quick-select option or submit action",
|
||||
contexts: ["Main flow", "Submit tab"],
|
||||
},
|
||||
{
|
||||
id: "fileReference",
|
||||
keys: ["@"],
|
||||
kind: "affordance",
|
||||
description: "Reference files while typing answers or notes",
|
||||
contexts: ["Editors"],
|
||||
},
|
||||
] as const;
|
||||
};
|
||||
|
||||
const footerHint = (
|
||||
binding: AskKeyBinding,
|
||||
@@ -213,6 +173,18 @@ const footerHint = (
|
||||
) => `${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) => {
|
||||
@@ -227,6 +199,10 @@ export function formatKeybindingLabel(key: string): string {
|
||||
.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) {
|
||||
@@ -242,33 +218,71 @@ export function matchesDigitShortcut(data: string): number | 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(" / "),
|
||||
},
|
||||
): Record<string, AskKeyBinding> {
|
||||
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<C extends AskKeymapContext>(
|
||||
config: AskConfig,
|
||||
context: C
|
||||
): Record<AskKeymapAction<C>, AskKeyBinding> {
|
||||
const bindings = getAskKeyBindings(config);
|
||||
const actions = config.keymaps[context] as Record<string, readonly string[]>;
|
||||
return Object.fromEntries(
|
||||
Object.keys(actions).map((action) => [
|
||||
action,
|
||||
bindings[`${context}.${action}`],
|
||||
])
|
||||
) as Record<AskFixedKeyBindingId, AskKeyBinding>;
|
||||
return {
|
||||
...customizable,
|
||||
...fixed,
|
||||
};
|
||||
) as Record<AskKeymapAction<C>, AskKeyBinding>;
|
||||
}
|
||||
|
||||
export function getGlobalBindings(
|
||||
config: AskConfig
|
||||
): Record<AskKeymapAction<"global">, AskKeyBinding> {
|
||||
return getAskContextBindings(config, "global");
|
||||
}
|
||||
|
||||
export function getAskKeymaps(config: AskConfig): {
|
||||
@@ -277,8 +291,10 @@ export function getAskKeymaps(config: AskConfig): {
|
||||
} {
|
||||
const bindings = getAskKeyBindings(config);
|
||||
return {
|
||||
customizable: CONFIGURABLE_BINDING_METADATA.map(({ id }) => bindings[id]),
|
||||
fixed: FIXED_BINDING_METADATA.map(({ id }) => bindings[id]),
|
||||
customizable: Object.keys(bindings)
|
||||
.filter((id) => id !== "numberShortcut" && id !== "fileReference")
|
||||
.map((id) => bindings[id]),
|
||||
fixed: [bindings.numberShortcut, bindings.fileReference],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -286,99 +302,260 @@ 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 = bindings.previousOption.label.replaceAll(
|
||||
" / ",
|
||||
""
|
||||
);
|
||||
const optionNavigationLabel =
|
||||
`${main.previousOption.label}${main.nextOption.label}`.replaceAll(
|
||||
" / ",
|
||||
""
|
||||
);
|
||||
const tabNavigationLabel = "⇆";
|
||||
const noteNavigationLabel = `${bindings.optionNote.label}/${bindings.questionNote.label}`;
|
||||
const noteNavigationLabel = `${main.optionNote.label}/${main.questionNote.label}`;
|
||||
const hintsByContext: Record<FooterKeymapContext, readonly string[]> = {
|
||||
input: [
|
||||
footerHint(bindings.confirm, "submit"),
|
||||
footerHint(bindings.cancel, "close"),
|
||||
footerHint(bindings.settings, "settings"),
|
||||
footerHint(editor.submit, "submit"),
|
||||
footerHint(editor.close, "close"),
|
||||
footerHint(global.settings, "settings"),
|
||||
],
|
||||
note: [
|
||||
footerHint(bindings.confirm, "save"),
|
||||
footerHint(bindings.cancel, "close"),
|
||||
footerHint(bindings.settings, "settings"),
|
||||
footerHint(noteEditor.save, "save"),
|
||||
footerHint(noteEditor.close, "close"),
|
||||
footerHint(global.settings, "settings"),
|
||||
],
|
||||
submit: [
|
||||
footerHint(bindings.numberShortcut, "hotkeys"),
|
||||
footerHint(bindings.previousOption, "select", optionNavigationLabel),
|
||||
footerHint(bindings.confirm, "confirm"),
|
||||
footerHint(bindings.cancel, "cancel"),
|
||||
footerHint(bindings.settings, "settings"),
|
||||
footerHint(main.previousOption, "select", optionNavigationLabel),
|
||||
footerHint(main.confirm, "confirm"),
|
||||
footerHint(main.cancel, "cancel"),
|
||||
footerHint(global.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"),
|
||||
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(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"),
|
||||
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: Partial<Record<AskConfigurableKeyAction, unknown>> | undefined
|
||||
keymaps: unknown
|
||||
): { ok: true; keymaps: AskConfigKeymaps } | { ok: false; error: string } {
|
||||
if (!keymaps) {
|
||||
return { ok: true, keymaps: { ...DEFAULT_ASK_KEYMAPS } };
|
||||
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 action of Object.keys(
|
||||
for (const context of Object.keys(
|
||||
DEFAULT_ASK_KEYMAPS
|
||||
) as AskConfigurableKeyAction[]) {
|
||||
const rawValue = keymaps[action];
|
||||
if (typeof rawValue !== "string") {
|
||||
return { ok: false, error: `Missing keymap for ${action}.` };
|
||||
) as AskKeymapContext[]) {
|
||||
const rawContext = (candidate as Record<string, unknown>)[context];
|
||||
if (!(rawContext && typeof rawContext === "object")) {
|
||||
return { ok: false, error: `Missing keymap context ${context}.` };
|
||||
}
|
||||
const parsed = normalizeKeyId(rawValue);
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Invalid keymap for ${action}: ${parsed.error}`,
|
||||
};
|
||||
const contextResult = normalizeContextKeymaps(
|
||||
context,
|
||||
rawContext as Record<string, unknown>
|
||||
);
|
||||
if (!contextResult.ok) {
|
||||
return contextResult;
|
||||
}
|
||||
if (RESERVED_CONFIGURABLE_BINDINGS.has(parsed.keyId)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Reserved keymap for ${action}: ${formatKeybindingLabel(parsed.keyId)}.`,
|
||||
};
|
||||
}
|
||||
normalized[action] = parsed.keyId;
|
||||
normalized[context] = contextResult.keymaps as never;
|
||||
}
|
||||
|
||||
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);
|
||||
const globalDuplicates = validateCrossContextConflicts(normalized);
|
||||
if (globalDuplicates) {
|
||||
return { ok: false, error: globalDuplicates };
|
||||
}
|
||||
|
||||
return { ok: true, keymaps: normalized };
|
||||
}
|
||||
|
||||
function normalizeContextKeymaps(
|
||||
context: AskKeymapContext,
|
||||
rawContext: Record<string, unknown>
|
||||
):
|
||||
| { ok: true; keymaps: Record<string, string[]> }
|
||||
| { ok: false; error: string } {
|
||||
const defaults = DEFAULT_ASK_KEYMAPS[context] as Record<
|
||||
string,
|
||||
readonly string[]
|
||||
>;
|
||||
const normalized: Record<string, string[]> = {};
|
||||
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, readonly string[]>
|
||||
): string | undefined {
|
||||
const seen = new Map<string, string>();
|
||||
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<string, readonly string[]>
|
||||
).map((binding) => ({ ...binding, context }))
|
||||
);
|
||||
}
|
||||
|
||||
function flattenBindings(
|
||||
bindings: Record<string, readonly string[]>
|
||||
): 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<string, unknown>;
|
||||
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 } {
|
||||
@@ -418,27 +595,48 @@ function normalizeKeyId(
|
||||
const modifierPrefix = MODIFIER_ORDER.filter((modifier) =>
|
||||
modifiers.has(modifier)
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
keyId: [...modifierPrefix, baseKey].join("+"),
|
||||
};
|
||||
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];
|
||||
function cloneKeymaps(keymaps: AskConfigKeymaps): AskConfigKeymaps {
|
||||
return {
|
||||
...binding,
|
||||
keys: [key],
|
||||
label: formatKeybindingLabel(key),
|
||||
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],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user