@d3ara1n/pi-ask-user 1.0.0 → 2.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.
- package/README.md +112 -44
- package/package.json +3 -2
- package/src/index.ts +740 -311
package/src/index.ts
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* pi-ask-user — An ask-user tool for pi.
|
|
3
3
|
*
|
|
4
4
|
* Design notes:
|
|
5
|
-
* - Collapsible panel (Ctrl+\)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* - Collapsible panel (Ctrl+\) shrinks the panel to one row so more of the
|
|
6
|
+
* transcript stays on screen. Chat scrolling does NOT depend on keyboard
|
|
7
|
+
* focus at all — it is the terminal scrollback (see Layout note below), so
|
|
8
|
+
* it works whether the panel is expanded, collapsed, or focused.
|
|
9
9
|
* - Per-question state (cursor position, scroll offset, type-something draft,
|
|
10
10
|
* multi-select picks) survives tab navigation — switching tabs never loses
|
|
11
11
|
* what you typed.
|
|
@@ -17,10 +17,18 @@
|
|
|
17
17
|
* field, the question renders in two equal columns (options | preview);
|
|
18
18
|
* otherwise it renders single-column full-width.
|
|
19
19
|
*
|
|
20
|
-
* Layout:
|
|
20
|
+
* Layout: renders into pi's bottom `editorContainer` slot (overlay:false, NOT a
|
|
21
|
+
* screen overlay). The chat transcript stays visible ABOVE the panel and is
|
|
22
|
+
* scrollable via the terminal's native scrollback (mouse wheel / Shift-PgUp /
|
|
23
|
+
* Cmd-↑). This works because pi's TUI never enters alt-screen and never tracks
|
|
24
|
+
* the mouse, so every rendered chat line lives in the terminal buffer and can
|
|
25
|
+
* be scrolled back at any time — the exact mechanism ctx.ui.select()/input()
|
|
26
|
+
* rely on. (overlay:true would route through ui.showOverlay(), compositing the
|
|
27
|
+
* panel over the whole screen and visually hiding the transcript — making it
|
|
28
|
+
* unscrollable, which was the original bug.) Collapses to one status row.
|
|
21
29
|
*/
|
|
22
30
|
|
|
23
|
-
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import type { ExtensionAPI, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
24
32
|
import {
|
|
25
33
|
type Component,
|
|
26
34
|
Editor,
|
|
@@ -44,13 +52,14 @@ const ICON_CHECK_EMPTY = "□"; // U+25A1
|
|
|
44
52
|
const ICON_CHECK_FILLED = "▣"; // U+25A3
|
|
45
53
|
const ICON_OTHER = "✎"; // pencil for "Type something."
|
|
46
54
|
const ICON_CURSOR = "▸"; // current cursor position, independent of selection
|
|
55
|
+
const ICON_NOTE = ICON_OTHER; // same ✎ pencil as custom answers — the note is also free-form user input
|
|
56
|
+
const ICON_ANSWER = "›"; // lead glyph on option-pick answers in the result card
|
|
47
57
|
|
|
48
58
|
// ────────────────────────────────────────────────────────────────────────────
|
|
49
59
|
// Types
|
|
50
60
|
// ────────────────────────────────────────────────────────────────────────────
|
|
51
61
|
|
|
52
62
|
interface QuestionOption {
|
|
53
|
-
value: string;
|
|
54
63
|
label: string;
|
|
55
64
|
description?: string;
|
|
56
65
|
/** Rich preview shown in the right column when this option is focused. */
|
|
@@ -62,37 +71,39 @@ interface RenderOption extends QuestionOption {
|
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
interface Question {
|
|
65
|
-
|
|
74
|
+
tab: string;
|
|
66
75
|
header: string;
|
|
67
76
|
prompt?: string;
|
|
68
77
|
options: QuestionOption[];
|
|
69
|
-
allowOther?: boolean;
|
|
70
78
|
multiSelect?: boolean;
|
|
71
79
|
allowSkip?: boolean;
|
|
72
80
|
}
|
|
73
81
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
82
|
+
/** A committed answer. Discriminated by `kind` so every state carries exactly
|
|
83
|
+
* the fields it needs — the compiler guarantees completeness, and the three
|
|
84
|
+
* display/serialization consumers all derive from `describeAnswer()` (single
|
|
85
|
+
* source of truth) so they can never drift apart.
|
|
86
|
+
*
|
|
87
|
+
* - `single`: the user picked one of the offered options.
|
|
88
|
+
* - `custom`: single-select, the user typed a custom answer ("Type something.").
|
|
89
|
+
* - `multi`: multi-select — `options` are the picked option labels; an
|
|
90
|
+
* optional `custom` carries any typed text alongside them. Pure-custom
|
|
91
|
+
* (no options checked) is `options: []` + `custom`; an empty commit
|
|
92
|
+
* (skippable, submitted with nothing) is `options: []` with no `custom`.
|
|
93
|
+
* - `skipped`: the user navigated past without answering (Tab/arrows).
|
|
94
|
+
*/
|
|
95
|
+
type Answer =
|
|
96
|
+
| { tab: string; kind: "single"; option: string }
|
|
97
|
+
| { tab: string; kind: "custom"; text: string }
|
|
98
|
+
| { tab: string; kind: "multi"; options: string[]; custom?: string }
|
|
99
|
+
| { tab: string; kind: "skipped" };
|
|
91
100
|
|
|
92
101
|
interface AskUserResult {
|
|
93
102
|
questions: Question[];
|
|
94
103
|
answers: Answer[];
|
|
95
104
|
cancelled: boolean;
|
|
105
|
+
/** Free-form note the user can attach on the review screen. Absent when empty. */
|
|
106
|
+
message?: string;
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
/** Per-tab ephemeral UI state. Preserved across tab switches. */
|
|
@@ -107,6 +118,8 @@ interface TabState {
|
|
|
107
118
|
editor: Editor;
|
|
108
119
|
/** Indices of committed options (multi-select). */
|
|
109
120
|
multiChecked: Set<number>;
|
|
121
|
+
/** Committed custom text for multi-select mode (kept alongside multiChecked, never overwriting it). Null if none. */
|
|
122
|
+
customText: string | null;
|
|
110
123
|
/** Committed single-select index (or -1 if none yet, -2 = answered via type-something). */
|
|
111
124
|
selectedSingle: number;
|
|
112
125
|
}
|
|
@@ -116,11 +129,6 @@ interface TabState {
|
|
|
116
129
|
// ────────────────────────────────────────────────────────────────────────────
|
|
117
130
|
|
|
118
131
|
const QuestionOptionSchema = Type.Object({
|
|
119
|
-
value: Type.Optional(
|
|
120
|
-
Type.String({
|
|
121
|
-
description: "Value returned when this option is chosen. Defaults to `label` if omitted.",
|
|
122
|
-
}),
|
|
123
|
-
),
|
|
124
132
|
label: Type.String({ description: "Short display label for the option (shown on the selection row)" }),
|
|
125
133
|
description: Type.Optional(
|
|
126
134
|
Type.String({
|
|
@@ -130,7 +138,7 @@ const QuestionOptionSchema = Type.Object({
|
|
|
130
138
|
preview: Type.Optional(
|
|
131
139
|
Type.String({
|
|
132
140
|
description:
|
|
133
|
-
"
|
|
141
|
+
"Use this when `description` (a short one-liner) is not enough and the user genuinely benefits from seeing more detail in a side column — e.g. an ASCII layout demo, a code skeleton, a Pro/Cons breakdown, or the reasoning behind why this option is offered and what choosing it entails. Rendered verbatim in a side column (spaces/newlines preserved). Do NOT treat preview as extra text capacity. Every line competes for the user's attention against the option list; only add a preview when the content is worth reading, not just because there's room for more words. If a short `description` already conveys the option, leave preview empty. Most options need only `description`.",
|
|
134
142
|
}),
|
|
135
143
|
),
|
|
136
144
|
});
|
|
@@ -139,17 +147,14 @@ const QuestionSchema = Type.Object({
|
|
|
139
147
|
header: Type.String({
|
|
140
148
|
description: "Short question title shown in the panel header, e.g. 'Which layout?'",
|
|
141
149
|
}),
|
|
142
|
-
|
|
143
|
-
description: "Short keyword
|
|
150
|
+
tab: Type.String({
|
|
151
|
+
description: "Short keyword that identifies this question. Shown on the tab bar when there are multiple questions, and returned in the result as the answer's prefix. Write it in the user's language (e.g. \"数据库\" or \"布局\" in a Chinese conversation, \"Database\" or \"Layout\" in English), not as a programmatic identifier like \"db_choice\". Must be unique across questions in one call." }),
|
|
144
152
|
prompt: Type.Optional(
|
|
145
153
|
Type.String({ description: "Optional longer body text shown under the header" }),
|
|
146
154
|
),
|
|
147
155
|
options: Type.Array(QuestionOptionSchema, {
|
|
148
156
|
description: "Available options. Pass 2-4; each needs a short `label` + a `description`, and a `preview` only when a description can't fully convey the option.",
|
|
149
157
|
}),
|
|
150
|
-
allowOther: Type.Optional(
|
|
151
|
-
Type.Boolean({ description: "Allow a 'Type something.' custom-input option (default: true)" }),
|
|
152
|
-
),
|
|
153
158
|
multiSelect: Type.Optional(
|
|
154
159
|
Type.Boolean({
|
|
155
160
|
description:
|
|
@@ -189,12 +194,10 @@ function wrapTab(index: number, total: number): number {
|
|
|
189
194
|
return ((index % total) + total) % total;
|
|
190
195
|
}
|
|
191
196
|
|
|
192
|
-
/** Build the full option list for a question, appending "Type something."
|
|
197
|
+
/** Build the full option list for a question, always appending the "Type something." custom-input row. */
|
|
193
198
|
function buildOptions(q: Question): RenderOption[] {
|
|
194
199
|
const opts: RenderOption[] = [...q.options];
|
|
195
|
-
|
|
196
|
-
opts.push({ value: "__other__", label: "Type something.", isOther: true });
|
|
197
|
-
}
|
|
200
|
+
opts.push({ label: "Type something.", isOther: true });
|
|
198
201
|
return opts;
|
|
199
202
|
}
|
|
200
203
|
|
|
@@ -216,7 +219,7 @@ function isDualColumn(q: Question | undefined): boolean {
|
|
|
216
219
|
function newTabState(tui: TuiLike, theme: EditorTheme, tabIndex: number, onSubmit: (tabIndex: number, value: string) => void): TabState {
|
|
217
220
|
const editor = new Editor(tui as never, theme);
|
|
218
221
|
editor.onSubmit = (value) => onSubmit(tabIndex, value);
|
|
219
|
-
return { cursor: 0, scrollOffset: 0, inputMode: false, editor, multiChecked: new Set(), selectedSingle: -1 };
|
|
222
|
+
return { cursor: 0, scrollOffset: 0, inputMode: false, editor, multiChecked: new Set(), customText: null, selectedSingle: -1 };
|
|
220
223
|
}
|
|
221
224
|
|
|
222
225
|
function errorResult(message: string, questions: Question[] = []): {
|
|
@@ -235,6 +238,49 @@ function padRight(s: string, width: number): string {
|
|
|
235
238
|
return v >= width ? s : s + " ".repeat(width - v);
|
|
236
239
|
}
|
|
237
240
|
|
|
241
|
+
/** Truncate to a visible width, appending “…” only when the text actually
|
|
242
|
+
* overflows. (truncateToWidth's third arg is a fill, not a suffix, so we
|
|
243
|
+
* reserve one column and append the ellipsis ourselves when needed.) */
|
|
244
|
+
function truncForDisplay(text: string, maxW: number): string {
|
|
245
|
+
if (maxW <= 0) return "";
|
|
246
|
+
if (maxW === 1) return "…";
|
|
247
|
+
const vw = visibleWidth(text);
|
|
248
|
+
if (vw <= maxW) return text;
|
|
249
|
+
return truncateToWidth(text, maxW - 1, "") + "…";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Structured interpretation of an Answer for display/serialization.
|
|
253
|
+
* Single source of truth — all three consumers (review screen's
|
|
254
|
+
* formatAnswerText, result card's formatAnswer, execute's JSON payload)
|
|
255
|
+
* derive from this, so they can never drift apart. */
|
|
256
|
+
interface AnswerView {
|
|
257
|
+
/** Human-readable text WITHOUT ANSI — e.g. "Sidebar" / "甲, 乙" /
|
|
258
|
+
* "✎ 自定义文本" / "(none)" / "(skipped)". Consumers wrap it in color. */
|
|
259
|
+
text: string;
|
|
260
|
+
/** Theme color name for the whole text. */
|
|
261
|
+
color: ThemeColor;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Interpret an Answer into display form. `customGlyph` (default "✎") prefixes
|
|
265
|
+
* any custom text. Returns `(no answer)` / dim for an absent answer. */
|
|
266
|
+
function describeAnswer(ans: Answer | undefined, customGlyph = ICON_OTHER): AnswerView {
|
|
267
|
+
if (!ans) return { text: "(no answer)", color: "dim" };
|
|
268
|
+
switch (ans.kind) {
|
|
269
|
+
case "skipped":
|
|
270
|
+
return { text: "(skipped)", color: "warning" };
|
|
271
|
+
case "multi": {
|
|
272
|
+
if (ans.options.length === 0 && !ans.custom) return { text: "(none)", color: "dim" };
|
|
273
|
+
const parts = [...ans.options];
|
|
274
|
+
if (ans.custom) parts.push(`${customGlyph} ${ans.custom}`);
|
|
275
|
+
return { text: parts.join(", "), color: "text" };
|
|
276
|
+
}
|
|
277
|
+
case "custom":
|
|
278
|
+
return { text: `${customGlyph} ${ans.text}`, color: "text" };
|
|
279
|
+
case "single":
|
|
280
|
+
return { text: ans.option, color: "text" };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
238
284
|
// ────────────────────────────────────────────────────────────────────────────
|
|
239
285
|
// The overlay component
|
|
240
286
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -245,7 +291,6 @@ interface TuiLike {
|
|
|
245
291
|
|
|
246
292
|
interface PanelCallbacks {
|
|
247
293
|
onResult: (result: AskUserResult) => void;
|
|
248
|
-
onCollapseChange: (collapsed: boolean) => void;
|
|
249
294
|
}
|
|
250
295
|
|
|
251
296
|
class AskUserPanel implements Component, Focusable {
|
|
@@ -263,17 +308,21 @@ class AskUserPanel implements Component, Focusable {
|
|
|
263
308
|
private tabs: TabState[];
|
|
264
309
|
/** Visible option rows (recomputed each render). */
|
|
265
310
|
private optionViewportH = 8;
|
|
266
|
-
/**
|
|
267
|
-
private reviewMode = false;
|
|
268
|
-
/** Cursor row in the review summary. */
|
|
311
|
+
/** Cursor row in the review summary (shown on the review tab). */
|
|
269
312
|
private reviewCursor = 0;
|
|
270
313
|
/** Vertical scroll offset for the review viewport. */
|
|
271
314
|
private reviewScrollOffset = 0;
|
|
272
315
|
/** Visible review rows (recomputed each render). */
|
|
273
316
|
private reviewViewportH = 8;
|
|
274
|
-
/** True
|
|
275
|
-
*
|
|
276
|
-
private
|
|
317
|
+
/** True while the user is editing the free-form "note to assistant" on the
|
|
318
|
+
* review tab. While true, all input goes to messageEditor. */
|
|
319
|
+
private messageEditing = false;
|
|
320
|
+
/** Committed note text (trimmed). Empty string = no note. Lives only on the
|
|
321
|
+
* review screen; the LLM cannot set it. */
|
|
322
|
+
private messageText = "";
|
|
323
|
+
/** Dedicated editor for the note. Single-line semantics: Enter saves (like
|
|
324
|
+
* the per-question "Type something." editor). */
|
|
325
|
+
private messageEditor: Editor;
|
|
277
326
|
|
|
278
327
|
// ── render cache ──
|
|
279
328
|
private cachedWidth?: number;
|
|
@@ -300,6 +349,11 @@ class AskUserPanel implements Component, Focusable {
|
|
|
300
349
|
// draft-loss bug (previously a single shared editor was swapped in/out and
|
|
301
350
|
// the swap was lossy across the input-mode / tab-switch boundary).
|
|
302
351
|
this.tabs = questions.map((_, i) => newTabState(tui, editorTheme, i, (ti, v) => this.handleSubmit(ti, v)));
|
|
352
|
+
// Dedicated editor for the review-screen note. Enter saves (single-line),
|
|
353
|
+
// Esc returns to the review without saving — mirroring the per-question
|
|
354
|
+
// "Type something." editor's semantics.
|
|
355
|
+
this.messageEditor = new Editor(tui as never, editorTheme);
|
|
356
|
+
this.messageEditor.onSubmit = (value) => this.handleMessageSubmit(value);
|
|
303
357
|
}
|
|
304
358
|
|
|
305
359
|
/** Shared submit logic bound to each tab's editor. */
|
|
@@ -309,27 +363,95 @@ class AskUserPanel implements Component, Focusable {
|
|
|
309
363
|
if (!q || !st) return;
|
|
310
364
|
const trimmed = value.trim();
|
|
311
365
|
if (!trimmed) {
|
|
312
|
-
// empty → back to options,
|
|
366
|
+
// empty → back to options. In multi-select mode, an empty submit also
|
|
367
|
+
// clears any previously committed custom text (blank = "remove my custom
|
|
368
|
+
// answer"), then re-commits the remaining checked options.
|
|
313
369
|
st.inputMode = false;
|
|
314
370
|
st.editor.setText("");
|
|
371
|
+
if (isMulti(q)) {
|
|
372
|
+
st.customText = null;
|
|
373
|
+
if (!this.commitMultiAnswer(q, st)) this.answers.delete(q.tab);
|
|
374
|
+
}
|
|
375
|
+
if (tabIndex === this.currentTab) this.invalidate();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (isMulti(q)) {
|
|
379
|
+
// Multi-select: the custom text is an extra entry kept ALONGSIDE the
|
|
380
|
+
// checked options — it must NOT overwrite them. (Previously this path
|
|
381
|
+
// did answers.set with only the custom text, dropping every check.)
|
|
382
|
+
// Committing custom text only records it — we return to the OPTION LIST
|
|
383
|
+
// (not advance) so the user can keep checking options and then press
|
|
384
|
+
// Enter on an option to confirm the whole question. Advancing here used
|
|
385
|
+
// to jump straight to review the moment the custom editor closed.
|
|
386
|
+
st.customText = trimmed;
|
|
387
|
+
this.commitMultiAnswer(q, st);
|
|
388
|
+
st.inputMode = false;
|
|
315
389
|
if (tabIndex === this.currentTab) this.invalidate();
|
|
316
390
|
return;
|
|
317
391
|
}
|
|
318
|
-
this.answers.set(q.
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
wasCustom: true,
|
|
392
|
+
this.answers.set(q.tab, {
|
|
393
|
+
tab: q.tab,
|
|
394
|
+
kind: "custom",
|
|
395
|
+
text: trimmed,
|
|
323
396
|
});
|
|
397
|
+
st.selectedSingle = -1; // clear any prior option pick — answer is now custom
|
|
324
398
|
st.inputMode = false;
|
|
325
|
-
st.selectedSingle = -2; // sentinel: "answered via type-something"
|
|
326
399
|
if (tabIndex === this.currentTab) {
|
|
327
400
|
this.advanceAfterAnswer();
|
|
328
401
|
}
|
|
329
402
|
}
|
|
330
403
|
|
|
404
|
+
/** Save the review-tab note: trim, store, return to the review tab.
|
|
405
|
+
* currentTab already points at the review tab (note editing is only
|
|
406
|
+
* entered from there), so we just clear the editing flag. Empty = no note. */
|
|
407
|
+
private handleMessageSubmit(value: string): void {
|
|
408
|
+
this.messageText = value.trim();
|
|
409
|
+
this.messageEditing = false;
|
|
410
|
+
this.invalidate();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Multi-select commit: merge the checked options (st.multiChecked) together
|
|
415
|
+
* with the committed custom text (st.customText) into one multi-select
|
|
416
|
+
* answer. Returns false when there is nothing to commit (no checks and no
|
|
417
|
+
* custom text), so the caller can delete the stale answer if desired.
|
|
418
|
+
*/
|
|
419
|
+
private commitMultiAnswer(q: Question, st: TabState): boolean {
|
|
420
|
+
const opts = buildOptions(q);
|
|
421
|
+
const picked = Array.from(st.multiChecked)
|
|
422
|
+
.sort((a, b) => a - b)
|
|
423
|
+
.map((i) => opts[i])
|
|
424
|
+
.filter((o): o is RenderOption => !!o && !o.isOther);
|
|
425
|
+
const labels = picked.map((o) => o.label);
|
|
426
|
+
const customText = st.customText;
|
|
427
|
+
// Empty commit = no option picks AND no custom text. (A skippable
|
|
428
|
+
// multi-select still records an explicit empty answer via the Enter
|
|
429
|
+
// path — see handleInput — so this function returning false just means
|
|
430
|
+
// "nothing to record here".)
|
|
431
|
+
if (labels.length === 0 && !customText) return false;
|
|
432
|
+
const ans: Answer = {
|
|
433
|
+
tab: q.tab,
|
|
434
|
+
kind: "multi",
|
|
435
|
+
options: labels,
|
|
436
|
+
};
|
|
437
|
+
if (customText) ans.custom = customText;
|
|
438
|
+
this.answers.set(q.tab, ans);
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
|
|
331
442
|
// ── accessors ──
|
|
332
443
|
|
|
444
|
+
/** Total number of tabs: one per question, plus the trailing review tab. */
|
|
445
|
+
private get totalTabs(): number {
|
|
446
|
+
return this.questions.length + 1;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The review tab sits at index === questions.length (the last tab).
|
|
450
|
+
* While true, the panel renders the review summary instead of a question. */
|
|
451
|
+
private get isReviewTab(): boolean {
|
|
452
|
+
return this.currentTab === this.questions.length;
|
|
453
|
+
}
|
|
454
|
+
|
|
333
455
|
private currentQuestion(): Question | undefined {
|
|
334
456
|
return this.questions[this.currentTab];
|
|
335
457
|
}
|
|
@@ -344,43 +466,47 @@ class AskUserPanel implements Component, Focusable {
|
|
|
344
466
|
}
|
|
345
467
|
|
|
346
468
|
private advanceAfterAnswer(): void {
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
return;
|
|
355
|
-
}
|
|
356
|
-
if (this.currentTab < this.questions.length - 1) {
|
|
357
|
-
this.switchTab(wrapTab(this.currentTab + 1, this.questions.length));
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
// Last question answered: enter review mode instead of submitting.
|
|
361
|
-
this.reviewMode = true;
|
|
362
|
-
this.reviewCursor = 0;
|
|
363
|
-
this.reviewScrollOffset = 0;
|
|
364
|
-
this.invalidate();
|
|
469
|
+
// Advance to the next tab. The review tab is the last tab, so answering
|
|
470
|
+
// the final question lands the user on the review tab (where Enter
|
|
471
|
+
// submits). Navigation is now uniform: review is just the next tab,
|
|
472
|
+
// reached by the same Tab/→ keys as any question — no special "enter
|
|
473
|
+
// review" step. Safe because this is only called from question tabs
|
|
474
|
+
// (currentTab < questions.length), so currentTab + 1 <= reviewTabIndex.
|
|
475
|
+
this.switchTab(this.currentTab + 1);
|
|
365
476
|
}
|
|
366
477
|
|
|
367
478
|
/**
|
|
368
|
-
* Called
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
479
|
+
* Called before navigating AWAY from a question tab (Tab/→/←/Shift+Tab).
|
|
480
|
+
* Resolves the current question's state so it can be left cleanly:
|
|
481
|
+
*
|
|
482
|
+
* - Already committed (answers.has): leave freely.
|
|
483
|
+
* - Multi-select with UNCOMMITTED checks (or a typed custom text): a check
|
|
484
|
+
* IS an answer — commit it first, then leave. Navigating away with
|
|
485
|
+
* pending checks must submit them (not skip, not block), regardless of
|
|
486
|
+
* allowSkip, because the user has already expressed a choice. (Single-
|
|
487
|
+
* select commits on Space, so it never has pending uncommitted state.)
|
|
488
|
+
* - Nothing selected at all:
|
|
489
|
+
* allowSkip true → record a skipped answer, allow leaving.
|
|
490
|
+
* allowSkip false → block (a required question must be answered).
|
|
491
|
+
*
|
|
492
|
+
* Returns true when navigation may proceed.
|
|
372
493
|
*/
|
|
373
|
-
private
|
|
494
|
+
private prepareQuestionForLeave(): boolean {
|
|
374
495
|
const q = this.currentQuestion();
|
|
375
496
|
if (!q) return true;
|
|
376
|
-
if (this.answers.has(q.
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
497
|
+
if (this.answers.has(q.tab)) return true;
|
|
498
|
+
const st = this.currentTabState();
|
|
499
|
+
// Multi-select: uncommitted checks count as an answer — commit them,
|
|
500
|
+
// then leave. commitMultiAnswer only returns false when there's nothing
|
|
501
|
+
// to commit (no checks, no custom), which the guard already rules out.
|
|
502
|
+
if (isMulti(q) && (st.multiChecked.size > 0 || !!st.customText)) {
|
|
503
|
+
this.commitMultiAnswer(q, st);
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
if (!canSkip(q)) return false; // required question, nothing chosen: block
|
|
507
|
+
this.answers.set(q.tab, {
|
|
508
|
+
tab: q.tab,
|
|
509
|
+
kind: "skipped",
|
|
384
510
|
});
|
|
385
511
|
return true;
|
|
386
512
|
}
|
|
@@ -390,20 +516,18 @@ class AskUserPanel implements Component, Focusable {
|
|
|
390
516
|
questions: this.questions,
|
|
391
517
|
answers: Array.from(this.answers.values()),
|
|
392
518
|
cancelled,
|
|
519
|
+
// Only attach the note when non-empty. A cancelled submit still carries
|
|
520
|
+
// the note if the user wrote one (it may explain why they cancelled).
|
|
521
|
+
message: this.messageText || undefined,
|
|
393
522
|
});
|
|
394
523
|
}
|
|
395
524
|
|
|
396
525
|
private setCollapsed(next: boolean): void {
|
|
397
526
|
if (this.collapsed === next) return;
|
|
398
527
|
this.collapsed = next;
|
|
399
|
-
this.cb.onCollapseChange(next);
|
|
400
528
|
this.invalidate();
|
|
401
529
|
}
|
|
402
530
|
|
|
403
|
-
expandFromShortcut(): void {
|
|
404
|
-
this.setCollapsed(false);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
531
|
private clampScrollToCursor(): void {
|
|
408
532
|
const opts = this.currentOptions();
|
|
409
533
|
if (opts.length === 0) return;
|
|
@@ -417,67 +541,76 @@ class AskUserPanel implements Component, Focusable {
|
|
|
417
541
|
// ── input ──
|
|
418
542
|
|
|
419
543
|
handleInput(data: string): void {
|
|
544
|
+
// 1. Note editor (messageEditing): owns all input while active. Esc
|
|
545
|
+
// returns to the review tab (currentTab already points there — note
|
|
546
|
+
// editing is only entered from the review tab).
|
|
547
|
+
if (this.messageEditing) {
|
|
548
|
+
if (matchesKey(data, Key.escape)) {
|
|
549
|
+
this.messageEditing = false;
|
|
550
|
+
this.invalidate();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
this.messageEditor.handleInput(data);
|
|
554
|
+
this.invalidate();
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// 2. Collapse toggle (global, any tab).
|
|
420
559
|
if (matchesKey(data, TOGGLE_KEY)) {
|
|
421
560
|
this.setCollapsed(!this.collapsed);
|
|
422
561
|
return;
|
|
423
562
|
}
|
|
424
563
|
|
|
564
|
+
// 3. Collapsed: only Esc (cancel) is meaningful.
|
|
425
565
|
if (this.collapsed) {
|
|
426
566
|
if (matchesKey(data, Key.escape)) this.submit(true);
|
|
427
567
|
return;
|
|
428
568
|
}
|
|
429
569
|
|
|
430
|
-
//
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
// "Type something." input mode: ALL editing keys (Tab, arrows, etc.) go to
|
|
438
|
-
// the editor. Only Esc (exit) is handled here. Tab is NOT hijacked for
|
|
439
|
-
// question switching — that would break indentation / cursor movement.
|
|
440
|
-
// To switch questions, press Esc first to return to the option list.
|
|
441
|
-
if (st.inputMode) {
|
|
570
|
+
// 4. Question tab + "Type something." input mode: the editor owns ALL
|
|
571
|
+
// editing keys (Tab, arrows, etc.). Tab is NOT hijacked for tab
|
|
572
|
+
// switching here, because that would break indentation / cursor
|
|
573
|
+
// movement. Esc exits back to the option list. The review tab has no
|
|
574
|
+
// input mode (it never edits options), so it skips this branch — the
|
|
575
|
+
// `!this.isReviewTab` short-circuit also avoids indexing tabs[] OOB.
|
|
576
|
+
if (!this.isReviewTab && this.currentTabState().inputMode) {
|
|
442
577
|
if (matchesKey(data, Key.escape)) {
|
|
578
|
+
const st = this.currentTabState();
|
|
443
579
|
st.inputMode = false;
|
|
444
580
|
// Keep the editor content (per-tab editor preserves it as draft).
|
|
445
581
|
this.invalidate();
|
|
446
582
|
return;
|
|
447
583
|
}
|
|
448
|
-
|
|
584
|
+
this.currentTabState().editor.handleInput(data);
|
|
449
585
|
this.invalidate();
|
|
450
586
|
return;
|
|
451
587
|
}
|
|
452
588
|
|
|
589
|
+
// 5. Esc = cancel submission (any tab, when not editing).
|
|
453
590
|
if (matchesKey(data, Key.escape)) {
|
|
454
591
|
this.submit(true);
|
|
455
592
|
return;
|
|
456
593
|
}
|
|
457
594
|
|
|
595
|
+
// 6. Shared tab navigation — Tab/→ forward, Shift+Tab/← backward.
|
|
596
|
+
// Runs on BOTH question tabs and the review tab, which is what makes
|
|
597
|
+
// the review reachable by the same keys as any question. The skip
|
|
598
|
+
// check only applies when LEAVING a question tab (never the review).
|
|
599
|
+
if (this.handleTabNavigation(data)) return;
|
|
600
|
+
|
|
601
|
+
// 7. Review tab: ↑↓ move · Space edit · Enter submit. (Esc + tab
|
|
602
|
+
// navigation were already handled above.)
|
|
603
|
+
if (this.isReviewTab) {
|
|
604
|
+
return this.handleReviewInput(data);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// 8. Question tab: ↑↓ move cursor · Space toggle/commit · Enter confirm.
|
|
608
|
+
const st = this.currentTabState();
|
|
458
609
|
const q = this.currentQuestion();
|
|
459
610
|
if (!q) return;
|
|
460
611
|
const opts = this.currentOptions();
|
|
461
612
|
const multi = isMulti(q);
|
|
462
613
|
|
|
463
|
-
// Tab navigation
|
|
464
|
-
if (this.questions.length > 1) {
|
|
465
|
-
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
|
466
|
-
// Forward navigation: a required (allowSkip:false) question cannot be
|
|
467
|
-
// left unanswered, so block + record-skip is gated on markSkippedIfNeeded.
|
|
468
|
-
if (!this.markSkippedIfNeeded()) return;
|
|
469
|
-
this.switchTab(wrapTab(this.currentTab + 1, this.questions.length));
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
|
473
|
-
// Backward navigation is always allowed — reviewing/editing earlier
|
|
474
|
-
// questions doesn't let the user bypass a required question (forward
|
|
475
|
-
// nav re-checks when they come back forward).
|
|
476
|
-
this.switchTab(wrapTab(this.currentTab - 1, this.questions.length));
|
|
477
|
-
return;
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
614
|
// Up / Down — moves ONLY the cursor (▸), does not change selection
|
|
482
615
|
if (matchesKey(data, Key.up)) {
|
|
483
616
|
if (st.cursor > 0) {
|
|
@@ -508,77 +641,84 @@ class AskUserPanel implements Component, Focusable {
|
|
|
508
641
|
return;
|
|
509
642
|
}
|
|
510
643
|
|
|
511
|
-
//
|
|
512
|
-
|
|
644
|
+
// Space — the "interact" key: select (single), toggle (multi), or EDIT
|
|
645
|
+
// (the "Type something." row). It never advances — that's Enter's job.
|
|
646
|
+
// This mirrors the review tab (where Space opens an entry for editing),
|
|
647
|
+
// so "the key that modifies things" is the same on every screen.
|
|
648
|
+
if (matchesKey(data, Key.space)) {
|
|
513
649
|
const opt = opts[st.cursor];
|
|
514
|
-
if (
|
|
515
|
-
|
|
516
|
-
|
|
650
|
+
if (!opt) return;
|
|
651
|
+
if (opt.isOther) {
|
|
652
|
+
// Enter edit mode for a custom answer. Prefill with any committed
|
|
653
|
+
// custom text so the user edits rather than retypes. Per-tab
|
|
654
|
+
// editor keeps the text for Esc-discard semantics automatically.
|
|
655
|
+
// - Single-select: custom text lives in the `custom` answer.
|
|
656
|
+
// - Multi-select: it lives in st.customText (kept alongside checks).
|
|
657
|
+
st.inputMode = true;
|
|
658
|
+
const existing = this.answers.get(q.tab);
|
|
659
|
+
const prefill = multi ? st.customText : existing?.kind === "custom" ? existing.text : null;
|
|
660
|
+
if (prefill) st.editor.setText(prefill);
|
|
517
661
|
this.invalidate();
|
|
662
|
+
return;
|
|
518
663
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
// Single-select: space commits the selection WITHOUT advancing (stay on question)
|
|
523
|
-
if (!multi && matchesKey(data, Key.space)) {
|
|
524
|
-
const opt = opts[st.cursor];
|
|
525
|
-
if (opt && !opt.isOther) {
|
|
526
|
-
st.selectedSingle = st.cursor;
|
|
527
|
-
this.answers.set(q.label, {
|
|
528
|
-
label: q.label,
|
|
529
|
-
value: opt.value,
|
|
530
|
-
answerLabel: opt.label,
|
|
531
|
-
wasCustom: false,
|
|
532
|
-
index: st.cursor,
|
|
533
|
-
});
|
|
664
|
+
if (multi) {
|
|
665
|
+
if (st.multiChecked.has(st.cursor)) st.multiChecked.delete(st.cursor);
|
|
666
|
+
else st.multiChecked.add(st.cursor);
|
|
534
667
|
this.invalidate();
|
|
668
|
+
return;
|
|
535
669
|
}
|
|
670
|
+
// single-select: mark the selection WITHOUT advancing (stay on question)
|
|
671
|
+
st.selectedSingle = st.cursor;
|
|
672
|
+
this.answers.set(q.tab, {
|
|
673
|
+
tab: q.tab,
|
|
674
|
+
kind: "single",
|
|
675
|
+
option: opt.label,
|
|
676
|
+
});
|
|
677
|
+
this.invalidate();
|
|
536
678
|
return;
|
|
537
679
|
}
|
|
538
680
|
|
|
539
|
-
//
|
|
681
|
+
// Enter — confirm + advance to the next tab. It does NOT enter edit mode
|
|
682
|
+
// (Space owns that now), keeping the two keys orthogonal: Space modifies,
|
|
683
|
+
// Enter commits. Single-select commits the cursor position and advances;
|
|
684
|
+
// multi-select commits the currently checked options as-is and advances
|
|
685
|
+
// (Space owns checking, so Enter no longer auto-checks the cursor option).
|
|
540
686
|
if (matchesKey(data, Key.enter)) {
|
|
541
687
|
const opt = opts[st.cursor];
|
|
542
688
|
if (!opt) return;
|
|
543
|
-
if (opt.isOther) {
|
|
544
|
-
|
|
545
|
-
//
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
const existing = this.answers.get(q.label);
|
|
549
|
-
if (existing?.wasCustom && existing.value) {
|
|
550
|
-
st.editor.setText(existing.value);
|
|
689
|
+
if (opt.isOther && !multi) {
|
|
690
|
+
// Single-select isOther: Enter never edits (Space owns that). Only
|
|
691
|
+
// advance if a custom answer was already committed; otherwise stay.
|
|
692
|
+
if (this.answers.get(q.tab)?.kind === "custom") {
|
|
693
|
+
this.advanceAfterAnswer();
|
|
551
694
|
}
|
|
552
|
-
this.invalidate();
|
|
553
695
|
return;
|
|
554
696
|
}
|
|
555
697
|
if (multi) {
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
this.
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
698
|
+
// Commit the current checks (+ any custom text) and advance. For a
|
|
699
|
+
// skippable multi-select, committing an EMPTY selection is still a
|
|
700
|
+
// commit — Enter means "submit (even if empty) and move on", not
|
|
701
|
+
// "skip" (Tab/arrows do skipping). So we record an explicit empty
|
|
702
|
+
// answer and advance; a required question (!canSkip) with an empty
|
|
703
|
+
// selection stays put, since it must have at least one pick.
|
|
704
|
+
if (this.commitMultiAnswer(q, st)) {
|
|
705
|
+
this.advanceAfterAnswer();
|
|
706
|
+
} else if (canSkip(q)) {
|
|
707
|
+
this.answers.set(q.tab, {
|
|
708
|
+
tab: q.tab,
|
|
709
|
+
kind: "multi",
|
|
710
|
+
options: [],
|
|
711
|
+
});
|
|
712
|
+
this.advanceAfterAnswer();
|
|
713
|
+
}
|
|
572
714
|
return;
|
|
573
715
|
}
|
|
574
|
-
// single-select: commit cursor position as the selection
|
|
716
|
+
// single-select: commit cursor position as the selection, then advance
|
|
575
717
|
st.selectedSingle = st.cursor;
|
|
576
|
-
this.answers.set(q.
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
wasCustom: false,
|
|
581
|
-
index: st.cursor,
|
|
718
|
+
this.answers.set(q.tab, {
|
|
719
|
+
tab: q.tab,
|
|
720
|
+
kind: "single",
|
|
721
|
+
option: opt.label,
|
|
582
722
|
});
|
|
583
723
|
this.advanceAfterAnswer();
|
|
584
724
|
return;
|
|
@@ -593,31 +733,62 @@ class AskUserPanel implements Component, Focusable {
|
|
|
593
733
|
this.invalidate();
|
|
594
734
|
}
|
|
595
735
|
|
|
596
|
-
/**
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
736
|
+
/** Shared tab navigation, invoked from handleInput for BOTH question tabs
|
|
737
|
+
* and the review tab. Returns true when the key was consumed.
|
|
738
|
+
*
|
|
739
|
+
* - Tab / → : forward. Tab WRAPS through every tab (questions → review →
|
|
740
|
+
* first question); → STOPS at the review tab (boundary).
|
|
741
|
+
* - Shift+Tab / ← : backward. Shift+Tab wraps; ← stops at the first
|
|
742
|
+
* question.
|
|
743
|
+
*
|
|
744
|
+
* Leaving a question tab may need to commit pending multi-select checks
|
|
745
|
+
* or record a skip (when it's unanswered and required) — that's handled
|
|
746
|
+
* by prepareQuestionForLeave. Leaving the review tab never needs that
|
|
747
|
+
* check (it isn't a question), so →/Tab work freely from review. */
|
|
748
|
+
private handleTabNavigation(data: string): boolean {
|
|
749
|
+
if (this.totalTabs <= 1) return false;
|
|
750
|
+
// Forward
|
|
751
|
+
if (matchesKey(data, Key.tab)) {
|
|
752
|
+
if (!this.isReviewTab && !this.prepareQuestionForLeave()) return true; // required: blocked
|
|
753
|
+
this.switchTab(wrapTab(this.currentTab + 1, this.totalTabs));
|
|
754
|
+
return true;
|
|
602
755
|
}
|
|
603
|
-
if (matchesKey(data, Key.
|
|
604
|
-
//
|
|
605
|
-
this.
|
|
606
|
-
|
|
756
|
+
if (matchesKey(data, Key.right)) {
|
|
757
|
+
if (!this.isReviewTab && !this.prepareQuestionForLeave()) return true; // required: blocked
|
|
758
|
+
if (this.currentTab + 1 >= this.totalTabs) return true; // stop at review
|
|
759
|
+
this.switchTab(this.currentTab + 1);
|
|
760
|
+
return true;
|
|
607
761
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
this.
|
|
611
|
-
|
|
612
|
-
this.switchTab(this.reviewCursor);
|
|
613
|
-
return;
|
|
762
|
+
// Backward
|
|
763
|
+
if (matchesKey(data, Key.shift("tab"))) {
|
|
764
|
+
this.switchTab(wrapTab(this.currentTab - 1, this.totalTabs));
|
|
765
|
+
return true;
|
|
614
766
|
}
|
|
767
|
+
if (matchesKey(data, Key.left)) {
|
|
768
|
+
if (this.currentTab - 1 < 0) return true; // stop at first question
|
|
769
|
+
this.switchTab(this.currentTab - 1);
|
|
770
|
+
return true;
|
|
771
|
+
}
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Handle input specific to the review tab. Esc and tab navigation
|
|
776
|
+
* (Tab/←/→) are already handled upstream in handleInput, so here we only
|
|
777
|
+
* deal with: ↑↓/PgUp/PgDn (move the review cursor), Space (open the entry
|
|
778
|
+
* under the cursor for editing), and Enter (submit the whole review).
|
|
779
|
+
*
|
|
780
|
+
* The review list has N question entries plus one trailing "note to
|
|
781
|
+
* assistant" entry (index N), so the cursor ranges over [0, N]. */
|
|
782
|
+
private handleReviewInput(data: string): void {
|
|
783
|
+
const n = this.questions.length;
|
|
784
|
+
const total = n + 1; // include the note entry
|
|
785
|
+
// ↑/↓/PgUp/PgDn — move the review cursor over [0, total-1]
|
|
615
786
|
if (matchesKey(data, Key.up)) {
|
|
616
787
|
if (this.reviewCursor > 0) { this.reviewCursor--; this.invalidate(); }
|
|
617
788
|
return;
|
|
618
789
|
}
|
|
619
790
|
if (matchesKey(data, Key.down)) {
|
|
620
|
-
if (this.reviewCursor <
|
|
791
|
+
if (this.reviewCursor < total - 1) { this.reviewCursor++; this.invalidate(); }
|
|
621
792
|
return;
|
|
622
793
|
}
|
|
623
794
|
if (matchesKey(data, Key.pageUp)) {
|
|
@@ -626,10 +797,33 @@ class AskUserPanel implements Component, Focusable {
|
|
|
626
797
|
return;
|
|
627
798
|
}
|
|
628
799
|
if (matchesKey(data, Key.pageDown)) {
|
|
629
|
-
this.reviewCursor = Math.min(
|
|
800
|
+
this.reviewCursor = Math.min(total - 1, this.reviewCursor + Math.max(1, this.reviewViewportH));
|
|
630
801
|
this.invalidate();
|
|
631
802
|
return;
|
|
632
803
|
}
|
|
804
|
+
// Space — "select" the entry under the cursor: jump into editing it.
|
|
805
|
+
// (Mirrors the option screens, where Space = select/toggle.)
|
|
806
|
+
if (matchesKey(data, Key.space)) {
|
|
807
|
+
if (this.reviewCursor === n) {
|
|
808
|
+
// Note entry: open the note editor. Prefill with the committed note
|
|
809
|
+
// (if any) so the user can tweak rather than retype.
|
|
810
|
+
this.messageEditing = true;
|
|
811
|
+
if (this.messageText) this.messageEditor.setText(this.messageText);
|
|
812
|
+
this.invalidate();
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
// Question entry: switch to that question's tab for editing.
|
|
816
|
+
// switchTab early-returns when next === currentTab, which is fine — that
|
|
817
|
+
// only happens on a single-question call where we're already on the
|
|
818
|
+
// question; nothing to redraw.
|
|
819
|
+
this.switchTab(this.reviewCursor);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
// Enter — submit the whole review, no matter where the cursor sits.
|
|
823
|
+
if (matchesKey(data, Key.enter)) {
|
|
824
|
+
this.submit(false);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
633
827
|
}
|
|
634
828
|
|
|
635
829
|
// ── render ──
|
|
@@ -650,48 +844,69 @@ class AskUserPanel implements Component, Focusable {
|
|
|
650
844
|
|
|
651
845
|
private renderCollapsed(width: number): string[] {
|
|
652
846
|
const th = this.theme;
|
|
653
|
-
const
|
|
654
|
-
.
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
})
|
|
659
|
-
|
|
847
|
+
const qParts = this.questions.map((q, i) => {
|
|
848
|
+
const done = this.answers.has(q.tab);
|
|
849
|
+
const active = i === this.currentTab && !this.isReviewTab;
|
|
850
|
+
const mark = active ? "▸" : done ? "✓" : "○";
|
|
851
|
+
const color = active ? "accent" : done ? "success" : "dim";
|
|
852
|
+
return th.fg(color, `${q.tab}${mark}`);
|
|
853
|
+
});
|
|
854
|
+
const reviewPart = this.isReviewTab
|
|
855
|
+
? th.fg("accent", "Review▸")
|
|
856
|
+
: th.fg("dim", "Review○");
|
|
857
|
+
const tabsPart = [...qParts, reviewPart].join(th.fg("dim", " "));
|
|
660
858
|
const inner = `${tabsPart} ${th.fg("dim", ` ${TOGGLE_HINT} expand `)}${th.fg("dim", " Esc cancel ")}`;
|
|
661
859
|
const line =
|
|
662
860
|
th.fg("border", "│") + inner + " ".repeat(Math.max(0, width - 2 - visibleWidth(inner))) + th.fg("border", "│");
|
|
663
861
|
return [truncateToWidth(line, width)];
|
|
664
862
|
}
|
|
665
863
|
|
|
864
|
+
/** Build the tab-bar content line (without border wrapping). Shared by the
|
|
865
|
+
* question screen and the review screen so the bar is always visible —
|
|
866
|
+
* the review tab is a real tab, so it must highlight when active just like
|
|
867
|
+
* any question. Callers wrap the returned string in their own row() so the
|
|
868
|
+
* border color matches the surrounding screen. */
|
|
869
|
+
private renderTabBarContent(th: Theme): string {
|
|
870
|
+
const tabCells = this.questions.map((q, i) => {
|
|
871
|
+
const active = i === this.currentTab;
|
|
872
|
+
const ans = this.answers.get(q.tab);
|
|
873
|
+
let mark = " ";
|
|
874
|
+
let baseColor: import("@earendil-works/pi-coding-agent").ThemeColor = active ? "accent" : "muted";
|
|
875
|
+
if (ans?.kind === "skipped") { mark = "—"; baseColor = "warning"; }
|
|
876
|
+
else if (ans) { mark = "✓"; baseColor = "success"; }
|
|
877
|
+
else if (active) mark = "▸";
|
|
878
|
+
const color = active ? "accent" : baseColor;
|
|
879
|
+
return th.fg(color, `${mark} ${q.tab}`);
|
|
880
|
+
});
|
|
881
|
+
const reviewActive = this.isReviewTab;
|
|
882
|
+
const reviewMark = reviewActive ? "▸" : " ";
|
|
883
|
+
const reviewColor: import("@earendil-works/pi-coding-agent").ThemeColor = reviewActive ? "accent" : "muted";
|
|
884
|
+
const reviewCell = th.fg(reviewColor, `${reviewMark} [ Review ]`);
|
|
885
|
+
const sep = th.fg("dim", " │");
|
|
886
|
+
return ` ${tabCells.join(th.fg("dim", " "))}${sep}${reviewCell}`;
|
|
887
|
+
}
|
|
888
|
+
|
|
666
889
|
private renderExpanded(width: number): string[] {
|
|
667
890
|
const th = this.theme;
|
|
668
891
|
const innerW = Math.max(20, width - 2);
|
|
669
892
|
const lines: string[] = [];
|
|
670
893
|
const row = (content: string) => th.fg("border", "│") + padRight(content, innerW) + th.fg("border", "│");
|
|
671
894
|
|
|
672
|
-
if (this.
|
|
673
|
-
return this.
|
|
895
|
+
if (this.messageEditing) {
|
|
896
|
+
return this.renderMessageEditor(width, innerW, th);
|
|
897
|
+
}
|
|
898
|
+
if (this.isReviewTab) {
|
|
899
|
+
return this.renderReview(width, innerW, th);
|
|
674
900
|
}
|
|
675
901
|
|
|
676
902
|
lines.push(th.fg("border", `╭${"─".repeat(innerW)}╮`));
|
|
677
903
|
|
|
678
904
|
// ── Tab bar ──
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
let mark = " ";
|
|
685
|
-
let baseColor: import("@earendil-works/pi-coding-agent").ThemeColor = active ? "accent" : "muted";
|
|
686
|
-
if (ans?.skipped) { mark = "—"; baseColor = "warning"; }
|
|
687
|
-
else if (ans) { mark = "✓"; baseColor = "success"; }
|
|
688
|
-
else if (active) mark = "▸";
|
|
689
|
-
const color = active ? "accent" : baseColor;
|
|
690
|
-
return th.fg(color, `${mark} ${label}`);
|
|
691
|
-
});
|
|
692
|
-
lines.push(row(` ${tabCells.join(th.fg("dim", " "))}`));
|
|
693
|
-
lines.push(row(""));
|
|
694
|
-
}
|
|
905
|
+
// Always shown: there is always at least one question tab plus the
|
|
906
|
+
// trailing review tab. renderTabBarContent is shared with the review
|
|
907
|
+
// screen so the active tab stays visible across every screen.
|
|
908
|
+
lines.push(row(this.renderTabBarContent(th)));
|
|
909
|
+
lines.push(row(""));
|
|
695
910
|
|
|
696
911
|
// ── Question header ──
|
|
697
912
|
const q = this.currentQuestion();
|
|
@@ -709,8 +924,10 @@ class AskUserPanel implements Component, Focusable {
|
|
|
709
924
|
// ── Prompt body ──
|
|
710
925
|
if (q?.prompt) {
|
|
711
926
|
for (const w of wrapTextWithAnsi(th.fg("muted", q.prompt), innerW - 2)) lines.push(row(` ${w}`));
|
|
712
|
-
lines.push(row(""));
|
|
713
927
|
}
|
|
928
|
+
// 空行分隔 header/prompt 与 options。原来只在有 prompt 时才加,导致无
|
|
929
|
+
// prompt 的问题其标题与选项紧贴("粘在一起")。现在无条件加。
|
|
930
|
+
lines.push(row(""));
|
|
714
931
|
|
|
715
932
|
// ── Body: options / preview / input editor ──
|
|
716
933
|
const st = this.currentTabState();
|
|
@@ -730,7 +947,7 @@ class AskUserPanel implements Component, Focusable {
|
|
|
730
947
|
this.questions.length > 1 ? th.fg("dim", ` ${doneCount}/${this.questions.length} answered · `) : th.fg("dim", " ");
|
|
731
948
|
const hint = multi
|
|
732
949
|
? `${TOGGLE_HINT} collapse · ↑↓ move · Space toggle · Enter confirm · Esc cancel`
|
|
733
|
-
: `${TOGGLE_HINT} collapse · ↑↓ move · Enter confirm · Esc cancel`;
|
|
950
|
+
: `${TOGGLE_HINT} collapse · ↑↓ move · Space select · Enter confirm · Esc cancel`;
|
|
734
951
|
lines.push(row(`${left}${th.fg("dim", hint)}`));
|
|
735
952
|
lines.push(th.fg("border", `╰${"─".repeat(innerW)}╯`));
|
|
736
953
|
return lines;
|
|
@@ -753,24 +970,19 @@ class AskUserPanel implements Component, Focusable {
|
|
|
753
970
|
return filled ? th.fg("success", ICON_RADIO_FILLED) : th.fg("dim", ICON_RADIO_EMPTY);
|
|
754
971
|
}
|
|
755
972
|
|
|
756
|
-
/** Format an answer for the review summary
|
|
973
|
+
/** Format an answer for the review summary. Delegates to describeAnswer
|
|
974
|
+
* (single source of truth), then wraps in the theme color + truncates. */
|
|
757
975
|
private formatAnswerText(ans: Answer | undefined, maxW: number, th: Theme): string {
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
if (ans.multiSelect) text = (ans.answerLabels?.length ? ans.answerLabels : ans.values ?? []).join(", ");
|
|
762
|
-
else text = ans.wasCustom ? ans.value : (ans.answerLabel || ans.value);
|
|
763
|
-
const vw = visibleWidth(text);
|
|
764
|
-
if (vw <= maxW) return th.fg("text", text);
|
|
765
|
-
// truncate: keep prefix, append “…”
|
|
766
|
-
const cut = truncateToWidth(text, maxW - 1, "");
|
|
767
|
-
return th.fg("text", cut) + th.fg("dim", "…");
|
|
976
|
+
const view = describeAnswer(ans);
|
|
977
|
+
const text = truncForDisplay(view.text, maxW);
|
|
978
|
+
return th.fg(view.color, text);
|
|
768
979
|
}
|
|
769
980
|
|
|
770
|
-
/** Clamp the review scroll offset so the cursor stays visible.
|
|
981
|
+
/** Clamp the review scroll offset so the cursor stays visible. The review
|
|
982
|
+
* list has N questions + 1 note entry, so the cursor may equal N. */
|
|
771
983
|
private clampReviewScroll(): void {
|
|
772
|
-
const
|
|
773
|
-
if (
|
|
984
|
+
const total = this.questions.length + 1;
|
|
985
|
+
if (total === 0) return;
|
|
774
986
|
const viewH = this.reviewViewportH;
|
|
775
987
|
if (this.reviewCursor < this.reviewScrollOffset) this.reviewScrollOffset = this.reviewCursor;
|
|
776
988
|
else if (this.reviewCursor >= this.reviewScrollOffset + viewH)
|
|
@@ -778,39 +990,94 @@ class AskUserPanel implements Component, Focusable {
|
|
|
778
990
|
if (this.reviewScrollOffset < 0) this.reviewScrollOffset = 0;
|
|
779
991
|
}
|
|
780
992
|
|
|
781
|
-
/** Review summary: one question per entry
|
|
782
|
-
*
|
|
783
|
-
|
|
993
|
+
/** Review summary: one question per entry (header + answer), plus a trailing
|
|
994
|
+
* "note to assistant" entry. Viewport scrolling reuses the option-screen
|
|
995
|
+
* layout primitives. */
|
|
996
|
+
private renderReview(width: number, innerW: number, th: Theme): string[] {
|
|
784
997
|
const lines: string[] = [];
|
|
785
|
-
|
|
998
|
+
// Review uses a distinct border color (success/green) so it's visually
|
|
999
|
+
// unmistakable as the review/confirm screen — not another question. The
|
|
1000
|
+
// question screen keeps the default "border" color.
|
|
1001
|
+
const bc: import("@earendil-works/pi-coding-agent").ThemeColor = "success";
|
|
1002
|
+
const row = (content: string) => th.fg(bc, "│") + padRight(content, innerW) + th.fg(bc, "│");
|
|
1003
|
+
lines.push(th.fg(bc, `╭${"─".repeat(innerW)}╮`));
|
|
1004
|
+
lines.push(row(this.renderTabBarContent(th)));
|
|
1005
|
+
lines.push(row(""));
|
|
786
1006
|
lines.push(row(` ${th.fg("accent", th.bold("Review your answers"))}`));
|
|
787
|
-
lines.push(th.fg(
|
|
1007
|
+
lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
|
|
788
1008
|
const n = this.questions.length;
|
|
789
|
-
|
|
1009
|
+
const total = n + 1; // +1 for the note entry
|
|
1010
|
+
// Body indent (6 cols): questions and the note carry a 2-visible-col marker
|
|
1011
|
+
// (`1.` / `2.` … or `✎ ` for the note) right after the cursor, plus a
|
|
1012
|
+
// separator space, so every title starts at the same column. The body is
|
|
1013
|
+
// indented one past that so header vs content stay visually distinct —
|
|
1014
|
+
// previously the body sat at 5 cols and the note's icon pushed its title
|
|
1015
|
+
// out of alignment with the question titles.
|
|
1016
|
+
const bodyIndent = " "; // 6 spaces
|
|
1017
|
+
const maxW = innerW - 2 - bodyIndent.length;
|
|
1018
|
+
this.reviewViewportH = Math.max(3, Math.min(total, 10));
|
|
790
1019
|
this.clampReviewScroll();
|
|
791
1020
|
const start = this.reviewScrollOffset;
|
|
792
|
-
const end = Math.min(
|
|
1021
|
+
const end = Math.min(total, start + this.reviewViewportH);
|
|
793
1022
|
for (let i = start; i < end; i++) {
|
|
794
|
-
const q = this.questions[i]!;
|
|
795
1023
|
const isCursor = i === this.reviewCursor;
|
|
796
|
-
const ans = this.answers.get(q.label);
|
|
797
|
-
// Header row: cursor + label, same coloring as the option screen.
|
|
798
1024
|
const prefix = isCursor ? `${th.fg("accent", ICON_CURSOR)} ` : " ";
|
|
799
|
-
const headerColor = isCursor ? "accent" : "muted";
|
|
800
|
-
|
|
1025
|
+
const headerColor: import("@earendil-works/pi-coding-agent").ThemeColor = isCursor ? "accent" : "muted";
|
|
1026
|
+
// marker: a fixed 2-visible-col slot + 1 separator space, so every title
|
|
1027
|
+
// (questions + note) aligns regardless of icon width. `1.` is 2 cols;
|
|
1028
|
+
// the note's ✎ is 1 col, padded to `✎ ` (hence one extra space between
|
|
1029
|
+
// ✎ and its title — the deliberate tradeoff of this layout).
|
|
1030
|
+
// ── Note entry (index n): always last, two rows like a question. ──
|
|
1031
|
+
if (i === n) {
|
|
1032
|
+
// 空行分隔:note 是异类条目(附加留言,非问答),用空行和上方
|
|
1033
|
+
// 问答列表隔开。保持简单,不用点线/装饰。
|
|
1034
|
+
lines.push(row(""));
|
|
1035
|
+
const marker = th.fg(headerColor, `${ICON_OTHER} `);
|
|
1036
|
+
lines.push(row(` ${prefix}${marker} ${th.fg(headerColor, "Note to assistant")}`));
|
|
1037
|
+
const msg = this.messageText;
|
|
1038
|
+
if (msg) {
|
|
1039
|
+
const vw = visibleWidth(msg);
|
|
1040
|
+
const body = vw <= maxW ? msg : `${truncateToWidth(msg, maxW - 1, "")}…`;
|
|
1041
|
+
lines.push(row(`${bodyIndent}${th.fg("text", body)}`));
|
|
1042
|
+
} else {
|
|
1043
|
+
lines.push(row(`${bodyIndent}${th.fg("dim", "(optional — Space to add a note)")}`));
|
|
1044
|
+
}
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
const q = this.questions[i]!;
|
|
1048
|
+
const ans = this.answers.get(q.tab);
|
|
1049
|
+
// Header row: cursor + marker + title.
|
|
1050
|
+
const marker = th.fg(headerColor, `${i + 1}.`);
|
|
1051
|
+
lines.push(row(` ${prefix}${marker} ${th.fg(headerColor, q.header)}`));
|
|
801
1052
|
// Answer row: reuse the description renderer's indent/wrap, fed the
|
|
802
1053
|
// formatted answer text. Skipped/custom/multi-select all flow through
|
|
803
1054
|
// formatAnswerText, so the coloring matches the option screen.
|
|
804
|
-
const maxW = innerW - 2 - " ".length;
|
|
805
1055
|
const ansText = this.formatAnswerText(ans, maxW, th);
|
|
806
|
-
lines.push(row(
|
|
1056
|
+
lines.push(row(`${bodyIndent}${ansText}`));
|
|
807
1057
|
}
|
|
808
|
-
if (
|
|
809
|
-
lines.push(row(th.fg("dim",
|
|
1058
|
+
if (total > this.reviewViewportH) {
|
|
1059
|
+
lines.push(row(th.fg("dim", `${bodyIndent}↑↓/PgUp/PgDn scroll · ${start + 1}-${end}/${total}`)));
|
|
810
1060
|
}
|
|
811
|
-
lines.push(th.fg(
|
|
812
|
-
lines.push(row(th.fg("dim", " ↑↓
|
|
813
|
-
lines.push(th.fg(
|
|
1061
|
+
lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
|
|
1062
|
+
lines.push(row(th.fg("dim", " ↑↓ move · Space edit · Enter confirm · Esc cancel")));
|
|
1063
|
+
lines.push(th.fg(bc, `╰${"─".repeat(innerW)}╯`));
|
|
1064
|
+
return lines;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/** Note editor screen: reached from the review's note entry via Space. Uses
|
|
1068
|
+
* the same success-bordered look as the review screen to signal it's part
|
|
1069
|
+
* of the review flow, not a fresh question. */
|
|
1070
|
+
private renderMessageEditor(width: number, innerW: number, th: Theme): string[] {
|
|
1071
|
+
const lines: string[] = [];
|
|
1072
|
+
const bc: import("@earendil-works/pi-coding-agent").ThemeColor = "success";
|
|
1073
|
+
const row = (content: string) => th.fg(bc, "│") + padRight(content, innerW) + th.fg(bc, "│");
|
|
1074
|
+
lines.push(th.fg(bc, `╭${"─".repeat(innerW)}╮`));
|
|
1075
|
+
lines.push(row(` ${th.fg("accent", th.bold(`${ICON_OTHER} Note to assistant`))}`));
|
|
1076
|
+
lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
|
|
1077
|
+
for (const el of this.messageEditor.render(innerW - 2)) lines.push(row(` ${el}`));
|
|
1078
|
+
lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
|
|
1079
|
+
lines.push(row(th.fg("dim", " Esc back to review · Enter save note")));
|
|
1080
|
+
lines.push(th.fg(bc, `╰${"─".repeat(innerW)}╯`));
|
|
814
1081
|
return lines;
|
|
815
1082
|
}
|
|
816
1083
|
|
|
@@ -829,10 +1096,12 @@ class AskUserPanel implements Component, Focusable {
|
|
|
829
1096
|
const opt = opts[i]!;
|
|
830
1097
|
const isCursor = i === st.cursor;
|
|
831
1098
|
const prefix = isCursor ? `${th.fg("accent", ICON_CURSOR)} ` : " ";
|
|
832
|
-
const
|
|
1099
|
+
const ans = this.answers.get(q.tab);
|
|
1100
|
+
const committedCustom = multi ? st.customText : ans?.kind === "custom" ? ans.text : null;
|
|
1101
|
+
const customAnswered = !!committedCustom;
|
|
833
1102
|
const glyph = this.optionGlyph(opt, i, st, multi, th, isCursor, customAnswered);
|
|
834
1103
|
// For "Type something.", show the committed text instead of the placeholder.
|
|
835
|
-
const displayLabel = opt.isOther && customAnswered ?
|
|
1104
|
+
const displayLabel = opt.isOther && customAnswered ? committedCustom! : opt.label;
|
|
836
1105
|
const labelColor = isCursor ? "accent" : opt.isOther ? (customAnswered ? "text" : "dim") : "text";
|
|
837
1106
|
const labelText = th.fg(labelColor, displayLabel);
|
|
838
1107
|
out.push(row(` ${prefix}${glyph} ${labelText}`));
|
|
@@ -871,13 +1140,15 @@ class AskUserPanel implements Component, Focusable {
|
|
|
871
1140
|
|
|
872
1141
|
// ── build left column lines (options) ──
|
|
873
1142
|
const leftLines: string[] = [];
|
|
874
|
-
const
|
|
1143
|
+
const dualAns = this.answers.get(q.tab);
|
|
1144
|
+
const dualCommittedCustom = multi ? st.customText : dualAns?.kind === "custom" ? dualAns.text : null;
|
|
1145
|
+
const customAnswered = !!dualCommittedCustom;
|
|
875
1146
|
for (let i = start; i < end; i++) {
|
|
876
1147
|
const opt = opts[i]!;
|
|
877
1148
|
const isCursor = i === st.cursor;
|
|
878
1149
|
const prefix = isCursor ? `${th.fg("accent", ICON_CURSOR)} ` : " ";
|
|
879
1150
|
const glyph = this.optionGlyph(opt, i, st, multi, th, isCursor, customAnswered);
|
|
880
|
-
const displayLabel = opt.isOther && customAnswered ?
|
|
1151
|
+
const displayLabel = opt.isOther && customAnswered ? dualCommittedCustom! : opt.label;
|
|
881
1152
|
const labelColor = isCursor ? "accent" : opt.isOther ? (customAnswered ? "text" : "dim") : "text";
|
|
882
1153
|
const labelLine = `${prefix}${glyph} ${th.fg(labelColor, displayLabel)}`;
|
|
883
1154
|
leftLines.push(truncateToWidth(labelLine, leftW - 1, ""));
|
|
@@ -937,24 +1208,155 @@ class AskUserPanel implements Component, Focusable {
|
|
|
937
1208
|
}
|
|
938
1209
|
|
|
939
1210
|
// ────────────────────────────────────────────────────────────────────────────
|
|
940
|
-
//
|
|
1211
|
+
// Result view — static card shown in the message stream after ask_user.
|
|
1212
|
+
// Lets the user verify at a glance what they chose. Two states driven by
|
|
1213
|
+
// options.expanded: collapsed = one-line summary, expanded = bordered card.
|
|
1214
|
+
// Reuses AskUserPanel's visual language (same border glyphs, theme colors,
|
|
1215
|
+
// ✓/⊘/○ status icons, ✎ for custom) so it reads as a continuation of the
|
|
1216
|
+
// interaction, not a foreign element.
|
|
941
1217
|
// ────────────────────────────────────────────────────────────────────────────
|
|
942
1218
|
|
|
943
|
-
|
|
944
|
-
|
|
1219
|
+
class AskUserResultView implements Component {
|
|
1220
|
+
private questions: ReadonlyArray<Pick<Question, "header" | "tab">>;
|
|
1221
|
+
private result: AskUserResult;
|
|
1222
|
+
private theme: Theme;
|
|
1223
|
+
private expanded = false;
|
|
1224
|
+
private cachedWidth?: number;
|
|
1225
|
+
private cachedLines?: string[];
|
|
945
1226
|
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1227
|
+
constructor(questions: ReadonlyArray<Pick<Question, "header" | "tab">>, result: AskUserResult, theme: Theme) {
|
|
1228
|
+
this.questions = questions;
|
|
1229
|
+
this.result = result;
|
|
1230
|
+
this.theme = theme;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
setExpanded(expanded: boolean): void {
|
|
1234
|
+
if (this.expanded !== expanded) {
|
|
1235
|
+
this.expanded = expanded;
|
|
1236
|
+
this.cachedWidth = undefined;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
invalidate(): void {
|
|
1241
|
+
this.cachedWidth = undefined;
|
|
1242
|
+
this.cachedLines = undefined;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
render(width: number): string[] {
|
|
1246
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
1247
|
+
this.cachedWidth = width;
|
|
1248
|
+
this.cachedLines = this.expanded ? this.renderCard(width) : this.renderCollapsed(width);
|
|
1249
|
+
return this.cachedLines;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/** Overall status → icon, color, and a short status phrase (plain text,
|
|
1253
|
+
* no ANSI — callers wrap it in color). */
|
|
1254
|
+
private getStatus(): { icon: string; color: ThemeColor; phrase: string } {
|
|
1255
|
+
const total = this.questions.length;
|
|
1256
|
+
if (this.result.cancelled) {
|
|
1257
|
+
return {
|
|
1258
|
+
icon: "⊘",
|
|
1259
|
+
color: "warning",
|
|
1260
|
+
phrase: `Cancelled · ${this.result.answers.length}/${total} answered`,
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
const anySkipped = this.result.answers.some((a) => a.kind === "skipped");
|
|
1264
|
+
return anySkipped
|
|
1265
|
+
? { icon: "○", color: "accent", phrase: "Answers (some skipped)" }
|
|
1266
|
+
: { icon: "✓", color: "success", phrase: "Answers submitted" };
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/** Format one answer for the card display. Delegates to describeAnswer. */
|
|
1270
|
+
private formatAnswer(ans: Answer | undefined): { text: string; color: ThemeColor } {
|
|
1271
|
+
return describeAnswer(ans);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
private renderCollapsed(width: number): string[] {
|
|
1275
|
+
const th = this.theme;
|
|
1276
|
+
const { icon, color, phrase } = this.getStatus();
|
|
1277
|
+
const head = `${th.fg(color, icon)} ${th.fg(color, phrase)}`;
|
|
1278
|
+
const sep = th.fg("dim", ": ");
|
|
1279
|
+
const pairs = this.questions.map((q) => {
|
|
1280
|
+
const ans = this.result.answers.find((a) => a.tab === q.tab);
|
|
1281
|
+
return `${q.header}=${this.formatAnswer(ans).text}`;
|
|
1282
|
+
});
|
|
1283
|
+
const body = pairs.join(th.fg("dim", " · "));
|
|
1284
|
+
return [th.fg("dim", truncForDisplay(`${head}${sep}${body}`, width))];
|
|
1285
|
+
}
|
|
952
1286
|
|
|
953
|
-
|
|
1287
|
+
private renderCard(width: number): string[] {
|
|
1288
|
+
const th = this.theme;
|
|
1289
|
+
const { icon, color, phrase } = this.getStatus();
|
|
1290
|
+
const lines: string[] = [];
|
|
1291
|
+
// Status line — icon + phrase in the status color. No border, no redundant
|
|
1292
|
+
// "Ask User" title (the tool-execution cell already renders the tool name
|
|
1293
|
+
// as its header above this component).
|
|
1294
|
+
lines.push(`${th.fg(color, icon)} ${th.fg(color, th.bold(phrase))}`);
|
|
1295
|
+
lines.push(""); // blank line separates status from the Q&A list
|
|
1296
|
+
|
|
1297
|
+
// Each question: header on its own row, then one or more answer rows.
|
|
1298
|
+
// Rows are prefixed by a glyph indicating the answer TYPE, not a uniform
|
|
1299
|
+
// marker: option picks get an arrow (›), custom text gets a pencil (✎).
|
|
1300
|
+
// A multi-select with BOTH options and custom renders as TWO rows. Long
|
|
1301
|
+
// content wraps (wrapTextWithAnsi) so nothing is ever truncated/lost.
|
|
1302
|
+
const indent = " "; // 4-space lead for answer rows
|
|
1303
|
+
const arrow = th.fg("dim", ICON_ANSWER);
|
|
1304
|
+
const pencil = th.fg("dim", ICON_OTHER);
|
|
1305
|
+
const answerRow = (glyph: string, text: string, textColor: ThemeColor) => {
|
|
1306
|
+
const lead = `${indent}${glyph} `;
|
|
1307
|
+
const w = Math.max(8, width - visibleWidth(lead));
|
|
1308
|
+
const wrapped = wrapTextWithAnsi(th.fg(textColor, text), w);
|
|
1309
|
+
const contIndent = " ".repeat(visibleWidth(lead));
|
|
1310
|
+
const out = [`${lead}${wrapped[0]}`];
|
|
1311
|
+
for (let i = 1; i < wrapped.length; i++) out.push(`${contIndent}${wrapped[i]}`);
|
|
1312
|
+
return out;
|
|
1313
|
+
};
|
|
1314
|
+
for (const q of this.questions) {
|
|
1315
|
+
const ans = this.result.answers.find((a) => a.tab === q.tab);
|
|
1316
|
+
lines.push(th.fg("muted", q.header));
|
|
1317
|
+
if (!ans) {
|
|
1318
|
+
lines.push(`${indent}${th.fg("dim", "(no answer)")}`);
|
|
1319
|
+
} else if (ans.kind === "skipped") {
|
|
1320
|
+
lines.push(`${indent}${th.fg("warning", "(skipped)")}`);
|
|
1321
|
+
} else if (ans.kind === "single") {
|
|
1322
|
+
lines.push(...answerRow(arrow, ans.option, "text"));
|
|
1323
|
+
} else if (ans.kind === "custom") {
|
|
1324
|
+
lines.push(...answerRow(pencil, ans.text, "text"));
|
|
1325
|
+
} else if (ans.kind === "multi") {
|
|
1326
|
+
if (ans.options.length === 0 && !ans.custom) {
|
|
1327
|
+
lines.push(`${indent}${th.fg("dim", "(none)")}`);
|
|
1328
|
+
} else {
|
|
1329
|
+
// Options row (arrow) + optional custom row (pencil) — two rows.
|
|
1330
|
+
if (ans.options.length > 0) lines.push(...answerRow(arrow, ans.options.join(", "), "text"));
|
|
1331
|
+
if (ans.custom) lines.push(...answerRow(pencil, ans.custom, "text"));
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// Note — separated by a blank line, no border, no indent. A speech-bubble
|
|
1337
|
+
// glyph marks it as a free-form message, distinct from the Q&A answers.
|
|
1338
|
+
if (this.result.message) {
|
|
1339
|
+
lines.push("");
|
|
1340
|
+
const prefix = th.fg("accent", ICON_NOTE);
|
|
1341
|
+
const noteW = Math.max(8, width - visibleWidth(prefix) - 1);
|
|
1342
|
+
const wrapped = wrapTextWithAnsi(this.result.message, noteW);
|
|
1343
|
+
lines.push(`${prefix} ${wrapped[0]}`);
|
|
1344
|
+
for (let i = 1; i < wrapped.length; i++) lines.push(wrapped[i]);
|
|
1345
|
+
}
|
|
1346
|
+
return lines;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
1351
|
+
// Extension entry
|
|
1352
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
1353
|
+
|
|
1354
|
+
export default function askUserExtension(pi: ExtensionAPI) {
|
|
1355
|
+
pi.registerTool<typeof AskUserParams, AskUserResult>({
|
|
954
1356
|
name: "ask_user",
|
|
955
1357
|
label: "Ask User",
|
|
956
1358
|
description:
|
|
957
|
-
"Ask the user one or more questions with options. Supports single-select (◎→◉) and multi-select (□→▣, space toggles)
|
|
1359
|
+
"Ask the user one or more questions with options. Supports single-select (◎→◉) and multi-select (□→▣, space toggles). Every question always includes a 'Type something.' row so the user can type a custom answer whenever none of the provided options fit — this is built in and cannot be disabled, so never assume the user is restricted to your listed options. The custom-input draft is preserved across tab switches, and a focused side panel shows extended detail (ASCII layouts, code, reasoning) when an option carries a `preview` field. Each option needs a short `label` + a `description` (shown beneath it); add a `preview` field only when a description can't fully convey the option. The panel is collapsible (Ctrl+\\). Use for clarifying requirements, getting preferences, or confirming decisions. Avoid using this to pick one item from a long list you just enumerated (e.g. \"which of these 8 fixes should I start with?\"): options are capped at a handful for a reason, and if the choice isn't a real either/or, present the list in a normal message and let the user reply freely, or just proceed with the highest-priority item — reserve ask_user for genuine decisions with a few distinct, mutually-exclusive paths. All displayed user-facing text should use the conversation's language. The result is returned as JSON: `{ \"cancelled\": bool, \"answers\": [{ \"tab\": <the question's tab>, ... }], \"message\"?: string }`. Each answer echoes the question's `tab` so you can correlate by key. Per answer only the relevant fields appear: single-select option pick → `answer`; single-select custom text → `custom`; multi-select option picks → `answers: [...]`; multi-select with custom → `answers` + `custom`; multi-select empty commit (skippable, submitted with nothing) → `answers: []`; Tab-skipped → `skipped: true`. `custom` is a sibling of `answer`/`answers`, never mixed in — it signals the user typed something outside the offered options. The optional top-level `message` is a free-form note the user can attach on the review screen (about overall direction, pacing, or anything beyond the specific questions); it is user-provided and out-of-band: you cannot set it via the parameters, and it may be absent; when present, treat it as high-priority context that can override or reframe the answers above it.",
|
|
958
1360
|
parameters: AskUserParams,
|
|
959
1361
|
|
|
960
1362
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
@@ -967,60 +1369,87 @@ export default function askUserExtension(pi: ExtensionAPI) {
|
|
|
967
1369
|
|
|
968
1370
|
const questions: Question[] = params.questions.map((q) => ({
|
|
969
1371
|
...q,
|
|
970
|
-
|
|
971
|
-
options: q.options.map((o) => ({ ...o, value: o.value ?? o.label })),
|
|
1372
|
+
options: q.options.map((o) => ({ ...o })),
|
|
972
1373
|
}));
|
|
973
1374
|
|
|
974
|
-
let overlayHandle: { focus: () => void; unfocus: () => void } | null = null;
|
|
975
|
-
let panel: AskUserPanel | null = null;
|
|
976
|
-
|
|
977
1375
|
const result = await ctx.ui.custom<AskUserResult>((tui, theme, _kb, done) => {
|
|
978
|
-
|
|
1376
|
+
return new AskUserPanel(questions, tui, theme, {
|
|
979
1377
|
onResult: (r) => done(r),
|
|
980
|
-
onCollapseChange: (collapsed) => {
|
|
981
|
-
if (collapsed) {
|
|
982
|
-
overlayHandle?.unfocus();
|
|
983
|
-
activeExpand = () => {
|
|
984
|
-
if (!panel) return;
|
|
985
|
-
panel.expandFromShortcut();
|
|
986
|
-
overlayHandle?.focus();
|
|
987
|
-
};
|
|
988
|
-
} else {
|
|
989
|
-
activeExpand = null;
|
|
990
|
-
overlayHandle?.focus();
|
|
991
|
-
}
|
|
992
|
-
},
|
|
993
1378
|
});
|
|
994
|
-
return panel;
|
|
995
1379
|
}, {
|
|
996
|
-
overlay:
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
overlayHandle = handle;
|
|
1004
|
-
handle.focus();
|
|
1005
|
-
},
|
|
1380
|
+
// overlay:false renders the panel into pi's bottom editorContainer slot
|
|
1381
|
+
// (the same path ctx.ui.select()/input() take) instead of compositing a
|
|
1382
|
+
// screen overlay over everything. The chat transcript stays visible above
|
|
1383
|
+
// the panel and is scrollable via the terminal's native scrollback. See
|
|
1384
|
+
// "Layout" note at the top of this file for why overlay:true breaks
|
|
1385
|
+
// transcript scrolling.
|
|
1386
|
+
overlay: false,
|
|
1006
1387
|
});
|
|
1007
1388
|
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1389
|
+
// ── Build the JSON payload returned to the LLM ──
|
|
1390
|
+
//
|
|
1391
|
+
// Structure is intentionally SYMMETRIC with the questions schema the LLM
|
|
1392
|
+
// authored (AskUserParams): each question carries a `tab` as its identity
|
|
1393
|
+
// key, and each answer echoes that same `tab`, so the LLM can correlate
|
|
1394
|
+
// answers back to its own questions by key with zero parsing effort. This
|
|
1395
|
+
// replaces the old `tab: answer` text format, which broke when a custom
|
|
1396
|
+
// answer contained a colon or newline (it could overwrite or collide with
|
|
1397
|
+
// adjacent lines).
|
|
1398
|
+
//
|
|
1399
|
+
// Field shape (only relevant fields are emitted — no noise):
|
|
1400
|
+
// single-select, option picked : { tab, answer }
|
|
1401
|
+
// single-select, custom typed : { tab, custom } // custom NOT in answer
|
|
1402
|
+
// multi-select, options picked : { tab, answers: [...] }
|
|
1403
|
+
// multi-select, options+custom : { tab, answers: [...], custom }
|
|
1404
|
+
// multi-select, custom only : { tab, custom }
|
|
1405
|
+
// multi-select, empty commit : { tab, answers: [] } // NOT skipped
|
|
1406
|
+
// any question, Tab-skipped : { tab, skipped: true }
|
|
1407
|
+
//
|
|
1408
|
+
// `custom` gets its own key (rather than being mixed into answer/answers)
|
|
1409
|
+
// so the LLM can tell the user stepped outside the offered options — a
|
|
1410
|
+
// useful signal, not an anti-spoofing measure. The empty-message note is
|
|
1411
|
+
// omitted entirely ("user left no note" is noise the LLM doesn't need).
|
|
1412
|
+
const jsonAnswers = result.answers.map((a): Record<string, unknown> => {
|
|
1413
|
+
const out: Record<string, unknown> = { tab: a.tab };
|
|
1414
|
+
switch (a.kind) {
|
|
1415
|
+
case "skipped":
|
|
1416
|
+
out.skipped = true;
|
|
1417
|
+
break;
|
|
1418
|
+
case "single":
|
|
1419
|
+
out.answer = a.option;
|
|
1420
|
+
break;
|
|
1421
|
+
case "custom":
|
|
1422
|
+
out.custom = a.text;
|
|
1423
|
+
break;
|
|
1424
|
+
case "multi":
|
|
1425
|
+
out.answers = a.options;
|
|
1426
|
+
if (a.custom) out.custom = a.custom;
|
|
1427
|
+
break;
|
|
1428
|
+
}
|
|
1429
|
+
return out;
|
|
1430
|
+
});
|
|
1431
|
+
const payload: Record<string, unknown> = {
|
|
1432
|
+
cancelled: result.cancelled,
|
|
1433
|
+
answers: jsonAnswers,
|
|
1434
|
+
};
|
|
1435
|
+
if (result.message) payload.message = result.message;
|
|
1019
1436
|
|
|
1020
1437
|
return {
|
|
1021
|
-
content: [{ type: "text", text:
|
|
1438
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
1022
1439
|
details: result,
|
|
1023
1440
|
};
|
|
1024
1441
|
},
|
|
1442
|
+
renderResult(result, options, theme, context) {
|
|
1443
|
+
// context.args.questions is the schema-static type; AskUserResultView
|
|
1444
|
+
// only needs header+tab, so the structural subtype is compatible.
|
|
1445
|
+
const questions = context.args?.questions ?? [];
|
|
1446
|
+
const details = result.details ?? { questions: [], answers: [], cancelled: true };
|
|
1447
|
+
const comp =
|
|
1448
|
+
context.lastComponent instanceof AskUserResultView
|
|
1449
|
+
? context.lastComponent
|
|
1450
|
+
: new AskUserResultView(questions, details, theme);
|
|
1451
|
+
comp.setExpanded(options.expanded);
|
|
1452
|
+
return comp;
|
|
1453
|
+
},
|
|
1025
1454
|
});
|
|
1026
1455
|
}
|