@d3ara1n/pi-ask-user 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.
Files changed (3) hide show
  1. package/README.md +170 -0
  2. package/package.json +33 -0
  3. package/src/index.ts +1014 -0
package/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # @d3ara1n/pi-ask-user
2
+
3
+ A collapsible **ask-user** tool for [pi](https://github.com/earendil-works/pi-mono).
4
+
5
+ ## Why
6
+
7
+ Other ask-user tools render a panel that covers the transcript, and even when
8
+ they "collapse" they keep keyboard focus locked on the panel — so you **can't
9
+ scroll the conversation** to read the analysis that should inform your choice.
10
+ You end up choosing blind.
11
+
12
+ This tool fixes that:
13
+
14
+ - **Collapse actually releases focus.** Press `Ctrl+\` and the panel shrinks to
15
+ a single status row while `OverlayHandle.unfocus()` hands focus back to the
16
+ transcript. Your normal scroll mechanisms work again — mouse wheel,
17
+ `Shift+PgUp`, `Ctrl+Left`/`Ctrl+Right` tree nav, `/tree`.
18
+ - **Re-expand works from anywhere.** A global shortcut (`pi.registerShortcut`)
19
+ captures `Ctrl+\` from the editor, so pressing it again re-expands and
20
+ re-focuses the panel — no matter where focus currently is.
21
+
22
+ > **Note:** Because pi removes the editor from the UI tree while an overlay is
23
+ > active, full transcript scrolling while collapsed depends on pi's overlay
24
+ > behavior. The collapse affordance is kept as best-effort.
25
+
26
+ ## Tool: `ask_user`
27
+
28
+ ```jsonc
29
+ {
30
+ "questions": [
31
+ {
32
+ "header": "Which layout?",
33
+ "prompt": "Pick the layout for the new settings page.",
34
+ "options": [
35
+ { "value": "sidebar", "label": "Sidebar", "description": "Nav on the left…" },
36
+ { "value": "tabs", "label": "Tabs", "description": "Top tabs…" }
37
+ ]
38
+ }
39
+ ]
40
+ }
41
+ ```
42
+
43
+ ### Fields
44
+
45
+ **Question**
46
+
47
+ | Field | Type | Required | Description |
48
+ |-------|------|----------|-------------|
49
+ | `header` | string | yes | Short title shown in the panel header |
50
+ | `options` | array | yes | 2–4 options |
51
+ | `id` | string | no | Unique id. Defaults to `q1`, `q2`, … |
52
+ | `label` | string | no | Tab label (multi-question). Defaults to `Q1`, … |
53
+ | `prompt` | string | no | Longer body text under the header |
54
+ | `allowOther` | boolean | no | Allow "Type something." custom input. Default `true` |
55
+ | `multiSelect` | boolean | no | Check multiple options. Default `false` |
56
+ | `allowSkip` | boolean | no | If `false`, the user MUST answer before proceeding. Default `true` |
57
+
58
+ **Option**
59
+
60
+ | Field | Type | Required | Description |
61
+ |-------|------|----------|-------------|
62
+ | `label` | string | yes | Display label |
63
+ | `value` | string | no | Returned value. Defaults to `label` if omitted |
64
+ | `description` | string | no | Explanation under the label (wraps). For ASCII diagrams/code use `preview` |
65
+ | `preview` | string | no | Rich preview shown in a right-hand column when focused. Triggers two-column layout if any option has it |
66
+
67
+ ### Icons
68
+
69
+ All selection icons live in the `U+25A0–25FF` Geometric Shapes block, so any
70
+ font that renders one renders all of them consistently:
71
+
72
+ - Single-select: `○` (white circle) → `◉` (fisheye) when committed
73
+ - Multi-select: `□` (white square) → `▣` (square with fill) when checked
74
+ - Cursor: `▸` marks the current position, **independent** of selection
75
+
76
+ Moving `↑`/`↓` only moves the cursor; selection is committed separately.
77
+
78
+ ### Single-select vs multi-select
79
+
80
+ - **Single-select**: `Space` selects (fills the circle) **without** advancing;
81
+ `Enter` selects **and** advances to the next question.
82
+ - **Multi-select** (`multiSelect: true`): `Space` toggles a checkbox; `Enter`
83
+ commits all checked options and advances.
84
+
85
+ ### Custom input ("Type something.")
86
+
87
+ When `allowOther` is `true` (default), a "Type something." row appears. Press
88
+ `Enter` on it to open a text editor:
89
+
90
+ - After submitting, the row displays the committed text with a filled glyph
91
+ (`◉ ✎ your text`).
92
+ - Press `Enter` again to re-edit it — the editor **prefills** the committed
93
+ text so you can tweak it.
94
+ - `Esc` discards the edit (the original answer is kept); `Enter` confirms the
95
+ change (the answer is updated).
96
+
97
+ ### Required questions
98
+
99
+ Set `allowSkip: false` to force an answer. The user cannot advance forward
100
+ (`Tab`/`→`) until they answer. Backward navigation (`Shift+Tab`/`←`) is always
101
+ allowed so they can review/edit earlier questions.
102
+
103
+ ### Rich previews
104
+
105
+ If **any** option of a question carries a `preview` field, that question
106
+ renders in **two columns**: option list on the left, the focused option's
107
+ preview on the right. Moving the cursor updates the right pane. Ideal for
108
+ comparing ASCII layouts / code samples:
109
+
110
+ ```jsonc
111
+ {
112
+ "id": "layout",
113
+ "header": "Which layout?",
114
+ "options": [
115
+ {
116
+ "label": "Sidebar",
117
+ "value": "sidebar",
118
+ "preview": "┌──┬────────┐\n│导│ 正文 │\n│航│ │\n└──┴────────┘\n左侧固定导航"
119
+ },
120
+ {
121
+ "label": "Top bar",
122
+ "value": "topbar",
123
+ "preview": "┌──────────────┐\n│ 导航条 │\n├──────────────┤\n│ 正文 │\n└──────────────┘\n顶部横向导航"
124
+ }
125
+ ]
126
+ }
127
+ ```
128
+
129
+ Plain `description` text (no newline) wraps normally. A `description` that
130
+ contains a newline renders verbatim as a fixed-width block.
131
+
132
+ ### Review screen
133
+
134
+ After the last question is answered, a **review screen** lists every question
135
+ and its answer (multi-select answers are comma-joined and truncated with `…`
136
+ when too long; skipped questions show `(skipped)`):
137
+
138
+ - `↑`/`↓` — move the cursor between questions
139
+ - `Tab` — jump to the focused question to edit it (returns to the review after)
140
+ - `Enter` — confirm and submit all answers
141
+ - `Esc` — cancel
142
+
143
+ ### Result
144
+
145
+ The tool returns a summary plus structured `answers`. If the user cancelled
146
+ mid-way, the message notes how many questions were answered before cancellation.
147
+
148
+ ## Keys
149
+
150
+ | Key | Action |
151
+ |-----|--------|
152
+ | `↑` `↓` / `PgUp` `PgDn` | Move cursor / scroll options |
153
+ | `Space` | Commit selection (single: select-only; multi: toggle) |
154
+ | `Enter` | Confirm & advance (single) / commit checked (multi) / enter custom input |
155
+ | `Tab` / `Shift+Tab` | Next / previous question (option list only — not hijacked inside the editor) |
156
+ | `→` / `←` | Same as `Tab` / `Shift+Tab` |
157
+ | `Esc` | Cancel (or exit custom-input editor without saving) |
158
+ | `Ctrl+\` | Collapse / expand the panel |
159
+
160
+ ## Install
161
+
162
+ Add to `~/.pi/agent/settings.json`:
163
+
164
+ ```jsonc
165
+ {
166
+ "extensions": [
167
+ "/path/to/pi-extensions/packages/pi-ask-user"
168
+ ]
169
+ }
170
+ ```
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@d3ara1n/pi-ask-user",
3
+ "version": "0.1.0",
4
+ "description": "Ask-user tool for pi — collapsible question overlay that releases focus so you can scroll the transcript while deciding",
5
+ "main": "src/index.ts",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi"
9
+ ],
10
+ "peerDependencies": {
11
+ "@earendil-works/pi-coding-agent": "*",
12
+ "@earendil-works/pi-tui": "*"
13
+ },
14
+ "peerDependenciesMeta": {
15
+ "@earendil-works/pi-coding-agent": {
16
+ "optional": true
17
+ },
18
+ "@earendil-works/pi-tui": {
19
+ "optional": true
20
+ }
21
+ },
22
+ "pi": {
23
+ "extensions": [
24
+ "./src/index.ts"
25
+ ]
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/d3ara1n/pi-extensions",
30
+ "directory": "packages/pi-ask-user"
31
+ },
32
+ "license": "MIT"
33
+ }
package/src/index.ts ADDED
@@ -0,0 +1,1014 @@
1
+ /**
2
+ * pi-ask-user — An ask-user tool for pi.
3
+ *
4
+ * Design notes:
5
+ * - Collapsible panel (Ctrl+\). Collapsing calls `handle.unfocus()` to release
6
+ * focus back to the editor. NOTE: because pi removes the editor from the UI
7
+ * tree while an overlay is active, full transcript scrolling while collapsed
8
+ * depends on pi behaviour; this is kept as a best-effort affordance.
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: bottom-anchored full-width overlay. Collapses to a single status row.
21
+ */
22
+
23
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
24
+ import {
25
+ type Component,
26
+ Editor,
27
+ type EditorTheme,
28
+ type Focusable,
29
+ Key,
30
+ matchesKey,
31
+ truncateToWidth,
32
+ visibleWidth,
33
+ wrapTextWithAnsi,
34
+ } from "@earendil-works/pi-tui";
35
+ import { Type } from "typebox";
36
+
37
+ // ────────────────────────────────────────────────────────────────────────────
38
+ // Icon constants — all in U+25A0–25FF Geometric Shapes for font consistency
39
+ // ────────────────────────────────────────────────────────────────────────────
40
+
41
+ const ICON_RADIO_EMPTY = "○"; // U+25CB white circle
42
+ const ICON_RADIO_FILLED = "◉"; // U+25C9 fisheye
43
+ const ICON_CHECK_EMPTY = "□"; // U+25A1
44
+ const ICON_CHECK_FILLED = "▣"; // U+25A3
45
+ const ICON_OTHER = "✎"; // pencil for "Type something."
46
+ const ICON_CURSOR = "▸"; // current cursor position, independent of selection
47
+
48
+ // ────────────────────────────────────────────────────────────────────────────
49
+ // Types
50
+ // ────────────────────────────────────────────────────────────────────────────
51
+
52
+ interface QuestionOption {
53
+ value: string;
54
+ label: string;
55
+ description?: string;
56
+ /** Rich preview shown in the right column when this option is focused. */
57
+ preview?: string;
58
+ }
59
+
60
+ interface RenderOption extends QuestionOption {
61
+ isOther?: boolean;
62
+ }
63
+
64
+ interface Question {
65
+ id: string;
66
+ label?: string;
67
+ header: string;
68
+ prompt?: string;
69
+ options: QuestionOption[];
70
+ allowOther?: boolean;
71
+ multiSelect?: boolean;
72
+ allowSkip?: boolean;
73
+ }
74
+
75
+ interface Answer {
76
+ id: string;
77
+ /** Single-select: the chosen value. Multi-select: empty string. */
78
+ value: string;
79
+ /** Multi-select: the chosen values. Single-select: absent. */
80
+ values?: string[];
81
+ /** Single-select label. */
82
+ label: string;
83
+ /** Multi-select labels. */
84
+ labels?: string[];
85
+ wasCustom: boolean;
86
+ index?: number;
87
+ /** True for multi-select answers. */
88
+ multiSelect?: boolean;
89
+ /** True if the user skipped this question (only set when allowSkip is true). */
90
+ skipped?: boolean;
91
+ }
92
+
93
+ interface AskUserResult {
94
+ questions: Question[];
95
+ answers: Answer[];
96
+ cancelled: boolean;
97
+ }
98
+
99
+ /** Per-tab ephemeral UI state. Preserved across tab switches. */
100
+ interface TabState {
101
+ /** Cursor position (where ▸ is). */
102
+ cursor: number;
103
+ /** Vertical scroll offset for the options viewport. */
104
+ scrollOffset: number;
105
+ /** Whether "Type something." input mode is active for this tab. */
106
+ inputMode: boolean;
107
+ /** This tab's own editor instance (its draft lives inside; no cross-tab sync needed). */
108
+ editor: Editor;
109
+ /** Indices of committed options (multi-select). */
110
+ multiChecked: Set<number>;
111
+ /** Committed single-select index (or -1 if none yet, -2 = answered via type-something). */
112
+ selectedSingle: number;
113
+ }
114
+
115
+ // ────────────────────────────────────────────────────────────────────────────
116
+ // Schema
117
+ // ────────────────────────────────────────────────────────────────────────────
118
+
119
+ const QuestionOptionSchema = Type.Object({
120
+ value: Type.Optional(
121
+ Type.String({
122
+ description: "Value returned when this option is chosen. Defaults to `label` if omitted.",
123
+ }),
124
+ ),
125
+ label: Type.String({ description: "Short display label for the option (shown on the selection row)" }),
126
+ description: Type.Optional(
127
+ Type.String({
128
+ description:
129
+ "Optional short explanation shown under the label (wraps). For ASCII diagrams/code use `preview`.",
130
+ }),
131
+ ),
132
+ preview: Type.Optional(
133
+ Type.String({
134
+ description:
135
+ "Optional rich preview (ASCII diagram, code sample, etc.) shown in a dedicated right-hand column when this option is focused. Triggers two-column layout for the whole question if any option has it.",
136
+ }),
137
+ ),
138
+ });
139
+
140
+ const QuestionSchema = Type.Object({
141
+ id: Type.Optional(
142
+ Type.String({
143
+ description: "Unique identifier for this question. Defaults to `q1`, `q2`, ... if omitted.",
144
+ }),
145
+ ),
146
+ header: Type.String({
147
+ description: "Short question title shown in the panel header, e.g. 'Which layout?'",
148
+ }),
149
+ label: Type.Optional(
150
+ Type.String({
151
+ description: "Short tab label for multi-question mode (defaults to Q1, Q2, ...)",
152
+ }),
153
+ ),
154
+ prompt: Type.Optional(
155
+ Type.String({ description: "Optional longer body text shown under the header" }),
156
+ ),
157
+ options: Type.Array(QuestionOptionSchema, {
158
+ description: "Available options. Pass 2-4; each may carry a description and/or preview.",
159
+ }),
160
+ allowOther: Type.Optional(
161
+ Type.Boolean({ description: "Allow a 'Type something.' custom-input option (default: true)" }),
162
+ ),
163
+ multiSelect: Type.Optional(
164
+ Type.Boolean({
165
+ description:
166
+ "If true, the user may check multiple options (space toggles, enter commits). Default false.",
167
+ }),
168
+ ),
169
+ allowSkip: Type.Optional(
170
+ Type.Boolean({
171
+ description:
172
+ "If false, the user MUST answer before proceeding (Tab/Enter with no selection is blocked). Default true. Use false for required questions.",
173
+ }),
174
+ ),
175
+ });
176
+
177
+ const AskUserParams = Type.Object({
178
+ questions: Type.Array(QuestionSchema, { description: "One or more questions to ask" }),
179
+ });
180
+
181
+ // ────────────────────────────────────────────────────────────────────────────
182
+ // Constants
183
+ // ────────────────────────────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Collapse/expand toggle. Ctrl+\ (0x1c) is free in pi's built-in keybindings
187
+ * (unlike Ctrl+] which collides with tui.editor.jumpForward) and is not used
188
+ * as a prefix by tmux/zellij/screen/ssh.
189
+ */
190
+ const TOGGLE_KEY = Key.ctrl("\\");
191
+ const TOGGLE_HINT = "Ctrl+\\";
192
+
193
+ // ────────────────────────────────────────────────────────────────────────────
194
+ // Helpers
195
+ // ────────────────────────────────────────────────────────────────────────────
196
+
197
+ function wrapTab(index: number, total: number): number {
198
+ if (total <= 0) return 0;
199
+ return ((index % total) + total) % total;
200
+ }
201
+
202
+ /** Build the full option list for a question, appending "Type something." if allowed. */
203
+ function buildOptions(q: Question): RenderOption[] {
204
+ const opts: RenderOption[] = [...q.options];
205
+ if (q.allowOther !== false) {
206
+ opts.push({ value: "__other__", label: "Type something.", isOther: true });
207
+ }
208
+ return opts;
209
+ }
210
+
211
+ function isMulti(q: Question | undefined): boolean {
212
+ return !!q?.multiSelect;
213
+ }
214
+
215
+ /** Whether the user is allowed to skip this question (default true). */
216
+ function canSkip(q: Question | undefined): boolean {
217
+ return q?.allowSkip !== false;
218
+ }
219
+
220
+ /** Does this question use the two-column (options | preview) layout? */
221
+ function isDualColumn(q: Question | undefined): boolean {
222
+ if (!q) return false;
223
+ return q.options.some((o) => o.preview);
224
+ }
225
+
226
+ function newTabState(tui: TuiLike, theme: EditorTheme, tabIndex: number, onSubmit: (tabIndex: number, value: string) => void): TabState {
227
+ const editor = new Editor(tui as never, theme);
228
+ editor.onSubmit = (value) => onSubmit(tabIndex, value);
229
+ return { cursor: 0, scrollOffset: 0, inputMode: false, editor, multiChecked: new Set(), selectedSingle: -1 };
230
+ }
231
+
232
+ function errorResult(message: string, questions: Question[] = []): {
233
+ content: { type: "text"; text: string }[];
234
+ details: AskUserResult;
235
+ } {
236
+ return {
237
+ content: [{ type: "text", text: message }],
238
+ details: { questions, answers: [], cancelled: true },
239
+ };
240
+ }
241
+
242
+ /** Pad a string with trailing spaces to a visible width (left-justified). */
243
+ function padRight(s: string, width: number): string {
244
+ const v = visibleWidth(s);
245
+ return v >= width ? s : s + " ".repeat(width - v);
246
+ }
247
+
248
+ // ────────────────────────────────────────────────────────────────────────────
249
+ // The overlay component
250
+ // ────────────────────────────────────────────────────────────────────────────
251
+
252
+ interface TuiLike {
253
+ requestRender(): void;
254
+ }
255
+
256
+ interface PanelCallbacks {
257
+ onResult: (result: AskUserResult) => void;
258
+ onCollapseChange: (collapsed: boolean) => void;
259
+ }
260
+
261
+ class AskUserPanel implements Component, Focusable {
262
+ focused = false;
263
+
264
+ private questions: Question[];
265
+ private theme: Theme;
266
+ private tui: TuiLike;
267
+ private cb: PanelCallbacks;
268
+
269
+ // ── state ──
270
+ private currentTab = 0;
271
+ private answers = new Map<string, Answer>();
272
+ private collapsed = false;
273
+ private tabs: TabState[];
274
+ /** Visible option rows (recomputed each render). */
275
+ private optionViewportH = 8;
276
+ /** When true, the panel shows a review summary of all answers; Enter submits. */
277
+ private reviewMode = false;
278
+ /** Cursor row in the review summary. */
279
+ private reviewCursor = 0;
280
+ /** True after jumping from review mode to edit a question; makes answering
281
+ * return to review instead of advancing to the next question. */
282
+ private editingFromReview = false;
283
+
284
+ // ── render cache ──
285
+ private cachedWidth?: number;
286
+ private cachedLines?: string[];
287
+
288
+ constructor(questions: Question[], tui: TuiLike, theme: Theme, cb: PanelCallbacks) {
289
+ this.questions = questions;
290
+ this.tui = tui;
291
+ this.theme = theme;
292
+ this.cb = cb;
293
+
294
+ const editorTheme: EditorTheme = {
295
+ borderColor: (s) => theme.fg("accent", s),
296
+ selectList: {
297
+ selectedPrefix: (t) => theme.fg("accent", t),
298
+ selectedText: (t) => theme.fg("accent", t),
299
+ description: (t) => theme.fg("muted", t),
300
+ scrollInfo: (t) => theme.fg("dim", t),
301
+ noMatch: (t) => theme.fg("warning", t),
302
+ },
303
+ };
304
+ // Each tab owns its own Editor instance — its internal state IS that tab's
305
+ // draft, so tab switching needs no text shuttling. This is the fix for the
306
+ // draft-loss bug (previously a single shared editor was swapped in/out and
307
+ // the swap was lossy across the input-mode / tab-switch boundary).
308
+ this.tabs = questions.map((_, i) => newTabState(tui, editorTheme, i, (ti, v) => this.handleSubmit(ti, v)));
309
+ }
310
+
311
+ /** Shared submit logic bound to each tab's editor. */
312
+ private handleSubmit(tabIndex: number, value: string): void {
313
+ const q = this.questions[tabIndex];
314
+ const st = this.tabs[tabIndex];
315
+ if (!q || !st) return;
316
+ const trimmed = value.trim();
317
+ if (!trimmed) {
318
+ // empty → back to options, keep nothing
319
+ st.inputMode = false;
320
+ st.editor.setText("");
321
+ if (tabIndex === this.currentTab) this.invalidate();
322
+ return;
323
+ }
324
+ this.answers.set(q.id, {
325
+ id: q.id,
326
+ value: trimmed,
327
+ label: trimmed,
328
+ wasCustom: true,
329
+ });
330
+ st.inputMode = false;
331
+ st.selectedSingle = -2; // sentinel: "answered via type-something"
332
+ if (tabIndex === this.currentTab) {
333
+ this.advanceAfterAnswer();
334
+ }
335
+ }
336
+
337
+ // ── accessors ──
338
+
339
+ private currentQuestion(): Question | undefined {
340
+ return this.questions[this.currentTab];
341
+ }
342
+
343
+ private currentTabState(): TabState {
344
+ return this.tabs[this.currentTab]!;
345
+ }
346
+
347
+ private currentOptions(): RenderOption[] {
348
+ const q = this.currentQuestion();
349
+ return q ? buildOptions(q) : [];
350
+ }
351
+
352
+ private advanceAfterAnswer(): void {
353
+ // If we came from review mode (editing a question), return to review
354
+ // instead of advancing to the next question.
355
+ if (this.editingFromReview) {
356
+ this.editingFromReview = false;
357
+ this.reviewMode = true;
358
+ this.invalidate();
359
+ return;
360
+ }
361
+ if (this.currentTab < this.questions.length - 1) {
362
+ this.switchTab(wrapTab(this.currentTab + 1, this.questions.length));
363
+ return;
364
+ }
365
+ // Last question answered: enter review mode instead of submitting.
366
+ this.reviewMode = true;
367
+ this.reviewCursor = 0;
368
+ this.invalidate();
369
+ }
370
+
371
+ /**
372
+ * Called when the user tries to leave the current question without answering.
373
+ * - If the question is already answered: return true (leave freely).
374
+ * - If unanswered + allowSkip is true: record a skipped answer, return true.
375
+ * - If unanswered + allowSkip is false: return false (block navigation).
376
+ */
377
+ private markSkippedIfNeeded(): boolean {
378
+ const q = this.currentQuestion();
379
+ if (!q) return true;
380
+ if (this.answers.has(q.id)) return true;
381
+ if (!canSkip(q)) return false; // required question: block
382
+ this.answers.set(q.id, {
383
+ id: q.id,
384
+ value: "",
385
+ label: "",
386
+ wasCustom: false,
387
+ skipped: true,
388
+ });
389
+ return true;
390
+ }
391
+
392
+ private submit(cancelled: boolean): void {
393
+ this.cb.onResult({
394
+ questions: this.questions,
395
+ answers: Array.from(this.answers.values()),
396
+ cancelled,
397
+ });
398
+ }
399
+
400
+ private setCollapsed(next: boolean): void {
401
+ if (this.collapsed === next) return;
402
+ this.collapsed = next;
403
+ this.cb.onCollapseChange(next);
404
+ this.invalidate();
405
+ }
406
+
407
+ expandFromShortcut(): void {
408
+ this.setCollapsed(false);
409
+ }
410
+
411
+ private clampScrollToCursor(): void {
412
+ const opts = this.currentOptions();
413
+ if (opts.length === 0) return;
414
+ const viewH = this.optionViewportH;
415
+ const st = this.currentTabState();
416
+ if (st.cursor < st.scrollOffset) st.scrollOffset = st.cursor;
417
+ else if (st.cursor >= st.scrollOffset + viewH) st.scrollOffset = st.cursor - viewH + 1;
418
+ if (st.scrollOffset < 0) st.scrollOffset = 0;
419
+ }
420
+
421
+ // ── input ──
422
+
423
+ handleInput(data: string): void {
424
+ if (matchesKey(data, TOGGLE_KEY)) {
425
+ this.setCollapsed(!this.collapsed);
426
+ return;
427
+ }
428
+
429
+ if (this.collapsed) {
430
+ if (matchesKey(data, Key.escape)) this.submit(true);
431
+ return;
432
+ }
433
+
434
+ // ── Review mode: list all answers, Enter submits, Tab/↑↓ jump to a question ──
435
+ if (this.reviewMode) {
436
+ return this.handleReviewInput(data);
437
+ }
438
+
439
+ const st = this.currentTabState();
440
+
441
+ // "Type something." input mode: ALL editing keys (Tab, arrows, etc.) go to
442
+ // the editor. Only Esc (exit) is handled here. Tab is NOT hijacked for
443
+ // question switching — that would break indentation / cursor movement.
444
+ // To switch questions, press Esc first to return to the option list.
445
+ if (st.inputMode) {
446
+ if (matchesKey(data, Key.escape)) {
447
+ st.inputMode = false;
448
+ // Keep the editor content (per-tab editor preserves it as draft).
449
+ this.invalidate();
450
+ return;
451
+ }
452
+ st.editor.handleInput(data);
453
+ this.invalidate();
454
+ return;
455
+ }
456
+
457
+ if (matchesKey(data, Key.escape)) {
458
+ this.submit(true);
459
+ return;
460
+ }
461
+
462
+ const q = this.currentQuestion();
463
+ if (!q) return;
464
+ const opts = this.currentOptions();
465
+ const multi = isMulti(q);
466
+
467
+ // Tab navigation
468
+ if (this.questions.length > 1) {
469
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
470
+ // Forward navigation: a required (allowSkip:false) question cannot be
471
+ // left unanswered, so block + record-skip is gated on markSkippedIfNeeded.
472
+ if (!this.markSkippedIfNeeded()) return;
473
+ this.switchTab(wrapTab(this.currentTab + 1, this.questions.length));
474
+ return;
475
+ }
476
+ if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
477
+ // Backward navigation is always allowed — reviewing/editing earlier
478
+ // questions doesn't let the user bypass a required question (forward
479
+ // nav re-checks when they come back forward).
480
+ this.switchTab(wrapTab(this.currentTab - 1, this.questions.length));
481
+ return;
482
+ }
483
+ }
484
+
485
+ // Up / Down — moves ONLY the cursor (▸), does not change selection
486
+ if (matchesKey(data, Key.up)) {
487
+ if (st.cursor > 0) {
488
+ st.cursor--;
489
+ this.clampScrollToCursor();
490
+ this.invalidate();
491
+ }
492
+ return;
493
+ }
494
+ if (matchesKey(data, Key.down)) {
495
+ if (st.cursor < opts.length - 1) {
496
+ st.cursor++;
497
+ this.clampScrollToCursor();
498
+ this.invalidate();
499
+ }
500
+ return;
501
+ }
502
+ if (matchesKey(data, Key.pageUp)) {
503
+ st.cursor = Math.max(0, st.cursor - Math.max(1, this.optionViewportH));
504
+ this.clampScrollToCursor();
505
+ this.invalidate();
506
+ return;
507
+ }
508
+ if (matchesKey(data, Key.pageDown)) {
509
+ st.cursor = Math.min(opts.length - 1, st.cursor + Math.max(1, this.optionViewportH));
510
+ this.clampScrollToCursor();
511
+ this.invalidate();
512
+ return;
513
+ }
514
+
515
+ // Multi-select: space toggles the option under the cursor
516
+ if (multi && matchesKey(data, Key.space)) {
517
+ const opt = opts[st.cursor];
518
+ if (opt && !opt.isOther) {
519
+ if (st.multiChecked.has(st.cursor)) st.multiChecked.delete(st.cursor);
520
+ else st.multiChecked.add(st.cursor);
521
+ this.invalidate();
522
+ }
523
+ return;
524
+ }
525
+
526
+ // Single-select: space commits the selection WITHOUT advancing (stay on question)
527
+ if (!multi && matchesKey(data, Key.space)) {
528
+ const opt = opts[st.cursor];
529
+ if (opt && !opt.isOther) {
530
+ st.selectedSingle = st.cursor;
531
+ this.answers.set(q.id, {
532
+ id: q.id,
533
+ value: opt.value,
534
+ label: opt.label,
535
+ wasCustom: false,
536
+ index: st.cursor,
537
+ });
538
+ this.invalidate();
539
+ }
540
+ return;
541
+ }
542
+
543
+ // Confirm
544
+ if (matchesKey(data, Key.enter)) {
545
+ const opt = opts[st.cursor];
546
+ if (!opt) return;
547
+ if (opt.isOther) {
548
+ st.inputMode = true;
549
+ // Prefill the editor with the committed custom text (if any) so the
550
+ // user can edit rather than retype. Per-tab editor keeps the text
551
+ // for Esc-discard semantics automatically.
552
+ const existing = this.answers.get(q.id);
553
+ if (existing?.wasCustom && existing.value) {
554
+ st.editor.setText(existing.value);
555
+ }
556
+ this.invalidate();
557
+ return;
558
+ }
559
+ if (multi) {
560
+ if (!st.multiChecked.has(st.cursor)) st.multiChecked.add(st.cursor);
561
+ const picked = Array.from(st.multiChecked)
562
+ .sort((a, b) => a - b)
563
+ .map((i) => opts[i])
564
+ .filter((o): o is RenderOption => !!o && !o.isOther);
565
+ if (picked.length === 0) return;
566
+ this.answers.set(q.id, {
567
+ id: q.id,
568
+ value: "",
569
+ values: picked.map((o) => o.value),
570
+ label: "",
571
+ labels: picked.map((o) => o.label),
572
+ wasCustom: false,
573
+ multiSelect: true,
574
+ });
575
+ this.advanceAfterAnswer();
576
+ return;
577
+ }
578
+ // single-select: commit cursor position as the selection
579
+ st.selectedSingle = st.cursor;
580
+ this.answers.set(q.id, {
581
+ id: q.id,
582
+ value: opt.value,
583
+ label: opt.label,
584
+ wasCustom: false,
585
+ index: st.cursor,
586
+ });
587
+ this.advanceAfterAnswer();
588
+ return;
589
+ }
590
+ }
591
+
592
+ /** Switch tab. Each tab owns its own Editor instance, so draft preservation
593
+ * is automatic — no text shuttling required. */
594
+ private switchTab(next: number): void {
595
+ if (next === this.currentTab) return;
596
+ this.currentTab = next;
597
+ this.invalidate();
598
+ }
599
+
600
+ /** Handle input while in review mode. */
601
+ private handleReviewInput(data: string): void {
602
+ const n = this.questions.length;
603
+ if (matchesKey(data, Key.escape)) {
604
+ this.submit(true);
605
+ return;
606
+ }
607
+ if (matchesKey(data, Key.enter)) {
608
+ // Confirm: submit all answers.
609
+ this.submit(false);
610
+ return;
611
+ }
612
+ if (matchesKey(data, Key.tab)) {
613
+ // Jump to the question under the review cursor for editing.
614
+ this.reviewMode = false;
615
+ this.editingFromReview = true;
616
+ this.switchTab(this.reviewCursor);
617
+ return;
618
+ }
619
+ if (matchesKey(data, Key.up)) {
620
+ if (this.reviewCursor > 0) { this.reviewCursor--; this.invalidate(); }
621
+ return;
622
+ }
623
+ if (matchesKey(data, Key.down)) {
624
+ if (this.reviewCursor < n - 1) { this.reviewCursor++; this.invalidate(); }
625
+ return;
626
+ }
627
+ }
628
+
629
+ // ── render ──
630
+
631
+ render(width: number): string[] {
632
+ if (this.collapsed) {
633
+ this.cachedWidth = width;
634
+ this.cachedLines = this.renderCollapsed(width);
635
+ return this.cachedLines;
636
+ }
637
+ if (this.cachedLines && this.cachedWidth === width) {
638
+ return this.cachedLines;
639
+ }
640
+ this.cachedWidth = width;
641
+ this.cachedLines = this.renderExpanded(width);
642
+ return this.cachedLines;
643
+ }
644
+
645
+ private renderCollapsed(width: number): string[] {
646
+ const th = this.theme;
647
+ const tabsPart = this.questions
648
+ .map((q, i) => {
649
+ const label = q.label || `Q${i + 1}`;
650
+ const done = this.answers.has(q.id);
651
+ return th.fg(done ? "success" : "dim", `${label}${done ? "✓" : "○"}`);
652
+ })
653
+ .join(th.fg("dim", " "));
654
+ const inner = `${tabsPart} ${th.fg("dim", ` ${TOGGLE_HINT} expand `)}${th.fg("dim", " Esc cancel ")}`;
655
+ const line =
656
+ th.fg("border", "│") + inner + " ".repeat(Math.max(0, width - 2 - visibleWidth(inner))) + th.fg("border", "│");
657
+ return [truncateToWidth(line, width)];
658
+ }
659
+
660
+ private renderExpanded(width: number): string[] {
661
+ const th = this.theme;
662
+ const innerW = Math.max(20, width - 2);
663
+ const lines: string[] = [];
664
+ const row = (content: string) => th.fg("border", "│") + padRight(content, innerW) + th.fg("border", "│");
665
+
666
+ if (this.reviewMode) {
667
+ return this.renderReview(width, innerW, row, th);
668
+ }
669
+
670
+ lines.push(th.fg("border", `╭${"─".repeat(innerW)}╮`));
671
+
672
+ // ── Tab bar ──
673
+ if (this.questions.length > 1) {
674
+ const tabCells = this.questions.map((q, i) => {
675
+ const label = q.label || `Q${i + 1}`;
676
+ const active = i === this.currentTab;
677
+ const ans = this.answers.get(q.id);
678
+ let mark = " ";
679
+ let baseColor: import("@earendil-works/pi-coding-agent").ThemeColor = active ? "accent" : "muted";
680
+ if (ans?.skipped) { mark = "—"; baseColor = "warning"; }
681
+ else if (ans) { mark = "✓"; baseColor = "success"; }
682
+ else if (active) mark = "▸";
683
+ const color = active ? "accent" : baseColor;
684
+ return th.fg(color, `${mark} ${label}`);
685
+ });
686
+ lines.push(row(` ${tabCells.join(th.fg("dim", " "))}`));
687
+ lines.push(row(""));
688
+ }
689
+
690
+ // ── Question header ──
691
+ const q = this.currentQuestion();
692
+ const multi = isMulti(q);
693
+ const dual = isDualColumn(q);
694
+ const progress = this.questions.length > 1 ? ` [${this.currentTab + 1}/${this.questions.length}]` : "";
695
+ const tag = multi ? th.fg("dim", " (multi)") : "";
696
+ const headerText = truncateToWidth(
697
+ ` ${th.fg("accent", th.bold(q?.header ?? ""))}${tag}${th.fg("dim", progress)}`,
698
+ innerW,
699
+ "",
700
+ );
701
+ lines.push(th.fg("border", "│") + padRight(headerText, innerW) + th.fg("border", "│"));
702
+
703
+ // ── Prompt body ──
704
+ if (q?.prompt) {
705
+ for (const w of wrapTextWithAnsi(th.fg("muted", q.prompt), innerW - 2)) lines.push(row(` ${w}`));
706
+ lines.push(row(""));
707
+ }
708
+
709
+ // ── Body: options / preview / input editor ──
710
+ const st = this.currentTabState();
711
+ if (st.inputMode) {
712
+ for (const el of st.editor.render(innerW - 2)) lines.push(row(` ${el}`));
713
+ lines.push(row(th.fg("dim", " Esc back to options · Enter submit")));
714
+ } else if (dual && q) {
715
+ lines.push(...this.renderDualColumn(q, st, innerW, row, th));
716
+ } else {
717
+ lines.push(...this.renderSingleColumn(st, innerW, row, th));
718
+ }
719
+
720
+ // ── Footer ──
721
+ lines.push(th.fg("border", `├${"─".repeat(innerW)}┤`));
722
+ const doneCount = Array.from(this.answers.keys()).length;
723
+ const left =
724
+ this.questions.length > 1 ? th.fg("dim", ` ${doneCount}/${this.questions.length} answered · `) : th.fg("dim", " ");
725
+ const hint = multi
726
+ ? `${TOGGLE_HINT} collapse · ↑↓ move · Space toggle · Enter confirm · Esc cancel`
727
+ : `${TOGGLE_HINT} collapse · ↑↓ move · Enter confirm · Esc cancel`;
728
+ lines.push(row(`${left}${th.fg("dim", hint)}`));
729
+ lines.push(th.fg("border", `╰${"─".repeat(innerW)}╯`));
730
+ return lines;
731
+ }
732
+
733
+ /** Render the option row glyph. The cursor (▸) is independent of selection. */
734
+ private optionGlyph(opt: RenderOption, index: number, st: TabState, multi: boolean, th: Theme, isCursor: boolean, customAnswered: boolean): string {
735
+ if (opt.isOther) {
736
+ // "Type something." is filled when a custom answer was committed.
737
+ return customAnswered
738
+ ? th.fg("success", ICON_RADIO_FILLED)
739
+ : th.fg("dim", ICON_OTHER);
740
+ }
741
+ if (multi) {
742
+ const checked = st.multiChecked.has(index);
743
+ return checked ? th.fg("success", ICON_CHECK_FILLED) : th.fg("dim", ICON_CHECK_EMPTY);
744
+ }
745
+ // single: filled only when committed (selectedSingle), not on cursor hover
746
+ const filled = st.selectedSingle === index;
747
+ return filled ? th.fg("success", ICON_RADIO_FILLED) : th.fg("dim", ICON_RADIO_EMPTY);
748
+ }
749
+
750
+ /** Format an answer for the review summary: comma-joined, truncated with “…” if too long. */
751
+ private formatAnswerText(ans: Answer | undefined, maxW: number, th: Theme): string {
752
+ if (!ans) return th.fg("dim", "(no answer)");
753
+ if (ans.skipped) return th.fg("warning", "(skipped)");
754
+ let text: string;
755
+ if (ans.multiSelect) text = (ans.labels?.length ? ans.labels : ans.values ?? []).join(", ");
756
+ else text = ans.wasCustom ? ans.value : (ans.label || ans.value);
757
+ const vw = visibleWidth(text);
758
+ if (vw <= maxW) return th.fg("text", text);
759
+ // truncate: keep prefix, append “…”
760
+ const cut = truncateToWidth(text, maxW - 1, "");
761
+ return th.fg("text", cut) + th.fg("dim", "…");
762
+ }
763
+
764
+ /** Review summary: one row per question showing its answer. */
765
+ private renderReview(width: number, innerW: number, row: (s: string) => string, th: Theme): string[] {
766
+ const lines: string[] = [];
767
+ lines.push(th.fg("border", `╭${"─".repeat(innerW)}╮`));
768
+ lines.push(row(` ${th.fg("accent", th.bold("Review your answers"))}`));
769
+ lines.push(th.fg("border", `├${"─".repeat(innerW)}┤`));
770
+ const n = this.questions.length;
771
+ for (let i = 0; i < n; i++) {
772
+ const q = this.questions[i]!;
773
+ const isCursor = i === this.reviewCursor;
774
+ const ans = this.answers.get(q.id);
775
+ const prefix = isCursor ? `${th.fg("accent", ICON_CURSOR)} ` : " ";
776
+ const header = q.header;
777
+ const labelW = Math.min(20, innerW - 4);
778
+ const headerStr = truncateToWidth(header, labelW, "");
779
+ const headerColor = isCursor ? "accent" : "muted";
780
+ const ansW = innerW - labelW - 5; // prefix + ": " + answer + padding
781
+ const ansStr = this.formatAnswerText(ans, Math.max(10, ansW), th);
782
+ lines.push(row(` ${prefix}${th.fg(headerColor, headerStr)}${th.fg("dim", ": ")}${ansStr}`));
783
+ }
784
+ lines.push(th.fg("border", `├${"─".repeat(innerW)}┤`));
785
+ lines.push(row(th.fg("dim", " ↑↓ navigate · Tab edit selected · Enter confirm · Esc cancel")));
786
+ lines.push(th.fg("border", `╰${"─".repeat(innerW)}╯`));
787
+ return lines;
788
+ }
789
+
790
+ /** Single-column layout: option label + wrapped description below. */
791
+ private renderSingleColumn(st: TabState, innerW: number, row: (s: string) => string, th: Theme): string[] {
792
+ const q = this.currentQuestion()!;
793
+ const multi = isMulti(q);
794
+ const opts = this.currentOptions();
795
+ const maxRows = Math.max(3, Math.min(opts.length, 10));
796
+ this.optionViewportH = maxRows;
797
+ this.clampScrollToCursor();
798
+ const start = st.scrollOffset;
799
+ const end = Math.min(opts.length, start + maxRows);
800
+ const out: string[] = [];
801
+ for (let i = start; i < end; i++) {
802
+ const opt = opts[i]!;
803
+ const isCursor = i === st.cursor;
804
+ const prefix = isCursor ? `${th.fg("accent", ICON_CURSOR)} ` : " ";
805
+ const customAnswered = !multi && !!this.answers.get(q.id)?.wasCustom;
806
+ const glyph = this.optionGlyph(opt, i, st, multi, th, isCursor, customAnswered);
807
+ // For "Type something.", show the committed text instead of the placeholder.
808
+ const displayLabel = opt.isOther && customAnswered ? this.answers.get(q.id)!.value : opt.label;
809
+ const labelColor = isCursor ? "accent" : opt.isOther ? (customAnswered ? "text" : "dim") : "text";
810
+ const labelText = th.fg(labelColor, displayLabel);
811
+ out.push(row(` ${prefix}${glyph} ${labelText}`));
812
+ if (opt.description) {
813
+ out.push(...this.renderDescription(opt.description, isCursor, innerW, row, th));
814
+ }
815
+ }
816
+ if (opts.length > maxRows) {
817
+ out.push(row(th.fg("dim", ` ↑↓/PgUp/PgDn scroll · ${start + 1}-${end}/${opts.length}`)));
818
+ }
819
+ return out;
820
+ }
821
+
822
+ /**
823
+ * Two-column layout (options | preview), each half-width. Triggered when any
824
+ * option of the question carries a `preview` field. The right pane shows the
825
+ * preview of the option currently under the cursor.
826
+ */
827
+ private renderDualColumn(
828
+ q: Question,
829
+ st: TabState,
830
+ innerW: number,
831
+ row: (s: string) => string,
832
+ th: Theme,
833
+ ): string[] {
834
+ const multi = isMulti(q);
835
+ const opts = this.currentOptions();
836
+ const halfW = Math.floor((innerW - 2) / 2); // 1-space gutter between columns
837
+ const leftW = halfW;
838
+ const rightW = innerW - 2 - halfW;
839
+ const maxRows = Math.max(3, Math.min(opts.length, 10));
840
+ this.optionViewportH = maxRows;
841
+ this.clampScrollToCursor();
842
+ const start = st.scrollOffset;
843
+ const end = Math.min(opts.length, start + maxRows);
844
+
845
+ // ── build left column lines (options) ──
846
+ const leftLines: string[] = [];
847
+ const customAnswered = !multi && !!this.answers.get(q.id)?.wasCustom;
848
+ for (let i = start; i < end; i++) {
849
+ const opt = opts[i]!;
850
+ const isCursor = i === st.cursor;
851
+ const prefix = isCursor ? `${th.fg("accent", ICON_CURSOR)} ` : " ";
852
+ const glyph = this.optionGlyph(opt, i, st, multi, th, isCursor, customAnswered);
853
+ const displayLabel = opt.isOther && customAnswered ? this.answers.get(q.id)!.value : opt.label;
854
+ const labelColor = isCursor ? "accent" : opt.isOther ? (customAnswered ? "text" : "dim") : "text";
855
+ const labelLine = `${prefix}${glyph} ${th.fg(labelColor, displayLabel)}`;
856
+ leftLines.push(truncateToWidth(labelLine, leftW - 1, ""));
857
+ }
858
+ if (opts.length > maxRows) {
859
+ leftLines.push(th.fg("dim", truncateToWidth(`${start + 1}-${end}/${opts.length}`, leftW - 1, "")));
860
+ }
861
+
862
+ // ── build right column lines (preview of cursor option) ──
863
+ const rightLines: string[] = [];
864
+ const cursorOpt = opts[st.cursor];
865
+ if (cursorOpt?.preview) {
866
+ // Render preview verbatim (preserve ASCII layout), truncate to rightW.
867
+ for (const ln of cursorOpt.preview.split("\n")) {
868
+ rightLines.push(th.fg("muted", truncateToWidth(ln, rightW - 1, "")));
869
+ }
870
+ } else if (cursorOpt?.description) {
871
+ // Fall back to wrapped description if no preview.
872
+ for (const ln of wrapTextWithAnsi(th.fg("muted", cursorOpt.description), rightW - 1)) {
873
+ rightLines.push(ln);
874
+ }
875
+ } else {
876
+ rightLines.push(th.fg("dim", truncateToWidth("(no preview)", rightW - 1, "")));
877
+ }
878
+
879
+ // ── merge columns side by side, padding the shorter one ──
880
+ const rowCount = Math.max(leftLines.length, rightLines.length);
881
+ const out: string[] = [];
882
+ for (let r = 0; r < rowCount; r++) {
883
+ const l = padRight(leftLines[r] ?? "", leftW);
884
+ const rr = padRight(rightLines[r] ?? "", rightW);
885
+ out.push(row(` ${l} ${rr}`));
886
+ }
887
+ return out;
888
+ }
889
+
890
+ /**
891
+ * Render an option description in single-column mode. Multi-line (newline-
892
+ * containing) descriptions render verbatim as a fixed-width block.
893
+ */
894
+ private renderDescription(
895
+ description: string,
896
+ selected: boolean,
897
+ innerW: number,
898
+ row: (s: string) => string,
899
+ th: Theme,
900
+ ): string[] {
901
+ const indent = " ";
902
+ const color = selected ? "muted" : "dim";
903
+ if (description.includes("\n")) {
904
+ const maxW = innerW - 2 - indent.length;
905
+ return description.split("\n").map((ln) => row(`${indent}${truncateToWidth(th.fg(color, ln), maxW, "")}`));
906
+ }
907
+ return wrapTextWithAnsi(`${indent}${th.fg(color, description)}`, innerW - 2).map((w) => row(w));
908
+ }
909
+
910
+ invalidate(): void {
911
+ this.cachedWidth = undefined;
912
+ this.cachedLines = undefined;
913
+ this.tui.requestRender();
914
+ }
915
+ }
916
+
917
+ // ────────────────────────────────────────────────────────────────────────────
918
+ // Extension entry
919
+ // ────────────────────────────────────────────────────────────────────────────
920
+
921
+ export default function askUserExtension(pi: ExtensionAPI) {
922
+ let activeExpand: (() => void) | null = null;
923
+
924
+ pi.registerShortcut(TOGGLE_KEY, {
925
+ description: "Expand the ask-user panel (when collapsed)",
926
+ handler: () => {
927
+ activeExpand?.();
928
+ },
929
+ });
930
+
931
+ pi.registerTool({
932
+ name: "ask_user",
933
+ label: "Ask User",
934
+ description:
935
+ "Ask the user one or more questions with options. Supports single-select (◎→◉) and multi-select (□→▣, space toggles), per-question 'Type something.' custom input with draft preserved across tab switches, and rich per-option previews (ASCII diagrams render verbatim in a side column when any option has a `preview` field). The panel is collapsible (Ctrl+\\). Use for clarifying requirements, getting preferences, or confirming decisions.",
936
+ parameters: AskUserParams,
937
+
938
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
939
+ if (!ctx.hasUI) {
940
+ return errorResult("Error: UI not available (running in non-interactive mode)");
941
+ }
942
+ if (params.questions.length === 0) {
943
+ return errorResult("Error: No questions provided");
944
+ }
945
+
946
+ // Default id: use `header` when unique, else fall back to `q{n}`.
947
+ // This keeps the result summary readable for both the LLM and the user
948
+ // (e.g. "颜色: 红色" instead of "q1: 红色").
949
+ const headerCounts = new Map<string, number>();
950
+ for (const q of params.questions) {
951
+ const h = q.id ? null : (q.header ?? "");
952
+ if (h) headerCounts.set(h, (headerCounts.get(h) ?? 0) + 1);
953
+ }
954
+ const questions: Question[] = params.questions.map((q, i) => ({
955
+ ...q,
956
+ id: q.id || (q.header && (headerCounts.get(q.header) ?? 0) === 1 ? q.header : `q${i + 1}`),
957
+ label: q.label || `Q${i + 1}`,
958
+ allowOther: q.allowOther !== false,
959
+ options: q.options.map((o) => ({ ...o, value: o.value ?? o.label })),
960
+ }));
961
+
962
+ let overlayHandle: { focus: () => void; unfocus: () => void } | null = null;
963
+ let panel: AskUserPanel | null = null;
964
+
965
+ const result = await ctx.ui.custom<AskUserResult>((tui, theme, _kb, done) => {
966
+ panel = new AskUserPanel(questions, tui, theme, {
967
+ onResult: (r) => done(r),
968
+ onCollapseChange: (collapsed) => {
969
+ if (collapsed) {
970
+ overlayHandle?.unfocus();
971
+ activeExpand = () => {
972
+ if (!panel) return;
973
+ panel.expandFromShortcut();
974
+ overlayHandle?.focus();
975
+ };
976
+ } else {
977
+ activeExpand = null;
978
+ overlayHandle?.focus();
979
+ }
980
+ },
981
+ });
982
+ return panel;
983
+ }, {
984
+ overlay: true,
985
+ overlayOptions: {
986
+ anchor: "bottom-center",
987
+ width: "100%",
988
+ margin: { bottom: 0 },
989
+ },
990
+ onHandle: (handle) => {
991
+ overlayHandle = handle;
992
+ handle.focus();
993
+ },
994
+ });
995
+
996
+ activeExpand = null;
997
+
998
+ const summary = result.cancelled
999
+ ? `User cancelled the question(s). ${result.answers.length} question(s) were answered before cancellation.`
1000
+ : result.answers
1001
+ .map((a) => {
1002
+ if (a.skipped) return `${a.id}: (skipped)`;
1003
+ if (a.multiSelect) return `${a.id}: ${a.labels?.join(", ") ?? a.values?.join(", ") ?? ""}`;
1004
+ return `${a.id}: ${a.wasCustom ? "(custom) " : ""}${a.label || a.value}`;
1005
+ })
1006
+ .join("\n");
1007
+
1008
+ return {
1009
+ content: [{ type: "text", text: summary }],
1010
+ details: result,
1011
+ };
1012
+ },
1013
+ });
1014
+ }