pi-ask-user 0.6.1 → 0.8.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 +38 -3
- package/index.ts +132 -32
- package/package.json +1 -1
- package/skills/ask-user/SKILL.md +1 -1
- package/skills/ask-user/references/ask-user-skill-extension-spec.md +13 -0
package/README.md
CHANGED
|
@@ -16,7 +16,8 @@ High-quality video: [ask-user-demo.mp4](./media/ask-user-demo.mp4)
|
|
|
16
16
|
- Optional freeform responses
|
|
17
17
|
- User-toggleable extra context on structured selections
|
|
18
18
|
- Context display support
|
|
19
|
-
-
|
|
19
|
+
- Configurable display mode: `overlay` (modal, default) or `inline` (rendered directly in the flow)
|
|
20
|
+
- Runtime overlay toggle: press `alt+o` while the prompt is open to temporarily hide/show the popup so you can read prior agent output, then press `alt+o` again to bring it back
|
|
20
21
|
- Pi-TUI-aligned keybinding and editor behavior
|
|
21
22
|
- Custom TUI rendering for tool calls and results
|
|
22
23
|
- System prompt integration via `promptSnippet` and `promptGuidelines`
|
|
@@ -63,7 +64,8 @@ The registered tool name is:
|
|
|
63
64
|
| `options` | `(string \| {title, description?})[]?` | `[]` | Multiple-choice options |
|
|
64
65
|
| `allowMultiple` | `boolean?` | `false` | Enable multi-select mode |
|
|
65
66
|
| `allowFreeform` | `boolean?` | `true` | Add a "Type something" freeform option |
|
|
66
|
-
| `allowComment` | `boolean?` | `false` | Expose a user-toggleable extra-context option in the
|
|
67
|
+
| `allowComment` | `boolean?` | `false` | Expose a user-toggleable extra-context option in the custom UI (`ctrl+g` or the toggle row) and collect an optional comment in fallback dialogs |
|
|
68
|
+
| `displayMode` | `"overlay" \| "inline"?` | env var or `"overlay"` | Controls custom UI rendering: `overlay` shows the centered modal (current behavior), `inline` renders without overlay framing |
|
|
67
69
|
| `timeout` | `number?` | — | Auto-dismiss after N ms and return `null` if the prompt times out |
|
|
68
70
|
|
|
69
71
|
## Example usage shape
|
|
@@ -78,10 +80,43 @@ The registered tool name is:
|
|
|
78
80
|
],
|
|
79
81
|
"allowMultiple": false,
|
|
80
82
|
"allowFreeform": true,
|
|
81
|
-
"allowComment": true
|
|
83
|
+
"allowComment": true,
|
|
84
|
+
"displayMode": "inline"
|
|
82
85
|
}
|
|
83
86
|
```
|
|
84
87
|
|
|
88
|
+
`displayMode: "inline"` uses the same interaction logic but skips overlay mode when calling `ctx.ui.custom(...)`. RPC/headless fallback behavior is unchanged.
|
|
89
|
+
|
|
90
|
+
## Personal display mode preference
|
|
91
|
+
|
|
92
|
+
Set the `PI_ASK_USER_DISPLAY_MODE` environment variable to configure your preferred default globally. Add it to your shell profile (`~/.zshrc`, `~/.bash_profile`, etc.):
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
export PI_ASK_USER_DISPLAY_MODE=inline
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Effective behavior order:
|
|
99
|
+
|
|
100
|
+
1. Per-call `displayMode` parameter (if provided)
|
|
101
|
+
2. `PI_ASK_USER_DISPLAY_MODE` environment variable (if set to `"overlay"` or `"inline"`)
|
|
102
|
+
3. Fallback default: `"overlay"`
|
|
103
|
+
|
|
104
|
+
Unrecognised values are silently ignored and fall back to `"overlay"`.
|
|
105
|
+
|
|
106
|
+
## Controls
|
|
107
|
+
|
|
108
|
+
While an `ask_user` prompt is open:
|
|
109
|
+
|
|
110
|
+
| Key | Action |
|
|
111
|
+
|-----|--------|
|
|
112
|
+
| `alt+o` | Hide/show the overlay popup so you can read the agent's prior output. Available in `overlay` mode only. The first time you hide it, a notification reminds you that `alt+o` brings it back. |
|
|
113
|
+
| `ctrl+g` | Toggle the optional comment/extra-context row (when `allowComment: true`). |
|
|
114
|
+
| `enter` | Confirm the focused option, submit a freeform response, or submit/skip an optional comment. |
|
|
115
|
+
| `esc` | Clear the search filter, exit freeform/comment mode, or cancel the prompt. |
|
|
116
|
+
| `↑` / `↓` | Navigate options. |
|
|
117
|
+
|
|
118
|
+
If you prefer never to see the overlay, set `displayMode: "inline"` per call or `PI_ASK_USER_DISPLAY_MODE=inline` globally.
|
|
119
|
+
|
|
85
120
|
## Result details
|
|
86
121
|
|
|
87
122
|
All tool results include a structured `details` object for rendering and session state reconstruction:
|
package/index.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
|
|
9
9
|
import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
|
|
10
|
-
import { Type } from "@sinclair/typebox";
|
|
10
|
+
import { Type, type TUnsafe } from "@sinclair/typebox";
|
|
11
11
|
import {
|
|
12
12
|
Container,
|
|
13
13
|
type Component,
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
Markdown,
|
|
22
22
|
type MarkdownTheme,
|
|
23
23
|
matchesKey,
|
|
24
|
+
type OverlayHandle,
|
|
24
25
|
Spacer,
|
|
25
26
|
Text,
|
|
26
27
|
type TUI,
|
|
@@ -33,8 +34,28 @@ import { createRequire } from "node:module";
|
|
|
33
34
|
const _require = createRequire(import.meta.url);
|
|
34
35
|
const ASK_USER_VERSION: string = (_require("./package.json") as { version: string }).version;
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Emit a flat `{ type: "string", enum: [...] }` JSON Schema instead of the
|
|
39
|
+
* `anyOf`/`oneOf` shape that `Type.Union([Type.Literal()])` produces. Google's
|
|
40
|
+
* function-calling API rejects the union form. Local copy of pi-ai's StringEnum
|
|
41
|
+
* to avoid a peer dependency for one helper.
|
|
42
|
+
*/
|
|
43
|
+
function StringEnum<const T extends readonly string[]>(
|
|
44
|
+
values: T,
|
|
45
|
+
options?: { description?: string; default?: T[number] },
|
|
46
|
+
): TUnsafe<T[number]> {
|
|
47
|
+
return Type.Unsafe<T[number]>({
|
|
48
|
+
type: "string",
|
|
49
|
+
enum: [...values],
|
|
50
|
+
...(options?.description ? { description: options.description } : {}),
|
|
51
|
+
...(options?.default !== undefined ? { default: options.default } : {}),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
36
55
|
type AskOptionInput = QuestionOption | string;
|
|
37
56
|
|
|
57
|
+
type AskDisplayMode = "overlay" | "inline";
|
|
58
|
+
|
|
38
59
|
interface AskParams {
|
|
39
60
|
question: string;
|
|
40
61
|
context?: string;
|
|
@@ -42,6 +63,7 @@ interface AskParams {
|
|
|
42
63
|
allowMultiple?: boolean;
|
|
43
64
|
allowFreeform?: boolean;
|
|
44
65
|
allowComment?: boolean;
|
|
66
|
+
displayMode?: AskDisplayMode;
|
|
45
67
|
timeout?: number;
|
|
46
68
|
}
|
|
47
69
|
|
|
@@ -227,6 +249,10 @@ function isCommentToggleKey(data: string): boolean {
|
|
|
227
249
|
return matchesKey(data, Key.ctrl("g"));
|
|
228
250
|
}
|
|
229
251
|
|
|
252
|
+
function isOverlayHideToggleKey(data: string): boolean {
|
|
253
|
+
return matchesKey(data, Key.alt("o"));
|
|
254
|
+
}
|
|
255
|
+
|
|
230
256
|
type AskMode = "select" | "freeform" | "comment";
|
|
231
257
|
|
|
232
258
|
const ASK_OVERLAY_MAX_HEIGHT_RATIO = 0.85;
|
|
@@ -238,6 +264,44 @@ const SINGLE_SELECT_SPLIT_PANE_RIGHT_MIN_WIDTH = 28;
|
|
|
238
264
|
const SINGLE_SELECT_SPLIT_PANE_SEPARATOR = " │ ";
|
|
239
265
|
const FREEFORM_SENTINEL = "\u270f\ufe0f Type custom response...";
|
|
240
266
|
const COMMENT_TOGGLE_LABEL = "Add extra context after selection";
|
|
267
|
+
const OVERLAY_HIDE_TOGGLE_LABEL = "alt+o";
|
|
268
|
+
|
|
269
|
+
function buildCustomUIOptions(
|
|
270
|
+
displayMode: AskDisplayMode,
|
|
271
|
+
onHandle?: (handle: OverlayHandle) => void,
|
|
272
|
+
) {
|
|
273
|
+
switch (displayMode) {
|
|
274
|
+
case "inline":
|
|
275
|
+
return undefined;
|
|
276
|
+
case "overlay":
|
|
277
|
+
return {
|
|
278
|
+
overlay: true,
|
|
279
|
+
overlayOptions: {
|
|
280
|
+
anchor: "center" as const,
|
|
281
|
+
width: ASK_OVERLAY_WIDTH,
|
|
282
|
+
minWidth: ASK_OVERLAY_MIN_WIDTH,
|
|
283
|
+
maxHeight: "85%",
|
|
284
|
+
margin: 1,
|
|
285
|
+
},
|
|
286
|
+
...(onHandle ? { onHandle } : {}),
|
|
287
|
+
};
|
|
288
|
+
default: {
|
|
289
|
+
const _exhaustive: never = displayMode;
|
|
290
|
+
void _exhaustive;
|
|
291
|
+
return {
|
|
292
|
+
overlay: true,
|
|
293
|
+
overlayOptions: {
|
|
294
|
+
anchor: "center" as const,
|
|
295
|
+
width: ASK_OVERLAY_WIDTH,
|
|
296
|
+
minWidth: ASK_OVERLAY_MIN_WIDTH,
|
|
297
|
+
maxHeight: "85%",
|
|
298
|
+
margin: 1,
|
|
299
|
+
},
|
|
300
|
+
...(onHandle ? { onHandle } : {}),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
241
305
|
|
|
242
306
|
class MultiSelectList implements Component {
|
|
243
307
|
private options: QuestionOption[];
|
|
@@ -815,6 +879,7 @@ class AskComponent extends Container {
|
|
|
815
879
|
private allowMultiple: boolean;
|
|
816
880
|
private allowFreeform: boolean;
|
|
817
881
|
private allowComment: boolean;
|
|
882
|
+
private displayMode: AskDisplayMode;
|
|
818
883
|
private tui: TUI;
|
|
819
884
|
private theme: Theme;
|
|
820
885
|
private keybindings: KeybindingsManager;
|
|
@@ -856,6 +921,7 @@ class AskComponent extends Container {
|
|
|
856
921
|
allowMultiple: boolean,
|
|
857
922
|
allowFreeform: boolean,
|
|
858
923
|
allowComment: boolean,
|
|
924
|
+
displayMode: AskDisplayMode,
|
|
859
925
|
tui: TUI,
|
|
860
926
|
theme: Theme,
|
|
861
927
|
keybindings: KeybindingsManager,
|
|
@@ -869,6 +935,7 @@ class AskComponent extends Container {
|
|
|
869
935
|
this.allowMultiple = allowMultiple;
|
|
870
936
|
this.allowFreeform = allowFreeform;
|
|
871
937
|
this.allowComment = allowComment;
|
|
938
|
+
this.displayMode = displayMode;
|
|
872
939
|
this.tui = tui;
|
|
873
940
|
this.theme = theme;
|
|
874
941
|
this.keybindings = keybindings;
|
|
@@ -991,6 +1058,9 @@ class AskComponent extends Container {
|
|
|
991
1058
|
|
|
992
1059
|
private updateHelpText(): void {
|
|
993
1060
|
const theme = this.theme;
|
|
1061
|
+
const overlayHint = this.displayMode === "overlay"
|
|
1062
|
+
? literalHint(theme, OVERLAY_HIDE_TOGGLE_LABEL, "hide")
|
|
1063
|
+
: null;
|
|
994
1064
|
if (this.mode === "freeform" || this.mode === "comment") {
|
|
995
1065
|
const alternateCancelKeys = this.keybindings
|
|
996
1066
|
.getKeys("tui.select.cancel")
|
|
@@ -999,6 +1069,7 @@ class AskComponent extends Container {
|
|
|
999
1069
|
keybindingHint(theme, this.keybindings, "tui.input.submit", this.mode === "comment" ? "submit/skip" : "submit"),
|
|
1000
1070
|
keybindingHint(theme, this.keybindings, "tui.input.newLine", "newline"),
|
|
1001
1071
|
literalHint(theme, "esc", "back"),
|
|
1072
|
+
overlayHint,
|
|
1002
1073
|
alternateCancelKeys.length > 0 ? literalHint(theme, formatKeyList(alternateCancelKeys), "cancel") : null,
|
|
1003
1074
|
]
|
|
1004
1075
|
.filter((hint): hint is string => !!hint)
|
|
@@ -1012,6 +1083,7 @@ class AskComponent extends Container {
|
|
|
1012
1083
|
literalHint(theme, "↑↓", "navigate"),
|
|
1013
1084
|
literalHint(theme, "space", "toggle"),
|
|
1014
1085
|
this.allowComment ? literalHint(theme, "ctrl+g", "toggle context") : null,
|
|
1086
|
+
overlayHint,
|
|
1015
1087
|
keybindingHint(theme, this.keybindings, "tui.select.confirm", "submit"),
|
|
1016
1088
|
keybindingHint(theme, this.keybindings, "tui.select.cancel", "cancel"),
|
|
1017
1089
|
]
|
|
@@ -1027,6 +1099,7 @@ class AskComponent extends Container {
|
|
|
1027
1099
|
keybindingHint(theme, this.keybindings, "tui.editor.deleteCharBackward", "erase"),
|
|
1028
1100
|
literalHint(theme, "↑↓", "navigate"),
|
|
1029
1101
|
this.allowComment ? literalHint(theme, "ctrl+g", "toggle context") : null,
|
|
1102
|
+
overlayHint,
|
|
1030
1103
|
keybindingHint(theme, this.keybindings, "tui.select.confirm", "select"),
|
|
1031
1104
|
literalHint(theme, "esc", "clear/cancel"),
|
|
1032
1105
|
alternateCancelKeys.length > 0
|
|
@@ -1331,6 +1404,11 @@ export default function(pi: ExtensionAPI) {
|
|
|
1331
1404
|
allowComment: Type.Optional(
|
|
1332
1405
|
Type.Boolean({ description: "Collect an optional comment after selecting one or more options. Default: false" }),
|
|
1333
1406
|
),
|
|
1407
|
+
displayMode: Type.Optional(
|
|
1408
|
+
StringEnum(["overlay", "inline"] as const, {
|
|
1409
|
+
description: "UI rendering mode. 'overlay' shows a centered modal, 'inline' renders in-place. Default: PI_ASK_USER_DISPLAY_MODE env var if set, otherwise 'overlay'. Omit to respect the user's configured preference.",
|
|
1410
|
+
}),
|
|
1411
|
+
),
|
|
1334
1412
|
timeout: Type.Optional(
|
|
1335
1413
|
Type.Number({ description: "Auto-dismiss after N milliseconds. Returns null (cancelled) when expired." }),
|
|
1336
1414
|
),
|
|
@@ -1351,8 +1429,13 @@ export default function(pi: ExtensionAPI) {
|
|
|
1351
1429
|
allowMultiple = false,
|
|
1352
1430
|
allowFreeform = true,
|
|
1353
1431
|
allowComment = false,
|
|
1432
|
+
displayMode,
|
|
1354
1433
|
timeout,
|
|
1355
1434
|
} = params as AskParams;
|
|
1435
|
+
const envMode = process.env.PI_ASK_USER_DISPLAY_MODE;
|
|
1436
|
+
const envDisplayMode: AskDisplayMode | undefined =
|
|
1437
|
+
envMode === "overlay" || envMode === "inline" ? envMode : undefined;
|
|
1438
|
+
const effectiveDisplayMode: AskDisplayMode = displayMode ?? envDisplayMode ?? "overlay";
|
|
1356
1439
|
const options = normalizeOptions(rawOptions);
|
|
1357
1440
|
const normalizedContext = context?.trim() || undefined;
|
|
1358
1441
|
|
|
@@ -1398,41 +1481,56 @@ export default function(pi: ExtensionAPI) {
|
|
|
1398
1481
|
});
|
|
1399
1482
|
|
|
1400
1483
|
let result: AskUIResult | null;
|
|
1484
|
+
let overlayHandle: OverlayHandle | undefined;
|
|
1485
|
+
let removeOverlayInputListener: (() => void) | undefined;
|
|
1486
|
+
let hasAnnouncedHide = false;
|
|
1401
1487
|
try {
|
|
1402
|
-
const
|
|
1403
|
-
(
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
}
|
|
1488
|
+
const customFactory = (tui: TUI, theme: Theme, keybindings: KeybindingsManager, done: (result: AskUIResult | null) => void) => {
|
|
1489
|
+
if (signal) {
|
|
1490
|
+
const onAbort = () => done(null);
|
|
1491
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1492
|
+
}
|
|
1408
1493
|
|
|
1409
|
-
|
|
1410
|
-
|
|
1494
|
+
if (timeout && timeout > 0) {
|
|
1495
|
+
setTimeout(() => done(null), timeout);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
return new AskComponent(
|
|
1499
|
+
question,
|
|
1500
|
+
normalizedContext,
|
|
1501
|
+
options,
|
|
1502
|
+
allowMultiple,
|
|
1503
|
+
allowFreeform,
|
|
1504
|
+
allowComment,
|
|
1505
|
+
effectiveDisplayMode,
|
|
1506
|
+
tui,
|
|
1507
|
+
theme,
|
|
1508
|
+
keybindings,
|
|
1509
|
+
done,
|
|
1510
|
+
);
|
|
1511
|
+
};
|
|
1512
|
+
|
|
1513
|
+
// Register a raw terminal input listener for alt+o so the overlay can be
|
|
1514
|
+
// toggled even while it is hidden (hidden overlays do not receive input).
|
|
1515
|
+
// Inline mode does not need this because the prompt is already non-modal.
|
|
1516
|
+
if (effectiveDisplayMode === "overlay" && typeof ctx.ui.onTerminalInput === "function") {
|
|
1517
|
+
removeOverlayInputListener = ctx.ui.onTerminalInput((data) => {
|
|
1518
|
+
if (!isOverlayHideToggleKey(data) || !overlayHandle) return undefined;
|
|
1519
|
+
const nextHidden = !overlayHandle.isHidden();
|
|
1520
|
+
overlayHandle.setHidden(nextHidden);
|
|
1521
|
+
if (nextHidden && !hasAnnouncedHide) {
|
|
1522
|
+
hasAnnouncedHide = true;
|
|
1523
|
+
ctx.ui.notify?.(`ask_user hidden — press ${OVERLAY_HIDE_TOGGLE_LABEL} to reopen`, "info");
|
|
1411
1524
|
}
|
|
1525
|
+
return { consume: true };
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1412
1528
|
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
allowFreeform,
|
|
1419
|
-
allowComment,
|
|
1420
|
-
tui,
|
|
1421
|
-
theme,
|
|
1422
|
-
keybindings,
|
|
1423
|
-
done,
|
|
1424
|
-
);
|
|
1425
|
-
},
|
|
1426
|
-
{
|
|
1427
|
-
overlay: true,
|
|
1428
|
-
overlayOptions: {
|
|
1429
|
-
anchor: "center",
|
|
1430
|
-
width: ASK_OVERLAY_WIDTH,
|
|
1431
|
-
minWidth: ASK_OVERLAY_MIN_WIDTH,
|
|
1432
|
-
maxHeight: "85%",
|
|
1433
|
-
margin: 1,
|
|
1434
|
-
},
|
|
1435
|
-
},
|
|
1529
|
+
const customResult = await ctx.ui.custom<AskUIResult | null>(
|
|
1530
|
+
customFactory,
|
|
1531
|
+
buildCustomUIOptions(effectiveDisplayMode, (handle) => {
|
|
1532
|
+
overlayHandle = handle;
|
|
1533
|
+
}),
|
|
1436
1534
|
);
|
|
1437
1535
|
|
|
1438
1536
|
if (customResult !== undefined) {
|
|
@@ -1449,6 +1547,8 @@ export default function(pi: ExtensionAPI) {
|
|
|
1449
1547
|
isError: true,
|
|
1450
1548
|
details: { error: message },
|
|
1451
1549
|
};
|
|
1550
|
+
} finally {
|
|
1551
|
+
removeOverlayInputListener?.();
|
|
1452
1552
|
}
|
|
1453
1553
|
|
|
1454
1554
|
if (result === null) {
|
package/package.json
CHANGED
package/skills/ask-user/SKILL.md
CHANGED
|
@@ -54,7 +54,7 @@ Call `ask_user` with one decision at a time:
|
|
|
54
54
|
- `options`: 2-5 clear choices when possible
|
|
55
55
|
- `allowMultiple`: `false` unless independent selections are genuinely needed
|
|
56
56
|
- `allowFreeform`: usually `true`
|
|
57
|
-
|
|
57
|
+
- `displayMode` *(optional)*: `"overlay"` (default) or `"inline"`. Use `"inline"` when preceding assistant context (summary, trade-offs, recommendation) is essential to the decision and should remain visible — overlays cover the conversation underneath. The user may set a personal default via the `PI_ASK_USER_DISPLAY_MODE` environment variable; only pass this when you intentionally want to override it for one call.
|
|
58
58
|
### 5) Commit the decision
|
|
59
59
|
After response:
|
|
60
60
|
- restate the decision in plain language
|
|
@@ -66,6 +66,19 @@ Use this protocol whenever the trigger matrix says to ask.
|
|
|
66
66
|
}
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
### Display mode (optional)
|
|
70
|
+
|
|
71
|
+
The `ask_user` tool accepts an optional `displayMode` parameter:
|
|
72
|
+
|
|
73
|
+
- `"overlay"` *(default)*: centered modal; covers the conversation underneath.
|
|
74
|
+
- `"inline"`: rendered in the conversation flow; preceding messages stay visible.
|
|
75
|
+
|
|
76
|
+
Guidance:
|
|
77
|
+
|
|
78
|
+
- Omit `displayMode` to respect the user's configured preference (`PI_ASK_USER_DISPLAY_MODE` environment variable).
|
|
79
|
+
- Pass `"inline"` only when the immediately preceding assistant message (summary, trade-offs, recommendation) is the primary context for the decision and must remain visible.
|
|
80
|
+
- Pass `"overlay"` only to explicitly force the modal style (rare).
|
|
81
|
+
|
|
69
82
|
### Requirement-priority decision
|
|
70
83
|
|
|
71
84
|
```json
|