pi-ask-user 0.11.2 → 0.13.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 +19 -4
  2. package/index.ts +424 -60
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -61,10 +61,10 @@ The registered tool name is:
61
61
  |-----------|------|---------|-------------|
62
62
  | `question` | `string` | *required* | The question to ask the user |
63
63
  | `context` | `string?` | — | Relevant context summary shown before the question |
64
- | `options` | `(string \| {title, description?})[]?` | `[]` | Multiple-choice options |
64
+ | `options` | `{title, description?}[]?` | `[]` | Multiple-choice options. The schema is a flat object shape (no `anyOf`, which some provider proxies strip or reject); plain strings and common alias keys (`label`, `text`, `value`, `name`, `option`) are still accepted at runtime |
65
65
  | `allowMultiple` | `boolean?` | `false` | Enable multi-select mode |
66
66
  | `allowFreeform` | `boolean?` | `true` | Add a "Type something" freeform option |
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 |
67
+ | `allowComment` | `boolean?` | env var or `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
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
70
  | `commentToggleKey` | `string?` | env var or `"ctrl+g"` | Shortcut for toggling the optional comment/extra-context row when `allowComment: true`. Pass `"off"` to disable. |
@@ -77,7 +77,7 @@ The registered tool name is:
77
77
  "question": "Which option should we use?",
78
78
  "context": "We are choosing a deploy target.",
79
79
  "options": [
80
- "staging",
80
+ { "title": "staging" },
81
81
  { "title": "production", "description": "Customer-facing" }
82
82
  ],
83
83
  "allowMultiple": false,
@@ -95,10 +95,13 @@ Configure your defaults globally by setting these in your shell profile (`~/.zsh
95
95
 
96
96
  ```bash
97
97
  export PI_ASK_USER_DISPLAY_MODE=inline
98
+ export PI_ASK_USER_ALLOW_COMMENT=true
98
99
  export PI_ASK_USER_OVERLAY_TOGGLE_KEY=alt+h
99
100
  export PI_ASK_USER_COMMENT_TOGGLE_KEY=alt+c
100
101
  ```
101
102
 
103
+ Environment variables must be present in the process that launches Pi. If Pi is launched from a desktop app or a different shell, changes in `~/.zshrc` may not be inherited; launch Pi from a terminal where `echo $PI_ASK_USER_DISPLAY_MODE` shows the expected value.
104
+
102
105
  ### Display mode
103
106
 
104
107
  Effective order:
@@ -109,6 +112,14 @@ Effective order:
109
112
 
110
113
  Unrecognised values are silently ignored and fall back to `"overlay"`.
111
114
 
115
+ ### Optional comments
116
+
117
+ Effective order:
118
+
119
+ 1. Per-call `allowComment` parameter (if provided)
120
+ 2. `PI_ASK_USER_ALLOW_COMMENT` (`true`, `1`, `yes`, or `on`; corresponding false values are also accepted)
121
+ 3. Fallback default: `false`
122
+
112
123
  ### Shortcuts
113
124
 
114
125
  Effective order for both `overlayToggleKey` and `commentToggleKey`:
@@ -133,6 +144,10 @@ While an `ask_user` prompt is open:
133
144
 
134
145
  If you prefer never to see the overlay, set `displayMode: "inline"` per call or `PI_ASK_USER_DISPLAY_MODE=inline` globally.
135
146
 
