pi-ask-popup 0.1.0 → 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 +95 -32
- package/docs/configuration.md +93 -0
- package/docs/hosts.md +71 -0
- package/docs/keyboard.md +65 -0
- package/docs/tool-schema.md +124 -0
- package/package.json +12 -2
- package/preview/popup-submit.webp +0 -0
- package/preview/popup-with-notes.webp +0 -0
- package/preview/popup-with-tab.webp +0 -0
- package/src/ask-user-question.ts +54 -21
- package/src/config.ts +118 -24
- package/src/rpc-fallback.ts +85 -28
- package/src/state/build-questionnaire.ts +45 -8
- package/src/state/external-editor.ts +24 -12
- package/src/state/key-router.ts +152 -41
- package/src/state/questionnaire-session.ts +79 -14
- package/src/state/row-intent.ts +6 -2
- package/src/state/selectors/derivations.ts +18 -6
- package/src/state/selectors/focus.ts +6 -2
- package/src/state/selectors/projections.ts +9 -1
- package/src/state/state-reducer.ts +110 -38
- package/src/tool/response-envelope.ts +71 -22
- package/src/tool/types.ts +28 -4
- package/src/view/component-binding.ts +3 -1
- package/src/view/components/inline-input.ts +3 -1
- package/src/view/components/multi-select-view.ts +32 -7
- package/src/view/components/option-list-view.ts +5 -0
- package/src/view/components/preview/markdown-content-cache.ts +75 -23
- package/src/view/components/preview/preview-block-renderer.ts +8 -1
- package/src/view/components/preview/preview-box-renderer.ts +4 -5
- package/src/view/components/preview/preview-layout-decider.ts +52 -15
- package/src/view/components/preview/preview-pane.ts +72 -28
- package/src/view/components/tab-bar.ts +26 -8
- package/src/view/components/wrapping-select.ts +110 -37
- package/src/view/dialog-builder.ts +44 -10
- package/src/view/props-adapter.ts +29 -7
- package/src/view/tab-content-strategy.ts +69 -21
package/src/ask-user-question.ts
CHANGED
|
@@ -74,7 +74,9 @@ export const BEL = "\x07";
|
|
|
74
74
|
*/
|
|
75
75
|
function emitTerminalAttention(): void {
|
|
76
76
|
try {
|
|
77
|
-
if (process.stdout.isTTY)
|
|
77
|
+
if (process.stdout.isTTY) {
|
|
78
|
+
process.stdout.write(BEL);
|
|
79
|
+
}
|
|
78
80
|
} catch {
|
|
79
81
|
// Best effort. Failing to get someone's attention must not stop the
|
|
80
82
|
// questionnaire from being asked.
|
|
@@ -127,6 +129,8 @@ type SessionLoad =
|
|
|
127
129
|
message: string;
|
|
128
130
|
};
|
|
129
131
|
|
|
132
|
+
type LoadConfigInput = { agentDir: string; projectDir?: string };
|
|
133
|
+
|
|
130
134
|
/**
|
|
131
135
|
* Load the render graph on first use, guarding its two failure shapes.
|
|
132
136
|
*
|
|
@@ -158,6 +162,9 @@ export async function loadQuestionnaireSession(
|
|
|
158
162
|
message: `${ERROR_SESSION_LOAD_FAILED} (cause: ${cause})`,
|
|
159
163
|
};
|
|
160
164
|
}
|
|
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.
|
|
161
168
|
if (typeof mod.QuestionnaireSession !== "function") {
|
|
162
169
|
const keys = JSON.stringify(Object.keys(mod));
|
|
163
170
|
return {
|
|
@@ -189,16 +196,25 @@ function registerCollapseKeyListener(
|
|
|
189
196
|
let hasAnnouncedHide = false;
|
|
190
197
|
return ctx.ui.onTerminalInput((data) => {
|
|
191
198
|
const handle = overlayHandleRef.current;
|
|
192
|
-
if (!handle)
|
|
199
|
+
if (!handle) {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
193
202
|
// Act only while this questionnaire is hidden (its own input is
|
|
194
203
|
// unreachable) or actually focused. With another overlay on top, the key
|
|
195
204
|
// belongs to that one; toggling from underneath it would be baffling.
|
|
196
|
-
if (!handle.isHidden() && !handle.isFocused())
|
|
197
|
-
|
|
205
|
+
if (!handle.isHidden() && !handle.isFocused()) {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
// SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
|
|
209
|
+
if (!matchesKey(data, collapseKey as Parameters<typeof matchesKey>[1])) {
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
198
212
|
// Kitty-protocol terminals report press, repeat and release separately.
|
|
199
213
|
// Toggling on all three would make a tap reopen what it just closed, and a
|
|
200
214
|
// held key flicker.
|
|
201
|
-
if (isKeyRelease(data) || isKeyRepeat(data))
|
|
215
|
+
if (isKeyRelease(data) || isKeyRepeat(data)) {
|
|
216
|
+
return { consume: true };
|
|
217
|
+
}
|
|
202
218
|
sessionRef.current?.toggleCollapsedExternal();
|
|
203
219
|
if (handle.isHidden() && !hasAnnouncedHide) {
|
|
204
220
|
// Once only. The dialog has just vanished, so say how to get it back —
|
|
@@ -251,7 +267,9 @@ function makeSessionFactory(config: {
|
|
|
251
267
|
const editorCommand = SettingsManager.create(ctx.cwd, undefined, {
|
|
252
268
|
projectTrusted: ctx.isProjectTrusted(),
|
|
253
269
|
}).getExternalEditorCommand();
|
|
254
|
-
if (!editorCommand)
|
|
270
|
+
if (!editorCommand) {
|
|
271
|
+
throw new Error("No external editor command is configured");
|
|
272
|
+
}
|
|
255
273
|
return await editWithExternalEditor(tui, editorCommand, value);
|
|
256
274
|
} catch (error) {
|
|
257
275
|
// Reported, then undefined, which the session reads as "keep the
|
|
@@ -341,12 +359,13 @@ Preview content is rendered as markdown in a monospace box. Multi-line text with
|
|
|
341
359
|
* exists to prevent. The collapse key, a local UI preference, does read both
|
|
342
360
|
* layers.
|
|
343
361
|
*/
|
|
344
|
-
function loadGuidance()
|
|
345
|
-
guidance: ReturnType<typeof validateGuidanceFields>;
|
|
346
|
-
warnings: readonly string[];
|
|
347
|
-
} {
|
|
362
|
+
function loadGuidance() {
|
|
348
363
|
const { config, warnings } = loadConfig({ agentDir: getAgentDir() });
|
|
349
|
-
|
|
364
|
+
const result = {
|
|
365
|
+
guidance: validateGuidanceFields(config.guidance),
|
|
366
|
+
warnings,
|
|
367
|
+
};
|
|
368
|
+
return result;
|
|
350
369
|
}
|
|
351
370
|
|
|
352
371
|
export function registerAskPopupTool(pi: ExtensionAPI): void {
|
|
@@ -365,10 +384,15 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
|
|
|
365
384
|
parameters: QuestionParamsSchema,
|
|
366
385
|
|
|
367
386
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
368
|
-
|
|
369
|
-
|
|
387
|
+
// SAFETY: params is validated by QuestionParamsSchema via validateQuestionnaire immediately after narrowing.
|
|
388
|
+
const typed = params as QuestionParams;
|
|
389
|
+
if (!ctx.hasUI) {
|
|
390
|
+
return rejectWithoutUi();
|
|
391
|
+
}
|
|
370
392
|
|
|
371
|
-
for (const warning of pendingWarnings)
|
|
393
|
+
for (const warning of pendingWarnings) {
|
|
394
|
+
ctx.ui.notify?.(warning, "warning");
|
|
395
|
+
}
|
|
372
396
|
pendingWarnings = [];
|
|
373
397
|
|
|
374
398
|
const validation = validateQuestionnaire(typed);
|
|
@@ -385,6 +409,7 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
|
|
|
385
409
|
// Hosts that advertise their mode go straight to the walker and never
|
|
386
410
|
// import the render graph at all. Older RPC builds fall through to the
|
|
387
411
|
// undefined-result backstop below.
|
|
412
|
+
// SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
|
|
388
413
|
if ((ctx as { mode?: string }).mode === "rpc" && hasDialogUI(ctx.ui)) {
|
|
389
414
|
return runRpcPath(pi, ctx.ui, typed);
|
|
390
415
|
}
|
|
@@ -393,6 +418,11 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
|
|
|
393
418
|
// primitives is malformed, and calling `custom` on it throws a bare
|
|
394
419
|
// TypeError that reaches the model as a broken tool rather than an
|
|
395
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.
|
|
425
|
+
// SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
|
|
396
426
|
if (typeof (ctx.ui as { custom?: unknown }).custom !== "function") {
|
|
397
427
|
return resolveUndefinedResult(ctx, typed);
|
|
398
428
|
}
|
|
@@ -414,12 +444,13 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
|
|
|
414
444
|
// Both layers here: unlike guidance, this only binds a key in the user's
|
|
415
445
|
// own terminal, and a project pinning a shortcut that suits its docs is
|
|
416
446
|
// reasonable. `resolveCollapseKey` refuses anything malformed.
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
447
|
+
const configInput: LoadConfigInput = {
|
|
448
|
+
agentDir: getAgentDir(),
|
|
449
|
+
};
|
|
450
|
+
if (ctx.isProjectTrusted()) {
|
|
451
|
+
configInput.projectDir = ctx.cwd;
|
|
452
|
+
}
|
|
453
|
+
const collapseKey = resolveCollapseKey(loadConfig(configInput).config);
|
|
423
454
|
|
|
424
455
|
const sessionRef: SessionRef = { current: null };
|
|
425
456
|
const overlayHandleRef: OverlayHandleRef = { current: undefined };
|
|
@@ -461,7 +492,9 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
|
|
|
461
492
|
},
|
|
462
493
|
);
|
|
463
494
|
|
|
464
|
-
if (result === undefined)
|
|
495
|
+
if (result === undefined) {
|
|
496
|
+
return resolveUndefinedResult(ctx, typed);
|
|
497
|
+
}
|
|
465
498
|
return buildQuestionnaireResponse(result, typed);
|
|
466
499
|
} finally {
|
|
467
500
|
removeOverlayInputListener?.();
|
package/src/config.ts
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
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
|
|
|
5
|
+
type JsonValue =
|
|
6
|
+
| string
|
|
7
|
+
| number
|
|
8
|
+
| boolean
|
|
9
|
+
| null
|
|
10
|
+
| JsonValue[]
|
|
11
|
+
| { readonly [key: string]: JsonValue };
|
|
12
|
+
type JsonRecord = Record<string, JsonValue>;
|
|
13
|
+
|
|
14
|
+
function isRecord(value: unknown): value is JsonRecord;
|
|
15
|
+
function isRecord(value: JsonValue | undefined): value is JsonRecord;
|
|
16
|
+
function isRecord(value: unknown): value is JsonRecord {
|
|
17
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
21
|
+
return typeof value === "string" && value.length > 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
5
24
|
/** Key spec for the overlay collapse/expand shortcut, e.g. `"ctrl+]"` or `"alt+o"`. */
|
|
6
25
|
export type CollapseKeySpec = string;
|
|
7
26
|
|
|
@@ -62,6 +81,35 @@ export function configPaths(sources: ConfigSources): string[] {
|
|
|
62
81
|
return paths;
|
|
63
82
|
}
|
|
64
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
|
+
|
|
65
113
|
/**
|
|
66
114
|
* Read one layer. An absent file means "no overrides" and is not a warning —
|
|
67
115
|
* having no config is the normal case, not a degraded one. Anything else that
|
|
@@ -71,33 +119,57 @@ export function configPaths(sources: ConfigSources): string[] {
|
|
|
71
119
|
* Deliberately `readFileSync` + catch rather than `existsSync` then read: the
|
|
72
120
|
* check-then-read pair races, and this keeps the no-write guarantee obvious —
|
|
73
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.
|
|
74
129
|
*/
|
|
75
|
-
function readLayer(path: string, warnings: string[]):
|
|
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 {
|
|
76
146
|
let text: string;
|
|
77
147
|
try {
|
|
78
148
|
text = readFileSync(path, "utf8");
|
|
79
149
|
} catch (err) {
|
|
150
|
+
// SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
|
|
80
151
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
152
|
+
// SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
|
|
81
153
|
warnings.push(`pi-ask-popup: cannot read ${path} — ${(err as Error).message}`);
|
|
82
154
|
}
|
|
83
155
|
return {};
|
|
84
156
|
}
|
|
85
157
|
try {
|
|
86
158
|
const parsed: unknown = JSON.parse(text);
|
|
87
|
-
|
|
88
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
159
|
+
if (!isRecord(parsed)) {
|
|
89
160
|
warnings.push(`pi-ask-popup: ${path} is not a JSON object, ignoring it`);
|
|
90
161
|
return {};
|
|
91
162
|
}
|
|
92
|
-
return parsed
|
|
163
|
+
return parsed;
|
|
93
164
|
} catch (err) {
|
|
165
|
+
// SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
|
|
94
166
|
warnings.push(`pi-ask-popup: invalid JSON in ${path}, ignoring it — ${(err as Error).message}`);
|
|
95
167
|
return {};
|
|
96
168
|
}
|
|
97
169
|
}
|
|
98
170
|
|
|
99
171
|
function nonEmptyString(value: unknown): string | undefined {
|
|
100
|
-
return
|
|
172
|
+
return isNonEmptyString(value) ? value : undefined;
|
|
101
173
|
}
|
|
102
174
|
|
|
103
175
|
/**
|
|
@@ -106,19 +178,26 @@ function nonEmptyString(value: unknown): string | undefined {
|
|
|
106
178
|
* the tool still registers.
|
|
107
179
|
*/
|
|
108
180
|
export function validateGuidanceFields(fields: unknown): GuidanceFields {
|
|
109
|
-
if (fields
|
|
110
|
-
|
|
181
|
+
if (!isRecord(fields)) {
|
|
182
|
+
return {};
|
|
183
|
+
}
|
|
184
|
+
const g = fields;
|
|
111
185
|
const out: GuidanceFields = {};
|
|
112
186
|
const description = nonEmptyString(g.description);
|
|
113
|
-
if (description !== undefined)
|
|
187
|
+
if (description !== undefined) {
|
|
188
|
+
out.description = description;
|
|
189
|
+
}
|
|
114
190
|
const promptSnippet = nonEmptyString(g.promptSnippet);
|
|
115
|
-
if (promptSnippet !== undefined)
|
|
191
|
+
if (promptSnippet !== undefined) {
|
|
192
|
+
out.promptSnippet = promptSnippet;
|
|
193
|
+
}
|
|
116
194
|
const guidelines = g.promptGuidelines;
|
|
117
195
|
if (
|
|
118
196
|
Array.isArray(guidelines) &&
|
|
119
197
|
guidelines.length > 0 &&
|
|
120
198
|
guidelines.every((s) => nonEmptyString(s) !== undefined)
|
|
121
199
|
) {
|
|
200
|
+
// SAFETY: guidelines elements are validated as non-empty strings above; preserving string array type is safe.
|
|
122
201
|
out.promptGuidelines = guidelines as string[];
|
|
123
202
|
}
|
|
124
203
|
return out;
|
|
@@ -135,7 +214,9 @@ export function loadConfig(sources: ConfigSources): ConfigLoadResult {
|
|
|
135
214
|
for (const path of configPaths(sources)) {
|
|
136
215
|
const raw = readLayer(path, warnings);
|
|
137
216
|
const collapseKey = nonEmptyString(raw.collapseKey);
|
|
138
|
-
if (collapseKey !== undefined)
|
|
217
|
+
if (collapseKey !== undefined) {
|
|
218
|
+
config.collapseKey = collapseKey;
|
|
219
|
+
}
|
|
139
220
|
const guidance = validateGuidanceFields(raw.guidance);
|
|
140
221
|
if (Object.keys(guidance).length > 0) {
|
|
141
222
|
config.guidance = { ...config.guidance, ...guidance };
|
|
@@ -183,7 +264,9 @@ const SPECIAL_KEYS = new Set([
|
|
|
183
264
|
const MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
|
|
184
265
|
|
|
185
266
|
function isBaseKey(key: string): boolean {
|
|
186
|
-
if (key.length !== 1)
|
|
267
|
+
if (key.length !== 1) {
|
|
268
|
+
return SPECIAL_KEYS.has(key);
|
|
269
|
+
}
|
|
187
270
|
return (key >= "a" && key <= "z") || (key >= "0" && key <= "9") || SYMBOL_KEYS.has(key);
|
|
188
271
|
}
|
|
189
272
|
|
|
@@ -199,12 +282,18 @@ function isBaseKey(key: string): boolean {
|
|
|
199
282
|
* user types, anywhere.
|
|
200
283
|
*/
|
|
201
284
|
function isValidCollapseKeySpec(spec: string): boolean {
|
|
202
|
-
if (!spec || spec.startsWith("+") || spec.endsWith("+") || spec.includes("++"))
|
|
285
|
+
if (!spec || spec.startsWith("+") || spec.endsWith("+") || spec.includes("++")) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
203
288
|
const parts = spec.split("+");
|
|
204
289
|
const base = parts[parts.length - 1] ?? "";
|
|
205
290
|
const modifiers = parts.slice(0, -1);
|
|
206
|
-
if (modifiers.length !== new Set(modifiers).size)
|
|
207
|
-
|
|
291
|
+
if (modifiers.length !== new Set(modifiers).size) {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
if (!modifiers.every((m) => MODIFIERS.has(m))) {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
208
297
|
return isBaseKey(base);
|
|
209
298
|
}
|
|
210
299
|
|
|
@@ -220,17 +309,21 @@ export function resolveCollapseKey(config: {
|
|
|
220
309
|
collapseKey?: CollapseKeySpec | undefined;
|
|
221
310
|
}): CollapseKeySpec {
|
|
222
311
|
const raw = config.collapseKey?.trim().toLowerCase();
|
|
223
|
-
if (raw === undefined || raw === "")
|
|
224
|
-
|
|
312
|
+
if (raw === undefined || raw === "") {
|
|
313
|
+
return DEFAULT_COLLAPSE_KEY;
|
|
314
|
+
}
|
|
315
|
+
if (raw === COLLAPSE_KEY_OFF) {
|
|
316
|
+
return COLLAPSE_KEY_OFF;
|
|
317
|
+
}
|
|
225
318
|
return isValidCollapseKeySpec(raw) ? raw : DEFAULT_COLLAPSE_KEY;
|
|
226
319
|
}
|
|
227
320
|
|
|
228
321
|
// The only compound-word names in SPECIAL_KEYS — capitalizing the first letter
|
|
229
322
|
// alone would render them "Pageup" and "Pagedown".
|
|
230
|
-
const COMPOUND_KEY_DISPLAY
|
|
323
|
+
const COMPOUND_KEY_DISPLAY = {
|
|
231
324
|
pageup: "PageUp",
|
|
232
325
|
pagedown: "PageDown",
|
|
233
|
-
}
|
|
326
|
+
} satisfies Record<string, string>;
|
|
234
327
|
|
|
235
328
|
/**
|
|
236
329
|
* Pretty-print a resolved spec for UI copy: `"ctrl+]"` → `"Ctrl+]"`, `"alt+o"` →
|
|
@@ -241,10 +334,11 @@ const COMPOUND_KEY_DISPLAY: Record<string, string> = {
|
|
|
241
334
|
export function formatKeySpecForDisplay(spec: CollapseKeySpec): string {
|
|
242
335
|
return spec
|
|
243
336
|
.split("+")
|
|
244
|
-
.map(
|
|
245
|
-
(part)
|
|
246
|
-
COMPOUND_KEY_DISPLAY[part]
|
|
247
|
-
|
|
248
|
-
|
|
337
|
+
.map((part) => {
|
|
338
|
+
if (part === "pageup" || part === "pagedown") {
|
|
339
|
+
return COMPOUND_KEY_DISPLAY[part];
|
|
340
|
+
}
|
|
341
|
+
return part.length <= 1 ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1);
|
|
342
|
+
})
|
|
249
343
|
.join("+");
|
|
250
344
|
}
|
package/src/rpc-fallback.ts
CHANGED
|
@@ -56,12 +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.
|
|
69
|
+
const u = ui as Partial<DialogUI> | null | undefined;
|
|
62
70
|
return typeof u?.select === "function" && typeof u?.input === "function";
|
|
63
71
|
}
|
|
64
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
|
+
|
|
65
88
|
type Option = QuestionData["options"][number];
|
|
66
89
|
|
|
67
90
|
function formatOptionLine(option: Option, index: number): string {
|
|
@@ -105,69 +128,92 @@ export async function runRpcQuestionnaire(
|
|
|
105
128
|
const dialogOpts = params.timeout === undefined ? undefined : { timeout: params.timeout };
|
|
106
129
|
for (let qi = 0; qi < params.questions.length; qi++) {
|
|
107
130
|
const q = params.questions[qi];
|
|
108
|
-
if (!q)
|
|
131
|
+
if (!q) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
109
134
|
const header = q.header ? `[${q.header}] ` : "";
|
|
110
|
-
const
|
|
135
|
+
const outcome = q.multiSelect
|
|
111
136
|
? await askMultiSelect(ui, q, qi, header, dialogOpts)
|
|
112
137
|
: await askSingleSelect(ui, q, qi, header, dialogOpts);
|
|
113
|
-
if (
|
|
114
|
-
|
|
138
|
+
if (outcome.kind === "dismissed") {
|
|
139
|
+
return { answers, cancelled: true };
|
|
140
|
+
}
|
|
141
|
+
if (outcome.kind === "host_error") {
|
|
142
|
+
return { answers, cancelled: true, error: "host_error", hostErrorDetail: outcome.detail };
|
|
143
|
+
}
|
|
144
|
+
answers.push(outcome.answer);
|
|
115
145
|
}
|
|
116
146
|
return { answers, cancelled: false };
|
|
117
147
|
}
|
|
118
148
|
|
|
119
|
-
/**
|
|
149
|
+
/** Dismissal cancels everything; a reply outside the offered list is the host's fault. */
|
|
120
150
|
async function askSingleSelect(
|
|
121
151
|
ui: DialogUI,
|
|
122
152
|
q: QuestionData,
|
|
123
153
|
questionIndex: number,
|
|
124
154
|
header: string,
|
|
125
155
|
opts?: { timeout?: number; signal?: AbortSignal },
|
|
126
|
-
): Promise<
|
|
156
|
+
): Promise<AskOutcome> {
|
|
127
157
|
const options = q.options.map(formatOptionLine);
|
|
128
158
|
options.push(`${q.options.length + 1}. ${ROW_INTENT_META.other.label}`);
|
|
129
159
|
const chosen = await ui.select(`${header}${q.question}${buildPreviewBlock(q)}`, options, opts);
|
|
130
|
-
if (chosen === undefined || chosen === null)
|
|
160
|
+
if (chosen === undefined || chosen === null) {
|
|
161
|
+
return DISMISSED;
|
|
162
|
+
}
|
|
131
163
|
const idx = parseIndex(chosen, options.length);
|
|
132
|
-
// A host
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
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.
|
|
168
|
+
if (idx === null) {
|
|
169
|
+
return { kind: "host_error", detail: `selection not in the offered list: "${chosen}"` };
|
|
170
|
+
}
|
|
136
171
|
const option = q.options[idx];
|
|
137
172
|
if (option) {
|
|
138
|
-
|
|
173
|
+
const answer: QuestionAnswer = {
|
|
139
174
|
questionIndex,
|
|
140
175
|
question: q.question,
|
|
141
176
|
kind: "option",
|
|
142
177
|
answer: option.label,
|
|
143
|
-
// Spread rather than assigned: the envelope's contract is that the key is
|
|
144
|
-
// absent when there was no preview, not present and undefined.
|
|
145
|
-
...(option.preview !== undefined && option.preview.length > 0
|
|
146
|
-
? { preview: option.preview }
|
|
147
|
-
: {}),
|
|
148
178
|
};
|
|
179
|
+
if (option.preview !== undefined && option.preview.length > 0) {
|
|
180
|
+
// SAFETY: preview is an optional string; present only when non-empty per contract.
|
|
181
|
+
(answer as QuestionAnswer & { preview: string }).preview = option.preview;
|
|
182
|
+
}
|
|
183
|
+
return { kind: "answer", answer };
|
|
149
184
|
}
|
|
150
185
|
// The "Type something." row, which is the one index past the authored options.
|
|
151
186
|
const typed = await ui.input(`${header}${q.question}\n\n${CUSTOM_ANSWER_TITLE}`, "", opts);
|
|
152
|
-
if (typed === undefined || typed === null)
|
|
153
|
-
|
|
187
|
+
if (typed === undefined || typed === null) {
|
|
188
|
+
return DISMISSED;
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
kind: "answer",
|
|
192
|
+
answer: { questionIndex, question: q.question, kind: "custom", answer: typed },
|
|
193
|
+
};
|
|
154
194
|
}
|
|
155
195
|
|
|
156
|
-
/**
|
|
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
|
+
*/
|
|
157
201
|
async function askMultiSelect(
|
|
158
202
|
ui: DialogUI,
|
|
159
203
|
q: QuestionData,
|
|
160
204
|
questionIndex: number,
|
|
161
205
|
header: string,
|
|
162
206
|
opts?: { timeout?: number; signal?: AbortSignal },
|
|
163
|
-
): Promise<
|
|
207
|
+
): Promise<AskOutcome> {
|
|
164
208
|
const list = q.options.map(formatOptionLine).join("\n");
|
|
165
209
|
const value = await ui.input(
|
|
166
210
|
`${header}${q.question}\n\n${list}\n\n${MULTI_SELECT_INSTRUCTIONS}`,
|
|
167
211
|
MULTI_SELECT_PLACEHOLDER,
|
|
168
212
|
opts,
|
|
169
213
|
);
|
|
170
|
-
if (value === undefined || value === null)
|
|
214
|
+
if (value === undefined || value === null) {
|
|
215
|
+
return DISMISSED;
|
|
216
|
+
}
|
|
171
217
|
const trimmed = value.trim();
|
|
172
218
|
if (trimmed.length === 0) {
|
|
173
219
|
// A deliberate empty commit, the same as pressing Next with nothing ticked.
|
|
@@ -176,7 +222,10 @@ async function askMultiSelect(
|
|
|
176
222
|
// no tokens the `every` below is vacuously true and produces an empty
|
|
177
223
|
// selection anyway. Removing this would leave an important behaviour
|
|
178
224
|
// resting on that, and no test could tell the two apart.
|
|
179
|
-
return {
|
|
225
|
+
return {
|
|
226
|
+
kind: "answer",
|
|
227
|
+
answer: { questionIndex, question: q.question, kind: "multi", answer: null, selected: [] },
|
|
228
|
+
};
|
|
180
229
|
}
|
|
181
230
|
const tokens = trimmed.split(/[,\s]+/).filter((tok) => tok.length > 0);
|
|
182
231
|
const indices = tokens.map((tok) =>
|
|
@@ -186,13 +235,21 @@ async function askMultiSelect(
|
|
|
186
235
|
const selected: string[] = [];
|
|
187
236
|
for (const i of indices) {
|
|
188
237
|
const label = q.options[i]?.label;
|
|
189
|
-
if (label !== undefined && !selected.includes(label))
|
|
238
|
+
if (label !== undefined && !selected.includes(label)) {
|
|
239
|
+
selected.push(label);
|
|
240
|
+
}
|
|
190
241
|
}
|
|
191
|
-
return {
|
|
242
|
+
return {
|
|
243
|
+
kind: "answer",
|
|
244
|
+
answer: { questionIndex, question: q.question, kind: "multi", answer: null, selected },
|
|
245
|
+
};
|
|
192
246
|
}
|
|
193
247
|
// Any token that is not an index -- a word, or a number like "13" when there
|
|
194
248
|
// are three options -- means the user typed an answer rather than picking
|
|
195
249
|
// from the list. Keeping it verbatim is both the honest reading and the
|
|
196
250
|
// multi-select half of the "Type something." escape.
|
|
197
|
-
return {
|
|
251
|
+
return {
|
|
252
|
+
kind: "answer",
|
|
253
|
+
answer: { questionIndex, question: q.question, kind: "custom", answer: trimmed },
|
|
254
|
+
};
|
|
198
255
|
}
|