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 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
- - Single-select option lists
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
- ### 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 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 DynamicBorder instead of manual ANSI box drawing.
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 { DynamicBorder, getMarkdownTheme, rawKeyHint } from "@mariozechner/pi-coding-agent";
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 (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
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 (matchesKey(data, Key.up) || matchesKey(data, Key.shift("tab"))) {
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 (matchesKey(data, Key.down) || matchesKey(data, Key.tab)) {
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 (matchesKey(data, Key.enter)) {
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 getItemCount(): number {
301
- return this.options.length + (this.allowFreeform ? 1 : 0);
370
+ private getFilteredOptions(): QuestionOption[] {
371
+ return fuzzyFilter(this.options, this.searchQuery, (option) => `${option.title} ${option.description ?? ""}`);
302
372
  }
303
373
 
304
- private isFreeformRow(index: number): boolean {
305
- return this.allowFreeform && index === this.options.length;
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 (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
310
- this.onCancel?.();
543
+ if (this.searchQuery && matchesKey(data, Key.escape)) {
544
+ this.setSearchQuery("");
311
545
  return;
312
546
  }
313
547
 
314
- const count = this.getItemCount();
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
- if (matchesKey(data, Key.up) || matchesKey(data, Key.shift("tab"))) {
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 (matchesKey(data, Key.down) || matchesKey(data, Key.tab)) {
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 (matchesKey(data, Key.enter)) {
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 = this.options[this.selectedIndex]?.title;
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 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
- }
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 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
- }
610
+ const splitPane = this.getSplitPaneWidths(width);
611
+ let lines: string[];
387
612
 
388
- return truncateToWidth(styled, width, "");
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 onDone: (result: string | null) => void;
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
- onDone: (result: string | null) => void,
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 DynamicBorder((s: string) => theme.fg("accent", s)));
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 DynamicBorder((s: string) => theme.fg("accent", s)));
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(width);
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
- // Defensive: ensure no line exceeds width, otherwise pi-tui will hard-crash.
514
- const lines = super.render(width);
515
- return lines.map((l) => truncateToWidth(l, width, ""));
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
- rawKeyHint("enter", "submit"),
554
- rawKeyHint("shift+enter", "newline"),
555
- rawKeyHint("esc", "back"),
556
- rawKeyHint("ctrl+c", "cancel"),
557
- ].join(" • ");
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
- rawKeyHint("↑↓", "navigate"),
565
- rawKeyHint("space", "toggle"),
566
- rawKeyHint("enter", "submit"),
567
- rawKeyHint("esc", "cancel"),
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
- rawKeyHint("↑↓", "navigate"),
573
- rawKeyHint("enter", "select"),
574
- rawKeyHint("esc", "cancel"),
575
- ].join(" ");
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 (matchesKey(data, Key.ctrl("c"))) {
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: string | null;
1052
+ let result: AskUIResult | null;
787
1053
  try {
788
- result = await ctx.ui.custom<string | null>(
789
- (tui, theme, _kb, done) => {
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", { question, context: normalizedContext, answer: result, wasCustom: false });
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: { question, context: normalizedContext, options, answer: result, cancelled: false } as AskToolDetails,
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.1",
4
- "description": "Interactive ask_user tool for pi-coding-agent with multi-select and freeform input UI",
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",
@@ -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) {