@pi-unipi/ask-user 0.1.3 → 0.1.5
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 +254 -74
- package/package.json +1 -1
- package/settings-tui.ts +19 -4
- package/skills/ask-user/SKILL.md +52 -0
- package/tools.ts +42 -1
- package/types.ts +21 -1
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 (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
//
|
|
89
|
-
if (
|
|
90
|
-
|
|
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 (
|
|
141
|
-
//
|
|
142
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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:
|
|
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
|
|
369
|
+
const innerWidth = Math.max(40, width - 2);
|
|
370
|
+
const border = (s: string) => theme.fg("accent", s);
|
|
282
371
|
|
|
283
|
-
|
|
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
|
-
|
|
288
|
-
|
|
391
|
+
addWrapped(theme.fg("muted", ` ${context}`));
|
|
392
|
+
addEmpty();
|
|
289
393
|
}
|
|
290
394
|
|
|
291
395
|
// Question
|
|
292
|
-
|
|
293
|
-
|
|
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,
|
|
400
|
+
renderOptions(lines, add, theme, innerWidth);
|
|
297
401
|
|
|
298
402
|
// Timeout countdown
|
|
299
403
|
if (timeout && remainingMs !== undefined && remainingMs > 0) {
|
|
300
|
-
|
|
404
|
+
addEmpty();
|
|
301
405
|
const secs = Math.ceil(remainingMs / 1000);
|
|
302
406
|
add(theme.fg("dim", ` ⏱ ${secs}s remaining`));
|
|
303
407
|
}
|
|
304
408
|
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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:
|
|
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",
|
|
373
|
-
: theme.fg("text",
|
|
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
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/skills/ask-user/SKILL.md
CHANGED
|
@@ -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
|
}
|