@itc-steve/pi-ask-complete 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1335 @@
1
+ /**
2
+ * pi-ask-user — An ask-user tool for pi.
3
+ *
4
+ * Design notes:
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
+ * - Per-question state (cursor position, scroll offset, type-something draft,
10
+ * multi-select picks) survives tab navigation — switching tabs never loses
11
+ * what you typed.
12
+ * - Single-select icons (○→◉) and multi-select icons (□→▣) all live in the
13
+ * U+25A0–25FF Geometric Shapes block, so any font that renders one renders
14
+ * all. The cursor indicator (▸) is independent of the "selected" glyph:
15
+ * moving up/down only moves ▸; Enter fills the selected glyph.
16
+ * - Rich option previews: if any option of a question carries a `preview`
17
+ * field, the question renders in two equal columns (options | preview);
18
+ * otherwise it renders single-column full-width.
19
+ *
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.) Collapses to one status row.
29
+ */
30
+
31
+ import type { ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
32
+ import {
33
+ type Component,
34
+ Editor,
35
+ type EditorTheme,
36
+ type Focusable,
37
+ Key,
38
+ matchesKey,
39
+ truncateToWidth,
40
+ visibleWidth,
41
+ wrapTextWithAnsi,
42
+ } from "@earendil-works/pi-tui";
43
+ import {
44
+ buildOptions,
45
+ canSkip,
46
+ describeAnswer,
47
+ errorResult,
48
+ isDualColumn,
49
+ isMulti,
50
+ newTabState,
51
+ padRight,
52
+ sanitizeMultiline,
53
+ sanitizeTabDisplay,
54
+ truncForDisplay,
55
+ wrapTab,
56
+ } from "./helpers.ts";
57
+ import { withHerdrBlocked } from "./herdr-attention.ts";
58
+ import { AskUserParams } from "./schema.ts";
59
+ import {
60
+ type Answer,
61
+ type AskUserResult,
62
+ ICON_ANSWER,
63
+ ICON_CHECK_EMPTY,
64
+ ICON_CHECK_FILLED,
65
+ ICON_CURSOR,
66
+ ICON_NOTE,
67
+ ICON_OTHER,
68
+ ICON_RADIO_EMPTY,
69
+ ICON_RADIO_FILLED,
70
+ type PanelCallbacks,
71
+ type Question,
72
+ type RenderOption,
73
+ type TabState,
74
+ TOGGLE_HINT,
75
+ TOGGLE_KEY,
76
+ type TuiLike,
77
+ } from "./types.ts";
78
+
79
+ // ────────────────────────────────────────────────────────────────────────────
80
+ // The overlay component
81
+ // ────────────────────────────────────────────────────────────────────────────
82
+
83
+ export class AskUserPanel implements Component, Focusable {
84
+ focused = false;
85
+
86
+ private questions: Question[];
87
+ private theme: Theme;
88
+ private tui: TuiLike;
89
+ private cb: PanelCallbacks;
90
+
91
+ // ── state ──
92
+ private currentTab = 0;
93
+ private answers = new Map<string, Answer>();
94
+ private collapsed = false;
95
+ private tabs: TabState[];
96
+ /** Visible option rows (recomputed each render). */
97
+ private optionViewportH = 8;
98
+ /** Cursor row in the review summary (shown on the review tab). */
99
+ private reviewCursor = 0;
100
+ /** Vertical scroll offset for the review viewport. */
101
+ private reviewScrollOffset = 0;
102
+ /** Visible review rows (recomputed each render). */
103
+ private reviewViewportH = 8;
104
+ /** True while the user is editing the free-form "note to assistant" on the
105
+ * review tab. While true, all input goes to messageEditor. */
106
+ private messageEditing = false;
107
+ /** Committed note text (trimmed). Empty string = no note. Lives only on the
108
+ * review screen; the LLM cannot set it. */
109
+ private messageText = "";
110
+ /** Dedicated editor for the note. Text input: Enter saves (like
111
+ * the per-question "Type something." editor). */
112
+ private messageEditor: Editor;
113
+
114
+ // ── render cache ──
115
+ private cachedWidth?: number;
116
+ private cachedLines?: string[];
117
+
118
+ constructor(questions: Question[], tui: TuiLike, theme: Theme, cb: PanelCallbacks) {
119
+ this.questions = questions;
120
+ this.tui = tui;
121
+ this.theme = theme;
122
+ this.cb = cb;
123
+
124
+ const editorTheme: EditorTheme = {
125
+ borderColor: (s) => theme.fg("accent", s),
126
+ selectList: {
127
+ selectedPrefix: (t) => theme.fg("accent", t),
128
+ selectedText: (t) => theme.fg("accent", t),
129
+ description: (t) => theme.fg("muted", t),
130
+ scrollInfo: (t) => theme.fg("dim", t),
131
+ noMatch: (t) => theme.fg("warning", t),
132
+ },
133
+ };
134
+ // Each tab owns its own Editor instance — its internal state IS that tab's
135
+ // draft, so tab switching needs no text shuttling.
136
+ this.tabs = questions.map((_, i) =>
137
+ newTabState(tui, editorTheme, i, (ti, v) => this.handleSubmit(ti, v)),
138
+ );
139
+ // Dedicated editor for the review-screen note. Enter saves the text,
140
+ // Esc returns to the review without saving — mirroring the per-question
141
+ // "Type something." editor's semantics.
142
+ this.messageEditor = new Editor(tui as never, editorTheme);
143
+ this.messageEditor.onSubmit = (value) => this.handleMessageSubmit(value);
144
+ }
145
+
146
+ /** Shared submit logic bound to each tab's editor. */
147
+ private handleSubmit(tabIndex: number, value: string): void {
148
+ const q = this.questions[tabIndex];
149
+ const st = this.tabs[tabIndex];
150
+ if (!q || !st) return;
151
+ const trimmed = value.trim();
152
+ if (!trimmed) {
153
+ // empty → back to options. In multi-select mode, an empty submit also
154
+ // clears any previously committed custom text (blank = "remove my custom
155
+ // answer"), then re-commits the remaining checked options.
156
+ st.inputMode = false;
157
+ st.editor.setText("");
158
+ if (isMulti(q)) {
159
+ st.customText = null;
160
+ if (!this.commitMultiAnswer(q, st)) this.answers.delete(q.id);
161
+ }
162
+ if (tabIndex === this.currentTab) this.invalidate();
163
+ return;
164
+ }
165
+ if (isMulti(q)) {
166
+ // Multi-select: the custom text is an extra entry kept ALONGSIDE the
167
+ // checked options — it must NOT overwrite them. (Previously this path
168
+ // did answers.set with only the custom text, dropping every check.)
169
+ // Committing custom text only records it — we return to the OPTION LIST
170
+ // (not advance) so the user can keep checking options and then press
171
+ // Enter on an option to confirm the whole question.
172
+ st.customText = trimmed;
173
+ this.commitMultiAnswer(q, st);
174
+ st.inputMode = false;
175
+ if (tabIndex === this.currentTab) this.invalidate();
176
+ return;
177
+ }
178
+ this.answers.set(q.id, {
179
+ id: q.id,
180
+ tab: q.tab,
181
+ kind: "custom",
182
+ text: trimmed,
183
+ });
184
+ st.selectedSingle = -1; // clear any prior option pick — answer is now custom
185
+ st.inputMode = false;
186
+ if (tabIndex === this.currentTab) {
187
+ this.advanceAfterAnswer();
188
+ }
189
+ }
190
+
191
+ /** Save the review-tab note: trim, store, return to the review tab.
192
+ * currentTab already points at the review tab (note editing is only
193
+ * entered from there), so we just clear the editing flag. Empty = no note. */
194
+ private handleMessageSubmit(value: string): void {
195
+ this.messageText = value.trim();
196
+ this.messageEditing = false;
197
+ this.invalidate();
198
+ }
199
+
200
+ /**
201
+ * Multi-select commit: merge the checked options (st.multiChecked) together
202
+ * with the committed custom text (st.customText) into one multi-select
203
+ * answer. Returns false when there is nothing to commit (no checks and no
204
+ * custom text), so the caller can delete the stale answer if desired.
205
+ */
206
+ private commitMultiAnswer(q: Question, st: TabState): boolean {
207
+ const opts = buildOptions(q);
208
+ const picked = Array.from(st.multiChecked)
209
+ .sort((a, b) => a - b)
210
+ .map((i) => opts[i])
211
+ .filter((o): o is RenderOption => !!o && !o.isOther);
212
+ const labels = picked.map((o) => o.label);
213
+ const customText = st.customText;
214
+ // Empty commit = no option picks AND no custom text. (A skippable
215
+ // multi-select still records an explicit empty answer via the Enter
216
+ // path — see handleInput — so this function returning false just means
217
+ // "nothing to record here".)
218
+ if (labels.length === 0 && !customText) return false;
219
+ const ans: Answer = {
220
+ id: q.id,
221
+ tab: q.tab,
222
+ kind: "multi",
223
+ options: labels,
224
+ };
225
+ if (customText) ans.custom = customText;
226
+ this.answers.set(q.id, ans);
227
+ return true;
228
+ }
229
+
230
+ // ── accessors ──
231
+
232
+ /** Total number of tabs: one per question, plus the trailing review tab. */
233
+ private get totalTabs(): number {
234
+ return this.questions.length + 1;
235
+ }
236
+
237
+ /** The review tab sits at index === questions.length (the last tab).
238
+ * While true, the panel renders the review summary instead of a question. */
239
+ private get isReviewTab(): boolean {
240
+ return this.currentTab === this.questions.length;
241
+ }
242
+
243
+ private currentQuestion(): Question | undefined {
244
+ return this.questions[this.currentTab];
245
+ }
246
+
247
+ private currentTabState(): TabState {
248
+ return this.tabs[this.currentTab]!;
249
+ }
250
+
251
+ private currentOptions(): RenderOption[] {
252
+ const q = this.currentQuestion();
253
+ return q ? buildOptions(q) : [];
254
+ }
255
+
256
+ private advanceAfterAnswer(): void {
257
+ // Advance to the next tab. The review tab is the last tab, so answering
258
+ // the final question lands the user on the review tab (where Enter
259
+ // submits). Navigation is now uniform: review is just the next tab,
260
+ // reached by the same Tab/→ keys as any question — no special "enter
261
+ // review" step. Safe because this is only called from question tabs
262
+ // (currentTab < questions.length), so currentTab + 1 <= reviewTabIndex.
263
+ this.switchTab(this.currentTab + 1);
264
+ }
265
+
266
+ /**
267
+ * Called before navigating FORWARD from a question tab (Tab/→ only).
268
+ * Resolves the current question's state so it can be left cleanly:
269
+ *
270
+ * - Already committed (answers.has): leave freely.
271
+ * - Multi-select with UNCOMMITTED checks (or a typed custom text): a check
272
+ * IS an answer — commit it first, then leave. Navigating away with
273
+ * pending checks must submit them (not skip, not block), regardless of
274
+ * allowSkip, because the user has already expressed a choice. (Single-
275
+ * select commits on Space, so it never has pending uncommitted state.)
276
+ * - Nothing selected at all:
277
+ * allowSkip true → record a skipped answer, allow leaving.
278
+ * allowSkip false → block (a required question must be answered).
279
+ *
280
+ * Returns true when navigation may proceed.
281
+ */
282
+ private prepareQuestionForLeave(): boolean {
283
+ const q = this.currentQuestion();
284
+ if (!q) return true;
285
+ if (this.answers.has(q.id)) return true;
286
+ const st = this.currentTabState();
287
+ // Multi-select: uncommitted checks count as an answer — commit them,
288
+ // then leave. commitMultiAnswer only returns false when there's nothing
289
+ // to commit (no checks, no custom), which the guard already rules out.
290
+ if (isMulti(q) && (st.multiChecked.size > 0 || !!st.customText)) {
291
+ this.commitMultiAnswer(q, st);
292
+ return true;
293
+ }
294
+ if (!canSkip(q)) return false; // required question, nothing chosen: block
295
+ this.answers.set(q.id, {
296
+ id: q.id,
297
+ tab: q.tab,
298
+ kind: "skipped",
299
+ });
300
+ return true;
301
+ }
302
+
303
+ private submit(cancelled: boolean): void {
304
+ this.cb.onResult({
305
+ questions: this.questions,
306
+ answers: Array.from(this.answers.values()),
307
+ cancelled,
308
+ // Only attach the note when non-empty. A cancelled submit still carries
309
+ // the note if the user wrote one (it may explain why they cancelled).
310
+ message: this.messageText || undefined,
311
+ });
312
+ }
313
+
314
+ private setCollapsed(next: boolean): void {
315
+ if (this.collapsed === next) return;
316
+ this.collapsed = next;
317
+ this.invalidate();
318
+ }
319
+
320
+ private clampScrollToCursor(): void {
321
+ const opts = this.currentOptions();
322
+ if (opts.length === 0) return;
323
+ const viewH = this.optionViewportH;
324
+ const st = this.currentTabState();
325
+ if (st.cursor < st.scrollOffset) st.scrollOffset = st.cursor;
326
+ else if (st.cursor >= st.scrollOffset + viewH) st.scrollOffset = st.cursor - viewH + 1;
327
+ if (st.scrollOffset < 0) st.scrollOffset = 0;
328
+ }
329
+
330
+ // ── input ──
331
+
332
+ handleInput(data: string): void {
333
+ // 1. Note editor (messageEditing): owns all input while active. Esc
334
+ // returns to the review tab (currentTab already points there — note
335
+ // editing is only entered from the review tab).
336
+ if (this.messageEditing) {
337
+ if (matchesKey(data, Key.escape)) {
338
+ this.messageEditing = false;
339
+ this.invalidate();
340
+ return;
341
+ }
342
+ this.messageEditor.handleInput(data);
343
+ this.invalidate();
344
+ return;
345
+ }
346
+
347
+ // 2. Collapse toggle (global, any tab).
348
+ if (matchesKey(data, TOGGLE_KEY)) {
349
+ this.setCollapsed(!this.collapsed);
350
+ return;
351
+ }
352
+
353
+ // 3. Collapsed: only Esc (cancel) is meaningful.
354
+ if (this.collapsed) {
355
+ if (matchesKey(data, Key.escape)) this.submit(true);
356
+ return;
357
+ }
358
+
359
+ // 4. Question tab + "Type something." input mode: the editor owns ALL
360
+ // editing keys (Tab, arrows, etc.). Tab is NOT hijacked for tab
361
+ // switching here, because that would break indentation / cursor
362
+ // movement. Esc exits back to the option list. The review tab has no
363
+ // input mode (it never edits options), so it skips this branch — the
364
+ // `!this.isReviewTab` short-circuit also avoids indexing tabs[] OOB.
365
+ if (!this.isReviewTab && this.currentTabState().inputMode) {
366
+ if (matchesKey(data, Key.escape)) {
367
+ const st = this.currentTabState();
368
+ st.inputMode = false;
369
+ // Keep the editor content (per-tab editor preserves it as draft).
370
+ this.invalidate();
371
+ return;
372
+ }
373
+ this.currentTabState().editor.handleInput(data);
374
+ this.invalidate();
375
+ return;
376
+ }
377
+
378
+ // 5. Esc = cancel submission (any tab, when not editing).
379
+ if (matchesKey(data, Key.escape)) {
380
+ this.submit(true);
381
+ return;
382
+ }
383
+
384
+ // 6. Shared tab navigation — Tab/→ forward, Shift+Tab/← backward.
385
+ // Runs on BOTH question tabs and the review tab, which is what makes
386
+ // the review reachable by the same keys as any question. The skip
387
+ // check only applies when LEAVING a question tab (never the review).
388
+ if (this.handleTabNavigation(data)) return;
389
+
390
+ // 7. Review tab: ↑↓ move · Space edit · Enter submit. (Esc + tab
391
+ // navigation were already handled above.)
392
+ if (this.isReviewTab) {
393
+ this.handleReviewInput(data);
394
+ return;
395
+ }
396
+
397
+ // 8. Question tab: ↑↓ move cursor · Space toggle/commit · Enter confirm.
398
+ const st = this.currentTabState();
399
+ const q = this.currentQuestion();
400
+ if (!q) return;
401
+ const opts = this.currentOptions();
402
+ const multi = isMulti(q);
403
+
404
+ // Up / Down — moves ONLY the cursor (▸), does not change selection
405
+ if (matchesKey(data, Key.up)) {
406
+ if (st.cursor > 0) {
407
+ st.cursor--;
408
+ this.clampScrollToCursor();
409
+ this.invalidate();
410
+ }
411
+ return;
412
+ }
413
+ if (matchesKey(data, Key.down)) {
414
+ if (st.cursor < opts.length - 1) {
415
+ st.cursor++;
416
+ this.clampScrollToCursor();
417
+ this.invalidate();
418
+ }
419
+ return;
420
+ }
421
+ if (matchesKey(data, Key.pageUp)) {
422
+ st.cursor = Math.max(0, st.cursor - Math.max(1, this.optionViewportH));
423
+ this.clampScrollToCursor();
424
+ this.invalidate();
425
+ return;
426
+ }
427
+ if (matchesKey(data, Key.pageDown)) {
428
+ st.cursor = Math.min(opts.length - 1, st.cursor + Math.max(1, this.optionViewportH));
429
+ this.clampScrollToCursor();
430
+ this.invalidate();
431
+ return;
432
+ }
433
+
434
+ // Space — the "interact" key: select (single), toggle (multi), or EDIT
435
+ // (the "Type something." row). It never advances — that's Enter's job.
436
+ // This mirrors the review tab (where Space opens an entry for editing),
437
+ // so "the key that modifies things" is the same on every screen.
438
+ if (matchesKey(data, Key.space)) {
439
+ const opt = opts[st.cursor];
440
+ if (!opt) return;
441
+ if (opt.isOther) {
442
+ // Enter edit mode for a custom answer. Prefill with any committed
443
+ // custom text so the user edits rather than retypes. Per-tab
444
+ // editor keeps the text for Esc-discard semantics automatically.
445
+ // - Single-select: custom text lives in the `custom` answer.
446
+ // - Multi-select: it lives in st.customText (kept alongside checks).
447
+ st.inputMode = true;
448
+ const existing = this.answers.get(q.id);
449
+ const prefill = multi ? st.customText : existing?.kind === "custom" ? existing.text : null;
450
+ if (prefill) st.editor.setText(prefill);
451
+ this.invalidate();
452
+ return;
453
+ }
454
+ if (multi) {
455
+ if (st.multiChecked.has(st.cursor)) st.multiChecked.delete(st.cursor);
456
+ else st.multiChecked.add(st.cursor);
457
+ this.invalidate();
458
+ return;
459
+ }
460
+ // single-select: mark the selection WITHOUT advancing (stay on question)
461
+ st.selectedSingle = st.cursor;
462
+ this.answers.set(q.id, {
463
+ id: q.id,
464
+ tab: q.tab,
465
+ kind: "single",
466
+ option: opt.label,
467
+ });
468
+ this.invalidate();
469
+ return;
470
+ }
471
+
472
+ // Enter — confirm + advance. Space owns editing; Enter commits what's at
473
+ // the cursor. Single-select commits the cursor option and advances;
474
+ // multi-select commits the currently checked options as-is and advances
475
+ // (Space owns checking; Enter commits the existing checks without toggling the cursor option).
476
+ // One exception: on the single-select custom row, Enter opens the editor
477
+ // if no custom answer is committed yet (engaging the custom option).
478
+ if (matchesKey(data, Key.enter)) {
479
+ const opt = opts[st.cursor];
480
+ if (!opt) return;
481
+ if (opt.isOther && !multi) {
482
+ // Single-select isOther: if a custom answer is already committed,
483
+ // advance; otherwise open the editor to type one. Space also opens the
484
+ // editor, but Enter on the custom row should engage the custom option
485
+ // rather than no-op when nothing's committed yet.
486
+ if (this.answers.get(q.id)?.kind === "custom") {
487
+ this.advanceAfterAnswer();
488
+ } else {
489
+ st.inputMode = true;
490
+ this.invalidate();
491
+ }
492
+ return;
493
+ }
494
+ if (multi) {
495
+ // Commit the current checks (+ any custom text) and advance. For a
496
+ // skippable multi-select, committing an EMPTY selection is still a
497
+ // commit — Enter means "submit (even if empty) and move on", not
498
+ // "skip" (Tab/arrows do skipping). So we record an explicit empty
499
+ // answer and advance; a required question (!canSkip) with an empty
500
+ // selection stays put, since it must have at least one pick.
501
+ if (this.commitMultiAnswer(q, st)) {
502
+ this.advanceAfterAnswer();
503
+ } else if (canSkip(q)) {
504
+ this.answers.set(q.id, {
505
+ id: q.id,
506
+ tab: q.tab,
507
+ kind: "multi",
508
+ options: [],
509
+ });
510
+ this.advanceAfterAnswer();
511
+ }
512
+ return;
513
+ }
514
+ // single-select: commit cursor position as the selection, then advance
515
+ st.selectedSingle = st.cursor;
516
+ this.answers.set(q.id, {
517
+ id: q.id,
518
+ tab: q.tab,
519
+ kind: "single",
520
+ option: opt.label,
521
+ });
522
+ this.advanceAfterAnswer();
523
+ return;
524
+ }
525
+ }
526
+
527
+ /** Switch tab. Each tab owns its own Editor instance, so draft preservation
528
+ * is automatic — no text shuttling required. */
529
+ private switchTab(next: number): void {
530
+ if (next === this.currentTab) return;
531
+ this.currentTab = next;
532
+ this.invalidate();
533
+ }
534
+
535
+ /** Shared tab navigation, invoked from handleInput for BOTH question tabs
536
+ * and the review tab. Returns true when the key was consumed.
537
+ *
538
+ * - Tab / → : forward. Tab WRAPS through every tab (questions → review →
539
+ * first question); → STOPS at the review tab (boundary).
540
+ * - Shift+Tab / ← : backward. Shift+Tab wraps; ← stops at the first
541
+ * question.
542
+ *
543
+ * Leaving a question tab may need to commit pending multi-select checks
544
+ * or record a skip (when it's unanswered and required) — that's handled
545
+ * by prepareQuestionForLeave. Leaving the review tab never needs that
546
+ * check (it isn't a question), so →/Tab work freely from review. */
547
+ private handleTabNavigation(data: string): boolean {
548
+ if (this.totalTabs <= 1) return false;
549
+ // Forward
550
+ if (matchesKey(data, Key.tab)) {
551
+ if (!this.isReviewTab && !this.prepareQuestionForLeave()) return true; // required: blocked
552
+ this.switchTab(wrapTab(this.currentTab + 1, this.totalTabs));
553
+ return true;
554
+ }
555
+ if (matchesKey(data, Key.right)) {
556
+ if (!this.isReviewTab && !this.prepareQuestionForLeave()) return true; // required: blocked
557
+ if (this.currentTab + 1 >= this.totalTabs) return true; // stop at review
558
+ this.switchTab(this.currentTab + 1);
559
+ return true;
560
+ }
561
+ // Backward
562
+ if (matchesKey(data, Key.shift("tab"))) {
563
+ // No backward wrap to review: backward navigation isn't validated by
564
+ // prepareQuestionForLeave, so wrapping Q0 → review would let a required
565
+ // (allowSkip:false) question be skipped. Stop at the first question,
566
+ // matching ← below.
567
+ if (this.currentTab - 1 < 0) return true; // stop at first question
568
+ this.switchTab(this.currentTab - 1);
569
+ return true;
570
+ }
571
+ if (matchesKey(data, Key.left)) {
572
+ if (this.currentTab - 1 < 0) return true; // stop at first question
573
+ this.switchTab(this.currentTab - 1);
574
+ return true;
575
+ }
576
+ return false;
577
+ }
578
+
579
+ /** Handle input specific to the review tab. Esc and tab navigation
580
+ * (Tab/←/→) are already handled upstream in handleInput, so here we only
581
+ * deal with: ↑↓/PgUp/PgDn (move the review cursor), Space (open the entry
582
+ * under the cursor for editing), and Enter (submit the whole review).
583
+ *
584
+ * The review list has N question entries plus one trailing "note to
585
+ * assistant" entry (index N), so the cursor ranges over [0, N]. */
586
+ private handleReviewInput(data: string): void {
587
+ const n = this.questions.length;
588
+ const total = n + 1; // include the note entry
589
+ // ↑/↓/PgUp/PgDn — move the review cursor over [0, total-1]
590
+ if (matchesKey(data, Key.up)) {
591
+ if (this.reviewCursor > 0) {
592
+ this.reviewCursor--;
593
+ this.invalidate();
594
+ }
595
+ return;
596
+ }
597
+ if (matchesKey(data, Key.down)) {
598
+ if (this.reviewCursor < total - 1) {
599
+ this.reviewCursor++;
600
+ this.invalidate();
601
+ }
602
+ return;
603
+ }
604
+ if (matchesKey(data, Key.pageUp)) {
605
+ this.reviewCursor = Math.max(0, this.reviewCursor - Math.max(1, this.reviewViewportH));
606
+ this.invalidate();
607
+ return;
608
+ }
609
+ if (matchesKey(data, Key.pageDown)) {
610
+ this.reviewCursor = Math.min(
611
+ total - 1,
612
+ this.reviewCursor + Math.max(1, this.reviewViewportH),
613
+ );
614
+ this.invalidate();
615
+ return;
616
+ }
617
+ // Space — "select" the entry under the cursor: jump into editing it.
618
+ // (Mirrors the option screens, where Space = select/toggle.)
619
+ if (matchesKey(data, Key.space)) {
620
+ if (this.reviewCursor === n) {
621
+ // Note entry: open the note editor. Prefill with the committed note
622
+ // (if any) so the user can tweak rather than retype.
623
+ this.messageEditing = true;
624
+ if (this.messageText) this.messageEditor.setText(this.messageText);
625
+ this.invalidate();
626
+ return;
627
+ }
628
+ // Question entry: switch to that question's tab for editing.
629
+ // switchTab early-returns when next === currentTab, which is fine — that
630
+ // only happens on a single-question call where we're already on the
631
+ // question; nothing to redraw.
632
+ this.switchTab(this.reviewCursor);
633
+ return;
634
+ }
635
+ // Enter — submit the whole review, no matter where the cursor sits.
636
+ if (matchesKey(data, Key.enter)) {
637
+ this.submit(false);
638
+ return;
639
+ }
640
+ }
641
+
642
+ /** Build a bordered content row that ALWAYS fits innerW: truncateToWidth
643
+ * is the hard safety net (any content — LLM-generated headers, user-typed
644
+ * custom answers, tab names — is clamped so the TUI render can never crash
645
+ * on an over-wide line), padRight then fills short content to keep the
646
+ * right border aligned. Shared by every bordered screen so the width
647
+ * invariant holds uniformly. */
648
+ private makeRow(th: Theme, borderColor: ThemeColor, innerW: number) {
649
+ return (content: string) => {
650
+ const fitted = truncateToWidth(content, innerW);
651
+ return th.fg(borderColor, "│") + padRight(fitted, innerW) + th.fg(borderColor, "│");
652
+ };
653
+ }
654
+
655
+ // ── render ──
656
+
657
+ render(width: number): string[] {
658
+ if (this.collapsed) {
659
+ this.cachedWidth = width;
660
+ this.cachedLines = this.renderCollapsed(width);
661
+ return this.cachedLines;
662
+ }
663
+ if (this.cachedLines && this.cachedWidth === width) {
664
+ return this.cachedLines;
665
+ }
666
+ this.cachedWidth = width;
667
+ this.cachedLines = this.renderExpanded(width);
668
+ return this.cachedLines;
669
+ }
670
+
671
+ private renderCollapsed(width: number): string[] {
672
+ const th = this.theme;
673
+ const qParts = this.questions.map((q, i) => {
674
+ const done = this.answers.has(q.id);
675
+ const active = i === this.currentTab && !this.isReviewTab;
676
+ const mark = active ? "▸" : done ? "✓" : "○";
677
+ const color = active ? "accent" : done ? "success" : "dim";
678
+ return th.fg(color, `${q.displayTab}${mark}`);
679
+ });
680
+ const reviewPart = this.isReviewTab ? th.fg("accent", "Review▸") : th.fg("dim", "Review○");
681
+ const tabsPart = [...qParts, reviewPart].join(th.fg("dim", " "));
682
+ const inner = `${tabsPart} ${th.fg("dim", ` ${TOGGLE_HINT} expand `)}${th.fg("dim", " Esc cancel ")}`;
683
+ const line =
684
+ th.fg("border", "│") +
685
+ inner +
686
+ " ".repeat(Math.max(0, width - 2 - visibleWidth(inner))) +
687
+ th.fg("border", "│");
688
+ return [truncateToWidth(line, width)];
689
+ }
690
+
691
+ /** Build the tab-bar content line (without border wrapping). Shared by the
692
+ * question screen and the review screen so the bar is always visible —
693
+ * the review tab is a real tab, so it must highlight when active just like
694
+ * any question. Callers wrap the returned string in their own row() so the
695
+ * border color matches the surrounding screen. */
696
+ private renderTabBarContent(th: Theme): string {
697
+ const tabCells = this.questions.map((q, i) => {
698
+ const active = i === this.currentTab;
699
+ const ans = this.answers.get(q.id);
700
+ let mark = " ";
701
+ let baseColor: import("@earendil-works/pi-coding-agent").ThemeColor = active
702
+ ? "accent"
703
+ : "muted";
704
+ if (ans?.kind === "skipped") {
705
+ mark = "—";
706
+ baseColor = "warning";
707
+ } else if (ans) {
708
+ mark = "✓";
709
+ baseColor = "success";
710
+ } else if (active) mark = "▸";
711
+ const color = active ? "accent" : baseColor;
712
+ // Always reserve one padding cell on each side so tab width is constant
713
+ // across active/inactive (no horizontal jump when switching). Only the
714
+ // active tab paints the bg, turning that reserved space into a pill.
715
+ const cell = th.fg(color, ` ${mark} ${q.displayTab} `);
716
+ return active ? th.bg("selectedBg", cell) : cell;
717
+ });
718
+ const reviewActive = this.isReviewTab;
719
+ const reviewMark = reviewActive ? "▸" : " ";
720
+ const reviewColor: import("@earendil-works/pi-coding-agent").ThemeColor = reviewActive
721
+ ? "accent"
722
+ : "muted";
723
+ const reviewCellRaw = th.fg(reviewColor, ` ${reviewMark} [ Review ] `);
724
+ const reviewCell = reviewActive ? th.bg("selectedBg", reviewCellRaw) : reviewCellRaw;
725
+ const sep = th.fg("dim", " │");
726
+ return ` ${tabCells.join(th.fg("dim", " "))}${sep}${reviewCell}`;
727
+ }
728
+
729
+ private renderExpanded(width: number): string[] {
730
+ const th = this.theme;
731
+ const innerW = Math.max(20, width - 2);
732
+ const lines: string[] = [];
733
+ const row = this.makeRow(th, "border", innerW);
734
+
735
+ if (this.messageEditing) {
736
+ return this.renderMessageEditor(width, innerW, th);
737
+ }
738
+ if (this.isReviewTab) {
739
+ return this.renderReview(width, innerW, th);
740
+ }
741
+
742
+ lines.push(th.fg("border", `╭${"─".repeat(innerW)}╮`));
743
+
744
+ // ── Tab bar ──
745
+ // Always shown: there is always at least one question tab plus the
746
+ // trailing review tab. renderTabBarContent is shared with the review
747
+ // screen so the active tab stays visible across every screen.
748
+ lines.push(row(this.renderTabBarContent(th)));
749
+ lines.push(row(""));
750
+
751
+ // ── Question header ──
752
+ const q = this.currentQuestion();
753
+ const multi = isMulti(q);
754
+ const dual = isDualColumn(q);
755
+ const progress =
756
+ this.questions.length > 1 ? ` [${this.currentTab + 1}/${this.questions.length}]` : "";
757
+ const tag = multi ? th.fg("dim", " (multi)") : "";
758
+ const headerText = truncateToWidth(
759
+ ` ${th.fg("accent", th.bold(q?.header ?? ""))}${tag}${th.fg("dim", progress)}`,
760
+ innerW,
761
+ "",
762
+ );
763
+ lines.push(th.fg("border", "│") + padRight(headerText, innerW) + th.fg("border", "│"));
764
+
765
+ // ── Prompt body ──
766
+ if (q?.prompt) {
767
+ for (const w of wrapTextWithAnsi(th.fg("muted", q.prompt), innerW - 2))
768
+ lines.push(row(` ${w}`));
769
+ }
770
+ // 空行分隔 header/prompt 与 options,无条件添加。
771
+ lines.push(row(""));
772
+
773
+ // ── Body: options / preview / input editor ──
774
+ const st = this.currentTabState();
775
+ if (st.inputMode) {
776
+ for (const el of st.editor.render(innerW - 2)) lines.push(row(` ${el}`));
777
+ lines.push(row(th.fg("dim", " Esc back to options · Enter submit")));
778
+ } else if (dual && q) {
779
+ lines.push(...this.renderDualColumn(q, st, innerW, row, th));
780
+ } else {
781
+ lines.push(...this.renderSingleColumn(st, innerW, row, th));
782
+ }
783
+
784
+ // ── Footer ──
785
+ lines.push(th.fg("border", `├${"─".repeat(innerW)}┤`));
786
+ const doneCount = Array.from(this.answers.values()).filter((a) => a.kind !== "skipped").length;
787
+ const left =
788
+ this.questions.length > 1
789
+ ? th.fg("dim", ` ${doneCount}/${this.questions.length} answered · `)
790
+ : th.fg("dim", " ");
791
+ const hint = multi
792
+ ? `${TOGGLE_HINT} collapse · ↑↓ move · Space toggle · Enter confirm · Esc cancel`
793
+ : `${TOGGLE_HINT} collapse · ↑↓ move · Space select · Enter confirm · Esc cancel`;
794
+ lines.push(row(`${left}${th.fg("dim", hint)}`));
795
+ lines.push(th.fg("border", `╰${"─".repeat(innerW)}╯`));
796
+ return lines;
797
+ }
798
+
799
+ /** Render the option row glyph. The cursor (▸) is independent of selection. */
800
+ private optionGlyph(
801
+ opt: RenderOption,
802
+ index: number,
803
+ st: TabState,
804
+ multi: boolean,
805
+ th: Theme,
806
+ customAnswered: boolean,
807
+ ): string {
808
+ if (opt.isOther) {
809
+ // "Type something." is filled when a custom answer was committed.
810
+ return customAnswered ? th.fg("success", ICON_RADIO_FILLED) : th.fg("dim", ICON_OTHER);
811
+ }
812
+ if (multi) {
813
+ const checked = st.multiChecked.has(index);
814
+ return checked ? th.fg("success", ICON_CHECK_FILLED) : th.fg("dim", ICON_CHECK_EMPTY);
815
+ }
816
+ // single: filled only when committed (selectedSingle), not on cursor hover
817
+ const filled = st.selectedSingle === index;
818
+ return filled ? th.fg("success", ICON_RADIO_FILLED) : th.fg("dim", ICON_RADIO_EMPTY);
819
+ }
820
+
821
+ /** Format an answer for the review summary. Delegates to describeAnswer
822
+ * (single source of truth), then wraps in the theme color + truncates. */
823
+ private formatAnswerText(ans: Answer | undefined, maxW: number, th: Theme): string {
824
+ const view = describeAnswer(ans);
825
+ const text = truncForDisplay(view.text, maxW);
826
+ return th.fg(view.color, text);
827
+ }
828
+
829
+ /** Clamp the review scroll offset so the cursor stays visible. The review
830
+ * list has N questions + 1 note entry, so the cursor may equal N. */
831
+ private clampReviewScroll(): void {
832
+ const total = this.questions.length + 1;
833
+ if (total === 0) return;
834
+ const viewH = this.reviewViewportH;
835
+ if (this.reviewCursor < this.reviewScrollOffset) this.reviewScrollOffset = this.reviewCursor;
836
+ else if (this.reviewCursor >= this.reviewScrollOffset + viewH)
837
+ this.reviewScrollOffset = this.reviewCursor - viewH + 1;
838
+ if (this.reviewScrollOffset < 0) this.reviewScrollOffset = 0;
839
+ }
840
+
841
+ /** Review summary: one question per entry (header + answer), plus a trailing
842
+ * "note to assistant" entry. Viewport scrolling reuses the option-screen
843
+ * layout primitives. */
844
+ private renderReview(_width: number, innerW: number, th: Theme): string[] {
845
+ const lines: string[] = [];
846
+ // Review uses a distinct border color (success/green) so it's visually
847
+ // unmistakable as the review/confirm screen — not another question. The
848
+ // question screen keeps the default "border" color.
849
+ const bc: import("@earendil-works/pi-coding-agent").ThemeColor = "success";
850
+ const row = this.makeRow(th, bc, innerW);
851
+ lines.push(th.fg(bc, `╭${"─".repeat(innerW)}╮`));
852
+ lines.push(row(this.renderTabBarContent(th)));
853
+ lines.push(row(""));
854
+ lines.push(row(` ${th.fg("accent", th.bold("Review your answers"))}`));
855
+ lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
856
+ const n = this.questions.length;
857
+ const total = n + 1; // +1 for the note entry
858
+ // Body indent (6 cols): questions and the note carry a 2-visible-col marker
859
+ // (`1.` / `2.` … or `✎ ` for the note) right after the cursor, plus a
860
+ // separator space, so every title starts at the same column. The body is
861
+ // indented one past that so header vs content stay visually distinct.
862
+ const bodyIndent = " "; // 6 spaces
863
+ const maxW = innerW - 2 - bodyIndent.length;
864
+ this.reviewViewportH = Math.max(3, Math.min(total, 10));
865
+ this.clampReviewScroll();
866
+ const start = this.reviewScrollOffset;
867
+ const end = Math.min(total, start + this.reviewViewportH);
868
+ for (let i = start; i < end; i++) {
869
+ const isCursor = i === this.reviewCursor;
870
+ const prefix = isCursor ? `${th.fg("accent", ICON_CURSOR)} ` : " ";
871
+ const headerColor: import("@earendil-works/pi-coding-agent").ThemeColor = isCursor
872
+ ? "accent"
873
+ : "muted";
874
+ // marker: a fixed 2-visible-col slot + 1 separator space, so every title
875
+ // (questions + note) aligns regardless of icon width. `1.` is 2 cols;
876
+ // the note's ✎ is 1 col, padded to `✎ ` (hence one extra space between
877
+ // ✎ and its title — the deliberate tradeoff of this layout).
878
+ // ── Note entry (index n): always last, two rows like a question. ──
879
+ if (i === n) {
880
+ // 空行分隔:note 是异类条目(附加留言,非问答),用空行和上方
881
+ // 问答列表隔开。保持简单,不用点线/装饰。
882
+ lines.push(row(""));
883
+ const marker = th.fg(headerColor, `${ICON_OTHER} `);
884
+ lines.push(row(` ${prefix}${marker} ${th.fg(headerColor, "Note to assistant")}`));
885
+ const msg = this.messageText;
886
+ if (msg) {
887
+ const vw = visibleWidth(msg);
888
+ const body = vw <= maxW ? msg : `${truncateToWidth(msg, maxW - 1, "")}…`;
889
+ lines.push(row(`${bodyIndent}${th.fg("text", body)}`));
890
+ } else {
891
+ lines.push(row(`${bodyIndent}${th.fg("dim", "(optional — Space to add a note)")}`));
892
+ }
893
+ continue;
894
+ }
895
+ const q = this.questions[i]!;
896
+ const ans = this.answers.get(q.id);
897
+ // Header row: cursor + marker + title.
898
+ const marker = th.fg(headerColor, `${i + 1}.`);
899
+ lines.push(row(` ${prefix}${marker} ${th.fg(headerColor, q.header)}`));
900
+ // Answer row: reuse the description renderer's indent/wrap, fed the
901
+ // formatted answer text. Skipped/custom/multi-select all flow through
902
+ // formatAnswerText, so the coloring matches the option screen.
903
+ const ansText = this.formatAnswerText(ans, maxW, th);
904
+ lines.push(row(`${bodyIndent}${ansText}`));
905
+ }
906
+ if (total > this.reviewViewportH) {
907
+ lines.push(
908
+ row(th.fg("dim", `${bodyIndent}↑↓/PgUp/PgDn scroll · ${start + 1}-${end}/${total}`)),
909
+ );
910
+ }
911
+ lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
912
+ lines.push(row(th.fg("dim", " ↑↓ move · Space edit · Enter confirm · Esc cancel")));
913
+ lines.push(th.fg(bc, `╰${"─".repeat(innerW)}╯`));
914
+ return lines;
915
+ }
916
+
917
+ /** Note editor screen: reached from the review's note entry via Space. Uses
918
+ * the same success-bordered look as the review screen to signal it's part
919
+ * of the review flow, not a fresh question. */
920
+ private renderMessageEditor(_width: number, innerW: number, th: Theme): string[] {
921
+ const lines: string[] = [];
922
+ const bc: import("@earendil-works/pi-coding-agent").ThemeColor = "success";
923
+ const row = this.makeRow(th, bc, innerW);
924
+ lines.push(th.fg(bc, `╭${"─".repeat(innerW)}╮`));
925
+ lines.push(row(` ${th.fg("accent", th.bold(`${ICON_OTHER} Note to assistant`))}`));
926
+ lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
927
+ for (const el of this.messageEditor.render(innerW - 2)) lines.push(row(` ${el}`));
928
+ lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
929
+ lines.push(row(th.fg("dim", " Esc back to review · Enter save note")));
930
+ lines.push(th.fg(bc, `╰${"─".repeat(innerW)}╯`));
931
+ return lines;
932
+ }
933
+
934
+ /** Single-column layout: option label + wrapped description below. */
935
+ private renderSingleColumn(
936
+ st: TabState,
937
+ innerW: number,
938
+ row: (s: string) => string,
939
+ th: Theme,
940
+ ): string[] {
941
+ const q = this.currentQuestion()!;
942
+ const multi = isMulti(q);
943
+ const opts = this.currentOptions();
944
+ const maxRows = Math.max(3, Math.min(opts.length, 10));
945
+ this.optionViewportH = maxRows;
946
+ this.clampScrollToCursor();
947
+ const start = st.scrollOffset;
948
+ const end = Math.min(opts.length, start + maxRows);
949
+ const out: string[] = [];
950
+ for (let i = start; i < end; i++) {
951
+ const opt = opts[i]!;
952
+ const isCursor = i === st.cursor;
953
+ const prefix = isCursor ? `${th.fg("accent", ICON_CURSOR)} ` : " ";
954
+ const ans = this.answers.get(q.id);
955
+ const committedCustom = multi ? st.customText : ans?.kind === "custom" ? ans.text : null;
956
+ const customAnswered = !!committedCustom;
957
+ const glyph = this.optionGlyph(opt, i, st, multi, th, customAnswered);
958
+ // For "Type something.", show the committed text instead of the placeholder.
959
+ // Custom text is arbitrary-length user input — truncate to the label
960
+ // column (the full text appears on the review screen and result card,
961
+ // both of which wrap). Without this a long custom answer makes the row
962
+ // exceed the terminal width and crashes the TUI render.
963
+ const labelMaxW = Math.max(0, innerW - 5); // 1 lead + 2 prefix + 1 glyph + 1 space
964
+ const displayLabel =
965
+ opt.isOther && customAnswered ? truncForDisplay(committedCustom!, labelMaxW) : opt.label;
966
+ const labelColor = isCursor
967
+ ? "accent"
968
+ : opt.isOther
969
+ ? customAnswered
970
+ ? "text"
971
+ : "dim"
972
+ : "text";
973
+ const labelText = th.fg(labelColor, displayLabel);
974
+ out.push(row(` ${prefix}${glyph} ${labelText}`));
975
+ if (opt.description) {
976
+ out.push(...this.renderDescription(opt.description, isCursor, innerW, row, th));
977
+ }
978
+ }
979
+ if (opts.length > maxRows) {
980
+ out.push(row(th.fg("dim", ` ↑↓/PgUp/PgDn scroll · ${start + 1}-${end}/${opts.length}`)));
981
+ }
982
+ return out;
983
+ }
984
+
985
+ /**
986
+ * Two-column layout (options | preview), each half-width. Triggered when any
987
+ * option of the question carries a `preview` field. The right pane shows the
988
+ * preview of the option currently under the cursor.
989
+ */
990
+ private renderDualColumn(
991
+ q: Question,
992
+ st: TabState,
993
+ innerW: number,
994
+ row: (s: string) => string,
995
+ th: Theme,
996
+ ): string[] {
997
+ const multi = isMulti(q);
998
+ const opts = this.currentOptions();
999
+ const halfW = Math.floor((innerW - 2) / 2); // 1-space gutter between columns
1000
+ const leftW = halfW;
1001
+ const rightW = innerW - 2 - halfW;
1002
+ const maxRows = Math.max(3, Math.min(opts.length, 10));
1003
+ this.optionViewportH = maxRows;
1004
+ this.clampScrollToCursor();
1005
+ const start = st.scrollOffset;
1006
+ const end = Math.min(opts.length, start + maxRows);
1007
+
1008
+ // ── build left column lines (options) ──
1009
+ const leftLines: string[] = [];
1010
+ const dualAns = this.answers.get(q.id);
1011
+ const dualCommittedCustom = multi
1012
+ ? st.customText
1013
+ : dualAns?.kind === "custom"
1014
+ ? dualAns.text
1015
+ : null;
1016
+ const customAnswered = !!dualCommittedCustom;
1017
+ for (let i = start; i < end; i++) {
1018
+ const opt = opts[i]!;
1019
+ const isCursor = i === st.cursor;
1020
+ const prefix = isCursor ? `${th.fg("accent", ICON_CURSOR)} ` : " ";
1021
+ const glyph = this.optionGlyph(opt, i, st, multi, th, customAnswered);
1022
+ const displayLabel = opt.isOther && customAnswered ? dualCommittedCustom! : opt.label;
1023
+ const labelColor = isCursor
1024
+ ? "accent"
1025
+ : opt.isOther
1026
+ ? customAnswered
1027
+ ? "text"
1028
+ : "dim"
1029
+ : "text";
1030
+ const labelLine = `${prefix}${glyph} ${th.fg(labelColor, displayLabel)}`;
1031
+ leftLines.push(truncateToWidth(labelLine, leftW - 1, ""));
1032
+ }
1033
+ if (opts.length > maxRows) {
1034
+ leftLines.push(
1035
+ th.fg("dim", truncateToWidth(`${start + 1}-${end}/${opts.length}`, leftW - 1, "")),
1036
+ );
1037
+ }
1038
+
1039
+ // ── build right column lines (preview of cursor option) ──
1040
+ const rightLines: string[] = [];
1041
+ const cursorOpt = opts[st.cursor];
1042
+ if (cursorOpt?.preview) {
1043
+ // Render preview verbatim (preserve ASCII layout), truncate to rightW.
1044
+ for (const ln of cursorOpt.preview.split("\n")) {
1045
+ rightLines.push(th.fg("muted", truncateToWidth(ln, rightW - 1, "")));
1046
+ }
1047
+ } else {
1048
+ rightLines.push(th.fg("dim", truncateToWidth("(no preview)", rightW - 1, "")));
1049
+ }
1050
+
1051
+ // ── merge columns side by side, padding the shorter one ──
1052
+ const rowCount = Math.max(leftLines.length, rightLines.length);
1053
+ const out: string[] = [];
1054
+ for (let r = 0; r < rowCount; r++) {
1055
+ const l = padRight(leftLines[r] ?? "", leftW);
1056
+ const rr = padRight(rightLines[r] ?? "", rightW);
1057
+ out.push(row(` ${l} ${rr}`));
1058
+ }
1059
+ return out;
1060
+ }
1061
+
1062
+ /**
1063
+ * Render an option description in single-column mode. Multi-line (newline-
1064
+ * containing) descriptions render verbatim as a fixed-width block.
1065
+ */
1066
+ private renderDescription(
1067
+ description: string,
1068
+ selected: boolean,
1069
+ innerW: number,
1070
+ row: (s: string) => string,
1071
+ th: Theme,
1072
+ ): string[] {
1073
+ const indent = " ";
1074
+ const color = selected ? "muted" : "dim";
1075
+ // description is already control-char-sanitized at ingress, so \n is the
1076
+ // only meaningful break here.
1077
+ if (description.includes("\n")) {
1078
+ const maxW = innerW - 2 - indent.length;
1079
+ return description
1080
+ .split("\n")
1081
+ .map((ln) => row(`${indent}${truncateToWidth(th.fg(color, ln), maxW, "")}`));
1082
+ }
1083
+ return wrapTextWithAnsi(`${indent}${th.fg(color, description)}`, innerW - 2).map((w) => row(w));
1084
+ }
1085
+
1086
+ invalidate(): void {
1087
+ this.cachedWidth = undefined;
1088
+ this.cachedLines = undefined;
1089
+ this.tui.requestRender();
1090
+ }
1091
+ }
1092
+
1093
+ // ────────────────────────────────────────────────────────────────────────────
1094
+ // Result view — static card shown in the message stream after ask_user.
1095
+ // Lets the user verify at a glance what they chose. Two states driven by
1096
+ // options.expanded: collapsed = one-line summary, expanded = bordered card.
1097
+ // Reuses AskUserPanel's visual language (same border glyphs, theme colors,
1098
+ // ✓/⊘/○ status icons, ✎ for custom) so it reads as a continuation of the
1099
+ // interaction, not a foreign element.
1100
+ // ────────────────────────────────────────────────────────────────────────────
1101
+
1102
+ export class AskUserResultView implements Component {
1103
+ private questions: ReadonlyArray<Pick<Question, "id" | "header" | "tab">>;
1104
+ private result: AskUserResult;
1105
+ private theme: Theme;
1106
+ private expanded = false;
1107
+ private cachedWidth?: number;
1108
+ private cachedLines?: string[];
1109
+
1110
+ constructor(
1111
+ questions: ReadonlyArray<Pick<Question, "id" | "header" | "tab">>,
1112
+ result: AskUserResult,
1113
+ theme: Theme,
1114
+ ) {
1115
+ this.questions = questions;
1116
+ this.result = result;
1117
+ this.theme = theme;
1118
+ }
1119
+
1120
+ setExpanded(expanded: boolean): void {
1121
+ if (this.expanded !== expanded) {
1122
+ this.expanded = expanded;
1123
+ this.cachedWidth = undefined;
1124
+ }
1125
+ }
1126
+
1127
+ invalidate(): void {
1128
+ this.cachedWidth = undefined;
1129
+ this.cachedLines = undefined;
1130
+ }
1131
+
1132
+ render(width: number): string[] {
1133
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
1134
+ this.cachedWidth = width;
1135
+ this.cachedLines = this.expanded ? this.renderCard(width) : this.renderCollapsed(width);
1136
+ return this.cachedLines;
1137
+ }
1138
+
1139
+ /** Overall status → icon, color, and a short status phrase (plain text,
1140
+ * no ANSI — callers wrap it in color). */
1141
+ private getStatus(): { icon: string; color: ThemeColor; phrase: string } {
1142
+ const total = this.questions.length;
1143
+ if (this.result.cancelled) {
1144
+ return {
1145
+ icon: "⊘",
1146
+ color: "warning",
1147
+ phrase: `Cancelled · ${this.result.answers.length}/${total} answered`,
1148
+ };
1149
+ }
1150
+ const anySkipped = this.result.answers.some((a) => a.kind === "skipped");
1151
+ return anySkipped
1152
+ ? { icon: "○", color: "accent", phrase: "Answers (some skipped)" }
1153
+ : { icon: "✓", color: "success", phrase: "Answers submitted" };
1154
+ }
1155
+
1156
+ /** Format one answer for the card display. Delegates to describeAnswer. */
1157
+ private formatAnswer(ans: Answer | undefined): { text: string; color: ThemeColor } {
1158
+ return describeAnswer(ans);
1159
+ }
1160
+
1161
+ private renderCollapsed(width: number): string[] {
1162
+ const th = this.theme;
1163
+ const { icon, color, phrase } = this.getStatus();
1164
+ const head = `${th.fg(color, icon)} ${th.fg(color, phrase)}`;
1165
+ const sep = th.fg("dim", ": ");
1166
+ const pairs = this.questions.map((q) => {
1167
+ const ans = this.result.answers.find((a) => a.id === q.id);
1168
+ return `${q.header}=${this.formatAnswer(ans).text}`;
1169
+ });
1170
+ const body = pairs.join(th.fg("dim", " · "));
1171
+ return [th.fg("dim", truncForDisplay(`${head}${sep}${body}`, width))];
1172
+ }
1173
+
1174
+ private renderCard(width: number): string[] {
1175
+ const th = this.theme;
1176
+ const { icon, color, phrase } = this.getStatus();
1177
+ const lines: string[] = [];
1178
+ // Status line — icon + phrase in the status color. No border, no redundant
1179
+ // "Ask User" title (the tool-execution cell already renders the tool name
1180
+ // as its header above this component).
1181
+ lines.push(`${th.fg(color, icon)} ${th.fg(color, th.bold(phrase))}`);
1182
+ lines.push(""); // blank line separates status from the Q&A list
1183
+
1184
+ // Each question: header on its own row, then one or more answer rows.
1185
+ // Rows are prefixed by a glyph indicating the answer TYPE, not a uniform
1186
+ // marker: option picks get an arrow (›), custom text gets a pencil (✎).
1187
+ // A multi-select with BOTH options and custom renders as TWO rows. Long
1188
+ // content wraps (wrapTextWithAnsi) so nothing is ever truncated/lost.
1189
+ const indent = " "; // 4-space lead for answer rows
1190
+ const arrow = th.fg("dim", ICON_ANSWER);
1191
+ const pencil = th.fg("dim", ICON_OTHER);
1192
+ const answerRow = (glyph: string, text: string, textColor: ThemeColor) => {
1193
+ const lead = `${indent}${glyph} `;
1194
+ const w = Math.max(8, width - visibleWidth(lead));
1195
+ const wrapped = wrapTextWithAnsi(th.fg(textColor, text), w);
1196
+ const contIndent = " ".repeat(visibleWidth(lead));
1197
+ const out = [`${lead}${wrapped[0]}`];
1198
+ for (let i = 1; i < wrapped.length; i++) out.push(`${contIndent}${wrapped[i]}`);
1199
+ return out;
1200
+ };
1201
+ for (const q of this.questions) {
1202
+ const ans = this.result.answers.find((a) => a.id === q.id);
1203
+ lines.push(truncateToWidth(th.fg("muted", q.header), width));
1204
+ if (!ans) {
1205
+ lines.push(`${indent}${th.fg("dim", "(no answer)")}`);
1206
+ } else if (ans.kind === "skipped") {
1207
+ lines.push(`${indent}${th.fg("warning", "(skipped)")}`);
1208
+ } else if (ans.kind === "single") {
1209
+ lines.push(...answerRow(arrow, ans.option, "text"));
1210
+ } else if (ans.kind === "custom") {
1211
+ lines.push(...answerRow(pencil, ans.text, "text"));
1212
+ } else if (ans.kind === "multi") {
1213
+ if (ans.options.length === 0 && !ans.custom) {
1214
+ lines.push(`${indent}${th.fg("dim", "(none)")}`);
1215
+ } else {
1216
+ // Options row (arrow) + optional custom row (pencil) — two rows.
1217
+ if (ans.options.length > 0)
1218
+ lines.push(...answerRow(arrow, ans.options.join(", "), "text"));
1219
+ if (ans.custom) lines.push(...answerRow(pencil, ans.custom, "text"));
1220
+ }
1221
+ }
1222
+ }
1223
+
1224
+ // Note — separated by a blank line, no border, no indent. A speech-bubble
1225
+ // glyph marks it as a free-form message, distinct from the Q&A answers.
1226
+ if (this.result.message) {
1227
+ lines.push("");
1228
+ const prefix = th.fg("accent", ICON_NOTE);
1229
+ const noteW = Math.max(8, width - visibleWidth(prefix) - 1);
1230
+ const wrapped = wrapTextWithAnsi(this.result.message, noteW);
1231
+ lines.push(`${prefix} ${wrapped[0]}`);
1232
+ for (let i = 1; i < wrapped.length; i++) lines.push(wrapped[i]);
1233
+ }
1234
+ return lines;
1235
+ }
1236
+ }
1237
+
1238
+ // ────────────────────────────────────────────────────────────────────────────
1239
+ // Shared runners — used by ask_user tool and the permission gate
1240
+ // ────────────────────────────────────────────────────────────────────────────
1241
+
1242
+ export function sanitizeQuestions(
1243
+ raw: Array<{
1244
+ header: string;
1245
+ tab: string;
1246
+ prompt?: string;
1247
+ options: Array<{ label: string; description?: string; preview?: string }>;
1248
+ multiSelect?: boolean;
1249
+ allowSkip?: boolean;
1250
+ }>,
1251
+ ): Question[] {
1252
+ return raw.map((q, index) => ({
1253
+ ...q,
1254
+ id: `question-${index + 1}`,
1255
+ displayTab: sanitizeTabDisplay(q.tab),
1256
+ header: sanitizeMultiline(q.header),
1257
+ prompt: q.prompt === undefined ? undefined : sanitizeMultiline(q.prompt),
1258
+ options: q.options.map((o) => ({
1259
+ ...o,
1260
+ label: sanitizeMultiline(o.label),
1261
+ description: o.description === undefined ? undefined : sanitizeMultiline(o.description),
1262
+ preview: o.preview === undefined ? undefined : sanitizeMultiline(o.preview),
1263
+ })),
1264
+ }));
1265
+ }
1266
+
1267
+ /** Run the bottom panel (overlay:false) and return the structured result. */
1268
+ export async function runAskUserPanel(
1269
+ ctx: Pick<ExtensionContext, "ui">,
1270
+ questions: Question[],
1271
+ ): Promise<AskUserResult> {
1272
+ return withHerdrBlocked("Waiting for your answer", async () => {
1273
+ const result = await ctx.ui.custom<AskUserResult>(
1274
+ (tui, theme, _kb, done) => {
1275
+ return new AskUserPanel(questions, tui as TuiLike, theme, {
1276
+ onResult: (r) => done(r),
1277
+ });
1278
+ },
1279
+ {
1280
+ // overlay:false → bottom editorContainer slot; transcript stays visible above.
1281
+ overlay: false,
1282
+ },
1283
+ );
1284
+ // custom() can return undefined in non-TUI modes
1285
+ return (
1286
+ result ?? {
1287
+ questions,
1288
+ answers: [],
1289
+ cancelled: true,
1290
+ }
1291
+ );
1292
+ });
1293
+ }
1294
+
1295
+ export function buildAskUserJsonPayload(
1296
+ questions: Question[],
1297
+ result: AskUserResult,
1298
+ ): Record<string, unknown> {
1299
+ const answerById = new Map(result.answers.map((answer) => [answer.id, answer]));
1300
+ const duplicateTabs = new Map<string, number>();
1301
+ const jsonAnswers = questions.flatMap((question): Record<string, unknown>[] => {
1302
+ const answer = answerById.get(question.id);
1303
+ if (!answer) return [];
1304
+ const count = (duplicateTabs.get(answer.tab) ?? 0) + 1;
1305
+ duplicateTabs.set(answer.tab, count);
1306
+ const out: Record<string, unknown> = {
1307
+ tab: count === 1 ? answer.tab : `${answer.tab}-${count}`,
1308
+ };
1309
+ switch (answer.kind) {
1310
+ case "skipped":
1311
+ out.skipped = true;
1312
+ break;
1313
+ case "single":
1314
+ out.answer = answer.option;
1315
+ break;
1316
+ case "custom":
1317
+ out.custom = answer.text;
1318
+ break;
1319
+ case "multi":
1320
+ out.answers = answer.options;
1321
+ if (answer.custom) out.custom = answer.custom;
1322
+ break;
1323
+ }
1324
+ return [out];
1325
+ });
1326
+ const payload: Record<string, unknown> = {
1327
+ cancelled: result.cancelled,
1328
+ answers: jsonAnswers,
1329
+ };
1330
+ if (result.message) payload.message = result.message;
1331
+ return payload;
1332
+ }
1333
+
1334
+ export { AskUserParams, errorResult };
1335
+ export type { AskUserResult, Question };