pi-ask-user 0.8.0 → 0.10.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.
Files changed (3) hide show
  1. package/README.md +23 -7
  2. package/index.ts +121 -20
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -17,7 +17,7 @@ High-quality video: [ask-user-demo.mp4](./media/ask-user-demo.mp4)
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
+ - Runtime overlay toggle: press the configured overlay-toggle key (`alt+o` by default, configurable per call or via env var) while the prompt is open to temporarily hide/show the popup so you can read prior agent output, then press it again to bring it back
21
21
  - Pi-TUI-aligned keybinding and editor behavior
22
22
  - Custom TUI rendering for tool calls and results
23
23
  - System prompt integration via `promptSnippet` and `promptGuidelines`
@@ -66,6 +66,8 @@ The registered tool name is:
66
66
  | `allowFreeform` | `boolean?` | `true` | Add a "Type something" freeform option |
67
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
68
  | `displayMode` | `"overlay" \| "inline"?` | env var or `"overlay"` | Controls custom UI rendering: `overlay` shows the centered modal (current behavior), `inline` renders without overlay framing |
69
+ | `overlayToggleKey` | `string?` | env var or `"alt+o"` | Shortcut for hiding/showing the overlay popup (overlay mode only). Pi-TUI key spec, e.g. `"alt+o"`, `"ctrl+shift+h"`. Pass `"off"` to disable. |
70
+ | `commentToggleKey` | `string?` | env var or `"ctrl+g"` | Shortcut for toggling the optional comment/extra-context row when `allowComment: true`. Pass `"off"` to disable. |
69
71
  | `timeout` | `number?` | — | Auto-dismiss after N ms and return `null` if the prompt times out |
70
72
 
71
73
  ## Example usage shape
@@ -87,30 +89,44 @@ The registered tool name is:
87
89
 
88
90
  `displayMode: "inline"` uses the same interaction logic but skips overlay mode when calling `ctx.ui.custom(...)`. RPC/headless fallback behavior is unchanged.
89
91
 
90
- ## Personal display mode preference
92
+ ## Personal preferences via environment variables
91
93
 
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.):
94
+ Configure your defaults globally by setting these in your shell profile (`~/.zshrc`, `~/.bash_profile`, etc.):
93
95
 
94
96
  ```bash
95
97
  export PI_ASK_USER_DISPLAY_MODE=inline
98
+ export PI_ASK_USER_OVERLAY_TOGGLE_KEY=alt+h
99
+ export PI_ASK_USER_COMMENT_TOGGLE_KEY=alt+c
96
100
  ```
97
101
 
98
- Effective behavior order:
102
+ ### Display mode
103
+
104
+ Effective order:
99
105
 
100
106
  1. Per-call `displayMode` parameter (if provided)
101
- 2. `PI_ASK_USER_DISPLAY_MODE` environment variable (if set to `"overlay"` or `"inline"`)
107
+ 2. `PI_ASK_USER_DISPLAY_MODE` (if set to `"overlay"` or `"inline"`)
102
108
  3. Fallback default: `"overlay"`
103
109
 
104
110
  Unrecognised values are silently ignored and fall back to `"overlay"`.
105
111
 
