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 +1 -1
- package/docs/tool-schema.md +7 -4
- package/package.json +1 -1
- package/src/ask-user-question.ts +14 -4
- package/src/config.ts +52 -1
- package/src/events.ts +8 -2
- package/src/index.ts +1 -0
- 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/docs/tool-schema.md
CHANGED
|
@@ -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
|
|
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`
|
|
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`
|
|
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
|
-
|
|
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.
|
|
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",
|
package/src/ask-user-question.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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 (
|
|
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
|
|
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
|
|
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
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
|
}
|