@pi9/ask 0.2.0 → 0.3.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/README.md +11 -0
- package/package.json +1 -1
- package/src/component.ts +5 -5
- package/src/deadline.ts +29 -4
- package/src/domain.ts +144 -0
- package/src/index.ts +32 -60
- package/src/questionnaire.ts +2 -2
- package/src/rpc.ts +24 -38
- package/src/session.ts +254 -0
- package/src/state.ts +21 -25
- package/src/config.ts +0 -30
- package/src/context.ts +0 -209
- package/src/replay-renderer.ts +0 -85
- package/src/replay.ts +0 -100
- package/src/response.ts +0 -31
- package/src/schema.ts +0 -43
- package/src/types.ts +0 -33
- package/src/validation.ts +0 -48
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@ Ask adds interactive questions to [Pi](https://github.com/earendil-works/pi-mono
|
|
|
5
5
|
## Feature overview
|
|
6
6
|
|
|
7
7
|
- **Answer revisions** — Reopen an Ask entry from `/tree` and continue with a new answer.
|
|
8
|
+
- **Tool-call rewriting** — Replace answered standalone Ask exchanges in model context with compact, decision-focused responses while preserving the original session history.
|
|
8
9
|
- **Lean tool definition** — A highly optimized description and schema minimize prompt overhead while staying compatible across providers.
|
|
9
10
|
- **Rich previews** — Inspect Markdown previews before choosing.
|
|
10
11
|
- **Multi-select** — Choose any number of options, then submit them together.
|
|
@@ -61,6 +62,16 @@ Comments let you qualify a selection without writing a separate message. When a
|
|
|
61
62
|
|
|
62
63
|
## Advanced behavior
|
|
63
64
|
|
|
65
|
+
### Tool-call rewriting
|
|
66
|
+
|
|
67
|
+
Ask rewrites answered standalone exchanges before they are sent back to the model, replacing the Ask call and its answer record with a compact `ask_response` message. The replacement keeps the question, optional background, single- or multi-select mode, selected option labels and descriptions, comments, and any freeform response. It leaves out unselected options and all Markdown previews.
|
|
68
|
+
|
|
69
|
+
The main purpose is to keep proposed alternatives from being mistaken for decisions. Once you choose an answer, the model sees only what you selected and the context attached to that selection—not every option it originally offered. As a secondary benefit, dropping unused answers, previews, and the surrounding tool-call protocol reduces the amount of context carried into later turns.
|
|
70
|
+
|
|
71
|
+
The answer can come from either a successful native tool result or a valid revision submitted from `/tree`. A revision becomes the authoritative answer, replacing the earlier result in model context even if that result is absent or unsuccessful.
|
|
72
|
+
|
|
73
|
+
This rewrite affects only the model-facing context. The original messages remain in the stored session, so the conversation still renders normally and the question can still be reopened from `/tree`. Ask rewrites only unambiguous calls and answers that validate against each other; otherwise, it leaves the messages untouched rather than risk producing a misleading response or an unmatched tool call.
|
|
74
|
+
|
|
64
75
|
### Responsive previews
|
|
65
76
|
|
|
66
77
|
Previews appear beside the options in terminals at least 88 columns wide and stack below them in narrower layouts. They support Markdown and update as you move between options. While you edit a comment or freeform response, the preview is hidden to leave more room for writing.
|
package/package.json
CHANGED
package/src/component.ts
CHANGED
|
@@ -33,11 +33,11 @@ import {
|
|
|
33
33
|
type FocusRange,
|
|
34
34
|
type ViewportOverflow,
|
|
35
35
|
} from "./viewport.js";
|
|
36
|
-
import type {
|
|
36
|
+
import type { Ask, AskAnswer } from "./domain.js";
|
|
37
37
|
|
|
38
38
|
const FRAME_WIDE_PREVIEW = true; // Set to false to compare the same layout without its outer frame.
|
|
39
39
|
|
|
40
|
-
type AskComponentOptions =
|
|
40
|
+
type AskComponentOptions = Ask & {
|
|
41
41
|
tui: TUI;
|
|
42
42
|
theme: Theme;
|
|
43
43
|
keybindings: KeybindingsManager;
|
|
@@ -377,17 +377,17 @@ export class AskComponent implements Component, Focusable {
|
|
|
377
377
|
|
|
378
378
|
if (row.kind === "option") {
|
|
379
379
|
const source = row.option;
|
|
380
|
-
const checked = this.questionnaireState.checked.has(
|
|
380
|
+
const checked = this.questionnaireState.checked.has(row.index);
|
|
381
381
|
const badge = this.questionnaireState.config.allowMultiple && checked
|
|
382
382
|
? this.config.theme.fg("success", " [selected]")
|
|
383
383
|
: "";
|
|
384
|
-
const comment = this.questionnaireState.comments.has(
|
|
384
|
+
const comment = this.questionnaireState.comments.has(row.index)
|
|
385
385
|
? this.config.theme.fg("warning", " ✎")
|
|
386
386
|
: "";
|
|
387
387
|
addPrefixed(marker, `${source.label}${badge}${comment}`, selected ? "accent" : "text");
|
|
388
388
|
if (source.description) addPrefixed(" ", source.description, "muted");
|
|
389
389
|
if (selected) {
|
|
390
|
-
const commentText = this.questionnaireState.comments.get(
|
|
390
|
+
const commentText = this.questionnaireState.comments.get(row.index);
|
|
391
391
|
if (commentText) addPrefixed(" ", `✎ ${commentText}`, "dim");
|
|
392
392
|
}
|
|
393
393
|
} else {
|
package/src/deadline.ts
CHANGED
|
@@ -1,16 +1,41 @@
|
|
|
1
|
+
export const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
2
|
+
|
|
3
|
+
type AskEnvironment = Readonly<{
|
|
4
|
+
PI9_ASK_TIMEOUT_MS?: string;
|
|
5
|
+
}>;
|
|
6
|
+
|
|
1
7
|
export interface DeadlineSignal {
|
|
2
8
|
signal: AbortSignal | undefined;
|
|
3
9
|
readonly timedOut: boolean;
|
|
4
10
|
dispose(): void;
|
|
5
11
|
}
|
|
6
12
|
|
|
13
|
+
export function resolveTimeoutMs(
|
|
14
|
+
perCallTimeout: number | undefined,
|
|
15
|
+
env: AskEnvironment,
|
|
16
|
+
): number | undefined {
|
|
17
|
+
if (perCallTimeout !== undefined) {
|
|
18
|
+
return Number.isInteger(perCallTimeout) && perCallTimeout > 0 && perCallTimeout <= MAX_TIMEOUT_MS
|
|
19
|
+
? perCallTimeout
|
|
20
|
+
: undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const envTimeout = env.PI9_ASK_TIMEOUT_MS;
|
|
24
|
+
if (envTimeout === undefined || !/^\d+$/.test(envTimeout)) return undefined;
|
|
25
|
+
|
|
26
|
+
const timeout = Number(envTimeout);
|
|
27
|
+
return Number.isInteger(timeout) && timeout > 0 && timeout <= MAX_TIMEOUT_MS
|
|
28
|
+
? timeout
|
|
29
|
+
: undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
7
32
|
export function createDeadlineSignal(
|
|
8
33
|
parent: AbortSignal | undefined,
|
|
9
|
-
|
|
34
|
+
perCallTimeout: number | undefined,
|
|
35
|
+
env: AskEnvironment = {},
|
|
10
36
|
): DeadlineSignal {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
&& timeoutMs > 0;
|
|
37
|
+
const timeoutMs = resolveTimeoutMs(perCallTimeout, env);
|
|
38
|
+
const hasTimeout = timeoutMs !== undefined;
|
|
14
39
|
|
|
15
40
|
if (parent === undefined && !hasTimeout) {
|
|
16
41
|
return {
|
package/src/domain.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Type, type Static } from "typebox";
|
|
2
|
+
import { Check } from "typebox/value";
|
|
3
|
+
|
|
4
|
+
import { MAX_TIMEOUT_MS } from "./deadline.js";
|
|
5
|
+
|
|
6
|
+
export const AskOptionSchema = Type.Object({
|
|
7
|
+
label: Type.String({ minLength: 1, description: "Short option title; keep distinct across options." }),
|
|
8
|
+
description: Type.Optional(Type.String({ description: "Brief explanation when the label alone is ambiguous." })),
|
|
9
|
+
preview: Type.Optional(Type.String({ description: "Markdown shown to the user, e.g. code, plans, or comparisons; not returned in the answer." })),
|
|
10
|
+
}, { additionalProperties: false });
|
|
11
|
+
|
|
12
|
+
export const AskParamsSchema = Type.Object({
|
|
13
|
+
question: Type.String({ minLength: 1 }),
|
|
14
|
+
context: Type.Optional(Type.String({ description: "Optional background shown above the question, e.g. what prompted the ask." })),
|
|
15
|
+
options: Type.Array(AskOptionSchema, { minItems: 1, description: "Suggested answers." }),
|
|
16
|
+
allowMultiple: Type.Optional(Type.Boolean({ default: false, description: "Allow selecting multiple options. Defaults to false." })),
|
|
17
|
+
allowFreeform: Type.Optional(Type.Boolean({ default: true, description: "Allow a typed response. Defaults to true." })),
|
|
18
|
+
timeout: Type.Optional(Type.Integer({ minimum: 0, maximum: MAX_TIMEOUT_MS, description: "Timeout in ms for answers that would go stale; the call returns unanswered on expiry. Zero disables any default." })),
|
|
19
|
+
}, { additionalProperties: false });
|
|
20
|
+
|
|
21
|
+
export const AskSelectionSchema = Type.Object({
|
|
22
|
+
option: Type.Integer({ minimum: 0 }),
|
|
23
|
+
comment: Type.Optional(Type.String({ minLength: 1 })),
|
|
24
|
+
}, { additionalProperties: false });
|
|
25
|
+
|
|
26
|
+
export const AskAnswerSchema = Type.Object({
|
|
27
|
+
selections: Type.Array(AskSelectionSchema),
|
|
28
|
+
freeform: Type.Optional(Type.String({ minLength: 1 })),
|
|
29
|
+
}, { additionalProperties: false });
|
|
30
|
+
|
|
31
|
+
export const AskReplayDetailsSchema = Type.Object({
|
|
32
|
+
toolCallId: Type.String({ minLength: 1 }),
|
|
33
|
+
answer: AskAnswerSchema,
|
|
34
|
+
}, { additionalProperties: false });
|
|
35
|
+
|
|
36
|
+
export const AskAnsweredDetailsSchema = Type.Object({
|
|
37
|
+
status: Type.Literal("answered"),
|
|
38
|
+
answer: AskAnswerSchema,
|
|
39
|
+
}, { additionalProperties: false });
|
|
40
|
+
|
|
41
|
+
export type AskOption = Static<typeof AskOptionSchema>;
|
|
42
|
+
export type AskParams = Static<typeof AskParamsSchema>;
|
|
43
|
+
export type AskSelection = Static<typeof AskSelectionSchema>;
|
|
44
|
+
export type AskAnswer = Static<typeof AskAnswerSchema>;
|
|
45
|
+
export type AskReplayDetails = Static<typeof AskReplayDetailsSchema>;
|
|
46
|
+
export type AskAnsweredDetails = Static<typeof AskAnsweredDetailsSchema>;
|
|
47
|
+
|
|
48
|
+
export type Ask = Omit<AskParams, "allowMultiple" | "allowFreeform"> & {
|
|
49
|
+
allowMultiple: boolean;
|
|
50
|
+
allowFreeform: boolean;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type AskToolDetails =
|
|
54
|
+
| AskAnsweredDetails
|
|
55
|
+
| { status: "unanswered" | "cancelled" | "ui_unavailable" };
|
|
56
|
+
|
|
57
|
+
export type AskResponse = {
|
|
58
|
+
content: Array<{ type: "text"; text: string }>;
|
|
59
|
+
details: AskToolDetails;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function normalizeAsk(params: AskParams): Ask {
|
|
63
|
+
const question = params.question.trim();
|
|
64
|
+
if (!question) throw new Error("Ask question must not be empty.");
|
|
65
|
+
|
|
66
|
+
const context = trimOptional(params.context);
|
|
67
|
+
const options = params.options.map(normalizeOption);
|
|
68
|
+
if (options.length === 0) throw new Error("Ask needs at least one option.");
|
|
69
|
+
|
|
70
|
+
const labels = new Set<string>();
|
|
71
|
+
for (const option of options) {
|
|
72
|
+
if (labels.has(option.label)) throw new Error(`Ask options contain duplicate label: ${option.label}.`);
|
|
73
|
+
labels.add(option.label);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
question,
|
|
78
|
+
...(context === undefined ? {} : { context }),
|
|
79
|
+
options,
|
|
80
|
+
allowMultiple: params.allowMultiple ?? false,
|
|
81
|
+
allowFreeform: params.allowFreeform ?? true,
|
|
82
|
+
...(params.timeout === undefined ? {} : { timeout: params.timeout }),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function parseAskReplayDetails(value: unknown): AskReplayDetails | undefined {
|
|
87
|
+
return Check(AskReplayDetailsSchema, value) ? value : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function answerMatchesAsk(answer: AskAnswer, ask: Ask): boolean {
|
|
91
|
+
if (answer.selections.length > 1 && !ask.allowMultiple) return false;
|
|
92
|
+
if (answer.freeform !== undefined && !ask.allowFreeform) return false;
|
|
93
|
+
|
|
94
|
+
const selected = new Set<number>();
|
|
95
|
+
for (const selection of answer.selections) {
|
|
96
|
+
if (selection.option >= ask.options.length || selected.has(selection.option)) return false;
|
|
97
|
+
selected.add(selection.option);
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function formatAskAnswer(ask: Ask, answer: AskAnswer): string {
|
|
103
|
+
const lines = answer.selections.map((selection) => {
|
|
104
|
+
const option = ask.options[selection.option]!;
|
|
105
|
+
const description = option.description ? ` — ${option.description}` : "";
|
|
106
|
+
const comment = selection.comment ? ` (${selection.comment})` : "";
|
|
107
|
+
return `Selected: ${option.label}${description}${comment}`;
|
|
108
|
+
});
|
|
109
|
+
if (answer.freeform) lines.push(`Freeform: ${answer.freeform}`);
|
|
110
|
+
return lines.join("\n") || "No answer provided.";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function buildAskResponse(ask: Ask, details: AskToolDetails): AskResponse {
|
|
114
|
+
if (details.status === "answered") {
|
|
115
|
+
return {
|
|
116
|
+
content: [{ type: "text", text: formatAskAnswer(ask, details.answer) }],
|
|
117
|
+
details,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const text = {
|
|
122
|
+
unanswered: "The question timed out without an answer.",
|
|
123
|
+
cancelled: "User cancelled the question.",
|
|
124
|
+
ui_unavailable: "Interactive UI is unavailable.",
|
|
125
|
+
}[details.status];
|
|
126
|
+
return { content: [{ type: "text", text }], details };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizeOption(option: AskOption): AskOption {
|
|
130
|
+
const label = option.label.trim();
|
|
131
|
+
if (!label) throw new Error("Ask option label must not be empty.");
|
|
132
|
+
const description = trimOptional(option.description);
|
|
133
|
+
const preview = option.preview?.trim() ? option.preview : undefined;
|
|
134
|
+
return {
|
|
135
|
+
label,
|
|
136
|
+
...(description === undefined ? {} : { description }),
|
|
137
|
+
...(preview === undefined ? {} : { preview }),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function trimOptional(value: string | undefined): string | undefined {
|
|
142
|
+
const trimmed = value?.trim();
|
|
143
|
+
return trimmed || undefined;
|
|
144
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
import type { ExtensionAPI, SessionEntry, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Text, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
|
|
3
3
|
|
|
4
|
-
import { rewriteAskContext } from "./context.js";
|
|
5
|
-
import { resolveTimeoutMs } from "./config.js";
|
|
6
4
|
import { createDeadlineSignal } from "./deadline.js";
|
|
5
|
+
import {
|
|
6
|
+
AskParamsSchema,
|
|
7
|
+
buildAskResponse,
|
|
8
|
+
normalizeAsk,
|
|
9
|
+
parseAskReplayDetails,
|
|
10
|
+
type AskAnswer,
|
|
11
|
+
type AskParams,
|
|
12
|
+
type AskToolDetails,
|
|
13
|
+
} from "./domain.js";
|
|
7
14
|
import { CHECKED_BOX, CHECKED_CIRCLE, EMPTY_BOX, EMPTY_CIRCLE } from "./glyphs.js";
|
|
8
15
|
import { launchQuestionnaire } from "./questionnaire.js";
|
|
9
|
-
import { renderAskReanswerMessage } from "./replay-renderer.js";
|
|
10
|
-
import { ASK_REPLAY_CUSTOM_TYPE, buildAskReplayMessage, parseAskReplayDetails, resolveAskReplayTarget } from "./replay.js";
|
|
11
|
-
import { buildAnsweredResponse, buildCancelledResponse, buildUiUnavailableResponse, buildUnansweredResponse } from "./response.js";
|
|
12
16
|
import { askWithRpc } from "./rpc.js";
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
import {
|
|
18
|
+
ASK_REPLAY_CUSTOM_TYPE,
|
|
19
|
+
buildAskReplayMessage,
|
|
20
|
+
resolveAskReplayTarget,
|
|
21
|
+
rewriteAskContext,
|
|
22
|
+
} from "./session.js";
|
|
18
23
|
|
|
19
24
|
type AskRendererState = {
|
|
20
25
|
callComponent?: Text;
|
|
@@ -23,13 +28,8 @@ type AskRendererState = {
|
|
|
23
28
|
};
|
|
24
29
|
|
|
25
30
|
type AskReplayState =
|
|
26
|
-
| { status: "idle" }
|
|
27
|
-
| { status: "
|
|
28
|
-
| {
|
|
29
|
-
status: "dispatched";
|
|
30
|
-
details: ReturnType<typeof buildAskReplayMessage>["details"];
|
|
31
|
-
clearEditor: boolean;
|
|
32
|
-
};
|
|
31
|
+
| { status: "idle" | "prompting" }
|
|
32
|
+
| { status: "dispatched"; details: ReturnType<typeof buildAskReplayMessage>["details"] };
|
|
33
33
|
|
|
34
34
|
function renderAskCall(args: AskParams, theme: Theme, state: AskRendererState): string {
|
|
35
35
|
const questionColor = state.status === "answered" ? "text" : "muted";
|
|
@@ -60,12 +60,12 @@ class AnsweredOptions implements Component {
|
|
|
60
60
|
invalidate(): void {}
|
|
61
61
|
|
|
62
62
|
render(width: number): string[] {
|
|
63
|
-
const selections = new Map(this.answer.selections.map(selection => [selection.
|
|
63
|
+
const selections = new Map(this.answer.selections.map(selection => [selection.option, selection]));
|
|
64
64
|
const checkedGlyph = this.args.allowMultiple === true ? CHECKED_BOX : CHECKED_CIRCLE;
|
|
65
65
|
const emptyGlyph = this.args.allowMultiple === true ? EMPTY_BOX : EMPTY_CIRCLE;
|
|
66
|
-
const rows = this.args.options.map(option => {
|
|
66
|
+
const rows = this.args.options.map((option, index) => {
|
|
67
67
|
const label = option.label.trim();
|
|
68
|
-
const selection = selections.get(
|
|
68
|
+
const selection = selections.get(index);
|
|
69
69
|
const comment = selection?.comment ? ` (${selection.comment})` : "";
|
|
70
70
|
return {
|
|
71
71
|
marker: this.theme.fg(selection ? "success" : "muted", selection ? checkedGlyph : emptyGlyph),
|
|
@@ -99,7 +99,6 @@ function wrapAnsweredRow(prefix: string, text: string, width: number): string[]
|
|
|
99
99
|
|
|
100
100
|
export default function askExtension(pi: ExtensionAPI) {
|
|
101
101
|
let replayState: AskReplayState = { status: "idle" };
|
|
102
|
-
let replayTreeSelection = false;
|
|
103
102
|
const revisedAnswers = new Map<string, AskAnswer>();
|
|
104
103
|
const rendererStates = new Map<string, AskRendererState>();
|
|
105
104
|
|
|
@@ -130,29 +129,17 @@ export default function askExtension(pi: ExtensionAPI) {
|
|
|
130
129
|
});
|
|
131
130
|
pi.on("before_agent_start", (_event, ctx) => reconcileAskTool(ctx.hasUI));
|
|
132
131
|
pi.on("context", (event) => ({ messages: rewriteAskContext(event.messages) }));
|
|
133
|
-
pi.on("agent_settled", (
|
|
132
|
+
pi.on("agent_settled", () => {
|
|
134
133
|
if (replayState.status !== "dispatched") return;
|
|
135
|
-
if (replayState.clearEditor) {
|
|
136
|
-
replayState = { ...replayState, clearEditor: false };
|
|
137
|
-
setTimeout(() => ctx.ui.setEditorText(""), 0);
|
|
138
|
-
}
|
|
139
134
|
pi.events.emit("ask:reanswered", replayState.details);
|
|
140
135
|
replayState = { status: "idle" };
|
|
141
136
|
});
|
|
142
137
|
pi.on("session_shutdown", () => {
|
|
143
138
|
replayState = { status: "idle" };
|
|
144
|
-
replayTreeSelection = false;
|
|
145
139
|
revisedAnswers.clear();
|
|
146
140
|
rendererStates.clear();
|
|
147
141
|
});
|
|
148
|
-
pi.registerMessageRenderer(ASK_REPLAY_CUSTOM_TYPE, renderAskReanswerMessage);
|
|
149
|
-
pi.on("session_before_tree", (event, ctx) => {
|
|
150
|
-
const target = ctx.sessionManager.getEntry(event.preparation.targetId);
|
|
151
|
-
replayTreeSelection = target?.type === "custom_message" && target.customType === ASK_REPLAY_CUSTOM_TYPE;
|
|
152
|
-
});
|
|
153
142
|
pi.on("session_tree", async (event, ctx) => {
|
|
154
|
-
const suppressEditorRestore = replayTreeSelection;
|
|
155
|
-
replayTreeSelection = false;
|
|
156
143
|
if (ctx.mode !== "tui" || replayState.status !== "idle") return;
|
|
157
144
|
|
|
158
145
|
const entries = ctx.sessionManager.getBranch();
|
|
@@ -167,30 +154,18 @@ export default function askExtension(pi: ExtensionAPI) {
|
|
|
167
154
|
return;
|
|
168
155
|
}
|
|
169
156
|
|
|
170
|
-
// Pi restores selected custom-message content after this hook returns. Keep
|
|
171
|
-
// the editor temporarily non-empty until the replay turn settles so the
|
|
172
|
-
// selected marker text cannot replace the guard.
|
|
173
|
-
const guardEditor = suppressEditorRestore && !ctx.ui.getEditorText().trim();
|
|
174
|
-
if (guardEditor) ctx.ui.setEditorText(TREE_EDITOR_GUARD);
|
|
175
|
-
|
|
176
157
|
replayState = { status: "prompting" };
|
|
177
|
-
const deadline = createDeadlineSignal(
|
|
178
|
-
undefined,
|
|
179
|
-
resolveTimeoutMs(resolution.params.timeout, process.env),
|
|
180
|
-
);
|
|
158
|
+
const deadline = createDeadlineSignal(undefined, resolution.ask.timeout, process.env);
|
|
181
159
|
try {
|
|
182
|
-
const answer = await launchQuestionnaire(ctx, resolution.
|
|
160
|
+
const answer = await launchQuestionnaire(ctx, resolution.ask, deadline.signal);
|
|
183
161
|
if (!answer) return;
|
|
184
|
-
const message = buildAskReplayMessage(resolution.toolCallId,
|
|
162
|
+
const message = buildAskReplayMessage(resolution.toolCallId, answer);
|
|
185
163
|
pi.sendMessage(message, { triggerTurn: true, deliverAs: "followUp" });
|
|
186
164
|
applyRevision(message.details.toolCallId, message.details.answer);
|
|
187
|
-
replayState = { status: "dispatched", details: message.details
|
|
165
|
+
replayState = { status: "dispatched", details: message.details };
|
|
188
166
|
} finally {
|
|
189
167
|
deadline.dispose();
|
|
190
|
-
if (replayState.status !== "dispatched") {
|
|
191
|
-
replayState = { status: "idle" };
|
|
192
|
-
if (guardEditor) setTimeout(() => ctx.ui.setEditorText(""), 0);
|
|
193
|
-
}
|
|
168
|
+
if (replayState.status !== "dispatched") replayState = { status: "idle" };
|
|
194
169
|
}
|
|
195
170
|
});
|
|
196
171
|
|
|
@@ -207,29 +182,26 @@ export default function askExtension(pi: ExtensionAPI) {
|
|
|
207
182
|
executionMode: "sequential",
|
|
208
183
|
|
|
209
184
|
async execute(_toolCallId, rawParams, signal, _onUpdate, ctx) {
|
|
210
|
-
const params =
|
|
211
|
-
if (!ctx.hasUI) return
|
|
185
|
+
const params = normalizeAsk(rawParams as AskParams);
|
|
186
|
+
if (!ctx.hasUI) return buildAskResponse(params, { status: "ui_unavailable" });
|
|
212
187
|
|
|
213
|
-
const deadline = createDeadlineSignal(
|
|
214
|
-
signal,
|
|
215
|
-
resolveTimeoutMs(params.timeout, process.env),
|
|
216
|
-
);
|
|
188
|
+
const deadline = createDeadlineSignal(signal, params.timeout, process.env);
|
|
217
189
|
try {
|
|
218
190
|
const answer = ctx.mode === "tui"
|
|
219
191
|
? await launchQuestionnaire(ctx, params, deadline.signal)
|
|
220
192
|
: await askWithRpc(ctx.ui, params, deadline.signal);
|
|
221
193
|
if (answer === null) {
|
|
222
194
|
if (deadline.timedOut) {
|
|
223
|
-
const result =
|
|
195
|
+
const result = buildAskResponse(params, { status: "unanswered" });
|
|
224
196
|
pi.events.emit("ask:unanswered", result.details);
|
|
225
197
|
return result;
|
|
226
198
|
}
|
|
227
|
-
const result =
|
|
199
|
+
const result = buildAskResponse(params, { status: "cancelled" });
|
|
228
200
|
pi.events.emit("ask:cancelled", result.details);
|
|
229
201
|
return result;
|
|
230
202
|
}
|
|
231
203
|
|
|
232
|
-
const result =
|
|
204
|
+
const result = buildAskResponse(params, { status: "answered", answer });
|
|
233
205
|
pi.events.emit("ask:answered", result.details);
|
|
234
206
|
return result;
|
|
235
207
|
} finally {
|
package/src/questionnaire.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
3
|
import { AskComponent } from "./component.js";
|
|
4
|
-
import type {
|
|
4
|
+
import type { Ask, AskAnswer } from "./domain.js";
|
|
5
5
|
|
|
6
6
|
interface QuestionnaireLaunchContext {
|
|
7
7
|
ui: Pick<ExtensionUIContext, "custom">;
|
|
@@ -9,7 +9,7 @@ interface QuestionnaireLaunchContext {
|
|
|
9
9
|
|
|
10
10
|
export async function launchQuestionnaire(
|
|
11
11
|
ctx: QuestionnaireLaunchContext,
|
|
12
|
-
params:
|
|
12
|
+
params: Ask,
|
|
13
13
|
signal?: AbortSignal,
|
|
14
14
|
): Promise<AskAnswer | null> {
|
|
15
15
|
let abortListener: (() => void) | undefined;
|
package/src/rpc.ts
CHANGED
|
@@ -1,101 +1,93 @@
|
|
|
1
|
-
import type { AskAnswer, AskOption,
|
|
1
|
+
import type { Ask, AskAnswer, AskOption, AskSelection } from "./domain.js";
|
|
2
2
|
|
|
3
3
|
export interface AskDialogUI {
|
|
4
4
|
select(title: string, options: string[], dialogOptions?: { signal?: AbortSignal }): Promise<string | undefined>;
|
|
5
5
|
input(title: string, placeholder?: string, dialogOptions?: { signal?: AbortSignal }): Promise<string | undefined>;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
type AskSelection = AskAnswer["selections"][number];
|
|
9
|
-
|
|
10
8
|
const FREEFORM_CHOICE = "Type a response…";
|
|
11
9
|
|
|
12
10
|
export async function askWithRpc(
|
|
13
11
|
ui: AskDialogUI,
|
|
14
|
-
|
|
12
|
+
ask: Ask,
|
|
15
13
|
signal?: AbortSignal,
|
|
16
14
|
): Promise<AskAnswer | null> {
|
|
17
|
-
const prompt =
|
|
15
|
+
const prompt = ask.context ? `${ask.context}\n\n${ask.question}` : ask.question;
|
|
18
16
|
if (signal?.aborted) return null;
|
|
19
17
|
|
|
20
|
-
return
|
|
21
|
-
? runMultiSelect(ui, prompt,
|
|
22
|
-
: runSingleSelect(ui, prompt,
|
|
18
|
+
return ask.allowMultiple
|
|
19
|
+
? runMultiSelect(ui, prompt, ask, signal)
|
|
20
|
+
: runSingleSelect(ui, prompt, ask, signal);
|
|
23
21
|
}
|
|
24
22
|
|
|
25
23
|
async function runSingleSelect(
|
|
26
24
|
ui: AskDialogUI,
|
|
27
25
|
prompt: string,
|
|
28
|
-
|
|
29
|
-
allowFreeform: boolean,
|
|
26
|
+
ask: Ask,
|
|
30
27
|
signal?: AbortSignal,
|
|
31
28
|
): Promise<AskAnswer | null> {
|
|
32
|
-
const choices = options.map((option, index) => `${index + 1}. ${formatOption(option)}`);
|
|
33
|
-
if (allowFreeform) choices.push(`${options.length + 1}. ${FREEFORM_CHOICE}`);
|
|
29
|
+
const choices = ask.options.map((option, index) => `${index + 1}. ${formatOption(option)}`);
|
|
30
|
+
if (ask.allowFreeform) choices.push(`${ask.options.length + 1}. ${FREEFORM_CHOICE}`);
|
|
34
31
|
|
|
35
32
|
const selected = await selectDialog(ui, prompt, choices, signal);
|
|
36
33
|
if (selected == null || signal?.aborted) return null;
|
|
37
34
|
|
|
38
35
|
const selectedIndex = choices.indexOf(selected);
|
|
39
36
|
if (selectedIndex === -1) return null;
|
|
40
|
-
if (selectedIndex === options.length) {
|
|
37
|
+
if (selectedIndex === ask.options.length) {
|
|
41
38
|
const freeform = await readInput(ui, prompt, signal);
|
|
42
39
|
return freeform === null ? null : makeAnswer([], freeform);
|
|
43
40
|
}
|
|
44
41
|
|
|
45
|
-
const
|
|
46
|
-
if (!selectedOption) return null;
|
|
47
|
-
const selections = await collectComments(ui, prompt, [selectedOption], signal);
|
|
42
|
+
const selections = await collectComments(ui, prompt, ask.options, [selectedIndex], signal);
|
|
48
43
|
return selections === null ? null : makeAnswer(selections);
|
|
49
44
|
}
|
|
50
45
|
|
|
51
46
|
async function runMultiSelect(
|
|
52
47
|
ui: AskDialogUI,
|
|
53
48
|
prompt: string,
|
|
54
|
-
|
|
55
|
-
allowFreeform: boolean,
|
|
49
|
+
ask: Ask,
|
|
56
50
|
signal?: AbortSignal,
|
|
57
51
|
): Promise<AskAnswer | null> {
|
|
58
|
-
const numberedOptions = options
|
|
52
|
+
const numberedOptions = ask.options
|
|
59
53
|
.map((option, index) => `${index + 1}. ${formatOption(option)}`)
|
|
60
54
|
.join("\n");
|
|
61
55
|
const selectionPrompt = `${prompt}\n\n${numberedOptions}\n\nEnter option numbers separated by commas:`;
|
|
62
56
|
const rawSelection = await readInput(ui, selectionPrompt, signal);
|
|
63
57
|
if (rawSelection === null) return null;
|
|
64
58
|
|
|
65
|
-
const
|
|
66
|
-
const freeform = allowFreeform
|
|
59
|
+
const selected = parseSelections(rawSelection, ask.options.length);
|
|
60
|
+
const freeform = ask.allowFreeform
|
|
67
61
|
? await readInput(ui, `${prompt}\n\nAdditional response (optional):`, signal)
|
|
68
62
|
: undefined;
|
|
69
63
|
if (freeform === null) return null;
|
|
70
64
|
|
|
71
|
-
const selections = await collectComments(ui, prompt,
|
|
65
|
+
const selections = await collectComments(ui, prompt, ask.options, selected, signal);
|
|
72
66
|
return selections === null ? null : makeAnswer(selections, freeform);
|
|
73
67
|
}
|
|
74
68
|
|
|
75
|
-
function parseSelections(raw: string,
|
|
76
|
-
const
|
|
69
|
+
function parseSelections(raw: string, optionCount: number): number[] {
|
|
70
|
+
const selected = new Set<number>();
|
|
77
71
|
for (const token of raw.split(",")) {
|
|
78
72
|
const value = token.trim();
|
|
79
73
|
if (!/^\d+$/.test(value)) continue;
|
|
80
74
|
|
|
81
75
|
const index = Number(value) - 1;
|
|
82
|
-
if (Number.isSafeInteger(index) && index >= 0 && index <
|
|
76
|
+
if (Number.isSafeInteger(index) && index >= 0 && index < optionCount) selected.add(index);
|
|
83
77
|
}
|
|
84
|
-
return
|
|
78
|
+
return [...selected].sort((a, b) => a - b);
|
|
85
79
|
}
|
|
86
80
|
|
|
87
81
|
async function collectComments(
|
|
88
82
|
ui: AskDialogUI,
|
|
89
83
|
prompt: string,
|
|
90
84
|
options: AskOption[],
|
|
85
|
+
selected: number[],
|
|
91
86
|
signal?: AbortSignal,
|
|
92
87
|
): Promise<AskSelection[] | null> {
|
|
93
|
-
const selections: AskSelection[] =
|
|
94
|
-
for (
|
|
95
|
-
const option = options[
|
|
96
|
-
const selection = selections[index];
|
|
97
|
-
if (!option || !selection) continue;
|
|
98
|
-
|
|
88
|
+
const selections: AskSelection[] = selected.map(option => ({ option }));
|
|
89
|
+
for (const selection of selections) {
|
|
90
|
+
const option = options[selection.option]!;
|
|
99
91
|
const comment = await readInput(ui, `${prompt}\n\nComment for \"${option.label}\" (optional):`, signal);
|
|
100
92
|
if (comment === null) return null;
|
|
101
93
|
if (comment) selection.comment = comment;
|
|
@@ -110,12 +102,6 @@ async function readInput(ui: AskDialogUI, title: string, signal?: AbortSignal):
|
|
|
110
102
|
return value.trim();
|
|
111
103
|
}
|
|
112
104
|
|
|
113
|
-
function toSelection(option: AskOption): AskSelection {
|
|
114
|
-
const selection: AskSelection = { label: option.label };
|
|
115
|
-
if (option.description !== undefined) selection.description = option.description;
|
|
116
|
-
return selection;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
105
|
function makeAnswer(selections: AskSelection[], freeform?: string): AskAnswer {
|
|
120
106
|
return freeform ? { selections, freeform } : { selections };
|
|
121
107
|
}
|