pi-ask-user 0.5.1 → 0.5.2

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/index.ts CHANGED
@@ -9,23 +9,23 @@ import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
9
9
  import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
10
10
  import { Type } from "@sinclair/typebox";
11
11
  import {
12
- Container,
13
- type Component,
14
- decodeKittyPrintable,
15
- Editor,
16
- type EditorTheme,
17
- fuzzyFilter,
18
- Key,
19
- type Keybinding,
20
- type KeybindingsManager,
21
- Markdown,
22
- type MarkdownTheme,
23
- matchesKey,
24
- Spacer,
25
- Text,
26
- type TUI,
27
- truncateToWidth,
28
- wrapTextWithAnsi,
12
+ Container,
13
+ type Component,
14
+ decodeKittyPrintable,
15
+ Editor,
16
+ type EditorTheme,
17
+ fuzzyFilter,
18
+ Key,
19
+ type Keybinding,
20
+ type KeybindingsManager,
21
+ Markdown,
22
+ type MarkdownTheme,
23
+ matchesKey,
24
+ Spacer,
25
+ Text,
26
+ type TUI,
27
+ truncateToWidth,
28
+ wrapTextWithAnsi,
29
29
  } from "@mariozechner/pi-tui";
30
30
  import { renderSingleSelectRows } from "./single-select-layout";
31
31
 
@@ -36,66 +36,66 @@ const ASK_USER_VERSION: string = (_require("./package.json") as { version: strin
36
36
  type AskOptionInput = QuestionOption | string;
37
37
 
38
38
  interface AskParams {
39
- question: string;
40
- context?: string;
41
- options?: AskOptionInput[];
42
- allowMultiple?: boolean;
43
- allowFreeform?: boolean;
44
- timeout?: number;
39
+ question: string;
40
+ context?: string;
41
+ options?: AskOptionInput[];
42
+ allowMultiple?: boolean;
43
+ allowFreeform?: boolean;
44
+ timeout?: number;
45
45
  }
46
46
 
47
47
  interface AskToolDetails {
48
- question: string;
49
- context?: string;
50
- options: QuestionOption[];
51
- answer: string | null;
52
- cancelled: boolean;
53
- wasCustom?: boolean;
48
+ question: string;
49
+ context?: string;
50
+ options: QuestionOption[];
51
+ answer: string | null;
52
+ cancelled: boolean;
53
+ wasCustom?: boolean;
54
54
  }
55
55
 
56
56
  interface AskUIResult {
57
- answer: string;
58
- wasCustom: boolean;
57
+ answer: string;
58
+ wasCustom: boolean;
59
59
  }
60
60
 
61
61
  function normalizeOptions(options: AskOptionInput[]): QuestionOption[] {
62
- return options
63
- .map((option) => {
64
- if (typeof option === "string") {
65
- return { title: option };
66
- }
67
- if (option && typeof option === "object" && typeof option.title === "string") {
68
- return { title: option.title, description: option.description };
69
- }
70
- return null;
71
- })
72
- .filter((option): option is QuestionOption => option !== null);
62
+ return options
63
+ .map((option) => {
64
+ if (typeof option === "string") {
65
+ return { title: option };
66
+ }
67
+ if (option && typeof option === "object" && typeof option.title === "string") {
68
+ return { title: option.title, description: option.description };
69
+ }
70
+ return null;
71
+ })
72
+ .filter((option): option is QuestionOption => option !== null);
73
73
  }
74
74
 
75
75
  function formatOptionsForMessage(options: QuestionOption[]): string {
76
- return options
77
- .map((option, index) => {
78
- const desc = option.description ? ` — ${option.description}` : "";
79
- return `${index + 1}. ${option.title}${desc}`;
80
- })
81
- .join("\n");
76
+ return options
77
+ .map((option, index) => {
78
+ const desc = option.description ? ` — ${option.description}` : "";
79
+ return `${index + 1}. ${option.title}${desc}`;
80
+ })
81
+ .join("\n");
82
82
  }
83
83
 
84
84
  function createSelectListTheme(theme: Theme) {
85
- return {
86
- selectedPrefix: (t: string) => theme.fg("accent", t),
87
- selectedText: (t: string) => theme.fg("accent", t),
88
- description: (t: string) => theme.fg("muted", t),
89
- scrollInfo: (t: string) => theme.fg("dim", t),
90
- noMatch: (t: string) => theme.fg("warning", t),
91
- };
85
+ return {
86
+ selectedPrefix: (t: string) => theme.fg("accent", t),
87
+ selectedText: (t: string) => theme.fg("accent", t),
88
+ description: (t: string) => theme.fg("muted", t),
89
+ scrollInfo: (t: string) => theme.fg("dim", t),
90
+ noMatch: (t: string) => theme.fg("warning", t),
91
+ };
92
92
  }
93
93
 
94
94
  function createEditorTheme(theme: Theme): EditorTheme {
95
- return {
96
- borderColor: (s: string) => theme.fg("accent", s),
97
- selectList: createSelectListTheme(theme),
98
- };
95
+ return {
96
+ borderColor: (s: string) => theme.fg("accent", s),
97
+ selectList: createSelectListTheme(theme),
98
+ };
99
99
  }
100
100
 
101
101
  const BOX_BORDER_LEFT = "│ ";
@@ -103,68 +103,68 @@ const BOX_BORDER_RIGHT = " │";
103
103
  const BOX_BORDER_OVERHEAD = BOX_BORDER_LEFT.length + BOX_BORDER_RIGHT.length;
104
104
 
105
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
- }
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
127
  }
128
128
 
129
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
- }
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
151
  }
152
152
 
153
153
  function formatKeyList(keys: string[]): string {
154
- return keys.join("/");
154
+ return keys.join("/");
155
155
  }
156
156
 
157
157
  function keybindingHint(
158
- theme: Theme,
159
- keybindings: KeybindingsManager,
160
- keybinding: Keybinding,
161
- description: string,
158
+ theme: Theme,
159
+ keybindings: KeybindingsManager,
160
+ keybinding: Keybinding,
161
+ description: string,
162
162
  ): string {
163
- return `${theme.fg("dim", formatKeyList(keybindings.getKeys(keybinding)))}${theme.fg("muted", ` ${description}`)}`;
163
+ return `${theme.fg("dim", formatKeyList(keybindings.getKeys(keybinding)))}${theme.fg("muted", ` ${description}`)}`;
164
164
  }
165
165
 
166
166
  function literalHint(theme: Theme, key: string, description: string): string {
167
- return `${theme.fg("dim", key)}${theme.fg("muted", ` ${description}`)}`;
167
+ return `${theme.fg("dim", key)}${theme.fg("muted", ` ${description}`)}`;
168
168
  }
169
169
 
170
170
  type AskMode = "select" | "freeform";
@@ -176,474 +176,480 @@ const SINGLE_SELECT_SPLIT_PANE_MIN_WIDTH = 84;
176
176
  const SINGLE_SELECT_SPLIT_PANE_LEFT_MIN_WIDTH = 32;
177
177
  const SINGLE_SELECT_SPLIT_PANE_RIGHT_MIN_WIDTH = 28;
178
178
  const SINGLE_SELECT_SPLIT_PANE_SEPARATOR = " │ ";
179
+ const FREEFORM_SENTINEL = "\u270f\ufe0f Type custom response...";
179
180
 
