@fyeeme/pi-ask-user 2.0.0 → 2.0.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/index.ts CHANGED
@@ -1,103 +1,102 @@
1
1
  /**
2
- * pi-ask-user - Structured multi-question ask tool for pi
2
+ * pi-ask-user — the oh-my-pi `ask` tool migrated to a pi extension
3
3
  *
4
- * Registers an `ask_user` tool that lets the LLM surface clarifying questions
5
- * with options while it works: all questions presented through one dialog with
6
- * left/right (or tab/shift+tab) navigation between them, a per-question status
7
- * strip, single or multi select, a recommended default, optional
8
- * descriptions/previews, numbered options, free-text "Other" and answer notes
9
- * captured by an editor embedded in the dialog (the option list stays visible
10
- * while typing), a review page that summarizes every answer before submitting,
11
- * an optional overall timeout that auto-selects the recommended options, and a
12
- * "Chat about this" redirect for deferring the decision into conversation.
4
+ * Source: oh-my-pi (github.com/can1357/oh-my-pi, a fork of badlogic/pi-mono)
5
+ * - packages/coding-agent/src/tools/ask.ts → execute + renderer (below)
6
+ * - packages/coding-agent/src/modes/components/ask-dialog.ts → src/ask-dialog.ts
7
+ * - packages/coding-agent/src/tools/ask.ts (askSingleQuestion) → src/ask-legacy.ts
8
+ * - packages/coding-agent/src/prompts/tools/ask.md → tool description
13
9
  *
14
- * Answers are stored in tool result `details` so they survive branching and
15
- * `/tree` inspection.
10
+ * The tool lets the LLM surface clarifying questions with selectable options
11
+ * while it works. Multi questions render as a tabbed dialog with a Submit
12
+ * review page; hosts without custom component support (RPC) degrade to
13
+ * per-question native dialogs; hosts without any UI never see the tool.
14
+ *
15
+ * Adaptations for pi's extension API (each maps an omp-internal surface to
16
+ * the public extension boundary):
17
+ *
18
+ * omp surface → pi adaptation
19
+ * ─────────────────────────────────────────────────────────────────────────
20
+ * AgentTool class + createIf gate → pi.registerTool + session_start strip
21
+ * (print/json hosts cannot prompt, so the
22
+ * tool is removed from the active set and
23
+ * execute() throws as a backstop)
24
+ * ArkType schema + narrow → typebox schema; the reserved-label
25
+ * narrow runs at the top of execute()
26
+ * concurrency: "exclusive" → executionMode: "sequential"
27
+ * settings ask.timeout/ask.notify → timeoutSeconds tool parameter + BEL
28
+ * (pi extensions cannot read pi settings)
29
+ * plan-mode timeout suppression → dropped (no plan-mode API on pi ctx)
30
+ * speech/TTS vocalizer → dropped
31
+ * collab guest race, ACP
32
+ * elicitation, /tree re-answer → dropped (host-internal surfaces)
33
+ * ToolAbortError + context.abort() → ctx.abort() + thrown Error
34
+ * ExtensionUIContext.askDialog → ctx.ui.custom() mounting AskDialogComponent
35
+ * askToolRenderer (mergeCall+Result) → split pi renderCall/renderResult slots
36
+ * (call slot renders a summary line
37
+ * only; the merged question/options/
38
+ * answer block renders once from the
39
+ * result slot — pi appends result
40
+ * renders below call renders, so a
41
+ * full call block would duplicate the
42
+ * transcript)
43
+ *
44
+ * omp result semantics are kept verbatim: `User selected: X`, custom input
45
+ * blocks, `User answers:` for multi-question, and
46
+ * `(auto-selected after timeout)` markers; answers persist in `details`.
16
47
  */
17
48
 
