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