pi-ask-popup 0.1.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/LICENSE +22 -0
- package/README.md +55 -0
- package/docs/adr/0001-fork-rpiv-ask-user-question-as-zero-dep-pi-ask-popup.md +90 -0
- package/package.json +48 -0
- package/src/ask-user-question.ts +474 -0
- package/src/config.ts +250 -0
- package/src/events.ts +107 -0
- package/src/index.ts +25 -0
- package/src/reconcile.ts +31 -0
- package/src/rpc-fallback.ts +198 -0
- package/src/state/build-questionnaire.ts +346 -0
- package/src/state/external-editor.ts +94 -0
- package/src/state/key-router.ts +378 -0
- package/src/state/questionnaire-session.ts +382 -0
- package/src/state/row-intent.ts +156 -0
- package/src/state/selectors/contract.ts +40 -0
- package/src/state/selectors/derivations.ts +40 -0
- package/src/state/selectors/focus.ts +17 -0
- package/src/state/selectors/projections.ts +111 -0
- package/src/state/state-reducer.ts +421 -0
- package/src/state/state.ts +110 -0
- package/src/tool/format-answer.ts +28 -0
- package/src/tool/response-envelope.ts +123 -0
- package/src/tool/types.ts +193 -0
- package/src/tool/validate-questionnaire.ts +74 -0
- package/src/view/component-binding.ts +51 -0
- package/src/view/components/inline-input.ts +66 -0
- package/src/view/components/multi-select-view.ts +208 -0
- package/src/view/components/option-list-view.ts +77 -0
- package/src/view/components/preview/markdown-content-cache.ts +76 -0
- package/src/view/components/preview/preview-block-renderer.ts +116 -0
- package/src/view/components/preview/preview-box-renderer.ts +88 -0
- package/src/view/components/preview/preview-layout-decider.ts +219 -0
- package/src/view/components/preview/preview-pane.ts +240 -0
- package/src/view/components/submit-picker.ts +66 -0
- package/src/view/components/tab-bar.ts +70 -0
- package/src/view/components/wrapping-select.ts +313 -0
- package/src/view/dialog-builder.ts +325 -0
- package/src/view/props-adapter.ts +124 -0
- package/src/view/stateful-view.ts +20 -0
- package/src/view/tab-components.ts +16 -0
- package/src/view/tab-content-strategy.ts +447 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionAPI,
|
|
3
|
+
type ExtensionContext,
|
|
4
|
+
getAgentDir,
|
|
5
|
+
type Theme,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
isKeyRelease,
|
|
9
|
+
isKeyRepeat,
|
|
10
|
+
matchesKey,
|
|
11
|
+
type OverlayHandle,
|
|
12
|
+
type TUI,
|
|
13
|
+
} from "@earendil-works/pi-tui";
|
|
14
|
+
import {
|
|
15
|
+
COLLAPSE_KEY_OFF,
|
|
16
|
+
formatKeySpecForDisplay,
|
|
17
|
+
loadConfig,
|
|
18
|
+
resolveCollapseKey,
|
|
19
|
+
validateGuidanceFields,
|
|
20
|
+
} from "./config.js";
|
|
21
|
+
import {
|
|
22
|
+
ASK_POPUP_BLOCKED_EVENT,
|
|
23
|
+
ASK_POPUP_PROMPT_EVENT,
|
|
24
|
+
buildBlockedPayload,
|
|
25
|
+
buildPromptPayload,
|
|
26
|
+
} from "./events.js";
|
|
27
|
+
// Static import: the walker pulls only types, none of the render graph the
|
|
28
|
+
// session lazy-loads.
|
|
29
|
+
import { type DialogUI, hasDialogUI, runRpcQuestionnaire } from "./rpc-fallback.js";
|
|
30
|
+
import type {
|
|
31
|
+
QuestionnaireSession,
|
|
32
|
+
QuestionnaireSessionComponent,
|
|
33
|
+
QuestionnaireSessionConfig,
|
|
34
|
+
} from "./state/questionnaire-session.js";
|
|
35
|
+
import { LABELS_BY_KIND, sentinelsToAppend, type WrappingSelectItem } from "./state/row-intent.js";
|
|
36
|
+
import { buildQuestionnaireResponse, buildToolResult } from "./tool/response-envelope.js";
|
|
37
|
+
import {
|
|
38
|
+
MAX_OPTIONS,
|
|
39
|
+
MAX_QUESTIONS,
|
|
40
|
+
MIN_OPTIONS,
|
|
41
|
+
type QuestionData,
|
|
42
|
+
type QuestionnaireError,
|
|
43
|
+
type QuestionnaireResult,
|
|
44
|
+
type QuestionParams,
|
|
45
|
+
QuestionParamsSchema,
|
|
46
|
+
} from "./tool/types.js";
|
|
47
|
+
import { validateQuestionnaire } from "./tool/validate-questionnaire.js";
|
|
48
|
+
|
|
49
|
+
/** The tool's name, shared with the reconciler so the two cannot disagree. */
|
|
50
|
+
export const ASK_POPUP_TOOL_NAME = "ask_user_question";
|
|
51
|
+
|
|
52
|
+
const ERROR_NO_UI = "Error: UI not available (running in non-interactive mode)";
|
|
53
|
+
|
|
54
|
+
const ERROR_NO_CUSTOM_UI =
|
|
55
|
+
"Error: this client cannot render the questionnaire (custom UI is unavailable, e.g. RPC/ACP hosts such as Zed or Paseo). The user never saw the questions — do NOT treat this as a decline. Ask the questions as plain chat text instead, without using this tool.";
|
|
56
|
+
|
|
57
|
+
const ERROR_SESSION_LOAD_FAILED =
|
|
58
|
+
"Error: the questionnaire UI failed to load — the host's installed dependencies were likely replaced or removed on disk while Pi was running (e.g. a package-manager install touched the store). The user never saw the questions — do NOT treat this as a decline. Ask the questions as plain chat text instead, and tell the user that restoring this tool requires repairing the install if needed and restarting Pi.";
|
|
59
|
+
|
|
60
|
+
const ERROR_STALE_MODULE_CACHE =
|
|
61
|
+
"Error: the questionnaire UI cannot load — the host's module cache went stale after an earlier failed load (typically dependencies replaced on disk mid-session). This is unrecoverable within the current Pi process. The user never saw the questions — do NOT treat this as a decline. Ask the questions as plain chat text instead, and tell the user to restart Pi to restore this tool.";
|
|
62
|
+
|
|
63
|
+
/** Terminal bell. */
|
|
64
|
+
export const BEL = "\x07";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Ring once, just before the wait, so someone who tabbed away notices.
|
|
68
|
+
*
|
|
69
|
+
* To stdout rather than `/dev/tty`, and only when stdout is a terminal. Both
|
|
70
|
+
* halves matter: the check proves an interactive terminal owns the wait, and
|
|
71
|
+
* writing to stdout keeps the byte out of a piped RPC transport, which would
|
|
72
|
+
* otherwise ring the local machine for a dialog rendering in a remote host's
|
|
73
|
+
* own window.
|
|
74
|
+
*/
|
|
75
|
+
function emitTerminalAttention(): void {
|
|
76
|
+
try {
|
|
77
|
+
if (process.stdout.isTTY) process.stdout.write(BEL);
|
|
78
|
+
} catch {
|
|
79
|
+
// Best effort. Failing to get someone's attention must not stop the
|
|
80
|
+
// questionnaire from being asked.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function emitPrompt(pi: ExtensionAPI, params: QuestionParams): void {
|
|
85
|
+
pi.events.emit(ASK_POPUP_PROMPT_EVENT, buildPromptPayload(params));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function emitBlocked(pi: ExtensionAPI, active: boolean): void {
|
|
89
|
+
pi.events.emit(ASK_POPUP_BLOCKED_EVENT, buildBlockedPayload(active));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The backstop for a host with no UI at all; the reconciler normally strips the tool first. */
|
|
93
|
+
function rejectWithoutUi() {
|
|
94
|
+
return buildToolResult(ERROR_NO_UI, { answers: [], cancelled: true, error: "no_ui" });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The native-dialog walk, bracketed by the blocked pair and the bell. */
|
|
98
|
+
async function runRpcPath(pi: ExtensionAPI, ui: DialogUI, typed: QuestionParams) {
|
|
99
|
+
emitBlocked(pi, true);
|
|
100
|
+
try {
|
|
101
|
+
emitTerminalAttention();
|
|
102
|
+
return buildQuestionnaireResponse(await runRpcQuestionnaire(ui, typed), typed);
|
|
103
|
+
} finally {
|
|
104
|
+
// In a finally so a listener is never left showing that the agent is
|
|
105
|
+
// waiting on someone who already answered.
|
|
106
|
+
emitBlocked(pi, false);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Only the one export the loader needs. A `typeof import(...)` of the whole
|
|
112
|
+
* namespace would say the same thing less precisely -- and this is the export
|
|
113
|
+
* whose absence the stale-cache branch below is looking for.
|
|
114
|
+
*
|
|
115
|
+
* Type-only, so importing the names here does not pull the module into the
|
|
116
|
+
* static graph and defeat the lazy load.
|
|
117
|
+
*/
|
|
118
|
+
type SessionModule = { QuestionnaireSession: typeof QuestionnaireSession };
|
|
119
|
+
type SessionRef = { current: QuestionnaireSession | null };
|
|
120
|
+
type OverlayHandleRef = { current: OverlayHandle | undefined };
|
|
121
|
+
|
|
122
|
+
type SessionLoad =
|
|
123
|
+
| { ok: true; module: SessionModule }
|
|
124
|
+
| {
|
|
125
|
+
ok: false;
|
|
126
|
+
error: Extract<QuestionnaireError, "session_load_failed" | "stale_module_cache">;
|
|
127
|
+
message: string;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Load the render graph on first use, guarding its two failure shapes.
|
|
132
|
+
*
|
|
133
|
+
* Pi's jiti loader registers a module in its graph cache before evaluating it
|
|
134
|
+
* and does not evict it when evaluation throws. So one failed load — a package
|
|
135
|
+
* manager replacing the store mid-session, say — leaves every later import of
|
|
136
|
+
* this specifier resolving to a namespace with no class in it, and that state
|
|
137
|
+
* cannot be recovered inside the process.
|
|
138
|
+
*
|
|
139
|
+
* Both branches return an envelope that says the user never saw the questions.
|
|
140
|
+
* That distinction is the entire point: a bare "not a constructor" would read
|
|
141
|
+
* to the model as a failed answer rather than a failure to ask.
|
|
142
|
+
*
|
|
143
|
+
* The import is a parameter so both failures can be provoked directly. They
|
|
144
|
+
* are otherwise reachable only by corrupting a real install mid-run, which is
|
|
145
|
+
* not something a test suite can stage.
|
|
146
|
+
*/
|
|
147
|
+
export async function loadQuestionnaireSession(
|
|
148
|
+
importSession: () => Promise<SessionModule> = () => import("./state/questionnaire-session.js"),
|
|
149
|
+
): Promise<SessionLoad> {
|
|
150
|
+
let mod: SessionModule;
|
|
151
|
+
try {
|
|
152
|
+
mod = await importSession();
|
|
153
|
+
} catch (e) {
|
|
154
|
+
const cause = e instanceof Error ? e.message : String(e);
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
error: "session_load_failed",
|
|
158
|
+
message: `${ERROR_SESSION_LOAD_FAILED} (cause: ${cause})`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (typeof mod.QuestionnaireSession !== "function") {
|
|
162
|
+
const keys = JSON.stringify(Object.keys(mod));
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
error: "stale_module_cache",
|
|
166
|
+
message: `${ERROR_STALE_MODULE_CACHE} (resolved namespace keys: ${keys})`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return { ok: true, module: mod };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Listen for the collapse key at the terminal, which is the only way to reach a
|
|
174
|
+
* hidden overlay.
|
|
175
|
+
*
|
|
176
|
+
* Returns the remover, or undefined when the shortcut is off or the host has no
|
|
177
|
+
* raw input hook. The caller reads `canReopenWhileHidden` from that, because
|
|
178
|
+
* hiding the dialog without this listener would make it unreachable.
|
|
179
|
+
*/
|
|
180
|
+
function registerCollapseKeyListener(
|
|
181
|
+
ctx: ExtensionContext,
|
|
182
|
+
collapseKey: string,
|
|
183
|
+
sessionRef: SessionRef,
|
|
184
|
+
overlayHandleRef: OverlayHandleRef,
|
|
185
|
+
): (() => void) | undefined {
|
|
186
|
+
if (collapseKey === COLLAPSE_KEY_OFF || typeof ctx.ui.onTerminalInput !== "function") {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
let hasAnnouncedHide = false;
|
|
190
|
+
return ctx.ui.onTerminalInput((data) => {
|
|
191
|
+
const handle = overlayHandleRef.current;
|
|
192
|
+
if (!handle) return undefined;
|
|
193
|
+
// Act only while this questionnaire is hidden (its own input is
|
|
194
|
+
// unreachable) or actually focused. With another overlay on top, the key
|
|
195
|
+
// belongs to that one; toggling from underneath it would be baffling.
|
|
196
|
+
if (!handle.isHidden() && !handle.isFocused()) return undefined;
|
|
197
|
+
if (!matchesKey(data, collapseKey as Parameters<typeof matchesKey>[1])) return undefined;
|
|
198
|
+
// Kitty-protocol terminals report press, repeat and release separately.
|
|
199
|
+
// Toggling on all three would make a tap reopen what it just closed, and a
|
|
200
|
+
// held key flicker.
|
|
201
|
+
if (isKeyRelease(data) || isKeyRepeat(data)) return { consume: true };
|
|
202
|
+
sessionRef.current?.toggleCollapsedExternal();
|
|
203
|
+
if (handle.isHidden() && !hasAnnouncedHide) {
|
|
204
|
+
// Once only. The dialog has just vanished, so say how to get it back —
|
|
205
|
+
// but repeating it every time would be nagging.
|
|
206
|
+
hasAnnouncedHide = true;
|
|
207
|
+
ctx.ui.notify?.(
|
|
208
|
+
`ask_user_question hidden — press ${formatKeySpecForDisplay(collapseKey)} to reopen`,
|
|
209
|
+
"info",
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return { consume: true };
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The component factory Pi calls with a live TUI. Builds the session, keeps a
|
|
218
|
+
* reference so the collapse listener can reach it, and hands back its component.
|
|
219
|
+
*/
|
|
220
|
+
function makeSessionFactory(config: {
|
|
221
|
+
ctx: ExtensionContext;
|
|
222
|
+
typed: QuestionParams;
|
|
223
|
+
itemsByTab: WrappingSelectItem[][];
|
|
224
|
+
collapseKey: string;
|
|
225
|
+
canReopenWhileHidden: boolean;
|
|
226
|
+
sessionRef: SessionRef;
|
|
227
|
+
Session: SessionModule["QuestionnaireSession"];
|
|
228
|
+
}) {
|
|
229
|
+
const { ctx, typed, itemsByTab, collapseKey, canReopenWhileHidden, sessionRef, Session } = config;
|
|
230
|
+
return (
|
|
231
|
+
tui: TUI,
|
|
232
|
+
theme: Theme,
|
|
233
|
+
keybindings: QuestionnaireSessionConfig["keybindings"],
|
|
234
|
+
done: (result: QuestionnaireResult) => void,
|
|
235
|
+
): QuestionnaireSessionComponent => {
|
|
236
|
+
const session = new Session({
|
|
237
|
+
tui,
|
|
238
|
+
theme,
|
|
239
|
+
params: typed,
|
|
240
|
+
itemsByTab,
|
|
241
|
+
done,
|
|
242
|
+
keybindings,
|
|
243
|
+
editInput: async (value) => {
|
|
244
|
+
try {
|
|
245
|
+
// Imported per invocation, not hoisted: the external editor is used
|
|
246
|
+
// rarely and pulls a settings manager with it.
|
|
247
|
+
const [{ SettingsManager }, { editWithExternalEditor }] = await Promise.all([
|
|
248
|
+
import("@earendil-works/pi-coding-agent"),
|
|
249
|
+
import("./state/external-editor.js"),
|
|
250
|
+
]);
|
|
251
|
+
const editorCommand = SettingsManager.create(ctx.cwd, undefined, {
|
|
252
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
253
|
+
}).getExternalEditorCommand();
|
|
254
|
+
if (!editorCommand) throw new Error("No external editor command is configured");
|
|
255
|
+
return await editWithExternalEditor(tui, editorCommand, value);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
// Reported, then undefined, which the session reads as "keep the
|
|
258
|
+
// draft". Losing what someone typed because their editor is
|
|
259
|
+
// misconfigured would be the worst possible response.
|
|
260
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
261
|
+
ctx.ui.notify(`External editor failed: ${message}`, "error");
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
collapseKey,
|
|
266
|
+
canReopenWhileHidden,
|
|
267
|
+
});
|
|
268
|
+
sessionRef.current = session;
|
|
269
|
+
return session.component;
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* A rendered questionnaire always resolves a result, cancel included. So
|
|
275
|
+
* undefined means the host could not render it, never that the user declined —
|
|
276
|
+
* and the two must not be confused, because one is a decision and the other is
|
|
277
|
+
* a malfunction. RPC builds too old to advertise their mode land here.
|
|
278
|
+
*/
|
|
279
|
+
async function resolveUndefinedResult(ctx: ExtensionContext, typed: QuestionParams) {
|
|
280
|
+
if (hasDialogUI(ctx.ui)) {
|
|
281
|
+
return buildQuestionnaireResponse(await runRpcQuestionnaire(ctx.ui, typed), typed);
|
|
282
|
+
}
|
|
283
|
+
return buildToolResult(ERROR_NO_CUSTOM_UI, {
|
|
284
|
+
answers: [],
|
|
285
|
+
cancelled: true,
|
|
286
|
+
error: "no_custom_ui",
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The rows for one question: its authored options, then whichever sentinels apply. */
|
|
291
|
+
export function buildItemsForQuestion(question: QuestionData): WrappingSelectItem[] {
|
|
292
|
+
const items: WrappingSelectItem[] = question.options.map((o) => ({
|
|
293
|
+
kind: "option",
|
|
294
|
+
label: o.label,
|
|
295
|
+
description: o.description,
|
|
296
|
+
}));
|
|
297
|
+
for (const kind of sentinelsToAppend(question)) {
|
|
298
|
+
items.push({ kind, label: LABELS_BY_KIND[kind] });
|
|
299
|
+
}
|
|
300
|
+
return items;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export const DEFAULT_PROMPT_SNIPPET = `Ask the user up to ${MAX_QUESTIONS} structured questions (${MIN_OPTIONS}-${MAX_OPTIONS} options each) when requirements are ambiguous`;
|
|
304
|
+
|
|
305
|
+
export const DEFAULT_PROMPT_GUIDELINES: string[] = [
|
|
306
|
+
`Use ask_user_question when the user's request is underspecified and you would otherwise decide for them. "I could guess and carry on" is the situation this tool exists for, not a reason to skip it — you can ask up to ${MAX_QUESTIONS} questions per invocation.`,
|
|
307
|
+
`Prefer ask_user_question over asking in prose whenever you can name the candidate answers: which approach, which library, what to call it, how far to take it, which trade-off to accept. Ask in plain text only when the answer is open-ended enough that ${MIN_OPTIONS} concrete options cannot be written.`,
|
|
308
|
+
`Each ask_user_question question MUST have ${MIN_OPTIONS}-${MAX_OPTIONS} options. Every option requires a concise label (1-5 words) and a description explaining what the choice means or its trade-offs. The user can additionally type a custom answer via the automatically appended "Type something." row on every question, or press Esc to abandon the questionnaire. Do NOT author "Other" or "Type something." labels yourself — reserved labels are rejected at runtime.`,
|
|
309
|
+
`In ask_user_question, set multiSelect: true when multiple answers are valid. Provide an options[].preview markdown string when an option benefits from richer side-by-side context (mockups, code snippets, diagrams, configs) — single-select only. The "Type something." row is appended to every question; in preview mode it expands to the full pane width while typing so the custom answer is not cramped into the narrow options column. If you recommend a specific option, make that the first option and append "(Recommended)" to its label.`,
|
|
310
|
+
"Do not stack multiple ask_user_question calls back-to-back — group all clarifying questions into one invocation.",
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
export const DEFAULT_TOOL_DESCRIPTION = `Ask the user one or more structured questions during execution. Use when you need to:
|
|
314
|
+
1. Gather user preferences or requirements
|
|
315
|
+
2. Clarify ambiguous instructions
|
|
316
|
+
3. Get decisions on implementation choices as you work
|
|
317
|
+
4. Offer choices to the user about what direction to take
|
|
318
|
+
|
|
319
|
+
Usage notes:
|
|
320
|
+
- Users can type a custom answer via the automatically appended "Type something." row on every question or press Esc to abandon the questionnaire. Do NOT author "Other" or "Type something." labels yourself — reserved labels are rejected at runtime.
|
|
321
|
+
- Use multiSelect: true when multiple answers are valid. The "Type something." row is available on every question, including when options carry a \`preview\`; in preview mode it expands to the full pane width while typing so the custom answer is not cramped into the narrow options column.
|
|
322
|
+
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label.
|
|
323
|
+
|
|
324
|
+
Preview feature:
|
|
325
|
+
Use the optional \`preview\` field on options when presenting concrete artifacts that users need to visually compare:
|
|
326
|
+
- ASCII mockups of UI layouts or components
|
|
327
|
+
- Code snippets showing different implementations
|
|
328
|
+
- Diagram variations
|
|
329
|
+
- Configuration examples
|
|
330
|
+
|
|
331
|
+
Preview content is rendered as markdown in a monospace box. Multi-line text with newlines is supported. When any option has a preview, the UI switches to a side-by-side layout with a vertical option list on the left and preview on the right. Do not use previews for simple preference questions where labels and descriptions suffice. Note: previews are only supported for single-select questions (not multiSelect).`;
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Guidance comes from the global config layer only.
|
|
335
|
+
*
|
|
336
|
+
* Two reasons, and the second is the one that decides it. Registration happens
|
|
337
|
+
* before any context exists, so there is no cwd to read a project layer from
|
|
338
|
+
* and no trust decision to consult. And guidance is text folded into the
|
|
339
|
+
* model's own prompt: honouring it from a repository would let a checked-in
|
|
340
|
+
* file rewrite what the agent is told, which is exactly what project trust
|
|
341
|
+
* exists to prevent. The collapse key, a local UI preference, does read both
|
|
342
|
+
* layers.
|
|
343
|
+
*/
|
|
344
|
+
function loadGuidance(): {
|
|
345
|
+
guidance: ReturnType<typeof validateGuidanceFields>;
|
|
346
|
+
warnings: readonly string[];
|
|
347
|
+
} {
|
|
348
|
+
const { config, warnings } = loadConfig({ agentDir: getAgentDir() });
|
|
349
|
+
return { guidance: validateGuidanceFields(config.guidance), warnings };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function registerAskPopupTool(pi: ExtensionAPI): void {
|
|
353
|
+
const { guidance, warnings } = loadGuidance();
|
|
354
|
+
// Reported once, on first use, because registration has no UI to report to.
|
|
355
|
+
// Config problems are worth surfacing: silently ignoring a file someone wrote
|
|
356
|
+
// makes the setting look broken rather than mistyped.
|
|
357
|
+
let pendingWarnings: readonly string[] = warnings;
|
|
358
|
+
|
|
359
|
+
pi.registerTool({
|
|
360
|
+
name: ASK_POPUP_TOOL_NAME,
|
|
361
|
+
label: "Ask User Question",
|
|
362
|
+
description: guidance.description ?? DEFAULT_TOOL_DESCRIPTION,
|
|
363
|
+
promptSnippet: guidance.promptSnippet ?? DEFAULT_PROMPT_SNIPPET,
|
|
364
|
+
promptGuidelines: guidance.promptGuidelines ?? DEFAULT_PROMPT_GUIDELINES,
|
|
365
|
+
parameters: QuestionParamsSchema,
|
|
366
|
+
|
|
367
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
368
|
+
const typed = params as unknown as QuestionParams;
|
|
369
|
+
if (!ctx.hasUI) return rejectWithoutUi();
|
|
370
|
+
|
|
371
|
+
for (const warning of pendingWarnings) ctx.ui.notify?.(warning, "warning");
|
|
372
|
+
pendingWarnings = [];
|
|
373
|
+
|
|
374
|
+
const validation = validateQuestionnaire(typed);
|
|
375
|
+
if (!validation.ok) {
|
|
376
|
+
return buildToolResult(validation.message, {
|
|
377
|
+
answers: [],
|
|
378
|
+
cancelled: true,
|
|
379
|
+
error: validation.error,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
emitPrompt(pi, typed);
|
|
384
|
+
|
|
385
|
+
// Hosts that advertise their mode go straight to the walker and never
|
|
386
|
+
// import the render graph at all. Older RPC builds fall through to the
|
|
387
|
+
// undefined-result backstop below.
|
|
388
|
+
if ((ctx as { mode?: string }).mode === "rpc" && hasDialogUI(ctx.ui)) {
|
|
389
|
+
return runRpcPath(pi, ctx.ui, typed);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// A host that claims a UI but offers neither the overlay nor the dialog
|
|
393
|
+
// primitives is malformed, and calling `custom` on it throws a bare
|
|
394
|
+
// TypeError that reaches the model as a broken tool rather than an
|
|
395
|
+
// unsupported one. Answer honestly instead: nobody saw the questions.
|
|
396
|
+
if (typeof (ctx.ui as { custom?: unknown }).custom !== "function") {
|
|
397
|
+
return resolveUndefinedResult(ctx, typed);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const itemsByTab: WrappingSelectItem[][] = typed.questions.map((q) =>
|
|
401
|
+
buildItemsForQuestion(q),
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
const sessionLoad = await loadQuestionnaireSession();
|
|
405
|
+
if (!sessionLoad.ok) {
|
|
406
|
+
return buildToolResult(sessionLoad.message, {
|
|
407
|
+
answers: [],
|
|
408
|
+
cancelled: true,
|
|
409
|
+
error: sessionLoad.error,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
const { QuestionnaireSession } = sessionLoad.module;
|
|
413
|
+
|
|
414
|
+
// Both layers here: unlike guidance, this only binds a key in the user's
|
|
415
|
+
// own terminal, and a project pinning a shortcut that suits its docs is
|
|
416
|
+
// reasonable. `resolveCollapseKey` refuses anything malformed.
|
|
417
|
+
const collapseKey = resolveCollapseKey(
|
|
418
|
+
loadConfig({
|
|
419
|
+
agentDir: getAgentDir(),
|
|
420
|
+
...(ctx.isProjectTrusted() ? { projectDir: ctx.cwd } : {}),
|
|
421
|
+
}).config,
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
const sessionRef: SessionRef = { current: null };
|
|
425
|
+
const overlayHandleRef: OverlayHandleRef = { current: undefined };
|
|
426
|
+
const removeOverlayInputListener = registerCollapseKeyListener(
|
|
427
|
+
ctx,
|
|
428
|
+
collapseKey,
|
|
429
|
+
sessionRef,
|
|
430
|
+
overlayHandleRef,
|
|
431
|
+
);
|
|
432
|
+
// Hiding is reversible only through that listener, so the session may
|
|
433
|
+
// hide the overlay only when it was actually registered.
|
|
434
|
+
const canReopenWhileHidden = removeOverlayInputListener !== undefined;
|
|
435
|
+
|
|
436
|
+
emitBlocked(pi, true);
|
|
437
|
+
try {
|
|
438
|
+
emitTerminalAttention();
|
|
439
|
+
const result = await ctx.ui.custom<QuestionnaireResult>(
|
|
440
|
+
makeSessionFactory({
|
|
441
|
+
ctx,
|
|
442
|
+
typed,
|
|
443
|
+
itemsByTab,
|
|
444
|
+
collapseKey,
|
|
445
|
+
canReopenWhileHidden,
|
|
446
|
+
sessionRef,
|
|
447
|
+
Session: QuestionnaireSession,
|
|
448
|
+
}),
|
|
449
|
+
{
|
|
450
|
+
overlay: true,
|
|
451
|
+
overlayOptions: {
|
|
452
|
+
anchor: "bottom-center",
|
|
453
|
+
width: "100%",
|
|
454
|
+
maxHeight: "100%",
|
|
455
|
+
margin: { left: 0, right: 0, bottom: 0 },
|
|
456
|
+
},
|
|
457
|
+
onHandle: (handle) => {
|
|
458
|
+
overlayHandleRef.current = handle;
|
|
459
|
+
sessionRef.current?.setOverlayHandle(handle);
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
if (result === undefined) return resolveUndefinedResult(ctx, typed);
|
|
465
|
+
return buildQuestionnaireResponse(result, typed);
|
|
466
|
+
} finally {
|
|
467
|
+
removeOverlayInputListener?.();
|
|
468
|
+
emitBlocked(pi, false);
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export { buildQuestionnaireResponse, buildToolResult };
|