18
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionUIContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
19
- import type { Theme } from "@earendil-works/pi-coding-agent";
20
- import { Editor, type EditorTheme, Key, matchesKey, Text, type TUI, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
49
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
50
+ import { Text } from "@earendil-works/pi-tui";
21
51
  import { Type } from "typebox";
52
+ import { AskDialogComponent } from "./src/ask-dialog.ts";
53
+ import {
54
+ askSingleQuestion,
55
+ RESERVED_OPTION_LABELS,
56
+ type AskOption,
57
+ type NavigationControls,
58
+ type UIContext,
59
+ uiContextFromExtension,
60
+ } from "./src/ask-legacy.ts";
61
+ import { replaceTabs } from "./src/compat.ts";
62
+ import type {
63
+ AskToolDetails,
64
+ ExtensionAskDialogQuestion,
65
+ ExtensionAskDialogResult,
66
+ QuestionResult,
67
+ } from "./src/types.ts";
22
68
 
23
69
  // ---------------------------------------------------------------------------
24
- // Types & schema
70
+ // Schema (omp askSchema; timeoutSeconds replaces omp's settings-driven budget)
25
71
  // ---------------------------------------------------------------------------
26
72
 
27
- export interface AskOption {
28
- label: string;
29
- description?: string;
30
- preview?: string;
31
- }
32
-
33
- export interface AskQuestion {
34
- id: string;
35
- question: string;
36
- header?: string;
37
- options: AskOption[];
38
- multi: boolean;
39
- recommended?: number;
40
- }
41
-
42
- export interface QuestionResult {
43
- id: string;
44
- question: string;
45
- options: string[];
46
- multi: boolean;
47
- selectedOptions: string[];
48
- customInput?: string;
49
- note?: string;
50
- /** True when the answer was auto-selected because the dialog timed out. */
51
- timedOut?: boolean;
52
- }
53
-
54
- export interface AskUserDetails {
55
- /** Per-question answers in the order the questions were asked. */
56
- results?: QuestionResult[];
57
- /** Original question texts (present when the user cancelled mid-way). */
58
- questions?: string[];
59
- /** True when the user pressed Escape before finishing all questions. */
60
- cancelled?: boolean;
61
- /** True when the user chose to discuss the questions instead of answering. */
62
- chatRedirect?: boolean;
63
- }
64
-
65
- const OTHER_OPTION = "Other (type your own)";
66
- const CHAT_OPTION = "Chat about this";
67
- const RECOMMENDED_SUFFIX = " (Recommended)";
68
-
69
- const RESERVED_LABELS = new Set([OTHER_OPTION, CHAT_OPTION]);
70
-
71
73
  const OptionSchema = Type.Object({
72
- label: Type.String({ description: "Short display label (1-5 words)" }),
73
- description: Type.Optional(Type.String({ description: "Optional tradeoff/explanation shown below the label" })),
74
+ label: Type.String({ description: "display label" }),
75
+ description: Type.Optional(Type.String({ description: "optional explanatory text displayed below the label" })),
74
76
  preview: Type.Optional(
75
- Type.String({ description: "Optional rich preview shown while the cursor rests on this option" }),
77
+ Type.String({ description: "optional rich preview content for interactive ask dialogs" }),
76
78
  ),
77
79
  });
78
80
 
79
81
  const AskQuestionSchema = Type.Object({
80
- id: Type.String({ description: "Stable identifier for the question, e.g. auth_method" }),
81
- question: Type.String({ description: "The question text shown to the user" }),
82
- header: Type.Optional(Type.String({ description: "Short display chip rendered next to the progress counter" })),
83
- options: Type.Array(OptionSchema, {
84
- minItems: 2,
85
- maxItems: 6,
86
- description: "2-6 distinct options",
87
- }),
88
- multi: Type.Optional(Type.Boolean({ description: "Allow selecting multiple options" })),
89
- recommended: Type.Optional(
90
- Type.Integer({ minimum: 0, description: "Index of the recommended option (0-based)" }),
91
- ),
82
+ id: Type.String({ description: "question id" }),
83
+ question: Type.String({ description: "question text" }),
84
+ header: Type.Optional(Type.String({ description: "optional short display chip for rich ask dialogs" })),
85
+ options: Type.Array(OptionSchema, { description: "available options" }),
86
+ multi: Type.Optional(Type.Boolean({ description: "allow multiple selections" })),
87
+ recommended: Type.Optional(Type.Integer({ minimum: 0, description: "recommended option index" })),
92
88
  });
93
89
 
94
90
  const AskParamsSchema = Type.Object({
95
- questions: Type.Array(AskQuestionSchema, { minItems: 1, maxItems: 6, description: "Questions to ask" }),
91
+ questions: Type.Array(AskQuestionSchema, { minItems: 1, description: "questions to ask" }),
96
92
  timeoutSeconds: Type.Optional(
97
93
  Type.Integer({
98
94
  minimum: 1,
99
95
  description:
100
- "Overall time budget for the whole dialog; on expiry unanswered questions auto-select the recommended option",
96
+ "Inactivity timeout in seconds for the whole dialog; keypresses in the dialog reset it " +
97
+ "(not while typing an Other answer, and not on hosts limited to plain sequential " +
98
+ "dialogs). On expiry unanswered questions auto-pick the recommended option " +
99
+ "(omp's settings-driven ask.timeout, parameterized for pi)",
101
100
  }),
102
101
  ),
103
102
  });
@@ -114,812 +113,478 @@ interface AskParams {
114
113
  timeoutSeconds?: number;
115
114
  }
116
115
 
117
- // ---------------------------------------------------------------------------
118
- // Pure helpers (exported for tests)
119
- // ---------------------------------------------------------------------------
120
-
121
- /**
122
- * Coerce untrusted call arguments (partially streamed, model-mangled, or
123
- * double-encoded as a JSON string) into well-formed questions. Returns
124
- * undefined when nothing renderable remains.
125
- */
126
- export function normalizeQuestions(raw: unknown): AskQuestion[] | undefined {
127
- if (typeof raw === "string") {
128
- try {
129
- raw = JSON.parse(raw);
130
- } catch {
131
- return undefined;
132
- }
133
- }
134
- if (!Array.isArray(raw)) return undefined;
135
-
136
- const questions: AskQuestion[] = [];
137
- for (const entry of raw) {
138
- if (!entry || typeof entry !== "object") continue;
139
- const q = entry as Record<string, unknown>;
140
- if (typeof q.id !== "string" || typeof q.question !== "string") continue;
141
- if (!Array.isArray(q.options)) continue;
142
-
143
- const options: AskOption[] = [];
144
- for (const opt of q.options) {
145
- if (typeof opt === "string") {
146
- options.push({ label: opt });
147
- continue;
148
- }
149
- if (!opt || typeof opt !== "object") continue;
150
- const o = opt as Record<string, unknown>;
151
- if (typeof o.label !== "string" || RESERVED_LABELS.has(o.label)) continue;
152
- const option: AskOption = { label: o.label };
153
- if (typeof o.description === "string") option.description = o.description;
154
- if (typeof o.preview === "string") option.preview = o.preview;
155
- options.push(option);
156
- }
157
- if (options.length < 2) continue;
158
-
159
- questions.push({
160
- id: q.id,
161
- question: q.question,
162
- ...(typeof q.header === "string" ? { header: q.header } : {}),
163
- options,
164
- multi: q.multi === true,
165
- recommended:
166
- typeof q.recommended === "number" &&
167
- Number.isInteger(q.recommended) &&
168
- q.recommended >= 0 &&
169
- q.recommended < options.length
170
- ? q.recommended
171
- : undefined,
172
- });
173
- }
174
- return questions.length > 0 ? questions : undefined;
175
- }
176
-
177
- /** Add "(Recommended)" to the label at `index`; other labels pass through. */
178
- export function addRecommendedSuffix(options: AskOption[], index: number | undefined): string[] {
179
- return options.map((option, i) =>
180
- i === index && !option.label.endsWith(RECOMMENDED_SUFFIX) ? option.label + RECOMMENDED_SUFFIX : option.label,
181
- );
182
- }
116
+ const ASK_DESCRIPTION = `Ask user for clarification/input during task execution.
183
117
 
184
- /** Strip a trailing "(Recommended)" marker added for display. */
185
- export function stripRecommendedSuffix(label: string): string {
186
- return label.endsWith(RECOMMENDED_SUFFIX) ? label.slice(0, -RECOMMENDED_SUFFIX.length) : label;
187
- }
188
-
189
- /** Auto-selection for one question on timeout: recommended option, else first. */
190
- export function autoSelectionForQuestion(question: AskQuestion): string[] {
191
- if (question.options.length === 0) return [];
192
- const index =
193
- typeof question.recommended === "number" && question.recommended >= 0 && question.recommended < question.options.length
194
- ? question.recommended
195
- : 0;
196
- return [stripRecommendedSuffix(question.options[index]!.label)];
197
- }
118
+ <conditions>
119
+ - Multiple approaches with significantly different tradeoffs user should weigh.
120
+ </conditions>
198
121
 
199
- /** Prefix a label with its 1-based option number, e.g. `2. OAuth2` (raw label when not found). */
200
- export function numberedLabel(options: readonly string[], label: string): string {
201
- const index = options.indexOf(label);
202
- return index >= 0 ? `${index + 1}. ${label}` : label;
203
- }
122
+ <instruction>
123
+ - \`recommended: <index>\` marks default (0-indexed); " (Recommended)" added automatically.
124
+ - Use \`questions\` for related questions, not one at a time.
125
+ - Set \`multi: true\` on a question to allow multiple selections.
126
+ - Short option labels; explanatory tradeoffs in \`description\`, not labels.
127
+ </instruction>
204
128
 
205
- /** Display form of an answer: `"custom text"`, `2. OAuth2`, `[1. A, 3. C]`, or `(no selection)`. */
206
- export function formatAnswerValue(
207
- options: string[],
208
- multi: boolean,
209
- answer: { selectedOptions: string[]; customInput?: string },
210
- ): string {
211
- if (answer.customInput !== undefined) return `"${answer.customInput}"`;
212
- if (answer.selectedOptions.length === 0) return "(no selection)";
213
- const numbered = answer.selectedOptions.map((label) => numberedLabel(options, label));
214
- return multi ? `[${numbered.join(", ")}]` : numbered[0]!;
215
- }
129
+ <caution>
130
+ - Provide 2-5 concise, distinct options.
131
+ </caution>
216
132
 
217
- /** Human-readable answer line for one question, e.g. `auth_method: 1. JWT`. */
218
- export function formatAnswerLine(result: QuestionResult): string {
219
- let line = `${result.id}: ${formatAnswerValue(result.options, result.multi, result)}`;
220
- if (result.timedOut) line += " (auto-selected after timeout)";
221
- if (result.note !== undefined) line += ` (note: ${result.note})`;
222
- return line;
223
- }
224
-
225
- /** Full text returned to the LLM describing what the user answered. */
226
- export function formatAnswerText(results: QuestionResult[], cancelled = false): string {
227
- const lines = results.map(formatAnswerLine);
228
- const header = results.length === 1 ? "User answer:" : "User answers:";
229
- let text = lines.length > 0 ? `${header}\n${lines.join("\n")}` : "No questions were answered";
230
- if (cancelled) {
231
- text +=
232
- "\n\nThe user cancelled before answering every question. Proceed with what you have or take the least destructive action.";
233
- }
234
- return text;
235
- }
133
+ <critical>
134
+ - Default to action. Resolve ambiguity via repo conventions, existing patterns, reasonable defaults. Exhaust existing sources (code, configs, docs, history) before asking. Ask only when options have materially different tradeoffs the user must decide.
135
+ - If multiple choices acceptable: pick most conservative/standard option; proceed; state choice.
136
+ - Do NOT include "Other"; UI automatically adds "Other (type your own)" to every question.
137
+ </critical>`;
236
138
 
237
139
  // ---------------------------------------------------------------------------
238
- // Multi-question dialog (radio / checkbox list rendered via ctx.ui.custom)
140
+ // Response formatting (omp ask.ts, verbatim)
239
141
  // ---------------------------------------------------------------------------
240
142
 
241
- type AskUi = Pick<ExtensionUIContext, "custom">;
242
-
243
- interface DialogAction {
244
- kind: "submit" | "chat" | "cancel";
245
- timedOut?: boolean;
246
- }
247
-
248
- interface DialogState {
249
- index: number;
250
- cursors: number[]; // per question: highlighted row 0..options.length+1 (Other, Chat)
251
- checked: Array<Set<number>>; // per question (multi mode)
252
- answers: Array<{ selectedOptions: string[]; customInput?: string } | undefined>;
253
- notes: Array<string | undefined>;
254
- }
255
-
256
- function initialDialogState(questions: AskQuestion[]): DialogState {
257
- return {
258
- index: 0,
259
- cursors: questions.map((q) => Math.min(q.recommended ?? 0, q.options.length)),
260
- checked: questions.map(() => new Set<number>()),
261
- answers: questions.map(() => undefined),
262
- notes: questions.map(() => undefined),
263
- };
264
- }
265
-
266
- const MAX_PREVIEW_LINES = 6;
267
-
268
- function createAskDialog(
269
- tui: TUI,
270
- theme: Theme,
271
- questions: AskQuestion[],
272
- state: DialogState,
273
- deadline: number | undefined,
274
- signal: AbortSignal | undefined,
275
- done: (action: DialogAction) => void,
276
- ): { render: (width: number) => string[]; invalidate: () => void; handleInput: (data: string) => boolean; dispose: () => void } {
277
- let cachedLines: string[] | undefined;
278
- let settled = false;
279
- /** Free-text overlay: "other" captures a custom answer, "note" annotates the current one. */
280
- let inputMode: "other" | "note" | null = null;
281
-
282
- const settle = (action: DialogAction): void => {
283
- if (settled) return;
284
- settled = true;
285
- clearTimeout(timerId);
286
- signal?.removeEventListener("abort", onAbort);
287
- done(action);
288
- };
289
-
290
- function onAbort(): void {
291
- settle({ kind: "cancel" });
292
- }
293
- signal?.addEventListener("abort", onAbort, { once: true });
294
-
295
- const remainingMs = deadline === undefined ? undefined : Math.max(0, deadline - Date.now());
296
- const timerId =
297
- remainingMs === undefined ? undefined : setTimeout(() => settle({ kind: "submit", timedOut: true }), remainingMs);
298
- if (typeof timerId === "object" && timerId !== null && "unref" in timerId) timerId.unref();
299
-
300
- const question = () => questions[state.index];
301
- const onSummary = () => state.index === questions.length;
302
- const allAnswered = () => questions.every((_q, i) => state.answers[i] !== undefined);
303
-
304
- // Embedded single-line editor for "Other" answers and notes: the option
305
- // list stays visible while typing and Esc returns to the rows.
306
- const editorTheme: EditorTheme = {
307
- borderColor: (s) => theme.fg("accent", s),
308
- selectList: {
309
- selectedPrefix: (t) => theme.fg("accent", t),
310
- selectedText: (t) => theme.fg("accent", t),
311
- description: (t) => theme.fg("muted", t),
312
- scrollInfo: (t) => theme.fg("dim", t),
313
- noMatch: (t) => theme.fg("warning", t),
314
- },
315
- };
316
- const editor = new Editor(tui, editorTheme);
317
-
318
- editor.onSubmit = (value: string): void => {
319
- if (inputMode === "other") {
320
- if (value.length > 0) {
321
- state.answers[state.index] = { selectedOptions: [], customInput: value };
322
- state.checked[state.index] = new Set();
323
- advanceAfterAnswer();
324
- } else {
325
- exitInputMode(); // empty submit declines the custom input
326
- }
327
- } else if (inputMode === "note") {
328
- state.notes[state.index] = value.length > 0 ? value : undefined;
329
- exitInputMode();
330
- }
331
- };
332
-
333
- function enterInputMode(mode: "other" | "note"): void {
334
- inputMode = mode;
335
- const prefill = mode === "note" ? state.notes[state.index] : state.answers[state.index]?.customInput;
336
- editor.setText(prefill ?? "");
337
- cachedLines = undefined;
143
+ function formatQuestionResult(result: QuestionResult): string {
144
+ if (result.customInput !== undefined) {
145
+ return `${result.id}: "${result.customInput}"`;
338
146
  }
339
-
340
- function exitInputMode(): void {
341
- inputMode = null;
342
- editor.setText("");
343
- cachedLines = undefined;
147
+ if (result.selectedOptions.length > 0) {
148
+ const suffix = result.timedOut ? " (auto-selected after timeout)" : "";
149
+ return result.multi
150
+ ? `${result.id}: [${result.selectedOptions.join(", ")}]${suffix}`
151
+ : `${result.id}: ${result.selectedOptions[0]}${suffix}`;
344
152
  }
153
+ return result.multi ? `${result.id}: []` : `${result.id}: (cancelled)`;
154
+ }
345
155
 
346
- function move(delta: number): void {
347
- if (onSummary()) return;
348
- const rows = question().options.length + 2; // + Other + Chat
349
- state.cursors[state.index] = Math.min(Math.max(state.cursors[state.index]! + delta, 0), rows - 1);
350
- cachedLines = undefined;
156
+ function formatSingleQuestionResponse(result: {
157
+ selectedOptions: string[];
158
+ customInput?: string;
159
+ timedOut?: boolean;
160
+ multi: boolean;
161
+ }): string {
162
+ const responseParts: string[] = [];
163
+ if (result.selectedOptions.length > 0) {
164
+ const selectedText = result.multi
165
+ ? `User selected: ${result.selectedOptions.join(", ")}`
166
+ : `User selected: ${result.selectedOptions[0]}`;
167
+ responseParts.push(result.timedOut ? `${selectedText} (auto-selected after timeout)` : selectedText);
351
168
  }
352
-
353
- function toggle(): void {
354
- const q = question();
355
- const cursor = state.cursors[state.index]!;
356
- if (cursor < q.options.length) {
357
- const checked = state.checked[state.index]!;
358
- if (checked.has(cursor)) checked.delete(cursor);
359
- else checked.add(cursor);
360
- cachedLines = undefined;
361
- }
169
+ if (result.customInput !== undefined) {
170
+ responseParts.push(
171
+ result.customInput.includes("\n")
172
+ ? `User provided custom input:\n${result.customInput
173
+ .split("\n")
174
+ .map(line => ` ${line}`)
175
+ .join("\n")}`
176
+ : `User provided custom input: ${result.customInput}`,
177
+ );
362
178
  }
179
+ if (responseParts.length > 0) return responseParts.join("\n");
180
+ return result.multi ? "User did not select any options" : "User cancelled the selection";
181
+ }
363
182
 
364
- function recordCurrentAnswer(): void {
365
- const q = question();
366
- const cursor = state.cursors[state.index]!;
367
- if (cursor === q.options.length) return; // handled by caller ("other")
368
- if (cursor === q.options.length + 1) return; // handled by caller ("chat")
369
- if (!q.multi) {
370
- const label = stripRecommendedSuffix(addRecommendedSuffix(q.options, q.recommended)[cursor]!);
371
- // Re-selecting an option replaces any previous custom input (omp semantics).
372
- state.answers[state.index] = { selectedOptions: [label] };
373
- } else {
374
- const labels = [...state.checked[state.index]!].sort((a, b) => a - b).map((i) => q.options[i]?.label ?? "");
375
- state.answers[state.index] = { selectedOptions: labels };
376
- }
377
- }
183
+ // ---------------------------------------------------------------------------
184
+ // Transcript rendering
185
+ // ---------------------------------------------------------------------------
378
186
 
379
- /**
380
- * Advance after an answer. Single-question dialogs submit immediately;
381
- * multi-question dialogs jump to the next unanswered question, or the
382
- * review page once every question has an answer (revising an earlier
383
- * answer skips the already-answered ones instead of re-walking them).
384
- */
385
- function advanceAfterAnswer(): void {
386
- exitInputMode();
387
- if (questions.length === 1) {
388
- settle({ kind: "submit" });
389
- return;
390
- }
391
- const next = questions.findIndex((_q, i) => state.answers[i] === undefined);
392
- state.index = next === -1 ? questions.length : next; // -1 → review page
393
- cachedLines = undefined;
394
- }
187
+ /** Flatten a question/custom input into one plain-text line for the compact
188
+ * result row. The dialog already showed the full options list; the transcript
189
+ * only needs the asked question and the given answer. */
190
+ function flattenToOneLine(text: string): string {
191
+ return replaceTabs(text).replace(/\s+/g, " ").trim();
192
+ }
395
193
 
396
- return {
397
- render(width: number): string[] {
398
- if (cachedLines) return cachedLines;
399
- const q = onSummary() ? undefined : question();
400
- const lines: string[] = [];
401
- const renderWidth = Math.max(1, width);
402
-
403
- const wrapWithPrefix = (prefix: string, text: string) => {
404
- const prefixWidth = visibleWidth(prefix);
405
- const wrapped = wrapTextWithAnsi(text, Math.max(1, renderWidth - prefixWidth));
406
- const continuationPrefix = " ".repeat(Math.min(prefixWidth, renderWidth));
407
- for (let i = 0; i < wrapped.length; i++) {
408
- lines.push(i === 0 ? prefix + wrapped[i] : continuationPrefix + wrapped[i]);
409
- }
410
- };
194
+ // ---------------------------------------------------------------------------
195
+ // Extension entry
196
+ // ---------------------------------------------------------------------------
411
197
 
412
- lines.push(theme.fg("accent", "-".repeat(renderWidth)));
198
+ export default function ompAskExtension(pi: ExtensionAPI): void {
199
+ pi.registerTool({
200
+ name: "ask",
201
+ label: "Ask",
202
+ description: ASK_DESCRIPTION,
203
+ promptSnippet: "Ask the user a clarifying question",
204
+ promptGuidelines: [
205
+ "Use ask only after exhausting repo conventions, configs, and docs; reserve it for decisions whose options have materially different tradeoffs.",
206
+ ],
207
+ parameters: AskParamsSchema,
208
+ executionMode: "sequential",
413
209
 
414
- // Per-question status strip (multi-question dialogs only).
415
- if (questions.length > 1) {
416
- const chips = questions.map((question_, i) => {
417
- const answered = state.answers[i] !== undefined;
418
- const marker = theme.fg(answered ? "success" : "dim", answered ? "●" : "○");
419
- const label = question_.header ?? `Q${i + 1}`;
420
- const color = i === state.index ? "text" : answered ? "muted" : "dim";
421
- return `${marker} ${theme.fg(color, label)}`;
422
- });
423
- wrapWithPrefix(" ", chips.join(" "));
210
+ async execute(_toolCallId, params: AskParams, signal, _onUpdate, ctx) {
211
+ // Headless fallback (omp: createIf gate + ToolAbortError). pi
212
+ // extension edition: the tool should already have been stripped at
213
+ // session_start; if it still runs, tell the model the question was
214
+ // never displayed instead of letting it read a cancel as an answer.
215
+ if (!ctx.hasUI || !ctx.ui) {
216
+ ctx.abort();
217
+ throw new Error(
218
+ "ask: this session has no interactive UI host, so the question was never shown to the user. " +
219
+ "Do not call ask again in this session; proceed with the safest default or state questions in plain text.",
220
+ );
424
221
  }
425
222
 
426
- if (!q) {
427
- // Review page: summarize every answer, confirm on Enter.
428
- const answeredCount = questions.filter((_question, i) => state.answers[i] !== undefined).length;
429
- wrapWithPrefix(
430
- " ",
431
- `${theme.fg("accent", "Review")} ${theme.fg("muted", `(${answeredCount}/${questions.length} answered)`)}`,
432
- );
433
- lines.push("");
434
- questions.forEach((question_, i) => {
435
- const answer = state.answers[i];
436
- const label = question_.header ?? question_.question;
437
- if (!answer) {
438
- wrapWithPrefix(" ", `${theme.fg("dim", "○")} ${theme.fg("warning", `${label}: (unanswered)`)}`);
439
- return;
440
- }
441
- const value = formatAnswerValue(
442
- question_.options.map((option) => option.label),
443
- question_.multi,
444
- answer,
223
+ // Reserved-label narrow (omp schema narrow, executed eagerly here).
224
+ for (const q of params.questions) {
225
+ const reserved = q.options.find(option => RESERVED_OPTION_LABELS[option.label] === true);
226
+ if (reserved) {
227
+ throw new Error(
228
+ `ask: question "${q.id}" uses reserved runtime label "${reserved.label}"; ` +
229
+ `do not include "Other" — the UI adds it automatically`,
445
230
  );
446
- wrapWithPrefix(" ", `${theme.fg("success", "●")} ${theme.fg("muted", `${label}: `)}${theme.fg("accent", value)}`);
447
- if (state.notes[i] !== undefined) {
448
- wrapWithPrefix(" ", theme.fg("dim", `note: ${state.notes[i]}`));
449
- }
450
- });
451
- lines.push("");
452
- if (allAnswered()) {
453
- wrapWithPrefix(" ", theme.fg("success", "Press Enter to submit"));
454
- wrapWithPrefix(" ", theme.fg("dim", "left revise - esc cancel"));
455
- } else {
456
- const missing = questions
457
- .filter((_question, i) => state.answers[i] === undefined)
458
- .map((question_) => question_.header ?? question_.question)
459
- .join(", ");
460
- wrapWithPrefix(" ", theme.fg("warning", `Unanswered: ${missing}`));
461
- }
462
- } else {
463
- const progress = `[${state.index + 1}/${questions.length}]`;
464
- const headerChip = q.header ? ` ${theme.fg("muted", q.header)}` : "";
465
- const noteMark = state.notes[state.index] !== undefined ? theme.fg("dim", " · n") : "";
466
- wrapWithPrefix(" ", `${theme.fg("accent", progress)}${headerChip}${noteMark} ${theme.fg("text", q.question)}`);
467
- lines.push("");
468
-
469
- const labels = addRecommendedSuffix(q.options, q.multi ? undefined : q.recommended);
470
- q.options.forEach((option, i) => {
471
- const cursorHere = state.cursors[state.index] === i;
472
- const checked = state.checked[state.index]!.has(i);
473
- const marker = q.multi
474
- ? checked
475
- ? theme.fg("success", "[x]")
476
- : theme.fg("dim", "[ ]")
477
- : checked
478
- ? theme.fg("success", "(o)")
479
- : theme.fg("dim", "( )");
480
- const cursorPrefix = cursorHere ? theme.fg("accent", "> ") : " ";
481
- wrapWithPrefix(cursorPrefix, `${marker} ${theme.fg(checked ? "accent" : "text", `${i + 1}. ${labels[i]}`)}`);
482
- if (option.description) wrapWithPrefix(" ", theme.fg("muted", option.description));
483
- if (cursorHere && option.preview) {
484
- for (const previewLine of option.preview.split("\n").slice(0, MAX_PREVIEW_LINES)) {
485
- wrapWithPrefix(" ", theme.fg("dim", previewLine));
486
- }
487
- }
488
- });
489
-
490
- lines.push("");
491
- const cursor = state.cursors[state.index]!;
492
- const otherCursor = cursor === q.options.length;
493
- const chatCursor = cursor === q.options.length + 1;
494
- const otherLabel = inputMode === "other" ? `${OTHER_OPTION} ✎` : OTHER_OPTION;
495
- wrapWithPrefix(
496
- otherCursor ? theme.fg("accent", "> ") : " ",
497
- theme.fg(otherCursor ? "accent" : "text", otherLabel),
498
- );
499
- wrapWithPrefix(
500
- chatCursor ? theme.fg("accent", "> ") : " ",
501
- theme.fg(chatCursor ? "accent" : "text", CHAT_OPTION),
502
- );
503
-
504
- if (inputMode !== null) {
505
- lines.push("");
506
- wrapWithPrefix(" ", theme.fg("muted", inputMode === "other" ? "Your answer:" : "Note:"));
507
- for (const line of editor.render(Math.max(1, renderWidth - 2))) {
508
- lines.push(` ${line}`);
509
- }
510
- lines.push("");
511
- wrapWithPrefix(" ", theme.fg("dim", "enter submit - esc back"));
512
- } else {
513
- lines.push("");
514
- const hints = q.multi
515
- ? questions.length > 1
516
- ? "space toggle - enter next - left/right/tab question - n note - esc cancel"
517
- : "space toggle - enter submit - n note - esc cancel"
518
- : questions.length > 1
519
- ? "enter select - left/right/tab question - n note - esc cancel"
520
- : "enter select - n note - esc cancel";
521
- wrapWithPrefix(" ", theme.fg("dim", hints));
522
231
  }
523
232
  }
524
233
 
525
- lines.push(theme.fg("accent", "-".repeat(renderWidth)));
234
+ const extensionUi = ctx.ui;
235
+ const ui: UIContext = uiContextFromExtension(extensionUi);
526
236
 
527
- cachedLines = lines;
528
- return lines;
529
- },
237
+ // omp derives the timeout from settings (ask.timeout) and disables
238
+ // it in plan mode; pi extensions cannot read settings, so the
239
+ // budget arrives as a tool parameter.
240
+ const timeout =
241
+ typeof params.timeoutSeconds === "number" && params.timeoutSeconds > 0
242
+ ? params.timeoutSeconds * 1000
243
+ : null;
530
244
 
531
- invalidate(): void {
532
- cachedLines = undefined;
533
- },
245
+ // omp ask.notify → terminal bell (pi extensions cannot send desktop
246
+ // notifications or read settings). omp's setting is opt-out-able, so
247
+ // the bell stays configurable via PI_OMK_ASK_NOTIFY=0.
248
+ if (process.env.PI_OMK_ASK_NOTIFY !== "0") process.stdout.write("\x07");
534
249
 
535
- handleInput(data: string): boolean {
536
- // Free-text mode: everything except Esc routes to the embedded editor.
537
- if (inputMode !== null) {
538
- if (matchesKey(data, Key.escape)) {
539
- exitInputMode();
540
- return true;
541
- }
542
- editor.handleInput(data);
543
- cachedLines = undefined;
544
- return true;
250
+ if (params.questions.length === 0) {
251
+ return {
252
+ content: [{ type: "text" as const, text: "Error: questions must not be empty" }],
253
+ details: {},
254
+ };
545
255
  }
546
256
 
547
- if (matchesKey(data, Key.up)) {
548
- move(-1);
549
- return true;
550
- }
551
- if (matchesKey(data, Key.down)) {
552
- move(1);
553
- return true;
554
- }
555
- if (matchesKey(data, Key.escape)) {
556
- settle({ kind: "cancel" });
557
- return true;
558
- }
257
+ const richQuestions: ExtensionAskDialogQuestion[] = params.questions.map(q => ({
258
+ id: q.id,
259
+ question: q.question,
260
+ ...(q.header?.trim() ? { header: q.header } : {}),
261
+ options: q.options.map(option => ({
262
+ label: option.label,
263
+ ...(option.description?.trim() ? { description: option.description.trim() } : {}),
264
+ ...(option.preview?.trim() ? { preview: option.preview } : {}),
265
+ })),
266
+ ...(q.multi !== undefined ? { multi: q.multi } : {}),
267
+ ...(q.recommended !== undefined ? { recommended: q.recommended } : {}),
268
+ }));
559
269
 
560
- // Question navigation: arrows plus tab/shift+tab aliases. Forward
561
- // moves require an answer for the current question; the review
562
- // page is the last stop.
563
- if (matchesKey(data, Key.left) || matchesKey(data, Key.shift("tab"))) {
564
- if (state.index > 0) {
565
- state.index -= 1;
566
- cachedLines = undefined;
567
- }
568
- return true;
569
- }
570
- if (matchesKey(data, Key.right) || matchesKey(data, Key.tab)) {
571
- if (
572
- questions.length > 1 &&
573
- state.index < questions.length &&
574
- state.answers[state.index] !== undefined
575
- ) {
576
- state.index += 1; // may land on the review page
577
- cachedLines = undefined;
270
+ // Rich dialog path (omp ExtensionUIContext.askDialog → pi ui.custom).
271
+ if (ctx.mode === "tui") {
272
+ let richResult: ExtensionAskDialogResult | undefined;
273
+ try {
274
+ const showRichDialog = () =>
275
+ extensionUi.custom<ExtensionAskDialogResult | undefined>((tui, theme, keybindings, done) => {
276
+ const dialog = new AskDialogComponent(
277
+ theme,
278
+ keybindings,
279
+ richQuestions,
280
+ {
281
+ onSubmit: result => done(result),
282
+ onCancel: () => done(undefined),
283
+ },
284
+ { timeout: timeout ?? undefined, tui: { requestRender: () => tui.requestRender() } },
285
+ );
286
+ // pi's custom() has no signal option; mirror omp's
287
+ // #presentDialog(signal) by cancelling the component
288
+ // when the agent turn is aborted.
289
+ signal?.addEventListener(
290
+ "abort",
291
+ () => {
292
+ dialog.externalCancel();
293
+ },
294
+ { once: true },
295
+ );
296
+ return dialog;
297
+ });
298
+ richResult = signal ? await raceWithSignal(signal, showRichDialog) : await showRichDialog();
299
+ } catch (error) {
300
+ if (error instanceof Error && error.name === "AbortError") {
301
+ ctx.abort();
302
+ throw new Error("Ask input was cancelled");
303
+ }
304
+ throw error;
578
305
  }
579
- return true;
580
- }
581
306
 
582
- // Review page: Enter submits once everything is answered.
583
- if (onSummary()) {
584
- if (matchesKey(data, Key.return) && allAnswered()) {
585
- settle({ kind: "submit" });
307
+ if (!richResult) {
308
+ // omp: cancel aborts the turn (ToolAbortError).
309
+ ctx.abort();
310
+ throw new Error("Ask tool was cancelled by the user");
586
311
  }
587
- return true;
588
- }
589
-
590
- if (data === "n" || data === "N") {
591
- enterInputMode("note");
592
- return true;
593
- }
594
-
595
- if (matchesKey(data, Key.return)) {
596
- const q = question();
597
- const cursor = state.cursors[state.index]!;
598
- if (cursor === q.options.length) {
599
- enterInputMode("other");
600
- return true;
312
+ if (richResult.results.length !== params.questions.length) {
313
+ throw new Error("Ask dialog returned a result count that does not match the requested questions");
601
314
  }
602
- if (cursor === q.options.length + 1) {
603
- settle({ kind: "chat" });
604
- return true;
315
+ const results: QuestionResult[] = [];
316
+ for (let index = 0; index < params.questions.length; index++) {
317
+ const question = params.questions[index];
318
+ const result = richResult.results[index];
319
+ if (!question || !result || result.id !== question.id) {
320
+ throw new Error("Ask dialog returned results that do not match the requested question order");
321
+ }
322
+ results.push({
323
+ id: question.id,
324
+ question: question.question,
325
+ options: question.options.map(option => option.label),
326
+ multi: question.multi ?? false,
327
+ selectedOptions: result.selectedOptions,
328
+ customInput: result.customInput,
329
+ timedOut: result.timedOut,
330
+ });
605
331
  }
606
- if (q.multi) {
607
- // Enter records the current checkbox set as-is; space does the toggling.
608
- // An empty set is not an answer: enter stays put instead of marking
609
- // the question answered with a misleading "(no selection)".
610
- if (state.checked[state.index]!.size === 0) return true;
611
- } else {
612
- // Single mode: exactly one marker — selecting a row replaces the
613
- // previous one instead of stacking another (o).
614
- state.checked[state.index] = new Set([cursor]);
332
+ if (params.questions.length === 1) {
333
+ const result = results[0];
334
+ // An empty multi-select submission is a valid "select none"
335
+ // answer (#8265 review); only a truly empty single-select
336
+ // result counts as cancellation.
337
+ if (
338
+ !result ||
339
+ (!result.timedOut &&
340
+ !result.multi &&
341
+ result.selectedOptions.length === 0 &&
342
+ result.customInput === undefined)
343
+ ) {
344
+ ctx.abort();
345
+ throw new Error("Ask tool was cancelled by the user");
346
+ }
347
+ const details: AskToolDetails = {
348
+ question: result.question,
349
+ options: result.options,
350
+ multi: result.multi,
351
+ selectedOptions: result.selectedOptions,
352
+ customInput: result.customInput,
353
+ timedOut: result.timedOut,
354
+ };
355
+ const responseText = formatSingleQuestionResponse(result);
356
+ return { content: [{ type: "text" as const, text: responseText }], details };
615
357
  }
616
- recordCurrentAnswer();
617
- advanceAfterAnswer();
618
- return true;
619
- }
620
-
621
- if (matchesKey(data, Key.space)) {
622
- // Toggling is multi-select only: in single mode space is a no-op so
623
- // one question can never accumulate several (o) markers.
624
- if (!onSummary() && question().multi) toggle();
625
- return true;
358
+ const details: AskToolDetails = { results };
359
+ const responseText = `User answers:\n${results.map(formatQuestionResult).join("\n")}`;
360
+ return { content: [{ type: "text" as const, text: responseText }], details };
626
361
  }
627
- return false;
628
- },
629
-
630
- dispose(): void {
631
- settled = true;
632
- clearTimeout(timerId);
633
- signal?.removeEventListener("abort", onAbort);
634
- },
635
- };
636
- }
637
362
 
638
- interface DialogOutcome {
639
- kind: "submit" | "chat" | "cancel";
640
- timedOut: boolean;
641
- state: DialogState;
642
- }
363
+ // Legacy per-question path (omp ask.ts, verbatim flow).
364
+ const askQuestion = async (
365
+ q: AskParams["questions"][number],
366
+ options?: { previous?: QuestionResult; navigation?: NavigationControls },
367
+ ) => {
368
+ const questionOptions: AskOption[] = q.options.map(option => ({
369
+ label: option.label,
370
+ ...(option.description?.trim() ? { description: option.description.trim() } : {}),
371
+ }));
372
+ const optionLabels = questionOptions.map(option => option.label);
373
+ try {
374
+ const { selectedOptions, customInput, navigation, cancelled, timedOut } = await askSingleQuestion(
375
+ ui,
376
+ q.question,
377
+ questionOptions,
378
+ q.multi ?? false,
379
+ {
380
+ recommended: q.recommended,
381
+ timeout: timeout ?? undefined,
382
+ signal,
383
+ initialSelection: options?.previous,
384
+ navigation: options?.navigation,
385
+ },
386
+ );
387
+ return { optionLabels, selectedOptions, customInput, navigation, cancelled, timedOut };
388
+ } catch (error) {
389
+ if (error instanceof Error && error.name === "AbortError") {
390
+ throw new Error("Ask input was cancelled");
391
+ }
392
+ throw error;
393
+ }
394
+ };
643
395
 
644
- /**
645
- * Run the multi-question dialog to completion. "Other"/"note" free text is
646
- * captured by an editor embedded in the dialog itself, so the component keeps
647
- * keyboard focus and its navigation state throughout.
648
- */
649
- async function runAskDialog(
650
- ui: AskUi,
651
- questions: AskQuestion[],
652
- options_: { deadline?: number; signal?: AbortSignal },
653
- ): Promise<DialogOutcome> {
654
- const state = initialDialogState(questions);
655
- const action = await ui.custom<DialogAction>((tui, theme, _kb, done) =>
656
- createAskDialog(tui, theme, questions, state, options_.deadline, options_.signal, done),
657
- );
658
- return { kind: action.kind, timedOut: action.timedOut === true, state };
659
- }
396
+ if (params.questions.length === 1) {
397
+ const [q] = params.questions;
398
+ const { optionLabels, selectedOptions, customInput, cancelled, timedOut } = await askQuestion(q);
660
399
 
661
- function buildResults(questions: AskQuestion[], state: DialogState, timedOut: boolean): QuestionResult[] {
662
- return questions.map((q, i) => {
663
- const answer = state.answers[i];
664
- const autoPicked = timedOut && answer === undefined;
665
- const selected = autoPicked ? autoSelectionForQuestion(q) : (answer?.selectedOptions ?? []);
666
- return {
667
- id: q.id,
668
- question: q.question,
669
- options: q.options.map((option) => option.label),
670
- multi: q.multi,
671
- selectedOptions: selected,
672
- ...(answer?.customInput !== undefined ? { customInput: answer.customInput } : {}),
673
- ...(state.notes[i] !== undefined ? { note: state.notes[i] } : {}),
674
- ...(autoPicked ? { timedOut: true } : {}),
675
- };
676
- });
677
- }
678
-
679
- // ---------------------------------------------------------------------------
680
- // Tool registration
681
- // ---------------------------------------------------------------------------
400
+ if (!timedOut && (cancelled || (selectedOptions.length === 0 && customInput === undefined))) {
401
+ ctx.abort();
402
+ throw new Error("Ask tool was cancelled by the user");
403
+ }
404
+ const details: AskToolDetails = {
405
+ question: q.question,
406
+ options: optionLabels,
407
+ multi: q.multi ?? false,
408
+ selectedOptions,
409
+ customInput,
410
+ timedOut: timedOut || undefined,
411
+ };
682
412
 
683
- const askUserTool: ToolDefinition<typeof AskParamsSchema, AskUserDetails> = {
684
- name: "ask_user",
685
- label: "Ask User",
686
- description:
687
- "Ask the user one or more clarifying questions with selectable options. " +
688
- "Use when choices have materially different tradeoffs the user must decide. " +
689
- "The user can always provide free-form input via an automatic 'Other' option.",
690
- promptSnippet: "Ask the user structured questions with options during execution",
691
- promptGuidelines: [
692
- "Use ask_user only after exhausting repo conventions, configs, and docs; reserve it for decisions whose options have materially different tradeoffs.",
693
- ],
694
- parameters: AskParamsSchema,
695
- executionMode: "sequential",
413
+ const responseText = formatSingleQuestionResponse({
414
+ selectedOptions,
415
+ customInput,
416
+ timedOut: timedOut || undefined,
417
+ multi: q.multi ?? false,
418
+ });
696
419
 
697
- async execute(_toolCallId, params: AskParams, signal, _onUpdate, ctx) {
698
- if (!ctx.hasUI) {
699
- throw new Error("ask_user requires an interactive session (TUI or RPC mode)");
420
+ return { content: [{ type: "text" as const, text: responseText }], details };
700
421
  }
701
422
 
702
- // Reserved-label collision check (mirrors omp's schema narrow).
703
- for (const q of params.questions) {
704
- const clash = q.options.find((option) => RESERVED_LABELS.has(option.label ?? ""));
705
- if (clash) {
706
- throw new Error(
707
- `ask_user: option label "${clash.label}" in question "${q.id}" collides with a reserved runtime label`,
708
- );
423
+ const resultsByIndex: Array<QuestionResult | undefined> = Array.from({ length: params.questions.length });
424
+ let questionIndex = 0;
425
+ while (questionIndex < params.questions.length) {
426
+ const q = params.questions[questionIndex];
427
+ if (!q) throw new Error("Ask question index exceeded the requested question list");
428
+ const previous = resultsByIndex[questionIndex];
429
+ const navigation: NavigationControls = {
430
+ allowBack: questionIndex > 0,
431
+ allowForward: true,
432
+ progressText: `${questionIndex + 1}/${params.questions.length}`,
433
+ };
434
+ const {
435
+ optionLabels,
436
+ selectedOptions,
437
+ customInput,
438
+ navigation: navAction,
439
+ cancelled,
440
+ timedOut,
441
+ } = await askQuestion(q, { previous, navigation });
442
+
443
+ if (cancelled && !timedOut) {
444
+ ctx.abort();
445
+ throw new Error("Ask tool was cancelled by the user");
709
446
  }
710
- }
711
447
 
712
- const questions: AskQuestion[] = params.questions.map((q) => ({
713
- id: q.id,
714
- question: q.question,
715
- ...(typeof q.header === "string" ? { header: q.header } : {}),
716
- options: q.options.map((option) => ({
717
- label: option.label,
718
- ...(typeof option.description === "string" ? { description: option.description } : {}),
719
- ...(typeof option.preview === "string" ? { preview: option.preview } : {}),
720
- })),
721
- multi: q.multi === true,
722
- recommended: typeof q.recommended === "number" ? q.recommended : undefined,
723
- }));
724
-
725
- const deadline =
726
- typeof params.timeoutSeconds === "number" && params.timeoutSeconds > 0 ? Date.now() + params.timeoutSeconds * 1000 : undefined;
448
+ resultsByIndex[questionIndex] = {
449
+ id: q.id,
450
+ question: q.question,
451
+ options: optionLabels,
452
+ multi: q.multi ?? false,
453
+ selectedOptions,
454
+ customInput,
455
+ timedOut: timedOut || undefined,
456
+ };
727
457
 
728
- const outcome = await runAskDialog(ctx.ui, questions, { deadline, signal });
458
+ if (navAction === "back") {
459
+ questionIndex = Math.max(0, questionIndex - 1);
460
+ continue;
461
+ }
729
462
 
730
- if (outcome.kind === "chat") {
731
- const questionTexts = questions.map((q) => q.question).join("\n");
732
- return {
733
- content: [
734
- {
735
- type: "text",
736
- text: `User chose to chat about this instead of answering.\n\nQuestions asked:\n${questionTexts}`,
737
- },
738
- ],
739
- details: { chatRedirect: true, questions: questions.map((q) => q.question), results: [] } satisfies AskUserDetails,
740
- };
463
+ questionIndex += 1;
741
464
  }
742
465
 
743
- if (outcome.kind === "cancel") {
744
- const details: AskUserDetails = {
745
- results: [],
746
- questions: questions.map((q) => q.question),
747
- cancelled: true,
748
- };
466
+ const results = params.questions.map((q, index) => {
467
+ const result = resultsByIndex[index];
468
+ if (result) return result;
749
469
  return {
750
- content: [{ type: "text", text: formatAnswerText([], true) }],
751
- details,
470
+ id: q.id,
471
+ question: q.question,
472
+ options: q.options.map(o => o.label),
473
+ multi: q.multi ?? false,
474
+ selectedOptions: [],
752
475
  };
753
- }
476
+ });
754
477
 
755
- const results = buildResults(questions, outcome.state, outcome.timedOut);
756
- const details: AskUserDetails = { results, questions: questions.map((q) => q.question) };
757
- return {
758
- content: [{ type: "text", text: formatAnswerText(results, false) }],
759
- details,
760
- };
478
+ const details: AskToolDetails = { results };
479
+ const responseLines = results.map(formatQuestionResult);
480
+ const responseText = `User answers:\n${responseLines.join("\n")}`;
481
+
482
+ return { content: [{ type: "text" as const, text: responseText }], details };
761
483
  },
762
484
 
763
- renderCall(args, theme) {
764
- const questions = normalizeQuestions(args?.questions);
765
- if (!questions) {
766
- return new Text(theme.fg("toolTitle", theme.bold("ask_user")), 0, 0);
767
- }
768
- const lines = questions.map((q) => {
769
- const mode = q.multi ? "multi" : "single";
770
- const header = q.header ? ` ${theme.fg("muted", q.header)}` : "";
771
- return `${theme.fg("dim", `[${q.id}]`)}${header} ${theme.fg("text", q.question)} ${theme.fg("muted", `(${mode}, ${q.options.length} options)`)}`;
772
- });
773
- return new Text(`${theme.fg("toolTitle", theme.bold("ask_user"))}\n${lines.join("\n")}`, 0, 0);
485
+ renderCall(args: { questions?: unknown }, theme) {
486
+ // omp updates the framed question block in place once the user
487
+ // answers; pi's ToolExecutionComponent appends the result render
488
+ // below the call render, so a full question block here would leave
489
+ // the transcript with every question shown twice. Render only a
490
+ // tense-neutral summary line (it also appears in HTML export above
491
+ // the result block); the merged question/options/answer block
492
+ // renders once from renderResult after the dialog settles.
493
+ const count = Array.isArray(args.questions) ? args.questions.length : 0;
494
+ const summary = count > 0 ? `Ask · ${count} ${count === 1 ? "question" : "questions"}` : "Ask";
495
+ return new Text(theme.fg("dim", summary), 0, 0);
774
496
  },
775
497
 
776
498
  renderResult(result, _options, theme) {
777
- const details = result.details as AskUserDetails | undefined;
778
-
779
- if (details?.chatRedirect) {
780
- const lines = (details.questions ?? []).map((q) => theme.fg("dim", q));
781
- return new Text(
782
- `${theme.fg("warning", "~")} ${theme.fg("muted", "chat redirect")}\n${lines.map((l) => ` ${l}`).join("\n")}`,
783
- 0,
784
- 0,
499
+ const details = result.details as AskToolDetails | undefined;
500
+
501
+ if (!details) {
502
+ const txt = result.content[0];
503
+ const fallback = txt?.type === "text" && txt.text ? txt.text : "";
504
+ return new Text(`${theme.fg("warning", "~ Ask")}${fallback ? `\n${theme.fg("dim", fallback)}` : ""}`, 0, 0);
505
+ }
506
+
507
+ // One compact line per question: the asked question and the given
508
+ // answer. The dialog already showed the full options list, so the
509
+ // transcript does not re-list unselected options.
510
+ const answerLine = (
511
+ label: string,
512
+ selectedOptions: string[] | undefined,
513
+ customInput: string | undefined,
514
+ timedOut: boolean | undefined,
515
+ ): string => {
516
+ const parts: string[] = [];
517
+ if (selectedOptions && selectedOptions.length > 0) parts.push(selectedOptions.join(", "));
518
+ if (customInput !== undefined) parts.push(`“${flattenToOneLine(customInput)}”`);
519
+ const answer =
520
+ parts.length > 0
521
+ ? theme.fg("toolOutput", parts.join(" · "))
522
+ : theme.fg("warning", "(no answer)");
523
+ // Distinguish auto-selection from a real user choice.
524
+ const timeoutMark = timedOut ? theme.fg("dim", " · auto-selected after timeout") : "";
525
+ return `${theme.fg("muted", label)}${theme.fg("dim", " → ")}${answer}${timeoutMark}`;
526
+ };
527
+
528
+ if (details.results && details.results.length > 0) {
529
+ const lines = details.results.map(r =>
530
+ answerLine(`[${r.id}] ${flattenToOneLine(r.question)}`, r.selectedOptions, r.customInput, r.timedOut),
785
531
  );
532
+ return new Text(lines.join("\n"), 0, 0);
786
533
  }
787
534
 
788
- const results = details?.results;
789
- if (!results || results.length === 0) {
790
- return new Text(theme.fg("warning", "Cancelled"), 0, 0);
535
+ if (!details.question) {
536
+ const txt = result.content[0];
537
+ const fallback = txt?.type === "text" && txt.text ? txt.text : "";
538
+ return new Text(fallback, 0, 0);
791
539
  }
792
- const lines = results.map((r) => formatAnswerLine(r));
793
- const status = details?.cancelled ? theme.fg("warning", "~") : theme.fg("success", "+");
540
+
794
541
  return new Text(
795
- `${status} ${theme.fg("muted", "answers:")}\n${lines.map((l) => ` ${theme.fg("accent", l)}`).join("\n")}`,
542
+ answerLine(flattenToOneLine(details.question), details.selectedOptions, details.customInput, details.timedOut),
796
543
  0,
797
544
  0,
798
545
  );
799
546
  },
800
- };
801
-
802
- // ---------------------------------------------------------------------------
803
- // /ask-demo: interactive battery covering every question type and path
804
- // ---------------------------------------------------------------------------
805
-
806
- const DEMO_TYPES: AskParams = {
807
- questions: [
808
- {
809
- id: "framework",
810
- question: "Which frontend framework should the demo project use?",
811
- header: "Framework",
812
- options: [
813
- {
814
- label: "React",
815
- description: "Largest ecosystem, most hiring pool",
816
- preview: "- Virtual DOM, hooks\n- Huge component marketplaces\n- Heavier bundle baseline",
817
- },
818
- { label: "Vue", description: "Gentle learning curve, SFC single-file components" },
819
- { label: "Svelte", description: "Compile-time reactivity, smallest bundles" },
820
- ],
821
- recommended: 0,
822
- },
823
- {
824
- id: "features",
825
- question: "Which features should be enabled out of the box?",
826
- header: "Features",
827
- options: [
828
- { label: "Telemetry", description: "Anonymous usage reporting" },
829
- { label: "Auto-update", description: "Background self-update checks" },
830
- { label: "Offline cache", description: "Service-worker asset caching" },
831
- ],
832
- multi: true,
833
- },
834
- {
835
- id: "style",
836
- question: "Which language for generated code? (try 'Other' + a note here)",
837
- header: "Style",
838
- options: [
839
- { label: "TypeScript", description: "Strict types, better tooling" },
840
- { label: "JavaScript", description: "Zero build setup for simple scripts" },
841
- ],
842
- },
843
- ],
844
- };
845
-
846
- const DEMO_TIMEOUT: AskParams = {
847
- questions: [
848
- {
849
- id: "deploy_target",
850
- question: "Where should CI deploy? (do nothing, let it time out)",
851
- header: "Deploy",
852
- options: [
853
- { label: "Staging", description: "Safe default ring" },
854
- { label: "Production", description: "Straight to live traffic" },
855
- ],
856
- recommended: 0,
857
- },
858
- ],
859
- timeoutSeconds: 6,
860
- };
861
-
862
- const DEMO_CHAT: AskParams = {
863
- questions: [
864
- {
865
- id: "migration",
866
- question: "Rewrite the legacy module now or plan first? (pick 'Chat about this')",
867
- header: "Migration",
868
- options: [
869
- { label: "Rewrite now", description: "Fast but risky on shared code" },
870
- { label: "Plan first", description: "Slower start, safer rollout" },
871
- ],
872
- },
873
- ],
874
- };
875
-
876
- const DEMO_CANCEL: AskParams = {
877
- questions: [
878
- { id: "a", question: "First of two (press Esc to cancel)", options: [{ label: "x" }, { label: "y" }] },
879
- { id: "b", question: "Second (should never be asked)", options: [{ label: "p" }, { label: "q" }] },
880
- ],
881
- };
882
-
883
- async function runAskDemo(ctx: ExtensionCommandContext): Promise<void> {
884
- if (!ctx.hasUI) {
885
- ctx.ui.notify("ask-demo requires an interactive session", "warning");
886
- return;
887
- }
547
+ });
888
548
 
889
- const run = async (label: string, params: AskParams): Promise<string> => {
890
- ctx.ui.notify(`ask-demo ${label} — follow the dialog instructions`, "info");
891
- try {
892
- const result = await askUserTool.execute(`demo-${label}`, params, undefined, undefined, ctx);
893
- const text = result.content[0]?.type === "text" ? result.content[0].text : "";
894
- ctx.ui.notify(`ask-demo ${label} → ${text.split("\n").filter(Boolean).join(" | ").slice(0, 200)}`, "info");
895
- return text;
896
- } catch (error) {
897
- ctx.ui.notify(`ask-demo ${label} failed: ${error instanceof Error ? error.message : String(error)}`, "error");
898
- return "";
549
+ // print/json hosts cannot prompt: hide the tool instead of letting the LLM
550
+ // discover the error at call time (omp's createIf gate, extension edition).
551
+ pi.on("session_start", (_event, ctx) => {
552
+ if (!ctx.hasUI) {
553
+ const active = pi.getActiveTools();
554
+ if (active.includes("ask")) {
555
+ pi.setActiveTools(active.filter((name) => name !== "ask"));
556
+ }
899
557
  }
900
- };
901
-
902
- ctx.ui.notify(
903
- "ask-demo 1/4 all question types: single+recommended+preview, multi, Other free input, note with n, review page before submit",
904
- "info",
905
- );
906
- await run("types", DEMO_TYPES);
907
-
908
- await run("timeout", DEMO_TIMEOUT); // dialog itself says: wait 6s
909
-
910
- ctx.ui.notify("ask-demo 3/4 chat redirect: choose 'Chat about this' in the next dialog", "info");
911
- await run("chat", DEMO_CHAT);
912
-
913
- ctx.ui.notify("ask-demo 4/4 cancel: press Esc in the next dialog", "info");
914
- await run("cancel", DEMO_CANCEL);
915
-
916
- ctx.ui.notify("ask-demo finished — review the four results above", "info");
558
+ });
917
559
  }
918
560
 
919
- export default function askUserExtension(pi: ExtensionAPI): void {
920
- pi.registerTool(askUserTool);
921
- pi.registerCommand("ask-demo", {
922
- description: "Interactive ask_user battery: all question types, timeout, chat redirect, cancel",
923
- handler: (_args, ctx) => runAskDemo(ctx),
561
+ /** Resolve `undefined` when the signal aborts before the dialog settles;
562
+ * rejections propagate untouched (omp untilAborted semantics). */
563
+ function raceWithSignal<T>(signal: AbortSignal, run: () => Promise<T>): Promise<T | undefined> {
564
+ if (signal.aborted) return Promise.resolve(undefined);
565
+ return new Promise<T | undefined>((resolve, reject) => {
566
+ let settled = false;
567
+ const onAbort = (): void => {
568
+ if (settled) return;
569
+ settled = true;
570
+ resolve(undefined);
571
+ };
572
+ signal.addEventListener("abort", onAbort, { once: true });
573
+ run().then(
574
+ value => {
575
+ if (settled) return;
576
+ settled = true;
577
+ signal.removeEventListener("abort", onAbort);
578
+ resolve(value);
579
+ },
580
+ error => {
581
+ if (settled) return;
582
+ settled = true;
583
+ signal.removeEventListener("abort", onAbort);
584
+ // Propagate like omp's untilAborted: real host failures must not
585
+ // surface as a phantom "user cancelled" outcome.
586
+ reject(error);
587
+ },
588
+ );
924
589
  });
925
590
  }