pi-ask-user 0.4.0 → 0.5.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 +6 -14
- package/index.ts +465 -120
- package/package.json +2 -2
- package/single-select-layout.ts +6 -3
package/README.md
CHANGED
|
@@ -10,14 +10,16 @@ High-quality video: [ask-user-demo.mp4](./media/ask-user-demo.mp4)
|
|
|
10
10
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
13
|
-
-
|
|
13
|
+
- Searchable single-select option lists with wrapped titles and descriptions
|
|
14
|
+
- Responsive split-pane details preview on wide terminals with single-column fallback on narrow terminals
|
|
14
15
|
- Multi-select option lists
|
|
15
16
|
- Optional freeform responses
|
|
16
17
|
- Context display support
|
|
17
18
|
- Overlay mode — dialog floats over conversation, preserving context
|
|
19
|
+
- Pi-TUI-aligned keybinding and editor behavior
|
|
18
20
|
- Custom TUI rendering for tool calls and results
|
|
19
21
|
- System prompt integration via `promptSnippet` and `promptGuidelines`
|
|
20
|
-
- Optional timeout for auto-dismiss
|
|
22
|
+
- Optional timeout for auto-dismiss in both overlay and fallback input modes
|
|
21
23
|
- Structured `details` on all results for session state reconstruction
|
|
22
24
|
- Graceful fallback when interactive UI is unavailable
|
|
23
25
|
- Bundled `ask-user` skill for mandatory decision-gating in high-stakes or ambiguous tasks
|
|
@@ -60,7 +62,7 @@ The registered tool name is:
|
|
|
60
62
|
| `options` | `(string \| {title, description?})[]?` | `[]` | Multiple-choice options |
|
|
61
63
|
| `allowMultiple` | `boolean?` | `false` | Enable multi-select mode |
|
|
62
64
|
| `allowFreeform` | `boolean?` | `true` | Add a "Type something" freeform option |
|
|
63
|
-
| `timeout` | `number?` | — | Auto-dismiss after N ms
|
|
65
|
+
| `timeout` | `number?` | — | Auto-dismiss after N ms and return `null` if the prompt times out |
|
|
64
66
|
|
|
65
67
|
## Example usage shape
|
|
66
68
|
|
|
@@ -94,14 +96,4 @@ interface AskToolDetails {
|
|
|
94
96
|
|
|
95
97
|
## Changelog
|
|
96
98
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
- Added `promptSnippet` and `promptGuidelines` for better LLM tool selection in the system prompt
|
|
100
|
-
- Added `renderCall` and `renderResult` for custom TUI rendering (compact tool call display, ✓/Cancelled result indicators)
|
|
101
|
-
- Added overlay mode — dialog now floats over the conversation instead of clearing the screen
|
|
102
|
-
- Added `timeout` parameter for auto-dismiss in fallback input mode (when no options are provided)
|
|
103
|
-
- Added structured `details` (`AskToolDetails`) to all result paths for session state reconstruction and branching support
|
|
104
|
-
|
|
105
|
-
### 0.2.1
|
|
106
|
-
|
|
107
|
-
- Initial public release
|
|
99
|
+
See [CHANGELOG.md](./CHANGELOG.md).
|
package/index.ts
CHANGED
|
@@ -2,18 +2,24 @@
|
|
|
2
2
|
* Ask Tool Extension - Interactive question UI for pi-coding-agent
|
|
3
3
|
*
|
|
4
4
|
* Refactored to use built-in TUI primitives (Container/Text/Spacer/SelectList/Editor)
|
|
5
|
-
* and
|
|
5
|
+
* and a custom box border instead of manual ANSI box drawing.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
|
|
9
|
-
import {
|
|
9
|
+
import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
|
|
10
10
|
import { Type } from "@sinclair/typebox";
|
|
11
11
|
import {
|
|
12
12
|
Container,
|
|
13
13
|
type Component,
|
|
14
|
+
decodeKittyPrintable,
|
|
14
15
|
Editor,
|
|
15
16
|
type EditorTheme,
|
|
17
|
+
fuzzyFilter,
|
|
16
18
|
Key,
|
|
19
|
+
type Keybinding,
|
|
20
|
+
type KeybindingsManager,
|
|
21
|
+
Markdown,
|
|
22
|
+
type MarkdownTheme,
|
|
17
23
|
matchesKey,
|
|
18
24
|
Spacer,
|
|
19
25
|
Text,
|
|
@@ -21,12 +27,7 @@ import {
|
|
|
21
27
|
truncateToWidth,
|
|
22
28
|
wrapTextWithAnsi,
|
|
23
29
|
} from "@mariozechner/pi-tui";
|
|
24
|
-
import { renderSingleSelectRows } from "./single-select-layout";
|
|
25
|
-
|
|
26
|
-
interface QuestionOption {
|
|
27
|
-
title: string;
|
|
28
|
-
description?: string;
|
|
29
|
-
}
|
|
30
|
+
import { renderSingleSelectRows, type QuestionOption } from "./single-select-layout";
|
|
30
31
|
|
|
31
32
|
type AskOptionInput = QuestionOption | string;
|
|
32
33
|
|
|
@@ -48,6 +49,11 @@ interface AskToolDetails {
|
|
|
48
49
|
wasCustom?: boolean;
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
interface AskUIResult {
|
|
53
|
+
answer: string;
|
|
54
|
+
wasCustom: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
51
57
|
function normalizeOptions(options: AskOptionInput[]): QuestionOption[] {
|
|
52
58
|
return options
|
|
53
59
|
.map((option) => {
|
|
@@ -71,8 +77,6 @@ function formatOptionsForMessage(options: QuestionOption[]): string {
|
|
|
71
77
|
.join("\n");
|
|
72
78
|
}
|
|
73
79
|
|
|
74
|
-
const FREEFORM_VALUE = "__freeform__";
|
|
75
|
-
|
|
76
80
|
function createSelectListTheme(theme: Theme) {
|
|
77
81
|
return {
|
|
78
82
|
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
@@ -90,15 +94,78 @@ function createEditorTheme(theme: Theme): EditorTheme {
|
|
|
90
94
|
};
|
|
91
95
|
}
|
|
92
96
|
|
|
97
|
+
const BOX_BORDER_LEFT = "│ ";
|
|
98
|
+
const BOX_BORDER_RIGHT = " │";
|
|
99
|
+
const BOX_BORDER_OVERHEAD = BOX_BORDER_LEFT.length + BOX_BORDER_RIGHT.length;
|
|
100
|
+
|
|
101
|
+
class BoxBorderTop implements Component {
|
|
102
|
+
private color: (s: string) => string;
|
|
103
|
+
private title?: string;
|
|
104
|
+
private titleColor?: (s: string) => string;
|
|
105
|
+
constructor(color: (s: string) => string, title?: string, titleColor?: (s: string) => string) {
|
|
106
|
+
this.color = color;
|
|
107
|
+
this.title = title;
|
|
108
|
+
this.titleColor = titleColor;
|
|
109
|
+
}
|
|
110
|
+
invalidate(): void {}
|
|
111
|
+
render(width: number): string[] {
|
|
112
|
+
const inner = Math.max(0, width - 2);
|
|
113
|
+
if (!this.title || inner < this.title.length + 4) {
|
|
114
|
+
return [this.color(`╭${"─".repeat(inner)}╮`)];
|
|
115
|
+
}
|
|
116
|
+
const label = ` ${this.title} `;
|
|
117
|
+
const remaining = inner - 1 - label.length;
|
|
118
|
+
const titleStyle = this.titleColor ?? this.color;
|
|
119
|
+
return [
|
|
120
|
+
this.color("╭─") + titleStyle(label) + this.color("─".repeat(Math.max(0, remaining)) + "╮"),
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
class BoxBorderBottom implements Component {
|
|
126
|
+
private color: (s: string) => string;
|
|
127
|
+
constructor(color: (s: string) => string) {
|
|
128
|
+
this.color = color;
|
|
129
|
+
}
|
|
130
|
+
invalidate(): void {}
|
|
131
|
+
render(width: number): string[] {
|
|
132
|
+
const inner = Math.max(0, width - 2);
|
|
133
|
+
return [this.color(`╰${"─".repeat(inner)}╯`)];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function formatKeyList(keys: string[]): string {
|
|
138
|
+
return keys.join("/");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function keybindingHint(
|
|
142
|
+
theme: Theme,
|
|
143
|
+
keybindings: KeybindingsManager,
|
|
144
|
+
keybinding: Keybinding,
|
|
145
|
+
description: string,
|
|
146
|
+
): string {
|
|
147
|
+
return `${theme.fg("dim", formatKeyList(keybindings.getKeys(keybinding)))}${theme.fg("muted", ` ${description}`)}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function literalHint(theme: Theme, key: string, description: string): string {
|
|
151
|
+
return `${theme.fg("dim", key)}${theme.fg("muted", ` ${description}`)}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
93
154
|
type AskMode = "select" | "freeform";
|
|
94
155
|
|
|
95
156
|
const ASK_OVERLAY_MAX_HEIGHT_RATIO = 0.85;
|
|
96
157
|
const ASK_OVERLAY_WIDTH = "92%";
|
|
158
|
+
const ASK_OVERLAY_MIN_WIDTH = 40;
|
|
159
|
+
const SINGLE_SELECT_SPLIT_PANE_MIN_WIDTH = 84;
|
|
160
|
+
const SINGLE_SELECT_SPLIT_PANE_LEFT_MIN_WIDTH = 32;
|
|
161
|
+
const SINGLE_SELECT_SPLIT_PANE_RIGHT_MIN_WIDTH = 28;
|
|
162
|
+
const SINGLE_SELECT_SPLIT_PANE_SEPARATOR = " │ ";
|
|
97
163
|
|
|
98
164
|
class MultiSelectList implements Component {
|
|
99
165
|
private options: QuestionOption[];
|
|
100
166
|
private allowFreeform: boolean;
|
|
101
167
|
private theme: Theme;
|
|
168
|
+
private keybindings: KeybindingsManager;
|
|
102
169
|
private selectedIndex = 0;
|
|
103
170
|
private checked = new Set<number>();
|
|
104
171
|
private cachedWidth?: number;
|
|
@@ -108,10 +175,11 @@ class MultiSelectList implements Component {
|
|
|
108
175
|
public onSubmit?: (result: string) => void;
|
|
109
176
|
public onEnterFreeform?: () => void;
|
|
110
177
|
|
|
111
|
-
constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme) {
|
|
178
|
+
constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme, keybindings: KeybindingsManager) {
|
|
112
179
|
this.options = options;
|
|
113
180
|
this.allowFreeform = allowFreeform;
|
|
114
181
|
this.theme = theme;
|
|
182
|
+
this.keybindings = keybindings;
|
|
115
183
|
}
|
|
116
184
|
|
|
117
185
|
invalidate(): void {
|
|
@@ -134,7 +202,7 @@ class MultiSelectList implements Component {
|
|
|
134
202
|
}
|
|
135
203
|
|
|
136
204
|
handleInput(data: string): void {
|
|
137
|
-
if (
|
|
205
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
138
206
|
this.onCancel?.();
|
|
139
207
|
return;
|
|
140
208
|
}
|
|
@@ -145,13 +213,13 @@ class MultiSelectList implements Component {
|
|
|
145
213
|
return;
|
|
146
214
|
}
|
|
147
215
|
|
|
148
|
-
if (
|
|
216
|
+
if (this.keybindings.matches(data, "tui.select.up") || matchesKey(data, Key.shift("tab"))) {
|
|
149
217
|
this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
|
|
150
218
|
this.invalidate();
|
|
151
219
|
return;
|
|
152
220
|
}
|
|
153
221
|
|
|
154
|
-
if (
|
|
222
|
+
if (this.keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.tab)) {
|
|
155
223
|
this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
|
|
156
224
|
this.invalidate();
|
|
157
225
|
return;
|
|
@@ -179,7 +247,7 @@ class MultiSelectList implements Component {
|
|
|
179
247
|
return;
|
|
180
248
|
}
|
|
181
249
|
|
|
182
|
-
if (
|
|
250
|
+
if (this.keybindings.matches(data, "tui.select.confirm")) {
|
|
183
251
|
if (this.isFreeformRow(this.selectedIndex)) {
|
|
184
252
|
this.onEnterFreeform?.();
|
|
185
253
|
return;
|
|
@@ -268,7 +336,9 @@ class WrappedSingleSelectList implements Component {
|
|
|
268
336
|
private options: QuestionOption[];
|
|
269
337
|
private allowFreeform: boolean;
|
|
270
338
|
private theme: Theme;
|
|
339
|
+
private keybindings: KeybindingsManager;
|
|
271
340
|
private selectedIndex = 0;
|
|
341
|
+
private searchQuery = "";
|
|
272
342
|
private maxVisibleRows = 12;
|
|
273
343
|
private cachedWidth?: number;
|
|
274
344
|
private cachedLines?: string[];
|
|
@@ -277,10 +347,11 @@ class WrappedSingleSelectList implements Component {
|
|
|
277
347
|
public onSubmit?: (result: string) => void;
|
|
278
348
|
public onEnterFreeform?: () => void;
|
|
279
349
|
|
|
280
|
-
constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme) {
|
|
350
|
+
constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme, keybindings: KeybindingsManager) {
|
|
281
351
|
this.options = options;
|
|
282
352
|
this.allowFreeform = allowFreeform;
|
|
283
353
|
this.theme = theme;
|
|
354
|
+
this.keybindings = keybindings;
|
|
284
355
|
}
|
|
285
356
|
|
|
286
357
|
setMaxVisibleRows(rows: number): void {
|
|
@@ -296,57 +367,234 @@ class WrappedSingleSelectList implements Component {
|
|
|
296
367
|
this.cachedLines = undefined;
|
|
297
368
|
}
|
|
298
369
|
|
|
299
|
-
private
|
|
300
|
-
return this.options.
|
|
370
|
+
private getFilteredOptions(): QuestionOption[] {
|
|
371
|
+
return fuzzyFilter(this.options, this.searchQuery, (option) => `${option.title} ${option.description ?? ""}`);
|
|
301
372
|
}
|
|
302
373
|
|
|
303
|
-
private
|
|
304
|
-
return this.allowFreeform
|
|
374
|
+
private getItemCount(filteredOptions: QuestionOption[]): number {
|
|
375
|
+
return filteredOptions.length + (this.allowFreeform ? 1 : 0);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private isFreeformRow(index: number, filteredOptions: QuestionOption[]): boolean {
|
|
379
|
+
return this.allowFreeform && index === filteredOptions.length;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private setSearchQuery(query: string): void {
|
|
383
|
+
this.searchQuery = query;
|
|
384
|
+
this.selectedIndex = 0;
|
|
385
|
+
this.invalidate();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private popSearchCharacter(): void {
|
|
389
|
+
if (!this.searchQuery) return;
|
|
390
|
+
const characters = [...this.searchQuery];
|
|
391
|
+
characters.pop();
|
|
392
|
+
this.setSearchQuery(characters.join(""));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private getPrintableInput(data: string): string | null {
|
|
396
|
+
const kittyPrintable = decodeKittyPrintable(data);
|
|
397
|
+
if (kittyPrintable !== undefined) return kittyPrintable;
|
|
398
|
+
|
|
399
|
+
const characters = [...data];
|
|
400
|
+
if (characters.length !== 1) return null;
|
|
401
|
+
|
|
402
|
+
const [character] = characters;
|
|
403
|
+
if (!character) return null;
|
|
404
|
+
|
|
405
|
+
const code = character.charCodeAt(0);
|
|
406
|
+
if (code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return character;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private styleListLine(line: string, width: number): string {
|
|
414
|
+
const trimmed = line.trim();
|
|
415
|
+
|
|
416
|
+
if (trimmed.startsWith("(")) {
|
|
417
|
+
return truncateToWidth(this.theme.fg("dim", line), width, "");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (line.startsWith(" ")) {
|
|
421
|
+
return truncateToWidth(this.theme.fg("muted", line), width, "");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (line.startsWith("→")) {
|
|
425
|
+
return truncateToWidth(this.theme.fg("accent", this.theme.bold(line)), width, "");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return truncateToWidth(this.theme.fg("text", line), width, "");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private getSplitPaneWidths(width: number): { left: number; right: number } | null {
|
|
432
|
+
if (width < SINGLE_SELECT_SPLIT_PANE_MIN_WIDTH) return null;
|
|
433
|
+
|
|
434
|
+
const availableWidth = width - SINGLE_SELECT_SPLIT_PANE_SEPARATOR.length;
|
|
435
|
+
if (availableWidth < SINGLE_SELECT_SPLIT_PANE_LEFT_MIN_WIDTH + SINGLE_SELECT_SPLIT_PANE_RIGHT_MIN_WIDTH) {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const preferredLeftWidth = Math.floor(availableWidth * 0.42);
|
|
440
|
+
const left = Math.max(
|
|
441
|
+
SINGLE_SELECT_SPLIT_PANE_LEFT_MIN_WIDTH,
|
|
442
|
+
Math.min(preferredLeftWidth, availableWidth - SINGLE_SELECT_SPLIT_PANE_RIGHT_MIN_WIDTH),
|
|
443
|
+
);
|
|
444
|
+
const right = availableWidth - left;
|
|
445
|
+
|
|
446
|
+
if (right < SINGLE_SELECT_SPLIT_PANE_RIGHT_MIN_WIDTH) return null;
|
|
447
|
+
return { left, right };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private buildListLines(width: number, filteredOptions: QuestionOption[], hideDescriptions = false): string[] {
|
|
451
|
+
const lines: string[] = [];
|
|
452
|
+
const count = this.getItemCount(filteredOptions);
|
|
453
|
+
const searchValue = this.searchQuery ? this.theme.fg("text", this.searchQuery) : this.theme.fg("dim", "type to filter");
|
|
454
|
+
lines.push(truncateToWidth(`${this.theme.fg("accent", "Filter:")} ${searchValue}`, width, ""));
|
|
455
|
+
|
|
456
|
+
if (this.searchQuery && filteredOptions.length === 0) {
|
|
457
|
+
lines.push(truncateToWidth(this.theme.fg("warning", "No matching options"), width, ""));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (count === 0) {
|
|
461
|
+
if (!this.searchQuery) {
|
|
462
|
+
lines.push(truncateToWidth(this.theme.fg("warning", "No options"), width, ""));
|
|
463
|
+
}
|
|
464
|
+
return lines.slice(0, this.maxVisibleRows);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const maxRows = Math.max(1, this.maxVisibleRows - lines.length);
|
|
468
|
+
const optionLines = renderSingleSelectRows({
|
|
469
|
+
options: filteredOptions,
|
|
470
|
+
selectedIndex: this.selectedIndex,
|
|
471
|
+
width,
|
|
472
|
+
allowFreeform: this.allowFreeform,
|
|
473
|
+
maxRows,
|
|
474
|
+
hideDescriptions,
|
|
475
|
+
}).map((line) => this.styleListLine(line, width));
|
|
476
|
+
|
|
477
|
+
lines.push(...optionLines);
|
|
478
|
+
return lines.slice(0, this.maxVisibleRows);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private buildPreviewLines(width: number, filteredOptions: QuestionOption[], maxLines: number): string[] {
|
|
482
|
+
if (maxLines <= 0) return [];
|
|
483
|
+
|
|
484
|
+
const lines: string[] = [];
|
|
485
|
+
const pushWrapped = (text: string, style: (line: string) => string): void => {
|
|
486
|
+
for (const line of wrapTextWithAnsi(text, Math.max(10, width))) {
|
|
487
|
+
lines.push(truncateToWidth(style(line), width, ""));
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
const pushBlank = (): void => {
|
|
491
|
+
if (lines.length === 0 || lines[lines.length - 1] !== "") {
|
|
492
|
+
lines.push("");
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
pushWrapped("Details", (line) => this.theme.fg("accent", this.theme.bold(line)));
|
|
497
|
+
pushBlank();
|
|
498
|
+
|
|
499
|
+
if (this.isFreeformRow(this.selectedIndex, filteredOptions)) {
|
|
500
|
+
pushWrapped("Custom response", (line) => this.theme.fg("text", this.theme.bold(line)));
|
|
501
|
+
pushBlank();
|
|
502
|
+
pushWrapped("Open the editor to write any answer.", (line) => this.theme.fg("text", line));
|
|
503
|
+
pushBlank();
|
|
504
|
+
pushWrapped("Use this when none of the listed options fit.", (line) => this.theme.fg("muted", line));
|
|
505
|
+
if (this.searchQuery) {
|
|
506
|
+
pushBlank();
|
|
507
|
+
pushWrapped(`Current filter: ${this.searchQuery}`, (line) => this.theme.fg("dim", line));
|
|
508
|
+
}
|
|
509
|
+
} else {
|
|
510
|
+
const selected = filteredOptions[this.selectedIndex];
|
|
511
|
+
if (!selected) {
|
|
512
|
+
pushWrapped("No option selected", (line) => this.theme.fg("muted", line));
|
|
513
|
+
} else {
|
|
514
|
+
pushWrapped(selected.title, (line) => this.theme.fg("text", this.theme.bold(line)));
|
|
515
|
+
pushBlank();
|
|
516
|
+
if (selected.description?.trim()) {
|
|
517
|
+
pushWrapped(selected.description, (line) => this.theme.fg("text", line));
|
|
518
|
+
} else {
|
|
519
|
+
pushWrapped("No additional details provided for this option.", (line) => this.theme.fg("muted", line));
|
|
520
|
+
}
|
|
521
|
+
pushBlank();
|
|
522
|
+
pushWrapped("Press Enter to select this option.", (line) => this.theme.fg("dim", line));
|
|
523
|
+
if (this.searchQuery) {
|
|
524
|
+
pushBlank();
|
|
525
|
+
pushWrapped(`Filter: ${this.searchQuery}`, (line) => this.theme.fg("dim", line));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
531
|
+
lines.pop();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (lines.length <= maxLines) return lines;
|
|
535
|
+
if (maxLines === 1) return [truncateToWidth(this.theme.fg("dim", "…"), width, "")];
|
|
536
|
+
|
|
537
|
+
const visibleLines = lines.slice(0, maxLines - 1);
|
|
538
|
+
visibleLines.push(truncateToWidth(this.theme.fg("dim", "…"), width, ""));
|
|
539
|
+
return visibleLines;
|
|
305
540
|
}
|
|
306
541
|
|
|
307
542
|
handleInput(data: string): void {
|
|
308
|
-
if (
|
|
309
|
-
this.
|
|
543
|
+
if (this.searchQuery && matchesKey(data, Key.escape)) {
|
|
544
|
+
this.setSearchQuery("");
|
|
310
545
|
return;
|
|
311
546
|
}
|
|
312
547
|
|
|
313
|
-
|
|
314
|
-
if (count === 0) {
|
|
548
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
315
549
|
this.onCancel?.();
|
|
316
550
|
return;
|
|
317
551
|
}
|
|
318
552
|
|
|
319
|
-
|
|
553
|
+
const filteredOptions = this.getFilteredOptions();
|
|
554
|
+
const count = this.getItemCount(filteredOptions);
|
|
555
|
+
|
|
556
|
+
if ((this.keybindings.matches(data, "tui.select.up") || matchesKey(data, Key.shift("tab"))) && count > 0) {
|
|
320
557
|
this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
|
|
321
558
|
this.invalidate();
|
|
322
559
|
return;
|
|
323
560
|
}
|
|
324
561
|
|
|
325
|
-
if (
|
|
562
|
+
if ((this.keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.tab)) && count > 0) {
|
|
326
563
|
this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
|
|
327
564
|
this.invalidate();
|
|
328
565
|
return;
|
|
329
566
|
}
|
|
330
567
|
|
|
331
568
|
const numMatch = data.match(/^[1-9]$/);
|
|
332
|
-
if (numMatch) {
|
|
569
|
+
if (numMatch && count > 0) {
|
|
333
570
|
const idx = Number.parseInt(numMatch[0], 10) - 1;
|
|
334
571
|
if (idx >= 0 && idx < count) {
|
|
335
572
|
this.selectedIndex = idx;
|
|
336
573
|
this.invalidate();
|
|
574
|
+
return;
|
|
337
575
|
}
|
|
338
|
-
return;
|
|
339
576
|
}
|
|
340
577
|
|
|
341
|
-
if (
|
|
342
|
-
if (this.isFreeformRow(this.selectedIndex)) {
|
|
578
|
+
if (this.keybindings.matches(data, "tui.select.confirm") && count > 0) {
|
|
579
|
+
if (this.isFreeformRow(this.selectedIndex, filteredOptions)) {
|
|
343
580
|
this.onEnterFreeform?.();
|
|
344
581
|
return;
|
|
345
582
|
}
|
|
346
583
|
|
|
347
|
-
const result =
|
|
584
|
+
const result = filteredOptions[this.selectedIndex]?.title;
|
|
348
585
|
if (result) this.onSubmit?.(result);
|
|
349
586
|
else this.onCancel?.();
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (this.keybindings.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, Key.backspace)) {
|
|
591
|
+
this.popSearchCharacter();
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const printableInput = this.getPrintableInput(data);
|
|
596
|
+
if (printableInput) {
|
|
597
|
+
this.setSearchQuery(this.searchQuery + printableInput);
|
|
350
598
|
}
|
|
351
599
|
}
|
|
352
600
|
|
|
@@ -355,37 +603,26 @@ class WrappedSingleSelectList implements Component {
|
|
|
355
603
|
return this.cachedLines;
|
|
356
604
|
}
|
|
357
605
|
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
this.cachedWidth = width;
|
|
362
|
-
return this.cachedLines;
|
|
363
|
-
}
|
|
606
|
+
const filteredOptions = this.getFilteredOptions();
|
|
607
|
+
const count = this.getItemCount(filteredOptions);
|
|
608
|
+
this.selectedIndex = count > 0 ? Math.max(0, Math.min(this.selectedIndex, count - 1)) : 0;
|
|
364
609
|
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
selectedIndex: this.selectedIndex,
|
|
368
|
-
width,
|
|
369
|
-
allowFreeform: this.allowFreeform,
|
|
370
|
-
maxRows: this.maxVisibleRows,
|
|
371
|
-
}).map((line) => {
|
|
372
|
-
const trimmed = line.trim();
|
|
373
|
-
let styled = line;
|
|
374
|
-
|
|
375
|
-
if (trimmed.startsWith("(")) {
|
|
376
|
-
styled = this.theme.fg("dim", line);
|
|
377
|
-
} else if (line.startsWith(" ")) {
|
|
378
|
-
styled = this.theme.fg("muted", line);
|
|
379
|
-
} else if (line.startsWith("→")) {
|
|
380
|
-
styled = this.theme.fg("accent", this.theme.bold(line));
|
|
381
|
-
} else if (trimmed.startsWith("Type something.")) {
|
|
382
|
-
styled = this.theme.fg("text", line);
|
|
383
|
-
} else {
|
|
384
|
-
styled = this.theme.fg("text", line);
|
|
385
|
-
}
|
|
610
|
+
const splitPane = this.getSplitPaneWidths(width);
|
|
611
|
+
let lines: string[];
|
|
386
612
|
|
|
387
|
-
|
|
388
|
-
|
|
613
|
+
if (!splitPane) {
|
|
614
|
+
lines = this.buildListLines(width, filteredOptions);
|
|
615
|
+
} else {
|
|
616
|
+
const listLines = this.buildListLines(splitPane.left, filteredOptions, true);
|
|
617
|
+
const previewLines = this.buildPreviewLines(splitPane.right, filteredOptions, this.maxVisibleRows);
|
|
618
|
+
const rowCount = Math.min(this.maxVisibleRows, Math.max(listLines.length, previewLines.length));
|
|
619
|
+
const separator = this.theme.fg("dim", SINGLE_SELECT_SPLIT_PANE_SEPARATOR);
|
|
620
|
+
lines = Array.from({ length: rowCount }, (_, index) => {
|
|
621
|
+
const left = truncateToWidth(listLines[index] ?? "", splitPane.left, "", true);
|
|
622
|
+
const right = truncateToWidth(previewLines[index] ?? "", splitPane.right, "");
|
|
623
|
+
return `${left}${separator}${right}`;
|
|
624
|
+
});
|
|
625
|
+
}
|
|
389
626
|
|
|
390
627
|
this.cachedWidth = width;
|
|
391
628
|
this.cachedLines = lines;
|
|
@@ -405,14 +642,15 @@ class AskComponent extends Container {
|
|
|
405
642
|
private allowFreeform: boolean;
|
|
406
643
|
private tui: TUI;
|
|
407
644
|
private theme: Theme;
|
|
408
|
-
private
|
|
645
|
+
private keybindings: KeybindingsManager;
|
|
646
|
+
private onDone: (result: AskUIResult | null) => void;
|
|
409
647
|
|
|
410
648
|
private mode: AskMode = "select";
|
|
411
649
|
|
|
412
650
|
// Static layout components
|
|
413
651
|
private titleText: Text;
|
|
414
652
|
private questionText: Text;
|
|
415
|
-
private
|
|
653
|
+
private contextComponent?: Component;
|
|
416
654
|
private modeContainer: Container;
|
|
417
655
|
private helpText: Text;
|
|
418
656
|
|
|
@@ -421,7 +659,7 @@ class AskComponent extends Container {
|
|
|
421
659
|
private multiSelectList?: MultiSelectList;
|
|
422
660
|
private editor?: Editor;
|
|
423
661
|
|
|
424
|
-
//
|
|
662
|
+
// Focusable - propagate to Editor for IME cursor positioning
|
|
425
663
|
private _focused = false;
|
|
426
664
|
get focused(): boolean {
|
|
427
665
|
return this._focused;
|
|
@@ -429,8 +667,7 @@ class AskComponent extends Container {
|
|
|
429
667
|
set focused(value: boolean) {
|
|
430
668
|
this._focused = value;
|
|
431
669
|
if (this.editor && this.mode === "freeform") {
|
|
432
|
-
|
|
433
|
-
anyEditor.focused = value;
|
|
670
|
+
(this.editor as any).focused = value;
|
|
434
671
|
}
|
|
435
672
|
}
|
|
436
673
|
|
|
@@ -442,7 +679,8 @@ class AskComponent extends Container {
|
|
|
442
679
|
allowFreeform: boolean,
|
|
443
680
|
tui: TUI,
|
|
444
681
|
theme: Theme,
|
|
445
|
-
|
|
682
|
+
keybindings: KeybindingsManager,
|
|
683
|
+
onDone: (result: AskUIResult | null) => void,
|
|
446
684
|
) {
|
|
447
685
|
super();
|
|
448
686
|
|
|
@@ -453,10 +691,15 @@ class AskComponent extends Container {
|
|
|
453
691
|
this.allowFreeform = allowFreeform;
|
|
454
692
|
this.tui = tui;
|
|
455
693
|
this.theme = theme;
|
|
694
|
+
this.keybindings = keybindings;
|
|
456
695
|
this.onDone = onDone;
|
|
457
696
|
|
|
458
697
|
// Layout skeleton
|
|
459
|
-
this.addChild(new
|
|
698
|
+
this.addChild(new BoxBorderTop(
|
|
699
|
+
(s: string) => theme.fg("accent", s),
|
|
700
|
+
"ask_user",
|
|
701
|
+
(s: string) => theme.fg("dim", theme.bold(s)),
|
|
702
|
+
));
|
|
460
703
|
this.addChild(new Spacer(1));
|
|
461
704
|
|
|
462
705
|
this.titleText = new Text("", 1, 0);
|
|
@@ -468,8 +711,16 @@ class AskComponent extends Container {
|
|
|
468
711
|
|
|
469
712
|
if (this.context) {
|
|
470
713
|
this.addChild(new Spacer(1));
|
|
471
|
-
|
|
472
|
-
|
|
714
|
+
let mdTheme: MarkdownTheme | undefined;
|
|
715
|
+
try {
|
|
716
|
+
mdTheme = getMarkdownTheme();
|
|
717
|
+
} catch {}
|
|
718
|
+
if (mdTheme) {
|
|
719
|
+
this.contextComponent = new Markdown("", 1, 0, mdTheme);
|
|
720
|
+
} else {
|
|
721
|
+
this.contextComponent = new Text("", 1, 0);
|
|
722
|
+
}
|
|
723
|
+
this.addChild(this.contextComponent);
|
|
473
724
|
}
|
|
474
725
|
|
|
475
726
|
this.addChild(new Spacer(1));
|
|
@@ -482,7 +733,7 @@ class AskComponent extends Container {
|
|
|
482
733
|
this.addChild(this.helpText);
|
|
483
734
|
|
|
484
735
|
this.addChild(new Spacer(1));
|
|
485
|
-
this.addChild(new
|
|
736
|
+
this.addChild(new BoxBorderBottom((s: string) => theme.fg("accent", s)));
|
|
486
737
|
|
|
487
738
|
this.updateStaticText();
|
|
488
739
|
this.showSelectMode();
|
|
@@ -495,16 +746,31 @@ class AskComponent extends Container {
|
|
|
495
746
|
}
|
|
496
747
|
|
|
497
748
|
override render(width: number): string[] {
|
|
749
|
+
const innerWidth = Math.max(1, width - BOX_BORDER_OVERHEAD);
|
|
750
|
+
|
|
498
751
|
if (this.mode === "select" && !this.allowMultiple) {
|
|
499
752
|
const overlayMaxHeight = Math.max(12, Math.floor(this.tui.terminal.rows * ASK_OVERLAY_MAX_HEIGHT_RATIO));
|
|
500
|
-
const staticLines = this.countStaticLines(
|
|
753
|
+
const staticLines = this.countStaticLines(innerWidth);
|
|
501
754
|
const availableOptionRows = Math.max(4, overlayMaxHeight - staticLines);
|
|
502
755
|
this.ensureSingleSelectList().setMaxVisibleRows(availableOptionRows);
|
|
503
756
|
}
|
|
504
757
|
|
|
505
|
-
//
|
|
506
|
-
const
|
|
507
|
-
|
|
758
|
+
// Render children at the inner width (excluding side border characters)
|
|
759
|
+
const rawLines = super.render(innerWidth);
|
|
760
|
+
|
|
761
|
+
// First and last lines are the top/bottom box borders — pass through at full width.
|
|
762
|
+
// All inner lines get wrapped with side borders.
|
|
763
|
+
const borderColor = (s: string) => this.theme.fg("accent", s);
|
|
764
|
+
const titleColor = (s: string) => this.theme.fg("dim", this.theme.bold(s));
|
|
765
|
+
return rawLines.map((line, index) => {
|
|
766
|
+
if (index === 0 || index === rawLines.length - 1) {
|
|
767
|
+
// Box top/bottom borders already rendered at innerWidth — re-render at full width
|
|
768
|
+
if (index === 0) return new BoxBorderTop(borderColor, "ask_user", titleColor).render(width)[0];
|
|
769
|
+
return new BoxBorderBottom(borderColor).render(width)[0];
|
|
770
|
+
}
|
|
771
|
+
const padded = truncateToWidth(line, innerWidth, "", true);
|
|
772
|
+
return `${borderColor(BOX_BORDER_LEFT)}${padded}${borderColor(BOX_BORDER_RIGHT)}`;
|
|
773
|
+
});
|
|
508
774
|
}
|
|
509
775
|
|
|
510
776
|
private countWrappedLines(text: string, width: number): number {
|
|
@@ -525,35 +791,70 @@ class AskComponent extends Container {
|
|
|
525
791
|
const theme = this.theme;
|
|
526
792
|
this.titleText.setText(theme.fg("accent", theme.bold("Question")));
|
|
527
793
|
this.questionText.setText(theme.fg("text", theme.bold(this.question)));
|
|
528
|
-
if (this.
|
|
529
|
-
this.
|
|
794
|
+
if (this.contextComponent && this.context) {
|
|
795
|
+
if (this.contextComponent instanceof Markdown) {
|
|
796
|
+
(this.contextComponent as Markdown).setText(
|
|
797
|
+
`**Context:**\n${this.context}`,
|
|
798
|
+
);
|
|
799
|
+
} else {
|
|
800
|
+
(this.contextComponent as Text).setText(
|
|
801
|
+
`${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", this.context)}`,
|
|
802
|
+
);
|
|
803
|
+
}
|
|
530
804
|
}
|
|
531
805
|
}
|
|
532
806
|
|
|
533
807
|
private updateHelpText(): void {
|
|
534
808
|
const theme = this.theme;
|
|
535
809
|
if (this.mode === "freeform") {
|
|
536
|
-
this.
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
),
|
|
541
|
-
|
|
810
|
+
const alternateCancelKeys = this.keybindings
|
|
811
|
+
.getKeys("tui.select.cancel")
|
|
812
|
+
.filter((key) => key !== "escape" && key !== "esc");
|
|
813
|
+
const hints = [
|
|
814
|
+
keybindingHint(theme, this.keybindings, "tui.input.submit", "submit"),
|
|
815
|
+
keybindingHint(theme, this.keybindings, "tui.input.newLine", "newline"),
|
|
816
|
+
literalHint(theme, "esc", "back"),
|
|
817
|
+
alternateCancelKeys.length > 0 ? literalHint(theme, formatKeyList(alternateCancelKeys), "cancel") : null,
|
|
818
|
+
]
|
|
819
|
+
.filter((hint): hint is string => !!hint)
|
|
820
|
+
.join(" • ");
|
|
821
|
+
this.helpText.setText(theme.fg("dim", hints));
|
|
542
822
|
return;
|
|
543
823
|
}
|
|
544
824
|
|
|
545
825
|
if (this.allowMultiple) {
|
|
546
|
-
|
|
826
|
+
const hints = [
|
|
827
|
+
literalHint(theme, "↑↓", "navigate"),
|
|
828
|
+
literalHint(theme, "space", "toggle"),
|
|
829
|
+
keybindingHint(theme, this.keybindings, "tui.select.confirm", "submit"),
|
|
830
|
+
keybindingHint(theme, this.keybindings, "tui.select.cancel", "cancel"),
|
|
831
|
+
].join(" • ");
|
|
832
|
+
this.helpText.setText(theme.fg("dim", hints));
|
|
547
833
|
} else {
|
|
548
|
-
|
|
834
|
+
const alternateCancelKeys = this.keybindings
|
|
835
|
+
.getKeys("tui.select.cancel")
|
|
836
|
+
.filter((key) => key !== "escape" && key !== "esc");
|
|
837
|
+
const hints = [
|
|
838
|
+
literalHint(theme, "type", "filter"),
|
|
839
|
+
keybindingHint(theme, this.keybindings, "tui.editor.deleteCharBackward", "erase"),
|
|
840
|
+
literalHint(theme, "↑↓", "navigate"),
|
|
841
|
+
keybindingHint(theme, this.keybindings, "tui.select.confirm", "select"),
|
|
842
|
+
literalHint(theme, "esc", "clear/cancel"),
|
|
843
|
+
alternateCancelKeys.length > 0
|
|
844
|
+
? literalHint(theme, formatKeyList(alternateCancelKeys), "cancel")
|
|
845
|
+
: null,
|
|
846
|
+
]
|
|
847
|
+
.filter((hint): hint is string => !!hint)
|
|
848
|
+
.join(" • ");
|
|
849
|
+
this.helpText.setText(theme.fg("dim", hints));
|
|
549
850
|
}
|
|
550
851
|
}
|
|
551
852
|
|
|
552
853
|
private ensureSingleSelectList(): WrappedSingleSelectList {
|
|
553
854
|
if (this.singleSelectList) return this.singleSelectList;
|
|
554
855
|
|
|
555
|
-
const list = new WrappedSingleSelectList(this.options, this.allowFreeform, this.theme);
|
|
556
|
-
list.onSubmit = (result) => this.onDone(result);
|
|
856
|
+
const list = new WrappedSingleSelectList(this.options, this.allowFreeform, this.theme, this.keybindings);
|
|
857
|
+
list.onSubmit = (result) => this.onDone({ answer: result, wasCustom: false });
|
|
557
858
|
list.onCancel = () => this.onDone(null);
|
|
558
859
|
list.onEnterFreeform = () => this.showFreeformMode();
|
|
559
860
|
|
|
@@ -564,9 +865,9 @@ class AskComponent extends Container {
|
|
|
564
865
|
private ensureMultiSelectList(): MultiSelectList {
|
|
565
866
|
if (this.multiSelectList) return this.multiSelectList;
|
|
566
867
|
|
|
567
|
-
const list = new MultiSelectList(this.options, this.allowFreeform, this.theme);
|
|
868
|
+
const list = new MultiSelectList(this.options, this.allowFreeform, this.theme, this.keybindings);
|
|
568
869
|
list.onCancel = () => this.onDone(null);
|
|
569
|
-
list.onSubmit = (result) => this.onDone(result);
|
|
870
|
+
list.onSubmit = (result) => this.onDone({ answer: result, wasCustom: false });
|
|
570
871
|
list.onEnterFreeform = () => this.showFreeformMode();
|
|
571
872
|
|
|
572
873
|
this.multiSelectList = list;
|
|
@@ -575,15 +876,11 @@ class AskComponent extends Container {
|
|
|
575
876
|
|
|
576
877
|
private ensureEditor(): Editor {
|
|
577
878
|
if (this.editor) return this.editor;
|
|
578
|
-
// Note: pi's bundled pi-tui Editor expects (tui, theme, options?)
|
|
579
879
|
const editor = new Editor(this.tui, createEditorTheme(this.theme));
|
|
580
|
-
// Default Editor behavior: Enter submits, Shift+Enter inserts newline.
|
|
581
|
-
// Ctrl+Enter is only distinguishable in terminals with Kitty protocol mappings,
|
|
582
|
-
// so we support it as an *additional* submit shortcut in our wrapper.
|
|
583
880
|
editor.disableSubmit = false;
|
|
584
881
|
editor.onSubmit = (text: string) => {
|
|
585
882
|
const trimmed = text.trim();
|
|
586
|
-
this.onDone(trimmed ? trimmed : null);
|
|
883
|
+
this.onDone(trimmed ? { answer: trimmed, wasCustom: true } : null);
|
|
587
884
|
};
|
|
588
885
|
this.editor = editor;
|
|
589
886
|
return editor;
|
|
@@ -609,8 +906,7 @@ class AskComponent extends Container {
|
|
|
609
906
|
this.modeContainer.clear();
|
|
610
907
|
|
|
611
908
|
const editor = this.ensureEditor();
|
|
612
|
-
|
|
613
|
-
(editor as unknown as { focused?: boolean }).focused = this._focused;
|
|
909
|
+
(editor as any).focused = this._focused;
|
|
614
910
|
|
|
615
911
|
this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold("Custom response")), 1, 0));
|
|
616
912
|
this.modeContainer.addChild(new Spacer(1));
|
|
@@ -621,12 +917,6 @@ class AskComponent extends Container {
|
|
|
621
917
|
this.tui.requestRender();
|
|
622
918
|
}
|
|
623
919
|
|
|
624
|
-
private submitFreeform(): void {
|
|
625
|
-
const editor = this.ensureEditor();
|
|
626
|
-
const text = editor.getText().trim();
|
|
627
|
-
this.onDone(text ? text : null);
|
|
628
|
-
}
|
|
629
|
-
|
|
630
920
|
handleInput(data: string): void {
|
|
631
921
|
if (this.mode === "freeform") {
|
|
632
922
|
if (matchesKey(data, Key.escape)) {
|
|
@@ -634,18 +924,11 @@ class AskComponent extends Container {
|
|
|
634
924
|
return;
|
|
635
925
|
}
|
|
636
926
|
|
|
637
|
-
if (
|
|
927
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
638
928
|
this.onDone(null);
|
|
639
929
|
return;
|
|
640
930
|
}
|
|
641
931
|
|
|
642
|
-
// Submit on Ctrl+Enter (only works if terminal distinguishes it, e.g. Kitty protocol)
|
|
643
|
-
if (matchesKey(data, Key.ctrl("enter")) || matchesKey(data, "ctrl+enter")) {
|
|
644
|
-
this.submitFreeform();
|
|
645
|
-
return;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
// Let Editor handle everything else (Enter submits, Shift+Enter newline)
|
|
649
932
|
this.ensureEditor().handleInput(data);
|
|
650
933
|
this.tui.requestRender();
|
|
651
934
|
return;
|
|
@@ -703,11 +986,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
703
986
|
Type.Boolean({ description: "Add a freeform text option. Default: true" }),
|
|
704
987
|
),
|
|
705
988
|
timeout: Type.Optional(
|
|
706
|
-
Type.Number({ description: "Auto-dismiss after N milliseconds
|
|
989
|
+
Type.Number({ description: "Auto-dismiss after N milliseconds. Returns null (cancelled) when expired." }),
|
|
707
990
|
),
|
|
708
991
|
}),
|
|
709
992
|
|
|
710
|
-
async execute(_toolCallId, params, signal,
|
|
993
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
711
994
|
if (signal?.aborted) {
|
|
712
995
|
return {
|
|
713
996
|
content: [{ type: "text", text: "Cancelled" }],
|
|
@@ -754,16 +1037,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
754
1037
|
};
|
|
755
1038
|
}
|
|
756
1039
|
|
|
1040
|
+
pi.events.emit("ask:answered", { question, context: normalizedContext, answer, wasCustom: true });
|
|
757
1041
|
return {
|
|
758
1042
|
content: [{ type: "text", text: `User answered: ${answer}` }],
|
|
759
1043
|
details: { question, context: normalizedContext, options, answer, cancelled: false, wasCustom: true } as AskToolDetails,
|
|
760
1044
|
};
|
|
761
1045
|
}
|
|
762
1046
|
|
|
763
|
-
|
|
1047
|
+
onUpdate?.({
|
|
1048
|
+
content: [{ type: "text", text: "Waiting for user input..." }],
|
|
1049
|
+
details: { question, context: normalizedContext, options, answer: null, cancelled: false },
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
let result: AskUIResult | null;
|
|
764
1053
|
try {
|
|
765
|
-
result = await ctx.ui.custom<
|
|
766
|
-
(tui, theme,
|
|
1054
|
+
result = await ctx.ui.custom<AskUIResult | null>(
|
|
1055
|
+
(tui, theme, keybindings, done) => {
|
|
1056
|
+
// Wire AbortSignal so agent cancellation auto-dismisses the overlay
|
|
1057
|
+
if (signal) {
|
|
1058
|
+
const onAbort = () => done(null);
|
|
1059
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// Wire timeout for overlay mode
|
|
1063
|
+
if (timeout && timeout > 0) {
|
|
1064
|
+
setTimeout(() => done(null), timeout);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
767
1067
|
return new AskComponent(
|
|
768
1068
|
question,
|
|
769
1069
|
normalizedContext,
|
|
@@ -772,6 +1072,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
772
1072
|
allowFreeform,
|
|
773
1073
|
tui,
|
|
774
1074
|
theme,
|
|
1075
|
+
keybindings,
|
|
775
1076
|
done,
|
|
776
1077
|
);
|
|
777
1078
|
},
|
|
@@ -780,6 +1081,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
780
1081
|
overlayOptions: {
|
|
781
1082
|
anchor: "center",
|
|
782
1083
|
width: ASK_OVERLAY_WIDTH,
|
|
1084
|
+
minWidth: ASK_OVERLAY_MIN_WIDTH,
|
|
783
1085
|
maxHeight: "85%",
|
|
784
1086
|
margin: 1,
|
|
785
1087
|
},
|
|
@@ -796,15 +1098,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
796
1098
|
}
|
|
797
1099
|
|
|
798
1100
|
if (result === null) {
|
|
1101
|
+
pi.events.emit("ask:cancelled", { question, context: normalizedContext, options });
|
|
799
1102
|
return {
|
|
800
1103
|
content: [{ type: "text", text: "User cancelled the question" }],
|
|
801
1104
|
details: { question, context: normalizedContext, options, answer: null, cancelled: true } as AskToolDetails,
|
|
802
1105
|
};
|
|
803
1106
|
}
|
|
804
1107
|
|
|
1108
|
+
pi.events.emit("ask:answered", {
|
|
1109
|
+
question,
|
|
1110
|
+
context: normalizedContext,
|
|
1111
|
+
answer: result.answer,
|
|
1112
|
+
wasCustom: result.wasCustom,
|
|
1113
|
+
});
|
|
805
1114
|
return {
|
|
806
|
-
content: [{ type: "text", text: `User answered: ${result}` }],
|
|
807
|
-
details: {
|
|
1115
|
+
content: [{ type: "text", text: `User answered: ${result.answer}` }],
|
|
1116
|
+
details: {
|
|
1117
|
+
question,
|
|
1118
|
+
context: normalizedContext,
|
|
1119
|
+
options,
|
|
1120
|
+
answer: result.answer,
|
|
1121
|
+
cancelled: false,
|
|
1122
|
+
wasCustom: result.wasCustom,
|
|
1123
|
+
} as AskToolDetails,
|
|
808
1124
|
};
|
|
809
1125
|
},
|
|
810
1126
|
|
|
@@ -825,26 +1141,55 @@ export default function (pi: ExtensionAPI) {
|
|
|
825
1141
|
return new Text(text, 0, 0);
|
|
826
1142
|
},
|
|
827
1143
|
|
|
828
|
-
renderResult(result,
|
|
1144
|
+
renderResult(result, options, theme) {
|
|
829
1145
|
const details = result.details as (AskToolDetails & { error?: string }) | undefined;
|
|
830
1146
|
|
|
831
|
-
// Error state
|
|
832
1147
|
if (details?.error) {
|
|
833
1148
|
return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0);
|
|
834
1149
|
}
|
|
835
1150
|
|
|
836
|
-
|
|
1151
|
+
if (options.isPartial) {
|
|
1152
|
+
const waitingText = result.content
|
|
1153
|
+
?.filter((part: { type?: string; text?: string }) => part?.type === "text")
|
|
1154
|
+
.map((part: { text?: string }) => part.text ?? "")
|
|
1155
|
+
.join("\n")
|
|
1156
|
+
.trim() || "Waiting for user input...";
|
|
1157
|
+
return new Text(theme.fg("muted", waitingText), 0, 0);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
837
1160
|
if (!details || details.cancelled) {
|
|
838
1161
|
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
839
1162
|
}
|
|
840
1163
|
|
|
841
|
-
// Success
|
|
842
1164
|
const answer = details.answer ?? "";
|
|
843
1165
|
let text = theme.fg("success", "✓ ");
|
|
844
1166
|
if (details.wasCustom) {
|
|
845
1167
|
text += theme.fg("muted", "(wrote) ");
|
|
846
1168
|
}
|
|
847
1169
|
text += theme.fg("accent", answer);
|
|
1170
|
+
|
|
1171
|
+
if (options.expanded) {
|
|
1172
|
+
const selectedTitles = new Set(
|
|
1173
|
+
answer
|
|
1174
|
+
.split(",")
|
|
1175
|
+
.map((value) => value.trim())
|
|
1176
|
+
.filter(Boolean),
|
|
1177
|
+
);
|
|
1178
|
+
text += "\n" + theme.fg("dim", `Q: ${details.question}`);
|
|
1179
|
+
if (details.context) {
|
|
1180
|
+
text += "\n" + theme.fg("dim", details.context);
|
|
1181
|
+
}
|
|
1182
|
+
if (details.options && details.options.length > 0) {
|
|
1183
|
+
text += "\n" + theme.fg("dim", "Options:");
|
|
1184
|
+
for (const opt of details.options) {
|
|
1185
|
+
const desc = opt.description ? ` — ${opt.description}` : "";
|
|
1186
|
+
const isSelected = opt.title === answer || selectedTitles.has(opt.title);
|
|
1187
|
+
const marker = isSelected ? theme.fg("success", "●") : theme.fg("dim", "○");
|
|
1188
|
+
text += `\n ${marker} ${theme.fg("dim", opt.title)}${theme.fg("dim", desc)}`;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
848
1193
|
return new Text(text, 0, 0);
|
|
849
1194
|
},
|
|
850
1195
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ask-user",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Interactive ask_user tool for pi-coding-agent with multi-select and freeform input
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Interactive ask_user tool for pi-coding-agent with searchable split-pane selection UI, multi-select, and freeform input",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package",
|
package/single-select-layout.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface RenderSingleSelectRowsParams {
|
|
|
9
9
|
width: number;
|
|
10
10
|
allowFreeform: boolean;
|
|
11
11
|
maxRows?: number;
|
|
12
|
+
hideDescriptions?: boolean;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
function wrapText(text: string, width: number): string[] {
|
|
@@ -42,12 +43,12 @@ function wrapText(text: string, width: number): string[] {
|
|
|
42
43
|
if (word.length <= width) {
|
|
43
44
|
current = word;
|
|
44
45
|
} else {
|
|
46
|
+
current = "";
|
|
45
47
|
for (let i = 0; i < word.length; i += width) {
|
|
46
48
|
const chunk = word.slice(i, i + width);
|
|
47
49
|
if (chunk.length === width || i + width < word.length) lines.push(chunk);
|
|
48
50
|
else current = chunk;
|
|
49
51
|
}
|
|
50
|
-
if (!current || current.length === width) current = "";
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
|
|
@@ -69,6 +70,7 @@ function buildItemBlocks(
|
|
|
69
70
|
width: number,
|
|
70
71
|
allowFreeform: boolean,
|
|
71
72
|
selectedIndex: number,
|
|
73
|
+
hideDescriptions = false,
|
|
72
74
|
): ItemBlock[] {
|
|
73
75
|
const normalizedWidth = Math.max(12, width);
|
|
74
76
|
const freeformLabel = "Type something. — Enter a custom response";
|
|
@@ -97,7 +99,7 @@ function buildItemBlocks(
|
|
|
97
99
|
lines.push(padLine(lineIndex === 0 ? numberPrefix : continuationPrefix, line));
|
|
98
100
|
});
|
|
99
101
|
|
|
100
|
-
if (item.option.description) {
|
|
102
|
+
if (item.option.description && !hideDescriptions) {
|
|
101
103
|
const descriptionPrefix = " ";
|
|
102
104
|
const descriptionLines = wrapText(
|
|
103
105
|
item.option.description,
|
|
@@ -122,9 +124,10 @@ export function renderSingleSelectRows({
|
|
|
122
124
|
width,
|
|
123
125
|
allowFreeform,
|
|
124
126
|
maxRows,
|
|
127
|
+
hideDescriptions,
|
|
125
128
|
}: RenderSingleSelectRowsParams): string[] {
|
|
126
129
|
const itemCount = options.length + (allowFreeform ? 1 : 0);
|
|
127
|
-
const blocks = buildItemBlocks(options, width, allowFreeform, selectedIndex);
|
|
130
|
+
const blocks = buildItemBlocks(options, width, allowFreeform, selectedIndex, hideDescriptions);
|
|
128
131
|
const allRows = flatten(blocks);
|
|
129
132
|
|
|
130
133
|
if (!Number.isFinite(maxRows) || !maxRows || maxRows <= 0 || allRows.length <= maxRows) {
|