@yandy0725/pi-ask-user 0.3.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 +19 -18
- package/README.zh.md +15 -15
- package/index.ts +242 -133
- package/package.json +5 -5
- package/src/single-select-layout.ts +55 -35
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ A Pi package that adds an interactive `ask_user` tool for collecting user decisi
|
|
|
11
11
|
- User-toggleable extra context on structured selections
|
|
12
12
|
- Context display support
|
|
13
13
|
- Configurable display mode: `overlay` (modal, default) or `inline` (rendered directly in the flow)
|
|
14
|
-
- Runtime overlay toggle: press the configured overlay-toggle key (`alt+o` by default, configurable
|
|
14
|
+
- Runtime overlay toggle: press the configured overlay-toggle key (`alt+o` by default, configurable via env var or `ask-user.json`) while the prompt is open to temporarily hide/show the popup so you can read prior agent output, then press it again to bring it back
|
|
15
15
|
- Pi-TUI-aligned keybinding and editor behavior
|
|
16
16
|
- Custom TUI rendering for tool calls and results
|
|
17
17
|
- System prompt integration via `promptSnippet` and `promptGuidelines`
|
|
@@ -59,11 +59,10 @@ The registered tool name is:
|
|
|
59
59
|
| `allowMultiple` | `boolean?` | `false` | Enable multi-select mode |
|
|
60
60
|
| `allowFreeform` | `boolean?` | `true` | Add a "Type something" freeform option |
|
|
61
61
|
| `allowComment` | `boolean?` | `true` | 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 |
|
|
62
|
-
| `displayMode` | `"overlay" \| "inline"?` | env var or `"overlay"` | Controls custom UI rendering: `overlay` shows the centered modal (current behavior), `inline` renders without overlay framing |
|
|
63
|
-
| `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. |
|
|
64
|
-
| `commentToggleKey` | `string?` | env var or `"ctrl+g"` | Shortcut for toggling the optional comment/extra-context row when `allowComment: true`. Pass `"off"` to disable. |
|
|
65
62
|
| `timeout` | `number?` | — | Auto-dismiss after N ms and return `null` if the prompt times out |
|
|
66
63
|
|
|
64
|
+
Appearance options (`displayMode`, `overlayToggleKey`, `commentToggleKey`) are **not** model-visible parameters. They are user preferences resolved from env vars and `ask-user.json` — see [Display mode](#display-mode) and [Shortcuts](#shortcuts). They remain accepted by the internal `AskParams` type for SDK/direct callers, but an LLM tool call cannot set them.
|
|
65
|
+
|
|
67
66
|
## Example usage shape
|
|
68
67
|
|
|
69
68
|
```json
|
|
@@ -76,8 +75,7 @@ The registered tool name is:
|
|
|
76
75
|
],
|
|
77
76
|
"allowMultiple": false,
|
|
78
77
|
"allowFreeform": true,
|
|
79
|
-
"allowComment": true
|
|
80
|
-
"displayMode": "inline"
|
|
78
|
+
"allowComment": true
|
|
81
79
|
}
|
|
82
80
|
```
|
|
83
81
|
|
|
@@ -95,11 +93,12 @@ export PI_ASK_USER_COMMENT_TOGGLE_KEY=alt+c
|
|
|
95
93
|
|
|
96
94
|
Effective order:
|
|
97
95
|
|
|
98
|
-
1.
|
|
99
|
-
2.
|
|
100
|
-
3.
|
|
101
|
-
4.
|
|
102
|
-
|
|
96
|
+
1. `PI_ASK_USER_DISPLAY_MODE` (if set to `"overlay"` or `"inline"`)
|
|
97
|
+
2. Project config file `.pi/ask-user.json` `displayMode` field (if exists)
|
|
98
|
+
3. User config file `~/.pi/agent/ask-user.json` `displayMode` field (if exists)
|
|
99
|
+
4. Fallback default: `"overlay"`
|
|
100
|
+
|
|
101
|
+
(A per-call `displayMode` still wins over all of the above for SDK/direct callers, but the field is no longer part of the model-visible tool schema.)
|
|
103
102
|
|
|
104
103
|
Unrecognised values are silently ignored and fall back to `"overlay"`.
|
|
105
104
|
|
|
@@ -111,7 +110,7 @@ In addition to environment variables, you can use JSON config files:
|
|
|
111
110
|
- User-level (global): `~/.pi/agent/ask-user.json`
|
|
112
111
|
- Project-level: `.pi/ask-user.json`
|
|
113
112
|
|
|
114
|
-
Fields in the project-level file override same-named fields in the user-level file. Priority:
|
|
113
|
+
Fields in the project-level file override same-named fields in the user-level file. Priority: env var > project config > user config > default.
|
|
115
114
|
|
|
116
115
|
**Example `~/.pi/agent/ask-user.json`:**
|
|
117
116
|
|
|
@@ -129,11 +128,12 @@ All fields are optional. Missing files or malformed JSON are silently ignored, e
|
|
|
129
128
|
|
|
130
129
|
Effective order for both `overlayToggleKey` and `commentToggleKey`:
|
|
131
130
|
|
|
132
|
-
1.
|
|
133
|
-
2.
|
|
134
|
-
3.
|
|
135
|
-
4.
|
|
136
|
-
|
|
131
|
+
1. Matching env var (`PI_ASK_USER_OVERLAY_TOGGLE_KEY` / `PI_ASK_USER_COMMENT_TOGGLE_KEY`)
|
|
132
|
+
2. Project config file `.pi/ask-user.json` (if exists)
|
|
133
|
+
3. User config file `~/.pi/agent/ask-user.json` (if exists)
|
|
134
|
+
4. Built-in defaults: `alt+o` and `ctrl+g`
|
|
135
|
+
|
|
136
|
+
(A per-call parameter still wins for SDK/direct callers, but these fields are no longer part of the model-visible tool schema.)
|
|
137
137
|
|
|
138
138
|
Pass `"off"`, `"none"`, or `"disabled"` (at any level) to disable the shortcut entirely. Invalid specs are silently dropped and the next source is used. Specs follow the Pi-TUI [`KeyId`](https://github.com/earendil-works/pi-mono/blob/main/packages/tui/src/keys.ts) format: `[mod+]...key` where modifiers are `ctrl`, `shift`, `alt`, `super`, in any order, joined by `+` (e.g. `ctrl+g`, `alt+shift+x`, `escape`, `tab`).
|
|
139
139
|
|
|
@@ -148,8 +148,9 @@ 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
|
-
If you prefer never to see the overlay, set `displayMode: "inline"`
|
|
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
|
|
|
154
155
|
## Result details
|
|
155
156
|
|
package/README.zh.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
- 选择后可切换添加额外上下文(可选注释)
|
|
12
12
|
- 上下文展示支持
|
|
13
13
|
- 可配置的显示模式:`overlay`(模态,默认)或 `inline`(直接内联渲染)
|
|
14
|
-
- 运行时浮层切换:当提示窗口打开时,按下配置的叠加层切换键(默认 `alt+o
|
|
14
|
+
- 运行时浮层切换:当提示窗口打开时,按下配置的叠加层切换键(默认 `alt+o`,可通过环境变量或 `ask-user.json` 配置)临时隐藏/显示弹窗,以便阅读之前的 Agent 输出
|
|
15
15
|
- Pi-TUI 对齐的按键绑定和编辑器行为
|
|
16
16
|
- 工具调用和结果的自定义 TUI 渲染
|
|
17
17
|
- 通过 `promptSnippet` 和 `promptGuidelines` 集成系统提示词
|
|
@@ -59,11 +59,10 @@ pi install npm:@yandy0725/pi-ask-user
|
|
|
59
59
|
| `allowMultiple` | `boolean?` | `false` | 启用多选模式 |
|
|
60
60
|
| `allowFreeform` | `boolean?` | `true` | 添加"自定义输入"自由文本选项 |
|
|
61
61
|
| `allowComment` | `boolean?` | `true` | 在自定义 UI 中提供可切换的额外上下文选项(`ctrl+g` 或切换行),并在回退对话框中收集可选注释 |
|
|
62
|
-
| `displayMode` | `"overlay" \| "inline"?` | 环境变量或 `"overlay"` | 控制自定义 UI 渲染:`overlay` 显示居中模态框(当前行为),`inline` 不显示叠加框架渲染 |
|
|
63
|
-
| `overlayToggleKey` | `string?` | 环境变量或 `"alt+o"` | 隐藏/显示叠加弹窗的快捷键(仅 overlay 模式)。Pi-TUI 键位格式,例如 `"alt+o"`、`"ctrl+shift+h"`。传入 `"off"` 禁用。 |
|
|
64
|
-
| `commentToggleKey` | `string?` | 环境变量或 `"ctrl+g"` | 当 `allowComment: true` 时切换可选注释/额外上下文行的快捷键。传入 `"off"` 禁用。 |
|
|
65
62
|
| `timeout` | `number?` | — | N 毫秒后自动关闭,超时返回 `null` |
|
|
66
63
|
|
|
64
|
+
外观类选项(`displayMode`、`overlayToggleKey`、`commentToggleKey`)**不是**模型可见参数:它们是用户偏好,由环境变量与 `ask-user.json` 解析(见[显示模式](#显示模式)与[快捷键](#快捷键))。内部 `AskParams` 类型仍接受它们(供 SDK/直接调用),但 LLM 的工具调用无法设置。
|
|
65
|
+
|
|
67
66
|
## 通过环境变量设置个人偏好
|
|
68
67
|
|
|
69
68
|
在你的 shell 配置(`~/.zshrc`、`~/.bash_profile` 等)中设置以下变量来全局配置默认值:
|
|
@@ -78,11 +77,12 @@ export PI_ASK_USER_COMMENT_TOGGLE_KEY=alt+c
|
|
|
78
77
|
|
|
79
78
|
生效优先级:
|
|
80
79
|
|
|
81
|
-
1.
|
|
82
|
-
2.
|
|
83
|
-
3.
|
|
84
|
-
4.
|
|
85
|
-
|
|
80
|
+
1. `PI_ASK_USER_DISPLAY_MODE`(如设置为 `"overlay"` 或 `"inline"`)
|
|
81
|
+
2. 项目配置文件 `.pi/ask-user.json`(如存在)中的 `displayMode`
|
|
82
|
+
3. 用户配置文件 `~/.pi/agent/ask-user.json`(如存在)中的 `displayMode`
|
|
83
|
+
4. 回退默认值:`"overlay"`
|
|
84
|
+
|
|
85
|
+
(SDK/直接调用传入的 `displayMode` 仍会覆盖以上所有层级,但该字段已不在模型可见的 tool schema 里。)
|
|
86
86
|
|
|
87
87
|
无法识别的值会被静默忽略并回退到 `"overlay"`。
|
|
88
88
|
|
|
@@ -94,7 +94,7 @@ export PI_ASK_USER_COMMENT_TOGGLE_KEY=alt+c
|
|
|
94
94
|
- 用户级(全局):`~/.pi/agent/ask-user.json`
|
|
95
95
|
- 项目级:`.pi/ask-user.json`
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
项目级文件中的字段会覆盖用户级文件中同名字段。配置优先级:环境变量 > 项目配置 > 用户配置 > 默认值。
|
|
98
98
|
|
|
99
99
|
**示例 `~/.pi/agent/ask-user.json`:**
|
|
100
100
|
|
|
@@ -112,10 +112,9 @@ export PI_ASK_USER_COMMENT_TOGGLE_KEY=alt+c
|
|
|
112
112
|
|
|
113
113
|
`overlayToggleKey` 和 `commentToggleKey` 的生效优先级:
|
|
114
114
|
|
|
115
|
-
1.
|
|
116
|
-
2.
|
|
117
|
-
3.
|
|
118
|
-
4. 用户配置文件 `~/.pi/agent/ask-user.json`(如存在)
|
|
115
|
+
1. 对应的环境变量(`PI_ASK_USER_OVERLAY_TOGGLE_KEY` / `PI_ASK_USER_COMMENT_TOGGLE_KEY`)
|
|
116
|
+
2. 项目配置文件 `.pi/ask-user.json`(如存在)
|
|
117
|
+
3. 用户配置文件 `~/.pi/agent/ask-user.json`(如存在)
|
|
119
118
|
5. 内置默认值:`alt+o` 和 `ctrl+g`
|
|
120
119
|
|
|
121
120
|
传入 `"off"`、`"none"` 或 `"disabled"`(任意级别)可以完全禁用快捷键。无效的键位格式会被静默丢弃,使用下一个来源。键位格式遵循 Pi-TUI 的 [`KeyId`](https://github.com/earendil-works/pi-mono/blob/main/packages/tui/src/keys.ts) 格式:`[mod+]...key`,修饰键为 `ctrl`、`shift`、`alt`、`super`,可任意顺序用 `+` 连接(如 `ctrl+g`、`alt+shift+x`、`escape`、`tab`)。
|
|
@@ -131,8 +130,9 @@ export PI_ASK_USER_COMMENT_TOGGLE_KEY=alt+c
|
|
|
131
130
|
| `enter` | 确认聚焦的选项、提交自由文本回复、或提交/跳过可选注释。 |
|
|
132
131
|
| `esc` | 清除搜索过滤、退出自由文本/注释模式、或取消提示。 |
|
|
133
132
|
| `↑` / `↓`、`ctrl+k` / `ctrl+j` | 导航选项。`ctrl+k` / `ctrl+j`(vim 风格)可在可搜索提示中打字时不干扰过滤输入。 |
|
|
133
|
+
| `pgup` / `pgdn` | 当问题/背景内容超出弹窗高度时滚动该区域。弹窗总高度不会超过终端的 85%,选项和帮助行始终可见;被隐藏的内容会用 `↑ n lines above` / `↓ n lines below` 指示行标注。仅在选项选择模式下可用(自由文本/注释模式下这两个按键归编辑器所有)。 |
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
如果你始终不希望看到叠加层,请全局设置 `PI_ASK_USER_DISPLAY_MODE=inline`,或在 `ask-user.json` 中设置 `"displayMode": "inline"`。
|
|
136
136
|
|
|
137
137
|
## 结果详情
|
|
138
138
|
|
package/index.ts
CHANGED
|
@@ -32,30 +32,17 @@ import {
|
|
|
32
32
|
truncateToWidth,
|
|
33
33
|
wrapTextWithAnsi,
|
|
34
34
|
} from "@earendil-works/pi-tui";
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
35
|
+
import { Type } from "typebox";
|
|
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;
|
|
40
45
|
|
|
41
|
-
/**
|
|
42
|
-
* Emit a flat `{ type: "string", enum: [...] }` JSON Schema instead of the
|
|
43
|
-
* `anyOf`/`oneOf` shape that `Type.Union([Type.Literal()])` produces. Google's
|
|
44
|
-
* function-calling API rejects the union form. Local copy of pi-ai's StringEnum
|
|
45
|
-
* to avoid a peer dependency for one helper.
|
|
46
|
-
*/
|
|
47
|
-
function StringEnum<const T extends readonly string[]>(
|
|
48
|
-
values: T,
|
|
49
|
-
options?: { description?: string; default?: T[number] },
|
|
50
|
-
): TUnsafe<T[number]> {
|
|
51
|
-
return Type.Unsafe<T[number]>({
|
|
52
|
-
type: "string",
|
|
53
|
-
enum: [...values],
|
|
54
|
-
...(options?.description ? { description: options.description } : {}),
|
|
55
|
-
...(options?.default !== undefined ? { default: options.default } : {}),
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
46
|
/**
|
|
60
47
|
* `getMarkdownTheme()` returns a bag of closures that read through a Proxy
|
|
61
48
|
* over the host's theme singleton. The Proxy only throws on property access,
|
|
@@ -386,6 +373,14 @@ function resolveShortcut(
|
|
|
386
373
|
type AskMode = "select" | "freeform" | "comment";
|
|
387
374
|
|
|
388
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;
|
|
389
384
|
// biome-ignore lint/suspicious/noExplicitAny: SizeValue compatibility with pi-tui
|
|
390
385
|
const ASK_OVERLAY_WIDTH: any = "92%";
|
|
391
386
|
const ASK_OVERLAY_MIN_WIDTH = 40;
|
|
@@ -465,6 +460,7 @@ class MultiSelectList implements Component {
|
|
|
465
460
|
private selectedIndex = 0;
|
|
466
461
|
private checked = new Set<number>();
|
|
467
462
|
private commentEnabled = false;
|
|
463
|
+
private maxVisibleRows = 10;
|
|
468
464
|
private cachedWidth?: number;
|
|
469
465
|
private cachedLines?: string[];
|
|
470
466
|
|
|
@@ -492,6 +488,14 @@ class MultiSelectList implements Component {
|
|
|
492
488
|
return this.commentEnabled;
|
|
493
489
|
}
|
|
494
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
|
+
|
|
495
499
|
invalidate(): void {
|
|
496
500
|
this.cachedWidth = undefined;
|
|
497
501
|
this.cachedLines = undefined;
|
|
@@ -614,7 +618,6 @@ class MultiSelectList implements Component {
|
|
|
614
618
|
|
|
615
619
|
const theme = this.theme;
|
|
616
620
|
const count = this.getItemCount();
|
|
617
|
-
const maxVisible = Math.min(count, 10);
|
|
618
621
|
|
|
619
622
|
if (count === 0) {
|
|
620
623
|
this.cachedLines = [theme.fg("warning", "No options")];
|
|
@@ -622,56 +625,55 @@ class MultiSelectList implements Component {
|
|
|
622
625
|
return this.cachedLines;
|
|
623
626
|
}
|
|
624
627
|
|
|
625
|
-
const
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
const lines: string[] = [];
|
|
629
|
-
|
|
630
|
-
for (let i = startIndex; i < endIndex; i++) {
|
|
628
|
+
const pointerWidth = 2; // "→ " / " "
|
|
629
|
+
const blocks: SelectableBlock[] = [];
|
|
630
|
+
for (let i = 0; i < count; i++) {
|
|
631
631
|
const isSelected = i === this.selectedIndex;
|
|
632
|
-
const
|
|
632
|
+
const pointer = isSelected ? theme.fg("accent", "→ ") : " ";
|
|
633
|
+
const continuation = " ".repeat(pointerWidth);
|
|
634
|
+
const lines: string[] = [];
|
|
633
635
|
|
|
634
636
|
if (this.isCommentToggleRow(i)) {
|
|
635
637
|
const checkbox = this.commentEnabled ? theme.fg("success", "[✓]") : theme.fg("dim", "[ ]");
|
|
636
638
|
const label = isSelected
|
|
637
639
|
? theme.fg("accent", theme.bold(COMMENT_TOGGLE_LABEL))
|
|
638
640
|
: theme.fg("text", theme.bold(COMMENT_TOGGLE_LABEL));
|
|
639
|
-
lines.push(
|
|
640
|
-
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
if (this.isFreeformRow(i)) {
|
|
641
|
+
lines.push(`${pointer} ${checkbox} ${label}`);
|
|
642
|
+
} else if (this.isFreeformRow(i)) {
|
|
644
643
|
const label = theme.fg("text", theme.bold("Type something."));
|
|
645
644
|
const desc = theme.fg("muted", "Enter a custom response");
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
for (const w of wrapped) {
|
|
666
|
-
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
|
+
}
|
|
667
664
|
}
|
|
668
665
|
}
|
|
669
|
-
}
|
|
670
666
|
|
|
671
|
-
|
|
672
|
-
lines.push(theme.fg("dim", truncateToWidth(` (${this.selectedIndex + 1}/${count})`, width, "")));
|
|
667
|
+
blocks.push({ lines, selected: isSelected });
|
|
673
668
|
}
|
|
674
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
|
+
|
|
675
677
|
this.cachedWidth = width;
|
|
676
678
|
this.cachedLines = lines;
|
|
677
679
|
return lines;
|
|
@@ -1050,14 +1052,22 @@ class AskComponent extends Container {
|
|
|
1050
1052
|
private titleText: Text;
|
|
1051
1053
|
private questionText: Text;
|
|
1052
1054
|
private contextComponent?: Component;
|
|
1053
|
-
private modeContainer: Container;
|
|
1054
1055
|
private helpText: Text;
|
|
1055
1056
|
|
|
1056
|
-
// 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[] = [];
|
|
1057
1060
|
private singleSelectList?: WrappedSingleSelectList;
|
|
1058
1061
|
private multiSelectList?: MultiSelectList;
|
|
1059
1062
|
private editor?: Editor;
|
|
1060
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
|
+
|
|
1061
1071
|
// Focusable - propagate to Editor for IME cursor positioning
|
|
1062
1072
|
private _focused = false;
|
|
1063
1073
|
get focused(): boolean {
|
|
@@ -1130,10 +1140,6 @@ class AskComponent extends Container {
|
|
|
1130
1140
|
|
|
1131
1141
|
this.addChild(new Spacer(1));
|
|
1132
1142
|
|
|
1133
|
-
this.modeContainer = new Container();
|
|
1134
|
-
this.addChild(this.modeContainer);
|
|
1135
|
-
|
|
1136
|
-
this.addChild(new Spacer(1));
|
|
1137
1143
|
this.helpText = new Text("", 1, 0);
|
|
1138
1144
|
this.addChild(this.helpText);
|
|
1139
1145
|
|
|
@@ -1156,48 +1162,146 @@ class AskComponent extends Container {
|
|
|
1156
1162
|
this.updateHelpText();
|
|
1157
1163
|
}
|
|
1158
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
|
+
*/
|
|
1159
1174
|
override render(width: number): string[] {
|
|
1160
1175
|
const innerWidth = Math.max(1, width - BOX_BORDER_OVERHEAD);
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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);
|
|
1167
1204
|
}
|
|
1168
1205
|
|
|
1169
|
-
|
|
1170
|
-
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);
|
|
1171
1219
|
|
|
1172
|
-
// First and last lines are the top/bottom box borders — pass through at full width.
|
|
1173
|
-
// All inner lines get wrapped with side borders.
|
|
1174
1220
|
const borderColor = (s: string) => this.theme.fg("accent", s);
|
|
1175
1221
|
const titleColor = (s: string) => this.theme.fg("dim", this.theme.bold(s));
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
// Box top/bottom borders already rendered at innerWidth — re-render at full width
|
|
1179
|
-
if (index === 0) return new BoxBorderTop(borderColor, "ask_user", titleColor).render(width)[0];
|
|
1180
|
-
return new BoxBorderBottom(borderColor, `v${ASK_USER_VERSION}`, (s: string) => this.theme.fg("dim", s)).render(
|
|
1181
|
-
width,
|
|
1182
|
-
)[0];
|
|
1183
|
-
}
|
|
1222
|
+
const lines: string[] = [new BoxBorderTop(borderColor, "ask_user", titleColor).render(width)[0] ?? ""];
|
|
1223
|
+
for (const line of inner) {
|
|
1184
1224
|
const padded = truncateToWidth(line, innerWidth, "", true);
|
|
1185
|
-
|
|
1186
|
-
}
|
|
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);
|
|
1187
1288
|
}
|
|
1188
1289
|
|
|
1189
|
-
private
|
|
1190
|
-
|
|
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));
|
|
1191
1297
|
}
|
|
1192
1298
|
|
|
1193
|
-
private
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
const spacerLines = this.context ? 6 : 5;
|
|
1200
|
-
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();
|
|
1201
1305
|
}
|
|
1202
1306
|
|
|
1203
1307
|
private updateStaticText(): void {
|
|
@@ -1370,6 +1474,16 @@ class AskComponent extends Container {
|
|
|
1370
1474
|
}
|
|
1371
1475
|
}
|
|
1372
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
|
+
|
|
1373
1487
|
private showSelectMode(): void {
|
|
1374
1488
|
if (this.mode === "freeform" || this.mode === "comment") {
|
|
1375
1489
|
this.saveEditorDraft();
|
|
@@ -1377,12 +1491,11 @@ class AskComponent extends Container {
|
|
|
1377
1491
|
|
|
1378
1492
|
this.mode = "select";
|
|
1379
1493
|
this.pendingSelections = [];
|
|
1380
|
-
this.modeContainer.clear();
|
|
1381
1494
|
|
|
1382
1495
|
if (this.allowMultiple) {
|
|
1383
|
-
this.
|
|
1496
|
+
this.setModeComponents([this.ensureMultiSelectList()]);
|
|
1384
1497
|
} else {
|
|
1385
|
-
this.
|
|
1498
|
+
this.setModeComponents([this.ensureSingleSelectList()]);
|
|
1386
1499
|
}
|
|
1387
1500
|
|
|
1388
1501
|
this.updateHelpText();
|
|
@@ -1396,16 +1509,17 @@ class AskComponent extends Container {
|
|
|
1396
1509
|
}
|
|
1397
1510
|
|
|
1398
1511
|
this.mode = "freeform";
|
|
1399
|
-
this.modeContainer.clear();
|
|
1400
1512
|
|
|
1401
1513
|
const editor = this.ensureEditor();
|
|
1402
1514
|
this.setEditorText(this.freeformDraft);
|
|
1403
1515
|
// biome-ignore lint/suspicious/noExplicitAny: Editor.focused setter is internal
|
|
1404
1516
|
(editor as any).focused = this._focused;
|
|
1405
1517
|
|
|
1406
|
-
this.
|
|
1407
|
-
|
|
1408
|
-
|
|
1518
|
+
this.setModeComponents([
|
|
1519
|
+
new Text(this.theme.fg("accent", this.theme.bold("Custom response")), 1, 0),
|
|
1520
|
+
new Spacer(1),
|
|
1521
|
+
editor,
|
|
1522
|
+
]);
|
|
1409
1523
|
|
|
1410
1524
|
this.updateHelpText();
|
|
1411
1525
|
this.invalidate();
|
|
@@ -1418,7 +1532,6 @@ class AskComponent extends Container {
|
|
|
1418
1532
|
}
|
|
1419
1533
|
|
|
1420
1534
|
this.mode = "comment";
|
|
1421
|
-
this.modeContainer.clear();
|
|
1422
1535
|
|
|
1423
1536
|
const editor = this.ensureEditor();
|
|
1424
1537
|
this.setEditorText(this.commentDraft);
|
|
@@ -1426,10 +1539,12 @@ class AskComponent extends Container {
|
|
|
1426
1539
|
(editor as any).focused = this._focused;
|
|
1427
1540
|
|
|
1428
1541
|
const selectedLabel = this.pendingSelections.length === 1 ? "Selected option:" : "Selected options:";
|
|
1429
|
-
this.
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
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
|
+
]);
|
|
1433
1548
|
|
|
1434
1549
|
this.updateHelpText();
|
|
1435
1550
|
this.invalidate();
|
|
@@ -1437,6 +1552,19 @@ class AskComponent extends Container {
|
|
|
1437
1552
|
}
|
|
1438
1553
|
|
|
1439
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
|
+
|
|
1440
1568
|
if (this.mode === "freeform" || this.mode === "comment") {
|
|
1441
1569
|
if (matchesKey(data, Key.escape)) {
|
|
1442
1570
|
this.showSelectMode();
|
|
@@ -1534,15 +1662,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1534
1662
|
pi.registerTool({
|
|
1535
1663
|
name: "ask_user",
|
|
1536
1664
|
label: "Ask User",
|
|
1537
|
-
description:
|
|
1538
|
-
|
|
1539
|
-
promptSnippet:
|
|
1540
|
-
"Ask the user one focused question with optional multiple-choice answers to gather information interactively",
|
|
1665
|
+
description: "Ask the user one focused question with optional multiple-choice answers.",
|
|
1666
|
+
promptSnippet: "Ask the user one focused question with optional multiple-choice answers",
|
|
1541
1667
|
promptGuidelines: [
|
|
1542
|
-
"Before calling ask_user, gather context with
|
|
1668
|
+
"Before calling ask_user, gather context with your read/search/web tools and pass a short summary via the context field.",
|
|
1543
1669
|
"Use ask_user when the user's intent is ambiguous, when a decision requires explicit user input, or when multiple valid options exist.",
|
|
1544
|
-
"Ask exactly one focused question per ask_user call.",
|
|
1545
|
-
"Do not combine multiple numbered, multipart, or unrelated questions into one ask_user prompt.",
|
|
1670
|
+
"Ask exactly one focused question per ask_user call — never combine multiple, numbered, or unrelated questions in one prompt.",
|
|
1546
1671
|
],
|
|
1547
1672
|
// Block other tool calls in the same assistant turn until the user answers,
|
|
1548
1673
|
// so the model can't batch ask_user with bash/edit/write and let those run
|
|
@@ -1572,27 +1697,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1572
1697
|
allowComment: Type.Optional(
|
|
1573
1698
|
Type.Boolean({ description: "Collect an optional comment after selecting one or more options. Default: true" }),
|
|
1574
1699
|
),
|
|
1575
|
-
displayMode
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
),
|
|
1581
|
-
overlayToggleKey: Type.Optional(
|
|
1582
|
-
Type.String({
|
|
1583
|
-
description:
|
|
1584
|
-
"Shortcut for hiding/showing the overlay popup (overlay mode only), e.g. 'alt+o' or 'ctrl+shift+h'. Pass 'off' to disable. Default: PI_ASK_USER_OVERLAY_TOGGLE_KEY env var if set, otherwise 'alt+o'.",
|
|
1585
|
-
}),
|
|
1586
|
-
),
|
|
1587
|
-
commentToggleKey: Type.Optional(
|
|
1588
|
-
Type.String({
|
|
1589
|
-
description:
|
|
1590
|
-
"Shortcut for toggling the optional comment/extra-context row when allowComment is true, e.g. 'ctrl+g'. Pass 'off' to disable. Default: PI_ASK_USER_COMMENT_TOGGLE_KEY env var if set, otherwise 'ctrl+g'.",
|
|
1591
|
-
}),
|
|
1592
|
-
),
|
|
1593
|
-
timeout: Type.Optional(
|
|
1594
|
-
Type.Number({ description: "Auto-dismiss after N milliseconds. Returns null (cancelled) when expired." }),
|
|
1595
|
-
),
|
|
1700
|
+
// 外观类选项(displayMode / overlayToggleKey / commentToggleKey)不再暴露给模型:
|
|
1701
|
+
// 它们是**用户偏好**,优先级为 模型参数 > env > ask-user.json > 默认值,模型能直接
|
|
1702
|
+
// 盖掉用户的设置,且占了 782 字符的 schema。取值改由 env(PI_ASK_USER_DISPLAY_MODE 等)
|
|
1703
|
+
// 与 ask-user.json 提供,内部解析链与 `AskParams` 类型保留(SDK/内部调用仍可传)。
|
|
1704
|
+
timeout: Type.Optional(Type.Number({ description: "Auto-dismiss after N ms; returns null when expired." })),
|
|
1596
1705
|
}),
|
|
1597
1706
|
|
|
1598
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.
|
|
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,10 +41,10 @@
|
|
|
41
41
|
"typebox": "*"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@biomejs/biome": "^2.
|
|
45
|
-
"@types/node": "^26.6.
|
|
46
|
-
"typebox": "^1.3
|
|
44
|
+
"@biomejs/biome": "^2.6.4",
|
|
45
|
+
"@types/node": "^26.6.6",
|
|
46
|
+
"typebox": "^1.4.3",
|
|
47
47
|
"typescript": "~7.0.2",
|
|
48
|
-
"vitest": "^5.0.
|
|
48
|
+
"vitest": "^5.0.5"
|
|
49
49
|
}
|
|
50
50
|
}
|
|
@@ -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
|
+
}
|