pi-ask-user 0.3.0 → 0.4.1

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
@@ -17,7 +17,7 @@ High-quality video: [ask-user-demo.mp4](./media/ask-user-demo.mp4)
17
17
  - Overlay mode — dialog floats over conversation, preserving context
18
18
  - Custom TUI rendering for tool calls and results
19
19
  - System prompt integration via `promptSnippet` and `promptGuidelines`
20
- - Optional timeout for auto-dismiss (fallback input mode)
20
+ - Optional timeout for auto-dismiss in both overlay and fallback input modes
21
21
  - Structured `details` on all results for session state reconstruction
22
22
  - Graceful fallback when interactive UI is unavailable
23
23
  - Bundled `ask-user` skill for mandatory decision-gating in high-stakes or ambiguous tasks
@@ -60,7 +60,7 @@ The registered tool name is:
60
60
  | `options` | `(string \| {title, description?})[]?` | `[]` | Multiple-choice options |
61
61
  | `allowMultiple` | `boolean?` | `false` | Enable multi-select mode |
62
62
  | `allowFreeform` | `boolean?` | `true` | Add a "Type something" freeform option |
63
- | `timeout` | `number?` | — | Auto-dismiss after N ms (applies to fallback input mode) |
63
+ | `timeout` | `number?` | — | Auto-dismiss after N ms and return `null` if the prompt times out |
64
64
 
65
65
  ## Example usage shape
66
66
 
@@ -99,7 +99,7 @@ interface AskToolDetails {
99
99
  - Added `promptSnippet` and `promptGuidelines` for better LLM tool selection in the system prompt
100
100
  - Added `renderCall` and `renderResult` for custom TUI rendering (compact tool call display, ✓/Cancelled result indicators)
101
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)
102
+ - Added `timeout` parameter for auto-dismiss and cancellation support
103
103
  - Added structured `details` (`AskToolDetails`) to all result paths for session state reconstruction and branching support
104
104
 
105
105
  ### 0.2.1
package/index.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
9
- import { DynamicBorder } from "@mariozechner/pi-coding-agent";
9
+ import { DynamicBorder, getMarkdownTheme, rawKeyHint } from "@mariozechner/pi-coding-agent";
10
10
  import { Type } from "@sinclair/typebox";
