@d3ara1n/pi-ask-user 0.1.0 → 2.0.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 +85 -30
  2. package/package.json +2 -1
  3. package/src/index.ts +316 -142
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @d3ara1n/pi-ask-user
2
2
 
3
- A collapsible **ask-user** tool for [pi](https://github.com/earendil-works/pi-mono).
3
+ A collapsible **ask-user** tool for [pi](https://github.com/earendil-works/pi).
4
4
 
5
5
  ## Why
6
6
 
@@ -30,10 +30,11 @@ This tool fixes that:
30
30
  "questions": [
31
31
  {
32
32
  "header": "Which layout?",
33
+ "tab": "layout",
33
34
  "prompt": "Pick the layout for the new settings page.",
34
35
  "options": [
35
- { "value": "sidebar", "label": "Sidebar", "description": "Nav on the left…" },
36
- { "value": "tabs", "label": "Tabs", "description": "Top tabs…" }
36
+ { "label": "Sidebar", "description": "Nav on the left…" },
37
+ { "label": "Tabs", "description": "Top tabs…" }
37
38
  ]
38
39
  }
39
40
  ]
@@ -47,11 +48,9 @@ This tool fixes that:
47
48
  | Field | Type | Required | Description |
48
49
  |-------|------|----------|-------------|
49
50
  | `header` | string | yes | Short title shown in the panel header |
51
+ | `tab` | string | yes | Short keyword identifying this question. Shown on the tab bar when there are multiple questions, and returned in the result as the answer's prefix. Write it in the user's language, not as a programmatic identifier. Must be unique across all questions in one call |
50
52
  | `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
53
  | `prompt` | string | no | Longer body text under the header |
54
- | `allowOther` | boolean | no | Allow "Type something." custom input. Default `true` |
55
54
  | `multiSelect` | boolean | no | Check multiple options. Default `false` |
56
55
  | `allowSkip` | boolean | no | If `false`, the user MUST answer before proceeding. Default `true` |
57
56
 
@@ -60,9 +59,8 @@ This tool fixes that:
60
59
  | Field | Type | Required | Description |
61
60
  |-------|------|----------|-------------|
62
61
  | `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 |
62
+ | `description` | string | no | Short explanation under the label (wraps). Add one when the label alone isn't self-explanatory |
63
+ | `preview` | string | no | Use when `description` (a short one-liner) isn't enough and the user genuinely benefits from more detail in a side column — ASCII layout demo, code skeleton, Pro/Cons breakdown, or the reasoning behind the option and what choosing it entails. Rendered verbatim. Don't treat it as extra text capacity — every line competes for the user's attention. If a short `description` already conveys the option, leave empty. Most options need only `description` |
66
64
 
67
65
  ### Icons
68
66
 
@@ -84,8 +82,9 @@ Moving `↑`/`↓` only moves the cursor; selection is committed separately.
84
82
 
85
83
  ### Custom input ("Type something.")
86
84
 
87
- When `allowOther` is `true` (default), a "Type something." row appears. Press
88
- `Enter` on it to open a text editor:
85
+ Every question always shows a "Type something." row (this cannot be turned off),
86
+ so the user is never locked into the provided options. Press `Enter` on it to
87
+ open a text editor:
89
88
 
90
89
  - After submitting, the row displays the committed text with a filled glyph
91
90
  (`◉ ✎ your text`).
@@ -94,33 +93,45 @@ When `allowOther` is `true` (default), a "Type something." row appears. Press
94
93
  - `Esc` discards the edit (the original answer is kept); `Enter` confirms the
95
94
  change (the answer is updated).
96
95
 
96
+ **Multi-select + custom input.** In `multiSelect` mode the custom text is an
97
+ *extra* entry kept alongside the checked options — it never overwrites them.
98
+ You can check several options, then open "Type something.", type a value, and
99
+ submit: both the checks and the custom text are preserved and returned
100
+ together. The same holds in reverse — edit the question later from the review
101
+ screen to add or remove checks and the committed custom text stays intact
102
+ (and vice versa). Submitting an empty custom value clears only the custom
103
+ entry; any remaining checks are kept.
104
+
97
105
  ### Required questions
98
106
 
99
107
  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.
108
+ (`Tab`/`→`) until they answer; the built-in "Type something." row always lets
109
+ them supply a custom answer, so they're never trapped by options they dislike.
110
+ Backward navigation (`Shift+Tab`/`←`) is always allowed so they can review/edit
111
+ earlier questions.
102
112
 
103
113
  ### Rich previews
104
114
 
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:
115
+ `description` is the default way to explain an option; reserve `preview` for
116
+ the rare case where a description can't fully convey it. If **any** option of
117
+ a question carries a `preview` field, that question renders in **two columns**:
118
+ option list on the left, the focused option's preview on the right. Moving the
119
+ cursor updates the right pane. Ideal for comparing ASCII layouts / code samples:
109
120
 
110
121
  ```jsonc
111
122
  {
112
- "id": "layout",
113
123
  "header": "Which layout?",
124
+ "tab": "layout",
114
125
  "options": [
115
126
  {
116
127
  "label": "Sidebar",
117
- "value": "sidebar",
118
- "preview": "┌──┬────────┐\n│导│ 正文 │\n│航│ │\n└──┴────────┘\n左侧固定导航"
128
+ "description": "Left-side navigation with the main content to its right.",
129
+ "preview": "┌──┬────────┐\n│NA│ body │\n│V │ │\n└──┴────────┘\nleft sidebar nav"
119
130
  },
120
131
  {
121
132
  "label": "Top bar",
122
- "value": "topbar",
123
- "preview": "┌──────────────┐\n│ 导航条 │\n├──────────────┤\n│ 正文 │\n└──────────────┘\n顶部横向导航"
133
+ "description": "Top horizontal nav with the main content below.",
134
+ "preview": "┌──────────────┐\n│ nav bar │\n├──────────────┤\n│ body │\n└──────────────┘\ntop horizontal nav"
124
135
  }
125
136
  ]
126
137
  }
@@ -133,17 +144,61 @@ contains a newline renders verbatim as a fixed-width block.
133
144
 
134
145
  After the last question is answered, a **review screen** lists every question
135
146
  and its answer (multi-select answers are comma-joined and truncated with `…`
136
- when too long; skipped questions show `(skipped)`):
147
+ when too long; skipped questions show `(skipped)`). Each question entry spans
148
+ two rows (header + answer), followed by a trailing **note entry**:
149
+
150
+ ```
151
+ ▸ 1. Which layout?
152
+ Sidebar
153
+ 2. Which database?
154
+ Postgres
155
+
156
+ ✎ Note to assistant
157
+ (optional — Tab to add a note)
158
+ ```
137
159
 
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
160
+ Each title carries a fixed-width marker (`1.`/`2.`… for questions, `✎ ` for
161
+ the note) so every title aligns; the body is indented one level deeper to keep
162
+ header vs content visually distinct. An empty line sets the note apart from
163
+ the Q&A list above it.
164
+
165
+ Each title carries a fixed-width marker (`1.`/`2.`… for questions, `✎ ` for
166
+ the note) so every title aligns; the body is indented one level deeper to keep
167
+ header vs content visually distinct.
168
+
169
+ - `↑`/`↓` — move the cursor between entries (questions + the note)
170
+ - `PgUp`/`PgDn` — scroll by page (when there are more entries than rows)
171
+ - `Tab` — edit the focused entry: a question (returns to review after) or the
172
+ note (opens a free-form editor)
173
+ - `Enter` — confirm and submit all answers. `Enter` is always "submit" on the
174
+ review screen, never "edit" — this deliberately differs from the question
175
+ screens (where `Enter` edits/advances) so you can never submit by
176
+ double-tapping `Enter` while trying to edit something. Use `Tab` to edit.
141
177
  - `Esc` — cancel
142
178
 
179
+ ### Note to assistant
180
+
181
+ The review screen ends with a **note** entry — a free-form message the user
182
+ can attach for the assistant, about anything *beyond* the specific questions
183
+ (overall direction, pacing, priorities, a correction to the premise, …).
184
+
185
+ - Move the cursor to the note row and press `Tab` to open the editor.
186
+ - `Enter` saves the note (empty = no note); `Esc` returns to the review without
187
+ saving (the in-progress draft is kept).
188
+ - The note is **out-of-band**: it is not part of `questions`/`options` and the
189
+ assistant cannot request or pre-fill it. It surfaces only in the tool result
190
+ as `message`, and only when non-empty.
191
+
192
+ Because the note can reframe or override the answers, the assistant is told to
193
+ treat it as high-priority context.
194
+
143
195
  ### Result
144
196
 
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.
197
+ The tool returns a summary plus structured `answers`, and optionally a
198
+ `message` (the user's review-screen note, only when non-empty). If the user
199
+ cancelled mid-way, the summary notes how many questions were answered before
200
+ cancellation. When present, the note is appended as a separate `Note from user:`
201
+ block so it's visually distinct from the per-question answers.
147
202
 
148
203
  ## Keys
149
204
 
@@ -152,8 +207,8 @@ mid-way, the message notes how many questions were answered before cancellation.
152
207
  | `↑` `↓` / `PgUp` `PgDn` | Move cursor / scroll options |
153
208
  | `Space` | Commit selection (single: select-only; multi: toggle) |
154
209
  | `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` |
210
+ | `Tab` / `Shift+Tab` | Next / previous question, **cycling** (last → first). Option list only — not hijacked inside the editor |
211
+ | `→` / `←` | Next / previous question, but **stop at the boundary** (no cycle) — safer than Tab when there are many questions |
157
212
  | `Esc` | Cancel (or exit custom-input editor without saving) |
158
213
  | `Ctrl+\` | Collapse / expand the panel |
159
214
 
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-ask-user",
3
- "version": "0.1.0",
3
+ "version": "2.0.0",
4
+ "type": "module",
4
5
  "description": "Ask-user tool for pi — collapsible question overlay that releases focus so you can scroll the transcript while deciding",
5
6
  "main": "src/index.ts",
6
7
  "keywords": [
package/src/index.ts CHANGED
@@ -50,7 +50,6 @@ const ICON_CURSOR = "▸"; // current cursor position, independent of selection
50
50
  // ────────────────────────────────────────────────────────────────────────────
51
51
 
52
52
  interface QuestionOption {
53
- value: string;
54
53
  label: string;
55
54
  description?: string;
56
55
  /** Rich preview shown in the right column when this option is focused. */
@@ -62,26 +61,20 @@ interface RenderOption extends QuestionOption {
62
61
  }
63
62
 
64
63
  interface Question {
65
- id: string;
66
- label?: string;
64
+ tab: string;
67
65
  header: string;
68
66
  prompt?: string;
69
67
  options: QuestionOption[];
70
- allowOther?: boolean;
71
68
  multiSelect?: boolean;
72
69
  allowSkip?: boolean;
73
70
  }
74
71
 
75
72
  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[];
73
+ tab: string;
74
+ /** Single-select: the chosen label. Multi-select: empty string. */
75
+ answerLabel: string;
76
+ /** Multi-select: all chosen labels. Single-select: absent. */
77
+ answerLabels?: string[];
85
78
  wasCustom: boolean;
86
79
  index?: number;
87
80
  /** True for multi-select answers. */
@@ -94,6 +87,8 @@ interface AskUserResult {
94
87
  questions: Question[];
95
88
  answers: Answer[];
96
89
  cancelled: boolean;
90
+ /** Free-form note the user can attach on the review screen. Absent when empty. */
91
+ message?: string;
97
92
  }
98
93
 
99
94
  /** Per-tab ephemeral UI state. Preserved across tab switches. */
@@ -108,6 +103,8 @@ interface TabState {
108
103
  editor: Editor;
109
104
  /** Indices of committed options (multi-select). */
110
105
  multiChecked: Set<number>;
106
+ /** Committed custom text for multi-select mode (kept alongside multiChecked, never overwriting it). Null if none. */
107
+ customText: string | null;
111
108
  /** Committed single-select index (or -1 if none yet, -2 = answered via type-something). */
112
109
  selectedSingle: number;
113
110
  }
@@ -117,49 +114,32 @@ interface TabState {
117
114
  // ────────────────────────────────────────────────────────────────────────────
118
115
 
119
116
  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
117
  label: Type.String({ description: "Short display label for the option (shown on the selection row)" }),
126
118
  description: Type.Optional(
127
119
  Type.String({
128
- description:
129
- "Optional short explanation shown under the label (wraps). For ASCII diagrams/code use `preview`.",
120
+ description: "Short explanation shown under the label (wraps). Add one when the label alone isn't self-explanatory.",
130
121
  }),
131
122
  ),
132
123
  preview: Type.Optional(
133
124
  Type.String({
134
125
  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.",
126
+ "Use this when `description` (a short one-liner) is not enough and the user genuinely benefits from seeing more detail in a side column — e.g. an ASCII layout demo, a code skeleton, a Pro/Cons breakdown, or the reasoning behind why this option is offered and what choosing it entails. Rendered verbatim in a side column (spaces/newlines preserved). Do NOT treat preview as extra text capacity. Every line competes for the user's attention against the option list; only add a preview when the content is worth reading, not just because there's room for more words. If a short `description` already conveys the option, leave preview empty. Most options need only `description`.",
136
127
  }),
137
128
  ),
138
129
  });
139
130
 
140
131
  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
132
  header: Type.String({
147
133
  description: "Short question title shown in the panel header, e.g. 'Which layout?'",
148
134
  }),
149
- label: Type.Optional(
150
- Type.String({
151
- description: "Short tab label for multi-question mode (defaults to Q1, Q2, ...)",
152
- }),
153
- ),
135
+ tab: Type.String({
136
+ description: "Short keyword that identifies this question. Shown on the tab bar when there are multiple questions, and returned in the result as the answer's prefix. Write it in the user's language (e.g. \"数据库\" or \"布局\" in a Chinese conversation, \"Database\" or \"Layout\" in English), not as a programmatic identifier like \"db_choice\". Must be unique across questions in one call." }),
154
137
  prompt: Type.Optional(
155
138
  Type.String({ description: "Optional longer body text shown under the header" }),
156
139
  ),
157
140
  options: Type.Array(QuestionOptionSchema, {
158
- description: "Available options. Pass 2-4; each may carry a description and/or preview.",
141
+ description: "Available options. Pass 2-4; each needs a short `label` + a `description`, and a `preview` only when a description can't fully convey the option.",
159
142
  }),
160
- allowOther: Type.Optional(
161
- Type.Boolean({ description: "Allow a 'Type something.' custom-input option (default: true)" }),
162
- ),
163
143
  multiSelect: Type.Optional(
164
144
  Type.Boolean({
165
145
  description:
@@ -199,12 +179,10 @@ function wrapTab(index: number, total: number): number {
199
179
  return ((index % total) + total) % total;
200
180
  }
201
181
 
202
- /** Build the full option list for a question, appending "Type something." if allowed. */
182
+ /** Build the full option list for a question, always appending the "Type something." custom-input row. */
203
183
  function buildOptions(q: Question): RenderOption[] {
204
184
  const opts: RenderOption[] = [...q.options];
205
- if (q.allowOther !== false) {
206
- opts.push({ value: "__other__", label: "Type something.", isOther: true });
207
- }
185
+ opts.push({ label: "Type something.", isOther: true });
208
186
  return opts;
209
187
  }
210
188
 
@@ -226,7 +204,7 @@ function isDualColumn(q: Question | undefined): boolean {
226
204
  function newTabState(tui: TuiLike, theme: EditorTheme, tabIndex: number, onSubmit: (tabIndex: number, value: string) => void): TabState {
227
205
  const editor = new Editor(tui as never, theme);
228
206
  editor.onSubmit = (value) => onSubmit(tabIndex, value);
229
- return { cursor: 0, scrollOffset: 0, inputMode: false, editor, multiChecked: new Set(), selectedSingle: -1 };
207
+ return { cursor: 0, scrollOffset: 0, inputMode: false, editor, multiChecked: new Set(), customText: null, selectedSingle: -1 };
230
208
  }
231
209
 
232
210
  function errorResult(message: string, questions: Question[] = []): {
@@ -277,9 +255,22 @@ class AskUserPanel implements Component, Focusable {
277
255
  private reviewMode = false;
278
256
  /** Cursor row in the review summary. */
279
257
  private reviewCursor = 0;
258
+ /** Vertical scroll offset for the review viewport. */
259
+ private reviewScrollOffset = 0;
260
+ /** Visible review rows (recomputed each render). */
261
+ private reviewViewportH = 8;
280
262
  /** True after jumping from review mode to edit a question; makes answering
281
263
  * return to review instead of advancing to the next question. */
282
264
  private editingFromReview = false;
265
+ /** True while the user is editing the free-form "note to assistant" on the
266
+ * review screen. While true, all input goes to messageEditor. */
267
+ private messageEditing = false;
268
+ /** Committed note text (trimmed). Empty string = no note. Lives only on the
269
+ * review screen; the LLM cannot set it. */
270
+ private messageText = "";
271
+ /** Dedicated editor for the note. Single-line semantics: Enter saves (like
272
+ * the per-question "Type something." editor). */
273
+ private messageEditor: Editor;
283
274
 
284
275
  // ── render cache ──
285
276
  private cachedWidth?: number;
@@ -306,6 +297,11 @@ class AskUserPanel implements Component, Focusable {
306
297
  // draft-loss bug (previously a single shared editor was swapped in/out and
307
298
  // the swap was lossy across the input-mode / tab-switch boundary).
308
299
  this.tabs = questions.map((_, i) => newTabState(tui, editorTheme, i, (ti, v) => this.handleSubmit(ti, v)));
300
+ // Dedicated editor for the review-screen note. Enter saves (single-line),
301
+ // Esc returns to the review without saving — mirroring the per-question
302
+ // "Type something." editor's semantics.
303
+ this.messageEditor = new Editor(tui as never, editorTheme);
304
+ this.messageEditor.onSubmit = (value) => this.handleMessageSubmit(value);
309
305
  }
310
306
 
311
307
  /** Shared submit logic bound to each tab's editor. */
@@ -315,25 +311,78 @@ class AskUserPanel implements Component, Focusable {
315
311
  if (!q || !st) return;
316
312
  const trimmed = value.trim();
317
313
  if (!trimmed) {
318
- // empty → back to options, keep nothing
314
+ // empty → back to options. In multi-select mode, an empty submit also
315
+ // clears any previously committed custom text (blank = "remove my custom
316
+ // answer"), then re-commits the remaining checked options.
319
317
  st.inputMode = false;
320
318
  st.editor.setText("");
319
+ if (isMulti(q)) {
320
+ st.customText = null;
321
+ if (!this.commitMultiAnswer(q, st)) this.answers.delete(q.tab);
322
+ }
323
+ if (tabIndex === this.currentTab) this.invalidate();
324
+ return;
325
+ }
326
+ if (isMulti(q)) {
327
+ // Multi-select: the custom text is an extra entry kept ALONGSIDE the
328
+ // checked options — it must NOT overwrite them. (Previously this path
329
+ // did answers.set with only the custom text, dropping every check.)
330
+ // Committing custom text only records it — we return to the OPTION LIST
331
+ // (not advance) so the user can keep checking options and then press
332
+ // Enter on an option to confirm the whole question. Advancing here used
333
+ // to jump straight to review the moment the custom editor closed.
334
+ st.customText = trimmed;
335
+ this.commitMultiAnswer(q, st);
336
+ st.inputMode = false;
321
337
  if (tabIndex === this.currentTab) this.invalidate();
322
338
  return;
323
339
  }
324
- this.answers.set(q.id, {
325
- id: q.id,
326
- value: trimmed,
327
- label: trimmed,
340
+ this.answers.set(q.tab, {
341
+ tab: q.tab,
342
+ answerLabel: trimmed,
328
343
  wasCustom: true,
329
344
  });
330
- st.inputMode = false;
331
345
  st.selectedSingle = -2; // sentinel: "answered via type-something"
346
+ st.inputMode = false;
332
347
  if (tabIndex === this.currentTab) {
333
348
  this.advanceAfterAnswer();
334
349
  }
335
350
  }
336
351
 
352
+ /** Save the review-screen note: trim, store, return to review. Empty = no note. */
353
+ private handleMessageSubmit(value: string): void {
354
+ this.messageText = value.trim();
355
+ this.messageEditing = false;
356
+ this.reviewMode = true;
357
+ this.invalidate();
358
+ }
359
+
360
+ /**
361
+ * Multi-select commit: merge the checked options (st.multiChecked) together
362
+ * with the committed custom text (st.customText) into one multi-select
363
+ * answer. Returns false when there is nothing to commit (no checks and no
364
+ * custom text), so the caller can delete the stale answer if desired.
365
+ */
366
+ private commitMultiAnswer(q: Question, st: TabState): boolean {
367
+ const opts = buildOptions(q);
368
+ const picked = Array.from(st.multiChecked)
369
+ .sort((a, b) => a - b)
370
+ .map((i) => opts[i])
371
+ .filter((o): o is RenderOption => !!o && !o.isOther);
372
+ const labels = picked.map((o) => o.label);
373
+ const hasCustom = !!st.customText;
374
+ if (hasCustom && st.customText) labels.push(st.customText);
375
+ if (labels.length === 0) return false;
376
+ this.answers.set(q.tab, {
377
+ tab: q.tab,
378
+ answerLabel: "",
379
+ answerLabels: labels,
380
+ wasCustom: hasCustom,
381
+ multiSelect: true,
382
+ });
383
+ return true;
384
+ }
385
+
337
386
  // ── accessors ──
338
387
 
339
388
  private currentQuestion(): Question | undefined {
@@ -355,6 +404,7 @@ class AskUserPanel implements Component, Focusable {
355
404
  if (this.editingFromReview) {
356
405
  this.editingFromReview = false;
357
406
  this.reviewMode = true;
407
+ this.reviewScrollOffset = 0;
358
408
  this.invalidate();
359
409
  return;
360
410
  }
@@ -365,6 +415,7 @@ class AskUserPanel implements Component, Focusable {
365
415
  // Last question answered: enter review mode instead of submitting.
366
416
  this.reviewMode = true;
367
417
  this.reviewCursor = 0;
418
+ this.reviewScrollOffset = 0;
368
419
  this.invalidate();
369
420
  }
370
421
 
@@ -377,12 +428,11 @@ class AskUserPanel implements Component, Focusable {
377
428
  private markSkippedIfNeeded(): boolean {
378
429
  const q = this.currentQuestion();
379
430
  if (!q) return true;
380
- if (this.answers.has(q.id)) return true;
431
+ if (this.answers.has(q.tab)) return true;
381
432
  if (!canSkip(q)) return false; // required question: block
382
- this.answers.set(q.id, {
383
- id: q.id,
384
- value: "",
385
- label: "",
433
+ this.answers.set(q.tab, {
434
+ tab: q.tab,
435
+ answerLabel: "",
386
436
  wasCustom: false,
387
437
  skipped: true,
388
438
  });
@@ -394,6 +444,9 @@ class AskUserPanel implements Component, Focusable {
394
444
  questions: this.questions,
395
445
  answers: Array.from(this.answers.values()),
396
446
  cancelled,
447
+ // Only attach the note when non-empty. A cancelled submit still carries
448
+ // the note if the user wrote one (it may explain why they cancelled).
449
+ message: this.messageText || undefined,
397
450
  });
398
451
  }
399
452
 
@@ -421,6 +474,20 @@ class AskUserPanel implements Component, Focusable {
421
474
  // ── input ──
422
475
 
423
476
  handleInput(data: string): void {
477
+ // Note editor active: all keys go to the editor; Esc returns to review.
478
+ // (Ctrl+\ collapse is intentionally ignored here — note editing is brief.)
479
+ if (this.messageEditing) {
480
+ if (matchesKey(data, Key.escape)) {
481
+ this.messageEditing = false;
482
+ this.reviewMode = true;
483
+ this.invalidate();
484
+ return;
485
+ }
486
+ this.messageEditor.handleInput(data);
487
+ this.invalidate();
488
+ return;
489
+ }
490
+
424
491
  if (matchesKey(data, TOGGLE_KEY)) {
425
492
  this.setCollapsed(!this.collapsed);
426
493
  return;
@@ -464,22 +531,37 @@ class AskUserPanel implements Component, Focusable {
464
531
  const opts = this.currentOptions();
465
532
  const multi = isMulti(q);
466
533
 
467
- // Tab navigation
534
+ // Tab navigation — Tab/Shift+Tab CYCLE through questions; arrow keys
535
+ // ←/→ navigate the same tabs but STOP at the boundary (no wrap).
536
+ // Cycling arrows was disorienting with many tabs: pressing → on the
537
+ // last question jumped back to the first, making it easy to lose your
538
+ // place and submit by accident. Arrows stopping at the edge fixes that
539
+ // while Tab keeps its familiar cycle for fast traversal.
468
540
  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.
541
+ // Forward (Tab cycles, stops at the last question)
542
+ if (matchesKey(data, Key.tab)) {
472
543
  if (!this.markSkippedIfNeeded()) return;
473
544
  this.switchTab(wrapTab(this.currentTab + 1, this.questions.length));
474
545
  return;
475
546
  }
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).
547
+ if (matchesKey(data, Key.right)) {
548
+ if (!this.markSkippedIfNeeded()) return;
549
+ const next = this.currentTab + 1;
550
+ if (next >= this.questions.length) return; // boundary: stop
551
+ this.switchTab(next);
552
+ return;
553
+ }
554
+ // Backward (Shift+Tab cycles, ← stops at the first question)
555
+ if (matchesKey(data, Key.shift("tab"))) {
480
556
  this.switchTab(wrapTab(this.currentTab - 1, this.questions.length));
481
557
  return;
482
558
  }
559
+ if (matchesKey(data, Key.left)) {
560
+ const prev = this.currentTab - 1;
561
+ if (prev < 0) return; // boundary: stop
562
+ this.switchTab(prev);
563
+ return;
564
+ }
483
565
  }
484
566
 
485
567
  // Up / Down — moves ONLY the cursor (▸), does not change selection
@@ -528,10 +610,9 @@ class AskUserPanel implements Component, Focusable {
528
610
  const opt = opts[st.cursor];
529
611
  if (opt && !opt.isOther) {
530
612
  st.selectedSingle = st.cursor;
531
- this.answers.set(q.id, {
532
- id: q.id,
533
- value: opt.value,
534
- label: opt.label,
613
+ this.answers.set(q.tab, {
614
+ tab: q.tab,
615
+ answerLabel: opt.label,
535
616
  wasCustom: false,
536
617
  index: st.cursor,
537
618
  });
@@ -549,38 +630,26 @@ class AskUserPanel implements Component, Focusable {
549
630
  // Prefill the editor with the committed custom text (if any) so the
550
631
  // user can edit rather than retype. Per-tab editor keeps the text
551
632
  // 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
- }
633
+ // - Single-select: custom text lives in answers[].answerLabel.
634
+ // - Multi-select: it lives in st.customText (kept alongside checks).
635
+ const existing = this.answers.get(q.tab);
636
+ const prefill = multi ? st.customText : existing?.wasCustom ? existing.answerLabel : null;
637
+ if (prefill) st.editor.setText(prefill);
556
638
  this.invalidate();
557
639
  return;
558
640
  }
559
641
  if (multi) {
560
642
  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();
643
+ // Re-commit merges checks + any previously entered custom text, so
644
+ // going back to add/remove a check never drops the custom value.
645
+ if (this.commitMultiAnswer(q, st)) this.advanceAfterAnswer();
576
646
  return;
577
647
  }
578
648
  // single-select: commit cursor position as the selection
579
649
  st.selectedSingle = st.cursor;
580
- this.answers.set(q.id, {
581
- id: q.id,
582
- value: opt.value,
583
- label: opt.label,
650
+ this.answers.set(q.tab, {
651
+ tab: q.tab,
652
+ answerLabel: opt.label,
584
653
  wasCustom: false,
585
654
  index: st.cursor,
586
655
  });
@@ -597,23 +666,45 @@ class AskUserPanel implements Component, Focusable {
597
666
  this.invalidate();
598
667
  }
599
668
 
600
- /** Handle input while in review mode. */
669
+ /** Handle input while in review mode.
670
+ * The review list has N question entries plus one trailing "note to
671
+ * assistant" entry (index N), so the cursor ranges over [0, N]. */
601
672
  private handleReviewInput(data: string): void {
602
673
  const n = this.questions.length;
674
+ const total = n + 1; // include the note entry
603
675
  if (matchesKey(data, Key.escape)) {
604
676
  this.submit(true);
605
677
  return;
606
678
  }
607
679
  if (matchesKey(data, Key.enter)) {
608
- // Confirm: submit all answers.
680
+ // Enter ALWAYS submits the whole review, no matter where the cursor
681
+ // sits. This deliberately differs from the question screens (where
682
+ // Enter edits/advances): in review, Enter = "I'm done, send it". The
683
+ // cursor-position-sensitive action here is Tab (edit selected entry).
609
684
  this.submit(false);
610
685
  return;
611
686
  }
612
687
  if (matchesKey(data, Key.tab)) {
688
+ if (this.reviewCursor === n) {
689
+ // Note entry: open the note editor. Prefill with the committed note
690
+ // (if any) so the user can tweak rather than retype.
691
+ this.reviewMode = false;
692
+ this.messageEditing = true;
693
+ if (this.messageText) this.messageEditor.setText(this.messageText);
694
+ this.invalidate();
695
+ return;
696
+ }
613
697
  // Jump to the question under the review cursor for editing.
698
+ // We set currentTab + invalidate directly rather than calling
699
+ // switchTab(): switchTab early-returns when next === currentTab, which
700
+ // happens whenever the review cursor sits on the already-active
701
+ // question (always true for a single-question call). That early return
702
+ // skipped invalidate(), so the panel kept showing the stale review
703
+ // render and Tab looked dead until ↑/↓ forced a redraw.
614
704
  this.reviewMode = false;
615
705
  this.editingFromReview = true;
616
- this.switchTab(this.reviewCursor);
706
+ this.currentTab = this.reviewCursor;
707
+ this.invalidate();
617
708
  return;
618
709
  }
619
710
  if (matchesKey(data, Key.up)) {
@@ -621,7 +712,17 @@ class AskUserPanel implements Component, Focusable {
621
712
  return;
622
713
  }
623
714
  if (matchesKey(data, Key.down)) {
624
- if (this.reviewCursor < n - 1) { this.reviewCursor++; this.invalidate(); }
715
+ if (this.reviewCursor < total - 1) { this.reviewCursor++; this.invalidate(); }
716
+ return;
717
+ }
718
+ if (matchesKey(data, Key.pageUp)) {
719
+ this.reviewCursor = Math.max(0, this.reviewCursor - Math.max(1, this.reviewViewportH));
720
+ this.invalidate();
721
+ return;
722
+ }
723
+ if (matchesKey(data, Key.pageDown)) {
724
+ this.reviewCursor = Math.min(total - 1, this.reviewCursor + Math.max(1, this.reviewViewportH));
725
+ this.invalidate();
625
726
  return;
626
727
  }
627
728
  }
@@ -646,8 +747,8 @@ class AskUserPanel implements Component, Focusable {
646
747
  const th = this.theme;
647
748
  const tabsPart = this.questions
648
749
  .map((q, i) => {
649
- const label = q.label || `Q${i + 1}`;
650
- const done = this.answers.has(q.id);
750
+ const label = q.tab;
751
+ const done = this.answers.has(q.tab);
651
752
  return th.fg(done ? "success" : "dim", `${label}${done ? "✓" : "○"}`);
652
753
  })
653
754
  .join(th.fg("dim", " "));
@@ -663,8 +764,11 @@ class AskUserPanel implements Component, Focusable {
663
764
  const lines: string[] = [];
664
765
  const row = (content: string) => th.fg("border", "│") + padRight(content, innerW) + th.fg("border", "│");
665
766
 
767
+ if (this.messageEditing) {
768
+ return this.renderMessageEditor(width, innerW, th);
769
+ }
666
770
  if (this.reviewMode) {
667
- return this.renderReview(width, innerW, row, th);
771
+ return this.renderReview(width, innerW, th);
668
772
  }
669
773
 
670
774
  lines.push(th.fg("border", `╭${"─".repeat(innerW)}╮`));
@@ -672,9 +776,9 @@ class AskUserPanel implements Component, Focusable {
672
776
  // ── Tab bar ──
673
777
  if (this.questions.length > 1) {
674
778
  const tabCells = this.questions.map((q, i) => {
675
- const label = q.label || `Q${i + 1}`;
779
+ const label = q.tab;
676
780
  const active = i === this.currentTab;
677
- const ans = this.answers.get(q.id);
781
+ const ans = this.answers.get(q.tab);
678
782
  let mark = " ";
679
783
  let baseColor: import("@earendil-works/pi-coding-agent").ThemeColor = active ? "accent" : "muted";
680
784
  if (ans?.skipped) { mark = "—"; baseColor = "warning"; }
@@ -703,8 +807,10 @@ class AskUserPanel implements Component, Focusable {
703
807
  // ── Prompt body ──
704
808
  if (q?.prompt) {
705
809
  for (const w of wrapTextWithAnsi(th.fg("muted", q.prompt), innerW - 2)) lines.push(row(` ${w}`));
706
- lines.push(row(""));
707
810
  }
811
+ // 空行分隔 header/prompt 与 options。原来只在有 prompt 时才加,导致无
812
+ // prompt 的问题其标题与选项紧贴("粘在一起")。现在无条件加。
813
+ lines.push(row(""));
708
814
 
709
815
  // ── Body: options / preview / input editor ──
710
816
  const st = this.currentTabState();
@@ -752,8 +858,8 @@ class AskUserPanel implements Component, Focusable {
752
858
  if (!ans) return th.fg("dim", "(no answer)");
753
859
  if (ans.skipped) return th.fg("warning", "(skipped)");
754
860
  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);
861
+ if (ans.multiSelect) text = (ans.answerLabels ?? []).join(", ");
862
+ else text = ans.answerLabel;
757
863
  const vw = visibleWidth(text);
758
864
  if (vw <= maxW) return th.fg("text", text);
759
865
  // truncate: keep prefix, append “…”
@@ -761,29 +867,104 @@ class AskUserPanel implements Component, Focusable {
761
867
  return th.fg("text", cut) + th.fg("dim", "…");
762
868
  }
763
869
 
764
- /** Review summary: one row per question showing its answer. */
765
- private renderReview(width: number, innerW: number, row: (s: string) => string, th: Theme): string[] {
870
+ /** Clamp the review scroll offset so the cursor stays visible. The review
871
+ * list has N questions + 1 note entry, so the cursor may equal N. */
872
+ private clampReviewScroll(): void {
873
+ const total = this.questions.length + 1;
874
+ if (total === 0) return;
875
+ const viewH = this.reviewViewportH;
876
+ if (this.reviewCursor < this.reviewScrollOffset) this.reviewScrollOffset = this.reviewCursor;
877
+ else if (this.reviewCursor >= this.reviewScrollOffset + viewH)
878
+ this.reviewScrollOffset = this.reviewCursor - viewH + 1;
879
+ if (this.reviewScrollOffset < 0) this.reviewScrollOffset = 0;
880
+ }
881
+
882
+ /** Review summary: one question per entry (header + answer), plus a trailing
883
+ * "note to assistant" entry. Viewport scrolling reuses the option-screen
884
+ * layout primitives. */
885
+ private renderReview(width: number, innerW: number, th: Theme): string[] {
766
886
  const lines: string[] = [];
767
- lines.push(th.fg("border", `╭${"─".repeat(innerW)}╮`));
887
+ // Review uses a distinct border color (success/green) so it's visually
888
+ // unmistakable as the review/confirm screen — not another question. The
889
+ // question screen keeps the default "border" color.
890
+ const bc: import("@earendil-works/pi-coding-agent").ThemeColor = "success";
891
+ const row = (content: string) => th.fg(bc, "│") + padRight(content, innerW) + th.fg(bc, "│");
892
+ lines.push(th.fg(bc, `╭${"─".repeat(innerW)}╮`));
768
893
  lines.push(row(` ${th.fg("accent", th.bold("Review your answers"))}`));
769
- lines.push(th.fg("border", `├${"─".repeat(innerW)}┤`));
894
+ lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
770
895
  const n = this.questions.length;
771
- for (let i = 0; i < n; i++) {
772
- const q = this.questions[i]!;
896
+ const total = n + 1; // +1 for the note entry
897
+ // Body indent (6 cols): questions and the note carry a 2-visible-col marker
898
+ // (`1.` / `2.` … or `✎ ` for the note) right after the cursor, plus a
899
+ // separator space, so every title starts at the same column. The body is
900
+ // indented one past that so header vs content stay visually distinct —
901
+ // previously the body sat at 5 cols and the note's icon pushed its title
902
+ // out of alignment with the question titles.
903
+ const bodyIndent = " "; // 6 spaces
904
+ const maxW = innerW - 2 - bodyIndent.length;
905
+ this.reviewViewportH = Math.max(3, Math.min(total, 10));
906
+ this.clampReviewScroll();
907
+ const start = this.reviewScrollOffset;
908
+ const end = Math.min(total, start + this.reviewViewportH);
909
+ for (let i = start; i < end; i++) {
773
910
  const isCursor = i === this.reviewCursor;
774
- const ans = this.answers.get(q.id);
775
911
  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}`));
