pi-ask-user 0.4.0 → 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,14 +10,16 @@ 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
- - Optional timeout for auto-dismiss (fallback input mode)
22
+ - Optional timeout for auto-dismiss in both overlay and fallback input modes
21
23
  - Structured `details` on all results for session state reconstruction
22
24
  - Graceful fallback when interactive UI is unavailable
23
25
  - Bundled `ask-user` skill for mandatory decision-gating in high-stakes or ambiguous tasks
@@ -60,7 +62,7 @@ The registered tool name is:
60
62
  | `options` | `(string \| {title, description?})[]?` | `[]` | Multiple-choice options |
61
63
  | `allowMultiple` | `boolean?` | `false` | Enable multi-select mode |
62
64
  | `allowFreeform` | `boolean?` | `true` | Add a "Type something" freeform option |
63
- | `timeout` | `number?` | — | Auto-dismiss after N ms (applies to fallback input mode) |
65
+ | `timeout` | `number?` | — | Auto-dismiss after N ms and return `null` if the prompt times out |
64
66
 
65
67
  ## Example usage shape
66
68
 
@@ -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 in fallback input mode (when no options are provided)
103
- - Added structured `details` (`AskToolDetails`) to all result paths for session state reconstruction and branching support
104
-
105
- ### 0.2.1
106
-
107
- - Initial public release
99
+ See [CHANGELOG.md](./CHANGELOG.md).
package/index.ts CHANGED
@@ -2,18 +2,24 @@
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 } 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,
21
+ Markdown,
22
+ type MarkdownTheme,
17
23
  matchesKey,
18
24
  Spacer,
19
25
  Text,
@@ -21,12 +27,7 @@ import {
21
27
  truncateToWidth,
22
28
  wrapTextWithAnsi,
23
29
  } from "@mariozechner/pi-tui";
24
- import { renderSingleSelectRows } from "./single-select-layout";
25
-
26
- interface QuestionOption {
27
- title: string;
28
- description?: string;
29
- }
30
+ import { renderSingleSelectRows, type QuestionOption } from "./single-select-layout";
30
31
 
31
32
  type AskOptionInput = QuestionOption | string;
32
33
 
@@ -48,6 +49,11 @@ interface AskToolDetails {
48
49
  wasCustom?: boolean;
49
50
  }
50
51
 
