@geoqiao/pi-ask 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +194 -0
- package/LICENSE +22 -0
- package/README.md +282 -0
- package/docs/README.md +33 -0
- package/docs/configuration.md +406 -0
- package/docs/contract.md +309 -0
- package/docs/remote-events.md +187 -0
- package/package.json +130 -0
- package/skills/ask-user/SKILL.md +110 -0
- package/src/answer-commands.ts +361 -0
- package/src/answer-extraction.ts +354 -0
- package/src/ask-payload-store.ts +86 -0
- package/src/ask-settings-command.ts +14 -0
- package/src/ask-tool-helpers.ts +172 -0
- package/src/ask-tool.ts +84 -0
- package/src/config/defaults.ts +216 -0
- package/src/config/migrate.ts +70 -0
- package/src/config/migrations/index.ts +139 -0
- package/src/config/migrations/types.ts +10 -0
- package/src/config/schema.ts +287 -0
- package/src/config/store.ts +227 -0
- package/src/constants/keymaps.ts +721 -0
- package/src/constants/text.ts +12 -0
- package/src/constants/ui.ts +22 -0
- package/src/index.ts +30 -0
- package/src/math.ts +3 -0
- package/src/notifications.ts +119 -0
- package/src/remote-ask.ts +563 -0
- package/src/result-format.ts +157 -0
- package/src/result.ts +23 -0
- package/src/schema.ts +74 -0
- package/src/state/answers.ts +251 -0
- package/src/state/create.ts +18 -0
- package/src/state/editor.ts +70 -0
- package/src/state/navigation.ts +86 -0
- package/src/state/normalize.ts +326 -0
- package/src/state/question-type.ts +128 -0
- package/src/state/result.ts +263 -0
- package/src/state/selectors.ts +135 -0
- package/src/state/transitions.ts +330 -0
- package/src/state/view.ts +28 -0
- package/src/text.ts +98 -0
- package/src/types.ts +169 -0
- package/src/ui/auto-submit.ts +36 -0
- package/src/ui/autocomplete.ts +52 -0
- package/src/ui/controller.ts +645 -0
- package/src/ui/dismiss-guard.ts +26 -0
- package/src/ui/input.ts +160 -0
- package/src/ui/render-frame.ts +235 -0
- package/src/ui/render-helpers.ts +385 -0
- package/src/ui/render-question.ts +288 -0
- package/src/ui/render-submit.ts +168 -0
- package/src/ui/render-types.ts +33 -0
- package/src/ui/render.ts +53 -0
- package/src/ui/review-shortcuts.ts +43 -0
- package/src/ui/settings-list.ts +461 -0
- package/src/ui/show-settings.ts +37 -0
- package/src/ui/view-models/question.ts +203 -0
- package/src/ui/view-models/review.ts +100 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { AskConfig, AskConfigKeymaps } from "../config/schema.ts";
|
|
3
|
+
|
|
4
|
+
const DIGIT_SHORTCUT_PATTERN = /^[1-9]$/;
|
|
5
|
+
const LETTER_PATTERN = /^[a-z]$/;
|
|
6
|
+
const DIGIT_PATTERN = /^[0-9]$/;
|
|
7
|
+
const SYMBOL_PATTERN = /^[`\-=[\]\\;'.,/!@#$%^&*()_+|~{}:<>?]$/;
|
|
8
|
+
const FUNCTION_KEY_PATTERN = /^f([1-9]|1[0-2])$/;
|
|
9
|
+
const MODIFIER_ORDER = ["ctrl", "shift", "alt", "super"] as const;
|
|
10
|
+
const KNOWN_SPECIAL_KEYS = new Set([
|
|
11
|
+
"esc",
|
|
12
|
+
"enter",
|
|
13
|
+
"tab",
|
|
14
|
+
"space",
|
|
15
|
+
"backspace",
|
|
16
|
+
"delete",
|
|
17
|
+
"insert",
|
|
18
|
+
"clear",
|
|
19
|
+
"home",
|
|
20
|
+
"end",
|
|
21
|
+
"pageUp",
|
|
22
|
+
"pageDown",
|
|
23
|
+
"up",
|
|
24
|
+
"down",
|
|
25
|
+
"left",
|
|
26
|
+
"right",
|
|
27
|
+
"f1",
|
|
28
|
+
"f2",
|
|
29
|
+
"f3",
|
|
30
|
+
"f4",
|
|
31
|
+
"f5",
|
|
32
|
+
"f6",
|
|
33
|
+
"f7",
|
|
34
|
+
"f8",
|
|
35
|
+
"f9",
|
|
36
|
+
"f10",
|
|
37
|
+
"f11",
|
|
38
|
+
"f12",
|
|
39
|
+
] as const);
|
|
40
|
+
const RESERVED_BINDINGS = new Set([
|
|
41
|
+
"1",
|
|
42
|
+
"2",
|
|
43
|
+
"3",
|
|
44
|
+
"4",
|
|
45
|
+
"5",
|
|
46
|
+
"6",
|
|
47
|
+
"7",
|
|
48
|
+
"8",
|
|
49
|
+
"9",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
const CONTEXT_LABELS: Record<AskKeymapContext, string> = {
|
|
53
|
+
global: "Global",
|
|
54
|
+
main: "Main flow",
|
|
55
|
+
editor: "Answer editor",
|
|
56
|
+
noteEditor: "Note editor",
|
|
57
|
+
settingsModal: "Settings modal",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type KeyId = Parameters<typeof matchesKey>[1];
|
|
61
|
+
type InputKey = KeyId | (string & {});
|
|
62
|
+
type ModifierName = (typeof MODIFIER_ORDER)[number];
|
|
63
|
+
|
|
64
|
+
export type AskKeymapContext = keyof AskConfigKeymaps;
|
|
65
|
+
export type AskKeymapAction<C extends AskKeymapContext = AskKeymapContext> =
|
|
66
|
+
keyof AskConfigKeymaps[C] & string;
|
|
67
|
+
export type AskKeyBindingKind = "command" | "affordance";
|
|
68
|
+
export type AskKeyBindingId =
|
|
69
|
+
| `${AskKeymapContext}.${string}`
|
|
70
|
+
| "numberShortcut"
|
|
71
|
+
| "fileReference";
|
|
72
|
+
|
|
73
|
+
export interface AskKeyBinding {
|
|
74
|
+
contexts: readonly string[];
|
|
75
|
+
description: string;
|
|
76
|
+
id: AskKeyBindingId;
|
|
77
|
+
keys: readonly InputKey[];
|
|
78
|
+
kind: AskKeyBindingKind;
|
|
79
|
+
label: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type FooterKeymapContext =
|
|
83
|
+
| "default"
|
|
84
|
+
| "multi"
|
|
85
|
+
| "submit"
|
|
86
|
+
| "input"
|
|
87
|
+
| "note";
|
|
88
|
+
|
|
89
|
+
export const DEFAULT_ASK_KEYMAPS: AskConfigKeymaps = {
|
|
90
|
+
global: {
|
|
91
|
+
dismiss: ["ctrl+c"],
|
|
92
|
+
settings: ["?"],
|
|
93
|
+
},
|
|
94
|
+
main: {
|
|
95
|
+
confirm: ["enter"],
|
|
96
|
+
cancel: ["esc"],
|
|
97
|
+
changeQuestionType: ["t"],
|
|
98
|
+
toggle: ["space"],
|
|
99
|
+
nextTab: ["tab", "right"],
|
|
100
|
+
previousTab: ["shift+tab", "left"],
|
|
101
|
+
nextOption: ["down"],
|
|
102
|
+
previousOption: ["up"],
|
|
103
|
+
optionNote: ["n"],
|
|
104
|
+
questionNote: ["shift+n"],
|
|
105
|
+
},
|
|
106
|
+
editor: {
|
|
107
|
+
submit: ["enter"],
|
|
108
|
+
close: ["esc"],
|
|
109
|
+
nextTabWhenEmpty: ["tab", "right"],
|
|
110
|
+
previousTabWhenEmpty: ["shift+tab", "left"],
|
|
111
|
+
nextOptionWhenEmpty: ["down"],
|
|
112
|
+
previousOptionWhenEmpty: ["up"],
|
|
113
|
+
},
|
|
114
|
+
noteEditor: {
|
|
115
|
+
save: ["enter"],
|
|
116
|
+
close: ["esc"],
|
|
117
|
+
nextTabWhenEmpty: ["tab", "right"],
|
|
118
|
+
previousTabWhenEmpty: ["shift+tab", "left"],
|
|
119
|
+
nextOptionWhenEmpty: ["down"],
|
|
120
|
+
previousOptionWhenEmpty: ["up"],
|
|
121
|
+
},
|
|
122
|
+
settingsModal: {
|
|
123
|
+
close: ["esc", "ctrl+c", "?"],
|
|
124
|
+
nextOption: ["down"],
|
|
125
|
+
previousOption: ["up"],
|
|
126
|
+
toggle: ["enter", "space"],
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const DESCRIPTIONS: Record<AskKeymapContext, Record<string, string>> = {
|
|
131
|
+
global: {
|
|
132
|
+
dismiss: "Dismiss the active ask surface",
|
|
133
|
+
settings: "Open ask settings",
|
|
134
|
+
},
|
|
135
|
+
main: {
|
|
136
|
+
confirm: "Confirm selection, continue, or submit",
|
|
137
|
+
cancel: "Cancel flow",
|
|
138
|
+
changeQuestionType: "Change current question type",
|
|
139
|
+
toggle: "Toggle selected option",
|
|
140
|
+
nextTab: "Switch to next tab",
|
|
141
|
+
previousTab: "Switch to previous tab",
|
|
142
|
+
nextOption: "Move to next option/action",
|
|
143
|
+
previousOption: "Move to previous option/action",
|
|
144
|
+
optionNote: "Edit selected option note",
|
|
145
|
+
questionNote: "Edit question note",
|
|
146
|
+
},
|
|
147
|
+
editor: {
|
|
148
|
+
submit: "Submit custom answer",
|
|
149
|
+
close: "Close editor and preserve draft",
|
|
150
|
+
nextTabWhenEmpty: "Switch to next tab when editor is empty",
|
|
151
|
+
previousTabWhenEmpty: "Switch to previous tab when editor is empty",
|
|
152
|
+
nextOptionWhenEmpty: "Move to next option when editor is empty",
|
|
153
|
+
previousOptionWhenEmpty: "Move to previous option when editor is empty",
|
|
154
|
+
},
|
|
155
|
+
noteEditor: {
|
|
156
|
+
save: "Save note",
|
|
157
|
+
close: "Close note editor",
|
|
158
|
+
nextTabWhenEmpty: "Switch to next tab when editor is empty",
|
|
159
|
+
previousTabWhenEmpty: "Switch to previous tab when editor is empty",
|
|
160
|
+
nextOptionWhenEmpty: "Move to next option when editor is empty",
|
|
161
|
+
previousOptionWhenEmpty: "Move to previous option when editor is empty",
|
|
162
|
+
},
|
|
163
|
+
settingsModal: {
|
|
164
|
+
close: "Close settings",
|
|
165
|
+
nextOption: "Move to next setting",
|
|
166
|
+
previousOption: "Move to previous setting",
|
|
167
|
+
toggle: "Toggle selected setting",
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const footerHint = (
|
|
172
|
+
binding: AskKeyBinding,
|
|
173
|
+
action: string,
|
|
174
|
+
label = binding.label
|
|
175
|
+
) => `${label} ${action}`;
|
|
176
|
+
|
|
177
|
+
const footerKeyIdLabel = (binding: AskKeyBinding) => binding.keys.join(" / ");
|
|
178
|
+
|
|
179
|
+
export function formatKeybindingLabel(key: string): string {
|
|
180
|
+
if (key === "up") {
|
|
181
|
+
return "↑";
|
|
182
|
+
}
|
|
183
|
+
if (key === "down") {
|
|
184
|
+
return "↓";
|
|
185
|
+
}
|
|
186
|
+
if (key === "left") {
|
|
187
|
+
return "←";
|
|
188
|
+
}
|
|
189
|
+
if (key === "right") {
|
|
190
|
+
return "→";
|
|
191
|
+
}
|
|
192
|
+
return key
|
|
193
|
+
.split("+")
|
|
194
|
+
.map((part) => {
|
|
195
|
+
if (part.length <= 1) {
|
|
196
|
+
return part.toUpperCase();
|
|
197
|
+
}
|
|
198
|
+
if (part === "pageUp" || part === "pageDown") {
|
|
199
|
+
return part;
|
|
200
|
+
}
|
|
201
|
+
return part.charAt(0).toUpperCase() + part.slice(1);
|
|
202
|
+
})
|
|
203
|
+
.join("+");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function formatBindingLabel(keys: readonly string[]): string {
|
|
207
|
+
return keys.map(formatKeybindingLabel).join(" / ");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function matchesBinding(data: string, binding: AskKeyBinding): boolean {
|
|
211
|
+
return binding.keys.some((key) => {
|
|
212
|
+
if (key.length === 1) {
|
|
213
|
+
return data === key;
|
|
214
|
+
}
|
|
215
|
+
return matchesKey(data, key as KeyId);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function matchesDigitShortcut(data: string): number | null {
|
|
220
|
+
return DIGIT_SHORTCUT_PATTERN.test(data) ? Number(data) : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function getAskKeyBindings(
|
|
224
|
+
config: AskConfig
|
|
225
|
+
): Record<string, AskKeyBinding> {
|
|
226
|
+
const entries: [string, AskKeyBinding][] = [];
|
|
227
|
+
for (const context of Object.keys(config.keymaps) as AskKeymapContext[]) {
|
|
228
|
+
const actions = config.keymaps[context] as Record<
|
|
229
|
+
string,
|
|
230
|
+
readonly string[]
|
|
231
|
+
>;
|
|
232
|
+
for (const [action, keys] of Object.entries(actions)) {
|
|
233
|
+
const id = `${context}.${action}` as AskKeyBindingId;
|
|
234
|
+
entries.push([
|
|
235
|
+
id,
|
|
236
|
+
{
|
|
237
|
+
contexts: [CONTEXT_LABELS[context]],
|
|
238
|
+
description: DESCRIPTIONS[context][action] ?? action,
|
|
239
|
+
id,
|
|
240
|
+
keys,
|
|
241
|
+
kind: "command",
|
|
242
|
+
label: formatBindingLabel(keys),
|
|
243
|
+
},
|
|
244
|
+
]);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
entries.push([
|
|
248
|
+
"numberShortcut",
|
|
249
|
+
{
|
|
250
|
+
contexts: ["Main flow", "Submit tab"],
|
|
251
|
+
description: "Quick-select option or submit action",
|
|
252
|
+
id: "numberShortcut",
|
|
253
|
+
keys: ["1..9"],
|
|
254
|
+
kind: "command",
|
|
255
|
+
label: "1..9",
|
|
256
|
+
},
|
|
257
|
+
]);
|
|
258
|
+
entries.push([
|
|
259
|
+
"fileReference",
|
|
260
|
+
{
|
|
261
|
+
contexts: ["Editors"],
|
|
262
|
+
description: "Reference files while typing answers or notes",
|
|
263
|
+
id: "fileReference",
|
|
264
|
+
keys: ["@"],
|
|
265
|
+
kind: "affordance",
|
|
266
|
+
label: "@",
|
|
267
|
+
},
|
|
268
|
+
]);
|
|
269
|
+
return Object.fromEntries(entries);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function getAskContextBindings<C extends AskKeymapContext>(
|
|
273
|
+
config: AskConfig,
|
|
274
|
+
context: C
|
|
275
|
+
): Record<AskKeymapAction<C>, AskKeyBinding> {
|
|
276
|
+
const bindings = getAskKeyBindings(config);
|
|
277
|
+
const actions = config.keymaps[context] as Record<string, readonly string[]>;
|
|
278
|
+
return Object.fromEntries(
|
|
279
|
+
Object.keys(actions).map((action) => [
|
|
280
|
+
action,
|
|
281
|
+
bindings[`${context}.${action}`],
|
|
282
|
+
])
|
|
283
|
+
) as Record<AskKeymapAction<C>, AskKeyBinding>;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function getGlobalBindings(
|
|
287
|
+
config: AskConfig
|
|
288
|
+
): Record<AskKeymapAction<"global">, AskKeyBinding> {
|
|
289
|
+
return getAskContextBindings(config, "global");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function getAskKeymaps(config: AskConfig): {
|
|
293
|
+
customizable: readonly AskKeyBinding[];
|
|
294
|
+
fixed: readonly AskKeyBinding[];
|
|
295
|
+
} {
|
|
296
|
+
const bindings = getAskKeyBindings(config);
|
|
297
|
+
return {
|
|
298
|
+
customizable: Object.keys(bindings)
|
|
299
|
+
.filter((id) => id !== "numberShortcut" && id !== "fileReference")
|
|
300
|
+
.map((id) => bindings[id]),
|
|
301
|
+
fixed: [bindings.numberShortcut, bindings.fileReference],
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function renderFooterKeymaps(
|
|
306
|
+
config: AskConfig,
|
|
307
|
+
context: FooterKeymapContext
|
|
308
|
+
): string {
|
|
309
|
+
const global = getGlobalBindings(config);
|
|
310
|
+
const main = getAskContextBindings(config, "main");
|
|
311
|
+
const editor = getAskContextBindings(config, "editor");
|
|
312
|
+
const noteEditor = getAskContextBindings(config, "noteEditor");
|
|
313
|
+
const bindings = getAskKeyBindings(config);
|
|
314
|
+
const noteNavigationLabel = `${main.optionNote.label}/${main.questionNote.label}`;
|
|
315
|
+
const hintsByContext: Record<FooterKeymapContext, readonly string[]> = {
|
|
316
|
+
input: [
|
|
317
|
+
footerHint(editor.submit, "submit"),
|
|
318
|
+
footerHint(editor.close, "close"),
|
|
319
|
+
footerHint(global.settings, "settings"),
|
|
320
|
+
],
|
|
321
|
+
note: [
|
|
322
|
+
footerHint(noteEditor.save, "save"),
|
|
323
|
+
footerHint(noteEditor.close, "close"),
|
|
324
|
+
footerHint(global.settings, "settings"),
|
|
325
|
+
],
|
|
326
|
+
submit: [
|
|
327
|
+
footerHint(bindings.numberShortcut, "hotkeys"),
|
|
328
|
+
footerHint(main.confirm, "confirm"),
|
|
329
|
+
footerHint(main.cancel, "cancel"),
|
|
330
|
+
footerHint(global.settings, "settings"),
|
|
331
|
+
],
|
|
332
|
+
multi: [
|
|
333
|
+
footerHint(main.toggle, "toggle"),
|
|
334
|
+
footerHint(
|
|
335
|
+
main.changeQuestionType,
|
|
336
|
+
"question type",
|
|
337
|
+
footerKeyIdLabel(main.changeQuestionType)
|
|
338
|
+
),
|
|
339
|
+
footerHint(main.confirm, "continue"),
|
|
340
|
+
footerHint(main.optionNote, "note", noteNavigationLabel),
|
|
341
|
+
footerHint(main.cancel, "dismiss"),
|
|
342
|
+
footerHint(global.settings, "settings"),
|
|
343
|
+
],
|
|
344
|
+
default: [
|
|
345
|
+
footerHint(
|
|
346
|
+
main.changeQuestionType,
|
|
347
|
+
"question type",
|
|
348
|
+
footerKeyIdLabel(main.changeQuestionType)
|
|
349
|
+
),
|
|
350
|
+
footerHint(main.confirm, "confirm"),
|
|
351
|
+
footerHint(main.optionNote, "note", noteNavigationLabel),
|
|
352
|
+
footerHint(main.cancel, "dismiss"),
|
|
353
|
+
footerHint(global.settings, "settings"),
|
|
354
|
+
],
|
|
355
|
+
};
|
|
356
|
+
return ` ${hintsByContext[context].join(" · ")}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function renderSettingsFooterKeymaps(config: AskConfig): string {
|
|
360
|
+
const bindings = getAskContextBindings(config, "settingsModal");
|
|
361
|
+
return `${bindings.toggle.label} to change · ${bindings.close.label} to close`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function normalizeConfiguredKeymaps(
|
|
365
|
+
keymaps: unknown
|
|
366
|
+
): { ok: true; keymaps: AskConfigKeymaps } | { ok: false; error: string } {
|
|
367
|
+
const candidate = normalizeLegacyFlatKeymaps(keymaps) ?? keymaps;
|
|
368
|
+
if (!candidate) {
|
|
369
|
+
return { ok: true, keymaps: cloneKeymaps(DEFAULT_ASK_KEYMAPS) };
|
|
370
|
+
}
|
|
371
|
+
if (!(candidate && typeof candidate === "object")) {
|
|
372
|
+
return { ok: false, error: "Keymaps must be an object." };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const normalized = {} as AskConfigKeymaps;
|
|
376
|
+
for (const context of Object.keys(
|
|
377
|
+
DEFAULT_ASK_KEYMAPS
|
|
378
|
+
) as AskKeymapContext[]) {
|
|
379
|
+
const rawContext = (candidate as Record<string, unknown>)[context];
|
|
380
|
+
if (!(rawContext && typeof rawContext === "object")) {
|
|
381
|
+
return { ok: false, error: `Missing keymap context ${context}.` };
|
|
382
|
+
}
|
|
383
|
+
const contextResult = normalizeContextKeymaps(
|
|
384
|
+
context,
|
|
385
|
+
rawContext as Record<string, unknown>
|
|
386
|
+
);
|
|
387
|
+
if (!contextResult.ok) {
|
|
388
|
+
return contextResult;
|
|
389
|
+
}
|
|
390
|
+
normalized[context] = contextResult.keymaps as never;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const globalDuplicates = validateCrossContextConflicts(normalized);
|
|
394
|
+
if (globalDuplicates) {
|
|
395
|
+
return { ok: false, error: globalDuplicates };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return { ok: true, keymaps: normalized };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function normalizeContextKeymaps(
|
|
402
|
+
context: AskKeymapContext,
|
|
403
|
+
rawContext: Record<string, unknown>
|
|
404
|
+
):
|
|
405
|
+
| { ok: true; keymaps: Record<string, string[]> }
|
|
406
|
+
| { ok: false; error: string } {
|
|
407
|
+
const defaults = DEFAULT_ASK_KEYMAPS[context] as Record<
|
|
408
|
+
string,
|
|
409
|
+
readonly string[]
|
|
410
|
+
>;
|
|
411
|
+
const normalized: Record<string, string[]> = {};
|
|
412
|
+
for (const action of Object.keys(defaults)) {
|
|
413
|
+
const rawValue = rawContext[action];
|
|
414
|
+
if (rawValue === undefined) {
|
|
415
|
+
return { ok: false, error: `Missing keymap for ${context}.${action}.` };
|
|
416
|
+
}
|
|
417
|
+
const parsed = normalizeBindingList(rawValue);
|
|
418
|
+
if (!parsed.ok) {
|
|
419
|
+
return {
|
|
420
|
+
ok: false,
|
|
421
|
+
error: `Invalid keymap for ${context}.${action}: ${parsed.error}`,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
normalized[action] = parsed.keys;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const duplicateError = validateContextBindings(context, normalized);
|
|
428
|
+
return duplicateError
|
|
429
|
+
? { ok: false, error: duplicateError }
|
|
430
|
+
: { ok: true, keymaps: normalized };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function normalizeBindingList(
|
|
434
|
+
rawValue: unknown
|
|
435
|
+
): { ok: true; keys: string[] } | { ok: false; error: string } {
|
|
436
|
+
const rawKeys = typeof rawValue === "string" ? [rawValue] : rawValue;
|
|
437
|
+
if (!Array.isArray(rawKeys)) {
|
|
438
|
+
return { ok: false, error: "binding must be a string or array of strings" };
|
|
439
|
+
}
|
|
440
|
+
if (rawKeys.length === 0) {
|
|
441
|
+
return { ok: false, error: "empty binding list" };
|
|
442
|
+
}
|
|
443
|
+
const keys: string[] = [];
|
|
444
|
+
for (const rawKey of rawKeys) {
|
|
445
|
+
if (typeof rawKey !== "string") {
|
|
446
|
+
return { ok: false, error: "binding list must contain only strings" };
|
|
447
|
+
}
|
|
448
|
+
const parsed = normalizeKeyId(rawKey);
|
|
449
|
+
if (!parsed.ok) {
|
|
450
|
+
return parsed;
|
|
451
|
+
}
|
|
452
|
+
if (!keys.includes(parsed.keyId)) {
|
|
453
|
+
keys.push(parsed.keyId);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return { ok: true, keys };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function validateContextBindings(
|
|
460
|
+
context: AskKeymapContext,
|
|
461
|
+
bindings: Record<string, readonly string[]>
|
|
462
|
+
): string | undefined {
|
|
463
|
+
const seen = new Map<string, string>();
|
|
464
|
+
for (const { action, key } of flattenBindings(bindings)) {
|
|
465
|
+
if (RESERVED_BINDINGS.has(key)) {
|
|
466
|
+
return `Reserved keymap for ${context}.${action}: ${formatKeybindingLabel(key)}.`;
|
|
467
|
+
}
|
|
468
|
+
const existing = seen.get(key);
|
|
469
|
+
if (existing) {
|
|
470
|
+
return `Duplicate keymap ${formatKeybindingLabel(key)} for ${context}.${existing} and ${context}.${action}.`;
|
|
471
|
+
}
|
|
472
|
+
seen.set(key, action);
|
|
473
|
+
}
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function validateCrossContextConflicts(
|
|
478
|
+
keymaps: AskConfigKeymaps
|
|
479
|
+
): string | undefined {
|
|
480
|
+
const globalSeen = new Map(
|
|
481
|
+
flattenBindings(keymaps.global).map(({ action, key }) => [
|
|
482
|
+
key,
|
|
483
|
+
`global.${action}`,
|
|
484
|
+
])
|
|
485
|
+
);
|
|
486
|
+
for (const { context, action, key } of flattenNonGlobalBindings(keymaps)) {
|
|
487
|
+
const existing = globalSeen.get(key);
|
|
488
|
+
if (existing) {
|
|
489
|
+
return `Duplicate global keymap ${formatKeybindingLabel(key)} for ${existing} and ${context}.${action}.`;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function flattenNonGlobalBindings(keymaps: AskConfigKeymaps): Array<{
|
|
496
|
+
action: string;
|
|
497
|
+
context: AskKeymapContext;
|
|
498
|
+
key: string;
|
|
499
|
+
}> {
|
|
500
|
+
return (Object.keys(keymaps) as AskKeymapContext[])
|
|
501
|
+
.filter((context) => context !== "global" && context !== "settingsModal")
|
|
502
|
+
.flatMap((context) =>
|
|
503
|
+
flattenBindings(
|
|
504
|
+
keymaps[context] as Record<string, readonly string[]>
|
|
505
|
+
).map((binding) => ({ ...binding, context }))
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function flattenBindings(
|
|
510
|
+
bindings: Record<string, readonly string[]>
|
|
511
|
+
): Array<{ action: string; key: string }> {
|
|
512
|
+
return Object.entries(bindings).flatMap(([action, keys]) =>
|
|
513
|
+
keys.map((key) => ({ action, key }))
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function normalizeLegacyFlatKeymaps(
|
|
518
|
+
keymaps: unknown
|
|
519
|
+
): AskConfigKeymaps | undefined {
|
|
520
|
+
if (!(keymaps && typeof keymaps === "object")) {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if ("main" in keymaps || "global" in keymaps) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const raw = keymaps as Record<string, unknown>;
|
|
527
|
+
const defaults = cloneKeymaps(DEFAULT_ASK_KEYMAPS);
|
|
528
|
+
return {
|
|
529
|
+
...defaults,
|
|
530
|
+
global: {
|
|
531
|
+
...defaults.global,
|
|
532
|
+
dismiss: coerceLegacyBinding(raw.dismiss, defaults.global.dismiss),
|
|
533
|
+
},
|
|
534
|
+
main: {
|
|
535
|
+
...defaults.main,
|
|
536
|
+
confirm: coerceLegacyBinding(raw.confirm, defaults.main.confirm),
|
|
537
|
+
cancel: coerceLegacyBinding(raw.cancel, defaults.main.cancel),
|
|
538
|
+
toggle: coerceLegacyBinding(raw.toggle, defaults.main.toggle),
|
|
539
|
+
optionNote: coerceLegacyBinding(raw.optionNote, defaults.main.optionNote),
|
|
540
|
+
questionNote: coerceLegacyBinding(
|
|
541
|
+
raw.questionNote,
|
|
542
|
+
defaults.main.questionNote
|
|
543
|
+
),
|
|
544
|
+
},
|
|
545
|
+
editor: {
|
|
546
|
+
...defaults.editor,
|
|
547
|
+
submit: coerceLegacyBinding(raw.confirm, defaults.editor.submit),
|
|
548
|
+
close: coerceLegacyBinding(raw.cancel, defaults.editor.close),
|
|
549
|
+
},
|
|
550
|
+
noteEditor: {
|
|
551
|
+
...defaults.noteEditor,
|
|
552
|
+
save: coerceLegacyBinding(raw.confirm, defaults.noteEditor.save),
|
|
553
|
+
close: coerceLegacyBinding(raw.cancel, defaults.noteEditor.close),
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function coerceLegacyBinding(value: unknown, fallback: string[]): string[] {
|
|
559
|
+
return typeof value === "string" ? [value] : fallback;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function normalizeKeyId(
|
|
563
|
+
rawKey: string
|
|
564
|
+
): { ok: true; keyId: string } | { ok: false; error: string } {
|
|
565
|
+
const normalizedInput = rawKey.trim();
|
|
566
|
+
if (!normalizedInput) {
|
|
567
|
+
return { ok: false, error: "empty binding" };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const parts = normalizedInput
|
|
571
|
+
.toLowerCase()
|
|
572
|
+
.split("+")
|
|
573
|
+
.map((part) => part.trim())
|
|
574
|
+
.filter(Boolean);
|
|
575
|
+
if (parts.length === 0) {
|
|
576
|
+
return { ok: false, error: "empty binding" };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const basePart = parts.at(-1);
|
|
580
|
+
if (!basePart) {
|
|
581
|
+
return { ok: false, error: "missing key" };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const modifiers = new Set<ModifierName>();
|
|
585
|
+
for (const part of parts.slice(0, -1)) {
|
|
586
|
+
const modifier = normalizeModifierName(part);
|
|
587
|
+
if (!modifier) {
|
|
588
|
+
return { ok: false, error: `unsupported modifier ${part}` };
|
|
589
|
+
}
|
|
590
|
+
modifiers.add(modifier);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const baseKey = normalizeBaseKey(basePart);
|
|
594
|
+
if (!baseKey) {
|
|
595
|
+
return { ok: false, error: `unsupported key ${basePart}` };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const modifierPrefix = MODIFIER_ORDER.filter((modifier) =>
|
|
599
|
+
modifiers.has(modifier)
|
|
600
|
+
);
|
|
601
|
+
return { ok: true, keyId: [...modifierPrefix, baseKey].join("+") };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function cloneKeymaps(keymaps: AskConfigKeymaps): AskConfigKeymaps {
|
|
605
|
+
return {
|
|
606
|
+
global: {
|
|
607
|
+
dismiss: [...keymaps.global.dismiss],
|
|
608
|
+
settings: [...keymaps.global.settings],
|
|
609
|
+
},
|
|
610
|
+
main: {
|
|
611
|
+
confirm: [...keymaps.main.confirm],
|
|
612
|
+
cancel: [...keymaps.main.cancel],
|
|
613
|
+
changeQuestionType: [...keymaps.main.changeQuestionType],
|
|
614
|
+
toggle: [...keymaps.main.toggle],
|
|
615
|
+
nextTab: [...keymaps.main.nextTab],
|
|
616
|
+
previousTab: [...keymaps.main.previousTab],
|
|
617
|
+
nextOption: [...keymaps.main.nextOption],
|
|
618
|
+
previousOption: [...keymaps.main.previousOption],
|
|
619
|
+
optionNote: [...keymaps.main.optionNote],
|
|
620
|
+
questionNote: [...keymaps.main.questionNote],
|
|
621
|
+
},
|
|
622
|
+
editor: {
|
|
623
|
+
submit: [...keymaps.editor.submit],
|
|
624
|
+
close: [...keymaps.editor.close],
|
|
625
|
+
nextTabWhenEmpty: [...keymaps.editor.nextTabWhenEmpty],
|
|
626
|
+
previousTabWhenEmpty: [...keymaps.editor.previousTabWhenEmpty],
|
|
627
|
+
nextOptionWhenEmpty: [...keymaps.editor.nextOptionWhenEmpty],
|
|
628
|
+
previousOptionWhenEmpty: [...keymaps.editor.previousOptionWhenEmpty],
|
|
629
|
+
},
|
|
630
|
+
noteEditor: {
|
|
631
|
+
save: [...keymaps.noteEditor.save],
|
|
632
|
+
close: [...keymaps.noteEditor.close],
|
|
633
|
+
nextTabWhenEmpty: [...keymaps.noteEditor.nextTabWhenEmpty],
|
|
634
|
+
previousTabWhenEmpty: [...keymaps.noteEditor.previousTabWhenEmpty],
|
|
635
|
+
nextOptionWhenEmpty: [...keymaps.noteEditor.nextOptionWhenEmpty],
|
|
636
|
+
previousOptionWhenEmpty: [...keymaps.noteEditor.previousOptionWhenEmpty],
|
|
637
|
+
},
|
|
638
|
+
settingsModal: {
|
|
639
|
+
close: [...keymaps.settingsModal.close],
|
|
640
|
+
nextOption: [...keymaps.settingsModal.nextOption],
|
|
641
|
+
previousOption: [...keymaps.settingsModal.previousOption],
|
|
642
|
+
toggle: [...keymaps.settingsModal.toggle],
|
|
643
|
+
},
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function normalizeModifierName(part: string): ModifierName | undefined {
|
|
648
|
+
switch (part) {
|
|
649
|
+
case "ctrl":
|
|
650
|
+
case "control":
|
|
651
|
+
case "ctl":
|
|
652
|
+
return "ctrl";
|
|
653
|
+
case "shift":
|
|
654
|
+
return "shift";
|
|
655
|
+
case "alt":
|
|
656
|
+
case "option":
|
|
657
|
+
return "alt";
|
|
658
|
+
case "super":
|
|
659
|
+
case "cmd":
|
|
660
|
+
case "command":
|
|
661
|
+
case "meta":
|
|
662
|
+
case "win":
|
|
663
|
+
case "windows":
|
|
664
|
+
return "super";
|
|
665
|
+
default:
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function normalizeBaseKey(part: string): string | undefined {
|
|
671
|
+
const aliased = normalizeBaseKeyAlias(part);
|
|
672
|
+
if (
|
|
673
|
+
LETTER_PATTERN.test(aliased) ||
|
|
674
|
+
DIGIT_PATTERN.test(aliased) ||
|
|
675
|
+
SYMBOL_PATTERN.test(aliased)
|
|
676
|
+
) {
|
|
677
|
+
return aliased;
|
|
678
|
+
}
|
|
679
|
+
if (FUNCTION_KEY_PATTERN.test(aliased)) {
|
|
680
|
+
return aliased;
|
|
681
|
+
}
|
|
682
|
+
if (
|
|
683
|
+
KNOWN_SPECIAL_KEYS.has(
|
|
684
|
+
aliased as typeof KNOWN_SPECIAL_KEYS extends Set<infer T> ? T : never
|
|
685
|
+
)
|
|
686
|
+
) {
|
|
687
|
+
return aliased;
|
|
688
|
+
}
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function normalizeBaseKeyAlias(part: string): string {
|
|
693
|
+
switch (part) {
|
|
694
|
+
case "escape":
|
|
695
|
+
return "esc";
|
|
696
|
+
case "return":
|
|
697
|
+
return "enter";
|
|
698
|
+
case "spacebar":
|
|
699
|
+
return "space";
|
|
700
|
+
case "del":
|
|
701
|
+
return "delete";
|
|
702
|
+
case "ins":
|
|
703
|
+
return "insert";
|
|
704
|
+
case "pgup":
|
|
705
|
+
case "pageup":
|
|
706
|
+
return "pageUp";
|
|
707
|
+
case "pgdown":
|
|
708
|
+
case "pagedown":
|
|
709
|
+
return "pageDown";
|
|
710
|
+
case "arrowup":
|
|
711
|
+
return "up";
|
|
712
|
+
case "arrowdown":
|
|
713
|
+
return "down";
|
|
714
|
+
case "arrowleft":
|
|
715
|
+
return "left";
|
|
716
|
+
case "arrowright":
|
|
717
|
+
return "right";
|
|
718
|
+
default:
|
|
719
|
+
return part;
|
|
720
|
+
}
|
|
721
|
+
}
|