@pi9/ask 0.1.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 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.1.0",
3
+ "version": "0.3.0",
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
@@ -19,9 +19,9 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
19
19
  import {
20
20
  createQuestionnaireState,
21
21
  transitionQuestionnaire,
22
+ type QuestionnaireRow,
22
23
  type QuestionnaireState,
23
24
  } from "./state.js";
24
- import { CHECKED_BOX, EMPTY_BOX } from "./glyphs.js";
25
25
  import {
26
26
  composePreviewRow,
27
27
  getPreviewPaneLayout,
@@ -33,9 +33,11 @@ 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
37
 
38
- type AskComponentOptions = ValidatedAskParams & {
38
+ const FRAME_WIDE_PREVIEW = true; // Set to false to compare the same layout without its outer frame.
39
+
40
+ type AskComponentOptions = Ask & {
39
41
  tui: TUI;
40
42
  theme: Theme;
41
43
  keybindings: KeybindingsManager;
@@ -166,7 +168,14 @@ export class AskComponent implements Component, Focusable {
166
168
  }
167
169
 
168
170
  render(width: number): string[] {
169
- const renderWidth = safeWidth(width);
171
+ const availableWidth = safeWidth(width);
172
+ const hasAuthoredPreviews = this.questionnaireState.rows.some(
173
+ row => row.kind === "option" && row.option.preview?.trim(),
174
+ );
175
+ const framed = FRAME_WIDE_PREVIEW
176
+ && hasAuthoredPreviews
177
+ && getPreviewPaneLayout(availableWidth - 4) !== undefined;
178
+ const renderWidth = framed ? availableWidth - 4 : availableWidth;
170
179
  const lines: string[] = [];
171
180
  const add = (line: string) => lines.push(fit(line, renderWidth));
172
181
  const addPrefixed = (prefix: string, text: string, color?: "text" | "muted" | "dim" | "accent") => {
@@ -176,11 +185,8 @@ export class AskComponent implements Component, Focusable {
176
185
 
177
186
  add(this.config.theme.fg("border", "─".repeat(renderWidth)));
178
187
 
179
- if (this.config.context) {
180
- addPrefixed(" ", this.config.context, "muted");
181
- add("");
182
- }
183
188
  addPrefixed(" ", this.config.theme.bold(this.config.question), "text");
189
+ if (this.config.context) addPrefixed(" ", this.config.context, "muted");
184
190
  add("");
185
191
 
186
192
  let focus: FocusRange | undefined;
@@ -198,16 +204,18 @@ export class AskComponent implements Component, Focusable {
198
204
  };
199
205
  if (this.questionnaireState.editor.kind === "select") {
200
206
  const preview = this.highlightedPreview();
201
- const hasPreviews = this.questionnaireState.rows.some(
202
- row => row.kind === "option" && row.option.preview?.trim(),
203
- );
204
- const paneLayout = hasPreviews ? getPreviewPaneLayout(renderWidth) : undefined;
207
+ const paneLayout = hasAuthoredPreviews ? getPreviewPaneLayout(renderWidth) : undefined;
205
208
  if (paneLayout) {
206
- const optionLines: string[] = [];
209
+ const optionLines = [this.config.theme.fg("dim", " OPTIONS"), ""];
207
210
  const optionFocus = this.renderOptions(optionLines, paneLayout.leftWidth);
208
211
  const submitFocus = this.renderSubmit(optionLines, paneLayout.leftWidth);
209
212
  const sectionStart = lines.length;
210
- const previewLines = this.renderPreview(preview, paneLayout.rightWidth);
213
+ const previewLines = [
214
+ this.config.theme.fg("dim", " PREVIEW · FOCUSED OPTION"),
215
+ this.config.theme.fg("dim", "─".repeat(paneLayout.rightWidth)),
216
+ "",
217
+ ...this.renderPreview(preview, paneLayout.rightWidth - 1).map(line => ` ${line}`),
218
+ ];
211
219
  const bodyHeight = Math.max(optionLines.length, previewLines.length);
212
220
  lines.push(...optionLines, ...Array.from({ length: bodyHeight - optionLines.length }, () => ""));
213
221
  wideBody = {
@@ -226,7 +234,7 @@ export class AskComponent implements Component, Focusable {
226
234
  } else {
227
235
  const optionFocus = this.renderOptions(lines, renderWidth);
228
236
  focus = optionFocus;
229
- if (hasPreviews) {
237
+ if (hasAuthoredPreviews) {
230
238
  const previewStart = lines.length;
231
239
  const previewLines = this.renderPreview(preview, renderWidth);
232
240
  lines.push(...previewLines);
@@ -247,28 +255,57 @@ export class AskComponent implements Component, Focusable {
247
255
  footerStart = lines.length;
248
256
  addFooter(" ", this.config.theme.fg("dim", this.helpText()));
249
257
  } else {
258
+ const activeEditor = this.questionnaireState.editor;
250
259
  // Keep the options visible while editing so the comment remains tied to
251
260
  // the option it belongs to, and so freeform editing does not feel like a
252
261
  // separate prompt.
253
- this.renderOptions(lines, renderWidth);
262
+ const paneLayout = hasAuthoredPreviews ? getPreviewPaneLayout(renderWidth) : undefined;
263
+ const optionLines = paneLayout
264
+ ? [this.config.theme.fg("dim", " OPTIONS"), ""]
265
+ : lines;
266
+ const optionWidth = paneLayout?.leftWidth ?? renderWidth;
254
267
  const inputPrefix = ` ${this.config.theme.fg("accent", "↳")} `;
255
268
  const continuationPrefix = " ".repeat(visibleWidth(inputPrefix));
256
- const inputWidth = Math.max(1, renderWidth - visibleWidth(inputPrefix));
269
+ const inputWidth = Math.max(1, optionWidth - visibleWidth(inputPrefix));
257
270
  const editorLines = this.editor.render(inputWidth).filter(line => visibleWidth(line) > 0);
258
- const editorStart = lines.length;
259
- for (const [index, line] of editorLines.entries()) {
260
- add(`${index === 0 ? inputPrefix : continuationPrefix}${line}`);
261
- }
262
- const editorEnd = lines.length;
271
+ let editorStart = -1;
272
+ let editorEnd = -1;
273
+ this.renderOptions(optionLines, optionWidth, (row) => {
274
+ if (row !== activeEditor.target) return;
275
+ editorStart = optionLines.length;
276
+ for (const [index, line] of editorLines.entries()) {
277
+ optionLines.push(fit(`${index === 0 ? inputPrefix : continuationPrefix}${line}`, optionWidth));
278
+ }
279
+ editorEnd = optionLines.length;
280
+ });
263
281
  if (editorEnd > editorStart) {
264
282
  const cursorLine = editorLines.findIndex(line => line.includes(CURSOR_MARKER));
265
283
  const focusedRow = cursorLine >= 0 ? editorStart + cursorLine : editorEnd - 1;
266
284
  focus = { start: focusedRow, end: focusedRow + 1 };
267
285
  }
268
286
 
287
+ this.renderSubmit(optionLines, optionWidth);
288
+ if (paneLayout) {
289
+ const sectionStart = lines.length;
290
+ const previewLines = [
291
+ this.config.theme.fg("dim", " PREVIEW · FOCUSED OPTION"),
292
+ this.config.theme.fg("dim", "─".repeat(paneLayout.rightWidth)),
293
+ "",
294
+ ...this.renderPreview(undefined, paneLayout.rightWidth - 1).map(line => ` ${line}`),
295
+ ];
296
+ const bodyHeight = Math.max(optionLines.length, previewLines.length);
297
+ lines.push(...optionLines, ...Array.from({ length: bodyHeight - optionLines.length }, () => ""));
298
+ wideBody = {
299
+ start: sectionStart,
300
+ end: sectionStart + bodyHeight,
301
+ previewLines,
302
+ layout: paneLayout,
303
+ };
304
+ if (focus) focus = { start: sectionStart + focus.start, end: sectionStart + focus.end };
305
+ }
306
+
269
307
  // The help line is the footer. Keep it adjacent to the bottom border so
270
308
  // fitViewport can keep both rows fixed while the editor is focused.
271
- this.renderSubmit(lines, renderWidth);
272
309
  footerStart = lines.length;
273
310
  addFooter(continuationPrefix, this.config.theme.fg("dim", this.editorHelpText()));
274
311
  }
@@ -287,7 +324,7 @@ export class AskComponent implements Component, Focusable {
287
324
  const visibleRows = fitViewport(rows, focus, maxRows, 1, lines.length - footerStart);
288
325
  let previewIndex = 0;
289
326
 
290
- return visibleRows.map(({ value: row, overflow }) => {
327
+ const projected = visibleRows.map(({ value: row, overflow }) => {
291
328
  if (row.kind === "full") {
292
329
  return projectFullRow(row.line, overflow, renderWidth);
293
330
  }
@@ -301,6 +338,7 @@ export class AskComponent implements Component, Focusable {
301
338
  this.config.theme.fg("dim", "│"),
302
339
  );
303
340
  });
341
+ return framed ? frameDialog(projected, availableWidth, this.config.theme) : projected;
304
342
  }
305
343
 
306
344
  /** Submit the current multi-select answer programmatically. */
@@ -320,7 +358,11 @@ export class AskComponent implements Component, Focusable {
320
358
  this.config.onCancel?.();
321
359
  }
322
360
 
323
- private renderOptions(lines: string[], width: number): FocusRange | undefined {
361
+ private renderOptions(
362
+ lines: string[],
363
+ width: number,
364
+ afterRow?: (row: QuestionnaireRow) => void,
365
+ ): FocusRange | undefined {
324
366
  const addPrefixed = (prefix: string, text: string, color?: "text" | "muted" | "dim" | "accent") => {
325
367
  const styled = color ? this.config.theme.fg(color, text) : text;
326
368
  addWrappedWithPrefix(lines, prefix, styled, width);
@@ -331,34 +373,34 @@ export class AskComponent implements Component, Focusable {
331
373
  if (row.kind === "submit") continue;
332
374
  const selected = this.questionnaireState.highlightedRow === index;
333
375
  const start = lines.length;
334
- const marker = selected ? this.config.theme.fg("accent", " ") : " ";
376
+ const marker = selected ? this.config.theme.fg("accent", " ") : " ";
335
377
 
336
378
  if (row.kind === "option") {
337
379
  const source = row.option;
338
- const checked = this.questionnaireState.checked.has(source.label);
339
- const check = this.questionnaireState.config.allowMultiple
340
- ? `${this.config.theme.fg(checked ? "success" : "muted", checked ? CHECKED_BOX : EMPTY_BOX)} `
380
+ const checked = this.questionnaireState.checked.has(row.index);
381
+ const badge = this.questionnaireState.config.allowMultiple && checked
382
+ ? this.config.theme.fg("success", " [selected]")
341
383
  : "";
342
- const comment = this.questionnaireState.comments.has(source.label)
384
+ const comment = this.questionnaireState.comments.has(row.index)
343
385
  ? this.config.theme.fg("warning", " ✎")
344
386
  : "";
345
- addPrefixed("", `${marker}${check}${source.label}${comment}`, selected ? "accent" : "text");
346
- if (source.description) addPrefixed(" ", source.description, "muted");
387
+ addPrefixed(marker, `${source.label}${badge}${comment}`, selected ? "accent" : "text");
388
+ if (source.description) addPrefixed(" ", source.description, "muted");
347
389
  if (selected) {
348
- const commentText = this.questionnaireState.comments.get(source.label);
390
+ const commentText = this.questionnaireState.comments.get(row.index);
349
391
  if (commentText) addPrefixed(" ", `✎ ${commentText}`, "dim");
350
392
  }
351
393
  } else {
352
- const checked = this.questionnaireState.freeformChecked;
353
- const check = this.questionnaireState.config.allowMultiple
354
- ? `${this.config.theme.fg(checked ? "success" : "muted", checked ? CHECKED_BOX : EMPTY_BOX)} `
394
+ const badge = this.questionnaireState.config.allowMultiple && this.questionnaireState.freeformChecked
395
+ ? this.config.theme.fg("success", " [selected]")
355
396
  : "";
356
397
  const suffix = this.questionnaireState.freeformDraft
357
398
  ? ` — ${this.questionnaireState.freeformDraft}`
358
399
  : "";
359
- addPrefixed("", `${marker}${check}${this.config.theme.fg(selected ? "accent" : "text", `Type a response…${suffix}`)}`);
400
+ addPrefixed(marker, `Type a response…${suffix}${badge}`, selected ? "accent" : "text");
360
401
  }
361
402
  if (selected) focus = { start, end: lines.length };
403
+ afterRow?.(row);
362
404
  }
363
405
  return focus;
364
406
  }
@@ -367,7 +409,7 @@ export class AskComponent implements Component, Focusable {
367
409
  const submitIndex = this.questionnaireState.rows.findIndex(row => row.kind === "submit");
368
410
  if (submitIndex < 0) return undefined;
369
411
  const selected = this.questionnaireState.highlightedRow === submitIndex;
370
- const marker = selected ? this.config.theme.fg("accent", " ") : " ";
412
+ const marker = selected ? this.config.theme.fg("accent", " ") : " ";
371
413
  lines.push("");
372
414
  const start = lines.length;
373
415
  addWrappedWithPrefix(
@@ -530,6 +572,17 @@ function overflowPrefix(overflow: ViewportOverflow | undefined): string {
530
572
  return "";
531
573
  }
532
574
 
575
+ function frameDialog(lines: readonly string[], width: number, theme: Theme): string[] {
576
+ const border = (text: string) => theme.fg("border", text);
577
+ const innerWidth = width - 4;
578
+ return lines.map((line, index) => {
579
+ if (index === 0) return border(`╭${"─".repeat(width - 2)}╮`);
580
+ if (index === lines.length - 1) return border(`╰${"─".repeat(width - 2)}╯`);
581
+ const content = fit(line, innerWidth);
582
+ return `${border("│")} ${content}${" ".repeat(innerWidth - visibleWidth(content))} ${border("│")}`;
583
+ });
584
+ }
585
+
533
586
  function safeWidth(width: number): number {
534
587
  return Math.max(1, Number.isFinite(width) ? Math.floor(width) : 1);
535
588
  }
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
+ }