@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.
- package/README.md +163 -67
- package/node_modules/@mrclrchtr/supi-core/README.md +52 -41
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +15 -13
- package/node_modules/@mrclrchtr/supi-core/src/{config-settings.ts → config/config-settings.ts} +2 -2
- package/node_modules/@mrclrchtr/supi-core/src/{context-provider-registry.ts → context/context-provider-registry.ts} +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/extension.ts +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +15 -13
- package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +40 -0
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +42 -10
- package/node_modules/@mrclrchtr/supi-core/src/{settings-registry.ts → settings/settings-registry.ts} +1 -1
- package/package.json +2 -2
- package/src/api.ts +19 -0
- package/src/ask-user.ts +71 -131
- package/src/index.ts +23 -1
- package/src/normalize.ts +153 -142
- package/src/render/result.ts +102 -0
- package/src/render/transcript.ts +65 -0
- package/src/render/tree-summary.ts +10 -0
- package/src/schema.ts +41 -38
- package/src/session/controller.ts +281 -0
- package/src/session/lock.ts +19 -0
- package/src/tool/guidance.ts +15 -0
- package/src/types.ts +56 -55
- package/src/ui/choose-renderer.ts +11 -0
- package/src/ui/overlay-actions.ts +42 -0
- package/src/ui/overlay-component.ts +400 -0
- package/src/ui/overlay-render.ts +219 -0
- package/src/ui/overlay-view.ts +313 -0
- package/src/ui/overlay.ts +28 -0
- package/src/ui/types.ts +38 -0
- package/src/flow.ts +0 -224
- package/src/format.ts +0 -66
- package/src/render/ui-rich-render-editor.ts +0 -51
- package/src/render/ui-rich-render-env.ts +0 -15
- package/src/render/ui-rich-render-footer.ts +0 -55
- package/src/render/ui-rich-render-markdown.ts +0 -33
- package/src/render/ui-rich-render-notes.ts +0 -80
- package/src/render/ui-rich-render-types.ts +0 -17
- package/src/render/ui-rich-render.ts +0 -323
- package/src/render.ts +0 -95
- package/src/result.ts +0 -90
- package/src/ui/ui-rich-handlers.ts +0 -369
- package/src/ui/ui-rich-inline.ts +0 -77
- package/src/ui/ui-rich-state.ts +0 -179
- package/src/ui/ui-rich.ts +0 -144
- /package/node_modules/@mrclrchtr/supi-core/src/{config.ts → config/config.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-core/src/{context-messages.ts → context/context-messages.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-core/src/{context-tag.ts → context/context-tag.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-core/src/{settings-command.ts → settings/settings-command.ts} +0 -0
- /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
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
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 "${
|
|
41
|
+
`Duplicate question id "${normalized.id}" — ids must be unique within one form.`,
|
|
42
42
|
);
|
|
43
43
|
}
|
|
44
|
-
seen.add(
|
|
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(
|
|
49
|
-
validateCommonFields(
|
|
50
|
-
|
|
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
|
|
59
|
-
if (
|
|
60
|
-
throw new AskUserValidationError(
|
|
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
|
-
|
|
63
|
-
|
|
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 (
|
|
79
|
+
if (header.length > ASK_USER_LIMITS.maxHeaderLength) {
|
|
66
80
|
throw new AskUserValidationError(
|
|
67
|
-
`Question "${
|
|
81
|
+
`Question "${question.id}" header exceeds ${ASK_USER_LIMITS.maxHeaderLength} characters.`,
|
|
68
82
|
);
|
|
69
83
|
}
|
|
70
|
-
if (
|
|
71
|
-
throw new AskUserValidationError(`Question "${
|
|
84
|
+
if (!prompt) {
|
|
85
|
+
throw new AskUserValidationError(`Question "${question.id}" must include a non-empty prompt.`);
|
|
72
86
|
}
|
|
73
|
-
if (
|
|
87
|
+
if (prompt.length > ASK_USER_LIMITS.maxPromptLength) {
|
|
74
88
|
throw new AskUserValidationError(
|
|
75
|
-
`Question "${
|
|
89
|
+
`Question "${question.id}" prompt exceeds ${ASK_USER_LIMITS.maxPromptLength} characters.`,
|
|
76
90
|
);
|
|
77
91
|
}
|
|
78
92
|
}
|
|
79
93
|
|
|
80
|
-
function normalizeChoice(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
recommendation
|
|
91
|
-
|
|
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:
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
recommendedIndexes:
|
|
113
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
`
|
|
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:
|
|
145
|
-
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
|
-
|
|
148
|
-
|
|
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
|
|
152
|
+
function normalizeOptions(
|
|
155
153
|
questionId: string,
|
|
156
|
-
options:
|
|
154
|
+
options: ExternalChoiceQuestion["options"],
|
|
157
155
|
): NormalizedOption[] {
|
|
158
|
-
const optionCount = options.length;
|
|
159
156
|
if (
|
|
160
|
-
|
|
161
|
-
|
|
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 ${
|
|
161
|
+
`choice question "${questionId}" must have ${ASK_USER_LIMITS.minChoiceOptions}-${ASK_USER_LIMITS.maxChoiceOptions} options (got ${options.length}).`,
|
|
165
162
|
);
|
|
166
163
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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 (
|
|
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
|
-
|
|
179
|
+
seen.add(value);
|
|
181
180
|
return {
|
|
182
181
|
value,
|
|
183
|
-
label
|
|
184
|
-
description:
|
|
185
|
-
preview:
|
|
182
|
+
label,
|
|
183
|
+
description: trimOptional(option.description),
|
|
184
|
+
preview: trimOptional(option.preview),
|
|
186
185
|
};
|
|
187
186
|
});
|
|
188
187
|
}
|
|
189
188
|
|
|
190
|
-
|
|
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" | "
|
|
197
|
-
):
|
|
198
|
-
if (value === undefined) return
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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((
|
|
214
|
-
const trimmed =
|
|
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
|
|
223
|
+
`choice question "${questionId}" has duplicate ${kind} value "${trimmed}".`,
|
|
218
224
|
);
|
|
219
225
|
}
|
|
220
226
|
seen.add(trimmed);
|
|
221
|
-
const
|
|
222
|
-
if (
|
|
227
|
+
const index = options.findIndex((option) => option.value === trimmed);
|
|
228
|
+
if (index < 0) {
|
|
223
229
|
throw new AskUserValidationError(
|
|
224
|
-
`choice question "${questionId}" ${
|
|
230
|
+
`choice question "${questionId}" ${kind} value "${trimmed}" does not match any option value.`,
|
|
225
231
|
);
|
|
226
232
|
}
|
|
227
|
-
return
|
|
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
|
+
}
|