pi-ask-popup 0.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.
Files changed (42) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +55 -0
  3. package/docs/adr/0001-fork-rpiv-ask-user-question-as-zero-dep-pi-ask-popup.md +90 -0
  4. package/package.json +48 -0
  5. package/src/ask-user-question.ts +474 -0
  6. package/src/config.ts +250 -0
  7. package/src/events.ts +107 -0
  8. package/src/index.ts +25 -0
  9. package/src/reconcile.ts +31 -0
  10. package/src/rpc-fallback.ts +198 -0
  11. package/src/state/build-questionnaire.ts +346 -0
  12. package/src/state/external-editor.ts +94 -0
  13. package/src/state/key-router.ts +378 -0
  14. package/src/state/questionnaire-session.ts +382 -0
  15. package/src/state/row-intent.ts +156 -0
  16. package/src/state/selectors/contract.ts +40 -0
  17. package/src/state/selectors/derivations.ts +40 -0
  18. package/src/state/selectors/focus.ts +17 -0
  19. package/src/state/selectors/projections.ts +111 -0
  20. package/src/state/state-reducer.ts +421 -0
  21. package/src/state/state.ts +110 -0
  22. package/src/tool/format-answer.ts +28 -0
  23. package/src/tool/response-envelope.ts +123 -0
  24. package/src/tool/types.ts +193 -0
  25. package/src/tool/validate-questionnaire.ts +74 -0
  26. package/src/view/component-binding.ts +51 -0
  27. package/src/view/components/inline-input.ts +66 -0
  28. package/src/view/components/multi-select-view.ts +208 -0
  29. package/src/view/components/option-list-view.ts +77 -0
  30. package/src/view/components/preview/markdown-content-cache.ts +76 -0
  31. package/src/view/components/preview/preview-block-renderer.ts +116 -0
  32. package/src/view/components/preview/preview-box-renderer.ts +88 -0
  33. package/src/view/components/preview/preview-layout-decider.ts +219 -0
  34. package/src/view/components/preview/preview-pane.ts +240 -0
  35. package/src/view/components/submit-picker.ts +66 -0
  36. package/src/view/components/tab-bar.ts +70 -0
  37. package/src/view/components/wrapping-select.ts +313 -0
  38. package/src/view/dialog-builder.ts +325 -0
  39. package/src/view/props-adapter.ts +124 -0
  40. package/src/view/stateful-view.ts +20 -0
  41. package/src/view/tab-components.ts +16 -0
  42. package/src/view/tab-content-strategy.ts +447 -0
