@pi9/ask 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi9/ask",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Preview-rich, replayable Pi questions with multi-select and comments.",
5
5
  "license": "MIT",
6
6
  "author": "Chase Cummings <chaseecummings@gmail.com>",
package/src/component.ts CHANGED
@@ -33,11 +33,12 @@ import {
33
33
  type FocusRange,
34
34
  type ViewportOverflow,
35
35
  } from "./viewport.js";
36
- import type { AskAnswer, ValidatedAskParams } from "./types.js";
36
+ import type { Ask, AskAnswer } from "./domain.js";
37
+ import { CHECKED_BOX, EMPTY_BOX } from "./glyphs.js";
37
38
 
38
39
  const FRAME_WIDE_PREVIEW = true; // Set to false to compare the same layout without its outer frame.
39
40
 
40
- type AskComponentOptions = ValidatedAskParams & {
41
+ type AskComponentOptions = Ask & {
41
42
  tui: TUI;
42
43
  theme: Theme;
43
44
  keybindings: KeybindingsManager;
@@ -368,36 +369,37 @@ export class AskComponent implements Component, Focusable {
368
369
  addWrappedWithPrefix(lines, prefix, styled, width);
369
370
  };
370
371
 
372
+ const multiple = this.questionnaireState.config.allowMultiple;
371
373
  let focus: FocusRange | undefined;
372
374
  for (const [index, row] of this.questionnaireState.rows.entries()) {
373
375
  if (row.kind === "submit") continue;
374
376
  const selected = this.questionnaireState.highlightedRow === index;
375
377
  const start = lines.length;
376
378
  const marker = selected ? this.config.theme.fg("accent", "┃ ") : " ";
379
+ const checked = row.kind === "option"
380
+ ? this.questionnaireState.checked.has(row.index)
381
+ : this.questionnaireState.freeformChecked;
382
+ const checkboxColor = checked ? "success" : selected ? "text" : "muted";
383
+ const checkbox = multiple
384
+ ? `${this.config.theme.fg(checkboxColor, checked ? CHECKED_BOX : EMPTY_BOX)} `
385
+ : "";
377
386
 
378
387
  if (row.kind === "option") {
379
388
  const source = row.option;
380
- const checked = this.questionnaireState.checked.has(source.label);
381
- const badge = this.questionnaireState.config.allowMultiple && checked
382
- ? this.config.theme.fg("success", " [selected]")
383
- : "";
384
- const comment = this.questionnaireState.comments.has(source.label)
389
+ const comment = this.questionnaireState.comments.has(row.index)
385
390
  ? this.config.theme.fg("warning", " ✎")
386
391
  : "";
387
- addPrefixed(marker, `${source.label}${badge}${comment}`, selected ? "accent" : "text");
388
- if (source.description) addPrefixed(" ", source.description, "muted");
392
+ addPrefixed(`${marker}${checkbox}`, `${source.label}${comment}`, selected ? "accent" : "text");
393
+ if (source.description) addPrefixed(multiple ? " " : " ", source.description, "muted");
389
394
  if (selected) {
390
- const commentText = this.questionnaireState.comments.get(source.label);
391
- if (commentText) addPrefixed(" ", `✎ ${commentText}`, "dim");
395
+ const commentText = this.questionnaireState.comments.get(row.index);
396
+ if (commentText) addPrefixed(multiple ? " " : " ", `✎ ${commentText}`, "dim");
392
397
  }
393
398
  } else {
394
- const badge = this.questionnaireState.config.allowMultiple && this.questionnaireState.freeformChecked
395
- ? this.config.theme.fg("success", " [selected]")
396
- : "";
397
399
  const suffix = this.questionnaireState.freeformDraft
398
400
  ? ` — ${this.questionnaireState.freeformDraft}`
399
401
  : "";
400
- addPrefixed(marker, `Type a response…${suffix}${badge}`, selected ? "accent" : "text");
402
+ addPrefixed(`${marker}${checkbox}`, `Type a response…${suffix}`, selected ? "accent" : "text");
401
403
  }
402
404
  if (selected) focus = { start, end: lines.length };
403
405
  afterRow?.(row);
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
- timeoutMs: number | undefined,
34
+ perCallTimeout: number | undefined,
35
+ env: AskEnvironment = {},
10
36
  ): DeadlineSignal {
11
- const hasTimeout = timeoutMs !== undefined
12
- && Number.isFinite(timeoutMs)
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 { AskParamsSchema } from "./schema.js";
14
- import type { AskAnswer, AskParams, AskToolDetails } from "./types.js";
15
- import { validateAskParams } from "./validation.js";
16
-
17
- const TREE_EDITOR_GUARD = "\u200B";
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: "prompting" }
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.label, 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(label);
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", (_event, ctx) => {
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.params, deadline.signal);
160
+ const answer = await launchQuestionnaire(ctx, resolution.ask, deadline.signal);
183
161
  if (!answer) return;
184
- const message = buildAskReplayMessage(resolution.toolCallId, resolution.params, answer);
162
+ const message = buildAskReplayMessage(resolution.toolCallId, resolution.ask, 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, clearEditor: guardEditor };
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 = validateAskParams(rawParams as AskParams);
211
- if (!ctx.hasUI) return buildUiUnavailableResponse(params.question);
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 = buildUnansweredResponse(params.question);
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 = buildCancelledResponse(params.question);
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 = buildAnsweredResponse(params.question, answer);
204
+ const result = buildAskResponse(params, { status: "answered", answer });
233
205
  pi.events.emit("ask:answered", result.details);
234
206
  return result;
235
207
  } finally {
@@ -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 { AskAnswer, ValidatedAskParams } from "./types.js";
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: ValidatedAskParams,
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, ValidatedAskParams } from "./types.js";
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
- params: ValidatedAskParams,
12
+ ask: Ask,
15
13
  signal?: AbortSignal,
16
14
  ): Promise<AskAnswer | null> {
17
- const prompt = params.context ? `${params.context}\n\n${params.question}` : params.question;
15
+ const prompt = ask.context ? `${ask.context}\n\n${ask.question}` : ask.question;
18
16
  if (signal?.aborted) return null;
19
17
 
20
- return params.allowMultiple
21
- ? runMultiSelect(ui, prompt, params.options, params.allowFreeform, signal)
22
- : runSingleSelect(ui, prompt, params.options, params.allowFreeform, signal);
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
- options: AskOption[],
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 selectedOption = options[selectedIndex];
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
- options: AskOption[],
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 selectedOptions = parseSelections(rawSelection, options);
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, selectedOptions, signal);
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, options: AskOption[]): AskOption[] {
76
- const indices = new Set<number>();
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 < options.length) indices.add(index);
76
+ if (Number.isSafeInteger(index) && index >= 0 && index < optionCount) selected.add(index);
83
77
  }
84
- return options.filter((_option, index) => indices.has(index));
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[] = options.map(toSelection);
94
- for (let index = 0; index < options.length; index++) {
95
- const option = options[index];
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
  }