pi-extensions-tool-display 0.1.1 → 0.2.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 +13 -3
- package/README.zh-CN.md +14 -4
- package/config.example.json +40 -0
- package/index.ts +6 -0
- package/package.json +16 -5
- package/src/agent-dir.ts +32 -0
- package/src/ansi-utils.ts +116 -0
- package/src/bash-display.ts +191 -0
- package/src/capabilities.ts +85 -0
- package/src/config-modal.ts +540 -0
- package/src/config-store.ts +303 -0
- package/src/debug-logger.ts +150 -0
- package/src/diff-presentation.ts +72 -0
- package/src/diff-renderer.ts +2517 -0
- package/src/disposable.ts +34 -0
- package/src/extension-lifecycle.ts +14 -0
- package/src/index.ts +126 -86
- package/src/line-width-safety.ts +93 -0
- package/src/modal-icons.ts +57 -0
- package/src/pending-diff-preview.ts +371 -0
- package/src/presets.ts +108 -0
- package/src/render-utils.ts +186 -0
- package/src/result-render-middleware.ts +98 -0
- package/src/settings-inspector-modal.ts +475 -0
- package/src/thinking-label.ts +343 -0
- package/src/tool-metadata.ts +139 -0
- package/src/tool-overrides.ts +2237 -0
- package/src/types.ts +105 -0
- package/src/user-message-box-markdown.ts +64 -0
- package/src/user-message-box-native.ts +66 -0
- package/src/user-message-box-patch.ts +72 -0
- package/src/user-message-box-renderer.ts +419 -0
- package/src/user-message-box-utils.ts +106 -0
- package/src/write-display-utils.ts +31 -0
- package/src/zellij-modal.ts +1027 -0
- package/tool-display-api-consumer.d.ts +68 -0
- package/tool-display-api-consumer.js +82 -0
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Container, type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
export const TOOL_DISPLAY_API_KEY = Symbol.for("pi-tool-display.api.v1");
|
|
4
|
+
export const PENDING_MIDDLEWARES_KEY = Symbol.for("pi-tool-display.pendingResultRenderMiddlewares.v1");
|
|
5
|
+
|
|
6
|
+
export type RenderTheme = {
|
|
7
|
+
fg(color: string, text: string): string;
|
|
8
|
+
bold(text: string): string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ResultMiddlewareContext = {
|
|
12
|
+
toolName: string;
|
|
13
|
+
result: unknown;
|
|
14
|
+
options: { expanded?: boolean };
|
|
15
|
+
theme: RenderTheme;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ResultMiddleware = (
|
|
19
|
+
context: ResultMiddlewareContext,
|
|
20
|
+
next: () => unknown,
|
|
21
|
+
) => unknown;
|
|
22
|
+
|
|
23
|
+
export type MiddlewareRegistration = {
|
|
24
|
+
id: string;
|
|
25
|
+
toolName: string;
|
|
26
|
+
middleware: ResultMiddleware;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ToolDisplayApi = {
|
|
30
|
+
registerResultRenderMiddleware?(registration: MiddlewareRegistration): string;
|
|
31
|
+
unregisterResultRenderMiddleware?(id: string): boolean;
|
|
32
|
+
hasResultRenderMiddleware?(id: string): boolean;
|
|
33
|
+
isResultRenderPipelineActive?(toolName: string): boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type GlobalProtocol = typeof globalThis & {
|
|
37
|
+
[TOOL_DISPLAY_API_KEY]?: ToolDisplayApi;
|
|
38
|
+
[PENDING_MIDDLEWARES_KEY]?: MiddlewareRegistration[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function getApi(): ToolDisplayApi | undefined {
|
|
42
|
+
return (globalThis as GlobalProtocol)[TOOL_DISPLAY_API_KEY];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function queueRegistration(registration: MiddlewareRegistration): void {
|
|
46
|
+
const globalProtocol = globalThis as GlobalProtocol;
|
|
47
|
+
const queue = Array.isArray(globalProtocol[PENDING_MIDDLEWARES_KEY])
|
|
48
|
+
? globalProtocol[PENDING_MIDDLEWARES_KEY]!
|
|
49
|
+
: [];
|
|
50
|
+
const index = queue.findIndex((entry) => entry?.id === registration.id);
|
|
51
|
+
if (index >= 0) queue[index] = registration;
|
|
52
|
+
else queue.push(registration);
|
|
53
|
+
globalProtocol[PENDING_MIDDLEWARES_KEY] = queue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function removeQueuedRegistration(id: string): void {
|
|
57
|
+
const queue = (globalThis as GlobalProtocol)[PENDING_MIDDLEWARES_KEY];
|
|
58
|
+
if (!Array.isArray(queue)) return;
|
|
59
|
+
const index = queue.findIndex((entry) => entry?.id === id);
|
|
60
|
+
if (index >= 0) queue.splice(index, 1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function registerResultRenderMiddleware(
|
|
64
|
+
registration: MiddlewareRegistration,
|
|
65
|
+
): () => void {
|
|
66
|
+
const api = getApi();
|
|
67
|
+
if (typeof api?.registerResultRenderMiddleware === "function") {
|
|
68
|
+
api.registerResultRenderMiddleware(registration);
|
|
69
|
+
} else {
|
|
70
|
+
queueRegistration(registration);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return () => {
|
|
74
|
+
getApi()?.unregisterResultRenderMiddleware?.(registration.id);
|
|
75
|
+
removeQueuedRegistration(registration.id);
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isResultRenderMiddlewareActive(id: string, toolName: string): boolean {
|
|
80
|
+
const api = getApi();
|
|
81
|
+
return api?.hasResultRenderMiddleware?.(id) === true
|
|
82
|
+
&& api.isResultRenderPipelineActive?.(toolName) === true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function asComponent(value: unknown): Component | undefined {
|
|
86
|
+
return value && typeof value === "object" && typeof (value as Component).render === "function"
|
|
87
|
+
? value as Component
|
|
88
|
+
: undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function appendResultRenderPanel(baseValue: unknown, panel: Component): Component {
|
|
92
|
+
const base = asComponent(baseValue);
|
|
93
|
+
if (!base) return panel;
|
|
94
|
+
const container = new Container();
|
|
95
|
+
container.addChild(base);
|
|
96
|
+
container.addChild(panel);
|
|
97
|
+
return container;
|
|
98
|
+
}
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Input, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { getModalIcons } from "./modal-icons.js";
|
|
4
|
+
import type { ZellijModalContentRenderer } from "./zellij-modal.js";
|
|
5
|
+
|
|
6
|
+
const SPLIT_PANE_MIN_WIDTH = 84;
|
|
7
|
+
const LIST_MIN_WIDTH = 28;
|
|
8
|
+
const INSPECTOR_MIN_WIDTH = 36;
|
|
9
|
+
const BODY_ROW_MIN = 8;
|
|
10
|
+
const BODY_ROW_MAX = 14;
|
|
11
|
+
const SEARCH_BOX_MIN_WIDTH = 12;
|
|
12
|
+
const SEARCH_BOX_MAX_WIDTH = 24;
|
|
13
|
+
const INSPECTOR_TITLE_GAP_ROWS = 1;
|
|
14
|
+
const INSPECTOR_PATH_GAP_ROWS = 1;
|
|
15
|
+
const FOOTER_ACTIONS = ["Space/Enter toggle", "↑↓ navigate", "Esc close"] as const;
|
|
16
|
+
|
|
17
|
+
export interface InspectorSettingItem {
|
|
18
|
+
id: string;
|
|
19
|
+
label: string;
|
|
20
|
+
currentValue: string;
|
|
21
|
+
values?: readonly string[];
|
|
22
|
+
inspectorTitle: string;
|
|
23
|
+
inspectorSummary: readonly string[];
|
|
24
|
+
inspectorOptions?: readonly string[];
|
|
25
|
+
inspectorPath?: string;
|
|
26
|
+
inspectorAdvanced?: readonly string[];
|
|
27
|
+
searchTerms?: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SplitPaneInspectorModalOptions {
|
|
31
|
+
getSettings: () => readonly InspectorSettingItem[];
|
|
32
|
+
onChange: (id: string, value: string) => void;
|
|
33
|
+
onClose: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface SplitPaneWidths {
|
|
37
|
+
list: number;
|
|
38
|
+
inspector: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clamp(value: number, min: number, max: number): number {
|
|
42
|
+
return Math.max(min, Math.min(max, value));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function fitText(text: string, width: number): string {
|
|
46
|
+
const safeWidth = Math.max(0, width);
|
|
47
|
+
if (safeWidth === 0) {
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
const fitted = truncateToWidth(text, safeWidth, "…", true);
|
|
51
|
+
const padding = Math.max(0, safeWidth - visibleWidth(fitted));
|
|
52
|
+
return `${fitted}${" ".repeat(padding)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function wrapText(text: string, width: number): string[] {
|
|
56
|
+
const safeWidth = Math.max(1, width);
|
|
57
|
+
const normalized = text.trim();
|
|
58
|
+
if (!normalized) {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const words = normalized.split(/\s+/).filter(Boolean);
|
|
63
|
+
const lines: string[] = [];
|
|
64
|
+
let current = "";
|
|
65
|
+
|
|
66
|
+
for (const word of words) {
|
|
67
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
68
|
+
if (visibleWidth(candidate) <= safeWidth) {
|
|
69
|
+
current = candidate;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (current) {
|
|
74
|
+
lines.push(current);
|
|
75
|
+
current = "";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (visibleWidth(word) <= safeWidth) {
|
|
79
|
+
current = word;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let remaining = word;
|
|
84
|
+
while (visibleWidth(remaining) > safeWidth) {
|
|
85
|
+
const piece = truncateToWidth(remaining, safeWidth, "", false);
|
|
86
|
+
lines.push(piece);
|
|
87
|
+
remaining = remaining.slice(Math.max(1, piece.length));
|
|
88
|
+
}
|
|
89
|
+
current = remaining;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (current) {
|
|
93
|
+
lines.push(current);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return lines.length > 0 ? lines : [normalized];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getScrollableWindow<T>(items: readonly T[], selectedIndex: number, visibleRows: number): readonly T[] {
|
|
100
|
+
if (items.length <= visibleRows) {
|
|
101
|
+
return items;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const halfWindow = Math.floor(visibleRows / 2);
|
|
105
|
+
const maxStart = Math.max(0, items.length - visibleRows);
|
|
106
|
+
const start = clamp(selectedIndex - halfWindow, 0, maxStart);
|
|
107
|
+
return items.slice(start, start + visibleRows);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function splitPaneWidths(totalWidth: number): SplitPaneWidths {
|
|
111
|
+
const usable = Math.max(LIST_MIN_WIDTH + INSPECTOR_MIN_WIDTH, totalWidth - 1);
|
|
112
|
+
const preferredList = Math.floor(usable * 0.38);
|
|
113
|
+
const list = clamp(preferredList, LIST_MIN_WIDTH, Math.max(LIST_MIN_WIDTH, usable - INSPECTOR_MIN_WIDTH));
|
|
114
|
+
const inspector = Math.max(INSPECTOR_MIN_WIDTH, usable - list);
|
|
115
|
+
return { list, inspector };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getBodyRowBudget(): number {
|
|
119
|
+
const terminalRows =
|
|
120
|
+
typeof process.stdout.rows === "number" && Number.isFinite(process.stdout.rows)
|
|
121
|
+
? process.stdout.rows
|
|
122
|
+
: 36;
|
|
123
|
+
return clamp(Math.floor(terminalRows * 0.33), BODY_ROW_MIN, BODY_ROW_MAX);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isSlashInput(data: string): boolean {
|
|
127
|
+
return data === "/" || matchesKey(data, "/");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function shouldForwardInputToSearch(data: string): boolean {
|
|
131
|
+
if (!data) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
if (matchesKey(data, "space") || data === " ") {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class SplitPaneInspectorModal implements ZellijModalContentRenderer {
|
|
141
|
+
private readonly options: SplitPaneInspectorModalOptions;
|
|
142
|
+
private readonly theme: Theme;
|
|
143
|
+
private readonly searchInput: Input;
|
|
144
|
+
private readonly icons = getModalIcons();
|
|
145
|
+
private selectedId: string | null = null;
|
|
146
|
+
private showAdvanced = false;
|
|
147
|
+
|
|
148
|
+
constructor(options: SplitPaneInspectorModalOptions, theme: Theme) {
|
|
149
|
+
this.options = options;
|
|
150
|
+
this.theme = theme;
|
|
151
|
+
this.searchInput = new Input();
|
|
152
|
+
this.searchInput.focused = true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
render(width: number): string[] {
|
|
156
|
+
const safeWidth = Math.max(1, width);
|
|
157
|
+
const items = this.getFilteredItems();
|
|
158
|
+
this.ensureSelection(items);
|
|
159
|
+
|
|
160
|
+
if (safeWidth < SPLIT_PANE_MIN_WIDTH) {
|
|
161
|
+
return this.renderStackedLayout(items, safeWidth);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return this.renderSplitLayout(items, safeWidth);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
invalidate(): void {
|
|
168
|
+
// Fully state-driven renderer.
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
handleInput(data: string): void {
|
|
172
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
173
|
+
this.options.onClose();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (matchesKey(data, "up")) {
|
|
178
|
+
this.moveSelection(-1);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (matchesKey(data, "down")) {
|
|
183
|
+
this.moveSelection(1);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (matchesKey(data, "return") || matchesKey(data, "space") || data === " ") {
|
|
188
|
+
this.cycleSelectedValue();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (isSlashInput(data)) {
|
|
193
|
+
this.showAdvanced = !this.showAdvanced;
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!shouldForwardInputToSearch(data)) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
this.searchInput.handleInput(data);
|
|
202
|
+
this.ensureSelection(this.getFilteredItems());
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private renderSplitLayout(items: readonly InspectorSettingItem[], width: number): string[] {
|
|
206
|
+
const paneWidths = splitPaneWidths(width);
|
|
207
|
+
const bodyRows = getBodyRowBudget();
|
|
208
|
+
const header = this.buildHeaderRow(width);
|
|
209
|
+
const divider = this.buildHorizontalDivider(paneWidths.list, paneWidths.inspector);
|
|
210
|
+
const footer = this.buildFooterRow(width);
|
|
211
|
+
const listLines = this.buildListPaneLines(items, paneWidths.list, bodyRows);
|
|
212
|
+
const inspectorLines = this.buildInspectorPaneLines(this.getSelectedItem(items), paneWidths.inspector, bodyRows);
|
|
213
|
+
const lines: string[] = [header, divider];
|
|
214
|
+
const dividerPaint = this.theme.fg("dim", "│");
|
|
215
|
+
|
|
216
|
+
for (let index = 0; index < bodyRows; index += 1) {
|
|
217
|
+
const listLine = listLines[index] ?? " ".repeat(paneWidths.list);
|
|
218
|
+
const inspectorLine = inspectorLines[index] ?? " ".repeat(paneWidths.inspector);
|
|
219
|
+
lines.push(`${listLine}${dividerPaint}${inspectorLine}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
lines.push(divider);
|
|
223
|
+
lines.push(footer);
|
|
224
|
+
return lines;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private renderStackedLayout(items: readonly InspectorSettingItem[], width: number): string[] {
|
|
228
|
+
const bodyRows = Math.max(BODY_ROW_MIN, Math.floor(getBodyRowBudget() / 2));
|
|
229
|
+
const divider = this.theme.fg("dim", "─".repeat(Math.max(1, width)));
|
|
230
|
+
return [
|
|
231
|
+
this.buildHeaderRow(width),
|
|
232
|
+
divider,
|
|
233
|
+
...this.buildListPaneLines(items, width, bodyRows),
|
|
234
|
+
divider,
|
|
235
|
+
...this.buildInspectorPaneLines(this.getSelectedItem(items), width, bodyRows + 2),
|
|
236
|
+
divider,
|
|
237
|
+
this.buildFooterRow(width),
|
|
238
|
+
];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private buildHeaderRow(width: number): string {
|
|
242
|
+
const searchBox = this.buildSearchBox(width);
|
|
243
|
+
const leftText = this.theme.fg("accent", this.theme.bold("Pi Tool Display Settings"));
|
|
244
|
+
const leftWidth = visibleWidth("Pi Tool Display Settings");
|
|
245
|
+
const searchWidth = visibleWidth(searchBox);
|
|
246
|
+
const gap = Math.max(1, width - leftWidth - searchWidth);
|
|
247
|
+
if (gap <= 1) {
|
|
248
|
+
return truncateToWidth(`${leftText} ${searchBox}`, width, "", true);
|
|
249
|
+
}
|
|
250
|
+
return `${leftText}${" ".repeat(gap)}${searchBox}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private buildSearchBox(width: number): string {
|
|
254
|
+
const desiredInnerWidth = clamp(Math.floor(width * 0.18), SEARCH_BOX_MIN_WIDTH, SEARCH_BOX_MAX_WIDTH);
|
|
255
|
+
const rendered = this.searchInput.render(desiredInnerWidth + 2)[0] ?? "> ";
|
|
256
|
+
const normalized = rendered.startsWith("> ") ? rendered.slice(2) : rendered;
|
|
257
|
+
const inner = fitText(normalized, desiredInnerWidth);
|
|
258
|
+
return `${this.theme.fg("muted", this.icons.search)} [${inner}]`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private buildHorizontalDivider(leftWidth: number, rightWidth: number): string {
|
|
262
|
+
const paint = (text: string) => this.theme.fg("dim", text);
|
|
263
|
+
return `${paint("─".repeat(Math.max(1, leftWidth)))}${paint("┬")}${paint("─".repeat(Math.max(1, rightWidth)))}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private buildFooterRow(width: number): string {
|
|
267
|
+
const modeAction = this.showAdvanced ? "/ basic" : "/ advanced";
|
|
268
|
+
const text = [...FOOTER_ACTIONS.slice(0, 2), modeAction, FOOTER_ACTIONS[2]].join(" │ ");
|
|
269
|
+
return this.theme.fg("dim", fitText(text, width));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private getAllSettings(): readonly InspectorSettingItem[] {
|
|
273
|
+
return this.options.getSettings();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private getFilteredItems(): readonly InspectorSettingItem[] {
|
|
277
|
+
const query = this.searchInput.getValue().trim().toLowerCase();
|
|
278
|
+
const settings = this.getAllSettings();
|
|
279
|
+
if (!query) {
|
|
280
|
+
return settings;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return settings.filter((item) => {
|
|
284
|
+
const haystack = [
|
|
285
|
+
item.label,
|
|
286
|
+
item.currentValue,
|
|
287
|
+
item.inspectorTitle,
|
|
288
|
+
...item.inspectorSummary,
|
|
289
|
+
...(item.inspectorOptions ?? []),
|
|
290
|
+
...(item.searchTerms ?? []),
|
|
291
|
+
]
|
|
292
|
+
.join(" ")
|
|
293
|
+
.toLowerCase();
|
|
294
|
+
return haystack.includes(query);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private ensureSelection(items: readonly InspectorSettingItem[]): void {
|
|
299
|
+
if (items.length === 0) {
|
|
300
|
+
this.selectedId = null;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (this.selectedId && items.some((item) => item.id === this.selectedId)) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
this.selectedId = items[0]?.id ?? null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private moveSelection(delta: number): void {
|
|
312
|
+
const items = this.getFilteredItems();
|
|
313
|
+
if (items.length === 0) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
this.ensureSelection(items);
|
|
318
|
+
const currentIndex = Math.max(0, items.findIndex((item) => item.id === this.selectedId));
|
|
319
|
+
const nextIndex = (currentIndex + delta + items.length) % items.length;
|
|
320
|
+
this.selectedId = items[nextIndex]?.id ?? this.selectedId;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private cycleSelectedValue(): void {
|
|
324
|
+
const item = this.getSelectedItem(this.getFilteredItems());
|
|
325
|
+
if (!item || !item.values || item.values.length === 0) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const currentIndex = item.values.indexOf(item.currentValue);
|
|
330
|
+
const nextIndex = (currentIndex + 1 + item.values.length) % item.values.length;
|
|
331
|
+
const nextValue = item.values[nextIndex] ?? item.values[0];
|
|
332
|
+
if (!nextValue) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
this.options.onChange(item.id, nextValue);
|
|
337
|
+
this.selectedId = item.id;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private getSelectedItem(items: readonly InspectorSettingItem[]): InspectorSettingItem | null {
|
|
341
|
+
this.ensureSelection(items);
|
|
342
|
+
return items.find((item) => item.id === this.selectedId) ?? null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private buildListPaneLines(items: readonly InspectorSettingItem[], width: number, rowCount: number): string[] {
|
|
346
|
+
const safeWidth = Math.max(1, width);
|
|
347
|
+
if (items.length === 0) {
|
|
348
|
+
return this.padRows(
|
|
349
|
+
[
|
|
350
|
+
this.theme.fg("warning", fitText("No matching settings.", safeWidth)),
|
|
351
|
+
this.theme.fg("dim", fitText("Backspace in search to widen the filter.", safeWidth)),
|
|
352
|
+
],
|
|
353
|
+
rowCount,
|
|
354
|
+
safeWidth,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const selectedIndex = Math.max(0, items.findIndex((item) => item.id === this.selectedId));
|
|
359
|
+
const visibleItems = getScrollableWindow(items, selectedIndex, rowCount);
|
|
360
|
+
const maxValueWidth = Math.max(...visibleItems.map((item) => visibleWidth(item.currentValue)), 6);
|
|
361
|
+
const valueWidth = clamp(maxValueWidth, 6, Math.max(6, Math.floor(safeWidth * 0.34)));
|
|
362
|
+
const labelWidth = Math.max(8, safeWidth - 3 - valueWidth);
|
|
363
|
+
const lines = visibleItems.map((item) => this.renderSettingRow(item, safeWidth, labelWidth, valueWidth));
|
|
364
|
+
return this.padRows(lines, rowCount, safeWidth);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private renderSettingRow(item: InspectorSettingItem, width: number, labelWidth: number, valueWidth: number): string {
|
|
368
|
+
const selected = item.id === this.selectedId;
|
|
369
|
+
const cursor = selected ? this.theme.fg("accent", this.theme.bold(">")) : " ";
|
|
370
|
+
const labelText = fitText(item.label, labelWidth);
|
|
371
|
+
const valueText = fitText(item.currentValue, valueWidth);
|
|
372
|
+
const styledLabel = selected ? this.theme.bold(labelText) : labelText;
|
|
373
|
+
const styledValue = selected ? this.theme.fg("accent", valueText) : this.theme.fg("muted", valueText);
|
|
374
|
+
return truncateToWidth(`${cursor} ${styledLabel} ${styledValue}`, width, "", true);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private buildInspectorPaneLines(selectedItem: InspectorSettingItem | null, width: number, rowCount: number): string[] {
|
|
378
|
+
const safeWidth = Math.max(1, width);
|
|
379
|
+
if (!selectedItem) {
|
|
380
|
+
return this.padRows(
|
|
381
|
+
[
|
|
382
|
+
this.theme.fg("accent", fitText("[ Search ]", safeWidth)),
|
|
383
|
+
"",
|
|
384
|
+
...this.colorWrappedParagraphs(
|
|
385
|
+
["No settings matched the current filter. Adjust the search field to repopulate the settings index."],
|
|
386
|
+
safeWidth,
|
|
387
|
+
"muted",
|
|
388
|
+
),
|
|
389
|
+
],
|
|
390
|
+
rowCount,
|
|
391
|
+
safeWidth,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const topLines: string[] = [this.theme.fg("accent", fitText(`[ ${selectedItem.inspectorTitle} ]`, safeWidth))];
|
|
396
|
+
for (let index = 0; index < INSPECTOR_TITLE_GAP_ROWS; index += 1) {
|
|
397
|
+
topLines.push("");
|
|
398
|
+
}
|
|
399
|
+
topLines.push(...this.colorWrappedParagraphs(selectedItem.inspectorSummary, safeWidth, "muted"));
|
|
400
|
+
|
|
401
|
+
if (this.showAdvanced && (selectedItem.inspectorAdvanced?.length ?? 0) > 0) {
|
|
402
|
+
topLines.push("");
|
|
403
|
+
topLines.push(this.theme.fg("accent", fitText("Advanced:", safeWidth)));
|
|
404
|
+
topLines.push(...this.colorWrappedBullets(selectedItem.inspectorAdvanced ?? [], safeWidth, "dim"));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if ((selectedItem.inspectorOptions?.length ?? 0) > 0) {
|
|
408
|
+
topLines.push("");
|
|
409
|
+
topLines.push(this.theme.fg("dim", fitText("Options:", safeWidth)));
|
|
410
|
+
topLines.push(...this.colorWrappedBullets(selectedItem.inspectorOptions ?? [], safeWidth, "muted"));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const bottomLines: string[] = [];
|
|
414
|
+
if (selectedItem.inspectorPath) {
|
|
415
|
+
for (let index = 0; index < INSPECTOR_PATH_GAP_ROWS; index += 1) {
|
|
416
|
+
bottomLines.push(" ".repeat(safeWidth));
|
|
417
|
+
}
|
|
418
|
+
bottomLines.push(this.theme.fg("dim", fitText("Path:", safeWidth)));
|
|
419
|
+
bottomLines.push(this.theme.fg("muted", fitText(selectedItem.inspectorPath, safeWidth)));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return this.composeInspectorRows(topLines, bottomLines, rowCount, safeWidth);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private composeInspectorRows(topLines: string[], bottomLines: string[], rowCount: number, width: number): string[] {
|
|
426
|
+
const totalLines = [...topLines];
|
|
427
|
+
if (bottomLines.length === 0) {
|
|
428
|
+
return this.padRows(totalLines, rowCount, width);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (topLines.length + bottomLines.length <= rowCount) {
|
|
432
|
+
const spacerCount = rowCount - topLines.length - bottomLines.length;
|
|
433
|
+
return [...topLines, ...Array.from({ length: spacerCount }, () => " ".repeat(width)), ...bottomLines];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const reservedBottom = bottomLines.length;
|
|
437
|
+
const maxTopLines = Math.max(1, rowCount - reservedBottom - 1);
|
|
438
|
+
const trimmedTop = topLines.slice(0, maxTopLines);
|
|
439
|
+
trimmedTop.push(this.theme.fg("dim", fitText("…", width)));
|
|
440
|
+
return [...trimmedTop, ...bottomLines].slice(0, rowCount);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private colorWrappedParagraphs(paragraphs: readonly string[], width: number, color: "muted" | "dim"): string[] {
|
|
444
|
+
const lines: string[] = [];
|
|
445
|
+
for (const paragraph of paragraphs) {
|
|
446
|
+
for (const line of wrapText(paragraph, width)) {
|
|
447
|
+
lines.push(this.theme.fg(color, fitText(line, width)));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return lines;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private colorWrappedBullets(bullets: readonly string[], width: number, color: "muted" | "dim"): string[] {
|
|
454
|
+
const lines: string[] = [];
|
|
455
|
+
const bulletPrefix = "• ";
|
|
456
|
+
const continuationPrefix = " ";
|
|
457
|
+
const contentWidth = Math.max(1, width - visibleWidth(bulletPrefix));
|
|
458
|
+
for (const bullet of bullets) {
|
|
459
|
+
const wrapped = wrapText(bullet, contentWidth);
|
|
460
|
+
for (const [index, line] of wrapped.entries()) {
|
|
461
|
+
const prefix = index === 0 ? bulletPrefix : continuationPrefix;
|
|
462
|
+
lines.push(this.theme.fg(color, fitText(`${prefix}${line}`, width)));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return lines;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private padRows(lines: string[], rowCount: number, width: number): string[] {
|
|
469
|
+
const padded = [...lines];
|
|
470
|
+
while (padded.length < rowCount) {
|
|
471
|
+
padded.push(" ".repeat(width));
|
|
472
|
+
}
|
|
473
|
+
return padded.slice(0, rowCount);
|
|
474
|
+
}
|
|
475
|
+
}
|