pi-ask-popup 0.1.0 → 0.1.1
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/README.md +95 -32
- package/docs/configuration.md +93 -0
- package/docs/hosts.md +71 -0
- package/docs/keyboard.md +65 -0
- package/docs/tool-schema.md +124 -0
- package/package.json +12 -2
- package/preview/popup-submit.webp +0 -0
- package/preview/popup-with-notes.webp +0 -0
- package/preview/popup-with-tab.webp +0 -0
- package/src/ask-user-question.ts +50 -24
- package/src/config.ts +66 -23
- package/src/rpc-fallback.ts +30 -15
- package/src/state/build-questionnaire.ts +3 -1
- package/src/state/external-editor.ts +6 -2
- package/src/state/key-router.ts +129 -41
- package/src/state/questionnaire-session.ts +35 -13
- package/src/state/row-intent.ts +6 -2
- package/src/state/selectors/derivations.ts +18 -6
- package/src/state/selectors/focus.ts +6 -2
- package/src/state/selectors/projections.ts +3 -1
- package/src/state/state-reducer.ts +99 -33
- package/src/tool/response-envelope.ts +32 -20
- package/src/tool/types.ts +13 -2
- package/src/view/component-binding.ts +3 -1
- package/src/view/components/inline-input.ts +3 -1
- package/src/view/components/multi-select-view.ts +15 -5
- package/src/view/components/preview/markdown-content-cache.ts +21 -6
- package/src/view/components/preview/preview-block-renderer.ts +8 -1
- package/src/view/components/preview/preview-box-renderer.ts +4 -5
- package/src/view/components/preview/preview-layout-decider.ts +20 -15
- package/src/view/components/preview/preview-pane.ts +54 -18
- package/src/view/components/wrapping-select.ts +15 -5
- package/src/view/dialog-builder.ts +24 -8
- package/src/view/props-adapter.ts +21 -7
- package/src/view/tab-content-strategy.ts +60 -20
package/src/ask-user-question.ts
CHANGED
|
@@ -74,7 +74,9 @@ export const BEL = "\x07";
|
|
|
74
74
|
*/
|
|
75
75
|
function emitTerminalAttention(): void {
|
|
76
76
|
try {
|
|
77
|
-
if (process.stdout.isTTY)
|
|
77
|
+
if (process.stdout.isTTY) {
|
|
78
|
+
process.stdout.write(BEL);
|
|
79
|
+
}
|
|
78
80
|
} catch {
|
|
79
81
|
// Best effort. Failing to get someone's attention must not stop the
|
|
80
82
|
// questionnaire from being asked.
|
|
@@ -127,6 +129,8 @@ type SessionLoad =
|
|
|
127
129
|
message: string;
|
|
128
130
|
};
|
|
129
131
|
|
|
132
|
+
type LoadConfigInput = { agentDir: string; projectDir?: string };
|
|
133
|
+
|
|
130
134
|
/**
|
|
131
135
|
* Load the render graph on first use, guarding its two failure shapes.
|
|
132
136
|
*
|
|
@@ -158,7 +162,7 @@ export async function loadQuestionnaireSession(
|
|
|
158
162
|
message: `${ERROR_SESSION_LOAD_FAILED} (cause: ${cause})`,
|
|
159
163
|
};
|
|
160
164
|
}
|
|
161
|
-
if (
|
|
165
|
+
if (!(mod.QuestionnaireSession instanceof Function)) {
|
|
162
166
|
const keys = JSON.stringify(Object.keys(mod));
|
|
163
167
|
return {
|
|
164
168
|
ok: false,
|
|
@@ -183,22 +187,31 @@ function registerCollapseKeyListener(
|
|
|
183
187
|
sessionRef: SessionRef,
|
|
184
188
|
overlayHandleRef: OverlayHandleRef,
|
|
185
189
|
): (() => void) | undefined {
|
|
186
|
-
if (collapseKey === COLLAPSE_KEY_OFF ||
|
|
190
|
+
if (collapseKey === COLLAPSE_KEY_OFF || !(ctx.ui.onTerminalInput instanceof Function)) {
|
|
187
191
|
return undefined;
|
|
188
192
|
}
|
|
189
193
|
let hasAnnouncedHide = false;
|
|
190
194
|
return ctx.ui.onTerminalInput((data) => {
|
|
191
195
|
const handle = overlayHandleRef.current;
|
|
192
|
-
if (!handle)
|
|
196
|
+
if (!handle) {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
193
199
|
// Act only while this questionnaire is hidden (its own input is
|
|
194
200
|
// unreachable) or actually focused. With another overlay on top, the key
|
|
195
201
|
// belongs to that one; toggling from underneath it would be baffling.
|
|
196
|
-
if (!handle.isHidden() && !handle.isFocused())
|
|
197
|
-
|
|
202
|
+
if (!handle.isHidden() && !handle.isFocused()) {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
// SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
|
|
206
|
+
if (!matchesKey(data, collapseKey as Parameters<typeof matchesKey>[1])) {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
198
209
|
// Kitty-protocol terminals report press, repeat and release separately.
|
|
199
210
|
// Toggling on all three would make a tap reopen what it just closed, and a
|
|
200
211
|
// held key flicker.
|
|
201
|
-
if (isKeyRelease(data) || isKeyRepeat(data))
|
|
212
|
+
if (isKeyRelease(data) || isKeyRepeat(data)) {
|
|
213
|
+
return { consume: true };
|
|
214
|
+
}
|
|
202
215
|
sessionRef.current?.toggleCollapsedExternal();
|
|
203
216
|
if (handle.isHidden() && !hasAnnouncedHide) {
|
|
204
217
|
// Once only. The dialog has just vanished, so say how to get it back —
|
|
@@ -251,7 +264,9 @@ function makeSessionFactory(config: {
|
|
|
251
264
|
const editorCommand = SettingsManager.create(ctx.cwd, undefined, {
|
|
252
265
|
projectTrusted: ctx.isProjectTrusted(),
|
|
253
266
|
}).getExternalEditorCommand();
|
|
254
|
-
if (!editorCommand)
|
|
267
|
+
if (!editorCommand) {
|
|
268
|
+
throw new Error("No external editor command is configured");
|
|
269
|
+
}
|
|
255
270
|
return await editWithExternalEditor(tui, editorCommand, value);
|
|
256
271
|
} catch (error) {
|
|
257
272
|
// Reported, then undefined, which the session reads as "keep the
|
|
@@ -341,12 +356,13 @@ Preview content is rendered as markdown in a monospace box. Multi-line text with
|
|
|
341
356
|
* exists to prevent. The collapse key, a local UI preference, does read both
|
|
342
357
|
* layers.
|
|
343
358
|
*/
|
|
344
|
-
function loadGuidance()
|
|
345
|
-
guidance: ReturnType<typeof validateGuidanceFields>;
|
|
346
|
-
warnings: readonly string[];
|
|
347
|
-
} {
|
|
359
|
+
function loadGuidance() {
|
|
348
360
|
const { config, warnings } = loadConfig({ agentDir: getAgentDir() });
|
|
349
|
-
|
|
361
|
+
const result = {
|
|
362
|
+
guidance: validateGuidanceFields(config.guidance),
|
|
363
|
+
warnings,
|
|
364
|
+
};
|
|
365
|
+
return result;
|
|
350
366
|
}
|
|
351
367
|
|
|
352
368
|
export function registerAskPopupTool(pi: ExtensionAPI): void {
|
|
@@ -365,10 +381,15 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
|
|
|
365
381
|
parameters: QuestionParamsSchema,
|
|
366
382
|
|
|
367
383
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
368
|
-
|
|
369
|
-
|
|
384
|
+
// SAFETY: params is validated by QuestionParamsSchema via validateQuestionnaire immediately after narrowing.
|
|
385
|
+
const typed = params as QuestionParams;
|
|
386
|
+
if (!ctx.hasUI) {
|
|
387
|
+
return rejectWithoutUi();
|
|
388
|
+
}
|
|
370
389
|
|
|
371
|
-
for (const warning of pendingWarnings)
|
|
390
|
+
for (const warning of pendingWarnings) {
|
|
391
|
+
ctx.ui.notify?.(warning, "warning");
|
|
392
|
+
}
|
|
372
393
|
pendingWarnings = [];
|
|
373
394
|
|
|
374
395
|
const validation = validateQuestionnaire(typed);
|
|
@@ -385,6 +406,7 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
|
|
|
385
406
|
// Hosts that advertise their mode go straight to the walker and never
|
|
386
407
|
// import the render graph at all. Older RPC builds fall through to the
|
|
387
408
|
// undefined-result backstop below.
|
|
409
|
+
// SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
|
|
388
410
|
if ((ctx as { mode?: string }).mode === "rpc" && hasDialogUI(ctx.ui)) {
|
|
389
411
|
return runRpcPath(pi, ctx.ui, typed);
|
|
390
412
|
}
|
|
@@ -393,7 +415,8 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
|
|
|
393
415
|
// primitives is malformed, and calling `custom` on it throws a bare
|
|
394
416
|
// TypeError that reaches the model as a broken tool rather than an
|
|
395
417
|
// unsupported one. Answer honestly instead: nobody saw the questions.
|
|
396
|
-
|
|
418
|
+
// SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
|
|
419
|
+
if (!((ctx.ui as { custom?: unknown }).custom instanceof Function)) {
|
|
397
420
|
return resolveUndefinedResult(ctx, typed);
|
|
398
421
|
}
|
|
399
422
|
|
|
@@ -414,12 +437,13 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
|
|
|
414
437
|
// Both layers here: unlike guidance, this only binds a key in the user's
|
|
415
438
|
// own terminal, and a project pinning a shortcut that suits its docs is
|
|
416
439
|
// reasonable. `resolveCollapseKey` refuses anything malformed.
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
440
|
+
const configInput: LoadConfigInput = {
|
|
441
|
+
agentDir: getAgentDir(),
|
|
442
|
+
};
|
|
443
|
+
if (ctx.isProjectTrusted()) {
|
|
444
|
+
configInput.projectDir = ctx.cwd;
|
|
445
|
+
}
|
|
446
|
+
const collapseKey = resolveCollapseKey(loadConfig(configInput).config);
|
|
423
447
|
|
|
424
448
|
const sessionRef: SessionRef = { current: null };
|
|
425
449
|
const overlayHandleRef: OverlayHandleRef = { current: undefined };
|
|
@@ -461,7 +485,9 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
|
|
|
461
485
|
},
|
|
462
486
|
);
|
|
463
487
|
|
|
464
|
-
if (result === undefined)
|
|
488
|
+
if (result === undefined) {
|
|
489
|
+
return resolveUndefinedResult(ctx, typed);
|
|
490
|
+
}
|
|
465
491
|
return buildQuestionnaireResponse(result, typed);
|
|
466
492
|
} finally {
|
|
467
493
|
removeOverlayInputListener?.();
|
package/src/config.ts
CHANGED
|
@@ -2,6 +2,25 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
|
|
5
|
+
type JsonValue =
|
|
6
|
+
| string
|
|
7
|
+
| number
|
|
8
|
+
| boolean
|
|
9
|
+
| null
|
|
10
|
+
| JsonValue[]
|
|
11
|
+
| { readonly [key: string]: JsonValue };
|
|
12
|
+
type JsonRecord = Record<string, JsonValue>;
|
|
13
|
+
|
|
14
|
+
function isRecord(value: unknown): value is JsonRecord;
|
|
15
|
+
function isRecord(value: JsonValue | undefined): value is JsonRecord;
|
|
16
|
+
function isRecord(value: unknown): value is JsonRecord {
|
|
17
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
21
|
+
return typeof value === "string" && value.length > 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
5
24
|
/** Key spec for the overlay collapse/expand shortcut, e.g. `"ctrl+]"` or `"alt+o"`. */
|
|
6
25
|
export type CollapseKeySpec = string;
|
|
7
26
|
|
|
@@ -72,32 +91,34 @@ export function configPaths(sources: ConfigSources): string[] {
|
|
|
72
91
|
* check-then-read pair races, and this keeps the no-write guarantee obvious —
|
|
73
92
|
* nothing here can create a file or a parent directory.
|
|
74
93
|
*/
|
|
75
|
-
function readLayer(path: string, warnings: string[]):
|
|
94
|
+
function readLayer(path: string, warnings: string[]): JsonRecord {
|
|
76
95
|
let text: string;
|
|
77
96
|
try {
|
|
78
97
|
text = readFileSync(path, "utf8");
|
|
79
98
|
} catch (err) {
|
|
99
|
+
// SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
|
|
80
100
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
101
|
+
// SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
|
|
81
102
|
warnings.push(`pi-ask-popup: cannot read ${path} — ${(err as Error).message}`);
|
|
82
103
|
}
|
|
83
104
|
return {};
|
|
84
105
|
}
|
|
85
106
|
try {
|
|
86
107
|
const parsed: unknown = JSON.parse(text);
|
|
87
|
-
|
|
88
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
108
|
+
if (!isRecord(parsed)) {
|
|
89
109
|
warnings.push(`pi-ask-popup: ${path} is not a JSON object, ignoring it`);
|
|
90
110
|
return {};
|
|
91
111
|
}
|
|
92
|
-
return parsed
|
|
112
|
+
return parsed;
|
|
93
113
|
} catch (err) {
|
|
114
|
+
// SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
|
|
94
115
|
warnings.push(`pi-ask-popup: invalid JSON in ${path}, ignoring it — ${(err as Error).message}`);
|
|
95
116
|
return {};
|
|
96
117
|
}
|
|
97
118
|
}
|
|
98
119
|
|
|
99
120
|
function nonEmptyString(value: unknown): string | undefined {
|
|
100
|
-
return
|
|
121
|
+
return isNonEmptyString(value) ? value : undefined;
|
|
101
122
|
}
|
|
102
123
|
|
|
103
124
|
/**
|
|
@@ -106,19 +127,26 @@ function nonEmptyString(value: unknown): string | undefined {
|
|
|
106
127
|
* the tool still registers.
|
|
107
128
|
*/
|
|
108
129
|
export function validateGuidanceFields(fields: unknown): GuidanceFields {
|
|
109
|
-
if (fields
|
|
110
|
-
|
|
130
|
+
if (!isRecord(fields)) {
|
|
131
|
+
return {};
|
|
132
|
+
}
|
|
133
|
+
const g = fields;
|
|
111
134
|
const out: GuidanceFields = {};
|
|
112
135
|
const description = nonEmptyString(g.description);
|
|
113
|
-
if (description !== undefined)
|
|
136
|
+
if (description !== undefined) {
|
|
137
|
+
out.description = description;
|
|
138
|
+
}
|
|
114
139
|
const promptSnippet = nonEmptyString(g.promptSnippet);
|
|
115
|
-
if (promptSnippet !== undefined)
|
|
140
|
+
if (promptSnippet !== undefined) {
|
|
141
|
+
out.promptSnippet = promptSnippet;
|
|
142
|
+
}
|
|
116
143
|
const guidelines = g.promptGuidelines;
|
|
117
144
|
if (
|
|
118
145
|
Array.isArray(guidelines) &&
|
|
119
146
|
guidelines.length > 0 &&
|
|
120
147
|
guidelines.every((s) => nonEmptyString(s) !== undefined)
|
|
121
148
|
) {
|
|
149
|
+
// SAFETY: guidelines elements are validated as non-empty strings above; preserving string array type is safe.
|
|
122
150
|
out.promptGuidelines = guidelines as string[];
|
|
123
151
|
}
|
|
124
152
|
return out;
|
|
@@ -135,7 +163,9 @@ export function loadConfig(sources: ConfigSources): ConfigLoadResult {
|
|
|
135
163
|
for (const path of configPaths(sources)) {
|
|
136
164
|
const raw = readLayer(path, warnings);
|
|
137
165
|
const collapseKey = nonEmptyString(raw.collapseKey);
|
|
138
|
-
if (collapseKey !== undefined)
|
|
166
|
+
if (collapseKey !== undefined) {
|
|
167
|
+
config.collapseKey = collapseKey;
|
|
168
|
+
}
|
|
139
169
|
const guidance = validateGuidanceFields(raw.guidance);
|
|
140
170
|
if (Object.keys(guidance).length > 0) {
|
|
141
171
|
config.guidance = { ...config.guidance, ...guidance };
|
|
@@ -183,7 +213,9 @@ const SPECIAL_KEYS = new Set([
|
|
|
183
213
|
const MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
|
|
184
214
|
|
|
185
215
|
function isBaseKey(key: string): boolean {
|
|
186
|
-
if (key.length !== 1)
|
|
216
|
+
if (key.length !== 1) {
|
|
217
|
+
return SPECIAL_KEYS.has(key);
|
|
218
|
+
}
|
|
187
219
|
return (key >= "a" && key <= "z") || (key >= "0" && key <= "9") || SYMBOL_KEYS.has(key);
|
|
188
220
|
}
|
|
189
221
|
|
|
@@ -199,12 +231,18 @@ function isBaseKey(key: string): boolean {
|
|
|
199
231
|
* user types, anywhere.
|
|
200
232
|
*/
|
|
201
233
|
function isValidCollapseKeySpec(spec: string): boolean {
|
|
202
|
-
if (!spec || spec.startsWith("+") || spec.endsWith("+") || spec.includes("++"))
|
|
234
|
+
if (!spec || spec.startsWith("+") || spec.endsWith("+") || spec.includes("++")) {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
203
237
|
const parts = spec.split("+");
|
|
204
238
|
const base = parts[parts.length - 1] ?? "";
|
|
205
239
|
const modifiers = parts.slice(0, -1);
|
|
206
|
-
if (modifiers.length !== new Set(modifiers).size)
|
|
207
|
-
|
|
240
|
+
if (modifiers.length !== new Set(modifiers).size) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
if (!modifiers.every((m) => MODIFIERS.has(m))) {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
208
246
|
return isBaseKey(base);
|
|
209
247
|
}
|
|
210
248
|
|
|
@@ -220,17 +258,21 @@ export function resolveCollapseKey(config: {
|
|
|
220
258
|
collapseKey?: CollapseKeySpec | undefined;
|
|
221
259
|
}): CollapseKeySpec {
|
|
222
260
|
const raw = config.collapseKey?.trim().toLowerCase();
|
|
223
|
-
if (raw === undefined || raw === "")
|
|
224
|
-
|
|
261
|
+
if (raw === undefined || raw === "") {
|
|
262
|
+
return DEFAULT_COLLAPSE_KEY;
|
|
263
|
+
}
|
|
264
|
+
if (raw === COLLAPSE_KEY_OFF) {
|
|
265
|
+
return COLLAPSE_KEY_OFF;
|
|
266
|
+
}
|
|
225
267
|
return isValidCollapseKeySpec(raw) ? raw : DEFAULT_COLLAPSE_KEY;
|
|
226
268
|
}
|
|
227
269
|
|
|
228
270
|
// The only compound-word names in SPECIAL_KEYS — capitalizing the first letter
|
|
229
271
|
// alone would render them "Pageup" and "Pagedown".
|
|
230
|
-
const COMPOUND_KEY_DISPLAY
|
|
272
|
+
const COMPOUND_KEY_DISPLAY = {
|
|
231
273
|
pageup: "PageUp",
|
|
232
274
|
pagedown: "PageDown",
|
|
233
|
-
}
|
|
275
|
+
} satisfies Record<string, string>;
|
|
234
276
|
|
|
235
277
|
/**
|
|
236
278
|
* Pretty-print a resolved spec for UI copy: `"ctrl+]"` → `"Ctrl+]"`, `"alt+o"` →
|
|
@@ -241,10 +283,11 @@ const COMPOUND_KEY_DISPLAY: Record<string, string> = {
|
|
|
241
283
|
export function formatKeySpecForDisplay(spec: CollapseKeySpec): string {
|
|
242
284
|
return spec
|
|
243
285
|
.split("+")
|
|
244
|
-
.map(
|
|
245
|
-
(part)
|
|
246
|
-
COMPOUND_KEY_DISPLAY[part]
|
|
247
|
-
|
|
248
|
-
|
|
286
|
+
.map((part) => {
|
|
287
|
+
if (part === "pageup" || part === "pagedown") {
|
|
288
|
+
return COMPOUND_KEY_DISPLAY[part];
|
|
289
|
+
}
|
|
290
|
+
return part.length <= 1 ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1);
|
|
291
|
+
})
|
|
249
292
|
.join("+");
|
|
250
293
|
}
|
package/src/rpc-fallback.ts
CHANGED
|
@@ -58,8 +58,9 @@ export type DialogUI = {
|
|
|
58
58
|
|
|
59
59
|
/** Whether the host implements the select and input primitives. */
|
|
60
60
|
export function hasDialogUI(ui: unknown): ui is DialogUI {
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
// SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
|
|
62
|
+
const u = ui as Partial<DialogUI> | null | undefined;
|
|
63
|
+
return u?.select instanceof Function && u?.input instanceof Function;
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
type Option = QuestionData["options"][number];
|
|
@@ -105,12 +106,16 @@ export async function runRpcQuestionnaire(
|
|
|
105
106
|
const dialogOpts = params.timeout === undefined ? undefined : { timeout: params.timeout };
|
|
106
107
|
for (let qi = 0; qi < params.questions.length; qi++) {
|
|
107
108
|
const q = params.questions[qi];
|
|
108
|
-
if (!q)
|
|
109
|
+
if (!q) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
109
112
|
const header = q.header ? `[${q.header}] ` : "";
|
|
110
113
|
const answer = q.multiSelect
|
|
111
114
|
? await askMultiSelect(ui, q, qi, header, dialogOpts)
|
|
112
115
|
: await askSingleSelect(ui, q, qi, header, dialogOpts);
|
|
113
|
-
if (answer === undefined)
|
|
116
|
+
if (answer === undefined) {
|
|
117
|
+
return { answers, cancelled: true };
|
|
118
|
+
}
|
|
114
119
|
answers.push(answer);
|
|
115
120
|
}
|
|
116
121
|
return { answers, cancelled: false };
|
|
@@ -127,29 +132,35 @@ async function askSingleSelect(
|
|
|
127
132
|
const options = q.options.map(formatOptionLine);
|
|
128
133
|
options.push(`${q.options.length + 1}. ${ROW_INTENT_META.other.label}`);
|
|
129
134
|
const chosen = await ui.select(`${header}${q.question}${buildPreviewBlock(q)}`, options, opts);
|
|
130
|
-
if (chosen === undefined || chosen === null)
|
|
135
|
+
if (chosen === undefined || chosen === null) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
131
138
|
const idx = parseIndex(chosen, options.length);
|
|
132
139
|
// A host that returns something outside the list it was given is
|
|
133
140
|
// indistinguishable from a dismissal. Treating it as one beats fabricating
|
|
134
141
|
// an answer the user never gave.
|
|
135
|
-
if (idx === null)
|
|
142
|
+
if (idx === null) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
136
145
|
const option = q.options[idx];
|
|
137
146
|
if (option) {
|
|
138
|
-
|
|
147
|
+
const answer: QuestionAnswer = {
|
|
139
148
|
questionIndex,
|
|
140
149
|
question: q.question,
|
|
141
150
|
kind: "option",
|
|
142
151
|
answer: option.label,
|
|
143
|
-
// Spread rather than assigned: the envelope's contract is that the key is
|
|
144
|
-
// absent when there was no preview, not present and undefined.
|
|
145
|
-
...(option.preview !== undefined && option.preview.length > 0
|
|
146
|
-
? { preview: option.preview }
|
|
147
|
-
: {}),
|
|
148
152
|
};
|
|
153
|
+
if (option.preview !== undefined && option.preview.length > 0) {
|
|
154
|
+
// SAFETY: preview is an optional string; present only when non-empty per contract.
|
|
155
|
+
(answer as QuestionAnswer & { preview: string }).preview = option.preview;
|
|
156
|
+
}
|
|
157
|
+
return answer;
|
|
149
158
|
}
|
|
150
159
|
// The "Type something." row, which is the one index past the authored options.
|
|
151
160
|
const typed = await ui.input(`${header}${q.question}\n\n${CUSTOM_ANSWER_TITLE}`, "", opts);
|
|
152
|
-
if (typed === undefined || typed === null)
|
|
161
|
+
if (typed === undefined || typed === null) {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
153
164
|
return { questionIndex, question: q.question, kind: "custom", answer: typed };
|
|
154
165
|
}
|
|
155
166
|
|
|
@@ -167,7 +178,9 @@ async function askMultiSelect(
|
|
|
167
178
|
MULTI_SELECT_PLACEHOLDER,
|
|
168
179
|
opts,
|
|
169
180
|
);
|
|
170
|
-
if (value === undefined || value === null)
|
|
181
|
+
if (value === undefined || value === null) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
171
184
|
const trimmed = value.trim();
|
|
172
185
|
if (trimmed.length === 0) {
|
|
173
186
|
// A deliberate empty commit, the same as pressing Next with nothing ticked.
|
|
@@ -186,7 +199,9 @@ async function askMultiSelect(
|
|
|
186
199
|
const selected: string[] = [];
|
|
187
200
|
for (const i of indices) {
|
|
188
201
|
const label = q.options[i]?.label;
|
|
189
|
-
if (label !== undefined && !selected.includes(label))
|
|
202
|
+
if (label !== undefined && !selected.includes(label)) {
|
|
203
|
+
selected.push(label);
|
|
204
|
+
}
|
|
190
205
|
}
|
|
191
206
|
return { questionIndex, question: q.question, kind: "multi", answer: null, selected };
|
|
192
207
|
}
|
|
@@ -23,7 +23,9 @@ function runEditor(command: string, file: string): Promise<void> {
|
|
|
23
23
|
const [editor, ...args] = command.split(" ");
|
|
24
24
|
// Unreachable given the caller's check, but the destructure is typed as
|
|
25
25
|
// possibly-undefined and a bare assertion here would be worse.
|
|
26
|
-
if (!editor)
|
|
26
|
+
if (!editor) {
|
|
27
|
+
return Promise.reject(new Error("External editor command is empty"));
|
|
28
|
+
}
|
|
27
29
|
|
|
28
30
|
return new Promise((resolve, reject) => {
|
|
29
31
|
const child = spawn(editor, [...args, file], {
|
|
@@ -64,7 +66,9 @@ export async function editWithExternalEditor(
|
|
|
64
66
|
// TUI has already been stopped, makes a misconfigured editor command flash
|
|
65
67
|
// the screen off and back on before reporting a problem that was knowable
|
|
66
68
|
// from the start.
|
|
67
|
-
if (command.trim().length === 0)
|
|
69
|
+
if (command.trim().length === 0) {
|
|
70
|
+
throw new Error("External editor command is empty");
|
|
71
|
+
}
|
|
68
72
|
|
|
69
73
|
const tempDir = mkdtempSync(join(tmpdir(), "pi-ask-popup-"));
|
|
70
74
|
const tempFile = join(tempDir, "answer.md");
|