@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/CHANGELOG.md +28 -32
- package/README.md +99 -79
- package/index.ts +478 -813
- package/package.json +6 -3
- package/src/ask-dialog.ts +1115 -0
- package/src/ask-legacy.ts +642 -0
- package/src/compat.ts +104 -0
- package/src/countdown-timer.ts +83 -0
- package/src/overlay-box.ts +53 -0
- package/src/types.ts +78 -0
package/index.ts
CHANGED
|
@@ -1,103 +1,102 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-ask-user
|
|
2
|
+
* pi-ask-user — the oh-my-pi `ask` tool migrated to a pi extension
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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
|
|
19
|
-
import
|
|
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
|
-
//
|
|
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: "
|
|
73
|
-
description: Type.Optional(Type.String({ description: "
|
|
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: "
|
|
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: "
|
|
81
|
-
question: Type.String({ description: "
|
|
82
|
-
header: Type.Optional(Type.String({ description: "
|
|
83
|
-
options: Type.Array(OptionSchema, {
|
|
84
|
-
|
|
85
|
-
|
|
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,
|
|
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
|
-
"
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
//
|
|
140
|
+
// Response formatting (omp ask.ts, verbatim)
|
|
239
141
|
// ---------------------------------------------------------------------------
|
|
240
142
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
234
|
+
const extensionUi = ctx.ui;
|
|
235
|
+
const ui: UIContext = uiContextFromExtension(extensionUi);
|
|
526
236
|
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
532
|
-
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
//
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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 (
|
|
607
|
-
|
|
608
|
-
// An empty
|
|
609
|
-
//
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
return
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
646
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
-
|
|
458
|
+
if (navAction === "back") {
|
|
459
|
+
questionIndex = Math.max(0, questionIndex - 1);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
729
462
|
|
|
730
|
-
|
|
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
|
-
|
|
744
|
-
const
|
|
745
|
-
|
|
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
|
-
|
|
751
|
-
|
|
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
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
}
|
|
773
|
-
return new Text(
|
|
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
|
|
778
|
-
|
|
779
|
-
if (details
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
793
|
-
const status = details?.cancelled ? theme.fg("warning", "~") : theme.fg("success", "+");
|
|
540
|
+
|
|
794
541
|
return new Text(
|
|
795
|
-
|
|
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
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
const
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
}
|