@mrclrchtr/supi-ask-user 0.1.0 → 1.1.2

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.
Files changed (43) hide show
  1. package/README.md +115 -0
  2. package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
  3. package/node_modules/@mrclrchtr/supi-core/package.json +26 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
  9. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
  11. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
  12. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
  13. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
  14. package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
  15. package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
  16. package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
  17. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
  18. package/package.json +13 -9
  19. package/src/ask-user.ts +191 -0
  20. package/{flow.ts → src/flow.ts} +45 -33
  21. package/src/format.ts +66 -0
  22. package/src/index.ts +1 -0
  23. package/src/normalize.ts +229 -0
  24. package/{ui-rich-render-editor.ts → src/render/ui-rich-render-editor.ts} +18 -16
  25. package/src/render/ui-rich-render-env.ts +15 -0
  26. package/src/render/ui-rich-render-footer.ts +55 -0
  27. package/src/render/ui-rich-render-markdown.ts +33 -0
  28. package/{ui-rich-render-notes.ts → src/render/ui-rich-render-notes.ts} +20 -27
  29. package/src/render/ui-rich-render-types.ts +17 -0
  30. package/src/render/ui-rich-render.ts +323 -0
  31. package/{render.ts → src/render.ts} +10 -5
  32. package/{result.ts → src/result.ts} +27 -6
  33. package/{schema.ts → src/schema.ts} +46 -44
  34. package/{types.ts → src/types.ts} +20 -53
  35. package/{ui-rich-handlers.ts → src/ui/ui-rich-handlers.ts} +100 -44
  36. package/{ui-rich-inline.ts → src/ui/ui-rich-inline.ts} +23 -10
  37. package/{ui-rich-state.ts → src/ui/ui-rich-state.ts} +52 -15
  38. package/{ui-rich.ts → src/ui/ui-rich.ts} +36 -11
  39. package/ask-user.ts +0 -131
  40. package/format.ts +0 -95
  41. package/normalize.ts +0 -218
  42. package/ui-fallback.ts +0 -274
  43. package/ui-rich-render.ts +0 -370
