@geoqiao/pi-ask 1.2.2 → 1.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.
@@ -13,13 +13,14 @@ export function formatResultLines(
13
13
  for (const question of result.questions) {
14
14
  const answer = result.answers[question.id];
15
15
  if (!answer) {
16
+ lines.push(formatUnansweredLine(question.label, options.mode));
16
17
  continue;
17
18
  }
18
19
 
19
20
  const answerLine = formatAnswerLine(question.label, answer, options.mode);
20
- if (answerLine) {
21
- lines.push(answerLine);
22
- }
21
+ lines.push(
22
+ answerLine ?? formatUnansweredLine(question.label, options.mode)
23
+ );
23
24
 
24
25
  if (hasPresentedTypeOverride(question.type, question.presentedType)) {
25
26
  hasPresentationOverride = true;
@@ -44,6 +45,15 @@ export function formatResultLines(
44
45
  return lines;
45
46
  }
46
47
 
48
+ function formatUnansweredLine(
49
+ questionLabel: string,
50
+ mode: "summary" | "render"
51
+ ): string {
52
+ return mode === "summary"
53
+ ? `${questionLabel}: (no answer)`
54
+ : `? ${questionLabel}: (no answer)`;
55
+ }
56
+
47
57
  function formatAnswerLine(
48
58
  questionLabel: string,
49
59
  answer: AskResult["answers"][string],
package/src/result.ts CHANGED
@@ -8,7 +8,7 @@ import type { AskResult } from "./types.ts";
8
8
 
9
9
  export function renderResultText(result: AskResult): string {
10
10
  if (result.error) {
11
- return "Invalid input";
11
+ return "Invalid tool payload";
12
12
  }
13
13
  if (result.cancelled) {
14
14
  return CANCELLED_RESULT_TEXT;
@@ -0,0 +1,95 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ SessionStartEvent,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { successfulResponse } from "./ask-tool-helpers.ts";
7
+ import {
8
+ appendPendingAskDismissal,
9
+ findPendingAskToolCall,
10
+ type PendingAskToolCall,
11
+ } from "./pending-ask.ts";
12
+ import type { RemoteAskRuntime } from "./remote-ask.ts";
13
+ import { runAskFlow } from "./ui/controller.ts";
14
+
15
+ const REOPEN_REASONS: ReadonlySet<SessionStartEvent["reason"]> = new Set([
16
+ "startup",
17
+ "resume",
18
+ "fork",
19
+ ]);
20
+ const DISMISS_NOTICE =
21
+ "Unanswered ask_user form dismissed; use /ask:replay to reopen it.";
22
+
23
+ export function registerPendingAskResume(
24
+ pi: ExtensionAPI,
25
+ remoteAsk: RemoteAskRuntime
26
+ ): void {
27
+ let reopening = false;
28
+
29
+ pi.on("session_start", (event, ctx) => {
30
+ if (reopening || ctx.mode !== "tui" || !REOPEN_REASONS.has(event.reason)) {
31
+ return;
32
+ }
33
+
34
+ const pendingAsk = findPendingAskToolCall(ctx);
35
+ if (!pendingAsk) {
36
+ return;
37
+ }
38
+
39
+ reopening = true;
40
+ queueMicrotask(() => {
41
+ reopenPendingAsk(pi, ctx, pendingAsk, remoteAsk)
42
+ .catch((error) => {
43
+ ctx.ui.notify(
44
+ `Could not reopen unanswered ask_user form: ${formatError(error)}`,
45
+ "error"
46
+ );
47
+ })
48
+ .finally(() => {
49
+ reopening = false;
50
+ });
51
+ });
52
+ });
53
+ }
54
+
55
+ async function reopenPendingAsk(
56
+ pi: Pick<ExtensionAPI, "appendEntry" | "sendUserMessage">,
57
+ ctx: ExtensionContext,
58
+ pendingAsk: PendingAskToolCall,
59
+ remoteAsk: RemoteAskRuntime
60
+ ): Promise<void> {
61
+ ctx.ui.notify(
62
+ `Reopening unanswered ask_user form: ${pendingAsk.params.questions.length} question(s).`,
63
+ "info"
64
+ );
65
+ ctx.ui.setWorkingVisible(false);
66
+
67
+ let result: Awaited<ReturnType<typeof runAskFlow>>;
68
+ try {
69
+ result = await runAskFlow(ctx, pendingAsk.params, {
70
+ remote: {
71
+ runtime: remoteAsk,
72
+ source: "ask:resume",
73
+ toolCallId: pendingAsk.toolCallId,
74
+ },
75
+ });
76
+ } finally {
77
+ ctx.ui.setWorkingVisible(true);
78
+ }
79
+
80
+ appendPendingAskDismissal(pi, pendingAsk.toolCallId);
81
+ if (result.cancelled) {
82
+ ctx.ui.notify(DISMISS_NOTICE, "info");
83
+ return;
84
+ }
85
+
86
+ const text = successfulResponse(result).content[0].text;
87
+ pi.sendUserMessage(
88
+ text,
89
+ ctx.isIdle() ? undefined : { deliverAs: "followUp" }
90
+ );
91
+ }
92
+
93
+ function formatError(error: unknown): string {
94
+ return error instanceof Error ? error.message : String(error);
95
+ }
@@ -247,13 +247,14 @@ function formatTitle(
247
247
  }
248
248
 
249
249
  function formatOption(option: AskDisplayOption, optionIndex: number): string {
250
+ const recommendation = option.recommended ? " (recommended)" : "";
250
251
  const description = option.description
251
252
  ? ` — ${compactText(option.description)}`
252
253
  : "";
253
254
  const preview = option.preview
254
255
  ? ` — Preview: ${compactText(option.preview)}`
255
256
  : "";
256
- return `${optionIndex + 1}. ${compactText(option.label)}${description}${preview}`;
257
+ return `${optionIndex + 1}. ${compactText(option.label)}${recommendation}${description}${preview}`;
257
258
  }
258
259
 
259
260
  function compactText(value: string): string {
package/src/schema.ts CHANGED
@@ -1,17 +1,14 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
1
2
  import { Type } from "typebox";
2
3
 
3
4
  export const AskOptionSchema = Type.Object({
4
- value: Type.Optional(
5
- Type.String({
6
- description:
7
- "Required machine-readable value returned for this option in the result",
8
- })
9
- ),
10
- label: Type.Optional(
11
- Type.String({
12
- description: "Required short visible option label shown in the list",
13
- })
14
- ),
5
+ value: Type.String({
6
+ description:
7
+ "Required machine-readable value returned for this option in the result",
8
+ }),
9
+ label: Type.String({
10
+ description: "Required short visible option label shown in the list",
11
+ }),
15
12
  description: Type.Optional(
16
13
  Type.String({
17
14
  description: "Optional one-line explanation to help the user choose",
@@ -23,28 +20,30 @@ export const AskOptionSchema = Type.Object({
23
20
  "Optional preview content shown in the dedicated preview pane for preview questions",
24
21
  })
25
22
  ),
26
- });
27
-
28
- export const AskQuestionSchema = Type.Object({
29
- id: Type.Optional(
30
- Type.String({
23
+ recommended: Type.Optional(
24
+ Type.Boolean({
31
25
  description:
32
- "Required stable question identifier used as the key in returned answers",
26
+ "Optional presentation marker for a grounded preference; use the description to explain the reason",
33
27
  })
34
28
  ),
29
+ });
30
+
31
+ export const AskQuestionSchema = Type.Object({
32
+ id: Type.String({
33
+ description:
34
+ "Required stable question identifier used as the key in returned answers",
35
+ }),
35
36
  label: Type.Optional(
36
37
  Type.String({
37
38
  description: "Short tab label, e.g. Goal, Audience, Tone, Scope",
38
39
  })
39
40
  ),
40
- prompt: Type.Optional(
41
- Type.String({
42
- description:
43
- "Required direct question shown to the user; ask about one decision at a time",
44
- })
45
- ),
41
+ prompt: Type.String({
42
+ description:
43
+ "Required direct question shown to the user; ask about one decision at a time",
44
+ }),
46
45
  type: Type.Optional(
47
- Type.String({
46
+ StringEnum(["single", "multi", "preview"] as const, {
48
47
  description:
49
48
  "Question type: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `preview` means options need preview-pane detail. Use `preview` only when every option includes `preview` text; descriptions alone are not enough.",
50
49
  })
@@ -72,3 +71,39 @@ export const AskParamsSchema = Type.Object({
72
71
  description: "Questions to ask in the interactive clarification flow",
73
72
  }),
74
73
  });
74
+
75
+ const AnswerExtractionOptionSchema = Type.Object(
76
+ {
77
+ value: Type.String({ description: "Machine-readable option value" }),
78
+ label: Type.String({ description: "Short visible option label" }),
79
+ description: AskOptionSchema.properties.description,
80
+ preview: AskOptionSchema.properties.preview,
81
+ freeform: Type.Optional(
82
+ Type.Boolean({
83
+ description:
84
+ "Use only when the assistant offered no concrete choices and the user should type an answer",
85
+ })
86
+ ),
87
+ },
88
+ { additionalProperties: false }
89
+ );
90
+
91
+ const AnswerExtractionQuestionSchema = Type.Object({
92
+ id: Type.String({ description: "Stable snake_case question identifier" }),
93
+ label: AskQuestionSchema.properties.label,
94
+ prompt: Type.String({ description: "Direct question shown to the user" }),
95
+ type: AskQuestionSchema.properties.type,
96
+ required: AskQuestionSchema.properties.required,
97
+ options: Type.Array(AnswerExtractionOptionSchema, {
98
+ description:
99
+ "Choices explicitly offered by the assistant, or one freeform option when the user should type an answer",
100
+ minItems: 1,
101
+ }),
102
+ });
103
+
104
+ export const AnswerExtractionParamsSchema = Type.Object({
105
+ ...AskParamsSchema.properties,
106
+ questions: Type.Array(AnswerExtractionQuestionSchema, {
107
+ description: "Questions extracted from the assistant message",
108
+ }),
109
+ });
@@ -12,7 +12,7 @@ export function createInitialState(
12
12
  options: CreateInitialStateOptions = {}
13
13
  ) {
14
14
  return createBaseState({
15
- title: params.title,
15
+ title: params.title?.trim() || undefined,
16
16
  questions: normalizeQuestions(params, options),
17
17
  });
18
18
  }
@@ -38,6 +38,51 @@ export function collectValidationIssues(
38
38
  return collector.issues;
39
39
  }
40
40
 
41
+ export function prepareAskParams(input: unknown): unknown {
42
+ if (!(isRecord(input) && Array.isArray(input.questions))) {
43
+ return input;
44
+ }
45
+ return {
46
+ ...input,
47
+ questions: input.questions.map((question) => {
48
+ if (!(isRecord(question) && Array.isArray(question.options))) {
49
+ return question;
50
+ }
51
+ return {
52
+ ...question,
53
+ options: question.options.map(prepareOption),
54
+ };
55
+ }),
56
+ };
57
+ }
58
+
59
+ function prepareOption(input: unknown): unknown {
60
+ if (!isRecord(input)) {
61
+ return input;
62
+ }
63
+ if (
64
+ input.label !== undefined &&
65
+ !(typeof input.label === "string" && !input.label.trim())
66
+ ) {
67
+ return input;
68
+ }
69
+ if (typeof input.value !== "string") {
70
+ return input;
71
+ }
72
+ const words = input.value.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
73
+ if (!words) {
74
+ return input;
75
+ }
76
+ return {
77
+ ...input,
78
+ label: words.charAt(0).toUpperCase() + words.slice(1),
79
+ };
80
+ }
81
+
82
+ function isRecord(value: unknown): value is Record<string, unknown> {
83
+ return !!value && typeof value === "object" && !Array.isArray(value);
84
+ }
85
+
41
86
  function normalizeQuestion(
42
87
  question: AskQuestionInput,
43
88
  index: number,
@@ -65,8 +110,11 @@ function normalizeOption(option: AskOption): AskOption {
65
110
  return {
66
111
  value: option.value.trim(),
67
112
  label: option.label.trim(),
68
- description: option.description?.trim(),
69
- preview: option.preview?.trim(),
113
+ description: option.description?.trim() || undefined,
114
+ preview: option.preview?.trim() || undefined,
115
+ ...(option.recommended === undefined
116
+ ? {}
117
+ : { recommended: option.recommended }),
70
118
  ...(option.freeform ? { freeform: true } : {}),
71
119
  };
72
120
  }
@@ -118,12 +166,6 @@ function validateQuestion(
118
166
  `${questionPath}.id`,
119
167
  `Question ${questionNumber}: duplicate question id "${questionId}"`
120
168
  );
121
- assertOptionalText(
122
- question.label,
123
- collector,
124
- `${questionPath}.label`,
125
- `Question ${questionNumber}: label must not be empty`
126
- );
127
169
  assertRequired(
128
170
  question.prompt?.trim(),
129
171
  collector,
@@ -218,18 +260,6 @@ function validateOption(
218
260
  `${optionPath}.label`,
219
261
  `${prefix}: label is required`
220
262
  );
221
- assertOptionalText(
222
- option.description,
223
- collector,
224
- `${optionPath}.description`,
225
- `${prefix}: description must not be empty`
226
- );
227
- assertOptionalText(
228
- option.preview,
229
- collector,
230
- `${optionPath}.preview`,
231
- `${prefix}: preview must not be empty`
232
- );
233
263
  if (questionType === "preview") {
234
264
  assertRequired(
235
265
  optionPreview,
@@ -287,17 +317,6 @@ function assertRequired(
287
317
  }
288
318
  }
289
319
 
290
- function assertOptionalText(
291
- value: string | undefined,
292
- collector: IssueCollector,
293
- path: string,
294
- message: string
295
- ) {
296
- if (value !== undefined && !value.trim()) {
297
- collector.add(path, message);
298
- }
299
- }
300
-
301
320
  function assertUnique(
302
321
  seen: Set<string>,
303
322
  value: string | undefined,
@@ -205,6 +205,9 @@ function cloneOption(option: AskState["questions"][number]["options"][number]) {
205
205
  label: option.label,
206
206
  ...(option.description ? { description: option.description } : {}),
207
207
  ...(option.preview ? { preview: option.preview } : {}),
208
+ ...(option.recommended === undefined
209
+ ? {}
210
+ : { recommended: option.recommended }),
208
211
  };
209
212
  }
210
213
 
package/src/types.ts CHANGED
@@ -5,6 +5,7 @@ export interface AskOption {
5
5
  freeform?: boolean;
6
6
  label: string;
7
7
  preview?: string;
8
+ recommended?: boolean;
8
9
  value: string;
9
10
  }
10
11
 
@@ -311,16 +311,23 @@ export function mergeColumns(
311
311
  }
312
312
 
313
313
  export function measurePreviewLeftWidth(
314
- options: Array<{ label: string; description?: string }>,
314
+ options: Array<{
315
+ description?: string;
316
+ label: string;
317
+ recommended?: boolean;
318
+ }>,
315
319
  width: number
316
320
  ): number {
317
321
  let widest = 0;
318
322
  for (let index = 0; index < options.length; index++) {
319
323
  const option = options[index];
324
+ const description = option.recommended
325
+ ? `${UI_TEXT.recommendedMarker}${option.description ? ` | ${option.description}` : ""}`
326
+ : option.description;
320
327
  widest = Math.max(
321
328
  widest,
322
329
  visibleWidth(`${index + 1}. ${option.label}`),
323
- option.description ? visibleWidth(option.description) : 0
330
+ description ? visibleWidth(description) : 0
324
331
  );
325
332
  }
326
333
 
@@ -1,5 +1,9 @@
1
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import { UI_DIMENSIONS } from "../constants/ui.ts";
1
+ import {
2
+ truncateToWidth,
3
+ visibleWidth,
4
+ wrapTextWithAnsi,
5
+ } from "@earendil-works/pi-tui";
6
+ import { UI_DIMENSIONS, UI_TEXT } from "../constants/ui.ts";
3
7
  import {
4
8
  measurePreviewLeftWidth,
5
9
  mergeColumns,
@@ -83,7 +87,13 @@ function renderStandardOption(
83
87
  row.pointer,
84
88
  " ".repeat(visibleWidth(row.pointer))
85
89
  );
86
- renderOptionDescription(lines, row.description, context.width, context.theme);
90
+ renderOptionSubtitle(
91
+ lines,
92
+ row.description,
93
+ row.recommended,
94
+ context.width,
95
+ context.theme
96
+ );
87
97
  renderOptionDetail(lines, row.detail, context, {
88
98
  suppressLeadingGap: !!row.description,
89
99
  });
@@ -170,7 +180,7 @@ function renderPreviewOptionList(
170
180
  row.pointer,
171
181
  " "
172
182
  );
173
- renderOptionDescription(lines, row.description, width, theme);
183
+ renderOptionSubtitle(lines, row.description, row.recommended, width, theme);
174
184
  }
175
185
  return lines;
176
186
  }
@@ -275,14 +285,36 @@ function renderInteractiveCustomOption(
275
285
  });
276
286
  }
277
287
 
278
- function renderOptionDescription(
288
+ function renderOptionSubtitle(
279
289
  lines: string[],
280
290
  description: string | undefined,
291
+ recommended: boolean,
281
292
  width: number,
282
293
  theme: Theme
283
294
  ) {
284
- if (!description) {
295
+ if (!recommended) {
296
+ if (description) {
297
+ pushWrappedText(
298
+ lines,
299
+ description,
300
+ width,
301
+ theme,
302
+ "muted",
303
+ " ",
304
+ " "
305
+ );
306
+ }
285
307
  return;
286
308
  }
287
- pushWrappedText(lines, description, width, theme, "muted", " ", " ");
309
+
310
+ const indent = " ";
311
+ const text =
312
+ theme.fg("warning", UI_TEXT.recommendedMarker) +
313
+ (description ? theme.fg("muted", ` | ${description}`) : "");
314
+ for (const line of wrapTextWithAnsi(
315
+ text,
316
+ Math.max(1, width - visibleWidth(indent))
317
+ )) {
318
+ lines.push(truncateToWidth(`${indent}${line}`, width));
319
+ }
288
320
  }
@@ -30,6 +30,7 @@ export interface OptionRowModel {
30
30
  label: string;
31
31
  pointer: string;
32
32
  prefix: string;
33
+ recommended: boolean;
33
34
  selected: boolean;
34
35
  }
35
36
 
@@ -104,6 +105,7 @@ function buildOptionRowModel(
104
105
  label: option.label,
105
106
  pointer,
106
107
  prefix: getOptionPrefix(question.type, option, answered),
108
+ recommended: option.recommended === true,
107
109
  selected,
108
110
  };
109
111
  }