147
+ ## Known limitations
148
+
149
+ - **Overlays cannot draw over inline images** ([#8](https://github.com/edlsh/pi-ask-user/issues/8)). Pi-TUI's overlay compositor skips rows occupied by terminal images (Kitty/iTerm2 graphics), so an `ask_user` overlay that intersects an image is partially or fully invisible. This must be fixed upstream in pi-tui (`compositeLineAt` returns image rows unchanged). Until then, `displayMode: "inline"` (or `PI_ASK_USER_DISPLAY_MODE=inline`) sidesteps the overlay compositor entirely and should keep the prompt visible.
150
+
136
151
  ## Result details
137
152
 
138
153
  All tool results include a structured `details` object for rendering and session state reconstruction:
@@ -153,4 +168,4 @@ interface AskToolDetails {
153
168
 
154
169
  ## Changelog
155
170
 
156
- See [CHANGELOG.md](./CHANGELOG.md).
171
+ See [CHANGELOG.md](./CHANGELOG.md).
package/index.ts CHANGED
@@ -11,6 +11,7 @@ import { Type, type TUnsafe } from "@sinclair/typebox";
11
11
  import {
12
12
  Container,
13
13
  type Component,
14
+ CURSOR_MARKER,
14
15
  decodeKittyPrintable,
15
16
  Editor,
16
17
  type EditorTheme,
@@ -22,13 +23,14 @@ import {
22
23
  type MarkdownTheme,
23
24
  matchesKey,
24
25
  type OverlayHandle,
26
+ type OverlayOptions,
25
27
  Spacer,
26
28
  Text,
27
29
  type TUI,
28
30
  truncateToWidth,
29
31
  wrapTextWithAnsi,
30
32
  } from "@earendil-works/pi-tui";
31
- import { renderSingleSelectRows } from "./single-select-layout";
33
+ import { renderSingleSelectRows, type QuestionOption } from "./single-select-layout";
32
34
 
33
35
  import { createRequire } from "node:module";
34
36
  const _require = createRequire(import.meta.url);
@@ -120,18 +122,28 @@ interface AskToolDetails {
120
122
 
121
123
  type AskUIResult = AskResponse;
122
124
 
123
- function normalizeOptions(options: AskOptionInput[]): QuestionOption[] {
124
- return options
125
- .map((option) => {
126
- if (typeof option === "string") {
127
- return { title: option };
128
- }
129
- if (option && typeof option === "object" && typeof option.title === "string") {
130
- return { title: option.title, description: option.description };
125
+ // Key aliases models fall back to when a schema-mangling proxy (Google
126
+ // function calling, Codex-style backends, cmux) strips the option shape and
127
+ // the model has to guess. See issue #22.
128
+ const OPTION_TITLE_KEYS = ["title", "label", "text", "value", "name", "option"] as const;
129
+
130
+ function coerceOption(option: unknown): QuestionOption | null {
131
+ if (typeof option === "string" || typeof option === "number" || typeof option === "boolean") {
132
+ const title = String(option).trim();
133
+ return title ? { title } : null;
134
+ }
135
+ if (option && typeof option === "object") {
136
+ const record = option as Record<string, unknown>;
137
+ for (const key of OPTION_TITLE_KEYS) {
138
+ const value = record[key];
139
+ if (typeof value === "string" && value.trim()) {
140
+ const description =
141
+ typeof record.description === "string" && record.description.trim() ? record.description : undefined;
142
+ return description ? { title: value.trim(), description } : { title: value.trim() };
131
143
  }
132
- return null;
133
- })
134
- .filter((option): option is QuestionOption => option !== null);
144
+ }
145
+ }
146
+ return null;
135
147
  }
136
148
 
137
149
  function formatOptionsForMessage(options: QuestionOption[]): string {
@@ -148,6 +160,24 @@ function normalizeOptionalComment(text: string | null | undefined): string | und
148
160
  return trimmed ? trimmed : undefined;
149
161
  }
150
162
 
163
+ function parseBooleanPreference(value: string | undefined): boolean | undefined {
164
+ if (value === undefined) return undefined;
165
+ switch (value.trim().toLowerCase()) {
166
+ case "1":
167
+ case "true":
168
+ case "yes":
169
+ case "on":
170
+ return true;
171
+ case "0":
172
+ case "false":
173
+ case "no":
174
+ case "off":
175
+ return false;
176
+ default:
177
+ return undefined;
178
+ }
179
+ }
180
+
151
181
  function createFreeformResponse(text: string | null | undefined): AskResponse | null {
152
182
  const trimmed = text?.trim();
153
183
  return trimmed ? { kind: "freeform", text: trimmed } : null;
@@ -339,6 +369,7 @@ function resolveShortcut(
339
369
  type AskMode = "select" | "freeform" | "comment";
340
370
 
341
371
  const ASK_OVERLAY_MAX_HEIGHT_RATIO = 0.85;
372
+ const ASK_OVERLAY_MIN_RENDER_LINES = 8;
342
373
  const ASK_OVERLAY_WIDTH = "92%";
343
374
  const ASK_OVERLAY_MIN_WIDTH = 40;
344
375
  const SINGLE_SELECT_SPLIT_PANE_MIN_WIDTH = 84;
@@ -354,6 +385,20 @@ const DEFAULT_COMMENT_TOGGLE_KEY = "ctrl+g";
354
385
  // searchable single-select because they don't collide with fuzzy-search input.
355
386
  const VIM_SELECT_UP_KEY = Key.ctrl("k");
356
387
  const VIM_SELECT_DOWN_KEY = Key.ctrl("j");
388
+ const PROMPT_SCROLL_PAGE_UP_KEY = Key.pageUp;
389
+ const PROMPT_SCROLL_PAGE_DOWN_KEY = Key.pageDown;
390
+ const PROMPT_SCROLL_HOME_KEY = Key.home;
391
+ const PROMPT_SCROLL_END_KEY = Key.end;
392
+ const PROMPT_SCROLL_HALF_PAGE_UP_KEY = Key.ctrl("u");
393
+ const PROMPT_SCROLL_HALF_PAGE_DOWN_KEY = Key.ctrl("d");
394
+
395
+ function getOverlayMaxRenderLinesForRows(rows: number): number {
396
+ const normalizedRows = Number.isFinite(rows) ? Math.max(1, Math.floor(rows)) : 24;
397
+ const availableRows = Math.max(1, normalizedRows - 2);
398
+ const ratioRows = Math.max(1, Math.floor(normalizedRows * ASK_OVERLAY_MAX_HEIGHT_RATIO));
399
+ const minimumRows = Math.min(ASK_OVERLAY_MIN_RENDER_LINES, availableRows);
400
+ return Math.min(availableRows, Math.max(minimumRows, ratioRows));
401
+ }
357
402
 
358
403
  function matchesSelectUp(data: string, keybindings: KeybindingsManager): boolean {
359
404
  return (
@@ -374,7 +419,7 @@ function matchesSelectDown(data: string, keybindings: KeybindingsManager): boole
374
419
  function buildCustomUIOptions(
375
420
  displayMode: AskDisplayMode,
376
421
  onHandle?: (handle: OverlayHandle) => void,
377
- ) {
422
+ ): { overlay?: boolean; overlayOptions?: OverlayOptions; onHandle?: (handle: OverlayHandle) => void } | undefined {
378
423
  switch (displayMode) {
379
424
  case "inline":
380
425
  return undefined;
@@ -998,6 +1043,9 @@ class AskComponent extends Container {
998
1043
  private pendingSelections: string[] = [];
999
1044
  private freeformDraft = "";
1000
1045
  private commentDraft = "";
1046
+ private promptScrollOffset = 0;
1047
+ private promptMaxScrollOffset = 0;
1048
+ private promptViewportRows = 0;
1001
1049
 
1002
1050
  // Static layout components
1003
1051
  private titleText: Text;
@@ -1107,43 +1155,290 @@ class AskComponent extends Container {
1107
1155
  override render(width: number): string[] {
1108
1156
  const innerWidth = Math.max(1, width - BOX_BORDER_OVERHEAD);
1109
1157
 
1158
+ if (this.displayMode === "overlay") {
1159
+ return this.renderOverlayLayout(width, innerWidth);
1160
+ }
1161
+
1110
1162
  if (this.mode === "select" && !this.allowMultiple) {
1111
- const overlayMaxHeight = Math.max(12, Math.floor(this.tui.terminal.rows * ASK_OVERLAY_MAX_HEIGHT_RATIO));
1112
- const staticLines = this.countStaticLines(innerWidth);
1113
- const availableOptionRows = Math.max(4, overlayMaxHeight - staticLines);
1114
- this.ensureSingleSelectList().setMaxVisibleRows(availableOptionRows);
1163
+ this.ensureSingleSelectList().setMaxVisibleRows(12);
1115
1164
  }
1116
1165
 
1117
- // Render children at the inner width (excluding side border characters)
1118
- const rawLines = super.render(innerWidth);
1166
+ return this.frameRawLines(super.render(innerWidth), width, innerWidth);
1167
+ }
1119
1168
 
1120
- // First and last lines are the top/bottom box borders — pass through at full width.
1121
- // All inner lines get wrapped with side borders.
1122
- const borderColor = (s: string) => this.theme.fg("accent", s);
1123
- const titleColor = (s: string) => this.theme.fg("dim", this.theme.bold(s));
1124
- return rawLines.map((line, index) => {
1125
- if (index === 0 || index === rawLines.length - 1) {
1126
- // Box top/bottom borders already rendered at innerWidth — re-render at full width
1127
- if (index === 0) return new BoxBorderTop(borderColor, "ask_user", titleColor).render(width)[0];
1128
- return new BoxBorderBottom(borderColor, `v${ASK_USER_VERSION}`, (s: string) => this.theme.fg("dim", s)).render(width)[0];
1169
+ private getOverlayMaxRenderLines(): number {
1170
+ const rows = Number.isFinite(this.tui.terminal.rows) ? Math.floor(this.tui.terminal.rows) : 24;
1171
+ return getOverlayMaxRenderLinesForRows(rows);
1172
+ }
1173
+
1174
+ private renderOverlayLayout(width: number, innerWidth: number): string[] {
1175
+ const maxLines = this.getOverlayMaxRenderLines();
1176
+ if (maxLines <= 1) return [this.renderTopBorder(width)];
1177
+ if (maxLines === 2) return [this.renderTopBorder(width), this.renderBottomBorder(width)];
1178
+
1179
+ const bodyCapacity = Math.max(0, maxLines - 2);
1180
+ const promptLines = this.buildPromptLines(innerWidth);
1181
+ const helpFullLines = this.helpText.render(innerWidth);
1182
+ const helpBudget = this.getOverlayHelpBudget(bodyCapacity, helpFullLines.length);
1183
+ const contentRows = Math.max(0, bodyCapacity - helpBudget);
1184
+
1185
+ let promptBudget = 0;
1186
+ let modeBudget = 0;
1187
+ let separatorRows = 0;
1188
+
1189
+ if (this.mode === "select") {
1190
+ separatorRows = contentRows >= 4 ? 1 : 0;
1191
+ const promptAndModeRows = Math.max(0, contentRows - separatorRows);
1192
+ promptBudget = promptAndModeRows;
1193
+
1194
+ if (promptAndModeRows > 0) {
1195
+ const promptMinRows = promptLines.length > 0 ? 1 : 0;
1196
+ const maximumModeRows = Math.max(0, promptAndModeRows - promptMinRows);
1197
+ const modeMinRows = Math.min(this.getMinimumModeRows(), maximumModeRows);
1198
+ modeBudget = Math.min(this.getPreferredModeRows(), maximumModeRows);
1199
+ modeBudget = Math.max(modeMinRows, modeBudget);
1200
+ promptBudget = promptAndModeRows - modeBudget;
1201
+
1202
+ const usefulPromptRows = Math.min(
1203
+ promptLines.length,
1204
+ promptAndModeRows >= modeMinRows + 2 ? 2 : promptMinRows,
1205
+ );
1206
+ if (promptBudget < usefulPromptRows && modeBudget > modeMinRows) {
1207
+ const shiftedRows = Math.min(usefulPromptRows - promptBudget, modeBudget - modeMinRows);
1208
+ modeBudget -= shiftedRows;
1209
+ promptBudget += shiftedRows;
1210
+ }
1129
1211
  }
1130
- const padded = truncateToWidth(line, innerWidth, "", true);
1131
- return `${borderColor(BOX_BORDER_LEFT)}${padded}${borderColor(BOX_BORDER_RIGHT)}`;
1132
- });
1212
+ } else {
1213
+ modeBudget = Math.min(this.getPreferredModeRows(), contentRows);
1214
+ modeBudget = Math.max(Math.min(this.getMinimumModeRows(), contentRows), modeBudget);
1215
+ promptBudget = Math.max(0, contentRows - modeBudget);
1216
+ if (promptBudget > 0 && modeBudget > 0) {
1217
+ separatorRows = 1;
1218
+ promptBudget = Math.max(0, promptBudget - separatorRows);
1219
+ }
1220
+ }
1221
+
1222
+ const modeLines = this.renderModeLines(innerWidth, modeBudget);
1223
+ if (modeLines.length < modeBudget) {
1224
+ promptBudget += modeBudget - modeLines.length;
1225
+ }
1226
+
1227
+ const promptPaneLines = this.renderPromptPane(promptLines, promptBudget, innerWidth);
1228
+ const helpLines = this.limitLines(helpFullLines, helpBudget, innerWidth, false);
1229
+ const bodyLines = [
1230
+ ...promptPaneLines,
1231
+ ...(separatorRows > 0 && promptPaneLines.length > 0 && modeLines.length > 0 ? [""] : []),
1232
+ ...modeLines,
1233
+ ...helpLines,
1234
+ ];
1235
+
1236
+ return this.frameBodyLines(bodyLines.slice(0, bodyCapacity), width, innerWidth);
1237
+ }
1238
+
1239
+ private buildPromptLines(width: number): string[] {
1240
+ return [
1241
+ ...this.titleText.render(width),
1242
+ ...this.questionText.render(width),
1243
+ ...(this.contextComponent ? ["", ...this.contextComponent.render(width)] : []),
1244
+ ];
1245
+ }
1246
+
1247
+ private getOverlayHelpBudget(bodyCapacity: number, renderedHelpRows: number): number {
1248
+ if (renderedHelpRows <= 0 || bodyCapacity <= 0) return 0;
1249
+ if (bodyCapacity >= 12) return Math.min(2, renderedHelpRows);
1250
+ return 1;
1251
+ }
1252
+
1253
+ private getMinimumModeRows(): number {
1254
+ if (this.mode === "freeform") return 5;
1255
+ if (this.mode === "comment") return 6;
1256
+ return this.allowMultiple ? 3 : 4;
1257
+ }
1258
+
1259
+ private getPreferredModeRows(): number {
1260
+ if (this.mode === "freeform") return 10;
1261
+ if (this.mode === "comment") return 11;
1262
+ return 8;
1263
+ }
1264
+
1265
+ private renderModeLines(width: number, budget: number): string[] {
1266
+ const safeBudget = Math.max(0, Math.floor(budget));
1267
+ if (safeBudget <= 0) return [];
1268
+
1269
+ if (this.mode === "select") {
1270
+ if (!this.allowMultiple) {
1271
+ this.ensureSingleSelectList().setMaxVisibleRows(Math.max(1, safeBudget));
1272
+ }
1273
+ return this.limitLines(this.modeContainer.render(width), safeBudget, width, true);
1274
+ }
1275
+
1276
+ return this.renderEditorModeLines(width, safeBudget);
1133
1277
  }
1134
1278
 
1135
- private countWrappedLines(text: string, width: number): number {
1136
- return Math.max(1, wrapTextWithAnsi(text, Math.max(10, width - 2)).length);
1279
+ private renderEditorModeLines(width: number, budget: number): string[] {
1280
+ const headerLines = this.buildEditorModeHeaderLines(width);
1281
+ const minimumEditorRows = Math.min(3, budget);
1282
+ const headerBudget = Math.max(0, budget - minimumEditorRows);
1283
+ const visibleHeaderLines = this.limitLines(headerLines, headerBudget, width, true);
1284
+ const editorBudget = Math.max(0, budget - visibleHeaderLines.length);
1285
+
1286
+ return [
1287
+ ...visibleHeaderLines,
1288
+ ...this.limitEditorLines(this.ensureEditor().render(width), editorBudget, width),
1289
+ ];
1137
1290
  }
1138
1291
 
1139
- private countStaticLines(width: number): number {
1140
- const titleLines = 1;
1141
- const questionLines = this.countWrappedLines(this.question, width);
1142
- const contextLines = this.context ? 1 + this.countWrappedLines(this.context, width) : 0;
1143
- const helpLines = 1;
1144
- const borderLines = 2;
1145
- const spacerLines = this.context ? 6 : 5;
1146
- return borderLines + spacerLines + titleLines + questionLines + contextLines + helpLines;
1292
+ private buildEditorModeHeaderLines(width: number): string[] {
1293
+ if (this.mode === "comment") {
1294
+ const selectedLabel = this.pendingSelections.length === 1 ? "Selected option:" : "Selected options:";
1295
+ return [
1296
+ ...new Text(this.theme.fg("accent", this.theme.bold(selectedLabel)), 1, 0).render(width),
1297
+ ...new Text(this.theme.fg("text", this.pendingSelections.join(", ")), 1, 0).render(width),
1298
+ "",
1299
+ ];
1300
+ }
1301
+
1302
+ return [
1303
+ ...new Text(this.theme.fg("accent", this.theme.bold("Custom response")), 1, 0).render(width),
1304
+ "",
1305
+ ];
1306
+ }
1307
+
1308
+ private limitEditorLines(lines: string[], budget: number, width: number): string[] {
1309
+ const safeBudget = Math.max(0, Math.floor(budget));
1310
+ if (safeBudget <= 0) return [];
1311
+ if (lines.length <= safeBudget) {
1312
+ return lines.map((line) => truncateToWidth(line, width, "", true));
1313
+ }
1314
+ if (safeBudget === 1) return [this.theme.fg("dim", "…")];
1315
+
1316
+ const topBorder = truncateToWidth(lines[0] ?? "", width, "", true);
1317
+ const bottomBorder = truncateToWidth(lines[lines.length - 1] ?? "", width, "", true);
1318
+ if (safeBudget === 2) return [topBorder, bottomBorder];
1319
+
1320
+ const contentLines = lines.slice(1, -1);
1321
+ const contentBudget = safeBudget - 2;
1322
+ // Locate the cursor row: prefer the zero-width CURSOR_MARKER the editor
1323
+ // emits while focused (the same mechanism pi-tui core uses for hardware
1324
+ // cursor placement), falling back to the inverse-video fake cursor.
1325
+ const cursorLineIndex = contentLines.findIndex(
1326
+ (line) => line.includes(CURSOR_MARKER) || line.includes("\x1b[7m"),
1327
+ );
1328
+ const maxStart = Math.max(0, contentLines.length - contentBudget);
1329
+ const start = cursorLineIndex >= 0
1330
+ ? Math.max(0, Math.min(cursorLineIndex - contentBudget + 1, maxStart))
1331
+ : maxStart;
1332
+ const visibleContentLines = contentLines.slice(start, start + contentBudget);
1333
+ const markedContentLines = this.applyPromptOverflowMarkers(
1334
+ visibleContentLines,
1335
+ width,
1336
+ start > 0,
1337
+ start + contentBudget < contentLines.length,
1338
+ );
1339
+
1340
+ return [topBorder, ...markedContentLines, bottomBorder];
1341
+ }
1342
+
1343
+ private renderPromptPane(promptLines: string[], budget: number, width: number): string[] {
1344
+ const viewportRows = Math.max(0, Math.floor(budget));
1345
+ this.promptViewportRows = viewportRows;
1346
+
1347
+ if (viewportRows <= 0 || promptLines.length === 0) {
1348
+ this.promptMaxScrollOffset = 0;
1349
+ this.promptScrollOffset = 0;
1350
+ return [];
1351
+ }
1352
+
1353
+ this.promptMaxScrollOffset = Math.max(0, promptLines.length - viewportRows);
1354
+ this.promptScrollOffset = Math.max(0, Math.min(this.promptScrollOffset, this.promptMaxScrollOffset));
1355
+
1356
+ const visibleLines = promptLines.slice(this.promptScrollOffset, this.promptScrollOffset + viewportRows);
1357
+ const hasHiddenAbove = this.promptScrollOffset > 0;
1358
+ const hasHiddenBelow = this.promptScrollOffset + viewportRows < promptLines.length;
1359
+ return this.applyPromptOverflowMarkers(visibleLines, width, hasHiddenAbove, hasHiddenBelow);
1360
+ }
1361
+
1362
+ private applyPromptOverflowMarkers(
1363
+ lines: string[],
1364
+ width: number,
1365
+ hasHiddenAbove: boolean,
1366
+ hasHiddenBelow: boolean,
1367
+ ): string[] {
1368
+ if (lines.length === 0) return lines;
1369
+
1370
+ const marked = [...lines];
1371
+ if (hasHiddenAbove && hasHiddenBelow && marked.length === 1) {
1372
+ marked[0] = this.addPromptOverflowMarker(marked[0] ?? "", "↕", width);
1373
+ return marked;
1374
+ }
1375
+
1376
+ if (hasHiddenAbove) {
1377
+ marked[0] = this.addPromptOverflowMarker(marked[0] ?? "", "↑", width);
1378
+ }
1379
+ if (hasHiddenBelow) {
1380
+ const lastIndex = marked.length - 1;
1381
+ marked[lastIndex] = this.addPromptOverflowMarker(marked[lastIndex] ?? "", "↓", width);
1382
+ }
1383
+ return marked;
1384
+ }
1385
+
1386
+ private addPromptOverflowMarker(line: string, marker: string, width: number): string {
1387
+ return truncateToWidth(`${this.theme.fg("dim", marker)} ${line}`, width, "", true);
1388
+ }
1389
+
1390
+ private limitLines(lines: string[], budget: number, width: number, showOverflowMarker: boolean): string[] {
1391
+ const safeBudget = Math.max(0, Math.floor(budget));
1392
+ if (safeBudget <= 0) return [];
1393
+ if (lines.length <= safeBudget) {
1394
+ return lines.map((line) => truncateToWidth(line, width, "", true));
1395
+ }
1396
+ if (!showOverflowMarker) {
1397
+ return lines.slice(0, safeBudget).map((line) => truncateToWidth(line, width, "", true));
1398
+ }
1399
+ if (safeBudget === 1) return [this.theme.fg("dim", "…")];
1400
+ return [
1401
+ ...lines.slice(0, safeBudget - 1).map((line) => truncateToWidth(line, width, "", true)),
1402
+ this.theme.fg("dim", "…"),
1403
+ ];
1404
+ }
1405
+
1406
+ private renderTopBorder(width: number): string {
1407
+ return new BoxBorderTop(
1408
+ (s: string) => this.theme.fg("accent", s),
1409
+ "ask_user",
1410
+ (s: string) => this.theme.fg("dim", this.theme.bold(s)),
1411
+ ).render(width)[0] ?? "";
1412
+ }
1413
+
1414
+ private renderBottomBorder(width: number): string {
1415
+ return new BoxBorderBottom(
1416
+ (s: string) => this.theme.fg("accent", s),
1417
+ `v${ASK_USER_VERSION}`,
1418
+ (s: string) => this.theme.fg("dim", s),
1419
+ ).render(width)[0] ?? "";
1420
+ }
1421
+
1422
+ private frameBodyLines(bodyLines: string[], width: number, innerWidth: number): string[] {
1423
+ const borderColor = (s: string) => this.theme.fg("accent", s);
1424
+ return [
1425
+ this.renderTopBorder(width),
1426
+ ...bodyLines.map((line) => {
1427
+ const padded = truncateToWidth(line, innerWidth, "", true);
1428
+ return `${borderColor(BOX_BORDER_LEFT)}${padded}${borderColor(BOX_BORDER_RIGHT)}`;
1429
+ }),
1430
+ this.renderBottomBorder(width),
1431
+ ];
1432
+ }
1433
+
1434
+ private frameRawLines(rawLines: string[], width: number, innerWidth: number): string[] {
1435
+ const borderColor = (s: string) => this.theme.fg("accent", s);
1436
+ return rawLines.map((line, index) => {
1437
+ if (index === 0) return this.renderTopBorder(width);
1438
+ if (index === rawLines.length - 1) return this.renderBottomBorder(width);
1439
+ const padded = truncateToWidth(line, innerWidth, "", true);
1440
+ return `${borderColor(BOX_BORDER_LEFT)}${padded}${borderColor(BOX_BORDER_RIGHT)}`;
1441
+ });
1147
1442
  }
1148
1443
 
1149
1444
  private updateStaticText(): void {
@@ -1169,6 +1464,9 @@ class AskComponent extends Container {
1169
1464
  const overlayHint = this.displayMode === "overlay" && !this.shortcuts.overlayToggle.disabled
1170
1465
  ? literalHint(theme, this.shortcuts.overlayToggle.spec, "hide")
1171
1466
  : null;
1467
+ const promptScrollHint = this.displayMode === "overlay"
1468
+ ? literalHint(theme, "PgUp/PgDn", "prompt")
1469
+ : null;
1172
1470
  const commentHint = this.allowComment && !this.shortcuts.commentToggle.disabled
1173
1471
  ? literalHint(theme, this.shortcuts.commentToggle.spec, "toggle context")
1174
1472
  : null;
@@ -1194,6 +1492,7 @@ class AskComponent extends Container {
1194
1492
  literalHint(theme, "↑↓", "navigate"),
1195
1493
  literalHint(theme, "space", "toggle"),
1196
1494
  commentHint,
1495
+ promptScrollHint,
1197
1496
  overlayHint,
1198
1497
  keybindingHint(theme, this.keybindings, "tui.select.confirm", "submit"),
1199
1498
  keybindingHint(theme, this.keybindings, "tui.select.cancel", "cancel"),
@@ -1207,9 +1506,10 @@ class AskComponent extends Container {
1207
1506
  .filter((key) => key !== "escape" && key !== "esc");
1208
1507
  const hints = [
1209
1508
  literalHint(theme, "type", "filter"),
1509
+ commentHint,
1510
+ promptScrollHint,
1210
1511
  keybindingHint(theme, this.keybindings, "tui.editor.deleteCharBackward", "erase"),
1211
1512
  literalHint(theme, "↑↓", "navigate"),
1212
- commentHint,
1213
1513
  overlayHint,
1214
1514
  keybindingHint(theme, this.keybindings, "tui.select.confirm", "select"),
1215
1515
  literalHint(theme, "esc", "clear/cancel"),
@@ -1380,7 +1680,53 @@ class AskComponent extends Container {
1380
1680
  this.tui.requestRender();
1381
1681
  }
1382
1682
 
1683
+ private setPromptScrollOffset(nextOffset: number): boolean {
1684
+ if (this.displayMode !== "overlay" || this.promptMaxScrollOffset <= 0) return false;
1685
+ const clamped = Math.max(0, Math.min(Math.floor(nextOffset), this.promptMaxScrollOffset));
1686
+ const changed = clamped !== this.promptScrollOffset;
1687
+ this.promptScrollOffset = clamped;
1688
+ return changed;
1689
+ }
1690
+
1691
+ private handlePromptScrollInput(data: string): boolean {
1692
+ if (this.displayMode !== "overlay" || this.promptMaxScrollOffset <= 0) return false;
1693
+ // Prompt scrolling is select-mode only: in freeform/comment modes the
1694
+ // editor owns PageUp/PageDown (tui.editor.pageUp/pageDown) for paging
1695
+ // through long input, so intercepting them here would steal editor keys.
1696
+ if (this.mode !== "select") return false;
1697
+
1698
+ const pageRows = Math.max(1, this.promptViewportRows - 1);
1699
+ const halfPageRows = Math.max(1, Math.floor(this.promptViewportRows / 2));
1700
+ let handled = false;
1701
+
1702
+ if (matchesKey(data, PROMPT_SCROLL_PAGE_UP_KEY)) {
1703
+ handled = true;
1704
+ this.setPromptScrollOffset(this.promptScrollOffset - pageRows);
1705
+ } else if (matchesKey(data, PROMPT_SCROLL_PAGE_DOWN_KEY)) {
1706
+ handled = true;
1707
+ this.setPromptScrollOffset(this.promptScrollOffset + pageRows);
1708
+ } else if (matchesKey(data, PROMPT_SCROLL_HOME_KEY)) {
1709
+ handled = true;
1710
+ this.setPromptScrollOffset(0);
1711
+ } else if (matchesKey(data, PROMPT_SCROLL_END_KEY)) {
1712
+ handled = true;
1713
+ this.setPromptScrollOffset(this.promptMaxScrollOffset);
1714
+ } else if (matchesKey(data, PROMPT_SCROLL_HALF_PAGE_UP_KEY)) {
1715
+ handled = true;
1716
+ this.setPromptScrollOffset(this.promptScrollOffset - halfPageRows);
1717
+ } else if (matchesKey(data, PROMPT_SCROLL_HALF_PAGE_DOWN_KEY)) {
1718
+ handled = true;
1719
+ this.setPromptScrollOffset(this.promptScrollOffset + halfPageRows);
1720
+ }
1721
+
1722
+ return handled;
1723
+ }
1724
+
1383
1725
  handleInput(data: string): void {
1726
+ if (this.handlePromptScrollInput(data)) {
1727
+ this.tui.requestRender();
1728
+ return;
1729
+ }
1384
1730
  if (this.mode === "freeform" || this.mode === "comment") {
1385
1731
  if (matchesKey(data, Key.escape)) {
1386
1732
  this.showSelectMode();
@@ -1500,15 +1846,17 @@ export default function(pi: ExtensionAPI) {
1500
1846
  ),
1501
1847
  options: Type.Optional(
1502
1848
  Type.Array(
1503
- Type.Union([
1504
- Type.String({ description: "Short title for this option" }),
1505
- Type.Object({
1506
- title: Type.String({ description: "Short title for this option" }),
1507
- description: Type.Optional(
1508
- Type.String({ description: "Longer description explaining this option" }),
1509
- ),
1510
- }),
1511
- ]),
1849
+ // Flat object shape: union item schemas get stripped or rejected
1850
+ // by several providers/proxies (Google function calling,
1851
+ // Codex-style backends, cmux), leaving the model to guess the shape
1852
+ // and produce empty options. Plain strings are still accepted at
1853
+ // runtime for older transcripts. See issue #22.
1854
+ Type.Object({
1855
+ title: Type.String({ description: "Short title for this option" }),
1856
+ description: Type.Optional(
1857
+ Type.String({ description: "Longer description explaining this option" }),
1858
+ ),
1859
+ }),
1512
1860
  { description: "List of options for the user to choose from" },
1513
1861
  ),
1514
1862
  ),
@@ -1519,7 +1867,7 @@ export default function(pi: ExtensionAPI) {
1519
1867
  Type.Boolean({ description: "Add a freeform text option. Default: true" }),
1520
1868
  ),
1521
1869
  allowComment: Type.Optional(
1522
- Type.Boolean({ description: "Collect an optional comment after selecting one or more options. Default: false" }),
1870
+ Type.Boolean({ description: "Collect an optional comment after selecting one or more options. Default: PI_ASK_USER_ALLOW_COMMENT env var if set, otherwise false." }),
1523
1871
  ),
1524
1872
  displayMode: Type.Optional(
1525
1873
  StringEnum(["overlay", "inline"] as const, {
@@ -1557,16 +1905,19 @@ export default function(pi: ExtensionAPI) {
1557
1905
  options: rawOptions = [],
1558
1906
  allowMultiple = false,
1559
1907
  allowFreeform = true,
1560
- allowComment = false,
1908
+ allowComment: requestedAllowComment,
1561
1909
  displayMode,
1562
1910
  overlayToggleKey,
1563
1911
  commentToggleKey,
1564
1912
  timeout,
1565
1913
  } = params as AskParams;
1566
- const envMode = process.env.PI_ASK_USER_DISPLAY_MODE;
1914
+ const envMode = process.env.PI_ASK_USER_DISPLAY_MODE?.trim().toLowerCase();
1567
1915
  const envDisplayMode: AskDisplayMode | undefined =
1568
1916
  envMode === "overlay" || envMode === "inline" ? envMode : undefined;
1569
1917
  const effectiveDisplayMode: AskDisplayMode = displayMode ?? envDisplayMode ?? "overlay";
1918
+ const allowComment = requestedAllowComment
1919
+ ?? parseBooleanPreference(process.env.PI_ASK_USER_ALLOW_COMMENT)
1920
+ ?? false;
1570
1921
  const shortcuts: ResolvedAskShortcuts = {
1571
1922
  overlayToggle: resolveShortcut(
1572
1923
  overlayToggleKey,
@@ -1579,9 +1930,25 @@ export default function(pi: ExtensionAPI) {
1579
1930
  DEFAULT_COMMENT_TOGGLE_KEY,
1580
1931
  ),
1581
1932
  };
1582
- const options = normalizeOptions(rawOptions);
1933
+ const options = rawOptions.map(coerceOption).filter((option): option is QuestionOption => option !== null);
1583
1934
  const normalizedContext = context?.trim() || undefined;
1584
1935
 
1936
+ if (rawOptions.length > 0 && options.length === 0) {
1937
+ return {
1938
+ content: [
1939
+ {
1940
+ type: "text",
1941
+ text:
1942
+ `All ${rawOptions.length} option(s) were malformed, so nothing could be shown to the user. `
1943
+ + `Each option must be a plain string or an object like { "title": "Short label", "description": "Optional detail" }. `
1944
+ + `Call ask_user again with corrected options.`,
1945
+ },
1946
+ ],
1947
+ isError: true,
1948
+ details: { error: "Malformed options: no entry had a usable title" },
1949
+ };
1950
+ }
1951
+
1585
1952
  if (!ctx.hasUI || !ctx.ui) {
1586
1953
  const optionText = options.length > 0 ? `\n\nOptions:\n${formatOptionsForMessage(options)}` : "";
1587
1954
  const freeformHint = allowFreeform ? "\n\nYou can also answer freely." : "";
@@ -1732,9 +2099,7 @@ export default function(pi: ExtensionAPI) {
1732
2099
  let text = theme.fg("toolTitle", theme.bold("ask_user "));
1733
2100
  text += theme.fg("muted", question);
1734
2101
  if (rawOptions.length > 0) {
1735
- const labels = rawOptions.map((o: unknown) =>
1736
- typeof o === "string" ? o : (o as QuestionOption)?.title ?? "",
1737
- );
2102
+ const labels = rawOptions.map((o: unknown) => coerceOption(o)?.title ?? "<invalid>");
1738
2103
  text += "\n" + theme.fg("dim", ` ${rawOptions.length} option(s): ${labels.join(", ")}`);
1739
2104
  }
1740
2105
  if (args.allowMultiple) {
@@ -1755,8 +2120,7 @@ export default function(pi: ExtensionAPI) {
1755
2120
 
1756
2121
  if (options.isPartial) {
1757
2122
  const waitingText = result.content
1758
- ?.filter((part: { type?: string; text?: string }) => part?.type === "text")
1759
- .map((part: { text?: string }) => part.text ?? "")
2123
+ ?.map((part) => part.type === "text" ? part.text : "")
1760
2124
  .join("\n")
1761
2125
  .trim() || "Waiting for user input...";
1762
2126
  return new Text(theme.fg("muted", waitingText), 0, 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ask-user",
3
- "version": "0.11.2",
3
+ "version": "0.13.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": [