912
+ const headerColor: import("@earendil-works/pi-coding-agent").ThemeColor = isCursor ? "accent" : "muted";
913
+ // marker: a fixed 2-visible-col slot + 1 separator space, so every title
914
+ // (questions + note) aligns regardless of icon width. `1.` is 2 cols;
915
+ // the note's is 1 col, padded to `✎ ` (hence one extra space between
916
+ // and its title the deliberate tradeoff of this layout).
917
+ // ── Note entry (index n): always last, two rows like a question. ──
918
+ if (i === n) {
919
+ // 空行分隔:note 是异类条目(附加留言,非问答),用空行和上方
920
+ // 问答列表隔开。保持简单,不用点线/装饰。
921
+ lines.push(row(""));
922
+ const marker = th.fg(headerColor, `${ICON_OTHER} `);
923
+ lines.push(row(` ${prefix}${marker} ${th.fg(headerColor, "Note to assistant")}`));
924
+ const msg = this.messageText;
925
+ if (msg) {
926
+ const vw = visibleWidth(msg);
927
+ const body = vw <= maxW ? msg : `${truncateToWidth(msg, maxW - 1, "")}…`;
928
+ lines.push(row(`${bodyIndent}${th.fg("text", body)}`));
929
+ } else {
930
+ lines.push(row(`${bodyIndent}${th.fg("dim", "(optional — Tab to add a note)")}`));
931
+ }
932
+ continue;
933
+ }
934
+ const q = this.questions[i]!;
935
+ const ans = this.answers.get(q.tab);
936
+ // Header row: cursor + marker + title.
937
+ const marker = th.fg(headerColor, `${i + 1}.`);
938
+ lines.push(row(` ${prefix}${marker} ${th.fg(headerColor, q.header)}`));
939
+ // Answer row: reuse the description renderer's indent/wrap, fed the
940
+ // formatted answer text. Skipped/custom/multi-select all flow through
941
+ // formatAnswerText, so the coloring matches the option screen.
942
+ const ansText = this.formatAnswerText(ans, maxW, th);
943
+ lines.push(row(`${bodyIndent}${ansText}`));
783
944
  }
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)}╯`));
945
+ if (total > this.reviewViewportH) {
946
+ lines.push(row(th.fg("dim", `${bodyIndent}↑↓/PgUp/PgDn scroll · ${start + 1}-${end}/${total}`)));
947
+ }
948
+ lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
949
+ lines.push(row(th.fg("dim", " ↑↓ navigate · Tab edit · Enter confirm · Esc cancel")));
950
+ lines.push(th.fg(bc, `╰${"─".repeat(innerW)}╯`));
951
+ return lines;
952
+ }
953
+
954
+ /** Note editor screen: reached from the review's note entry via Tab. Uses
955
+ * the same success-bordered look as the review screen to signal it's part
956
+ * of the review flow, not a fresh question. */
957
+ private renderMessageEditor(width: number, innerW: number, th: Theme): string[] {
958
+ const lines: string[] = [];
959
+ const bc: import("@earendil-works/pi-coding-agent").ThemeColor = "success";
960
+ const row = (content: string) => th.fg(bc, "│") + padRight(content, innerW) + th.fg(bc, "│");
961
+ lines.push(th.fg(bc, `╭${"─".repeat(innerW)}╮`));
962
+ lines.push(row(` ${th.fg("accent", th.bold(`${ICON_OTHER} Note to assistant`))}`));
963
+ lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
964
+ for (const el of this.messageEditor.render(innerW - 2)) lines.push(row(` ${el}`));
965
+ lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
966
+ lines.push(row(th.fg("dim", " Esc back to review · Enter save note")));
967
+ lines.push(th.fg(bc, `╰${"─".repeat(innerW)}╯`));
787
968
  return lines;
788
969
  }
789
970
 
@@ -802,10 +983,12 @@ class AskUserPanel implements Component, Focusable {
802
983
  const opt = opts[i]!;
803
984
  const isCursor = i === st.cursor;
804
985
  const prefix = isCursor ? `${th.fg("accent", ICON_CURSOR)} ` : " ";
805
- const customAnswered = !multi && !!this.answers.get(q.id)?.wasCustom;
986
+ const ans = this.answers.get(q.tab);
987
+ const committedCustom = multi ? st.customText : ans?.wasCustom ? ans.answerLabel : null;
988
+ const customAnswered = !!committedCustom;
806
989
  const glyph = this.optionGlyph(opt, i, st, multi, th, isCursor, customAnswered);
807
990
  // 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;
991
+ const displayLabel = opt.isOther && customAnswered ? committedCustom! : opt.label;
809
992
  const labelColor = isCursor ? "accent" : opt.isOther ? (customAnswered ? "text" : "dim") : "text";
810
993
  const labelText = th.fg(labelColor, displayLabel);
811
994
  out.push(row(` ${prefix}${glyph} ${labelText}`));
@@ -844,13 +1027,15 @@ class AskUserPanel implements Component, Focusable {
844
1027
 
845
1028
  // ── build left column lines (options) ──
846
1029
  const leftLines: string[] = [];
847
- const customAnswered = !multi && !!this.answers.get(q.id)?.wasCustom;
1030
+ const dualAns = this.answers.get(q.tab);
1031
+ const dualCommittedCustom = multi ? st.customText : dualAns?.wasCustom ? dualAns.answerLabel : null;
1032
+ const customAnswered = !!dualCommittedCustom;
848
1033
  for (let i = start; i < end; i++) {
849
1034
  const opt = opts[i]!;
850
1035
  const isCursor = i === st.cursor;
851
1036
  const prefix = isCursor ? `${th.fg("accent", ICON_CURSOR)} ` : " ";
852
1037
  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;
1038
+ const displayLabel = opt.isOther && customAnswered ? dualCommittedCustom! : opt.label;
854
1039
  const labelColor = isCursor ? "accent" : opt.isOther ? (customAnswered ? "text" : "dim") : "text";
855
1040
  const labelLine = `${prefix}${glyph} ${th.fg(labelColor, displayLabel)}`;
856
1041
  leftLines.push(truncateToWidth(labelLine, leftW - 1, ""));
@@ -867,11 +1052,6 @@ class AskUserPanel implements Component, Focusable {
867
1052
  for (const ln of cursorOpt.preview.split("\n")) {
868
1053
  rightLines.push(th.fg("muted", truncateToWidth(ln, rightW - 1, "")));
869
1054
  }
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
1055
  } else {
876
1056
  rightLines.push(th.fg("dim", truncateToWidth("(no preview)", rightW - 1, "")));
877
1057
  }
@@ -932,7 +1112,7 @@ export default function askUserExtension(pi: ExtensionAPI) {
932
1112
  name: "ask_user",
933
1113
  label: "Ask User",
934
1114
  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.",
1115
+ "Ask the user one or more questions with options. Supports single-select (◎→◉) and multi-select (□→▣, space toggles). Every question always includes a 'Type something.' row so the user can type a custom answer whenever none of the provided options fit — this is built in and cannot be disabled, so never assume the user is restricted to your listed options. The custom-input draft is preserved across tab switches, and a focused side panel shows extended detail (ASCII layouts, code, reasoning) when an option carries a `preview` field. Each option needs a short `label` + a `description` (shown beneath it); add a `preview` field only when a description can't fully convey the option. The panel is collapsible (Ctrl+\\). Use for clarifying requirements, getting preferences, or confirming decisions. Avoid using this to pick one item from a long list you just enumerated (e.g. \"which of these 8 fixes should I start with?\"): options are capped at a handful for a reason, and if the choice isn't a real either/or, present the list in a normal message and let the user reply freely, or just proceed with the highest-priority item — reserve ask_user for genuine decisions with a few distinct, mutually-exclusive paths. All displayed user-facing text should use the conversation's language. The result may carry an optional `message` — a free-form note the user can attach on the review screen (about overall direction, pacing, or anything beyond the specific questions). It is user-provided and out-of-band: you cannot set it via the parameters, and it may be absent; when present, treat it as high-priority context that can override or reframe the answers above it.",
936
1116
  parameters: AskUserParams,
937
1117
 
938
1118
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -943,20 +1123,9 @@ export default function askUserExtension(pi: ExtensionAPI) {
943
1123
  return errorResult("Error: No questions provided");
944
1124
  }
945
1125
 
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) => ({
1126
+ const questions: Question[] = params.questions.map((q) => ({
955
1127
  ...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 })),
1128
+ options: q.options.map((o) => ({ ...o })),
960
1129
  }));
961
1130
 
962
1131
  let overlayHandle: { focus: () => void; unfocus: () => void } | null = null;
@@ -999,14 +1168,19 @@ export default function askUserExtension(pi: ExtensionAPI) {
999
1168
  ? `User cancelled the question(s). ${result.answers.length} question(s) were answered before cancellation.`
1000
1169
  : result.answers
1001
1170
  .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}`;
1171
+ if (a.skipped) return `${a.tab}: (skipped)`;
1172
+ if (a.multiSelect) return `${a.tab}: ${a.answerLabels?.join(", ") ?? ""}`;
1173
+ return `${a.tab}: ${a.wasCustom ? "(custom) " : ""}${a.answerLabel}`;
1005
1174
  })
1006
1175
  .join("\n");
1176
+ // Attach the user's free-form note only when present. When empty, say
1177
+ // nothing — "user left no note" is noise the LLM doesn't need.
1178
+ const withNote = result.message
1179
+ ? `${summary}\n\nNote from user:\n${result.message}`
1180
+ : summary;
1007
1181
 
1008
1182
  return {
1009
- content: [{ type: "text", text: summary }],
1183
+ content: [{ type: "text", text: withNote }],
1010
1184
  details: result,
1011
1185
  };
1012
1186
  },