pi-ask-user 0.4.1 → 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 +4 -12
- package/index.ts +378 -99
- package/package.json +2 -2
- package/single-select-layout.ts +6 -3
package/README.md
CHANGED
|
@@ -10,11 +10,13 @@ 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
22
|
- Optional timeout for auto-dismiss in both overlay and fallback input modes
|
|
@@ -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 and cancellation support
|
|
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,22 @@
|
|
|
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,
|
|
17
21
|
Markdown,
|
|
18
22
|
type MarkdownTheme,
|
|
19
23
|
matchesKey,
|
|
@@ -23,12 +27,7 @@ import {
|
|
|
23
27
|
truncateToWidth,
|
|
24
28
|
wrapTextWithAnsi,
|
|
25
29
|
} from "@mariozechner/pi-tui";
|
|
26
|
-
import { renderSingleSelectRows } from "./single-select-layout";
|
|
27
|
-
|
|
28
|
-
interface QuestionOption {
|
|
29
|
-
title: string;
|
|
30
|
-
description?: string;
|
|
31
|
-
}
|
|
30
|
+
import { renderSingleSelectRows, type QuestionOption } from "./single-select-layout";
|
|
32
31
|
|
|
33
32
|
type AskOptionInput = QuestionOption | string;
|
|
34
33
|
|
|
@@ -50,6 +49,11 @@ interface AskToolDetails {
|
|
|
50
49
|
wasCustom?: boolean;
|
|
51
50
|
}
|
|
52
51
|
|
|
52
|
+
interface AskUIResult {
|
|
53
|
+
answer: string;
|
|
54
|
+
wasCustom: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
53
57
|
function normalizeOptions(options: AskOptionInput[]): QuestionOption[] {
|
|
54
58
|
return options
|
|
55
59
|
.map((option) => {
|
|
@@ -90,16 +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%";
|
|
97
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 = " │ ";
|
|
98
163
|
|
|
99
164
|
class MultiSelectList implements Component {
|
|
100
165
|
private options: QuestionOption[];
|
|
101
166
|
private allowFreeform: boolean;
|
|
102
167
|
private theme: Theme;
|
|
168
|
+
private keybindings: KeybindingsManager;
|
|
103
169
|
private selectedIndex = 0;
|
|
104
170
|
private checked = new Set<number>();
|
|
105
171
|
private cachedWidth?: number;
|
|
@@ -109,10 +175,11 @@ class MultiSelectList implements Component {
|
|
|
109
175
|
public onSubmit?: (result: string) => void;
|
|
110
176
|
public onEnterFreeform?: () => void;
|
|
111
177
|
|
|
112
|
-
constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme) {
|
|
178
|
+
constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme, keybindings: KeybindingsManager) {
|
|
113
179
|
this.options = options;
|
|
114
180
|
this.allowFreeform = allowFreeform;
|
|
115
181
|
this.theme = theme;
|
|
182
|
+
this.keybindings = keybindings;
|
|
116
183
|
}
|
|
117
184
|
|
|
118
185
|
invalidate(): void {
|
|
@@ -135,7 +202,7 @@ class MultiSelectList implements Component {
|
|
|
135
202
|
}
|
|
136
203
|
|
|
137
204
|
handleInput(data: string): void {
|
|
138
|
-
if (
|
|
205
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
139
206
|
this.onCancel?.();
|
|
140
207
|
return;
|
|
141
208
|
}
|
|
@@ -146,13 +213,13 @@ class MultiSelectList implements Component {
|
|
|
146
213
|
return;
|
|
147
214
|
}
|
|
148
215
|
|
|
149
|
-
if (
|
|
216
|
+
if (this.keybindings.matches(data, "tui.select.up") || matchesKey(data, Key.shift("tab"))) {
|
|
150
217
|
this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
|
|
151
218
|
this.invalidate();
|
|
152
219
|
return;
|
|
153
220
|
}
|
|
154
221
|
|
|
155
|
-
if (
|
|
222
|
+
if (this.keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.tab)) {
|
|
156
223
|
this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
|
|
157
224
|
this.invalidate();
|
|
158
225
|
return;
|
|
@@ -180,7 +247,7 @@ class MultiSelectList implements Component {
|
|
|
180
247
|
return;
|
|
181
248
|
}
|
|
182
249
|
|
|
183
|
-
if (
|
|
250
|
+
if (this.keybindings.matches(data, "tui.select.confirm")) {
|
|
184
251
|
if (this.isFreeformRow(this.selectedIndex)) {
|
|
185
252
|
this.onEnterFreeform?.();
|
|
186
253
|
return;
|
|
@@ -269,7 +336,9 @@ class WrappedSingleSelectList implements Component {
|
|
|
269
336
|
private options: QuestionOption[];
|
|
270
337
|
private allowFreeform: boolean;
|
|
271
338
|
private theme: Theme;
|
|
339
|
+
private keybindings: KeybindingsManager;
|
|
272
340
|
private selectedIndex = 0;
|
|
341
|
+
private searchQuery = "";
|
|
273
342
|
private maxVisibleRows = 12;
|
|
274
343
|
private cachedWidth?: number;
|
|
275
344
|
private cachedLines?: string[];
|
|
@@ -278,10 +347,11 @@ class WrappedSingleSelectList implements Component {
|
|
|
278
347
|
public onSubmit?: (result: string) => void;
|
|
279
348
|
public onEnterFreeform?: () => void;
|
|
280
349
|
|
|
281
|
-
constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme) {
|
|
350
|
+
constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme, keybindings: KeybindingsManager) {
|
|
282
351
|
this.options = options;
|
|
283
352
|
this.allowFreeform = allowFreeform;
|
|
284
353
|
this.theme = theme;
|
|
354
|
+
this.keybindings = keybindings;
|
|
285
355
|
}
|
|
286
356
|
|
|
287
357
|
setMaxVisibleRows(rows: number): void {
|
|
@@ -297,57 +367,234 @@ class WrappedSingleSelectList implements Component {
|
|
|
297
367
|
this.cachedLines = undefined;
|
|
298
368
|
}
|
|
299
369
|
|
|
300
|
-
private
|
|
301
|
-
return this.options.
|
|
370
|
+
private getFilteredOptions(): QuestionOption[] {
|
|
371
|
+
return fuzzyFilter(this.options, this.searchQuery, (option) => `${option.title} ${option.description ?? ""}`);
|
|
302
372
|
}
|
|
303
373
|
|
|
304
|
-
private
|
|
305
|
-
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;
|
|
306
540
|
}
|
|
307
541
|
|
|
308
542
|
handleInput(data: string): void {
|
|
309
|
-
if (
|
|
310
|
-
this.
|
|
543
|
+
if (this.searchQuery && matchesKey(data, Key.escape)) {
|
|
544
|
+
this.setSearchQuery("");
|
|
311
545
|
return;
|
|
312
546
|
}
|
|
313
547
|
|
|
314
|
-
|
|
315
|
-
if (count === 0) {
|
|
548
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
316
549
|
this.onCancel?.();
|
|
317
550
|
return;
|
|
318
551
|
}
|
|
319
552
|
|
|
320
|
-
|
|
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) {
|
|
321
557
|
this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
|
|
322
558
|
this.invalidate();
|
|
323
559
|
return;
|
|
324
560
|
}
|
|
325
561
|
|
|
326
|
-
if (
|
|
562
|
+
if ((this.keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.tab)) && count > 0) {
|
|
327
563
|
this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
|
|
328
564
|
this.invalidate();
|
|
329
565
|
return;
|
|
330
566
|
}
|
|
331
567
|
|
|
332
568
|
const numMatch = data.match(/^[1-9]$/);
|
|
333
|
-
if (numMatch) {
|
|
569
|
+
if (numMatch && count > 0) {
|
|
334
570
|
const idx = Number.parseInt(numMatch[0], 10) - 1;
|
|
335
571
|
if (idx >= 0 && idx < count) {
|
|
336
572
|
this.selectedIndex = idx;
|
|
337
573
|
this.invalidate();
|
|
574
|
+
return;
|
|
338
575
|
}
|
|
339
|
-
return;
|
|
340
576
|
}
|
|
341
577
|
|
|
342
|
-
if (
|
|
343
|
-
if (this.isFreeformRow(this.selectedIndex)) {
|
|
578
|
+
if (this.keybindings.matches(data, "tui.select.confirm") && count > 0) {
|
|
579
|
+
if (this.isFreeformRow(this.selectedIndex, filteredOptions)) {
|
|
344
580
|
this.onEnterFreeform?.();
|
|
345
581
|
return;
|
|
346
582
|
}
|
|
347
583
|
|
|
348
|
-
const result =
|
|
584
|
+
const result = filteredOptions[this.selectedIndex]?.title;
|
|
349
585
|
if (result) this.onSubmit?.(result);
|
|
350
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);
|
|
351
598
|
}
|
|
352
599
|
}
|
|
353
600
|
|
|
@@ -356,37 +603,26 @@ class WrappedSingleSelectList implements Component {
|
|
|
356
603
|
return this.cachedLines;
|
|
357
604
|
}
|
|
358
605
|
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
this.cachedWidth = width;
|
|
363
|
-
return this.cachedLines;
|
|
364
|
-
}
|
|
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;
|
|
365
609
|
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
selectedIndex: this.selectedIndex,
|
|
369
|
-
width,
|
|
370
|
-
allowFreeform: this.allowFreeform,
|
|
371
|
-
maxRows: this.maxVisibleRows,
|
|
372
|
-
}).map((line) => {
|
|
373
|
-
const trimmed = line.trim();
|
|
374
|
-
let styled = line;
|
|
375
|
-
|
|
376
|
-
if (trimmed.startsWith("(")) {
|
|
377
|
-
styled = this.theme.fg("dim", line);
|
|
378
|
-
} else if (line.startsWith(" ")) {
|
|
379
|
-
styled = this.theme.fg("muted", line);
|
|
380
|
-
} else if (line.startsWith("→")) {
|
|
381
|
-
styled = this.theme.fg("accent", this.theme.bold(line));
|
|
382
|
-
} else if (trimmed.startsWith("Type something.")) {
|
|
383
|
-
styled = this.theme.fg("text", line);
|
|
384
|
-
} else {
|
|
385
|
-
styled = this.theme.fg("text", line);
|
|
386
|
-
}
|
|
610
|
+
const splitPane = this.getSplitPaneWidths(width);
|
|
611
|
+
let lines: string[];
|
|
387
612
|
|
|
388
|
-
|
|
389
|
-
|
|
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
|
+
}
|
|
390
626
|
|
|
391
627
|
this.cachedWidth = width;
|
|
392
628
|
this.cachedLines = lines;
|
|
@@ -406,7 +642,8 @@ class AskComponent extends Container {
|
|
|
406
642
|
private allowFreeform: boolean;
|
|
407
643
|
private tui: TUI;
|
|
408
644
|
private theme: Theme;
|
|
409
|
-
private
|
|
645
|
+
private keybindings: KeybindingsManager;
|
|
646
|
+
private onDone: (result: AskUIResult | null) => void;
|
|
410
647
|
|
|
411
648
|
private mode: AskMode = "select";
|
|
412
649
|
|
|
@@ -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);
|
|
@@ -490,7 +733,7 @@ class AskComponent extends Container {
|
|
|
490
733
|
this.addChild(this.helpText);
|
|
491
734
|
|
|
492
735
|
this.addChild(new Spacer(1));
|
|
493
|
-
this.addChild(new
|
|
736
|
+
this.addChild(new BoxBorderBottom((s: string) => theme.fg("accent", s)));
|
|
494
737
|
|
|
495
738
|
this.updateStaticText();
|
|
496
739
|
this.showSelectMode();
|
|
@@ -503,16 +746,31 @@ class AskComponent extends Container {
|
|
|
503
746
|
}
|
|
504
747
|
|
|
505
748
|
override render(width: number): string[] {
|
|
749
|
+
const innerWidth = Math.max(1, width - BOX_BORDER_OVERHEAD);
|
|
750
|
+
|
|
506
751
|
if (this.mode === "select" && !this.allowMultiple) {
|
|
507
752
|
const overlayMaxHeight = Math.max(12, Math.floor(this.tui.terminal.rows * ASK_OVERLAY_MAX_HEIGHT_RATIO));
|
|
508
|
-
const staticLines = this.countStaticLines(
|
|
753
|
+
const staticLines = this.countStaticLines(innerWidth);
|
|
509
754
|
const availableOptionRows = Math.max(4, overlayMaxHeight - staticLines);
|
|
510
755
|
this.ensureSingleSelectList().setMaxVisibleRows(availableOptionRows);
|
|
511
756
|
}
|
|
512
757
|
|
|
513
|
-
//
|
|
514
|
-
const
|
|
515
|
-
|
|
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
|
+
});
|
|
516
774
|
}
|
|
517
775
|
|
|
518
776
|
private countWrappedLines(text: string, width: number): number {
|
|
@@ -549,30 +807,45 @@ class AskComponent extends Container {
|
|
|
549
807
|
private updateHelpText(): void {
|
|
550
808
|
const theme = this.theme;
|
|
551
809
|
if (this.mode === "freeform") {
|
|
810
|
+
const alternateCancelKeys = this.keybindings
|
|
811
|
+
.getKeys("tui.select.cancel")
|
|
812
|
+
.filter((key) => key !== "escape" && key !== "esc");
|
|
552
813
|
const hints = [
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
]
|
|
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(" • ");
|
|
558
821
|
this.helpText.setText(theme.fg("dim", hints));
|
|
559
822
|
return;
|
|
560
823
|
}
|
|
561
824
|
|
|
562
825
|
if (this.allowMultiple) {
|
|
563
826
|
const hints = [
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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"),
|
|
568
831
|
].join(" • ");
|
|
569
832
|
this.helpText.setText(theme.fg("dim", hints));
|
|
570
833
|
} else {
|
|
834
|
+
const alternateCancelKeys = this.keybindings
|
|
835
|
+
.getKeys("tui.select.cancel")
|
|
836
|
+
.filter((key) => key !== "escape" && key !== "esc");
|
|
571
837
|
const hints = [
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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(" • ");
|
|
576
849
|
this.helpText.setText(theme.fg("dim", hints));
|
|
577
850
|
}
|
|
578
851
|
}
|
|
@@ -580,8 +853,8 @@ class AskComponent extends Container {
|
|
|
580
853
|
private ensureSingleSelectList(): WrappedSingleSelectList {
|
|
581
854
|
if (this.singleSelectList) return this.singleSelectList;
|
|
582
855
|
|
|
583
|
-
const list = new WrappedSingleSelectList(this.options, this.allowFreeform, this.theme);
|
|
584
|
-
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 });
|
|
585
858
|
list.onCancel = () => this.onDone(null);
|
|
586
859
|
list.onEnterFreeform = () => this.showFreeformMode();
|
|
587
860
|
|
|
@@ -592,9 +865,9 @@ class AskComponent extends Container {
|
|
|
592
865
|
private ensureMultiSelectList(): MultiSelectList {
|
|
593
866
|
if (this.multiSelectList) return this.multiSelectList;
|
|
594
867
|
|
|
595
|
-
const list = new MultiSelectList(this.options, this.allowFreeform, this.theme);
|
|
868
|
+
const list = new MultiSelectList(this.options, this.allowFreeform, this.theme, this.keybindings);
|
|
596
869
|
list.onCancel = () => this.onDone(null);
|
|
597
|
-
list.onSubmit = (result) => this.onDone(result);
|
|
870
|
+
list.onSubmit = (result) => this.onDone({ answer: result, wasCustom: false });
|
|
598
871
|
list.onEnterFreeform = () => this.showFreeformMode();
|
|
599
872
|
|
|
600
873
|
this.multiSelectList = list;
|
|
@@ -603,11 +876,11 @@ class AskComponent extends Container {
|
|
|
603
876
|
|
|
604
877
|
private ensureEditor(): Editor {
|
|
605
878
|
if (this.editor) return this.editor;
|
|
606
|
-
const editor = new Editor(createEditorTheme(this.theme));
|
|
879
|
+
const editor = new Editor(this.tui, createEditorTheme(this.theme));
|
|
607
880
|
editor.disableSubmit = false;
|
|
608
881
|
editor.onSubmit = (text: string) => {
|
|
609
882
|
const trimmed = text.trim();
|
|
610
|
-
this.onDone(trimmed ? trimmed : null);
|
|
883
|
+
this.onDone(trimmed ? { answer: trimmed, wasCustom: true } : null);
|
|
611
884
|
};
|
|
612
885
|
this.editor = editor;
|
|
613
886
|
return editor;
|
|
@@ -651,18 +924,11 @@ class AskComponent extends Container {
|
|
|
651
924
|
return;
|
|
652
925
|
}
|
|
653
926
|
|
|
654
|
-
if (
|
|
927
|
+
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
655
928
|
this.onDone(null);
|
|
656
929
|
return;
|
|
657
930
|
}
|
|
658
931
|
|
|
659
|
-
if (matchesKey(data, Key.ctrl("enter")) || matchesKey(data, "ctrl+enter")) {
|
|
660
|
-
const editor = this.ensureEditor();
|
|
661
|
-
const text = editor.getText().trim();
|
|
662
|
-
this.onDone(text ? text : null);
|
|
663
|
-
return;
|
|
664
|
-
}
|
|
665
|
-
|
|
666
932
|
this.ensureEditor().handleInput(data);
|
|
667
933
|
this.tui.requestRender();
|
|
668
934
|
return;
|
|
@@ -783,10 +1049,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
783
1049
|
details: { question, context: normalizedContext, options, answer: null, cancelled: false },
|
|
784
1050
|
});
|
|
785
1051
|
|
|
786
|
-
let result:
|
|
1052
|
+
let result: AskUIResult | null;
|
|
787
1053
|
try {
|
|
788
|
-
result = await ctx.ui.custom<
|
|
789
|
-
(tui, theme,
|
|
1054
|
+
result = await ctx.ui.custom<AskUIResult | null>(
|
|
1055
|
+
(tui, theme, keybindings, done) => {
|
|
790
1056
|
// Wire AbortSignal so agent cancellation auto-dismisses the overlay
|
|
791
1057
|
if (signal) {
|
|
792
1058
|
const onAbort = () => done(null);
|
|
@@ -806,6 +1072,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
806
1072
|
allowFreeform,
|
|
807
1073
|
tui,
|
|
808
1074
|
theme,
|
|
1075
|
+
keybindings,
|
|
809
1076
|
done,
|
|
810
1077
|
);
|
|
811
1078
|
},
|
|
@@ -838,10 +1105,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
838
1105
|
};
|
|
839
1106
|
}
|
|
840
1107
|
|
|
841
|
-
pi.events.emit("ask:answered", {
|
|
1108
|
+
pi.events.emit("ask:answered", {
|
|
1109
|
+
question,
|
|
1110
|
+
context: normalizedContext,
|
|
1111
|
+
answer: result.answer,
|
|
1112
|
+
wasCustom: result.wasCustom,
|
|
1113
|
+
});
|
|
842
1114
|
return {
|
|
843
|
-
content: [{ type: "text", text: `User answered: ${result}` }],
|
|
844
|
-
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,
|
|
845
1124
|
};
|
|
846
1125
|
},
|
|
847
1126
|
|
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) {
|