112
+ ### Shortcuts
113
+
114
+ Effective order for both `overlayToggleKey` and `commentToggleKey`:
115
+
116
+ 1. Per-call parameter (if provided)
117
+ 2. Matching env var (`PI_ASK_USER_OVERLAY_TOGGLE_KEY` / `PI_ASK_USER_COMMENT_TOGGLE_KEY`)
118
+ 3. Built-in defaults: `alt+o` and `ctrl+g`
119
+
120
+ Pass `"off"`, `"none"`, or `"disabled"` (at any level) to disable the shortcut entirely. Invalid specs are silently dropped and the next source is used. Specs follow the Pi-TUI [`KeyId`](https://github.com/earendil-works/pi-mono/blob/main/packages/tui/src/keys.ts) format: `[mod+]...key` where modifiers are `ctrl`, `shift`, `alt`, `super`, in any order, joined by `+` (e.g. `ctrl+g`, `alt+shift+x`, `escape`, `tab`).
121
+
106
122
  ## Controls
107
123
 
108
124
  While an `ask_user` prompt is open:
109
125
 
110
126
  | Key | Action |
111
127
  |-----|--------|
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`). |
128
+ | `alt+o` (configurable via `overlayToggleKey`) | 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 which key brings it back. |
129
+ | `ctrl+g` (configurable via `commentToggleKey`) | Toggle the optional comment/extra-context row (when `allowComment: true`). |
114
130
  | `enter` | Confirm the focused option, submit a freeform response, or submit/skip an optional comment. |
115
131
  | `esc` | Clear the search filter, exit freeform/comment mode, or cancel the prompt. |
116
132
  | `↑` / `↓` | Navigate options. |
package/index.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * and a custom box border instead of manual ANSI box drawing.
6
6
  */
7
7
 
8
- import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
9
- import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
8
+ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
9
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
10
10
  import { Type, type TUnsafe } from "@sinclair/typebox";
11
11
  import {
12
12
  Container,
@@ -27,7 +27,7 @@ import {
27
27
  type TUI,
28
28
  truncateToWidth,
29
29
  wrapTextWithAnsi,
30
- } from "@mariozechner/pi-tui";
30
+ } from "@earendil-works/pi-tui";
31
31
  import { renderSingleSelectRows } from "./single-select-layout";
32
32
 
33
33
  import { createRequire } from "node:module";
@@ -64,6 +64,8 @@ interface AskParams {
64
64
  allowFreeform?: boolean;
65
65
  allowComment?: boolean;
66
66
  displayMode?: AskDisplayMode;
67
+ overlayToggleKey?: string | null;
68
+ commentToggleKey?: string | null;
67
69
  timeout?: number;
68
70
  }
69
71
 
@@ -245,12 +247,63 @@ function literalHint(theme: Theme, key: string, description: string): string {
245
247
  return `${theme.fg("dim", key)}${theme.fg("muted", ` ${description}`)}`;
246
248
  }
247
249
 
248
- function isCommentToggleKey(data: string): boolean {
249
- return matchesKey(data, Key.ctrl("g"));
250
+ type ResolvedShortcut =
251
+ | { disabled: false; spec: string; matches: (data: string) => boolean }
252
+ | { disabled: true; spec: null; matches: (data: string) => false };
253
+
254
+ interface ResolvedAskShortcuts {
255
+ overlayToggle: ResolvedShortcut;
256
+ commentToggle: ResolvedShortcut;
257
+ }
258
+
259
+ const DISABLED_SHORTCUT: ResolvedShortcut = {
260
+ disabled: true,
261
+ spec: null,
262
+ matches: ((_data: string) => false) as (data: string) => false,
263
+ };
264
+
265
+ const SHORTCUT_DISABLE_VALUES = new Set(["off", "none", "disabled", ""]);
266
+
267
+ function normalizeShortcutSpec(value: string | null | undefined): string | null | undefined {
268
+ if (value === undefined) return undefined;
269
+ if (value === null) return null;
270
+ const trimmed = value.trim().toLowerCase();
271
+ if (SHORTCUT_DISABLE_VALUES.has(trimmed)) return null;
272
+ return trimmed;
273
+ }
274
+
275
+ function isValidShortcutSpec(spec: string): boolean {
276
+ // KeyId is canonical lowercase: modifiers (`ctrl|shift|alt|super`) joined by `+`,
277
+ // plus a base key. We do a light syntactic sanity check; matchesKey() does the rest.
278
+ if (!spec) return false;
279
+ if (!/^[a-z0-9+_\-!@#$%^&*()|~`'":;,./<>?[\]{}=\\]+$/i.test(spec)) return false;
280
+ if (spec.startsWith("+") || spec.endsWith("+")) return false;
281
+ if (spec.includes("++")) return false;
282
+ return true;
283
+ }
284
+
285
+ function buildShortcut(spec: string): ResolvedShortcut {
286
+ return {
287
+ disabled: false,
288
+ spec,
289
+ matches: (data: string) => matchesKey(data, spec as any),
290
+ };
250
291
  }
251
292
 
