@d3ara1n/pi-ask-user 1.0.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 +73 -30
  2. package/package.json +2 -1
  3. package/src/index.ts +276 -114
package/README.md CHANGED
@@ -30,11 +30,11 @@ This tool fixes that:
30
30
  "questions": [
31
31
  {
32
32
  "header": "Which layout?",
33
- "label": "layout",
33
+ "tab": "layout",
34
34
  "prompt": "Pick the layout for the new settings page.",
35
35
  "options": [
36
- { "value": "sidebar", "label": "Sidebar", "description": "Nav on the left…" },
37
- { "value": "tabs", "label": "Tabs", "description": "Top tabs…" }
36
+ { "label": "Sidebar", "description": "Nav on the left…" },
37
+ { "label": "Tabs", "description": "Top tabs…" }
38
38
  ]
39
39
  }
40
40
  ]
@@ -48,10 +48,9 @@ This tool fixes that:
48
48
  | Field | Type | Required | Description |
49
49
  |-------|------|----------|-------------|
50
50
  | `header` | string | yes | Short title shown in the panel header |
51
- | `label` | string | yes | Short keyword shown on the tab bar and returned to identify this question. Must be unique across all questions in one call |
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 |
52
52
  | `options` | array | yes | 2–4 options |
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
62
  | `description` | string | no | Short explanation under the label (wraps). Add one when the label alone isn't self-explanatory |
65
- | `preview` | string | no | Only add when `description` alone can't make the option clear — ASCII layout demo, code snippet, or detailed reasoning. Most options need only `description`. Triggers a side column when present |
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,11 +93,22 @@ 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
 
@@ -111,19 +121,17 @@ cursor updates the right pane. Ideal for comparing ASCII layouts / code samples:
111
121
  ```jsonc
112
122
  {
113
123
  "header": "Which layout?",
114
- "label": "layout",
124
+ "tab": "layout",
115
125
  "options": [
116
126
  {
117
127
  "label": "Sidebar",
118
- "value": "sidebar",
119
128
  "description": "Left-side navigation with the main content to its right.",
120
- "preview": "┌──┬────────┐\n│导│ 正文 │\n│航│ │\n└──┴────────┘\n左侧固定导航"
129
+ "preview": "┌──┬────────┐\n│NA│ body │\n│V │ │\n└──┴────────┘\nleft sidebar nav"
121
130
  },
122
131
  {
123
132
  "label": "Top bar",
124
- "value": "topbar",
125
133
  "description": "Top horizontal nav with the main content below.",
126
- "preview": "┌──────────────┐\n│ 导航条 │\n├──────────────┤\n│ 正文 │\n└──────────────┘\n顶部横向导航"
134
+ "preview": "┌──────────────┐\n│ nav bar │\n├──────────────┤\n│ body │\n└──────────────┘\ntop horizontal nav"
127
135
  }
128
136
  ]
129
137
  }
@@ -136,26 +144,61 @@ contains a newline renders verbatim as a fixed-width block.
136
144
 
137
145
  After the last question is answered, a **review screen** lists every question
138
146
  and its answer (multi-select answers are comma-joined and truncated with `…`
139
- when too long; skipped questions show `(skipped)`). Each entry spans two rows:
140
- the question header and, below it, the answer:
147
+ when too long; skipped questions show `(skipped)`). Each question entry spans
148
+ two rows (header + answer), followed by a trailing **note entry**:
141
149
 
142
150
  ```
143
- ▸ Which layout?
144
- Sidebar
145
- Which database?
146
- Postgres
151
+ 1. Which layout?
152
+ Sidebar
153
+ 2. Which database?
154
+ Postgres
155
+
156
+ ✎ Note to assistant
157
+ (optional — Tab to add a note)
147
158
  ```
148
159
 
149
- - `↑`/`↓` move the cursor between questions
150
- - `PgUp`/`PgDn` scroll by page (when there are more questions than rows)
151
- - `Tab` jump to the focused question to edit it (returns to the review after)
152
- - `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.
153
177
  - `Esc` — cancel
154
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
+
155
195
  ### Result
156
196
 
157
- The tool returns a summary plus structured `answers`. If the user cancelled
158
- 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.
159
202
 
160
203
  ## Keys
161
204
 