@@ -0,0 +1,323 @@
1
+ // Pure rendering helpers for the rich overlay. Kept separate from
2
+ // ui-rich.ts to stay within Biome's per-file line limit and so the input
3
+ // dispatch logic can be read without scrolling past a wall of theme strings.
4
+
5
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
6
+ import { decorateOption, formatReviewLines, NOTE_MARKER } from "../format.ts";
7
+ import type { NormalizedStructuredQuestion } from "../types.ts";
8
+ import { inlineStructuredRowLines, structuredRowLabel } from "../ui/ui-rich-inline.ts";
9
+ import {
10
+ hasPreview,
11
+ type InteractiveRow,
12
+ interactiveRows,
13
+ selectedIndexesForQuestion,
14
+ } from "../ui/ui-rich-state.ts";
15
+ import {
16
+ editorCaption,
17
+ padRight,
18
+ renderEditorBlock,
19
+ renderEditorPane,
20
+ usesSeparateEditorPane,
21
+ } from "./ui-rich-render-editor.ts";
22
+ import type { RenderEnv } from "./ui-rich-render-env.ts";
23
+ import { footerHelp } from "./ui-rich-render-footer.ts";
24
+ import { renderMarkdown, renderMarkdownPreview } from "./ui-rich-render-markdown.ts";
25
+ import { currentNote, renderNoteStatus, visibleNoteMarker } from "./ui-rich-render-notes.ts";
26
+
27
+ export function renderOverlay(env: RenderEnv): string[] {
28
+ const lines: string[] = [];
29
+ lines.push(env.theme.fg("accent", "─".repeat(env.width)));
30
+ if (env.flow.isMultiQuestion) renderTabBar(lines, env);
31
+ if (env.flow.currentMode === "reviewing") {
32
+ lines.push(...renderReview(env));
33
+ } else {
34
+ renderQuestion(lines, env);
35
+ }
36
+ lines.push(
37
+ truncateToWidth(env.theme.fg("dim", ` ${footerHelp(env.flow, env.state)}`), env.width),
38
+ );
39
+ lines.push(env.theme.fg("accent", "─".repeat(env.width)));
40
+ return lines;
41
+ }
42
+
43
+ function tabSegment(
44
+ env: RenderEnv,
45
+ text: string,
46
+ active: boolean,
47
+ color: "success" | "muted" | "dim" | "text",
48
+ ): string {
49
+ return active
50
+ ? env.theme.bg("selectedBg", env.theme.fg("text", text))
51
+ : env.theme.fg(color, text);
52
+ }
53
+
54
+ function renderTabBar(lines: string[], env: RenderEnv): void {
55
+ const segments: string[] = [env.theme.fg("dim", "← ")];
56
+ for (const [index, question] of env.flow.questions.entries()) {
57
+ const answered = env.flow.hasAnswer(question.id);
58
+ const active = env.flow.currentMode === "answering" && env.flow.currentIndex === index;
59
+ const marker = answered ? "■" : question.required ? "□" : "○";
60
+ const color = answered ? "success" : question.required ? "muted" : "dim";
61
+ segments.push(tabSegment(env, ` ${marker} ${question.header} `, active, color));
62
+ segments.push(" ");
63
+ }
64
+ const reviewActive = env.flow.currentMode === "reviewing";
65
+ segments.push(tabSegment(env, " ✓ Review ", reviewActive, reviewActive ? "text" : "dim"));
66
+ segments.push(env.theme.fg("dim", " →"));
67
+ lines.push(truncateToWidth(` ${segments.join("")}`, env.width));
68
+ lines.push("");
69
+ }
70
+
71
+ function renderQuestion(lines: string[], env: RenderEnv): void {
72
+ const question = env.flow.currentQuestion;
73
+ if (!question) return;
74
+ lines.push(...renderMarkdown(question.prompt, env.width - 1, env.theme, { paddingX: 0 }));
75
+ lines.push("");
76
+ if (question.type === "text") {
77
+ renderTextQuestion(lines, env);
78
+ return;
79
+ }
80
+ renderStructuredQuestion(lines, env, question);
81
+ }
82
+
83
+ function renderSplitView(
84
+ lines: string[],
85
+ env: RenderEnv,
86
+ question: NormalizedStructuredQuestion,
87
+ rows: InteractiveRow[],
88
+ ): void {
89
+ const leftWidth = Math.max(36, Math.floor(env.width * 0.55));
90
+ const rightWidth = Math.max(24, env.width - leftWidth - 3);
91
+ const leftEnv: RenderEnv = { ...env, width: leftWidth };
92
+ const leftLines = renderPaneRows(leftEnv, question, rows);
93
+ const rightLines = usesSeparateEditorPane(env.state)
94
+ ? renderEditorPane(rightWidth, env.theme, env.editor, editorCaption(env.state))
95
+ : renderPreviewPane(
96
+ rightWidth,
97
+ env.theme,
98
+ previewForSelection(question, rows[env.state.selectedIndex]),
99
+ );
100
+ const total = Math.max(leftLines.length, rightLines.length);
101
+ for (let index = 0; index < total; index += 1) {
102
+ const left = padRight(leftLines[index] ?? "", leftWidth);
103
+ const right = padRight(rightLines[index] ?? "", rightWidth);
104
+ lines.push(`${left} ${env.theme.fg("accent", "│")} ${right}`);
105
+ }
106
+ }
107
+
108
+ function renderTextQuestion(lines: string[], env: RenderEnv): void {
109
+ lines.push(...renderEditorBlock(env, "Answer"));
110
+ }
111
+
112
+ function renderStructuredQuestion(
113
+ lines: string[],
114
+ env: RenderEnv,
115
+ question: NormalizedStructuredQuestion,
116
+ ): void {
117
+ const rows = interactiveRows(question);
118
+ // Split view at >=100 cols: left pane 42% (min 34 cols) for option rows,
119
+ // right pane (min 24 cols) for preview or editor. Below this threshold,
120
+ // previews render as an inline block below the options.
121
+ if (hasPreview(question) && env.width >= 100) {
122
+ renderSplitView(lines, env, question, rows);
123
+ } else {
124
+ renderStandardStructuredQuestion(lines, env, question, rows);
125
+ }
126
+ const note = currentNote(env.flow, env.state, question);
127
+ if (note) lines.push(...renderNoteStatus(env.theme, note));
128
+ }
129
+
130
+ function renderStandardStructuredQuestion(
131
+ lines: string[],
132
+ env: RenderEnv,
133
+ question: NormalizedStructuredQuestion,
134
+ rows: InteractiveRow[],
135
+ ): void {
136
+ lines.push(...renderRows(env, question, rows));
137
+ if (usesSeparateEditorPane(env.state)) {
138
+ lines.push(...renderEditorBlock(env, editorCaption(env.state)));
139
+ return;
140
+ }
141
+ const preview = previewForSelection(question, rows[env.state.selectedIndex]);
142
+ if (preview) lines.push(...renderPreviewBlock(env, preview));
143
+ }
144
+
145
+ function renderPaneRows(
146
+ env: RenderEnv,
147
+ question: NormalizedStructuredQuestion,
148
+ rows: InteractiveRow[],
149
+ ): string[] {
150
+ return renderRows(env, question, rows);
151
+ }
152
+
153
+ /** Render a single interactive row, or its inline editor lines when the row
154
+ * is an active other/discuss input and the editor is not rendered separately. */
155
+ function renderInlineEditorLine(
156
+ env: RenderEnv,
157
+ prefix: string,
158
+ row: InteractiveRow,
159
+ out: string[],
160
+ ): boolean {
161
+ if (usesSeparateEditorPane(env.state)) return false;
162
+ const lines = inlineStructuredRowLines({
163
+ width: env.width,
164
+ theme: env.theme,
165
+ state: env.state,
166
+ editor: env.editor,
167
+ row,
168
+ prefix,
169
+ });
170
+ if (!lines) return false;
171
+ const continuation = " ".repeat(visibleWidth(prefix));
172
+ for (const [i, line] of lines.entries()) {
173
+ out.push(`${i === 0 ? prefix : continuation}${line}`);
174
+ }
175
+ return true;
176
+ }
177
+
178
+ function renderRows(
179
+ env: RenderEnv,
180
+ question: NormalizedStructuredQuestion,
181
+ rows: InteractiveRow[],
182
+ ): string[] {
183
+ const out: string[] = [];
184
+ for (const [index, row] of rows.entries()) {
185
+ const active = env.state.selectedIndex === index;
186
+ const prefix = active ? env.theme.fg("accent", "> ") : " ";
187
+ if (renderInlineEditorLine(env, prefix, row, out)) continue;
188
+ addWrapped(out, env, prefix, rowLabel(env, question, row, active));
189
+ const description = rowDescription(question, row);
190
+ if (description) renderRowDescription(out, env, description);
191
+ }
192
+ return out;
193
+ }
194
+
195
+ function rowLabel(
196
+ env: RenderEnv,
197
+ question: NormalizedStructuredQuestion,
198
+ row: InteractiveRow,
199
+ active: boolean,
200
+ ): string {
201
+ const selected = selectedIndexesForQuestion(env.flow, env.state, question);
202
+ if (row.kind === "option") {
203
+ const option = question.options[row.optionIndex];
204
+ const recommended = question.recommendedIndexes.includes(row.optionIndex);
205
+ const noteMarker = visibleNoteMarker({
206
+ flow: env.flow,
207
+ state: env.state,
208
+ question,
209
+ row,
210
+ active,
211
+ });
212
+ const baseLabel = `${decorateOption(option.label, recommended)}${noteMarker ? ` ${NOTE_MARKER}` : ""}`;
213
+ if (question.multi) {
214
+ const checked = selected.includes(row.optionIndex) ? "[x]" : "[ ]";
215
+ return env.theme.fg("text", `${checked} ${baseLabel}`);
216
+ }
217
+ return env.theme.fg("text", `${row.optionIndex + 1}. ${baseLabel}`);
218
+ }
219
+ if (row.kind === "other")
220
+ return env.theme.fg("text", structuredRowLabel(env.flow, question, row));
221
+ return env.theme.fg("text", structuredRowLabel(env.flow, question, row));
222
+ }
223
+
224
+ function rowDescription(
225
+ question: NormalizedStructuredQuestion,
226
+ row: InteractiveRow,
227
+ ): string | undefined {
228
+ if (row.kind === "option") return question.options[row.optionIndex].description;
229
+ return undefined;
230
+ }
231
+
232
+ function renderRowDescription(out: string[], env: RenderEnv, description: string): void {
233
+ const descLines = renderMarkdown(description, env.width - 5, env.theme, {
234
+ paddingX: 0,
235
+ defaultColor: "muted",
236
+ });
237
+ for (const line of descLines) {
238
+ out.push(` ${line}`);
239
+ }
240
+ }
241
+
242
+ function renderReviewAnswer(out: string[], env: RenderEnv, line: string): void {
243
+ const reviewLines = renderMarkdown(line, env.width - 3, env.theme, {
244
+ paddingX: 0,
245
+ defaultColor: "text",
246
+ });
247
+ for (const reviewLine of reviewLines) {
248
+ out.push(` ${reviewLine}`);
249
+ }
250
+ }
251
+
252
+ function renderPreviewPane(
253
+ width: number,
254
+ theme: RenderEnv["theme"],
255
+ preview: string | undefined,
256
+ ): string[] {
257
+ const out: string[] = [];
258
+ const push = (text = "") => out.push(text);
259
+ push(theme.fg("accent", " Preview"));
260
+ push("");
261
+ if (!preview) {
262
+ push(theme.fg("muted", " No preview for the current selection."));
263
+ return out;
264
+ }
265
+ out.push(...renderMarkdownPreview(preview, width, theme));
266
+ return out;
267
+ }
268
+
269
+ function renderPreviewBlock(env: RenderEnv, preview: string): string[] {
270
+ const out: string[] = [];
271
+ out.push("");
272
+ out.push(env.theme.fg("accent", " Preview:"));
273
+ out.push(...renderMarkdownPreview(preview, env.width, env.theme));
274
+ return out;
275
+ }
276
+
277
+ function previewForSelection(
278
+ question: NormalizedStructuredQuestion,
279
+ row: InteractiveRow | undefined,
280
+ ): string | undefined {
281
+ return row?.kind === "option" ? question.options[row.optionIndex].preview : undefined;
282
+ }
283
+
284
+ function renderReview(env: RenderEnv): string[] {
285
+ const out: string[] = [];
286
+ const add = (text: string) => out.push(truncateToWidth(text, env.width));
287
+ add(env.theme.fg("accent", " Review answers:"));
288
+ add("");
289
+ for (const question of env.flow.questions) {
290
+ const answer = env.flow.getAnswer(question.id);
291
+ const answerLines = answer
292
+ ? formatReviewLines(question, answer)
293
+ : [question.required ? "(no answer)" : "(skipped)"];
294
+ add(env.theme.fg("muted", ` ${question.header}:`));
295
+ for (const line of answerLines) renderReviewAnswer(out, env, line);
296
+ }
297
+ add("");
298
+ if (env.flow.showSkip) {
299
+ add(
300
+ env.theme.fg(
301
+ env.flow.allRequiredAnswered() ? "success" : "warning",
302
+ " Press Enter to submit • s to skip",
303
+ ),
304
+ );
305
+ } else {
306
+ add(
307
+ env.theme.fg(
308
+ env.flow.allRequiredAnswered() ? "success" : "warning",
309
+ " Press Enter to submit",
310
+ ),
311
+ );
312
+ }
313
+ return out;
314
+ }
315
+
316
+ function addWrapped(lines: string[], env: RenderEnv, prefix: string, text: string): void {
317
+ const prefixWidth = visibleWidth(prefix);
318
+ const contentWidth = Math.max(1, env.width - prefixWidth);
319
+ const continuationPrefix = " ".repeat(prefixWidth);
320
+ for (const [index, line] of wrapTextWithAnsi(text, contentWidth).entries()) {
321
+ lines.push(`${index === 0 ? prefix : continuationPrefix}${line}`);
322
+ }
323
+ }
@@ -2,21 +2,19 @@
2
2
  // session transcript readable: a one-line "asking N questions: …" header on
