pi-ask-popup 0.1.1 → 0.2.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 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`.
@@ -106,19 +106,22 @@ When `error` is `timed_out`, the text is `Questionnaire timed out, the user did
106
106
 
107
107
  ## Events
108
108
 
109
- The package emits one event on Pi's event bus, after validation passes and before the dialog shows. Import it from the `./events` subpath:
109
+ The package emits lifecycle events on Pi's event bus after validation passes. Import their names and payload types from the `./events` subpath:
110
110
 
111
111
  ```ts
112
112
  import {
113
113
  ASK_POPUP_PROMPT_EVENT,
114
114
  ASK_POPUP_BLOCKED_EVENT,
115
+ HERDR_BLOCKED_EVENT,
115
116
  type AskPopupPromptEventPayload,
116
117
  type AskPopupBlockedEventPayload,
117
118
  } from "pi-ask-popup/events";
118
119
  ```
119
120
 
120
- `ASK_POPUP_PROMPT_EVENT` channel is `pi-ask-popup:prompt`. Payload is `questions[].{ question, header, multiSelect, options[] }` where each option is `{ label, description, hasPreview }`. Preview content is not shipped, only `hasPreview: boolean`, so listeners that forward the event stay small.
121
+ `ASK_POPUP_PROMPT_EVENT` is `pi-ask-popup:prompt`. It fires once before the dialog shows. Its payload is `questions[].{ question, header, multiSelect, options[] }`, where each option is `{ label, description, hasPreview }`. Preview content is not shipped, only `hasPreview: boolean`, so listeners that forward the event stay small.
121
122
 
122
- `ASK_POPUP_BLOCKED_EVENT` channel is `pi-ask-popup:blocked`. Payload is `{ blocked: boolean }`. It brackets the wait, true when the questionnaire starts and false when it resolves, so status or footer extensions can show that the agent is waiting.
123
+ `ASK_POPUP_BLOCKED_EVENT` is `pi-ask-popup:blocked`. Its payload is `{ active: boolean }`. It brackets the wait with `true` when the questionnaire starts and `false` when it resolves, so status or footer extensions can show that the agent is waiting.
123
124
 
124
- Both payloads are JSON-safe. Channel names are stable. Changes are append-only and optional, and any breaking change ships as a new channel rather than a version field.
125
+ `HERDR_BLOCKED_EVENT` is `herdr:blocked`. It receives the same blocked payload at the same points. Herdr's Pi integration consumes this channel to mark the agent as waiting for input and issue its configured request notification. Nothing happens when Herdr is absent.
126
+
127
+ The payloads are JSON-safe. Channel names are stable. Changes are append-only and optional, and any breaking change ships as a new channel rather than a version field.
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.1",
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",
@@ -22,6 +22,7 @@ import {
22
22
  ASK_POPUP_BLOCKED_EVENT,
23
23
  ASK_POPUP_PROMPT_EVENT,
24
24
  buildBlockedPayload,
25
+ HERDR_BLOCKED_EVENT,
25
26
  buildPromptPayload,
26
27
  } from "./events.js";
27
28
  // Static import: the walker pulls only types, none of the render graph the
@@ -88,7 +89,9 @@ function emitPrompt(pi: ExtensionAPI, params: QuestionParams): void {
88
89
  }
89
90
 
90
91
  function emitBlocked(pi: ExtensionAPI, active: boolean): void {
91
- pi.events.emit(ASK_POPUP_BLOCKED_EVENT, buildBlockedPayload(active));
92
+ const payload = buildBlockedPayload(active);
93
+ pi.events.emit(ASK_POPUP_BLOCKED_EVENT, payload);
94
+ pi.events.emit(HERDR_BLOCKED_EVENT, payload);
92
95
  }
93
96
 
94
97
  /** The backstop for a host with no UI at all; the reconciler normally strips the tool first. */
