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