@mrclrchtr/supi-ask-user 1.4.0 → 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 +108 -70
- package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +2 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +2 -0
- 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/package.json +2 -2
- package/src/ask-user.ts +6 -0
- package/src/render/result.ts +5 -1
- package/src/session/controller.ts +122 -4
- package/src/types.ts +7 -0
- package/src/ui/overlay-component.ts +400 -0
- package/src/ui/overlay-render.ts +29 -6
- package/src/ui/overlay-view.ts +106 -9
- package/src/ui/overlay.ts +13 -373
- package/src/ui/types.ts +6 -3
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Component,
|
|
3
|
+
Editor,
|
|
4
|
+
type Focusable,
|
|
5
|
+
Key,
|
|
6
|
+
matchesKey,
|
|
7
|
+
SelectList,
|
|
8
|
+
} from "@earendil-works/pi-tui";
|
|
9
|
+
import type { NormalizedChoiceQuestion } from "../types.ts";
|
|
10
|
+
import { createActionList } from "./overlay-actions.ts";
|
|
11
|
+
import {
|
|
12
|
+
clampIndex,
|
|
13
|
+
currentCustomValue,
|
|
14
|
+
currentPreviewText,
|
|
15
|
+
currentTextValue,
|
|
16
|
+
makeEditorTheme,
|
|
17
|
+
makeSelectListTheme,
|
|
18
|
+
renderOverlayFrame,
|
|
19
|
+
} from "./overlay-render.ts";
|
|
20
|
+
import {
|
|
21
|
+
buildChoiceItems,
|
|
22
|
+
buildChoiceRows,
|
|
23
|
+
type ChoiceRow,
|
|
24
|
+
choiceRowValue,
|
|
25
|
+
defaultChoiceRowIndex,
|
|
26
|
+
type FocusTarget,
|
|
27
|
+
type OverlayAction,
|
|
28
|
+
type OverlayMode,
|
|
29
|
+
previewOptionIndexForRows,
|
|
30
|
+
} from "./overlay-view.ts";
|
|
31
|
+
import type { OverlayArgs } from "./types.ts";
|
|
32
|
+
|
|
33
|
+
export class AskUserOverlay implements Component, Focusable {
|
|
34
|
+
focused = false;
|
|
35
|
+
|
|
36
|
+
private readonly editor: Editor;
|
|
37
|
+
private focus: FocusTarget = "choices";
|
|
38
|
+
private mode: OverlayMode = "choice";
|
|
39
|
+
private closed = false;
|
|
40
|
+
private cachedWidth: number | undefined;
|
|
41
|
+
private cachedLines: string[] | undefined;
|
|
42
|
+
private readonly onAbort: () => void;
|
|
43
|
+
|
|
44
|
+
private choiceRows: ChoiceRow[] = [];
|
|
45
|
+
private choiceRowIndex = 0;
|
|
46
|
+
private previewOptionIndex = 0;
|
|
47
|
+
private choiceList: SelectList | undefined;
|
|
48
|
+
private textActions: Array<{ action: OverlayAction; label: string }> = [];
|
|
49
|
+
private actionIndex = 0;
|
|
50
|
+
private actionList: SelectList | undefined;
|
|
51
|
+
|
|
52
|
+
constructor(private readonly args: OverlayArgs) {
|
|
53
|
+
this.editor = new Editor(args.tui, makeEditorTheme(args.theme));
|
|
54
|
+
this.editor.onSubmit = (value) => this.handleEditorSubmit(value);
|
|
55
|
+
this.syncCurrentQuestion();
|
|
56
|
+
this.onAbort = () => {
|
|
57
|
+
this.args.controller.abort();
|
|
58
|
+
this.finish();
|
|
59
|
+
};
|
|
60
|
+
args.signal?.addEventListener("abort", this.onAbort);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
render(width: number): string[] {
|
|
64
|
+
this.editor.focused = this.focus === "editor";
|
|
65
|
+
if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
|
|
66
|
+
this.cachedWidth = width;
|
|
67
|
+
this.cachedLines = renderOverlayFrame({
|
|
68
|
+
width,
|
|
69
|
+
theme: this.args.theme,
|
|
70
|
+
controller: this.args.controller,
|
|
71
|
+
mode: this.mode,
|
|
72
|
+
focus: this.focus,
|
|
73
|
+
editor: this.editor,
|
|
74
|
+
choiceRows: this.choiceRows,
|
|
75
|
+
choiceRowIndex: this.choiceRowIndex,
|
|
76
|
+
actionList: this.actionList,
|
|
77
|
+
textActionLabels: this.textActions.map(({ label }) => label),
|
|
78
|
+
previewText: currentPreviewText(
|
|
79
|
+
this.args.controller.currentQuestion,
|
|
80
|
+
this.previewOptionIndex,
|
|
81
|
+
),
|
|
82
|
+
});
|
|
83
|
+
return this.cachedLines;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
handleInput(data: string): void {
|
|
87
|
+
if (this.closed || this.args.controller.isTerminal) return;
|
|
88
|
+
|
|
89
|
+
if (this.args.keybindings.matches(data, "app.tools.expand")) {
|
|
90
|
+
this.args.onToggleToolsExpanded?.();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (this.mode === "note-input" && matchesKey(data, Key.escape)) {
|
|
95
|
+
const question = this.args.controller.currentQuestion;
|
|
96
|
+
const row = this.choiceRows[this.choiceRowIndex];
|
|
97
|
+
if (question.type === "choice" && row?.kind === "option") {
|
|
98
|
+
this.restoreChoiceMode(question, row.optionIndex);
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (matchesKey(data, Key.escape)) {
|
|
103
|
+
this.args.controller.cancel();
|
|
104
|
+
this.finish();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (this.mode !== "note-input" && matchesKey(data, Key.left)) {
|
|
108
|
+
if (this.args.controller.goBack()) {
|
|
109
|
+
this.syncCurrentQuestion();
|
|
110
|
+
this.refresh();
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
switch (this.focus) {
|
|
116
|
+
case "choices":
|
|
117
|
+
this.handleChoiceKey(data);
|
|
118
|
+
return;
|
|
119
|
+
case "actions":
|
|
120
|
+
this.handleActionKey(data);
|
|
121
|
+
return;
|
|
122
|
+
case "editor":
|
|
123
|
+
this.handleEditorKey(data);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
invalidate(): void {
|
|
129
|
+
this.cachedLines = undefined;
|
|
130
|
+
this.choiceList?.invalidate();
|
|
131
|
+
this.actionList?.invalidate();
|
|
132
|
+
this.editor.invalidate();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
dispose(): void {
|
|
136
|
+
this.closed = true;
|
|
137
|
+
this.args.signal?.removeEventListener("abort", this.onAbort);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private handleChoiceKey(data: string): void {
|
|
141
|
+
const question = this.args.controller.currentQuestion;
|
|
142
|
+
if (question.type !== "choice" || !this.choiceList) return;
|
|
143
|
+
|
|
144
|
+
if (data === "n") {
|
|
145
|
+
const row = this.choiceRows[this.choiceRowIndex];
|
|
146
|
+
if (row?.kind === "option") this.openNoteEditor(question, row.optionIndex);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (matchesKey(data, Key.space)) {
|
|
151
|
+
const row = this.choiceRows[this.choiceRowIndex];
|
|
152
|
+
if (row?.kind === "option") this.applyChoiceSelection(question, row.optionIndex, false);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this.choiceList.handleInput(data);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private handleActionKey(data: string): void {
|
|
160
|
+
const question = this.args.controller.currentQuestion;
|
|
161
|
+
if (!this.actionList) return;
|
|
162
|
+
|
|
163
|
+
if (question.type === "text") {
|
|
164
|
+
if (matchesKey(data, Key.up) && this.actionIndex === 0) {
|
|
165
|
+
this.focus = "editor";
|
|
166
|
+
this.refresh();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
} else if (matchesKey(data, Key.up) && this.actionIndex === 0) {
|
|
170
|
+
this.focus = "choices";
|
|
171
|
+
this.refresh();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
this.actionList.handleInput(data);
|
|
176
|
+
this.refresh();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private handleEditorKey(data: string): void {
|
|
180
|
+
if (this.mode === "text" && matchesKey(data, Key.down) && this.textActions.length > 0) {
|
|
181
|
+
this.focus = "actions";
|
|
182
|
+
this.refresh();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
this.editor.handleInput(data);
|
|
187
|
+
this.refresh();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private handleEditorSubmit(value: string): void {
|
|
191
|
+
const trimmed = value.trim();
|
|
192
|
+
const question = this.args.controller.currentQuestion;
|
|
193
|
+
|
|
194
|
+
if (this.mode === "discuss-input") {
|
|
195
|
+
this.args.controller.finishDiscuss(trimmed || undefined);
|
|
196
|
+
this.finish();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (this.mode === "note-input") {
|
|
200
|
+
if (question.type !== "choice") return;
|
|
201
|
+
const row = this.choiceRows[this.choiceRowIndex];
|
|
202
|
+
if (row?.kind !== "option") return;
|
|
203
|
+
this.args.controller.setChoiceOptionNote(question, row.optionIndex, trimmed || undefined);
|
|
204
|
+
this.restoreChoiceMode(question, row.optionIndex);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (this.mode === "custom-input") {
|
|
208
|
+
if (trimmed.length === 0) return;
|
|
209
|
+
this.args.controller.setAnswer(question.id, { kind: "custom", value: trimmed });
|
|
210
|
+
this.advanceAfterQuestion();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (trimmed.length === 0) {
|
|
214
|
+
if (question.required) return;
|
|
215
|
+
this.args.controller.clearAnswer(question.id);
|
|
216
|
+
this.advanceAfterQuestion();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
this.args.controller.setAnswer(question.id, { kind: "text", value: trimmed });
|
|
221
|
+
this.advanceAfterQuestion();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private applyChoiceSelection(
|
|
225
|
+
question: NormalizedChoiceQuestion,
|
|
226
|
+
optionIndex: number,
|
|
227
|
+
submit: boolean,
|
|
228
|
+
): void {
|
|
229
|
+
if (question.multi) {
|
|
230
|
+
this.toggleMultiChoice(question, optionIndex);
|
|
231
|
+
if (submit && this.args.controller.hasAnswer(question.id)) this.advanceAfterQuestion();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
this.args.controller.selectChoiceOption(question, optionIndex);
|
|
236
|
+
this.choiceRowIndex = optionIndex;
|
|
237
|
+
this.previewOptionIndex = optionIndex;
|
|
238
|
+
if (submit) {
|
|
239
|
+
this.advanceAfterQuestion();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this.buildChoiceList(question);
|
|
243
|
+
this.refresh();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private toggleMultiChoice(question: NormalizedChoiceQuestion, optionIndex: number): void {
|
|
247
|
+
this.args.controller.toggleChoiceOption(question, optionIndex);
|
|
248
|
+
this.choiceRowIndex = optionIndex;
|
|
249
|
+
this.previewOptionIndex = optionIndex;
|
|
250
|
+
this.buildChoiceList(question);
|
|
251
|
+
this.refresh();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private openNoteEditor(question: NormalizedChoiceQuestion, optionIndex: number): void {
|
|
255
|
+
const option = question.options[optionIndex];
|
|
256
|
+
if (!option) return;
|
|
257
|
+
this.mode = "note-input";
|
|
258
|
+
this.focus = "editor";
|
|
259
|
+
this.choiceRowIndex = optionIndex;
|
|
260
|
+
this.previewOptionIndex = optionIndex;
|
|
261
|
+
this.editor.setText(this.args.controller.getChoiceOptionNote(question.id, option.value) ?? "");
|
|
262
|
+
this.refresh();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private restoreChoiceMode(question: NormalizedChoiceQuestion, optionIndex: number): void {
|
|
266
|
+
this.mode = "choice";
|
|
267
|
+
this.focus = "choices";
|
|
268
|
+
this.editor.setText("");
|
|
269
|
+
this.choiceRows = buildChoiceRows(this.args.controller, question);
|
|
270
|
+
const nextIndex = this.choiceRows.findIndex(
|
|
271
|
+
(row) => row.kind === "option" && row.optionIndex === optionIndex,
|
|
272
|
+
);
|
|
273
|
+
this.choiceRowIndex = clampIndex(
|
|
274
|
+
nextIndex >= 0 ? nextIndex : optionIndex,
|
|
275
|
+
this.choiceRows.length,
|
|
276
|
+
);
|
|
277
|
+
this.previewOptionIndex = optionIndex;
|
|
278
|
+
this.buildChoiceList(question);
|
|
279
|
+
this.refresh();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private handleAction(action: OverlayAction): void {
|
|
283
|
+
switch (action) {
|
|
284
|
+
case "other":
|
|
285
|
+
this.mode = "custom-input";
|
|
286
|
+
this.focus = "editor";
|
|
287
|
+
this.editor.setText(currentCustomValue(this.args.controller));
|
|
288
|
+
this.refresh();
|
|
289
|
+
return;
|
|
290
|
+
case "skip":
|
|
291
|
+
this.args.controller.clearAnswer(this.args.controller.currentQuestion.id);
|
|
292
|
+
this.advanceAfterQuestion();
|
|
293
|
+
return;
|
|
294
|
+
case "discuss":
|
|
295
|
+
this.mode = "discuss-input";
|
|
296
|
+
this.focus = "editor";
|
|
297
|
+
this.editor.setText("");
|
|
298
|
+
this.refresh();
|
|
299
|
+
return;
|
|
300
|
+
case "partial":
|
|
301
|
+
this.args.controller.finishPartial();
|
|
302
|
+
this.finish();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private advanceAfterQuestion(): void {
|
|
308
|
+
if (!this.args.controller.goNext()) {
|
|
309
|
+
this.args.controller.finishSubmitted();
|
|
310
|
+
this.finish();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
this.syncCurrentQuestion();
|
|
314
|
+
this.refresh();
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private syncCurrentQuestion(): void {
|
|
318
|
+
const question = this.args.controller.currentQuestion;
|
|
319
|
+
if (question.type === "text") {
|
|
320
|
+
this.mode = "text";
|
|
321
|
+
this.focus = "editor";
|
|
322
|
+
this.editor.setText(currentTextValue(this.args.controller, question.initial));
|
|
323
|
+
this.buildTextActions();
|
|
324
|
+
this.choiceRows = [];
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
this.mode = "choice";
|
|
329
|
+
this.focus = "choices";
|
|
330
|
+
this.textActions = [];
|
|
331
|
+
this.actionList = undefined;
|
|
332
|
+
this.editor.setText("");
|
|
333
|
+
this.choiceRows = buildChoiceRows(this.args.controller, question);
|
|
334
|
+
this.choiceRowIndex = clampIndex(
|
|
335
|
+
defaultChoiceRowIndex(this.args.controller, question, this.choiceRows),
|
|
336
|
+
this.choiceRows.length,
|
|
337
|
+
);
|
|
338
|
+
this.previewOptionIndex =
|
|
339
|
+
previewOptionIndexForRows(this.choiceRows, this.choiceRowIndex, this.previewOptionIndex) ?? 0;
|
|
340
|
+
this.buildChoiceList(question);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private buildChoiceList(question: NormalizedChoiceQuestion): void {
|
|
344
|
+
const items = buildChoiceItems(this.args.controller, question, this.choiceRows);
|
|
345
|
+
|
|
346
|
+
const list = new SelectList(
|
|
347
|
+
items,
|
|
348
|
+
Math.min(this.choiceRows.length, 10),
|
|
349
|
+
makeSelectListTheme(this.args.theme),
|
|
350
|
+
);
|
|
351
|
+
list.onSelectionChange = (item) => {
|
|
352
|
+
const nextIndex = this.choiceRows.findIndex((row) => choiceRowValue(row) === item.value);
|
|
353
|
+
if (nextIndex < 0) return;
|
|
354
|
+
this.choiceRowIndex = nextIndex;
|
|
355
|
+
this.previewOptionIndex =
|
|
356
|
+
previewOptionIndexForRows(this.choiceRows, nextIndex, this.previewOptionIndex) ??
|
|
357
|
+
this.previewOptionIndex;
|
|
358
|
+
this.refresh();
|
|
359
|
+
};
|
|
360
|
+
list.onSelect = (item) => {
|
|
361
|
+
const row = this.choiceRows.find((candidate) => choiceRowValue(candidate) === item.value);
|
|
362
|
+
if (!row) return;
|
|
363
|
+
if (row.kind === "option") {
|
|
364
|
+
this.applyChoiceSelection(question, row.optionIndex, true);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
this.handleAction(row.action);
|
|
368
|
+
};
|
|
369
|
+
list.setSelectedIndex(this.choiceRowIndex);
|
|
370
|
+
this.choiceList = list;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private buildTextActions(): void {
|
|
374
|
+
const state = createActionList({
|
|
375
|
+
controller: this.args.controller,
|
|
376
|
+
theme: this.args.theme,
|
|
377
|
+
actionIndex: this.actionIndex,
|
|
378
|
+
onIndexChange: (index) => {
|
|
379
|
+
this.actionIndex = index;
|
|
380
|
+
this.refresh();
|
|
381
|
+
},
|
|
382
|
+
onAction: (action) => this.handleAction(action),
|
|
383
|
+
});
|
|
384
|
+
this.textActions = state.entries;
|
|
385
|
+
this.actionList = state.list;
|
|
386
|
+
this.actionIndex = state.index;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private refresh(): void {
|
|
390
|
+
this.cachedLines = undefined;
|
|
391
|
+
this.args.tui.requestRender();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private finish(): void {
|
|
395
|
+
if (this.closed) return;
|
|
396
|
+
this.closed = true;
|
|
397
|
+
this.args.signal?.removeEventListener("abort", this.onAbort);
|
|
398
|
+
this.args.done(this.args.controller.outcome());
|
|
399
|
+
}
|
|
400
|
+
}
|
package/src/ui/overlay-render.ts
CHANGED
|
@@ -8,7 +8,14 @@ import {
|
|
|
8
8
|
} from "@earendil-works/pi-tui";
|
|
9
9
|
import type { AskUserController } from "../session/controller.ts";
|
|
10
10
|
import type { NormalizedQuestionnaire } from "../types.ts";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
type ChoiceRow,
|
|
13
|
+
type FocusTarget,
|
|
14
|
+
footerText,
|
|
15
|
+
type OverlayMode,
|
|
16
|
+
renderChoiceList,
|
|
17
|
+
splitColumns,
|
|
18
|
+
} from "./overlay-view.ts";
|
|
12
19
|
|
|
13
20
|
export interface RenderOverlayFrameArgs {
|
|
14
21
|
width: number;
|
|
@@ -17,7 +24,8 @@ export interface RenderOverlayFrameArgs {
|
|
|
17
24
|
mode: OverlayMode;
|
|
18
25
|
focus: FocusTarget;
|
|
19
26
|
editor: Editor;
|
|
20
|
-
|
|
27
|
+
choiceRows: ChoiceRow[];
|
|
28
|
+
choiceRowIndex: number;
|
|
21
29
|
actionList: SelectList | undefined;
|
|
22
30
|
textActionLabels: string[];
|
|
23
31
|
previewText?: string;
|
|
@@ -114,9 +122,22 @@ function renderBody(args: RenderOverlayFrameArgs): string[] {
|
|
|
114
122
|
}
|
|
115
123
|
|
|
116
124
|
function renderChoiceBody(args: RenderOverlayFrameArgs): string[] {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
125
|
+
const question = args.controller.currentQuestion;
|
|
126
|
+
const listWidth = splitLeftWidth(args.width);
|
|
127
|
+
|
|
128
|
+
const leftLines =
|
|
129
|
+
question.type === "choice"
|
|
130
|
+
? renderChoiceList({
|
|
131
|
+
controller: args.controller,
|
|
132
|
+
question,
|
|
133
|
+
rows: args.choiceRows,
|
|
134
|
+
selectedIndex: args.choiceRowIndex,
|
|
135
|
+
theme: args.theme,
|
|
136
|
+
width: listWidth,
|
|
137
|
+
})
|
|
138
|
+
: [];
|
|
139
|
+
|
|
140
|
+
if (args.mode === "custom-input" || args.mode === "discuss-input" || args.mode === "note-input") {
|
|
120
141
|
const rightLines = renderEditorLines(args, splitRightWidth(args.width));
|
|
121
142
|
if (args.width >= 100) {
|
|
122
143
|
return splitColumns({
|
|
@@ -169,7 +190,9 @@ function renderEditorLines(args: RenderOverlayFrameArgs, width: number): string[
|
|
|
169
190
|
? "Discuss instead"
|
|
170
191
|
: args.mode === "custom-input"
|
|
171
192
|
? "Other answer"
|
|
172
|
-
:
|
|
193
|
+
: args.mode === "note-input"
|
|
194
|
+
? "Option note"
|
|
195
|
+
: "Your answer";
|
|
173
196
|
|
|
174
197
|
const lines = [args.theme.fg("accent", label), ...args.editor.render(Math.max(20, width - 1))];
|
|
175
198
|
const question = args.controller.currentQuestion;
|
package/src/ui/overlay-view.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { SelectItem } from "@earendil-works/pi-tui";
|
|
3
|
-
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
4
4
|
import type { AskUserController } from "../session/controller.ts";
|
|
5
5
|
import type { NormalizedChoiceQuestion } from "../types.ts";
|
|
6
6
|
|
|
7
7
|
export type OverlayAction = "other" | "skip" | "discuss" | "partial";
|
|
8
8
|
export type FocusTarget = "choices" | "editor" | "actions";
|
|
9
|
-
export type OverlayMode = "choice" | "text" | "custom-input" | "discuss-input";
|
|
9
|
+
export type OverlayMode = "choice" | "text" | "custom-input" | "discuss-input" | "note-input";
|
|
10
10
|
|
|
11
11
|
export type ChoiceRow =
|
|
12
12
|
| { kind: "option"; optionIndex: number }
|
|
@@ -45,8 +45,8 @@ export function buildChoiceItems(
|
|
|
45
45
|
question,
|
|
46
46
|
optionIndex: row.optionIndex,
|
|
47
47
|
label: option.label,
|
|
48
|
-
description: option.description,
|
|
49
48
|
selectedIndexes,
|
|
49
|
+
hasNote: !!controller.getChoiceOptionNote(question.id, option.value),
|
|
50
50
|
}),
|
|
51
51
|
]
|
|
52
52
|
: [];
|
|
@@ -117,9 +117,12 @@ export function footerText(args: {
|
|
|
117
117
|
if (mode === "custom-input" || mode === "discuss-input") {
|
|
118
118
|
return "Enter submit • Esc cancel";
|
|
119
119
|
}
|
|
120
|
+
if (mode === "note-input") {
|
|
121
|
+
return "Enter save • Esc close";
|
|
122
|
+
}
|
|
120
123
|
return question.multi
|
|
121
|
-
? "↑↓ move • Space toggle • Enter submit • ← back • Esc cancel"
|
|
122
|
-
: "↑↓ move • Space select • Enter submit • ← back • Esc cancel";
|
|
124
|
+
? "↑↓ move • Space toggle • Enter submit • n note • ← back • Esc cancel"
|
|
125
|
+
: "↑↓ move • Space select • Enter submit • n note • ← back • Esc cancel";
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
export function splitColumns(args: {
|
|
@@ -147,14 +150,109 @@ export function choiceRowValue(row: ChoiceRow): string {
|
|
|
147
150
|
return row.kind === "option" ? `option:${row.optionIndex}` : `action:${row.action}`;
|
|
148
151
|
}
|
|
149
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
|
+
|
|
150
248
|
function buildOptionItem(args: {
|
|
151
249
|
question: NormalizedChoiceQuestion;
|
|
152
250
|
optionIndex: number;
|
|
153
251
|
label: string;
|
|
154
|
-
description: string | undefined;
|
|
155
252
|
selectedIndexes: Set<number>;
|
|
253
|
+
hasNote: boolean;
|
|
156
254
|
}): SelectItem {
|
|
157
|
-
const { question, optionIndex, label,
|
|
255
|
+
const { question, optionIndex, label, selectedIndexes, hasNote } = args;
|
|
158
256
|
const recommended = question.recommendedIndexes.includes(optionIndex) ? " (recommended)" : "";
|
|
159
257
|
const marker = question.multi
|
|
160
258
|
? selectedIndexes.has(optionIndex)
|
|
@@ -165,8 +263,7 @@ function buildOptionItem(args: {
|
|
|
165
263
|
: "( )";
|
|
166
264
|
return {
|
|
167
265
|
value: choiceRowValue({ kind: "option", optionIndex }),
|
|
168
|
-
label: `${marker} ${label}${recommended}`,
|
|
169
|
-
description,
|
|
266
|
+
label: `${marker} ${label}${recommended}${hasNote ? " [note]" : ""}`,
|
|
170
267
|
};
|
|
171
268
|
}
|
|
172
269
|
|