@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
|
@@ -0,0 +1,370 @@
|
|
|
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
|
+
}
|
package/ui-rich-state.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Shared state types and pure helpers for the rich overlay.
|
|
2
|
+
|
|
3
|
+
import type { Editor } from "@mariozechner/pi-tui";
|
|
4
|
+
import type { QuestionnaireFlow } from "./flow.ts";
|
|
5
|
+
import type {
|
|
6
|
+
NormalizedQuestion,
|
|
7
|
+
NormalizedStructuredQuestion,
|
|
8
|
+
QuestionnaireOutcome,
|
|
9
|
+
} from "./types.ts";
|
|
10
|
+
import { isStructuredQuestion, primaryRecommendationIndex } from "./types.ts";
|
|
11
|
+
import type { OverlayRenderState, SubMode } from "./ui-rich-render.ts";
|
|
12
|
+
|
|
13
|
+
export interface NoteTargetSingle {
|
|
14
|
+
mode: "single";
|
|
15
|
+
questionId: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface NoteTargetMulti {
|
|
19
|
+
mode: "multi";
|
|
20
|
+
questionId: string;
|
|
21
|
+
optionIndex: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type NoteTarget = NoteTargetSingle | NoteTargetMulti;
|
|
25
|
+
|
|
26
|
+
export interface OverlayState extends OverlayRenderState {
|
|
27
|
+
noteTarget?: NoteTarget;
|
|
28
|
+
cachedLines: string[] | undefined;
|
|
29
|
+
cachedWidth: number | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface OverlayDeps {
|
|
33
|
+
flow: QuestionnaireFlow;
|
|
34
|
+
state: OverlayState;
|
|
35
|
+
editor: Editor;
|
|
36
|
+
refresh: () => void;
|
|
37
|
+
finish: (o: QuestionnaireOutcome) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type InteractiveRow =
|
|
41
|
+
| { kind: "option"; optionIndex: number }
|
|
42
|
+
| { kind: "other" }
|
|
43
|
+
| { kind: "discuss" };
|
|
44
|
+
|
|
45
|
+
export function interactiveRows(question: NormalizedStructuredQuestion): InteractiveRow[] {
|
|
46
|
+
const rows: InteractiveRow[] = question.options.map((_, optionIndex) => ({
|
|
47
|
+
kind: "option",
|
|
48
|
+
optionIndex,
|
|
49
|
+
}));
|
|
50
|
+
if (question.allowOther) rows.push({ kind: "other" });
|
|
51
|
+
if (question.allowDiscuss) rows.push({ kind: "discuss" });
|
|
52
|
+
return rows;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function rowCount(question: NormalizedQuestion): number {
|
|
56
|
+
return isStructuredQuestion(question) ? interactiveRows(question).length : 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function hasPreview(question: NormalizedQuestion): boolean {
|
|
60
|
+
return isStructuredQuestion(question) && question.options.some((option) => !!option.preview);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function initialSubMode(question: NormalizedQuestion | undefined): SubMode {
|
|
64
|
+
if (!question) return "select";
|
|
65
|
+
return question.type === "text" ? "text-input" : "select";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function resetStateForCurrent(deps: OverlayDeps): void {
|
|
69
|
+
const question = deps.flow.currentQuestion;
|
|
70
|
+
deps.state.subMode = deps.flow.currentMode === "reviewing" ? "select" : initialSubMode(question);
|
|
71
|
+
deps.state.selectedIndex = selectedRowIndex(deps.flow, question);
|
|
72
|
+
deps.state.noteTarget = undefined;
|
|
73
|
+
deps.editor.setText("");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function existingStructuredInputValue(
|
|
77
|
+
flow: QuestionnaireFlow,
|
|
78
|
+
questionId: string,
|
|
79
|
+
kind: "other" | "discuss",
|
|
80
|
+
): string {
|
|
81
|
+
const answer = flow.getAnswer(questionId);
|
|
82
|
+
if (kind === "other") return answer?.source === "other" ? answer.value : "";
|
|
83
|
+
return answer?.source === "discuss" ? (answer.value ?? "") : "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function singleNoteFromAnswer(
|
|
87
|
+
flow: Pick<QuestionnaireFlow, "getAnswer">,
|
|
88
|
+
questionId: string,
|
|
89
|
+
): string | undefined {
|
|
90
|
+
const answer = flow.getAnswer(questionId);
|
|
91
|
+
if (!answer) return undefined;
|
|
92
|
+
if (answer.source === "option" || answer.source === "yesno") return answer.note;
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function multiNoteMapFromAnswer(
|
|
97
|
+
flow: Pick<QuestionnaireFlow, "getAnswer">,
|
|
98
|
+
questionId: string,
|
|
99
|
+
): Map<number, string> {
|
|
100
|
+
const answer = flow.getAnswer(questionId);
|
|
101
|
+
const map = new Map<number, string>();
|
|
102
|
+
if (!answer || answer.source !== "options") return map;
|
|
103
|
+
for (const selection of answer.selections) {
|
|
104
|
+
if (selection.note) map.set(selection.optionIndex, selection.note);
|
|
105
|
+
}
|
|
106
|
+
return map;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function mergedMultiNoteMap(deps: OverlayDeps, questionId: string): Map<number, string> {
|
|
110
|
+
const answerMap = multiNoteMapFromAnswer(deps.flow, questionId);
|
|
111
|
+
const staged = deps.state.stagedMultiNotes.get(questionId);
|
|
112
|
+
if (!staged) return answerMap;
|
|
113
|
+
const merged = new Map(answerMap);
|
|
114
|
+
for (const [optionIndex, note] of staged.entries()) merged.set(optionIndex, note);
|
|
115
|
+
return merged;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function selectedRowIndex(
|
|
119
|
+
flow: Pick<QuestionnaireFlow, "getAnswer">,
|
|
120
|
+
question: NormalizedQuestion | undefined,
|
|
121
|
+
): number {
|
|
122
|
+
if (!question || question.type === "text") return 0;
|
|
123
|
+
const rows = interactiveRows(question);
|
|
124
|
+
const answer = flow.getAnswer(question.id);
|
|
125
|
+
if (!answer) {
|
|
126
|
+
const recommended = primaryRecommendationIndex(question);
|
|
127
|
+
return recommended ?? 0;
|
|
128
|
+
}
|
|
129
|
+
switch (answer.source) {
|
|
130
|
+
case "option":
|
|
131
|
+
case "yesno":
|
|
132
|
+
return answer.optionIndex;
|
|
133
|
+
case "options":
|
|
134
|
+
return answer.optionIndexes[0] ?? 0;
|
|
135
|
+
case "other":
|
|
136
|
+
return rows.findIndex((row) => row.kind === "other");
|
|
137
|
+
case "discuss":
|
|
138
|
+
return rows.findIndex((row) => row.kind === "discuss");
|
|
139
|
+
case "text":
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
}
|
package/ui-rich.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Rich questionnaire UI built on `ctx.ui.custom()`. Supports explicit choice,
|
|
2
|
+
// multichoice, notes, other, discuss, preview, and review flows. Returns a
|
|
3
|
+
// QuestionnaireOutcome whose terminal state is owned by the shared flow.
|
|
4
|
+
|
|
5
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import { type Component, Editor, type EditorTheme, type TUI } from "@mariozechner/pi-tui";
|
|
7
|
+
import { QuestionnaireFlow } from "./flow.ts";
|
|
8
|
+
import type { NormalizedQuestion, QuestionnaireOutcome } from "./types.ts";
|
|
9
|
+
import { handleOverlayInput, onEditorSubmit } from "./ui-rich-handlers.ts";
|
|
10
|
+
import { renderOverlay } from "./ui-rich-render.ts";
|
|
11
|
+
import { initialSubMode, type OverlayState, selectedRowIndex } from "./ui-rich-state.ts";
|
|
12
|
+
|
|
13
|
+
export interface RichCustomOptions {
|
|
14
|
+
overlay?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RichUiHost {
|
|
18
|
+
custom<T>(
|
|
19
|
+
factory: (
|
|
20
|
+
tui: TUI,
|
|
21
|
+
theme: Theme,
|
|
22
|
+
// biome-ignore lint/suspicious/noExplicitAny: keybindings parameter type isn't needed here
|
|
23
|
+
keybindings: any,
|
|
24
|
+
done: (result: T) => void,
|
|
25
|
+
) => Component & { dispose?(): void },
|
|
26
|
+
options?: RichCustomOptions,
|
|
27
|
+
): Promise<T> | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RunRichOptions {
|
|
31
|
+
ui: RichUiHost;
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function runRichQuestionnaire(
|
|
36
|
+
questions: NormalizedQuestion[],
|
|
37
|
+
opts: RunRichOptions,
|
|
38
|
+
): Promise<QuestionnaireOutcome | "unsupported"> {
|
|
39
|
+
const flow = new QuestionnaireFlow(questions);
|
|
40
|
+
// Short-circuit before opening the overlay if we were already aborted.
|
|
41
|
+
// Otherwise signal.addEventListener("abort", …) would never fire and the
|
|
42
|
+
// overlay would stay open until the user dismissed it manually.
|
|
43
|
+
if (opts.signal?.aborted) {
|
|
44
|
+
flow.abort();
|
|
45
|
+
return flow.outcome();
|
|
46
|
+
}
|
|
47
|
+
const promise = opts.ui.custom<QuestionnaireOutcome>((tui, theme, _kb, done) =>
|
|
48
|
+
buildOverlay({ tui, theme, flow, signal: opts.signal, done }),
|
|
49
|
+
);
|
|
50
|
+
if (!promise) return "unsupported";
|
|
51
|
+
return promise;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface BuildOverlayArgs {
|
|
55
|
+
tui: TUI;
|
|
56
|
+
theme: Theme;
|
|
57
|
+
flow: QuestionnaireFlow;
|
|
58
|
+
signal: AbortSignal | undefined;
|
|
59
|
+
done: (result: QuestionnaireOutcome) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildOverlay(args: BuildOverlayArgs): Component {
|
|
63
|
+
const { tui, theme, flow, signal, done } = args;
|
|
64
|
+
const state: OverlayState = {
|
|
65
|
+
selectedIndex: selectedRowIndex(flow, flow.currentQuestion),
|
|
66
|
+
subMode: initialSubMode(flow.currentQuestion),
|
|
67
|
+
stagedSelections: new Map(),
|
|
68
|
+
stagedSingleNotes: new Map(),
|
|
69
|
+
stagedMultiNotes: new Map(),
|
|
70
|
+
noteTarget: undefined,
|
|
71
|
+
cachedLines: undefined,
|
|
72
|
+
cachedWidth: undefined,
|
|
73
|
+
};
|
|
74
|
+
const editor = new Editor(tui, makeEditorTheme(theme));
|
|
75
|
+
const refresh = () => {
|
|
76
|
+
state.cachedLines = undefined;
|
|
77
|
+
tui.requestRender();
|
|
78
|
+
};
|
|
79
|
+
const finish = (outcome: QuestionnaireOutcome) => done(outcome);
|
|
80
|
+
|
|
81
|
+
signal?.addEventListener("abort", () => {
|
|
82
|
+
flow.abort();
|
|
83
|
+
done(flow.outcome());
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const deps = { flow, state, editor, refresh, finish };
|
|
87
|
+
editor.onSubmit = (value) => onEditorSubmit(value, deps);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
render: (width) => {
|
|
91
|
+
// pi's TUI does not call invalidate() on terminal resize, so a width
|
|
92
|
+
// change has to invalidate the cache here or we'd return stale lines
|
|
93
|
+
// truncated for the previous width.
|
|
94
|
+
if (state.cachedWidth !== width) {
|
|
95
|
+
state.cachedLines = undefined;
|
|
96
|
+
state.cachedWidth = width;
|
|
97
|
+
}
|
|
98
|
+
if (!state.cachedLines) state.cachedLines = renderOverlay(width, theme, flow, state, editor);
|
|
99
|
+
return state.cachedLines;
|
|
100
|
+
},
|
|
101
|
+
invalidate: () => {
|
|
102
|
+
state.cachedLines = undefined;
|
|
103
|
+
},
|
|
104
|
+
handleInput: (data: string) => handleOverlayInput(data, deps),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function makeEditorTheme(theme: Theme): EditorTheme {
|
|
109
|
+
return {
|
|
110
|
+
borderColor: (text) => theme.fg("accent", text),
|
|
111
|
+
selectList: {
|
|
112
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
113
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
114
|
+
description: (text) => theme.fg("muted", text),
|
|
115
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
116
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|