3
3
  // the call, and a compact ✓ / cancelled / aborted summary on the result.
4
4
 
5
- import type { Theme } from "@mariozechner/pi-coding-agent";
6
- import { Text, truncateToWidth } from "@mariozechner/pi-tui";
5
+ import type { Theme } from "@earendil-works/pi-coding-agent";
6
+ import { Text } from "@earendil-works/pi-tui";
7
7
  import { formatSummaryBody } from "./format.ts";
8
8
  import { ASK_USER_ERROR_MARKER } from "./result.ts";
9
9
  import type { AskUserDetails, NormalizedQuestion } from "./types.ts";
10
10
 
11
- const MAX_HEADER_LIST = 60;
12
-
13
11
  export function renderAskUserCall(args: unknown, theme: Theme): Text {
14
12
  const headers = extractHeadersFromArgs(args);
15
13
  const count = headers.length;
16
14
  let text = theme.fg("toolTitle", theme.bold("ask_user "));
17
15
  text += theme.fg("muted", `${count || "?"} question${count === 1 ? "" : "s"}`);
18
16
  if (count > 0) {
19
- text += theme.fg("dim", ` (${truncateToWidth(headers.join(", "), MAX_HEADER_LIST)})`);
17
+ text += theme.fg("dim", ` (${headers.join(", ")})`);
20
18
  }
21
19
  return new Text(text, 0, 0);
22
20
  }
