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 +1 -1
- package/package.json +1 -1
- package/src/ask-user-question.ts +10 -3
- package/src/config.ts +52 -1
- package/src/rpc-fallback.ts +63 -21
- package/src/state/build-questionnaire.ts +42 -7
- package/src/state/external-editor.ts +18 -10
- package/src/state/key-router.ts +23 -0
- package/src/state/questionnaire-session.ts +44 -1
- package/src/state/selectors/projections.ts +6 -0
- package/src/state/state-reducer.ts +14 -8
- package/src/tool/response-envelope.ts +39 -2
- package/src/tool/types.ts +15 -2
- package/src/view/components/multi-select-view.ts +17 -2
- package/src/view/components/option-list-view.ts +5 -0
- package/src/view/components/preview/markdown-content-cache.ts +61 -24
- package/src/view/components/preview/preview-layout-decider.ts +32 -0
- package/src/view/components/preview/preview-pane.ts +24 -16
- package/src/view/components/tab-bar.ts +26 -8
- package/src/view/components/wrapping-select.ts +100 -37
- package/src/view/dialog-builder.ts +20 -2
- package/src/view/props-adapter.ts +8 -0
- package/src/view/tab-content-strategy.ts +10 -2
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.
|
|
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",
|
package/src/ask-user-question.ts
CHANGED
|
@@ -162,7 +162,10 @@ export async function loadQuestionnaireSession(
|
|
|
162
162
|
message: `${ERROR_SESSION_LOAD_FAILED} (cause: ${cause})`,
|
|
163
163
|
};
|
|
164
164
|
}
|
|
165
|
-
|
|
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 ||
|
|
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 (
|
|
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");
|
package/src/rpc-fallback.ts
CHANGED
|
@@ -56,13 +56,35 @@ export type DialogUI = {
|
|
|
56
56
|
) => Promise<string | undefined>;
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
-
/**
|
|
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
|
|
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
|
|
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 (
|
|
138
|
+
if (outcome.kind === "dismissed") {
|
|
117
139
|
return { answers, cancelled: true };
|
|
118
140
|
}
|
|
119
|
-
|
|
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
|
-
/**
|
|
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<
|
|
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
|
|
161
|
+
return DISMISSED;
|
|
137
162
|
}
|
|
138
163
|
const idx = parseIndex(chosen, options.length);
|
|
139
|
-
// A host
|
|
140
|
-
//
|
|
141
|
-
//
|
|
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
|
|
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
|
|
188
|
+
return DISMISSED;
|
|
163
189
|
}
|
|
164
|
-
return {
|
|
190
|
+
return {
|
|
191
|
+
kind: "answer",
|
|
192
|
+
answer: { questionIndex, question: q.question, kind: "custom", answer: typed },
|
|
193
|
+
};
|
|
165
194
|
}
|
|
166
195
|
|
|
167
|
-
/**
|
|
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<
|
|
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
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
|
90
|
+
return (await readFile(tempFile, "utf8")).replace(/\r?\n$/, "");
|
|
86
91
|
} finally {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
}
|
package/src/state/key-router.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|