pi-ask-user 0.2.1 → 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/README.md CHANGED
@@ -14,6 +14,11 @@ High-quality video: [ask-user-demo.mp4](./media/ask-user-demo.mp4)
14
14
  - Multi-select option lists
15
15
  - Optional freeform responses
16
16
  - Context display support
17
+ - Overlay mode — dialog floats over conversation, preserving context
18
+ - Custom TUI rendering for tool calls and results
19
+ - System prompt integration via `promptSnippet` and `promptGuidelines`
20
+ - Optional timeout for auto-dismiss (fallback input mode)
21
+ - Structured `details` on all results for session state reconstruction
17
22
  - Graceful fallback when interactive UI is unavailable
18
23
  - Bundled `ask-user` skill for mandatory decision-gating in high-stakes or ambiguous tasks
19
24
 
@@ -46,6 +51,17 @@ The registered tool name is:
46
51
 
47
52
  - `ask_user`
48
53
 
54
+ ## Parameters
55
+
56
+ | Parameter | Type | Default | Description |
57
+ |-----------|------|---------|-------------|
58
+ | `question` | `string` | *required* | The question to ask the user |
59
+ | `context` | `string?` | — | Relevant context summary shown before the question |
60
+ | `options` | `(string \| {title, description?})[]?` | `[]` | Multiple-choice options |
61
+ | `allowMultiple` | `boolean?` | `false` | Enable multi-select mode |
62
+ | `allowFreeform` | `boolean?` | `true` | Add a "Type something" freeform option |
63
+ | `timeout` | `number?` | — | Auto-dismiss after N ms (applies to fallback input mode) |
64
+
49
65
  ## Example usage shape
50
66
 
51
67
  ```json
@@ -60,3 +76,32 @@ The registered tool name is:
60
76
  "allowFreeform": true
61
77
  }
62
78
  ```
79
+
80
+ ## Result details
81
+
82
+ All tool results include a structured `details` object for rendering and session state reconstruction:
83
+
84
+ ```typescript
85
+ interface AskToolDetails {
86
+ question: string;
87
+ context?: string;
88
+ options: QuestionOption[];
89
+ answer: string | null;
90
+ cancelled: boolean;
91
+ wasCustom?: boolean;
92
+ }
93
+ ```
94
+
95
+ ## Changelog
96
+
97
+ ### 0.3.0
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
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;
@@ -37,6 +36,16 @@ interface AskParams {
37
36
  options?: AskOptionInput[];
38
37
  allowMultiple?: boolean;
39
38
  allowFreeform?: boolean;
39
+ timeout?: number;
40
+ }
41
+
42
+ interface AskToolDetails {
43
+ question: string;
44
+ context?: string;
45
+ options: QuestionOption[];
46
+ answer: string | null;
47
+ cancelled: boolean;
48
+ wasCustom?: boolean;
40
49
  }
41
50
 
