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 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 selectList?: SelectList;
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 buildSingleSelectItems(): SelectItem[] {
401
- const items: SelectItem[] = this.options.map((o, idx) => ({
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 items = this.buildSingleSelectItems();
422
- const selectList = new SelectList(items, Math.min(items.length, 10), createSelectListTheme(this.theme));
423
-
424
- selectList.onSelect = (item) => {
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.selectList = selectList;
436
- return selectList;
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
- { overlay: true },
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.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
+ }