@yandy0725/pi-ask-user 0.4.0 → 0.4.1
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 +1 -0
- package/README.zh.md +1 -0
- package/index.ts +233 -89
- package/package.json +3 -3
- package/src/single-select-layout.ts +55 -35
package/README.md
CHANGED
|
@@ -148,6 +148,7 @@ While an `ask_user` prompt is open:
|
|
|
148
148
|
| `enter` | Confirm the focused option, submit a freeform response, or submit/skip an optional comment. |
|
|
149
149
|
| `esc` | Clear the search filter, exit freeform/comment mode, or cancel the prompt. |
|
|
150
150
|
| `↑` / `↓`, `ctrl+k` / `ctrl+j` | Navigate options. `ctrl+k` / `ctrl+j` (vim-style) work while typing in searchable prompts without disturbing the filter. |
|
|
151
|
+
| `pgup` / `pgdn` | Scroll the question/context region when it is taller than the popup. The popup never exceeds 85% of the terminal height, so options and the help line always stay visible; a `↑ n lines above` / `↓ n lines below` indicator marks the hidden content. Only in option-select mode (the editor owns these keys in freeform/comment mode). |
|
|
151
152
|
|
|
152
153
|
If you prefer never to see the overlay, set `PI_ASK_USER_DISPLAY_MODE=inline` globally or `"displayMode": "inline"` in `ask-user.json`.
|
|
153
154
|
|
package/README.zh.md
CHANGED
|
@@ -130,6 +130,7 @@ export PI_ASK_USER_COMMENT_TOGGLE_KEY=alt+c
|
|
|
130
130
|
| `enter` | 确认聚焦的选项、提交自由文本回复、或提交/跳过可选注释。 |
|
|
131
131
|
| `esc` | 清除搜索过滤、退出自由文本/注释模式、或取消提示。 |
|
|
132
132
|
| `↑` / `↓`、`ctrl+k` / `ctrl+j` | 导航选项。`ctrl+k` / `ctrl+j`(vim 风格)可在可搜索提示中打字时不干扰过滤输入。 |
|
|
133
|
+
| `pgup` / `pgdn` | 当问题/背景内容超出弹窗高度时滚动该区域。弹窗总高度不会超过终端的 85%,选项和帮助行始终可见;被隐藏的内容会用 `↑ n lines above` / `↓ n lines below` 指示行标注。仅在选项选择模式下可用(自由文本/注释模式下这两个按键归编辑器所有)。 |
|
|
133
134
|
|
|
134
135
|
如果你始终不希望看到叠加层,请全局设置 `PI_ASK_USER_DISPLAY_MODE=inline`,或在 `ask-user.json` 中设置 `"displayMode": "inline"`。
|
|
135
136
|
|
package/index.ts
CHANGED
|
@@ -33,7 +33,12 @@ import {
|
|
|
33
33
|
wrapTextWithAnsi,
|
|
34
34
|
} from "@earendil-works/pi-tui";
|
|
35
35
|
import { Type } from "typebox";
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
type QuestionOption,
|
|
38
|
+
renderSingleSelectRows,
|
|
39
|
+
type SelectableBlock,
|
|
40
|
+
windowSelectedBlocks,
|
|
41
|
+
} from "./src/single-select-layout.js";
|
|
37
42
|
|
|
38
43
|
const _require = createRequire(import.meta.url);
|
|
39
44
|
const ASK_USER_VERSION: string = (_require("./package.json") as { version: string }).version;
|
|
@@ -368,6 +373,14 @@ function resolveShortcut(
|
|
|
368
373
|
type AskMode = "select" | "freeform" | "comment";
|
|
369
374
|
|
|
370
375
|
const ASK_OVERLAY_MAX_HEIGHT_RATIO = 0.85;
|
|
376
|
+
/** Fixed lines in the box: 2 borders + 5 spacers (title/help measured at render time). */
|
|
377
|
+
const ASK_OVERLAY_FIXED_LINES = 7;
|
|
378
|
+
/** Maximum share of the available rows the header (question+context) may claim. */
|
|
379
|
+
const ASK_HEADER_MAX_SHARE = 0.5;
|
|
380
|
+
/** Minimum header rows (question) kept visible when space is tight. */
|
|
381
|
+
const MIN_HEADER_ROWS = 1;
|
|
382
|
+
/** Minimum interactive-region rows (option list) kept visible when tight. */
|
|
383
|
+
const MIN_MODE_ROWS = 4;
|
|
371
384
|
// biome-ignore lint/suspicious/noExplicitAny: SizeValue compatibility with pi-tui
|
|
372
385
|
const ASK_OVERLAY_WIDTH: any = "92%";
|
|
373
386
|
const ASK_OVERLAY_MIN_WIDTH = 40;
|
|
@@ -447,6 +460,7 @@ class MultiSelectList implements Component {
|
|
|
447
460
|
private selectedIndex = 0;
|
|
448
461
|
private checked = new Set<number>();
|
|
449
462
|
private commentEnabled = false;
|
|
463
|
+
private maxVisibleRows = 10;
|
|
450
464
|
private cachedWidth?: number;
|
|
451
465
|
private cachedLines?: string[];
|
|
452
466
|
|
|
@@ -474,6 +488,14 @@ class MultiSelectList implements Component {
|
|
|
474
488
|
return this.commentEnabled;
|
|
475
489
|
}
|
|
476
490
|
|
|
491
|
+
setMaxVisibleRows(rows: number): void {
|
|
492
|
+
const next = Math.max(1, Math.floor(rows));
|
|
493
|
+
if (next !== this.maxVisibleRows) {
|
|
494
|
+
this.maxVisibleRows = next;
|
|
495
|
+
this.invalidate();
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
477
499
|
invalidate(): void {
|
|
478
500
|
this.cachedWidth = undefined;
|
|
479
501
|
this.cachedLines = undefined;
|
|
@@ -596,7 +618,6 @@ class MultiSelectList implements Component {
|
|
|
596
618
|
|
|
597
619
|
const theme = this.theme;
|
|
598
620
|
const count = this.getItemCount();
|
|
599
|
-
const maxVisible = Math.min(count, 10);
|
|
600
621
|
|
|
601
622
|
if (count === 0) {
|
|
602
623
|
this.cachedLines = [theme.fg("warning", "No options")];
|
|
@@ -604,56 +625,55 @@ class MultiSelectList implements Component {
|
|
|
604
625
|
return this.cachedLines;
|
|
605
626
|
}
|
|
606
627
|
|
|
607
|
-
const
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
const lines: string[] = [];
|
|
611
|
-
|
|
612
|
-
for (let i = startIndex; i < endIndex; i++) {
|
|
628
|
+
const pointerWidth = 2; // "→ " / " "
|
|
629
|
+
const blocks: SelectableBlock[] = [];
|
|
630
|
+
for (let i = 0; i < count; i++) {
|
|
613
631
|
const isSelected = i === this.selectedIndex;
|
|
614
|
-
const
|
|
632
|
+
const pointer = isSelected ? theme.fg("accent", "→ ") : " ";
|
|
633
|
+
const continuation = " ".repeat(pointerWidth);
|
|
634
|
+
const lines: string[] = [];
|
|
615
635
|
|
|
616
636
|
if (this.isCommentToggleRow(i)) {
|
|
617
637
|
const checkbox = this.commentEnabled ? theme.fg("success", "[✓]") : theme.fg("dim", "[ ]");
|
|
618
638
|
const label = isSelected
|
|
619
639
|
? theme.fg("accent", theme.bold(COMMENT_TOGGLE_LABEL))
|
|
620
640
|
: theme.fg("text", theme.bold(COMMENT_TOGGLE_LABEL));
|
|
621
|
-
lines.push(
|
|
622
|
-
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
if (this.isFreeformRow(i)) {
|
|
641
|
+
lines.push(`${pointer} ${checkbox} ${label}`);
|
|
642
|
+
} else if (this.isFreeformRow(i)) {
|
|
626
643
|
const label = theme.fg("text", theme.bold("Type something."));
|
|
627
644
|
const desc = theme.fg("muted", "Enter a custom response");
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
for (const w of wrapped) {
|
|
648
|
-
lines.push(truncateToWidth(indent + theme.fg("muted", w), width, ""));
|
|
645
|
+
lines.push(`${pointer} ${label} ${theme.fg("dim", "—")} ${desc}`);
|
|
646
|
+
} else {
|
|
647
|
+
const option = this.options[i];
|
|
648
|
+
if (!option) continue;
|
|
649
|
+
|
|
650
|
+
const checkbox = this.checked.has(i) ? theme.fg("success", "[✓]") : theme.fg("dim", "[ ]");
|
|
651
|
+
const num = theme.fg("dim", `${i + 1}.`);
|
|
652
|
+
const title = isSelected
|
|
653
|
+
? theme.fg("accent", theme.bold(option.title))
|
|
654
|
+
: theme.fg("text", theme.bold(option.title));
|
|
655
|
+
lines.push(`${pointer}${num} ${checkbox} ${title}`);
|
|
656
|
+
|
|
657
|
+
if (option.description) {
|
|
658
|
+
const indent = `${continuation} `;
|
|
659
|
+
const wrapWidth = Math.max(10, width - indent.length);
|
|
660
|
+
const wrapped = wrapTextWithAnsi(option.description, wrapWidth);
|
|
661
|
+
for (const w of wrapped) {
|
|
662
|
+
lines.push(indent + theme.fg("muted", w));
|
|
663
|
+
}
|
|
649
664
|
}
|
|
650
665
|
}
|
|
651
|
-
}
|
|
652
666
|
|
|
653
|
-
|
|
654
|
-
lines.push(theme.fg("dim", truncateToWidth(` (${this.selectedIndex + 1}/${count})`, width, "")));
|
|
667
|
+
blocks.push({ lines, selected: isSelected });
|
|
655
668
|
}
|
|
656
669
|
|
|
670
|
+
const rows = windowSelectedBlocks({
|
|
671
|
+
blocks,
|
|
672
|
+
maxRows: this.maxVisibleRows,
|
|
673
|
+
indicator: theme.fg("dim", `(${this.selectedIndex + 1}/${count})`),
|
|
674
|
+
});
|
|
675
|
+
const lines = rows.map((row) => truncateToWidth(row.line, width, ""));
|
|
676
|
+
|
|
657
677
|
this.cachedWidth = width;
|
|
658
678
|
this.cachedLines = lines;
|
|
659
679
|
return lines;
|
|
@@ -1032,14 +1052,22 @@ class AskComponent extends Container {
|
|
|
1032
1052
|
private titleText: Text;
|
|
1033
1053
|
private questionText: Text;
|
|
1034
1054
|
private contextComponent?: Component;
|
|
1035
|
-
private modeContainer: Container;
|
|
1036
1055
|
private helpText: Text;
|
|
1037
1056
|
|
|
1038
|
-
// Mode components
|
|
1057
|
+
// Mode components (rendered in order; tracked separately from the static
|
|
1058
|
+
// children so the budget-aware render can measure and clamp them directly)
|
|
1059
|
+
private modeComponents: Component[] = [];
|
|
1039
1060
|
private singleSelectList?: WrappedSingleSelectList;
|
|
1040
1061
|
private multiSelectList?: MultiSelectList;
|
|
1041
1062
|
private editor?: Editor;
|
|
1042
1063
|
|
|
1064
|
+
// Header (question + context) scroll state. The overlay is hard-clamped by
|
|
1065
|
+
// pi-tui at maxHeight (85% of terminal rows), and lines beyond that are
|
|
1066
|
+
// silently sliced off — so the component must budget its own height and
|
|
1067
|
+
// scroll the header region when question+context exceed their share.
|
|
1068
|
+
private headerScrollOffset = 0;
|
|
1069
|
+
private headerScrollState = { scrollable: false, pageSize: 0 };
|
|
1070
|
+
|
|
1043
1071
|
// Focusable - propagate to Editor for IME cursor positioning
|
|
1044
1072
|
private _focused = false;
|
|
1045
1073
|
get focused(): boolean {
|
|
@@ -1112,10 +1140,6 @@ class AskComponent extends Container {
|
|
|
1112
1140
|
|
|
1113
1141
|
this.addChild(new Spacer(1));
|
|
1114
1142
|
|
|
1115
|
-
this.modeContainer = new Container();
|
|
1116
|
-
this.addChild(this.modeContainer);
|
|
1117
|
-
|
|
1118
|
-
this.addChild(new Spacer(1));
|
|
1119
1143
|
this.helpText = new Text("", 1, 0);
|
|
1120
1144
|
this.addChild(this.helpText);
|
|
1121
1145
|
|
|
@@ -1138,48 +1162,146 @@ class AskComponent extends Container {
|
|
|
1138
1162
|
this.updateHelpText();
|
|
1139
1163
|
}
|
|
1140
1164
|
|
|
1165
|
+
/**
|
|
1166
|
+
* Budget-aware render. pi-tui slices any overlay lines beyond maxHeight
|
|
1167
|
+
* (85% of terminal rows, minus margins) without scrolling, so the component
|
|
1168
|
+
* must never emit more lines than that. The available height is split
|
|
1169
|
+
* between the header (question + context) and the interactive region; the
|
|
1170
|
+
* interactive region keeps a floor of a few rows so options stay visible,
|
|
1171
|
+
* and the header becomes a scrollable viewport (pageUp/pageDown) whenever it
|
|
1172
|
+
* does not fit.
|
|
1173
|
+
*/
|
|
1141
1174
|
override render(width: number): string[] {
|
|
1142
1175
|
const innerWidth = Math.max(1, width - BOX_BORDER_OVERHEAD);
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1176
|
+
const termRows = this.tui.terminal?.rows ?? 24;
|
|
1177
|
+
// Mirrors the overlay options: maxHeight "85%" clamped by margin 1.
|
|
1178
|
+
const budgetHeight = Math.max(1, Math.min(Math.floor(termRows * ASK_OVERLAY_MAX_HEIGHT_RATIO), termRows - 2));
|
|
1179
|
+
|
|
1180
|
+
// Measure the fixed chrome — the title and help lines can wrap (real
|
|
1181
|
+
// keybinding hints span two rows at common widths), so they must be
|
|
1182
|
+
// counted, not assumed: 2 borders + 5 spacers + measured rows.
|
|
1183
|
+
const titleLines = this.titleText.render(innerWidth);
|
|
1184
|
+
const helpLines = this.helpText.render(innerWidth);
|
|
1185
|
+
const chromeLines = ASK_OVERLAY_FIXED_LINES + titleLines.length + helpLines.length;
|
|
1186
|
+
|
|
1187
|
+
const headerLines = this.buildHeaderLines(innerWidth);
|
|
1188
|
+
|
|
1189
|
+
// Measure the interactive region at its natural (unclamped) height.
|
|
1190
|
+
const modeDesired = this.measureModeRegion(innerWidth);
|
|
1191
|
+
|
|
1192
|
+
// Allocate the remaining rows between header and interactive region:
|
|
1193
|
+
// the header may claim at most ~half the available rows, the interactive
|
|
1194
|
+
// region takes the rest (with a floor so options stay usable), and any
|
|
1195
|
+
// rows the interactive region cannot use flow back to the header.
|
|
1196
|
+
const available = Math.max(2, budgetHeight - chromeLines);
|
|
1197
|
+
const headerCap = Math.max(MIN_HEADER_ROWS, Math.floor(available * ASK_HEADER_MAX_SHARE));
|
|
1198
|
+
let headerBudget = Math.min(headerLines.length, headerCap);
|
|
1199
|
+
let modeBudget = Math.min(modeDesired, Math.max(MIN_MODE_ROWS, available - headerBudget));
|
|
1200
|
+
headerBudget = Math.min(headerLines.length, Math.max(MIN_HEADER_ROWS, available - modeBudget));
|
|
1201
|
+
// Hard clamp for tiny terminals where even the floors exceed the budget.
|
|
1202
|
+
if (headerBudget + modeBudget > available) {
|
|
1203
|
+
modeBudget = Math.max(1, available - headerBudget);
|
|
1149
1204
|
}
|
|
1150
1205
|
|
|
1151
|
-
|
|
1152
|
-
const
|
|
1206
|
+
const modeLines = this.renderModeRegion(innerWidth, modeBudget).slice(0, Math.max(1, modeBudget));
|
|
1207
|
+
const headerViewport = this.renderHeaderViewport(headerLines, headerBudget, innerWidth);
|
|
1208
|
+
|
|
1209
|
+
// Assemble: borders, title, header viewport, interactive region, help.
|
|
1210
|
+
const inner: string[] = [];
|
|
1211
|
+
inner.push("");
|
|
1212
|
+
inner.push(...titleLines);
|
|
1213
|
+
inner.push("");
|
|
1214
|
+
inner.push(...headerViewport);
|
|
1215
|
+
inner.push("");
|
|
1216
|
+
inner.push(...modeLines);
|
|
1217
|
+
inner.push("");
|
|
1218
|
+
inner.push(...helpLines);
|
|
1153
1219
|
|
|
1154
|
-
// First and last lines are the top/bottom box borders — pass through at full width.
|
|
1155
|
-
// All inner lines get wrapped with side borders.
|
|
1156
1220
|
const borderColor = (s: string) => this.theme.fg("accent", s);
|
|
1157
1221
|
const titleColor = (s: string) => this.theme.fg("dim", this.theme.bold(s));
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
// Box top/bottom borders already rendered at innerWidth — re-render at full width
|
|
1161
|
-
if (index === 0) return new BoxBorderTop(borderColor, "ask_user", titleColor).render(width)[0];
|
|
1162
|
-
return new BoxBorderBottom(borderColor, `v${ASK_USER_VERSION}`, (s: string) => this.theme.fg("dim", s)).render(
|
|
1163
|
-
width,
|
|
1164
|
-
)[0];
|
|
1165
|
-
}
|
|
1222
|
+
const lines: string[] = [new BoxBorderTop(borderColor, "ask_user", titleColor).render(width)[0] ?? ""];
|
|
1223
|
+
for (const line of inner) {
|
|
1166
1224
|
const padded = truncateToWidth(line, innerWidth, "", true);
|
|
1167
|
-
|
|
1168
|
-
}
|
|
1225
|
+
lines.push(`${borderColor(BOX_BORDER_LEFT)}${padded}${borderColor(BOX_BORDER_RIGHT)}`);
|
|
1226
|
+
}
|
|
1227
|
+
lines.push("");
|
|
1228
|
+
lines.push(
|
|
1229
|
+
new BoxBorderBottom(borderColor, `v${ASK_USER_VERSION}`, (s: string) => this.theme.fg("dim", s)).render(width)[0] ??
|
|
1230
|
+
"",
|
|
1231
|
+
);
|
|
1232
|
+
|
|
1233
|
+
// Hard guarantee: never exceed the overlay budget (tiny terminals).
|
|
1234
|
+
return lines.slice(0, Math.max(budgetHeight, 3));
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
private buildHeaderLines(width: number): string[] {
|
|
1238
|
+
const lines: string[] = [];
|
|
1239
|
+
lines.push(...this.questionText.render(width));
|
|
1240
|
+
if (this.contextComponent && this.context) {
|
|
1241
|
+
lines.push("");
|
|
1242
|
+
lines.push(...this.contextComponent.render(width));
|
|
1243
|
+
}
|
|
1244
|
+
return lines;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
private renderHeaderViewport(content: string[], budget: number, width: number): string[] {
|
|
1248
|
+
if (budget <= 0) {
|
|
1249
|
+
this.headerScrollState = { scrollable: false, pageSize: 0 };
|
|
1250
|
+
return [];
|
|
1251
|
+
}
|
|
1252
|
+
if (content.length <= budget) {
|
|
1253
|
+
this.headerScrollOffset = 0;
|
|
1254
|
+
this.headerScrollState = { scrollable: false, pageSize: 0 };
|
|
1255
|
+
return content;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Reserve one line for the scroll indicator.
|
|
1259
|
+
const visible = Math.max(1, budget - 1);
|
|
1260
|
+
const maxOffset = Math.max(0, content.length - visible);
|
|
1261
|
+
const offset = Math.max(0, Math.min(this.headerScrollOffset, maxOffset));
|
|
1262
|
+
this.headerScrollOffset = offset;
|
|
1263
|
+
this.headerScrollState = { scrollable: true, pageSize: Math.max(1, visible - 1) };
|
|
1264
|
+
|
|
1265
|
+
const window = content.slice(offset, offset + visible);
|
|
1266
|
+
const aboveCount = offset;
|
|
1267
|
+
const belowCount = content.length - (offset + visible);
|
|
1268
|
+
const parts: string[] = [];
|
|
1269
|
+
if (aboveCount > 0) {
|
|
1270
|
+
parts.push(`↑ ${aboveCount} line${aboveCount === 1 ? "" : "s"} above`);
|
|
1271
|
+
}
|
|
1272
|
+
if (belowCount > 0) {
|
|
1273
|
+
parts.push(`↓ ${belowCount} line${belowCount === 1 ? "" : "s"} below`);
|
|
1274
|
+
}
|
|
1275
|
+
parts.push("pgup/pgdn scroll");
|
|
1276
|
+
const indicator = this.theme.fg("dim", truncateToWidth(` ${parts.join(" · ")}`, width, ""));
|
|
1277
|
+
return [...window, indicator];
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
private measureModeRegion(width: number): number {
|
|
1281
|
+
if (this.mode === "select") {
|
|
1282
|
+
// Unclamped measurement: let the list render every row it wants.
|
|
1283
|
+
const list = this.allowMultiple ? this.ensureMultiSelectList() : this.ensureSingleSelectList();
|
|
1284
|
+
list.setMaxVisibleRows(Number.POSITIVE_INFINITY);
|
|
1285
|
+
return list.render(width).length;
|
|
1286
|
+
}
|
|
1287
|
+
return this.modeComponents.reduce((count, component) => count + component.render(width).length, 0);
|
|
1169
1288
|
}
|
|
1170
1289
|
|
|
1171
|
-
private
|
|
1172
|
-
|
|
1290
|
+
private renderModeRegion(width: number, budget: number): string[] {
|
|
1291
|
+
if (this.mode === "select") {
|
|
1292
|
+
const list = this.allowMultiple ? this.ensureMultiSelectList() : this.ensureSingleSelectList();
|
|
1293
|
+
list.setMaxVisibleRows(Math.max(1, budget));
|
|
1294
|
+
return list.render(width);
|
|
1295
|
+
}
|
|
1296
|
+
return this.modeComponents.flatMap((component) => component.render(width));
|
|
1173
1297
|
}
|
|
1174
1298
|
|
|
1175
|
-
private
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
const spacerLines = this.context ? 6 : 5;
|
|
1182
|
-
return borderLines + spacerLines + titleLines + questionLines + contextLines + helpLines;
|
|
1299
|
+
private scrollHeader(delta: number): void {
|
|
1300
|
+
if (!this.headerScrollState.scrollable) return;
|
|
1301
|
+
this.headerScrollOffset = Math.max(0, this.headerScrollOffset + delta);
|
|
1302
|
+
// Re-clamped to the valid range on the next render.
|
|
1303
|
+
this.invalidate();
|
|
1304
|
+
this.tui.requestRender();
|
|
1183
1305
|
}
|
|
1184
1306
|
|
|
1185
1307
|
private updateStaticText(): void {
|
|
@@ -1352,6 +1474,16 @@ class AskComponent extends Container {
|
|
|
1352
1474
|
}
|
|
1353
1475
|
}
|
|
1354
1476
|
|
|
1477
|
+
private setModeComponents(components: Component[]): void {
|
|
1478
|
+
for (const component of this.modeComponents) {
|
|
1479
|
+
this.removeChild(component);
|
|
1480
|
+
}
|
|
1481
|
+
this.modeComponents = components;
|
|
1482
|
+
for (const component of components) {
|
|
1483
|
+
this.addChild(component);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1355
1487
|
private showSelectMode(): void {
|
|
1356
1488
|
if (this.mode === "freeform" || this.mode === "comment") {
|
|
1357
1489
|
this.saveEditorDraft();
|
|
@@ -1359,12 +1491,11 @@ class AskComponent extends Container {
|
|
|
1359
1491
|
|
|
1360
1492
|
this.mode = "select";
|
|
1361
1493
|
this.pendingSelections = [];
|
|
1362
|
-
this.modeContainer.clear();
|
|
1363
1494
|
|
|
1364
1495
|
if (this.allowMultiple) {
|
|
1365
|
-
this.
|
|
1496
|
+
this.setModeComponents([this.ensureMultiSelectList()]);
|
|
1366
1497
|
} else {
|
|
1367
|
-
this.
|
|
1498
|
+
this.setModeComponents([this.ensureSingleSelectList()]);
|
|
1368
1499
|
}
|
|
1369
1500
|
|
|
1370
1501
|
this.updateHelpText();
|
|
@@ -1378,16 +1509,17 @@ class AskComponent extends Container {
|
|
|
1378
1509
|
}
|
|
1379
1510
|
|
|
1380
1511
|
this.mode = "freeform";
|
|
1381
|
-
this.modeContainer.clear();
|
|
1382
1512
|
|
|
1383
1513
|
const editor = this.ensureEditor();
|
|
1384
1514
|
this.setEditorText(this.freeformDraft);
|
|
1385
1515
|
// biome-ignore lint/suspicious/noExplicitAny: Editor.focused setter is internal
|
|
1386
1516
|
(editor as any).focused = this._focused;
|
|
1387
1517
|
|
|
1388
|
-
this.
|
|
1389
|
-
|
|
1390
|
-
|
|
1518
|
+
this.setModeComponents([
|
|
1519
|
+
new Text(this.theme.fg("accent", this.theme.bold("Custom response")), 1, 0),
|
|
1520
|
+
new Spacer(1),
|
|
1521
|
+
editor,
|
|
1522
|
+
]);
|
|
1391
1523
|
|
|
1392
1524
|
this.updateHelpText();
|
|
1393
1525
|
this.invalidate();
|
|
@@ -1400,7 +1532,6 @@ class AskComponent extends Container {
|
|
|
1400
1532
|
}
|
|
1401
1533
|
|
|
1402
1534
|
this.mode = "comment";
|
|
1403
|
-
this.modeContainer.clear();
|
|
1404
1535
|
|
|
1405
1536
|
const editor = this.ensureEditor();
|
|
1406
1537
|
this.setEditorText(this.commentDraft);
|
|
@@ -1408,10 +1539,12 @@ class AskComponent extends Container {
|
|
|
1408
1539
|
(editor as any).focused = this._focused;
|
|
1409
1540
|
|
|
1410
1541
|
const selectedLabel = this.pendingSelections.length === 1 ? "Selected option:" : "Selected options:";
|
|
1411
|
-
this.
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1542
|
+
this.setModeComponents([
|
|
1543
|
+
new Text(this.theme.fg("accent", this.theme.bold(selectedLabel)), 1, 0),
|
|
1544
|
+
new Text(this.theme.fg("text", this.pendingSelections.join(", ")), 1, 0),
|
|
1545
|
+
new Spacer(1),
|
|
1546
|
+
editor,
|
|
1547
|
+
]);
|
|
1415
1548
|
|
|
1416
1549
|
this.updateHelpText();
|
|
1417
1550
|
this.invalidate();
|
|
@@ -1419,6 +1552,19 @@ class AskComponent extends Container {
|
|
|
1419
1552
|
}
|
|
1420
1553
|
|
|
1421
1554
|
handleInput(data: string): void {
|
|
1555
|
+
// Scroll the clamped question/context viewport. Only in select mode:
|
|
1556
|
+
// the editor owns pageUp/pageDown in freeform/comment modes.
|
|
1557
|
+
if (this.mode === "select" && this.headerScrollState.scrollable) {
|
|
1558
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
1559
|
+
this.scrollHeader(-this.headerScrollState.pageSize);
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
1563
|
+
this.scrollHeader(this.headerScrollState.pageSize);
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1422
1568
|
if (this.mode === "freeform" || this.mode === "comment") {
|
|
1423
1569
|
if (matchesKey(data, Key.escape)) {
|
|
1424
1570
|
this.showSelectMode();
|
|
@@ -1555,9 +1701,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1555
1701
|
// 它们是**用户偏好**,优先级为 模型参数 > env > ask-user.json > 默认值,模型能直接
|
|
1556
1702
|
// 盖掉用户的设置,且占了 782 字符的 schema。取值改由 env(PI_ASK_USER_DISPLAY_MODE 等)
|
|
1557
1703
|
// 与 ask-user.json 提供,内部解析链与 `AskParams` 类型保留(SDK/内部调用仍可传)。
|
|
1558
|
-
timeout: Type.Optional(
|
|
1559
|
-
Type.Number({ description: "Auto-dismiss after N ms; returns null when expired." }),
|
|
1560
|
-
),
|
|
1704
|
+
timeout: Type.Optional(Type.Number({ description: "Auto-dismiss after N ms; returns null when expired." })),
|
|
1561
1705
|
}),
|
|
1562
1706
|
|
|
1563
1707
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.4.
|
|
6
|
+
"version": "0.4.1",
|
|
7
7
|
"description": "Interactive ask_user tool for pi-coding-agent with searchable split-pane selection UI, multi-select, and freeform input",
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"repository": {
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"typebox": "*"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@biomejs/biome": "^2.6.
|
|
45
|
-
"@types/node": "^26.6.
|
|
44
|
+
"@biomejs/biome": "^2.6.4",
|
|
45
|
+
"@types/node": "^26.6.6",
|
|
46
46
|
"typebox": "^1.4.3",
|
|
47
47
|
"typescript": "~7.0.2",
|
|
48
48
|
"vitest": "^5.0.5"
|
|
@@ -66,7 +66,6 @@ function wrapText(text: string, width: number): string[] {
|
|
|
66
66
|
function padLine(prefix: string, content: string): string {
|
|
67
67
|
return `${prefix}${content}`.trimEnd();
|
|
68
68
|
}
|
|
69
|
-
|
|
70
69
|
interface ItemBlock {
|
|
71
70
|
itemIndex: number;
|
|
72
71
|
lines: string[];
|
|
@@ -129,46 +128,37 @@ function buildItemBlocks(
|
|
|
129
128
|
});
|
|
130
129
|
}
|
|
131
130
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
line,
|
|
136
|
-
selected: block.itemIndex === selectedIndex,
|
|
137
|
-
})),
|
|
138
|
-
);
|
|
131
|
+
export interface SelectableBlock {
|
|
132
|
+
lines: string[];
|
|
133
|
+
selected: boolean;
|
|
139
134
|
}
|
|
140
135
|
|
|
141
|
-
export
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
allowComment = false,
|
|
147
|
-
commentEnabled = false,
|
|
148
|
-
maxRows,
|
|
149
|
-
hideDescriptions,
|
|
150
|
-
}: RenderSingleSelectRowsParams): AnnotatedRow[] {
|
|
151
|
-
const itemCount = options.length + (allowComment ? 1 : 0) + (allowFreeform ? 1 : 0);
|
|
152
|
-
const blocks = buildItemBlocks(
|
|
153
|
-
options,
|
|
154
|
-
width,
|
|
155
|
-
allowFreeform,
|
|
156
|
-
allowComment,
|
|
157
|
-
commentEnabled,
|
|
158
|
-
selectedIndex,
|
|
159
|
-
hideDescriptions,
|
|
160
|
-
);
|
|
161
|
-
const allRows = flatten(blocks, selectedIndex);
|
|
136
|
+
export interface WindowSelectedBlocksParams {
|
|
137
|
+
blocks: SelectableBlock[];
|
|
138
|
+
maxRows: number;
|
|
139
|
+
indicator: string;
|
|
140
|
+
}
|
|
162
141
|
|
|
163
|
-
|
|
164
|
-
|
|
142
|
+
/**
|
|
143
|
+
* Window pre-rendered blocks around the selected one so the total row count
|
|
144
|
+
* stays within `maxRows`. When everything fits, all rows are returned as-is;
|
|
145
|
+
* otherwise the selected block is kept visible, neighbouring blocks fill the
|
|
146
|
+
* remaining space, and a single indicator row (e.g. " (3/12)") is appended.
|
|
147
|
+
*/
|
|
148
|
+
export function windowSelectedBlocks({ blocks, maxRows, indicator }: WindowSelectedBlocksParams): AnnotatedRow[] {
|
|
149
|
+
const totalRows = blocks.reduce((count, block) => count + block.lines.length, 0);
|
|
150
|
+
if (!Number.isFinite(maxRows) || !maxRows || maxRows <= 0 || totalRows <= maxRows) {
|
|
151
|
+
return blocks.flatMap((block) => block.lines.map((line) => ({ line, selected: block.selected })));
|
|
165
152
|
}
|
|
166
153
|
|
|
167
154
|
const safeMaxRows = Math.max(1, Math.floor(maxRows));
|
|
168
|
-
const
|
|
155
|
+
const selectedIndex = Math.max(
|
|
156
|
+
blocks.findIndex((block) => block.selected),
|
|
157
|
+
0,
|
|
158
|
+
);
|
|
159
|
+
const selectedBlock = blocks[selectedIndex];
|
|
169
160
|
if (!selectedBlock) return [];
|
|
170
161
|
|
|
171
|
-
const indicator = ` (${selectedIndex + 1}/${itemCount})`;
|
|
172
162
|
const availableRows = safeMaxRows > 1 ? safeMaxRows - 1 : 1;
|
|
173
163
|
|
|
174
164
|
if (selectedBlock.lines.length >= availableRows) {
|
|
@@ -181,7 +171,7 @@ export function renderSingleSelectRows({
|
|
|
181
171
|
}
|
|
182
172
|
|
|
183
173
|
let start = selectedIndex;
|
|
184
|
-
let end =
|
|
174
|
+
let end = start + 1;
|
|
185
175
|
let usedRows = selectedBlock.lines.length;
|
|
186
176
|
|
|
187
177
|
while (true) {
|
|
@@ -202,7 +192,37 @@ export function renderSingleSelectRows({
|
|
|
202
192
|
break;
|
|
203
193
|
}
|
|
204
194
|
|
|
205
|
-
const visible =
|
|
195
|
+
const visible = blocks
|
|
196
|
+
.slice(start, end)
|
|
197
|
+
.flatMap((block) => block.lines.map((line) => ({ line, selected: block.selected })));
|
|
206
198
|
visible.push({ line: indicator, selected: false });
|
|
207
199
|
return visible.slice(0, safeMaxRows);
|
|
208
200
|
}
|
|
201
|
+
|
|
202
|
+
export function renderSingleSelectRows({
|
|
203
|
+
options,
|
|
204
|
+
selectedIndex,
|
|
205
|
+
width,
|
|
206
|
+
allowFreeform,
|
|
207
|
+
allowComment = false,
|
|
208
|
+
commentEnabled = false,
|
|
209
|
+
maxRows,
|
|
210
|
+
hideDescriptions,
|
|
211
|
+
}: RenderSingleSelectRowsParams): AnnotatedRow[] {
|
|
212
|
+
const itemCount = options.length + (allowComment ? 1 : 0) + (allowFreeform ? 1 : 0);
|
|
213
|
+
const blocks = buildItemBlocks(
|
|
214
|
+
options,
|
|
215
|
+
width,
|
|
216
|
+
allowFreeform,
|
|
217
|
+
allowComment,
|
|
218
|
+
commentEnabled,
|
|
219
|
+
selectedIndex,
|
|
220
|
+
hideDescriptions,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
return windowSelectedBlocks({
|
|
224
|
+
blocks: blocks.map((block) => ({ lines: block.lines, selected: block.itemIndex === selectedIndex })),
|
|
225
|
+
maxRows: maxRows ?? Number.POSITIVE_INFINITY,
|
|
226
|
+
indicator: ` (${selectedIndex + 1}/${itemCount})`,
|
|
227
|
+
});
|
|
228
|
+
}
|