@@ -162,7 +165,10 @@ export async function loadQuestionnaireSession(
162
165
  message: `${ERROR_SESSION_LOAD_FAILED} (cause: ${cause})`,
163
166
  };
164
167
  }
165
- if (!(mod.QuestionnaireSession instanceof Function)) {
168
+ // jiti hands back a namespace built in its own realm, so this class can fail
169
+ // an `instanceof Function` here while being perfectly constructible. `typeof`
170
+ // asks the question that actually matters: is there something to call.
171
+ if (typeof mod.QuestionnaireSession !== "function") {
166
172
  const keys = JSON.stringify(Object.keys(mod));
167
173
  return {
168
174
  ok: false,
@@ -187,7 +193,7 @@ function registerCollapseKeyListener(
187
193
  sessionRef: SessionRef,
188
194
  overlayHandleRef: OverlayHandleRef,
189
195
  ): (() => void) | undefined {
190
- if (collapseKey === COLLAPSE_KEY_OFF || !(ctx.ui.onTerminalInput instanceof Function)) {
196
+ if (collapseKey === COLLAPSE_KEY_OFF || typeof ctx.ui.onTerminalInput !== "function") {
191
197
  return undefined;
192
198
  }
193
199
  let hasAnnouncedHide = false;
@@ -415,8 +421,12 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
415
421
  // primitives is malformed, and calling `custom` on it throws a bare
416
422
  // TypeError that reaches the model as a broken tool rather than an
417
423
  // unsupported one. Answer honestly instead: nobody saw the questions.
424
+ //
425
+ // `typeof` rather than `instanceof Function`, for the same reason as
426
+ // `hasDialogUI`: a cross-realm `custom` is callable and must not be
427
+ // mistaken for a missing one.
418
428
  // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
419
- if (!((ctx.ui as { custom?: unknown }).custom instanceof Function)) {
429
+ if (typeof (ctx.ui as { custom?: unknown }).custom !== "function") {
420
430
  return resolveUndefinedResult(ctx, typed);
421
431
  }
422
432
 
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");
package/src/events.ts CHANGED
@@ -4,11 +4,11 @@
4
4
  * This module deliberately imports nothing. A footer, statusline or notifier
5
5
  * subscribes to these channels to know what is being asked and whether the
6
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
7
+ * a schema compiler, to read three strings and four interfaces. Adding an import
8
8
  * here defeats the reason the `./events` subpath exists, so there is a test
9
9
  * that fails if one appears.
10
10
  *
11
- * Stability rules for both channels:
11
+ * Stability rules for these channels:
12
12
  *
13
13
  * 1. Channel names never change once published. Subscribers hardcode them.
14
14
  * 2. Payload changes are append-only, and new fields ship optional.
@@ -29,6 +29,12 @@ export const ASK_POPUP_PROMPT_EVENT = "pi-ask-popup:prompt" as const;
29
29
  /** Fired true before the wait and false when it ends, however it ends. */
30
30
  export const ASK_POPUP_BLOCKED_EVENT = "pi-ask-popup:blocked" as const;
31
31
 
32
+ /**
33
+ * Herdr's Pi integration listens on this shared channel for nested UI that
34
+ * needs a decision. Emitting it is inert when Herdr is not installed.
35
+ */
36
+ export const HERDR_BLOCKED_EVENT = "herdr:blocked" as const;
37
+
32
38
  export interface AskPopupPromptOption {
33
39
  label: string;
34
40
  description: string;
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ import { registerAskPopupReconciler } from "./reconcile.js";
13
13
  export {
14
14
  ASK_POPUP_BLOCKED_EVENT,
15
15
  ASK_POPUP_PROMPT_EVENT,
16
+ HERDR_BLOCKED_EVENT,
16
17
  type AskPopupBlockedEventPayload,
17
18
  type AskPopupPromptEventPayload,
18
19
  type AskPopupPromptOption,
@@ -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
  }