180
181
  class MultiSelectList implements Component {
181
- private options: QuestionOption[];
182
- private allowFreeform: boolean;
183
- private theme: Theme;
184
- private keybindings: KeybindingsManager;
185
- private selectedIndex = 0;
186
- private checked = new Set<number>();
187
- private cachedWidth?: number;
188
- private cachedLines?: string[];
189
-
190
- public onCancel?: () => void;
191
- public onSubmit?: (result: string) => void;
192
- public onEnterFreeform?: () => void;
193
-
194
- constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme, keybindings: KeybindingsManager) {
195
- this.options = options;
196
- this.allowFreeform = allowFreeform;
197
- this.theme = theme;
198
- this.keybindings = keybindings;
199
- }
200
-
201
- invalidate(): void {
202
- this.cachedWidth = undefined;
203
- this.cachedLines = undefined;
204
- }
205
-
206
- private getItemCount(): number {
207
- return this.options.length + (this.allowFreeform ? 1 : 0);
208
- }
209
-
210
- private isFreeformRow(index: number): boolean {
211
- return this.allowFreeform && index === this.options.length;
212
- }
213
-
214
- private toggle(index: number): void {
215
- if (index < 0 || index >= this.options.length) return;
216
- if (this.checked.has(index)) this.checked.delete(index);
217
- else this.checked.add(index);
218
- }
219
-
220
- handleInput(data: string): void {
221
- if (this.keybindings.matches(data, "tui.select.cancel")) {
222
- this.onCancel?.();
223
- return;
224
- }
225
-
226
- const count = this.getItemCount();
227
- if (count === 0) {
228
- this.onCancel?.();
229
- return;
230
- }
231
-
232
- if (this.keybindings.matches(data, "tui.select.up") || matchesKey(data, Key.shift("tab"))) {
233
- this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
234
- this.invalidate();
235
- return;
236
- }
237
-
238
- if (this.keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.tab)) {
239
- this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
240
- this.invalidate();
241
- return;
242
- }
243
-
244
- // Number keys (1-9) toggle checkboxes for normal items
245
- const numMatch = data.match(/^[1-9]$/);
246
- if (numMatch) {
247
- const idx = Number.parseInt(numMatch[0], 10) - 1;
248
- if (idx >= 0 && idx < this.options.length) {
249
- this.toggle(idx);
250
- this.selectedIndex = Math.min(idx, count - 1);
251
- this.invalidate();
252
- }
253
- return;
254
- }
255
-
256
- if (matchesKey(data, Key.space)) {
257
- if (this.isFreeformRow(this.selectedIndex)) {
258
- this.onEnterFreeform?.();
259
- return;
260
- }
261
- this.toggle(this.selectedIndex);
262
- this.invalidate();
263
- return;
264
- }
265
-
266
- if (this.keybindings.matches(data, "tui.select.confirm")) {
267
- if (this.isFreeformRow(this.selectedIndex)) {
268
- this.onEnterFreeform?.();
269
- return;
270
- }
271
-
272
- const selectedTitles = Array.from(this.checked)
273
- .sort((a, b) => a - b)
274
- .map((i) => this.options[i]?.title)
275
- .filter((t): t is string => !!t);
276
-
277
- // If nothing checked, fall back to current row
278
- const fallback = this.options[this.selectedIndex]?.title;
279
- const result = selectedTitles.length > 0 ? selectedTitles.join(", ") : fallback;
280
-
281
- if (result) this.onSubmit?.(result);
282
- else this.onCancel?.();
283
- }
284
- }
285
-
286
- render(width: number): string[] {
287
- if (this.cachedLines && this.cachedWidth === width) {
288
- return this.cachedLines;
289
- }
290
-
291
- const theme = this.theme;
292
- const count = this.getItemCount();
293
- const maxVisible = Math.min(count, 10);
294
-
295
- if (count === 0) {
296
- this.cachedLines = [theme.fg("warning", "No options")];
297
- this.cachedWidth = width;
298
- return this.cachedLines;
299
- }
300
-
301
- const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), count - maxVisible));
302
- const endIndex = Math.min(startIndex + maxVisible, count);
303
-
304
- const lines: string[] = [];
305
-
306
- for (let i = startIndex; i < endIndex; i++) {
307
- const isSelected = i === this.selectedIndex;
308
- const prefix = isSelected ? theme.fg("accent", "→") : " ";
309
-
310
- if (this.isFreeformRow(i)) {
311
- const label = theme.fg("text", theme.bold("Type something."));
312
- const desc = theme.fg("muted", "Enter a custom response");
313
- const line = `${prefix} ${label} ${theme.fg("dim", "—")} ${desc}`;
314
- lines.push(truncateToWidth(line, width, ""));
315
- continue;
316
- }
317
-
318
- const option = this.options[i];
319
- if (!option) continue;
320
-
321
- const checkbox = this.checked.has(i) ? theme.fg("success", "[✓]") : theme.fg("dim", "[ ]");
322
- const num = theme.fg("dim", `${i + 1}.`);
323
- const title = isSelected
324
- ? theme.fg("accent", theme.bold(option.title))
325
- : theme.fg("text", theme.bold(option.title));
326
-
327
- const firstLine = `${prefix} ${num} ${checkbox} ${title}`;
328
- lines.push(truncateToWidth(firstLine, width, ""));
329
-
330
- if (option.description) {
331
- const indent = " ";
332
- const wrapWidth = Math.max(10, width - indent.length);
333
- const wrapped = wrapTextWithAnsi(option.description, wrapWidth);
334
- for (const w of wrapped) {
335
- lines.push(truncateToWidth(indent + theme.fg("muted", w), width, ""));
336
- }
337
- }
338
- }
339
-
340
- // Scroll indicator
341
- if (startIndex > 0 || endIndex < count) {
342
- lines.push(theme.fg("dim", truncateToWidth(` (${this.selectedIndex + 1}/${count})`, width, "")));
343
- }
344
-
345
- this.cachedWidth = width;
346
- this.cachedLines = lines;
347
- return lines;
348
- }
182
+ private options: QuestionOption[];
183
+ private allowFreeform: boolean;
184
+ private theme: Theme;
185
+ private keybindings: KeybindingsManager;
186
+ private selectedIndex = 0;
187
+ private checked = new Set<number>();
188
+ private cachedWidth?: number;
189
+ private cachedLines?: string[];
190
+
191
+ public onCancel?: () => void;
192
+ public onSubmit?: (result: string) => void;
193
+ public onEnterFreeform?: () => void;
194
+
195
+ constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme, keybindings: KeybindingsManager) {
196
+ this.options = options;
197
+ this.allowFreeform = allowFreeform;
198
+ this.theme = theme;
199
+ this.keybindings = keybindings;
200
+ }
201
+
202
+ invalidate(): void {
203
+ this.cachedWidth = undefined;
204
+ this.cachedLines = undefined;
205
+ }
206
+
207
+ private getItemCount(): number {
208
+ return this.options.length + (this.allowFreeform ? 1 : 0);
209
+ }
210
+
211
+ private isFreeformRow(index: number): boolean {
212
+ return this.allowFreeform && index === this.options.length;
213
+ }
214
+
215
+ private toggle(index: number): void {
216
+ if (index < 0 || index >= this.options.length) return;
217
+ if (this.checked.has(index)) this.checked.delete(index);
218
+ else this.checked.add(index);
219
+ }
220
+
221
+ handleInput(data: string): void {
222
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
223
+ this.onCancel?.();
224
+ return;
225
+ }
226
+
227
+ const count = this.getItemCount();
228
+ if (count === 0) {
229
+ this.onCancel?.();
230
+ return;
231
+ }
232
+
233
+ if (this.keybindings.matches(data, "tui.select.up") || matchesKey(data, Key.shift("tab"))) {
234
+ this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
235
+ this.invalidate();
236
+ return;
237
+ }
238
+
239
+ if (this.keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.tab)) {
240
+ this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
241
+ this.invalidate();
242
+ return;
243
+ }
244
+
245
+ // Number keys (1-9) toggle checkboxes for normal items
246
+ const numMatch = data.match(/^[1-9]$/);
247
+ if (numMatch) {
248
+ const idx = Number.parseInt(numMatch[0], 10) - 1;
249
+ if (idx >= 0 && idx < this.options.length) {
250
+ this.toggle(idx);
251
+ this.selectedIndex = Math.min(idx, count - 1);
252
+ this.invalidate();
253
+ }
254
+ return;
255
+ }
256
+
257
+ if (matchesKey(data, Key.space)) {
258
+ if (this.isFreeformRow(this.selectedIndex)) {
259
+ this.onEnterFreeform?.();
260
+ return;
261
+ }
262
+ this.toggle(this.selectedIndex);
263
+ this.invalidate();
264
+ return;
265
+ }
266
+
267
+ if (this.keybindings.matches(data, "tui.select.confirm")) {
268
+ if (this.isFreeformRow(this.selectedIndex)) {
269
+ this.onEnterFreeform?.();
270
+ return;
271
+ }
272
+
273
+ const selectedTitles = Array.from(this.checked)
274
+ .sort((a, b) => a - b)
275
+ .map((i) => this.options[i]?.title)
276
+ .filter((t): t is string => !!t);
277
+
278
+ // If nothing checked, fall back to current row
279
+ const fallback = this.options[this.selectedIndex]?.title;
280
+ const result = selectedTitles.length > 0 ? selectedTitles.join(", ") : fallback;
281
+
282
+ if (result) this.onSubmit?.(result);
283
+ else this.onCancel?.();
284
+ }
285
+ }
286
+
287
+ render(width: number): string[] {
288
+ if (this.cachedLines && this.cachedWidth === width) {
289
+ return this.cachedLines;
290
+ }
291
+
292
+ const theme = this.theme;
293
+ const count = this.getItemCount();
294
+ const maxVisible = Math.min(count, 10);
295
+
296
+ if (count === 0) {
297
+ this.cachedLines = [theme.fg("warning", "No options")];
298
+ this.cachedWidth = width;
299
+ return this.cachedLines;
300
+ }
301
+
302
+ const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), count - maxVisible));
303
+ const endIndex = Math.min(startIndex + maxVisible, count);
304
+
305
+ const lines: string[] = [];
306
+
307
+ for (let i = startIndex; i < endIndex; i++) {
308
+ const isSelected = i === this.selectedIndex;
309
+ const prefix = isSelected ? theme.fg("accent", "→") : " ";
310
+
311
+ if (this.isFreeformRow(i)) {
312
+ const label = theme.fg("text", theme.bold("Type something."));
313
+ const desc = theme.fg("muted", "Enter a custom response");
314
+ const line = `${prefix} ${label} ${theme.fg("dim", "—")} ${desc}`;
315
+ lines.push(truncateToWidth(line, width, ""));
316
+ continue;
317
+ }
318
+
319
+ const option = this.options[i];
320
+ if (!option) continue;
321
+
322
+ const checkbox = this.checked.has(i) ? theme.fg("success", "[✓]") : theme.fg("dim", "[ ]");
323
+ const num = theme.fg("dim", `${i + 1}.`);
324
+ const title = isSelected
325
+ ? theme.fg("accent", theme.bold(option.title))
326
+ : theme.fg("text", theme.bold(option.title));
327
+
328
+ const firstLine = `${prefix} ${num} ${checkbox} ${title}`;
329
+ lines.push(truncateToWidth(firstLine, width, ""));
330
+
331
+ if (option.description) {
332
+ const indent = " ";
333
+ const wrapWidth = Math.max(10, width - indent.length);
334
+ const wrapped = wrapTextWithAnsi(option.description, wrapWidth);
335
+ for (const w of wrapped) {
336
+ lines.push(truncateToWidth(indent + theme.fg("muted", w), width, ""));
337
+ }
338
+ }
339
+ }
340
+
341
+ // Scroll indicator
342
+ if (startIndex > 0 || endIndex < count) {
343
+ lines.push(theme.fg("dim", truncateToWidth(` (${this.selectedIndex + 1}/${count})`, width, "")));
344
+ }
345
+
346
+ this.cachedWidth = width;
347
+ this.cachedLines = lines;
348
+ return lines;
349
+ }
349
350
  }
350
351
 
