@mrclrchtr/supi-ask-user 1.3.1 → 1.5.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.
Files changed (51) hide show
  1. package/README.md +163 -67
  2. package/node_modules/@mrclrchtr/supi-core/README.md +52 -41
  3. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +15 -13
  5. package/node_modules/@mrclrchtr/supi-core/src/{config-settings.ts → config/config-settings.ts} +2 -2
  6. package/node_modules/@mrclrchtr/supi-core/src/{context-provider-registry.ts → context/context-provider-registry.ts} +1 -1
  7. package/node_modules/@mrclrchtr/supi-core/src/extension.ts +1 -1
  8. package/node_modules/@mrclrchtr/supi-core/src/index.ts +15 -13
  9. package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +40 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +42 -10
  11. package/node_modules/@mrclrchtr/supi-core/src/{settings-registry.ts → settings/settings-registry.ts} +1 -1
  12. package/package.json +2 -2
  13. package/src/api.ts +19 -0
  14. package/src/ask-user.ts +71 -131
  15. package/src/index.ts +23 -1
  16. package/src/normalize.ts +153 -142
  17. package/src/render/result.ts +102 -0
  18. package/src/render/transcript.ts +65 -0
  19. package/src/render/tree-summary.ts +10 -0
  20. package/src/schema.ts +41 -38
  21. package/src/session/controller.ts +281 -0
  22. package/src/session/lock.ts +19 -0
  23. package/src/tool/guidance.ts +15 -0
  24. package/src/types.ts +56 -55
  25. package/src/ui/choose-renderer.ts +11 -0
  26. package/src/ui/overlay-actions.ts +42 -0
  27. package/src/ui/overlay-component.ts +400 -0
  28. package/src/ui/overlay-render.ts +219 -0
  29. package/src/ui/overlay-view.ts +313 -0
  30. package/src/ui/overlay.ts +28 -0
  31. package/src/ui/types.ts +38 -0
  32. package/src/flow.ts +0 -224
  33. package/src/format.ts +0 -66
  34. package/src/render/ui-rich-render-editor.ts +0 -51
  35. package/src/render/ui-rich-render-env.ts +0 -15
  36. package/src/render/ui-rich-render-footer.ts +0 -55
  37. package/src/render/ui-rich-render-markdown.ts +0 -33
  38. package/src/render/ui-rich-render-notes.ts +0 -80
  39. package/src/render/ui-rich-render-types.ts +0 -17
  40. package/src/render/ui-rich-render.ts +0 -323
  41. package/src/render.ts +0 -95
  42. package/src/result.ts +0 -90
  43. package/src/ui/ui-rich-handlers.ts +0 -369
  44. package/src/ui/ui-rich-inline.ts +0 -77
  45. package/src/ui/ui-rich-state.ts +0 -179
  46. package/src/ui/ui-rich.ts +0 -144
  47. /package/node_modules/@mrclrchtr/supi-core/src/{config.ts → config/config.ts} +0 -0
  48. /package/node_modules/@mrclrchtr/supi-core/src/{context-messages.ts → context/context-messages.ts} +0 -0
  49. /package/node_modules/@mrclrchtr/supi-core/src/{context-tag.ts → context/context-tag.ts} +0 -0
  50. /package/node_modules/@mrclrchtr/supi-core/src/{settings-command.ts → settings/settings-command.ts} +0 -0
  51. /package/node_modules/@mrclrchtr/supi-core/src/{settings-ui.ts → settings/settings-ui.ts} +0 -0
package/src/normalize.ts CHANGED
@@ -1,13 +1,18 @@
1
- // Validates raw `ask_user` parameters and lowers them into the shared internal
2
- // questionnaire model. Both UI paths and result formatting consume only this
3
- // normalized model, so the overlay and dialog flows cannot drift apart.
1
+ // Validation and normalization for ask_user tool calls.
4
2
 
