@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.
@@ -0,0 +1,313 @@
1
+ // Input handlers for the rich overlay questionnaire UI.
2
+
3
+ import { Key, matchesKey } from "@mariozechner/pi-tui";
4
+ import type { NormalizedQuestion, NormalizedStructuredQuestion } from "./types.ts";
5
+ import { isEditorMode, selectedIndexesForQuestion } from "./ui-rich-render.ts";
6
+ import { currentNote, currentRowSupportsNotes } from "./ui-rich-render-notes.ts";
7
+ import {
8
+ existingStructuredInputValue,
9
+ interactiveRows,
10
+ mergedMultiNoteMap,
11
+ multiNoteMapFromAnswer,
12
+ type OverlayDeps,
13
+ resetStateForCurrent,
14
+ rowCount,
15
+ singleNoteFromAnswer,
16
+ } from "./ui-rich-state.ts";
17
+
18
+ export function onEditorSubmit(value: string, deps: OverlayDeps): void {
19
+ const question = deps.flow.currentQuestion;
20
+ if (!question) return;
21
+ const trimmed = value.trim();
22
+ const { state, editor, refresh } = deps;
23
+ if (state.subMode === "text-input") {
24
+ if (trimmed.length === 0) return;
25
+ deps.flow.setAnswer({ questionId: question.id, source: "text", value: trimmed });
26
+ moveAfterAnswer(deps);
27
+ return;
28
+ }
29
+ if (state.subMode === "other-input") {
30
+ if (trimmed.length === 0) {
31
+ state.subMode = "select";
32
+ editor.setText("");
33
+ refresh();
34
+ return;
35
+ }
36
+ deps.flow.setAnswer({ questionId: question.id, source: "other", value: trimmed });
37
+ moveAfterAnswer(deps);
38
+ return;
39
+ }
40
+ if (state.subMode === "discuss-input") {
41
+ deps.flow.setAnswer(
42
+ trimmed.length > 0
43
+ ? { questionId: question.id, source: "discuss", value: trimmed }
44
+ : { questionId: question.id, source: "discuss" },
45
+ );
46
+ moveAfterAnswer(deps);
47
+ return;
48
+ }
49
+ if (state.subMode === "note-input") {
50
+ applyNoteEdit(trimmed, deps);
51
+ state.subMode = "select";
52
+ state.noteTarget = undefined;
53
+ editor.setText("");
54
+ refresh();
55
+ }
56
+ }
57
+
58
+ function applyNoteEdit(note: string, deps: OverlayDeps): void {
59
+ const { flow, state } = deps;
60
+ const target = state.noteTarget;
61
+ if (!target) return;
62
+ if (target.mode === "single") {
63
+ if (note.length > 0) state.stagedSingleNotes.set(target.questionId, note);
64
+ else state.stagedSingleNotes.delete(target.questionId);
65
+ return;
66
+ }
67
+ const existing = new Map(
68
+ state.stagedMultiNotes.get(target.questionId) ??
69
+ multiNoteMapFromAnswer(flow, target.questionId),
70
+ );
71
+ if (note.length > 0) existing.set(target.optionIndex, note);
72
+ else existing.delete(target.optionIndex);
73
+ if (existing.size > 0) state.stagedMultiNotes.set(target.questionId, existing);
74
+ else state.stagedMultiNotes.delete(target.questionId);
75
+ }
76
+
77
+ export function handleOverlayInput(data: string, deps: OverlayDeps): void {
78
+ const { state, flow } = deps;
79
+ if (isEditorMode(state.subMode)) {
80
+ if (matchesKey(data, Key.escape)) {
81
+ handleEditorEscape(deps);
82
+ return;
83
+ }
84
+ deps.editor.handleInput(data);
85
+ deps.refresh();
86
+ return;
87
+ }
88
+ if (matchesKey(data, Key.escape)) {
89
+ flow.cancel();
90
+ deps.finish(flow.outcome());
91
+ return;
92
+ }
93
+ if (flow.currentMode === "reviewing") {
94
+ handleReviewInput(data, deps);
95
+ return;
96
+ }
97
+ handleSelectInput(data, deps);
98
+ }
99
+
100
+ function handleEditorEscape(deps: OverlayDeps): void {
101
+ const { state, editor, flow, refresh } = deps;
102
+ if (state.subMode === "text-input") {
103
+ flow.cancel();
104
+ deps.finish(flow.outcome());
105
+ return;
106
+ }
107
+ state.subMode = "select";
108
+ state.noteTarget = undefined;
109
+ editor.setText("");
110
+ refresh();
111
+ }
112
+
113
+ function handleSelectInput(data: string, deps: OverlayDeps): void {
114
+ const { flow, state } = deps;
115
+ const question = flow.currentQuestion;
116
+ if (!question) return;
117
+ const maxIndex = Math.max(0, rowCount(question) - 1);
118
+ if (matchesKey(data, Key.up)) {
119
+ moveSelection(question, Math.max(0, state.selectedIndex - 1), deps);
120
+ return;
121
+ }
122
+ if (matchesKey(data, Key.down)) {
123
+ moveSelection(question, Math.min(maxIndex, state.selectedIndex + 1), deps);
124
+ return;
125
+ }
126
+ if (handleSelectNav(data, deps)) return;
127
+ if (question.type === "multichoice" && matchesKey(data, Key.space)) {
128
+ toggleCurrentSelection(question, deps);
129
+ return;
130
+ }
131
+ if (matchesKey(data, "n") && currentRowSupportsNotes(question, state)) {
132
+ openNoteEditor(question, deps);
133
+ return;
134
+ }
135
+ if (matchesKey(data, Key.enter)) {
136
+ handleSelectEnter(question, deps);
137
+ return;
138
+ }
139
+ }
140
+
141
+ function openNoteEditor(question: NormalizedQuestion, deps: OverlayDeps): void {
142
+ if (question.type === "text") return;
143
+ const row = interactiveRows(question)[deps.state.selectedIndex];
144
+ if (!row || row.kind !== "option") return;
145
+ deps.state.subMode = "note-input";
146
+ deps.state.noteTarget =
147
+ question.type === "multichoice"
148
+ ? { mode: "multi", questionId: question.id, optionIndex: row.optionIndex }
149
+ : { mode: "single", questionId: question.id };
150
+ deps.editor.setText(currentNote(deps.flow, deps.state, question) ?? "");
151
+ deps.refresh();
152
+ }
153
+
154
+ function handleSelectNav(data: string, deps: OverlayDeps): boolean {
155
+ const { flow, refresh } = deps;
156
+ if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
157
+ if (flow.goBack()) {
158
+ resetStateForCurrent(deps);
159
+ refresh();
160
+ }
161
+ return true;
162
+ }
163
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
164
+ if (flow.allAnswered() && flow.enterReview()) refresh();
165
+ return true;
166
+ }
167
+ return false;
168
+ }
169
+
170
+ function handleSelectEnter(question: NormalizedQuestion, deps: OverlayDeps): void {
171
+ if (question.type === "text") return;
172
+ const row = interactiveRows(question)[deps.state.selectedIndex];
173
+ if (!row) return;
174
+ if (row.kind === "option") {
175
+ if (question.type === "multichoice") {
176
+ handleSubmitSelections(question, deps);
177
+ return;
178
+ }
179
+ handleOptionRow(question, row.optionIndex, deps);
180
+ return;
181
+ }
182
+ openStructuredInput(question, row.kind, deps);
183
+ }
184
+
185
+ function handleOptionRow(
186
+ question: NormalizedStructuredQuestion,
187
+ optionIndex: number,
188
+ deps: OverlayDeps,
189
+ ): void {
190
+ if (question.type === "multichoice") {
191
+ toggleSelection(question, optionIndex, deps);
192
+ deps.refresh();
193
+ return;
194
+ }
195
+ const option = question.options[optionIndex];
196
+ const note =
197
+ deps.state.stagedSingleNotes.get(question.id) ?? singleNoteFromAnswer(deps.flow, question.id);
198
+ deps.flow.setAnswer(
199
+ question.type === "yesno"
200
+ ? {
201
+ questionId: question.id,
202
+ source: "yesno",
203
+ value: option.value as "yes" | "no",
204
+ optionIndex: optionIndex as 0 | 1,
205
+ note,
206
+ }
207
+ : {
208
+ questionId: question.id,
209
+ source: "option",
210
+ value: option.value,
211
+ optionIndex,
212
+ note,
213
+ },
214
+ );
215
+ moveAfterAnswer(deps);
216
+ }
217
+
218
+ function toggleCurrentSelection(
219
+ question: Extract<NormalizedStructuredQuestion, { type: "multichoice" }>,
220
+ deps: OverlayDeps,
221
+ ): void {
222
+ const row = interactiveRows(question)[deps.state.selectedIndex];
223
+ if (!row || row.kind !== "option") return;
224
+ toggleSelection(question, row.optionIndex, deps);
225
+ deps.refresh();
226
+ }
227
+
228
+ function openStructuredInput(
229
+ question: NormalizedStructuredQuestion,
230
+ kind: "other" | "discuss",
231
+ deps: OverlayDeps,
232
+ ): void {
233
+ deps.state.subMode = kind === "other" ? "other-input" : "discuss-input";
234
+ deps.editor.setText(existingStructuredInputValue(deps.flow, question.id, kind));
235
+ deps.refresh();
236
+ }
237
+
238
+ function moveSelection(question: NormalizedQuestion, nextIndex: number, deps: OverlayDeps): void {
239
+ const { state, refresh } = deps;
240
+ if (state.selectedIndex === nextIndex) {
241
+ refresh();
242
+ return;
243
+ }
244
+ state.selectedIndex = nextIndex;
245
+ if (question.type === "text") {
246
+ refresh();
247
+ return;
248
+ }
249
+ const row = interactiveRows(question)[nextIndex];
250
+ if (row?.kind === "other" || row?.kind === "discuss") {
251
+ openStructuredInput(question, row.kind, deps);
252
+ return;
253
+ }
254
+ refresh();
255
+ }
256
+
257
+ function toggleSelection(
258
+ question: NormalizedStructuredQuestion,
259
+ optionIndex: number,
260
+ deps: OverlayDeps,
261
+ ): void {
262
+ const existing = new Set(selectedIndexesForQuestion(deps.flow, deps.state, question));
263
+ if (existing.has(optionIndex)) existing.delete(optionIndex);
264
+ else existing.add(optionIndex);
265
+ deps.state.stagedSelections.set(
266
+ question.id,
267
+ [...existing].sort((a, b) => a - b),
268
+ );
269
+ }
270
+
271
+ function handleSubmitSelections(question: NormalizedStructuredQuestion, deps: OverlayDeps): void {
272
+ if (question.type !== "multichoice") return;
273
+ const indexes = selectedIndexesForQuestion(deps.flow, deps.state, question);
274
+ if (indexes.length === 0) return;
275
+ const noteMap = mergedMultiNoteMap(deps, question.id);
276
+ const selections = indexes.map((optionIndex) => ({
277
+ value: question.options[optionIndex].value,
278
+ optionIndex,
279
+ note: noteMap.get(optionIndex),
280
+ }));
281
+ deps.flow.setAnswer({
282
+ questionId: question.id,
283
+ source: "options",
284
+ values: selections.map((s) => s.value),
285
+ optionIndexes: selections.map((s) => s.optionIndex),
286
+ selections,
287
+ });
288
+ moveAfterAnswer(deps);
289
+ }
290
+
291
+ function handleReviewInput(data: string, deps: OverlayDeps): void {
292
+ const { flow, refresh } = deps;
293
+ if (matchesKey(data, Key.enter)) {
294
+ if (flow.submit()) deps.finish(flow.outcome());
295
+ return;
296
+ }
297
+ if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left) || matchesKey(data, "b")) {
298
+ if (flow.goBack()) {
299
+ resetStateForCurrent(deps);
300
+ refresh();
301
+ }
302
+ }
303
+ }
304
+
305
+ export function moveAfterAnswer(deps: OverlayDeps): void {
306
+ deps.flow.advance();
307
+ if (deps.flow.isTerminal()) {
308
+ deps.finish(deps.flow.outcome());
309
+ return;
310
+ }
311
+ resetStateForCurrent(deps);
312
+ deps.refresh();
313
+ }
@@ -0,0 +1,64 @@
1
+ import type { Theme } from "@mariozechner/pi-coding-agent";
2
+ import type { Editor } from "@mariozechner/pi-tui";
3
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
4
+ import type { QuestionnaireFlow } from "./flow.ts";
5
+ import { DISCUSS_LABEL, OTHER_LABEL } from "./format.ts";
6
+ import type { NormalizedStructuredQuestion } from "./types.ts";
7
+ import type { OverlayRenderState } from "./ui-rich-render.ts";
8
+ import type { InteractiveRow } from "./ui-rich-state.ts";
9
+
10
+ export function structuredRowLabel(
11
+ flow: Pick<QuestionnaireFlow, "getAnswer">,
12
+ question: NormalizedStructuredQuestion,
13
+ row: Extract<InteractiveRow, { kind: "other" | "discuss" }>,
14
+ ): string {
15
+ const label = row.kind === "other" ? OTHER_LABEL : DISCUSS_LABEL;
16
+ const answer = flow.getAnswer(question.id);
17
+ if (row.kind === "other" && answer?.source === "other") return `${label}: ${answer.value}`;
18
+ if (row.kind === "discuss" && answer?.source === "discuss" && answer.value) {
19
+ return `${label}: ${answer.value}`;
20
+ }
21
+ return label;
22
+ }
23
+
24
+ export function inlineStructuredRowLines(args: {
25
+ width: number;
26
+ theme: Theme;
27
+ state: OverlayRenderState;
28
+ editor: Editor;
29
+ row: InteractiveRow;
30
+ prefix: string;
31
+ }): string[] | undefined {
32
+ const { width, theme, state, editor, row, prefix } = args;
33
+ if (!isInlineStructuredInput(state, row)) return undefined;
34
+ const label = row.kind === "other" ? OTHER_LABEL : DISCUSS_LABEL;
35
+ return renderInlineEditorLines(width - visibleWidth(prefix), theme, editor, `${label}: `);
36
+ }
37
+
38
+ function isInlineStructuredInput(state: OverlayRenderState, row: InteractiveRow): boolean {
39
+ return (
40
+ (state.subMode === "other-input" && row.kind === "other") ||
41
+ (state.subMode === "discuss-input" && row.kind === "discuss")
42
+ );
43
+ }
44
+
45
+ function renderInlineEditorLines(
46
+ width: number,
47
+ theme: Theme,
48
+ editor: Editor,
49
+ prefix = "",
50
+ ): string[] {
51
+ const lines = editor.getLines();
52
+ const cursor = editor.getCursor();
53
+ return lines.map((line, index) => {
54
+ const content = index === cursor.line ? highlightCursor(theme, line, cursor.col) : line;
55
+ return truncateToWidth(`${prefix}${content}`, Math.max(1, width));
56
+ });
57
+ }
58
+
59
+ function highlightCursor(theme: Theme, line: string, col: number): string {
60
+ const before = line.slice(0, col);
61
+ const current = line.slice(col, col + 1) || " ";
62
+ const after = line.slice(col + 1);
63
+ return `${before}${theme.bg("selectedBg", theme.fg("text", current))}${after}`;
64
+ }
@@ -0,0 +1,49 @@
1
+ // Editor pane rendering helpers for the rich overlay.
2
+
3
+ import type { Theme } from "@mariozechner/pi-coding-agent";
4
+ import type { Editor } from "@mariozechner/pi-tui";
5
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
6
+ import type { OverlayRenderState } from "./ui-rich-render.ts";
7
+
8
+ export function editorCaption(state: OverlayRenderState): string {
9
+ if (state.subMode === "other-input") return "Other answer";
10
+ if (state.subMode === "discuss-input") return "Discuss";
11
+ if (state.subMode === "note-input") return "Note";
12
+ return "Answer";
13
+ }
14
+
15
+ export function usesSeparateEditorPane(state: OverlayRenderState): boolean {
16
+ return state.subMode === "note-input";
17
+ }
18
+
19
+ export function renderEditorPane(
20
+ width: number,
21
+ theme: Theme,
22
+ editor: Editor,
23
+ caption: string,
24
+ ): string[] {
25
+ const out: string[] = [];
26
+ out.push(truncateToWidth(theme.fg("accent", ` ${caption}`), width));
27
+ out.push("");
28
+ for (const line of editor.render(width - 2)) out.push(truncateToWidth(` ${line}`, width));
29
+ return out;
30
+ }
31
+
32
+ // biome-ignore lint/complexity/useMaxParams: helper mirrors render context
33
+ export function renderEditorBlock(
34
+ add: (text: string) => void,
35
+ lines: string[],
36
+ theme: Theme,
37
+ editor: Editor,
38
+ width: number,
39
+ caption: string,
40
+ ): void {
41
+ add(theme.fg("muted", ` ${caption}:`));
42
+ for (const line of editor.render(width - 2)) lines.push(` ${truncateToWidth(line, width - 1)}`);
43
+ }
44
+
45
+ export function padRight(text: string, width: number): string {
46
+ const visible = truncateToWidth(text, width);
47
+ const padding = Math.max(0, width - visibleWidth(visible));
48
+ return `${visible}${" ".repeat(padding)}`;
49
+ }
@@ -0,0 +1,87 @@
1
+ // Note-related rendering helpers for the rich overlay.
2
+
3
+ import type { Theme } from "@mariozechner/pi-coding-agent";
4
+ import type { QuestionnaireFlow } from "./flow.ts";
5
+ import type { MultiSelection, NormalizedQuestion, NormalizedStructuredQuestion } from "./types.ts";
6
+ import type { OverlayRenderState } from "./ui-rich-render.ts";
7
+ import { type InteractiveRow, interactiveRows } from "./ui-rich-state.ts";
8
+
9
+ export function currentNote(
10
+ flow: Pick<QuestionnaireFlow, "getAnswer">,
11
+ state: OverlayRenderState,
12
+ question: NormalizedQuestion | undefined,
13
+ ): string | undefined {
14
+ if (!question || question.type === "text") return undefined;
15
+ const row = interactiveRows(question)[state.selectedIndex];
16
+ if (!row || row.kind !== "option") return undefined;
17
+ if (question.type === "multichoice")
18
+ return noteForMultiOption(flow, state, question, row.optionIndex);
19
+ return noteForSingle(flow, state, question);
20
+ }
21
+
22
+ export function currentRowSupportsNotes(
23
+ question: NormalizedQuestion | undefined,
24
+ state: Pick<OverlayRenderState, "selectedIndex">,
25
+ ): boolean {
26
+ if (!question || question.type === "text") return false;
27
+ const row = interactiveRows(question)[state.selectedIndex];
28
+ return row?.kind === "option";
29
+ }
30
+
31
+ export function visibleNoteMarker(args: {
32
+ flow: Pick<QuestionnaireFlow, "getAnswer">;
33
+ state: OverlayRenderState;
34
+ question: NormalizedStructuredQuestion;
35
+ row: Extract<InteractiveRow, { kind: "option" }>;
36
+ active: boolean;
37
+ }): boolean {
38
+ const { flow, state, question, row, active } = args;
39
+ if (question.type === "multichoice")
40
+ return !!noteForMultiOption(flow, state, question, row.optionIndex);
41
+ return active && !!noteForSingle(flow, state, question);
42
+ }
43
+
44
+ export function noteForSingle(
45
+ flow: Pick<QuestionnaireFlow, "getAnswer">,
46
+ state: Pick<OverlayRenderState, "stagedSingleNotes">,
47
+ question: Exclude<NormalizedStructuredQuestion, { type: "multichoice" }>,
48
+ ): string | undefined {
49
+ return state.stagedSingleNotes.get(question.id) ?? storedSingleNote(flow.getAnswer(question.id));
50
+ }
51
+
52
+ function storedSingleNote(
53
+ answer: ReturnType<Pick<QuestionnaireFlow, "getAnswer">["getAnswer"]>,
54
+ ): string | undefined {
55
+ if (!answer) return undefined;
56
+ if (answer.source === "option" || answer.source === "yesno") return answer.note;
57
+ return undefined;
58
+ }
59
+
60
+ export function noteForMultiOption(
61
+ flow: Pick<QuestionnaireFlow, "getAnswer">,
62
+ state: Pick<OverlayRenderState, "stagedMultiNotes">,
63
+ question: Extract<NormalizedStructuredQuestion, { type: "multichoice" }>,
64
+ optionIndex: number,
65
+ ): string | undefined {
66
+ const staged = state.stagedMultiNotes.get(question.id)?.get(optionIndex);
67
+ if (staged !== undefined) return staged;
68
+ return storedMultiSelections(flow.getAnswer(question.id)).find(
69
+ (selection) => selection.optionIndex === optionIndex,
70
+ )?.note;
71
+ }
72
+
73
+ function storedMultiSelections(
74
+ answer: ReturnType<Pick<QuestionnaireFlow, "getAnswer">["getAnswer"]>,
75
+ ): MultiSelection[] {
76
+ if (!answer || answer.source !== "options") return [];
77
+ if (answer.selections.length > 0) return answer.selections;
78
+ return answer.optionIndexes.map((optionIndex, index) => ({
79
+ optionIndex,
80
+ value: answer.values[index] ?? "",
81
+ }));
82
+ }
83
+
84
+ export function renderNoteStatus(add: (text: string) => void, theme: Theme, note: string): void {
85
+ add("");
86
+ add(theme.fg("muted", ` Notes: ${note}`));
87
+ }