351
352
  class WrappedSingleSelectList implements Component {
352
- private options: QuestionOption[];
353
- private allowFreeform: boolean;
354
- private theme: Theme;
355
- private keybindings: KeybindingsManager;
356
- private selectedIndex = 0;
357
- private searchQuery = "";
358
- private maxVisibleRows = 12;
359
- private cachedWidth?: number;
360
- private cachedLines?: string[];
361
-
362
- public onCancel?: () => void;
363
- public onSubmit?: (result: string) => void;
364
- public onEnterFreeform?: () => void;
365
-
366
- constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme, keybindings: KeybindingsManager) {
367
- this.options = options;
368
- this.allowFreeform = allowFreeform;
369
- this.theme = theme;
370
- this.keybindings = keybindings;
371
- }
372
-
373
- setMaxVisibleRows(rows: number): void {
374
- const next = Math.max(1, Math.floor(rows));
375
- if (next !== this.maxVisibleRows) {
376
- this.maxVisibleRows = next;
377
- this.invalidate();
378
- }
379
- }
380
-
381
- invalidate(): void {
382
- this.cachedWidth = undefined;
383
- this.cachedLines = undefined;
384
- }
385
-
386
- private getFilteredOptions(): QuestionOption[] {
387
- return fuzzyFilter(this.options, this.searchQuery, (option) => `${option.title} ${option.description ?? ""}`);
388
- }
389
-
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;
556
- }
557
-
558
- handleInput(data: string): void {
559
- if (this.searchQuery && matchesKey(data, Key.escape)) {
560
- this.setSearchQuery("");
561
- return;
562
- }
563
-
564
- if (this.keybindings.matches(data, "tui.select.cancel")) {
565
- this.onCancel?.();
566
- return;
567
- }
568
-
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) {
573
- this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
574
- this.invalidate();
575
- return;
576
- }
577
-
578
- if ((this.keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.tab)) && count > 0) {
579
- this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
580
- this.invalidate();
581
- return;
582
- }
583
-
584
- const numMatch = data.match(/^[1-9]$/);
585
- if (numMatch && count > 0) {
586
- const idx = Number.parseInt(numMatch[0], 10) - 1;
587
- if (idx >= 0 && idx < count) {
588
- this.selectedIndex = idx;
589
- this.invalidate();
590
- return;
591
- }
592
- }
593
-
594
- if (this.keybindings.matches(data, "tui.select.confirm") && count > 0) {
595
- if (this.isFreeformRow(this.selectedIndex, filteredOptions)) {
596
- this.onEnterFreeform?.();
597
- return;
598
- }
599
-
600
- const result = filteredOptions[this.selectedIndex]?.title;
601
- if (result) this.onSubmit?.(result);
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);
614
- }
615
- }
616
-
617
- render(width: number): string[] {
618
- if (this.cachedLines && this.cachedWidth === width) {
619
- return this.cachedLines;
620
- }
621
-
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;
625
-
626
- const splitPane = this.getSplitPaneWidths(width);
627
- let lines: string[];
628
-
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
- }
642
-
643
- this.cachedWidth = width;
644
- this.cachedLines = lines;
645
- return lines;
646
- }
353
+ private options: QuestionOption[];
354
+ private allowFreeform: boolean;
355
+ private theme: Theme;
356
+ private keybindings: KeybindingsManager;
357
+ private selectedIndex = 0;
358
+ private searchQuery = "";
359
+ private maxVisibleRows = 12;
360
+ private cachedWidth?: number;
361
+ private cachedLines?: string[];
362
+
363
+ public onCancel?: () => void;
364
+ public onSubmit?: (result: string) => void;
365
+ public onEnterFreeform?: () => void;
366
+
367
+ constructor(options: QuestionOption[], allowFreeform: boolean, theme: Theme, keybindings: KeybindingsManager) {
368
+ this.options = options;
369
+ this.allowFreeform = allowFreeform;
370
+ this.theme = theme;
371
+ this.keybindings = keybindings;
372
+ }
373
+
374
+ setMaxVisibleRows(rows: number): void {
375
+ const next = Math.max(1, Math.floor(rows));
376
+ if (next !== this.maxVisibleRows) {
377
+ this.maxVisibleRows = next;
378
+ this.invalidate();
379
+ }
380
+ }
381
+
382
+ invalidate(): void {
383
+ this.cachedWidth = undefined;
384
+ this.cachedLines = undefined;
385
+ }
386
+
387
+ private getFilteredOptions(): QuestionOption[] {
388
+ return fuzzyFilter(this.options, this.searchQuery, (option) => `${option.title} ${option.description ?? ""}`);
389
+ }
390
+
391
+ private getItemCount(filteredOptions: QuestionOption[]): number {
392
+ return filteredOptions.length + (this.allowFreeform ? 1 : 0);
393
+ }
394
+
395
+ private isFreeformRow(index: number, filteredOptions: QuestionOption[]): boolean {
396
+ return this.allowFreeform && index === filteredOptions.length;
397
+ }
398
+
399
+ private setSearchQuery(query: string): void {
400
+ this.searchQuery = query;
401
+ this.selectedIndex = 0;
402
+ this.invalidate();
403
+ }
404
+
405
+ private popSearchCharacter(): void {
406
+ if (!this.searchQuery) return;
407
+ const characters = [...this.searchQuery];
408
+ characters.pop();
409
+ this.setSearchQuery(characters.join(""));
410
+ }
411
+
412
+ private getPrintableInput(data: string): string | null {
413
+ const kittyPrintable = decodeKittyPrintable(data);
414
+ if (kittyPrintable !== undefined) return kittyPrintable;
415
+
416
+ const characters = [...data];
417
+ if (characters.length !== 1) return null;
418
+
419
+ const [character] = characters;
420
+ if (!character) return null;
421
+
422
+ const code = character.charCodeAt(0);
423
+ if (code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
424
+ return null;
425
+ }
426
+
427
+ return character;
428
+ }
429
+
430
+ private styleListLine(line: string, width: number, isSelected: boolean): string {
431
+ const trimmed = line.trim();
432
+
433
+ if (trimmed.startsWith("(")) {
434
+ return truncateToWidth(this.theme.fg("dim", line), width, "");
435
+ }
436
+
437
+ if (isSelected) {
438
+ return truncateToWidth(this.theme.fg("accent", this.theme.bold(line)), width, "");
439
+ }
440
+
441
+ if (line.startsWith(" ")) {
442
+ return truncateToWidth(this.theme.fg("muted", line), width, "");
443
+ }
444
+
445
+ if (line.startsWith("")) {
446
+ return truncateToWidth(this.theme.fg("accent", this.theme.bold(line)), width, "");
447
+ }
448
+
449
+ return truncateToWidth(this.theme.fg("text", line), width, "");
450
+ }
451
+
452
+ private getSplitPaneWidths(width: number): { left: number; right: number } | null {
453
+ if (width < SINGLE_SELECT_SPLIT_PANE_MIN_WIDTH) return null;
454
+
455
+ const availableWidth = width - SINGLE_SELECT_SPLIT_PANE_SEPARATOR.length;
456
+ if (availableWidth < SINGLE_SELECT_SPLIT_PANE_LEFT_MIN_WIDTH + SINGLE_SELECT_SPLIT_PANE_RIGHT_MIN_WIDTH) {
457
+ return null;
458
+ }
459
+
460
+ const preferredLeftWidth = Math.floor(availableWidth * 0.42);
461
+ const left = Math.max(
462
+ SINGLE_SELECT_SPLIT_PANE_LEFT_MIN_WIDTH,
463
+ Math.min(preferredLeftWidth, availableWidth - SINGLE_SELECT_SPLIT_PANE_RIGHT_MIN_WIDTH),
464
+ );
465
+ const right = availableWidth - left;
466
+
467
+ if (right < SINGLE_SELECT_SPLIT_PANE_RIGHT_MIN_WIDTH) return null;
468
+ return { left, right };
469
+ }
470
+
471
+ private buildListLines(width: number, filteredOptions: QuestionOption[], hideDescriptions = false): string[] {
472
+ const lines: string[] = [];
473
+ const count = this.getItemCount(filteredOptions);
474
+ const searchValue = this.searchQuery ? this.theme.fg("text", this.searchQuery) : this.theme.fg("dim", "type to filter");
475
+ lines.push(truncateToWidth(`${this.theme.fg("accent", "Filter:")} ${searchValue}`, width, ""));
476
+
477
+ if (this.searchQuery && filteredOptions.length === 0) {
478
+ lines.push(truncateToWidth(this.theme.fg("warning", "No matching options"), width, ""));
479
+ }
480
+
481
+ if (count === 0) {
482
+ if (!this.searchQuery) {
483
+ lines.push(truncateToWidth(this.theme.fg("warning", "No options"), width, ""));
484
+ }
485
+ return lines.slice(0, this.maxVisibleRows);
486
+ }
487
+
488
+ const maxRows = Math.max(1, this.maxVisibleRows - lines.length);
489
+ const optionRows = renderSingleSelectRows({
490
+ options: filteredOptions,
491
+ selectedIndex: this.selectedIndex,
492
+ width,
493
+ allowFreeform: this.allowFreeform,
494
+ maxRows,
495
+ hideDescriptions,
496
+ });
497
+ const optionLines = optionRows.map((row) => this.styleListLine(row.line, width, row.selected));
498
+
499
+ lines.push(...optionLines);
500
+ return lines.slice(0, this.maxVisibleRows);
501
+ }
502
+
503
+ private buildPreviewLines(width: number, filteredOptions: QuestionOption[], maxLines: number): string[] {
504
+ if (maxLines <= 0) return [];
505
+
506
+ let mdTheme: MarkdownTheme | undefined;
507
+ try {
508
+ mdTheme = getMarkdownTheme();
509
+ } catch { }
510
+
511
+ // Build a markdown string for the preview content
512
+ let md = "";
513
+
514
+ if (this.isFreeformRow(this.selectedIndex, filteredOptions)) {
515
+ md += "## Custom response\n\n";
516
+ md += "Open the editor to write **any** answer.\n\n";
517
+ md += "*Use this when none of the listed options fit.*\n";
518
+ if (this.searchQuery) {
519
+ md += `\n> Current filter: \`${this.searchQuery}\`\n`;
520
+ }
521
+ } else {
522
+ const selected = filteredOptions[this.selectedIndex];
523
+ if (!selected) {
524
+ md += "*No option selected*\n";
525
+ } else {
526
+ md += `## ${selected.title}\n\n`;
527
+ if (selected.description?.trim()) {
528
+ md += `${selected.description}\n`;
529
+ } else {
530
+ md += "*No additional details provided for this option.*\n";
531
+ }
532
+ md += `\n---\n\nPress \`Enter\` to select this option.\n`;
533
+ if (this.searchQuery) {
534
+ md += `\n> Filter: \`${this.searchQuery}\`\n`;
535
+ }
536
+ }
537
+ }
538
+
539
+ // Render via Markdown component if theme is available, otherwise fall back to plain text
540
+ let lines: string[];
541
+ if (mdTheme) {
542
+ const mdComponent = new Markdown(md.trim(), 0, 0, mdTheme);
543
+ lines = mdComponent.render(width);
544
+ } else {
545
+ lines = [];
546
+ for (const line of wrapTextWithAnsi(md.trim(), Math.max(10, width))) {
547
+ lines.push(truncateToWidth(line, width, ""));
548
+ }
549
+ }
550
+
551
+ // Trim trailing blanks
552
+ while (lines.length > 0 && lines[lines.length - 1]?.trim() === "") {
553
+ lines.pop();
554
+ }
555
+
556
+ if (lines.length <= maxLines) return lines;
557
+ if (maxLines === 1) return [truncateToWidth(this.theme.fg("dim", "…"), width, "")];
558
+
559
+ const visibleLines = lines.slice(0, maxLines - 1);
560
+ visibleLines.push(truncateToWidth(this.theme.fg("dim", "…"), width, ""));
561
+ return visibleLines;
562
+ }
563
+
564
+ handleInput(data: string): void {
565
+ if (this.searchQuery && matchesKey(data, Key.escape)) {
566
+ this.setSearchQuery("");
567
+ return;
568
+ }
569
+
570
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
571
+ this.onCancel?.();
572
+ return;
573
+ }
574
+
575
+ const filteredOptions = this.getFilteredOptions();
576
+ const count = this.getItemCount(filteredOptions);
577
+
578
+ if ((this.keybindings.matches(data, "tui.select.up") || matchesKey(data, Key.shift("tab"))) && count > 0) {
579
+ this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
580
+ this.invalidate();
581
+ return;
582
+ }
583
+
584
+ if ((this.keybindings.matches(data, "tui.select.down") || matchesKey(data, Key.tab)) && count > 0) {
585
+ this.selectedIndex = this.selectedIndex === count - 1 ? 0 : this.selectedIndex + 1;
586
+ this.invalidate();
587
+ return;
588
+ }
589
+
590
+ const numMatch = data.match(/^[1-9]$/);
591
+ if (numMatch && count > 0) {
592
+ const idx = Number.parseInt(numMatch[0], 10) - 1;
593
+ if (idx >= 0 && idx < count) {
594
+ this.selectedIndex = idx;
595
+ this.invalidate();
596
+ return;
597
+ }
598
+ }
599
+
600
+ if (this.keybindings.matches(data, "tui.select.confirm") && count > 0) {
601
+ if (this.isFreeformRow(this.selectedIndex, filteredOptions)) {
602
+ this.onEnterFreeform?.();
603
+ return;
604
+ }
605
+
606
+ const result = filteredOptions[this.selectedIndex]?.title;
607
+ if (result) this.onSubmit?.(result);
608
+ else this.onCancel?.();
609
+ return;
610
+ }
611
+
612
+ if (this.keybindings.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, Key.backspace)) {
613
+ this.popSearchCharacter();
614
+ return;
615
+ }
616
+
617
+ const printableInput = this.getPrintableInput(data);
618
+ if (printableInput) {
619
+ this.setSearchQuery(this.searchQuery + printableInput);
620
+ }
621
+ }
622
+
623
+ render(width: number): string[] {
624
+ if (this.cachedLines && this.cachedWidth === width) {
625
+ return this.cachedLines;
626
+ }
627
+
628
+ const filteredOptions = this.getFilteredOptions();
629
+ const count = this.getItemCount(filteredOptions);
630
+ this.selectedIndex = count > 0 ? Math.max(0, Math.min(this.selectedIndex, count - 1)) : 0;
631
+
632
+ const splitPane = this.getSplitPaneWidths(width);
633
+ let lines: string[];
634
+
635
+ if (!splitPane) {
636
+ lines = this.buildListLines(width, filteredOptions);
637
+ } else {
638
+ const listLines = this.buildListLines(splitPane.left, filteredOptions, true);
639
+ const previewLines = this.buildPreviewLines(splitPane.right, filteredOptions, this.maxVisibleRows);
640
+ const rowCount = Math.min(this.maxVisibleRows, Math.max(listLines.length, previewLines.length));
641
+ const separator = this.theme.fg("dim", SINGLE_SELECT_SPLIT_PANE_SEPARATOR);
642
+ lines = Array.from({ length: rowCount }, (_, index) => {
643
+ const left = truncateToWidth(listLines[index] ?? "", splitPane.left, "", true);
644
+ const right = truncateToWidth(previewLines[index] ?? "", splitPane.right, "");
645
+ return `${left}${separator}${right}`;
646
+ });
647
+ }
648
+
649
+ this.cachedWidth = width;
650
+ this.cachedLines = lines;
651
+ return lines;
652
+ }
647
653
  }