11
11
  import {
12
12
  Container,
@@ -14,15 +14,16 @@ import {
14
14
  Editor,
15
15
  type EditorTheme,
16
16
  Key,
17
+ Markdown,
18
+ type MarkdownTheme,
17
19
  matchesKey,
18
- SelectList,
19
- type SelectItem,
20
20
  Spacer,
21
21
  Text,
22
22
  type TUI,
23
23
  truncateToWidth,
24
24
  wrapTextWithAnsi,
25
25
  } from "@mariozechner/pi-tui";
26
+ import { renderSingleSelectRows } from "./single-select-layout";
26
27
 
27
28
  interface QuestionOption {
28
29
  title: string;
@@ -72,8 +73,6 @@ function formatOptionsForMessage(options: QuestionOption[]): string {
72
73
  .join("\n");
73
74
  }
74
75
 
75
- const FREEFORM_VALUE = "__freeform__";
76
-
77
76
  function createSelectListTheme(theme: Theme) {
78
77
  return {
79
78
  selectedPrefix: (t: string) => theme.fg("accent", t),
@@ -93,6 +92,10 @@ 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
+ const ASK_OVERLAY_MIN_WIDTH = 40;
98
+
96
99
  class MultiSelectList implements Component {
97
100
  private options: QuestionOption[];
98
101
  private allowFreeform: boolean;
@@ -262,6 +265,135 @@ class MultiSelectList implements Component {
262
265
  }
263
266
  }
264
267
 
268
+ class WrappedSingleSelectList implements Component {
269
+ private options: QuestionOption[];
270
+ private allowFreeform: boolean;
271
+ private theme: Theme;
272
+ private selectedIndex = 0;
273
+ private maxVisibleRows = 12;
274
+ private cachedWidth?: number;
275
+ private cachedLines?: string[];
276
+
277
+ public onCancel?: () => void;
278
+ public onSubmit?: (result: string) => void;
279
+ public onEnterFreeform?: () => void;
280
+
281
+ constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme) {
282
+ this.options = options;
283
+ this.allowFreeform = allowFreeform;
284
+ this.theme = theme;
285
+ }
286
+
287
+ setMaxVisibleRows(rows: number): void {
288
+ const next = Math.max(1, Math.floor(rows));
289
+ if (next !== this.maxVisibleRows) {
290
+ this.maxVisibleRows = next;
291
+ this.invalidate();
292
+ }
293
+ }
294
+
295
+ invalidate(): void {
296
+ this.cachedWidth = undefined;
297
+ this.cachedLines = undefined;
298
+ }
299
+
300
+ private getItemCount(): number {
301
+ return this.options.length + (this.allowFreeform ? 1 : 0);
302
+ }
303
+
304
+ private isFreeformRow(index: number): boolean {
305
+ return this.allowFreeform && index === this.options.length;
306
+ }
307
+
308
+ handleInput(data: string): void {
309
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
310
+ this.onCancel?.();
311
+ return;
312
+ }
313
+
314
+ const count = this.getItemCount();
315
+ if (count === 0) {
316
+ this.onCancel?.();
317
+ return;
318
+ }
319
+
320
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.shift("tab"))) {
321
+ this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
322
+ this.invalidate();
323
+ return;
324
+ }
325
+
326
+ if (matchesKey(data, Key.down) || matchesKey(data, Key.tab)) {
327
+ this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
328
+ this.invalidate();
329
+ return;
330
+ }
331
+
332
+ const numMatch = data.match(/^[1-9]$/);
333
+ if (numMatch) {
334
+ const idx = Number.parseInt(numMatch[0], 10) - 1;
335
+ if (idx >= 0 && idx < count) {
336
+ this.selectedIndex = idx;
337
+ this.invalidate();
338
+ }
339
+ return;
340
+ }
341
+
342
+ if (matchesKey(data, Key.enter)) {
343
+ if (this.isFreeformRow(this.selectedIndex)) {
344
+ this.onEnterFreeform?.();
345
+ return;
346
+ }
347
+
348
+ const result = this.options[this.selectedIndex]?.title;
349
+ if (result) this.onSubmit?.(result);
350
+ else this.onCancel?.();
351
+ }
352
+ }
353
+
354
+ render(width: number): string[] {
355
+ if (this.cachedLines && this.cachedWidth === width) {
356
+ return this.cachedLines;
357
+ }
358
+
359
+ const count = this.getItemCount();
360
+ if (count === 0) {
361
+ this.cachedLines = [this.theme.fg("warning", "No options")];
362
+ this.cachedWidth = width;
363
+ return this.cachedLines;
364
+ }
365
+
366
+ const lines = renderSingleSelectRows({
367
+ options: this.options,
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
+ }
387
+
388
+ return truncateToWidth(styled, width, "");
389
+ });
390
+
391
+ this.cachedWidth = width;
392
+ this.cachedLines = lines;
393
+ return lines;
394
+ }
395
+ }
396
+
265
397
  /**
266
398
  * Interactive ask UI. Uses a root Container for layout and swaps the center
267
399
  * component between SelectList/MultiSelectList and an Editor (freeform mode).
@@ -281,16 +413,16 @@ class AskComponent extends Container {
281
413
  // Static layout components
282
414
  private titleText: Text;
283
415
  private questionText: Text;
284
- private contextText?: Text;
416
+ private contextComponent?: Component;
285
417
  private modeContainer: Container;
286
418
  private helpText: Text;
287
419
 
288
420
  // Mode components
289
- private selectList?: SelectList;
421
+ private singleSelectList?: WrappedSingleSelectList;
290
422
  private multiSelectList?: MultiSelectList;
291
423
  private editor?: Editor;
292
424
 
293
- // Focus propagation for IME cursor positioning (Editor is Focusable)
425
+ // Focusable - propagate to Editor for IME cursor positioning
294
426
  private _focused = false;
295
427
  get focused(): boolean {
296
428
  return this._focused;
@@ -298,8 +430,7 @@ class AskComponent extends Container {
298
430
  set focused(value: boolean) {
299
431
  this._focused = value;
300
432
  if (this.editor && this.mode === "freeform") {
301
- const anyEditor = this.editor as unknown as { focused?: boolean };
302
- anyEditor.focused = value;
433
+ (this.editor as any).focused = value;
303
434
  }
304
435
  }
305
436
 
@@ -337,8 +468,16 @@ class AskComponent extends Container {
337
468
 
338
469
  if (this.context) {
339
470
  this.addChild(new Spacer(1));
340
- this.contextText = new Text("", 1, 0);
341
- this.addChild(this.contextText);
471
+ let mdTheme: MarkdownTheme | undefined;
472
+ try {
473
+ mdTheme = getMarkdownTheme();
474
+ } catch {}
475
+ if (mdTheme) {
476
+ this.contextComponent = new Markdown("", 1, 0, mdTheme);
477
+ } else {
478
+ this.contextComponent = new Text("", 1, 0);
479
+ }
480
+ this.addChild(this.contextComponent);
342
481
  }
343
482
 
344
483
  this.addChild(new Spacer(1));
@@ -364,76 +503,90 @@ class AskComponent extends Container {
364
503
  }
365
504
 
366
505
  override render(width: number): string[] {
506
+ if (this.mode === "select" && !this.allowMultiple) {
507
+ const overlayMaxHeight = Math.max(12, Math.floor(this.tui.terminal.rows * ASK_OVERLAY_MAX_HEIGHT_RATIO));
508
+ const staticLines = this.countStaticLines(width);
509
+ const availableOptionRows = Math.max(4, overlayMaxHeight - staticLines);
510
+ this.ensureSingleSelectList().setMaxVisibleRows(availableOptionRows);
511
+ }
512
+
367
513
  // Defensive: ensure no line exceeds width, otherwise pi-tui will hard-crash.
368
514
  const lines = super.render(width);
369
515
  return lines.map((l) => truncateToWidth(l, width, ""));
370
516
  }
371
517
 
518
+ private countWrappedLines(text: string, width: number): number {
519
+ return Math.max(1, wrapTextWithAnsi(text, Math.max(10, width - 2)).length);
520
+ }
521
+
522
+ private countStaticLines(width: number): number {
523
+ const titleLines = 1;
524
+ const questionLines = this.countWrappedLines(this.question, width);
525
+ const contextLines = this.context ? 1 + this.countWrappedLines(this.context, width) : 0;
526
+ const helpLines = 1;
527
+ const borderLines = 2;
528
+ const spacerLines = this.context ? 6 : 5;
529
+ return borderLines + spacerLines + titleLines + questionLines + contextLines + helpLines;
530
+ }
531
+
372
532
  private updateStaticText(): void {
373
533
  const theme = this.theme;
374
534
  this.titleText.setText(theme.fg("accent", theme.bold("Question")));
375
535
  this.questionText.setText(theme.fg("text", theme.bold(this.question)));
376
- if (this.contextText && this.context) {
377
- this.contextText.setText(`${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", this.context)}`);
536
+ if (this.contextComponent && this.context) {
537
+ if (this.contextComponent instanceof Markdown) {
538
+ (this.contextComponent as Markdown).setText(
539
+ `**Context:**\n${this.context}`,
540
+ );
541
+ } else {
542
+ (this.contextComponent as Text).setText(
543
+ `${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", this.context)}`,
544
+ );
545
+ }
378
546
  }
379
547
  }
380
548
 
381
549
  private updateHelpText(): void {
382
550
  const theme = this.theme;
383
551
  if (this.mode === "freeform") {
384
- this.helpText.setText(
385
- theme.fg(
386
- "dim",
387
- "enter submit • shift+enter newline • (ctrl+enter submit if supported) • esc back • ctrl+c cancel",
388
- ),
389
- );
552
+ const hints = [
553
+ rawKeyHint("enter", "submit"),
554
+ rawKeyHint("shift+enter", "newline"),
555
+ rawKeyHint("esc", "back"),
556
+ rawKeyHint("ctrl+c", "cancel"),
557
+ ].join(" • ");
558
+ this.helpText.setText(theme.fg("dim", hints));
390
559
  return;
391
560
  }
392
561
 
393
562
  if (this.allowMultiple) {
394
- this.helpText.setText(theme.fg("dim", "↑↓ navigate • space toggle • enter submit • esc cancel"));
563
+ const hints = [
564
+ rawKeyHint("↑↓", "navigate"),
565
+ rawKeyHint("space", "toggle"),
566
+ rawKeyHint("enter", "submit"),
567
+ rawKeyHint("esc", "cancel"),
568
+ ].join(" • ");
569
+ this.helpText.setText(theme.fg("dim", hints));
395
570
  } else {
396
- this.helpText.setText(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"));
397
- }
398
- }
399
-
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
- });
571
+ const hints = [
572
+ rawKeyHint("↑↓", "navigate"),
573
+ rawKeyHint("enter", "select"),
574
+ rawKeyHint("esc", "cancel"),
575
+ ].join(" ");
576
+ this.helpText.setText(theme.fg("dim", hints));
413
577
  }
414
-
415
- return items;
416
578
  }
417
579
 
418
- private ensureSingleSelectList(): SelectList {
419
- if (this.selectList) return this.selectList;
420
-
421
- const items = this.buildSingleSelectItems();
422
- const selectList = new SelectList(items, Math.min(items.length, 10), createSelectListTheme(this.theme));
580
+ private ensureSingleSelectList(): WrappedSingleSelectList {
581
+ if (this.singleSelectList) return this.singleSelectList;
423
582
 
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);
583
+ const list = new WrappedSingleSelectList(this.options, this.allowFreeform, this.theme);
584
+ list.onSubmit = (result) => this.onDone(result);
585
+ list.onCancel = () => this.onDone(null);
586
+ list.onEnterFreeform = () => this.showFreeformMode();
434
587
 
435
- this.selectList = selectList;
436
- return selectList;
588
+ this.singleSelectList = list;
589
+ return list;
437
590
  }
438
591
 
439
592
  private ensureMultiSelectList(): MultiSelectList {
@@ -450,11 +603,7 @@ class AskComponent extends Container {
450
603
 
451
604
  private ensureEditor(): Editor {
452
605
  if (this.editor) return this.editor;
453
- // Note: pi's bundled pi-tui Editor expects (tui, theme, options?)
454
- const editor = new Editor(this.tui, createEditorTheme(this.theme));
455
- // Default Editor behavior: Enter submits, Shift+Enter inserts newline.
456
- // Ctrl+Enter is only distinguishable in terminals with Kitty protocol mappings,
457
- // so we support it as an *additional* submit shortcut in our wrapper.
606
+ const editor = new Editor(createEditorTheme(this.theme));
458
607
  editor.disableSubmit = false;
459
608
  editor.onSubmit = (text: string) => {
460
609
  const trimmed = text.trim();
@@ -484,8 +633,7 @@ class AskComponent extends Container {
484
633
  this.modeContainer.clear();
485
634
 
486
635
  const editor = this.ensureEditor();
487
- // Ensure focus is propagated immediately when switching modes.
488
- (editor as unknown as { focused?: boolean }).focused = this._focused;
636
+ (editor as any).focused = this._focused;
489
637
 
490
638
  this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold("Custom response")), 1, 0));
491
639
  this.modeContainer.addChild(new Spacer(1));
@@ -496,12 +644,6 @@ class AskComponent extends Container {
496
644
  this.tui.requestRender();
497
645
  }
498
646
 
499
- private submitFreeform(): void {
500
- const editor = this.ensureEditor();
501
- const text = editor.getText().trim();
502
- this.onDone(text ? text : null);
503
- }
504
-
505
647
  handleInput(data: string): void {
506
648
  if (this.mode === "freeform") {
507
649
  if (matchesKey(data, Key.escape)) {
@@ -514,13 +656,13 @@ class AskComponent extends Container {
514
656
  return;
515
657
  }
516
658
 
517
- // Submit on Ctrl+Enter (only works if terminal distinguishes it, e.g. Kitty protocol)
518
659
  if (matchesKey(data, Key.ctrl("enter")) || matchesKey(data, "ctrl+enter")) {
519
- this.submitFreeform();
660
+ const editor = this.ensureEditor();
661
+ const text = editor.getText().trim();
662
+ this.onDone(text ? text : null);
520
663
  return;
521
664
  }
522
665
 
523
- // Let Editor handle everything else (Enter submits, Shift+Enter newline)
524
666
  this.ensureEditor().handleInput(data);
525
667
  this.tui.requestRender();
526
668
  return;
@@ -578,11 +720,11 @@ export default function (pi: ExtensionAPI) {
578
720
  Type.Boolean({ description: "Add a freeform text option. Default: true" }),
579
721
  ),
580
722
  timeout: Type.Optional(
581
- Type.Number({ description: "Auto-dismiss after N milliseconds (applies to fallback input mode when no options are provided)" }),
723
+ Type.Number({ description: "Auto-dismiss after N milliseconds. Returns null (cancelled) when expired." }),
582
724
  ),
583
725
  }),
584
726
 
585
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
727
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
586
728
  if (signal?.aborted) {
587
729
  return {
588
730
  content: [{ type: "text", text: "Cancelled" }],
@@ -629,16 +771,33 @@ export default function (pi: ExtensionAPI) {
629
771
  };
630
772
  }
631
773
 
774
+ pi.events.emit("ask:answered", { question, context: normalizedContext, answer, wasCustom: true });
632
775
  return {
633
776
  content: [{ type: "text", text: `User answered: ${answer}` }],
634
777
  details: { question, context: normalizedContext, options, answer, cancelled: false, wasCustom: true } as AskToolDetails,
635
778
  };
636
779
  }
637
780
 
781
+ onUpdate?.({
782
+ content: [{ type: "text", text: "Waiting for user input..." }],
783
+ details: { question, context: normalizedContext, options, answer: null, cancelled: false },
784
+ });
785
+
638
786
  let result: string | null;
639
787
  try {
640
788
  result = await ctx.ui.custom<string | null>(
641
789
  (tui, theme, _kb, done) => {
790
+ // Wire AbortSignal so agent cancellation auto-dismisses the overlay
791
+ if (signal) {
792
+ const onAbort = () => done(null);
793
+ signal.addEventListener("abort", onAbort, { once: true });
794
+ }
795
+
796
+ // Wire timeout for overlay mode
797
+ if (timeout && timeout > 0) {
798
+ setTimeout(() => done(null), timeout);
799
+ }
800
+
642
801
  return new AskComponent(
643
802
  question,
644
803
  normalizedContext,
@@ -650,7 +809,16 @@ export default function (pi: ExtensionAPI) {
650
809
  done,
651
810
  );
652
811
  },
653
- { overlay: true },
812
+ {
813
+ overlay: true,
814
+ overlayOptions: {
815
+ anchor: "center",
816
+ width: ASK_OVERLAY_WIDTH,
817
+ minWidth: ASK_OVERLAY_MIN_WIDTH,
818
+ maxHeight: "85%",
819
+ margin: 1,
820
+ },
821
+ },
654
822
  );
655
823
  } catch (error) {
656
824
  const message =
@@ -663,12 +831,14 @@ export default function (pi: ExtensionAPI) {
663
831
  }
664
832
 
665
833
  if (result === null) {
834
+ pi.events.emit("ask:cancelled", { question, context: normalizedContext, options });
666
835
  return {
667
836
  content: [{ type: "text", text: "User cancelled the question" }],
668
837
  details: { question, context: normalizedContext, options, answer: null, cancelled: true } as AskToolDetails,
669
838
  };
670
839
  }
671
840
 
841
+ pi.events.emit("ask:answered", { question, context: normalizedContext, answer: result, wasCustom: false });
672
842
  return {
673
843
  content: [{ type: "text", text: `User answered: ${result}` }],
674
844
  details: { question, context: normalizedContext, options, answer: result, cancelled: false } as AskToolDetails,
@@ -692,26 +862,55 @@ export default function (pi: ExtensionAPI) {
692
862
  return new Text(text, 0, 0);
693
863
  },
694
864
 
695
- renderResult(result, _options, theme) {
865
+ renderResult(result, options, theme) {
696
866
  const details = result.details as (AskToolDetails & { error?: string }) | undefined;
697
867
 
698
- // Error state
699
868
  if (details?.error) {
700
869
  return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0);
701
870
  }
702
871
 
703
- // Cancelled / no details
872
+ if (options.isPartial) {
873
+ const waitingText = result.content
874
+ ?.filter((part: { type?: string; text?: string }) => part?.type === "text")
875
+ .map((part: { text?: string }) => part.text ?? "")
876
+ .join("\n")
877
+ .trim() || "Waiting for user input...";
878
+ return new Text(theme.fg("muted", waitingText), 0, 0);
879
+ }
880
+
704
881
  if (!details || details.cancelled) {
705
882
  return new Text(theme.fg("warning", "Cancelled"), 0, 0);
706
883
  }
707
884
 
708
- // Success
709
885
  const answer = details.answer ?? "";
710
886
  let text = theme.fg("success", "✓ ");
711
887
  if (details.wasCustom) {
712
888
  text += theme.fg("muted", "(wrote) ");
713
889
  }
714
890
  text += theme.fg("accent", answer);
891
+
892
+ if (options.expanded) {
893
+ const selectedTitles = new Set(
894
+ answer
895
+ .split(",")
896
+ .map((value) => value.trim())
897
+ .filter(Boolean),
898
+ );
899
+ text += "\n" + theme.fg("dim", `Q: ${details.question}`);
900
+ if (details.context) {
901
+ text += "\n" + theme.fg("dim", details.context);
902
+ }
903
+ if (details.options && details.options.length > 0) {
904
+ text += "\n" + theme.fg("dim", "Options:");
905
+ for (const opt of details.options) {
906
+ const desc = opt.description ? ` — ${opt.description}` : "";
907
+ const isSelected = opt.title === answer || selectedTitles.has(opt.title);
908
+ const marker = isSelected ? theme.fg("success", "●") : theme.fg("dim", "○");
909
+ text += `\n ${marker} ${theme.fg("dim", opt.title)}${theme.fg("dim", desc)}`;
910
+ }
911
+ }
912
+ }
913
+
715
914
  return new Text(text, 0, 0);
716
915
  },
717
916
  });
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.1",
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
+ }