pi-ask-popup 0.1.0 → 0.2.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.
Files changed (37) hide show
  1. package/README.md +95 -32
  2. package/docs/configuration.md +93 -0
  3. package/docs/hosts.md +71 -0
  4. package/docs/keyboard.md +65 -0
  5. package/docs/tool-schema.md +124 -0
  6. package/package.json +12 -2
  7. package/preview/popup-submit.webp +0 -0
  8. package/preview/popup-with-notes.webp +0 -0
  9. package/preview/popup-with-tab.webp +0 -0
  10. package/src/ask-user-question.ts +54 -21
  11. package/src/config.ts +118 -24
  12. package/src/rpc-fallback.ts +85 -28
  13. package/src/state/build-questionnaire.ts +45 -8
  14. package/src/state/external-editor.ts +24 -12
  15. package/src/state/key-router.ts +152 -41
  16. package/src/state/questionnaire-session.ts +79 -14
  17. package/src/state/row-intent.ts +6 -2
  18. package/src/state/selectors/derivations.ts +18 -6
  19. package/src/state/selectors/focus.ts +6 -2
  20. package/src/state/selectors/projections.ts +9 -1
  21. package/src/state/state-reducer.ts +110 -38
  22. package/src/tool/response-envelope.ts +71 -22
  23. package/src/tool/types.ts +28 -4
  24. package/src/view/component-binding.ts +3 -1
  25. package/src/view/components/inline-input.ts +3 -1
  26. package/src/view/components/multi-select-view.ts +32 -7
  27. package/src/view/components/option-list-view.ts +5 -0
  28. package/src/view/components/preview/markdown-content-cache.ts +75 -23
  29. package/src/view/components/preview/preview-block-renderer.ts +8 -1
  30. package/src/view/components/preview/preview-box-renderer.ts +4 -5
  31. package/src/view/components/preview/preview-layout-decider.ts +52 -15
  32. package/src/view/components/preview/preview-pane.ts +72 -28
  33. package/src/view/components/tab-bar.ts +26 -8
  34. package/src/view/components/wrapping-select.ts +110 -37
  35. package/src/view/dialog-builder.ts +44 -10
  36. package/src/view/props-adapter.ts +29 -7
  37. package/src/view/tab-content-strategy.ts +69 -21
@@ -74,7 +74,9 @@ export const BEL = "\x07";
74
74
  */