648
654
 
649
655
  /**
@@ -651,566 +657,619 @@ class WrappedSingleSelectList implements Component {
651
657
  * component between SelectList/MultiSelectList and an Editor (freeform mode).
652
658
  */
653
659
  class AskComponent extends Container {
654
- private question: string;
655
- private context?: string;
656
- private options: QuestionOption[];
657
- private allowMultiple: boolean;
658
- private allowFreeform: boolean;
659
- private tui: TUI;
660
- private theme: Theme;
661
- private keybindings: KeybindingsManager;
662
- private onDone: (result: AskUIResult | null) => void;
663
-
664
- private mode: AskMode = "select";
665
-
666
- // Static layout components
667
- private titleText: Text;
668
- private questionText: Text;
669
- private contextComponent?: Component;
670
- private modeContainer: Container;
671
- private helpText: Text;
672
-
673
- // Mode components
674
- private singleSelectList?: WrappedSingleSelectList;
675
- private multiSelectList?: MultiSelectList;
676
- private editor?: Editor;
677
-
678
- // Focusable - propagate to Editor for IME cursor positioning
679
- private _focused = false;
680
- get focused(): boolean {
681
- return this._focused;
682
- }
683
- set focused(value: boolean) {
684
- this._focused = value;
685
- if (this.editor && this.mode === "freeform") {
686
- (this.editor as any).focused = value;
687
- }
688
- }
689
-
690
- constructor(
691
- question: string,
692
- context: string | undefined,
693
- options: QuestionOption[],
694
- allowMultiple: boolean,
695
- allowFreeform: boolean,
696
- tui: TUI,
697
- theme: Theme,
698
- keybindings: KeybindingsManager,
699
- onDone: (result: AskUIResult | null) => void,
700
- ) {
701
- super();
702
-
703
- this.question = question;
704
- this.context = context;
705
- this.options = options;
706
- this.allowMultiple = allowMultiple;
707
- this.allowFreeform = allowFreeform;
708
- this.tui = tui;
709
- this.theme = theme;
710
- this.keybindings = keybindings;
711
- this.onDone = onDone;
712
-
713
- // Layout skeleton
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
- ));
719
- this.addChild(new Spacer(1));
720
-
721
- this.titleText = new Text("", 1, 0);
722
- this.addChild(this.titleText);
723
- this.addChild(new Spacer(1));
724
-
725
- this.questionText = new Text("", 1, 0);
726
- this.addChild(this.questionText);
727
-
728
- if (this.context) {
729
- this.addChild(new Spacer(1));
730
- let mdTheme: MarkdownTheme | undefined;
731
- try {
732
- mdTheme = getMarkdownTheme();
733
- } catch {}
734
- if (mdTheme) {
735
- this.contextComponent = new Markdown("", 1, 0, mdTheme);
736
- } else {
737
- this.contextComponent = new Text("", 1, 0);
738
- }
739
- this.addChild(this.contextComponent);
740
- }
741
-
742
- this.addChild(new Spacer(1));
743
-
744
- this.modeContainer = new Container();
745
- this.addChild(this.modeContainer);
746
-
747
- this.addChild(new Spacer(1));
748
- this.helpText = new Text("", 1, 0);
749
- this.addChild(this.helpText);
750
-
751
- this.addChild(new Spacer(1));
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
- ));
757
-
758
- this.updateStaticText();
759
- this.showSelectMode();
760
- }
761
-
762
- override invalidate(): void {
763
- super.invalidate();
764
- this.updateStaticText();
765
- this.updateHelpText();
766
- }
767
-
768
- override render(width: number): string[] {
769
- const innerWidth = Math.max(1, width - BOX_BORDER_OVERHEAD);
770
-
771
- if (this.mode === "select" && !this.allowMultiple) {
772
- const overlayMaxHeight = Math.max(12, Math.floor(this.tui.terminal.rows * ASK_OVERLAY_MAX_HEIGHT_RATIO));
773
- const staticLines = this.countStaticLines(innerWidth);
774
- const availableOptionRows = Math.max(4, overlayMaxHeight - staticLines);
775
- this.ensureSingleSelectList().setMaxVisibleRows(availableOptionRows);
776
- }
777
-
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
- });
794
- }
795
-
796
- private countWrappedLines(text: string, width: number): number {
797
- return Math.max(1, wrapTextWithAnsi(text, Math.max(10, width - 2)).length);
798
- }
799
-
800
- private countStaticLines(width: number): number {
801
- const titleLines = 1;
802
- const questionLines = this.countWrappedLines(this.question, width);
803
- const contextLines = this.context ? 1 + this.countWrappedLines(this.context, width) : 0;
804
- const helpLines = 1;
805
- const borderLines = 2;
806
- const spacerLines = this.context ? 6 : 5;
807
- return borderLines + spacerLines + titleLines + questionLines + contextLines + helpLines;
808
- }
809
-
810
- private updateStaticText(): void {
811
- const theme = this.theme;
812
- this.titleText.setText(theme.fg("accent", theme.bold("Question")));
813
- this.questionText.setText(theme.fg("text", theme.bold(this.question)));
814
- if (this.contextComponent && this.context) {
815
- if (this.contextComponent instanceof Markdown) {
816
- (this.contextComponent as Markdown).setText(
817
- `**Context:**\n${this.context}`,
818
- );
819
- } else {
820
- (this.contextComponent as Text).setText(
821
- `${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", this.context)}`,
822
- );
823
- }
824
- }
825
- }
826
-
827
- private updateHelpText(): void {
828
- const theme = this.theme;
829
- if (this.mode === "freeform") {
830
- const alternateCancelKeys = this.keybindings
831
- .getKeys("tui.select.cancel")
832
- .filter((key) => key !== "escape" && key !== "esc");
833
- const hints = [
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(" • ");
841
- this.helpText.setText(theme.fg("dim", hints));
842
- return;
843
- }
844
-
845
- if (this.allowMultiple) {
846
- const hints = [
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"),
851
- ].join(" • ");
852
- this.helpText.setText(theme.fg("dim", hints));
853
- } else {
854
- const alternateCancelKeys = this.keybindings
855
- .getKeys("tui.select.cancel")
856
- .filter((key) => key !== "escape" && key !== "esc");
857
- const hints = [
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(" • ");
869
- this.helpText.setText(theme.fg("dim", hints));
870
- }
871
- }
872
-
873
- private ensureSingleSelectList(): WrappedSingleSelectList {
874
- if (this.singleSelectList) return this.singleSelectList;
875
-
876
- const list = new WrappedSingleSelectList(this.options, this.allowFreeform, this.theme, this.keybindings);
877
- list.onSubmit = (result) => this.onDone({ answer: result, wasCustom: false });
878
- list.onCancel = () => this.onDone(null);
879
- list.onEnterFreeform = () => this.showFreeformMode();
880
-
881
- this.singleSelectList = list;
882
- return list;
883
- }
884
-
885
- private ensureMultiSelectList(): MultiSelectList {
886
- if (this.multiSelectList) return this.multiSelectList;
887
-
888
- const list = new MultiSelectList(this.options, this.allowFreeform, this.theme, this.keybindings);
889
- list.onCancel = () => this.onDone(null);
890
- list.onSubmit = (result) => this.onDone({ answer: result, wasCustom: false });
891
- list.onEnterFreeform = () => this.showFreeformMode();
892
-
893
- this.multiSelectList = list;
894
- return list;
895
- }
896
-
897
- private ensureEditor(): Editor {
898
- if (this.editor) return this.editor;
899
- const editor = new Editor(this.tui, createEditorTheme(this.theme));
900
- editor.disableSubmit = false;
901
- editor.onSubmit = (text: string) => {
902
- const trimmed = text.trim();
903
- this.onDone(trimmed ? { answer: trimmed, wasCustom: true } : null);
904
- };
905
- this.editor = editor;
906
- return editor;
907
- }
908
-
909
- private showSelectMode(): void {
910
- this.mode = "select";
911
- this.modeContainer.clear();
912
-
913
- if (this.allowMultiple) {
914
- this.modeContainer.addChild(this.ensureMultiSelectList());
915
- } else {
916
- this.modeContainer.addChild(this.ensureSingleSelectList());
917
- }
918
-
919
- this.updateHelpText();
920
- this.invalidate();
921
- this.tui.requestRender();
922
- }
923
-
924
- private showFreeformMode(): void {
925
- this.mode = "freeform";
926
- this.modeContainer.clear();
927
-
928
- const editor = this.ensureEditor();
929
- (editor as any).focused = this._focused;
930
-
931
- this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold("Custom response")), 1, 0));
932
- this.modeContainer.addChild(new Spacer(1));
933
- this.modeContainer.addChild(editor);
934
-
935
- this.updateHelpText();
936
- this.invalidate();
937
- this.tui.requestRender();
938
- }
939
-
940
- handleInput(data: string): void {
941
- if (this.mode === "freeform") {
942
- if (matchesKey(data, Key.escape)) {
943
- this.showSelectMode();
944
- return;
945
- }
946
-
947
- if (this.keybindings.matches(data, "tui.select.cancel")) {
948
- this.onDone(null);
949
- return;
950
- }
951
-
952
- this.ensureEditor().handleInput(data);
953
- this.tui.requestRender();
954
- return;
955
- }
956
-
957
- // Selection mode
958
- if (this.allowMultiple) {
959
- this.ensureMultiSelectList().handleInput?.(data);
960
- this.tui.requestRender();
961
- return;
962
- }
963
-
964
- this.ensureSingleSelectList().handleInput?.(data);
965
- this.tui.requestRender();
966
- }
660
+ private question: string;
661
+ private context?: string;
662
+ private options: QuestionOption[];
663
+ private allowMultiple: boolean;
664
+ private allowFreeform: boolean;
665
+ private tui: TUI;
666
+ private theme: Theme;
667
+ private keybindings: KeybindingsManager;
668
+ private onDone: (result: AskUIResult | null) => void;
669
+
670
+ private mode: AskMode = "select";
671
+
672
+ // Static layout components
673
+ private titleText: Text;
674
+ private questionText: Text;
675
+ private contextComponent?: Component;
676
+ private modeContainer: Container;
677
+ private helpText: Text;
678
+
679
+ // Mode components
680
+ private singleSelectList?: WrappedSingleSelectList;
681
+ private multiSelectList?: MultiSelectList;
682
+ private editor?: Editor;
683
+
684
+ // Focusable - propagate to Editor for IME cursor positioning
685
+ private _focused = false;
686
+ get focused(): boolean {
687
+ return this._focused;
688
+ }
689
+ set focused(value: boolean) {
690
+ this._focused = value;
691
+ if (this.editor && this.mode === "freeform") {
692
+ (this.editor as any).focused = value;
693
+ }
694
+ }
695
+
696
+ constructor(
697
+ question: string,
698
+ context: string | undefined,
699
+ options: QuestionOption[],
700
+ allowMultiple: boolean,
701
+ allowFreeform: boolean,
702
+ tui: TUI,
703
+ theme: Theme,
704
+ keybindings: KeybindingsManager,
705
+ onDone: (result: AskUIResult | null) => void,
706
+ ) {
707
+ super();
708
+
709
+ this.question = question;
710
+ this.context = context;
711
+ this.options = options;
712
+ this.allowMultiple = allowMultiple;
713
+ this.allowFreeform = allowFreeform;
714
+ this.tui = tui;
715
+ this.theme = theme;
716
+ this.keybindings = keybindings;
717
+ this.onDone = onDone;
718
+
719
+ // Layout skeleton
720
+ this.addChild(new BoxBorderTop(
721
+ (s: string) => theme.fg("accent", s),
722
+ "ask_user",
723
+ (s: string) => theme.fg("dim", theme.bold(s)),
724
+ ));
725
+ this.addChild(new Spacer(1));
726
+
727
+ this.titleText = new Text("", 1, 0);
728
+ this.addChild(this.titleText);
729
+ this.addChild(new Spacer(1));
730
+
731
+ this.questionText = new Text("", 1, 0);
732
+ this.addChild(this.questionText);
733
+
734
+ if (this.context) {
735
+ this.addChild(new Spacer(1));
736
+ let mdTheme: MarkdownTheme | undefined;
737
+ try {
738
+ mdTheme = getMarkdownTheme();
739
+ } catch { }
740
+ if (mdTheme) {
741
+ this.contextComponent = new Markdown("", 1, 0, mdTheme);
742
+ } else {
743
+ this.contextComponent = new Text("", 1, 0);
744
+ }
745
+ this.addChild(this.contextComponent);
746
+ }
747
+
748
+ this.addChild(new Spacer(1));
749
+
750
+ this.modeContainer = new Container();
751
+ this.addChild(this.modeContainer);
752
+
753
+ this.addChild(new Spacer(1));
754
+ this.helpText = new Text("", 1, 0);
755
+ this.addChild(this.helpText);
756
+
757
+ this.addChild(new Spacer(1));
758
+ this.addChild(new BoxBorderBottom(
759
+ (s: string) => theme.fg("accent", s),
760
+ `v${ASK_USER_VERSION}`,
761
+ (s: string) => theme.fg("dim", s),
762
+ ));
763
+
764
+ this.updateStaticText();
765
+ this.showSelectMode();
766
+ }
767
+
768
+ override invalidate(): void {
769
+ super.invalidate();
770
+ this.updateStaticText();
771
+ this.updateHelpText();
772
+ }
773
+
774
+ override render(width: number): string[] {
775
+ const innerWidth = Math.max(1, width - BOX_BORDER_OVERHEAD);
776
+
777
+ if (this.mode === "select" && !this.allowMultiple) {
778
+ const overlayMaxHeight = Math.max(12, Math.floor(this.tui.terminal.rows * ASK_OVERLAY_MAX_HEIGHT_RATIO));
779
+ const staticLines = this.countStaticLines(innerWidth);
780
+ const availableOptionRows = Math.max(4, overlayMaxHeight - staticLines);
781
+ this.ensureSingleSelectList().setMaxVisibleRows(availableOptionRows);
782
+ }
783
+
784
+ // Render children at the inner width (excluding side border characters)
785
+ const rawLines = super.render(innerWidth);
786
+
787
+ // First and last lines are the top/bottom box borders — pass through at full width.
788
+ // All inner lines get wrapped with side borders.
789
+ const borderColor = (s: string) => this.theme.fg("accent", s);
790
+ const titleColor = (s: string) => this.theme.fg("dim", this.theme.bold(s));
791
+ return rawLines.map((line, index) => {
792
+ if (index === 0 || index === rawLines.length - 1) {
793
+ // Box top/bottom borders already rendered at innerWidth — re-render at full width
794
+ if (index === 0) return new BoxBorderTop(borderColor, "ask_user", titleColor).render(width)[0];
795
+ return new BoxBorderBottom(borderColor, `v${ASK_USER_VERSION}`, (s: string) => this.theme.fg("dim", s)).render(width)[0];
796
+ }
797
+ const padded = truncateToWidth(line, innerWidth, "", true);
798
+ return `${borderColor(BOX_BORDER_LEFT)}${padded}${borderColor(BOX_BORDER_RIGHT)}`;
799
+ });
800
+ }
801
+
802
+ private countWrappedLines(text: string, width: number): number {
803
+ return Math.max(1, wrapTextWithAnsi(text, Math.max(10, width - 2)).length);
804
+ }
805
+
806
+ private countStaticLines(width: number): number {
807
+ const titleLines = 1;
808
+ const questionLines = this.countWrappedLines(this.question, width);
809
+ const contextLines = this.context ? 1 + this.countWrappedLines(this.context, width) : 0;
810
+ const helpLines = 1;
811
+ const borderLines = 2;
812
+ const spacerLines = this.context ? 6 : 5;
813
+ return borderLines + spacerLines + titleLines + questionLines + contextLines + helpLines;
814
+ }
815
+
816
+ private updateStaticText(): void {
817
+ const theme = this.theme;
818
+ this.titleText.setText(theme.fg("accent", theme.bold("Question")));
819
+ this.questionText.setText(theme.fg("text", theme.bold(this.question)));
820
+ if (this.contextComponent && this.context) {
821
+ if (this.contextComponent instanceof Markdown) {
822
+ (this.contextComponent as Markdown).setText(
823
+ `**Context:**\n${this.context}`,
824
+ );
825
+ } else {
826
+ (this.contextComponent as Text).setText(
827
+ `${theme.fg("accent", theme.bold("Context:"))}\n${theme.fg("dim", this.context)}`,
828
+ );
829
+ }
830
+ }
831
+ }
832
+
833
+ private updateHelpText(): void {
834
+ const theme = this.theme;
835
+ if (this.mode === "freeform") {
836
+ const alternateCancelKeys = this.keybindings
837
+ .getKeys("tui.select.cancel")
838
+ .filter((key) => key !== "escape" && key !== "esc");
839
+ const hints = [
840
+ keybindingHint(theme, this.keybindings, "tui.input.submit", "submit"),
841
+ keybindingHint(theme, this.keybindings, "tui.input.newLine", "newline"),
842
+ literalHint(theme, "esc", "back"),
843
+ alternateCancelKeys.length > 0 ? literalHint(theme, formatKeyList(alternateCancelKeys), "cancel") : null,
844
+ ]
845
+ .filter((hint): hint is string => !!hint)
846
+ .join(" • ");
847
+ this.helpText.setText(theme.fg("dim", hints));
848
+ return;
849
+ }
850
+
851
+ if (this.allowMultiple) {
852
+ const hints = [
853
+ literalHint(theme, "↑↓", "navigate"),
854
+ literalHint(theme, "space", "toggle"),
855
+ keybindingHint(theme, this.keybindings, "tui.select.confirm", "submit"),
856
+ keybindingHint(theme, this.keybindings, "tui.select.cancel", "cancel"),
857
+ ].join(" • ");
858
+ this.helpText.setText(theme.fg("dim", hints));
859
+ } else {
860
+ const alternateCancelKeys = this.keybindings
861
+ .getKeys("tui.select.cancel")
862
+ .filter((key) => key !== "escape" && key !== "esc");
863
+ const hints = [
864
+ literalHint(theme, "type", "filter"),
865
+ keybindingHint(theme, this.keybindings, "tui.editor.deleteCharBackward", "erase"),
866
+ literalHint(theme, "↑↓", "navigate"),
867
+ keybindingHint(theme, this.keybindings, "tui.select.confirm", "select"),
868
+ literalHint(theme, "esc", "clear/cancel"),
869
+ alternateCancelKeys.length > 0
870
+ ? literalHint(theme, formatKeyList(alternateCancelKeys), "cancel")
871
+ : null,
872
+ ]
873
+ .filter((hint): hint is string => !!hint)
874
+ .join(" • ");
875
+ this.helpText.setText(theme.fg("dim", hints));
876
+ }
877
+ }
878
+
879
+ private ensureSingleSelectList(): WrappedSingleSelectList {
880
+ if (this.singleSelectList) return this.singleSelectList;
881
+
882
+ const list = new WrappedSingleSelectList(this.options, this.allowFreeform, this.theme, this.keybindings);
883
+ list.onSubmit = (result) => this.onDone({ answer: result, wasCustom: false });
884
+ list.onCancel = () => this.onDone(null);
885
+ list.onEnterFreeform = () => this.showFreeformMode();
886
+
887
+ this.singleSelectList = list;
888
+ return list;
889
+ }
890
+
891
+ private ensureMultiSelectList(): MultiSelectList {
892
+ if (this.multiSelectList) return this.multiSelectList;
893
+
894
+ const list = new MultiSelectList(this.options, this.allowFreeform, this.theme, this.keybindings);
895
+ list.onCancel = () => this.onDone(null);
896
+ list.onSubmit = (result) => this.onDone({ answer: result, wasCustom: false });
897
+ list.onEnterFreeform = () => this.showFreeformMode();
898
+
899
+ this.multiSelectList = list;
900
+ return list;
901
+ }
902
+
903
+ private ensureEditor(): Editor {
904
+ if (this.editor) return this.editor;
905
+ const editor = new Editor(this.tui, createEditorTheme(this.theme));
906
+ editor.disableSubmit = false;
907
+ editor.onSubmit = (text: string) => {
908
+ const trimmed = text.trim();
909
+ this.onDone(trimmed ? { answer: trimmed, wasCustom: true } : null);
910
+ };
911
+ this.editor = editor;
912
+ return editor;
913
+ }
914
+
915
+ private showSelectMode(): void {
916
+ this.mode = "select";
917
+ this.modeContainer.clear();
918
+
919
+ if (this.allowMultiple) {
920
+ this.modeContainer.addChild(this.ensureMultiSelectList());
921
+ } else {
922
+ this.modeContainer.addChild(this.ensureSingleSelectList());
923
+ }
924
+
925
+ this.updateHelpText();
926
+ this.invalidate();
927
+ this.tui.requestRender();
928
+ }
929
+
930
+ private showFreeformMode(): void {
931
+ this.mode = "freeform";
932
+ this.modeContainer.clear();
933
+
934
+ const editor = this.ensureEditor();
935
+ (editor as any).focused = this._focused;
936
+
937
+ this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold("Custom response")), 1, 0));
938
+ this.modeContainer.addChild(new Spacer(1));
939
+ this.modeContainer.addChild(editor);
940
+
941
+ this.updateHelpText();
942
+ this.invalidate();
943
+ this.tui.requestRender();
944
+ }
945
+
946
+ handleInput(data: string): void {
947
+ if (this.mode === "freeform") {
948
+ if (matchesKey(data, Key.escape)) {
949
+ this.showSelectMode();
950
+ return;
951
+ }
952
+
953
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
954
+ this.onDone(null);
955
+ return;
956
+ }
957
+
958
+ this.ensureEditor().handleInput(data);
959
+ this.tui.requestRender();
960
+ return;
961
+ }
962
+
963
+ // Selection mode
964
+ if (this.allowMultiple) {
965
+ this.ensureMultiSelectList().handleInput?.(data);
966
+ this.tui.requestRender();
967
+ return;
968
+ }
969
+
970
+ this.ensureSingleSelectList().handleInput?.(data);
971
+ this.tui.requestRender();
972
+ }
967
973
  }