@@ -164,8 +207,8 @@ mid-way, the message notes how many questions were answered before cancellation.
164
207
  | `↑` `↓` / `PgUp` `PgDn` | Move cursor / scroll options |
165
208
  | `Space` | Commit selection (single: select-only; multi: toggle) |
166
209
  | `Enter` | Confirm & advance (single) / commit checked (multi) / enter custom input |
167
- | `Tab` / `Shift+Tab` | Next / previous question (option list only — not hijacked inside the editor) |
168
- | `→` / `←` | 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 |
169
212
  | `Esc` | Cancel (or exit custom-input editor without saving) |
170
213
  | `Ctrl+\` | Collapse / expand the panel |
171
214
 
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-ask-user",
3
- "version": "1.0.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,24 +61,19 @@ interface RenderOption extends QuestionOption {
62
61
  }
63
62
 
64
63
  interface Question {
65
- label: string;
64
+ tab: string;
66
65
  header: string;
67
66
  prompt?: string;
68
67
  options: QuestionOption[];
69
- allowOther?: boolean;
70
68
  multiSelect?: boolean;
71
69
  allowSkip?: boolean;
72
70
  }
73
71
 
74
72
  interface Answer {
75
- label: string;
76
- /** Single-select: the chosen value. Multi-select: empty string. */
77
- value: string;
78
- /** Multi-select: the chosen values. Single-select: absent. */
79
- values?: string[];
80
- /** Single-select answer label. */
73
+ tab: string;
74
+ /** Single-select: the chosen label. Multi-select: empty string. */
81
75
  answerLabel: string;
82
- /** Multi-select answer labels. */
76
+ /** Multi-select: all chosen labels. Single-select: absent. */
83
77
  answerLabels?: string[];
84
78
  wasCustom: boolean;
85
79
  index?: number;
@@ -93,6 +87,8 @@ interface AskUserResult {
93
87
  questions: Question[];
94
88
  answers: Answer[];
95
89
  cancelled: boolean;
90
+ /** Free-form note the user can attach on the review screen. Absent when empty. */
91
+ message?: string;
96
92
  }
97
93
 
98
94
  /** Per-tab ephemeral UI state. Preserved across tab switches. */
@@ -107,6 +103,8 @@ interface TabState {
107
103
  editor: Editor;
108
104
  /** Indices of committed options (multi-select). */
109
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;
110
108
  /** Committed single-select index (or -1 if none yet, -2 = answered via type-something). */
111
109
  selectedSingle: number;
112
110
  }
@@ -116,11 +114,6 @@ interface TabState {
116
114
  // ────────────────────────────────────────────────────────────────────────────
117
115
 
118
116
  const QuestionOptionSchema = Type.Object({
119
- value: Type.Optional(
120
- Type.String({
121
- description: "Value returned when this option is chosen. Defaults to `label` if omitted.",
122
- }),
123
- ),
124
117
  label: Type.String({ description: "Short display label for the option (shown on the selection row)" }),
125
118
  description: Type.Optional(
126
119
  Type.String({
@@ -130,7 +123,7 @@ const QuestionOptionSchema = Type.Object({
130
123
  preview: Type.Optional(
131
124
  Type.String({
132
125
  description:
133
- "Only add this when `description` alone can't make the option clear — e.g. an ASCII layout demo, a code snippet, or detailed reasoning. Omit it otherwise; most options need only `description`. Triggers a side column when present.",
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`.",
134
127
  }),
135
128
  ),
136
129
  });
