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 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
- - Overlay mode dialog floats over conversation, preserving context
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 overlay (`ctrl+g` or the toggle row) and collect an optional comment in fallback dialogs |
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 customResult = await ctx.ui.custom<AskUIResult | null>(
1403
- (tui, theme, keybindings, done) => {
1404
- if (signal) {
1405
- const onAbort = () => done(null);
1406
- signal.addEventListener("abort", onAbort, { once: true });
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
- if (timeout && timeout > 0) {
1410
- setTimeout(() => done(null), timeout);
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
- return new AskComponent(
1414
- question,
1415
- normalizedContext,
1416
- options,
1417
- allowMultiple,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ask-user",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "Interactive ask_user tool for pi-coding-agent with searchable split-pane selection UI, multi-select, and freeform input",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -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