@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/schema.ts CHANGED
@@ -1,100 +1,103 @@
1
- // External (model-facing) parameter schema for the `ask_user` tool.
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`.
1
+ // External, model-facing parameter schema for the redesigned ask_user tool.
6
2
 
7
3
  import { type Static, Type } from "typebox";
8
4
 
9
- const StructuredOptionSchema = Type.Object({
10
- value: Type.String({ description: "Stable identifier returned in the answer" }),
5
+ const OptionSchema = Type.Object({
6
+ value: Type.String({ description: "Stable identifier returned when this option is selected" }),
11
7
  label: Type.String({ description: "Display label shown to the user" }),
12
8
  description: Type.Optional(
13
9
  Type.String({
14
- description:
15
- "Optional clarification shown under the label (wraps naturally, a short paragraph is fine)",
10
+ description: "Optional clarification shown under the label in richer UIs",
16
11
  }),
17
12
  ),
18
13
  preview: Type.Optional(
19
14
  Type.String({
20
- description:
21
- "Optional rich preview content shown in the TUI (markdown, code, or ASCII mockups)",
15
+ description: "Optional preview content shown for the currently focused option",
22
16
  }),
23
17
  ),
24
18
  });
25
19
 
26
20
  const ChoiceQuestionSchema = Type.Object({
27
21
  type: Type.Literal("choice"),
28
- id: Type.String({ description: "Unique question id within this questionnaire" }),
29
- header: Type.String({ description: "Short label (chip) describing the decision" }),
22
+ id: Type.String({ description: "Unique question id within this form" }),
23
+ header: Type.String({ description: "Short label describing the decision" }),
30
24
  prompt: Type.String({ description: "Full question text shown to the user" }),
31
- options: Type.Array(StructuredOptionSchema, {
32
- description: "Allowed answers (2-12). Use distinct, mutually exclusive options.",
25
+ options: Type.Array(OptionSchema, {
26
+ description: "Allowed answers (2-12 distinct options)",
33
27
  }),
34
28
  required: Type.Optional(
35
29
  Type.Boolean({
36
30
  default: true,
37
- description: "Whether this question must be answered before submission (default true)",
38
- }),
39
- ),
40
- allowOther: Type.Optional(
41
- Type.Boolean({
42
- description: "Allow an explicit custom answer path instead of forcing one of the options",
31
+ description: "Whether this question must be answered for a full submit",
43
32
  }),
44
33
  ),
45
- allowDiscuss: Type.Optional(
34
+ multi: Type.Optional(
46
35
  Type.Boolean({
47
- description:
48
- "Allow the user to choose discussion instead of committing to a decision right now",
36
+ default: false,
37
+ description: "Allow selecting multiple options instead of one",
49
38
  }),
50
39
  ),
51
- multi: Type.Optional(
40
+ allowOther: Type.Optional(
52
41
  Type.Boolean({
53
- default: false,
54
42
  description:
55
- "Allow selecting multiple options (multi-select). Default false (single-select).",
43
+ "Allow a custom freeform answer instead of the listed options. Only valid for single-select choice questions.",
56
44
  }),
57
45
  ),
58
46
  recommendation: Type.Optional(
59
47
  Type.Union([Type.String(), Type.Array(Type.String())], {
60
48
  description:
61
- "Recommended option value(s). String for single-select, array for multi-select. Each must match an option value.",
49
+ "Recommended option value or values. Use a string for single-select and an array for multi-select.",
62
50
  }),
63
51
  ),
64
- default: Type.Optional(
52
+ initial: Type.Optional(
65
53
  Type.Union([Type.String(), Type.Array(Type.String())], {
66
54
  description:
67
- "Pre-selected option value(s). String for single-select, array for multi-select. Each must match an option value.",
55
+ "Initial selected option value or values. Use a string for single-select and an array for multi-select.",
68
56
  }),
69
57
  ),
70
58
  });
71
59
 
72
60
  const TextQuestionSchema = Type.Object({
73
61
  type: Type.Literal("text"),
74
- id: Type.String({ description: "Unique question id within this questionnaire" }),
75
- header: Type.String({ description: "Short label (chip) describing the prompt" }),
62
+ id: Type.String({ description: "Unique question id within this form" }),
63
+ header: Type.String({ description: "Short label describing the prompt" }),
76
64
  prompt: Type.String({ description: "Full question text shown to the user" }),
77
65
  required: Type.Optional(
78
66
  Type.Boolean({
79
67
  default: true,
80
- description: "Whether this question must be answered before submission (default true)",
68
+ description: "Whether this question must be answered for a full submit",
81
69
  }),
82
70
  ),
83
- default: Type.Optional(
84
- Type.String({ description: "Pre-filled default value shown in the text input" }),
71
+ initial: Type.Optional(Type.String({ description: "Initial value shown in the editor" })),
72
+ placeholder: Type.Optional(
73
+ Type.String({ description: "Placeholder shown before the user types" }),
85
74
  ),
86
75
  });
87
76
 
88
77
  const QuestionSchema = Type.Union([ChoiceQuestionSchema, TextQuestionSchema]);
89
78
 
90
79
  export const AskUserParamsSchema = Type.Object({
80
+ title: Type.Optional(
81
+ Type.String({ description: "Optional short title explaining the overall decision" }),
82
+ ),
83
+ intro: Type.Optional(
84
+ Type.String({
85
+ description: "Optional introductory context explaining why the agent is asking",
86
+ }),
87
+ ),
91
88
  questions: Type.Array(QuestionSchema, {
92
- description: "Between 1 and 4 focused decision questions",
89
+ description: "Between 1 and 4 focused questions that belong to the same decision",
93
90
  }),
94
- allowSkip: Type.Optional(
91
+ allowPartialSubmit: Type.Optional(
92
+ Type.Boolean({
93
+ description:
94
+ "Allow the user to submit a partial form when some required questions remain unanswered",
95
+ }),
96
+ ),
97
+ allowDiscuss: Type.Optional(
95
98
  Type.Boolean({
96
99
  description:
97
- "Expose a Skip action so the user can submit a partial result without answering all questions",
100
+ "Allow the user to switch back into discussion instead of committing to a final answer",
98
101
  }),
99
102
  ),
100
103
  });
@@ -0,0 +1,281 @@
1
+ import type {
2
+ Answer,
3
+ AnswerSelection,
4
+ AskUserOutcome,
5
+ AskUserStatus,
6
+ NormalizedChoiceQuestion,
7
+ NormalizedQuestion,
8
+ NormalizedQuestionnaire,
9
+ } from "../types.ts";
10
+
11
+ export class AskUserController {
12
+ private readonly answers = new Map<string, Answer>();
13
+ private index = 0;
14
+ private status: AskUserStatus | null = null;
15
+ private discussMessage: string | undefined;
16
+
17
+ constructor(public readonly questionnaire: NormalizedQuestionnaire) {
18
+ if (questionnaire.questions.length === 0) {
19
+ throw new Error("AskUserController requires at least one question.");
20
+ }
21
+ }
22
+
23
+ get questions(): NormalizedQuestion[] {
24
+ return this.questionnaire.questions;
25
+ }
26
+
27
+ get currentIndex(): number {
28
+ return this.index;
29
+ }
30
+
31
+ get currentQuestion(): NormalizedQuestion {
32
+ const question = this.questionnaire.questions[this.index];
33
+ if (!question) {
34
+ throw new Error(`No question at index ${this.index}.`);
35
+ }
36
+ return question;
37
+ }
38
+
39
+ get isTerminal(): boolean {
40
+ return this.status !== null;
41
+ }
42
+
43
+ get answerCount(): number {
44
+ return this.answers.size;
45
+ }
46
+
47
+ hasAnswer(questionId: string): boolean {
48
+ return this.answers.has(questionId);
49
+ }
50
+
51
+ getAnswer(questionId: string): Answer | undefined {
52
+ return this.answers.get(questionId);
53
+ }
54
+
55
+ getSelectedIndexes(question: NormalizedChoiceQuestion): number[] {
56
+ const answer = this.answers.get(question.id);
57
+ if (answer?.kind !== "choice") return [...question.initialIndexes];
58
+ return answer.selections
59
+ .map((selection) => question.options.findIndex((option) => option.value === selection.value))
60
+ .filter((index) => index >= 0);
61
+ }
62
+
63
+ getChoiceOptionNote(questionId: string, optionValue: string): string | undefined {
64
+ const answer = this.answers.get(questionId);
65
+ if (answer?.kind !== "choice") return undefined;
66
+ return answer.selections.find((selection) => selection.value === optionValue)?.note;
67
+ }
68
+
69
+ selectChoiceOption(question: NormalizedChoiceQuestion, optionIndex: number): void {
70
+ if (this.isTerminal) return;
71
+ const option = question.options[optionIndex];
72
+ if (!option) return;
73
+ const existingNote = this.getChoiceOptionNote(question.id, option.value);
74
+ this.commitChoiceSelections(question, [
75
+ buildSelection(option.value, option.label, existingNote),
76
+ ]);
77
+ }
78
+
79
+ toggleChoiceOption(question: NormalizedChoiceQuestion, optionIndex: number): void {
80
+ if (this.isTerminal) return;
81
+ const option = question.options[optionIndex];
82
+ if (!option) return;
83
+ if (!question.multi) {
84
+ this.selectChoiceOption(question, optionIndex);
85
+ return;
86
+ }
87
+
88
+ const selections = this.getStoredChoiceSelections(question.id);
89
+ const filtered = selections.filter((selection) => selection.value !== option.value);
90
+ if (filtered.length !== selections.length) {
91
+ this.commitChoiceSelections(question, filtered);
92
+ return;
93
+ }
94
+
95
+ this.commitChoiceSelections(question, [
96
+ ...selections,
97
+ buildSelection(option.value, option.label),
98
+ ]);
99
+ }
100
+
101
+ setChoiceOptionNote(
102
+ question: NormalizedChoiceQuestion,
103
+ optionIndex: number,
104
+ note: string | undefined,
105
+ ): void {
106
+ if (this.isTerminal) return;
107
+ const option = question.options[optionIndex];
108
+ if (!option) return;
109
+
110
+ const trimmedNote = trimOptional(note);
111
+ const selections = this.getStoredChoiceSelections(question.id);
112
+ const existing = selections.find((selection) => selection.value === option.value);
113
+
114
+ if (existing) {
115
+ this.commitChoiceSelections(
116
+ question,
117
+ selections.map((selection) => {
118
+ if (selection.value !== option.value) return selection;
119
+ return buildSelection(selection.value, selection.label, trimmedNote);
120
+ }),
121
+ );
122
+ return;
123
+ }
124
+
125
+ if (!trimmedNote) return;
126
+
127
+ const nextSelection = buildSelection(option.value, option.label, trimmedNote);
128
+ this.commitChoiceSelections(
129
+ question,
130
+ question.multi ? [...selections, nextSelection] : [nextSelection],
131
+ );
132
+ }
133
+
134
+ setAnswer(questionId: string, answer: Answer): void {
135
+ if (this.isTerminal) return;
136
+ this.answers.set(questionId, normalizeAnswer(answer));
137
+ }
138
+
139
+ clearAnswer(questionId: string): void {
140
+ if (this.isTerminal) return;
141
+ this.answers.delete(questionId);
142
+ }
143
+
144
+ goNext(): boolean {
145
+ if (this.isTerminal) return false;
146
+ if (this.index >= this.questionnaire.questions.length - 1) return false;
147
+ this.index += 1;
148
+ return true;
149
+ }
150
+
151
+ goBack(): boolean {
152
+ if (this.isTerminal) return false;
153
+ if (this.index === 0) return false;
154
+ this.index -= 1;
155
+ return true;
156
+ }
157
+
158
+ canSubmit(): boolean {
159
+ return this.missingQuestionIds().length === 0;
160
+ }
161
+
162
+ canPartialSubmit(): boolean {
163
+ return (
164
+ this.questionnaire.allowPartialSubmit &&
165
+ this.answerCount > 0 &&
166
+ this.missingQuestionIds().length > 0
167
+ );
168
+ }
169
+
170
+ finishSubmitted(): boolean {
171
+ if (!this.canSubmit() || this.isTerminal) return false;
172
+ this.status = "submitted";
173
+ return true;
174
+ }
175
+
176
+ finishPartial(): boolean {
177
+ if (!this.canPartialSubmit() || this.isTerminal) return false;
178
+ this.status = "partial";
179
+ return true;
180
+ }
181
+
182
+ finishDiscuss(message?: string): boolean {
183
+ if (!this.questionnaire.allowDiscuss || this.isTerminal) return false;
184
+ this.status = "discuss";
185
+ this.discussMessage = trimOptional(message);
186
+ return true;
187
+ }
188
+
189
+ cancel(): void {
190
+ if (this.isTerminal) return;
191
+ this.status = "cancelled";
192
+ }
193
+
194
+ abort(): void {
195
+ if (this.isTerminal) return;
196
+ this.status = "aborted";
197
+ }
198
+
199
+ outcome(): AskUserOutcome {
200
+ return {
201
+ status: this.status ?? "cancelled",
202
+ answersById: Object.fromEntries(this.answers),
203
+ missingQuestionIds: this.missingQuestionIds(),
204
+ ...(this.discussMessage ? { discussMessage: this.discussMessage } : {}),
205
+ };
206
+ }
207
+
208
+ missingQuestionIds(): string[] {
209
+ return this.questionnaire.questions
210
+ .filter((question) => question.required && !this.answers.has(question.id))
211
+ .map((question) => question.id);
212
+ }
213
+
214
+ private getStoredChoiceSelections(questionId: string): AnswerSelection[] {
215
+ const answer = this.answers.get(questionId);
216
+ if (answer?.kind !== "choice") return [];
217
+ return answer.selections.map((selection) => ({ ...selection }));
218
+ }
219
+
220
+ private commitChoiceSelections(
221
+ question: NormalizedChoiceQuestion,
222
+ selections: AnswerSelection[],
223
+ ): void {
224
+ const ordered = orderSelections(question, normalizeChoiceSelections(selections));
225
+ const nextSelections = question.multi ? ordered : ordered.slice(0, 1);
226
+ if (nextSelections.length === 0) {
227
+ this.answers.delete(question.id);
228
+ return;
229
+ }
230
+
231
+ this.answers.set(question.id, {
232
+ kind: "choice",
233
+ selections: nextSelections,
234
+ });
235
+ }
236
+ }
237
+
238
+ function normalizeAnswer(answer: Answer): Answer {
239
+ switch (answer.kind) {
240
+ case "choice":
241
+ return {
242
+ kind: "choice",
243
+ selections: normalizeChoiceSelections(answer.selections),
244
+ };
245
+ case "custom":
246
+ return { kind: "custom", value: answer.value.trim() };
247
+ case "text":
248
+ return { kind: "text", value: answer.value.trim() };
249
+ }
250
+ }
251
+
252
+ function normalizeChoiceSelections(selections: AnswerSelection[]): AnswerSelection[] {
253
+ return selections.map((selection) =>
254
+ buildSelection(selection.value, selection.label, selection.note),
255
+ );
256
+ }
257
+
258
+ function orderSelections(
259
+ question: NormalizedChoiceQuestion,
260
+ selections: AnswerSelection[],
261
+ ): AnswerSelection[] {
262
+ const byValue = new Map(selections.map((selection) => [selection.value, selection]));
263
+ return question.options.flatMap((option) => {
264
+ const selection = byValue.get(option.value);
265
+ return selection ? [selection] : [];
266
+ });
267
+ }
268
+
269
+ function buildSelection(value: string, label: string, note?: string): AnswerSelection {
270
+ const trimmedNote = trimOptional(note);
271
+ return {
272
+ value: value.trim(),
273
+ label: label.trim(),
274
+ ...(trimmedNote ? { note: trimmedNote } : {}),
275
+ };
276
+ }
277
+
278
+ function trimOptional(value: string | undefined): string | undefined {
279
+ const trimmed = value?.trim();
280
+ return trimmed ? trimmed : undefined;
281
+ }
@@ -0,0 +1,19 @@
1
+ // Session-scoped single-active interaction guard for ask_user.
2
+
3
+ export class ActiveQuestionnaireLock {
4
+ private active = false;
5
+
6
+ acquire(): boolean {
7
+ if (this.active) return false;
8
+ this.active = true;
9
+ return true;
10
+ }
11
+
12
+ release(): void {
13
+ this.active = false;
14
+ }
15
+
16
+ isActive(): boolean {
17
+ return this.active;
18
+ }
19
+ }
@@ -0,0 +1,15 @@
1
+ // Prompt guidance and tool description for the redesigned ask_user tool.
2
+
3
+ export const toolDescription =
4
+ "Ask the user for a focused blocking decision when explicit input is required to proceed safely. Requires an interactive UI with custom overlay support, and only one ask_user form can be active at a time. Use 1-4 related `choice` or `text` questions. Do not use ask_user for open-ended interviews or repo facts you can get yourself. Forms may allow partial submit or discussion handoff.";
5
+
6
+ export const promptSnippet = "ask_user — request a focused blocking user decision";
7
+
8
+ export const promptGuidelines = [
9
+ "Use ask_user only when explicit user input is required to proceed safely; do not use ask_user for open-ended interviews or repo facts.",
10
+ "Use ask_user with 1-4 related questions; prefer one when possible.",
11
+ 'Use ask_user `choice` for fixed options and ask_user `text` for freeform input; model yes/no as `choice` with `{ value: "yes", label: "Yes" }` and `{ value: "no", label: "No" }`.',
12
+ "Use ask_user `allowOther` only on single-select `choice` questions.",
13
+ "Use ask_user `allowDiscuss` or `allowPartialSubmit` only when that outcome is actionable.",
14
+ "Do not call ask_user while another ask_user form is already in flight.",
15
+ ];
package/src/types.ts CHANGED
@@ -1,10 +1,8 @@
1
- // Internal data model used by both UI paths and result formatting.
2
- // The external (model-facing) schema lives in `schema.ts`; everything beyond
3
- // parsing passes through normalization into the shapes defined here.
1
+ // Shared internal and public data model for the redesigned ask_user tool.
2
+ // The external tool-call schema lives in schema.ts; everything past validation
3
+ // works with the normalized shapes defined here.
4
4
 
5
- export type QuestionType = "choice" | "text";
6
-
7
- export type TerminalState = "submitted" | "cancelled" | "aborted" | "skipped";
5
+ export type AskUserStatus = "submitted" | "partial" | "discuss" | "cancelled" | "aborted";
8
6
 
9
7
  export interface NormalizedOption {
10
8
  value: string;
@@ -17,102 +15,105 @@ interface BaseQuestion {
17
15
  id: string;
18
16
  header: string;
19
17
  prompt: string;
20
- type: QuestionType;
21
18
  required: boolean;
22
19
  }
23
20
 
24
- interface StructuredQuestionBase extends BaseQuestion {
21
+ export interface NormalizedChoiceQuestion extends BaseQuestion {
22
+ type: "choice";
25
23
  options: NormalizedOption[];
24
+ multi: boolean;
26
25
  allowOther: boolean;
27
- allowDiscuss: boolean;
28
26
  recommendedIndexes: number[];
29
- defaultIndexes: number[];
30
- multi: boolean;
31
- }
32
-
33
- export interface NormalizedChoiceQuestion extends StructuredQuestionBase {
34
- type: "choice";
27
+ initialIndexes: number[];
35
28
  }
36
29
 
37
30
  export interface NormalizedTextQuestion extends BaseQuestion {
38
31
  type: "text";
39
- options: [];
40
- default?: string;
32
+ initial?: string;
33
+ placeholder?: string;
41
34
  }
42
35
 
43
- export type NormalizedStructuredQuestion = NormalizedChoiceQuestion;
44
-
45
36
  export type NormalizedQuestion = NormalizedChoiceQuestion | NormalizedTextQuestion;
46
37
 
47
38
  export interface NormalizedQuestionnaire {
39
+ title?: string;
40
+ intro?: string;
48
41
  questions: NormalizedQuestion[];
49
- allowSkip: boolean;
42
+ allowPartialSubmit: boolean;
43
+ allowDiscuss: boolean;
50
44
  }
51
45
 
52
- export interface Selection {
46
+ /**
47
+ * One selected choice option returned from `ask_user`.
48
+ *
49
+ * `note` is optional user-entered context attached to this specific option.
50
+ * It is only used on `choice` answers and is absent for `text` / `custom` answers.
51
+ */
52
+ export interface AnswerSelection {
53
53
  value: string;
54
- optionIndex: number;
54
+ label: string;
55
55
  note?: string;
56
56
  }
57
57
 
58
58
  export interface ChoiceAnswer {
59
- questionId: string;
60
- source: "choice";
61
- selections: Selection[];
59
+ kind: "choice";
60
+ selections: AnswerSelection[];
62
61
  }
63
62
 
64
- export interface OtherAnswer {
65
- questionId: string;
66
- source: "other";
63
+ export interface CustomAnswer {
64
+ kind: "custom";
67
65
  value: string;
68
66
  }
69
67
 
70
- export interface DiscussAnswer {
71
- questionId: string;
72
- source: "discuss";
73
- value?: string;
74
- }
75
-
76
68
  export interface TextAnswer {
77
- questionId: string;
78
- source: "text";
69
+ kind: "text";
79
70
  value: string;
80
71
  }
81
72
 
82
- export type Answer = ChoiceAnswer | OtherAnswer | DiscussAnswer | TextAnswer;
73
+ export type Answer = ChoiceAnswer | CustomAnswer | TextAnswer;
83
74
 
84
- export interface QuestionnaireOutcome {
85
- terminalState: TerminalState;
86
- answers: Answer[];
87
- skipped?: true;
75
+ export interface AskUserOutcome {
76
+ status: AskUserStatus;
77
+ answersById: Record<string, Answer>;
78
+ missingQuestionIds: string[];
79
+ discussMessage?: string;
88
80
  }
89
81
 
90
- export interface AskUserDetails {
82
+ export interface AskUserDetails extends AskUserOutcome {
83
+ title?: string;
84
+ intro?: string;
91
85
  questions: NormalizedQuestion[];
92
- answers: Answer[];
93
- answersById: Record<string, Answer | undefined>;
94
- terminalState: TerminalState;
95
86
  }
96
87
 
97
- export const QUESTION_LIMITS = {
88
+ export interface AskUserErrorDetails {
89
+ kind: "error";
90
+ message: string;
91
+ }
92
+
93
+ export type AskUserToolDetails = AskUserDetails | AskUserErrorDetails;
94
+
95
+ export const ASK_USER_LIMITS = {
98
96
  minQuestions: 1,
99
97
  maxQuestions: 4,
100
- maxHeaderLength: 60,
101
- maxPromptLength: 4000,
102
98
  minChoiceOptions: 2,
103
99
  maxChoiceOptions: 12,
100
+ maxHeaderLength: 60,
101
+ maxPromptLength: 4000,
102
+ maxTitleLength: 120,
103
+ maxIntroLength: 4000,
104
+ maxPlaceholderLength: 200,
104
105
  } as const;
105
106
 
106
- export function isStructuredQuestion(
107
+ export function isChoiceQuestion(
107
108
  question: NormalizedQuestion,
108
- ): question is NormalizedStructuredQuestion {
109
- return question.type !== "text";
109
+ ): question is NormalizedChoiceQuestion {
110
+ return question.type === "choice";
110
111
  }
111
112
 
112
- export function needsReview(questions: NormalizedQuestion[]): boolean {
113
- return questions.length > 1 || questions.some((q) => q.type !== "text" && q.multi);
113
+ export function isTextQuestion(question: NormalizedQuestion): question is NormalizedTextQuestion {
114
+ return question.type === "text";
114
115
  }
115
116
 
116
- export function primaryRecommendationIndex(question: NormalizedQuestion): number | undefined {
117
- return isStructuredQuestion(question) ? question.recommendedIndexes[0] : undefined;
117
+ export function isErrorDetails(details: AskUserToolDetails): details is AskUserErrorDetails {
118
+ return "kind" in details && details.kind === "error";
118
119
  }
@@ -0,0 +1,11 @@
1
+ import type { AskUserOutcome, NormalizedQuestionnaire } from "../types.ts";
2
+ import { runOverlayQuestionnaire } from "./overlay.ts";
3
+ import type { RunQuestionnaireOptions } from "./types.ts";
4
+
5
+ export async function runQuestionnaire(
6
+ questionnaire: NormalizedQuestionnaire,
7
+ opts: RunQuestionnaireOptions,
8
+ ): Promise<AskUserOutcome | "unsupported"> {
9
+ if (typeof opts.ui.custom !== "function") return "unsupported";
10
+ return runOverlayQuestionnaire(questionnaire, opts);
11
+ }
@@ -0,0 +1,42 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { SelectList } from "@earendil-works/pi-tui";
3
+ import type { AskUserController } from "../session/controller.ts";
4
+ import { makeSelectListTheme } from "./overlay-render.ts";
5
+ import { buildTextActionItems, type OverlayAction } from "./overlay-view.ts";
6
+
7
+ export interface ActionListState {
8
+ entries: Array<{ action: OverlayAction; label: string }>;
9
+ list: SelectList | undefined;
10
+ index: number;
11
+ }
12
+
13
+ export function createActionList(args: {
14
+ controller: AskUserController;
15
+ theme: Theme;
16
+ actionIndex: number;
17
+ onIndexChange: (index: number) => void;
18
+ onAction: (action: OverlayAction) => void;
19
+ }): ActionListState {
20
+ const actions = buildTextActionItems(args.controller);
21
+ const entries = actions.map(({ action, item }) => ({ action, label: item.label }));
22
+ if (actions.length === 0) {
23
+ return { entries, list: undefined, index: 0 };
24
+ }
25
+
26
+ const index = Math.max(0, Math.min(args.actionIndex, actions.length - 1));
27
+ const list = new SelectList(
28
+ actions.map(({ item }) => item),
29
+ Math.min(actions.length, 6),
30
+ makeSelectListTheme(args.theme),
31
+ );
32
+ list.onSelectionChange = (item) => {
33
+ const nextIndex = actions.findIndex(({ item: candidate }) => candidate.value === item.value);
34
+ if (nextIndex >= 0) args.onIndexChange(nextIndex);
35
+ };
36
+ list.onSelect = (item) => {
37
+ const action = actions.find(({ item: candidate }) => candidate.value === item.value)?.action;
38
+ if (action) args.onAction(action);
39
+ };
40
+ list.setSelectedIndex(index);
41
+ return { entries, list, index };
42
+ }