package/src/config.ts ADDED
@@ -0,0 +1,250 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
4
+
5
+ /** Key spec for the overlay collapse/expand shortcut, e.g. `"ctrl+]"` or `"alt+o"`. */
6
+ export type CollapseKeySpec = string;
7
+
8
+ export const DEFAULT_COLLAPSE_KEY: CollapseKeySpec = "ctrl+]";
9
+ export const COLLAPSE_KEY_OFF: CollapseKeySpec = "off";
10
+
11
+ /** Filename both layers use, under the agent dir and under the project's config dir. */
12
+ export const CONFIG_FILE_NAME = "pi-ask-popup.json";
13
+
14
+ /** Operator-supplied copy folded into the tool's registered description and prompt. */
15
+ export interface GuidanceFields {
16
+ description?: string;
17
+ promptSnippet?: string;
18
+ promptGuidelines?: string[];
19
+ }
20
+
21
+ export interface AskPopupConfig {
22
+ /**
23
+ * Key spec for the collapse/expand shortcut, in pi-coding-agent keybinding id
24
+ * format (`modifier+key`: `ctrl+]`, `alt+o`, `ctrl+shift+h`). Defaults to
25
+ * `"ctrl+]"`. Pick something reachable on your layout — Latin American
26
+ * keyboards put `]` on the shifted layer and usually want `"ctrl+}"`. Pass
27
+ * `"off"` to disable the shortcut entirely.
28
+ */
29
+ collapseKey?: CollapseKeySpec;
30
+ guidance?: GuidanceFields;
31
+ }
32
+
33
+ export interface ConfigLoadResult {
34
+ config: AskPopupConfig;
35
+ /**
36
+ * Problems worth telling the user about, as data. Never printed here: on RPC
37
+ * and JSON hosts this process is speaking a protocol on stdout, and a stray
38
+ * `console.warn` corrupts the stream. The caller decides where these go.
39
+ */
40
+ warnings: readonly string[];
41
+ }
42
+
43
+ export interface ConfigSources {
44
+ /** Pi's global agent directory, normally `getAgentDir()` (`~/.pi/agent`). */
45
+ agentDir: string;
46
+ /**
47
+ * Workspace root whose `<CONFIG_DIR_NAME>/pi-ask-popup.json` overrides the
48
+ * global layer. Omit it to skip the project layer entirely — callers pass
49
+ * `ctx.cwd` only when `ctx.isProjectTrusted()`, so an untrusted checkout
50
+ * cannot rebind a global keyboard shortcut or rewrite the tool description
51
+ * the model is given.
52
+ */
53
+ projectDir?: string;
54
+ }
55
+
56
+ /** The layer files, global first. Existence is not checked; reading never creates them. */
57
+ export function configPaths(sources: ConfigSources): string[] {
58
+ const paths = [join(sources.agentDir, CONFIG_FILE_NAME)];
59
+ if (sources.projectDir !== undefined) {
60
+ paths.push(join(sources.projectDir, CONFIG_DIR_NAME, CONFIG_FILE_NAME));
61
+ }
62
+ return paths;
63
+ }
64
+
65
+ /**
66
+ * Read one layer. An absent file means "no overrides" and is not a warning —
67
+ * having no config is the normal case, not a degraded one. Anything else that
68
+ * goes wrong (malformed JSON, a directory in the file's place, no read
69
+ * permission) degrades to no overrides and reports why.
70
+ *
71
+ * Deliberately `readFileSync` + catch rather than `existsSync` then read: the
72
+ * check-then-read pair races, and this keeps the no-write guarantee obvious —
73
+ * nothing here can create a file or a parent directory.
74
+ */
75
+ function readLayer(path: string, warnings: string[]): Record<string, unknown> {
76
+ let text: string;
77
+ try {
78
+ text = readFileSync(path, "utf8");
79
+ } catch (err) {
80
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
81
+ warnings.push(`pi-ask-popup: cannot read ${path} — ${(err as Error).message}`);
82
+ }
83
+ return {};
84
+ }
85
+ try {
86
+ 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)) {
89
+ warnings.push(`pi-ask-popup: ${path} is not a JSON object, ignoring it`);
90
+ return {};
91
+ }
92
+ return parsed as Record<string, unknown>;
93
+ } catch (err) {
94
+ warnings.push(`pi-ask-popup: invalid JSON in ${path}, ignoring it — ${(err as Error).message}`);
95
+ return {};
96
+ }
97
+ }
98
+
99
+ function nonEmptyString(value: unknown): string | undefined {
100
+ return typeof value === "string" && value.length > 0 ? value : undefined;
101
+ }
102
+
103
+ /**
104
+ * Keep only the guidance entries that are usable, silently. A single bad field
105
+ * is not worth a warning — the field falls back to the built-in default and
106
+ * the tool still registers.
107
+ */
108
+ export function validateGuidanceFields(fields: unknown): GuidanceFields {
109
+ if (fields === null || typeof fields !== "object") return {};
110
+ const g = fields as Record<string, unknown>;
111
+ const out: GuidanceFields = {};
112
+ const description = nonEmptyString(g.description);
113
+ if (description !== undefined) out.description = description;
114
+ const promptSnippet = nonEmptyString(g.promptSnippet);
115
+ if (promptSnippet !== undefined) out.promptSnippet = promptSnippet;
116
+ const guidelines = g.promptGuidelines;
117
+ if (
118
+ Array.isArray(guidelines) &&
119
+ guidelines.length > 0 &&
120
+ guidelines.every((s) => nonEmptyString(s) !== undefined)
121
+ ) {
122
+ out.promptGuidelines = guidelines as string[];
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /**
128
+ * Read the global layer, then let the project layer override it. Guidance
129
+ * merges per field rather than wholesale, so a workspace can pin one line of
130
+ * copy without restating the rest.
131
+ */
132
+ export function loadConfig(sources: ConfigSources): ConfigLoadResult {
133
+ const warnings: string[] = [];
134
+ const config: AskPopupConfig = {};
135
+ for (const path of configPaths(sources)) {
136
+ const raw = readLayer(path, warnings);
137
+ const collapseKey = nonEmptyString(raw.collapseKey);
138
+ if (collapseKey !== undefined) config.collapseKey = collapseKey;
139
+ const guidance = validateGuidanceFields(raw.guidance);
140
+ if (Object.keys(guidance).length > 0) {
141
+ config.guidance = { ...config.guidance, ...guidance };
142
+ }
143
+ }
144
+ return { config, warnings };
145
+ }
146
+
147
+ /**
148
+ * Base keys pi-tui's `matchesKey` actually recognizes, transcribed from its
149
+ * `SYMBOL_KEYS` set and the named cases of its match switch. Two deliberate
150
+ * differences from the set upstream accepted:
151
+ *
152
+ * - `"` is gone. pi-tui does not carry it, so `ctrl+"` parsed fine and then
153
+ * matched nothing: a dead shortcut with no fallback and no diagnostic.
154
+ * - `+` is gone. It is the separator, so no spec can name it as a base key
155
+ * anyway, and `matchesKey("+", "+")` is false.
156
+ *
157
+ * Named keys are lowercase because `parseKeyId` lowercases the whole id before
158
+ * matching — `pageUp` reaches the switch as `pageup`.
159
+ */
160
+ const SYMBOL_KEYS = new Set("`-=[]\\;',./!@#$%^&*()_|~{}:<>?");
161
+ const SPECIAL_KEYS = new Set([
162
+ "escape",
163
+ "esc",
164
+ "enter",
165
+ "return",
166
+ "tab",
167
+ "space",
168
+ "backspace",
169
+ "delete",
170
+ "insert",
171
+ "clear",
172
+ "home",
173
+ "end",
174
+ "pageup",
175
+ "pagedown",
176
+ "up",
177
+ "down",
178
+ "left",
179
+ "right",
180
+ ...Array.from({ length: 12 }, (_, i) => `f${i + 1}`),
181
+ ]);
182
+
183
+ const MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
184
+
185
+ function isBaseKey(key: string): boolean {
186
+ if (key.length !== 1) return SPECIAL_KEYS.has(key);
187
+ return (key >= "a" && key <= "z") || (key >= "0" && key <= "9") || SYMBOL_KEYS.has(key);
188
+ }
189
+
190
+ /**
191
+ * Mirror pi-tui's KeyId grammar strictly: zero or more distinct modifiers, then
192
+ * one base key.
193
+ *
194
+ * Loose acceptance is not merely untidy here. pi-tui's `parseKeyId` takes the
195
+ * LAST `+`-part as the key and asks only whether the remaining parts *include*
196
+ * "ctrl"/"shift"/"alt"/"super" — unknown parts are discarded, not rejected. So
197
+ * a typo like `ctr+]` parses as a bare `]` with no modifiers, and the raw
198
+ * terminal listener that owns this shortcut would then swallow every `]` the
199
+ * user types, anywhere.
200
+ */
201
+ function isValidCollapseKeySpec(spec: string): boolean {
202
+ if (!spec || spec.startsWith("+") || spec.endsWith("+") || spec.includes("++")) return false;
203
+ const parts = spec.split("+");
204
+ const base = parts[parts.length - 1] ?? "";
205
+ 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;
208
+ return isBaseKey(base);
209
+ }
210
+
211
+ /**
212
+ * Normalize and validate the configured shortcut, falling back to the default.
213
+ *
214
+ * The parameter widens `collapseKey` to include an explicit `undefined` rather
215
+ * than leaving it merely optional: callers spread a partially-populated config
216
+ * in, and under `exactOptionalPropertyTypes` a present-but-undefined key is a
217
+ * different type from an absent one. Both mean "not configured" here.
218
+ */
219
+ export function resolveCollapseKey(config: {
220
+ collapseKey?: CollapseKeySpec | undefined;
221
+ }): CollapseKeySpec {
222
+ 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;
225
+ return isValidCollapseKeySpec(raw) ? raw : DEFAULT_COLLAPSE_KEY;
226
+ }
227
+
228
+ // The only compound-word names in SPECIAL_KEYS — capitalizing the first letter
229
+ // alone would render them "Pageup" and "Pagedown".
230
+ const COMPOUND_KEY_DISPLAY: Record<string, string> = {
231
+ pageup: "PageUp",
232
+ pagedown: "PageDown",
233
+ };
234
+
235
+ /**
236
+ * Pretty-print a resolved spec for UI copy: `"ctrl+]"` → `"Ctrl+]"`, `"alt+o"` →
237
+ * `"Alt+O"`, `"ctrl+pagedown"` → `"Ctrl+PageDown"`. Display only — matching
238
+ * always uses the raw lowercase spec, so never feed the result back into
239
+ * `matchesKey`.
240
+ */
241
+ export function formatKeySpecForDisplay(spec: CollapseKeySpec): string {
242
+ return spec
243
+ .split("+")
244
+ .map(
245
+ (part) =>
246
+ COMPOUND_KEY_DISPLAY[part] ??
247
+ (part.length <= 1 ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1)),
248
+ )
249
+ .join("+");
250
+ }
package/src/events.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The public event contract.
3
+ *
4
+ * This module deliberately imports nothing. A footer, statusline or notifier
5
+ * subscribes to these channels to know what is being asked and whether the
6
+ * agent is waiting on a person; it should not have to load a render graph, or
7
+ * a schema compiler, to read two strings and four interfaces. Adding an import
8
+ * here defeats the reason the `./events` subpath exists, so there is a test
9
+ * that fails if one appears.
10
+ *
11
+ * Stability rules for both channels:
12
+ *
13
+ * 1. Channel names never change once published. Subscribers hardcode them.
14
+ * 2. Payload changes are append-only, and new fields ship optional.
15
+ * Listeners must tolerate fields they do not know.
16
+ * 3. Anything breaking -- a rename, a retype, a removal, a change in when
17
+ * the event fires -- takes a new channel name and a period of emitting
18
+ * both.
19
+ * 4. No version field inside a payload. The channel name carries the
20
+ * version, because that is what a subscriber matches on.
21
+ * 5. Payloads stay JSON-safe: primitives, arrays and plain objects. No Set,
22
+ * Map, Date or class instance. Listeners forward these across process and
23
+ * network boundaries, and a Map arrives at the far end as `{}`.
24
+ */
25
+
26
+ /** Fired once, as the questionnaire is put to the user. */
27
+ export const ASK_POPUP_PROMPT_EVENT = "pi-ask-popup:prompt" as const;
28
+
29
+ /** Fired true before the wait and false when it ends, however it ends. */
30
+ export const ASK_POPUP_BLOCKED_EVENT = "pi-ask-popup:blocked" as const;
31
+
32
+ export interface AskPopupPromptOption {
33
+ label: string;
34
+ description: string;
35
+ /**
36
+ * Whether the option carries markdown preview content. The content itself is
37
+ * deliberately not shipped: it can run to hundreds of lines, and a listener
38
+ * that wants to say "this one has details" only needs the boolean.
39
+ */
40
+ hasPreview: boolean;
41
+ }
42
+
43
+ export interface AskPopupPromptQuestion {
44
+ /** The question text, as the agent wrote it. */
45
+ question: string;
46
+ /** The short chip shown beside the question. */
47
+ header: string;
48
+ /** Normalized from the optional parameter, so listeners never see undefined. */
49
+ multiSelect: boolean;
50
+ options: readonly AskPopupPromptOption[];
51
+ }
52
+
53
+ export interface AskPopupPromptEventPayload {
54
+ questions: readonly AskPopupPromptQuestion[];
55
+ }
56
+
57
+ export interface AskPopupBlockedEventPayload {
58
+ /** True while input is awaited; false once it is answered, cancelled or failed. */
59
+ active: boolean;
60
+ }
61
+
62
+ /**
63
+ * What `buildPromptPayload` reads. Declared structurally rather than imported
64
+ * from the tool's typebox schemas: the shapes match, so the real parameters
65
+ * satisfy this at the call site, and this file stays import-free.
66
+ */
67
+ export interface PromptSourceOption {
68
+ label: string;
69
+ description: string;
70
+ preview?: string | undefined;
71
+ }
72
+
73
+ export interface PromptSourceQuestion {
74
+ question: string;
75
+ header: string;
76
+ multiSelect?: boolean | undefined;
77
+ options: readonly PromptSourceOption[];
78
+ }
79
+
80
+ export interface PromptSource {
81
+ questions: readonly PromptSourceQuestion[];
82
+ }
83
+
84
+ /**
85
+ * Project the validated tool parameters onto the prompt payload. Copies field
86
+ * by field rather than spreading: a spread would forward whatever else the
87
+ * parameters happen to carry, and preview content is the thing this payload
88
+ * exists to leave behind.
89
+ */
90
+ export function buildPromptPayload(source: PromptSource): AskPopupPromptEventPayload {
91
+ return {
92
+ questions: source.questions.map((q) => ({
93
+ question: q.question,
94
+ header: q.header,
95
+ multiSelect: q.multiSelect ?? false,
96
+ options: q.options.map((o) => ({
97
+ label: o.label,
98
+ description: o.description,
99
+ hasPreview: typeof o.preview === "string" && o.preview.length > 0,
100
+ })),
101
+ })),
102
+ };
103
+ }
104
+
105
+ export function buildBlockedPayload(active: boolean): AskPopupBlockedEventPayload {
106
+ return { active };
107
+ }
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * pi-ask-popup — a tabbed questionnaire the model can put to you when it would
3
+ * otherwise guess.
4
+ *
5
+ * Registers `ask_user_question`, plus a reconciler that keeps the tool out of
6
+ * the model's tool list on hosts with no way to show it.
7
+ */
8
+
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { registerAskPopupTool } from "./ask-user-question.js";
11
+ import { registerAskPopupReconciler } from "./reconcile.js";
12
+
13
+ export {
14
+ ASK_POPUP_BLOCKED_EVENT,
15
+ ASK_POPUP_PROMPT_EVENT,
16
+ type AskPopupBlockedEventPayload,
17
+ type AskPopupPromptEventPayload,
18
+ type AskPopupPromptOption,
19
+ type AskPopupPromptQuestion,
20
+ } from "./events.js";
21
+
22
+ export default function piAskPopup(pi: ExtensionAPI): void {
23
+ registerAskPopupTool(pi);
24
+ registerAskPopupReconciler(pi);
25
+ }
@@ -0,0 +1,31 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { ASK_POPUP_TOOL_NAME } from "./ask-user-question.js";
3
+
4
+ /**
5
+ * Keep the tool out of the model's hands when there is no way to show it.
6
+ *
7
+ * A model offered a tool it cannot use will call it, get an error, and have to
8
+ * recover; not offering it is simply better. `ctx.hasUI` is the honest signal:
9
+ * RPC hosts report true and the dialog walker works there, so they stay.
10
+ *
11
+ * Idempotent by construction. When the tool is already in the right state,
12
+ * nothing is written, so sibling tools another extension added are untouched.
13
+ */
14
+ export function reconcileAskPopupTool(pi: ExtensionAPI, ctx: ExtensionContext): void {
15
+ const active = pi.getActiveTools();
16
+ const hasTool = active.includes(ASK_POPUP_TOOL_NAME);
17
+ if (!ctx.hasUI && hasTool) {
18
+ pi.setActiveTools(active.filter((n) => n !== ASK_POPUP_TOOL_NAME));
19
+ } else if (ctx.hasUI && !hasTool) {
20
+ pi.setActiveTools([...active, ASK_POPUP_TOOL_NAME]);
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Run the reconciler before each turn, which is when the tool list the model
26
+ * sees is snapshotted. The in-handler guards stay as a one-turn backstop in
27
+ * case that ordering ever changes, or a host claims a UI it cannot render with.
28
+ */
29
+ export function registerAskPopupReconciler(pi: ExtensionAPI): void {
30
+ pi.on("before_agent_start", (_event, ctx) => reconcileAskPopupTool(pi, ctx));
31
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * The dialog-primitive path for hosts that cannot render the overlay.
3
+ *
4
+ * The TUI path draws a tabbed overlay through `ctx.ui.custom()`, which needs a
5
+ * real terminal. RPC hosts -- the VS Code pendant, ACP clients like Zed and
6
+ * Paseo -- report `hasUI: true`, because Pi's dialog sub-protocol genuinely
7
+ * works there, and yet `ui.custom()` resolves undefined without drawing
8
+ * anything. What those hosts do have is `ui.select()` and `ui.input()`, which
9
+ * they render natively. So this walks the questions one dialog at a time and
10
+ * returns the same `QuestionnaireResult` the overlay would, feeding the same
11
+ * envelope.
12
+ *
13
+ * What is lost, and why it cannot be helped: the select and input primitives
14
+ * take a title and a list, so there is no side-by-side preview pane (previews
15
+ * fold into the title), no tabbed review (one dialog per question), and
16
+ * multi-select becomes a free-text list of numbers instead of checkbox rows.
17
+ * Notes do not exist on this path at all -- neither primitive carries a field
18
+ * for them, and inventing a second dialog to collect one would double the
19
+ * number of prompts for something most answers never use.
20
+ *
21
+ * The "Type something." escape does survive, on both variants.
22
+ */
23
+
24
+ import { ROW_INTENT_META } from "./state/row-intent.js";
25
+ import type {
26
+ QuestionAnswer,
27
+ QuestionData,
28
+ QuestionnaireResult,
29
+ QuestionParams,
30
+ } from "./tool/types.js";
31
+
32
+ const MULTI_SELECT_INSTRUCTIONS =
33
+ 'Enter the numbers of all that apply, comma-separated (e.g. "1,3"), or type a custom answer as plain text.';
34
+ const CUSTOM_ANSWER_TITLE = "Type your answer:";
35
+ const MULTI_SELECT_PLACEHOLDER = "1,3";
36
+
37
+ /** How much of an option's preview is folded into a select title before truncation. */
38
+ const MAX_PREVIEW_CHARS = 600;
39
+
40
+ /**
41
+ * The slice of Pi's UI context this walker needs, declared structurally.
42
+ * `hasDialogUI` is the runtime gate that makes the shape trustworthy: jiti
43
+ * transpiles without type-checking, so a host that does not implement these
44
+ * would otherwise fail at the call rather than at the check.
45
+ */
46
+ export type DialogUI = {
47
+ select: (
48
+ title: string,
49
+ options: string[],
50
+ opts?: { timeout?: number; signal?: AbortSignal },
51
+ ) => Promise<string | undefined>;
52
+ input: (
53
+ title: string,
54
+ placeholder?: string,
55
+ opts?: { timeout?: number; signal?: AbortSignal },
56
+ ) => Promise<string | undefined>;
57
+ };
58
+
59
+ /** Whether the host implements the select and input primitives. */
60
+ export function hasDialogUI(ui: unknown): ui is DialogUI {
61
+ const u = ui as Partial<Record<"select" | "input", unknown>> | null | undefined;
62
+ return typeof u?.select === "function" && typeof u?.input === "function";
63
+ }
64
+
65
+ type Option = QuestionData["options"][number];
66
+
67
+ function formatOptionLine(option: Option, index: number): string {
68
+ return `${index + 1}. ${option.label} — ${option.description}`;
69
+ }
70
+
71
+ /**
72
+ * Read a leading option number as a zero-based index, or null when it is not
73
+ * one. `Number.parseInt` reads "2. B — b" as 2, which is what makes it work on
74
+ * the string a select dialog hands back. NaN and out-of-range both fail the
75
+ * bounds check.
76
+ */
77
+ function parseIndex(token: string, count: number): number | null {
78
+ const i = Number.parseInt(token, 10) - 1;
79
+ return i >= 0 && i < count ? i : null;
80
+ }
81
+
82
+ /** Previews folded into the title, since there is no pane to put them in. */
83
+ function buildPreviewBlock(question: QuestionData): string {
84
+ const blocks = question.options.flatMap((o, i) =>
85
+ o.preview !== undefined && o.preview.length > 0
86
+ ? [`--- ${i + 1}. ${o.label} preview ---\n${o.preview.slice(0, MAX_PREVIEW_CHARS)}`]
87
+ : [],
88
+ );
89
+ return blocks.length > 0 ? `\n\n${blocks.join("\n\n")}` : "";
90
+ }
91
+
92
+ /**
93
+ * Walk the questionnaire, one native dialog at a time.
94
+ *
95
+ * Dismissing any dialog cancels the whole questionnaire, which is what Esc
96
+ * does in the overlay, and the shared envelope turns that into a decline. Any
97
+ * other outcome produces one `QuestionAnswer` per question, so what the model
98
+ * receives is indistinguishable from the overlay path.
99
+ */
100
+ export async function runRpcQuestionnaire(
101
+ ui: DialogUI,
102
+ params: QuestionParams,
103
+ ): Promise<QuestionnaireResult> {
104
+ const answers: QuestionAnswer[] = [];
105
+ const dialogOpts = params.timeout === undefined ? undefined : { timeout: params.timeout };
106
+ for (let qi = 0; qi < params.questions.length; qi++) {
107
+ const q = params.questions[qi];
108
+ if (!q) continue;
109
+ const header = q.header ? `[${q.header}] ` : "";
110
+ const answer = q.multiSelect
111
+ ? await askMultiSelect(ui, q, qi, header, dialogOpts)
112
+ : await askSingleSelect(ui, q, qi, header, dialogOpts);
113
+ if (answer === undefined) return { answers, cancelled: true };
114
+ answers.push(answer);
115
+ }
116
+ return { answers, cancelled: false };
117
+ }
118
+
119
+ /** Undefined means the user dismissed the dialog, which cancels everything. */
120
+ async function askSingleSelect(
121
+ ui: DialogUI,
122
+ q: QuestionData,
123
+ questionIndex: number,
124
+ header: string,
125
+ opts?: { timeout?: number; signal?: AbortSignal },
126
+ ): Promise<QuestionAnswer | undefined> {
127
+ const options = q.options.map(formatOptionLine);
128
+ options.push(`${q.options.length + 1}. ${ROW_INTENT_META.other.label}`);
129
+ const chosen = await ui.select(`${header}${q.question}${buildPreviewBlock(q)}`, options, opts);
130
+ if (chosen === undefined || chosen === null) return undefined;
131
+ 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;
136
+ const option = q.options[idx];
137
+ if (option) {
138
+ return {
139
+ questionIndex,
140
+ question: q.question,
141
+ kind: "option",
142
+ 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
+ };
149
+ }
150
+ // The "Type something." row, which is the one index past the authored options.
151
+ 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 };
154
+ }
155
+
156
+ /** Undefined means the user dismissed the dialog, which cancels everything. */
157
+ async function askMultiSelect(
158
+ ui: DialogUI,
159
+ q: QuestionData,
160
+ questionIndex: number,
161
+ header: string,
162
+ opts?: { timeout?: number; signal?: AbortSignal },
163
+ ): Promise<QuestionAnswer | undefined> {
164
+ const list = q.options.map(formatOptionLine).join("\n");
165
+ const value = await ui.input(
166
+ `${header}${q.question}\n\n${list}\n\n${MULTI_SELECT_INSTRUCTIONS}`,
167
+ MULTI_SELECT_PLACEHOLDER,
168
+ opts,
169
+ );
170
+ if (value === undefined || value === null) return undefined;
171
+ const trimmed = value.trim();
172
+ if (trimmed.length === 0) {
173
+ // A deliberate empty commit, the same as pressing Next with nothing ticked.
174
+ //
175
+ // Kept explicit even though falling through reaches the same answer: with
176
+ // no tokens the `every` below is vacuously true and produces an empty
177
+ // selection anyway. Removing this would leave an important behaviour
178
+ // resting on that, and no test could tell the two apart.
179
+ return { questionIndex, question: q.question, kind: "multi", answer: null, selected: [] };
180
+ }
181
+ const tokens = trimmed.split(/[,\s]+/).filter((tok) => tok.length > 0);
182
+ const indices = tokens.map((tok) =>
183
+ /^\d+\.?$/.test(tok) ? parseIndex(tok, q.options.length) : null,
184
+ );
185
+ if (indices.every((i): i is number => i !== null)) {
186
+ const selected: string[] = [];
187
+ for (const i of indices) {
188
+ const label = q.options[i]?.label;
189
+ if (label !== undefined && !selected.includes(label)) selected.push(label);
190
+ }
191
+ return { questionIndex, question: q.question, kind: "multi", answer: null, selected };
192
+ }
193
+ // Any token that is not an index -- a word, or a number like "13" when there
194
+ // are three options -- means the user typed an answer rather than picking
195
+ // from the list. Keeping it verbatim is both the honest reading and the
196
+ // multi-select half of the "Type something." escape.
197
+ return { questionIndex, question: q.question, kind: "custom", answer: trimmed };
198
+ }