pi-ask-user 0.4.1 → 0.5.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
@@ -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,
@@ -25,10 +29,9 @@ import {
25
29
  } from "@mariozechner/pi-tui";
26
30
  import { renderSingleSelectRows } from "./single-select-layout";
27
31
 
28
- interface QuestionOption {
29
- title: string;
30
- description?: string;
31
- }
32
+ import { createRequire } from "node:module";
33
+ const _require = createRequire(import.meta.url);
34
+ const ASK_USER_VERSION: string = (_require("./package.json") as { version: string }).version;
32
35
 
33
36
  type AskOptionInput = QuestionOption | string;
34
37
 
@@ -50,6 +53,11 @@ interface AskToolDetails {
50
53
  wasCustom?: boolean;
51
54
  }
52
55
 
56
+ interface AskUIResult {
57
+ answer: string;
58
+ wasCustom: boolean;
59
+ }
60
+
53
61
  function normalizeOptions(options: AskOptionInput[]): QuestionOption[] {
54
62
  return options
55
63
  .map((option) => {
@@ -90,16 +98,90 @@ function createEditorTheme(theme: Theme): EditorTheme {
90
98
  };
91
99
  }
92
100
 
101
+ const BOX_BORDER_LEFT = "│ ";
102
+ const BOX_BORDER_RIGHT = " │";
103
+ const BOX_BORDER_OVERHEAD = BOX_BORDER_LEFT.length + BOX_BORDER_RIGHT.length;
104
+
105
+ class BoxBorderTop implements Component {
106
+ private color: (s: string) => string;
107
+ private title?: string;
108
+ private titleColor?: (s: string) => string;
109
+ constructor(color: (s: string) => string, title?: string, titleColor?: (s: string) => string) {
110
+ this.color = color;
111
+ this.title = title;
112
+ this.titleColor = titleColor;
113
+ }
114
+ invalidate(): void {}
115
+ render(width: number): string[] {
116
+ const inner = Math.max(0, width - 2);
117
+ if (!this.title || inner < this.title.length + 4) {
118
+ return [this.color(`╭${"─".repeat(inner)}╮`)];
119
+ }
120
+ const label = ` ${this.title} `;
121
+ const remaining = inner - 1 - label.length;
122
+ const titleStyle = this.titleColor ?? this.color;
123
+ return [
124
+ this.color("╭─") + titleStyle(label) + this.color("─".repeat(Math.max(0, remaining)) + "╮"),
125
+ ];
126
+ }
127
+ }
128
+
129
+ class BoxBorderBottom implements Component {
130
+ private color: (s: string) => string;
131
+ private label?: string;
132
+ private labelColor?: (s: string) => string;
133
+ constructor(color: (s: string) => string, label?: string, labelColor?: (s: string) => string) {
134
+ this.color = color;
135
+ this.label = label;
136
+ this.labelColor = labelColor;
137
+ }
138
+ invalidate(): void {}
139
+ render(width: number): string[] {
140
+ const inner = Math.max(0, width - 2);
141
+ if (!this.label || inner < this.label.length + 4) {
142
+ return [this.color(`╰${"─".repeat(inner)}╯`)];
143
+ }
144
+ const tag = ` ${this.label} `;
145
+ const leftDashes = inner - tag.length - 1;
146
+ const style = this.labelColor ?? this.color;
147
+ return [
148
+ this.color("╰" + "─".repeat(Math.max(0, leftDashes))) + style(tag) + this.color("─╯"),
149
+ ];
150
+ }
151
+ }
152
+
153
+ function formatKeyList(keys: string[]): string {
154
+ return keys.join("/");
155
+ }
156
+
157
+ function keybindingHint(
158
+ theme: Theme,
159
+ keybindings: KeybindingsManager,
160
+ keybinding: Keybinding,
161
+ description: string,
162
+ ): string {
163
+ return `${theme.fg("dim", formatKeyList(keybindings.getKeys(keybinding)))}${theme.fg("muted", ` ${description}`)}`;
164
+ }
165
+
166
+ function literalHint(theme: Theme, key: string, description: string): string {
167
+ return `${theme.fg("dim", key)}${theme.fg("muted", ` ${description}`)}`;
168
+ }
169
+
93
170
  type AskMode = "select" | "freeform";
94
171
 
95
172
  const ASK_OVERLAY_MAX_HEIGHT_RATIO = 0.85;
96
173
  const ASK_OVERLAY_WIDTH = "92%";
97
174
  const ASK_OVERLAY_MIN_WIDTH = 40;
175
+ const SINGLE_SELECT_SPLIT_PANE_MIN_WIDTH = 84;
176
+ const SINGLE_SELECT_SPLIT_PANE_LEFT_MIN_WIDTH = 32;
177
+ const SINGLE_SELECT_SPLIT_PANE_RIGHT_MIN_WIDTH = 28;
178
+ const SINGLE_SELECT_SPLIT_PANE_SEPARATOR = " │ ";
98
179
 
99
180
  class MultiSelectList implements Component {
100
181
  private options: QuestionOption[];
101
182
  private allowFreeform: boolean;
102
183
  private theme: Theme;
184
+ private keybindings: KeybindingsManager;
103
185
  private selectedIndex = 0;
104
186
  private checked = new Set<number>();
105
187
  private cachedWidth?: number;
@@ -109,10 +191,11 @@ class MultiSelectList implements Component {
109
191
  public onSubmit?: (result: string) => void;
110
192
  public onEnterFreeform?: () => void;
111
193
 
112
- constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme) {
194
+ constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme, keybindings: KeybindingsManager) {
113
195
  this.options = options;
114
196
  this.allowFreeform = allowFreeform;
115
197
  this.theme = theme;
198
+ this.keybindings = keybindings;
116
199
  }
117
200
 
118
201
  invalidate(): void {
@@ -135,7 +218,7 @@ class MultiSelectList implements Component {
135
218
  }
136
219
 
137
220
  handleInput(data: string): void {
138
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
221
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
139
222
  this.onCancel?.();
140
223
  return;
141
224
  }
@@ -146,13 +229,13 @@ class MultiSelectList implements Component {
146
229
  return;
147
230
  }
148
231
 
149
- if (matchesKey(data, Key.up) || matchesKey(data, Key.shift("tab"))) {
232
+ if (this.keybindings.matches(data, "tui.select.up") || matchesKey(data, Key.shift("tab"))) {
150
233
  this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
151
234
  this.invalidate();
152
235
  return;
153
236
  }
154
237
 
155
- if (matchesKey(data, Key.down) || matchesKey(data, Key.tab)) {
238
+ if (this.keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.tab)) {
156
239
  this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
157
240
  this.invalidate();
158
241
  return;
@@ -180,7 +263,7 @@ class MultiSelectList implements Component {
180
263
  return;
181
264
  }
182
265
 
183
- if (matchesKey(data, Key.enter)) {
266
+ if (this.keybindings.matches(data, "tui.select.confirm")) {
184
267
  if (this.isFreeformRow(this.selectedIndex)) {
185
268
  this.onEnterFreeform?.();
186
269
  return;
@@ -269,7 +352,9 @@ class WrappedSingleSelectList implements Component {
269
352
  private options: QuestionOption[];
270
353
  private allowFreeform: boolean;
271
354
  private theme: Theme;
355
+ private keybindings: KeybindingsManager;
272
356
  private selectedIndex = 0;
357
+ private searchQuery = "";
273
358
  private maxVisibleRows = 12;
274
359
  private cachedWidth?: number;
275
360
  private cachedLines?: string[];
@@ -278,10 +363,11 @@ class WrappedSingleSelectList implements Component {
278
363
  public onSubmit?: (result: string) => void;
279
364
  public onEnterFreeform?: () => void;
280
365
 
281
- constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme) {
366
+ constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme, keybindings: KeybindingsManager) {
282
367
  this.options = options;
283
368
  this.allowFreeform = allowFreeform;
284
369
  this.theme = theme;
370
+ this.keybindings = keybindings;
285
371
  }
286
372
 
287
373
  setMaxVisibleRows(rows: number): void {
@@ -297,57 +383,234 @@ class WrappedSingleSelectList implements Component {
297
383
  this.cachedLines = undefined;
298
384
  }
299
385
 
300
- private getItemCount(): number {
301
- return this.options.length + (this.allowFreeform ? 1 : 0);
386
+ private getFilteredOptions(): QuestionOption[] {
387
+ return fuzzyFilter(this.options, this.searchQuery, (option) => `${option.title} ${option.description ?? ""}`);
302
388
  }
303
389
 
304
- private isFreeformRow(index: number): boolean {
305
- return this.allowFreeform && index === this.options.length;
390
+ private getItemCount(filteredOptions: QuestionOption[]): number {
391
+ return filteredOptions.length + (this.allowFreeform ? 1 : 0);
392
+ }
393
+
394
+ private isFreeformRow(index: number, filteredOptions: QuestionOption[]): boolean {
395
+ return this.allowFreeform && index === filteredOptions.length;
396
+ }
397
+
398
+ private setSearchQuery(query: string): void {
399
+ this.searchQuery = query;
400
+ this.selectedIndex = 0;
401
+ this.invalidate();
402
+ }
403
+
404
+ private popSearchCharacter(): void {
405
+ if (!this.searchQuery) return;
406
+ const characters = [...this.searchQuery];
407
+ characters.pop();
408
+ this.setSearchQuery(characters.join(""));
409
+ }
410
+
411
+ private getPrintableInput(data: string): string | null {
412
+ const kittyPrintable = decodeKittyPrintable(data);
413
+ if (kittyPrintable !== undefined) return kittyPrintable;
414
+
415
+ const characters = [...data];
416
+ if (characters.length !== 1) return null;
417
+
418
+ const [character] = characters;
419
+ if (!character) return null;
420
+
421
+ const code = character.charCodeAt(0);
422
+ if (code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
423
+ return null;
424
+ }
425
+
426
+ return character;
427
+ }
428
+
429
+ private styleListLine(line: string, width: number): string {
430
+ const trimmed = line.trim();
431
+
432
+ if (trimmed.startsWith("(")) {
433
+ return truncateToWidth(this.theme.fg("dim", line), width, "");
434
+ }
435
+
436
+ if (line.startsWith(" ")) {
437
+ return truncateToWidth(this.theme.fg("muted", line), width, "");
438
+ }
439
+
440
+ if (line.startsWith("→")) {
441
+ return truncateToWidth(this.theme.fg("accent", this.theme.bold(line)), width, "");
442
+ }
443
+
444
+ return truncateToWidth(this.theme.fg("text", line), width, "");
445
+ }
446
+
447
+ private getSplitPaneWidths(width: number): { left: number; right: number } | null {
448
+ if (width < SINGLE_SELECT_SPLIT_PANE_MIN_WIDTH) return null;
449
+
450
+ const availableWidth = width - SINGLE_SELECT_SPLIT_PANE_SEPARATOR.length;
451
+ if (availableWidth < SINGLE_SELECT_SPLIT_PANE_LEFT_MIN_WIDTH + SINGLE_SELECT_SPLIT_PANE_RIGHT_MIN_WIDTH) {
452
+ return null;
453
+ }
454
+
455
+ const preferredLeftWidth = Math.floor(availableWidth * 0.42);
456
+ const left = Math.max(
457
+ SINGLE_SELECT_SPLIT_PANE_LEFT_MIN_WIDTH,
458
+ Math.min(preferredLeftWidth, availableWidth - SINGLE_SELECT_SPLIT_PANE_RIGHT_MIN_WIDTH),
459
+ );
460
+ const right = availableWidth - left;
461
+
462
+ if (right < SINGLE_SELECT_SPLIT_PANE_RIGHT_MIN_WIDTH) return null;
463
+ return { left, right };
464
+ }
465
+
466
+ private buildListLines(width: number, filteredOptions: QuestionOption[], hideDescriptions = false): string[] {
467
+ const lines: string[] = [];
468
+ const count = this.getItemCount(filteredOptions);
469
+ const searchValue = this.searchQuery ? this.theme.fg("text", this.searchQuery) : this.theme.fg("dim", "type to filter");
470
+ lines.push(truncateToWidth(`${this.theme.fg("accent", "Filter:")} ${searchValue}`, width, ""));
471
+
472
+ if (this.searchQuery && filteredOptions.length === 0) {
473
+ lines.push(truncateToWidth(this.theme.fg("warning", "No matching options"), width, ""));
474
+ }
475
+
476
+ if (count === 0) {
477
+ if (!this.searchQuery) {
478
+ lines.push(truncateToWidth(this.theme.fg("warning", "No options"), width, ""));
479
+ }
480
+ return lines.slice(0, this.maxVisibleRows);
481
+ }
482
+
483
+ const maxRows = Math.max(1, this.maxVisibleRows - lines.length);
484
+ const optionLines = renderSingleSelectRows({
485
+ options: filteredOptions,
486
+ selectedIndex: this.selectedIndex,
487
+ width,
488
+ allowFreeform: this.allowFreeform,
489
+ maxRows,
490
+ hideDescriptions,
491
+ }).map((line) => this.styleListLine(line, width));
492
+
493
+ lines.push(...optionLines);
494
+ return lines.slice(0, this.maxVisibleRows);
495
+ }
496
+
497
+ private buildPreviewLines(width: number, filteredOptions: QuestionOption[], maxLines: number): string[] {
498
+ if (maxLines <= 0) return [];
499
+
500
+ let mdTheme: MarkdownTheme | undefined;
501
+ try {
502
+ mdTheme = getMarkdownTheme();
503
+ } catch {}
504
+
505
+ // Build a markdown string for the preview content
506
+ let md = "";
507
+
508
+ if (this.isFreeformRow(this.selectedIndex, filteredOptions)) {
509
+ md += "## Custom response\n\n";
510
+ md += "Open the editor to write **any** answer.\n\n";
511
+ md += "*Use this when none of the listed options fit.*\n";
512
+ if (this.searchQuery) {
513
+ md += `\n> Current filter: \`${this.searchQuery}\`\n`;
514
+ }
515
+ } else {
516
+ const selected = filteredOptions[this.selectedIndex];
517
+ if (!selected) {
518
+ md += "*No option selected*\n";
519
+ } else {
520
+ md += `## ${selected.title}\n\n`;
521
+ if (selected.description?.trim()) {
522
+ md += `${selected.description}\n`;
523
+ } else {
524
+ md += "*No additional details provided for this option.*\n";
525
+ }
526
+ md += `\n---\n\nPress \`Enter\` to select this option.\n`;
527
+ if (this.searchQuery) {
528
+ md += `\n> Filter: \`${this.searchQuery}\`\n`;
529
+ }
530
+ }
531
+ }
532
+
533
+ // Render via Markdown component if theme is available, otherwise fall back to plain text
534
+ let lines: string[];
535
+ if (mdTheme) {
536
+ const mdComponent = new Markdown(md.trim(), 0, 0, mdTheme);
537
+ lines = mdComponent.render(width);
538
+ } else {
539
+ lines = [];
540
+ for (const line of wrapTextWithAnsi(md.trim(), Math.max(10, width))) {
541
+ lines.push(truncateToWidth(line, width, ""));
542
+ }
543
+ }
544
+
545
+ // Trim trailing blanks
546
+ while (lines.length > 0 && lines[lines.length - 1]?.trim() === "") {
547
+ lines.pop();
548
+ }
549
+
550
+ if (lines.length <= maxLines) return lines;
551
+ if (maxLines === 1) return [truncateToWidth(this.theme.fg("dim", "…"), width, "")];
552
+
553
+ const visibleLines = lines.slice(0, maxLines - 1);
554
+ visibleLines.push(truncateToWidth(this.theme.fg("dim", "…"), width, ""));
555
+ return visibleLines;
306
556
  }
307
557
 
308
558
  handleInput(data: string): void {
309
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
310
- this.onCancel?.();
559
+ if (this.searchQuery && matchesKey(data, Key.escape)) {
560
+ this.setSearchQuery("");
311
561
  return;
312
562
  }
313
563
 
314
- const count = this.getItemCount();
315
- if (count === 0) {
564
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
316
565
  this.onCancel?.();
317
566
  return;
318
567
  }
319
568
 
320
- if (matchesKey(data, Key.up) || matchesKey(data, Key.shift("tab"))) {
569
+ const filteredOptions = this.getFilteredOptions();
570
+ const count = this.getItemCount(filteredOptions);
571
+
572
+ if ((this.keybindings.matches(data, "tui.select.up") || matchesKey(data, Key.shift("tab"))) && count > 0) {
321
573
  this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
322
574
  this.invalidate();
323
575
  return;
324
576
  }
325
577
 
326
- if (matchesKey(data, Key.down) || matchesKey(data, Key.tab)) {
578
+ if ((this.keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.tab)) && count > 0) {
327
579
  this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
328
580
  this.invalidate();
329
581
  return;
330
582
  }
331
583
 
332
584
  const numMatch = data.match(/^[1-9]$/);
333
- if (numMatch) {
585
+ if (numMatch && count > 0) {
334
586
  const idx = Number.parseInt(numMatch[0], 10) - 1;
335
587
  if (idx >= 0 && idx < count) {
336
588
  this.selectedIndex = idx;
337
589
  this.invalidate();
590
+ return;
338
591
  }
339
- return;
340
592
  }
341
593
 
342
- if (matchesKey(data, Key.enter)) {
343
- if (this.isFreeformRow(this.selectedIndex)) {
594
+ if (this.keybindings.matches(data, "tui.select.confirm") && count > 0) {
595
+ if (this.isFreeformRow(this.selectedIndex, filteredOptions)) {
344
596
  this.onEnterFreeform?.();
345
597
  return;
346
598
  }
347
599
 
348
- const result = this.options[this.selectedIndex]?.title;
600
+ const result = filteredOptions[this.selectedIndex]?.title;
349
601
  if (result) this.onSubmit?.(result);
350
602
  else this.onCancel?.();
603
+ return;
604
+ }
605
+
606
+ if (this.keybindings.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, Key.backspace)) {
607
+ this.popSearchCharacter();
608
+ return;
609
+ }
610
+
611
+ const printableInput = this.getPrintableInput(data);
612
+ if (printableInput) {
613
+ this.setSearchQuery(this.searchQuery + printableInput);
351
614
  }
352
615
  }
353
616
 
@@ -356,37 +619,26 @@ class WrappedSingleSelectList implements Component {
356
619
  return this.cachedLines;
357
620
  }
358
621
 
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
- }
622
+ const filteredOptions = this.getFilteredOptions();
623
+ const count = this.getItemCount(filteredOptions);
624
+ this.selectedIndex = count > 0 ? Math.max(0, Math.min(this.selectedIndex, count - 1)) : 0;
365
625
 
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
- }
626
+ const splitPane = this.getSplitPaneWidths(width);
627
+ let lines: string[];
387
628
 
388
- return truncateToWidth(styled, width, "");
389
- });
629
+ if (!splitPane) {
630
+ lines = this.buildListLines(width, filteredOptions);
631
+ } else {
632
+ const listLines = this.buildListLines(splitPane.left, filteredOptions, true);
633
+ const previewLines = this.buildPreviewLines(splitPane.right, filteredOptions, this.maxVisibleRows);
634
+ const rowCount = Math.min(this.maxVisibleRows, Math.max(listLines.length, previewLines.length));
635
+ const separator = this.theme.fg("dim", SINGLE_SELECT_SPLIT_PANE_SEPARATOR);
636
+ lines = Array.from({ length: rowCount }, (_, index) => {
637
+ const left = truncateToWidth(listLines[index] ?? "", splitPane.left, "", true);
638
+ const right = truncateToWidth(previewLines[index] ?? "", splitPane.right, "");
639
+ return `${left}${separator}${right}`;
640
+ });
641
+ }
390
642
 
391
643
  this.cachedWidth = width;
392
644
  this.cachedLines = lines;
@@ -406,7 +658,8 @@ class AskComponent extends Container {
406
658
  private allowFreeform: boolean;
407
659
  private tui: TUI;
408
660
  private theme: Theme;
409
- private onDone: (result: string | null) => void;
661
+ private keybindings: KeybindingsManager;
662
+ private onDone: (result: AskUIResult | null) => void;
410
663
 
411
664
  private mode: AskMode = "select";
412
665
 
@@ -442,7 +695,8 @@ class AskComponent extends Container {
442
695
  allowFreeform: boolean,
443
696
  tui: TUI,
444
697
  theme: Theme,
445
- onDone: (result: string | null) => void,
698
+ keybindings: KeybindingsManager,
699
+ onDone: (result: AskUIResult | null) => void,
446
700
  ) {
447
701
  super();
448
702
 
@@ -453,10 +707,15 @@ class AskComponent extends Container {
453
707
  this.allowFreeform = allowFreeform;
454
708
  this.tui = tui;
455
709
  this.theme = theme;
710
+ this.keybindings = keybindings;
456
711
  this.onDone = onDone;
457
712
 
458
713
  // Layout skeleton
459
- this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
714
+ this.addChild(new BoxBorderTop(
715
+ (s: string) => theme.fg("accent", s),
716
+ "ask_user",
717
+ (s: string) => theme.fg("dim", theme.bold(s)),
718
+ ));
460
719
  this.addChild(new Spacer(1));
461
720
 
462
721
  this.titleText = new Text("", 1, 0);
@@ -490,7 +749,11 @@ class AskComponent extends Container {
490
749
  this.addChild(this.helpText);
491
750
 
492
751
  this.addChild(new Spacer(1));
493
- this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
752
+ this.addChild(new BoxBorderBottom(
753
+ (s: string) => theme.fg("accent", s),
754
+ `v${ASK_USER_VERSION}`,
755
+ (s: string) => theme.fg("dim", s),
756
+ ));
494
757
 
495
758
  this.updateStaticText();
496
759
  this.showSelectMode();
@@ -503,16 +766,31 @@ class AskComponent extends Container {
503
766
  }
504
767
 
505
768
  override render(width: number): string[] {
769
+ const innerWidth = Math.max(1, width - BOX_BORDER_OVERHEAD);
770
+
506
771
  if (this.mode === "select" && !this.allowMultiple) {
507
772
  const overlayMaxHeight = Math.max(12, Math.floor(this.tui.terminal.rows * ASK_OVERLAY_MAX_HEIGHT_RATIO));
508
- const staticLines = this.countStaticLines(width);
773
+ const staticLines = this.countStaticLines(innerWidth);
509
774
  const availableOptionRows = Math.max(4, overlayMaxHeight - staticLines);
510
775
  this.ensureSingleSelectList().setMaxVisibleRows(availableOptionRows);
511
776
  }
512
777
 
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, ""));
778
+ // Render children at the inner width (excluding side border characters)
779
+ const rawLines = super.render(innerWidth);
780
+
781
+ // First and last lines are the top/bottom box borders — pass through at full width.
782
+ // All inner lines get wrapped with side borders.
783
+ const borderColor = (s: string) => this.theme.fg("accent", s);
784
+ const titleColor = (s: string) => this.theme.fg("dim", this.theme.bold(s));
785
+ return rawLines.map((line, index) => {
786
+ if (index === 0 || index === rawLines.length - 1) {
787
+ // Box top/bottom borders already rendered at innerWidth — re-render at full width
788
+ if (index === 0) return new BoxBorderTop(borderColor, "ask_user", titleColor).render(width)[0];
789
+ return new BoxBorderBottom(borderColor, `v${ASK_USER_VERSION}`, (s: string) => this.theme.fg("dim", s)).render(width)[0];
790
+ }
791
+ const padded = truncateToWidth(line, innerWidth, "", true);
792
+ return `${borderColor(BOX_BORDER_LEFT)}${padded}${borderColor(BOX_BORDER_RIGHT)}`;
793
+ });
516
794
  }
517
795
 
518
796
  private countWrappedLines(text: string, width: number): number {
@@ -549,30 +827,45 @@ class AskComponent extends Container {
549
827
  private updateHelpText(): void {
550
828
  const theme = this.theme;
551
829
  if (this.mode === "freeform") {
830
+ const alternateCancelKeys = this.keybindings
831
+ .getKeys("tui.select.cancel")
832
+ .filter((key) => key !== "escape" && key !== "esc");
552
833
  const hints = [
553
- rawKeyHint("enter", "submit"),
554
- rawKeyHint("shift+enter", "newline"),
555
- rawKeyHint("esc", "back"),
556
- rawKeyHint("ctrl+c", "cancel"),
557
- ].join(" • ");
834
+ keybindingHint(theme, this.keybindings, "tui.input.submit", "submit"),
835
+ keybindingHint(theme, this.keybindings, "tui.input.newLine", "newline"),
836
+ literalHint(theme, "esc", "back"),
837
+ alternateCancelKeys.length > 0 ? literalHint(theme, formatKeyList(alternateCancelKeys), "cancel") : null,
838
+ ]
839
+ .filter((hint): hint is string => !!hint)
840
+ .join(" • ");
558
841
  this.helpText.setText(theme.fg("dim", hints));
559
842
  return;
560
843
  }
561
844
 
562
845
  if (this.allowMultiple) {
563
846
  const hints = [
564
- rawKeyHint("↑↓", "navigate"),
565
- rawKeyHint("space", "toggle"),
566
- rawKeyHint("enter", "submit"),
567
- rawKeyHint("esc", "cancel"),
847
+ literalHint(theme, "↑↓", "navigate"),
848
+ literalHint(theme, "space", "toggle"),
849
+ keybindingHint(theme, this.keybindings, "tui.select.confirm", "submit"),
850
+ keybindingHint(theme, this.keybindings, "tui.select.cancel", "cancel"),
568
851
  ].join(" • ");
569
852
  this.helpText.setText(theme.fg("dim", hints));
570
853
  } else {
854
+ const alternateCancelKeys = this.keybindings
855
+ .getKeys("tui.select.cancel")
856
+ .filter((key) => key !== "escape" && key !== "esc");
571
857
  const hints = [
572
- rawKeyHint("↑↓", "navigate"),
573
- rawKeyHint("enter", "select"),
574
- rawKeyHint("esc", "cancel"),
575
- ].join(" ");
858
+ literalHint(theme, "type", "filter"),
859
+ keybindingHint(theme, this.keybindings, "tui.editor.deleteCharBackward", "erase"),
860
+ literalHint(theme, "↑↓", "navigate"),
861
+ keybindingHint(theme, this.keybindings, "tui.select.confirm", "select"),
862
+ literalHint(theme, "esc", "clear/cancel"),
863
+ alternateCancelKeys.length > 0
864
+ ? literalHint(theme, formatKeyList(alternateCancelKeys), "cancel")
865
+ : null,
866
+ ]
867
+ .filter((hint): hint is string => !!hint)
868
+ .join(" • ");
576
869
  this.helpText.setText(theme.fg("dim", hints));
577
870
  }
578
871
  }
@@ -580,8 +873,8 @@ class AskComponent extends Container {
580
873
  private ensureSingleSelectList(): WrappedSingleSelectList {
581
874
  if (this.singleSelectList) return this.singleSelectList;
582
875
 
583
- const list = new WrappedSingleSelectList(this.options, this.allowFreeform, this.theme);
584
- list.onSubmit = (result) => this.onDone(result);
876
+ const list = new WrappedSingleSelectList(this.options, this.allowFreeform, this.theme, this.keybindings);
877
+ list.onSubmit = (result) => this.onDone({ answer: result, wasCustom: false });
585
878
  list.onCancel = () => this.onDone(null);
586
879
  list.onEnterFreeform = () => this.showFreeformMode();
587
880
 
@@ -592,9 +885,9 @@ class AskComponent extends Container {
592
885
  private ensureMultiSelectList(): MultiSelectList {
593
886
  if (this.multiSelectList) return this.multiSelectList;
594
887
 
595
- const list = new MultiSelectList(this.options, this.allowFreeform, this.theme);
888
+ const list = new MultiSelectList(this.options, this.allowFreeform, this.theme, this.keybindings);
596
889
  list.onCancel = () => this.onDone(null);
597
- list.onSubmit = (result) => this.onDone(result);
890
+ list.onSubmit = (result) => this.onDone({ answer: result, wasCustom: false });
598
891
  list.onEnterFreeform = () => this.showFreeformMode();
599
892
 
600
893
  this.multiSelectList = list;
@@ -603,11 +896,11 @@ class AskComponent extends Container {
603
896
 
604
897
  private ensureEditor(): Editor {
605
898
  if (this.editor) return this.editor;
606
- const editor = new Editor(createEditorTheme(this.theme));
899
+ const editor = new Editor(this.tui, createEditorTheme(this.theme));
607
900
  editor.disableSubmit = false;
608
901
  editor.onSubmit = (text: string) => {
609
902
  const trimmed = text.trim();
610
- this.onDone(trimmed ? trimmed : null);
903
+ this.onDone(trimmed ? { answer: trimmed, wasCustom: true } : null);
611
904
  };
612
905
  this.editor = editor;
613
906
  return editor;
@@ -651,18 +944,11 @@ class AskComponent extends Container {
651
944
  return;
652
945
  }
653
946
 
654
- if (matchesKey(data, Key.ctrl("c"))) {
947
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
655
948
  this.onDone(null);
656
949
  return;
657
950
  }
658
951
 
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
952
  this.ensureEditor().handleInput(data);
667
953
  this.tui.requestRender();
668
954
  return;
@@ -685,11 +971,11 @@ export default function (pi: ExtensionAPI) {
685
971
  name: "ask_user",
686
972
  label: "Ask User",
687
973
  description:
688
- "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.",
974
+ "Ask the user a question with optional multiple-choice answers. Use this to gather information interactively. Before calling, gather context with tools (read/web/ref) and pass a short summary via the context field.",
689
975
  promptSnippet:
690
976
  "Ask the user a question with optional multiple-choice answers to gather information interactively",
691
977
  promptGuidelines: [
692
- "Before calling ask_user, gather context with tools (read/exa/ref) and pass a short summary via the context field.",
978
+ "Before calling ask_user, gather context with tools (read/web/ref) and pass a short summary via the context field.",
693
979
  "Use ask_user when the user's intent is ambiguous, when a decision requires explicit user input, or when multiple valid options exist.",
694
980
  ],
695
981
  parameters: Type.Object({
@@ -783,10 +1069,10 @@ export default function (pi: ExtensionAPI) {
783
1069
  details: { question, context: normalizedContext, options, answer: null, cancelled: false },
784
1070
  });
785
1071
 
786
- let result: string | null;
1072
+ let result: AskUIResult | null;
787
1073
  try {
788
- result = await ctx.ui.custom<string | null>(
789
- (tui, theme, _kb, done) => {
1074
+ result = await ctx.ui.custom<AskUIResult | null>(
1075
+ (tui, theme, keybindings, done) => {
790
1076
  // Wire AbortSignal so agent cancellation auto-dismisses the overlay
791
1077
  if (signal) {
792
1078
  const onAbort = () => done(null);
@@ -806,6 +1092,7 @@ export default function (pi: ExtensionAPI) {
806
1092
  allowFreeform,
807
1093
  tui,
808
1094
  theme,
1095
+ keybindings,
809
1096
  done,
810
1097
  );
811
1098
  },
@@ -838,10 +1125,22 @@ export default function (pi: ExtensionAPI) {
838
1125
  };
839
1126
  }
840
1127
 
841
- pi.events.emit("ask:answered", { question, context: normalizedContext, answer: result, wasCustom: false });
1128
+ pi.events.emit("ask:answered", {
1129
+ question,
1130
+ context: normalizedContext,
1131
+ answer: result.answer,
1132
+ wasCustom: result.wasCustom,
1133
+ });
842
1134
  return {
843
- content: [{ type: "text", text: `User answered: ${result}` }],
844
- details: { question, context: normalizedContext, options, answer: result, cancelled: false } as AskToolDetails,
1135
+ content: [{ type: "text", text: `User answered: ${result.answer}` }],
1136
+ details: {
1137
+ question,
1138
+ context: normalizedContext,
1139
+ options,
1140
+ answer: result.answer,
1141
+ cancelled: false,
1142
+ wasCustom: result.wasCustom,
1143
+ } as AskToolDetails,
845
1144
  };
846
1145
  },
847
1146
 
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.1",
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) {