5
- import type { AskUserParams, ExternalQuestion } from "./schema.ts";
3
+ import type {
4
+ AskUserParams,
5
+ ExternalChoiceQuestion,
6
+ ExternalQuestion,
7
+ ExternalTextQuestion,
8
+ } from "./schema.ts";
6
9
  import {
10
+ ASK_USER_LIMITS,
11
+ type NormalizedChoiceQuestion,
7
12
  type NormalizedOption,
8
13
  type NormalizedQuestion,
9
14
  type NormalizedQuestionnaire,
10
- QUESTION_LIMITS,
15
+ type NormalizedTextQuestion,
11
16
  } from "./types.ts";
12
17
 
13
18
  export class AskUserValidationError extends Error {
@@ -18,212 +23,218 @@ export class AskUserValidationError extends Error {
18
23
  }
19
24
 
20
25
  export function normalizeQuestionnaire(params: AskUserParams): NormalizedQuestionnaire {
21
- validateQuestionnaireShape(params);
22
- return {
23
- questions: params.questions.map((q) => normalizeQuestion(q)),
24
- allowSkip: params.allowSkip ?? false,
25
- };
26
- }
27
-
28
- function validateQuestionnaireShape(params: AskUserParams): void {
29
- const count = params.questions.length;
30
- if (count < QUESTION_LIMITS.minQuestions || count > QUESTION_LIMITS.maxQuestions) {
31
- throw new AskUserValidationError(
32
- `ask_user supports ${QUESTION_LIMITS.minQuestions}-${QUESTION_LIMITS.maxQuestions} questions only (got ${count}).`,
33
- );
26
+ validateQuestionCount(params.questions.length);
27
+ const title = trimOptional(params.title);
28
+ const intro = trimOptional(params.intro);
29
+ if (title && title.length > ASK_USER_LIMITS.maxTitleLength) {
30
+ throw new AskUserValidationError(`title exceeds ${ASK_USER_LIMITS.maxTitleLength} characters.`);
34
31
  }
32
+ if (intro && intro.length > ASK_USER_LIMITS.maxIntroLength) {
33
+ throw new AskUserValidationError(`intro exceeds ${ASK_USER_LIMITS.maxIntroLength} characters.`);
34
+ }
35
+
35
36
  const seen = new Set<string>();
36
- for (const q of params.questions) {
37
- const questionId = q.id.trim();
38
- if (questionId.length === 0) continue;
39
- if (seen.has(questionId)) {
37
+ const questions = params.questions.map((question) => {
38
+ const normalized = normalizeQuestion(question);
39
+ if (seen.has(normalized.id)) {
40
40
  throw new AskUserValidationError(
41
- `Duplicate question id "${questionId}" — question ids must be unique within a questionnaire.`,
41
+ `Duplicate question id "${normalized.id}" — ids must be unique within one form.`,
42
42
  );
43
43
  }
44
- seen.add(questionId);
45
- }
44
+ seen.add(normalized.id);
45
+ return normalized;
46
+ });
47
+
48
+ return {
49
+ ...(title ? { title } : {}),
50
+ ...(intro ? { intro } : {}),
51
+ questions,
52
+ allowPartialSubmit: params.allowPartialSubmit ?? false,
53
+ allowDiscuss: params.allowDiscuss ?? false,
54
+ };
46
55
  }
47
56
 
48
- function normalizeQuestion(q: ExternalQuestion): NormalizedQuestion {
49
- validateCommonFields(q);
50
- switch (q.type) {
51
- case "choice":
52
- return normalizeChoice(q);
53
- case "text":
54
- return normalizeText(q);
55
- }
57
+ function normalizeQuestion(question: ExternalQuestion): NormalizedQuestion {
58
+ validateCommonFields(question);
59
+ return question.type === "choice" ? normalizeChoice(question) : normalizeText(question);
56
60
  }
57
61
 
58
- function validateCommonFields(q: ExternalQuestion): void {
59
- if (q.id.trim().length === 0) {
60
- throw new AskUserValidationError("Question id must be a non-empty string.");
62
+ function validateQuestionCount(count: number): void {
63
+ if (count < ASK_USER_LIMITS.minQuestions || count > ASK_USER_LIMITS.maxQuestions) {
64
+ throw new AskUserValidationError(
65
+ `ask_user supports ${ASK_USER_LIMITS.minQuestions}-${ASK_USER_LIMITS.maxQuestions} questions only (got ${count}).`,
66
+ );
61
67
  }
62
- if (q.header.trim().length === 0) {
63
- throw new AskUserValidationError(`Question "${q.id}" must include a non-empty header.`);
68
+ }
69
+
70
+ function validateCommonFields(question: ExternalQuestion): void {
71
+ const id = question.id.trim();
72
+ const header = question.header.trim();
73
+ const prompt = question.prompt.trim();
74
+
75
+ if (!id) throw new AskUserValidationError("Question id must be a non-empty string.");
76
+ if (!header) {
77
+ throw new AskUserValidationError(`Question "${question.id}" must include a non-empty header.`);
64
78
  }
65
- if (q.header.length > QUESTION_LIMITS.maxHeaderLength) {
79
+ if (header.length > ASK_USER_LIMITS.maxHeaderLength) {
66
80
  throw new AskUserValidationError(
67
- `Question "${q.id}" header exceeds ${QUESTION_LIMITS.maxHeaderLength} characters.`,
81
+ `Question "${question.id}" header exceeds ${ASK_USER_LIMITS.maxHeaderLength} characters.`,
68
82
  );
69
83
  }
70
- if (q.prompt.trim().length === 0) {
71
- throw new AskUserValidationError(`Question "${q.id}" must include a non-empty prompt.`);
84
+ if (!prompt) {
85
+ throw new AskUserValidationError(`Question "${question.id}" must include a non-empty prompt.`);
72
86
  }
73
- if (q.prompt.length > QUESTION_LIMITS.maxPromptLength) {
87
+ if (prompt.length > ASK_USER_LIMITS.maxPromptLength) {
74
88
  throw new AskUserValidationError(
75
- `Question "${q.id}" prompt exceeds ${QUESTION_LIMITS.maxPromptLength} characters.`,
89
+ `Question "${question.id}" prompt exceeds ${ASK_USER_LIMITS.maxPromptLength} characters.`,
76
90
  );
77
91
  }
78
92
  }
79
93
 
80
- function normalizeChoice(q: {
81
- type: "choice";
82
- id: string;
83
- header: string;
84
- prompt: string;
85
- required?: boolean;
86
- multi?: boolean;
87
- options: { value: string; label: string; description?: string; preview?: string }[];
88
- allowOther?: boolean;
89
- allowDiscuss?: boolean;
90
- recommendation?: string | string[];
91
- default?: string | string[];
92
- }): NormalizedQuestion {
93
- const id = q.id.trim();
94
- const options = normalizeStructuredOptions(id, q.options);
95
- const multi = q.multi ?? false;
96
- const recommendation = q.recommendation;
97
- const defaultValue = q.default;
98
-
99
- validateRecDefaultShape(id, recommendation, multi, "recommendation");
100
- validateRecDefaultShape(id, defaultValue, multi, "default");
94
+ function normalizeChoice(question: ExternalChoiceQuestion): NormalizedChoiceQuestion {
95
+ const options = normalizeOptions(question.id.trim(), question.options);
96
+ const multi = question.multi ?? false;
97
+ const allowOther = question.allowOther ?? false;
98
+ if (multi && allowOther) {
99
+ throw new AskUserValidationError(
100
+ `choice question "${question.id}" cannot use allowOther together with multi-select.`,
101
+ );
102
+ }
103
+
104
+ validateSelectionShape(question.id, question.recommendation, multi, "recommendation");
105
+ validateSelectionShape(question.id, question.initial, multi, "initial");
101
106
 
102
107
  return {
103
- id,
104
- header: q.header,
108
+ id: question.id.trim(),
109
+ header: question.header.trim(),
110
+ prompt: question.prompt.trim(),
111
+ required: question.required ?? true,
105
112
  type: "choice",
106
- prompt: q.prompt,
107
- required: q.required ?? true,
108
- multi,
109
113
  options,
110
- allowOther: q.allowOther ?? false,
111
- allowDiscuss: q.allowDiscuss ?? false,
112
- recommendedIndexes: resolveRecDefault(id, options, recommendation, multi, "recommendation"),
113
- defaultIndexes: resolveRecDefault(id, options, defaultValue, multi, "default"),
114
+ multi,
115
+ allowOther,
116
+ recommendedIndexes: resolveIndexes({
117
+ questionId: question.id,
118
+ options,
119
+ value: question.recommendation,
120
+ multi,
121
+ kind: "recommendation",
122
+ }),
123
+ initialIndexes: resolveIndexes({
124
+ questionId: question.id,
125
+ options,
126
+ value: question.initial,
127
+ multi,
128
+ kind: "initial",
129
+ }),
114
130
  };
115
131
  }
116
132
 
117
- function validateRecDefaultShape(
118
- questionId: string,
119
- value: string | string[] | undefined,
120
- multi: boolean,
121
- kind: string,
122
- ): void {
123
- if (value === undefined) return;
124
- if (!multi && Array.isArray(value)) {
125
- throw new AskUserValidationError(
126
- `single-select question "${questionId}" ${kind} must be a string, not an array.`,
127
- );
128
- }
129
- if (multi && !Array.isArray(value)) {
133
+ function normalizeText(question: ExternalTextQuestion): NormalizedTextQuestion {
134
+ const placeholder = trimOptional(question.placeholder);
135
+ if (placeholder && placeholder.length > ASK_USER_LIMITS.maxPlaceholderLength) {
130
136
  throw new AskUserValidationError(
131
- `multi-select question "${questionId}" ${kind} must be an array, not a string.`,
137
+ `Question "${question.id}" placeholder exceeds ${ASK_USER_LIMITS.maxPlaceholderLength} characters.`,
132
138
  );
133
139
  }
134
- }
135
140
 
136
- function normalizeText(q: {
137
- id: string;
138
- header: string;
139
- prompt: string;
140
- required?: boolean;
141
- default?: string;
142
- }): NormalizedQuestion {
143
141
  return {
144
- id: q.id.trim(),
145
- header: q.header,
142
+ id: question.id.trim(),
143
+ header: question.header.trim(),
144
+ prompt: question.prompt.trim(),
145
+ required: question.required ?? true,
146
146
  type: "text",
147
- prompt: q.prompt,
148
- required: q.required ?? true,
149
- options: [],
150
- ...(q.default !== undefined ? { default: q.default.trim() } : {}),
147
+ ...(question.initial !== undefined ? { initial: question.initial } : {}),
148
+ ...(placeholder ? { placeholder } : {}),
151
149
  };
152
150
  }
153
151
 
154
- function normalizeStructuredOptions(
152
+ function normalizeOptions(
155
153
  questionId: string,
156
- options: { value: string; label: string; description?: string; preview?: string }[],
154
+ options: ExternalChoiceQuestion["options"],
157
155
  ): NormalizedOption[] {
158
- const optionCount = options.length;
159
156
  if (
160
- optionCount < QUESTION_LIMITS.minChoiceOptions ||
161
- optionCount > QUESTION_LIMITS.maxChoiceOptions
157
+ options.length < ASK_USER_LIMITS.minChoiceOptions ||
158
+ options.length > ASK_USER_LIMITS.maxChoiceOptions
162
159
  ) {
163
160
  throw new AskUserValidationError(
164
- `choice question "${questionId}" must have ${QUESTION_LIMITS.minChoiceOptions}-${QUESTION_LIMITS.maxChoiceOptions} options (got ${optionCount}).`,
161
+ `choice question "${questionId}" must have ${ASK_USER_LIMITS.minChoiceOptions}-${ASK_USER_LIMITS.maxChoiceOptions} options (got ${options.length}).`,
165
162
  );
166
163
  }
167
- const seenValues = new Set<string>();
168
- return options.map((opt) => {
169
- const value = opt.value.trim();
170
- if (value.length === 0 || opt.label.trim().length === 0) {
164
+
165
+ const seen = new Set<string>();
166
+ return options.map((option) => {
167
+ const value = option.value.trim();
168
+ const label = option.label.trim();
169
+ if (!value || !label) {
171
170
  throw new AskUserValidationError(
172
171
  `choice question "${questionId}" has an option with empty value or label.`,
173
172
  );
174
173
  }
175
- if (seenValues.has(value)) {
174
+ if (seen.has(value)) {
176
175
  throw new AskUserValidationError(
177
176
  `choice question "${questionId}" has duplicate option value "${value}".`,
178
177
  );
179
178
  }
180
- seenValues.add(value);
179
+ seen.add(value);
181
180
  return {
182
181
  value,
183
- label: opt.label,
184
- description: opt.description,
185
- preview: opt.preview,
182
+ label,
183
+ description: trimOptional(option.description),
184
+ preview: trimOptional(option.preview),
186
185
  };
187
186
  });
188
187
  }
189
188
 
190
- // biome-ignore lint/complexity/useMaxParams: five distinct positional params are cleaner than a non-reusable options object for this internal helper
191
- function resolveRecDefault(
189
+ function validateSelectionShape(
192
190
  questionId: string,
193
- options: NormalizedOption[],
194
191
  value: string | string[] | undefined,
195
192
  multi: boolean,
196
- kind: "recommendation" | "default",
197
- ): number[] {
198
- if (value === undefined) return [];
199
- const verb = kind === "recommendation" ? "recommends" : "defaults to";
200
- if (!multi) {
201
- const trimmed = (value as string).trim();
202
- const idx = options.findIndex((opt) => opt.value === trimmed);
203
- if (idx < 0) {
204
- throw new AskUserValidationError(
205
- `choice question "${questionId}" ${verb} "${trimmed}", which is not one of its option values.`,
206
- );
207
- }
208
- return [idx];
193
+ kind: "recommendation" | "initial",
194
+ ): void {
195
+ if (value === undefined) return;
196
+ if (multi && !Array.isArray(value)) {
197
+ throw new AskUserValidationError(
198
+ `multi-select question "${questionId}" ${kind} must be an array, not a string.`,
199
+ );
209
200
  }
210
- const values = value as string[];
211
- if (values.length === 0) return [];
201
+ if (!multi && Array.isArray(value)) {
202
+ throw new AskUserValidationError(
203
+ `single-select question "${questionId}" ${kind} must be a string, not an array.`,
204
+ );
205
+ }
206
+ }
207
+
208
+ function resolveIndexes(args: {
209
+ questionId: string;
210
+ options: NormalizedOption[];
211
+ value: string | string[] | undefined;
212
+ multi: boolean;
213
+ kind: "recommendation" | "initial";
214
+ }): number[] {
215
+ const { questionId, options, value, multi, kind } = args;
216
+ if (value === undefined) return [];
217
+ const values = multi ? (value as string[]) : [value as string];
212
218
  const seen = new Set<string>();
213
- return values.map((v) => {
214
- const trimmed = v.trim();
219
+ return values.map((entry: string) => {
220
+ const trimmed = entry.trim();
215
221
  if (seen.has(trimmed)) {
216
222
  throw new AskUserValidationError(
217
- `choice question "${questionId}" has duplicate ${kind === "recommendation" ? "recommended" : "default"} value "${trimmed}".`,
223
+ `choice question "${questionId}" has duplicate ${kind} value "${trimmed}".`,
218
224
  );
219
225
  }
220
226
  seen.add(trimmed);
221
- const idx = options.findIndex((opt) => opt.value === trimmed);
222
- if (idx < 0) {
227
+ const index = options.findIndex((option) => option.value === trimmed);
228
+ if (index < 0) {
223
229
  throw new AskUserValidationError(
224
- `choice question "${questionId}" ${verb} "${trimmed}", which is not one of its option values.`,
230
+ `choice question "${questionId}" ${kind} value "${trimmed}" does not match any option value.`,
225
231
  );
226
232
  }
227
- return idx;
233
+ return index;
228
234
  });
229
235
  }
236
+
237
+ function trimOptional(value: string | undefined): string | undefined {
238
+ const trimmed = value?.trim();
239
+ return trimmed ? trimmed : undefined;
240
+ }
@@ -0,0 +1,102 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ Answer,
4
+ AskUserDetails,
5
+ AskUserErrorDetails,
6
+ AskUserOutcome,
7
+ AskUserToolDetails,
8
+ NormalizedQuestion,
9
+ NormalizedQuestionnaire,
10
+ } from "../types.ts";
11
+
12
+ export type AskUserToolResult = AgentToolResult<AskUserToolDetails>;
13
+
14
+ export function buildResult(
15
+ questionnaire: NormalizedQuestionnaire,
16
+ outcome: AskUserOutcome,
17
+ ): AskUserToolResult {
18
+ const details: AskUserDetails = {
19
+ ...(questionnaire.title ? { title: questionnaire.title } : {}),
20
+ ...(questionnaire.intro ? { intro: questionnaire.intro } : {}),
21
+ questions: questionnaire.questions,
22
+ status: outcome.status,
23
+ answersById: outcome.answersById,
24
+ missingQuestionIds: outcome.missingQuestionIds,
25
+ ...(outcome.discussMessage ? { discussMessage: outcome.discussMessage } : {}),
26
+ };
27
+
28
+ return {
29
+ content: [{ type: "text", text: summarizeOutcome(questionnaire.questions, outcome) }],
30
+ details,
31
+ };
32
+ }
33
+
34
+ export function buildErrorResult(message: string): AskUserToolResult {
35
+ const details: AskUserErrorDetails = { kind: "error", message };
36
+ return {
37
+ content: [{ type: "text", text: message }],
38
+ details,
39
+ };
40
+ }
41
+
42
+ export function formatAnswerSummary(_question: NormalizedQuestion, answer: Answer): string {
43
+ switch (answer.kind) {
44
+ case "choice":
45
+ return answer.selections.map(formatChoiceSelectionSummary).join("; ");
46
+ case "custom":
47
+ return `Other — ${answer.value}`;
48
+ case "text":
49
+ return answer.value;
50
+ }
51
+ }
52
+
53
+ function formatChoiceSelectionSummary(selection: { label: string; note?: string }): string {
54
+ return selection.note ? `${selection.label} (note: ${selection.note})` : selection.label;
55
+ }
56
+
57
+ function summarizeOutcome(questions: NormalizedQuestion[], outcome: AskUserOutcome): string {
58
+ if (outcome.status === "cancelled") return "User cancelled the form.";
59
+ if (outcome.status === "aborted") return "The form was aborted before completion.";
60
+
61
+ const lines = formatAnsweredLines(questions, outcome.answersById);
62
+ const missing = formatMissingHeaders(questions, outcome.missingQuestionIds);
63
+
64
+ if (outcome.status === "submitted") {
65
+ return lines.length > 0 ? lines.join("\n") : "User submitted the form.";
66
+ }
67
+
68
+ if (outcome.status === "partial") {
69
+ return [
70
+ "User submitted a partial form.",
71
+ ...lines,
72
+ ...(missing ? [`Missing required answers: ${missing}`] : []),
73
+ ].join("\n");
74
+ }
75
+
76
+ return [
77
+ "User wants to discuss before deciding.",
78
+ ...(outcome.discussMessage ? [`Discussion request: ${outcome.discussMessage}`] : []),
79
+ ...lines,
80
+ ...(missing ? [`Still missing: ${missing}`] : []),
81
+ ].join("\n");
82
+ }
83
+
84
+ function formatAnsweredLines(
85
+ questions: NormalizedQuestion[],
86
+ answersById: Record<string, Answer>,
87
+ ): string[] {
88
+ return questions.flatMap((question) => {
89
+ const answer = answersById[question.id];
90
+ return answer ? [`${question.header}: ${formatAnswerSummary(question, answer)}`] : [];
91
+ });
92
+ }
93
+
94
+ export function formatMissingHeaders(
95
+ questions: NormalizedQuestion[],
96
+ missingQuestionIds: string[],
97
+ ): string | undefined {
98
+ const headers = questions
99
+ .filter((question) => missingQuestionIds.includes(question.id))
100
+ .map((question) => question.header);
101
+ return headers.length > 0 ? headers.join(", ") : undefined;
102
+ }
@@ -0,0 +1,65 @@
1
+ import type { AgentToolResult, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import type { AskUserParams } from "../schema.ts";
4
+ import type { AskUserDetails, AskUserToolDetails } from "../types.ts";
5
+ import { isErrorDetails } from "../types.ts";
6
+ import { formatAnswerSummary, formatMissingHeaders } from "./result.ts";
7
+
8
+ export function renderAskUserCall(args: AskUserParams, theme: Theme): Text {
9
+ const title = args.title?.trim();
10
+ const headers = args.questions.map((question) => question.header.trim()).filter(Boolean);
11
+ const label = title || `${headers.length} question${headers.length === 1 ? "" : "s"}`;
12
+ const suffix = title && headers.length > 0 ? ` (${headers.join(", ")})` : headers.join(", ");
13
+ const text = `${theme.fg("toolTitle", theme.bold("ask_user "))}${theme.fg("muted", label)}${suffix ? theme.fg("dim", ` ${suffix}`) : ""}`;
14
+ return new Text(text, 0, 0);
15
+ }
16
+
17
+ export function renderAskUserResult(
18
+ result: Pick<AgentToolResult<AskUserToolDetails>, "content" | "details">,
19
+ theme: Theme,
20
+ ): Text {
21
+ if (isErrorDetails(result.details)) {
22
+ return new Text(theme.fg("error", result.details.message), 0, 0);
23
+ }
24
+
25
+ const lines = buildResultLines(result.details, theme);
26
+ return new Text(lines.join("\n"), 0, 0);
27
+ }
28
+
29
+ function buildResultLines(details: AskUserDetails, theme: Theme): string[] {
30
+ const answersById = details.answersById;
31
+ const answerLines = details.questions.flatMap((question) => {
32
+ const answer = answersById[question.id];
33
+ return answer
34
+ ? [
35
+ `${theme.fg("success", "✓ ")}${theme.fg("accent", question.header)}: ${theme.fg("text", formatAnswerSummary(question, answer))}`,
36
+ ]
37
+ : [];
38
+ });
39
+
40
+ switch (details.status) {
41
+ case "submitted":
42
+ return answerLines.length > 0 ? answerLines : [theme.fg("success", "Submitted")];
43
+ case "partial": {
44
+ const missing = formatMissingHeaders(details.questions, details.missingQuestionIds);
45
+ return [
46
+ theme.fg("warning", "Partial"),
47
+ ...answerLines,
48
+ ...(missing ? [theme.fg("dim", `Missing required: ${missing}`)] : []),
49
+ ];
50
+ }
51
+ case "discuss": {
52
+ const missing = formatMissingHeaders(details.questions, details.missingQuestionIds);
53
+ return [
54
+ theme.fg("warning", "Discuss"),
55
+ ...(details.discussMessage ? [theme.fg("text", `Message: ${details.discussMessage}`)] : []),
56
+ ...answerLines,
57
+ ...(missing ? [theme.fg("dim", `Still missing: ${missing}`)] : []),
58
+ ];
59
+ }
60
+ case "cancelled":
61
+ return [theme.fg("warning", "Cancelled")];
62
+ case "aborted":
63
+ return [theme.fg("error", "Aborted")];
64
+ }
65
+ }
@@ -0,0 +1,10 @@
1
+ import type { NormalizedQuestionnaire } from "../types.ts";
2
+
3
+ export function buildTreeSummaryLabel(
4
+ questionnaire: Pick<NormalizedQuestionnaire, "title" | "questions">,
5
+ ): string {
6
+ const base =
7
+ questionnaire.title?.trim() || questionnaire.questions.map((q) => q.header).join(", ");
8
+ const trimmed = base.length > 70 ? `${base.slice(0, 67)}...` : base;
9
+ return `ask_user · ${trimmed}`;
10
+ }