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.
- package/README.md +2 -2
- package/index.ts +404 -57
- 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` | `
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1118
|
-
|
|
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
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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
|
-
|
|
1131
|
-
|
|
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
|
|
1136
|
-
|
|
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
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
const
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
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 =
|
|
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
|
-
?.
|
|
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