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.
- package/README.md +19 -4
- package/index.ts +424 -60
- 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` | `
|
|
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
|
-
|
|
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 {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
1118
|
-
|
|
1166
|
+
return this.frameRawLines(super.render(innerWidth), width, innerWidth);
|
|
1167
|
+
}
|
|
1119
1168
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
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
|
-
|
|
1131
|
-
|
|
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
|
|
1136
|
-
|
|
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
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
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
|
|
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 =
|
|
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
|
-
?.
|
|
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