252
- function isOverlayHideToggleKey(data: string): boolean {
253
- return matchesKey(data, Key.alt("o"));
293
+ function resolveShortcut(
294
+ paramValue: string | null | undefined,
295
+ envValue: string | undefined,
296
+ defaultSpec: string,
297
+ ): ResolvedShortcut {
298
+ const candidates: Array<string | null | undefined> = [paramValue, envValue, defaultSpec];
299
+ for (const raw of candidates) {
300
+ const normalized = normalizeShortcutSpec(raw);
301
+ if (normalized === undefined) continue; // not provided, fall through
302
+ if (normalized === null) return DISABLED_SHORTCUT; // explicit disable
303
+ if (isValidShortcutSpec(normalized)) return buildShortcut(normalized);
304
+ // Invalid spec: silently fall through to next candidate.
305
+ }
306
+ return DISABLED_SHORTCUT;
254
307
  }
255
308
 
256
309
  type AskMode = "select" | "freeform" | "comment";
@@ -264,7 +317,8 @@ const SINGLE_SELECT_SPLIT_PANE_RIGHT_MIN_WIDTH = 28;
264
317
  const SINGLE_SELECT_SPLIT_PANE_SEPARATOR = " │ ";
265
318
  const FREEFORM_SENTINEL = "\u270f\ufe0f Type custom response...";
266
319
  const COMMENT_TOGGLE_LABEL = "Add extra context after selection";
267
- const OVERLAY_HIDE_TOGGLE_LABEL = "alt+o";
320
+ const DEFAULT_OVERLAY_TOGGLE_KEY = "alt+o";
321
+ const DEFAULT_COMMENT_TOGGLE_KEY = "ctrl+g";
268
322
 
269
323
  function buildCustomUIOptions(
270
324
  displayMode: AskDisplayMode,
@@ -309,6 +363,7 @@ class MultiSelectList implements Component {
309
363
  private allowComment: boolean;
310
364
  private theme: Theme;
311
365
  private keybindings: KeybindingsManager;
366
+ private commentToggle: ResolvedShortcut;
312
367
  private selectedIndex = 0;
313
368
  private checked = new Set<number>();
314
369
  private commentEnabled = false;
@@ -325,12 +380,14 @@ class MultiSelectList implements Component {
325
380
  allowComment: boolean,
326
381
  theme: Theme,
327
382
  keybindings: KeybindingsManager,
383
+ commentToggle: ResolvedShortcut,
328
384
  ) {
329
385
  this.options = options;
330
386
  this.allowFreeform = allowFreeform;
331
387
  this.allowComment = allowComment;
332
388
  this.theme = theme;
333
389
  this.keybindings = keybindings;
390
+ this.commentToggle = commentToggle;
334
391
  }
335
392
 
336
393
  public isCommentEnabled(): boolean {
@@ -387,7 +444,7 @@ class MultiSelectList implements Component {
387
444
  return;
388
445
  }
389
446
 
390
- if (this.allowComment && isCommentToggleKey(data)) {
447
+ if (this.allowComment && !this.commentToggle.disabled && this.commentToggle.matches(data)) {
391
448
  this.toggleComment();
392
449
  return;
393
450
  }
@@ -531,6 +588,7 @@ class WrappedSingleSelectList implements Component {
531
588
  private allowComment: boolean;
532
589
  private theme: Theme;
533
590
  private keybindings: KeybindingsManager;
591
+ private commentToggle: ResolvedShortcut;
534
592
  private selectedIndex = 0;
535
593
  private searchQuery = "";
536
594
  private commentEnabled = false;
@@ -548,12 +606,14 @@ class WrappedSingleSelectList implements Component {
548
606
  allowComment: boolean,
549
607
  theme: Theme,
550
608
  keybindings: KeybindingsManager,
609
+ commentToggle: ResolvedShortcut,
551
610
  ) {
552
611
  this.options = options;
553
612
  this.allowFreeform = allowFreeform;
554
613
  this.allowComment = allowComment;
555
614
  this.theme = theme;
556
615
  this.keybindings = keybindings;
616
+ this.commentToggle = commentToggle;
557
617
  }
558
618
 
559
619
  public isCommentEnabled(): boolean {
@@ -774,7 +834,7 @@ class WrappedSingleSelectList implements Component {
774
834
  return;
775
835
  }
776
836
 
777
- if (this.allowComment && isCommentToggleKey(data)) {
837
+ if (this.allowComment && !this.commentToggle.disabled && this.commentToggle.matches(data)) {
778
838
  this.toggleComment();
779
839
  return;
780
840
  }
@@ -883,6 +943,7 @@ class AskComponent extends Container {
883
943
  private tui: TUI;
884
944
  private theme: Theme;
885
945
  private keybindings: KeybindingsManager;
946
+ private shortcuts: ResolvedAskShortcuts;
886
947
  private onDone: (result: AskUIResult | null) => void;
887
948
 
888
949
  private mode: AskMode = "select";
@@ -925,6 +986,7 @@ class AskComponent extends Container {
925
986
  tui: TUI,
926
987
  theme: Theme,
927
988
  keybindings: KeybindingsManager,
989
+ shortcuts: ResolvedAskShortcuts,
928
990
  onDone: (result: AskUIResult | null) => void,
929
991
  ) {
930
992
  super();
@@ -939,6 +1001,7 @@ class AskComponent extends Container {
939
1001
  this.tui = tui;
940
1002
  this.theme = theme;
941
1003
  this.keybindings = keybindings;
1004
+ this.shortcuts = shortcuts;
942
1005
  this.onDone = onDone;
943
1006
 
944
1007
  // Layout skeleton
@@ -1058,8 +1121,11 @@ class AskComponent extends Container {
1058
1121
 
1059
1122
  private updateHelpText(): void {
1060
1123
  const theme = this.theme;
1061
- const overlayHint = this.displayMode === "overlay"
1062
- ? literalHint(theme, OVERLAY_HIDE_TOGGLE_LABEL, "hide")
1124
+ const overlayHint = this.displayMode === "overlay" && !this.shortcuts.overlayToggle.disabled
1125
+ ? literalHint(theme, this.shortcuts.overlayToggle.spec, "hide")
1126
+ : null;
1127
+ const commentHint = this.allowComment && !this.shortcuts.commentToggle.disabled
1128
+ ? literalHint(theme, this.shortcuts.commentToggle.spec, "toggle context")
1063
1129
  : null;
1064
1130
  if (this.mode === "freeform" || this.mode === "comment") {
1065
1131
  const alternateCancelKeys = this.keybindings
@@ -1082,7 +1148,7 @@ class AskComponent extends Container {
1082
1148
  const hints = [
1083
1149
  literalHint(theme, "↑↓", "navigate"),
1084
1150
  literalHint(theme, "space", "toggle"),
1085
- this.allowComment ? literalHint(theme, "ctrl+g", "toggle context") : null,
1151
+ commentHint,
1086
1152
  overlayHint,
1087
1153
  keybindingHint(theme, this.keybindings, "tui.select.confirm", "submit"),
1088
1154
  keybindingHint(theme, this.keybindings, "tui.select.cancel", "cancel"),
@@ -1098,7 +1164,7 @@ class AskComponent extends Container {
1098
1164
  literalHint(theme, "type", "filter"),
1099
1165
  keybindingHint(theme, this.keybindings, "tui.editor.deleteCharBackward", "erase"),
1100
1166
  literalHint(theme, "↑↓", "navigate"),
1101
- this.allowComment ? literalHint(theme, "ctrl+g", "toggle context") : null,
1167
+ commentHint,
1102
1168
  overlayHint,
1103
1169
  keybindingHint(theme, this.keybindings, "tui.select.confirm", "select"),
1104
1170
  literalHint(theme, "esc", "clear/cancel"),
@@ -1121,6 +1187,7 @@ class AskComponent extends Container {
1121
1187
  this.allowComment,
1122
1188
  this.theme,
1123
1189
  this.keybindings,
1190
+ this.shortcuts.commentToggle,
1124
1191
  );
1125
1192
  list.onSubmit = (result) => this.handleSelectionSubmit([result], list.isCommentEnabled());
1126
1193
  list.onCancel = () => this.onDone(null);
@@ -1139,6 +1206,7 @@ class AskComponent extends Container {
1139
1206
  this.allowComment,
1140
1207
  this.theme,
1141
1208
  this.keybindings,
1209
+ this.shortcuts.commentToggle,
1142
1210
  );
1143
1211
  list.onCancel = () => this.onDone(null);
1144
1212
  list.onSubmit = (result) => this.handleSelectionSubmit(result, list.isCommentEnabled());
@@ -1409,6 +1477,18 @@ export default function(pi: ExtensionAPI) {
1409
1477
  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
1478
  }),
1411
1479
  ),
1480
+ overlayToggleKey: Type.Optional(
1481
+ Type.String({
1482
+ description:
1483
+ "Shortcut for hiding/showing the overlay popup (overlay mode only), e.g. 'alt+o' or 'ctrl+shift+h'. Pass 'off' to disable. Default: PI_ASK_USER_OVERLAY_TOGGLE_KEY env var if set, otherwise 'alt+o'.",
1484
+ }),
1485
+ ),
1486
+ commentToggleKey: Type.Optional(
1487
+ Type.String({
1488
+ description:
1489
+ "Shortcut for toggling the optional comment/extra-context row when allowComment is true, e.g. 'ctrl+g'. Pass 'off' to disable. Default: PI_ASK_USER_COMMENT_TOGGLE_KEY env var if set, otherwise 'ctrl+g'.",
1490
+ }),
1491
+ ),
1412
1492
  timeout: Type.Optional(
1413
1493
  Type.Number({ description: "Auto-dismiss after N milliseconds. Returns null (cancelled) when expired." }),
1414
1494
  ),
@@ -1430,12 +1510,26 @@ export default function(pi: ExtensionAPI) {
1430
1510
  allowFreeform = true,
1431
1511
  allowComment = false,
1432
1512
  displayMode,
1513
+ overlayToggleKey,
1514
+ commentToggleKey,
1433
1515
  timeout,
1434
1516
  } = params as AskParams;
1435
1517
  const envMode = process.env.PI_ASK_USER_DISPLAY_MODE;
1436
1518
  const envDisplayMode: AskDisplayMode | undefined =
1437
1519
  envMode === "overlay" || envMode === "inline" ? envMode : undefined;
1438
1520
  const effectiveDisplayMode: AskDisplayMode = displayMode ?? envDisplayMode ?? "overlay";
1521
+ const shortcuts: ResolvedAskShortcuts = {
1522
+ overlayToggle: resolveShortcut(
1523
+ overlayToggleKey,
1524
+ process.env.PI_ASK_USER_OVERLAY_TOGGLE_KEY,
1525
+ DEFAULT_OVERLAY_TOGGLE_KEY,
1526
+ ),
1527
+ commentToggle: resolveShortcut(
1528
+ commentToggleKey,
1529
+ process.env.PI_ASK_USER_COMMENT_TOGGLE_KEY,
1530
+ DEFAULT_COMMENT_TOGGLE_KEY,
1531
+ ),
1532
+ };
1439
1533
  const options = normalizeOptions(rawOptions);
1440
1534
  const normalizedContext = context?.trim() || undefined;
1441
1535
 
@@ -1506,21 +1600,28 @@ export default function(pi: ExtensionAPI) {
1506
1600
  tui,
1507
1601
  theme,
1508
1602
  keybindings,
1603
+ shortcuts,
1509
1604
  done,
1510
1605
  );
1511
1606
  };
1512
1607
 
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") {
1608
+ // Register a raw terminal input listener for the overlay-toggle key so the
1609
+ // overlay can be toggled even while it is hidden (hidden overlays do not
1610
+ // receive input). Inline mode does not need this because the prompt is
1611
+ // already non-modal. Skipped entirely if the user disabled the shortcut.
1612
+ const overlayToggle = shortcuts.overlayToggle;
1613
+ if (
1614
+ effectiveDisplayMode === "overlay"
1615
+ && !overlayToggle.disabled
1616
+ && typeof ctx.ui.onTerminalInput === "function"
1617
+ ) {
1517
1618
  removeOverlayInputListener = ctx.ui.onTerminalInput((data) => {
1518
- if (!isOverlayHideToggleKey(data) || !overlayHandle) return undefined;
1619
+ if (!overlayToggle.matches(data) || !overlayHandle) return undefined;
1519
1620
  const nextHidden = !overlayHandle.isHidden();
1520
1621
  overlayHandle.setHidden(nextHidden);
1521
1622
  if (nextHidden && !hasAnnouncedHide) {
1522
1623
  hasAnnouncedHide = true;
1523
- ctx.ui.notify?.(`ask_user hidden — press ${OVERLAY_HIDE_TOGGLE_LABEL} to reopen`, "info");
1624
+ ctx.ui.notify?.(`ask_user hidden — press ${overlayToggle.spec} to reopen`, "info");
1524
1625
  }
1525
1626
  return { consume: true };
1526
1627
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ask-user",
3
- "version": "0.8.0",
3
+ "version": "0.10.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": [
@@ -44,8 +44,8 @@
44
44
  "check": "npm pack --dry-run"
45
45
  },
46
46
  "peerDependencies": {
47
- "@mariozechner/pi-coding-agent": "*",
48
- "@mariozechner/pi-tui": "*",
47
+ "@earendil-works/pi-coding-agent": "*",
48
+ "@earendil-works/pi-tui": "*",
49
49
  "@sinclair/typebox": "*"
50
50
  }
51
51
  }