@geoqiao/pi-ask 1.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/LICENSE +22 -0
  3. package/README.md +282 -0
  4. package/docs/README.md +33 -0
  5. package/docs/configuration.md +406 -0
  6. package/docs/contract.md +309 -0
  7. package/docs/remote-events.md +187 -0
  8. package/package.json +130 -0
  9. package/skills/ask-user/SKILL.md +110 -0
  10. package/src/answer-commands.ts +361 -0
  11. package/src/answer-extraction.ts +354 -0
  12. package/src/ask-payload-store.ts +86 -0
  13. package/src/ask-settings-command.ts +14 -0
  14. package/src/ask-tool-helpers.ts +172 -0
  15. package/src/ask-tool.ts +84 -0
  16. package/src/config/defaults.ts +216 -0
  17. package/src/config/migrate.ts +70 -0
  18. package/src/config/migrations/index.ts +139 -0
  19. package/src/config/migrations/types.ts +10 -0
  20. package/src/config/schema.ts +287 -0
  21. package/src/config/store.ts +227 -0
  22. package/src/constants/keymaps.ts +721 -0
  23. package/src/constants/text.ts +12 -0
  24. package/src/constants/ui.ts +22 -0
  25. package/src/index.ts +30 -0
  26. package/src/math.ts +3 -0
  27. package/src/notifications.ts +119 -0
  28. package/src/remote-ask.ts +563 -0
  29. package/src/result-format.ts +157 -0
  30. package/src/result.ts +23 -0
  31. package/src/schema.ts +74 -0
  32. package/src/state/answers.ts +251 -0
  33. package/src/state/create.ts +18 -0
  34. package/src/state/editor.ts +70 -0
  35. package/src/state/navigation.ts +86 -0
  36. package/src/state/normalize.ts +326 -0
  37. package/src/state/question-type.ts +128 -0
  38. package/src/state/result.ts +263 -0
  39. package/src/state/selectors.ts +135 -0
  40. package/src/state/transitions.ts +330 -0
  41. package/src/state/view.ts +28 -0
  42. package/src/text.ts +98 -0
  43. package/src/types.ts +169 -0
  44. package/src/ui/auto-submit.ts +36 -0
  45. package/src/ui/autocomplete.ts +52 -0
  46. package/src/ui/controller.ts +645 -0
  47. package/src/ui/dismiss-guard.ts +26 -0
  48. package/src/ui/input.ts +160 -0
  49. package/src/ui/render-frame.ts +235 -0
  50. package/src/ui/render-helpers.ts +385 -0
  51. package/src/ui/render-question.ts +288 -0
  52. package/src/ui/render-submit.ts +168 -0
  53. package/src/ui/render-types.ts +33 -0
  54. package/src/ui/render.ts +53 -0
  55. package/src/ui/review-shortcuts.ts +43 -0
  56. package/src/ui/settings-list.ts +461 -0
  57. package/src/ui/show-settings.ts +37 -0
  58. package/src/ui/view-models/question.ts +203 -0
  59. package/src/ui/view-models/review.ts +100 -0
