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