pi-ask-user 0.3.0 → 0.4.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/index.ts +172 -39
- package/package.json +2 -1
- package/single-select-layout.ts +172 -0
package/index.ts
CHANGED
|
@@ -15,14 +15,13 @@ import {
|
|
|
15
15
|
type EditorTheme,
|
|
16
16
|
Key,
|
|
17
17
|
matchesKey,
|
|
18
|
-
SelectList,
|
|
19
|
-
type SelectItem,
|
|
20
18
|
Spacer,
|
|
21
19
|
Text,
|
|
22
20
|
type TUI,
|
|
23
21
|
truncateToWidth,
|
|
24
22
|
wrapTextWithAnsi,
|
|
25
23
|
} from "@mariozechner/pi-tui";
|
|
24
|
+
import { renderSingleSelectRows } from "./single-select-layout";
|
|
26
25
|
|
|
27
26
|
interface QuestionOption {
|
|
28
27
|
title: string;
|
|
@@ -93,6 +92,9 @@ function createEditorTheme(theme: Theme): EditorTheme {
|
|
|
93
92
|
|
|
94
93
|
type AskMode = "select" | "freeform";
|
|
95
94
|
|
|
95
|
+
const ASK_OVERLAY_MAX_HEIGHT_RATIO = 0.85;
|
|
96
|
+
const ASK_OVERLAY_WIDTH = "92%";
|
|
97
|
+
|
|
96
98
|
class MultiSelectList implements Component {
|
|
97
99
|
private options: QuestionOption[];
|
|
98
100
|
private allowFreeform: boolean;
|
|
@@ -262,6 +264,135 @@ class MultiSelectList implements Component {
|
|
|
262
264
|
}
|
|
263
265
|
}
|
|
264
266
|
|
|
267
|
+
class WrappedSingleSelectList implements Component {
|
|
268
|
+
private options: QuestionOption[];
|
|
269
|
+
private allowFreeform: boolean;
|
|
270
|
+
private theme: Theme;
|
|
271
|
+
private selectedIndex = 0;
|
|
272
|
+
private maxVisibleRows = 12;
|
|
273
|
+
private cachedWidth?: number;
|
|
274
|
+
private cachedLines?: string[];
|
|
275
|
+
|
|
276
|
+
public onCancel?: () => void;
|
|
277
|
+
public onSubmit?: (result: string) => void;
|
|
278
|
+
public onEnterFreeform?: () => void;
|
|
279
|
+
|
|
280
|
+
constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme) {
|
|
281
|
+
this.options = options;
|
|
282
|
+
this.allowFreeform = allowFreeform;
|
|
283
|
+
this.theme = theme;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
setMaxVisibleRows(rows: number): void {
|
|
287
|
+
const next = Math.max(1, Math.floor(rows));
|
|
288
|
+
if (next !== this.maxVisibleRows) {
|
|
289
|
+
this.maxVisibleRows = next;
|
|
290
|
+
this.invalidate();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
invalidate(): void {
|
|
295
|
+
this.cachedWidth = undefined;
|
|
296
|
+
this.cachedLines = undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private getItemCount(): number {
|
|
300
|
+
return this.options.length + (this.allowFreeform ? 1 : 0);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private isFreeformRow(index: number): boolean {
|
|
304
|
+
return this.allowFreeform && index === this.options.length;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
handleInput(data: string): void {
|
|
308
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
309
|
+
this.onCancel?.();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const count = this.getItemCount();
|
|
314
|
+
if (count === 0) {
|
|
315
|
+
this.onCancel?.();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.shift("tab"))) {
|
|
320
|
+
this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
|
|
321
|
+
this.invalidate();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (matchesKey(data, Key.down) || matchesKey(data, Key.tab)) {
|
|
326
|
+
this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
|
|
327
|
+
this.invalidate();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const numMatch = data.match(/^[1-9]$/);
|
|
332
|
+
if (numMatch) {
|
|
333
|
+
const idx = Number.parseInt(numMatch[0], 10) - 1;
|
|
334
|
+
if (idx >= 0 && idx < count) {
|
|
335
|
+
this.selectedIndex = idx;
|
|
336
|
+
this.invalidate();
|
|
337
|
+
}
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (matchesKey(data, Key.enter)) {
|
|
342
|
+
if (this.isFreeformRow(this.selectedIndex)) {
|
|
343
|
+
this.onEnterFreeform?.();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const result = this.options[this.selectedIndex]?.title;
|
|
348
|
+
if (result) this.onSubmit?.(result);
|
|
349
|
+
else this.onCancel?.();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
render(width: number): string[] {
|
|
354
|
+
if (this.cachedLines && this.cachedWidth === width) {
|
|
355
|
+
return this.cachedLines;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const count = this.getItemCount();
|
|
359
|
+
if (count === 0) {
|
|
360
|
+
this.cachedLines = [this.theme.fg("warning", "No options")];
|
|
361
|
+
this.cachedWidth = width;
|
|
362
|
+
return this.cachedLines;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const lines = renderSingleSelectRows({
|
|
366
|
+
options: this.options,
|
|
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
|
+
}
|
|
386
|
+
|
|
387
|
+
return truncateToWidth(styled, width, "");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
this.cachedWidth = width;
|
|
391
|
+
this.cachedLines = lines;
|
|
392
|
+
return lines;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
265
396
|
/**
|
|
266
397
|
* Interactive ask UI. Uses a root Container for layout and swaps the center
|
|
267
398
|
* component between SelectList/MultiSelectList and an Editor (freeform mode).
|
|
@@ -286,7 +417,7 @@ class AskComponent extends Container {
|
|
|
286
417
|
private helpText: Text;
|
|
287
418
|
|
|
288
419
|
// Mode components
|
|
289
|
-
private
|
|
420
|
+
private singleSelectList?: WrappedSingleSelectList;
|
|
290
421
|
private multiSelectList?: MultiSelectList;
|
|
291
422
|
private editor?: Editor;
|
|
292
423
|
|
|
@@ -364,11 +495,32 @@ class AskComponent extends Container {
|
|
|
364
495
|
}
|
|
365
496
|
|
|
366
497
|
override render(width: number): string[] {
|
|
498
|
+
if (this.mode === "select" && !this.allowMultiple) {
|
|
499
|
+
const overlayMaxHeight = Math.max(12, Math.floor(this.tui.terminal.rows * ASK_OVERLAY_MAX_HEIGHT_RATIO));
|
|
500
|
+
const staticLines = this.countStaticLines(width);
|
|
501
|
+
const availableOptionRows = Math.max(4, overlayMaxHeight - staticLines);
|
|
502
|
+
this.ensureSingleSelectList().setMaxVisibleRows(availableOptionRows);
|
|
503
|
+
}
|
|
504
|
+
|
|
367
505
|
// Defensive: ensure no line exceeds width, otherwise pi-tui will hard-crash.
|
|
368
506
|
const lines = super.render(width);
|
|
369
507
|
return lines.map((l) => truncateToWidth(l, width, ""));
|
|
370
508
|
}
|
|
371
509
|
|
|
510
|
+
private countWrappedLines(text: string, width: number): number {
|
|
511
|
+
return Math.max(1, wrapTextWithAnsi(text, Math.max(10, width - 2)).length);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private countStaticLines(width: number): number {
|
|
515
|
+
const titleLines = 1;
|
|
516
|
+
const questionLines = this.countWrappedLines(this.question, width);
|
|
517
|
+
const contextLines = this.context ? 1 + this.countWrappedLines(this.context, width) : 0;
|
|
518
|
+
const helpLines = 1;
|
|
519
|
+
const borderLines = 2;
|
|
520
|
+
const spacerLines = this.context ? 6 : 5;
|
|
521
|
+
return borderLines + spacerLines + titleLines + questionLines + contextLines + helpLines;
|
|
522
|
+
}
|
|
523
|
+
|
|
372
524
|
private updateStaticText(): void {
|
|
373
525
|
const theme = this.theme;
|
|
374
526
|
this.titleText.setText(theme.fg("accent", theme.bold("Question")));
|
|
@@ -397,43 +549,16 @@ class AskComponent extends Container {
|
|
|
397
549
|
}
|
|
398
550
|
}
|
|
399
551
|
|
|
400
|
-
private
|
|
401
|
-
|
|
402
|
-
value: String(idx),
|
|
403
|
-
label: o.title,
|
|
404
|
-
description: o.description,
|
|
405
|
-
}));
|
|
406
|
-
|
|
407
|
-
if (this.allowFreeform) {
|
|
408
|
-
items.push({
|
|
409
|
-
value: FREEFORM_VALUE,
|
|
410
|
-
label: "Type something.",
|
|
411
|
-
description: "Enter a custom response",
|
|
412
|
-
});
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
return items;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
private ensureSingleSelectList(): SelectList {
|
|
419
|
-
if (this.selectList) return this.selectList;
|
|
552
|
+
private ensureSingleSelectList(): WrappedSingleSelectList {
|
|
553
|
+
if (this.singleSelectList) return this.singleSelectList;
|
|
420
554
|
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (item.value === FREEFORM_VALUE) {
|
|
426
|
-
this.showFreeformMode();
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
const idx = Number.parseInt(item.value, 10);
|
|
430
|
-
const option = this.options[idx];
|
|
431
|
-
this.onDone(option?.title ?? null);
|
|
432
|
-
};
|
|
433
|
-
selectList.onCancel = () => this.onDone(null);
|
|
555
|
+
const list = new WrappedSingleSelectList(this.options, this.allowFreeform, this.theme);
|
|
556
|
+
list.onSubmit = (result) => this.onDone(result);
|
|
557
|
+
list.onCancel = () => this.onDone(null);
|
|
558
|
+
list.onEnterFreeform = () => this.showFreeformMode();
|
|
434
559
|
|
|
435
|
-
this.
|
|
436
|
-
return
|
|
560
|
+
this.singleSelectList = list;
|
|
561
|
+
return list;
|
|
437
562
|
}
|
|
438
563
|
|
|
439
564
|
private ensureMultiSelectList(): MultiSelectList {
|
|
@@ -650,7 +775,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
650
775
|
done,
|
|
651
776
|
);
|
|
652
777
|
},
|
|
653
|
-
{
|
|
778
|
+
{
|
|
779
|
+
overlay: true,
|
|
780
|
+
overlayOptions: {
|
|
781
|
+
anchor: "center",
|
|
782
|
+
width: ASK_OVERLAY_WIDTH,
|
|
783
|
+
maxHeight: "85%",
|
|
784
|
+
margin: 1,
|
|
785
|
+
},
|
|
786
|
+
},
|
|
654
787
|
);
|
|
655
788
|
} catch (error) {
|
|
656
789
|
const message =
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ask-user",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Interactive ask_user tool for pi-coding-agent with multi-select and freeform input UI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"files": [
|
|
37
37
|
"index.ts",
|
|
38
|
+
"single-select-layout.ts",
|
|
38
39
|
"skills",
|
|
39
40
|
"README.md",
|
|
40
41
|
"LICENSE"
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
export interface QuestionOption {
|
|
2
|
+
title: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface RenderSingleSelectRowsParams {
|
|
7
|
+
options: QuestionOption[];
|
|
8
|
+
selectedIndex: number;
|
|
9
|
+
width: number;
|
|
10
|
+
allowFreeform: boolean;
|
|
11
|
+
maxRows?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function wrapText(text: string, width: number): string[] {
|
|
15
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
16
|
+
if (!normalized) return [""];
|
|
17
|
+
if (width <= 1) return normalized.split("");
|
|
18
|
+
|
|
19
|
+
const words = normalized.split(" ");
|
|
20
|
+
const lines: string[] = [];
|
|
21
|
+
let current = "";
|
|
22
|
+
|
|
23
|
+
for (const word of words) {
|
|
24
|
+
if (!current) {
|
|
25
|
+
if (word.length <= width) {
|
|
26
|
+
current = word;
|
|
27
|
+
} else {
|
|
28
|
+
for (let i = 0; i < word.length; i += width) {
|
|
29
|
+
lines.push(word.slice(i, i + width));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const candidate = `${current} ${word}`;
|
|
36
|
+
if (candidate.length <= width) {
|
|
37
|
+
current = candidate;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
lines.push(current);
|
|
42
|
+
if (word.length <= width) {
|
|
43
|
+
current = word;
|
|
44
|
+
} else {
|
|
45
|
+
for (let i = 0; i < word.length; i += width) {
|
|
46
|
+
const chunk = word.slice(i, i + width);
|
|
47
|
+
if (chunk.length === width || i + width < word.length) lines.push(chunk);
|
|
48
|
+
else current = chunk;
|
|
49
|
+
}
|
|
50
|
+
if (!current || current.length === width) current = "";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (current) lines.push(current);
|
|
55
|
+
return lines;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function padLine(prefix: string, content: string): string {
|
|
59
|
+
return `${prefix}${content}`.trimEnd();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface ItemBlock {
|
|
63
|
+
itemIndex: number;
|
|
64
|
+
lines: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildItemBlocks(
|
|
68
|
+
options: QuestionOption[],
|
|
69
|
+
width: number,
|
|
70
|
+
allowFreeform: boolean,
|
|
71
|
+
selectedIndex: number,
|
|
72
|
+
): ItemBlock[] {
|
|
73
|
+
const normalizedWidth = Math.max(12, width);
|
|
74
|
+
const freeformLabel = "Type something. — Enter a custom response";
|
|
75
|
+
const allItems = options.map((option) => ({ type: "option" as const, option }));
|
|
76
|
+
if (allowFreeform) {
|
|
77
|
+
allItems.push({ type: "freeform" as const, option: { title: freeformLabel } });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return allItems.map((item, itemIndex) => {
|
|
81
|
+
const pointer = itemIndex === selectedIndex ? "→" : " ";
|
|
82
|
+
const lines: string[] = [];
|
|
83
|
+
|
|
84
|
+
if (item.type === "freeform") {
|
|
85
|
+
const prefix = `${pointer} `;
|
|
86
|
+
const wrapped = wrapText(item.option.title, Math.max(8, normalizedWidth - prefix.length));
|
|
87
|
+
wrapped.forEach((line, lineIndex) => {
|
|
88
|
+
lines.push(padLine(lineIndex === 0 ? prefix : " ".repeat(prefix.length), line));
|
|
89
|
+
});
|
|
90
|
+
return { itemIndex, lines };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const numberPrefix = `${pointer} ${itemIndex + 1}. `;
|
|
94
|
+
const continuationPrefix = " ".repeat(numberPrefix.length);
|
|
95
|
+
const titleLines = wrapText(item.option.title, Math.max(8, normalizedWidth - numberPrefix.length));
|
|
96
|
+
titleLines.forEach((line, lineIndex) => {
|
|
97
|
+
lines.push(padLine(lineIndex === 0 ? numberPrefix : continuationPrefix, line));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (item.option.description) {
|
|
101
|
+
const descriptionPrefix = " ";
|
|
102
|
+
const descriptionLines = wrapText(
|
|
103
|
+
item.option.description,
|
|
104
|
+
Math.max(8, normalizedWidth - descriptionPrefix.length),
|
|
105
|
+
);
|
|
106
|
+
descriptionLines.forEach((line) => {
|
|
107
|
+
lines.push(padLine(descriptionPrefix, line));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { itemIndex, lines };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function flatten(blocks: ItemBlock[]): string[] {
|
|
116
|
+
return blocks.flatMap((block) => block.lines);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function renderSingleSelectRows({
|
|
120
|
+
options,
|
|
121
|
+
selectedIndex,
|
|
122
|
+
width,
|
|
123
|
+
allowFreeform,
|
|
124
|
+
maxRows,
|
|
125
|
+
}: RenderSingleSelectRowsParams): string[] {
|
|
126
|
+
const itemCount = options.length + (allowFreeform ? 1 : 0);
|
|
127
|
+
const blocks = buildItemBlocks(options, width, allowFreeform, selectedIndex);
|
|
128
|
+
const allRows = flatten(blocks);
|
|
129
|
+
|
|
130
|
+
if (!Number.isFinite(maxRows) || !maxRows || maxRows <= 0 || allRows.length <= maxRows) {
|
|
131
|
+
return allRows;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const safeMaxRows = Math.max(1, Math.floor(maxRows));
|
|
135
|
+
const selectedBlock = blocks[selectedIndex] ?? blocks[0];
|
|
136
|
+
if (!selectedBlock) return [];
|
|
137
|
+
|
|
138
|
+
const indicator = ` (${selectedIndex + 1}/${itemCount})`;
|
|
139
|
+
const availableRows = safeMaxRows > 1 ? safeMaxRows - 1 : 1;
|
|
140
|
+
|
|
141
|
+
if (selectedBlock.lines.length >= availableRows) {
|
|
142
|
+
const visible = selectedBlock.lines.slice(0, availableRows);
|
|
143
|
+
if (safeMaxRows > 1) visible.push(indicator);
|
|
144
|
+
return visible.slice(0, safeMaxRows);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let start = selectedIndex;
|
|
148
|
+
let end = selectedIndex + 1;
|
|
149
|
+
let usedRows = selectedBlock.lines.length;
|
|
150
|
+
|
|
151
|
+
while (true) {
|
|
152
|
+
const nextCanFit = end < blocks.length && usedRows + blocks[end]!.lines.length <= availableRows;
|
|
153
|
+
if (nextCanFit) {
|
|
154
|
+
usedRows += blocks[end]!.lines.length;
|
|
155
|
+
end += 1;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const prevCanFit = start > 0 && usedRows + blocks[start - 1]!.lines.length <= availableRows;
|
|
160
|
+
if (prevCanFit) {
|
|
161
|
+
start -= 1;
|
|
162
|
+
usedRows += blocks[start]!.lines.length;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const visible = flatten(blocks.slice(start, end));
|
|
170
|
+
visible.push(indicator);
|
|
171
|
+
return visible.slice(0, safeMaxRows);
|
|
172
|
+
}
|