@@ -0,0 +1,354 @@
1
+ import {
2
+ type Api,
3
+ complete,
4
+ type Model,
5
+ type UserMessage,
6
+ } from "@earendil-works/pi-ai";
7
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import type { AskConfig } from "./config/schema.ts";
9
+ import type { AskParams } from "./types.ts";
10
+
11
+ export const ANSWER_EXTRACTION_SYSTEM_PROMPT = `You extract user-input questions from an assistant message and return ONLY raw JSON.
12
+
13
+ Return JSON matching this TypeScript shape:
14
+ {
15
+ "title"?: string,
16
+ "questions": [
17
+ {
18
+ "id": string,
19
+ "label"?: string,
20
+ "prompt": string,
21
+ "type"?: "single" | "multi" | "preview",
22
+ "required"?: boolean,
23
+ "options": [
24
+ {
25
+ "value": string,
26
+ "label": string,
27
+ "description"?: string,
28
+ "preview"?: string,
29
+ "freeform"?: boolean
30
+ }
31
+ ]
32
+ }
33
+ ]
34
+ }
35
+
36
+ Rules:
37
+ - Output raw JSON only. No Markdown. No prose. No code fences.
38
+ - Extract questions that require user input.
39
+ - Ignore generic conversational or clarification prompts such as "How can I help?", "Could you clarify?", or "Let me know what you need" unless they include concrete choices.
40
+ - Extract a question when it has explicit choices or when the user should type their own answer.
41
+ - Preserve question order.
42
+ - Generate stable snake_case ids.
43
+ - Choose question type from the question semantics.
44
+ - Use type "single" when one answer is expected.
45
+ - Use type "multi" when multiple answers could reasonably be selected.
46
+ - Use type "preview" when options need preview-pane detail and every option has non-empty preview text.
47
+ - Avoid defaulting mechanically; infer from whether the options are mutually exclusive, can coexist, or need preview-pane detail.
48
+ - Each extracted question must have at least one option.
49
+ - Options rule:
50
+ - Extract options only from choices explicitly offered by the assistant.
51
+ - If the assistant gives concrete choices, use those choices as normal options.
52
+ - If the assistant gives examples only, do not treat examples as choices unless they are presented as selectable answers.
53
+ - If no concrete choices are given and the user should type their own answer, create exactly one freeform option: {"value":"freeform","label":"Type answer","freeform":true}.
54
+ - Never invent options.
55
+ - Never mix a freeform option with normal options.
56
+ - Provide clear, distinct options. Do not add filler options.
57
+ - Do not create an option that merely restates the question.
58
+ - Include descriptions only when helpful.
59
+ - Return {"questions":[]} if no questions are found.`;
60
+
61
+ interface SelectedExtractionModel {
62
+ auth: { apiKey?: string; headers?: Record<string, string> };
63
+ model: Model<Api>;
64
+ usedFallback: boolean;
65
+ }
66
+
67
+ export async function selectExtractionModel(
68
+ ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
69
+ preferences: AskConfig["answer"]["extractionModels"]
70
+ ): Promise<SelectedExtractionModel | { error: string }> {
71
+ for (const preference of preferences) {
72
+ const model = ctx.modelRegistry.find(preference.provider, preference.id);
73
+ if (!model) {
74
+ continue;
75
+ }
76
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
77
+ if (auth.ok) {
78
+ return { model, auth, usedFallback: false };
79
+ }
80
+ }
81
+
82
+ if (!ctx.model) {
83
+ return {
84
+ error:
85
+ "No available extraction model. Configure answer.extractionModels or select a chat model.",
86
+ };
87
+ }
88
+
89
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
90
+ if (!auth.ok) {
91
+ return {
92
+ error: `No auth for fallback chat model: ${ctx.model.provider}/${ctx.model.id}.`,
93
+ };
94
+ }
95
+
96
+ return { model: ctx.model, auth, usedFallback: true };
97
+ }
98
+
99
+ export async function extractAskParams(options: {
100
+ assistantText: string;
101
+ model: Model<Api>;
102
+ auth: { apiKey?: string; headers?: Record<string, string> };
103
+ retries: number;
104
+ signal?: AbortSignal;
105
+ timeoutMs: number;
106
+ onRetry?: (attempt: number, maxRetries: number) => void;
107
+ }): Promise<AskParams> {
108
+ let lastCandidate: AskParams | undefined;
109
+ let lastResponse = "";
110
+ let lastError = "";
111
+ for (let attempt = 0; attempt <= options.retries; attempt++) {
112
+ if (attempt > 0) {
113
+ options.onRetry?.(attempt, options.retries);
114
+ }
115
+ const responseText = await runExtractionAttempt({
116
+ ...options,
117
+ attempt,
118
+ lastError,
119
+ lastResponse,
120
+ });
121
+ lastResponse = responseText;
122
+ const parsed = parseExtractionCandidate(responseText);
123
+ if (!parsed.ok) {
124
+ lastError = parsed.error;
125
+ continue;
126
+ }
127
+ lastCandidate = parsed.params;
128
+ if (parsed.issues.length === 0) {
129
+ return parsed.params;
130
+ }
131
+ lastError = parsed.issues.join("\n");
132
+ }
133
+ if (lastCandidate) {
134
+ return repairExtractionParams(lastCandidate);
135
+ }
136
+ throw new Error(
137
+ "Question extraction did not return valid JSON after retries."
138
+ );
139
+ }
140
+
141
+ async function runExtractionAttempt(options: {
142
+ assistantText: string;
143
+ attempt: number;
144
+ auth: { apiKey?: string; headers?: Record<string, string> };
145
+ lastError: string;
146
+ lastResponse: string;
147
+ model: Model<Api>;
148
+ signal?: AbortSignal;
149
+ timeoutMs: number;
150
+ }): Promise<string> {
151
+ const controller = new AbortController();
152
+ let timedOut = false;
153
+ const timeout = setTimeout(() => {
154
+ timedOut = true;
155
+ controller.abort();
156
+ }, options.timeoutMs);
157
+ const abortFromParent = () => controller.abort();
158
+ options.signal?.addEventListener("abort", abortFromParent, { once: true });
159
+ try {
160
+ const userMessage: UserMessage = {
161
+ role: "user",
162
+ content: [
163
+ {
164
+ type: "text",
165
+ text:
166
+ options.attempt === 0
167
+ ? options.assistantText
168
+ : formatRetryPrompt(options),
169
+ },
170
+ ],
171
+ timestamp: Date.now(),
172
+ };
173
+ const response = await complete(
174
+ options.model,
175
+ {
176
+ systemPrompt: ANSWER_EXTRACTION_SYSTEM_PROMPT,
177
+ messages: [userMessage],
178
+ },
179
+ {
180
+ apiKey: options.auth.apiKey,
181
+ headers: options.auth.headers,
182
+ signal: controller.signal,
183
+ }
184
+ );
185
+ if (response.stopReason === "aborted") {
186
+ throw new Error(
187
+ timedOut
188
+ ? "Question extraction timed out. Try again or configure a faster extraction model."
189
+ : "Question extraction cancelled."
190
+ );
191
+ }
192
+ return response.content
193
+ .filter(
194
+ (part): part is { text: string; type: "text" } => part.type === "text"
195
+ )
196
+ .map((part) => part.text)
197
+ .join("\n");
198
+ } finally {
199
+ clearTimeout(timeout);
200
+ options.signal?.removeEventListener("abort", abortFromParent);
201
+ }
202
+ }
203
+
204
+ const MAX_EXTRACTED_OPTIONS_PER_QUESTION = 4;
205
+
206
+ function parseExtractionCandidate(
207
+ responseText: string
208
+ ):
209
+ | { ok: true; params: AskParams; issues: string[] }
210
+ | { ok: false; error: string } {
211
+ try {
212
+ const parsed = JSON.parse(responseText.trim()) as unknown;
213
+ if (!isAskParamsLike(parsed)) {
214
+ return { ok: false, error: "root.questions must be an array" };
215
+ }
216
+ return {
217
+ ok: true,
218
+ params: parsed,
219
+ issues: collectExtractionBusinessIssues(parsed),
220
+ };
221
+ } catch (error) {
222
+ return {
223
+ ok: false,
224
+ error: error instanceof Error ? error.message : String(error),
225
+ };
226
+ }
227
+ }
228
+
229
+ function isAskParamsLike(value: unknown): value is AskParams {
230
+ return (
231
+ !!value &&
232
+ typeof value === "object" &&
233
+ Array.isArray((value as { questions?: unknown }).questions)
234
+ );
235
+ }
236
+
237
+ export function collectExtractionBusinessIssues(params: AskParams): string[] {
238
+ const issues: string[] = [];
239
+ params.questions.forEach((question, questionIndex) => {
240
+ if (isGenericConversationalPrompt(question.prompt)) {
241
+ issues.push(
242
+ `questions[${questionIndex}].prompt is generic conversational text; omit this question unless it includes concrete choices`
243
+ );
244
+ }
245
+ if (question.options.length > MAX_EXTRACTED_OPTIONS_PER_QUESTION) {
246
+ issues.push(
247
+ `questions[${questionIndex}].options has ${question.options.length} items; max is ${MAX_EXTRACTED_OPTIONS_PER_QUESTION}`
248
+ );
249
+ }
250
+ if (
251
+ question.options.length === 1 &&
252
+ optionRestatesQuestion(question.options[0]?.label, question.prompt)
253
+ ) {
254
+ issues.push(
255
+ `questions[${questionIndex}].options[0] merely restates the question; omit this question or provide meaningful distinct options`
256
+ );
257
+ }
258
+ });
259
+ return issues;
260
+ }
261
+
262
+ export function repairExtractionParams(params: AskParams): AskParams {
263
+ return {
264
+ ...params,
265
+ questions: params.questions
266
+ .filter((question) => !isGenericConversationalPrompt(question.prompt))
267
+ .filter(
268
+ (question) =>
269
+ question.options.length !== 1 ||
270
+ !optionRestatesQuestion(question.options[0]?.label, question.prompt)
271
+ )
272
+ .map((question) => ({
273
+ ...question,
274
+ options: capOptionsPreservingOther(question.options),
275
+ })),
276
+ };
277
+ }
278
+
279
+ function capOptionsPreservingOther<T extends { label: string; value: string }>(
280
+ options: T[]
281
+ ): T[] {
282
+ if (options.length <= MAX_EXTRACTED_OPTIONS_PER_QUESTION) {
283
+ return options;
284
+ }
285
+ const other = options.find((option) => isOtherOption(option));
286
+ if (!other) {
287
+ return options.slice(0, MAX_EXTRACTED_OPTIONS_PER_QUESTION);
288
+ }
289
+ const head = options
290
+ .filter((option) => option !== other)
291
+ .slice(0, MAX_EXTRACTED_OPTIONS_PER_QUESTION - 1);
292
+ return [...head, other];
293
+ }
294
+
295
+ function isOtherOption(option: { label: string; value: string }): boolean {
296
+ const label = normalizeText(option.label);
297
+ const value = normalizeText(option.value);
298
+ return (
299
+ label === "other" || label.includes("something else") || value === "other"
300
+ );
301
+ }
302
+
303
+ function optionRestatesQuestion(
304
+ label: string | undefined,
305
+ prompt: string
306
+ ): boolean {
307
+ if (!label) {
308
+ return false;
309
+ }
310
+ const normalizedLabel = normalizeText(label);
311
+ const normalizedPrompt = normalizeText(prompt);
312
+ return (
313
+ normalizedLabel.length > 8 &&
314
+ (normalizedPrompt.includes(normalizedLabel) ||
315
+ normalizedLabel.includes(normalizedPrompt))
316
+ );
317
+ }
318
+
319
+ function isGenericConversationalPrompt(prompt: string): boolean {
320
+ const normalized = normalizeText(prompt);
321
+ return [
322
+ "how can i help",
323
+ "could you clarify",
324
+ "let me know what you need",
325
+ "what else is on your mind",
326
+ "anything on your mind i can help with",
327
+ ].some((phrase) => normalized.includes(phrase));
328
+ }
329
+
330
+ function normalizeText(value: string): string {
331
+ return value
332
+ .toLowerCase()
333
+ .replace(/[^a-z0-9]+/g, " ")
334
+ .trim();
335
+ }
336
+
337
+ function formatRetryPrompt(options: {
338
+ assistantText: string;
339
+ lastError: string;
340
+ lastResponse: string;
341
+ }): string {
342
+ return `Your previous response was not valid raw JSON.
343
+
344
+ JSON.parse error:
345
+ ${options.lastError}
346
+
347
+ Previous response:
348
+ ${options.lastResponse}
349
+
350
+ Original assistant message:
351
+ ${options.assistantText}
352
+
353
+ Return ONLY valid raw JSON matching the schema and fix the reported issues. No Markdown. No prose.`;
354
+ }
@@ -0,0 +1,86 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { validateParams } from "./ask-tool-helpers.ts";
6
+ import type { AskParams } from "./types.ts";
7
+
8
+ export const ASK_PAYLOAD_ENTRY_TYPE = "ask:payload";
9
+ export const ASK_PAYLOAD_ENTRY_VERSION = 1;
10
+
11
+ export type AskPayloadSource = "answer-extraction" | "tool";
12
+
13
+ export interface AskPayloadEntryData {
14
+ params: AskParams;
15
+ source: AskPayloadSource;
16
+ sourceEntryId?: string;
17
+ timestamp: number;
18
+ version: typeof ASK_PAYLOAD_ENTRY_VERSION;
19
+ }
20
+
21
+ export function appendAskPayload(
22
+ pi: Pick<ExtensionAPI, "appendEntry">,
23
+ data: Omit<AskPayloadEntryData, "timestamp" | "version">
24
+ ): void {
25
+ pi.appendEntry(ASK_PAYLOAD_ENTRY_TYPE, {
26
+ version: ASK_PAYLOAD_ENTRY_VERSION,
27
+ timestamp: Date.now(),
28
+ ...data,
29
+ });
30
+ }
31
+
32
+ export function findLatestPayloadInCurrentBranch(
33
+ ctx: Pick<ExtensionContext, "sessionManager">,
34
+ source: AskPayloadSource
35
+ ): { data?: AskPayloadEntryData; invalidMatchFound: boolean } {
36
+ let invalidMatchFound = false;
37
+ for (const entry of [...ctx.sessionManager.getBranch()].reverse()) {
38
+ if (!isAskPayloadEntry(entry)) {
39
+ continue;
40
+ }
41
+ const data = entry.data;
42
+ if (data?.source !== source) {
43
+ continue;
44
+ }
45
+ if (isValidAskPayloadData(data)) {
46
+ return { data, invalidMatchFound };
47
+ }
48
+ invalidMatchFound = true;
49
+ }
50
+ return { invalidMatchFound };
51
+ }
52
+
53
+ function isAskPayloadEntry(entry: unknown): entry is {
54
+ customType: string;
55
+ data?: Partial<AskPayloadEntryData>;
56
+ type: "custom";
57
+ } {
58
+ return (
59
+ !!entry &&
60
+ typeof entry === "object" &&
61
+ (entry as { type?: unknown }).type === "custom" &&
62
+ (entry as { customType?: unknown }).customType === ASK_PAYLOAD_ENTRY_TYPE
63
+ );
64
+ }
65
+
66
+ function isValidAskPayloadData(data: unknown): data is AskPayloadEntryData {
67
+ if (!(data && typeof data === "object")) {
68
+ return false;
69
+ }
70
+ const payload = data as Partial<AskPayloadEntryData>;
71
+ if (payload.version !== ASK_PAYLOAD_ENTRY_VERSION) {
72
+ return false;
73
+ }
74
+ if (payload.source !== "tool" && payload.source !== "answer-extraction") {
75
+ return false;
76
+ }
77
+ if (
78
+ !payload.params ||
79
+ validateParams(payload.params, {
80
+ allowFreeform: payload.source === "answer-extraction",
81
+ }).ok === false
82
+ ) {
83
+ return false;
84
+ }
85
+ return true;
86
+ }
@@ -0,0 +1,14 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { showAskSettings } from "./ui/show-settings.ts";
6
+
7
+ export function registerAskSettingsCommand(pi: ExtensionAPI) {
8
+ pi.registerCommand("ask-settings", {
9
+ description: "Open ask settings",
10
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
11
+ await showAskSettings(ctx);
12
+ },
13
+ });
14
+ }
@@ -0,0 +1,172 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { UI_DIMENSIONS } from "./constants/ui.ts";
4
+ import { renderResultText } from "./result.ts";
5
+ import { createInitialState } from "./state/create.ts";
6
+ import { collectValidationIssues } from "./state/normalize.ts";
7
+ import { summarizeResult, toAskResult } from "./state/result.ts";
8
+ import type {
9
+ AskParams,
10
+ AskQuestionInput,
11
+ AskResult,
12
+ AskValidationIssue,
13
+ } from "./types.ts";
14
+
15
+ export const ASK_TOOL_DESCRIPTION =
16
+ "Interactive clarification tool for cases where the next step depends on user preferences, missing requirements, or choosing between multiple valid directions. Ask a short structured interview, collect normalized answers, and continue using those answers explicitly instead of guessing. Supports single-select, multi-select, and preview-pane questions. Always include a machine-readable `value` for every option. Use `preview` only when every option includes `preview` text; descriptions alone are not enough.";
17
+
18
+ export const ASK_TOOL_PROMPT_GUIDELINES = [
19
+ "Use `ask_user` before making preference-sensitive decisions about scope, tone, UX, naming, architecture, docs, or implementation direction.",
20
+ "When multiple valid directions exist, call `ask_user` with 1-3 concise questions instead of committing to one path on your own.",
21
+ "When calling `ask_user`, prefer one focused decision per question. Use short labels. Provide clear, distinct options. Do not add filler options.",
22
+ "When calling `ask_user`, always include a non-empty machine-readable `value` for every option.",
23
+ "When calling `ask_user`, choose question `type` from the question semantics: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `preview` means options need preview-pane detail.",
24
+ 'When calling `ask_user`, use `type: "preview"` only when every option includes non-empty `preview` text. Option descriptions do not satisfy this requirement.',
25
+ "After an `ask_user` elaboration or follow-up note, prefer another structured `ask_user` follow-up if a choice is still needed instead of switching to plain-text multiple choice in chat.",
26
+ "When prior `ask_user` answers narrow the branch, bundle the next 2-3 related unresolved decisions into one follow-up `ask_user` call when possible.",
27
+ "Use one-at-a-time `ask_user` follow-up calls only when the next question materially depends on the previous answer.",
28
+ ] as const;
29
+
30
+ interface ValidateParamsOptions {
31
+ allowFreeform?: boolean;
32
+ presentSingleAsMulti?: boolean;
33
+ }
34
+
35
+ export function validateParams(
36
+ params: AskParams,
37
+ options: ValidateParamsOptions = {}
38
+ ):
39
+ | { ok: true; state: ReturnType<typeof createInitialState> }
40
+ | { ok: false; issues: AskValidationIssue[] } {
41
+ const issues = collectValidationIssues(params, options);
42
+ if (issues.length > 0) {
43
+ return { ok: false, issues };
44
+ }
45
+
46
+ return {
47
+ ok: true,
48
+ state: createInitialState(params, options),
49
+ };
50
+ }
51
+
52
+ export function invalidPayloadResponse(
53
+ params: AskParams,
54
+ issues: AskValidationIssue[]
55
+ ) {
56
+ return {
57
+ content: [{ type: "text" as const, text: formatValidationError(issues) }],
58
+ details: errorResultDetails(params, issues),
59
+ };
60
+ }
61
+
62
+ export function nonInteractiveResponse(
63
+ state: ReturnType<typeof createInitialState>
64
+ ) {
65
+ return {
66
+ content: [
67
+ { type: "text" as const, text: formatNonInteractiveMessage(state) },
68
+ ],
69
+ details: {
70
+ ...toAskResult(state),
71
+ cancelled: true,
72
+ },
73
+ };
74
+ }
75
+
76
+ export function successfulResponse(result: AskResult) {
77
+ return {
78
+ content: [{ type: "text" as const, text: summarizeResult(result) }],
79
+ details: result,
80
+ };
81
+ }
82
+
83
+ type ToolTheme = ExtensionContext["ui"]["theme"];
84
+
85
+ export function renderAskToolCall(args: unknown, theme: ToolTheme) {
86
+ const params = args as AskParams;
87
+ const labels = Array.isArray(params.questions)
88
+ ? params.questions
89
+ .map(
90
+ (question: AskQuestionInput, index) =>
91
+ question.label || `Q${index + 1}`
92
+ )
93
+ .join(", ")
94
+ : "";
95
+ let text = theme.fg("toolTitle", theme.bold("ask_user "));
96
+ text += theme.fg("muted", `${params.questions?.length ?? 0} question(s)`);
97
+ if (labels) {
98
+ text += theme.fg(
99
+ "dim",
100
+ ` (${truncateToWidth(labels, UI_DIMENSIONS.callLabelTruncateWidth)})`
101
+ );
102
+ }
103
+ return new Text(text, 0, 0);
104
+ }
105
+
106
+ export function renderAskToolResult(
107
+ result: {
108
+ content: Array<{ type?: string; text?: string }>;
109
+ details?: AskResult;
110
+ },
111
+ _options: unknown,
112
+ theme: ToolTheme
113
+ ) {
114
+ const details = result.details;
115
+ if (!details) {
116
+ const text = result.content[0];
117
+ return new Text(text?.type === "text" ? (text.text ?? "") : "", 0, 0);
118
+ }
119
+ if (details.error) {
120
+ return new Text(theme.fg("warning", "Invalid input"), 0, 0);
121
+ }
122
+ if (details.cancelled) {
123
+ return new Text(theme.fg("warning", "Cancelled"), 0, 0);
124
+ }
125
+ return new Text(renderResultText(details), 0, 0);
126
+ }
127
+
128
+ function errorResultDetails(
129
+ params: AskParams,
130
+ issues: AskValidationIssue[]
131
+ ): AskResult {
132
+ return {
133
+ title: params.title,
134
+ cancelled: true,
135
+ mode: "submit",
136
+ questions: [],
137
+ answers: {},
138
+ error: {
139
+ kind: "invalid_input",
140
+ issues,
141
+ },
142
+ };
143
+ }
144
+
145
+ function formatValidationError(issues: AskValidationIssue[]): string {
146
+ return [
147
+ "Invalid ask_user payload:",
148
+ ...issues.map((issue) => `- ${issue.path}: ${issue.message}`),
149
+ ].join("\n");
150
+ }
151
+
152
+ function formatNonInteractiveMessage(
153
+ state: ReturnType<typeof createInitialState>
154
+ ): string {
155
+ const lines = [
156
+ "Needs user input: ask_user requires interactive TUI mode.",
157
+ "Run same tool call in interactive TUI mode, or ask user these questions manually:",
158
+ ];
159
+
160
+ for (const [index, question] of state.questions.entries()) {
161
+ lines.push(`${index + 1}. ${question.label}: ${question.prompt}`);
162
+ for (const option of question.options) {
163
+ lines.push(` - ${option.label} [${option.value}]`);
164
+ }
165
+ lines.push(" - Type your own [custom]");
166
+ }
167
+
168
+ lines.push(
169
+ "details.questions contains normalized pending questions. details.answers stays empty until user responds."
170
+ );
171
+ return lines.join("\n");
172
+ }
@@ -0,0 +1,84 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { appendAskPayload } from "./ask-payload-store.ts";
6
+ import {
7
+ ASK_TOOL_DESCRIPTION,
8
+ ASK_TOOL_PROMPT_GUIDELINES,
9
+ invalidPayloadResponse,
10
+ nonInteractiveResponse,
11
+ renderAskToolCall,
12
+ renderAskToolResult,
13
+ successfulResponse,
14
+ validateParams,
15
+ } from "./ask-tool-helpers.ts";
16
+ import { getAskConfigStore } from "./config/store.ts";
17
+ import type { RemoteAskRuntime } from "./remote-ask.ts";
18
+ import { AskParamsSchema } from "./schema.ts";
19
+ import type { AskParams } from "./types.ts";
20
+ import { runAskFlow } from "./ui/controller.ts";
21
+
22
+ export function registerAskTool(
23
+ pi: ExtensionAPI,
24
+ remoteAsk?: RemoteAskRuntime
25
+ ) {
26
+ pi.registerTool({
27
+ name: "ask_user",
28
+ label: "Ask User",
29
+ description: ASK_TOOL_DESCRIPTION,
30
+ promptSnippet:
31
+ "Clarify ambiguous or preference-sensitive decisions with a short interactive interview before proceeding",
32
+ promptGuidelines: [...ASK_TOOL_PROMPT_GUIDELINES],
33
+ parameters: AskParamsSchema,
34
+ execute: (toolCallId, params, signal, onUpdate, ctx) =>
35
+ executeAskTool(
36
+ pi,
37
+ toolCallId,
38
+ params as AskParams,
39
+ signal,
40
+ onUpdate,
41
+ ctx,
42
+ remoteAsk
43
+ ),
44
+ renderCall: renderAskToolCall,
45
+ renderResult: renderAskToolResult,
46
+ });
47
+ }
48
+
49
+ async function executeAskTool(
50
+ pi: Pick<ExtensionAPI, "appendEntry">,
51
+ toolCallId: string,
52
+ params: AskParams,
53
+ _signal: AbortSignal | undefined,
54
+ _onUpdate: unknown,
55
+ ctx: ExtensionContext,
56
+ remoteAsk?: RemoteAskRuntime
57
+ ) {
58
+ const config = await getAskConfigStore().getConfig();
59
+ const validation = validateParams(params, {
60
+ presentSingleAsMulti: config.behaviour.presentSingleAsMulti,
61
+ });
62
+ if (!validation.ok) {
63
+ return invalidPayloadResponse(params, validation.issues);
64
+ }
65
+ appendAskPayload(pi, {
66
+ params,
67
+ source: "tool",
68
+ sourceEntryId: toolCallId,
69
+ });
70
+ if (ctx.mode !== "tui") {
71
+ return nonInteractiveResponse(validation.state);
72
+ }
73
+ ctx.ui.setWorkingVisible(false);
74
+ try {
75
+ const result = await runAskFlow(ctx, params, {
76
+ remote: remoteAsk
77
+ ? { runtime: remoteAsk, source: "tool", toolCallId }
78
+ : undefined,
79
+ });
80
+ return successfulResponse(result);
81
+ } finally {
82
+ ctx.ui.setWorkingVisible(true);
83
+ }
84
+ }