@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.
- package/README.md +73 -30
- package/package.json +2 -1
- 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
|
-
"
|
|
33
|
+
"tab": "layout",
|
|
34
34
|
"prompt": "Pick the layout for the new settings page.",
|
|
35
35
|
"options": [
|
|
36
|
-
{ "
|
|
37
|
-
{ "
|
|
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
|
-
| `
|
|
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 |
|
|
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
|
-
|
|
88
|
-
`Enter` on it to
|
|
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
|
|
101
|
-
|
|
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
|
-
"
|
|
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
|
|
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│
|
|
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
|
|
140
|
-
|
|
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
|
-
|
|
145
|
-
Which database?
|
|
146
|
-
|
|
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
|
-
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
158
|
-
|
|
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 (
|
|
168
|
-
| `→` / `←` |
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
/** Single-select: the chosen
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
143
|
-
description: "Short keyword
|
|
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."
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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.
|
|
431
|
+
if (this.answers.has(q.tab)) return true;
|
|
377
432
|
if (!canSkip(q)) return false; // required question: block
|
|
378
|
-
this.answers.set(q.
|
|
379
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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.
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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.
|
|
528
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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.
|
|
577
|
-
|
|
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
|
-
//
|
|
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.
|
|
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 <
|
|
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(
|
|
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.
|
|
656
|
-
const done = this.answers.has(q.
|
|
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,
|
|
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.
|
|
779
|
+
const label = q.tab;
|
|
682
780
|
const active = i === this.currentTab;
|
|
683
|
-
const ans = this.answers.get(q.
|
|
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
|
|
762
|
-
else text = ans.
|
|
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
|
|
773
|
-
if (
|
|
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
|
|
782
|
-
*
|
|
783
|
-
|
|
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
|
-
|
|
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(
|
|
894
|
+
lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
|
|
788
895
|
const n = this.questions.length;
|
|
789
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
943
|
+
lines.push(row(`${bodyIndent}${ansText}`));
|
|
807
944
|
}
|
|
808
|
-
if (
|
|
809
|
-
lines.push(row(th.fg("dim",
|
|
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(
|
|
812
|
-
lines.push(row(th.fg("dim", " ↑↓ navigate · Tab edit
|
|
813
|
-
lines.push(th.fg(
|
|
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
|
|
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 ?
|
|
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
|
|
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 ?
|
|
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)
|
|
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
|
-
|
|
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.
|
|
1015
|
-
if (a.multiSelect) return `${a.
|
|
1016
|
-
return `${a.
|
|
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:
|
|
1183
|
+
content: [{ type: "text", text: withNote }],
|
|
1022
1184
|
details: result,
|
|
1023
1185
|
};
|
|
1024
1186
|
},
|