pi-ask-user 0.5.2 → 0.6.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 +9 -3
- package/index.ts +353 -81
- package/package.json +1 -1
- package/single-select-layout.ts +20 -5
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@ High-quality video: [ask-user-demo.mp4](./media/ask-user-demo.mp4)
|
|
|
14
14
|
- Responsive split-pane details preview on wide terminals with single-column fallback on narrow terminals
|
|
15
15
|
- Multi-select option lists
|
|
16
16
|
- Optional freeform responses
|
|
17
|
+
- User-toggleable extra context on structured selections
|
|
17
18
|
- Context display support
|
|
18
19
|
- Overlay mode — dialog floats over conversation, preserving context
|
|
19
20
|
- Pi-TUI-aligned keybinding and editor behavior
|
|
@@ -62,6 +63,7 @@ The registered tool name is:
|
|
|
62
63
|
| `options` | `(string \| {title, description?})[]?` | `[]` | Multiple-choice options |
|
|
63
64
|
| `allowMultiple` | `boolean?` | `false` | Enable multi-select mode |
|
|
64
65
|
| `allowFreeform` | `boolean?` | `true` | Add a "Type something" freeform option |
|
|
66
|
+
| `allowComment` | `boolean?` | `false` | Expose a user-toggleable extra-context option in the overlay (`ctrl+g` or the toggle row) and collect an optional comment in fallback dialogs |
|
|
65
67
|
| `timeout` | `number?` | — | Auto-dismiss after N ms and return `null` if the prompt times out |
|
|
66
68
|
|
|
67
69
|
## Example usage shape
|
|
@@ -75,7 +77,8 @@ The registered tool name is:
|
|
|
75
77
|
{ "title": "production", "description": "Customer-facing" }
|
|
76
78
|
],
|
|
77
79
|
"allowMultiple": false,
|
|
78
|
-
"allowFreeform": true
|
|
80
|
+
"allowFreeform": true,
|
|
81
|
+
"allowComment": true
|
|
79
82
|
}
|
|
80
83
|
```
|
|
81
84
|
|
|
@@ -84,13 +87,16 @@ The registered tool name is:
|
|
|
84
87
|
All tool results include a structured `details` object for rendering and session state reconstruction:
|
|
85
88
|
|
|
86
89
|
```typescript
|
|
90
|
+
type AskResponse =
|
|
91
|
+
| { kind: "selection"; selections: string[]; comment?: string }
|
|
92
|
+
| { kind: "freeform"; text: string };
|
|
93
|
+
|
|
87
94
|
interface AskToolDetails {
|
|
88
95
|
question: string;
|
|
89
96
|
context?: string;
|
|
90
97
|
options: QuestionOption[];
|
|
91
|
-
|
|
98
|
+
response: AskResponse | null;
|
|
92
99
|
cancelled: boolean;
|
|
93
|
-
wasCustom?: boolean;
|
|
94
100
|
}
|
|
95
101
|
```
|
|
96
102
|
|
package/index.ts
CHANGED
|
@@ -41,22 +41,30 @@ interface AskParams {
|
|
|
41
41
|
options?: AskOptionInput[];
|
|
42
42
|
allowMultiple?: boolean;
|
|
43
43
|
allowFreeform?: boolean;
|
|
44
|
+
allowComment?: boolean;
|
|
44
45
|
timeout?: number;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
type AskResponse =
|
|
49
|
+
| {
|
|
50
|
+
kind: "selection";
|
|
51
|
+
selections: string[];
|
|
52
|
+
comment?: string;
|
|
53
|
+
}
|
|
54
|
+
| {
|
|
55
|
+
kind: "freeform";
|
|
56
|
+
text: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
47
59
|
interface AskToolDetails {
|
|
48
60
|
question: string;
|
|
49
61
|
context?: string;
|
|
50
62
|
options: QuestionOption[];
|
|
51
|
-
|
|
63
|
+
response: AskResponse | null;
|
|
52
64
|
cancelled: boolean;
|
|
53
|
-
wasCustom?: boolean;
|
|
54
65
|
}
|
|
55
66
|
|
|
56
|
-
|
|
57
|
-
answer: string;
|
|
58
|
-
wasCustom: boolean;
|
|
59
|
-
}
|
|
67
|
+
type AskUIResult = AskResponse;
|
|
60
68
|
|
|
61
69
|
function normalizeOptions(options: AskOptionInput[]): QuestionOption[] {
|
|
62
70
|
return options
|
|
@@ -81,6 +89,54 @@ function formatOptionsForMessage(options: QuestionOption[]): string {
|
|
|
81
89
|
.join("\n");
|
|
82
90
|
}
|
|
83
91
|
|
|
92
|
+
function normalizeOptionalComment(text: string | null | undefined): string | undefined {
|
|
93
|
+
const trimmed = text?.trim();
|
|
94
|
+
return trimmed ? trimmed : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function createFreeformResponse(text: string | null | undefined): AskResponse | null {
|
|
98
|
+
const trimmed = text?.trim();
|
|
99
|
+
return trimmed ? { kind: "freeform", text: trimmed } : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function createSelectionResponse(selections: string[], comment?: string | null): AskResponse | null {
|
|
103
|
+
const normalizedSelections = selections.map((selection) => selection.trim()).filter(Boolean);
|
|
104
|
+
if (normalizedSelections.length === 0) return null;
|
|
105
|
+
|
|
106
|
+
const normalizedComment = normalizeOptionalComment(comment);
|
|
107
|
+
return normalizedComment
|
|
108
|
+
? { kind: "selection", selections: normalizedSelections, comment: normalizedComment }
|
|
109
|
+
: { kind: "selection", selections: normalizedSelections };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatResponseSummary(response: AskResponse): string {
|
|
113
|
+
if (response.kind === "freeform") return response.text;
|
|
114
|
+
|
|
115
|
+
const selections = response.selections.join(", ");
|
|
116
|
+
return response.comment ? `${selections} — ${response.comment}` : selections;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildCommentPrompt(prompt: string, selections: string[]): string {
|
|
120
|
+
const label = selections.length === 1 ? "Selected option" : "Selected options";
|
|
121
|
+
const lines = selections.map((selection) => `- ${selection}`).join("\n");
|
|
122
|
+
return `${prompt}\n\n${label}:\n${lines}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseDialogSelections(input: string): string[] {
|
|
126
|
+
return input
|
|
127
|
+
.split(",")
|
|
128
|
+
.map((selection) => selection.trim())
|
|
129
|
+
.filter(Boolean);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isCancelledInput(value: unknown): value is null | undefined {
|
|
133
|
+
return value === null || value === undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isSelectionResponse(response: AskResponse): response is Extract<AskResponse, { kind: "selection" }> {
|
|
137
|
+
return response.kind === "selection";
|
|
138
|
+
}
|
|
139
|
+
|
|
84
140
|
function createSelectListTheme(theme: Theme) {
|
|
85
141
|
return {
|
|
86
142
|
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
@@ -167,7 +223,11 @@ function literalHint(theme: Theme, key: string, description: string): string {
|
|
|
167
223
|
return `${theme.fg("dim", key)}${theme.fg("muted", ` ${description}`)}`;
|
|
168
224
|
}
|
|
169
225
|
|
|
170
|
-
|
|
226
|
+
function isCommentToggleKey(data: string): boolean {
|
|
227
|
+
return matchesKey(data, Key.ctrl("g"));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
type AskMode = "select" | "freeform" | "comment";
|
|
171
231
|
|
|
172
232
|
const ASK_OVERLAY_MAX_HEIGHT_RATIO = 0.85;
|
|
173
233
|
const ASK_OVERLAY_WIDTH = "92%";
|
|
@@ -177,39 +237,66 @@ const SINGLE_SELECT_SPLIT_PANE_LEFT_MIN_WIDTH = 32;
|
|
|
177
237
|
const SINGLE_SELECT_SPLIT_PANE_RIGHT_MIN_WIDTH = 28;
|
|
178
238
|
const SINGLE_SELECT_SPLIT_PANE_SEPARATOR = " │ ";
|
|
179
239
|
const FREEFORM_SENTINEL = "\u270f\ufe0f Type custom response...";
|
|
240
|
+
const COMMENT_TOGGLE_LABEL = "Add extra context after selection";
|
|
180
241
|
|
|
181
242
|
class MultiSelectList implements Component {
|
|
182
243
|
private options: QuestionOption[];
|
|
183
244
|
private allowFreeform: boolean;
|
|
245
|
+
private allowComment: boolean;
|
|
184
246
|
private theme: Theme;
|
|
185
247
|
private keybindings: KeybindingsManager;
|
|
186
248
|
private selectedIndex = 0;
|
|
187
249
|
private checked = new Set<number>();
|
|
250
|
+
private commentEnabled = false;
|
|
188
251
|
private cachedWidth?: number;
|
|
189
252
|
private cachedLines?: string[];
|
|
190
253
|
|
|
191
254
|
public onCancel?: () => void;
|
|
192
|
-
public onSubmit?: (result: string) => void;
|
|
255
|
+
public onSubmit?: (result: string[]) => void;
|
|
193
256
|
public onEnterFreeform?: () => void;
|
|
194
257
|
|
|
195
|
-
constructor(
|
|
258
|
+
constructor(
|
|
259
|
+
options: QuestionOption[],
|
|
260
|
+
allowFreeform: boolean,
|
|
261
|
+
allowComment: boolean,
|
|
262
|
+
theme: Theme,
|
|
263
|
+
keybindings: KeybindingsManager,
|
|
264
|
+
) {
|
|
196
265
|
this.options = options;
|
|
197
266
|
this.allowFreeform = allowFreeform;
|
|
267
|
+
this.allowComment = allowComment;
|
|
198
268
|
this.theme = theme;
|
|
199
269
|
this.keybindings = keybindings;
|
|
200
270
|
}
|
|
201
271
|
|
|
272
|
+
public isCommentEnabled(): boolean {
|
|
273
|
+
return this.commentEnabled;
|
|
274
|
+
}
|
|
275
|
+
|
|
202
276
|
invalidate(): void {
|
|
203
277
|
this.cachedWidth = undefined;
|
|
204
278
|
this.cachedLines = undefined;
|
|
205
279
|
}
|
|
206
280
|
|
|
207
281
|
private getItemCount(): number {
|
|
208
|
-
return this.options.length + (this.allowFreeform ? 1 : 0);
|
|
282
|
+
return this.options.length + (this.allowComment ? 1 : 0) + (this.allowFreeform ? 1 : 0);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private getCommentToggleIndex(): number | null {
|
|
286
|
+
return this.allowComment ? this.options.length : null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private getFreeformIndex(): number {
|
|
290
|
+
return this.options.length + (this.allowComment ? 1 : 0);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private isCommentToggleRow(index: number): boolean {
|
|
294
|
+
const toggleIndex = this.getCommentToggleIndex();
|
|
295
|
+
return toggleIndex !== null && index === toggleIndex;
|
|
209
296
|
}
|
|
210
297
|
|
|
211
298
|
private isFreeformRow(index: number): boolean {
|
|
212
|
-
return this.allowFreeform && index === this.
|
|
299
|
+
return this.allowFreeform && index === this.getFreeformIndex();
|
|
213
300
|
}
|
|
214
301
|
|
|
215
302
|
private toggle(index: number): void {
|
|
@@ -218,6 +305,12 @@ class MultiSelectList implements Component {
|
|
|
218
305
|
else this.checked.add(index);
|
|
219
306
|
}
|
|
220
307
|
|
|
308
|
+
private toggleComment(): void {
|
|
309
|
+
if (!this.allowComment) return;
|
|
310
|
+
this.commentEnabled = !this.commentEnabled;
|
|
311
|
+
this.invalidate();
|
|
312
|
+
}
|
|
313
|
+
|
|
221
314
|
handleInput(data: string): void {
|
|
222
315
|
if (this.keybindings.matches(data, "tui.select.cancel")) {
|
|
223
316
|
this.onCancel?.();
|
|
@@ -230,6 +323,11 @@ class MultiSelectList implements Component {
|
|
|
230
323
|
return;
|
|
231
324
|
}
|
|
232
325
|
|
|
326
|
+
if (this.allowComment && isCommentToggleKey(data)) {
|
|
327
|
+
this.toggleComment();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
233
331
|
if (this.keybindings.matches(data, "tui.select.up") || matchesKey(data, Key.shift("tab"))) {
|
|
234
332
|
this.selectedIndex = this.selectedIndex === 0 ? count - 1 : this.selectedIndex - 1;
|
|
235
333
|
this.invalidate();
|
|
@@ -242,7 +340,6 @@ class MultiSelectList implements Component {
|
|
|
242
340
|
return;
|
|
243
341
|
}
|
|
244
342
|
|
|
245
|
-
// Number keys (1-9) toggle checkboxes for normal items
|
|
246
343
|
const numMatch = data.match(/^[1-9]$/);
|
|
247
344
|
if (numMatch) {
|
|
248
345
|
const idx = Number.parseInt(numMatch[0], 10) - 1;
|
|
@@ -255,6 +352,10 @@ class MultiSelectList implements Component {
|
|
|
255
352
|
}
|
|
256
353
|
|
|
257
354
|
if (matchesKey(data, Key.space)) {
|
|
355
|
+
if (this.isCommentToggleRow(this.selectedIndex)) {
|
|
356
|
+
this.toggleComment();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
258
359
|
if (this.isFreeformRow(this.selectedIndex)) {
|
|
259
360
|
this.onEnterFreeform?.();
|
|
260
361
|
return;
|
|
@@ -265,6 +366,10 @@ class MultiSelectList implements Component {
|
|
|
265
366
|
}
|
|
266
367
|
|
|
267
368
|
if (this.keybindings.matches(data, "tui.select.confirm")) {
|
|
369
|
+
if (this.isCommentToggleRow(this.selectedIndex)) {
|
|
370
|
+
this.toggleComment();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
268
373
|
if (this.isFreeformRow(this.selectedIndex)) {
|
|
269
374
|
this.onEnterFreeform?.();
|
|
270
375
|
return;
|
|
@@ -275,11 +380,10 @@ class MultiSelectList implements Component {
|
|
|
275
380
|
.map((i) => this.options[i]?.title)
|
|
276
381
|
.filter((t): t is string => !!t);
|
|
277
382
|
|
|
278
|
-
// If nothing checked, fall back to current row
|
|
279
383
|
const fallback = this.options[this.selectedIndex]?.title;
|
|
280
|
-
const result = selectedTitles.length > 0 ? selectedTitles
|
|
384
|
+
const result = selectedTitles.length > 0 ? selectedTitles : fallback ? [fallback] : [];
|
|
281
385
|
|
|
282
|
-
if (result) this.onSubmit?.(result);
|
|
386
|
+
if (result.length > 0) this.onSubmit?.(result);
|
|
283
387
|
else this.onCancel?.();
|
|
284
388
|
}
|
|
285
389
|
}
|
|
@@ -308,6 +412,15 @@ class MultiSelectList implements Component {
|
|
|
308
412
|
const isSelected = i === this.selectedIndex;
|
|
309
413
|
const prefix = isSelected ? theme.fg("accent", "→") : " ";
|
|
310
414
|
|
|
415
|
+
if (this.isCommentToggleRow(i)) {
|
|
416
|
+
const checkbox = this.commentEnabled ? theme.fg("success", "[✓]") : theme.fg("dim", "[ ]");
|
|
417
|
+
const label = isSelected
|
|
418
|
+
? theme.fg("accent", theme.bold(COMMENT_TOGGLE_LABEL))
|
|
419
|
+
: theme.fg("text", theme.bold(COMMENT_TOGGLE_LABEL));
|
|
420
|
+
lines.push(truncateToWidth(`${prefix} ${checkbox} ${label}`, width, ""));
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
311
424
|
if (this.isFreeformRow(i)) {
|
|
312
425
|
const label = theme.fg("text", theme.bold("Type something."));
|
|
313
426
|
const desc = theme.fg("muted", "Enter a custom response");
|
|
@@ -338,7 +451,6 @@ class MultiSelectList implements Component {
|
|
|
338
451
|
}
|
|
339
452
|
}
|
|
340
453
|
|
|
341
|
-
// Scroll indicator
|
|
342
454
|
if (startIndex > 0 || endIndex < count) {
|
|
343
455
|
lines.push(theme.fg("dim", truncateToWidth(` (${this.selectedIndex + 1}/${count})`, width, "")));
|
|
344
456
|
}
|
|
@@ -352,10 +464,12 @@ class MultiSelectList implements Component {
|
|
|
352
464
|
class WrappedSingleSelectList implements Component {
|
|
353
465
|
private options: QuestionOption[];
|
|
354
466
|
private allowFreeform: boolean;
|
|
467
|
+
private allowComment: boolean;
|
|
355
468
|
private theme: Theme;
|
|
356
469
|
private keybindings: KeybindingsManager;
|
|
357
470
|
private selectedIndex = 0;
|
|
358
471
|
private searchQuery = "";
|
|
472
|
+
private commentEnabled = false;
|
|
359
473
|
private maxVisibleRows = 12;
|
|
360
474
|
private cachedWidth?: number;
|
|
361
475
|
private cachedLines?: string[];
|
|
@@ -364,13 +478,24 @@ class WrappedSingleSelectList implements Component {
|
|
|
364
478
|
public onSubmit?: (result: string) => void;
|
|
365
479
|
public onEnterFreeform?: () => void;
|
|
366
480
|
|
|
367
|
-
constructor(
|
|
481
|
+
constructor(
|
|
482
|
+
options: QuestionOption[],
|
|
483
|
+
allowFreeform: boolean,
|
|
484
|
+
allowComment: boolean,
|
|
485
|
+
theme: Theme,
|
|
486
|
+
keybindings: KeybindingsManager,
|
|
487
|
+
) {
|
|
368
488
|
this.options = options;
|
|
369
489
|
this.allowFreeform = allowFreeform;
|
|
490
|
+
this.allowComment = allowComment;
|
|
370
491
|
this.theme = theme;
|
|
371
492
|
this.keybindings = keybindings;
|
|
372
493
|
}
|
|
373
494
|
|
|
495
|
+
public isCommentEnabled(): boolean {
|
|
496
|
+
return this.commentEnabled;
|
|
497
|
+
}
|
|
498
|
+
|
|
374
499
|
setMaxVisibleRows(rows: number): void {
|
|
375
500
|
const next = Math.max(1, Math.floor(rows));
|
|
376
501
|
if (next !== this.maxVisibleRows) {
|
|
@@ -389,11 +514,21 @@ class WrappedSingleSelectList implements Component {
|
|
|
389
514
|
}
|
|
390
515
|
|
|
391
516
|
private getItemCount(filteredOptions: QuestionOption[]): number {
|
|
392
|
-
return filteredOptions.length + (this.allowFreeform ? 1 : 0);
|
|
517
|
+
return filteredOptions.length + (this.allowComment ? 1 : 0) + (this.allowFreeform ? 1 : 0);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private isCommentToggleRow(index: number, filteredOptions: QuestionOption[]): boolean {
|
|
521
|
+
return this.allowComment && index === filteredOptions.length;
|
|
393
522
|
}
|
|
394
523
|
|
|
395
524
|
private isFreeformRow(index: number, filteredOptions: QuestionOption[]): boolean {
|
|
396
|
-
return this.allowFreeform && index === filteredOptions.length;
|
|
525
|
+
return this.allowFreeform && index === filteredOptions.length + (this.allowComment ? 1 : 0);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
private toggleComment(): void {
|
|
529
|
+
if (!this.allowComment) return;
|
|
530
|
+
this.commentEnabled = !this.commentEnabled;
|
|
531
|
+
this.invalidate();
|
|
397
532
|
}
|
|
398
533
|
|
|
399
534
|
private setSearchQuery(query: string): void {
|
|
@@ -491,6 +626,8 @@ class WrappedSingleSelectList implements Component {
|
|
|
491
626
|
selectedIndex: this.selectedIndex,
|
|
492
627
|
width,
|
|
493
628
|
allowFreeform: this.allowFreeform,
|
|
629
|
+
allowComment: this.allowComment,
|
|
630
|
+
commentEnabled: this.commentEnabled,
|
|
494
631
|
maxRows,
|
|
495
632
|
hideDescriptions,
|
|
496
633
|
});
|
|
@@ -508,10 +645,13 @@ class WrappedSingleSelectList implements Component {
|
|
|
508
645
|
mdTheme = getMarkdownTheme();
|
|
509
646
|
} catch { }
|
|
510
647
|
|
|
511
|
-
// Build a markdown string for the preview content
|
|
512
648
|
let md = "";
|
|
513
649
|
|
|
514
|
-
if (this.
|
|
650
|
+
if (this.isCommentToggleRow(this.selectedIndex, filteredOptions)) {
|
|
651
|
+
md += "## Additional context\n\n";
|
|
652
|
+
md += `Currently: **${this.commentEnabled ? "Enabled" : "Disabled"}**\n\n`;
|
|
653
|
+
md += "Turn this on when the selected option needs extra explanation before the tool submits.\n";
|
|
654
|
+
} else if (this.isFreeformRow(this.selectedIndex, filteredOptions)) {
|
|
515
655
|
md += "## Custom response\n\n";
|
|
516
656
|
md += "Open the editor to write **any** answer.\n\n";
|
|
517
657
|
md += "*Use this when none of the listed options fit.*\n";
|
|
@@ -536,7 +676,6 @@ class WrappedSingleSelectList implements Component {
|
|
|
536
676
|
}
|
|
537
677
|
}
|
|
538
678
|
|
|
539
|
-
// Render via Markdown component if theme is available, otherwise fall back to plain text
|
|
540
679
|
let lines: string[];
|
|
541
680
|
if (mdTheme) {
|
|
542
681
|
const mdComponent = new Markdown(md.trim(), 0, 0, mdTheme);
|
|
@@ -548,7 +687,6 @@ class WrappedSingleSelectList implements Component {
|
|
|
548
687
|
}
|
|
549
688
|
}
|
|
550
689
|
|
|
551
|
-
// Trim trailing blanks
|
|
552
690
|
while (lines.length > 0 && lines[lines.length - 1]?.trim() === "") {
|
|
553
691
|
lines.pop();
|
|
554
692
|
}
|
|
@@ -572,6 +710,11 @@ class WrappedSingleSelectList implements Component {
|
|
|
572
710
|
return;
|
|
573
711
|
}
|
|
574
712
|
|
|
713
|
+
if (this.allowComment && isCommentToggleKey(data)) {
|
|
714
|
+
this.toggleComment();
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
575
718
|
const filteredOptions = this.getFilteredOptions();
|
|
576
719
|
const count = this.getItemCount(filteredOptions);
|
|
577
720
|
|
|
@@ -588,16 +731,25 @@ class WrappedSingleSelectList implements Component {
|
|
|
588
731
|
}
|
|
589
732
|
|
|
590
733
|
const numMatch = data.match(/^[1-9]$/);
|
|
591
|
-
if (numMatch &&
|
|
734
|
+
if (numMatch && filteredOptions.length > 0) {
|
|
592
735
|
const idx = Number.parseInt(numMatch[0], 10) - 1;
|
|
593
|
-
if (idx >= 0 && idx <
|
|
736
|
+
if (idx >= 0 && idx < filteredOptions.length) {
|
|
594
737
|
this.selectedIndex = idx;
|
|
595
738
|
this.invalidate();
|
|
596
739
|
return;
|
|
597
740
|
}
|
|
598
741
|
}
|
|
599
742
|
|
|
743
|
+
if (matchesKey(data, Key.space) && count > 0 && this.isCommentToggleRow(this.selectedIndex, filteredOptions)) {
|
|
744
|
+
this.toggleComment();
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
600
748
|
if (this.keybindings.matches(data, "tui.select.confirm") && count > 0) {
|
|
749
|
+
if (this.isCommentToggleRow(this.selectedIndex, filteredOptions)) {
|
|
750
|
+
this.toggleComment();
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
601
753
|
if (this.isFreeformRow(this.selectedIndex, filteredOptions)) {
|
|
602
754
|
this.onEnterFreeform?.();
|
|
603
755
|
return;
|
|
@@ -662,12 +814,16 @@ class AskComponent extends Container {
|
|
|
662
814
|
private options: QuestionOption[];
|
|
663
815
|
private allowMultiple: boolean;
|
|
664
816
|
private allowFreeform: boolean;
|
|
817
|
+
private allowComment: boolean;
|
|
665
818
|
private tui: TUI;
|
|
666
819
|
private theme: Theme;
|
|
667
820
|
private keybindings: KeybindingsManager;
|
|
668
821
|
private onDone: (result: AskUIResult | null) => void;
|
|
669
822
|
|
|
670
823
|
private mode: AskMode = "select";
|
|
824
|
+
private pendingSelections: string[] = [];
|
|
825
|
+
private freeformDraft = "";
|
|
826
|
+
private commentDraft = "";
|
|
671
827
|
|
|
672
828
|
// Static layout components
|
|
673
829
|
private titleText: Text;
|
|
@@ -688,7 +844,7 @@ class AskComponent extends Container {
|
|
|
688
844
|
}
|
|
689
845
|
set focused(value: boolean) {
|
|
690
846
|
this._focused = value;
|
|
691
|
-
if (this.editor && this.mode === "freeform") {
|
|
847
|
+
if (this.editor && (this.mode === "freeform" || this.mode === "comment")) {
|
|
692
848
|
(this.editor as any).focused = value;
|
|
693
849
|
}
|
|
694
850
|
}
|
|
@@ -699,6 +855,7 @@ class AskComponent extends Container {
|
|
|
699
855
|
options: QuestionOption[],
|
|
700
856
|
allowMultiple: boolean,
|
|
701
857
|
allowFreeform: boolean,
|
|
858
|
+
allowComment: boolean,
|
|
702
859
|
tui: TUI,
|
|
703
860
|
theme: Theme,
|
|
704
861
|
keybindings: KeybindingsManager,
|
|
@@ -711,6 +868,7 @@ class AskComponent extends Container {
|
|
|
711
868
|
this.options = options;
|
|
712
869
|
this.allowMultiple = allowMultiple;
|
|
713
870
|
this.allowFreeform = allowFreeform;
|
|
871
|
+
this.allowComment = allowComment;
|
|
714
872
|
this.tui = tui;
|
|
715
873
|
this.theme = theme;
|
|
716
874
|
this.keybindings = keybindings;
|
|
@@ -815,7 +973,8 @@ class AskComponent extends Container {
|
|
|
815
973
|
|
|
816
974
|
private updateStaticText(): void {
|
|
817
975
|
const theme = this.theme;
|
|
818
|
-
this.
|
|
976
|
+
const title = this.mode === "comment" ? "Optional comment" : "Question";
|
|
977
|
+
this.titleText.setText(theme.fg("accent", theme.bold(title)));
|
|
819
978
|
this.questionText.setText(theme.fg("text", theme.bold(this.question)));
|
|
820
979
|
if (this.contextComponent && this.context) {
|
|
821
980
|
if (this.contextComponent instanceof Markdown) {
|
|
@@ -832,12 +991,12 @@ class AskComponent extends Container {
|
|
|
832
991
|
|
|
833
992
|
private updateHelpText(): void {
|
|
834
993
|
const theme = this.theme;
|
|
835
|
-
if (this.mode === "freeform") {
|
|
994
|
+
if (this.mode === "freeform" || this.mode === "comment") {
|
|
836
995
|
const alternateCancelKeys = this.keybindings
|
|
837
996
|
.getKeys("tui.select.cancel")
|
|
838
997
|
.filter((key) => key !== "escape" && key !== "esc");
|
|
839
998
|
const hints = [
|
|
840
|
-
keybindingHint(theme, this.keybindings, "tui.input.submit", "submit"),
|
|
999
|
+
keybindingHint(theme, this.keybindings, "tui.input.submit", this.mode === "comment" ? "submit/skip" : "submit"),
|
|
841
1000
|
keybindingHint(theme, this.keybindings, "tui.input.newLine", "newline"),
|
|
842
1001
|
literalHint(theme, "esc", "back"),
|
|
843
1002
|
alternateCancelKeys.length > 0 ? literalHint(theme, formatKeyList(alternateCancelKeys), "cancel") : null,
|
|
@@ -852,9 +1011,12 @@ class AskComponent extends Container {
|
|
|
852
1011
|
const hints = [
|
|
853
1012
|
literalHint(theme, "↑↓", "navigate"),
|
|
854
1013
|
literalHint(theme, "space", "toggle"),
|
|
1014
|
+
this.allowComment ? literalHint(theme, "ctrl+g", "toggle context") : null,
|
|
855
1015
|
keybindingHint(theme, this.keybindings, "tui.select.confirm", "submit"),
|
|
856
1016
|
keybindingHint(theme, this.keybindings, "tui.select.cancel", "cancel"),
|
|
857
|
-
]
|
|
1017
|
+
]
|
|
1018
|
+
.filter((hint): hint is string => !!hint)
|
|
1019
|
+
.join(" • ");
|
|
858
1020
|
this.helpText.setText(theme.fg("dim", hints));
|
|
859
1021
|
} else {
|
|
860
1022
|
const alternateCancelKeys = this.keybindings
|
|
@@ -864,6 +1026,7 @@ class AskComponent extends Container {
|
|
|
864
1026
|
literalHint(theme, "type", "filter"),
|
|
865
1027
|
keybindingHint(theme, this.keybindings, "tui.editor.deleteCharBackward", "erase"),
|
|
866
1028
|
literalHint(theme, "↑↓", "navigate"),
|
|
1029
|
+
this.allowComment ? literalHint(theme, "ctrl+g", "toggle context") : null,
|
|
867
1030
|
keybindingHint(theme, this.keybindings, "tui.select.confirm", "select"),
|
|
868
1031
|
literalHint(theme, "esc", "clear/cancel"),
|
|
869
1032
|
alternateCancelKeys.length > 0
|
|
@@ -879,8 +1042,14 @@ class AskComponent extends Container {
|
|
|
879
1042
|
private ensureSingleSelectList(): WrappedSingleSelectList {
|
|
880
1043
|
if (this.singleSelectList) return this.singleSelectList;
|
|
881
1044
|
|
|
882
|
-
const list = new WrappedSingleSelectList(
|
|
883
|
-
|
|
1045
|
+
const list = new WrappedSingleSelectList(
|
|
1046
|
+
this.options,
|
|
1047
|
+
this.allowFreeform,
|
|
1048
|
+
this.allowComment,
|
|
1049
|
+
this.theme,
|
|
1050
|
+
this.keybindings,
|
|
1051
|
+
);
|
|
1052
|
+
list.onSubmit = (result) => this.handleSelectionSubmit([result], list.isCommentEnabled());
|
|
884
1053
|
list.onCancel = () => this.onDone(null);
|
|
885
1054
|
list.onEnterFreeform = () => this.showFreeformMode();
|
|
886
1055
|
|
|
@@ -891,9 +1060,15 @@ class AskComponent extends Container {
|
|
|
891
1060
|
private ensureMultiSelectList(): MultiSelectList {
|
|
892
1061
|
if (this.multiSelectList) return this.multiSelectList;
|
|
893
1062
|
|
|
894
|
-
const list = new MultiSelectList(
|
|
1063
|
+
const list = new MultiSelectList(
|
|
1064
|
+
this.options,
|
|
1065
|
+
this.allowFreeform,
|
|
1066
|
+
this.allowComment,
|
|
1067
|
+
this.theme,
|
|
1068
|
+
this.keybindings,
|
|
1069
|
+
);
|
|
895
1070
|
list.onCancel = () => this.onDone(null);
|
|
896
|
-
list.onSubmit = (result) => this.
|
|
1071
|
+
list.onSubmit = (result) => this.handleSelectionSubmit(result, list.isCommentEnabled());
|
|
897
1072
|
list.onEnterFreeform = () => this.showFreeformMode();
|
|
898
1073
|
|
|
899
1074
|
this.multiSelectList = list;
|
|
@@ -905,15 +1080,63 @@ class AskComponent extends Container {
|
|
|
905
1080
|
const editor = new Editor(this.tui, createEditorTheme(this.theme));
|
|
906
1081
|
editor.disableSubmit = false;
|
|
907
1082
|
editor.onSubmit = (text: string) => {
|
|
908
|
-
|
|
909
|
-
this.onDone(trimmed ? { answer: trimmed, wasCustom: true } : null);
|
|
1083
|
+
this.handleEditorSubmit(text);
|
|
910
1084
|
};
|
|
911
1085
|
this.editor = editor;
|
|
912
1086
|
return editor;
|
|
913
1087
|
}
|
|
914
1088
|
|
|
1089
|
+
private saveEditorDraft(): void {
|
|
1090
|
+
if (!this.editor) return;
|
|
1091
|
+
const getText = (this.editor as any).getText;
|
|
1092
|
+
if (typeof getText !== "function") return;
|
|
1093
|
+
|
|
1094
|
+
const currentText = String(getText.call(this.editor) ?? "");
|
|
1095
|
+
if (this.mode === "freeform") {
|
|
1096
|
+
this.freeformDraft = currentText;
|
|
1097
|
+
} else if (this.mode === "comment") {
|
|
1098
|
+
this.commentDraft = currentText;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
private setEditorText(text: string): void {
|
|
1103
|
+
const editor = this.ensureEditor();
|
|
1104
|
+
const setText = (editor as any).setText;
|
|
1105
|
+
if (typeof setText === "function") {
|
|
1106
|
+
setText.call(editor, text);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
private handleSelectionSubmit(selections: string[], wantsComment: boolean): void {
|
|
1111
|
+
if (this.allowComment && wantsComment) {
|
|
1112
|
+
this.pendingSelections = selections;
|
|
1113
|
+
this.commentDraft = "";
|
|
1114
|
+
this.showCommentMode();
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
this.onDone(createSelectionResponse(selections));
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
private handleEditorSubmit(text: string): void {
|
|
1122
|
+
if (this.mode === "freeform") {
|
|
1123
|
+
this.onDone(createFreeformResponse(text));
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
if (this.mode === "comment") {
|
|
1128
|
+
this.commentDraft = text;
|
|
1129
|
+
this.onDone(createSelectionResponse(this.pendingSelections, text));
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
915
1133
|
private showSelectMode(): void {
|
|
1134
|
+
if (this.mode === "freeform" || this.mode === "comment") {
|
|
1135
|
+
this.saveEditorDraft();
|
|
1136
|
+
}
|
|
1137
|
+
|
|
916
1138
|
this.mode = "select";
|
|
1139
|
+
this.pendingSelections = [];
|
|
917
1140
|
this.modeContainer.clear();
|
|
918
1141
|
|
|
919
1142
|
if (this.allowMultiple) {
|
|
@@ -928,10 +1151,15 @@ class AskComponent extends Container {
|
|
|
928
1151
|
}
|
|
929
1152
|
|
|
930
1153
|
private showFreeformMode(): void {
|
|
1154
|
+
if (this.mode === "comment") {
|
|
1155
|
+
this.saveEditorDraft();
|
|
1156
|
+
}
|
|
1157
|
+
|
|
931
1158
|
this.mode = "freeform";
|
|
932
1159
|
this.modeContainer.clear();
|
|
933
1160
|
|
|
934
1161
|
const editor = this.ensureEditor();
|
|
1162
|
+
this.setEditorText(this.freeformDraft);
|
|
935
1163
|
(editor as any).focused = this._focused;
|
|
936
1164
|
|
|
937
1165
|
this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold("Custom response")), 1, 0));
|
|
@@ -943,8 +1171,31 @@ class AskComponent extends Container {
|
|
|
943
1171
|
this.tui.requestRender();
|
|
944
1172
|
}
|
|
945
1173
|
|
|
946
|
-
|
|
1174
|
+
private showCommentMode(): void {
|
|
947
1175
|
if (this.mode === "freeform") {
|
|
1176
|
+
this.saveEditorDraft();
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
this.mode = "comment";
|
|
1180
|
+
this.modeContainer.clear();
|
|
1181
|
+
|
|
1182
|
+
const editor = this.ensureEditor();
|
|
1183
|
+
this.setEditorText(this.commentDraft);
|
|
1184
|
+
(editor as any).focused = this._focused;
|
|
1185
|
+
|
|
1186
|
+
const selectedLabel = this.pendingSelections.length === 1 ? "Selected option:" : "Selected options:";
|
|
1187
|
+
this.modeContainer.addChild(new Text(this.theme.fg("accent", this.theme.bold(selectedLabel)), 1, 0));
|
|
1188
|
+
this.modeContainer.addChild(new Text(this.theme.fg("text", this.pendingSelections.join(", ")), 1, 0));
|
|
1189
|
+
this.modeContainer.addChild(new Spacer(1));
|
|
1190
|
+
this.modeContainer.addChild(editor);
|
|
1191
|
+
|
|
1192
|
+
this.updateHelpText();
|
|
1193
|
+
this.invalidate();
|
|
1194
|
+
this.tui.requestRender();
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
handleInput(data: string): void {
|
|
1198
|
+
if (this.mode === "freeform" || this.mode === "comment") {
|
|
948
1199
|
if (matchesKey(data, Key.escape)) {
|
|
949
1200
|
this.showSelectMode();
|
|
950
1201
|
return;
|
|
@@ -960,7 +1211,6 @@ class AskComponent extends Container {
|
|
|
960
1211
|
return;
|
|
961
1212
|
}
|
|
962
1213
|
|
|
963
|
-
// Selection mode
|
|
964
1214
|
if (this.allowMultiple) {
|
|
965
1215
|
this.ensureMultiSelectList().handleInput?.(data);
|
|
966
1216
|
this.tui.requestRender();
|
|
@@ -983,38 +1233,58 @@ async function askViaDialogs(
|
|
|
983
1233
|
options: QuestionOption[],
|
|
984
1234
|
allowMultiple: boolean,
|
|
985
1235
|
allowFreeform: boolean,
|
|
1236
|
+
allowComment: boolean,
|
|
986
1237
|
timeout?: number,
|
|
987
1238
|
): Promise<AskUIResult | null> {
|
|
988
1239
|
const dialogOpts = timeout ? { timeout } : undefined;
|
|
989
1240
|
const prompt = context ? `${question}\n\nContext:\n${context}` : question;
|
|
990
1241
|
|
|
991
1242
|
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
1243
|
const optionList = formatOptionsForMessage(options);
|
|
995
|
-
const
|
|
1244
|
+
const rawSelections = await ui.input(
|
|
996
1245
|
`${prompt}\n\nOptions (select one or more):\n${optionList}`,
|
|
997
1246
|
"Type your selection(s)...",
|
|
998
1247
|
dialogOpts,
|
|
999
1248
|
) as string | undefined;
|
|
1000
|
-
if (
|
|
1001
|
-
|
|
1249
|
+
if (isCancelledInput(rawSelections)) return null;
|
|
1250
|
+
|
|
1251
|
+
const selections = parseDialogSelections(rawSelections);
|
|
1252
|
+
if (selections.length === 0) return null;
|
|
1253
|
+
|
|
1254
|
+
if (!allowComment) {
|
|
1255
|
+
return createSelectionResponse(selections);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
const comment = await ui.input(
|
|
1259
|
+
buildCommentPrompt(prompt, selections),
|
|
1260
|
+
"Optional comment (press Enter to skip)...",
|
|
1261
|
+
dialogOpts,
|
|
1262
|
+
) as string | undefined;
|
|
1263
|
+
return createSelectionResponse(selections, comment);
|
|
1002
1264
|
}
|
|
1003
1265
|
|
|
1004
|
-
// Single-select: present options via ctx.ui.select()
|
|
1005
1266
|
const selectOptions = options.map((o) => o.title);
|
|
1006
1267
|
if (allowFreeform) selectOptions.push(FREEFORM_SENTINEL);
|
|
1007
1268
|
|
|
1008
1269
|
const selected = await ui.select(prompt, selectOptions, dialogOpts) as string | undefined;
|
|
1009
|
-
if (
|
|
1270
|
+
if (isCancelledInput(selected)) return null;
|
|
1010
1271
|
|
|
1011
1272
|
if (selected === FREEFORM_SENTINEL) {
|
|
1012
1273
|
const answer = await ui.input(prompt, "Type your answer...", dialogOpts) as string | undefined;
|
|
1013
|
-
if (
|
|
1014
|
-
return
|
|
1274
|
+
if (isCancelledInput(answer)) return null;
|
|
1275
|
+
return createFreeformResponse(answer);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
if (!allowComment) {
|
|
1279
|
+
return createSelectionResponse([selected]);
|
|
1015
1280
|
}
|
|
1016
1281
|
|
|
1017
|
-
|
|
1282
|
+
const comment = await ui.input(
|
|
1283
|
+
buildCommentPrompt(prompt, [selected]),
|
|
1284
|
+
"Optional comment (press Enter to skip)...",
|
|
1285
|
+
dialogOpts,
|
|
1286
|
+
) as string | undefined;
|
|
1287
|
+
return createSelectionResponse([selected], comment);
|
|
1018
1288
|
}
|
|
1019
1289
|
|
|
1020
1290
|
export default function(pi: ExtensionAPI) {
|
|
@@ -1056,6 +1326,9 @@ export default function(pi: ExtensionAPI) {
|
|
|
1056
1326
|
allowFreeform: Type.Optional(
|
|
1057
1327
|
Type.Boolean({ description: "Add a freeform text option. Default: true" }),
|
|
1058
1328
|
),
|
|
1329
|
+
allowComment: Type.Optional(
|
|
1330
|
+
Type.Boolean({ description: "Collect an optional comment after selecting one or more options. Default: false" }),
|
|
1331
|
+
),
|
|
1059
1332
|
timeout: Type.Optional(
|
|
1060
1333
|
Type.Number({ description: "Auto-dismiss after N milliseconds. Returns null (cancelled) when expired." }),
|
|
1061
1334
|
),
|
|
@@ -1065,7 +1338,7 @@ export default function(pi: ExtensionAPI) {
|
|
|
1065
1338
|
if (signal?.aborted) {
|
|
1066
1339
|
return {
|
|
1067
1340
|
content: [{ type: "text", text: "Cancelled" }],
|
|
1068
|
-
details: { question: params.question, options: [],
|
|
1341
|
+
details: { question: params.question, options: [], response: null, cancelled: true } as AskToolDetails,
|
|
1069
1342
|
};
|
|
1070
1343
|
}
|
|
1071
1344
|
|
|
@@ -1075,6 +1348,7 @@ export default function(pi: ExtensionAPI) {
|
|
|
1075
1348
|
options: rawOptions = [],
|
|
1076
1349
|
allowMultiple = false,
|
|
1077
1350
|
allowFreeform = true,
|
|
1351
|
+
allowComment = false,
|
|
1078
1352
|
timeout,
|
|
1079
1353
|
} = params as AskParams;
|
|
1080
1354
|
const options = normalizeOptions(rawOptions);
|
|
@@ -1083,55 +1357,53 @@ export default function(pi: ExtensionAPI) {
|
|
|
1083
1357
|
if (!ctx.hasUI || !ctx.ui) {
|
|
1084
1358
|
const optionText = options.length > 0 ? `\n\nOptions:\n${formatOptionsForMessage(options)}` : "";
|
|
1085
1359
|
const freeformHint = allowFreeform ? "\n\nYou can also answer freely." : "";
|
|
1360
|
+
const commentHint = allowComment ? "\n\nAfter choosing an option, you may add an optional comment." : "";
|
|
1086
1361
|
const contextText = normalizedContext ? `\n\nContext:\n${normalizedContext}` : "";
|
|
1087
1362
|
return {
|
|
1088
1363
|
content: [
|
|
1089
1364
|
{
|
|
1090
1365
|
type: "text",
|
|
1091
|
-
text: `Ask requires interactive mode. Please answer:\n\n${question}${contextText}${optionText}${freeformHint}`,
|
|
1366
|
+
text: `Ask requires interactive mode. Please answer:\n\n${question}${contextText}${optionText}${freeformHint}${commentHint}`,
|
|
1092
1367
|
},
|
|
1093
1368
|
],
|
|
1094
1369
|
isError: true,
|
|
1095
|
-
details: { question, context: normalizedContext, options,
|
|
1370
|
+
details: { question, context: normalizedContext, options, response: null, cancelled: true } as AskToolDetails,
|
|
1096
1371
|
};
|
|
1097
1372
|
}
|
|
1098
1373
|
|
|
1099
|
-
// If no options provided, fall back to freeform input prompt.
|
|
1100
1374
|
if (options.length === 0) {
|
|
1101
1375
|
const prompt = normalizedContext ? `${question}\n\nContext:\n${normalizedContext}` : question;
|
|
1102
1376
|
const answer = await ctx.ui.input(prompt, "Type your answer...", timeout ? { timeout } : undefined);
|
|
1377
|
+
const response = createFreeformResponse(answer);
|
|
1103
1378
|
|
|
1104
|
-
if (!
|
|
1379
|
+
if (!response) {
|
|
1105
1380
|
return {
|
|
1106
1381
|
content: [{ type: "text", text: "User cancelled the question" }],
|
|
1107
|
-
details: { question, context: normalizedContext, options,
|
|
1382
|
+
details: { question, context: normalizedContext, options, response: null, cancelled: true } as AskToolDetails,
|
|
1108
1383
|
};
|
|
1109
1384
|
}
|
|
1110
1385
|
|
|
1111
|
-
pi.events.emit("ask:answered", { question, context: normalizedContext,
|
|
1386
|
+
pi.events.emit("ask:answered", { question, context: normalizedContext, response });
|
|
1112
1387
|
return {
|
|
1113
|
-
content: [{ type: "text", text: `User answered: ${
|
|
1114
|
-
details: { question, context: normalizedContext, options,
|
|
1388
|
+
content: [{ type: "text", text: `User answered: ${formatResponseSummary(response)}` }],
|
|
1389
|
+
details: { question, context: normalizedContext, options, response, cancelled: false } as AskToolDetails,
|
|
1115
1390
|
};
|
|
1116
1391
|
}
|
|
1117
1392
|
|
|
1118
1393
|
onUpdate?.({
|
|
1119
1394
|
content: [{ type: "text", text: "Waiting for user input..." }],
|
|
1120
|
-
details: { question, context: normalizedContext, options,
|
|
1395
|
+
details: { question, context: normalizedContext, options, response: null, cancelled: false },
|
|
1121
1396
|
});
|
|
1122
1397
|
|
|
1123
1398
|
let result: AskUIResult | null;
|
|
1124
1399
|
try {
|
|
1125
|
-
// custom() returns undefined in RPC/headless mode — fall back to dialog methods
|
|
1126
1400
|
const customResult = await ctx.ui.custom<AskUIResult | null>(
|
|
1127
1401
|
(tui, theme, keybindings, done) => {
|
|
1128
|
-
// Wire AbortSignal so agent cancellation auto-dismisses the overlay
|
|
1129
1402
|
if (signal) {
|
|
1130
1403
|
const onAbort = () => done(null);
|
|
1131
1404
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
1132
1405
|
}
|
|
1133
1406
|
|
|
1134
|
-
// Wire timeout for overlay mode
|
|
1135
1407
|
if (timeout && timeout > 0) {
|
|
1136
1408
|
setTimeout(() => done(null), timeout);
|
|
1137
1409
|
}
|
|
@@ -1142,6 +1414,7 @@ export default function(pi: ExtensionAPI) {
|
|
|
1142
1414
|
options,
|
|
1143
1415
|
allowMultiple,
|
|
1144
1416
|
allowFreeform,
|
|
1417
|
+
allowComment,
|
|
1145
1418
|
tui,
|
|
1146
1419
|
theme,
|
|
1147
1420
|
keybindings,
|
|
@@ -1164,7 +1437,7 @@ export default function(pi: ExtensionAPI) {
|
|
|
1164
1437
|
result = customResult;
|
|
1165
1438
|
} else {
|
|
1166
1439
|
// RPC/headless mode: degrade to select()/input() dialog protocol
|
|
1167
|
-
result = await askViaDialogs(ctx.ui, question, normalizedContext, options, allowMultiple, allowFreeform, timeout);
|
|
1440
|
+
result = await askViaDialogs(ctx.ui, question, normalizedContext, options, allowMultiple, allowFreeform, allowComment, timeout);
|
|
1168
1441
|
}
|
|
1169
1442
|
} catch (error) {
|
|
1170
1443
|
const message =
|
|
@@ -1180,25 +1453,23 @@ export default function(pi: ExtensionAPI) {
|
|
|
1180
1453
|
pi.events.emit("ask:cancelled", { question, context: normalizedContext, options });
|
|
1181
1454
|
return {
|
|
1182
1455
|
content: [{ type: "text", text: "User cancelled the question" }],
|
|
1183
|
-
details: { question, context: normalizedContext, options,
|
|
1456
|
+
details: { question, context: normalizedContext, options, response: null, cancelled: true } as AskToolDetails,
|
|
1184
1457
|
};
|
|
1185
1458
|
}
|
|
1186
1459
|
|
|
1187
1460
|
pi.events.emit("ask:answered", {
|
|
1188
1461
|
question,
|
|
1189
1462
|
context: normalizedContext,
|
|
1190
|
-
|
|
1191
|
-
wasCustom: result.wasCustom,
|
|
1463
|
+
response: result,
|
|
1192
1464
|
});
|
|
1193
1465
|
return {
|
|
1194
|
-
content: [{ type: "text", text: `User answered: ${result
|
|
1466
|
+
content: [{ type: "text", text: `User answered: ${formatResponseSummary(result)}` }],
|
|
1195
1467
|
details: {
|
|
1196
1468
|
question,
|
|
1197
1469
|
context: normalizedContext,
|
|
1198
1470
|
options,
|
|
1199
|
-
|
|
1471
|
+
response: result,
|
|
1200
1472
|
cancelled: false,
|
|
1201
|
-
wasCustom: result.wasCustom,
|
|
1202
1473
|
} as AskToolDetails,
|
|
1203
1474
|
};
|
|
1204
1475
|
},
|
|
@@ -1217,6 +1488,9 @@ export default function(pi: ExtensionAPI) {
|
|
|
1217
1488
|
if (args.allowMultiple) {
|
|
1218
1489
|
text += theme.fg("dim", " [multi-select]");
|
|
1219
1490
|
}
|
|
1491
|
+
if (args.allowComment) {
|
|
1492
|
+
text += theme.fg("dim", " [optional comment]");
|
|
1493
|
+
}
|
|
1220
1494
|
return new Text(text, 0, 0);
|
|
1221
1495
|
},
|
|
1222
1496
|
|
|
@@ -1236,36 +1510,34 @@ export default function(pi: ExtensionAPI) {
|
|
|
1236
1510
|
return new Text(theme.fg("muted", waitingText), 0, 0);
|
|
1237
1511
|
}
|
|
1238
1512
|
|
|
1239
|
-
if (!details || details.cancelled) {
|
|
1513
|
+
if (!details || details.cancelled || !details.response) {
|
|
1240
1514
|
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
1241
1515
|
}
|
|
1242
1516
|
|
|
1243
|
-
const
|
|
1517
|
+
const response = details.response;
|
|
1244
1518
|
let text = theme.fg("success", "✓ ");
|
|
1245
|
-
if (
|
|
1519
|
+
if (response.kind === "freeform") {
|
|
1246
1520
|
text += theme.fg("muted", "(wrote) ");
|
|
1247
1521
|
}
|
|
1248
|
-
text += theme.fg("accent",
|
|
1522
|
+
text += theme.fg("accent", formatResponseSummary(response));
|
|
1249
1523
|
|
|
1250
1524
|
if (options.expanded) {
|
|
1251
|
-
const selectedTitles = new Set(
|
|
1252
|
-
answer
|
|
1253
|
-
.split(",")
|
|
1254
|
-
.map((value) => value.trim())
|
|
1255
|
-
.filter(Boolean),
|
|
1256
|
-
);
|
|
1257
1525
|
text += "\n" + theme.fg("dim", `Q: ${details.question}`);
|
|
1258
1526
|
if (details.context) {
|
|
1259
1527
|
text += "\n" + theme.fg("dim", details.context);
|
|
1260
1528
|
}
|
|
1261
|
-
|
|
1529
|
+
|
|
1530
|
+
if (isSelectionResponse(response) && details.options.length > 0) {
|
|
1531
|
+
const selectedTitles = new Set(response.selections);
|
|
1262
1532
|
text += "\n" + theme.fg("dim", "Options:");
|
|
1263
1533
|
for (const opt of details.options) {
|
|
1264
1534
|
const desc = opt.description ? ` — ${opt.description}` : "";
|
|
1265
|
-
const
|
|
1266
|
-
const marker = isSelected ? theme.fg("success", "●") : theme.fg("dim", "○");
|
|
1535
|
+
const marker = selectedTitles.has(opt.title) ? theme.fg("success", "●") : theme.fg("dim", "○");
|
|
1267
1536
|
text += `\n ${marker} ${theme.fg("dim", opt.title)}${theme.fg("dim", desc)}`;
|
|
1268
1537
|
}
|
|
1538
|
+
if (response.comment) {
|
|
1539
|
+
text += `\n${theme.fg("dim", "Comment:")} ${theme.fg("dim", response.comment)}`;
|
|
1540
|
+
}
|
|
1269
1541
|
}
|
|
1270
1542
|
}
|
|
1271
1543
|
|
package/package.json
CHANGED
package/single-select-layout.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface RenderSingleSelectRowsParams {
|
|
|
13
13
|
selectedIndex: number;
|
|
14
14
|
width: number;
|
|
15
15
|
allowFreeform: boolean;
|
|
16
|
+
allowComment?: boolean;
|
|
17
|
+
commentEnabled?: boolean;
|
|
16
18
|
maxRows?: number;
|
|
17
19
|
hideDescriptions?: boolean;
|
|
18
20
|
}
|
|
@@ -70,25 +72,36 @@ interface ItemBlock {
|
|
|
70
72
|
lines: string[];
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
type ListItem =
|
|
76
|
+
| { type: "option"; option: QuestionOption }
|
|
77
|
+
| { type: "comment-toggle"; option: QuestionOption }
|
|
78
|
+
| { type: "freeform"; option: QuestionOption };
|
|
79
|
+
|
|
73
80
|
function buildItemBlocks(
|
|
74
81
|
options: QuestionOption[],
|
|
75
82
|
width: number,
|
|
76
83
|
allowFreeform: boolean,
|
|
84
|
+
allowComment: boolean,
|
|
85
|
+
commentEnabled: boolean,
|
|
77
86
|
selectedIndex: number,
|
|
78
87
|
hideDescriptions = false,
|
|
79
88
|
): ItemBlock[] {
|
|
80
89
|
const normalizedWidth = Math.max(12, width);
|
|
81
90
|
const freeformLabel = "Type something. — Enter a custom response";
|
|
82
|
-
const
|
|
91
|
+
const commentToggleLabel = `${commentEnabled ? "[✓]" : "[ ]"} Add extra context after selection`;
|
|
92
|
+
const allItems: ListItem[] = options.map((option) => ({ type: "option", option }));
|
|
93
|
+
if (allowComment) {
|
|
94
|
+
allItems.push({ type: "comment-toggle", option: { title: commentToggleLabel } });
|
|
95
|
+
}
|
|
83
96
|
if (allowFreeform) {
|
|
84
|
-
allItems.push({ type: "freeform"
|
|
97
|
+
allItems.push({ type: "freeform", option: { title: freeformLabel } });
|
|
85
98
|
}
|
|
86
99
|
|
|
87
100
|
return allItems.map((item, itemIndex) => {
|
|
88
101
|
const pointer = itemIndex === selectedIndex ? "→" : " ";
|
|
89
102
|
const lines: string[] = [];
|
|
90
103
|
|
|
91
|
-
if (item.type === "freeform") {
|
|
104
|
+
if (item.type === "comment-toggle" || item.type === "freeform") {
|
|
92
105
|
const prefix = `${pointer} `;
|
|
93
106
|
const wrapped = wrapText(item.option.title, Math.max(8, normalizedWidth - prefix.length));
|
|
94
107
|
wrapped.forEach((line, lineIndex) => {
|
|
@@ -133,11 +146,13 @@ export function renderSingleSelectRows({
|
|
|
133
146
|
selectedIndex,
|
|
134
147
|
width,
|
|
135
148
|
allowFreeform,
|
|
149
|
+
allowComment = false,
|
|
150
|
+
commentEnabled = false,
|
|
136
151
|
maxRows,
|
|
137
152
|
hideDescriptions,
|
|
138
153
|
}: RenderSingleSelectRowsParams): AnnotatedRow[] {
|
|
139
|
-
const itemCount = options.length + (allowFreeform ? 1 : 0);
|
|
140
|
-
const blocks = buildItemBlocks(options, width, allowFreeform, selectedIndex, hideDescriptions);
|
|
154
|
+
const itemCount = options.length + (allowComment ? 1 : 0) + (allowFreeform ? 1 : 0);
|
|
155
|
+
const blocks = buildItemBlocks(options, width, allowFreeform, allowComment, commentEnabled, selectedIndex, hideDescriptions);
|
|
141
156
|
const allRows = flatten(blocks, selectedIndex);
|
|
142
157
|
|
|
143
158
|
if (!Number.isFinite(maxRows) || !maxRows || maxRows <= 0 || allRows.length <= maxRows) {
|