pi-ask-user 0.11.2 → 0.12.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 +2 -2
  2. package/index.ts +400 -57
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -61,7 +61,7 @@ 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
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 |
@@ -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,
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 {
@@ -339,6 +351,7 @@ function resolveShortcut(
339
351
  type AskMode = "select" | "freeform" | "comment";
340
352
 
341
353
  const ASK_OVERLAY_MAX_HEIGHT_RATIO = 0.85;
354
+ const ASK_OVERLAY_MIN_RENDER_LINES = 8;
342
355
  const ASK_OVERLAY_WIDTH = "92%";
343
356
  const ASK_OVERLAY_MIN_WIDTH = 40;
344
357
  const SINGLE_SELECT_SPLIT_PANE_MIN_WIDTH = 84;
@@ -354,6 +367,20 @@ const DEFAULT_COMMENT_TOGGLE_KEY = "ctrl+g";
354
367
  // searchable single-select because they don't collide with fuzzy-search input.
355
368
  const VIM_SELECT_UP_KEY = Key.ctrl("k");
356
369
  const VIM_SELECT_DOWN_KEY = Key.ctrl("j");
370
+ const PROMPT_SCROLL_PAGE_UP_KEY = Key.pageUp;
371
+ const PROMPT_SCROLL_PAGE_DOWN_KEY = Key.pageDown;
372
+ const PROMPT_SCROLL_HOME_KEY = Key.home;
373
+ const PROMPT_SCROLL_END_KEY = Key.end;
374
+ const PROMPT_SCROLL_HALF_PAGE_UP_KEY = Key.ctrl("u");
375
+ const PROMPT_SCROLL_HALF_PAGE_DOWN_KEY = Key.ctrl("d");
376
+
377
+ function getOverlayMaxRenderLinesForRows(rows: number): number {
378
+ const normalizedRows = Number.isFinite(rows) ? Math.max(1, Math.floor(rows)) : 24;
379
+ const availableRows = Math.max(1, normalizedRows - 2);
380
+ const ratioRows = Math.max(1, Math.floor(normalizedRows * ASK_OVERLAY_MAX_HEIGHT_RATIO));
381
+ const minimumRows = Math.min(ASK_OVERLAY_MIN_RENDER_LINES, availableRows);
382
+ return Math.min(availableRows, Math.max(minimumRows, ratioRows));
383
+ }
357
384
 
358
385
  function matchesSelectUp(data: string, keybindings: KeybindingsManager): boolean {
359
386
  return (
@@ -374,7 +401,7 @@ function matchesSelectDown(data: string, keybindings: KeybindingsManager): boole
374
401
  function buildCustomUIOptions(
375
402
  displayMode: AskDisplayMode,
376
403
  onHandle?: (handle: OverlayHandle) => void,
377
- ) {
404
+ ): { overlay?: boolean; overlayOptions?: OverlayOptions; onHandle?: (handle: OverlayHandle) => void } | undefined {
378
405
  switch (displayMode) {
379
406
  case "inline":
380
407
  return undefined;
@@ -998,6 +1025,9 @@ class AskComponent extends Container {
998
1025
  private pendingSelections: string[] = [];
999
1026
  private freeformDraft = "";
1000
1027
  private commentDraft = "";
1028
+ private promptScrollOffset = 0;
1029
+ private promptMaxScrollOffset = 0;
1030
+ private promptViewportRows = 0;
1001
1031
 
1002
1032
  // Static layout components
1003
1033
  private titleText: Text;
@@ -1107,43 +1137,290 @@ class AskComponent extends Container {
1107
1137
  override render(width: number): string[] {
1108
1138
  const innerWidth = Math.max(1, width - BOX_BORDER_OVERHEAD);
1109
1139
 
1140
+ if (this.displayMode === "overlay") {
1141
+ return this.renderOverlayLayout(width, innerWidth);
1142
+ }
1143
+
1110
1144
  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);
1145
+ this.ensureSingleSelectList().setMaxVisibleRows(12);
1115
1146
  }
1116
1147
 
1117
- // Render children at the inner width (excluding side border characters)
1118
- const rawLines = super.render(innerWidth);
1148
+ return this.frameRawLines(super.render(innerWidth), width, innerWidth);
1149
+ }
1150
+
1151
+ private getOverlayMaxRenderLines(): number {
1152
+ const rows = Number.isFinite(this.tui.terminal.rows) ? Math.floor(this.tui.terminal.rows) : 24;
1153
+ return getOverlayMaxRenderLinesForRows(rows);
1154
+ }
1119
1155
 
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];
1156
+ private renderOverlayLayout(width: number, innerWidth: number): string[] {
1157
+ const maxLines = this.getOverlayMaxRenderLines();
1158
+ if (maxLines <= 1) return [this.renderTopBorder(width)];
1159
+ if (maxLines === 2) return [this.renderTopBorder(width), this.renderBottomBorder(width)];
1160
+
1161
+ const bodyCapacity = Math.max(0, maxLines - 2);
1162
+ const promptLines = this.buildPromptLines(innerWidth);
1163
+ const helpFullLines = this.helpText.render(innerWidth);
1164
+ const helpBudget = this.getOverlayHelpBudget(bodyCapacity, helpFullLines.length);
1165
+ const contentRows = Math.max(0, bodyCapacity - helpBudget);
1166
+
1167
+ let promptBudget = 0;
1168
+ let modeBudget = 0;
1169
+ let separatorRows = 0;
1170
+
1171
+ if (this.mode === "select") {
1172
+ separatorRows = contentRows >= 4 ? 1 : 0;
1173
+ const promptAndModeRows = Math.max(0, contentRows - separatorRows);
1174
+ promptBudget = promptAndModeRows;
1175
+
1176
+ if (promptAndModeRows > 0) {
1177
+ const promptMinRows = promptLines.length > 0 ? 1 : 0;
1178
+ const maximumModeRows = Math.max(0, promptAndModeRows - promptMinRows);
1179
+ const modeMinRows = Math.min(this.getMinimumModeRows(), maximumModeRows);
1180
+ modeBudget = Math.min(this.getPreferredModeRows(), maximumModeRows);
1181
+ modeBudget = Math.max(modeMinRows, modeBudget);
1182
+ promptBudget = promptAndModeRows - modeBudget;
1183
+
1184
+ const usefulPromptRows = Math.min(
1185
+ promptLines.length,
1186
+ promptAndModeRows >= modeMinRows + 2 ? 2 : promptMinRows,
1187
+ );
1188
+ if (promptBudget < usefulPromptRows && modeBudget > modeMinRows) {
1189
+ const shiftedRows = Math.min(usefulPromptRows - promptBudget, modeBudget - modeMinRows);
1190
+ modeBudget -= shiftedRows;
1191
+ promptBudget += shiftedRows;
1192
+ }
1129
1193
  }
1130
- const padded = truncateToWidth(line, innerWidth, "", true);
1131
- return `${borderColor(BOX_BORDER_LEFT)}${padded}${borderColor(BOX_BORDER_RIGHT)}`;
1132
- });
1194
+ } else {
1195
+ modeBudget = Math.min(this.getPreferredModeRows(), contentRows);
1196
+ modeBudget = Math.max(Math.min(this.getMinimumModeRows(), contentRows), modeBudget);
1197
+ promptBudget = Math.max(0, contentRows - modeBudget);
1198
+ if (promptBudget > 0 && modeBudget > 0) {
1199
+ separatorRows = 1;
1200
+ promptBudget = Math.max(0, promptBudget - separatorRows);
1201
+ }
1202
+ }
1203
+
1204
+ const modeLines = this.renderModeLines(innerWidth, modeBudget);
1205
+ if (modeLines.length < modeBudget) {
1206
+ promptBudget += modeBudget - modeLines.length;
1207
+ }
1208
+
1209
+ const promptPaneLines = this.renderPromptPane(promptLines, promptBudget, innerWidth);
1210
+ const helpLines = this.limitLines(helpFullLines, helpBudget, innerWidth, false);
1211
+ const bodyLines = [
1212
+ ...promptPaneLines,
1213
+ ...(separatorRows > 0 && promptPaneLines.length > 0 && modeLines.length > 0 ? [""] : []),
1214
+ ...modeLines,
1215
+ ...helpLines,
1216
+ ];
1217
+
1218
+ return this.frameBodyLines(bodyLines.slice(0, bodyCapacity), width, innerWidth);
1219
+ }
1220
+
1221
+ private buildPromptLines(width: number): string[] {
1222
+ return [
1223
+ ...this.titleText.render(width),
1224
+ ...this.questionText.render(width),
1225
+ ...(this.contextComponent ? ["", ...this.contextComponent.render(width)] : []),
1226
+ ];
1227
+ }
1228
+
1229
+ private getOverlayHelpBudget(bodyCapacity: number, renderedHelpRows: number): number {
1230
+ if (renderedHelpRows <= 0 || bodyCapacity <= 0) return 0;
1231
+ if (bodyCapacity >= 12) return Math.min(2, renderedHelpRows);
1232
+ return 1;
1233
+ }
1234
+
1235
+ private getMinimumModeRows(): number {
1236
+ if (this.mode === "freeform") return 5;
1237
+ if (this.mode === "comment") return 6;
1238
+ return this.allowMultiple ? 3 : 4;
1239
+ }
1240
+
1241
+ private getPreferredModeRows(): number {
1242
+ if (this.mode === "freeform") return 10;
1243
+ if (this.mode === "comment") return 11;
1244
+ return 8;
1245
+ }
1246
+
1247
+ private renderModeLines(width: number, budget: number): string[] {
1248
+ const safeBudget = Math.max(0, Math.floor(budget));
1249
+ if (safeBudget <= 0) return [];
1250
+
1251
+ if (this.mode === "select") {
1252
+ if (!this.allowMultiple) {
1253
+ this.ensureSingleSelectList().setMaxVisibleRows(Math.max(1, safeBudget));
1254
+ }
1255
+ return this.limitLines(this.modeContainer.render(width), safeBudget, width, true);
1256
+ }
1257
+
1258
+ return this.renderEditorModeLines(width, safeBudget);
1259
+ }
1260
+
1261
+ private renderEditorModeLines(width: number, budget: number): string[] {
1262
+ const headerLines = this.buildEditorModeHeaderLines(width);
1263
+ const minimumEditorRows = Math.min(3, budget);
1264
+ const headerBudget = Math.max(0, budget - minimumEditorRows);
1265
+ const visibleHeaderLines = this.limitLines(headerLines, headerBudget, width, true);
1266
+ const editorBudget = Math.max(0, budget - visibleHeaderLines.length);
1267
+
1268
+ return [
1269
+ ...visibleHeaderLines,
1270
+ ...this.limitEditorLines(this.ensureEditor().render(width), editorBudget, width),
1271
+ ];
1272
+ }
1273
+
1274
+ private buildEditorModeHeaderLines(width: number): string[] {
1275
+ if (this.mode === "comment") {
1276
+ const selectedLabel = this.pendingSelections.length === 1 ? "Selected option:" : "Selected options:";
1277
+ return [
1278
+ ...new Text(this.theme.fg("accent", this.theme.bold(selectedLabel)), 1, 0).render(width),
1279
+ ...new Text(this.theme.fg("text", this.pendingSelections.join(", ")), 1, 0).render(width),
1280
+ "",
1281
+ ];
1282
+ }
1283
+
1284
+ return [
1285
+ ...new Text(this.theme.fg("accent", this.theme.bold("Custom response")), 1, 0).render(width),
1286
+ "",
1287
+ ];
1288
+ }
1289
+
1290
+ private limitEditorLines(lines: string[], budget: number, width: number): string[] {
1291
+ const safeBudget = Math.max(0, Math.floor(budget));
1292
+ if (safeBudget <= 0) return [];
1293
+ if (lines.length <= safeBudget) {
1294
+ return lines.map((line) => truncateToWidth(line, width, "", true));
1295
+ }
1296
+ if (safeBudget === 1) return [this.theme.fg("dim", "…")];
1297
+
1298
+ const topBorder = truncateToWidth(lines[0] ?? "", width, "", true);
1299
+ const bottomBorder = truncateToWidth(lines[lines.length - 1] ?? "", width, "", true);
1300
+ if (safeBudget === 2) return [topBorder, bottomBorder];
1301
+
1302
+ const contentLines = lines.slice(1, -1);
1303
+ const contentBudget = safeBudget - 2;
1304
+ // Locate the cursor row: prefer the zero-width CURSOR_MARKER the editor
1305
+ // emits while focused (the same mechanism pi-tui core uses for hardware
1306
+ // cursor placement), falling back to the inverse-video fake cursor.
1307
+ const cursorLineIndex = contentLines.findIndex(
1308
+ (line) => line.includes(CURSOR_MARKER) || line.includes("\x1b[7m"),
1309
+ );
1310
+ const maxStart = Math.max(0, contentLines.length - contentBudget);
1311
+ const start = cursorLineIndex >= 0
1312
+ ? Math.max(0, Math.min(cursorLineIndex - contentBudget + 1, maxStart))
1313
+ : maxStart;
1314
+ const visibleContentLines = contentLines.slice(start, start + contentBudget);
1315
+ const markedContentLines = this.applyPromptOverflowMarkers(
1316
+ visibleContentLines,
1317
+ width,
1318
+ start > 0,
1319
+ start + contentBudget < contentLines.length,
1320
+ );
1321
+
1322
+ return [topBorder, ...markedContentLines, bottomBorder];
1323
+ }
1324
+
1325
+ private renderPromptPane(promptLines: string[], budget: number, width: number): string[] {
1326
+ const viewportRows = Math.max(0, Math.floor(budget));
1327
+ this.promptViewportRows = viewportRows;
1328
+
1329
+ if (viewportRows <= 0 || promptLines.length === 0) {
1330
+ this.promptMaxScrollOffset = 0;
1331
+ this.promptScrollOffset = 0;
1332
+ return [];
1333
+ }
1334
+
1335
+ this.promptMaxScrollOffset = Math.max(0, promptLines.length - viewportRows);
1336
+ this.promptScrollOffset = Math.max(0, Math.min(this.promptScrollOffset, this.promptMaxScrollOffset));
1337
+
1338
+ const visibleLines = promptLines.slice(this.promptScrollOffset, this.promptScrollOffset + viewportRows);
1339
+ const hasHiddenAbove = this.promptScrollOffset > 0;
1340
+ const hasHiddenBelow = this.promptScrollOffset + viewportRows < promptLines.length;
1341
+ return this.applyPromptOverflowMarkers(visibleLines, width, hasHiddenAbove, hasHiddenBelow);
1133
1342
  }
1134
1343
 
1135
- private countWrappedLines(text: string, width: number): number {
1136
- return Math.max(1, wrapTextWithAnsi(text, Math.max(10, width - 2)).length);
1344
+ private applyPromptOverflowMarkers(
1345
+ lines: string[],
1346
+ width: number,
1347
+ hasHiddenAbove: boolean,
1348
+ hasHiddenBelow: boolean,
1349
+ ): string[] {
1350
+ if (lines.length === 0) return lines;
1351
+
1352
+ const marked = [...lines];
1353
+ if (hasHiddenAbove && hasHiddenBelow && marked.length === 1) {
1354
+ marked[0] = this.addPromptOverflowMarker(marked[0] ?? "", "↕", width);
1355
+ return marked;
1356
+ }
1357
+
1358
+ if (hasHiddenAbove) {
1359
+ marked[0] = this.addPromptOverflowMarker(marked[0] ?? "", "↑", width);
1360
+ }
1361
+ if (hasHiddenBelow) {
1362
+ const lastIndex = marked.length - 1;
1363
+ marked[lastIndex] = this.addPromptOverflowMarker(marked[lastIndex] ?? "", "↓", width);
1364
+ }
1365
+ return marked;
1137
1366
  }
1138
1367
 
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;
1368
+ private addPromptOverflowMarker(line: string, marker: string, width: number): string {
1369
+ return truncateToWidth(`${this.theme.fg("dim", marker)} ${line}`, width, "", true);
1370
+ }
1371
+
1372
+ private limitLines(lines: string[], budget: number, width: number, showOverflowMarker: boolean): string[] {
1373
+ const safeBudget = Math.max(0, Math.floor(budget));
1374
+ if (safeBudget <= 0) return [];
1375
+ if (lines.length <= safeBudget) {
1376
+ return lines.map((line) => truncateToWidth(line, width, "", true));
1377
+ }
1378
+ if (!showOverflowMarker) {
1379
+ return lines.slice(0, safeBudget).map((line) => truncateToWidth(line, width, "", true));
1380
+ }
1381
+ if (safeBudget === 1) return [this.theme.fg("dim", "…")];
1382
+ return [
1383
+ ...lines.slice(0, safeBudget - 1).map((line) => truncateToWidth(line, width, "", true)),
1384
+ this.theme.fg("dim", "…"),
1385
+ ];
1386
+ }
1387
+
1388
+ private renderTopBorder(width: number): string {
1389
+ return new BoxBorderTop(
1390
+ (s: string) => this.theme.fg("accent", s),
1391
+ "ask_user",
1392
+ (s: string) => this.theme.fg("dim", this.theme.bold(s)),
1393
+ ).render(width)[0] ?? "";
1394
+ }
1395
+
1396
+ private renderBottomBorder(width: number): string {
1397
+ return new BoxBorderBottom(
1398
+ (s: string) => this.theme.fg("accent", s),
1399
+ `v${ASK_USER_VERSION}`,
1400
+ (s: string) => this.theme.fg("dim", s),
1401
+ ).render(width)[0] ?? "";
1402
+ }
1403
+
1404
+ private frameBodyLines(bodyLines: string[], width: number, innerWidth: number): string[] {
1405
+ const borderColor = (s: string) => this.theme.fg("accent", s);
1406
+ return [
1407
+ this.renderTopBorder(width),
1408
+ ...bodyLines.map((line) => {
1409
+ const padded = truncateToWidth(line, innerWidth, "", true);
1410
+ return `${borderColor(BOX_BORDER_LEFT)}${padded}${borderColor(BOX_BORDER_RIGHT)}`;
1411
+ }),
1412
+ this.renderBottomBorder(width),
1413
+ ];
1414
+ }
1415
+
1416
+ private frameRawLines(rawLines: string[], width: number, innerWidth: number): string[] {
1417
+ const borderColor = (s: string) => this.theme.fg("accent", s);
1418
+ return rawLines.map((line, index) => {
1419
+ if (index === 0) return this.renderTopBorder(width);
1420
+ if (index === rawLines.length - 1) return this.renderBottomBorder(width);
1421
+ const padded = truncateToWidth(line, innerWidth, "", true);
1422
+ return `${borderColor(BOX_BORDER_LEFT)}${padded}${borderColor(BOX_BORDER_RIGHT)}`;
1423
+ });
1147
1424
  }
1148
1425
 
1149
1426
  private updateStaticText(): void {
@@ -1169,6 +1446,9 @@ class AskComponent extends Container {
1169
1446
  const overlayHint = this.displayMode === "overlay" && !this.shortcuts.overlayToggle.disabled
1170
1447
  ? literalHint(theme, this.shortcuts.overlayToggle.spec, "hide")
1171
1448
  : null;
1449
+ const promptScrollHint = this.displayMode === "overlay"
1450
+ ? literalHint(theme, "PgUp/PgDn", "prompt")
1451
+ : null;
1172
1452
  const commentHint = this.allowComment && !this.shortcuts.commentToggle.disabled
1173
1453
  ? literalHint(theme, this.shortcuts.commentToggle.spec, "toggle context")
1174
1454
  : null;
@@ -1194,6 +1474,7 @@ class AskComponent extends Container {
1194
1474
  literalHint(theme, "↑↓", "navigate"),
1195
1475
  literalHint(theme, "space", "toggle"),
1196
1476
  commentHint,
1477
+ promptScrollHint,
1197
1478
  overlayHint,
1198
1479
  keybindingHint(theme, this.keybindings, "tui.select.confirm", "submit"),
1199
1480
  keybindingHint(theme, this.keybindings, "tui.select.cancel", "cancel"),
@@ -1207,9 +1488,10 @@ class AskComponent extends Container {
1207
1488
  .filter((key) => key !== "escape" && key !== "esc");
1208
1489
  const hints = [
1209
1490
  literalHint(theme, "type", "filter"),
1491
+ commentHint,
1492
+ promptScrollHint,
1210
1493
  keybindingHint(theme, this.keybindings, "tui.editor.deleteCharBackward", "erase"),
1211
1494
  literalHint(theme, "↑↓", "navigate"),
1212
- commentHint,
1213
1495
  overlayHint,
1214
1496
  keybindingHint(theme, this.keybindings, "tui.select.confirm", "select"),
1215
1497
  literalHint(theme, "esc", "clear/cancel"),
@@ -1380,7 +1662,53 @@ class AskComponent extends Container {
1380
1662
  this.tui.requestRender();
1381
1663
  }
1382
1664
 
1665
+ private setPromptScrollOffset(nextOffset: number): boolean {
1666
+ if (this.displayMode !== "overlay" || this.promptMaxScrollOffset <= 0) return false;
1667
+ const clamped = Math.max(0, Math.min(Math.floor(nextOffset), this.promptMaxScrollOffset));
1668
+ const changed = clamped !== this.promptScrollOffset;
1669
+ this.promptScrollOffset = clamped;
1670
+ return changed;
1671
+ }
1672
+
1673
+ private handlePromptScrollInput(data: string): boolean {
1674
+ if (this.displayMode !== "overlay" || this.promptMaxScrollOffset <= 0) return false;
1675
+ // Prompt scrolling is select-mode only: in freeform/comment modes the
1676
+ // editor owns PageUp/PageDown (tui.editor.pageUp/pageDown) for paging
1677
+ // through long input, so intercepting them here would steal editor keys.
1678
+ if (this.mode !== "select") return false;
1679
+
1680
+ const pageRows = Math.max(1, this.promptViewportRows - 1);
1681
+ const halfPageRows = Math.max(1, Math.floor(this.promptViewportRows / 2));
1682
+ let handled = false;
1683
+
1684
+ if (matchesKey(data, PROMPT_SCROLL_PAGE_UP_KEY)) {
1685
+ handled = true;
1686
+ this.setPromptScrollOffset(this.promptScrollOffset - pageRows);
1687
+ } else if (matchesKey(data, PROMPT_SCROLL_PAGE_DOWN_KEY)) {
1688
+ handled = true;
1689
+ this.setPromptScrollOffset(this.promptScrollOffset + pageRows);
1690
+ } else if (matchesKey(data, PROMPT_SCROLL_HOME_KEY)) {
1691
+ handled = true;
1692
+ this.setPromptScrollOffset(0);
1693
+ } else if (matchesKey(data, PROMPT_SCROLL_END_KEY)) {
1694
+ handled = true;
1695
+ this.setPromptScrollOffset(this.promptMaxScrollOffset);
1696
+ } else if (matchesKey(data, PROMPT_SCROLL_HALF_PAGE_UP_KEY)) {
1697
+ handled = true;
1698
+ this.setPromptScrollOffset(this.promptScrollOffset - halfPageRows);
1699
+ } else if (matchesKey(data, PROMPT_SCROLL_HALF_PAGE_DOWN_KEY)) {
1700
+ handled = true;
1701
+ this.setPromptScrollOffset(this.promptScrollOffset + halfPageRows);
1702
+ }
1703
+
1704
+ return handled;
1705
+ }
1706
+
1383
1707
  handleInput(data: string): void {
1708
+ if (this.handlePromptScrollInput(data)) {
1709
+ this.tui.requestRender();
1710
+ return;
1711
+ }
1384
1712
  if (this.mode === "freeform" || this.mode === "comment") {
1385
1713
  if (matchesKey(data, Key.escape)) {
1386
1714
  this.showSelectMode();
@@ -1500,15 +1828,17 @@ export default function(pi: ExtensionAPI) {
1500
1828
  ),
1501
1829
  options: Type.Optional(
1502
1830
  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
- ]),
1831
+ // Flat object shape: union item schemas get stripped or rejected
1832
+ // by several providers/proxies (Google function calling,
1833
+ // Codex-style backends, cmux), leaving the model to guess the shape
1834
+ // and produce empty options. Plain strings are still accepted at
1835
+ // runtime for older transcripts. See issue #22.
1836
+ Type.Object({
1837
+ title: Type.String({ description: "Short title for this option" }),
1838
+ description: Type.Optional(
1839
+ Type.String({ description: "Longer description explaining this option" }),
1840
+ ),
1841
+ }),
1512
1842
  { description: "List of options for the user to choose from" },
1513
1843
  ),
1514
1844
  ),
@@ -1579,9 +1909,25 @@ export default function(pi: ExtensionAPI) {
1579
1909
  DEFAULT_COMMENT_TOGGLE_KEY,
1580
1910
  ),
1581
1911
  };
1582
- const options = normalizeOptions(rawOptions);
1912
+ const options = rawOptions.map(coerceOption).filter((option): option is QuestionOption => option !== null);
1583
1913
  const normalizedContext = context?.trim() || undefined;
1584
1914
 
1915
+ if (rawOptions.length > 0 && options.length === 0) {
1916
+ return {
1917
+ content: [
1918
+ {
1919
+ type: "text",
1920
+ text:
1921
+ `All ${rawOptions.length} option(s) were malformed, so nothing could be shown to the user. `
1922
+ + `Each option must be a plain string or an object like { "title": "Short label", "description": "Optional detail" }. `
1923
+ + `Call ask_user again with corrected options.`,
1924
+ },
1925
+ ],
1926
+ isError: true,
1927
+ details: { error: "Malformed options: no entry had a usable title" },
1928
+ };
1929
+ }
1930
+
1585
1931
  if (!ctx.hasUI || !ctx.ui) {
1586
1932
  const optionText = options.length > 0 ? `\n\nOptions:\n${formatOptionsForMessage(options)}` : "";
1587
1933
  const freeformHint = allowFreeform ? "\n\nYou can also answer freely." : "";
@@ -1732,9 +2078,7 @@ export default function(pi: ExtensionAPI) {
1732
2078
  let text = theme.fg("toolTitle", theme.bold("ask_user "));
1733
2079
  text += theme.fg("muted", question);
1734
2080
  if (rawOptions.length > 0) {
1735
- const labels = rawOptions.map((o: unknown) =>
1736
- typeof o === "string" ? o : (o as QuestionOption)?.title ?? "",
1737
- );
2081
+ const labels = rawOptions.map((o: unknown) => coerceOption(o)?.title ?? "<invalid>");
1738
2082
  text += "\n" + theme.fg("dim", ` ${rawOptions.length} option(s): ${labels.join(", ")}`);
1739
2083
  }
1740
2084
  if (args.allowMultiple) {
@@ -1755,8 +2099,7 @@ export default function(pi: ExtensionAPI) {
1755
2099
 
1756
2100
  if (options.isPartial) {
1757
2101
  const waitingText = result.content
1758
- ?.filter((part: { type?: string; text?: string }) => part?.type === "text")
1759
- .map((part: { text?: string }) => part.text ?? "")
2102
+ ?.map((part) => part.type === "text" ? part.text : "")
1760
2103
  .join("\n")
1761
2104
  .trim() || "Waiting for user input...";
1762
2105
  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.12.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": [