@mrclrchtr/supi-ask-user 1.3.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +163 -67
- package/node_modules/@mrclrchtr/supi-core/README.md +52 -41
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +15 -13
- package/node_modules/@mrclrchtr/supi-core/src/{config-settings.ts → config/config-settings.ts} +2 -2
- package/node_modules/@mrclrchtr/supi-core/src/{context-provider-registry.ts → context/context-provider-registry.ts} +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/extension.ts +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +15 -13
- package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +40 -0
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +42 -10
- package/node_modules/@mrclrchtr/supi-core/src/{settings-registry.ts → settings/settings-registry.ts} +1 -1
- package/package.json +2 -2
- package/src/api.ts +19 -0
- package/src/ask-user.ts +71 -131
- package/src/index.ts +23 -1
- package/src/normalize.ts +153 -142
- package/src/render/result.ts +102 -0
- package/src/render/transcript.ts +65 -0
- package/src/render/tree-summary.ts +10 -0
- package/src/schema.ts +41 -38
- package/src/session/controller.ts +281 -0
- package/src/session/lock.ts +19 -0
- package/src/tool/guidance.ts +15 -0
- package/src/types.ts +56 -55
- package/src/ui/choose-renderer.ts +11 -0
- package/src/ui/overlay-actions.ts +42 -0
- package/src/ui/overlay-component.ts +400 -0
- package/src/ui/overlay-render.ts +219 -0
- package/src/ui/overlay-view.ts +313 -0
- package/src/ui/overlay.ts +28 -0
- package/src/ui/types.ts +38 -0
- package/src/flow.ts +0 -224
- package/src/format.ts +0 -66
- package/src/render/ui-rich-render-editor.ts +0 -51
- package/src/render/ui-rich-render-env.ts +0 -15
- package/src/render/ui-rich-render-footer.ts +0 -55
- package/src/render/ui-rich-render-markdown.ts +0 -33
- package/src/render/ui-rich-render-notes.ts +0 -80
- package/src/render/ui-rich-render-types.ts +0 -17
- package/src/render/ui-rich-render.ts +0 -323
- package/src/render.ts +0 -95
- package/src/result.ts +0 -90
- package/src/ui/ui-rich-handlers.ts +0 -369
- package/src/ui/ui-rich-inline.ts +0 -77
- package/src/ui/ui-rich-state.ts +0 -179
- package/src/ui/ui-rich.ts +0 -144
- /package/node_modules/@mrclrchtr/supi-core/src/{config.ts → config/config.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-core/src/{context-messages.ts → context/context-messages.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-core/src/{context-tag.ts → context/context-tag.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-core/src/{settings-command.ts → settings/settings-command.ts} +0 -0
- /package/node_modules/@mrclrchtr/supi-core/src/{settings-ui.ts → settings/settings-ui.ts} +0 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { SelectItem } from "@earendil-works/pi-tui";
|
|
3
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
4
|
+
import type { AskUserController } from "../session/controller.ts";
|
|
5
|
+
import type { NormalizedChoiceQuestion } from "../types.ts";
|
|
6
|
+
|
|
7
|
+
export type OverlayAction = "other" | "skip" | "discuss" | "partial";
|
|
8
|
+
export type FocusTarget = "choices" | "editor" | "actions";
|
|
9
|
+
export type OverlayMode = "choice" | "text" | "custom-input" | "discuss-input" | "note-input";
|
|
10
|
+
|
|
11
|
+
export type ChoiceRow =
|
|
12
|
+
| { kind: "option"; optionIndex: number }
|
|
13
|
+
| { kind: "action"; action: OverlayAction };
|
|
14
|
+
|
|
15
|
+
export function buildChoiceRows(
|
|
16
|
+
controller: AskUserController,
|
|
17
|
+
question: NormalizedChoiceQuestion,
|
|
18
|
+
): ChoiceRow[] {
|
|
19
|
+
const rows: ChoiceRow[] = question.options.map((_option, optionIndex) => ({
|
|
20
|
+
kind: "option",
|
|
21
|
+
optionIndex,
|
|
22
|
+
}));
|
|
23
|
+
if (question.allowOther) rows.push({ kind: "action", action: "other" });
|
|
24
|
+
rows.push(
|
|
25
|
+
...buildExceptionalActions(controller, question.required).map((action) => ({
|
|
26
|
+
kind: "action" as const,
|
|
27
|
+
action,
|
|
28
|
+
})),
|
|
29
|
+
);
|
|
30
|
+
return rows;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildChoiceItems(
|
|
34
|
+
controller: AskUserController,
|
|
35
|
+
question: NormalizedChoiceQuestion,
|
|
36
|
+
rows: ChoiceRow[],
|
|
37
|
+
): SelectItem[] {
|
|
38
|
+
const selectedIndexes = new Set(controller.getSelectedIndexes(question));
|
|
39
|
+
return rows.flatMap((row) => {
|
|
40
|
+
if (row.kind === "option") {
|
|
41
|
+
const option = question.options[row.optionIndex];
|
|
42
|
+
return option
|
|
43
|
+
? [
|
|
44
|
+
buildOptionItem({
|
|
45
|
+
question,
|
|
46
|
+
optionIndex: row.optionIndex,
|
|
47
|
+
label: option.label,
|
|
48
|
+
selectedIndexes,
|
|
49
|
+
hasNote: !!controller.getChoiceOptionNote(question.id, option.value),
|
|
50
|
+
}),
|
|
51
|
+
]
|
|
52
|
+
: [];
|
|
53
|
+
}
|
|
54
|
+
return [buildActionItem(controller, question.id, row.action)];
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildTextActionItems(
|
|
59
|
+
controller: AskUserController,
|
|
60
|
+
): Array<{ action: OverlayAction; item: SelectItem }> {
|
|
61
|
+
return buildExceptionalActions(controller, controller.currentQuestion.required).map((action) => ({
|
|
62
|
+
action,
|
|
63
|
+
item: {
|
|
64
|
+
value: action,
|
|
65
|
+
label: actionLabel(action),
|
|
66
|
+
},
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function defaultChoiceRowIndex(
|
|
71
|
+
controller: AskUserController,
|
|
72
|
+
question: NormalizedChoiceQuestion,
|
|
73
|
+
rows: ChoiceRow[],
|
|
74
|
+
): number {
|
|
75
|
+
const current = controller.getAnswer(question.id);
|
|
76
|
+
if (current?.kind === "custom") {
|
|
77
|
+
return rows.findIndex((row) => row.kind === "action" && row.action === "other");
|
|
78
|
+
}
|
|
79
|
+
if (current?.kind === "choice") {
|
|
80
|
+
const first = current.selections[0];
|
|
81
|
+
if (!first) return 0;
|
|
82
|
+
const optionIndex = question.options.findIndex((option) => option.value === first.value);
|
|
83
|
+
return optionIndex >= 0 ? optionIndex : 0;
|
|
84
|
+
}
|
|
85
|
+
return question.initialIndexes[0] ?? question.recommendedIndexes[0] ?? 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function previewOptionIndexForRows(
|
|
89
|
+
rows: ChoiceRow[],
|
|
90
|
+
rowIndex: number,
|
|
91
|
+
fallbackOptionIndex: number,
|
|
92
|
+
): number | undefined {
|
|
93
|
+
const row = rows[rowIndex];
|
|
94
|
+
if (row?.kind === "option") return row.optionIndex;
|
|
95
|
+
return fallbackOptionIndex >= 0 ? fallbackOptionIndex : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function footerText(args: {
|
|
99
|
+
controller: AskUserController;
|
|
100
|
+
mode: OverlayMode;
|
|
101
|
+
focus: FocusTarget;
|
|
102
|
+
hasTextActions: boolean;
|
|
103
|
+
}): string {
|
|
104
|
+
const { controller, mode, focus, hasTextActions } = args;
|
|
105
|
+
const question = controller.currentQuestion;
|
|
106
|
+
|
|
107
|
+
if (question.type === "text") {
|
|
108
|
+
if (mode === "discuss-input") return "Enter submit • Esc cancel";
|
|
109
|
+
if (focus === "editor") {
|
|
110
|
+
return hasTextActions
|
|
111
|
+
? "Enter submit • ↓ actions • ← back • Esc cancel"
|
|
112
|
+
: "Enter submit • ← back • Esc cancel";
|
|
113
|
+
}
|
|
114
|
+
return "↑↓ move • Enter select • ↑ editor • ← back • Esc cancel";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (mode === "custom-input" || mode === "discuss-input") {
|
|
118
|
+
return "Enter submit • Esc cancel";
|
|
119
|
+
}
|
|
120
|
+
if (mode === "note-input") {
|
|
121
|
+
return "Enter save • Esc close";
|
|
122
|
+
}
|
|
123
|
+
return question.multi
|
|
124
|
+
? "↑↓ move • Space toggle • Enter submit • n note • ← back • Esc cancel"
|
|
125
|
+
: "↑↓ move • Space select • Enter submit • n note • ← back • Esc cancel";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function splitColumns(args: {
|
|
129
|
+
width: number;
|
|
130
|
+
theme: Theme;
|
|
131
|
+
leftLines: string[];
|
|
132
|
+
rightLines: string[];
|
|
133
|
+
leftRatio?: number;
|
|
134
|
+
}): string[] {
|
|
135
|
+
const { width, theme, leftLines, rightLines, leftRatio = 0.55 } = args;
|
|
136
|
+
const leftWidth = Math.max(36, Math.floor(width * leftRatio));
|
|
137
|
+
const rightWidth = Math.max(24, width - leftWidth - 3);
|
|
138
|
+
const total = Math.max(leftLines.length, rightLines.length);
|
|
139
|
+
const lines: string[] = [];
|
|
140
|
+
|
|
141
|
+
for (let index = 0; index < total; index += 1) {
|
|
142
|
+
const left = padRight(leftLines[index] ?? "", leftWidth);
|
|
143
|
+
const right = padRight(rightLines[index] ?? "", rightWidth);
|
|
144
|
+
lines.push(`${left} ${theme.fg("accent", "│")} ${right}`);
|
|
145
|
+
}
|
|
146
|
+
return lines;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function choiceRowValue(row: ChoiceRow): string {
|
|
150
|
+
return row.kind === "option" ? `option:${row.optionIndex}` : `action:${row.action}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function renderOptionRow(args: {
|
|
154
|
+
option: { label: string; description?: string };
|
|
155
|
+
labelText: string;
|
|
156
|
+
isSelected: boolean;
|
|
157
|
+
theme: Theme;
|
|
158
|
+
width: number;
|
|
159
|
+
}): string[] {
|
|
160
|
+
const { theme, isSelected, labelText, width, option } = args;
|
|
161
|
+
const prefix = isSelected ? "\u2192 " : " ";
|
|
162
|
+
|
|
163
|
+
const lines: string[] = [
|
|
164
|
+
isSelected ? theme.fg("accent", `${prefix}${labelText}`) : `${prefix}${labelText}`,
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
if (option.description) {
|
|
168
|
+
const descWidth = Math.max(10, width - 2);
|
|
169
|
+
const wrapped = wrapTextWithAnsi(option.description, descWidth);
|
|
170
|
+
for (const descLine of wrapped) {
|
|
171
|
+
lines.push(theme.fg("muted", ` ${descLine}`));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return lines;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function renderActionRow(args: {
|
|
179
|
+
actionLabel: string;
|
|
180
|
+
isSelected: boolean;
|
|
181
|
+
theme: Theme;
|
|
182
|
+
}): string[] {
|
|
183
|
+
const { theme, isSelected, actionLabel } = args;
|
|
184
|
+
const prefix = isSelected ? "\u2192 " : " ";
|
|
185
|
+
return [isSelected ? theme.fg("accent", `${prefix}${actionLabel}`) : `${prefix}${actionLabel}`];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function prepareOptionMarker(
|
|
189
|
+
question: NormalizedChoiceQuestion,
|
|
190
|
+
optionIndex: number,
|
|
191
|
+
selectedIndexes: Set<number>,
|
|
192
|
+
): string {
|
|
193
|
+
if (question.multi) {
|
|
194
|
+
return selectedIndexes.has(optionIndex) ? "[x]" : "[ ]";
|
|
195
|
+
}
|
|
196
|
+
return selectedIndexes.has(optionIndex) ? "(*)" : "( )";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function prepareOptionLabel(
|
|
200
|
+
option: { label: string },
|
|
201
|
+
marker: string,
|
|
202
|
+
recommended: boolean,
|
|
203
|
+
hasNote: boolean,
|
|
204
|
+
): string {
|
|
205
|
+
return `${marker} ${option.label}${recommended ? " (recommended)" : ""}${hasNote ? " [note]" : ""}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function renderChoiceList(args: {
|
|
209
|
+
controller: AskUserController;
|
|
210
|
+
question: NormalizedChoiceQuestion;
|
|
211
|
+
rows: ChoiceRow[];
|
|
212
|
+
selectedIndex: number;
|
|
213
|
+
theme: Theme;
|
|
214
|
+
width: number;
|
|
215
|
+
}): string[] {
|
|
216
|
+
const { controller, question, rows, selectedIndex, theme, width } = args;
|
|
217
|
+
const lines: string[] = [];
|
|
218
|
+
const selectedIndexes = new Set(controller.getSelectedIndexes(question));
|
|
219
|
+
|
|
220
|
+
for (let i = 0; i < rows.length; i++) {
|
|
221
|
+
const row = rows[i];
|
|
222
|
+
const isSelected = i === selectedIndex;
|
|
223
|
+
|
|
224
|
+
if (row.kind === "option") {
|
|
225
|
+
const option = question.options[row.optionIndex];
|
|
226
|
+
if (!option) continue;
|
|
227
|
+
|
|
228
|
+
const marker = prepareOptionMarker(question, row.optionIndex, selectedIndexes);
|
|
229
|
+
const recommended = question.recommendedIndexes.includes(row.optionIndex);
|
|
230
|
+
const hasNote = !!controller.getChoiceOptionNote(question.id, option.value);
|
|
231
|
+
const labelText = prepareOptionLabel(option, marker, recommended, hasNote);
|
|
232
|
+
|
|
233
|
+
lines.push(...renderOptionRow({ option, labelText, isSelected, theme, width }));
|
|
234
|
+
} else {
|
|
235
|
+
const answer = controller.getAnswer(question.id);
|
|
236
|
+
const actionLabelText =
|
|
237
|
+
row.action === "other" && answer?.kind === "custom"
|
|
238
|
+
? `Other \u2014 ${answer.value}`
|
|
239
|
+
: actionLabel(row.action);
|
|
240
|
+
|
|
241
|
+
lines.push(...renderActionRow({ actionLabel: actionLabelText, isSelected, theme }));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return lines;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function buildOptionItem(args: {
|
|
249
|
+
question: NormalizedChoiceQuestion;
|
|
250
|
+
optionIndex: number;
|
|
251
|
+
label: string;
|
|
252
|
+
selectedIndexes: Set<number>;
|
|
253
|
+
hasNote: boolean;
|
|
254
|
+
}): SelectItem {
|
|
255
|
+
const { question, optionIndex, label, selectedIndexes, hasNote } = args;
|
|
256
|
+
const recommended = question.recommendedIndexes.includes(optionIndex) ? " (recommended)" : "";
|
|
257
|
+
const marker = question.multi
|
|
258
|
+
? selectedIndexes.has(optionIndex)
|
|
259
|
+
? "[x]"
|
|
260
|
+
: "[ ]"
|
|
261
|
+
: selectedIndexes.has(optionIndex)
|
|
262
|
+
? "(*)"
|
|
263
|
+
: "( )";
|
|
264
|
+
return {
|
|
265
|
+
value: choiceRowValue({ kind: "option", optionIndex }),
|
|
266
|
+
label: `${marker} ${label}${recommended}${hasNote ? " [note]" : ""}`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function buildActionItem(
|
|
271
|
+
controller: AskUserController,
|
|
272
|
+
questionId: string,
|
|
273
|
+
action: OverlayAction,
|
|
274
|
+
): SelectItem {
|
|
275
|
+
const answer = controller.getAnswer(questionId);
|
|
276
|
+
return {
|
|
277
|
+
value: choiceRowValue({ kind: "action", action }),
|
|
278
|
+
label:
|
|
279
|
+
action === "other" && answer?.kind === "custom"
|
|
280
|
+
? `Other — ${answer.value}`
|
|
281
|
+
: actionLabel(action),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function buildExceptionalActions(
|
|
286
|
+
controller: AskUserController,
|
|
287
|
+
required: boolean,
|
|
288
|
+
): OverlayAction[] {
|
|
289
|
+
const actions: OverlayAction[] = [];
|
|
290
|
+
if (!required) actions.push("skip");
|
|
291
|
+
if (controller.questionnaire.allowDiscuss) actions.push("discuss");
|
|
292
|
+
if (controller.canPartialSubmit()) actions.push("partial");
|
|
293
|
+
return actions;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function actionLabel(action: OverlayAction): string {
|
|
297
|
+
switch (action) {
|
|
298
|
+
case "other":
|
|
299
|
+
return "Other…";
|
|
300
|
+
case "skip":
|
|
301
|
+
return "Skip question";
|
|
302
|
+
case "discuss":
|
|
303
|
+
return "Discuss instead…";
|
|
304
|
+
case "partial":
|
|
305
|
+
return "Submit partial answers";
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function padRight(text: string, width: number): string {
|
|
310
|
+
const visible = visibleWidth(text);
|
|
311
|
+
if (visible >= width) return truncateToWidth(text, width);
|
|
312
|
+
return `${text}${" ".repeat(width - visible)}`;
|
|
313
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { AskUserController } from "../session/controller.ts";
|
|
2
|
+
import type { AskUserOutcome, NormalizedQuestionnaire } from "../types.ts";
|
|
3
|
+
import { AskUserOverlay } from "./overlay-component.ts";
|
|
4
|
+
import type { RunQuestionnaireOptions } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export async function runOverlayQuestionnaire(
|
|
7
|
+
questionnaire: NormalizedQuestionnaire,
|
|
8
|
+
opts: RunQuestionnaireOptions,
|
|
9
|
+
): Promise<AskUserOutcome> {
|
|
10
|
+
const controller = new AskUserController(questionnaire);
|
|
11
|
+
if (opts.signal?.aborted) {
|
|
12
|
+
controller.abort();
|
|
13
|
+
return controller.outcome();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return opts.ui.custom?.<AskUserOutcome>(
|
|
17
|
+
(tui, theme, kb, done) =>
|
|
18
|
+
new AskUserOverlay({
|
|
19
|
+
tui,
|
|
20
|
+
theme,
|
|
21
|
+
controller,
|
|
22
|
+
done,
|
|
23
|
+
signal: opts.signal,
|
|
24
|
+
keybindings: kb,
|
|
25
|
+
onToggleToolsExpanded: opts.onToggleToolsExpanded,
|
|
26
|
+
}),
|
|
27
|
+
) as Promise<AskUserOutcome>;
|
|
28
|
+
}
|
package/src/ui/types.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { AskUserController } from "../session/controller.ts";
|
|
4
|
+
import type { AskUserOutcome, NormalizedQuestionnaire } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
export interface AskUserUiContext {
|
|
7
|
+
notify?(message: string, type?: "info" | "warning" | "error"): void;
|
|
8
|
+
custom?<T>(
|
|
9
|
+
factory: (
|
|
10
|
+
tui: TUI,
|
|
11
|
+
theme: Theme,
|
|
12
|
+
keybindings: KeybindingsManager,
|
|
13
|
+
done: (result: T) => void,
|
|
14
|
+
) => Component & { dispose?(): void },
|
|
15
|
+
): Promise<T>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RunQuestionnaireOptions {
|
|
19
|
+
ui: AskUserUiContext;
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
/** Callback to toggle tool output expansion (Ctrl+O passthrough). */
|
|
22
|
+
onToggleToolsExpanded?: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RenderContext {
|
|
26
|
+
questionnaire: NormalizedQuestionnaire;
|
|
27
|
+
options: RunQuestionnaireOptions;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface OverlayArgs {
|
|
31
|
+
tui: TUI;
|
|
32
|
+
theme: Theme;
|
|
33
|
+
controller: AskUserController;
|
|
34
|
+
done: (result: AskUserOutcome) => void;
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
keybindings: KeybindingsManager;
|
|
37
|
+
onToggleToolsExpanded?: () => void;
|
|
38
|
+
}
|
package/src/flow.ts
DELETED
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
// Shared questionnaire flow state used by the overlay UI and the
|
|
2
|
-
// single-active-questionnaire concurrency guard. The flow owns terminal-state
|
|
3
|
-
// transitions (`submitted`, `cancelled`, `aborted`) to keep cancellation/abort
|
|
4
|
-
// semantics consistent.
|
|
5
|
-
|
|
6
|
-
import type { Answer, NormalizedQuestion, QuestionnaireOutcome, TerminalState } from "./types.ts";
|
|
7
|
-
import { needsReview } from "./types.ts";
|
|
8
|
-
|
|
9
|
-
export type FlowMode = "answering" | "reviewing" | "terminal";
|
|
10
|
-
|
|
11
|
-
export class QuestionnaireFlow {
|
|
12
|
-
private readonly answers = new Map<string, Answer>();
|
|
13
|
-
private index = 0;
|
|
14
|
-
private mode: FlowMode = "answering";
|
|
15
|
-
private terminalState: TerminalState | null = null;
|
|
16
|
-
|
|
17
|
-
constructor(
|
|
18
|
-
public readonly questions: NormalizedQuestion[],
|
|
19
|
-
public readonly allowSkip = false,
|
|
20
|
-
) {
|
|
21
|
-
if (questions.length === 0) {
|
|
22
|
-
throw new Error("QuestionnaireFlow requires at least one question.");
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
get hasOptionalQuestions(): boolean {
|
|
27
|
-
return this.questions.some((q) => !q.required);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
get showSkip(): boolean {
|
|
31
|
-
return this.allowSkip || this.hasOptionalQuestions;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
get currentIndex(): number {
|
|
35
|
-
return this.index;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
get currentMode(): FlowMode {
|
|
39
|
-
return this.mode;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
get isMultiQuestion(): boolean {
|
|
43
|
-
return this.questions.length > 1;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
get currentQuestion(): NormalizedQuestion | undefined {
|
|
47
|
-
return this.questions[this.index];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
hasAnswer(questionId: string): boolean {
|
|
51
|
-
return this.answers.has(questionId);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
getAnswer(questionId: string): Answer | undefined {
|
|
55
|
-
return this.answers.get(questionId);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
allAnswered(): boolean {
|
|
59
|
-
return this.questions.every((q) => this.answers.has(q.id));
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
allRequiredAnswered(): boolean {
|
|
63
|
-
return this.questions.every((q) => !q.required || this.answers.has(q.id));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
setAnswer(answer: Answer): void {
|
|
67
|
-
this.answers.set(answer.questionId, normalizeAnswer(answer));
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
advance(): boolean {
|
|
71
|
-
if (this.mode !== "answering") return false;
|
|
72
|
-
const current = this.currentQuestion;
|
|
73
|
-
if (current?.required && !this.answers.has(current.id)) return false;
|
|
74
|
-
if (this.index < this.questions.length - 1) {
|
|
75
|
-
this.index += 1;
|
|
76
|
-
return true;
|
|
77
|
-
}
|
|
78
|
-
if (needsReview(this.questions)) {
|
|
79
|
-
this.mode = "reviewing";
|
|
80
|
-
return true;
|
|
81
|
-
}
|
|
82
|
-
this.markSubmitted();
|
|
83
|
-
return true;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
goBack(): boolean {
|
|
87
|
-
if (!this.guardNotTerminal()) return false;
|
|
88
|
-
if (this.mode === "reviewing") {
|
|
89
|
-
this.mode = "answering";
|
|
90
|
-
this.index = this.questions.length - 1;
|
|
91
|
-
return true;
|
|
92
|
-
}
|
|
93
|
-
if (this.index > 0) {
|
|
94
|
-
this.index -= 1;
|
|
95
|
-
return true;
|
|
96
|
-
}
|
|
97
|
-
return false;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
enterReview(): boolean {
|
|
101
|
-
if (!this.guardNotTerminal()) return false;
|
|
102
|
-
if (!needsReview(this.questions)) return false;
|
|
103
|
-
if (!this.allRequiredAnswered()) return false;
|
|
104
|
-
this.mode = "reviewing";
|
|
105
|
-
return true;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
submit(): boolean {
|
|
109
|
-
if (!this.guardNotTerminal()) return false;
|
|
110
|
-
if (!this.allRequiredAnswered()) return false;
|
|
111
|
-
this.markSubmitted();
|
|
112
|
-
return true;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
skip(): boolean {
|
|
116
|
-
if (!this.guardNotTerminal()) return false;
|
|
117
|
-
this.mode = "terminal";
|
|
118
|
-
this.terminalState = "skipped";
|
|
119
|
-
return true;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
cancel(): void {
|
|
123
|
-
if (!this.guardNotTerminal()) return;
|
|
124
|
-
this.mode = "terminal";
|
|
125
|
-
this.terminalState = "cancelled";
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
abort(): void {
|
|
129
|
-
if (!this.guardNotTerminal()) return;
|
|
130
|
-
this.mode = "terminal";
|
|
131
|
-
this.terminalState = "aborted";
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
isTerminal(): boolean {
|
|
135
|
-
return this.mode === "terminal";
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
outcome(): QuestionnaireOutcome {
|
|
139
|
-
const state = this.terminalState ?? "cancelled";
|
|
140
|
-
return {
|
|
141
|
-
terminalState: state,
|
|
142
|
-
answers:
|
|
143
|
-
state === "submitted" || state === "skipped"
|
|
144
|
-
? this.collectAnswers()
|
|
145
|
-
: [...this.answers.values()],
|
|
146
|
-
...(state === "skipped" ? { skipped: true } : {}),
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
private guardNotTerminal(): boolean {
|
|
151
|
-
return this.mode !== "terminal";
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
private markSubmitted(): void {
|
|
155
|
-
this.mode = "terminal";
|
|
156
|
-
this.terminalState = "submitted";
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
private collectAnswers(): Answer[] {
|
|
160
|
-
return this.questions.flatMap((q) => {
|
|
161
|
-
const answer = this.answers.get(q.id);
|
|
162
|
-
return answer ? [answer] : [];
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function normalizeAnswer(answer: Answer): Answer {
|
|
168
|
-
switch (answer.source) {
|
|
169
|
-
case "choice":
|
|
170
|
-
return {
|
|
171
|
-
questionId: answer.questionId,
|
|
172
|
-
source: "choice",
|
|
173
|
-
selections: answer.selections.map((selection) => ({
|
|
174
|
-
value: selection.value.trim(),
|
|
175
|
-
optionIndex: selection.optionIndex,
|
|
176
|
-
note: trimOptional(selection.note),
|
|
177
|
-
})),
|
|
178
|
-
};
|
|
179
|
-
case "other":
|
|
180
|
-
return {
|
|
181
|
-
questionId: answer.questionId,
|
|
182
|
-
source: "other",
|
|
183
|
-
value: answer.value.trim(),
|
|
184
|
-
};
|
|
185
|
-
case "discuss": {
|
|
186
|
-
const value = trimOptional(answer.value);
|
|
187
|
-
return value
|
|
188
|
-
? { questionId: answer.questionId, source: "discuss", value }
|
|
189
|
-
: { questionId: answer.questionId, source: "discuss" };
|
|
190
|
-
}
|
|
191
|
-
case "text":
|
|
192
|
-
return {
|
|
193
|
-
questionId: answer.questionId,
|
|
194
|
-
source: "text",
|
|
195
|
-
value: answer.value.trim(),
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function trimOptional(value: string | undefined): string | undefined {
|
|
201
|
-
const trimmed = value?.trim();
|
|
202
|
-
return trimmed && trimmed.length > 0 ? trimmed : undefined;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Session-scoped lock: only one in-flight `ask_user` interaction at a time.
|
|
206
|
-
// Stored at module scope per extension instance (the extension factory runs
|
|
207
|
-
// once per session in pi).
|
|
208
|
-
export class ActiveQuestionnaireLock {
|
|
209
|
-
private active = false;
|
|
210
|
-
|
|
211
|
-
acquire(): boolean {
|
|
212
|
-
if (this.active) return false;
|
|
213
|
-
this.active = true;
|
|
214
|
-
return true;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
release(): void {
|
|
218
|
-
this.active = false;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
isActive(): boolean {
|
|
222
|
-
return this.active;
|
|
223
|
-
}
|
|
224
|
-
}
|
package/src/format.ts
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
// Shared formatting helpers used by the overlay UI and result rendering.
|
|
2
|
-
// Keeps summary/review formatting in one place so the overlay, transcript
|
|
3
|
-
// renderer, and tool-content summary cannot accidentally diverge.
|
|
4
|
-
|
|
5
|
-
import type { Answer, NormalizedQuestion } from "./types.ts";
|
|
6
|
-
|
|
7
|
-
export const OTHER_LABEL = "Other answer";
|
|
8
|
-
export const DISCUSS_LABEL = "Discuss instead";
|
|
9
|
-
export const SUBMIT_SELECTIONS_LABEL = "Submit selections";
|
|
10
|
-
export const NOTE_MARKER = "✎";
|
|
11
|
-
|
|
12
|
-
export function decorateOption(label: string, recommended: boolean): string {
|
|
13
|
-
if (!recommended) return label;
|
|
14
|
-
if (label.trimEnd().toLowerCase().endsWith("(recommended)")) return label;
|
|
15
|
-
return `${label} (recommended)`;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// formatSummaryBody and formatReviewLines must be kept in sync —
|
|
19
|
-
// when adding a new answer source, update both functions.
|
|
20
|
-
export function formatSummaryBody(question: NormalizedQuestion, answer: Answer): string {
|
|
21
|
-
switch (answer.source) {
|
|
22
|
-
case "choice":
|
|
23
|
-
return answer.selections
|
|
24
|
-
.map((selection) => {
|
|
25
|
-
const label = question.options[selection.optionIndex]?.label ?? selection.value;
|
|
26
|
-
return withNote(label, selection.note);
|
|
27
|
-
})
|
|
28
|
-
.join("; ");
|
|
29
|
-
case "other":
|
|
30
|
-
return `Other — ${answer.value}`;
|
|
31
|
-
case "discuss":
|
|
32
|
-
return answer.value ? `Discuss — ${answer.value}` : "Discuss";
|
|
33
|
-
case "text":
|
|
34
|
-
return answer.value;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function formatReviewBody(question: NormalizedQuestion, answer: Answer): string {
|
|
39
|
-
return formatReviewLines(question, answer).join("; ");
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function formatReviewLines(question: NormalizedQuestion, answer: Answer): string[] {
|
|
43
|
-
switch (answer.source) {
|
|
44
|
-
case "choice":
|
|
45
|
-
if (answer.selections.length === 0) return ["(no selections)"];
|
|
46
|
-
return answer.selections.map((selection) => {
|
|
47
|
-
const label = question.options[selection.optionIndex]?.label ?? selection.value;
|
|
48
|
-
return withNote(label, selection.note);
|
|
49
|
-
});
|
|
50
|
-
case "other":
|
|
51
|
-
return [`Other: ${answer.value}`];
|
|
52
|
-
case "discuss":
|
|
53
|
-
return [answer.value ? `Discuss: ${answer.value}` : "Discuss"];
|
|
54
|
-
case "text":
|
|
55
|
-
return [answer.value];
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function formatReviewLine(question: NormalizedQuestion, answer: Answer | undefined): string {
|
|
60
|
-
if (!answer) return "(no answer)";
|
|
61
|
-
return formatReviewBody(question, answer);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function withNote(body: string, note: string | undefined): string {
|
|
65
|
-
return note ? `${body} — ${note}` : body;
|
|
66
|
-
}
|