pi-ask-popup 0.1.1 → 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.
package/README.md CHANGED
@@ -38,7 +38,7 @@ A note written on a question you never answer still reaches the model, and a glo
38
38
  ## What it does
39
39
 
40
40
  - **Typed options, not a wall of prose.** Each question carries 2 to 4 authored choices, and every choice explains what it means or what it costs you.
41
- - **You can always answer in your own words.** A `Type something.` row is added to every question and widens to the full pane while you type.
41
+ - **You can always answer in your own words.** A `Type something.` row is added to every question and widens to the full pane while you type. On a multi-select question it ticks itself the moment you type into it, and what you wrote is submitted alongside whatever boxes you ticked. Clear the text and the tick goes with it.
42
42
  - **Compare artifacts, not labels.** An option can carry a markdown `preview` that renders in a bordered box beside the option list.
43
43
  - **One interruption, not five.** Up to four questions arrive in a single tabbed dialog, and a Submit tab names anything still blank before you commit.
44
44
  - **Notes on any answer, or on all of them.** `n` opens a note editor on any question tab, and on the Submit tab it writes one note covering everything. A written note stays on its tab, dimmed, and the tab bar marks which tabs carry one. A note on a question you never answer still reaches the model as `unansweredNotes`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ask-popup",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Pi extension. A tabbed terminal questionnaire the model can put to you when it would otherwise guess, with typed options, markdown previews and notes instead of free-form replies.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -162,7 +162,10 @@ export async function loadQuestionnaireSession(
162
162
  message: `${ERROR_SESSION_LOAD_FAILED} (cause: ${cause})`,
163
163
  };
164
164
  }