@@ -139,17 +132,14 @@ const QuestionSchema = Type.Object({
139
132
  header: Type.String({
140
133
  description: "Short question title shown in the panel header, e.g. 'Which layout?'",
141
134
  }),
142
- label: Type.String({
143
- description: "Short keyword shown on the tab bar and returned to identify this question. Must be unique across all questions in one call." }),
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." }),
144
137
  prompt: Type.Optional(
145
138
  Type.String({ description: "Optional longer body text shown under the header" }),
146
139
  ),
147
140
  options: Type.Array(QuestionOptionSchema, {
148
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.",
149
142
  }),
150
- allowOther: Type.Optional(
151
- Type.Boolean({ description: "Allow a 'Type something.' custom-input option (default: true)" }),
152
- ),
153
143
  multiSelect: Type.Optional(
154
144
  Type.Boolean({
155
145
  description:
@@ -189,12 +179,10 @@ function wrapTab(index: number, total: number): number {
189
179
  return ((index % total) + total) % total;
190
180
  }
191
181
 
192
- /** 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. */
193
183
  function buildOptions(q: Question): RenderOption[] {
194
184
  const opts: RenderOption[] = [...q.options];
195
- if (q.allowOther !== false) {
196
- opts.push({ value: "__other__", label: "Type something.", isOther: true });
197
- }
185
+ opts.push({ label: "Type something.", isOther: true });
198
186
  return opts;
199
187
  }
200
188
 
@@ -216,7 +204,7 @@ function isDualColumn(q: Question | undefined): boolean {
216
204
  function newTabState(tui: TuiLike, theme: EditorTheme, tabIndex: number, onSubmit: (tabIndex: number, value: string) => void): TabState {
217
205
  const editor = new Editor(tui as never, theme);
218
206
  editor.onSubmit = (value) => onSubmit(tabIndex, value);
219
- 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 };
220
208
  }
221
209
 
222
210
  function errorResult(message: string, questions: Question[] = []): {
@@ -274,6 +262,15 @@ class AskUserPanel implements Component, Focusable {
274
262
  /** True after jumping from review mode to edit a question; makes answering
275
263
  * return to review instead of advancing to the next question. */
276
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;
277
274
 
278
275
  // ── render cache ──
279
276
  private cachedWidth?: number;
@@ -300,6 +297,11 @@ class AskUserPanel implements Component, Focusable {
300
297
  // draft-loss bug (previously a single shared editor was swapped in/out and
301
298
  // the swap was lossy across the input-mode / tab-switch boundary).
302
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);
303
305
  }
304
306
 
305
307
  /** Shared submit logic bound to each tab's editor. */
@@ -309,25 +311,78 @@ class AskUserPanel implements Component, Focusable {
309
311
  if (!q || !st) return;
310
312
  const trimmed = value.trim();
311
313
  if (!trimmed) {
312
- // 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.
313
317
  st.inputMode = false;
314
318
  st.editor.setText("");
319
+ if (isMulti(q)) {
320
+ st.customText = null;
321
+ if (!this.commitMultiAnswer(q, st)) this.answers.delete(q.tab);
322
+ }
315
323
  if (tabIndex === this.currentTab) this.invalidate();
316
324
  return;
317
325
  }
318
- this.answers.set(q.label, {
319
- label: q.label,
320
- value: trimmed,
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;
337
+ if (tabIndex === this.currentTab) this.invalidate();
338
+ return;
339
+ }
340
+ this.answers.set(q.tab, {
341
+ tab: q.tab,
321
342
  answerLabel: trimmed,
322
343
  wasCustom: true,
323
344
  });
324
- st.inputMode = false;
325
345
  st.selectedSingle = -2; // sentinel: "answered via type-something"
346
+ st.inputMode = false;
326
347
  if (tabIndex === this.currentTab) {
327
348
  this.advanceAfterAnswer();
328
349
  }
329
350
  }
330
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
+
331
386
  // ── accessors ──
332
387
 
333
388
  private currentQuestion(): Question | undefined {
@@ -373,11 +428,10 @@ class AskUserPanel implements Component, Focusable {
373
428
  private markSkippedIfNeeded(): boolean {
374
429
  const q = this.currentQuestion();
375
430
  if (!q) return true;
376
- if (this.answers.has(q.label)) return true;
431
+ if (this.answers.has(q.tab)) return true;
377
432
  if (!canSkip(q)) return false; // required question: block
378
- this.answers.set(q.label, {
379
- label: q.label,
380
- value: "",
433
+ this.answers.set(q.tab, {
434
+ tab: q.tab,
381
435
  answerLabel: "",
382
436
  wasCustom: false,
383
437
  skipped: true,
@@ -390,6 +444,9 @@ class AskUserPanel implements Component, Focusable {
390
444
  questions: this.questions,
391
445
  answers: Array.from(this.answers.values()),
392
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,
393
450
  });
394
451
  }
395
452
 
@@ -417,6 +474,20 @@ class AskUserPanel implements Component, Focusable {
417
474
  // ── input ──
418
475
 
419
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
+
420
491
  if (matchesKey(data, TOGGLE_KEY)) {
421
492
  this.setCollapsed(!this.collapsed);
422
493
  return;
@@ -460,22 +531,37 @@ class AskUserPanel implements Component, Focusable {
460
531
  const opts = this.currentOptions();
461
532
  const multi = isMulti(q);
462
533
 
463
- // 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.
464
540
  if (this.questions.length > 1) {
465
- if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
466
- // Forward navigation: a required (allowSkip:false) question cannot be
467
- // left unanswered, so block + record-skip is gated on markSkippedIfNeeded.
541
+ // Forward (Tab cycles, stops at the last question)
542
+ if (matchesKey(data, Key.tab)) {
468
543
  if (!this.markSkippedIfNeeded()) return;
469
544
  this.switchTab(wrapTab(this.currentTab + 1, this.questions.length));
470
545
  return;
471
546
  }
472
- if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
473
- // Backward navigation is always allowed — reviewing/editing earlier
474
- // questions doesn't let the user bypass a required question (forward
475
- // nav re-checks when they come back forward).
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"))) {
476
556
  this.switchTab(wrapTab(this.currentTab - 1, this.questions.length));
477
557
  return;
478
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
+ }
479
565
  }
480
566
 
481
567
  // Up / Down — moves ONLY the cursor (▸), does not change selection
@@ -524,9 +610,8 @@ class AskUserPanel implements Component, Focusable {
524
610
  const opt = opts[st.cursor];
525
611
  if (opt && !opt.isOther) {
526
612
  st.selectedSingle = st.cursor;
527
- this.answers.set(q.label, {
528
- label: q.label,
529
- value: opt.value,
613
+ this.answers.set(q.tab, {
614
+ tab: q.tab,
530
615
  answerLabel: opt.label,
531
616
  wasCustom: false,
532
617
  index: st.cursor,
@@ -545,37 +630,25 @@ class AskUserPanel implements Component, Focusable {
545
630
  // Prefill the editor with the committed custom text (if any) so the
546
631
  // user can edit rather than retype. Per-tab editor keeps the text
547
632
  // for Esc-discard semantics automatically.
548
- const existing = this.answers.get(q.label);
549
- if (existing?.wasCustom && existing.value) {
550
- st.editor.setText(existing.value);
551
- }
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);
552
638
  this.invalidate();
553
639
  return;
554
640
  }
555
641
  if (multi) {
556
642
  if (!st.multiChecked.has(st.cursor)) st.multiChecked.add(st.cursor);
557
- const picked = Array.from(st.multiChecked)
558
- .sort((a, b) => a - b)
559
- .map((i) => opts[i])
560
- .filter((o): o is RenderOption => !!o && !o.isOther);
561
- if (picked.length === 0) return;
562
- this.answers.set(q.label, {
563
- label: q.label,
564
- value: "",
565
- values: picked.map((o) => o.value),
566
- answerLabel: "",
567
- answerLabels: picked.map((o) => o.label),
568
- wasCustom: false,
569
- multiSelect: true,
570
- });
571
- 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();
572
646
  return;
573
647
  }
574
648
  // single-select: commit cursor position as the selection
575
649
  st.selectedSingle = st.cursor;
576
- this.answers.set(q.label, {
577
- label: q.label,
578
- value: opt.value,
650
+ this.answers.set(q.tab, {
651
+ tab: q.tab,
579
652
  answerLabel: opt.label,
580
653
  wasCustom: false,
581
654
  index: st.cursor,
@@ -593,23 +666,45 @@ class AskUserPanel implements Component, Focusable {
593
666
  this.invalidate();
594
667
  }
595
668
 
596
- /** 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]. */
597
672
  private handleReviewInput(data: string): void {
598
673
  const n = this.questions.length;
674
+ const total = n + 1; // include the note entry
599
675
  if (matchesKey(data, Key.escape)) {
600
676
  this.submit(true);
601
677
  return;
602
678
  }
603
679
  if (matchesKey(data, Key.enter)) {
604
- // 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).
605
684
  this.submit(false);
606
685
  return;
607
686
  }
608
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
+ }
609
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.
610
704
  this.reviewMode = false;
611
705
  this.editingFromReview = true;
612
- this.switchTab(this.reviewCursor);
706
+ this.currentTab = this.reviewCursor;
707
+ this.invalidate();
613
708
  return;
614
709
  }
615
710
  if (matchesKey(data, Key.up)) {
@@ -617,7 +712,7 @@ class AskUserPanel implements Component, Focusable {
617
712
  return;
618
713
  }
619
714
  if (matchesKey(data, Key.down)) {
620
- if (this.reviewCursor < n - 1) { this.reviewCursor++; this.invalidate(); }
715
+ if (this.reviewCursor < total - 1) { this.reviewCursor++; this.invalidate(); }
621
716
  return;
622
717
  }
623
718
  if (matchesKey(data, Key.pageUp)) {
@@ -626,7 +721,7 @@ class AskUserPanel implements Component, Focusable {
626
721
  return;
627
722
  }
628
723
  if (matchesKey(data, Key.pageDown)) {
629
- this.reviewCursor = Math.min(n - 1, this.reviewCursor + Math.max(1, this.reviewViewportH));
724
+ this.reviewCursor = Math.min(total - 1, this.reviewCursor + Math.max(1, this.reviewViewportH));
630
725
  this.invalidate();
631
726
  return;
632
727
  }
@@ -652,8 +747,8 @@ class AskUserPanel implements Component, Focusable {
652
747
  const th = this.theme;
653
748
  const tabsPart = this.questions
654
749
  .map((q, i) => {
655
- const label = q.label;
656
- const done = this.answers.has(q.label);
750
+ const label = q.tab;
751
+ const done = this.answers.has(q.tab);
657
752
  return th.fg(done ? "success" : "dim", `${label}${done ? "✓" : "○"}`);
658
753
  })
659
754
  .join(th.fg("dim", " "));
@@ -669,8 +764,11 @@ class AskUserPanel implements Component, Focusable {
669
764
  const lines: string[] = [];
670
765
  const row = (content: string) => th.fg("border", "│") + padRight(content, innerW) + th.fg("border", "│");
671
766
 
767
+ if (this.messageEditing) {
768
+ return this.renderMessageEditor(width, innerW, th);
769
+ }
672
770
  if (this.reviewMode) {
673
- return this.renderReview(width, innerW, row, th);
771
+ return this.renderReview(width, innerW, th);
674
772
  }
675
773
 
676
774
  lines.push(th.fg("border", `╭${"─".repeat(innerW)}╮`));
@@ -678,9 +776,9 @@ class AskUserPanel implements Component, Focusable {
678
776
  // ── Tab bar ──
679
777
  if (this.questions.length > 1) {
680
778
  const tabCells = this.questions.map((q, i) => {
681
- const label = q.label;
779
+ const label = q.tab;
682
780
  const active = i === this.currentTab;
683
- const ans = this.answers.get(q.label);
781
+ const ans = this.answers.get(q.tab);
684
782
  let mark = " ";
685
783
  let baseColor: import("@earendil-works/pi-coding-agent").ThemeColor = active ? "accent" : "muted";
686
784
  if (ans?.skipped) { mark = "—"; baseColor = "warning"; }
@@ -709,8 +807,10 @@ class AskUserPanel implements Component, Focusable {
709
807
  // ── Prompt body ──
710
808
  if (q?.prompt) {
711
809
  for (const w of wrapTextWithAnsi(th.fg("muted", q.prompt), innerW - 2)) lines.push(row(` ${w}`));
712
- lines.push(row(""));
713
810
  }
811
+ // 空行分隔 header/prompt 与 options。原来只在有 prompt 时才加,导致无
812
+ // prompt 的问题其标题与选项紧贴("粘在一起")。现在无条件加。
813
+ lines.push(row(""));
714
814
 
715
815
  // ── Body: options / preview / input editor ──
716
816
  const st = this.currentTabState();
@@ -758,8 +858,8 @@ class AskUserPanel implements Component, Focusable {
758
858
  if (!ans) return th.fg("dim", "(no answer)");
759
859
  if (ans.skipped) return th.fg("warning", "(skipped)");
760
860
  let text: string;
761
- if (ans.multiSelect) text = (ans.answerLabels?.length ? ans.answerLabels : ans.values ?? []).join(", ");
762
- else text = ans.wasCustom ? ans.value : (ans.answerLabel || ans.value);
861
+ if (ans.multiSelect) text = (ans.answerLabels ?? []).join(", ");
862
+ else text = ans.answerLabel;
763
863
  const vw = visibleWidth(text);
764
864
  if (vw <= maxW) return th.fg("text", text);
765
865
  // truncate: keep prefix, append “…”
@@ -767,10 +867,11 @@ class AskUserPanel implements Component, Focusable {
767
867
  return th.fg("text", cut) + th.fg("dim", "…");
768
868
  }
769
869
 
770
- /** Clamp the review scroll offset so the cursor stays visible. */
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. */
771
872
  private clampReviewScroll(): void {
772
- const n = this.questions.length;
773
- if (n === 0) return;
873
+ const total = this.questions.length + 1;
874
+ if (total === 0) return;
774
875
  const viewH = this.reviewViewportH;
775
876
  if (this.reviewCursor < this.reviewScrollOffset) this.reviewScrollOffset = this.reviewCursor;
776
877
  else if (this.reviewCursor >= this.reviewScrollOffset + viewH)
@@ -778,39 +879,92 @@ class AskUserPanel implements Component, Focusable {
778
879
  if (this.reviewScrollOffset < 0) this.reviewScrollOffset = 0;
779
880
  }
780
881
 
781
- /** Review summary: one question per entry, header row + answer row, with
782
- * viewport scrolling reusing the option-screen layout primitives. */
783
- private renderReview(width: number, innerW: number, row: (s: string) => string, th: Theme): string[] {
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[] {
784
886
  const lines: string[] = [];
785
- 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)}╮`));
786
893
  lines.push(row(` ${th.fg("accent", th.bold("Review your answers"))}`));
787
- lines.push(th.fg("border", `├${"─".repeat(innerW)}┤`));
894
+ lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
788
895
  const n = this.questions.length;
789
- this.reviewViewportH = Math.max(3, Math.min(n, 10));
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));
790
906
  this.clampReviewScroll();
791
907
  const start = this.reviewScrollOffset;
792
- const end = Math.min(n, start + this.reviewViewportH);
908
+ const end = Math.min(total, start + this.reviewViewportH);
793
909
  for (let i = start; i < end; i++) {
794
- const q = this.questions[i]!;
795
910
  const isCursor = i === this.reviewCursor;
796
- const ans = this.answers.get(q.label);
797
- // Header row: cursor + label, same coloring as the option screen.
798
911
  const prefix = isCursor ? `${th.fg("accent", ICON_CURSOR)} ` : " ";
799
- const headerColor = isCursor ? "accent" : "muted";
800
- lines.push(row(` ${prefix}${th.fg(headerColor, q.header)}`));
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)}`));
801
939
  // Answer row: reuse the description renderer's indent/wrap, fed the
802
940
  // formatted answer text. Skipped/custom/multi-select all flow through
803
941
  // formatAnswerText, so the coloring matches the option screen.
804
- const maxW = innerW - 2 - " ".length;
805
942
  const ansText = this.formatAnswerText(ans, maxW, th);
806
- lines.push(row(` ${ansText}`));
943
+ lines.push(row(`${bodyIndent}${ansText}`));
807
944
  }
808
- if (n > this.reviewViewportH) {
809
- lines.push(row(th.fg("dim", ` ↑↓/PgUp/PgDn scroll · ${start + 1}-${end}/${n}`)));
945
+ if (total > this.reviewViewportH) {
946
+ lines.push(row(th.fg("dim", `${bodyIndent}↑↓/PgUp/PgDn scroll · ${start + 1}-${end}/${total}`)));
810
947
  }
811
- lines.push(th.fg("border", `├${"─".repeat(innerW)}┤`));
812
- lines.push(row(th.fg("dim", " ↑↓ navigate · Tab edit selected · Enter confirm · Esc cancel")));
813
- lines.push(th.fg("border", `╰${"─".repeat(innerW)}╯`));
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)}╯`));
814
968
  return lines;
815
969
  }
816
970
 
@@ -829,10 +983,12 @@ class AskUserPanel implements Component, Focusable {
829
983
  const opt = opts[i]!;
830
984
  const isCursor = i === st.cursor;
831
985
  const prefix = isCursor ? `${th.fg("accent", ICON_CURSOR)} ` : " ";
832
- const customAnswered = !multi && !!this.answers.get(q.label)?.wasCustom;
986
+ const ans = this.answers.get(q.tab);
987
+ const committedCustom = multi ? st.customText : ans?.wasCustom ? ans.answerLabel : null;
988
+ const customAnswered = !!committedCustom;
833
989
  const glyph = this.optionGlyph(opt, i, st, multi, th, isCursor, customAnswered);
834
990
  // For "Type something.", show the committed text instead of the placeholder.
835
- const displayLabel = opt.isOther && customAnswered ? this.answers.get(q.label)!.value : opt.label;
991
+ const displayLabel = opt.isOther && customAnswered ? committedCustom! : opt.label;
836
992
  const labelColor = isCursor ? "accent" : opt.isOther ? (customAnswered ? "text" : "dim") : "text";
837
993
  const labelText = th.fg(labelColor, displayLabel);
838
994
  out.push(row(` ${prefix}${glyph} ${labelText}`));
@@ -871,13 +1027,15 @@ class AskUserPanel implements Component, Focusable {
871
1027
 
872
1028
  // ── build left column lines (options) ──
873
1029
  const leftLines: string[] = [];
874
- const customAnswered = !multi && !!this.answers.get(q.label)?.wasCustom;
1030
+ const dualAns = this.answers.get(q.tab);
1031
+ const dualCommittedCustom = multi ? st.customText : dualAns?.wasCustom ? dualAns.answerLabel : null;
1032
+ const customAnswered = !!dualCommittedCustom;
875
1033
  for (let i = start; i < end; i++) {
876
1034
  const opt = opts[i]!;
877
1035
  const isCursor = i === st.cursor;
878
1036
  const prefix = isCursor ? `${th.fg("accent", ICON_CURSOR)} ` : " ";
879
1037
  const glyph = this.optionGlyph(opt, i, st, multi, th, isCursor, customAnswered);
880
- const displayLabel = opt.isOther && customAnswered ? this.answers.get(q.label)!.value : opt.label;
1038
+ const displayLabel = opt.isOther && customAnswered ? dualCommittedCustom! : opt.label;
881
1039
  const labelColor = isCursor ? "accent" : opt.isOther ? (customAnswered ? "text" : "dim") : "text";
882
1040
  const labelLine = `${prefix}${glyph} ${th.fg(labelColor, displayLabel)}`;
883
1041
  leftLines.push(truncateToWidth(labelLine, leftW - 1, ""));
@@ -954,7 +1112,7 @@ export default function askUserExtension(pi: ExtensionAPI) {
954
1112
  name: "ask_user",
955
1113
  label: "Ask User",
956
1114
  description:
957
- "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 a focused side panel for 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.",
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.",
958
1116
  parameters: AskUserParams,
959
1117
 
960
1118
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -967,8 +1125,7 @@ export default function askUserExtension(pi: ExtensionAPI) {
967
1125
 
968
1126
  const questions: Question[] = params.questions.map((q) => ({
969
1127
  ...q,
970
- allowOther: q.allowOther !== false,
971
- options: q.options.map((o) => ({ ...o, value: o.value ?? o.label })),
1128
+ options: q.options.map((o) => ({ ...o })),
972
1129
  }));
973
1130
 
974
1131
  let overlayHandle: { focus: () => void; unfocus: () => void } | null = null;
@@ -1011,14 +1168,19 @@ export default function askUserExtension(pi: ExtensionAPI) {
1011
1168
  ? `User cancelled the question(s). ${result.answers.length} question(s) were answered before cancellation.`
1012
1169
  : result.answers
1013
1170
  .map((a) => {
1014
- if (a.skipped) return `${a.label}: (skipped)`;
1015
- if (a.multiSelect) return `${a.label}: ${a.answerLabels?.join(", ") ?? a.values?.join(", ") ?? ""}`;
1016
- return `${a.label}: ${a.wasCustom ? "(custom) " : ""}${a.answerLabel || 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}`;
1017
1174
  })
1018
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;
1019
1181
 
1020
1182
  return {
1021
- content: [{ type: "text", text: summary }],
1183
+ content: [{ type: "text", text: withNote }],
1022
1184
  details: result,
1023
1185
  };
1024
1186
  },