52
+ interface AskUIResult {
53
+ answer: string;
54
+ wasCustom: boolean;
55
+ }
56
+
51
57
  function normalizeOptions(options: AskOptionInput[]): QuestionOption[] {
52
58
  return options
53
59
  .map((option) => {
@@ -71,8 +77,6 @@ function formatOptionsForMessage(options: QuestionOption[]): string {
71
77
  .join("\n");
72
78
  }
73
79
 
74
- const FREEFORM_VALUE = "__freeform__";
75
-
76
80
  function createSelectListTheme(theme: Theme) {
77
81
  return {
78
82
  selectedPrefix: (t: string) => theme.fg("accent", t),
@@ -90,15 +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%";
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 = " │ ";
97
163
 
98
164
  class MultiSelectList implements Component {
99
165
  private options: QuestionOption[];
100
166
  private allowFreeform: boolean;
101
167
  private theme: Theme;
168
+ private keybindings: KeybindingsManager;
102
169
  private selectedIndex = 0;
103
170
  private checked = new Set<number>();
104
171
  private cachedWidth?: number;
@@ -108,10 +175,11 @@ class MultiSelectList implements Component {
108
175
  public onSubmit?: (result: string) => void;
109
176
  public onEnterFreeform?: () => void;
110
177
 
111
- constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme) {
178
+ constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme, keybindings: KeybindingsManager) {
112
179
  this.options = options;
113
180
  this.allowFreeform = allowFreeform;
114
181
  this.theme = theme;
182
+ this.keybindings = keybindings;
115
183
  }
116
184
 
117
185
  invalidate(): void {
@@ -134,7 +202,7 @@ class MultiSelectList implements Component {
134
202
  }
135
203
 
136
204
  handleInput(data: string): void {
137
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
205
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
138
206
  this.onCancel?.();
139
207
  return;
140
208
  }
@@ -145,13 +213,13 @@ class MultiSelectList implements Component {
145
213
  return;
146
214
  }
147
215
 
148
- if (matchesKey(data, Key.up) || matchesKey(data, Key.shift("tab"))) {
216
+ if (this.keybindings.matches(data, "tui.select.up") || matchesKey(data, Key.shift("tab"))) {
149
217
  this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
150
218
  this.invalidate();
151
219
  return;
152
220
  }
153
221
 
154
- if (matchesKey(data, Key.down) || matchesKey(data, Key.tab)) {
222
+ if (this.keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.tab)) {
155
223
  this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
156
224
  this.invalidate();
157
225
  return;
@@ -179,7 +247,7 @@ class MultiSelectList implements Component {
179
247
  return;
180
248
  }
181
249
 
182
- if (matchesKey(data, Key.enter)) {
250
+ if (this.keybindings.matches(data, "tui.select.confirm")) {
183
251
  if (this.isFreeformRow(this.selectedIndex)) {
184
252
  this.onEnterFreeform?.();
185
253
  return;
@@ -268,7 +336,9 @@ class WrappedSingleSelectList implements Component {
268
336
  private options: QuestionOption[];
269
337
  private allowFreeform: boolean;
270
338
  private theme: Theme;
339
+ private keybindings: KeybindingsManager;
271
340
  private selectedIndex = 0;
341
+ private searchQuery = "";
272
342
  private maxVisibleRows = 12;
273
343
  private cachedWidth?: number;
274
344
  private cachedLines?: string[];
@@ -277,10 +347,11 @@ class WrappedSingleSelectList implements Component {
277
347
  public onSubmit?: (result: string) => void;
278
348
  public onEnterFreeform?: () => void;
279
349
 
280
- constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme) {
350
+ constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme, keybindings: KeybindingsManager) {
281
351
  this.options = options;
282
352
  this.allowFreeform = allowFreeform;
283
353
  this.theme = theme;
354
+ this.keybindings = keybindings;
284
355
  }
285
356
 
286
357
  setMaxVisibleRows(rows: number): void {
@@ -296,57 +367,234 @@ class WrappedSingleSelectList implements Component {
296
367
  this.cachedLines = undefined;
297
368
  }
298
369
 
299
- private getItemCount(): number {
300
- return this.options.length + (this.allowFreeform ? 1 : 0);
370
+ private getFilteredOptions(): QuestionOption[] {
371
+ return fuzzyFilter(this.options, this.searchQuery, (option) => `${option.title} ${option.description ?? ""}`);
301
372
  }
302
373
 
303
- private isFreeformRow(index: number): boolean {
304
- 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;
305
540
  }
306
541
 
307
542
  handleInput(data: string): void {
308
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
309
- this.onCancel?.();
543
+ if (this.searchQuery && matchesKey(data, Key.escape)) {
544
+ this.setSearchQuery("");
310
545
  return;
311
546
  }
312
547
 
313
- const count = this.getItemCount();
314
- if (count === 0) {
548
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
315
549
  this.onCancel?.();
316
550
  return;
317
551
  }
318
552
 
319
- 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) {
320
557
  this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
321
558
  this.invalidate();
322
559
  return;
323
560
  }
324
561
 
325
- if (matchesKey(data, Key.down) || matchesKey(data, Key.tab)) {
562
+ if ((this.keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.tab)) && count > 0) {
326
563
  this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
327
564
  this.invalidate();
328
565
  return;
329
566
  }
330
567
 
331
568
  const numMatch = data.match(/^[1-9]$/);
332
- if (numMatch) {
569
+ if (numMatch && count > 0) {
333
570
  const idx = Number.parseInt(numMatch[0], 10) - 1;
334
571
  if (idx >= 0 && idx < count) {
335
572
  this.selectedIndex = idx;
336
573
  this.invalidate();
574
+ return;
337
575
  }
338
- return;
339
576
  }
340
577
 
341
- if (matchesKey(data, Key.enter)) {
342
- if (this.isFreeformRow(this.selectedIndex)) {
578
+ if (this.keybindings.matches(data, "tui.select.confirm") && count > 0) {
579
+ if (this.isFreeformRow(this.selectedIndex, filteredOptions)) {
343
580
  this.onEnterFreeform?.();
344
581
  return;
345
582
  }
346
583
 
347
- const result = this.options[this.selectedIndex]?.title;
584
+ const result = filteredOptions[this.selectedIndex]?.title;
348
585
  if (result) this.onSubmit?.(result);
349
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);
350
598
  }
351
599
  }
352
600
 
@@ -355,37 +603,26 @@ class WrappedSingleSelectList implements Component {
355
603
  return this.cachedLines;
356
604
  }
357
605
 
358
- const count = this.getItemCount();
359
- if (count === 0) {
360
- this.cachedLines = [this.theme.fg("warning", "No options")];
361
- this.cachedWidth = width;
362
- return this.cachedLines;
363
- }
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;
364
609
 
365
- const lines = renderSingleSelectRows({
366
- options: this.options,
367
- selectedIndex: this.selectedIndex,
368
- width,
369
- allowFreeform: this.allowFreeform,
370
- maxRows: this.maxVisibleRows,
371
- }).map((line) => {
372
- const trimmed = line.trim();
373
- let styled = line;
374
-
375
- if (trimmed.startsWith("(")) {
376
- styled = this.theme.fg("dim", line);
377
- } else if (line.startsWith(" ")) {
378
- styled = this.theme.fg("muted", line);
379
- } else if (line.startsWith("→")) {
380
- styled = this.theme.fg("accent", this.theme.bold(line));
381
- } else if (trimmed.startsWith("Type something.")) {
382
- styled = this.theme.fg("text", line);
383
- } else {
384
- styled = this.theme.fg("text", line);
385
- }
610
+ const splitPane = this.getSplitPaneWidths(width);
611
+ let lines: string[];
386
612
 
387
- return truncateToWidth(styled, width, "");
388
- });
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
+ }
389
626
 
390
627
  this.cachedWidth = width;
391
628
  this.cachedLines = lines;
@@ -405,14 +642,15 @@ class AskComponent extends Container {
405
642
  private allowFreeform: boolean;
406
643
  private tui: TUI;
407
644
  private theme: Theme;
408
- private onDone: (result: string | null) => void;
645
+ private keybindings: KeybindingsManager;
646
+ private onDone: (result: AskUIResult | null) => void;
409
647
 
410
648
  private mode: AskMode = "select";
411
649
 
412
650
  // Static layout components
413
651
  private titleText: Text;
414
652
  private questionText: Text;
415
- private contextText?: Text;
653
+ private contextComponent?: Component;
416
654
  private modeContainer: Container;
417
655
  private helpText: Text;
418
656
 
@@ -421,7 +659,7 @@ class AskComponent extends Container {
421
659
  private multiSelectList?: MultiSelectList;
422
660
  private editor?: Editor;
423
661
 
424
- // Focus propagation for IME cursor positioning (Editor is Focusable)
662
+ // Focusable - propagate to Editor for IME cursor positioning
425
663
  private _focused = false;
426
664
  get focused(): boolean {
427
665
  return this._focused;
@@ -429,8 +667,7 @@ class AskComponent extends Container {
429
667
  set focused(value: boolean) {
430
668
  this._focused = value;
431
669
  if (this.editor && this.mode === "freeform") {
432
- const anyEditor = this.editor as unknown as { focused?: boolean };
433
- anyEditor.focused = value;
670
+ (this.editor as any).focused = value;
434
671
  }
435
672
  }
436
673
 
@@ -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);
@@ -468,8 +711,16 @@ class AskComponent extends Container {
468
711
 
469
712
  if (this.context) {
470
713
  this.addChild(new Spacer(1));
471
- this.contextText = new Text("", 1, 0);
472
- this.addChild(this.contextText);
714
+ let mdTheme: MarkdownTheme | undefined;
715
+ try {
716
+ mdTheme = getMarkdownTheme();
717
+ } catch {}
718
+ if (mdTheme) {
719
+ this.contextComponent = new Markdown("", 1, 0, mdTheme);
720
+ } else {
721
+ this.contextComponent = new Text("", 1, 0);
722
+ }
723
+ this.addChild(this.contextComponent);
473
724
  }
474
725
 
475
726
  this.addChild(new Spacer(1));
@@ -482,7 +733,7 @@ class AskComponent extends Container {
482
733
  this.addChild(this.helpText);
483
734
 
484
735
  this.addChild(new Spacer(1));
485
- this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
736
+ this.addChild(new BoxBorderBottom((s: string) => theme.fg("accent", s)));
486
737
 
487
738
  this.updateStaticText();
488
739
  this.showSelectMode();
@@ -495,16 +746,31 @@ class AskComponent extends Container {
495
746
  }
496
747
 
497
748
  override render(width: number): string[] {
749
+ const innerWidth = Math.max(1, width - BOX_BORDER_OVERHEAD);
750
+
498
751
  if (this.mode === "select" && !this.allowMultiple) {
499
752
  const overlayMaxHeight = Math.max(12, Math.floor(this.tui.terminal.rows * ASK_OVERLAY_MAX_HEIGHT_RATIO));
500
- const staticLines = this.countStaticLines(width);
753
+ const staticLines = this.countStaticLines(innerWidth);
501
754
  const availableOptionRows = Math.max(4, overlayMaxHeight - staticLines);
502
755
  this.ensureSingleSelectList().setMaxVisibleRows(availableOptionRows);
503
756
  }
504
757
 
505
- // Defensive: ensure no line exceeds width, otherwise pi-tui will hard-crash.
506
- const lines = super.render(width);
507
- 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
+ });
508
774
  }
509
775
 
510
776
  private countWrappedLines(text: string, width: number): number {
@@ -525,35 +791,70 @@ class AskComponent extends Container {
525
791
  const theme = this.theme;
526
792
  this.titleText.setText(theme.fg("accent", theme.bold("Question")));
527
793
  this.questionText.setText(theme.fg("text", theme.bold(this.question)));
528
- if (this.contextText && this.context) {
529
- this.contextText.setText(`${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", this.context)}`);
794
+ if (this.contextComponent && this.context) {
795
+ if (this.contextComponent instanceof Markdown) {
796
+ (this.contextComponent as Markdown).setText(
797
+ `**Context:**\n${this.context}`,
798
+ );
799
+ } else {
800
+ (this.contextComponent as Text).setText(
801
+ `${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", this.context)}`,
802
+ );
803
+ }
530
804
  }
531
805
  }
532
806
 
533
807
  private updateHelpText(): void {
534
808
  const theme = this.theme;
535
809
  if (this.mode === "freeform") {
536
- this.helpText.setText(
537
- theme.fg(
538
- "dim",
539
- "enter submit shift+enter newline • (ctrl+enter submit if supported) • esc back • ctrl+c cancel",
540
- ),
541
- );
810
+ const alternateCancelKeys = this.keybindings
811
+ .getKeys("tui.select.cancel")
812
+ .filter((key) => key !== "escape" && key !== "esc");
813
+ const hints = [
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(" • ");
821
+ this.helpText.setText(theme.fg("dim", hints));
542
822
  return;
543
823
  }
544
824
 
545
825
  if (this.allowMultiple) {
546
- this.helpText.setText(theme.fg("dim", "↑↓ navigate • space toggle • enter submit • esc cancel"));
826
+ const hints = [
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"),
831
+ ].join(" • ");
832
+ this.helpText.setText(theme.fg("dim", hints));
547
833
  } else {
548
- this.helpText.setText(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"));
834
+ const alternateCancelKeys = this.keybindings
835
+ .getKeys("tui.select.cancel")
836
+ .filter((key) => key !== "escape" && key !== "esc");
837
+ const hints = [
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(" • ");
849
+ this.helpText.setText(theme.fg("dim", hints));
549
850
  }
550
851
  }
551
852
 
552
853
  private ensureSingleSelectList(): WrappedSingleSelectList {
553
854
  if (this.singleSelectList) return this.singleSelectList;
554
855
 
555
- const list = new WrappedSingleSelectList(this.options, this.allowFreeform, this.theme);
556
- 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 });
557
858
  list.onCancel = () => this.onDone(null);
558
859
  list.onEnterFreeform = () => this.showFreeformMode();
559
860
 
@@ -564,9 +865,9 @@ class AskComponent extends Container {
564
865
  private ensureMultiSelectList(): MultiSelectList {
565
866
  if (this.multiSelectList) return this.multiSelectList;
566
867
 
567
- const list = new MultiSelectList(this.options, this.allowFreeform, this.theme);
868
+ const list = new MultiSelectList(this.options, this.allowFreeform, this.theme, this.keybindings);
568
869
  list.onCancel = () => this.onDone(null);
569
- list.onSubmit = (result) => this.onDone(result);
870
+ list.onSubmit = (result) => this.onDone({ answer: result, wasCustom: false });
570
871
  list.onEnterFreeform = () => this.showFreeformMode();
571
872
 
572
873
  this.multiSelectList = list;
@@ -575,15 +876,11 @@ class AskComponent extends Container {
575
876
 
576
877
  private ensureEditor(): Editor {
577
878
  if (this.editor) return this.editor;
578
- // Note: pi's bundled pi-tui Editor expects (tui, theme, options?)
579
879
  const editor = new Editor(this.tui, createEditorTheme(this.theme));
580
- // Default Editor behavior: Enter submits, Shift+Enter inserts newline.
581
- // Ctrl+Enter is only distinguishable in terminals with Kitty protocol mappings,
582
- // so we support it as an *additional* submit shortcut in our wrapper.
583
880
  editor.disableSubmit = false;
584
881
  editor.onSubmit = (text: string) => {
585
882
  const trimmed = text.trim();
586
- this.onDone(trimmed ? trimmed : null);
883
+ this.onDone(trimmed ? { answer: trimmed, wasCustom: true } : null);
587
884
  };
588
885
  this.editor = editor;
589
886
  return editor;
@@ -609,8 +906,7 @@ class AskComponent extends Container {
609
906
  this.modeContainer.clear();
610
907
 
611
908
  const editor = this.ensureEditor();
612
- // Ensure focus is propagated immediately when switching modes.
613
- (editor as unknown as { focused?: boolean }).focused = this._focused;
909
+ (editor as any).focused = this._focused;
614
910
 
615
911
  this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold("Custom response")), 1, 0));
616
912
  this.modeContainer.addChild(new Spacer(1));
@@ -621,12 +917,6 @@ class AskComponent extends Container {
621
917
  this.tui.requestRender();
622
918
  }
623
919
 
624
- private submitFreeform(): void {
625
- const editor = this.ensureEditor();
626
- const text = editor.getText().trim();
627
- this.onDone(text ? text : null);
628
- }
629
-
630
920
  handleInput(data: string): void {
631
921
  if (this.mode === "freeform") {
632
922
  if (matchesKey(data, Key.escape)) {
@@ -634,18 +924,11 @@ class AskComponent extends Container {
634
924
  return;
635
925
  }
636
926
 
637
- if (matchesKey(data, Key.ctrl("c"))) {
927
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
638
928
  this.onDone(null);
639
929
  return;
640
930
  }
641
931
 
642
- // Submit on Ctrl+Enter (only works if terminal distinguishes it, e.g. Kitty protocol)
643
- if (matchesKey(data, Key.ctrl("enter")) || matchesKey(data, "ctrl+enter")) {
644
- this.submitFreeform();
645
- return;
646
- }
647
-
648
- // Let Editor handle everything else (Enter submits, Shift+Enter newline)
649
932
  this.ensureEditor().handleInput(data);
650
933
  this.tui.requestRender();
651
934
  return;
@@ -703,11 +986,11 @@ export default function (pi: ExtensionAPI) {
703
986
  Type.Boolean({ description: "Add a freeform text option. Default: true" }),
704
987
  ),
705
988
  timeout: Type.Optional(
706
- Type.Number({ description: "Auto-dismiss after N milliseconds (applies to fallback input mode when no options are provided)" }),
989
+ Type.Number({ description: "Auto-dismiss after N milliseconds. Returns null (cancelled) when expired." }),
707
990
  ),
708
991
  }),
709
992
 
710
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
993
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
711
994
  if (signal?.aborted) {
712
995
  return {
713
996
  content: [{ type: "text", text: "Cancelled" }],
@@ -754,16 +1037,33 @@ export default function (pi: ExtensionAPI) {
754
1037
  };
755
1038
  }
756
1039
 
1040
+ pi.events.emit("ask:answered", { question, context: normalizedContext, answer, wasCustom: true });
757
1041
  return {
758
1042
  content: [{ type: "text", text: `User answered: ${answer}` }],
759
1043
  details: { question, context: normalizedContext, options, answer, cancelled: false, wasCustom: true } as AskToolDetails,
760
1044
  };
761
1045
  }
762
1046
 
763
- let result: string | null;
1047
+ onUpdate?.({
1048
+ content: [{ type: "text", text: "Waiting for user input..." }],
1049
+ details: { question, context: normalizedContext, options, answer: null, cancelled: false },
1050
+ });
1051
+
1052
+ let result: AskUIResult | null;
764
1053
  try {
765
- result = await ctx.ui.custom<string | null>(
766
- (tui, theme, _kb, done) => {
1054
+ result = await ctx.ui.custom<AskUIResult | null>(
1055
+ (tui, theme, keybindings, done) => {
1056
+ // Wire AbortSignal so agent cancellation auto-dismisses the overlay
1057
+ if (signal) {
1058
+ const onAbort = () => done(null);
1059
+ signal.addEventListener("abort", onAbort, { once: true });
1060
+ }
1061
+
1062
+ // Wire timeout for overlay mode
1063
+ if (timeout && timeout > 0) {
1064
+ setTimeout(() => done(null), timeout);
1065
+ }
1066
+
767
1067
  return new AskComponent(
768
1068
  question,
769
1069
  normalizedContext,
@@ -772,6 +1072,7 @@ export default function (pi: ExtensionAPI) {
772
1072
  allowFreeform,
773
1073
  tui,
774
1074
  theme,
1075
+ keybindings,
775
1076
  done,
776
1077
  );
777
1078
  },
@@ -780,6 +1081,7 @@ export default function (pi: ExtensionAPI) {
780
1081
  overlayOptions: {
781
1082
  anchor: "center",
782
1083
  width: ASK_OVERLAY_WIDTH,
1084
+ minWidth: ASK_OVERLAY_MIN_WIDTH,
783
1085
  maxHeight: "85%",
784
1086
  margin: 1,
785
1087
  },
@@ -796,15 +1098,29 @@ export default function (pi: ExtensionAPI) {
796
1098
  }
797
1099
 
798
1100
  if (result === null) {
1101
+ pi.events.emit("ask:cancelled", { question, context: normalizedContext, options });
799
1102
  return {
800
1103
  content: [{ type: "text", text: "User cancelled the question" }],
801
1104
  details: { question, context: normalizedContext, options, answer: null, cancelled: true } as AskToolDetails,
802
1105
  };
803
1106
  }
804
1107
 
1108
+ pi.events.emit("ask:answered", {
1109
+ question,
1110
+ context: normalizedContext,
1111
+ answer: result.answer,
1112
+ wasCustom: result.wasCustom,
1113
+ });
805
1114
  return {
806
- content: [{ type: "text", text: `User answered: ${result}` }],
807
- 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,
808
1124
  };
809
1125
  },
810
1126
 
@@ -825,26 +1141,55 @@ export default function (pi: ExtensionAPI) {
825
1141
  return new Text(text, 0, 0);
826
1142
  },
827
1143
 
828
- renderResult(result, _options, theme) {
1144
+ renderResult(result, options, theme) {
829
1145
  const details = result.details as (AskToolDetails & { error?: string }) | undefined;
830
1146
 
831
- // Error state
832
1147
  if (details?.error) {
833
1148
  return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0);
834
1149
  }
835
1150
 
836
- // Cancelled / no details
1151
+ if (options.isPartial) {
1152
+ const waitingText = result.content
1153
+ ?.filter((part: { type?: string; text?: string }) => part?.type === "text")
1154
+ .map((part: { text?: string }) => part.text ?? "")
1155
+ .join("\n")
1156
+ .trim() || "Waiting for user input...";
1157
+ return new Text(theme.fg("muted", waitingText), 0, 0);
1158
+ }
1159
+
837
1160
  if (!details || details.cancelled) {
838
1161
  return new Text(theme.fg("warning", "Cancelled"), 0, 0);
839
1162
  }
840
1163
 
841
- // Success
842
1164
  const answer = details.answer ?? "";
843
1165
  let text = theme.fg("success", "✓ ");
844
1166
  if (details.wasCustom) {
845
1167
  text += theme.fg("muted", "(wrote) ");
846
1168
  }
847
1169
  text += theme.fg("accent", answer);
1170
+
1171
+ if (options.expanded) {
1172
+ const selectedTitles = new Set(
1173
+ answer
1174
+ .split(",")
1175
+ .map((value) => value.trim())
1176
+ .filter(Boolean),
1177
+ );
1178
+ text += "\n" + theme.fg("dim", `Q: ${details.question}`);
1179
+ if (details.context) {
1180
+ text += "\n" + theme.fg("dim", details.context);
1181
+ }
1182
+ if (details.options && details.options.length > 0) {
1183
+ text += "\n" + theme.fg("dim", "Options:");
1184
+ for (const opt of details.options) {
1185
+ const desc = opt.description ? ` — ${opt.description}` : "";
1186
+ const isSelected = opt.title === answer || selectedTitles.has(opt.title);
1187
+ const marker = isSelected ? theme.fg("success", "●") : theme.fg("dim", "○");
1188
+ text += `\n ${marker} ${theme.fg("dim", opt.title)}${theme.fg("dim", desc)}`;
1189
+ }
1190
+ }
1191
+ }
1192
+
848
1193
  return new Text(text, 0, 0);
849
1194
  },
850
1195
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-ask-user",
3
- "version": "0.4.0",
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) {