165
- if (!(mod.QuestionnaireSession instanceof Function)) {
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.
168
+ if (typeof mod.QuestionnaireSession !== "function") {
166
169
  const keys = JSON.stringify(Object.keys(mod));
167
170
  return {
168
171
  ok: false,
@@ -187,7 +190,7 @@ function registerCollapseKeyListener(
187
190
  sessionRef: SessionRef,
188
191
  overlayHandleRef: OverlayHandleRef,
189
192
  ): (() => void) | undefined {
190
- if (collapseKey === COLLAPSE_KEY_OFF || !(ctx.ui.onTerminalInput instanceof Function)) {
193
+ if (collapseKey === COLLAPSE_KEY_OFF || typeof ctx.ui.onTerminalInput !== "function") {
191
194
  return undefined;
192
195
  }
193
196
  let hasAnnouncedHide = false;
@@ -415,8 +418,12 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
415
418
  // primitives is malformed, and calling `custom` on it throws a bare
416
419
  // TypeError that reaches the model as a broken tool rather than an
417
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.
418
425
  // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
419
- if (!((ctx.ui as { custom?: unknown }).custom instanceof Function)) {
426
+ if (typeof (ctx.ui as { custom?: unknown }).custom !== "function") {
420
427
  return resolveUndefinedResult(ctx, typed);
421
428
  }
422
429
 
package/src/config.ts CHANGED
@@ -1,4 +1,4 @@
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
 
@@ -81,6 +81,35 @@ export function configPaths(sources: ConfigSources): string[] {
81
81
  return paths;
82
82
  }
83
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
+
84
113
  /**
85
114
  * Read one layer. An absent file means "no overrides" and is not a warning —
86
115
  * having no config is the normal case, not a degraded one. Anything else that
@@ -90,8 +119,30 @@ export function configPaths(sources: ConfigSources): string[] {
90
119
  * Deliberately `readFileSync` + catch rather than `existsSync` then read: the
91
120
  * check-then-read pair races, and this keeps the no-write guarantee obvious —
92
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.
93
129
  */
94
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 {
95
146
  let text: string;
96
147
  try {
97
148
  text = readFileSync(path, "utf8");
@@ -56,13 +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
68
  // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
62
69
  const u = ui as Partial<DialogUI> | null | undefined;
63
- return u?.select instanceof Function && u?.input instanceof Function;
70
+ return typeof u?.select === "function" && typeof u?.input === "function";
64
71
  }
65
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
+
66
88
  type Option = QuestionData["options"][number];
67
89
 
68
90
  function formatOptionLine(option: Option, index: number): string {
@@ -110,37 +132,41 @@ export async function runRpcQuestionnaire(
110
132
  continue;
111
133
  }
112
134
  const header = q.header ? `[${q.header}] ` : "";
113
- const answer = q.multiSelect
135
+ const outcome = q.multiSelect
114
136
  ? await askMultiSelect(ui, q, qi, header, dialogOpts)
115
137
  : await askSingleSelect(ui, q, qi, header, dialogOpts);
116
- if (answer === undefined) {
138
+ if (outcome.kind === "dismissed") {
117
139
  return { answers, cancelled: true };
118
140
  }
119
- answers.push(answer);
141
+ if (outcome.kind === "host_error") {
142
+ return { answers, cancelled: true, error: "host_error", hostErrorDetail: outcome.detail };
143
+ }
144
+ answers.push(outcome.answer);
120
145
  }
121
146
  return { answers, cancelled: false };
122
147
  }
123
148
 
124
- /** 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. */
125
150
  async function askSingleSelect(
126
151
  ui: DialogUI,
127
152
  q: QuestionData,
128
153
  questionIndex: number,
129
154
  header: string,
130
155
  opts?: { timeout?: number; signal?: AbortSignal },
131
- ): Promise<QuestionAnswer | undefined> {
156
+ ): Promise<AskOutcome> {
132
157
  const options = q.options.map(formatOptionLine);
133
158
  options.push(`${q.options.length + 1}. ${ROW_INTENT_META.other.label}`);
134
159
  const chosen = await ui.select(`${header}${q.question}${buildPreviewBlock(q)}`, options, opts);
135
160
  if (chosen === undefined || chosen === null) {
136
- return undefined;
161
+ return DISMISSED;
137
162
  }
138
163
  const idx = parseIndex(chosen, options.length);
139
- // A host that returns something outside the list it was given is
140
- // indistinguishable from a dismissal. Treating it as one beats fabricating
141
- // an answer the user never gave.
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.
142
168
  if (idx === null) {
143
- return undefined;
169
+ return { kind: "host_error", detail: `selection not in the offered list: "${chosen}"` };
144
170
  }
145
171
  const option = q.options[idx];
146
172
  if (option) {
@@ -154,24 +180,31 @@ async function askSingleSelect(
154
180
  // SAFETY: preview is an optional string; present only when non-empty per contract.
155
181
  (answer as QuestionAnswer & { preview: string }).preview = option.preview;
156
182
  }
157
- return answer;
183
+ return { kind: "answer", answer };
158
184
  }
159
185
  // The "Type something." row, which is the one index past the authored options.
160
186
  const typed = await ui.input(`${header}${q.question}\n\n${CUSTOM_ANSWER_TITLE}`, "", opts);
161
187
  if (typed === undefined || typed === null) {
162
- return undefined;
188
+ return DISMISSED;
163
189
  }
164
- return { questionIndex, question: q.question, kind: "custom", answer: typed };
190
+ return {
191
+ kind: "answer",
192
+ answer: { questionIndex, question: q.question, kind: "custom", answer: typed },
193
+ };
165
194
  }
166
195
 
167
- /** 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
+ */
168
201
  async function askMultiSelect(
169
202
  ui: DialogUI,
170
203
  q: QuestionData,
171
204
  questionIndex: number,
172
205
  header: string,
173
206
  opts?: { timeout?: number; signal?: AbortSignal },
174
- ): Promise<QuestionAnswer | undefined> {
207
+ ): Promise<AskOutcome> {
175
208
  const list = q.options.map(formatOptionLine).join("\n");
176
209
  const value = await ui.input(
177
210
  `${header}${q.question}\n\n${list}\n\n${MULTI_SELECT_INSTRUCTIONS}`,
@@ -179,7 +212,7 @@ async function askMultiSelect(
179
212
  opts,
180
213
  );
181
214
  if (value === undefined || value === null) {
182
- return undefined;
215
+ return DISMISSED;
183
216
  }
184
217
  const trimmed = value.trim();
185
218
  if (trimmed.length === 0) {
@@ -189,7 +222,10 @@ async function askMultiSelect(
189
222
  // no tokens the `every` below is vacuously true and produces an empty
190
223
  // selection anyway. Removing this would leave an important behaviour
191
224
  // resting on that, and no test could tell the two apart.
192
- 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
+ };
193
229
  }
194
230
  const tokens = trimmed.split(/[,\s]+/).filter((tok) => tok.length > 0);
195
231
  const indices = tokens.map((tok) =>
@@ -203,11 +239,17 @@ async function askMultiSelect(
203
239
  selected.push(label);
204
240
  }
205
241
  }
206
- 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
+ };
207
246
  }
208
247
  // Any token that is not an index -- a word, or a number like "13" when there
209
248
  // are three options -- means the user typed an answer rather than picking
210
249
  // from the list. Keeping it verbatim is both the honest reading and the
211
250
  // multi-select half of the "Type something." escape.
212
- 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
+ };
213
255
  }
@@ -10,7 +10,10 @@ import {
10
10
  import { MultiSelectView } from "../view/components/multi-select-view.js";
11
11
  import { OptionListView } from "../view/components/option-list-view.js";
12
12
  import { PreviewBlockRenderer } from "../view/components/preview/preview-block-renderer.js";
13
- import { crossTabLeftWidthWithDonation } from "../view/components/preview/preview-layout-decider.js";
13
+ import {
14
+ crossTabLeftWidthWithDonation,
15
+ memoizeByPaneWidth,
16
+ } from "../view/components/preview/preview-layout-decider.js";
14
17
  import { PreviewPane, type PreviewPaneProps } from "../view/components/preview/preview-pane.js";
15
18
  import { SubmitPicker } from "../view/components/submit-picker.js";
16
19
  import { TabBar } from "../view/components/tab-bar.js";
@@ -121,8 +124,29 @@ class QuestionnaireBuilder {
121
124
  private readonly markdownTheme = getMarkdownTheme();
122
125
  private readonly notesInput: Editor;
123
126
  private readonly inlineInput: Editor;
124
- private readonly getTerminalWidth = () => this.tui.terminal.columns;
125
- private readonly getTerminalRows = () => this.tui.terminal.rows;
127
+ /**
128
+ * Terminal size for the frame being painted, refreshed by `beginFrame` and
129
+ * read back by everything in that frame.
130
+ *
131
+ * Components used to reach `tui.terminal` themselves, each at the moment it
132
+ * happened to run: the pane deciding side-by-side against one reading, the
133
+ * dialog cutting the scroll window against a later one. A resize landing
134
+ * between the two split the frame in half. One read at the top of
135
+ * `DialogView.render` cannot.
136
+ *
137
+ * Do not debounce this. A drag fires a resize every few milliseconds, but
138
+ * each one only reaches `tui.requestRender()`, which sets a flag, schedules
139
+ * on `process.nextTick` and holds a 16 ms floor between paints. The widths in
140
+ * between are never painted and never reach a cache; a debounce on top would
141
+ * buy nothing and delay the frame the user is dragging towards.
142
+ */
143
+ private readonly frameTerminal = { columns: 0, rows: 0 };
144
+ private readonly beginFrame = (): void => {
145
+ this.frameTerminal.columns = this.tui.terminal.columns;
146
+ this.frameTerminal.rows = this.tui.terminal.rows;
147
+ };
148
+ private readonly getFrameTerminalWidth = () => this.frameTerminal.columns;
149
+ private readonly getFrameTerminalRows = () => this.frameTerminal.rows;
126
150
 
127
151
  constructor(config: QuestionnaireBuildConfig) {
128
152
  this.tui = config.tui;
@@ -133,6 +157,9 @@ class QuestionnaireBuilder {
133
157
  this.initialState = config.initialState;
134
158
  this.getCurrentTab = config.getCurrentTab;
135
159
  this.collapseKey = config.collapseKey;
160
+ // Seeded here so a pane that renders before any frame begins sees a real
161
+ // terminal rather than a zero-width one.
162
+ this.beginFrame();
136
163
 
137
164
  this.selectTheme = this.makeSelectTheme();
138
165
  const textEditorTheme = editorTheme(this.theme);
@@ -185,7 +212,7 @@ class QuestionnaireBuilder {
185
212
  });
186
213
  const preview = new PreviewPane({
187
214
  question,
188
- getTerminalWidth: this.getTerminalWidth,
215
+ getFrameTerminalWidth: this.getFrameTerminalWidth,
189
216
  optionListView: optionList,
190
217
  previewBlock,
191
218
  });
@@ -215,8 +242,11 @@ class QuestionnaireBuilder {
215
242
  // objects first, as upstream did, only produced a shape that already
216
243
  // existed -- and produced it with an explicit undefined, which is a
217
244
  // different type from an absent key here.
218
- const globalLeftWidth = (paneWidth: number): number =>
219
- crossTabLeftWidthWithDonation(questions, itemsByTab, questions, paneWidth);
245
+ // Memoized: the questions and their rows are fixed for the life of the
246
+ // questionnaire, so the donation is pure of the pane width alone.
247
+ const globalLeftWidth = memoizeByPaneWidth((paneWidth: number): number =>
248
+ crossTabLeftWidthWithDonation(questions, itemsByTab, questions, paneWidth),
249
+ );
220
250
  for (const tab of tabs) {
221
251
  tab.preview.setGlobalLeftWidth(globalLeftWidth);
222
252
  }
@@ -279,7 +309,8 @@ class QuestionnaireBuilder {
279
309
  ...(submitPicker === undefined ? {} : { submitPicker }),
280
310
  getBodyHeight: heights.global,
281
311
  getCurrentBodyHeight: heights.current,
282
- getTerminalRows: this.getTerminalRows,
312
+ beginFrame: this.beginFrame,
313
+ getFrameTerminalRows: this.getFrameTerminalRows,
283
314
  collapseKey: this.collapseKey,
284
315
  },
285
316
  { state: this.initialState, activePreviewPane },
@@ -314,6 +345,10 @@ class QuestionnaireBuilder {
314
345
  }),
315
346
  perTabBinding({
316
347
  resolve: (tab) => tab.multiSelect,
348
+ // Gated like the other two. A write to an inactive tab's view clears the
349
+ // layout it cached, and nothing was going to read the result: the props
350
+ // for an inactive tab only change while that tab is the active one.
351
+ predicate: isActiveTab,
317
352
  select: selectMultiSelectProps,
318
353
  }),
319
354
  ];
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
@@ -53,6 +53,11 @@ function runEditor(command: string, file: string): Promise<void> {
53
53
  * editor, a failed temp write, anything at all must not leave the user in a
54
54
  * stopped TUI with no way back.
55
55
  *
56
+ * Every filesystem step is async. The TUI is stopped anyway, so nothing here is
57
+ * painting, but the event loop is shared with the rest of Pi and blocking it on
58
+ * a temp write over a slow or network-backed `TMPDIR` stalls every other
59
+ * extension in the process.
60
+ *
56
61
  * One trailing newline is stripped, matching Pi's main editor flow: most editors
57
62
  * add one on save, and keeping it would silently append a blank line to every
58
63
  * answer that went through here.
@@ -70,29 +75,32 @@ export async function editWithExternalEditor(
70
75
  throw new Error("External editor command is empty");
71
76
  }
72
77
 
73
- const tempDir = mkdtempSync(join(tmpdir(), "pi-ask-popup-"));
78
+ const tempDir = await mkdtemp(join(tmpdir(), "pi-ask-popup-"));
74
79
  const tempFile = join(tempDir, "answer.md");
75
80
  let tuiStopped = false;
76
81
 
77
82
  try {
78
- writeFileSync(tempFile, value, "utf8");
83
+ await writeFile(tempFile, value, "utf8");
79
84
  tui.stop();
80
85
  tuiStopped = true;
81
86
  process.stdout.write(
82
87
  `Launching external editor: ${command}\nPi will resume when the editor exits.\n`,
83
88
  );
84
89
  await runEditor(command, tempFile);
85
- return readFileSync(tempFile, "utf8").replace(/\r?\n$/, "");
90
+ return (await readFile(tempFile, "utf8")).replace(/\r?\n$/, "");
86
91
  } finally {
87
- try {
88
- rmSync(tempDir, { recursive: true, force: true });
89
- } catch {
90
- // Best effort. A temp directory left behind is a nuisance; a TUI left
91
- // stopped because cleanup threw is a hung session.
92
- }
92
+ // The terminal comes back before the cleanup, not after. Both are in the
93
+ // finally, but a slow or wedged unlink must not be what stands between the
94
+ // user and a working screen.
93
95
  if (tuiStopped) {
94
96
  tui.start();
95
97
  tui.requestRender(true);
96
98
  }
99
+ try {
100
+ await rm(tempDir, { recursive: true, force: true });
101
+ } catch {
102
+ // Best effort. A temp directory left behind is a nuisance; a failed
103
+ // cleanup that propagated would replace the answer with an error.
104
+ }
97
105
  }
98
106
  }
@@ -142,6 +142,14 @@ function isString(value: JsonValue | undefined): value is string {
142
142
  return typeof value === "string";
143
143
  }
144
144
 
145
+ /**
146
+ * The ticked labels, plus the typed row's text when there is any.
147
+ *
148
+ * The typed row has no checkbox of its own to consult: text in it IS the tick,
149
+ * which is why an empty or whitespace-only draft contributes nothing. The text
150
+ * comes from the live editor rather than the reducer's copy, which is only
151
+ * refreshed when the user navigates off the row.
152
+ */
145
153
  function buildMultiSelected(state: QuestionnaireState, runtime: QuestionnaireRuntime): string[] {
146
154
  const q = runtime.questions[state.currentTab];
147
155
  if (!q) {
@@ -156,6 +164,10 @@ function buildMultiSelected(state: QuestionnaireState, runtime: QuestionnaireRun
156
164
  }
157
165
  }
158
166
  }
167
+ const typed = runtime.inputBuffer.trim();
168
+ if (typed.length > 0 && !out.includes(typed)) {
169
+ out.push(typed);
170
+ }
159
171
  return out;
160
172
  }
161
173
 
@@ -236,6 +248,17 @@ function routeInputMode(
236
248
  return { kind: "ignore" };
237
249
  }
238
250
  if (isConfirm(kb, data)) {
251
+ // On a multi-select question, Enter here commits the question the way the
252
+ // Next row does, and `buildMultiSelected` carries the typed text along with
253
+ // whatever boxes are ticked. It used to commit the text ALONE, discarding
254
+ // every tick the user had made.
255
+ if (runtime.questions[state.currentTab]?.multiSelect === true) {
256
+ return {
257
+ kind: "multi_confirm",
258
+ selected: buildMultiSelected(state, runtime),
259
+ autoAdvanceTab: computeAutoAdvanceTab(state, runtime),
260
+ };
261
+ }
239
262
  const answer = buildSingleSelectAnswer(state, runtime);
240
263
  if (!answer) {
241
264
  return { kind: "ignore" };
@@ -1,5 +1,6 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import type { Editor, OverlayHandle, TUI } from "@earendil-works/pi-tui";
3
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
4
  import { COLLAPSE_KEY_OFF, formatKeySpecForDisplay } from "../config.js";
4
5
  import type { QuestionData, QuestionnaireResult, QuestionParams } from "../tool/types.js";
5
6
  import {
@@ -38,6 +39,32 @@ export interface QuestionnaireSessionConfig {
38
39
  canReopenWhileHidden: boolean;
39
40
  }
40
41
 
42
+ /** Header of the screen shown when the overlay could not be painted. */
43
+ export const RENDER_FAILED_TEXT = "Questionnaire failed to render — press Esc to cancel";
44
+
45
+ /** Never throws, whatever was thrown: this runs inside the boundary. */
46
+ function describeThrown(thrown: unknown): string {
47
+ try {
48
+ return thrown instanceof Error ? thrown.message : String(thrown);
49
+ } catch {
50
+ return "unknown error";
51
+ }
52
+ }
53
+
54
+ /**
55
+ * The screen the boundary paints in place of the overlay.
56
+ *
57
+ * Deliberately themeless and unstyled: a theme call is one of the things that
58
+ * can have thrown, and a fallback that needs the failing machinery to work is
59
+ * not a fallback. Two plain rows, clipped to the width.
60
+ */
61
+ function renderFailureScreen(width: number, thrown: unknown): string[] {
62
+ return [
63
+ truncateToWidth(` ${RENDER_FAILED_TEXT} `, width, ""),
64
+ truncateToWidth(` ${describeThrown(thrown)} `, width, "…"),
65
+ ];
66
+ }
67
+
41
68
  export interface QuestionnaireSessionComponent {
42
69
  render(width: number): string[];
43
70
  invalidate(): void;
@@ -163,7 +190,18 @@ export class QuestionnaireSession {
163
190
  ): QuestionnaireSessionComponent {
164
191
  const collapsedRender = this.buildCollapsedRender(theme);
165
192
  return {
166
- render: (width) => (this.state.collapsed ? collapsedRender(width) : built.render(width)),
193
+ // The boundary. Everything below it — markdown from the model, border
194
+ // arithmetic, width math — runs inside pi-tui's render loop, where a
195
+ // throw takes the overlay down and leaves the user with no way to answer
196
+ // or dismiss it. Input still routes through `handleInput`, so Esc works
197
+ // on the fallback screen.
198
+ render: (width) => {
199
+ try {
200
+ return this.state.collapsed ? collapsedRender(width) : built.render(width);
201
+ } catch (thrown) {
202
+ return renderFailureScreen(width, thrown);
203
+ }
204
+ },
167
205
  invalidate: built.invalidate,
168
206
  handleInput: (data) => this.dispatch(data),
169
207
  };
@@ -349,6 +387,11 @@ export class QuestionnaireSession {
349
387
  }
350
388
  return;
351
389
  }
390
+ // One apply, and the editor adds none of its own: pi-tui's `Editor`
391
+ // requests a render from its setters and its autocomplete callbacks, never
392
+ // from `handleInput`, and this editor has no autocomplete provider. So a
393
+ // keystroke here asks for exactly one render — which the session's tests
394
+ // pin, since a second would mean two owners of one tick.
352
395
  this.inlineInput.handleInput(data);
353
396
  this.viewAdapter.apply(this.state);
354
397
  }
@@ -22,6 +22,7 @@ function emptyMultiSelectProps(ctx: PerTabBindingContext): MultiSelectViewProps
22
22
  rows: [],
23
23
  other: {
24
24
  active: false,
25
+ checked: false,
25
26
  inputMode: false,
26
27
  inputBuffer: ctx.inputBuffer,
27
28
  inputCursorOffset: ctx.inputCursorOffset,
@@ -56,6 +57,11 @@ export const selectMultiSelectProps: PerTabSelector<MultiSelectViewProps> = (sta
56
57
  rows,
57
58
  other: {
58
59
  active: focused && state.optionIndex === question.options.length,
60
+ // Text in the row is the tick: it appears on the first character typed and
61
+ // goes on the first delete that empties the row, without a keystroke of
62
+ // its own. Read from the live editor text, because typing never reaches
63
+ // the reducer — `handleIgnoreInline` feeds the editor directly.
64
+ checked: ctx.inputBuffer.trim().length > 0,
59
65
  inputMode: state.inputMode,
60
66
  inputBuffer: ctx.inputBuffer,
61
67
  inputCursorOffset: ctx.inputCursorOffset,
@@ -116,6 +116,8 @@ function syncMultiSelectFromAnswers(
116
116
  indices.add(i);
117
117
  }
118
118
  }
119
+ // The typed row needs nothing here. Its tick is its text, and the text comes
120
+ // back with the tab's draft.
119
121
  return indices;
120
122
  }
121
123
 
@@ -133,6 +135,12 @@ function persistMultiSelectAnswer(
133
135
  selected.push(q.options[i]!.label);
134
136
  }
135
137
  }
138
+ // Text in the typed row is itself the tick, so a non-blank draft joins the
139
+ // selection. A draft that repeats an option label is not listed twice.
140
+ const typed = customDraftValueFor(state, state.currentTab).trim();
141
+ if (typed.length > 0 && !selected.includes(typed)) {
142
+ selected.push(typed);
143
+ }
136
144
  const out = new Map(state.answers);
137
145
  if (selected.length === 0) {
138
146
  out.delete(state.currentTab);
@@ -258,6 +266,12 @@ const navHandler: Handler<"nav"> = (state, action, ctx) => {
258
266
  inputMode,
259
267
  customDraftsByTab,
260
268
  };
269
+ // Leaving the typed row: keystrokes never reached the reducer, so this is the
270
+ // first moment it sees the finished text. Re-state the answer here or the
271
+ // Submit review quotes a draft that is several characters out of date.
272
+ if (state.inputMode) {
273
+ next.answers = persistMultiSelectAnswer(next, ctx);
274
+ }
261
275
  if (!inputMode) {
262
276
  return { state: next, effects: [] };
263
277
  }
@@ -298,11 +312,6 @@ const confirmHandler: Handler<"confirm"> = (state, action, ctx) => {
298
312
  }
299
313
  const answers = new Map(state.answers);
300
314
  answers.set(answer.questionIndex, answer);
301
- // Custom free-text on a multi-select tab is mutually exclusive with checkbox selections:
302
- // clear the checked set immediately so [✔] glyphs vanish on Enter. (A custom answer
303
- // carries no `selected` array, so syncMultiSelectFromAnswers keeps it empty on tab-back.)
304
- const isCustomMulti =
305
- answer.kind === "custom" && ctx.questions[answer.questionIndex]?.multiSelect === true;
306
315
  const customDraftsByTab =
307
316
  answer.kind === "custom"
308
317
  ? withoutCustomDraft(state, answer.questionIndex)
@@ -312,9 +321,6 @@ const confirmHandler: Handler<"confirm"> = (state, action, ctx) => {
312
321
  answers,
313
322
  customDraftsByTab,
314
323
  };
315
- if (isCustomMulti) {
316
- next.multiSelectChecked = new Set<number>();
317
- }
318
324
  if (action.autoAdvanceTab !== undefined) {
319
325
  return switchTabResult(next, action.autoAdvanceTab, ctx);
320
326
  }