@hheei/pi-ask-user 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +68 -2
- package/dist/ask-prompt.d.ts +60 -0
- package/dist/ask-prompt.js +360 -0
- package/dist/batch-prompt.d.ts +42 -0
- package/dist/batch-prompt.js +192 -0
- package/dist/constants.d.ts +15 -0
- package/dist/constants.js +17 -0
- package/dist/custom-prompt.d.ts +20 -0
- package/dist/custom-prompt.js +56 -0
- package/dist/dialogs.d.ts +25 -0
- package/dist/dialogs.js +93 -0
- package/dist/events.d.ts +23 -0
- package/dist/events.js +22 -0
- package/dist/execute.d.ts +5 -0
- package/dist/execute.js +157 -0
- package/dist/format.d.ts +18 -0
- package/dist/format.js +84 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/multi-select-list.d.ts +25 -0
- package/dist/multi-select-list.js +155 -0
- package/dist/option-rows.d.ts +33 -0
- package/dist/option-rows.js +89 -0
- package/dist/preferences.d.ts +8 -0
- package/dist/preferences.js +25 -0
- package/dist/presentation.d.ts +6 -0
- package/dist/presentation.js +129 -0
- package/dist/prompt-context.d.ts +26 -0
- package/dist/prompt-context.js +68 -0
- package/dist/prompt-help.d.ts +23 -0
- package/dist/prompt-help.js +50 -0
- package/dist/prompt-layout.d.ts +43 -0
- package/dist/prompt-layout.js +89 -0
- package/dist/prompt-overflow.d.ts +5 -0
- package/dist/prompt-overflow.js +59 -0
- package/dist/response.d.ts +46 -0
- package/dist/response.js +31 -0
- package/dist/schema.d.ts +43 -0
- package/dist/schema.js +87 -0
- package/dist/settings-provider.d.ts +15 -0
- package/dist/settings-provider.js +42 -0
- package/dist/shortcuts.d.ts +7 -0
- package/dist/shortcuts.js +21 -0
- package/dist/single-select-layout.d.ts +19 -0
- package/dist/single-select-layout.js +61 -0
- package/dist/single-select-list.d.ts +23 -0
- package/dist/single-select-list.js +99 -0
- package/dist/tool.d.ts +2 -0
- package/dist/tool.js +91 -0
- package/package.json +59 -5
- package/skills/ask-user/SKILL.md +189 -0
- package/skills/ask-user/references/ask-user-skill-extension-spec.md +106 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { decodeKittyPrintable, Key, matchesKey, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
2
|
+
import { AskComponent } from "./ask-prompt";
|
|
3
|
+
import { INLINE_DOCK_RESERVED_ROWS } from "./constants";
|
|
4
|
+
import { formatKeyList } from "./prompt-help";
|
|
5
|
+
import { getOverlayMaxRenderLinesForRows, railRow } from "./prompt-layout";
|
|
6
|
+
import { formatResponseSummary, } from "./response";
|
|
7
|
+
/**
|
|
8
|
+
* One prompt for a whole `questions` batch: a page per question plus a review
|
|
9
|
+
* page. Each page is an AskComponent that stays alive, so drafts and checkboxes
|
|
10
|
+
* survive moving between pages. Answers are recorded per page and only the
|
|
11
|
+
* review page submits; esc that would cancel a page cancels the batch.
|
|
12
|
+
*/
|
|
13
|
+
export class BatchAskComponent {
|
|
14
|
+
questions;
|
|
15
|
+
settings;
|
|
16
|
+
tui;
|
|
17
|
+
theme;
|
|
18
|
+
keybindings;
|
|
19
|
+
onDone;
|
|
20
|
+
heading;
|
|
21
|
+
pages;
|
|
22
|
+
answers;
|
|
23
|
+
current = 0;
|
|
24
|
+
title = "";
|
|
25
|
+
confirmingSkips = false;
|
|
26
|
+
_focused = false;
|
|
27
|
+
constructor(questions, settings, tui, theme, keybindings, onDone, heading) {
|
|
28
|
+
this.questions = questions;
|
|
29
|
+
this.settings = settings;
|
|
30
|
+
this.tui = tui;
|
|
31
|
+
this.theme = theme;
|
|
32
|
+
this.keybindings = keybindings;
|
|
33
|
+
this.onDone = onDone;
|
|
34
|
+
this.heading = heading;
|
|
35
|
+
this.answers = questions.map(() => undefined);
|
|
36
|
+
this.pages = questions.map((entry, index) => new AskComponent(entry, settings, tui, theme, keybindings, (result) => this.handlePageDone(index, result)));
|
|
37
|
+
this.updateChrome();
|
|
38
|
+
}
|
|
39
|
+
get focused() {
|
|
40
|
+
return this._focused;
|
|
41
|
+
}
|
|
42
|
+
set focused(value) {
|
|
43
|
+
this._focused = value;
|
|
44
|
+
const page = this.pages[this.current];
|
|
45
|
+
if (page)
|
|
46
|
+
page.focused = value;
|
|
47
|
+
}
|
|
48
|
+
invalidate() {
|
|
49
|
+
for (const page of this.pages)
|
|
50
|
+
page.invalidate();
|
|
51
|
+
}
|
|
52
|
+
render(width) {
|
|
53
|
+
const page = this.pages[this.current];
|
|
54
|
+
return page ? page.render(width) : this.renderReview(width);
|
|
55
|
+
}
|
|
56
|
+
handleInput(data) {
|
|
57
|
+
const pageCount = this.questions.length + 1;
|
|
58
|
+
// Tab and shift+tab move between pages here; inside a page, arrows still
|
|
59
|
+
// move the option selection.
|
|
60
|
+
if (matchesKey(data, Key.tab)) {
|
|
61
|
+
this.goTo((this.current + 1) % pageCount);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (matchesKey(data, Key.shift("tab"))) {
|
|
65
|
+
this.goTo((this.current + pageCount - 1) % pageCount);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const page = this.pages[this.current];
|
|
69
|
+
if (page) {
|
|
70
|
+
page.handleInput(data);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.handleReviewInput(data);
|
|
74
|
+
}
|
|
75
|
+
handlePageDone(index, result) {
|
|
76
|
+
if (result === null) {
|
|
77
|
+
this.onDone(null);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
this.answers[index] = result;
|
|
81
|
+
const count = this.questions.length;
|
|
82
|
+
for (let step = 1; step <= count; step++) {
|
|
83
|
+
const next = (index + step) % count;
|
|
84
|
+
if (!this.answers[next]) {
|
|
85
|
+
this.goTo(next);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
this.goTo(count);
|
|
90
|
+
}
|
|
91
|
+
goTo(target) {
|
|
92
|
+
const previous = this.pages[this.current];
|
|
93
|
+
if (previous)
|
|
94
|
+
previous.focused = false;
|
|
95
|
+
this.current = target;
|
|
96
|
+
this.confirmingSkips = false;
|
|
97
|
+
const next = this.pages[target];
|
|
98
|
+
if (next)
|
|
99
|
+
next.focused = this._focused;
|
|
100
|
+
this.updateChrome();
|
|
101
|
+
this.tui.requestRender();
|
|
102
|
+
}
|
|
103
|
+
updateChrome() {
|
|
104
|
+
const labels = this.questions.map((_, index) => {
|
|
105
|
+
const label = `${index + 1}${this.answers[index] ? "\u2713" : ""}`;
|
|
106
|
+
return index === this.current ? `[${label}]` : label;
|
|
107
|
+
});
|
|
108
|
+
const review = this.current === this.questions.length ? "[review]" : "review";
|
|
109
|
+
// The strip is one terminal row, so the heading's line breaks collapse here only.
|
|
110
|
+
const heading = this.heading ? ` \u00b7 ${this.heading.replace(/\s+/g, " ").slice(0, 40)}` : "";
|
|
111
|
+
this.title = `ask_user${heading} ${labels.join(" ")} \u00b7 ${review}`;
|
|
112
|
+
const hint = "\u21e5 questions";
|
|
113
|
+
for (const page of this.pages)
|
|
114
|
+
page.setBatchChrome(this.title, hint);
|
|
115
|
+
}
|
|
116
|
+
/** Submit from the review page; with unanswered questions, the first submit asks to confirm. */
|
|
117
|
+
submitReview() {
|
|
118
|
+
if (this.unansweredCount() > 0 && !this.confirmingSkips) {
|
|
119
|
+
this.confirmingSkips = true;
|
|
120
|
+
this.tui.requestRender();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this.onDone(this.answers.map((response) => response ? { status: "answered", response } : { status: "skipped" }));
|
|
124
|
+
}
|
|
125
|
+
unansweredCount() {
|
|
126
|
+
return this.answers.filter((answer) => !answer).length;
|
|
127
|
+
}
|
|
128
|
+
handleReviewInput(data) {
|
|
129
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
130
|
+
this.onDone(null);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (this.keybindings.matches(data, "tui.select.confirm")) {
|
|
134
|
+
this.submitReview();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
// Kitty's keyboard protocol can deliver digits as CSI-u sequences.
|
|
138
|
+
const key = decodeKittyPrintable(data) ?? data;
|
|
139
|
+
const jump = key.length === 1 ? Number.parseInt(key, 10) : Number.NaN;
|
|
140
|
+
if (jump >= 1 && jump <= this.questions.length)
|
|
141
|
+
this.goTo(jump - 1);
|
|
142
|
+
}
|
|
143
|
+
renderReview(width) {
|
|
144
|
+
const theme = this.theme;
|
|
145
|
+
const wrap = (text) => wrapTextWithAnsi(text, Math.max(1, width - 1));
|
|
146
|
+
const answerLines = this.questions.flatMap((entry, index) => {
|
|
147
|
+
const response = this.answers[index];
|
|
148
|
+
const marker = response ? theme.fg("success", "\u2713") : theme.fg("warning", "\u25cb");
|
|
149
|
+
return [
|
|
150
|
+
...wrap(`${marker} ${theme.fg("text", `${index + 1}. ${entry.question}`)}`),
|
|
151
|
+
...wrap(response
|
|
152
|
+
? ` ${theme.fg("dim", "\u2192")} ${theme.fg("accent", formatResponseSummary(response))}`
|
|
153
|
+
: ` ${theme.fg("warning", "unanswered")}`),
|
|
154
|
+
];
|
|
155
|
+
});
|
|
156
|
+
const unanswered = this.unansweredCount();
|
|
157
|
+
const confirmKey = formatKeyList(this.keybindings.getKeys("tui.select.confirm"));
|
|
158
|
+
const cancelKey = formatKeyList(this.keybindings.getKeys("tui.select.cancel"));
|
|
159
|
+
const hints = [
|
|
160
|
+
`${confirmKey} ${unanswered > 0 ? "submit with skips" : "submit"}`,
|
|
161
|
+
`1-${this.questions.length} edit`,
|
|
162
|
+
"\u21e5 questions",
|
|
163
|
+
`${cancelKey} cancel`,
|
|
164
|
+
].join(" \u00b7 ");
|
|
165
|
+
const warning = this.confirmingSkips
|
|
166
|
+
? theme.fg("warning", `${unanswered} unanswered \u2014 press ${confirmKey} again to submit`)
|
|
167
|
+
: undefined;
|
|
168
|
+
const footerLines = [...(warning ? wrap(warning) : []), ...wrap(theme.fg("dim", hints))];
|
|
169
|
+
// The title and the footer are fixed; the answers take what is left and are
|
|
170
|
+
// cut from the end, so a short prompt still shows the first ones.
|
|
171
|
+
const budget = Math.max(1, this.reviewLineCap() - 3 - footerLines.length);
|
|
172
|
+
return [
|
|
173
|
+
railRow(theme, width),
|
|
174
|
+
theme.fg("dim", this.title),
|
|
175
|
+
...answerLines.slice(0, budget),
|
|
176
|
+
...footerLines,
|
|
177
|
+
railRow(theme, width),
|
|
178
|
+
];
|
|
179
|
+
}
|
|
180
|
+
/** Rows the review page may use, rails included. */
|
|
181
|
+
reviewLineCap() {
|
|
182
|
+
const rows = Number.isFinite(this.tui.terminal.rows) ? Math.floor(this.tui.terminal.rows) : 24;
|
|
183
|
+
const overlayCap = getOverlayMaxRenderLinesForRows(rows);
|
|
184
|
+
if (this.settings.displayMode === "overlay")
|
|
185
|
+
return overlayCap;
|
|
186
|
+
// Inline prompts sit in Pi's fullscreen input dock, which clips anything
|
|
187
|
+
// taller than what remains after the transcript's minimum row, the
|
|
188
|
+
// working status, and the 2-3 row footer. Components only learn their
|
|
189
|
+
// width, so stay within that space instead of growing with the content.
|
|
190
|
+
return Math.max(4, Math.min(overlayCap, rows - INLINE_DOCK_RESERVED_ROWS));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const BATCH_MIN_QUESTIONS = 2;
|
|
2
|
+
export declare const BATCH_MAX_QUESTIONS = 4;
|
|
3
|
+
export type AskMode = "select" | "freeform";
|
|
4
|
+
export declare const ASK_OVERLAY_MAX_HEIGHT_RATIO = 0.85;
|
|
5
|
+
export declare const ASK_OVERLAY_MIN_RENDER_LINES = 8;
|
|
6
|
+
export declare const ASK_OVERLAY_WIDTH = "92%";
|
|
7
|
+
export declare const ASK_OVERLAY_MIN_WIDTH = 40;
|
|
8
|
+
export declare const FREEFORM_SENTINEL = "\u270F\uFE0F Type custom response...";
|
|
9
|
+
export declare const FREEFORM_LABEL = "Type something.";
|
|
10
|
+
export declare const FREEFORM_HINT = "Enter a custom response";
|
|
11
|
+
export declare const CONTEXT_TOGGLE_KEYS: readonly ["ctrl+e", "ctrl+x", "ctrl+y"];
|
|
12
|
+
export declare const INLINE_CONTEXT_MAX_ROWS = 3;
|
|
13
|
+
export declare const INLINE_DOCK_RESERVED_ROWS = 5;
|
|
14
|
+
/** Section of `<agent-dir>/ext_settings.json` (and its project override) holding these preferences. */
|
|
15
|
+
export declare const SETTINGS_SECTION = "askUser";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Key } from "@earendil-works/pi-tui";
|
|
2
|
+
export const BATCH_MIN_QUESTIONS = 2;
|
|
3
|
+
export const BATCH_MAX_QUESTIONS = 4;
|
|
4
|
+
export const ASK_OVERLAY_MAX_HEIGHT_RATIO = 0.85;
|
|
5
|
+
export const ASK_OVERLAY_MIN_RENDER_LINES = 8;
|
|
6
|
+
export const ASK_OVERLAY_WIDTH = "92%";
|
|
7
|
+
export const ASK_OVERLAY_MIN_WIDTH = 40;
|
|
8
|
+
export const FREEFORM_SENTINEL = "\u270f\ufe0f Type custom response...";
|
|
9
|
+
export const FREEFORM_LABEL = "Type something.";
|
|
10
|
+
export const FREEFORM_HINT = "Enter a custom response";
|
|
11
|
+
export const CONTEXT_TOGGLE_KEYS = [Key.ctrl("e"), Key.ctrl("x"), Key.ctrl("y")];
|
|
12
|
+
export const INLINE_CONTEXT_MAX_ROWS = 3;
|
|
13
|
+
// Rows Pi's fullscreen layout keeps outside the input dock: the transcript's
|
|
14
|
+
// minimum row, the working status, and up to three footer rows.
|
|
15
|
+
export const INLINE_DOCK_RESERVED_ROWS = 5;
|
|
16
|
+
/** Section of `<agent-dir>/ext_settings.json` (and its project override) holding these preferences. */
|
|
17
|
+
export const SETTINGS_SECTION = "askUser";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { AskDisplayMode } from "./schema";
|
|
4
|
+
/** Report the session as blocked on the user (`herdr:blocked`) for the duration of `run`. */
|
|
5
|
+
export declare function whileBlocked<T>(pi: ExtensionAPI, run: () => Promise<T>): Promise<T>;
|
|
6
|
+
export interface CustomPromptRequest<T> {
|
|
7
|
+
signal?: AbortSignal | undefined;
|
|
8
|
+
timeout?: number | undefined;
|
|
9
|
+
displayMode: AskDisplayMode;
|
|
10
|
+
/** Absent when the host's own dialog answers the prompt, e.g. a question without options. */
|
|
11
|
+
createComponent?: (tui: TUI, theme: Theme, keybindings: KeybindingsManager, complete: (value: T | null) => void) => Component;
|
|
12
|
+
/** Non-TUI hosts (RPC, print) answer through dialogs instead of a custom surface. */
|
|
13
|
+
fallback: (signal: AbortSignal, deadline: number | undefined) => Promise<T | null>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Runs one prompt and owns everything around it: the surface slot, the abort
|
|
17
|
+
* forwarding, and the timeout. `null` means the user cancelled, the deadline
|
|
18
|
+
* passed, or the host has no custom UI.
|
|
19
|
+
*/
|
|
20
|
+
export declare function runCustomPrompt<T>(pi: ExtensionAPI, ctx: ExtensionContext, request: CustomPromptRequest<T>): Promise<T | null>;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { openTuiSurface } from "@hheei/pi-ext-core";
|
|
2
|
+
import { ASK_OVERLAY_MIN_WIDTH, ASK_OVERLAY_WIDTH } from "./constants";
|
|
3
|
+
/** Report the session as blocked on the user (`herdr:blocked`) for the duration of `run`. */
|
|
4
|
+
export async function whileBlocked(pi, run) {
|
|
5
|
+
pi.events.emit("herdr:blocked", { active: true, label: "Waiting for user response" });
|
|
6
|
+
try {
|
|
7
|
+
return await run();
|
|
8
|
+
}
|
|
9
|
+
finally {
|
|
10
|
+
pi.events.emit("herdr:blocked", { active: false });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function overlayOptions(displayMode) {
|
|
14
|
+
if (displayMode !== "overlay")
|
|
15
|
+
return undefined;
|
|
16
|
+
return {
|
|
17
|
+
anchor: "center",
|
|
18
|
+
width: ASK_OVERLAY_WIDTH,
|
|
19
|
+
minWidth: ASK_OVERLAY_MIN_WIDTH,
|
|
20
|
+
maxHeight: "85%",
|
|
21
|
+
margin: 1,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Runs one prompt and owns everything around it: the surface slot, the abort
|
|
26
|
+
* forwarding, and the timeout. `null` means the user cancelled, the deadline
|
|
27
|
+
* passed, or the host has no custom UI.
|
|
28
|
+
*/
|
|
29
|
+
export async function runCustomPrompt(pi, ctx, request) {
|
|
30
|
+
const { signal, timeout, displayMode, createComponent } = request;
|
|
31
|
+
const deadline = timeout && timeout > 0 ? Date.now() + timeout : undefined;
|
|
32
|
+
const local = new AbortController();
|
|
33
|
+
const abortLocally = () => local.abort(signal?.reason);
|
|
34
|
+
if (signal?.aborted)
|
|
35
|
+
return null;
|
|
36
|
+
signal?.addEventListener("abort", abortLocally, { once: true });
|
|
37
|
+
const timer = deadline === undefined ? undefined : setTimeout(abortLocally, timeout);
|
|
38
|
+
try {
|
|
39
|
+
if (ctx.mode !== "tui" || createComponent === undefined) {
|
|
40
|
+
return await request.fallback(local.signal, deadline);
|
|
41
|
+
}
|
|
42
|
+
const options = overlayOptions(displayMode);
|
|
43
|
+
const result = await openTuiSurface(pi, ctx, {
|
|
44
|
+
hostId: "ask-user",
|
|
45
|
+
signal: local.signal,
|
|
46
|
+
...(options === undefined ? {} : { overlay: true, overlayOptions: options }),
|
|
47
|
+
create: ({ tui, theme, keybindings, close }) => createComponent(tui, theme, keybindings, close),
|
|
48
|
+
});
|
|
49
|
+
return result.status === "closed" ? result.value : null;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
if (timer !== undefined)
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
signal?.removeEventListener("abort", abortLocally);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type AskUIResult, type BatchAnswer } from "./response";
|
|
3
|
+
import type { BatchQuestion } from "./schema";
|
|
4
|
+
import type { QuestionOption } from "./single-select-layout";
|
|
5
|
+
type DialogOptions = {
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
timeout?: number;
|
|
8
|
+
};
|
|
9
|
+
/** The host's text dialog, typed so the caller only deals with the answer. */
|
|
10
|
+
export declare function askText(ui: DialogUI, prompt: string, placeholder: string, options: DialogOptions | undefined): Promise<string | undefined>;
|
|
11
|
+
/** The two host dialog methods the RPC/headless fallback degrades to. */
|
|
12
|
+
type DialogUI = Pick<ExtensionUIContext, "select" | "input">;
|
|
13
|
+
/**
|
|
14
|
+
* RPC/headless fallback: use dialog methods (select/input) instead of the rich TUI overlay.
|
|
15
|
+
* ctx.ui.custom() returns undefined in RPC mode, so we degrade gracefully.
|
|
16
|
+
*/
|
|
17
|
+
export declare function askViaDialogs(ui: DialogUI, question: string, context: string | undefined, options: QuestionOption[], allowMultiple: boolean, allowFreeform: boolean, dialogOpts?: DialogOptions, deadline?: number): Promise<AskUIResult | null>;
|
|
18
|
+
/**
|
|
19
|
+
* RPC/headless fallback for a `questions` batch: ask each question in turn
|
|
20
|
+
* with the select()/input() dialogs. There is no review step here, so
|
|
21
|
+
* cancelling any question cancels the whole batch, and every dialog stage
|
|
22
|
+
* shares one deadline.
|
|
23
|
+
*/
|
|
24
|
+
export declare function askBatchViaDialogs(ui: DialogUI, questions: BatchQuestion[], signal: AbortSignal | undefined, deadline: number | undefined, heading?: string): Promise<BatchAnswer[] | null>;
|
|
25
|
+
export {};
|
package/dist/dialogs.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { FREEFORM_SENTINEL } from "./constants";
|
|
2
|
+
import { createFreeformResponse, createSelectionResponse, formatOptionsForMessage, isCancelledInput, } from "./response";
|
|
3
|
+
function parseDialogSelections(input) {
|
|
4
|
+
return input
|
|
5
|
+
.split(",")
|
|
6
|
+
.map((selection) => selection.trim())
|
|
7
|
+
.filter(Boolean);
|
|
8
|
+
}
|
|
9
|
+
/** The host's text dialog, typed so the caller only deals with the answer. */
|
|
10
|
+
export function askText(ui, prompt, placeholder, options) {
|
|
11
|
+
return ui.input(prompt, placeholder, options);
|
|
12
|
+
}
|
|
13
|
+
/** The host's single-choice dialog; `undefined` means the user dismissed it. */
|
|
14
|
+
function askSelect(ui, prompt, options, dialogOptions) {
|
|
15
|
+
return ui.select(prompt, options, dialogOptions);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Runs one dialog stage with the time the deadline has left. `null` means the
|
|
19
|
+
* prompt was already cancelled or the deadline passed, so a host that answers
|
|
20
|
+
* late cannot continue the flow.
|
|
21
|
+
*/
|
|
22
|
+
async function runDialogStage(dialogOpts, deadline, run) {
|
|
23
|
+
if (dialogOpts?.signal?.aborted)
|
|
24
|
+
return null;
|
|
25
|
+
let options = dialogOpts;
|
|
26
|
+
if (deadline !== undefined) {
|
|
27
|
+
const remaining = deadline - Date.now();
|
|
28
|
+
if (remaining <= 0)
|
|
29
|
+
return null;
|
|
30
|
+
options = { ...dialogOpts, timeout: remaining };
|
|
31
|
+
}
|
|
32
|
+
const value = await run(options);
|
|
33
|
+
return dialogOpts?.signal?.aborted ? null : { value };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* RPC/headless fallback: use dialog methods (select/input) instead of the rich TUI overlay.
|
|
37
|
+
* ctx.ui.custom() returns undefined in RPC mode, so we degrade gracefully.
|
|
38
|
+
*/
|
|
39
|
+
export async function askViaDialogs(ui, question, context, options, allowMultiple, allowFreeform, dialogOpts, deadline) {
|
|
40
|
+
const prompt = context ? `${question}\n\nContext:\n${context}` : question;
|
|
41
|
+
if (allowMultiple) {
|
|
42
|
+
const optionList = formatOptionsForMessage(options);
|
|
43
|
+
const selection = await runDialogStage(dialogOpts, deadline, (opts) => askText(ui, `${prompt}\n\nOptions (select one or more):\n${optionList}`, "Type your selection(s)...", opts));
|
|
44
|
+
const typed = selection?.value;
|
|
45
|
+
if (typed === undefined || isCancelledInput(typed))
|
|
46
|
+
return null;
|
|
47
|
+
const selections = parseDialogSelections(typed);
|
|
48
|
+
return selections.length === 0 ? null : createSelectionResponse(selections);
|
|
49
|
+
}
|
|
50
|
+
const selectOptions = options.map((option) => option.title);
|
|
51
|
+
if (allowFreeform)
|
|
52
|
+
selectOptions.push(FREEFORM_SENTINEL);
|
|
53
|
+
const selection = await runDialogStage(dialogOpts, deadline, (opts) => askSelect(ui, prompt, selectOptions, opts));
|
|
54
|
+
const value = selection?.value;
|
|
55
|
+
if (value === undefined || isCancelledInput(value))
|
|
56
|
+
return null;
|
|
57
|
+
if (value === FREEFORM_SENTINEL) {
|
|
58
|
+
const answer = await runDialogStage(dialogOpts, deadline, (opts) => askText(ui, prompt, "Type your answer...", opts));
|
|
59
|
+
const text = answer?.value;
|
|
60
|
+
if (text === undefined || isCancelledInput(text))
|
|
61
|
+
return null;
|
|
62
|
+
return createFreeformResponse(text);
|
|
63
|
+
}
|
|
64
|
+
return createSelectionResponse([value]);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* RPC/headless fallback for a `questions` batch: ask each question in turn
|
|
68
|
+
* with the select()/input() dialogs. There is no review step here, so
|
|
69
|
+
* cancelling any question cancels the whole batch, and every dialog stage
|
|
70
|
+
* shares one deadline.
|
|
71
|
+
*/
|
|
72
|
+
export async function askBatchViaDialogs(ui, questions, signal, deadline, heading) {
|
|
73
|
+
const dialogOpts = signal ? { signal } : undefined;
|
|
74
|
+
const answers = [];
|
|
75
|
+
for (const [index, entry] of questions.entries()) {
|
|
76
|
+
const title = `${heading ? `${heading}\n` : ""}(${index + 1}/${questions.length}) ${entry.question}`;
|
|
77
|
+
let response;
|
|
78
|
+
if (entry.options.length === 0) {
|
|
79
|
+
const prompt = entry.context ? `${title}\n\nContext:\n${entry.context}` : title;
|
|
80
|
+
const answer = await runDialogStage(dialogOpts, deadline, (opts) => askText(ui, prompt, "Type your answer...", opts));
|
|
81
|
+
if (answer === null)
|
|
82
|
+
return null;
|
|
83
|
+
response = createFreeformResponse(answer.value);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
response = await askViaDialogs(ui, title, entry.context, entry.options, entry.allowMultiple, entry.allowFreeform, dialogOpts, deadline);
|
|
87
|
+
}
|
|
88
|
+
if (!response)
|
|
89
|
+
return null;
|
|
90
|
+
answers.push({ status: "answered", response });
|
|
91
|
+
}
|
|
92
|
+
return answers;
|
|
93
|
+
}
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AskResponse } from "./response";
|
|
3
|
+
import type { QuestionOption } from "./single-select-layout";
|
|
4
|
+
export interface AskEventSubject {
|
|
5
|
+
question: string;
|
|
6
|
+
context?: string | undefined;
|
|
7
|
+
options: QuestionOption[];
|
|
8
|
+
}
|
|
9
|
+
/** Position of a question inside a `questions` batch, attached to its ask:* events. */
|
|
10
|
+
interface BatchPosition {
|
|
11
|
+
index: number;
|
|
12
|
+
total: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Every installed extension receives these events, so they carry only the
|
|
16
|
+
* question and the response kind; the context and the user's actual answer
|
|
17
|
+
* stay inside the tool result (#51).
|
|
18
|
+
*/
|
|
19
|
+
export declare function createAskEventEmitter(pi: ExtensionAPI): {
|
|
20
|
+
answered(subject: AskEventSubject, response: AskResponse, batch?: BatchPosition): void;
|
|
21
|
+
cancelled(subject: AskEventSubject, batch?: BatchPosition): void;
|
|
22
|
+
};
|
|
23
|
+
export {};
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every installed extension receives these events, so they carry only the
|
|
3
|
+
* question and the response kind; the context and the user's actual answer
|
|
4
|
+
* stay inside the tool result (#51).
|
|
5
|
+
*/
|
|
6
|
+
export function createAskEventEmitter(pi) {
|
|
7
|
+
return {
|
|
8
|
+
answered(subject, response, batch) {
|
|
9
|
+
pi.events.emit("ask:answered", {
|
|
10
|
+
question: subject.question,
|
|
11
|
+
response: { kind: response.kind },
|
|
12
|
+
...(batch ? { batch } : {}),
|
|
13
|
+
});
|
|
14
|
+
},
|
|
15
|
+
cancelled(subject, batch) {
|
|
16
|
+
pi.events.emit("ask:cancelled", {
|
|
17
|
+
question: subject.question,
|
|
18
|
+
...(batch ? { batch } : {}),
|
|
19
|
+
});
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AgentToolResult, AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type AskBatchDetails, type AskToolDetails } from "./response";
|
|
3
|
+
import { type AskParams } from "./schema";
|
|
4
|
+
export declare function executeSingle(pi: ExtensionAPI, params: AskParams, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback<AskToolDetails> | undefined, ctx: ExtensionContext): Promise<AgentToolResult<AskToolDetails>>;
|
|
5
|
+
export declare function executeBatch(pi: ExtensionAPI, params: AskParams, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback<AskBatchDetails> | undefined, ctx: ExtensionContext): Promise<AgentToolResult<AskBatchDetails>>;
|
package/dist/execute.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { AskComponent } from "./ask-prompt";
|
|
2
|
+
import { BatchAskComponent } from "./batch-prompt";
|
|
3
|
+
import { runCustomPrompt, whileBlocked } from "./custom-prompt";
|
|
4
|
+
import { askBatchViaDialogs, askText, askViaDialogs } from "./dialogs";
|
|
5
|
+
import { createAskEventEmitter } from "./events";
|
|
6
|
+
import { formatBatchAnswers, formatBatchForMessage, formatSingleForMessage } from "./format";
|
|
7
|
+
import { resolvePromptSettings } from "./preferences";
|
|
8
|
+
import { createFreeformResponse, formatResponseSummary, } from "./response";
|
|
9
|
+
import { batchHeading, coerceOptions, malformedOptionsError, normalizeBatchQuestions, } from "./schema";
|
|
10
|
+
/** Host text dialog for a prompt without options; shares the caller's deadline and abort. */
|
|
11
|
+
async function askForText(ctx, question, context, signal, deadline) {
|
|
12
|
+
const prompt = context ? `${question}\n\nContext:\n${context}` : question;
|
|
13
|
+
const timeout = deadline === undefined ? undefined : Math.max(1, deadline - Date.now());
|
|
14
|
+
const answer = await askText(ctx.ui, prompt, "Type your answer...", {
|
|
15
|
+
signal,
|
|
16
|
+
...(timeout === undefined ? {} : { timeout }),
|
|
17
|
+
});
|
|
18
|
+
// A host dialog that ignores the signal can still resolve after cancellation.
|
|
19
|
+
return signal.aborted ? null : createFreeformResponse(answer);
|
|
20
|
+
}
|
|
21
|
+
/** One prompt run: the surface, its deadline, the abort forwarding, and the dialog fallback. */
|
|
22
|
+
async function runInteractive(pi, ctx, request) {
|
|
23
|
+
const { settings, createComponent } = request;
|
|
24
|
+
return await whileBlocked(pi, () => runCustomPrompt(pi, ctx, {
|
|
25
|
+
signal: request.signal,
|
|
26
|
+
timeout: request.timeout,
|
|
27
|
+
displayMode: settings.displayMode,
|
|
28
|
+
...(createComponent === undefined ? {} : { createComponent }),
|
|
29
|
+
fallback: request.fallback,
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
export async function executeSingle(pi, params, signal, onUpdate, ctx) {
|
|
33
|
+
const question = params.question;
|
|
34
|
+
if (typeof question !== "string") {
|
|
35
|
+
throw new Error("ask_user needs question (one focused question) or questions (2-4 independent questions).");
|
|
36
|
+
}
|
|
37
|
+
if (signal?.aborted) {
|
|
38
|
+
return {
|
|
39
|
+
content: [{ type: "text", text: "Cancelled" }],
|
|
40
|
+
details: { question, context: params.context, options: [], response: null, cancelled: true },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const settings = await resolvePromptSettings(params, signal);
|
|
44
|
+
const context = params.context?.trim() || undefined;
|
|
45
|
+
const rawOptions = params.options ?? [];
|
|
46
|
+
const options = coerceOptions(rawOptions);
|
|
47
|
+
if (rawOptions.length > 0 && options.length === 0) {
|
|
48
|
+
throw malformedOptionsError(rawOptions.length);
|
|
49
|
+
}
|
|
50
|
+
const prompt = {
|
|
51
|
+
question,
|
|
52
|
+
context,
|
|
53
|
+
options,
|
|
54
|
+
allowMultiple: params.allowMultiple ?? false,
|
|
55
|
+
allowFreeform: params.allowFreeform ?? true,
|
|
56
|
+
};
|
|
57
|
+
const base = { question, context, options, response: null, cancelled: false };
|
|
58
|
+
if (!ctx.hasUI || !ctx.ui) {
|
|
59
|
+
throw new Error(formatSingleForMessage({
|
|
60
|
+
question,
|
|
61
|
+
context,
|
|
62
|
+
options,
|
|
63
|
+
allowFreeform: prompt.allowFreeform,
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
const events = createAskEventEmitter(pi);
|
|
67
|
+
const subject = { question, context, options };
|
|
68
|
+
if (options.length > 0) {
|
|
69
|
+
onUpdate?.({
|
|
70
|
+
content: [{ type: "text", text: "Waiting for user input..." }],
|
|
71
|
+
details: base,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const response = await runInteractive(pi, ctx, {
|
|
75
|
+
settings,
|
|
76
|
+
signal,
|
|
77
|
+
timeout: params.timeout_ms,
|
|
78
|
+
createComponent: options.length === 0
|
|
79
|
+
? undefined
|
|
80
|
+
: (tui, theme, keybindings, complete) => new AskComponent(prompt, settings, tui, theme, keybindings, complete),
|
|
81
|
+
fallback: (fallbackSignal, deadline) => options.length === 0
|
|
82
|
+
? askForText(ctx, question, context, fallbackSignal, deadline)
|
|
83
|
+
: askViaDialogs(ctx.ui, question, context, options, prompt.allowMultiple, prompt.allowFreeform, { signal: fallbackSignal }, deadline),
|
|
84
|
+
});
|
|
85
|
+
// A caller abort that lands while the prompt resolves still cancels the answer.
|
|
86
|
+
if (response === null || signal?.aborted) {
|
|
87
|
+
events.cancelled(subject);
|
|
88
|
+
return {
|
|
89
|
+
content: [{ type: "text", text: "User cancelled the question" }],
|
|
90
|
+
details: { ...base, cancelled: true },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
events.answered(subject, response);
|
|
94
|
+
return {
|
|
95
|
+
content: [{ type: "text", text: `User answered: ${formatResponseSummary(response)}` }],
|
|
96
|
+
details: { ...base, response },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
export async function executeBatch(pi, params, signal, onUpdate, ctx) {
|
|
100
|
+
const questions = normalizeBatchQuestions(params);
|
|
101
|
+
const heading = batchHeading(params);
|
|
102
|
+
const subjects = questions.map(({ question, context, options }) => ({
|
|
103
|
+
question,
|
|
104
|
+
context,
|
|
105
|
+
options,
|
|
106
|
+
}));
|
|
107
|
+
const details = (answers, cancelled) => ({
|
|
108
|
+
kind: "batch",
|
|
109
|
+
...(heading ? { heading } : {}),
|
|
110
|
+
questions: subjects,
|
|
111
|
+
answers,
|
|
112
|
+
cancelled,
|
|
113
|
+
});
|
|
114
|
+
if (signal?.aborted) {
|
|
115
|
+
return {
|
|
116
|
+
content: [{ type: "text", text: "Cancelled" }],
|
|
117
|
+
details: details([], true),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const settings = await resolvePromptSettings(params, signal);
|
|
121
|
+
const events = createAskEventEmitter(pi);
|
|
122
|
+
if (!ctx.hasUI || !ctx.ui) {
|
|
123
|
+
throw new Error(formatBatchForMessage(questions, heading));
|
|
124
|
+
}
|
|
125
|
+
onUpdate?.({
|
|
126
|
+
content: [{ type: "text", text: "Waiting for user input..." }],
|
|
127
|
+
details: details([], false),
|
|
128
|
+
});
|
|
129
|
+
const answers = await runInteractive(pi, ctx, {
|
|
130
|
+
settings,
|
|
131
|
+
signal,
|
|
132
|
+
timeout: params.timeout_ms,
|
|
133
|
+
createComponent: (tui, theme, keybindings, complete) => new BatchAskComponent(questions, settings, tui, theme, keybindings, complete, heading),
|
|
134
|
+
fallback: (fallbackSignal, deadline) => askBatchViaDialogs(ctx.ui, questions, fallbackSignal, deadline, heading),
|
|
135
|
+
});
|
|
136
|
+
if (answers === null || signal?.aborted) {
|
|
137
|
+
subjects.forEach((subject, index) => {
|
|
138
|
+
events.cancelled(subject, { index, total: subjects.length });
|
|
139
|
+
});
|
|
140
|
+
return {
|
|
141
|
+
content: [{ type: "text", text: "User cancelled the questions" }],
|
|
142
|
+
details: details([], true),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// Skipped questions emit nothing; each answered one emits its usual event.
|
|
146
|
+
answers.forEach((answer, index) => {
|
|
147
|
+
const subject = subjects[index];
|
|
148
|
+
if (answer.status === "answered" && subject) {
|
|
149
|
+
events.answered(subject, answer.response, { index, total: subjects.length });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
const result = details(answers, false);
|
|
153
|
+
return {
|
|
154
|
+
content: [{ type: "text", text: formatBatchAnswers(result) }],
|
|
155
|
+
details: result,
|
|
156
|
+
};
|
|
157
|
+
}
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type AskBatchDetails, type AskResponse } from "./response";
|
|
3
|
+
import type { BatchQuestion } from "./schema";
|
|
4
|
+
import type { QuestionOption } from "./single-select-layout";
|
|
5
|
+
export declare function formatBatchAnswers(details: AskBatchDetails): string;
|
|
6
|
+
/** Non-interactive hosts get the whole prompt as text, so the model can still ask. */
|
|
7
|
+
export declare function formatSingleForMessage(input: {
|
|
8
|
+
question: string;
|
|
9
|
+
context: string | undefined;
|
|
10
|
+
options: QuestionOption[];
|
|
11
|
+
allowFreeform: boolean;
|
|
12
|
+
}): string;
|
|
13
|
+
export declare function formatBatchForMessage(questions: BatchQuestion[], heading?: string): string;
|
|
14
|
+
/** Expanded-result block: every option with the selected ones marked. */
|
|
15
|
+
export declare function formatOptionMarkers(theme: Theme, options: QuestionOption[], response: Extract<AskResponse, {
|
|
16
|
+
kind: "selection";
|
|
17
|
+
}>, indent?: string): string;
|
|
18
|
+
export declare function formatBatchResult(theme: Theme, details: AskBatchDetails, expanded: boolean): string;
|