@geoqiao/pi-ask 1.2.3 → 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,15 @@ import type {
13
13
  } from "./types.ts";
14
14
 
15
15
  export const ASK_TOOL_DESCRIPTION =
16
- "Interactive clarification tool for cases where the next step depends on user preferences, missing requirements, or choosing between multiple valid directions. Ask a short structured interview, collect normalized answers, and continue using those answers explicitly instead of guessing. TUI mode supports single-select, multi-select, and preview-pane questions; RPC mode presents questions sequentially, offers one portable choice per question plus a typed-answer fallback, and flattens preview details into option text. Always include a machine-readable `value` for every option. Use `preview` only when every option includes `preview` text; descriptions alone are not enough.";
16
+ "Interactive clarification tool for cases where the next step depends on user preferences, missing requirements, or choosing between multiple valid directions. Ask a short structured interview, collect normalized answers, and continue using those answers explicitly instead of guessing. TUI mode supports single-select, multi-select, and preview-pane questions; RPC mode presents questions sequentially, offers one portable choice per question plus a typed-answer fallback, and flattens preview details into option text. Always include a stable `id` and non-empty `prompt` for every question, plus a machine-readable `value` and visible `label` for every option. Use `preview` only when every option includes `preview` text; descriptions alone are not enough.";
17
17
 