75
75
  function emitTerminalAttention(): void {
76
76
  try {
77
- if (process.stdout.isTTY) process.stdout.write(BEL);
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,6 +162,9 @@ export async function loadQuestionnaireSession(
158
162
  message: `${ERROR_SESSION_LOAD_FAILED} (cause: ${cause})`,
159
163
  };
160
164
  }
165
+ // jiti hands back a namespace built in its own realm, so this class can fail
166
+ // an `instanceof Function` here while being perfectly constructible. `typeof`
167
+ // asks the question that actually matters: is there something to call.
161
168
  if (typeof mod.QuestionnaireSession !== "function") {
162
169
  const keys = JSON.stringify(Object.keys(mod));
163
170
  return {
@@ -189,16 +196,25 @@ function registerCollapseKeyListener(
189
196
  let hasAnnouncedHide = false;
190
197
  return ctx.ui.onTerminalInput((data) => {
191
198
  const handle = overlayHandleRef.current;
192
- if (!handle) return undefined;
199
+ if (!handle) {
200
+ return undefined;
201
+ }
193
202
  // Act only while this questionnaire is hidden (its own input is
194
203
  // unreachable) or actually focused. With another overlay on top, the key
195
204
  // belongs to that one; toggling from underneath it would be baffling.
196
- if (!handle.isHidden() && !handle.isFocused()) return undefined;
197
- if (!matchesKey(data, collapseKey as Parameters<typeof matchesKey>[1])) return undefined;
205
+ if (!handle.isHidden() && !handle.isFocused()) {
206
+ return undefined;
207
+ }
208
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
209
+ if (!matchesKey(data, collapseKey as Parameters<typeof matchesKey>[1])) {
210
+ return undefined;
211
+ }
198
212
  // Kitty-protocol terminals report press, repeat and release separately.
199
213
  // Toggling on all three would make a tap reopen what it just closed, and a
200
214
  // held key flicker.
201
- if (isKeyRelease(data) || isKeyRepeat(data)) return { consume: true };
215
+ if (isKeyRelease(data) || isKeyRepeat(data)) {
216
+ return { consume: true };
217
+ }
202
218
  sessionRef.current?.toggleCollapsedExternal();
203
219
  if (handle.isHidden() && !hasAnnouncedHide) {
204
220
  // Once only. The dialog has just vanished, so say how to get it back —
@@ -251,7 +267,9 @@ function makeSessionFactory(config: {
251
267
  const editorCommand = SettingsManager.create(ctx.cwd, undefined, {
252
268
  projectTrusted: ctx.isProjectTrusted(),
253
269
  }).getExternalEditorCommand();
254
- if (!editorCommand) throw new Error("No external editor command is configured");
270
+ if (!editorCommand) {
271
+ throw new Error("No external editor command is configured");
272
+ }
255
273
  return await editWithExternalEditor(tui, editorCommand, value);
256
274
  } catch (error) {
257
275
  // Reported, then undefined, which the session reads as "keep the
@@ -341,12 +359,13 @@ Preview content is rendered as markdown in a monospace box. Multi-line text with
341
359
  * exists to prevent. The collapse key, a local UI preference, does read both
342
360
  * layers.
343
361
  */
344
- function loadGuidance(): {
345
- guidance: ReturnType<typeof validateGuidanceFields>;
346
- warnings: readonly string[];
347
- } {
362
+ function loadGuidance() {
348
363
  const { config, warnings } = loadConfig({ agentDir: getAgentDir() });
349
- return { guidance: validateGuidanceFields(config.guidance), warnings };
364
+ const result = {
365
+ guidance: validateGuidanceFields(config.guidance),
366
+ warnings,
367
+ };
368
+ return result;
350
369
  }
351
370
 
352
371
  export function registerAskPopupTool(pi: ExtensionAPI): void {
@@ -365,10 +384,15 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
365
384
  parameters: QuestionParamsSchema,
366
385
 
367
386
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
368
- const typed = params as unknown as QuestionParams;
369
- if (!ctx.hasUI) return rejectWithoutUi();
387
+ // SAFETY: params is validated by QuestionParamsSchema via validateQuestionnaire immediately after narrowing.
388
+ const typed = params as QuestionParams;
389
+ if (!ctx.hasUI) {
390
+ return rejectWithoutUi();
391
+ }
370
392
 
371
- for (const warning of pendingWarnings) ctx.ui.notify?.(warning, "warning");
393
+ for (const warning of pendingWarnings) {
394
+ ctx.ui.notify?.(warning, "warning");
395
+ }
372
396
  pendingWarnings = [];
373
397
 
374
398
  const validation = validateQuestionnaire(typed);
@@ -385,6 +409,7 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
385
409
  // Hosts that advertise their mode go straight to the walker and never
386
410
  // import the render graph at all. Older RPC builds fall through to the
387
411
  // undefined-result backstop below.
412
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
388
413
  if ((ctx as { mode?: string }).mode === "rpc" && hasDialogUI(ctx.ui)) {
389
414
  return runRpcPath(pi, ctx.ui, typed);
390
415
  }
@@ -393,6 +418,11 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
393
418
  // primitives is malformed, and calling `custom` on it throws a bare
394
419
  // TypeError that reaches the model as a broken tool rather than an
395
420
  // unsupported one. Answer honestly instead: nobody saw the questions.
421
+ //
422
+ // `typeof` rather than `instanceof Function`, for the same reason as
423
+ // `hasDialogUI`: a cross-realm `custom` is callable and must not be
424
+ // mistaken for a missing one.
425
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
396
426
  if (typeof (ctx.ui as { custom?: unknown }).custom !== "function") {
397
427
  return resolveUndefinedResult(ctx, typed);
398
428
  }
@@ -414,12 +444,13 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
414
444
  // Both layers here: unlike guidance, this only binds a key in the user's
415
445
  // own terminal, and a project pinning a shortcut that suits its docs is
416
446
  // reasonable. `resolveCollapseKey` refuses anything malformed.
417
- const collapseKey = resolveCollapseKey(
418
- loadConfig({
419
- agentDir: getAgentDir(),
420
- ...(ctx.isProjectTrusted() ? { projectDir: ctx.cwd } : {}),
421
- }).config,
422
- );
447
+ const configInput: LoadConfigInput = {
448
+ agentDir: getAgentDir(),
449
+ };
450
+ if (ctx.isProjectTrusted()) {
451
+ configInput.projectDir = ctx.cwd;
452
+ }
453
+ const collapseKey = resolveCollapseKey(loadConfig(configInput).config);
423
454
 
424
455
  const sessionRef: SessionRef = { current: null };
425
456
  const overlayHandleRef: OverlayHandleRef = { current: undefined };
@@ -461,7 +492,9 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
461
492
  },
462
493
  );
463
494
 
464
- if (result === undefined) return resolveUndefinedResult(ctx, typed);
495
+ if (result === undefined) {
496
+ return resolveUndefinedResult(ctx, typed);
497
+ }
465
498
  return buildQuestionnaireResponse(result, typed);
466
499
  } finally {
467
500
  removeOverlayInputListener?.();
package/src/config.ts CHANGED
@@ -1,7 +1,26 @@
1
- import { readFileSync } from "node:fs";
1
+ import { readFileSync, statSync } 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
 
@@ -62,6 +81,35 @@ export function configPaths(sources: ConfigSources): string[] {
62
81
  return paths;
63
82
  }
64
83
 
84
+ /** What one layer parsed to, and the stamp of the file it parsed from. */
85
+ interface CachedLayer {
86
+ /** `mtimeMs:size`, or undefined when the file could not be stat'd (usually absent). */
87
+ stamp: string | undefined;
88
+ value: JsonRecord;
89
+ /** Replayed on every hit, so a malformed file keeps complaining. */
90
+ warnings: readonly string[];
91
+ }
92
+
93
+ const layerCache = new Map<string, CachedLayer>();
94
+
95
+ /**
96
+ * Drop the memo. Tests rewrite the same path within a millisecond, which is
97
+ * finer than the stamp can see; production has no reason to call it.
98
+ */
99
+ export function clearConfigCache(): void {
100
+ layerCache.clear();
101
+ }
102
+
103
+ /** Undefined for a file that cannot be stat'd — absent, or gone behind a bad mount. */
104
+ function layerStamp(path: string): string | undefined {
105
+ try {
106
+ const stat = statSync(path);
107
+ return `${stat.mtimeMs}:${stat.size}`;
108
+ } catch {
109
+ return undefined;
110
+ }
111
+ }
112
+
65
113
  /**
66
114
  * Read one layer. An absent file means "no overrides" and is not a warning —
67
115
  * having no config is the normal case, not a degraded one. Anything else that
@@ -71,33 +119,57 @@ export function configPaths(sources: ConfigSources): string[] {
71
119
  * Deliberately `readFileSync` + catch rather than `existsSync` then read: the
72
120
  * check-then-read pair races, and this keeps the no-write guarantee obvious —
73
121
  * nothing here can create a file or a parent directory.
122
+ *
123
+ * Memoized on `mtimeMs:size`, because this sits on the tool-call hot path: the
124
+ * user has just triggered a questionnaire and the overlay is about to paint.
125
+ * An unchanged layer then costs one `stat` instead of a read and a JSON parse,
126
+ * and the usual case — no config file at all — costs the failed `stat` alone.
127
+ * The stamp is the same race the read already had: a file rewritten inside one
128
+ * millisecond at the same size is read as unchanged until it changes again.
74
129
  */
75
- function readLayer(path: string, warnings: string[]): Record<string, unknown> {
130
+ function readLayer(path: string, warnings: string[]): JsonRecord {
131
+ const stamp = layerStamp(path);
132
+ const cached = layerCache.get(path);
133
+ if (cached && cached.stamp === stamp) {
134
+ warnings.push(...cached.warnings);
135
+ return cached.value;
136
+ }
137
+ const layerWarnings: string[] = [];
138
+ const value = parseLayer(path, layerWarnings);
139
+ layerCache.set(path, { stamp, value, warnings: layerWarnings });
140
+ warnings.push(...layerWarnings);
141
+ return value;
142
+ }
143
+
144
+ /** The read itself. Separated so the memo above stays about caching. */
145
+ function parseLayer(path: string, warnings: string[]): JsonRecord {
76
146
  let text: string;
77
147
  try {
78
148
  text = readFileSync(path, "utf8");
79
149
  } catch (err) {
150
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
80
151
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
152
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
81
153
  warnings.push(`pi-ask-popup: cannot read ${path} — ${(err as Error).message}`);
82
154
  }
83
155
  return {};
84
156
  }
85
157
  try {
86
158
  const parsed: unknown = JSON.parse(text);
87
- // `typeof null === "object"` and so is an array; a config file is neither.
88
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
159
+ if (!isRecord(parsed)) {
89
160
  warnings.push(`pi-ask-popup: ${path} is not a JSON object, ignoring it`);
90
161
  return {};
91
162
  }
92
- return parsed as Record<string, unknown>;
163
+ return parsed;
93
164
  } catch (err) {
165
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
94
166
  warnings.push(`pi-ask-popup: invalid JSON in ${path}, ignoring it — ${(err as Error).message}`);
95
167
  return {};
96
168
  }
97
169
  }
98
170
 
99
171
  function nonEmptyString(value: unknown): string | undefined {
100
- return typeof value === "string" && value.length > 0 ? value : undefined;
172
+ return isNonEmptyString(value) ? value : undefined;
101
173
  }
102
174
 
103
175
  /**
@@ -106,19 +178,26 @@ function nonEmptyString(value: unknown): string | undefined {
106
178
  * the tool still registers.
107
179
  */
108
180
  export function validateGuidanceFields(fields: unknown): GuidanceFields {
109
- if (fields === null || typeof fields !== "object") return {};
110
- const g = fields as Record<string, unknown>;
181
+ if (!isRecord(fields)) {
182
+ return {};
183
+ }
184
+ const g = fields;
111
185
  const out: GuidanceFields = {};
112
186
  const description = nonEmptyString(g.description);
113
- if (description !== undefined) out.description = description;
187
+ if (description !== undefined) {
188
+ out.description = description;
189
+ }
114
190
  const promptSnippet = nonEmptyString(g.promptSnippet);
115
- if (promptSnippet !== undefined) out.promptSnippet = promptSnippet;
191
+ if (promptSnippet !== undefined) {
192
+ out.promptSnippet = promptSnippet;
193
+ }
116
194
  const guidelines = g.promptGuidelines;
117
195
  if (
118
196
  Array.isArray(guidelines) &&
119
197
  guidelines.length > 0 &&
120
198
  guidelines.every((s) => nonEmptyString(s) !== undefined)
121
199
  ) {
200
+ // SAFETY: guidelines elements are validated as non-empty strings above; preserving string array type is safe.
122
201
  out.promptGuidelines = guidelines as string[];
123
202
  }
124
203
  return out;
@@ -135,7 +214,9 @@ export function loadConfig(sources: ConfigSources): ConfigLoadResult {
135
214
  for (const path of configPaths(sources)) {
136
215
  const raw = readLayer(path, warnings);
137
216
  const collapseKey = nonEmptyString(raw.collapseKey);
138
- if (collapseKey !== undefined) config.collapseKey = collapseKey;
217
+ if (collapseKey !== undefined) {
218
+ config.collapseKey = collapseKey;
219
+ }
139
220
  const guidance = validateGuidanceFields(raw.guidance);
140
221
  if (Object.keys(guidance).length > 0) {
141
222
  config.guidance = { ...config.guidance, ...guidance };
@@ -183,7 +264,9 @@ const SPECIAL_KEYS = new Set([
183
264
  const MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
184
265
 
185
266
  function isBaseKey(key: string): boolean {
186
- if (key.length !== 1) return SPECIAL_KEYS.has(key);
267
+ if (key.length !== 1) {
268
+ return SPECIAL_KEYS.has(key);
269
+ }
187
270
  return (key >= "a" && key <= "z") || (key >= "0" && key <= "9") || SYMBOL_KEYS.has(key);
188
271
  }
189
272
 
@@ -199,12 +282,18 @@ function isBaseKey(key: string): boolean {
199
282
  * user types, anywhere.
200
283
  */
201
284
  function isValidCollapseKeySpec(spec: string): boolean {
202
- if (!spec || spec.startsWith("+") || spec.endsWith("+") || spec.includes("++")) return false;
285
+ if (!spec || spec.startsWith("+") || spec.endsWith("+") || spec.includes("++")) {
286
+ return false;
287
+ }
203
288
  const parts = spec.split("+");
204
289
  const base = parts[parts.length - 1] ?? "";
205
290
  const modifiers = parts.slice(0, -1);
206
- if (modifiers.length !== new Set(modifiers).size) return false;
207
- if (!modifiers.every((m) => MODIFIERS.has(m))) return false;
291
+ if (modifiers.length !== new Set(modifiers).size) {
292
+ return false;
293
+ }
294
+ if (!modifiers.every((m) => MODIFIERS.has(m))) {
295
+ return false;
296
+ }
208
297
  return isBaseKey(base);
209
298
  }
210
299
 
@@ -220,17 +309,21 @@ export function resolveCollapseKey(config: {
220
309
  collapseKey?: CollapseKeySpec | undefined;
221
310
  }): CollapseKeySpec {
222
311
  const raw = config.collapseKey?.trim().toLowerCase();
223
- if (raw === undefined || raw === "") return DEFAULT_COLLAPSE_KEY;
224
- if (raw === COLLAPSE_KEY_OFF) return COLLAPSE_KEY_OFF;
312
+ if (raw === undefined || raw === "") {
313
+ return DEFAULT_COLLAPSE_KEY;
314
+ }
315
+ if (raw === COLLAPSE_KEY_OFF) {
316
+ return COLLAPSE_KEY_OFF;
317
+ }
225
318
  return isValidCollapseKeySpec(raw) ? raw : DEFAULT_COLLAPSE_KEY;
226
319
  }
227
320
 
228
321
  // The only compound-word names in SPECIAL_KEYS — capitalizing the first letter
229
322
  // alone would render them "Pageup" and "Pagedown".
230
- const COMPOUND_KEY_DISPLAY: Record<string, string> = {
323
+ const COMPOUND_KEY_DISPLAY = {
231
324
  pageup: "PageUp",
232
325
  pagedown: "PageDown",
233
- };
326
+ } satisfies Record<string, string>;
234
327
 
235
328
  /**
236
329
  * Pretty-print a resolved spec for UI copy: `"ctrl+]"` → `"Ctrl+]"`, `"alt+o"` →
@@ -241,10 +334,11 @@ const COMPOUND_KEY_DISPLAY: Record<string, string> = {
241
334
  export function formatKeySpecForDisplay(spec: CollapseKeySpec): string {
242
335
  return spec
243
336
  .split("+")
244
- .map(
245
- (part) =>
246
- COMPOUND_KEY_DISPLAY[part] ??
247
- (part.length <= 1 ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1)),
248
- )
337
+ .map((part) => {
338
+ if (part === "pageup" || part === "pagedown") {
339
+ return COMPOUND_KEY_DISPLAY[part];
340
+ }
341
+ return part.length <= 1 ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1);
342
+ })
249
343
  .join("+");
250
344
  }
@@ -56,12 +56,35 @@ export type DialogUI = {
56
56
  ) => Promise<string | undefined>;
57
57
  };
58
58
 
59
- /** Whether the host implements the select and input primitives. */
59
+ /**
60
+ * Whether the host implements the select and input primitives.
61
+ *
62
+ * `typeof`, not `instanceof Function`: a method that arrives from another realm
63
+ * — an Electron context bridge, a VM context, a proxy around a host object — is
64
+ * callable but fails an `instanceof` against this realm's `Function`, and the
65
+ * walker would then decline a host that works.
66
+ */
60
67
  export function hasDialogUI(ui: unknown): ui is DialogUI {
61
- const u = ui as Partial<Record<"select" | "input", unknown>> | null | undefined;
68
+ // SAFETY: safe cast value is validated at boundary or test fixture with known shape.
69
+ const u = ui as Partial<DialogUI> | null | undefined;
62
70
  return typeof u?.select === "function" && typeof u?.input === "function";
63
71
  }
64
72
 
73
+ /**
74
+ * What one native dialog produced.
75
+ *
76
+ * `dismissed` is the user pressing Esc, which cancels the questionnaire.
77
+ * `host_error` is the host replying with something it was never offered —
78
+ * neither a decision nor an answer, and the two must not collapse into one
79
+ * result, because a decline tells the model the user said no.
80
+ */
81
+ type AskOutcome =
82
+ | { kind: "answer"; answer: QuestionAnswer }
83
+ | { kind: "dismissed" }
84
+ | { kind: "host_error"; detail: string };
85
+
86
+ const DISMISSED: AskOutcome = { kind: "dismissed" };
87
+
65
88
  type Option = QuestionData["options"][number];
66
89
 
67
90
  function formatOptionLine(option: Option, index: number): string {
@@ -105,69 +128,92 @@ export async function runRpcQuestionnaire(
105
128
  const dialogOpts = params.timeout === undefined ? undefined : { timeout: params.timeout };
106
129
  for (let qi = 0; qi < params.questions.length; qi++) {
107
130
  const q = params.questions[qi];
108
- if (!q) continue;
131
+ if (!q) {
132
+ continue;
133
+ }
109
134
  const header = q.header ? `[${q.header}] ` : "";
110
- const answer = q.multiSelect
135
+ const outcome = q.multiSelect
111
136
  ? await askMultiSelect(ui, q, qi, header, dialogOpts)
112
137
  : await askSingleSelect(ui, q, qi, header, dialogOpts);
113
- if (answer === undefined) return { answers, cancelled: true };
114
- answers.push(answer);
138
+ if (outcome.kind === "dismissed") {
139
+ return { answers, cancelled: true };
140
+ }
141
+ if (outcome.kind === "host_error") {
142
+ return { answers, cancelled: true, error: "host_error", hostErrorDetail: outcome.detail };
143
+ }
144
+ answers.push(outcome.answer);
115
145
  }
116
146
  return { answers, cancelled: false };
117
147
  }
118
148
 
119
- /** Undefined means the user dismissed the dialog, which cancels everything. */
149
+ /** Dismissal cancels everything; a reply outside the offered list is the host's fault. */
120
150
  async function askSingleSelect(
121
151
  ui: DialogUI,
122
152
  q: QuestionData,
123
153
  questionIndex: number,
124
154
  header: string,
125
155
  opts?: { timeout?: number; signal?: AbortSignal },
126
- ): Promise<QuestionAnswer | undefined> {
156
+ ): Promise<AskOutcome> {
127
157
  const options = q.options.map(formatOptionLine);
128
158
  options.push(`${q.options.length + 1}. ${ROW_INTENT_META.other.label}`);
129
159
  const chosen = await ui.select(`${header}${q.question}${buildPreviewBlock(q)}`, options, opts);
130
- if (chosen === undefined || chosen === null) return undefined;
160
+ if (chosen === undefined || chosen === null) {
161
+ return DISMISSED;
162
+ }
131
163
  const idx = parseIndex(chosen, options.length);
132
- // A host that returns something outside the list it was given is
133
- // indistinguishable from a dismissal. Treating it as one beats fabricating
134
- // an answer the user never gave.
135
- if (idx === null) return undefined;
164
+ // A host returning something outside the list it was given used to read as a
165
+ // dismissal, which told the model the user had declined. Nobody declined
166
+ // anything: the host is broken, or is rewriting the option text (a localising
167
+ // client will), and the model needs to hear which.
168
+ if (idx === null) {
169
+ return { kind: "host_error", detail: `selection not in the offered list: "${chosen}"` };
170
+ }
136
171
  const option = q.options[idx];
137
172
  if (option) {
138
- return {
173
+ const answer: QuestionAnswer = {
139
174
  questionIndex,
140
175
  question: q.question,
141
176
  kind: "option",
142
177
  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
178
  };
179
+ if (option.preview !== undefined && option.preview.length > 0) {
180
+ // SAFETY: preview is an optional string; present only when non-empty per contract.
181
+ (answer as QuestionAnswer & { preview: string }).preview = option.preview;
182
+ }
183
+ return { kind: "answer", answer };
149
184
  }
150
185
  // The "Type something." row, which is the one index past the authored options.
151
186
  const typed = await ui.input(`${header}${q.question}\n\n${CUSTOM_ANSWER_TITLE}`, "", opts);
152
- if (typed === undefined || typed === null) return undefined;
153
- return { questionIndex, question: q.question, kind: "custom", answer: typed };
187
+ if (typed === undefined || typed === null) {
188
+ return DISMISSED;
189
+ }
190
+ return {
191
+ kind: "answer",
192
+ answer: { questionIndex, question: q.question, kind: "custom", answer: typed },
193
+ };
154
194
  }
155
195
 
156
- /** Undefined means the user dismissed the dialog, which cancels everything. */
196
+ /**
197
+ * Dismissal cancels everything. Nothing else here can be a host error: the text
198
+ * comes from the user's own keyboard, so an out-of-range number like "13" on a
199
+ * three-option question is a typed answer, not a host returning garbage.
200
+ */
157
201
  async function askMultiSelect(
158
202
  ui: DialogUI,
159
203
  q: QuestionData,
160
204
  questionIndex: number,
161
205
  header: string,
162
206
  opts?: { timeout?: number; signal?: AbortSignal },
163
- ): Promise<QuestionAnswer | undefined> {
207
+ ): Promise<AskOutcome> {
164
208
  const list = q.options.map(formatOptionLine).join("\n");
165
209
  const value = await ui.input(
166
210
  `${header}${q.question}\n\n${list}\n\n${MULTI_SELECT_INSTRUCTIONS}`,
167
211
  MULTI_SELECT_PLACEHOLDER,
168
212
  opts,
169
213
  );
170
- if (value === undefined || value === null) return undefined;
214
+ if (value === undefined || value === null) {
215
+ return DISMISSED;
216
+ }
171
217
  const trimmed = value.trim();
172
218
  if (trimmed.length === 0) {
173
219
  // A deliberate empty commit, the same as pressing Next with nothing ticked.
@@ -176,7 +222,10 @@ async function askMultiSelect(
176
222
  // no tokens the `every` below is vacuously true and produces an empty
177
223
  // selection anyway. Removing this would leave an important behaviour
178
224
  // resting on that, and no test could tell the two apart.
179
- return { questionIndex, question: q.question, kind: "multi", answer: null, selected: [] };
225
+ return {
226
+ kind: "answer",
227
+ answer: { questionIndex, question: q.question, kind: "multi", answer: null, selected: [] },
228
+ };
180
229
  }
181
230
  const tokens = trimmed.split(/[,\s]+/).filter((tok) => tok.length > 0);
182
231
  const indices = tokens.map((tok) =>
@@ -186,13 +235,21 @@ async function askMultiSelect(
186
235
  const selected: string[] = [];
187
236
  for (const i of indices) {
188
237
  const label = q.options[i]?.label;
189
- if (label !== undefined && !selected.includes(label)) selected.push(label);
238
+ if (label !== undefined && !selected.includes(label)) {
239
+ selected.push(label);
240
+ }
190
241
  }
191
- return { questionIndex, question: q.question, kind: "multi", answer: null, selected };
242
+ return {
243
+ kind: "answer",
244
+ answer: { questionIndex, question: q.question, kind: "multi", answer: null, selected },
245
+ };
192
246
  }
193
247
  // Any token that is not an index -- a word, or a number like "13" when there
194
248
  // are three options -- means the user typed an answer rather than picking
195
249
  // from the list. Keeping it verbatim is both the honest reading and the
196
250
  // multi-select half of the "Type something." escape.
197
- return { questionIndex, question: q.question, kind: "custom", answer: trimmed };
251
+ return {
252
+ kind: "answer",
253
+ answer: { questionIndex, question: q.question, kind: "custom", answer: trimmed },
254
+ };
198
255
  }