@pi-unipi/ask-user 0.1.3 → 0.1.4

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/ask-ui.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * Uses ctx.ui.custom() callback pattern following question.ts/questionnaire.ts.
6
6
  */
7
7
 
8
- import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
8
+ import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
9
9
  import type { NormalizedOption, AskUserResponse } from "./types.js";
10
10
 
11
11
  /** Result returned by the ask UI */
@@ -58,9 +58,11 @@ export function renderAskUI(params: {
58
58
  // State
59
59
  let optionIndex = 0;
60
60
  let editMode = false;
61
+ let editTarget: "freeform" | number = "freeform"; // which option is being edited
61
62
  let cachedLines: string[] | undefined;
62
63
  const selected = new Set<string>();
63
- let customText: string | null = null; // Store custom text
64
+ let customText: string | null = null; // Store custom text (global freeform)
65
+ const optionCustomTexts = new Map<string, string>(); // Per-option custom text for allowCustom
64
66
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
65
67
  let remainingMs = timeout;
66
68
 
@@ -77,21 +79,72 @@ export function renderAskUI(params: {
77
79
  };
78
80
  const editor = new Editor(tui, editorTheme);
79
81
 
82
+ function getOptionCustomText(optIndex: number): string | null {
83
+ const opt = displayOptions[optIndex];
84
+ return optionCustomTexts.get(opt.value) ?? null;
85
+ }
86
+
87
+ function setOptionCustomText(optIndex: number, text: string | null) {
88
+ const opt = displayOptions[optIndex];
89
+ if (text) {
90
+ optionCustomTexts.set(opt.value, text);
91
+ } else {
92
+ optionCustomTexts.delete(opt.value);
93
+ }
94
+ }
95
+
96
+ /** Get effective action for an option (allowCustom maps to input) */
97
+ function getAction(opt: NormalizedOption & { isFreeform?: boolean }): string {
98
+ if (opt.isFreeform) return "freeform";
99
+ if (opt.action && opt.action !== "select") return opt.action;
100
+ if (opt.allowCustom) return "input";
101
+ return "select";
102
+ }
103
+
80
104
  editor.onSubmit = (value: string) => {
81
105
  const trimmed = value.trim();
82
- if (trimmed) {
83
- customText = trimmed;
84
- editMode = false;
85
- editor.setText("");
86
- refresh();
106
+ if (editTarget === "freeform") {
107
+ // Global freeform input
108
+ if (trimmed) {
109
+ customText = trimmed;
110
+ editMode = false;
111
+ editor.setText("");
112
+ refresh();
113
+ } else {
114
+ // If empty and no previous custom text, uncheck freeform option
115
+ if (!customText) {
116
+ selected.delete("__freeform__");
117
+ }
118
+ editMode = false;
119
+ editor.setText("");
120
+ refresh();
121
+ }
87
122
  } else {
88
- // If empty and no previous custom text, uncheck freeform option
89
- if (!customText) {
90
- selected.delete("__freeform__");
123
+ // Per-option custom input (allowCustom)
124
+ if (trimmed) {
125
+ setOptionCustomText(editTarget, trimmed);
126
+ editMode = false;
127
+ editor.setText("");
128
+ // Auto-submit in single-select mode
129
+ if (!allowMultiple) {
130
+ cleanup();
131
+ const opt = displayOptions[editTarget];
132
+ done({
133
+ response: {
134
+ kind: "combined",
135
+ selections: [opt.value],
136
+ text: trimmed,
137
+ },
138
+ });
139
+ return;
140
+ }
141
+ refresh();
142
+ } else {
143
+ // Empty: cancel edit mode, keep option selected but without custom text
144
+ editMode = false;
145
+ editor.setText("");
146
+ refresh();
91
147
  }
92
- editMode = false;
93
- editor.setText("");
94
- refresh();
95
148
  }
96
149
  };
97
150
 
@@ -137,10 +190,13 @@ export function renderAskUI(params: {
137
190
  if (editMode) {
138
191
  if (matchesKey(data, Key.escape)) {
139
192
  // Cancel text input
140
- if (!customText) {
141
- // No custom text yet: uncheck freeform option
142
- selected.delete("__freeform__");
193
+ if (editTarget === "freeform") {
194
+ // Global freeform: uncheck if no previous text
195
+ if (!customText) {
196
+ selected.delete("__freeform__");
197
+ }
143
198
  }
199
+ // For per-option: just cancel edit, keep option selected
144
200
  editMode = false;
145
201
  editor.setText("");
146
202
  refresh();
@@ -167,17 +223,41 @@ export function renderAskUI(params: {
167
223
  if (allowMultiple && matchesKey(data, Key.space)) {
168
224
  const opt = displayOptions[optionIndex];
169
225
  const val = opt.value;
170
- if (opt.isFreeform) {
226
+ const action = getAction(opt);
227
+
228
+ if (action === "freeform") {
171
229
  // Freeform option: toggle and enter edit mode if checking
172
230
  if (selected.has(val)) {
173
- // Unchecking: clear custom text
174
231
  selected.delete(val);
175
232
  customText = null;
176
233
  } else {
177
- // Checking: enter edit mode to get custom text
178
234
  selected.add(val);
179
235
  if (!customText) {
180
236
  editMode = true;
237
+ editTarget = "freeform";
238
+ editor.setText("");
239
+ }
240
+ }
241
+ } else if (action === "end_turn") {
242
+ // End turn: immediate
243
+ cleanup();
244
+ done({ response: { kind: "end_turn", selections: [val] } });
245
+ return;
246
+ } else if (action === "new_session") {
247
+ // New session: immediate
248
+ cleanup();
249
+ done({ response: { kind: "new_session", selections: [val], prefill: opt.prefill } });
250
+ return;
251
+ } else if (action === "input") {
252
+ // Input action: toggle and enter edit mode if checking
253
+ if (selected.has(val)) {
254
+ selected.delete(val);
255
+ optionCustomTexts.delete(val);
256
+ } else {
257
+ selected.add(val);
258
+ if (!getOptionCustomText(optionIndex)) {
259
+ editMode = true;
260
+ editTarget = optionIndex;
181
261
  editor.setText("");
182
262
  }
183
263
  }
@@ -196,74 +276,82 @@ export function renderAskUI(params: {
196
276
  // Enter: select or submit
197
277
  if (matchesKey(data, Key.enter)) {
198
278
  const opt = displayOptions[optionIndex];
279
+ const action = getAction(opt);
199
280
 
200
- if (opt.isFreeform) {
281
+ if (action === "freeform") {
201
282
  // Freeform option: if already checked with text, submit; otherwise enter edit mode
202
283
  if (selected.has(opt.value) && customText) {
203
- // Already checked with text: submit the form
204
284
  cleanup();
205
285
  const regularSelections = Array.from(selected).filter(v => v !== "__freeform__");
206
286
  if (regularSelections.length > 0) {
207
- done({
208
- response: {
209
- kind: "combined",
210
- selections: regularSelections,
211
- text: customText,
212
- },
213
- });
287
+ done({ response: { kind: "combined", selections: regularSelections, text: customText } });
214
288
  } else {
215
- done({
216
- response: {
217
- kind: "freeform",
218
- text: customText,
219
- },
220
- });
289
+ done({ response: { kind: "freeform", text: customText } });
221
290
  }
222
291
  } else {
223
- // Not checked or no text: check it and enter edit mode
224
292
  selected.add(opt.value);
225
293
  editMode = true;
294
+ editTarget = "freeform";
226
295
  editor.setText("");
227
296
  }
228
297
  refresh();
229
298
  return;
230
299
  }
231
300
 
301
+ if (action === "end_turn") {
302
+ cleanup();
303
+ done({ response: { kind: "end_turn", selections: [opt.value] } });
304
+ return;
305
+ }
306
+
307
+ if (action === "new_session") {
308
+ cleanup();
309
+ done({ response: { kind: "new_session", selections: [opt.value], prefill: opt.prefill } });
310
+ return;
311
+ }
312
+
232
313
  if (allowMultiple) {
233
314
  // In multi-select, Enter submits current selection
234
315
  if (selected.size > 0) {
235
316
  cleanup();
236
317
  if (customText && selected.has("__freeform__")) {
237
- // Combined response with custom text
238
318
  const regularSelections = Array.from(selected).filter(v => v !== "__freeform__");
239
- done({
240
- response: {
241
- kind: "combined",
242
- selections: regularSelections,
243
- text: customText,
244
- },
245
- });
319
+ done({ response: { kind: "combined", selections: regularSelections, text: customText } });
246
320
  } else {
247
- // Regular selection
248
- done({
249
- response: {
250
- kind: "selection",
251
- selections: Array.from(selected),
252
- },
253
- });
321
+ const selections = Array.from(selected);
322
+ const combinedTexts: string[] = [];
323
+ for (const sel of selections) {
324
+ const txt = optionCustomTexts.get(sel);
325
+ if (txt) combinedTexts.push(`${sel}: ${txt}`);
326
+ }
327
+ if (combinedTexts.length > 0) {
328
+ done({ response: { kind: "combined", selections, text: combinedTexts.join("\n") } });
329
+ } else {
330
+ done({ response: { kind: "selection", selections } });
331
+ }
254
332
  }
255
333
  }
256
334
  return;
257
335
  }
258
336
 
259
- // Single-select: return immediately
337
+ // Single-select: check if option has input action
338
+ if (action === "input") {
339
+ const existing = getOptionCustomText(optionIndex);
340
+ if (existing) {
341
+ cleanup();
342
+ done({ response: { kind: "combined", selections: [opt.value], text: existing } });
343
+ } else {
344
+ editMode = true;
345
+ editTarget = optionIndex;
346
+ editor.setText("");
347
+ refresh();
348
+ }
349
+ return;
350
+ }
351
+
352
+ // Single-select without special action: return immediately
260
353
  cleanup();
261
- done({
262
- response: {
263
- kind: "selection",
264
- selections: [opt.value],
265
- },
266
- });
354
+ done({ response: { kind: "selection", selections: [opt.value] } });
267
355
  return;
268
356
  }
269
357
 
@@ -278,39 +366,78 @@ export function renderAskUI(params: {
278
366
  if (cachedLines) return cachedLines;
279
367
 
280
368
  const lines: string[] = [];
281
- const add = (s: string) => lines.push(truncateToWidth(s, width));
369
+ const innerWidth = Math.max(40, width - 2);
370
+ const border = (s: string) => theme.fg("accent", s);
282
371
 
283
- add(theme.fg("accent", "─".repeat(width)));
372
+ function padVisible(content: string, targetWidth: number): string {
373
+ const vw = visibleWidth(content);
374
+ const pad = Math.max(0, targetWidth - vw);
375
+ return content + " ".repeat(pad);
376
+ }
377
+
378
+ const add = (s: string) => lines.push(border("│") + padVisible(truncateToWidth(s, innerWidth), innerWidth) + border("│"));
379
+ const addWrapped = (s: string) => {
380
+ for (const line of wrapTextWithAnsi(s, innerWidth)) {
381
+ lines.push(border("│") + padVisible(truncateToWidth(line, innerWidth), innerWidth) + border("│"));
382
+ }
383
+ };
384
+ const addEmpty = () => lines.push(border("│") + " ".repeat(innerWidth) + border("│"));
385
+
386
+ // Top border
387
+ lines.push(border(`╭${"─".repeat(innerWidth)}╮`));
284
388
 
285
389
  // Context
286
390
  if (context) {
287
- add(theme.fg("muted", ` ${context}`));
288
- lines.push("");
391
+ addWrapped(theme.fg("muted", ` ${context}`));
392
+ addEmpty();
289
393
  }
290
394
 
291
395
  // Question
292
- add(theme.fg("text", ` ${question}`));
293
- lines.push("");
396
+ addWrapped(theme.fg("text", ` ${question}`));
397
+ addEmpty();
294
398
 
295
399
  // Options (editor is now inline with freeform option)
296
- renderOptions(lines, add, theme, width);
400
+ renderOptions(lines, add, theme, innerWidth);
297
401
 
298
402
  // Timeout countdown
299
403
  if (timeout && remainingMs !== undefined && remainingMs > 0) {
300
- lines.push("");
404
+ addEmpty();
301
405
  const secs = Math.ceil(remainingMs / 1000);
302
406
  add(theme.fg("dim", ` ⏱ ${secs}s remaining`));
303
407
  }
304
408
 
305
- lines.push("");
409
+ addEmpty();
306
410
  if (editMode) {
307
411
  add(theme.fg("dim", " Enter to confirm text • Esc to cancel text input"));
308
412
  } else if (allowMultiple) {
309
- add(theme.fg("dim", " ↑↓ navigate • Space toggle • Enter submit • Esc cancel"));
413
+ const currentOpt = displayOptions[optionIndex];
414
+ const action = currentOpt ? getAction(currentOpt) : "select";
415
+ const base = " ↑↓ navigate • Space toggle • Enter submit • Esc cancel";
416
+ let hint = "";
417
+ if (action === "input" && !optionCustomTexts.get(currentOpt.value)) {
418
+ hint = " • Space to add note";
419
+ } else if (action === "end_turn") {
420
+ hint = " • Space to end turn";
421
+ } else if (action === "new_session") {
422
+ hint = " • Space to start new session";
423
+ }
424
+ add(theme.fg("dim", base + hint));
310
425
  } else {
311
- add(theme.fg("dim", " ↑↓ navigate • Enter select • Esc cancel"));
426
+ const currentOpt = displayOptions[optionIndex];
427
+ const action = currentOpt ? getAction(currentOpt) : "select";
428
+ if (action === "input" && !optionCustomTexts.get(currentOpt.value)) {
429
+ add(theme.fg("dim", " ↑↓ navigate • Enter to add note • Esc cancel"));
430
+ } else if (action === "end_turn") {
431
+ add(theme.fg("dim", " ↑↓ navigate • Enter to end turn • Esc cancel"));
432
+ } else if (action === "new_session") {
433
+ add(theme.fg("dim", " ↑↓ navigate • Enter to start new session • Esc cancel"));
434
+ } else {
435
+ add(theme.fg("dim", " ↑↓ navigate • Enter select • Esc cancel"));
436
+ }
312
437
  }
313
- add(theme.fg("accent", "─".repeat(width)));
438
+
439
+ // Bottom border
440
+ lines.push(border(`╰${"─".repeat(innerWidth)}╯`));
314
441
 
315
442
  cachedLines = lines;
316
443
  return lines;
@@ -347,7 +474,7 @@ export function renderAskUI(params: {
347
474
  );
348
475
 
349
476
  // Show edit indicator if in edit mode for this option
350
- if (editMode && isSelected) {
477
+ if (editMode && editTarget === "freeform" && isSelected) {
351
478
  add(` ${theme.fg("muted", "Type your response:")}`);
352
479
  for (const line of editor.render(width - 4)) {
353
480
  add(` ${line}`);
@@ -358,20 +485,59 @@ export function renderAskUI(params: {
358
485
  const checked = selected.has(opt.value);
359
486
  const box = checked ? "✓" : " ";
360
487
  const color = checked ? "success" : isSelected ? "accent" : "text";
488
+
489
+ let label = opt.label;
490
+ const optCustom = optionCustomTexts.get(opt.value);
491
+ if (optCustom) {
492
+ label = `${opt.label}: "${optCustom}"`;
493
+ }
494
+
361
495
  add(
362
496
  prefix +
363
497
  theme.fg(color, `[${box}]`) +
364
498
  " " +
365
- theme.fg(isSelected ? "accent" : "text", opt.label),
499
+ theme.fg(isSelected ? "accent" : "text", label),
366
500
  );
501
+
502
+ // Show edit indicator if in edit mode for this option
503
+ if (editMode && editTarget === i && isSelected) {
504
+ add(` ${theme.fg("muted", "Type your response:")}`);
505
+ for (const line of editor.render(width - 4)) {
506
+ add(` ${line}`);
507
+ }
508
+ }
367
509
  } else {
368
- // Single-select: simple option
510
+ // Single-select: option
511
+ let label = opt.label;
512
+ const optCustom = optionCustomTexts.get(opt.value);
513
+ if (optCustom) {
514
+ label = `${opt.label}: "${optCustom}"`;
515
+ }
516
+
517
+ // Show action indicator
518
+ const action = getAction(opt);
519
+ if (action === "input" && !optCustom) {
520
+ label += theme.fg("dim", " (add note)");
521
+ } else if (action === "end_turn") {
522
+ label += theme.fg("dim", " ↵");
523
+ } else if (action === "new_session") {
524
+ label += theme.fg("dim", " ↗");
525
+ }
526
+
369
527
  add(
370
528
  prefix +
371
529
  (isSelected
372
- ? theme.fg("accent", opt.label)
373
- : theme.fg("text", opt.label)),
530
+ ? theme.fg("accent", label)
531
+ : theme.fg("text", label)),
374
532
  );
533
+
534
+ // Show edit indicator if in edit mode for this option
535
+ if (editMode && editTarget === i && isSelected) {
536
+ add(` ${theme.fg("muted", "Type your response:")}`);
537
+ for (const line of editor.render(width - 4)) {
538
+ add(` ${line}`);
539
+ }
540
+ }
375
541
  }
376
542
 
377
543
  // Description
@@ -466,6 +632,20 @@ export function createRenderResult() {
466
632
  0,
467
633
  );
468
634
  }
635
+ case "end_turn":
636
+ return new Text(
637
+ theme.fg("success", "✓ ") + theme.fg("muted", "end turn"),
638
+ 0,
639
+ 0,
640
+ );
641
+ case "new_session":
642
+ return new Text(
643
+ theme.fg("success", "✓ ") +
644
+ theme.fg("muted", "new session") +
645
+ (response.prefill ? theme.fg("accent", `: ${response.prefill}`) : ""),
646
+ 0,
647
+ 0,
648
+ );
469
649
  default:
470
650
  return new Text(
471
651
  theme.fg("text", JSON.stringify(response)),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ask-user",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Structured user input tool for Pi coding agent — single-select, multi-select, freeform",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/settings-tui.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { Component } from "@mariozechner/pi-tui";
9
- import { truncateToWidth } from "@mariozechner/pi-tui";
9
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
10
10
  import { getAskUserSettings, saveAskUserSettings, type AskUserSettings } from "./config.js";
11
11
 
12
12
  /** ANSI escape codes */
@@ -135,12 +135,24 @@ export class AskUserSettingsOverlay implements Component {
135
135
  */
136
136
  render(width: number): string[] {
137
137
  const lines: string[] = [];
138
- const add = (s: string) => lines.push(truncateToWidth(s, width));
138
+ const innerWidth = Math.max(40, width - 2);
139
+
140
+ function padVisible(content: string, targetWidth: number): string {
141
+ const vw = visibleWidth(content);
142
+ const pad = Math.max(0, targetWidth - vw);
143
+ return content + " ".repeat(pad);
144
+ }
145
+
146
+ const add = (s: string) => lines.push(`${ansi.cyan}│${ansi.reset}` + padVisible(truncateToWidth(s, innerWidth), innerWidth) + `${ansi.cyan}│${ansi.reset}`);
147
+ const addEmpty = () => lines.push(`${ansi.cyan}│${ansi.reset}` + " ".repeat(innerWidth) + `${ansi.cyan}│${ansi.reset}`);
148
+
149
+ // Top border
150
+ lines.push(`${ansi.cyan}╭${"─".repeat(innerWidth)}╮${ansi.reset}`);
139
151
 
140
152
  // Header
141
153
  add(`${ansi.bold}${ansi.cyan}Ask User Settings${ansi.reset}`);
142
154
  add(`${ansi.dim}Configure how the agent can ask you questions${ansi.reset}`);
143
- add("");
155
+ addEmpty();
144
156
 
145
157
  // Settings list
146
158
  for (let i = 0; i < SETTINGS.length; i++) {
@@ -156,9 +168,12 @@ export class AskUserSettingsOverlay implements Component {
156
168
  }
157
169
 
158
170
  // Footer
159
- add("");
171
+ addEmpty();
160
172
  add(`${ansi.dim}↑↓ navigate • Space toggle • Enter save • Esc cancel${ansi.reset}`);
161
173
 
174
+ // Bottom border
175
+ lines.push(`${ansi.cyan}╰${"─".repeat(innerWidth)}╯${ansi.reset}`);
176
+
162
177
  return lines;
163
178
  }
164
179
  }
@@ -38,6 +38,26 @@ Use the `ask_user` tool to collect structured input from the user.
38
38
  | `allowFreeform` | boolean? | true | Add "Custom response" checkable option |
39
39
  | `timeout` | number? | — | Auto-dismiss after N ms |
40
40
 
41
+ ### Option Properties
42
+
43
+ | Property | Type | Default | Description |
44
+ |----------|------|---------|-------------|
45
+ | `label` | string | required | Display label |
46
+ | `description` | string? | — | Description shown below label |
47
+ | `value` | string? | label | Value returned when selected |
48
+ | `allowCustom` | boolean? | false | Allow user to add custom text for this option (shorthand for `action: "input"`) |
49
+ | `action` | string? | "select" | Special action: `"select"`, `"input"`, `"end_turn"`, `"new_session"` |
50
+ | `prefill` | string? | — | Prefill message for `"new_session"` action |
51
+
52
+ ### Action Types
53
+
54
+ | Action | Behavior |
55
+ |--------|----------|
56
+ | `"select"` | Normal selection (default). Returns immediately. |
57
+ | `"input"` | Enters text input mode. Returns `combined` response with selection + text. |
58
+ | `"end_turn"` | Signals end of agent turn. Returns `end_turn` response kind. |
59
+ | `"new_session"` | Starts a new session. Returns `new_session` response kind with optional `prefill`. |
60
+
41
61
  ## Examples
42
62
 
43
63
  Single choice:
@@ -98,3 +118,35 @@ ask_user({
98
118
  })
99
119
  ```
100
120
  User can check "Auth", "Cache", and "Custom response" to type additional features.
121
+
122
+ With per-option custom text:
123
+ ```
124
+ ask_user({
125
+ question: "Does this look right?",
126
+ options: [
127
+ { label: "Yes", value: "yes" },
128
+ { label: "Partially", value: "partial", allowCustom: true },
129
+ { label: "No", value: "no", allowCustom: true }
130
+ ],
131
+ allowFreeform: false
132
+ })
133
+ ```
134
+ Selecting "Partially" or "No" enters text input so the user can explain what needs to change.
135
+
136
+ With end_turn and new_session actions:
137
+ ```
138
+ ask_user({
139
+ question: "How would you like to proceed?",
140
+ options: [
141
+ { label: "Looks good, proceed", value: "proceed" },
142
+ { label: "I want changes", value: "changes", action: "input" },
143
+ { label: "Done for now", value: "done", action: "end_turn" },
144
+ { label: "Start fresh", value: "new", action: "new_session", prefill: "Let's redesign the..." }
145
+ ],
146
+ allowFreeform: false
147
+ })
148
+ ```
149
+ - "Looks good" returns immediately with selection
150
+ - "I want changes" enters text input mode for the user to explain
151
+ - "Done for now" signals the agent to end its turn
152
+ - "Start fresh" starts a new session with the prefill message
package/tools.ts CHANGED
@@ -29,6 +29,9 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
29
29
  "Provide clear options with labels and optional descriptions.",
30
30
  "Use allowMultiple for multi-select scenarios (e.g., choosing features to enable).",
31
31
  "Use allowFreeform: false to restrict to predefined options only.",
32
+ "Use action: 'input' on an option to let the user add custom text before submitting.",
33
+ "Use action: 'end_turn' on an option to let the user signal end of turn.",
34
+ "Use action: 'new_session' with prefill to let the user start a new session.",
32
35
  ],
33
36
  parameters: Type.Object({
34
37
  question: Type.String({
@@ -51,6 +54,33 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
51
54
  description: "Value returned when selected (defaults to label)",
52
55
  }),
53
56
  ),
57
+ allowCustom: Type.Optional(
58
+ Type.Boolean({
59
+ description:
60
+ "When true, selecting this option enters text input mode " +
61
+ "so the user can add a custom comment before submitting.",
62
+ }),
63
+ ),
64
+ action: Type.Optional(
65
+ Type.Union(
66
+ [
67
+ Type.Literal("select"),
68
+ Type.Literal("input"),
69
+ Type.Literal("end_turn"),
70
+ Type.Literal("new_session"),
71
+ ],
72
+ {
73
+ description:
74
+ "Special action: 'select' (default), 'input' (text input), " +
75
+ "'end_turn' (signal end of turn), 'new_session' (start new session with prefill).",
76
+ },
77
+ ),
78
+ ),
79
+ prefill: Type.Optional(
80
+ Type.String({
81
+ description: "Prefill message for new_session action.",
82
+ }),
83
+ ),
54
84
  }),
55
85
  {
56
86
  description:
@@ -86,7 +116,7 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
86
116
  } = params as {
87
117
  question: string;
88
118
  context?: string;
89
- options?: { label: string; description?: string; value?: string }[];
119
+ options?: { label: string; description?: string; value?: string; allowCustom?: boolean; action?: string; prefill?: string }[];
90
120
  allowMultiple?: boolean;
91
121
  allowFreeform?: boolean;
92
122
  timeout?: number;
@@ -211,6 +241,9 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
211
241
  label: opt.label,
212
242
  description: opt.description,
213
243
  value: opt.value ?? opt.label,
244
+ allowCustom: opt.allowCustom ?? false,
245
+ action: (opt.action as NormalizedOption["action"]) ?? "select",
246
+ prefill: opt.prefill,
214
247
  }));
215
248
 
216
249
  // Render interactive UI
@@ -268,6 +301,14 @@ export function registerAskUserTools(pi: ExtensionAPI): void {
268
301
  contentText = `User selected: ${selText} and wrote: ${response.text}`;
269
302
  break;
270
303
  }
304
+ case "end_turn":
305
+ contentText = "User chose to end the turn.";
306
+ break;
307
+ case "new_session":
308
+ contentText = response.prefill
309
+ ? `User chose to start a new session: ${response.prefill}`
310
+ : "User chose to start a new session.";
311
+ break;
271
312
  case "timed_out":
272
313
  contentText = "User did not respond (timed out)";
273
314
  break;
package/types.ts CHANGED
@@ -10,6 +10,18 @@ export interface AskUserOption {
10
10
  description?: string;
11
11
  /** Value returned when selected (defaults to label) */
12
12
  value?: string;
13
+ /** When true, selecting this option allows the user to add custom text before submitting */
14
+ allowCustom?: boolean;
15
+ /**
16
+ * Special action for this option:
17
+ * - "select": normal selection (default)
18
+ * - "input": enter text input mode, return combined response
19
+ * - "end_turn": signal end of agent turn
20
+ * - "new_session": start a new session with optional prefill message
21
+ */
22
+ action?: "select" | "input" | "end_turn" | "new_session";
23
+ /** Prefill message for new_session action */
24
+ prefill?: string;
13
25
  }
14
26
 
15
27
  /** Parameters for ask_user tool */
@@ -31,11 +43,13 @@ export interface AskUserParams {
31
43
  /** Response from ask_user tool */
32
44
  export interface AskUserResponse {
33
45
  /** Response kind */
34
- kind: "selection" | "freeform" | "combined" | "cancelled" | "timed_out";
46
+ kind: "selection" | "freeform" | "combined" | "cancelled" | "timed_out" | "end_turn" | "new_session";
35
47
  /** Selected option values (for selection kind) */
36
48
  selections?: string[];
37
49
  /** Freeform text (for freeform kind) */
38
50
  text?: string;
51
+ /** Prefill message (for new_session kind) */
52
+ prefill?: string;
39
53
  /** Optional user comment */
40
54
  comment?: string;
41
55
  }
@@ -45,4 +59,10 @@ export interface NormalizedOption {
45
59
  label: string;
46
60
  description?: string;
47
61
  value: string;
62
+ /** When true, selecting this option allows the user to add custom text before submitting */
63
+ allowCustom?: boolean;
64
+ /** Special action for this option */
65
+ action?: "select" | "input" | "end_turn" | "new_session";
66
+ /** Prefill message for new_session action */
67
+ prefill?: string;
48
68
  }