@mrclrchtr/supi-ask-user 0.1.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/ask-user.ts +131 -0
- package/flow.ts +212 -0
- package/format.ts +95 -0
- package/normalize.ts +218 -0
- package/package.json +35 -0
- package/render.ts +90 -0
- package/result.ts +69 -0
- package/schema.ts +103 -0
- package/types.ts +151 -0
- package/ui-fallback.ts +274 -0
- package/ui-rich-handlers.ts +313 -0
- package/ui-rich-inline.ts +64 -0
- package/ui-rich-render-editor.ts +49 -0
- package/ui-rich-render-notes.ts +87 -0
- package/ui-rich-render.ts +370 -0
- package/ui-rich-state.ts +142 -0
- package/ui-rich.ts +119 -0
package/render.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Custom renderCall / renderResult for the `ask_user` tool. Keeps the in-line
|
|
2
|
+
// session transcript readable: a one-line "asking N questions: …" header on
|
|
3
|
+
// the call, and a compact ✓ / cancelled / aborted summary on the result.
|
|
4
|
+
|
|
5
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import { Text, truncateToWidth } from "@mariozechner/pi-tui";
|
|
7
|
+
import { formatSummaryBody } from "./format.ts";
|
|
8
|
+
import { ASK_USER_ERROR_MARKER } from "./result.ts";
|
|
9
|
+
import type { AskUserDetails, NormalizedQuestion } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
const MAX_HEADER_LIST = 60;
|
|
12
|
+
|
|
13
|
+
export function renderAskUserCall(args: unknown, theme: Theme): Text {
|
|
14
|
+
const headers = extractHeadersFromArgs(args);
|
|
15
|
+
const count = headers.length;
|
|
16
|
+
let text = theme.fg("toolTitle", theme.bold("ask_user "));
|
|
17
|
+
text += theme.fg("muted", `${count || "?"} question${count === 1 ? "" : "s"}`);
|
|
18
|
+
if (count > 0) {
|
|
19
|
+
text += theme.fg("dim", ` (${truncateToWidth(headers.join(", "), MAX_HEADER_LIST)})`);
|
|
20
|
+
}
|
|
21
|
+
return new Text(text, 0, 0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function renderAskUserResult(
|
|
25
|
+
result: { details?: unknown; content: { type: string; text?: string }[] },
|
|
26
|
+
theme: Theme,
|
|
27
|
+
): Text {
|
|
28
|
+
if (isErrorDetails(result.details)) {
|
|
29
|
+
return new Text(theme.fg("error", result.content[0]?.text ?? "Error"), 0, 0);
|
|
30
|
+
}
|
|
31
|
+
const details = coerceDetails(result.details);
|
|
32
|
+
if (!details) {
|
|
33
|
+
const fallback = result.content[0];
|
|
34
|
+
return new Text(fallback?.type === "text" ? (fallback.text ?? "") : "", 0, 0);
|
|
35
|
+
}
|
|
36
|
+
if (details.terminalState === "cancelled") {
|
|
37
|
+
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
38
|
+
}
|
|
39
|
+
if (details.terminalState === "aborted") {
|
|
40
|
+
return new Text(theme.fg("error", "Aborted"), 0, 0);
|
|
41
|
+
}
|
|
42
|
+
return new Text(formatSubmittedSummary(details, theme), 0, 0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isErrorDetails(details: unknown): boolean {
|
|
46
|
+
return (
|
|
47
|
+
!!details &&
|
|
48
|
+
typeof details === "object" &&
|
|
49
|
+
(details as Record<string, unknown>)[ASK_USER_ERROR_MARKER] === true
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function extractHeadersFromArgs(args: unknown): string[] {
|
|
54
|
+
if (!args || typeof args !== "object") return [];
|
|
55
|
+
const questions = (args as { questions?: unknown }).questions;
|
|
56
|
+
if (!Array.isArray(questions)) return [];
|
|
57
|
+
return questions
|
|
58
|
+
.map((question) =>
|
|
59
|
+
question && typeof question === "object"
|
|
60
|
+
? (question as { header?: unknown }).header
|
|
61
|
+
: undefined,
|
|
62
|
+
)
|
|
63
|
+
.filter((header): header is string => typeof header === "string" && header.length > 0);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function coerceDetails(details: unknown): AskUserDetails | null {
|
|
67
|
+
if (!details || typeof details !== "object") return null;
|
|
68
|
+
const obj = details as { terminalState?: unknown; questions?: unknown; answers?: unknown };
|
|
69
|
+
if (typeof obj.terminalState !== "string") return null;
|
|
70
|
+
if (!Array.isArray(obj.questions) || !Array.isArray(obj.answers)) return null;
|
|
71
|
+
return obj as AskUserDetails;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function formatSubmittedSummary(details: AskUserDetails, theme: Theme): string {
|
|
75
|
+
const byId = new Map(details.answers.map((answer) => [answer.questionId, answer]));
|
|
76
|
+
return details.questions
|
|
77
|
+
.map((question) => formatLine(question, byId.get(question.id), theme))
|
|
78
|
+
.join("\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatLine(
|
|
82
|
+
question: NormalizedQuestion,
|
|
83
|
+
answer: AskUserDetails["answers"][number] | undefined,
|
|
84
|
+
theme: Theme,
|
|
85
|
+
): string {
|
|
86
|
+
if (!answer) {
|
|
87
|
+
return `${theme.fg("warning", "○ ")}${theme.fg("muted", question.header)}: (no answer)`;
|
|
88
|
+
}
|
|
89
|
+
return `${theme.fg("success", "✓ ")}${theme.fg("accent", question.header)}: ${theme.fg("text", formatSummaryBody(question, answer))}`;
|
|
90
|
+
}
|
package/result.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Hybrid result formatting: a concise natural-language summary the model can
|
|
2
|
+
// continue from, plus structured per-question details for transcript rendering
|
|
3
|
+
// and future state reconstruction.
|
|
4
|
+
|
|
5
|
+
import { formatSummaryBody } from "./format.ts";
|
|
6
|
+
import type {
|
|
7
|
+
Answer,
|
|
8
|
+
AskUserDetails,
|
|
9
|
+
NormalizedQuestion,
|
|
10
|
+
QuestionnaireOutcome,
|
|
11
|
+
TerminalState,
|
|
12
|
+
} from "./types.ts";
|
|
13
|
+
|
|
14
|
+
export interface HybridResult {
|
|
15
|
+
content: { type: "text"; text: string }[];
|
|
16
|
+
details: AskUserDetails;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function buildResult(
|
|
20
|
+
questions: NormalizedQuestion[],
|
|
21
|
+
outcome: QuestionnaireOutcome,
|
|
22
|
+
): HybridResult {
|
|
23
|
+
return {
|
|
24
|
+
content: [{ type: "text", text: summarize(questions, outcome) }],
|
|
25
|
+
details: {
|
|
26
|
+
questions,
|
|
27
|
+
answers: outcome.answers,
|
|
28
|
+
answersById: indexById(outcome.answers),
|
|
29
|
+
terminalState: outcome.terminalState,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function indexById(answers: Answer[]): Record<string, Answer> {
|
|
35
|
+
const out: Record<string, Answer> = {};
|
|
36
|
+
for (const answer of answers) out[answer.questionId] = answer;
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const ASK_USER_ERROR_MARKER = "__ask_user_error__";
|
|
41
|
+
|
|
42
|
+
export function buildErrorResult(message: string): HybridResult {
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: "text", text: message }],
|
|
45
|
+
details: {
|
|
46
|
+
questions: [],
|
|
47
|
+
answers: [],
|
|
48
|
+
answersById: {},
|
|
49
|
+
terminalState: "cancelled",
|
|
50
|
+
[ASK_USER_ERROR_MARKER]: true,
|
|
51
|
+
} as AskUserDetails & { [ASK_USER_ERROR_MARKER]: boolean },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function summarize(questions: NormalizedQuestion[], outcome: QuestionnaireOutcome): string {
|
|
56
|
+
if (outcome.terminalState !== "submitted") return summarizeTerminal(outcome.terminalState);
|
|
57
|
+
const byId = new Map(outcome.answers.map((answer) => [answer.questionId, answer]));
|
|
58
|
+
return questions.map((question) => formatAnswerLine(question, byId.get(question.id))).join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function summarizeTerminal(state: TerminalState): string {
|
|
62
|
+
if (state === "cancelled") return "User cancelled the questionnaire.";
|
|
63
|
+
return "Questionnaire was aborted before the user submitted answers.";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatAnswerLine(question: NormalizedQuestion, answer: Answer | undefined): string {
|
|
67
|
+
if (!answer) return `${question.header}: (no answer)`;
|
|
68
|
+
return `${question.header}: ${formatSummaryBody(question, answer)}`;
|
|
69
|
+
}
|
package/schema.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
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.
|
|
4
|
+
|
|
5
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
6
|
+
|
|
7
|
+
const StructuredOptionSchema = Type.Object({
|
|
8
|
+
value: Type.String({ description: "Stable identifier returned in the answer" }),
|
|
9
|
+
label: Type.String({ description: "Display label shown to the user" }),
|
|
10
|
+
description: Type.Optional(
|
|
11
|
+
Type.String({ description: "Optional one-line clarification shown under the label" }),
|
|
12
|
+
),
|
|
13
|
+
preview: Type.Optional(
|
|
14
|
+
Type.String({
|
|
15
|
+
description:
|
|
16
|
+
"Optional rich preview content shown in the TUI (markdown, code, or ASCII mockups)",
|
|
17
|
+
}),
|
|
18
|
+
),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const StructuredQuestionBaseSchema = {
|
|
22
|
+
id: Type.String({ description: "Unique question id within this questionnaire" }),
|
|
23
|
+
header: Type.String({ description: "Short label (chip) describing the decision" }),
|
|
24
|
+
prompt: Type.String({ description: "Full question text shown to the user" }),
|
|
25
|
+
options: Type.Array(StructuredOptionSchema, {
|
|
26
|
+
description: "Allowed answers (2-8). Use distinct, mutually exclusive options.",
|
|
27
|
+
}),
|
|
28
|
+
allowOther: Type.Optional(
|
|
29
|
+
Type.Boolean({
|
|
30
|
+
description: "Allow an explicit custom answer path instead of forcing one of the options",
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
allowDiscuss: Type.Optional(
|
|
34
|
+
Type.Boolean({
|
|
35
|
+
description:
|
|
36
|
+
"Allow the user to choose discussion instead of committing to a decision right now",
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
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`)" }),
|
|
46
|
+
),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const MultiChoiceQuestionSchema = Type.Object({
|
|
50
|
+
type: Type.Literal("multichoice"),
|
|
51
|
+
...StructuredQuestionBaseSchema,
|
|
52
|
+
recommendation: Type.Optional(
|
|
53
|
+
Type.Array(Type.String(), {
|
|
54
|
+
description:
|
|
55
|
+
"Recommended option `value`s for multi-select questions (each must match one of `options`)",
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const TextQuestionSchema = Type.Object({
|
|
61
|
+
type: Type.Literal("text"),
|
|
62
|
+
id: Type.String({ description: "Unique question id within this questionnaire" }),
|
|
63
|
+
header: Type.String({ description: "Short label (chip) describing the prompt" }),
|
|
64
|
+
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`)",
|
|
81
|
+
}),
|
|
82
|
+
),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const QuestionSchema = Type.Union([
|
|
86
|
+
ChoiceQuestionSchema,
|
|
87
|
+
MultiChoiceQuestionSchema,
|
|
88
|
+
TextQuestionSchema,
|
|
89
|
+
YesNoQuestionSchema,
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
export const AskUserParamsSchema = Type.Object({
|
|
93
|
+
questions: Type.Array(QuestionSchema, {
|
|
94
|
+
description: "Between 1 and 4 focused decision questions",
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
export type AskUserParams = Static<typeof AskUserParamsSchema>;
|
|
99
|
+
export type ExternalQuestion = Static<typeof QuestionSchema>;
|
|
100
|
+
export type ExternalChoiceQuestion = Static<typeof ChoiceQuestionSchema>;
|
|
101
|
+
export type ExternalMultiChoiceQuestion = Static<typeof MultiChoiceQuestionSchema>;
|
|
102
|
+
export type ExternalTextQuestion = Static<typeof TextQuestionSchema>;
|
|
103
|
+
export type ExternalYesNoQuestion = Static<typeof YesNoQuestionSchema>;
|
package/types.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
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.
|
|
4
|
+
|
|
5
|
+
export type QuestionType = "choice" | "multichoice" | "text" | "yesno";
|
|
6
|
+
|
|
7
|
+
export type TerminalState = "submitted" | "cancelled" | "aborted";
|
|
8
|
+
|
|
9
|
+
export interface NormalizedOption {
|
|
10
|
+
value: string;
|
|
11
|
+
label: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
preview?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface BaseQuestion {
|
|
17
|
+
id: string;
|
|
18
|
+
header: string;
|
|
19
|
+
prompt: string;
|
|
20
|
+
type: QuestionType;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface StructuredQuestionBase extends BaseQuestion {
|
|
24
|
+
options: NormalizedOption[];
|
|
25
|
+
allowOther: boolean;
|
|
26
|
+
allowDiscuss: boolean;
|
|
27
|
+
recommendedIndexes: number[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface NormalizedChoiceQuestion extends StructuredQuestionBase {
|
|
31
|
+
type: "choice";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface NormalizedMultiChoiceQuestion extends StructuredQuestionBase {
|
|
35
|
+
type: "multichoice";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface NormalizedYesNoQuestion extends StructuredQuestionBase {
|
|
39
|
+
type: "yesno";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface NormalizedTextQuestion extends BaseQuestion {
|
|
43
|
+
type: "text";
|
|
44
|
+
options: [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type NormalizedStructuredQuestion =
|
|
48
|
+
| NormalizedChoiceQuestion
|
|
49
|
+
| NormalizedMultiChoiceQuestion
|
|
50
|
+
| NormalizedYesNoQuestion;
|
|
51
|
+
|
|
52
|
+
export type NormalizedQuestion =
|
|
53
|
+
| NormalizedChoiceQuestion
|
|
54
|
+
| NormalizedMultiChoiceQuestion
|
|
55
|
+
| NormalizedTextQuestion
|
|
56
|
+
| NormalizedYesNoQuestion;
|
|
57
|
+
|
|
58
|
+
export interface NormalizedQuestionnaire {
|
|
59
|
+
questions: NormalizedQuestion[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface OptionAnswer {
|
|
63
|
+
questionId: string;
|
|
64
|
+
source: "option";
|
|
65
|
+
value: string;
|
|
66
|
+
optionIndex: number;
|
|
67
|
+
note?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface MultiSelection {
|
|
71
|
+
value: string;
|
|
72
|
+
optionIndex: number;
|
|
73
|
+
note?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface OptionsAnswer {
|
|
77
|
+
questionId: string;
|
|
78
|
+
source: "options";
|
|
79
|
+
values: string[];
|
|
80
|
+
optionIndexes: number[];
|
|
81
|
+
selections: MultiSelection[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface OtherAnswer {
|
|
85
|
+
questionId: string;
|
|
86
|
+
source: "other";
|
|
87
|
+
value: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface DiscussAnswer {
|
|
91
|
+
questionId: string;
|
|
92
|
+
source: "discuss";
|
|
93
|
+
value?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface TextAnswer {
|
|
97
|
+
questionId: string;
|
|
98
|
+
source: "text";
|
|
99
|
+
value: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface YesNoAnswer {
|
|
103
|
+
questionId: string;
|
|
104
|
+
source: "yesno";
|
|
105
|
+
value: "yes" | "no";
|
|
106
|
+
optionIndex: 0 | 1;
|
|
107
|
+
note?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type Answer =
|
|
111
|
+
| OptionAnswer
|
|
112
|
+
| OptionsAnswer
|
|
113
|
+
| OtherAnswer
|
|
114
|
+
| DiscussAnswer
|
|
115
|
+
| TextAnswer
|
|
116
|
+
| YesNoAnswer;
|
|
117
|
+
|
|
118
|
+
export interface QuestionnaireOutcome {
|
|
119
|
+
terminalState: TerminalState;
|
|
120
|
+
answers: Answer[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface AskUserDetails {
|
|
124
|
+
questions: NormalizedQuestion[];
|
|
125
|
+
answers: Answer[];
|
|
126
|
+
answersById: Record<string, Answer>;
|
|
127
|
+
terminalState: TerminalState;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const QUESTION_LIMITS = {
|
|
131
|
+
minQuestions: 1,
|
|
132
|
+
maxQuestions: 4,
|
|
133
|
+
maxHeaderLength: 40,
|
|
134
|
+
maxPromptLength: 2000,
|
|
135
|
+
minChoiceOptions: 2,
|
|
136
|
+
maxChoiceOptions: 8,
|
|
137
|
+
} as const;
|
|
138
|
+
|
|
139
|
+
export function isStructuredQuestion(
|
|
140
|
+
question: NormalizedQuestion,
|
|
141
|
+
): question is NormalizedStructuredQuestion {
|
|
142
|
+
return question.type !== "text";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function needsReview(questions: NormalizedQuestion[]): boolean {
|
|
146
|
+
return questions.length > 1 || questions.some((q) => q.type === "multichoice");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function primaryRecommendationIndex(question: NormalizedQuestion): number | undefined {
|
|
150
|
+
return isStructuredQuestion(question) ? question.recommendedIndexes[0] : undefined;
|
|
151
|
+
}
|
package/ui-fallback.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Dialog/input fallback for environments where ctx.ui.custom() is unavailable.
|
|
2
|
+
// This path intentionally preserves the redesigned answer semantics while
|
|
3
|
+
// flattening preview-heavy affordances into plain dialog lists.
|
|
4
|
+
|
|
5
|
+
import { QuestionnaireFlow } from "./flow.ts";
|
|
6
|
+
import { DISCUSS_LABEL, decorateOption, formatReviewLine, OTHER_LABEL } from "./format.ts";
|
|
7
|
+
import type {
|
|
8
|
+
Answer,
|
|
9
|
+
NormalizedQuestion,
|
|
10
|
+
NormalizedStructuredQuestion,
|
|
11
|
+
QuestionnaireOutcome,
|
|
12
|
+
} from "./types.ts";
|
|
13
|
+
|
|
14
|
+
export interface FallbackDialogOptions {
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FallbackUi {
|
|
19
|
+
select(
|
|
20
|
+
title: string,
|
|
21
|
+
options: string[],
|
|
22
|
+
opts?: FallbackDialogOptions,
|
|
23
|
+
): Promise<string | undefined>;
|
|
24
|
+
input(
|
|
25
|
+
title: string,
|
|
26
|
+
placeholder?: string,
|
|
27
|
+
opts?: FallbackDialogOptions,
|
|
28
|
+
): Promise<string | undefined>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface RunOptions {
|
|
32
|
+
ui: FallbackUi;
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type StepOutcome = "answered" | "cancelled" | "aborted";
|
|
37
|
+
type CollectOutcome = Exclude<StepOutcome, "answered">;
|
|
38
|
+
|
|
39
|
+
const REVIEW_SUBMIT = "Submit answers";
|
|
40
|
+
const REVIEW_CANCEL = "Cancel questionnaire";
|
|
41
|
+
const MULTI_SUBMIT = "Submit selections";
|
|
42
|
+
|
|
43
|
+
export async function runFallbackQuestionnaire(
|
|
44
|
+
questions: NormalizedQuestion[],
|
|
45
|
+
options: RunOptions,
|
|
46
|
+
): Promise<QuestionnaireOutcome> {
|
|
47
|
+
const flow = new QuestionnaireFlow(questions);
|
|
48
|
+
while (!flow.isTerminal()) {
|
|
49
|
+
if (options.signal?.aborted) {
|
|
50
|
+
flow.abort();
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
const question = flow.currentQuestion;
|
|
54
|
+
if (!question) break;
|
|
55
|
+
const step = await askAndStore(question, flow, options);
|
|
56
|
+
if (step === "aborted") flow.abort();
|
|
57
|
+
else if (step === "cancelled") flow.cancel();
|
|
58
|
+
else await applyAdvance(flow, options);
|
|
59
|
+
}
|
|
60
|
+
return flow.outcome();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function applyAdvance(flow: QuestionnaireFlow, opts: RunOptions): Promise<void> {
|
|
64
|
+
flow.advance();
|
|
65
|
+
if (flow.currentMode !== "reviewing" || !flow.allAnswered()) return;
|
|
66
|
+
const review = await runReviewStep(flow, opts);
|
|
67
|
+
if (review === "aborted") flow.abort();
|
|
68
|
+
else if (review === "cancelled") flow.cancel();
|
|
69
|
+
else flow.submit();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function runReviewStep(
|
|
73
|
+
flow: QuestionnaireFlow,
|
|
74
|
+
opts: RunOptions,
|
|
75
|
+
): Promise<"submit" | "cancelled" | "aborted"> {
|
|
76
|
+
const summary = flow.questions
|
|
77
|
+
.map(
|
|
78
|
+
(question) =>
|
|
79
|
+
`${question.header}: ${formatReviewLine(question, flow.getAnswer(question.id))}`,
|
|
80
|
+
)
|
|
81
|
+
.join(" | ");
|
|
82
|
+
const choice = await opts.ui.select(
|
|
83
|
+
`Review answers — ${summary}`,
|
|
84
|
+
[REVIEW_SUBMIT, REVIEW_CANCEL],
|
|
85
|
+
{
|
|
86
|
+
signal: opts.signal,
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
if (opts.signal?.aborted) return "aborted";
|
|
90
|
+
if (choice === undefined || choice === REVIEW_CANCEL) return "cancelled";
|
|
91
|
+
return "submit";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function askAndStore(
|
|
95
|
+
question: NormalizedQuestion,
|
|
96
|
+
flow: QuestionnaireFlow,
|
|
97
|
+
opts: RunOptions,
|
|
98
|
+
): Promise<StepOutcome> {
|
|
99
|
+
const answer = await collectAnswer(question, opts);
|
|
100
|
+
if (answer === "aborted" || answer === "cancelled") return answer;
|
|
101
|
+
flow.setAnswer(answer);
|
|
102
|
+
return "answered";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function collectAnswer(
|
|
106
|
+
question: NormalizedQuestion,
|
|
107
|
+
opts: RunOptions,
|
|
108
|
+
): Promise<Answer | CollectOutcome> {
|
|
109
|
+
if (question.type === "text") return collectText(question, opts);
|
|
110
|
+
if (question.type === "multichoice") return collectMultichoice(question, opts);
|
|
111
|
+
return collectStructured(question, opts, question.type === "yesno" ? "yesno" : "option");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function collectText(
|
|
115
|
+
question: Extract<NormalizedQuestion, { type: "text" }>,
|
|
116
|
+
opts: RunOptions,
|
|
117
|
+
): Promise<Answer | CollectOutcome> {
|
|
118
|
+
while (true) {
|
|
119
|
+
const value = await opts.ui.input(question.prompt, undefined, { signal: opts.signal });
|
|
120
|
+
if (opts.signal?.aborted) return "aborted";
|
|
121
|
+
if (value === undefined) return "cancelled";
|
|
122
|
+
const trimmed = value.trim();
|
|
123
|
+
if (trimmed.length > 0) return { questionId: question.id, source: "text", value: trimmed };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function collectStructured(
|
|
128
|
+
question: NormalizedStructuredQuestion,
|
|
129
|
+
opts: RunOptions,
|
|
130
|
+
source: "option" | "yesno",
|
|
131
|
+
): Promise<Answer | CollectOutcome> {
|
|
132
|
+
const labels = structuredChoiceLabels(question);
|
|
133
|
+
const choice = await opts.ui.select(question.prompt, labels, { signal: opts.signal });
|
|
134
|
+
if (opts.signal?.aborted) return "aborted";
|
|
135
|
+
if (choice === undefined) return "cancelled";
|
|
136
|
+
const index = labels.indexOf(choice);
|
|
137
|
+
if (index < 0) return "cancelled";
|
|
138
|
+
if (index < question.options.length) {
|
|
139
|
+
const option = question.options[index];
|
|
140
|
+
return source === "yesno"
|
|
141
|
+
? {
|
|
142
|
+
questionId: question.id,
|
|
143
|
+
source: "yesno",
|
|
144
|
+
value: option.value as "yes" | "no",
|
|
145
|
+
optionIndex: index as 0 | 1,
|
|
146
|
+
}
|
|
147
|
+
: { questionId: question.id, source: "option", value: option.value, optionIndex: index };
|
|
148
|
+
}
|
|
149
|
+
return collectStructuredAction(question, index - question.options.length, opts);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function structuredChoiceLabels(question: NormalizedStructuredQuestion): string[] {
|
|
153
|
+
const labels = question.options.map((option, index) =>
|
|
154
|
+
numberedLabel(
|
|
155
|
+
index,
|
|
156
|
+
appendDescription(
|
|
157
|
+
decorateOption(option.label, question.recommendedIndexes.includes(index)),
|
|
158
|
+
option.description,
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
let offset = question.options.length;
|
|
163
|
+
if (question.allowOther) {
|
|
164
|
+
labels.push(numberedLabel(offset, OTHER_LABEL));
|
|
165
|
+
offset += 1;
|
|
166
|
+
}
|
|
167
|
+
if (question.allowDiscuss) labels.push(numberedLabel(offset, DISCUSS_LABEL));
|
|
168
|
+
return labels;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function collectStructuredAction(
|
|
172
|
+
question: NormalizedStructuredQuestion,
|
|
173
|
+
actionIndex: number,
|
|
174
|
+
opts: RunOptions,
|
|
175
|
+
): Promise<Answer | CollectOutcome> {
|
|
176
|
+
if (question.allowOther && actionIndex === 0) return collectOther(question, opts);
|
|
177
|
+
return collectDiscuss(question, opts);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function collectOther(
|
|
181
|
+
question: NormalizedStructuredQuestion,
|
|
182
|
+
opts: RunOptions,
|
|
183
|
+
): Promise<Answer | CollectOutcome> {
|
|
184
|
+
while (true) {
|
|
185
|
+
const value = await opts.ui.input(`${question.prompt} (${OTHER_LABEL})`, undefined, {
|
|
186
|
+
signal: opts.signal,
|
|
187
|
+
});
|
|
188
|
+
if (opts.signal?.aborted) return "aborted";
|
|
189
|
+
if (value === undefined) return "cancelled";
|
|
190
|
+
const trimmed = value.trim();
|
|
191
|
+
if (trimmed.length > 0) return { questionId: question.id, source: "other", value: trimmed };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function collectDiscuss(
|
|
196
|
+
question: NormalizedStructuredQuestion,
|
|
197
|
+
opts: RunOptions,
|
|
198
|
+
): Promise<Answer | CollectOutcome> {
|
|
199
|
+
const value = await opts.ui.input(`${question.prompt} (${DISCUSS_LABEL})`, "Optional context", {
|
|
200
|
+
signal: opts.signal,
|
|
201
|
+
});
|
|
202
|
+
if (opts.signal?.aborted) return "aborted";
|
|
203
|
+
if (value === undefined) return "cancelled";
|
|
204
|
+
const trimmed = value.trim();
|
|
205
|
+
return trimmed.length > 0
|
|
206
|
+
? { questionId: question.id, source: "discuss", value: trimmed }
|
|
207
|
+
: { questionId: question.id, source: "discuss" };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: fallback multiselect loop is intentionally linear
|
|
211
|
+
async function collectMultichoice(
|
|
212
|
+
question: Extract<NormalizedQuestion, { type: "multichoice" }>,
|
|
213
|
+
opts: RunOptions,
|
|
214
|
+
): Promise<Answer | CollectOutcome> {
|
|
215
|
+
const selected = new Set<number>();
|
|
216
|
+
while (true) {
|
|
217
|
+
const labels = multichoiceLabels(question, selected);
|
|
218
|
+
const choice = await opts.ui.select(question.prompt, labels, { signal: opts.signal });
|
|
219
|
+
if (opts.signal?.aborted) return "aborted";
|
|
220
|
+
if (choice === undefined) return "cancelled";
|
|
221
|
+
const index = labels.indexOf(choice);
|
|
222
|
+
if (index < 0) return "cancelled";
|
|
223
|
+
if (index < question.options.length) {
|
|
224
|
+
if (selected.has(index)) selected.delete(index);
|
|
225
|
+
else selected.add(index);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const submitIndex = question.options.length;
|
|
229
|
+
if (index === submitIndex) {
|
|
230
|
+
if (selected.size === 0) continue;
|
|
231
|
+
const optionIndexes = [...selected].sort((a, b) => a - b);
|
|
232
|
+
const selections = optionIndexes.map((optionIndex) => ({
|
|
233
|
+
optionIndex,
|
|
234
|
+
value: question.options[optionIndex].value,
|
|
235
|
+
}));
|
|
236
|
+
return {
|
|
237
|
+
questionId: question.id,
|
|
238
|
+
source: "options",
|
|
239
|
+
values: selections.map((selection) => selection.value),
|
|
240
|
+
optionIndexes,
|
|
241
|
+
selections,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
return collectDiscuss(question, opts);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function multichoiceLabels(
|
|
249
|
+
question: Extract<NormalizedQuestion, { type: "multichoice" }>,
|
|
250
|
+
selected: Set<number>,
|
|
251
|
+
): string[] {
|
|
252
|
+
const labels = question.options.map((option, index) => {
|
|
253
|
+
const checked = selected.has(index) ? "[x]" : "[ ]";
|
|
254
|
+
const recommended = question.recommendedIndexes.includes(index);
|
|
255
|
+
return numberedLabel(
|
|
256
|
+
index,
|
|
257
|
+
appendDescription(
|
|
258
|
+
`${checked} ${decorateOption(option.label, recommended)}`,
|
|
259
|
+
option.description,
|
|
260
|
+
),
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
labels.push(numberedLabel(question.options.length, MULTI_SUBMIT));
|
|
264
|
+
if (question.allowDiscuss) labels.push(numberedLabel(question.options.length + 1, DISCUSS_LABEL));
|
|
265
|
+
return labels;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function numberedLabel(index: number, label: string): string {
|
|
269
|
+
return `${index + 1}. ${label}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function appendDescription(label: string, description: string | undefined): string {
|
|
273
|
+
return description ? `${label} — ${description}` : label;
|
|
274
|
+
}
|