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