@d3ara1n/pi-ask-user 2.0.0 → 2.1.1
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 +49 -20
- package/package.json +4 -3
- package/preview.png +0 -0
- package/src/index.ts +561 -294
package/README.md
CHANGED
|
@@ -4,24 +4,20 @@ A collapsible **ask-user** tool for [pi](https://github.com/earendil-works/pi).
|
|
|
4
4
|
|
|
5
5
|
## Why
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
they "collapse" they keep keyboard focus locked on the panel — so you **can't
|
|
7
|
+
Most ask-user tools render a panel that covers the transcript, so you **can't
|
|
9
8
|
scroll the conversation** to read the analysis that should inform your choice.
|
|
10
9
|
You end up choosing blind.
|
|
11
10
|
|
|
12
|
-
This tool fixes that
|
|
11
|
+
This tool fixes that by **not** using a screen overlay. The panel renders into
|
|
12
|
+
pi's bottom `editorContainer` slot (the same path `ctx.ui.select()` /
|
|
13
|
+
`ctx.ui.input()` take), so the transcript stays visible **above** the panel and
|
|
14
|
+
remains scrollable via the terminal's native scrollback — mouse wheel,
|
|
15
|
+
`Shift+PgUp`, `Cmd+↑`. This works because pi's TUI never enters alt-screen and
|
|
16
|
+
never tracks the mouse, so every rendered chat line lives in the terminal
|
|
17
|
+
buffer and can be scrolled back at any time, no focus gymnastics needed.
|
|
13
18
|
|
|
14
|
-
- **Collapse
|
|
15
|
-
|
|
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.
|
|
19
|
+
- **Collapse** (`Ctrl+\`) shrinks the panel to a single status row, leaving
|
|
20
|
+
even more of the transcript on screen while you decide.
|
|
25
21
|
|
|
26
22
|
## Tool: `ask_user`
|
|
27
23
|
|
|
@@ -194,11 +190,40 @@ treat it as high-priority context.
|
|
|
194
190
|
|
|
195
191
|
### Result
|
|
196
192
|
|
|
197
|
-
The tool
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
193
|
+
The tool result returned to the model is **JSON**, shaped symmetrically with
|
|
194
|
+
the questions schema so the model can correlate each answer back to its own
|
|
195
|
+
question by the `tab` key:
|
|
196
|
+
|
|
197
|
+
```json
|
|
198
|
+
{
|
|
199
|
+
"cancelled": false,
|
|
200
|
+
"answers": [
|
|
201
|
+
{ "tab": "layout", "answer": "Sidebar" },
|
|
202
|
+
{ "tab": "extras", "answers": ["dark-mode"], "custom": "also add export-to-pdf" },
|
|
203
|
+
{ "tab": "db", "skipped": true }
|
|
204
|
+
],
|
|
205
|
+
"message": "leaning towards the minimal option"
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Only the relevant fields appear per answer (no noise):
|
|
210
|
+
|
|
211
|
+
| Situation | Fields |
|
|
212
|
+
|-----------|--------|
|
|
213
|
+
| single-select, option picked | `answer` |
|
|
214
|
+
| single-select, custom typed | `custom` |
|
|
215
|
+
| multi-select, options picked | `answers: [...]` |
|
|
216
|
+
| multi-select, options + custom | `answers` + `custom` |
|
|
217
|
+
| multi-select, custom only | `custom` |
|
|
218
|
+
| multi-select, empty commit (skippable, submitted with nothing) | `answers: []` |
|
|
219
|
+
| any question, Tab-skipped | `skipped: true` |
|
|
220
|
+
|
|
221
|
+
`custom` is always a sibling of `answer`/`answers`, never mixed in — it signals
|
|
222
|
+
the user typed something outside the offered options. The top-level `message`
|
|
223
|
+
(the user's review-screen note) appears only when non-empty. `cancelled: true`
|
|
224
|
+
means the user pressed `Esc`; `answers` still lists whatever was answered
|
|
225
|
+
before cancellation. This JSON shape replaces the old `"tab: answer"` text
|
|
226
|
+
format, which could break when a custom answer contained a colon or newline.
|
|
202
227
|
|
|
203
228
|
## Keys
|
|
204
229
|
|
|
@@ -212,7 +237,11 @@ block so it's visually distinct from the per-question answers.
|
|
|
212
237
|
| `Esc` | Cancel (or exit custom-input editor without saving) |
|
|
213
238
|
| `Ctrl+\` | Collapse / expand the panel |
|
|
214
239
|
|
|
215
|
-
##
|
|
240
|
+
## Dependencies
|
|
241
|
+
|
|
242
|
+
None.
|
|
243
|
+
|
|
244
|
+
## Installation
|
|
216
245
|
|
|
217
246
|
Add to `~/.pi/agent/settings.json`:
|
|
218
247
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d3ara1n/pi-ask-user",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Ask-user tool for pi —
|
|
5
|
+
"description": "Ask-user tool for pi — renders in the bottom editor slot (not a screen overlay), so the transcript stays visible and scrollable above the panel while you decide",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"pi-package",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"pi": {
|
|
24
24
|
"extensions": [
|
|
25
25
|
"./src/index.ts"
|
|
26
|
-
]
|
|
26
|
+
],
|
|
27
|
+
"image": "https://raw.githubusercontent.com/d3ara1n/pi-extensions/main/packages/pi-ask-user/preview.png"
|
|
27
28
|
},
|
|
28
29
|
"repository": {
|
|
29
30
|
"type": "git",
|
package/preview.png
ADDED
|
Binary file
|
package/src/index.ts
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* pi-ask-user — An ask-user tool for pi.
|
|
3
3
|
*
|
|
4
4
|
* Design notes:
|
|
5
|
-
* - Collapsible panel (Ctrl+\)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* - Collapsible panel (Ctrl+\) shrinks the panel to one row so more of the
|
|
6
|
+
* transcript stays on screen. Chat scrolling does NOT depend on keyboard
|
|
7
|
+
* focus at all — it is the terminal scrollback (see Layout note below), so
|
|
8
|
+
* it works whether the panel is expanded, collapsed, or focused.
|
|
9
9
|
* - Per-question state (cursor position, scroll offset, type-something draft,
|
|
10
10
|
* multi-select picks) survives tab navigation — switching tabs never loses
|
|
11
11
|
* what you typed.
|
|
@@ -17,10 +17,18 @@
|
|
|
17
17
|
* field, the question renders in two equal columns (options | preview);
|
|
18
18
|
* otherwise it renders single-column full-width.
|
|
19
19
|
*
|
|
20
|
-
* Layout:
|
|
20
|
+
* Layout: renders into pi's bottom `editorContainer` slot (overlay:false, NOT a
|
|
21
|
+
* screen overlay). The chat transcript stays visible ABOVE the panel and is
|
|
22
|
+
* scrollable via the terminal's native scrollback (mouse wheel / Shift-PgUp /
|
|
23
|
+
* Cmd-↑). This works because pi's TUI never enters alt-screen and never tracks
|
|
24
|
+
* the mouse, so every rendered chat line lives in the terminal buffer and can
|
|
25
|
+
* be scrolled back at any time — the exact mechanism ctx.ui.select()/input()
|
|
26
|
+
* rely on. (overlay:true would route through ui.showOverlay(), compositing the
|
|
27
|
+
* panel over the whole screen and visually hiding the transcript — making it
|
|
28
|
+
* unscrollable, which was the original bug.) Collapses to one status row.
|
|
21
29
|
*/
|
|
22
30
|
|
|
23
|
-
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import type { ExtensionAPI, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
24
32
|
import {
|
|
25
33
|
type Component,
|
|
26
34
|
Editor,
|
|
@@ -44,6 +52,8 @@ const ICON_CHECK_EMPTY = "□"; // U+25A1
|
|
|
44
52
|
const ICON_CHECK_FILLED = "▣"; // U+25A3
|
|
45
53
|
const ICON_OTHER = "✎"; // pencil for "Type something."
|
|
46
54
|
const ICON_CURSOR = "▸"; // current cursor position, independent of selection
|
|
55
|
+
const ICON_NOTE = ICON_OTHER; // same ✎ pencil as custom answers — the note is also free-form user input
|
|
56
|
+
const ICON_ANSWER = "›"; // lead glyph on option-pick answers in the result card
|
|
47
57
|
|
|
48
58
|
// ────────────────────────────────────────────────────────────────────────────
|
|
49
59
|
// Types
|
|
@@ -69,19 +79,24 @@ interface Question {
|
|
|
69
79
|
allowSkip?: boolean;
|
|
70
80
|
}
|
|
71
81
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
/** A committed answer. Discriminated by `kind` so every state carries exactly
|
|
83
|
+
* the fields it needs — the compiler guarantees completeness, and the three
|
|
84
|
+
* display/serialization consumers all derive from `describeAnswer()` (single
|
|
85
|
+
* source of truth) so they can never drift apart.
|
|
86
|
+
*
|
|
87
|
+
* - `single`: the user picked one of the offered options.
|
|
88
|
+
* - `custom`: single-select, the user typed a custom answer ("Type something.").
|
|
89
|
+
* - `multi`: multi-select — `options` are the picked option labels; an
|
|
90
|
+
* optional `custom` carries any typed text alongside them. Pure-custom
|
|
91
|
+
* (no options checked) is `options: []` + `custom`; an empty commit
|
|
92
|
+
* (skippable, submitted with nothing) is `options: []` with no `custom`.
|
|
93
|
+
* - `skipped`: the user navigated past without answering (Tab/arrows).
|
|
94
|
+
*/
|
|
95
|
+
type Answer =
|
|
96
|
+
| { tab: string; kind: "single"; option: string }
|
|
97
|
+
| { tab: string; kind: "custom"; text: string }
|
|
98
|
+
| { tab: string; kind: "multi"; options: string[]; custom?: string }
|
|
99
|
+
| { tab: string; kind: "skipped" };
|
|
85
100
|
|
|
86
101
|
interface AskUserResult {
|
|
87
102
|
questions: Question[];
|
|
@@ -223,6 +238,49 @@ function padRight(s: string, width: number): string {
|
|
|
223
238
|
return v >= width ? s : s + " ".repeat(width - v);
|
|
224
239
|
}
|
|
225
240
|
|
|
241
|
+
/** Truncate to a visible width, appending “…” only when the text actually
|
|
242
|
+
* overflows. (truncateToWidth's third arg is a fill, not a suffix, so we
|
|
243
|
+
* reserve one column and append the ellipsis ourselves when needed.) */
|
|
244
|
+
function truncForDisplay(text: string, maxW: number): string {
|
|
245
|
+
if (maxW <= 0) return "";
|
|
246
|
+
if (maxW === 1) return "…";
|
|
247
|
+
const vw = visibleWidth(text);
|
|
248
|
+
if (vw <= maxW) return text;
|
|
249
|
+
return truncateToWidth(text, maxW - 1, "") + "…";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Structured interpretation of an Answer for display/serialization.
|
|
253
|
+
* Single source of truth — all three consumers (review screen's
|
|
254
|
+
* formatAnswerText, result card's formatAnswer, execute's JSON payload)
|
|
255
|
+
* derive from this, so they can never drift apart. */
|
|
256
|
+
interface AnswerView {
|
|
257
|
+
/** Human-readable text WITHOUT ANSI — e.g. "Sidebar" / "甲, 乙" /
|
|
258
|
+
* "✎ 自定义文本" / "(none)" / "(skipped)". Consumers wrap it in color. */
|
|
259
|
+
text: string;
|
|
260
|
+
/** Theme color name for the whole text. */
|
|
261
|
+
color: ThemeColor;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Interpret an Answer into display form. `customGlyph` (default "✎") prefixes
|
|
265
|
+
* any custom text. Returns `(no answer)` / dim for an absent answer. */
|
|
266
|
+
function describeAnswer(ans: Answer | undefined, customGlyph = ICON_OTHER): AnswerView {
|
|
267
|
+
if (!ans) return { text: "(no answer)", color: "dim" };
|
|
268
|
+
switch (ans.kind) {
|
|
269
|
+
case "skipped":
|
|
270
|
+
return { text: "(skipped)", color: "warning" };
|
|
271
|
+
case "multi": {
|
|
272
|
+
if (ans.options.length === 0 && !ans.custom) return { text: "(none)", color: "dim" };
|
|
273
|
+
const parts = [...ans.options];
|
|
274
|
+
if (ans.custom) parts.push(`${customGlyph} ${ans.custom}`);
|
|
275
|
+
return { text: parts.join(", "), color: "text" };
|
|
276
|
+
}
|
|
277
|
+
case "custom":
|
|
278
|
+
return { text: `${customGlyph} ${ans.text}`, color: "text" };
|
|
279
|
+
case "single":
|
|
280
|
+
return { text: ans.option, color: "text" };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
226
284
|
// ────────────────────────────────────────────────────────────────────────────
|
|
227
285
|
// The overlay component
|
|
228
286
|
// ────────────────────────────────────────────────────────────────────────────
|
|
@@ -233,7 +291,6 @@ interface TuiLike {
|
|
|
233
291
|
|
|
234
292
|
interface PanelCallbacks {
|
|
235
293
|
onResult: (result: AskUserResult) => void;
|
|
236
|
-
onCollapseChange: (collapsed: boolean) => void;
|
|
237
294
|
}
|
|
238
295
|
|
|
239
296
|
class AskUserPanel implements Component, Focusable {
|
|
@@ -251,19 +308,14 @@ class AskUserPanel implements Component, Focusable {
|
|
|
251
308
|
private tabs: TabState[];
|
|
252
309
|
/** Visible option rows (recomputed each render). */
|
|
253
310
|
private optionViewportH = 8;
|
|
254
|
-
/**
|
|
255
|
-
private reviewMode = false;
|
|
256
|
-
/** Cursor row in the review summary. */
|
|
311
|
+
/** Cursor row in the review summary (shown on the review tab). */
|
|
257
312
|
private reviewCursor = 0;
|
|
258
313
|
/** Vertical scroll offset for the review viewport. */
|
|
259
314
|
private reviewScrollOffset = 0;
|
|
260
315
|
/** Visible review rows (recomputed each render). */
|
|
261
316
|
private reviewViewportH = 8;
|
|
262
|
-
/** True after jumping from review mode to edit a question; makes answering
|
|
263
|
-
* return to review instead of advancing to the next question. */
|
|
264
|
-
private editingFromReview = false;
|
|
265
317
|
/** True while the user is editing the free-form "note to assistant" on the
|
|
266
|
-
* review
|
|
318
|
+
* review tab. While true, all input goes to messageEditor. */
|
|
267
319
|
private messageEditing = false;
|
|
268
320
|
/** Committed note text (trimmed). Empty string = no note. Lives only on the
|
|
269
321
|
* review screen; the LLM cannot set it. */
|
|
@@ -339,21 +391,22 @@ class AskUserPanel implements Component, Focusable {
|
|
|
339
391
|
}
|
|
340
392
|
this.answers.set(q.tab, {
|
|
341
393
|
tab: q.tab,
|
|
342
|
-
|
|
343
|
-
|
|
394
|
+
kind: "custom",
|
|
395
|
+
text: trimmed,
|
|
344
396
|
});
|
|
345
|
-
st.selectedSingle = -
|
|
397
|
+
st.selectedSingle = -1; // clear any prior option pick — answer is now custom
|
|
346
398
|
st.inputMode = false;
|
|
347
399
|
if (tabIndex === this.currentTab) {
|
|
348
400
|
this.advanceAfterAnswer();
|
|
349
401
|
}
|
|
350
402
|
}
|
|
351
403
|
|
|
352
|
-
/** Save the review-
|
|
404
|
+
/** Save the review-tab note: trim, store, return to the review tab.
|
|
405
|
+
* currentTab already points at the review tab (note editing is only
|
|
406
|
+
* entered from there), so we just clear the editing flag. Empty = no note. */
|
|
353
407
|
private handleMessageSubmit(value: string): void {
|
|
354
408
|
this.messageText = value.trim();
|
|
355
409
|
this.messageEditing = false;
|
|
356
|
-
this.reviewMode = true;
|
|
357
410
|
this.invalidate();
|
|
358
411
|
}
|
|
359
412
|
|
|
@@ -370,21 +423,35 @@ class AskUserPanel implements Component, Focusable {
|
|
|
370
423
|
.map((i) => opts[i])
|
|
371
424
|
.filter((o): o is RenderOption => !!o && !o.isOther);
|
|
372
425
|
const labels = picked.map((o) => o.label);
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
this
|
|
426
|
+
const customText = st.customText;
|
|
427
|
+
// Empty commit = no option picks AND no custom text. (A skippable
|
|
428
|
+
// multi-select still records an explicit empty answer via the Enter
|
|
429
|
+
// path — see handleInput — so this function returning false just means
|
|
430
|
+
// "nothing to record here".)
|
|
431
|
+
if (labels.length === 0 && !customText) return false;
|
|
432
|
+
const ans: Answer = {
|
|
377
433
|
tab: q.tab,
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
434
|
+
kind: "multi",
|
|
435
|
+
options: labels,
|
|
436
|
+
};
|
|
437
|
+
if (customText) ans.custom = customText;
|
|
438
|
+
this.answers.set(q.tab, ans);
|
|
383
439
|
return true;
|
|
384
440
|
}
|
|
385
441
|
|
|
386
442
|
// ── accessors ──
|
|
387
443
|
|
|
444
|
+
/** Total number of tabs: one per question, plus the trailing review tab. */
|
|
445
|
+
private get totalTabs(): number {
|
|
446
|
+
return this.questions.length + 1;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The review tab sits at index === questions.length (the last tab).
|
|
450
|
+
* While true, the panel renders the review summary instead of a question. */
|
|
451
|
+
private get isReviewTab(): boolean {
|
|
452
|
+
return this.currentTab === this.questions.length;
|
|
453
|
+
}
|
|
454
|
+
|
|
388
455
|
private currentQuestion(): Question | undefined {
|
|
389
456
|
return this.questions[this.currentTab];
|
|
390
457
|
}
|
|
@@ -399,42 +466,47 @@ class AskUserPanel implements Component, Focusable {
|
|
|
399
466
|
}
|
|
400
467
|
|
|
401
468
|
private advanceAfterAnswer(): void {
|
|
402
|
-
//
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
if (this.currentTab < this.questions.length - 1) {
|
|
412
|
-
this.switchTab(wrapTab(this.currentTab + 1, this.questions.length));
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
// Last question answered: enter review mode instead of submitting.
|
|
416
|
-
this.reviewMode = true;
|
|
417
|
-
this.reviewCursor = 0;
|
|
418
|
-
this.reviewScrollOffset = 0;
|
|
419
|
-
this.invalidate();
|
|
469
|
+
// Advance to the next tab. The review tab is the last tab, so answering
|
|
470
|
+
// the final question lands the user on the review tab (where Enter
|
|
471
|
+
// submits). Navigation is now uniform: review is just the next tab,
|
|
472
|
+
// reached by the same Tab/→ keys as any question — no special "enter
|
|
473
|
+
// review" step. Safe because this is only called from question tabs
|
|
474
|
+
// (currentTab < questions.length), so currentTab + 1 <= reviewTabIndex.
|
|
475
|
+
this.switchTab(this.currentTab + 1);
|
|
420
476
|
}
|
|
421
477
|
|
|
422
478
|
/**
|
|
423
|
-
* Called
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
479
|
+
* Called before navigating AWAY from a question tab (Tab/→/←/Shift+Tab).
|
|
480
|
+
* Resolves the current question's state so it can be left cleanly:
|
|
481
|
+
*
|
|
482
|
+
* - Already committed (answers.has): leave freely.
|
|
483
|
+
* - Multi-select with UNCOMMITTED checks (or a typed custom text): a check
|
|
484
|
+
* IS an answer — commit it first, then leave. Navigating away with
|
|
485
|
+
* pending checks must submit them (not skip, not block), regardless of
|
|
486
|
+
* allowSkip, because the user has already expressed a choice. (Single-
|
|
487
|
+
* select commits on Space, so it never has pending uncommitted state.)
|
|
488
|
+
* - Nothing selected at all:
|
|
489
|
+
* allowSkip true → record a skipped answer, allow leaving.
|
|
490
|
+
* allowSkip false → block (a required question must be answered).
|
|
491
|
+
*
|
|
492
|
+
* Returns true when navigation may proceed.
|
|
427
493
|
*/
|
|
428
|
-
private
|
|
494
|
+
private prepareQuestionForLeave(): boolean {
|
|
429
495
|
const q = this.currentQuestion();
|
|
430
496
|
if (!q) return true;
|
|
431
497
|
if (this.answers.has(q.tab)) return true;
|
|
432
|
-
|
|
498
|
+
const st = this.currentTabState();
|
|
499
|
+
// Multi-select: uncommitted checks count as an answer — commit them,
|
|
500
|
+
// then leave. commitMultiAnswer only returns false when there's nothing
|
|
501
|
+
// to commit (no checks, no custom), which the guard already rules out.
|
|
502
|
+
if (isMulti(q) && (st.multiChecked.size > 0 || !!st.customText)) {
|
|
503
|
+
this.commitMultiAnswer(q, st);
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
if (!canSkip(q)) return false; // required question, nothing chosen: block
|
|
433
507
|
this.answers.set(q.tab, {
|
|
434
508
|
tab: q.tab,
|
|
435
|
-
|
|
436
|
-
wasCustom: false,
|
|
437
|
-
skipped: true,
|
|
509
|
+
kind: "skipped",
|
|
438
510
|
});
|
|
439
511
|
return true;
|
|
440
512
|
}
|
|
@@ -453,14 +525,9 @@ class AskUserPanel implements Component, Focusable {
|
|
|
453
525
|
private setCollapsed(next: boolean): void {
|
|
454
526
|
if (this.collapsed === next) return;
|
|
455
527
|
this.collapsed = next;
|
|
456
|
-
this.cb.onCollapseChange(next);
|
|
457
528
|
this.invalidate();
|
|
458
529
|
}
|
|
459
530
|
|
|
460
|
-
expandFromShortcut(): void {
|
|
461
|
-
this.setCollapsed(false);
|
|
462
|
-
}
|
|
463
|
-
|
|
464
531
|
private clampScrollToCursor(): void {
|
|
465
532
|
const opts = this.currentOptions();
|
|
466
533
|
if (opts.length === 0) return;
|
|
@@ -474,12 +541,12 @@ class AskUserPanel implements Component, Focusable {
|
|
|
474
541
|
// ── input ──
|
|
475
542
|
|
|
476
543
|
handleInput(data: string): void {
|
|
477
|
-
// Note editor
|
|
478
|
-
//
|
|
544
|
+
// 1. Note editor (messageEditing): owns all input while active. Esc
|
|
545
|
+
// returns to the review tab (currentTab already points there — note
|
|
546
|
+
// editing is only entered from the review tab).
|
|
479
547
|
if (this.messageEditing) {
|
|
480
548
|
if (matchesKey(data, Key.escape)) {
|
|
481
549
|
this.messageEditing = false;
|
|
482
|
-
this.reviewMode = true;
|
|
483
550
|
this.invalidate();
|
|
484
551
|
return;
|
|
485
552
|
}
|
|
@@ -488,82 +555,62 @@ class AskUserPanel implements Component, Focusable {
|
|
|
488
555
|
return;
|
|
489
556
|
}
|
|
490
557
|
|
|
558
|
+
// 2. Collapse toggle (global, any tab).
|
|
491
559
|
if (matchesKey(data, TOGGLE_KEY)) {
|
|
492
560
|
this.setCollapsed(!this.collapsed);
|
|
493
561
|
return;
|
|
494
562
|
}
|
|
495
563
|
|
|
564
|
+
// 3. Collapsed: only Esc (cancel) is meaningful.
|
|
496
565
|
if (this.collapsed) {
|
|
497
566
|
if (matchesKey(data, Key.escape)) this.submit(true);
|
|
498
567
|
return;
|
|
499
568
|
}
|
|
500
569
|
|
|
501
|
-
//
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
// "Type something." input mode: ALL editing keys (Tab, arrows, etc.) go to
|
|
509
|
-
// the editor. Only Esc (exit) is handled here. Tab is NOT hijacked for
|
|
510
|
-
// question switching — that would break indentation / cursor movement.
|
|
511
|
-
// To switch questions, press Esc first to return to the option list.
|
|
512
|
-
if (st.inputMode) {
|
|
570
|
+
// 4. Question tab + "Type something." input mode: the editor owns ALL
|
|
571
|
+
// editing keys (Tab, arrows, etc.). Tab is NOT hijacked for tab
|
|
572
|
+
// switching here, because that would break indentation / cursor
|
|
573
|
+
// movement. Esc exits back to the option list. The review tab has no
|
|
574
|
+
// input mode (it never edits options), so it skips this branch — the
|
|
575
|
+
// `!this.isReviewTab` short-circuit also avoids indexing tabs[] OOB.
|
|
576
|
+
if (!this.isReviewTab && this.currentTabState().inputMode) {
|
|
513
577
|
if (matchesKey(data, Key.escape)) {
|
|
578
|
+
const st = this.currentTabState();
|
|
514
579
|
st.inputMode = false;
|
|
515
580
|
// Keep the editor content (per-tab editor preserves it as draft).
|
|
516
581
|
this.invalidate();
|
|
517
582
|
return;
|
|
518
583
|
}
|
|
519
|
-
|
|
584
|
+
this.currentTabState().editor.handleInput(data);
|
|
520
585
|
this.invalidate();
|
|
521
586
|
return;
|
|
522
587
|
}
|
|
523
588
|
|
|
589
|
+
// 5. Esc = cancel submission (any tab, when not editing).
|
|
524
590
|
if (matchesKey(data, Key.escape)) {
|
|
525
591
|
this.submit(true);
|
|
526
592
|
return;
|
|
527
593
|
}
|
|
528
594
|
|
|
595
|
+
// 6. Shared tab navigation — Tab/→ forward, Shift+Tab/← backward.
|
|
596
|
+
// Runs on BOTH question tabs and the review tab, which is what makes
|
|
597
|
+
// the review reachable by the same keys as any question. The skip
|
|
598
|
+
// check only applies when LEAVING a question tab (never the review).
|
|
599
|
+
if (this.handleTabNavigation(data)) return;
|
|
600
|
+
|
|
601
|
+
// 7. Review tab: ↑↓ move · Space edit · Enter submit. (Esc + tab
|
|
602
|
+
// navigation were already handled above.)
|
|
603
|
+
if (this.isReviewTab) {
|
|
604
|
+
return this.handleReviewInput(data);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// 8. Question tab: ↑↓ move cursor · Space toggle/commit · Enter confirm.
|
|
608
|
+
const st = this.currentTabState();
|
|
529
609
|
const q = this.currentQuestion();
|
|
530
610
|
if (!q) return;
|
|
531
611
|
const opts = this.currentOptions();
|
|
532
612
|
const multi = isMulti(q);
|
|
533
613
|
|
|
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.
|
|
540
|
-
if (this.questions.length > 1) {
|
|
541
|
-
// Forward (Tab cycles, → stops at the last question)
|
|
542
|
-
if (matchesKey(data, Key.tab)) {
|
|
543
|
-
if (!this.markSkippedIfNeeded()) return;
|
|
544
|
-
this.switchTab(wrapTab(this.currentTab + 1, this.questions.length));
|
|
545
|
-
return;
|
|
546
|
-
}
|
|
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"))) {
|
|
556
|
-
this.switchTab(wrapTab(this.currentTab - 1, this.questions.length));
|
|
557
|
-
return;
|
|
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
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
567
614
|
// Up / Down — moves ONLY the cursor (▸), does not change selection
|
|
568
615
|
if (matchesKey(data, Key.up)) {
|
|
569
616
|
if (st.cursor > 0) {
|
|
@@ -594,64 +641,84 @@ class AskUserPanel implements Component, Focusable {
|
|
|
594
641
|
return;
|
|
595
642
|
}
|
|
596
643
|
|
|
597
|
-
//
|
|
598
|
-
|
|
644
|
+
// Space — the "interact" key: select (single), toggle (multi), or EDIT
|
|
645
|
+
// (the "Type something." row). It never advances — that's Enter's job.
|
|
646
|
+
// This mirrors the review tab (where Space opens an entry for editing),
|
|
647
|
+
// so "the key that modifies things" is the same on every screen.
|
|
648
|
+
if (matchesKey(data, Key.space)) {
|
|
599
649
|
const opt = opts[st.cursor];
|
|
600
|
-
if (
|
|
601
|
-
|
|
602
|
-
|
|
650
|
+
if (!opt) return;
|
|
651
|
+
if (opt.isOther) {
|
|
652
|
+
// Enter edit mode for a custom answer. Prefill with any committed
|
|
653
|
+
// custom text so the user edits rather than retypes. Per-tab
|
|
654
|
+
// editor keeps the text for Esc-discard semantics automatically.
|
|
655
|
+
// - Single-select: custom text lives in the `custom` answer.
|
|
656
|
+
// - Multi-select: it lives in st.customText (kept alongside checks).
|
|
657
|
+
st.inputMode = true;
|
|
658
|
+
const existing = this.answers.get(q.tab);
|
|
659
|
+
const prefill = multi ? st.customText : existing?.kind === "custom" ? existing.text : null;
|
|
660
|
+
if (prefill) st.editor.setText(prefill);
|
|
603
661
|
this.invalidate();
|
|
662
|
+
return;
|
|
604
663
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
// Single-select: space commits the selection WITHOUT advancing (stay on question)
|
|
609
|
-
if (!multi && matchesKey(data, Key.space)) {
|
|
610
|
-
const opt = opts[st.cursor];
|
|
611
|
-
if (opt && !opt.isOther) {
|
|
612
|
-
st.selectedSingle = st.cursor;
|
|
613
|
-
this.answers.set(q.tab, {
|
|
614
|
-
tab: q.tab,
|
|
615
|
-
answerLabel: opt.label,
|
|
616
|
-
wasCustom: false,
|
|
617
|
-
index: st.cursor,
|
|
618
|
-
});
|
|
664
|
+
if (multi) {
|
|
665
|
+
if (st.multiChecked.has(st.cursor)) st.multiChecked.delete(st.cursor);
|
|
666
|
+
else st.multiChecked.add(st.cursor);
|
|
619
667
|
this.invalidate();
|
|
668
|
+
return;
|
|
620
669
|
}
|
|
670
|
+
// single-select: mark the selection WITHOUT advancing (stay on question)
|
|
671
|
+
st.selectedSingle = st.cursor;
|
|
672
|
+
this.answers.set(q.tab, {
|
|
673
|
+
tab: q.tab,
|
|
674
|
+
kind: "single",
|
|
675
|
+
option: opt.label,
|
|
676
|
+
});
|
|
677
|
+
this.invalidate();
|
|
621
678
|
return;
|
|
622
679
|
}
|
|
623
680
|
|
|
624
|
-
//
|
|
681
|
+
// Enter — confirm + advance to the next tab. It does NOT enter edit mode
|
|
682
|
+
// (Space owns that now), keeping the two keys orthogonal: Space modifies,
|
|
683
|
+
// Enter commits. Single-select commits the cursor position and advances;
|
|
684
|
+
// multi-select commits the currently checked options as-is and advances
|
|
685
|
+
// (Space owns checking, so Enter no longer auto-checks the cursor option).
|
|
625
686
|
if (matchesKey(data, Key.enter)) {
|
|
626
687
|
const opt = opts[st.cursor];
|
|
627
688
|
if (!opt) return;
|
|
628
|
-
if (opt.isOther) {
|
|
629
|
-
|
|
630
|
-
//
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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);
|
|
638
|
-
this.invalidate();
|
|
689
|
+
if (opt.isOther && !multi) {
|
|
690
|
+
// Single-select isOther: Enter never edits (Space owns that). Only
|
|
691
|
+
// advance if a custom answer was already committed; otherwise stay.
|
|
692
|
+
if (this.answers.get(q.tab)?.kind === "custom") {
|
|
693
|
+
this.advanceAfterAnswer();
|
|
694
|
+
}
|
|
639
695
|
return;
|
|
640
696
|
}
|
|
641
697
|
if (multi) {
|
|
642
|
-
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
|
|
698
|
+
// Commit the current checks (+ any custom text) and advance. For a
|
|
699
|
+
// skippable multi-select, committing an EMPTY selection is still a
|
|
700
|
+
// commit — Enter means "submit (even if empty) and move on", not
|
|
701
|
+
// "skip" (Tab/arrows do skipping). So we record an explicit empty
|
|
702
|
+
// answer and advance; a required question (!canSkip) with an empty
|
|
703
|
+
// selection stays put, since it must have at least one pick.
|
|
704
|
+
if (this.commitMultiAnswer(q, st)) {
|
|
705
|
+
this.advanceAfterAnswer();
|
|
706
|
+
} else if (canSkip(q)) {
|
|
707
|
+
this.answers.set(q.tab, {
|
|
708
|
+
tab: q.tab,
|
|
709
|
+
kind: "multi",
|
|
710
|
+
options: [],
|
|
711
|
+
});
|
|
712
|
+
this.advanceAfterAnswer();
|
|
713
|
+
}
|
|
646
714
|
return;
|
|
647
715
|
}
|
|
648
|
-
// single-select: commit cursor position as the selection
|
|
716
|
+
// single-select: commit cursor position as the selection, then advance
|
|
649
717
|
st.selectedSingle = st.cursor;
|
|
650
718
|
this.answers.set(q.tab, {
|
|
651
719
|
tab: q.tab,
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
index: st.cursor,
|
|
720
|
+
kind: "single",
|
|
721
|
+
option: opt.label,
|
|
655
722
|
});
|
|
656
723
|
this.advanceAfterAnswer();
|
|
657
724
|
return;
|
|
@@ -666,47 +733,56 @@ class AskUserPanel implements Component, Focusable {
|
|
|
666
733
|
this.invalidate();
|
|
667
734
|
}
|
|
668
735
|
|
|
669
|
-
/**
|
|
736
|
+
/** Shared tab navigation, invoked from handleInput for BOTH question tabs
|
|
737
|
+
* and the review tab. Returns true when the key was consumed.
|
|
738
|
+
*
|
|
739
|
+
* - Tab / → : forward. Tab WRAPS through every tab (questions → review →
|
|
740
|
+
* first question); → STOPS at the review tab (boundary).
|
|
741
|
+
* - Shift+Tab / ← : backward. Shift+Tab wraps; ← stops at the first
|
|
742
|
+
* question.
|
|
743
|
+
*
|
|
744
|
+
* Leaving a question tab may need to commit pending multi-select checks
|
|
745
|
+
* or record a skip (when it's unanswered and required) — that's handled
|
|
746
|
+
* by prepareQuestionForLeave. Leaving the review tab never needs that
|
|
747
|
+
* check (it isn't a question), so →/Tab work freely from review. */
|
|
748
|
+
private handleTabNavigation(data: string): boolean {
|
|
749
|
+
if (this.totalTabs <= 1) return false;
|
|
750
|
+
// Forward
|
|
751
|
+
if (matchesKey(data, Key.tab)) {
|
|
752
|
+
if (!this.isReviewTab && !this.prepareQuestionForLeave()) return true; // required: blocked
|
|
753
|
+
this.switchTab(wrapTab(this.currentTab + 1, this.totalTabs));
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
if (matchesKey(data, Key.right)) {
|
|
757
|
+
if (!this.isReviewTab && !this.prepareQuestionForLeave()) return true; // required: blocked
|
|
758
|
+
if (this.currentTab + 1 >= this.totalTabs) return true; // stop at review
|
|
759
|
+
this.switchTab(this.currentTab + 1);
|
|
760
|
+
return true;
|
|
761
|
+
}
|
|
762
|
+
// Backward
|
|
763
|
+
if (matchesKey(data, Key.shift("tab"))) {
|
|
764
|
+
this.switchTab(wrapTab(this.currentTab - 1, this.totalTabs));
|
|
765
|
+
return true;
|
|
766
|
+
}
|
|
767
|
+
if (matchesKey(data, Key.left)) {
|
|
768
|
+
if (this.currentTab - 1 < 0) return true; // stop at first question
|
|
769
|
+
this.switchTab(this.currentTab - 1);
|
|
770
|
+
return true;
|
|
771
|
+
}
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Handle input specific to the review tab. Esc and tab navigation
|
|
776
|
+
* (Tab/←/→) are already handled upstream in handleInput, so here we only
|
|
777
|
+
* deal with: ↑↓/PgUp/PgDn (move the review cursor), Space (open the entry
|
|
778
|
+
* under the cursor for editing), and Enter (submit the whole review).
|
|
779
|
+
*
|
|
670
780
|
* The review list has N question entries plus one trailing "note to
|
|
671
781
|
* assistant" entry (index N), so the cursor ranges over [0, N]. */
|
|
672
782
|
private handleReviewInput(data: string): void {
|
|
673
783
|
const n = this.questions.length;
|
|
674
784
|
const total = n + 1; // include the note entry
|
|
675
|
-
|
|
676
|
-
this.submit(true);
|
|
677
|
-
return;
|
|
678
|
-
}
|
|
679
|
-
if (matchesKey(data, Key.enter)) {
|
|
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).
|
|
684
|
-
this.submit(false);
|
|
685
|
-
return;
|
|
686
|
-
}
|
|
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
|
-
}
|
|
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.
|
|
704
|
-
this.reviewMode = false;
|
|
705
|
-
this.editingFromReview = true;
|
|
706
|
-
this.currentTab = this.reviewCursor;
|
|
707
|
-
this.invalidate();
|
|
708
|
-
return;
|
|
709
|
-
}
|
|
785
|
+
// ↑/↓/PgUp/PgDn — move the review cursor over [0, total-1]
|
|
710
786
|
if (matchesKey(data, Key.up)) {
|
|
711
787
|
if (this.reviewCursor > 0) { this.reviewCursor--; this.invalidate(); }
|
|
712
788
|
return;
|
|
@@ -725,6 +801,29 @@ class AskUserPanel implements Component, Focusable {
|
|
|
725
801
|
this.invalidate();
|
|
726
802
|
return;
|
|
727
803
|
}
|
|
804
|
+
// Space — "select" the entry under the cursor: jump into editing it.
|
|
805
|
+
// (Mirrors the option screens, where Space = select/toggle.)
|
|
806
|
+
if (matchesKey(data, Key.space)) {
|
|
807
|
+
if (this.reviewCursor === n) {
|
|
808
|
+
// Note entry: open the note editor. Prefill with the committed note
|
|
809
|
+
// (if any) so the user can tweak rather than retype.
|
|
810
|
+
this.messageEditing = true;
|
|
811
|
+
if (this.messageText) this.messageEditor.setText(this.messageText);
|
|
812
|
+
this.invalidate();
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
// Question entry: switch to that question's tab for editing.
|
|
816
|
+
// switchTab early-returns when next === currentTab, which is fine — that
|
|
817
|
+
// only happens on a single-question call where we're already on the
|
|
818
|
+
// question; nothing to redraw.
|
|
819
|
+
this.switchTab(this.reviewCursor);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
// Enter — submit the whole review, no matter where the cursor sits.
|
|
823
|
+
if (matchesKey(data, Key.enter)) {
|
|
824
|
+
this.submit(false);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
728
827
|
}
|
|
729
828
|
|
|
730
829
|
// ── render ──
|
|
@@ -745,19 +844,48 @@ class AskUserPanel implements Component, Focusable {
|
|
|
745
844
|
|
|
746
845
|
private renderCollapsed(width: number): string[] {
|
|
747
846
|
const th = this.theme;
|
|
748
|
-
const
|
|
749
|
-
.
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
})
|
|
754
|
-
|
|
847
|
+
const qParts = this.questions.map((q, i) => {
|
|
848
|
+
const done = this.answers.has(q.tab);
|
|
849
|
+
const active = i === this.currentTab && !this.isReviewTab;
|
|
850
|
+
const mark = active ? "▸" : done ? "✓" : "○";
|
|
851
|
+
const color = active ? "accent" : done ? "success" : "dim";
|
|
852
|
+
return th.fg(color, `${q.tab}${mark}`);
|
|
853
|
+
});
|
|
854
|
+
const reviewPart = this.isReviewTab
|
|
855
|
+
? th.fg("accent", "Review▸")
|
|
856
|
+
: th.fg("dim", "Review○");
|
|
857
|
+
const tabsPart = [...qParts, reviewPart].join(th.fg("dim", " "));
|
|
755
858
|
const inner = `${tabsPart} ${th.fg("dim", ` ${TOGGLE_HINT} expand `)}${th.fg("dim", " Esc cancel ")}`;
|
|
756
859
|
const line =
|
|
757
860
|
th.fg("border", "│") + inner + " ".repeat(Math.max(0, width - 2 - visibleWidth(inner))) + th.fg("border", "│");
|
|
758
861
|
return [truncateToWidth(line, width)];
|
|
759
862
|
}
|
|
760
863
|
|
|
864
|
+
/** Build the tab-bar content line (without border wrapping). Shared by the
|
|
865
|
+
* question screen and the review screen so the bar is always visible —
|
|
866
|
+
* the review tab is a real tab, so it must highlight when active just like
|
|
867
|
+
* any question. Callers wrap the returned string in their own row() so the
|
|
868
|
+
* border color matches the surrounding screen. */
|
|
869
|
+
private renderTabBarContent(th: Theme): string {
|
|
870
|
+
const tabCells = this.questions.map((q, i) => {
|
|
871
|
+
const active = i === this.currentTab;
|
|
872
|
+
const ans = this.answers.get(q.tab);
|
|
873
|
+
let mark = " ";
|
|
874
|
+
let baseColor: import("@earendil-works/pi-coding-agent").ThemeColor = active ? "accent" : "muted";
|
|
875
|
+
if (ans?.kind === "skipped") { mark = "—"; baseColor = "warning"; }
|
|
876
|
+
else if (ans) { mark = "✓"; baseColor = "success"; }
|
|
877
|
+
else if (active) mark = "▸";
|
|
878
|
+
const color = active ? "accent" : baseColor;
|
|
879
|
+
return th.fg(color, `${mark} ${q.tab}`);
|
|
880
|
+
});
|
|
881
|
+
const reviewActive = this.isReviewTab;
|
|
882
|
+
const reviewMark = reviewActive ? "▸" : " ";
|
|
883
|
+
const reviewColor: import("@earendil-works/pi-coding-agent").ThemeColor = reviewActive ? "accent" : "muted";
|
|
884
|
+
const reviewCell = th.fg(reviewColor, `${reviewMark} [ Review ]`);
|
|
885
|
+
const sep = th.fg("dim", " │");
|
|
886
|
+
return ` ${tabCells.join(th.fg("dim", " "))}${sep}${reviewCell}`;
|
|
887
|
+
}
|
|
888
|
+
|
|
761
889
|
private renderExpanded(width: number): string[] {
|
|
762
890
|
const th = this.theme;
|
|
763
891
|
const innerW = Math.max(20, width - 2);
|
|
@@ -767,29 +895,18 @@ class AskUserPanel implements Component, Focusable {
|
|
|
767
895
|
if (this.messageEditing) {
|
|
768
896
|
return this.renderMessageEditor(width, innerW, th);
|
|
769
897
|
}
|
|
770
|
-
if (this.
|
|
898
|
+
if (this.isReviewTab) {
|
|
771
899
|
return this.renderReview(width, innerW, th);
|
|
772
900
|
}
|
|
773
901
|
|
|
774
902
|
lines.push(th.fg("border", `╭${"─".repeat(innerW)}╮`));
|
|
775
903
|
|
|
776
904
|
// ── Tab bar ──
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
let mark = " ";
|
|
783
|
-
let baseColor: import("@earendil-works/pi-coding-agent").ThemeColor = active ? "accent" : "muted";
|
|
784
|
-
if (ans?.skipped) { mark = "—"; baseColor = "warning"; }
|
|
785
|
-
else if (ans) { mark = "✓"; baseColor = "success"; }
|
|
786
|
-
else if (active) mark = "▸";
|
|
787
|
-
const color = active ? "accent" : baseColor;
|
|
788
|
-
return th.fg(color, `${mark} ${label}`);
|
|
789
|
-
});
|
|
790
|
-
lines.push(row(` ${tabCells.join(th.fg("dim", " "))}`));
|
|
791
|
-
lines.push(row(""));
|
|
792
|
-
}
|
|
905
|
+
// Always shown: there is always at least one question tab plus the
|
|
906
|
+
// trailing review tab. renderTabBarContent is shared with the review
|
|
907
|
+
// screen so the active tab stays visible across every screen.
|
|
908
|
+
lines.push(row(this.renderTabBarContent(th)));
|
|
909
|
+
lines.push(row(""));
|
|
793
910
|
|
|
794
911
|
// ── Question header ──
|
|
795
912
|
const q = this.currentQuestion();
|
|
@@ -830,7 +947,7 @@ class AskUserPanel implements Component, Focusable {
|
|
|
830
947
|
this.questions.length > 1 ? th.fg("dim", ` ${doneCount}/${this.questions.length} answered · `) : th.fg("dim", " ");
|
|
831
948
|
const hint = multi
|
|
832
949
|
? `${TOGGLE_HINT} collapse · ↑↓ move · Space toggle · Enter confirm · Esc cancel`
|
|
833
|
-
: `${TOGGLE_HINT} collapse · ↑↓ move · Enter confirm · Esc cancel`;
|
|
950
|
+
: `${TOGGLE_HINT} collapse · ↑↓ move · Space select · Enter confirm · Esc cancel`;
|
|
834
951
|
lines.push(row(`${left}${th.fg("dim", hint)}`));
|
|
835
952
|
lines.push(th.fg("border", `╰${"─".repeat(innerW)}╯`));
|
|
836
953
|
return lines;
|
|
@@ -853,18 +970,12 @@ class AskUserPanel implements Component, Focusable {
|
|
|
853
970
|
return filled ? th.fg("success", ICON_RADIO_FILLED) : th.fg("dim", ICON_RADIO_EMPTY);
|
|
854
971
|
}
|
|
855
972
|
|
|
856
|
-
/** Format an answer for the review summary
|
|
973
|
+
/** Format an answer for the review summary. Delegates to describeAnswer
|
|
974
|
+
* (single source of truth), then wraps in the theme color + truncates. */
|
|
857
975
|
private formatAnswerText(ans: Answer | undefined, maxW: number, th: Theme): string {
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
if (ans.multiSelect) text = (ans.answerLabels ?? []).join(", ");
|
|
862
|
-
else text = ans.answerLabel;
|
|
863
|
-
const vw = visibleWidth(text);
|
|
864
|
-
if (vw <= maxW) return th.fg("text", text);
|
|
865
|
-
// truncate: keep prefix, append “…”
|
|
866
|
-
const cut = truncateToWidth(text, maxW - 1, "");
|
|
867
|
-
return th.fg("text", cut) + th.fg("dim", "…");
|
|
976
|
+
const view = describeAnswer(ans);
|
|
977
|
+
const text = truncForDisplay(view.text, maxW);
|
|
978
|
+
return th.fg(view.color, text);
|
|
868
979
|
}
|
|
869
980
|
|
|
870
981
|
/** Clamp the review scroll offset so the cursor stays visible. The review
|
|
@@ -890,6 +1001,8 @@ class AskUserPanel implements Component, Focusable {
|
|
|
890
1001
|
const bc: import("@earendil-works/pi-coding-agent").ThemeColor = "success";
|
|
891
1002
|
const row = (content: string) => th.fg(bc, "│") + padRight(content, innerW) + th.fg(bc, "│");
|
|
892
1003
|
lines.push(th.fg(bc, `╭${"─".repeat(innerW)}╮`));
|
|
1004
|
+
lines.push(row(this.renderTabBarContent(th)));
|
|
1005
|
+
lines.push(row(""));
|
|
893
1006
|
lines.push(row(` ${th.fg("accent", th.bold("Review your answers"))}`));
|
|
894
1007
|
lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
|
|
895
1008
|
const n = this.questions.length;
|
|
@@ -927,7 +1040,7 @@ class AskUserPanel implements Component, Focusable {
|
|
|
927
1040
|
const body = vw <= maxW ? msg : `${truncateToWidth(msg, maxW - 1, "")}…`;
|
|
928
1041
|
lines.push(row(`${bodyIndent}${th.fg("text", body)}`));
|
|
929
1042
|
} else {
|
|
930
|
-
lines.push(row(`${bodyIndent}${th.fg("dim", "(optional —
|
|
1043
|
+
lines.push(row(`${bodyIndent}${th.fg("dim", "(optional — Space to add a note)")}`));
|
|
931
1044
|
}
|
|
932
1045
|
continue;
|
|
933
1046
|
}
|
|
@@ -946,12 +1059,12 @@ class AskUserPanel implements Component, Focusable {
|
|
|
946
1059
|
lines.push(row(th.fg("dim", `${bodyIndent}↑↓/PgUp/PgDn scroll · ${start + 1}-${end}/${total}`)));
|
|
947
1060
|
}
|
|
948
1061
|
lines.push(th.fg(bc, `├${"─".repeat(innerW)}┤`));
|
|
949
|
-
lines.push(row(th.fg("dim", " ↑↓
|
|
1062
|
+
lines.push(row(th.fg("dim", " ↑↓ move · Space edit · Enter confirm · Esc cancel")));
|
|
950
1063
|
lines.push(th.fg(bc, `╰${"─".repeat(innerW)}╯`));
|
|
951
1064
|
return lines;
|
|
952
1065
|
}
|
|
953
1066
|
|
|
954
|
-
/** Note editor screen: reached from the review's note entry via
|
|
1067
|
+
/** Note editor screen: reached from the review's note entry via Space. Uses
|
|
955
1068
|
* the same success-bordered look as the review screen to signal it's part
|
|
956
1069
|
* of the review flow, not a fresh question. */
|
|
957
1070
|
private renderMessageEditor(width: number, innerW: number, th: Theme): string[] {
|
|
@@ -984,7 +1097,7 @@ class AskUserPanel implements Component, Focusable {
|
|
|
984
1097
|
const isCursor = i === st.cursor;
|
|
985
1098
|
const prefix = isCursor ? `${th.fg("accent", ICON_CURSOR)} ` : " ";
|
|
986
1099
|
const ans = this.answers.get(q.tab);
|
|
987
|
-
const committedCustom = multi ? st.customText : ans?.
|
|
1100
|
+
const committedCustom = multi ? st.customText : ans?.kind === "custom" ? ans.text : null;
|
|
988
1101
|
const customAnswered = !!committedCustom;
|
|
989
1102
|
const glyph = this.optionGlyph(opt, i, st, multi, th, isCursor, customAnswered);
|
|
990
1103
|
// For "Type something.", show the committed text instead of the placeholder.
|
|
@@ -1028,7 +1141,7 @@ class AskUserPanel implements Component, Focusable {
|
|
|
1028
1141
|
// ── build left column lines (options) ──
|
|
1029
1142
|
const leftLines: string[] = [];
|
|
1030
1143
|
const dualAns = this.answers.get(q.tab);
|
|
1031
|
-
const dualCommittedCustom = multi ? st.customText : dualAns?.
|
|
1144
|
+
const dualCommittedCustom = multi ? st.customText : dualAns?.kind === "custom" ? dualAns.text : null;
|
|
1032
1145
|
const customAnswered = !!dualCommittedCustom;
|
|
1033
1146
|
for (let i = start; i < end; i++) {
|
|
1034
1147
|
const opt = opts[i]!;
|
|
@@ -1095,24 +1208,155 @@ class AskUserPanel implements Component, Focusable {
|
|
|
1095
1208
|
}
|
|
1096
1209
|
|
|
1097
1210
|
// ────────────────────────────────────────────────────────────────────────────
|
|
1098
|
-
//
|
|
1211
|
+
// Result view — static card shown in the message stream after ask_user.
|
|
1212
|
+
// Lets the user verify at a glance what they chose. Two states driven by
|
|
1213
|
+
// options.expanded: collapsed = one-line summary, expanded = bordered card.
|
|
1214
|
+
// Reuses AskUserPanel's visual language (same border glyphs, theme colors,
|
|
1215
|
+
// ✓/⊘/○ status icons, ✎ for custom) so it reads as a continuation of the
|
|
1216
|
+
// interaction, not a foreign element.
|
|
1099
1217
|
// ────────────────────────────────────────────────────────────────────────────
|
|
1100
1218
|
|
|
1101
|
-
|
|
1102
|
-
|
|
1219
|
+
class AskUserResultView implements Component {
|
|
1220
|
+
private questions: ReadonlyArray<Pick<Question, "header" | "tab">>;
|
|
1221
|
+
private result: AskUserResult;
|
|
1222
|
+
private theme: Theme;
|
|
1223
|
+
private expanded = false;
|
|
1224
|
+
private cachedWidth?: number;
|
|
1225
|
+
private cachedLines?: string[];
|
|
1103
1226
|
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
});
|
|
1227
|
+
constructor(questions: ReadonlyArray<Pick<Question, "header" | "tab">>, result: AskUserResult, theme: Theme) {
|
|
1228
|
+
this.questions = questions;
|
|
1229
|
+
this.result = result;
|
|
1230
|
+
this.theme = theme;
|
|
1231
|
+
}
|
|
1110
1232
|
|
|
1111
|
-
|
|
1233
|
+
setExpanded(expanded: boolean): void {
|
|
1234
|
+
if (this.expanded !== expanded) {
|
|
1235
|
+
this.expanded = expanded;
|
|
1236
|
+
this.cachedWidth = undefined;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
invalidate(): void {
|
|
1241
|
+
this.cachedWidth = undefined;
|
|
1242
|
+
this.cachedLines = undefined;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
render(width: number): string[] {
|
|
1246
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
1247
|
+
this.cachedWidth = width;
|
|
1248
|
+
this.cachedLines = this.expanded ? this.renderCard(width) : this.renderCollapsed(width);
|
|
1249
|
+
return this.cachedLines;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/** Overall status → icon, color, and a short status phrase (plain text,
|
|
1253
|
+
* no ANSI — callers wrap it in color). */
|
|
1254
|
+
private getStatus(): { icon: string; color: ThemeColor; phrase: string } {
|
|
1255
|
+
const total = this.questions.length;
|
|
1256
|
+
if (this.result.cancelled) {
|
|
1257
|
+
return {
|
|
1258
|
+
icon: "⊘",
|
|
1259
|
+
color: "warning",
|
|
1260
|
+
phrase: `Cancelled · ${this.result.answers.length}/${total} answered`,
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
const anySkipped = this.result.answers.some((a) => a.kind === "skipped");
|
|
1264
|
+
return anySkipped
|
|
1265
|
+
? { icon: "○", color: "accent", phrase: "Answers (some skipped)" }
|
|
1266
|
+
: { icon: "✓", color: "success", phrase: "Answers submitted" };
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/** Format one answer for the card display. Delegates to describeAnswer. */
|
|
1270
|
+
private formatAnswer(ans: Answer | undefined): { text: string; color: ThemeColor } {
|
|
1271
|
+
return describeAnswer(ans);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
private renderCollapsed(width: number): string[] {
|
|
1275
|
+
const th = this.theme;
|
|
1276
|
+
const { icon, color, phrase } = this.getStatus();
|
|
1277
|
+
const head = `${th.fg(color, icon)} ${th.fg(color, phrase)}`;
|
|
1278
|
+
const sep = th.fg("dim", ": ");
|
|
1279
|
+
const pairs = this.questions.map((q) => {
|
|
1280
|
+
const ans = this.result.answers.find((a) => a.tab === q.tab);
|
|
1281
|
+
return `${q.header}=${this.formatAnswer(ans).text}`;
|
|
1282
|
+
});
|
|
1283
|
+
const body = pairs.join(th.fg("dim", " · "));
|
|
1284
|
+
return [th.fg("dim", truncForDisplay(`${head}${sep}${body}`, width))];
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
private renderCard(width: number): string[] {
|
|
1288
|
+
const th = this.theme;
|
|
1289
|
+
const { icon, color, phrase } = this.getStatus();
|
|
1290
|
+
const lines: string[] = [];
|
|
1291
|
+
// Status line — icon + phrase in the status color. No border, no redundant
|
|
1292
|
+
// "Ask User" title (the tool-execution cell already renders the tool name
|
|
1293
|
+
// as its header above this component).
|
|
1294
|
+
lines.push(`${th.fg(color, icon)} ${th.fg(color, th.bold(phrase))}`);
|
|
1295
|
+
lines.push(""); // blank line separates status from the Q&A list
|
|
1296
|
+
|
|
1297
|
+
// Each question: header on its own row, then one or more answer rows.
|
|
1298
|
+
// Rows are prefixed by a glyph indicating the answer TYPE, not a uniform
|
|
1299
|
+
// marker: option picks get an arrow (›), custom text gets a pencil (✎).
|
|
1300
|
+
// A multi-select with BOTH options and custom renders as TWO rows. Long
|
|
1301
|
+
// content wraps (wrapTextWithAnsi) so nothing is ever truncated/lost.
|
|
1302
|
+
const indent = " "; // 4-space lead for answer rows
|
|
1303
|
+
const arrow = th.fg("dim", ICON_ANSWER);
|
|
1304
|
+
const pencil = th.fg("dim", ICON_OTHER);
|
|
1305
|
+
const answerRow = (glyph: string, text: string, textColor: ThemeColor) => {
|
|
1306
|
+
const lead = `${indent}${glyph} `;
|
|
1307
|
+
const w = Math.max(8, width - visibleWidth(lead));
|
|
1308
|
+
const wrapped = wrapTextWithAnsi(th.fg(textColor, text), w);
|
|
1309
|
+
const contIndent = " ".repeat(visibleWidth(lead));
|
|
1310
|
+
const out = [`${lead}${wrapped[0]}`];
|
|
1311
|
+
for (let i = 1; i < wrapped.length; i++) out.push(`${contIndent}${wrapped[i]}`);
|
|
1312
|
+
return out;
|
|
1313
|
+
};
|
|
1314
|
+
for (const q of this.questions) {
|
|
1315
|
+
const ans = this.result.answers.find((a) => a.tab === q.tab);
|
|
1316
|
+
lines.push(th.fg("muted", q.header));
|
|
1317
|
+
if (!ans) {
|
|
1318
|
+
lines.push(`${indent}${th.fg("dim", "(no answer)")}`);
|
|
1319
|
+
} else if (ans.kind === "skipped") {
|
|
1320
|
+
lines.push(`${indent}${th.fg("warning", "(skipped)")}`);
|
|
1321
|
+
} else if (ans.kind === "single") {
|
|
1322
|
+
lines.push(...answerRow(arrow, ans.option, "text"));
|
|
1323
|
+
} else if (ans.kind === "custom") {
|
|
1324
|
+
lines.push(...answerRow(pencil, ans.text, "text"));
|
|
1325
|
+
} else if (ans.kind === "multi") {
|
|
1326
|
+
if (ans.options.length === 0 && !ans.custom) {
|
|
1327
|
+
lines.push(`${indent}${th.fg("dim", "(none)")}`);
|
|
1328
|
+
} else {
|
|
1329
|
+
// Options row (arrow) + optional custom row (pencil) — two rows.
|
|
1330
|
+
if (ans.options.length > 0) lines.push(...answerRow(arrow, ans.options.join(", "), "text"));
|
|
1331
|
+
if (ans.custom) lines.push(...answerRow(pencil, ans.custom, "text"));
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// Note — separated by a blank line, no border, no indent. A speech-bubble
|
|
1337
|
+
// glyph marks it as a free-form message, distinct from the Q&A answers.
|
|
1338
|
+
if (this.result.message) {
|
|
1339
|
+
lines.push("");
|
|
1340
|
+
const prefix = th.fg("accent", ICON_NOTE);
|
|
1341
|
+
const noteW = Math.max(8, width - visibleWidth(prefix) - 1);
|
|
1342
|
+
const wrapped = wrapTextWithAnsi(this.result.message, noteW);
|
|
1343
|
+
lines.push(`${prefix} ${wrapped[0]}`);
|
|
1344
|
+
for (let i = 1; i < wrapped.length; i++) lines.push(wrapped[i]);
|
|
1345
|
+
}
|
|
1346
|
+
return lines;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
1351
|
+
// Extension entry
|
|
1352
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
1353
|
+
|
|
1354
|
+
export default function askUserExtension(pi: ExtensionAPI) {
|
|
1355
|
+
pi.registerTool<typeof AskUserParams, AskUserResult>({
|
|
1112
1356
|
name: "ask_user",
|
|
1113
1357
|
label: "Ask User",
|
|
1114
1358
|
description:
|
|
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
|
|
1359
|
+
"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 is returned as JSON: `{ \"cancelled\": bool, \"answers\": [{ \"tab\": <the question's tab>, ... }], \"message\"?: string }`. Each answer echoes the question's `tab` so you can correlate by key. Per answer only the relevant fields appear: single-select option pick → `answer`; single-select custom text → `custom`; multi-select option picks → `answers: [...]`; multi-select with custom → `answers` + `custom`; multi-select empty commit (skippable, submitted with nothing) → `answers: []`; Tab-skipped → `skipped: true`. `custom` is a sibling of `answer`/`answers`, never mixed in — it signals the user typed something outside the offered options. The optional top-level `message` is 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.",
|
|
1116
1360
|
parameters: AskUserParams,
|
|
1117
1361
|
|
|
1118
1362
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
@@ -1128,61 +1372,84 @@ export default function askUserExtension(pi: ExtensionAPI) {
|
|
|
1128
1372
|
options: q.options.map((o) => ({ ...o })),
|
|
1129
1373
|
}));
|
|
1130
1374
|
|
|
1131
|
-
let overlayHandle: { focus: () => void; unfocus: () => void } | null = null;
|
|
1132
|
-
let panel: AskUserPanel | null = null;
|
|
1133
|
-
|
|
1134
1375
|
const result = await ctx.ui.custom<AskUserResult>((tui, theme, _kb, done) => {
|
|
1135
|
-
|
|
1376
|
+
return new AskUserPanel(questions, tui, theme, {
|
|
1136
1377
|
onResult: (r) => done(r),
|
|
1137
|
-
onCollapseChange: (collapsed) => {
|
|
1138
|
-
if (collapsed) {
|
|
1139
|
-
overlayHandle?.unfocus();
|
|
1140
|
-
activeExpand = () => {
|
|
1141
|
-
if (!panel) return;
|
|
1142
|
-
panel.expandFromShortcut();
|
|
1143
|
-
overlayHandle?.focus();
|
|
1144
|
-
};
|
|
1145
|
-
} else {
|
|
1146
|
-
activeExpand = null;
|
|
1147
|
-
overlayHandle?.focus();
|
|
1148
|
-
}
|
|
1149
|
-
},
|
|
1150
1378
|
});
|
|
1151
|
-
return panel;
|
|
1152
1379
|
}, {
|
|
1153
|
-
overlay:
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
overlayHandle = handle;
|
|
1161
|
-
handle.focus();
|
|
1162
|
-
},
|
|
1380
|
+
// overlay:false renders the panel into pi's bottom editorContainer slot
|
|
1381
|
+
// (the same path ctx.ui.select()/input() take) instead of compositing a
|
|
1382
|
+
// screen overlay over everything. The chat transcript stays visible above
|
|
1383
|
+
// the panel and is scrollable via the terminal's native scrollback. See
|
|
1384
|
+
// "Layout" note at the top of this file for why overlay:true breaks
|
|
1385
|
+
// transcript scrolling.
|
|
1386
|
+
overlay: false,
|
|
1163
1387
|
});
|
|
1164
1388
|
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
//
|
|
1177
|
-
//
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1389
|
+
// ── Build the JSON payload returned to the LLM ──
|
|
1390
|
+
//
|
|
1391
|
+
// Structure is intentionally SYMMETRIC with the questions schema the LLM
|
|
1392
|
+
// authored (AskUserParams): each question carries a `tab` as its identity
|
|
1393
|
+
// key, and each answer echoes that same `tab`, so the LLM can correlate
|
|
1394
|
+
// answers back to its own questions by key with zero parsing effort. This
|
|
1395
|
+
// replaces the old `tab: answer` text format, which broke when a custom
|
|
1396
|
+
// answer contained a colon or newline (it could overwrite or collide with
|
|
1397
|
+
// adjacent lines).
|
|
1398
|
+
//
|
|
1399
|
+
// Field shape (only relevant fields are emitted — no noise):
|
|
1400
|
+
// single-select, option picked : { tab, answer }
|
|
1401
|
+
// single-select, custom typed : { tab, custom } // custom NOT in answer
|
|
1402
|
+
// multi-select, options picked : { tab, answers: [...] }
|
|
1403
|
+
// multi-select, options+custom : { tab, answers: [...], custom }
|
|
1404
|
+
// multi-select, custom only : { tab, custom }
|
|
1405
|
+
// multi-select, empty commit : { tab, answers: [] } // NOT skipped
|
|
1406
|
+
// any question, Tab-skipped : { tab, skipped: true }
|
|
1407
|
+
//
|
|
1408
|
+
// `custom` gets its own key (rather than being mixed into answer/answers)
|
|
1409
|
+
// so the LLM can tell the user stepped outside the offered options — a
|
|
1410
|
+
// useful signal, not an anti-spoofing measure. The empty-message note is
|
|
1411
|
+
// omitted entirely ("user left no note" is noise the LLM doesn't need).
|
|
1412
|
+
const jsonAnswers = result.answers.map((a): Record<string, unknown> => {
|
|
1413
|
+
const out: Record<string, unknown> = { tab: a.tab };
|
|
1414
|
+
switch (a.kind) {
|
|
1415
|
+
case "skipped":
|
|
1416
|
+
out.skipped = true;
|
|
1417
|
+
break;
|
|
1418
|
+
case "single":
|
|
1419
|
+
out.answer = a.option;
|
|
1420
|
+
break;
|
|
1421
|
+
case "custom":
|
|
1422
|
+
out.custom = a.text;
|
|
1423
|
+
break;
|
|
1424
|
+
case "multi":
|
|
1425
|
+
out.answers = a.options;
|
|
1426
|
+
if (a.custom) out.custom = a.custom;
|
|
1427
|
+
break;
|
|
1428
|
+
}
|
|
1429
|
+
return out;
|
|
1430
|
+
});
|
|
1431
|
+
const payload: Record<string, unknown> = {
|
|
1432
|
+
cancelled: result.cancelled,
|
|
1433
|
+
answers: jsonAnswers,
|
|
1434
|
+
};
|
|
1435
|
+
if (result.message) payload.message = result.message;
|
|
1181
1436
|
|
|
1182
1437
|
return {
|
|
1183
|
-
content: [{ type: "text", text:
|
|
1438
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
1184
1439
|
details: result,
|
|
1185
1440
|
};
|
|
1186
1441
|
},
|
|
1442
|
+
renderResult(result, options, theme, context) {
|
|
1443
|
+
// context.args.questions is the schema-static type; AskUserResultView
|
|
1444
|
+
// only needs header+tab, so the structural subtype is compatible.
|
|
1445
|
+
const questions = context.args?.questions ?? [];
|
|
1446
|
+
const details = result.details ?? { questions: [], answers: [], cancelled: true };
|
|
1447
|
+
const comp =
|
|
1448
|
+
context.lastComponent instanceof AskUserResultView
|
|
1449
|
+
? context.lastComponent
|
|
1450
|
+
: new AskUserResultView(questions, details, theme);
|
|
1451
|
+
comp.setExpanded(options.expanded);
|
|
1452
|
+
return comp;
|
|
1453
|
+
},
|
|
1187
1454
|
});
|
|
1188
1455
|
}
|