18
18
  export const ASK_TOOL_PROMPT_GUIDELINES = [
19
19
  "Use `ask_user` before making preference-sensitive decisions about scope, tone, UX, naming, architecture, docs, or implementation direction.",
20
20
  "When multiple valid directions exist, call `ask_user` with 1-3 concise questions instead of committing to one path on your own.",
21
21
  "When calling `ask_user`, prefer one focused decision per question. Use short labels. Provide clear, distinct options. Do not add filler options.",
22
- "When calling `ask_user`, always include a non-empty machine-readable `value` for every option.",
22
+ "When calling `ask_user`, always include a stable `id` and non-empty `prompt` for every question.",
23
+ "When calling `ask_user`, always include a non-empty machine-readable `value` and visible `label` for every option.",
24
+ "When calling `ask_user`, mark grounded preferences with `recommended: true` and use the option `description` to state the reason.",
23
25
  "When calling `ask_user`, choose question `type` from the question semantics: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `preview` means options need preview-pane detail.",
24
26
  'When calling `ask_user`, use `type: "preview"` only when every option includes non-empty `preview` text. Option descriptions do not satisfy this requirement.',
25
27
  "After an `ask_user` elaboration or follow-up note, prefer another structured `ask_user` follow-up if a choice is still needed instead of switching to plain-text multiple choice in chat.",
@@ -89,7 +91,7 @@ export function renderAskToolCall(args: unknown, theme: ToolTheme) {
89
91
  ? params.questions
90
92
  .map(
91
93
  (question: AskQuestionInput, index) =>
92
- question.label || `Q${index + 1}`
94
+ question.label?.trim() || `Q${index + 1}`
93
95
  )
94
96
  .join(", ")
95
97
  : "";
@@ -113,17 +115,16 @@ export function renderAskToolResult(
113
115
  theme: ToolTheme
114
116
  ) {
115
117
  const details = result.details;
116
- if (!details) {
118
+ if (!(details && Array.isArray(details.questions))) {
117
119
  const text = result.content[0];
118
120
  return new Text(text?.type === "text" ? (text.text ?? "") : "", 0, 0);
119
121
  }
120
- if (details.error) {
121
- return new Text(theme.fg("warning", "Invalid input"), 0, 0);
122
- }
123
- if (details.cancelled) {
124
- return new Text(theme.fg("warning", "Cancelled"), 0, 0);
125
- }
126
- return new Text(renderResultText(details), 0, 0);
122
+ const text = renderResultText(details);
123
+ return new Text(
124
+ details.error || details.cancelled ? theme.fg("warning", text) : text,
125
+ 0,
126
+ 0
127
+ );
127
128
  }
128
129
 
129
130
  function errorResultDetails(
package/src/ask-tool.ts CHANGED
@@ -17,6 +17,7 @@ import { getAskConfigStore } from "./config/store.ts";
17
17
  import type { RemoteAskRuntime } from "./remote-ask.ts";
18
18
  import { runRpcAskFlow } from "./rpc/controller.ts";
19
19
  import { AskParamsSchema } from "./schema.ts";
20
+ import { prepareAskParams } from "./state/normalize.ts";
20
21
  import type { AskParams } from "./types.ts";
21
22
  import { runAskFlow } from "./ui/controller.ts";
22
23
 
@@ -32,6 +33,7 @@ export function registerAskTool(
32
33
  "Clarify ambiguous or preference-sensitive decisions with a short interactive interview before proceeding",
33
34
  promptGuidelines: [...ASK_TOOL_PROMPT_GUIDELINES],
34
35
  parameters: AskParamsSchema,
36
+ prepareArguments: (args) => prepareAskParams(args) as AskParams,
35
37
  execute: (toolCallId, params, signal, onUpdate, ctx) =>
36
38
  executeAskTool(
37
39
  pi,
@@ -14,6 +14,7 @@ export const UI_DIMENSIONS = {
14
14
  } as const;
15
15
 
16
16
  export const UI_TEXT = {
17
+ recommendedMarker: "(recommended)",
17
18
  questionNoteTitle: "Note:",
18
19
  reviewTitle: "Review answers",
19
20
  unanswered: "→ unanswered",
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import { registerAskSettingsCommand } from "./ask-settings-command.ts";
6
6
  import { registerAskTool } from "./ask-tool.ts";
7
7
  import { resetAskConfigStore } from "./config/store.ts";
8
8
  import { createRemoteAskRuntime } from "./remote-ask.ts";
9
+ import { registerPendingAskResume } from "./resume-pending-ask.ts";
9
10
 
10
11
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
11
12
  const CONFIGURATION_DOC_PATH = resolve(
@@ -27,4 +28,5 @@ export default function askExtension(pi: ExtensionAPI) {
27
28
  registerAskTool(pi, remoteAsk);
28
29
  registerAskSettingsCommand(pi);
29
30
  registerAnswerCommands(pi, remoteAsk);
31
+ registerPendingAskResume(pi, remoteAsk);
30
32
  }
@@ -0,0 +1,128 @@
1
+ import type { ToolCall } from "@earendil-works/pi-ai";
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ SessionEntry,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import { Value } from "typebox/value";
8
+ import { findPayloadForSourceEntry } from "./ask-payload-store.ts";
9
+ import { validateParams } from "./ask-tool-helpers.ts";
10
+ import { AskParamsSchema } from "./schema.ts";
11
+ import type { AskParams } from "./types.ts";
12
+
13
+ export const ASK_PENDING_DISMISSED_ENTRY_TYPE = "ask:pending-dismissed";
14
+ const ASK_TOOL_NAME = "ask_user";
15
+
16
+ export interface PendingAskToolCall {
17
+ params: AskParams;
18
+ toolCallId: string;
19
+ }
20
+
21
+ export function appendPendingAskDismissal(
22
+ pi: Pick<ExtensionAPI, "appendEntry">,
23
+ toolCallId: string
24
+ ): void {
25
+ pi.appendEntry(ASK_PENDING_DISMISSED_ENTRY_TYPE, { toolCallId });
26
+ }
27
+
28
+ export function findPendingAskToolCall(
29
+ ctx: Pick<ExtensionContext, "sessionManager">
30
+ ): PendingAskToolCall | undefined {
31
+ const branch = ctx.sessionManager.getBranch();
32
+ const resolvedToolCallIds = collectResolvedToolCallIds(branch);
33
+
34
+ for (let entryIndex = branch.length - 1; entryIndex >= 0; entryIndex--) {
35
+ const toolCall = findUnresolvedAskToolCall(
36
+ branch[entryIndex],
37
+ resolvedToolCallIds
38
+ );
39
+ if (!toolCall) {
40
+ continue;
41
+ }
42
+
43
+ const params = resolvePendingAskParams(ctx, toolCall);
44
+ if (params) {
45
+ return { params, toolCallId: toolCall.id };
46
+ }
47
+ }
48
+ return;
49
+ }
50
+
51
+ function collectResolvedToolCallIds(
52
+ branch: readonly SessionEntry[]
53
+ ): Set<string> {
54
+ const resolved = new Set<string>();
55
+ for (const entry of branch) {
56
+ const dismissedToolCallId = getDismissedToolCallId(entry);
57
+ if (dismissedToolCallId) {
58
+ resolved.add(dismissedToolCallId);
59
+ continue;
60
+ }
61
+ if (entry.type === "message" && entry.message.role === "toolResult") {
62
+ resolved.add(entry.message.toolCallId);
63
+ }
64
+ }
65
+ return resolved;
66
+ }
67
+
68
+ function findUnresolvedAskToolCall(
69
+ entry: SessionEntry,
70
+ resolvedToolCallIds: ReadonlySet<string>
71
+ ): ToolCall | undefined {
72
+ if (
73
+ entry.type !== "message" ||
74
+ entry.message.role !== "assistant" ||
75
+ entry.message.stopReason !== "toolUse"
76
+ ) {
77
+ return;
78
+ }
79
+
80
+ for (
81
+ let partIndex = entry.message.content.length - 1;
82
+ partIndex >= 0;
83
+ partIndex--
84
+ ) {
85
+ const part = entry.message.content[partIndex];
86
+ if (
87
+ part.type === "toolCall" &&
88
+ part.name === ASK_TOOL_NAME &&
89
+ !resolvedToolCallIds.has(part.id)
90
+ ) {
91
+ return part;
92
+ }
93
+ }
94
+ return;
95
+ }
96
+
97
+ function resolvePendingAskParams(
98
+ ctx: Pick<ExtensionContext, "sessionManager">,
99
+ toolCall: ToolCall
100
+ ): AskParams | undefined {
101
+ const persistedPayload = findPayloadForSourceEntry(ctx, toolCall.id, "tool");
102
+ if (persistedPayload) {
103
+ return persistedPayload.params;
104
+ }
105
+
106
+ const argumentsFallback = toolCall.arguments;
107
+ if (
108
+ Value.Check(AskParamsSchema, argumentsFallback) &&
109
+ validateParams(argumentsFallback).ok
110
+ ) {
111
+ return argumentsFallback;
112
+ }
113
+ return;
114
+ }
115
+
116
+ function getDismissedToolCallId(entry: SessionEntry): string | undefined {
117
+ if (
118
+ entry.type !== "custom" ||
119
+ entry.customType !== ASK_PENDING_DISMISSED_ENTRY_TYPE ||
120
+ !entry.data ||
121
+ typeof entry.data !== "object"
122
+ ) {
123
+ return;
124
+ }
125
+
126
+ const toolCallId = (entry.data as { toolCallId?: unknown }).toolCallId;
127
+ return typeof toolCallId === "string" ? toolCallId : undefined;
128
+ }
package/src/remote-ask.ts CHANGED
@@ -7,12 +7,17 @@ import type {
7
7
  AskStateAnswer,
8
8
  } from "./types.ts";
9
9
 
10
- export const PI_ASK_STARTED_EVENT = "@eko24ive/pi-ask:started";
11
- export const PI_ASK_COMPLETED_EVENT = "@eko24ive/pi-ask:completed";
12
- export const PI_ASK_SUBMIT_EVENT = "@eko24ive/pi-ask:submit";
13
- export const PI_ASK_SUBMIT_RESULT_EVENT = "@eko24ive/pi-ask:submit-result";
14
-
15
- export type RemoteAskSource = "tool" | "answer" | "answer:again" | "ask:replay";
10
+ export const PI_ASK_STARTED_EVENT = "@geoqiao/pi-ask:started";
11
+ export const PI_ASK_COMPLETED_EVENT = "@geoqiao/pi-ask:completed";
12
+ export const PI_ASK_SUBMIT_EVENT = "@geoqiao/pi-ask:submit";
13
+ export const PI_ASK_SUBMIT_RESULT_EVENT = "@geoqiao/pi-ask:submit-result";
14
+
15
+ export type RemoteAskSource =
16
+ | "tool"
17
+ | "answer"
18
+ | "answer:again"
19
+ | "ask:replay"
20
+ | "ask:resume";
16
21
 
17
22
  export interface RemoteAskAnswer {
18
23
  customText?: string;
@@ -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