pi-ask-user 0.11.1 → 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 +404 -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();
@@ -1487,6 +1815,10 @@ export default function(pi: ExtensionAPI) {
1487
1815
  "Ask exactly one focused question per ask_user call.",
1488
1816
  "Do not combine multiple numbered, multipart, or unrelated questions into one ask_user prompt.",
1489
1817
  ],
1818
+ // Block other tool calls in the same assistant turn until the user answers,
1819
+ // so the model can't batch ask_user with bash/edit/write and let those run
1820
+ // (potentially with side effects) before the user sees the prompt.
1821
+ executionMode: "sequential",
1490
1822
  parameters: Type.Object({
1491
1823
  question: Type.String({ description: "The question to ask the user" }),
1492
1824
  context: Type.Optional(
@@ -1496,15 +1828,17 @@ export default function(pi: ExtensionAPI) {
1496
1828
  ),
1497
1829
  options: Type.Optional(
1498
1830
  Type.Array(
1499
- Type.Union([
1500
- Type.String({ description: "Short title for this option" }),
1501
- Type.Object({
1502
- title: Type.String({ description: "Short title for this option" }),
1503
- description: Type.Optional(
1504
- Type.String({ description: "Longer description explaining this option" }),
1505
- ),
1506
- }),
1507
- ]),
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
+ }),
1508
1842
  { description: "List of options for the user to choose from" },
1509
1843
  ),
1510
1844
  ),
@@ -1575,9 +1909,25 @@ export default function(pi: ExtensionAPI) {
1575
1909
  DEFAULT_COMMENT_TOGGLE_KEY,
1576
1910
  ),
1577
1911
  };
1578
- const options = normalizeOptions(rawOptions);
1912
+ const options = rawOptions.map(coerceOption).filter((option): option is QuestionOption => option !== null);
1579
1913
  const normalizedContext = context?.trim() || undefined;
1580
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
+
1581
1931
  if (!ctx.hasUI || !ctx.ui) {
1582
1932
  const optionText = options.length > 0 ? `\n\nOptions:\n${formatOptionsForMessage(options)}` : "";
1583
1933
  const freeformHint = allowFreeform ? "\n\nYou can also answer freely." : "";
@@ -1728,9 +2078,7 @@ export default function(pi: ExtensionAPI) {
1728
2078
  let text = theme.fg("toolTitle", theme.bold("ask_user "));
1729
2079
  text += theme.fg("muted", question);
1730
2080
  if (rawOptions.length > 0) {
1731
- const labels = rawOptions.map((o: unknown) =>
1732
- typeof o === "string" ? o : (o as QuestionOption)?.title ?? "",
1733
- );
2081
+ const labels = rawOptions.map((o: unknown) => coerceOption(o)?.title ?? "<invalid>");
1734
2082
  text += "\n" + theme.fg("dim", ` ${rawOptions.length} option(s): ${labels.join(", ")}`);
1735
2083
  }
1736
2084
  if (args.allowMultiple) {
@@ -1751,8 +2099,7 @@ export default function(pi: ExtensionAPI) {
1751
2099
 
1752
2100
  if (options.isPartial) {
1753
2101
  const waitingText = result.content
1754
- ?.filter((part: { type?: string; text?: string }) => part?.type === "text")
1755
- .map((part: { text?: string }) => part.text ?? "")
2102
+ ?.map((part) => part.type === "text" ? part.text : "")
1756
2103
  .join("\n")
1757
2104
  .trim() || "Waiting for user input...";
1758
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.1",
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": [