42
51
  function normalizeOptions(options: AskOptionInput[]): QuestionOption[] {
@@ -83,6 +92,9 @@ function createEditorTheme(theme: Theme): EditorTheme {
83
92
 
84
93
  type AskMode = "select" | "freeform";
85
94
 
95
+ const ASK_OVERLAY_MAX_HEIGHT_RATIO = 0.85;
96
+ const ASK_OVERLAY_WIDTH = "92%";
97
+
86
98
  class MultiSelectList implements Component {
87
99
  private options: QuestionOption[];
88
100
  private allowFreeform: boolean;
@@ -252,6 +264,135 @@ class MultiSelectList implements Component {
252
264
  }
253
265
  }
254
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
+
255
396
  /**
256
397
  * Interactive ask UI. Uses a root Container for layout and swaps the center
257
398
  * component between SelectList/MultiSelectList and an Editor (freeform mode).
@@ -276,7 +417,7 @@ class AskComponent extends Container {
276
417
  private helpText: Text;
277
418
 
278
419
  // Mode components
279
- private selectList?: SelectList;
420
+ private singleSelectList?: WrappedSingleSelectList;
280
421
  private multiSelectList?: MultiSelectList;
281
422
  private editor?: Editor;
282
423
 
@@ -354,11 +495,32 @@ class AskComponent extends Container {
354
495
  }
355
496
 
356
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
+
357
505
  // Defensive: ensure no line exceeds width, otherwise pi-tui will hard-crash.
358
506
  const lines = super.render(width);
359
507
  return lines.map((l) => truncateToWidth(l, width, ""));
360
508
  }
361
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
+
362
524
  private updateStaticText(): void {
363
525
  const theme = this.theme;
364
526
  this.titleText.setText(theme.fg("accent", theme.bold("Question")));
@@ -387,43 +549,16 @@ class AskComponent extends Container {
387
549
  }
388
550
  }
389
551
 
390
- private buildSingleSelectItems(): SelectItem[] {
391
- const items: SelectItem[] = this.options.map((o, idx) => ({
392
- value: String(idx),
393
- label: o.title,
394
- description: o.description,
395
- }));
396
-
397
- if (this.allowFreeform) {
398
- items.push({
399
- value: FREEFORM_VALUE,
400
- label: "Type something.",
401
- description: "Enter a custom response",
402
- });
403
- }
552
+ private ensureSingleSelectList(): WrappedSingleSelectList {
553
+ if (this.singleSelectList) return this.singleSelectList;
404
554
 
405
- return items;
406
- }
407
-
408
- private ensureSingleSelectList(): SelectList {
409
- if (this.selectList) return this.selectList;
410
-
411
- const items = this.buildSingleSelectItems();
412
- const selectList = new SelectList(items, Math.min(items.length, 10), createSelectListTheme(this.theme));
413
-
414
- selectList.onSelect = (item) => {
415
- if (item.value === FREEFORM_VALUE) {
416
- this.showFreeformMode();
417
- return;
418
- }
419
- const idx = Number.parseInt(item.value, 10);
420
- const option = this.options[idx];
421
- this.onDone(option?.title ?? null);
422
- };
423
- 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();
424
559
 
425
- this.selectList = selectList;
426
- return selectList;
560
+ this.singleSelectList = list;
561
+ return list;
427
562
  }
428
563
 
429
564
  private ensureMultiSelectList(): MultiSelectList {
@@ -534,6 +669,12 @@ export default function (pi: ExtensionAPI) {
534
669
  label: "Ask User",
535
670
  description:
536
671
  "Ask the user a question with optional multiple-choice answers. Use this to gather information interactively. Before calling, gather context with tools (read/exa/ref) and pass a short summary via the context field.",
672
+ promptSnippet:
673
+ "Ask the user a question with optional multiple-choice answers to gather information interactively",
674
+ promptGuidelines: [
675
+ "Before calling ask_user, gather context with tools (read/exa/ref) and pass a short summary via the context field.",
676
+ "Use ask_user when the user's intent is ambiguous, when a decision requires explicit user input, or when multiple valid options exist.",
677
+ ],
537
678
  parameters: Type.Object({
538
679
  question: Type.String({ description: "The question to ask the user" }),
539
680
  context: Type.Optional(
@@ -561,11 +702,17 @@ export default function (pi: ExtensionAPI) {
561
702
  allowFreeform: Type.Optional(
562
703
  Type.Boolean({ description: "Add a freeform text option. Default: true" }),
563
704
  ),
705
+ timeout: Type.Optional(
706
+ Type.Number({ description: "Auto-dismiss after N milliseconds (applies to fallback input mode when no options are provided)" }),
707
+ ),
564
708
  }),
565
709
 
566
710
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
567
711
  if (signal?.aborted) {
568
- return { content: [{ type: "text", text: "Cancelled" }] };
712
+ return {
713
+ content: [{ type: "text", text: "Cancelled" }],
714
+ details: { question: params.question, options: [], answer: null, cancelled: true } as AskToolDetails,
715
+ };
569
716
  }
570
717
 
571
718
  const {
@@ -574,6 +721,7 @@ export default function (pi: ExtensionAPI) {
574
721
  options: rawOptions = [],
575
722
  allowMultiple = false,
576
723
  allowFreeform = true,
724
+ timeout,
577
725
  } = params as AskParams;
578
726
  const options = normalizeOptions(rawOptions);
579
727
  const normalizedContext = context?.trim() || undefined;
@@ -590,36 +738,53 @@ export default function (pi: ExtensionAPI) {
590
738
  },
591
739
  ],
592
740
  isError: true,
593
- details: { question, context: normalizedContext, options },
741
+ details: { question, context: normalizedContext, options, answer: null, cancelled: true } as AskToolDetails,
594
742
  };
595
743
  }
596
744
 
597
745
  // If no options provided, fall back to freeform input prompt.
598
746
  if (options.length === 0) {
599
747
  const prompt = normalizedContext ? `${question}\n\nContext:\n${normalizedContext}` : question;
600
- const answer = await ctx.ui.input(prompt, "Type your answer...");
748
+ const answer = await ctx.ui.input(prompt, "Type your answer...", timeout ? { timeout } : undefined);
601
749
 
602
750
  if (!answer) {
603
- return { content: [{ type: "text", text: "User cancelled the question" }] };
751
+ return {
752
+ content: [{ type: "text", text: "User cancelled the question" }],
753
+ details: { question, context: normalizedContext, options, answer: null, cancelled: true } as AskToolDetails,
754
+ };
604
755
  }
605
756
 
606
- return { content: [{ type: "text", text: `User answered: ${answer}` }] };
757
+ return {
758
+ content: [{ type: "text", text: `User answered: ${answer}` }],
759
+ details: { question, context: normalizedContext, options, answer, cancelled: false, wasCustom: true } as AskToolDetails,
760
+ };
607
761
  }
608
762
 
609
763
  let result: string | null;
610
764
  try {
611
- result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
612
- return new AskComponent(
613
- question,
614
- normalizedContext,
615
- options,
616
- allowMultiple,
617
- allowFreeform,
618
- tui,
619
- theme,
620
- done,
621
- );
622
- });
765
+ result = await ctx.ui.custom<string | null>(
766
+ (tui, theme, _kb, done) => {
767
+ return new AskComponent(
768
+ question,
769
+ normalizedContext,
770
+ options,
771
+ allowMultiple,
772
+ allowFreeform,
773
+ tui,
774
+ theme,
775
+ done,
776
+ );
777
+ },
778
+ {
779
+ overlay: true,
780
+ overlayOptions: {
781
+ anchor: "center",
782
+ width: ASK_OVERLAY_WIDTH,
783
+ maxHeight: "85%",
784
+ margin: 1,
785
+ },
786
+ },
787
+ );
623
788
  } catch (error) {
624
789
  const message =
625
790
  error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error);
@@ -631,10 +796,56 @@ export default function (pi: ExtensionAPI) {
631
796
  }
632
797
 
633
798
  if (result === null) {
634
- return { content: [{ type: "text", text: "User cancelled the question" }] };
799
+ return {
800
+ content: [{ type: "text", text: "User cancelled the question" }],
801
+ details: { question, context: normalizedContext, options, answer: null, cancelled: true } as AskToolDetails,
802
+ };
635
803
  }
636
804
 
637
- return { content: [{ type: "text", text: `User answered: ${result}` }] };
805
+ return {
806
+ content: [{ type: "text", text: `User answered: ${result}` }],
807
+ details: { question, context: normalizedContext, options, answer: result, cancelled: false } as AskToolDetails,
808
+ };
809
+ },
810
+
811
+ renderCall(args, theme) {
812
+ const question = (args.question as string) || "";
813
+ const rawOptions = Array.isArray(args.options) ? args.options : [];
814
+ let text = theme.fg("toolTitle", theme.bold("ask_user "));
815
+ text += theme.fg("muted", question);
816
+ if (rawOptions.length > 0) {
817
+ const labels = rawOptions.map((o: unknown) =>
818
+ typeof o === "string" ? o : (o as QuestionOption)?.title ?? "",
819
+ );
820
+ text += "\n" + theme.fg("dim", ` ${rawOptions.length} option(s): ${labels.join(", ")}`);
821
+ }
822
+ if (args.allowMultiple) {
823
+ text += theme.fg("dim", " [multi-select]");
824
+ }
825
+ return new Text(text, 0, 0);
826
+ },
827
+
828
+ renderResult(result, _options, theme) {
829
+ const details = result.details as (AskToolDetails & { error?: string }) | undefined;
830
+
831
+ // Error state
832
+ if (details?.error) {
833
+ return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0);
834
+ }
835
+
836
+ // Cancelled / no details
837
+ if (!details || details.cancelled) {
838
+ return new Text(theme.fg("warning", "Cancelled"), 0, 0);
839
+ }
840
+
841
+ // Success
842
+ const answer = details.answer ?? "";
843
+ let text = theme.fg("success", "✓ ");
844
+ if (details.wasCustom) {
845
+ text += theme.fg("muted", "(wrote) ");
846
+ }
847
+ text += theme.fg("accent", answer);
848
+ return new Text(text, 0, 0);
638
849
  },
639
850
  });
640
851
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ask-user",
3
- "version": "0.2.1",
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
+ }