@mrclrchtr/supi-ask-user 0.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -0
- package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +26 -0
- package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
- package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
- package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
- package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
- package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
- package/package.json +13 -9
- package/src/ask-user.ts +191 -0
- package/{flow.ts → src/flow.ts} +45 -33
- package/src/format.ts +66 -0
- package/src/index.ts +1 -0
- package/src/normalize.ts +229 -0
- package/{ui-rich-render-editor.ts → src/render/ui-rich-render-editor.ts} +18 -16
- package/src/render/ui-rich-render-env.ts +15 -0
- package/src/render/ui-rich-render-footer.ts +55 -0
- package/src/render/ui-rich-render-markdown.ts +33 -0
- package/{ui-rich-render-notes.ts → src/render/ui-rich-render-notes.ts} +20 -27
- package/src/render/ui-rich-render-types.ts +17 -0
- package/src/render/ui-rich-render.ts +323 -0
- package/{render.ts → src/render.ts} +10 -5
- package/{result.ts → src/result.ts} +27 -6
- package/{schema.ts → src/schema.ts} +46 -44
- package/{types.ts → src/types.ts} +20 -53
- package/{ui-rich-handlers.ts → src/ui/ui-rich-handlers.ts} +100 -44
- package/{ui-rich-inline.ts → src/ui/ui-rich-inline.ts} +23 -10
- package/{ui-rich-state.ts → src/ui/ui-rich-state.ts} +52 -15
- package/{ui-rich.ts → src/ui/ui-rich.ts} +36 -11
- package/ask-user.ts +0 -131
- package/format.ts +0 -95
- package/normalize.ts +0 -218
- package/ui-fallback.ts +0 -274
- package/ui-rich-render.ts +0 -370
package/ui-fallback.ts
DELETED
|
@@ -1,274 +0,0 @@
|
|
|
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
|
-
}
|
package/ui-rich-render.ts
DELETED
|
@@ -1,370 +0,0 @@
|
|
|
1
|
-
// Pure rendering helpers for the rich overlay. Kept separate from
|
|
2
|
-
// ui-rich.ts to stay within Biome's per-file line limit and so the input
|
|
3
|
-
// dispatch logic can be read without scrolling past a wall of theme strings.
|
|
4
|
-
|
|
5
|
-
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
6
|
-
import { type Editor, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
7
|
-
import type { QuestionnaireFlow } from "./flow.ts";
|
|
8
|
-
import { decorateOption, formatReviewLines, NOTE_MARKER } from "./format.ts";
|
|
9
|
-
import type { NormalizedStructuredQuestion } from "./types.ts";
|
|
10
|
-
import { inlineStructuredRowLines, structuredRowLabel } from "./ui-rich-inline.ts";
|
|
11
|
-
import {
|
|
12
|
-
editorCaption,
|
|
13
|
-
padRight,
|
|
14
|
-
renderEditorBlock,
|
|
15
|
-
renderEditorPane,
|
|
16
|
-
usesSeparateEditorPane,
|
|
17
|
-
} from "./ui-rich-render-editor.ts";
|
|
18
|
-
import {
|
|
19
|
-
currentNote,
|
|
20
|
-
currentRowSupportsNotes,
|
|
21
|
-
renderNoteStatus,
|
|
22
|
-
visibleNoteMarker,
|
|
23
|
-
} from "./ui-rich-render-notes.ts";
|
|
24
|
-
import { hasPreview, type InteractiveRow, interactiveRows } from "./ui-rich-state.ts";
|
|
25
|
-
|
|
26
|
-
export type SubMode = "select" | "other-input" | "text-input" | "discuss-input" | "note-input";
|
|
27
|
-
|
|
28
|
-
export interface OverlayRenderState {
|
|
29
|
-
selectedIndex: number;
|
|
30
|
-
subMode: SubMode;
|
|
31
|
-
stagedSelections: Map<string, number[]>;
|
|
32
|
-
stagedSingleNotes: Map<string, string>;
|
|
33
|
-
stagedMultiNotes: Map<string, Map<number, string>>;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function isEditorMode(mode: SubMode): boolean {
|
|
37
|
-
return (
|
|
38
|
-
mode === "text-input" ||
|
|
39
|
-
mode === "other-input" ||
|
|
40
|
-
mode === "discuss-input" ||
|
|
41
|
-
mode === "note-input"
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function selectedIndexesForQuestion(
|
|
46
|
-
flow: Pick<QuestionnaireFlow, "getAnswer">,
|
|
47
|
-
state: Pick<OverlayRenderState, "stagedSelections">,
|
|
48
|
-
question: NormalizedStructuredQuestion,
|
|
49
|
-
): number[] {
|
|
50
|
-
const staged = state.stagedSelections.get(question.id);
|
|
51
|
-
if (staged) return [...staged];
|
|
52
|
-
const answer = flow.getAnswer(question.id);
|
|
53
|
-
if (!answer) return [];
|
|
54
|
-
if (answer.source === "option" || answer.source === "yesno") return [answer.optionIndex];
|
|
55
|
-
if (answer.source === "options") return [...answer.optionIndexes];
|
|
56
|
-
return [];
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// biome-ignore lint/complexity/useMaxParams: render entry needs full overlay context
|
|
60
|
-
export function renderOverlay(
|
|
61
|
-
width: number,
|
|
62
|
-
theme: Theme,
|
|
63
|
-
flow: QuestionnaireFlow,
|
|
64
|
-
state: OverlayRenderState,
|
|
65
|
-
editor: Editor,
|
|
66
|
-
): string[] {
|
|
67
|
-
const lines: string[] = [];
|
|
68
|
-
const add = (text: string) => lines.push(truncateToWidth(text, width));
|
|
69
|
-
add(theme.fg("accent", "─".repeat(width)));
|
|
70
|
-
if (flow.isMultiQuestion) renderTabBar(add, theme, flow);
|
|
71
|
-
if (flow.currentMode === "reviewing") {
|
|
72
|
-
renderReview(add, theme, flow);
|
|
73
|
-
} else {
|
|
74
|
-
renderQuestion(add, lines, width, theme, flow, state, editor);
|
|
75
|
-
}
|
|
76
|
-
add(theme.fg("dim", ` ${footerHelp(flow, state)}`));
|
|
77
|
-
add(theme.fg("accent", "─".repeat(width)));
|
|
78
|
-
return lines;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function renderTabBar(add: (text: string) => void, theme: Theme, flow: QuestionnaireFlow): void {
|
|
82
|
-
// Active segment uses the selected-bg highlight (matches pi's reference
|
|
83
|
-
// questionnaire and Claude's UI). Inactive segments stay foreground-only:
|
|
84
|
-
// success when answered, muted when pending.
|
|
85
|
-
const segments: string[] = [theme.fg("dim", "← ")];
|
|
86
|
-
for (const [index, question] of flow.questions.entries()) {
|
|
87
|
-
const answered = flow.hasAnswer(question.id);
|
|
88
|
-
const active = flow.currentMode === "answering" && flow.currentIndex === index;
|
|
89
|
-
const marker = answered ? "■" : "□";
|
|
90
|
-
const text = ` ${marker} ${question.header} `;
|
|
91
|
-
segments.push(
|
|
92
|
-
active
|
|
93
|
-
? theme.bg("selectedBg", theme.fg("text", text))
|
|
94
|
-
: theme.fg(answered ? "success" : "muted", text),
|
|
95
|
-
);
|
|
96
|
-
segments.push(" ");
|
|
97
|
-
}
|
|
98
|
-
const reviewText = " ✓ Review ";
|
|
99
|
-
segments.push(
|
|
100
|
-
flow.currentMode === "reviewing"
|
|
101
|
-
? theme.bg("selectedBg", theme.fg("text", reviewText))
|
|
102
|
-
: theme.fg("dim", reviewText),
|
|
103
|
-
);
|
|
104
|
-
segments.push(theme.fg("dim", " →"));
|
|
105
|
-
add(` ${segments.join("")}`);
|
|
106
|
-
add("");
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// biome-ignore lint/complexity/useMaxParams: question render needs full context
|
|
110
|
-
function renderQuestion(
|
|
111
|
-
add: (text: string) => void,
|
|
112
|
-
lines: string[],
|
|
113
|
-
width: number,
|
|
114
|
-
theme: Theme,
|
|
115
|
-
flow: QuestionnaireFlow,
|
|
116
|
-
state: OverlayRenderState,
|
|
117
|
-
editor: Editor,
|
|
118
|
-
): void {
|
|
119
|
-
const question = flow.currentQuestion;
|
|
120
|
-
if (!question) return;
|
|
121
|
-
for (const line of wrapTextWithAnsi(` ${question.prompt}`, width)) {
|
|
122
|
-
add(theme.fg("text", line));
|
|
123
|
-
}
|
|
124
|
-
lines.push("");
|
|
125
|
-
if (question.type === "text") {
|
|
126
|
-
renderTextQuestion(add, lines, theme, editor, width);
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
renderStructuredQuestion(add, lines, width, theme, flow, state, editor, question);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// biome-ignore lint/complexity/useMaxParams: split view layout needs full context
|
|
133
|
-
function renderSplitView(
|
|
134
|
-
lines: string[],
|
|
135
|
-
width: number,
|
|
136
|
-
theme: Theme,
|
|
137
|
-
flow: QuestionnaireFlow,
|
|
138
|
-
state: OverlayRenderState,
|
|
139
|
-
editor: Editor,
|
|
140
|
-
question: NormalizedStructuredQuestion,
|
|
141
|
-
rows: InteractiveRow[],
|
|
142
|
-
): void {
|
|
143
|
-
const leftWidth = Math.max(34, Math.floor(width * 0.42));
|
|
144
|
-
const rightWidth = Math.max(24, width - leftWidth - 3);
|
|
145
|
-
const leftLines = renderPaneRows(leftWidth, theme, flow, state, editor, question, rows);
|
|
146
|
-
const rightLines = usesSeparateEditorPane(state)
|
|
147
|
-
? renderEditorPane(rightWidth, theme, editor, editorCaption(state))
|
|
148
|
-
: renderPreviewPane(
|
|
149
|
-
rightWidth,
|
|
150
|
-
theme,
|
|
151
|
-
previewForSelection(question, rows[state.selectedIndex]),
|
|
152
|
-
);
|
|
153
|
-
const total = Math.max(leftLines.length, rightLines.length);
|
|
154
|
-
for (let index = 0; index < total; index += 1) {
|
|
155
|
-
const left = padRight(leftLines[index] ?? "", leftWidth);
|
|
156
|
-
const right = padRight(rightLines[index] ?? "", rightWidth);
|
|
157
|
-
lines.push(`${left} ${theme.fg("accent", "│")} ${right}`);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// biome-ignore lint/complexity/useMaxParams: thin adapter for text question rendering
|
|
162
|
-
function renderTextQuestion(
|
|
163
|
-
add: (text: string) => void,
|
|
164
|
-
lines: string[],
|
|
165
|
-
theme: Theme,
|
|
166
|
-
editor: Editor,
|
|
167
|
-
width: number,
|
|
168
|
-
): void {
|
|
169
|
-
renderEditorBlock(add, lines, theme, editor, width, "Answer");
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// biome-ignore lint/complexity/useMaxParams: structured render needs full context
|
|
173
|
-
function renderStructuredQuestion(
|
|
174
|
-
add: (text: string) => void,
|
|
175
|
-
lines: string[],
|
|
176
|
-
width: number,
|
|
177
|
-
theme: Theme,
|
|
178
|
-
flow: QuestionnaireFlow,
|
|
179
|
-
state: OverlayRenderState,
|
|
180
|
-
editor: Editor,
|
|
181
|
-
question: NormalizedStructuredQuestion,
|
|
182
|
-
): void {
|
|
183
|
-
const rows = interactiveRows(question);
|
|
184
|
-
if (hasPreview(question) && width >= 100) {
|
|
185
|
-
renderSplitView(lines, width, theme, flow, state, editor, question, rows);
|
|
186
|
-
} else {
|
|
187
|
-
renderStandardStructuredQuestion(add, lines, width, theme, flow, state, editor, question, rows);
|
|
188
|
-
}
|
|
189
|
-
const note = currentNote(flow, state, question);
|
|
190
|
-
if (note) renderNoteStatus(add, theme, note);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// biome-ignore lint/complexity/useMaxParams: standard layout needs full context
|
|
194
|
-
function renderStandardStructuredQuestion(
|
|
195
|
-
add: (text: string) => void,
|
|
196
|
-
lines: string[],
|
|
197
|
-
width: number,
|
|
198
|
-
theme: Theme,
|
|
199
|
-
flow: QuestionnaireFlow,
|
|
200
|
-
state: OverlayRenderState,
|
|
201
|
-
editor: Editor,
|
|
202
|
-
question: NormalizedStructuredQuestion,
|
|
203
|
-
rows: InteractiveRow[],
|
|
204
|
-
): void {
|
|
205
|
-
renderRows(add, width, theme, flow, state, editor, question, rows);
|
|
206
|
-
if (usesSeparateEditorPane(state)) {
|
|
207
|
-
renderEditorBlock(add, lines, theme, editor, width, editorCaption(state));
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
const preview = previewForSelection(question, rows[state.selectedIndex]);
|
|
211
|
-
if (preview) renderPreviewBlock(add, lines, theme, width, preview);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// biome-ignore lint/complexity/useMaxParams: helper mirrors render context
|
|
215
|
-
function renderPaneRows(
|
|
216
|
-
width: number,
|
|
217
|
-
theme: Theme,
|
|
218
|
-
flow: QuestionnaireFlow,
|
|
219
|
-
state: OverlayRenderState,
|
|
220
|
-
editor: Editor,
|
|
221
|
-
question: NormalizedStructuredQuestion,
|
|
222
|
-
rows: InteractiveRow[],
|
|
223
|
-
): string[] {
|
|
224
|
-
const out: string[] = [];
|
|
225
|
-
const push = (text = "") => out.push(truncateToWidth(text, width));
|
|
226
|
-
renderRows(push, width, theme, flow, state, editor, question, rows);
|
|
227
|
-
return out;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// biome-ignore lint/complexity/useMaxParams: helper mirrors render context
|
|
231
|
-
function renderRows(
|
|
232
|
-
add: (text: string) => void,
|
|
233
|
-
width: number,
|
|
234
|
-
theme: Theme,
|
|
235
|
-
flow: QuestionnaireFlow,
|
|
236
|
-
state: OverlayRenderState,
|
|
237
|
-
editor: Editor,
|
|
238
|
-
question: NormalizedStructuredQuestion,
|
|
239
|
-
rows: InteractiveRow[],
|
|
240
|
-
): void {
|
|
241
|
-
const selected = selectedIndexesForQuestion(flow, state, question);
|
|
242
|
-
for (const [index, row] of rows.entries()) {
|
|
243
|
-
const active = state.selectedIndex === index;
|
|
244
|
-
const prefix = active ? theme.fg("accent", "> ") : " ";
|
|
245
|
-
const inlineEditorLines = inlineStructuredRowLines({
|
|
246
|
-
width,
|
|
247
|
-
theme,
|
|
248
|
-
state,
|
|
249
|
-
editor,
|
|
250
|
-
row,
|
|
251
|
-
prefix,
|
|
252
|
-
});
|
|
253
|
-
if (inlineEditorLines) {
|
|
254
|
-
for (const [lineIndex, line] of inlineEditorLines.entries()) {
|
|
255
|
-
add(`${lineIndex === 0 ? prefix : " "}${line}`);
|
|
256
|
-
}
|
|
257
|
-
continue;
|
|
258
|
-
}
|
|
259
|
-
add(prefix + rowLabel(theme, flow, state, question, row, active, selected));
|
|
260
|
-
const description = rowDescription(question, row);
|
|
261
|
-
if (description) add(` ${theme.fg("muted", description)}`);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// biome-ignore lint/complexity/useMaxParams: helper mirrors render context
|
|
266
|
-
function rowLabel(
|
|
267
|
-
theme: Theme,
|
|
268
|
-
flow: QuestionnaireFlow,
|
|
269
|
-
state: OverlayRenderState,
|
|
270
|
-
question: NormalizedStructuredQuestion,
|
|
271
|
-
row: InteractiveRow,
|
|
272
|
-
active: boolean,
|
|
273
|
-
selected: number[],
|
|
274
|
-
): string {
|
|
275
|
-
if (row.kind === "option") {
|
|
276
|
-
const option = question.options[row.optionIndex];
|
|
277
|
-
const recommended = question.recommendedIndexes.includes(row.optionIndex);
|
|
278
|
-
const noteMarker = visibleNoteMarker({ flow, state, question, row, active });
|
|
279
|
-
const baseLabel = `${decorateOption(option.label, recommended)}${noteMarker ? ` ${NOTE_MARKER}` : ""}`;
|
|
280
|
-
if (question.type === "multichoice") {
|
|
281
|
-
const checked = selected.includes(row.optionIndex) ? "[x]" : "[ ]";
|
|
282
|
-
return theme.fg("text", `${checked} ${baseLabel}`);
|
|
283
|
-
}
|
|
284
|
-
return theme.fg("text", `${row.optionIndex + 1}. ${baseLabel}`);
|
|
285
|
-
}
|
|
286
|
-
if (row.kind === "other") return theme.fg("text", structuredRowLabel(flow, question, row));
|
|
287
|
-
return theme.fg("text", structuredRowLabel(flow, question, row));
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function rowDescription(
|
|
291
|
-
question: NormalizedStructuredQuestion,
|
|
292
|
-
row: InteractiveRow,
|
|
293
|
-
): string | undefined {
|
|
294
|
-
if (row.kind === "option") return question.options[row.optionIndex].description;
|
|
295
|
-
return undefined;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
function renderPreviewPane(width: number, theme: Theme, preview: string | undefined): string[] {
|
|
299
|
-
const out: string[] = [];
|
|
300
|
-
const push = (text = "") => out.push(truncateToWidth(text, width));
|
|
301
|
-
push(theme.fg("accent", " Preview"));
|
|
302
|
-
push("");
|
|
303
|
-
if (!preview) {
|
|
304
|
-
push(theme.fg("muted", " No preview for the current selection."));
|
|
305
|
-
return out;
|
|
306
|
-
}
|
|
307
|
-
for (const line of preview.split("\n")) push(theme.fg("text", ` ${line}`));
|
|
308
|
-
return out;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// biome-ignore lint/complexity/useMaxParams: helper mirrors render context
|
|
312
|
-
function renderPreviewBlock(
|
|
313
|
-
add: (text: string) => void,
|
|
314
|
-
lines: string[],
|
|
315
|
-
theme: Theme,
|
|
316
|
-
width: number,
|
|
317
|
-
preview: string,
|
|
318
|
-
): void {
|
|
319
|
-
add("");
|
|
320
|
-
add(theme.fg("accent", " Preview:"));
|
|
321
|
-
for (const line of preview.split("\n")) {
|
|
322
|
-
lines.push(truncateToWidth(` ${line}`, width));
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function previewForSelection(
|
|
327
|
-
question: NormalizedStructuredQuestion,
|
|
328
|
-
row: InteractiveRow | undefined,
|
|
329
|
-
): string | undefined {
|
|
330
|
-
return row?.kind === "option" ? question.options[row.optionIndex].preview : undefined;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function renderReview(add: (text: string) => void, theme: Theme, flow: QuestionnaireFlow): void {
|
|
334
|
-
add(theme.fg("accent", " Review answers:"));
|
|
335
|
-
add("");
|
|
336
|
-
for (const question of flow.questions) {
|
|
337
|
-
const answer = flow.getAnswer(question.id);
|
|
338
|
-
const lines = answer ? formatReviewLines(question, answer) : ["(no answer)"];
|
|
339
|
-
add(theme.fg("muted", ` ${question.header}:`));
|
|
340
|
-
for (const line of lines) add(` ${theme.fg("text", line)}`);
|
|
341
|
-
}
|
|
342
|
-
add("");
|
|
343
|
-
add(theme.fg(flow.allAnswered() ? "success" : "warning", " Press Enter to submit"));
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function footerHelp(flow: QuestionnaireFlow, state: OverlayRenderState): string {
|
|
347
|
-
if (state.subMode === "text-input") return "Enter to submit • Esc to cancel";
|
|
348
|
-
if (isEditorMode(state.subMode)) return "Enter to submit • Esc to go back";
|
|
349
|
-
if (flow.currentMode === "reviewing") {
|
|
350
|
-
return "Enter to submit • ←/Shift-Tab to revise • Esc to cancel";
|
|
351
|
-
}
|
|
352
|
-
const question = flow.currentQuestion;
|
|
353
|
-
if (!question || question.type === "text") return "Esc cancel";
|
|
354
|
-
const canGoBack = flow.currentIndex > 0;
|
|
355
|
-
const canReview = flow.allAnswered();
|
|
356
|
-
const parts = ["↑↓ navigate"];
|
|
357
|
-
if (question.type === "multichoice") {
|
|
358
|
-
parts.push("Space toggle");
|
|
359
|
-
if (selectedIndexesForQuestion(flow, state, question).length > 0) parts.push("Enter submit");
|
|
360
|
-
} else {
|
|
361
|
-
parts.push("Enter confirm/select");
|
|
362
|
-
}
|
|
363
|
-
if (currentRowSupportsNotes(question, state)) {
|
|
364
|
-
parts.push(currentNote(flow, state, question) ? "n edit note" : "n add note");
|
|
365
|
-
}
|
|
366
|
-
if (canGoBack) parts.push("←/Shift-Tab back");
|
|
367
|
-
if (canReview) parts.push("→/Tab review");
|
|
368
|
-
parts.push("Esc cancel");
|
|
369
|
-
return parts.join(" • ");
|
|
370
|
-
}
|