@@ -33,6 +31,13 @@ export function renderAskUserResult(
33
31
  const fallback = result.content[0];
34
32
  return new Text(fallback?.type === "text" ? (fallback.text ?? "") : "", 0, 0);
35
33
  }
34
+ if (details.terminalState === "skipped") {
35
+ return new Text(
36
+ `${theme.fg("dim", "Skipped")}\n${formatSubmittedSummary(details, theme)}`,
37
+ 0,
38
+ 0,
39
+ );
40
+ }
36
41
  if (details.terminalState === "cancelled") {
37
42
  return new Text(theme.fg("warning", "Cancelled"), 0, 0);
38
43
  }
@@ -14,25 +14,32 @@ import type {
14
14
  export interface HybridResult {
15
15
  content: { type: "text"; text: string }[];
16
16
  details: AskUserDetails;
17
+ skip?: true;
17
18
  }
18
19
 
19
20
  export function buildResult(
20
21
  questions: NormalizedQuestion[],
21
22
  outcome: QuestionnaireOutcome,
22
23
  ): HybridResult {
23
- return {
24
+ const result: HybridResult = {
24
25
  content: [{ type: "text", text: summarize(questions, outcome) }],
25
26
  details: {
26
27
  questions,
27
28
  answers: outcome.answers,
28
- answersById: indexById(outcome.answers),
29
+ answersById: indexById(questions, outcome.answers),
29
30
  terminalState: outcome.terminalState,
30
31
  },
31
32
  };
33
+ if (outcome.skipped) result.skip = true;
34
+ return result;
32
35
  }
33
36
 
34
- function indexById(answers: Answer[]): Record<string, Answer> {
35
- const out: Record<string, Answer> = {};
37
+ function indexById(
38
+ questions: NormalizedQuestion[],
39
+ answers: Answer[],
40
+ ): Record<string, Answer | undefined> {
41
+ const out: Record<string, Answer | undefined> = {};
42
+ for (const question of questions) out[question.id] = undefined;
36
43
  for (const answer of answers) out[answer.questionId] = answer;
37
44
  return out;
38
45
  }
@@ -53,6 +60,13 @@ export function buildErrorResult(message: string): HybridResult {
53
60
  }
54
61
 
55
62
  function summarize(questions: NormalizedQuestion[], outcome: QuestionnaireOutcome): string {
63
+ if (outcome.terminalState === "skipped") {
64
+ const byId = new Map(outcome.answers.map((answer) => [answer.questionId, answer]));
65
+ const lines = questions.map((question) =>
66
+ formatAnswerLine(question, byId.get(question.id), true),
67
+ );
68
+ return `User skipped the questionnaire.\n${lines.join("\n")}`;
69
+ }
56
70
  if (outcome.terminalState !== "submitted") return summarizeTerminal(outcome.terminalState);
57
71
  const byId = new Map(outcome.answers.map((answer) => [answer.questionId, answer]));
58
72
  return questions.map((question) => formatAnswerLine(question, byId.get(question.id))).join("\n");
@@ -63,7 +77,14 @@ function summarizeTerminal(state: TerminalState): string {
63
77
  return "Questionnaire was aborted before the user submitted answers.";
64
78
  }
65
79
 
66
- function formatAnswerLine(question: NormalizedQuestion, answer: Answer | undefined): string {
67
- if (!answer) return `${question.header}: (no answer)`;
80
+ function formatAnswerLine(
81
+ question: NormalizedQuestion,
82
+ answer: Answer | undefined,
83
+ skipped = false,
84
+ ): string {
85
+ if (!answer) {
86
+ if (skipped && !question.required) return `${question.header}: (skipped)`;
87
+ return `${question.header}: (no answer)`;
88
+ }
68
89
  return `${question.header}: ${formatSummaryBody(question, answer)}`;
69
90
  }
@@ -1,14 +1,19 @@
1
1
  // External (model-facing) parameter schema for the `ask_user` tool.
2
- // Rich-TUI-first redesign: explicit question kinds, explicit escape hatches,
3
- // and first-class option previews for structured comparisons.
2
+ // Two question types: choice (structured pick-one-or-many with options)
3
+ // and text (freeform input). Yes/no and multichoice have been unified
4
+ // into choice — yes/no is just choice with two options, multi-select
5
+ // is choice with `multi: true`.
4
6
 
5
- import { type Static, Type } from "@sinclair/typebox";
7
+ import { type Static, Type } from "typebox";
6
8
 
7
9
  const StructuredOptionSchema = Type.Object({
8
10
  value: Type.String({ description: "Stable identifier returned in the answer" }),
9
11
  label: Type.String({ description: "Display label shown to the user" }),
10
12
  description: Type.Optional(
11
- Type.String({ description: "Optional one-line clarification shown under the label" }),
13
+ Type.String({
14
+ description:
15
+ "Optional clarification shown under the label (wraps naturally, a short paragraph is fine)",
16
+ }),
12
17
  ),
13
18
  preview: Type.Optional(
14
19
  Type.String({
@@ -18,13 +23,20 @@ const StructuredOptionSchema = Type.Object({
18
23
  ),
19
24
  });
20
25
 
21
- const StructuredQuestionBaseSchema = {
26
+ const ChoiceQuestionSchema = Type.Object({
27
+ type: Type.Literal("choice"),
22
28
  id: Type.String({ description: "Unique question id within this questionnaire" }),
23
29
  header: Type.String({ description: "Short label (chip) describing the decision" }),
24
30
  prompt: Type.String({ description: "Full question text shown to the user" }),
25
31
  options: Type.Array(StructuredOptionSchema, {
26
- description: "Allowed answers (2-8). Use distinct, mutually exclusive options.",
32
+ description: "Allowed answers (2-12). Use distinct, mutually exclusive options.",
27
33
  }),
34
+ required: Type.Optional(
35
+ Type.Boolean({
36
+ default: true,
37
+ description: "Whether this question must be answered before submission (default true)",
38
+ }),
39
+ ),
28
40
  allowOther: Type.Optional(
29
41
  Type.Boolean({
30
42
  description: "Allow an explicit custom answer path instead of forcing one of the options",
@@ -36,23 +48,23 @@ const StructuredQuestionBaseSchema = {
36
48
  "Allow the user to choose discussion instead of committing to a decision right now",
37
49
  }),
38
50
  ),
39
- } as const;
40
-
41
- const ChoiceQuestionSchema = Type.Object({
42
- type: Type.Literal("choice"),
43
- ...StructuredQuestionBaseSchema,
44
- recommendation: Type.Optional(
45
- Type.String({ description: "Recommended option `value` (must match one of `options`)" }),
51
+ multi: Type.Optional(
52
+ Type.Boolean({
53
+ default: false,
54
+ description:
55
+ "Allow selecting multiple options (multi-select). Default false (single-select).",
56
+ }),
46
57
  ),
47
- });
48
-
49
- const MultiChoiceQuestionSchema = Type.Object({
50
- type: Type.Literal("multichoice"),
51
- ...StructuredQuestionBaseSchema,
52
58
  recommendation: Type.Optional(
53
- Type.Array(Type.String(), {
59
+ Type.Union([Type.String(), Type.Array(Type.String())], {
54
60
  description:
55
- "Recommended option `value`s for multi-select questions (each must match one of `options`)",
61
+ "Recommended option value(s). String for single-select, array for multi-select. Each must match an option value.",
62
+ }),
63
+ ),
64
+ default: Type.Optional(
65
+ Type.Union([Type.String(), Type.Array(Type.String())], {
66
+ description:
67
+ "Pre-selected option value(s). String for single-select, array for multi-select. Each must match an option value.",
56
68
  }),
57
69
  ),
58
70
  });
@@ -62,42 +74,32 @@ const TextQuestionSchema = Type.Object({
62
74
  id: Type.String({ description: "Unique question id within this questionnaire" }),
63
75
  header: Type.String({ description: "Short label (chip) describing the prompt" }),
64
76
  prompt: Type.String({ description: "Full question text shown to the user" }),
65
- });
66
-
67
- const YesNoQuestionSchema = Type.Object({
68
- type: Type.Literal("yesno"),
69
- id: Type.String({ description: "Unique question id within this questionnaire" }),
70
- header: Type.String({ description: "Short label (chip) describing the decision" }),
71
- prompt: Type.String({ description: "Full yes/no question shown to the user" }),
72
- allowOther: Type.Optional(
73
- Type.Boolean({ description: "Allow an explicit custom answer path besides yes/no" }),
74
- ),
75
- allowDiscuss: Type.Optional(
76
- Type.Boolean({ description: "Allow discussion instead of choosing yes or no" }),
77
- ),
78
- recommendation: Type.Optional(
79
- Type.Union([Type.Literal("yes"), Type.Literal("no")], {
80
- description: "Recommended answer (`yes` or `no`)",
77
+ required: Type.Optional(
78
+ Type.Boolean({
79
+ default: true,
80
+ description: "Whether this question must be answered before submission (default true)",
81
81
  }),
82
82
  ),
83
+ default: Type.Optional(
84
+ Type.String({ description: "Pre-filled default value shown in the text input" }),
85
+ ),
83
86
  });
84
87
 
85
- const QuestionSchema = Type.Union([
86
- ChoiceQuestionSchema,
87
- MultiChoiceQuestionSchema,
88
- TextQuestionSchema,
89
- YesNoQuestionSchema,
90
- ]);
88
+ const QuestionSchema = Type.Union([ChoiceQuestionSchema, TextQuestionSchema]);
91
89
 
92
90
  export const AskUserParamsSchema = Type.Object({
93
91
  questions: Type.Array(QuestionSchema, {
94
92
  description: "Between 1 and 4 focused decision questions",
95
93
  }),
94
+ allowSkip: Type.Optional(
95
+ Type.Boolean({
96
+ description:
97
+ "Expose a Skip action so the user can submit a partial result without answering all questions",
98
+ }),
99
+ ),
96
100
  });
97
101
 
98
102
  export type AskUserParams = Static<typeof AskUserParamsSchema>;
99
103
  export type ExternalQuestion = Static<typeof QuestionSchema>;
100
104
  export type ExternalChoiceQuestion = Static<typeof ChoiceQuestionSchema>;
101
- export type ExternalMultiChoiceQuestion = Static<typeof MultiChoiceQuestionSchema>;
102
105
  export type ExternalTextQuestion = Static<typeof TextQuestionSchema>;
103
- export type ExternalYesNoQuestion = Static<typeof YesNoQuestionSchema>;