@fyeeme/pi-ask-user 2.0.0 → 2.0.2
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 +33 -30
- package/README.md +99 -79
- package/index.ts +473 -814
- 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,472 @@ 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" });
|
|
143
|
+
function formatQuestionResult(result: QuestionResult): string {
|
|
144
|
+
if (result.customInput !== undefined) {
|
|
145
|
+
return `${result.id}: "${result.customInput}"`;
|
|
292
146
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function exitInputMode(): void {
|
|
341
|
-
inputMode = null;
|
|
342
|
-
editor.setText("");
|
|
343
|
-
cachedLines = undefined;
|
|
344
|
-
}
|
|
345
|
-
|
|
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;
|
|
351
|
-
}
|
|
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
|
-
}
|
|
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}`;
|
|
362
152
|
}
|
|
153
|
+
return result.multi ? `${result.id}: []` : `${result.id}: (cancelled)`;
|
|
154
|
+
}
|
|
363
155
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}
|
|
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);
|
|
377
168
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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;
|
|
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
|
+
);
|
|
394
178
|
}
|
|
395
|
-
|
|
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
|
-
};
|
|
411
|
-
|
|
412
|
-
lines.push(theme.fg("accent", "-".repeat(renderWidth)));
|
|
413
|
-
|
|
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(" "));
|
|
424
|
-
}
|
|
425
|
-
|
|
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,
|
|
445
|
-
);
|
|
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
|
-
}
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
lines.push(theme.fg("accent", "-".repeat(renderWidth)));
|
|
526
|
-
|
|
527
|
-
cachedLines = lines;
|
|
528
|
-
return lines;
|
|
529
|
-
},
|
|
530
|
-
|
|
531
|
-
invalidate(): void {
|
|
532
|
-
cachedLines = undefined;
|
|
533
|
-
},
|
|
534
|
-
|
|
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;
|
|
545
|
-
}
|
|
546
|
-
|
|
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
|
-
}
|
|
559
|
-
|
|
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;
|
|
578
|
-
}
|
|
579
|
-
return true;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
// Review page: Enter submits once everything is answered.
|
|
583
|
-
if (onSummary()) {
|
|
584
|
-
if (matchesKey(data, Key.return) && allAnswered()) {
|
|
585
|
-
settle({ kind: "submit" });
|
|
586
|
-
}
|
|
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;
|
|
601
|
-
}
|
|
602
|
-
if (cursor === q.options.length + 1) {
|
|
603
|
-
settle({ kind: "chat" });
|
|
604
|
-
return true;
|
|
605
|
-
}
|
|
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]);
|
|
615
|
-
}
|
|
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;
|
|
626
|
-
}
|
|
627
|
-
return false;
|
|
628
|
-
},
|
|
629
|
-
|
|
630
|
-
dispose(): void {
|
|
631
|
-
settled = true;
|
|
632
|
-
clearTimeout(timerId);
|
|
633
|
-
signal?.removeEventListener("abort", onAbort);
|
|
634
|
-
},
|
|
635
|
-
};
|
|
179
|
+
if (responseParts.length > 0) return responseParts.join("\n");
|
|
180
|
+
return result.multi ? "User did not select any options" : "User cancelled the selection";
|
|
636
181
|
}
|
|
637
182
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
state: DialogState;
|
|
642
|
-
}
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Transcript rendering
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
643
186
|
|
|
644
|
-
/**
|
|
645
|
-
*
|
|
646
|
-
*
|
|
647
|
-
|
|
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
|
-
}
|
|
660
|
-
|
|
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
|
-
});
|
|
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();
|
|
677
192
|
}
|
|
678
193
|
|
|
679
194
|
// ---------------------------------------------------------------------------
|
|
680
|
-
//
|
|
195
|
+
// Extension entry
|
|
681
196
|
// ---------------------------------------------------------------------------
|
|
682
197
|
|
|
683
|
-
|
|
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",
|
|
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",
|
|
691
204
|
promptGuidelines: [
|
|
692
|
-
"Use
|
|
205
|
+
"Use ask only after exhausting repo conventions, configs, and docs; reserve it for decisions whose options have materially different tradeoffs.",
|
|
693
206
|
],
|
|
694
207
|
parameters: AskParamsSchema,
|
|
695
208
|
executionMode: "sequential",
|
|
696
209
|
|
|
697
210
|
async execute(_toolCallId, params: AskParams, signal, _onUpdate, ctx) {
|
|
698
|
-
|
|
699
|
-
|
|
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
|
+
);
|
|
700
221
|
}
|
|
701
222
|
|
|
702
|
-
// Reserved-label
|
|
223
|
+
// Reserved-label narrow (omp schema narrow, executed eagerly here).
|
|
703
224
|
for (const q of params.questions) {
|
|
704
|
-
const
|
|
705
|
-
if (
|
|
225
|
+
const reserved = q.options.find(option => RESERVED_OPTION_LABELS[option.label] === true);
|
|
226
|
+
if (reserved) {
|
|
706
227
|
throw new Error(
|
|
707
|
-
`
|
|
228
|
+
`ask: question "${q.id}" uses reserved runtime label "${reserved.label}"; ` +
|
|
229
|
+
`do not include "Other" — the UI adds it automatically`,
|
|
708
230
|
);
|
|
709
231
|
}
|
|
710
232
|
}
|
|
711
233
|
|
|
712
|
-
const
|
|
234
|
+
const extensionUi = ctx.ui;
|
|
235
|
+
const ui: UIContext = uiContextFromExtension(extensionUi);
|
|
236
|
+
|
|
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;
|
|
244
|
+
|
|
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. Terminal-direct:
|
|
248
|
+
// TUI only — RPC stdout is the JSON protocol channel (Mode Behavior).
|
|
249
|
+
if (ctx.mode === "tui" && process.env.PI_OMK_ASK_NOTIFY !== "0") process.stdout.write("\x07");
|
|
250
|
+
|
|
251
|
+
const richQuestions: ExtensionAskDialogQuestion[] = params.questions.map(q => ({
|
|
713
252
|
id: q.id,
|
|
714
253
|
question: q.question,
|
|
715
|
-
...(
|
|
716
|
-
options: q.options.map(
|
|
254
|
+
...(q.header?.trim() ? { header: q.header } : {}),
|
|
255
|
+
options: q.options.map(option => ({
|
|
717
256
|
label: option.label,
|
|
718
|
-
...(
|
|
719
|
-
...(
|
|
257
|
+
...(option.description?.trim() ? { description: option.description.trim() } : {}),
|
|
258
|
+
...(option.preview?.trim() ? { preview: option.preview } : {}),
|
|
720
259
|
})),
|
|
721
|
-
multi: q.multi
|
|
722
|
-
|
|
260
|
+
...(q.multi !== undefined ? { multi: q.multi } : {}),
|
|
261
|
+
...(q.recommended !== undefined ? { recommended: q.recommended } : {}),
|
|
723
262
|
}));
|
|
724
263
|
|
|
725
|
-
|
|
726
|
-
|
|
264
|
+
// Rich dialog path (omp ExtensionUIContext.askDialog → pi ui.custom).
|
|
265
|
+
if (ctx.mode === "tui") {
|
|
266
|
+
let richResult: ExtensionAskDialogResult | undefined;
|
|
267
|
+
try {
|
|
268
|
+
const showRichDialog = () =>
|
|
269
|
+
extensionUi.custom<ExtensionAskDialogResult | undefined>((tui, theme, keybindings, done) => {
|
|
270
|
+
const dialog = new AskDialogComponent(
|
|
271
|
+
theme,
|
|
272
|
+
keybindings,
|
|
273
|
+
richQuestions,
|
|
274
|
+
{
|
|
275
|
+
onSubmit: result => done(result),
|
|
276
|
+
onCancel: () => done(undefined),
|
|
277
|
+
},
|
|
278
|
+
{ timeout: timeout ?? undefined, tui: { requestRender: () => tui.requestRender() } },
|
|
279
|
+
);
|
|
280
|
+
// pi's custom() has no signal option; mirror omp's
|
|
281
|
+
// #presentDialog(signal) by cancelling the component
|
|
282
|
+
// when the agent turn is aborted.
|
|
283
|
+
signal?.addEventListener(
|
|
284
|
+
"abort",
|
|
285
|
+
() => {
|
|
286
|
+
dialog.externalCancel();
|
|
287
|
+
},
|
|
288
|
+
{ once: true },
|
|
289
|
+
);
|
|
290
|
+
return dialog;
|
|
291
|
+
});
|
|
292
|
+
richResult = signal ? await raceWithSignal(signal, showRichDialog) : await showRichDialog();
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
295
|
+
ctx.abort();
|
|
296
|
+
throw new Error("Ask input was cancelled");
|
|
297
|
+
}
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
727
300
|
|
|
728
|
-
|
|
301
|
+
if (!richResult) {
|
|
302
|
+
// omp: cancel aborts the turn (ToolAbortError).
|
|
303
|
+
ctx.abort();
|
|
304
|
+
throw new Error("Ask tool was cancelled by the user");
|
|
305
|
+
}
|
|
306
|
+
if (richResult.results.length !== params.questions.length) {
|
|
307
|
+
throw new Error("Ask dialog returned a result count that does not match the requested questions");
|
|
308
|
+
}
|
|
309
|
+
const results: QuestionResult[] = [];
|
|
310
|
+
for (let index = 0; index < params.questions.length; index++) {
|
|
311
|
+
const question = params.questions[index];
|
|
312
|
+
const result = richResult.results[index];
|
|
313
|
+
if (!question || !result || result.id !== question.id) {
|
|
314
|
+
throw new Error("Ask dialog returned results that do not match the requested question order");
|
|
315
|
+
}
|
|
316
|
+
results.push({
|
|
317
|
+
id: question.id,
|
|
318
|
+
question: question.question,
|
|
319
|
+
options: question.options.map(option => option.label),
|
|
320
|
+
multi: question.multi ?? false,
|
|
321
|
+
selectedOptions: result.selectedOptions,
|
|
322
|
+
customInput: result.customInput,
|
|
323
|
+
timedOut: result.timedOut,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
if (params.questions.length === 1) {
|
|
327
|
+
const result = results[0];
|
|
328
|
+
// An empty multi-select submission is a valid "select none"
|
|
329
|
+
// answer (#8265 review); only a truly empty single-select
|
|
330
|
+
// result counts as cancellation.
|
|
331
|
+
if (
|
|
332
|
+
!result ||
|
|
333
|
+
(!result.timedOut &&
|
|
334
|
+
!result.multi &&
|
|
335
|
+
result.selectedOptions.length === 0 &&
|
|
336
|
+
result.customInput === undefined)
|
|
337
|
+
) {
|
|
338
|
+
ctx.abort();
|
|
339
|
+
throw new Error("Ask tool was cancelled by the user");
|
|
340
|
+
}
|
|
341
|
+
const details: AskToolDetails = {
|
|
342
|
+
question: result.question,
|
|
343
|
+
options: result.options,
|
|
344
|
+
multi: result.multi,
|
|
345
|
+
selectedOptions: result.selectedOptions,
|
|
346
|
+
customInput: result.customInput,
|
|
347
|
+
timedOut: result.timedOut,
|
|
348
|
+
};
|
|
349
|
+
const responseText = formatSingleQuestionResponse(result);
|
|
350
|
+
return { content: [{ type: "text" as const, text: responseText }], details };
|
|
351
|
+
}
|
|
352
|
+
const details: AskToolDetails = { results };
|
|
353
|
+
const responseText = `User answers:\n${results.map(formatQuestionResult).join("\n")}`;
|
|
354
|
+
return { content: [{ type: "text" as const, text: responseText }], details };
|
|
355
|
+
}
|
|
729
356
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
357
|
+
// Legacy per-question path (omp ask.ts, verbatim flow).
|
|
358
|
+
const askQuestion = async (
|
|
359
|
+
q: AskParams["questions"][number],
|
|
360
|
+
options?: { previous?: QuestionResult; navigation?: NavigationControls },
|
|
361
|
+
) => {
|
|
362
|
+
const questionOptions: AskOption[] = q.options.map(option => ({
|
|
363
|
+
label: option.label,
|
|
364
|
+
...(option.description?.trim() ? { description: option.description.trim() } : {}),
|
|
365
|
+
}));
|
|
366
|
+
const optionLabels = questionOptions.map(option => option.label);
|
|
367
|
+
try {
|
|
368
|
+
const { selectedOptions, customInput, navigation, cancelled, timedOut } = await askSingleQuestion(
|
|
369
|
+
ui,
|
|
370
|
+
q.question,
|
|
371
|
+
questionOptions,
|
|
372
|
+
q.multi ?? false,
|
|
734
373
|
{
|
|
735
|
-
|
|
736
|
-
|
|
374
|
+
recommended: q.recommended,
|
|
375
|
+
timeout: timeout ?? undefined,
|
|
376
|
+
signal,
|
|
377
|
+
initialSelection: options?.previous,
|
|
378
|
+
navigation: options?.navigation,
|
|
737
379
|
},
|
|
738
|
-
|
|
739
|
-
|
|
380
|
+
);
|
|
381
|
+
return { optionLabels, selectedOptions, customInput, navigation, cancelled, timedOut };
|
|
382
|
+
} catch (error) {
|
|
383
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
384
|
+
throw new Error("Ask input was cancelled");
|
|
385
|
+
}
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
if (params.questions.length === 1) {
|
|
391
|
+
const [q] = params.questions;
|
|
392
|
+
const { optionLabels, selectedOptions, customInput, cancelled, timedOut } = await askQuestion(q);
|
|
393
|
+
|
|
394
|
+
if (!timedOut && (cancelled || (selectedOptions.length === 0 && customInput === undefined))) {
|
|
395
|
+
ctx.abort();
|
|
396
|
+
throw new Error("Ask tool was cancelled by the user");
|
|
397
|
+
}
|
|
398
|
+
const details: AskToolDetails = {
|
|
399
|
+
question: q.question,
|
|
400
|
+
options: optionLabels,
|
|
401
|
+
multi: q.multi ?? false,
|
|
402
|
+
selectedOptions,
|
|
403
|
+
customInput,
|
|
404
|
+
timedOut: timedOut || undefined,
|
|
740
405
|
};
|
|
406
|
+
|
|
407
|
+
const responseText = formatSingleQuestionResponse({
|
|
408
|
+
selectedOptions,
|
|
409
|
+
customInput,
|
|
410
|
+
timedOut: timedOut || undefined,
|
|
411
|
+
multi: q.multi ?? false,
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
return { content: [{ type: "text" as const, text: responseText }], details };
|
|
741
415
|
}
|
|
742
416
|
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
417
|
+
const resultsByIndex: Array<QuestionResult | undefined> = Array.from({ length: params.questions.length });
|
|
418
|
+
let questionIndex = 0;
|
|
419
|
+
while (questionIndex < params.questions.length) {
|
|
420
|
+
const q = params.questions[questionIndex];
|
|
421
|
+
if (!q) throw new Error("Ask question index exceeded the requested question list");
|
|
422
|
+
const previous = resultsByIndex[questionIndex];
|
|
423
|
+
const navigation: NavigationControls = {
|
|
424
|
+
allowBack: questionIndex > 0,
|
|
425
|
+
allowForward: true,
|
|
426
|
+
progressText: `${questionIndex + 1}/${params.questions.length}`,
|
|
748
427
|
};
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
428
|
+
const {
|
|
429
|
+
optionLabels,
|
|
430
|
+
selectedOptions,
|
|
431
|
+
customInput,
|
|
432
|
+
navigation: navAction,
|
|
433
|
+
cancelled,
|
|
434
|
+
timedOut,
|
|
435
|
+
} = await askQuestion(q, { previous, navigation });
|
|
436
|
+
|
|
437
|
+
if (cancelled && !timedOut) {
|
|
438
|
+
ctx.abort();
|
|
439
|
+
throw new Error("Ask tool was cancelled by the user");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
resultsByIndex[questionIndex] = {
|
|
443
|
+
id: q.id,
|
|
444
|
+
question: q.question,
|
|
445
|
+
options: optionLabels,
|
|
446
|
+
multi: q.multi ?? false,
|
|
447
|
+
selectedOptions,
|
|
448
|
+
customInput,
|
|
449
|
+
timedOut: timedOut || undefined,
|
|
752
450
|
};
|
|
753
|
-
}
|
|
754
451
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
details,
|
|
760
|
-
};
|
|
761
|
-
},
|
|
452
|
+
if (navAction === "back") {
|
|
453
|
+
questionIndex = Math.max(0, questionIndex - 1);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
762
456
|
|
|
763
|
-
|
|
764
|
-
const questions = normalizeQuestions(args?.questions);
|
|
765
|
-
if (!questions) {
|
|
766
|
-
return new Text(theme.fg("toolTitle", theme.bold("ask_user")), 0, 0);
|
|
457
|
+
questionIndex += 1;
|
|
767
458
|
}
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
const
|
|
771
|
-
|
|
459
|
+
|
|
460
|
+
const results = params.questions.map((q, index) => {
|
|
461
|
+
const result = resultsByIndex[index];
|
|
462
|
+
if (result) return result;
|
|
463
|
+
return {
|
|
464
|
+
id: q.id,
|
|
465
|
+
question: q.question,
|
|
466
|
+
options: q.options.map(o => o.label),
|
|
467
|
+
multi: q.multi ?? false,
|
|
468
|
+
selectedOptions: [],
|
|
469
|
+
};
|
|
772
470
|
});
|
|
773
|
-
|
|
471
|
+
|
|
472
|
+
const details: AskToolDetails = { results };
|
|
473
|
+
const responseLines = results.map(formatQuestionResult);
|
|
474
|
+
const responseText = `User answers:\n${responseLines.join("\n")}`;
|
|
475
|
+
|
|
476
|
+
return { content: [{ type: "text" as const, text: responseText }], details };
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
renderCall(args: { questions?: unknown }, theme) {
|
|
480
|
+
// omp updates the framed question block in place once the user
|
|
481
|
+
// answers; pi's ToolExecutionComponent appends the result render
|
|
482
|
+
// below the call render, so a full question block here would leave
|
|
483
|
+
// the transcript with every question shown twice. Render only a
|
|
484
|
+
// tense-neutral summary line (it also appears in HTML export above
|
|
485
|
+
// the result block); the merged question/options/answer block
|
|
486
|
+
// renders once from renderResult after the dialog settles.
|
|
487
|
+
const count = Array.isArray(args.questions) ? args.questions.length : 0;
|
|
488
|
+
const summary = count > 0 ? `Ask · ${count} ${count === 1 ? "question" : "questions"}` : "Ask";
|
|
489
|
+
return new Text(theme.fg("dim", summary), 0, 0);
|
|
774
490
|
},
|
|
775
491
|
|
|
776
492
|
renderResult(result, _options, theme) {
|
|
777
|
-
const details = result.details as
|
|
778
|
-
|
|
779
|
-
if (details
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
493
|
+
const details = result.details as AskToolDetails | undefined;
|
|
494
|
+
|
|
495
|
+
if (!details) {
|
|
496
|
+
const txt = result.content[0];
|
|
497
|
+
const fallback = txt?.type === "text" && txt.text ? txt.text : "";
|
|
498
|
+
return new Text(`${theme.fg("warning", "~ Ask")}${fallback ? `\n${theme.fg("dim", fallback)}` : ""}`, 0, 0);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// One compact line per question: the asked question and the given
|
|
502
|
+
// answer. The dialog already showed the full options list, so the
|
|
503
|
+
// transcript does not re-list unselected options.
|
|
504
|
+
const answerLine = (
|
|
505
|
+
label: string,
|
|
506
|
+
selectedOptions: string[] | undefined,
|
|
507
|
+
customInput: string | undefined,
|
|
508
|
+
timedOut: boolean | undefined,
|
|
509
|
+
): string => {
|
|
510
|
+
const parts: string[] = [];
|
|
511
|
+
if (selectedOptions && selectedOptions.length > 0) parts.push(selectedOptions.join(", "));
|
|
512
|
+
if (customInput !== undefined) parts.push(`“${flattenToOneLine(customInput)}”`);
|
|
513
|
+
const answer =
|
|
514
|
+
parts.length > 0
|
|
515
|
+
? theme.fg("toolOutput", parts.join(" · "))
|
|
516
|
+
: theme.fg("warning", "(no answer)");
|
|
517
|
+
// Distinguish auto-selection from a real user choice.
|
|
518
|
+
const timeoutMark = timedOut ? theme.fg("dim", " · auto-selected after timeout") : "";
|
|
519
|
+
return `${theme.fg("muted", label)}${theme.fg("dim", " → ")}${answer}${timeoutMark}`;
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
if (details.results && details.results.length > 0) {
|
|
523
|
+
const lines = details.results.map(r =>
|
|
524
|
+
answerLine(`[${r.id}] ${flattenToOneLine(r.question)}`, r.selectedOptions, r.customInput, r.timedOut),
|
|
785
525
|
);
|
|
526
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
786
527
|
}
|
|
787
528
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
529
|
+
if (!details.question) {
|
|
530
|
+
const txt = result.content[0];
|
|
531
|
+
const fallback = txt?.type === "text" && txt.text ? txt.text : "";
|
|
532
|
+
return new Text(fallback, 0, 0);
|
|
791
533
|
}
|
|
792
|
-
|
|
793
|
-
const status = details?.cancelled ? theme.fg("warning", "~") : theme.fg("success", "+");
|
|
534
|
+
|
|
794
535
|
return new Text(
|
|
795
|
-
|
|
536
|
+
answerLine(flattenToOneLine(details.question), details.selectedOptions, details.customInput, details.timedOut),
|
|
796
537
|
0,
|
|
797
538
|
0,
|
|
798
539
|
);
|
|
799
540
|
},
|
|
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
|
-
}
|
|
541
|
+
});
|
|
888
542
|
|
|
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 "";
|
|
543
|
+
// print/json hosts cannot prompt: hide the tool instead of letting the LLM
|
|
544
|
+
// discover the error at call time (omp's createIf gate, extension edition).
|
|
545
|
+
pi.on("session_start", (_event, ctx) => {
|
|
546
|
+
if (!ctx.hasUI) {
|
|
547
|
+
const active = pi.getActiveTools();
|
|
548
|
+
if (active.includes("ask")) {
|
|
549
|
+
pi.setActiveTools(active.filter((name) => name !== "ask"));
|
|
550
|
+
}
|
|
899
551
|
}
|
|
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");
|
|
552
|
+
});
|
|
917
553
|
}
|
|
918
554
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
555
|
+
/** Resolve `undefined` when the signal aborts before the dialog settles;
|
|
556
|
+
* rejections propagate untouched (omp untilAborted semantics). */
|
|
557
|
+
function raceWithSignal<T>(signal: AbortSignal, run: () => Promise<T>): Promise<T | undefined> {
|
|
558
|
+
if (signal.aborted) return Promise.resolve(undefined);
|
|
559
|
+
return new Promise<T | undefined>((resolve, reject) => {
|
|
560
|
+
let settled = false;
|
|
561
|
+
const onAbort = (): void => {
|
|
562
|
+
if (settled) return;
|
|
563
|
+
settled = true;
|
|
564
|
+
resolve(undefined);
|
|
565
|
+
};
|
|
566
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
567
|
+
run().then(
|
|
568
|
+
value => {
|
|
569
|
+
if (settled) return;
|
|
570
|
+
settled = true;
|
|
571
|
+
signal.removeEventListener("abort", onAbort);
|
|
572
|
+
resolve(value);
|
|
573
|
+
},
|
|
574
|
+
error => {
|
|
575
|
+
if (settled) return;
|
|
576
|
+
settled = true;
|
|
577
|
+
signal.removeEventListener("abort", onAbort);
|
|
578
|
+
// Propagate like omp's untilAborted: real host failures must not
|
|
579
|
+
// surface as a phantom "user cancelled" outcome.
|
|
580
|
+
reject(error);
|
|
581
|
+
},
|
|
582
|
+
);
|
|
924
583
|
});
|
|
925
584
|
}
|