968
974
 
969
- export default function (pi: ExtensionAPI) {
970
- pi.registerTool({
971
- name: "ask_user",
972
- label: "Ask User",
973
- description:
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.",
975
- promptSnippet:
976
- "Ask the user a question with optional multiple-choice answers to gather information interactively",
977
- promptGuidelines: [
978
- "Before calling ask_user, gather context with tools (read/web/ref) and pass a short summary via the context field.",
979
- "Use ask_user when the user's intent is ambiguous, when a decision requires explicit user input, or when multiple valid options exist.",
980
- ],
981
- parameters: Type.Object({
982
- question: Type.String({ description: "The question to ask the user" }),
983
- context: Type.Optional(
984
- Type.String({
985
- description: "Relevant context to show before the question (summary of findings)",
986
- }),
987
- ),
988
- options: Type.Optional(
989
- Type.Array(
990
- Type.Union([
991
- Type.String({ description: "Short title for this option" }),
992
- Type.Object({
993
- title: Type.String({ description: "Short title for this option" }),
994
- description: Type.Optional(
995
- Type.String({ description: "Longer description explaining this option" }),
996
- ),
997
- }),
998
- ]),
999
- { description: "List of options for the user to choose from" },
1000
- ),
1001
- ),
1002
- allowMultiple: Type.Optional(
1003
- Type.Boolean({ description: "Allow selecting multiple options. Default: false" }),
1004
- ),
1005
- allowFreeform: Type.Optional(
1006
- Type.Boolean({ description: "Add a freeform text option. Default: true" }),
1007
- ),
1008
- timeout: Type.Optional(
1009
- Type.Number({ description: "Auto-dismiss after N milliseconds. Returns null (cancelled) when expired." }),
1010
- ),
1011
- }),
1012
-
1013
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
1014
- if (signal?.aborted) {
1015
- return {
1016
- content: [{ type: "text", text: "Cancelled" }],
1017
- details: { question: params.question, options: [], answer: null, cancelled: true } as AskToolDetails,
1018
- };
1019
- }
1020
-
1021
- const {
1022
- question,
1023
- context,
1024
- options: rawOptions = [],
1025
- allowMultiple = false,
1026
- allowFreeform = true,
1027
- timeout,
1028
- } = params as AskParams;
1029
- const options = normalizeOptions(rawOptions);
1030
- const normalizedContext = context?.trim() || undefined;
1031
-
1032
- if (!ctx.hasUI || !ctx.ui) {
1033
- const optionText = options.length > 0 ? `\n\nOptions:\n${formatOptionsForMessage(options)}` : "";
1034
- const freeformHint = allowFreeform ? "\n\nYou can also answer freely." : "";
1035
- const contextText = normalizedContext ? `\n\nContext:\n${normalizedContext}` : "";
1036
- return {
1037
- content: [
1038
- {
1039
- type: "text",
1040
- text: `Ask requires interactive mode. Please answer:\n\n${question}${contextText}${optionText}${freeformHint}`,
1041
- },
1042
- ],
1043
- isError: true,
1044
- details: { question, context: normalizedContext, options, answer: null, cancelled: true } as AskToolDetails,
1045
- };
1046
- }
1047
-
1048
- // If no options provided, fall back to freeform input prompt.
1049
- if (options.length === 0) {
1050
- const prompt = normalizedContext ? `${question}\n\nContext:\n${normalizedContext}` : question;
1051
- const answer = await ctx.ui.input(prompt, "Type your answer...", timeout ? { timeout } : undefined);
1052
-
1053
- if (!answer) {
1054
- return {
1055
- content: [{ type: "text", text: "User cancelled the question" }],
1056
- details: { question, context: normalizedContext, options, answer: null, cancelled: true } as AskToolDetails,
1057
- };
1058
- }
1059
-
1060
- pi.events.emit("ask:answered", { question, context: normalizedContext, answer, wasCustom: true });
1061
- return {
1062
- content: [{ type: "text", text: `User answered: ${answer}` }],
1063
- details: { question, context: normalizedContext, options, answer, cancelled: false, wasCustom: true } as AskToolDetails,
1064
- };
1065
- }
1066
-
1067
- onUpdate?.({
1068
- content: [{ type: "text", text: "Waiting for user input..." }],
1069
- details: { question, context: normalizedContext, options, answer: null, cancelled: false },
1070
- });
1071
-
1072
- let result: AskUIResult | null;
1073
- try {
1074
- result = await ctx.ui.custom<AskUIResult | null>(
1075
- (tui, theme, keybindings, done) => {
1076
- // Wire AbortSignal so agent cancellation auto-dismisses the overlay
1077
- if (signal) {
1078
- const onAbort = () => done(null);
1079
- signal.addEventListener("abort", onAbort, { once: true });
1080
- }
1081
-
1082
- // Wire timeout for overlay mode
1083
- if (timeout && timeout > 0) {
1084
- setTimeout(() => done(null), timeout);
1085
- }
1086
-
1087
- return new AskComponent(
1088
- question,
1089
- normalizedContext,
1090
- options,
1091
- allowMultiple,
1092
- allowFreeform,
1093
- tui,
1094
- theme,
1095
- keybindings,
1096
- done,
1097
- );
1098
- },
1099
- {
1100
- overlay: true,
1101
- overlayOptions: {
1102
- anchor: "center",
1103
- width: ASK_OVERLAY_WIDTH,
1104
- minWidth: ASK_OVERLAY_MIN_WIDTH,
1105
- maxHeight: "85%",
1106
- margin: 1,
1107
- },
1108
- },
1109
- );
1110
- } catch (error) {
1111
- const message =
1112
- error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error);
1113
- return {
1114
- content: [{ type: "text", text: `Ask tool failed: ${message}` }],
1115
- isError: true,
1116
- details: { error: message },
1117
- };
1118
- }
1119
-
1120
- if (result === null) {
1121
- pi.events.emit("ask:cancelled", { question, context: normalizedContext, options });
1122
- return {
1123
- content: [{ type: "text", text: "User cancelled the question" }],
1124
- details: { question, context: normalizedContext, options, answer: null, cancelled: true } as AskToolDetails,
1125
- };
1126
- }
1127
-
1128
- pi.events.emit("ask:answered", {
1129
- question,
1130
- context: normalizedContext,
1131
- answer: result.answer,
1132
- wasCustom: result.wasCustom,
1133
- });
1134
- return {
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,
1144
- };
1145
- },
1146
-
1147
- renderCall(args, theme) {
1148
- const question = (args.question as string) || "";
1149
- const rawOptions = Array.isArray(args.options) ? args.options : [];
1150
- let text = theme.fg("toolTitle", theme.bold("ask_user "));
1151
- text += theme.fg("muted", question);
1152
- if (rawOptions.length > 0) {
1153
- const labels = rawOptions.map((o: unknown) =>
1154
- typeof o === "string" ? o : (o as QuestionOption)?.title ?? "",
1155
- );
1156
- text += "\n" + theme.fg("dim", ` ${rawOptions.length} option(s): ${labels.join(", ")}`);
1157
- }
1158
- if (args.allowMultiple) {
1159
- text += theme.fg("dim", " [multi-select]");
1160
- }
1161
- return new Text(text, 0, 0);
1162
- },
1163
-
1164
- renderResult(result, options, theme) {
1165
- const details = result.details as (AskToolDetails & { error?: string }) | undefined;
1166
-
1167
- if (details?.error) {
1168
- return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0);
1169
- }
1170
-
1171
- if (options.isPartial) {
1172
- const waitingText = result.content
1173
- ?.filter((part: { type?: string; text?: string }) => part?.type === "text")
1174
- .map((part: { text?: string }) => part.text ?? "")
1175
- .join("\n")
1176
- .trim() || "Waiting for user input...";
1177
- return new Text(theme.fg("muted", waitingText), 0, 0);
1178
- }
1179
-
1180
- if (!details || details.cancelled) {
1181
- return new Text(theme.fg("warning", "Cancelled"), 0, 0);
1182
- }
1183
-
1184
- const answer = details.answer ?? "";
1185
- let text = theme.fg("success", "✓ ");
1186
- if (details.wasCustom) {
1187
- text += theme.fg("muted", "(wrote) ");
1188
- }
1189
- text += theme.fg("accent", answer);
1190
-
1191
- if (options.expanded) {
1192
- const selectedTitles = new Set(
1193
- answer
1194
- .split(",")
1195
- .map((value) => value.trim())
1196
- .filter(Boolean),
1197
- );
1198
- text += "\n" + theme.fg("dim", `Q: ${details.question}`);
1199
- if (details.context) {
1200
- text += "\n" + theme.fg("dim", details.context);
1201
- }
1202
- if (details.options && details.options.length > 0) {
1203
- text += "\n" + theme.fg("dim", "Options:");
1204
- for (const opt of details.options) {
1205
- const desc = opt.description ? ` — ${opt.description}` : "";
1206
- const isSelected = opt.title === answer || selectedTitles.has(opt.title);
1207
- const marker = isSelected ? theme.fg("success", "●") : theme.fg("dim", "○");
1208
- text += `\n ${marker} ${theme.fg("dim", opt.title)}${theme.fg("dim", desc)}`;
1209
- }
1210
- }
1211
- }
1212
-
1213
- return new Text(text, 0, 0);
1214
- },
1215
- });
975
+ /**
976
+ * RPC/headless fallback: use dialog methods (select/input) instead of the rich TUI overlay.
977
+ * ctx.ui.custom() returns undefined in RPC mode, so we degrade gracefully.
978
+ */
979
+ async function askViaDialogs(
980
+ ui: { select: Function; input: Function },
981
+ question: string,
982
+ context: string | undefined,
983
+ options: QuestionOption[],
984
+ allowMultiple: boolean,
985
+ allowFreeform: boolean,
986
+ timeout?: number,
987
+ ): Promise<AskUIResult | null> {
988
+ const dialogOpts = timeout ? { timeout } : undefined;
989
+ const prompt = context ? `${question}\n\nContext:\n${context}` : question;
990
+
991
+ if (allowMultiple) {
992
+ // Multi-select degrades to a freeform input with options listed in the prompt.
993
+ // RPC's select() is single-choice only; input() lets the user type freely.
994
+ const optionList = formatOptionsForMessage(options);
995
+ const answer = await ui.input(
996
+ `${prompt}\n\nOptions (select one or more):\n${optionList}`,
997
+ "Type your selection(s)...",
998
+ dialogOpts,
999
+ ) as string | undefined;
1000
+ if (!answer) return null;
1001
+ return { answer: answer.trim(), wasCustom: true };
1002
+ }
1003
+
1004
+ // Single-select: present options via ctx.ui.select()
1005
+ const selectOptions = options.map((o) => o.title);
1006
+ if (allowFreeform) selectOptions.push(FREEFORM_SENTINEL);
1007
+
1008
+ const selected = await ui.select(prompt, selectOptions, dialogOpts) as string | undefined;
1009
+ if (!selected) return null;
1010
+
1011
+ if (selected === FREEFORM_SENTINEL) {
1012
+ const answer = await ui.input(prompt, "Type your answer...", dialogOpts) as string | undefined;
1013
+ if (!answer) return null;
1014
+ return { answer: answer.trim(), wasCustom: true };
1015
+ }
1016
+
1017
+ return { answer: selected, wasCustom: false };
1018
+ }
1019
+
1020
+ export default function(pi: ExtensionAPI) {
1021
+ pi.registerTool({
1022
+ name: "ask_user",
1023
+ label: "Ask User",
1024
+ description:
1025
+ "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.",
1026
+ promptSnippet:
1027
+ "Ask the user a question with optional multiple-choice answers to gather information interactively",
1028
+ promptGuidelines: [
1029
+ "Before calling ask_user, gather context with tools (read/web/ref) and pass a short summary via the context field.",
1030
+ "Use ask_user when the user's intent is ambiguous, when a decision requires explicit user input, or when multiple valid options exist.",
1031
+ ],
1032
+ parameters: Type.Object({
1033
+ question: Type.String({ description: "The question to ask the user" }),
1034
+ context: Type.Optional(
1035
+ Type.String({
1036
+ description: "Relevant context to show before the question (summary of findings)",
1037
+ }),
1038
+ ),
1039
+ options: Type.Optional(
1040
+ Type.Array(
1041
+ Type.Union([
1042
+ Type.String({ description: "Short title for this option" }),
1043
+ Type.Object({
1044
+ title: Type.String({ description: "Short title for this option" }),
1045
+ description: Type.Optional(
1046
+ Type.String({ description: "Longer description explaining this option" }),
1047
+ ),
1048
+ }),
1049
+ ]),
1050
+ { description: "List of options for the user to choose from" },
1051
+ ),
1052
+ ),
1053
+ allowMultiple: Type.Optional(
1054
+ Type.Boolean({ description: "Allow selecting multiple options. Default: false" }),
1055
+ ),
1056
+ allowFreeform: Type.Optional(
1057
+ Type.Boolean({ description: "Add a freeform text option. Default: true" }),
1058
+ ),
1059
+ timeout: Type.Optional(
1060
+ Type.Number({ description: "Auto-dismiss after N milliseconds. Returns null (cancelled) when expired." }),
1061
+ ),
1062
+ }),
1063
+
1064
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
1065
+ if (signal?.aborted) {
1066
+ return {
1067
+ content: [{ type: "text", text: "Cancelled" }],
1068
+ details: { question: params.question, options: [], answer: null, cancelled: true } as AskToolDetails,
1069
+ };
1070
+ }
1071
+
1072
+ const {
1073
+ question,
1074
+ context,
1075
+ options: rawOptions = [],
1076
+ allowMultiple = false,
1077
+ allowFreeform = true,
1078
+ timeout,
1079
+ } = params as AskParams;
1080
+ const options = normalizeOptions(rawOptions);
1081
+ const normalizedContext = context?.trim() || undefined;
1082
+
1083
+ if (!ctx.hasUI || !ctx.ui) {
1084
+ const optionText = options.length > 0 ? `\n\nOptions:\n${formatOptionsForMessage(options)}` : "";
1085
+ const freeformHint = allowFreeform ? "\n\nYou can also answer freely." : "";
1086
+ const contextText = normalizedContext ? `\n\nContext:\n${normalizedContext}` : "";
1087
+ return {
1088
+ content: [
1089
+ {
1090
+ type: "text",
1091
+ text: `Ask requires interactive mode. Please answer:\n\n${question}${contextText}${optionText}${freeformHint}`,
1092
+ },
1093
+ ],
1094
+ isError: true,
1095
+ details: { question, context: normalizedContext, options, answer: null, cancelled: true } as AskToolDetails,
1096
+ };
1097
+ }
1098
+
1099
+ // If no options provided, fall back to freeform input prompt.
1100
+ if (options.length === 0) {
1101
+ const prompt = normalizedContext ? `${question}\n\nContext:\n${normalizedContext}` : question;
1102
+ const answer = await ctx.ui.input(prompt, "Type your answer...", timeout ? { timeout } : undefined);
1103
+
1104
+ if (!answer) {
1105
+ return {
1106
+ content: [{ type: "text", text: "User cancelled the question" }],
1107
+ details: { question, context: normalizedContext, options, answer: null, cancelled: true } as AskToolDetails,
1108
+ };
1109
+ }
1110
+
1111
+ pi.events.emit("ask:answered", { question, context: normalizedContext, answer, wasCustom: true });
1112
+ return {
1113
+ content: [{ type: "text", text: `User answered: ${answer}` }],
1114
+ details: { question, context: normalizedContext, options, answer, cancelled: false, wasCustom: true } as AskToolDetails,
1115
+ };
1116
+ }
1117
+
1118
+ onUpdate?.({
1119
+ content: [{ type: "text", text: "Waiting for user input..." }],
1120
+ details: { question, context: normalizedContext, options, answer: null, cancelled: false },
1121
+ });
1122
+
1123
+ let result: AskUIResult | null;
1124
+ try {
1125
+ // custom() returns undefined in RPC/headless mode — fall back to dialog methods
1126
+ const customResult = await ctx.ui.custom<AskUIResult | null>(
1127
+ (tui, theme, keybindings, done) => {
1128
+ // Wire AbortSignal so agent cancellation auto-dismisses the overlay
1129
+ if (signal) {
1130
+ const onAbort = () => done(null);
1131
+ signal.addEventListener("abort", onAbort, { once: true });
1132
+ }
1133
+
1134
+ // Wire timeout for overlay mode
1135
+ if (timeout && timeout > 0) {
1136
+ setTimeout(() => done(null), timeout);
1137
+ }
1138
+
1139
+ return new AskComponent(
1140
+ question,
1141
+ normalizedContext,
1142
+ options,
1143
+ allowMultiple,
1144
+ allowFreeform,
1145
+ tui,
1146
+ theme,
1147
+ keybindings,
1148
+ done,
1149
+ );
1150
+ },
1151
+ {
1152
+ overlay: true,
1153
+ overlayOptions: {
1154
+ anchor: "center",
1155
+ width: ASK_OVERLAY_WIDTH,
1156
+ minWidth: ASK_OVERLAY_MIN_WIDTH,
1157
+ maxHeight: "85%",
1158
+ margin: 1,
1159
+ },
1160
+ },
1161
+ );
1162
+
1163
+ if (customResult !== undefined) {
1164
+ result = customResult;
1165
+ } else {
1166
+ // RPC/headless mode: degrade to select()/input() dialog protocol
1167
+ result = await askViaDialogs(ctx.ui, question, normalizedContext, options, allowMultiple, allowFreeform, timeout);
1168
+ }
1169
+ } catch (error) {
1170
+ const message =
1171
+ error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error);
1172
+ return {
1173
+ content: [{ type: "text", text: `Ask tool failed: ${message}` }],
1174
+ isError: true,
1175
+ details: { error: message },
1176
+ };
1177
+ }
1178
+
1179
+ if (result === null) {
1180
+ pi.events.emit("ask:cancelled", { question, context: normalizedContext, options });
1181
+ return {
1182
+ content: [{ type: "text", text: "User cancelled the question" }],
1183
+ details: { question, context: normalizedContext, options, answer: null, cancelled: true } as AskToolDetails,
1184
+ };
1185
+ }
1186
+
1187
+ pi.events.emit("ask:answered", {
1188
+ question,
1189
+ context: normalizedContext,
1190
+ answer: result.answer,
1191
+ wasCustom: result.wasCustom,
1192
+ });
1193
+ return {
1194
+ content: [{ type: "text", text: `User answered: ${result.answer}` }],
1195
+ details: {
1196
+ question,
1197
+ context: normalizedContext,
1198
+ options,
1199
+ answer: result.answer,
1200
+ cancelled: false,
1201
+ wasCustom: result.wasCustom,
1202
+ } as AskToolDetails,
1203
+ };
1204
+ },
1205
+
1206
+ renderCall(args, theme) {
1207
+ const question = (args.question as string) || "";
1208
+ const rawOptions = Array.isArray(args.options) ? args.options : [];
1209
+ let text = theme.fg("toolTitle", theme.bold("ask_user "));
1210
+ text += theme.fg("muted", question);
1211
+ if (rawOptions.length > 0) {
1212
+ const labels = rawOptions.map((o: unknown) =>
1213
+ typeof o === "string" ? o : (o as QuestionOption)?.title ?? "",
1214
+ );
1215
+ text += "\n" + theme.fg("dim", ` ${rawOptions.length} option(s): ${labels.join(", ")}`);
1216
+ }
1217
+ if (args.allowMultiple) {
1218
+ text += theme.fg("dim", " [multi-select]");
1219
+ }
1220
+ return new Text(text, 0, 0);
1221
+ },
1222
+
1223
+ renderResult(result, options, theme) {
1224
+ const details = result.details as (AskToolDetails & { error?: string }) | undefined;
1225
+
1226
+ if (details?.error) {
1227
+ return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0);
1228
+ }
1229
+
1230
+ if (options.isPartial) {
1231
+ const waitingText = result.content
1232
+ ?.filter((part: { type?: string; text?: string }) => part?.type === "text")
1233
+ .map((part: { text?: string }) => part.text ?? "")
1234
+ .join("\n")
1235
+ .trim() || "Waiting for user input...";
1236
+ return new Text(theme.fg("muted", waitingText), 0, 0);
1237
+ }
1238
+
1239
+ if (!details || details.cancelled) {
1240
+ return new Text(theme.fg("warning", "Cancelled"), 0, 0);
1241
+ }
1242
+
1243
+ const answer = details.answer ?? "";
1244
+ let text = theme.fg("success", "✓ ");
1245
+ if (details.wasCustom) {
1246
+ text += theme.fg("muted", "(wrote) ");
1247
+ }
1248
+ text += theme.fg("accent", answer);
1249
+
1250
+ if (options.expanded) {
1251
+ const selectedTitles = new Set(
1252
+ answer
1253
+ .split(",")
1254
+ .map((value) => value.trim())
1255
+ .filter(Boolean),
1256
+ );
1257
+ text += "\n" + theme.fg("dim", `Q: ${details.question}`);
1258
+ if (details.context) {
1259
+ text += "\n" + theme.fg("dim", details.context);
1260
+ }
1261
+ if (details.options && details.options.length > 0) {
1262
+ text += "\n" + theme.fg("dim", "Options:");
1263
+ for (const opt of details.options) {
1264
+ const desc = opt.description ? ` — ${opt.description}` : "";
1265
+ const isSelected = opt.title === answer || selectedTitles.has(opt.title);
1266
+ const marker = isSelected ? theme.fg("success", "●") : theme.fg("dim", "○");
1267
+ text += `\n ${marker} ${theme.fg("dim", opt.title)}${theme.fg("dim", desc)}`;
1268
+ }
1269
+ }
1270
+ }
1271
+
1272
+ return new Text(text, 0, 0);
1273
+ },
1274
+ });
1216
1275
  }