@underactive/pi-topping-statusline 0.2.7 → 0.3.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 +11 -10
- package/index.ts +3 -2
- package/package.json +8 -5
- package/src/cache-hit.ts +107 -0
- package/src/compaction-bar.ts +9 -49
- package/src/context-graph.ts +6 -3
- package/src/context.ts +29 -3
- package/src/footer.ts +2 -4
- package/src/nvidia-green.ts +14 -10
- package/src/rainbow.ts +3 -23
- package/src/segments.ts +13 -1
- package/src/settings-menu.ts +73 -41
- package/src/settings.ts +49 -40
- package/src/tests/cache-hit.test.ts +318 -0
- package/src/tests/color-mode.test.ts +20 -0
- package/src/tests/compaction-bar.test.ts +9 -48
- package/src/tests/compaction-embed.test.ts +6 -2
- package/src/tests/context-estimate.test.ts +1 -1
- package/src/tests/feeds.test.ts +29 -11
- package/src/tests/git-status.test.ts +3 -3
- package/src/tests/nvidia-green.test.ts +0 -9
- package/src/tests/rainbow.test.ts +1 -1
- package/src/tests/settings-menu.test.ts +179 -0
- package/src/tests/settings.test.ts +56 -0
- package/src/tests/token-rate.test.ts +1 -1
- package/src/tests/working-status.test.ts +8 -8
- package/src/tests/working.test.ts +1 -7
- package/src/theme.ts +24 -101
- package/src/token-rate.ts +16 -117
- package/src/types.ts +14 -5
- package/src/utils.ts +8 -17
- package/src/working-status-embed.ts +0 -7
- package/src/working-status.ts +4 -5
- package/src/menu.ts +0 -676
- package/src/tests/menu.test.ts +0 -138
package/src/menu.ts
DELETED
|
@@ -1,676 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* menu.ts
|
|
3
|
-
*
|
|
4
|
-
* A reusable, dependency-light box-drawing toggle-menu component for Pi
|
|
5
|
-
* extensions, built on top of `@earendil-works/pi-tui`'s `Component`
|
|
6
|
-
* contract and `ctx.ui.custom()` overlay API. Adapted from pi-topping's
|
|
7
|
-
* menu.ts, minus its reorder rows, plus a preview width parameter.
|
|
8
|
-
*
|
|
9
|
-
* Renders a titled box containing one or more sections of boolean toggle or
|
|
10
|
-
* multi-value cycle items, plus an optional live-updating preview section
|
|
11
|
-
* driven by a caller-supplied render callback, e.g.:
|
|
12
|
-
*
|
|
13
|
-
* ╔═[ Settings ]═════════════════════════════════════╗
|
|
14
|
-
* ╟─ Preview ────────────────────────────────────────╢
|
|
15
|
-
* ║ ║
|
|
16
|
-
* ║ ╭── π ⬢ model ───────────────────── session ──╮ ║
|
|
17
|
-
* ║ ║
|
|
18
|
-
* ╟─ Global ─────────────────────────────────────────╢
|
|
19
|
-
* ║ > [■] Transparent Segments ON ║
|
|
20
|
-
* ║ Separator ‹ powerline-thin › ║
|
|
21
|
-
* ║ ║
|
|
22
|
-
* ╟──────────────────────────────────────────────────╢
|
|
23
|
-
* ║ ↑↓ move ␣ toggle ⏎ apply esc cancel ║
|
|
24
|
-
* ╚══════════════════════════════════════════[ 1/2 ]═╝
|
|
25
|
-
*
|
|
26
|
-
* Intended to be reused by any extension that needs a simple modal toggle
|
|
27
|
-
* menu; it has no dependency on this extension's own settings shape.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
31
|
-
import {
|
|
32
|
-
type Component,
|
|
33
|
-
decodeKittyPrintable,
|
|
34
|
-
Key,
|
|
35
|
-
matchesKey,
|
|
36
|
-
truncateToWidth,
|
|
37
|
-
type TUI,
|
|
38
|
-
visibleWidth,
|
|
39
|
-
} from "@earendil-works/pi-tui";
|
|
40
|
-
|
|
41
|
-
export type MenuValue = boolean | string;
|
|
42
|
-
|
|
43
|
-
/** Lines to display and an optional delay before the preview should refresh. */
|
|
44
|
-
export interface PreviewResult {
|
|
45
|
-
lines: string[];
|
|
46
|
-
nextRefreshInMs?: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface MenuItem {
|
|
50
|
-
id: string;
|
|
51
|
-
label: string;
|
|
52
|
-
value: MenuValue;
|
|
53
|
-
/** Values cycled with left/right arrows. Omit for a boolean space-toggle. */
|
|
54
|
-
cycleValues?: readonly string[];
|
|
55
|
-
/** ID of the boolean value that gates cycling; space toggles it. */
|
|
56
|
-
cycleEnabledBy?: string;
|
|
57
|
-
/** Initial enabled state when `cycleEnabledBy` is set (default true). */
|
|
58
|
-
cycleEnabled?: boolean;
|
|
59
|
-
/** Value snapped to when the gating checkbox is unchecked. */
|
|
60
|
-
cycleDisabledValue?: string;
|
|
61
|
-
/** Free-text row: Enter opens an inline editor on the row itself. */
|
|
62
|
-
text?: boolean;
|
|
63
|
-
/** Dimmed stand-in shown while a text row's value is empty. */
|
|
64
|
-
placeholder?: string;
|
|
65
|
-
/** Runs `MenuConfig.onAction` on Enter or space instead of holding a value. */
|
|
66
|
-
action?: boolean;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export interface MenuSection {
|
|
70
|
-
title: string;
|
|
71
|
-
items: MenuItem[];
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export interface MenuConfig {
|
|
75
|
-
title: string;
|
|
76
|
-
sections: MenuSection[];
|
|
77
|
-
hints?: string[];
|
|
78
|
-
/**
|
|
79
|
-
* Optional preview renderer, shown in its own "Preview" section above the
|
|
80
|
-
* toggle sections. Called on every render with the menu's current (possibly
|
|
81
|
-
* toggled but not-yet-applied) values, the number of milliseconds elapsed
|
|
82
|
-
* since the menu opened, the focused item id, and the box's inner width in
|
|
83
|
-
* cells. Lines may contain ANSI styling and are truncated/padded to fit
|
|
84
|
-
* automatically.
|
|
85
|
-
*
|
|
86
|
-
* Return `string[]` for legacy interval-based animation (see
|
|
87
|
-
* `previewIntervalMs`), or a `PreviewResult` to declare the next refresh.
|
|
88
|
-
* Omitting `nextRefreshInMs` from a `PreviewResult` makes the preview static.
|
|
89
|
-
*/
|
|
90
|
-
preview?: (
|
|
91
|
-
values: Record<string, MenuValue>,
|
|
92
|
-
elapsedMs: number,
|
|
93
|
-
activeItemId: string | undefined,
|
|
94
|
-
innerWidth: number,
|
|
95
|
-
) => string[] | PreviewResult;
|
|
96
|
-
/** Fallback delay in ms for legacy `string[]` previews. Default 50. */
|
|
97
|
-
previewIntervalMs?: number;
|
|
98
|
-
/**
|
|
99
|
-
* Invoked when an action row fires, with the menu's current values. Return
|
|
100
|
-
* fresh sections to rebuild the menu around them (adding or removing rows);
|
|
101
|
-
* return undefined to leave the rows as they are. Values belonging to rows
|
|
102
|
-
* that survive the rebuild are preserved.
|
|
103
|
-
*/
|
|
104
|
-
onAction?: (id: string, values: Record<string, MenuValue>) => MenuSection[] | undefined;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export interface MenuResult<T> {
|
|
108
|
-
applied: boolean;
|
|
109
|
-
values: T;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const DEFAULT_HINTS = ["\u2191\u2193 move", "\u2423 toggle", "\u23ce apply", "esc cancel"];
|
|
113
|
-
const EDIT_HINTS = ["type to edit", "\u232b delete", "\u23ce commit", "esc discard"];
|
|
114
|
-
const OVERLAY_WIDTH = "90%";
|
|
115
|
-
/** Width of the " <marker> " prefix shared by rows without a checkbox. */
|
|
116
|
-
const PLAIN_ROW_PREFIX_LEN = 5;
|
|
117
|
-
/** Width of the " <marker> [■] " prefix shared by checkbox rows. */
|
|
118
|
-
const CHECKBOX_ROW_PREFIX_LEN = 8;
|
|
119
|
-
|
|
120
|
-
interface FlatItem {
|
|
121
|
-
item: MenuItem;
|
|
122
|
-
sectionIndex: number;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function flattenSections(sections: MenuSection[]): FlatItem[] {
|
|
126
|
-
const flat: FlatItem[] = [];
|
|
127
|
-
for (const [sectionIndex, section] of sections.entries()) {
|
|
128
|
-
for (const item of section.items) {
|
|
129
|
-
flat.push({ item, sectionIndex });
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return flat;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function buildInitialValues(config: MenuConfig): Record<string, MenuValue> {
|
|
136
|
-
const values: Record<string, MenuValue> = {};
|
|
137
|
-
for (const section of config.sections) {
|
|
138
|
-
for (const item of section.items) {
|
|
139
|
-
if (item.action) continue;
|
|
140
|
-
values[item.id] = item.value;
|
|
141
|
-
if (item.cycleEnabledBy) values[item.cycleEnabledBy] = item.cycleEnabled ?? true;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
return values;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** Internal Component implementing the box-drawing toggle menu. */
|
|
148
|
-
export class MenuComponent implements Component {
|
|
149
|
-
private readonly theme: Theme;
|
|
150
|
-
private readonly done: (result: MenuResult<Record<string, MenuValue>>) => void;
|
|
151
|
-
private readonly title: string;
|
|
152
|
-
// Sections and their flattened rows are rebuilt whenever an action row adds
|
|
153
|
-
// or removes entries, so neither can be readonly.
|
|
154
|
-
private sections: MenuSection[];
|
|
155
|
-
private readonly hints: string[];
|
|
156
|
-
private readonly initialValues: Record<string, MenuValue>;
|
|
157
|
-
private values: Record<string, MenuValue>;
|
|
158
|
-
private flat: FlatItem[];
|
|
159
|
-
private readonly onAction: MenuConfig["onAction"];
|
|
160
|
-
/** Set while a text row is being edited; holds the uncommitted buffer. */
|
|
161
|
-
private editing: { id: string; buffer: string } | undefined;
|
|
162
|
-
private readonly previewFn: MenuConfig["preview"];
|
|
163
|
-
private readonly previewIntervalMs: number | undefined;
|
|
164
|
-
private readonly previewOrigin: number | undefined;
|
|
165
|
-
private readonly tui: TUI | undefined;
|
|
166
|
-
private previewTimer: ReturnType<typeof setTimeout> | undefined;
|
|
167
|
-
private previewNextRefreshMs: number | undefined;
|
|
168
|
-
private disposed = false;
|
|
169
|
-
private cursor = 0;
|
|
170
|
-
private scrollStart = 0;
|
|
171
|
-
private cachedWidth: number | undefined;
|
|
172
|
-
private cachedRows: number | undefined;
|
|
173
|
-
private cachedLines: string[] | undefined;
|
|
174
|
-
|
|
175
|
-
constructor(
|
|
176
|
-
config: MenuConfig,
|
|
177
|
-
theme: Theme,
|
|
178
|
-
done: (result: MenuResult<Record<string, MenuValue>>) => void,
|
|
179
|
-
tui?: TUI,
|
|
180
|
-
) {
|
|
181
|
-
this.theme = theme;
|
|
182
|
-
this.done = done;
|
|
183
|
-
this.title = config.title;
|
|
184
|
-
this.sections = config.sections;
|
|
185
|
-
this.hints = config.hints ?? DEFAULT_HINTS;
|
|
186
|
-
this.tui = tui;
|
|
187
|
-
this.onAction = config.onAction;
|
|
188
|
-
this.values = buildInitialValues(config);
|
|
189
|
-
this.flat = flattenSections(this.sections);
|
|
190
|
-
this.initialValues = { ...this.values };
|
|
191
|
-
|
|
192
|
-
this.previewFn = config.preview;
|
|
193
|
-
this.previewIntervalMs = config.previewIntervalMs;
|
|
194
|
-
if (this.previewFn) {
|
|
195
|
-
this.previewOrigin = Date.now();
|
|
196
|
-
if (tui) {
|
|
197
|
-
this.samplePreview(Math.max(0, Math.floor(tui.terminal.columns * 0.9) - 2));
|
|
198
|
-
this.schedulePreview();
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** Stops the preview animation timer, if any. Called automatically when the overlay closes. */
|
|
204
|
-
dispose(): void {
|
|
205
|
-
this.disposed = true;
|
|
206
|
-
if (this.previewTimer !== undefined) {
|
|
207
|
-
clearTimeout(this.previewTimer);
|
|
208
|
-
this.previewTimer = undefined;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
handleInput(data: string): void {
|
|
213
|
-
if (this.editing) {
|
|
214
|
-
this.handleEditInput(data);
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
if (this.flat.length === 0) {
|
|
218
|
-
if (matchesKey(data, Key.enter)) {
|
|
219
|
-
this.done({ applied: true, values: { ...this.values } });
|
|
220
|
-
} else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
221
|
-
this.done({ applied: false, values: { ...this.initialValues } });
|
|
222
|
-
}
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Map input to a normalized key name.
|
|
227
|
-
let mappedKey: string | undefined;
|
|
228
|
-
for (const k of [Key.up, Key.down, Key.left, Key.right, Key.space, Key.enter, Key.escape]) {
|
|
229
|
-
if (matchesKey(data, k)) {
|
|
230
|
-
mappedKey = k;
|
|
231
|
-
break;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
if (!mappedKey && matchesKey(data, Key.ctrl("c"))) mappedKey = Key.escape;
|
|
235
|
-
|
|
236
|
-
const keyActions: Record<string, () => void> = {
|
|
237
|
-
[Key.up]: () => {
|
|
238
|
-
this.cursor = (this.cursor - 1 + this.flat.length) % this.flat.length;
|
|
239
|
-
this.invalidate();
|
|
240
|
-
},
|
|
241
|
-
[Key.down]: () => {
|
|
242
|
-
this.cursor = (this.cursor + 1) % this.flat.length;
|
|
243
|
-
this.invalidate();
|
|
244
|
-
},
|
|
245
|
-
[Key.left]: () => this.cycleCurrentValue(-1),
|
|
246
|
-
[Key.right]: () => this.cycleCurrentValue(1),
|
|
247
|
-
[Key.space]: () => {
|
|
248
|
-
const item = this.flat[this.cursor]!;
|
|
249
|
-
if (item.item.action) {
|
|
250
|
-
this.runAction(item.item.id);
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
if (item.item.text) {
|
|
254
|
-
this.beginEditing(item);
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
if (item.item.cycleValues && item.item.cycleEnabledBy) {
|
|
258
|
-
this.values[item.item.cycleEnabledBy] = !this.values[item.item.cycleEnabledBy] as boolean;
|
|
259
|
-
if (!this.values[item.item.cycleEnabledBy] && item.item.cycleDisabledValue !== undefined) {
|
|
260
|
-
this.values[item.item.id] = item.item.cycleDisabledValue;
|
|
261
|
-
}
|
|
262
|
-
} else if (!item.item.cycleValues) this.values[item.item.id] = !this.values[item.item.id] as boolean;
|
|
263
|
-
this.invalidate();
|
|
264
|
-
},
|
|
265
|
-
// Enter opens an editor or fires an action on those rows, so applying
|
|
266
|
-
// the whole menu stays available from any other row.
|
|
267
|
-
[Key.enter]: () => {
|
|
268
|
-
const item = this.flat[this.cursor]!;
|
|
269
|
-
if (item.item.action) this.runAction(item.item.id);
|
|
270
|
-
else if (item.item.text) this.beginEditing(item);
|
|
271
|
-
else this.done({ applied: true, values: { ...this.values } });
|
|
272
|
-
},
|
|
273
|
-
[Key.escape]: () => this.done({ applied: false, values: { ...this.initialValues } }),
|
|
274
|
-
};
|
|
275
|
-
|
|
276
|
-
const handler = mappedKey ? keyActions[mappedKey] : undefined;
|
|
277
|
-
if (handler) handler();
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
invalidate(): void {
|
|
281
|
-
this.cachedWidth = undefined;
|
|
282
|
-
this.cachedRows = undefined;
|
|
283
|
-
this.cachedLines = undefined;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
private beginEditing(item: FlatItem): void {
|
|
287
|
-
this.editing = { id: item.item.id, buffer: String(this.values[item.item.id] ?? "") };
|
|
288
|
-
this.invalidate();
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
private handleEditInput(data: string): void {
|
|
292
|
-
const editing = this.editing!;
|
|
293
|
-
if (matchesKey(data, Key.enter)) {
|
|
294
|
-
this.values[editing.id] = editing.buffer;
|
|
295
|
-
this.editing = undefined;
|
|
296
|
-
this.invalidate();
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
300
|
-
this.editing = undefined;
|
|
301
|
-
this.invalidate();
|
|
302
|
-
return;
|
|
303
|
-
}
|
|
304
|
-
if (matchesKey(data, Key.backspace)) {
|
|
305
|
-
editing.buffer = [...editing.buffer].slice(0, -1).join("");
|
|
306
|
-
this.invalidate();
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
// Mirrors pi-tui's editor: prefer the terminal protocol's decoded key and
|
|
310
|
-
// fall back to raw data, so escape sequences never leak into the buffer.
|
|
311
|
-
const printable = decodeKittyPrintable(data) ?? (data.charCodeAt(0) >= 32 ? data : undefined);
|
|
312
|
-
if (printable === undefined || printable.startsWith("\x1b")) return;
|
|
313
|
-
const clean = [...printable].filter(ch => {
|
|
314
|
-
const code = ch.codePointAt(0) ?? 0;
|
|
315
|
-
return code >= 32 && code !== 127;
|
|
316
|
-
});
|
|
317
|
-
if (clean.length === 0) return;
|
|
318
|
-
editing.buffer += clean.join("");
|
|
319
|
-
this.invalidate();
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/** Run an action row's handler and rebuild around any sections it returns. */
|
|
323
|
-
private runAction(id: string): void {
|
|
324
|
-
const next = this.onAction?.(id, { ...this.values });
|
|
325
|
-
if (next) this.rebuild(next);
|
|
326
|
-
this.invalidate();
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
private rebuild(sections: MenuSection[]): void {
|
|
330
|
-
this.sections = sections;
|
|
331
|
-
this.flat = flattenSections(sections);
|
|
332
|
-
// Carry over values for rows that survived; drop the rest so a removed
|
|
333
|
-
// row cannot resurrect its value on the next apply.
|
|
334
|
-
const next: Record<string, MenuValue> = {};
|
|
335
|
-
for (const section of sections) {
|
|
336
|
-
for (const item of section.items) {
|
|
337
|
-
if (item.action) continue;
|
|
338
|
-
next[item.id] = this.values[item.id] ?? item.value;
|
|
339
|
-
if (item.cycleEnabledBy) {
|
|
340
|
-
next[item.cycleEnabledBy] = this.values[item.cycleEnabledBy] ?? item.cycleEnabled ?? true;
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
this.values = next;
|
|
345
|
-
this.editing = undefined;
|
|
346
|
-
this.cursor = Math.max(0, Math.min(this.cursor, this.flat.length - 1));
|
|
347
|
-
this.scrollStart = Math.max(0, Math.min(this.scrollStart, Math.max(0, this.flat.length - 1)));
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
render(width: number): string[] {
|
|
351
|
-
const rows = this.availableRows();
|
|
352
|
-
if (this.cachedWidth === width && this.cachedRows === rows && this.cachedLines) return this.cachedLines;
|
|
353
|
-
const lines = this.buildLines(width, rows);
|
|
354
|
-
this.cachedWidth = width;
|
|
355
|
-
this.cachedRows = rows;
|
|
356
|
-
this.cachedLines = lines;
|
|
357
|
-
this.schedulePreview();
|
|
358
|
-
return lines;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
private cycleCurrentValue(delta: number): void {
|
|
362
|
-
const item = this.flat[this.cursor]!;
|
|
363
|
-
if (!item.item.cycleValues?.length || (item.item.cycleEnabledBy && !this.values[item.item.cycleEnabledBy])) return;
|
|
364
|
-
const current = item.item.cycleValues.indexOf(this.values[item.item.id] as string);
|
|
365
|
-
const index = (current + delta + item.item.cycleValues.length) % item.item.cycleValues.length;
|
|
366
|
-
this.values[item.item.id] = item.item.cycleValues[index]!;
|
|
367
|
-
this.invalidate();
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
private samplePreview(innerWidth: number): string[] | undefined {
|
|
371
|
-
if (!this.previewFn) return undefined;
|
|
372
|
-
|
|
373
|
-
const result = this.previewFn(
|
|
374
|
-
this.values,
|
|
375
|
-
this.previewOrigin !== undefined ? Date.now() - this.previewOrigin : 0,
|
|
376
|
-
this.flat[this.cursor]?.item.id,
|
|
377
|
-
innerWidth,
|
|
378
|
-
);
|
|
379
|
-
if (Array.isArray(result)) {
|
|
380
|
-
this.previewNextRefreshMs = this.previewIntervalMs ?? 50;
|
|
381
|
-
return result;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
this.previewNextRefreshMs = result.nextRefreshInMs;
|
|
385
|
-
return result.lines;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
private schedulePreview(): void {
|
|
389
|
-
if (this.previewTimer !== undefined) {
|
|
390
|
-
clearTimeout(this.previewTimer);
|
|
391
|
-
this.previewTimer = undefined;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
const nextRefreshInMs = this.previewNextRefreshMs;
|
|
395
|
-
if (this.disposed || !this.tui || nextRefreshInMs === undefined || nextRefreshInMs <= 0) return;
|
|
396
|
-
|
|
397
|
-
this.previewTimer = setTimeout(() => {
|
|
398
|
-
this.invalidate();
|
|
399
|
-
this.tui?.requestRender();
|
|
400
|
-
}, nextRefreshInMs);
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
private buildPreviewBlock(previewLines: string[] | undefined, innerWidth: number): string[] {
|
|
404
|
-
if (!previewLines?.length) return [];
|
|
405
|
-
return [
|
|
406
|
-
this.renderSectionDivider("Preview", innerWidth),
|
|
407
|
-
this.renderBlankRow(innerWidth),
|
|
408
|
-
...previewLines.map(line => this.renderContentRow(` ${line}`, innerWidth)),
|
|
409
|
-
this.renderBlankRow(innerWidth),
|
|
410
|
-
];
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/** Render every section from `this.flat`, the single source of truth for row order. */
|
|
414
|
-
private buildToggleSections(innerWidth: number): string[] {
|
|
415
|
-
const lines: string[] = [];
|
|
416
|
-
let currentSection = -1;
|
|
417
|
-
for (const [index, flat] of this.flat.entries()) {
|
|
418
|
-
if (flat.sectionIndex !== currentSection) {
|
|
419
|
-
if (currentSection !== -1) lines.push(this.renderBlankRow(innerWidth));
|
|
420
|
-
lines.push(this.renderSectionDivider(this.sections[flat.sectionIndex]!.title, innerWidth));
|
|
421
|
-
currentSection = flat.sectionIndex;
|
|
422
|
-
}
|
|
423
|
-
lines.push(this.renderItemRow(flat.item, index === this.cursor, innerWidth));
|
|
424
|
-
}
|
|
425
|
-
if (lines.length) lines.push(this.renderBlankRow(innerWidth));
|
|
426
|
-
return lines;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/** Build a section-aware item window, keeping a divider above each visible section. */
|
|
430
|
-
private buildToggleWindow(innerWidth: number, start: number, maxRows: number): { lines: string[]; end: number } {
|
|
431
|
-
const lines: string[] = [];
|
|
432
|
-
let currentSection = -1;
|
|
433
|
-
let end = start - 1;
|
|
434
|
-
for (let index = start; index < this.flat.length && lines.length < maxRows; index++) {
|
|
435
|
-
const flat = this.flat[index]!;
|
|
436
|
-
if (flat.sectionIndex !== currentSection) {
|
|
437
|
-
const remaining = maxRows - lines.length;
|
|
438
|
-
if (lines.length > 0 && remaining >= 3) lines.push(this.renderBlankRow(innerWidth));
|
|
439
|
-
if (maxRows - lines.length >= 2) {
|
|
440
|
-
lines.push(this.renderSectionDivider(this.sections[flat.sectionIndex]!.title, innerWidth));
|
|
441
|
-
}
|
|
442
|
-
currentSection = flat.sectionIndex;
|
|
443
|
-
}
|
|
444
|
-
if (lines.length >= maxRows) break;
|
|
445
|
-
lines.push(this.renderItemRow(flat.item, index === this.cursor, innerWidth));
|
|
446
|
-
end = index;
|
|
447
|
-
}
|
|
448
|
-
if (end === this.flat.length - 1 && lines.length < maxRows) lines.push(this.renderBlankRow(innerWidth));
|
|
449
|
-
return { lines, end };
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
/** Render the scrolling settings body while keeping the selected item in view. */
|
|
453
|
-
private buildResponsiveToggleSections(innerWidth: number, maxRows: number, naturalBody: string[]): string[] {
|
|
454
|
-
const allLines = naturalBody;
|
|
455
|
-
if (allLines.length <= maxRows) {
|
|
456
|
-
this.scrollStart = 0;
|
|
457
|
-
return allLines;
|
|
458
|
-
}
|
|
459
|
-
if (maxRows <= 0) return [];
|
|
460
|
-
if (maxRows === 1) return this.buildToggleWindow(innerWidth, this.cursor, 1).lines;
|
|
461
|
-
|
|
462
|
-
const contentRows = maxRows - 1; // Reserve one fixed row for scroll status.
|
|
463
|
-
if (this.cursor < this.scrollStart) this.scrollStart = this.cursor;
|
|
464
|
-
this.scrollStart = Math.max(0, Math.min(this.scrollStart, this.flat.length - 1));
|
|
465
|
-
|
|
466
|
-
let window = this.buildToggleWindow(innerWidth, this.scrollStart, contentRows);
|
|
467
|
-
while (window.end < this.cursor && this.scrollStart < this.cursor) {
|
|
468
|
-
this.scrollStart++;
|
|
469
|
-
window = this.buildToggleWindow(innerWidth, this.scrollStart, contentRows);
|
|
470
|
-
}
|
|
471
|
-
// Backfill from above whenever more context fits without hiding the cursor.
|
|
472
|
-
while (this.scrollStart > 0) {
|
|
473
|
-
const candidate = this.buildToggleWindow(innerWidth, this.scrollStart - 1, contentRows);
|
|
474
|
-
if (candidate.end < this.cursor) break;
|
|
475
|
-
this.scrollStart--;
|
|
476
|
-
window = candidate;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
const rows = [...window.lines];
|
|
480
|
-
while (rows.length < contentRows) rows.push(this.renderBlankRow(innerWidth));
|
|
481
|
-
rows.push(this.renderScrollStatus(this.scrollStart > 0, window.end < this.flat.length - 1, innerWidth));
|
|
482
|
-
return rows;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
private buildFooter(innerWidth: number): string[] {
|
|
486
|
-
return [this.renderSeparator(innerWidth), this.renderHintsRow(innerWidth), this.renderBottomBorder(innerWidth)];
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
private availableRows(): number | undefined {
|
|
490
|
-
const rows = this.tui?.terminal.rows;
|
|
491
|
-
return rows !== undefined && rows > 0 ? rows : undefined;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
private buildLines(maxWidth: number, maxRows?: number): string[] {
|
|
495
|
-
const boxWidth = Math.max(0, maxWidth);
|
|
496
|
-
const innerWidth = Math.max(0, boxWidth - 2);
|
|
497
|
-
const previewLines = this.samplePreview(innerWidth);
|
|
498
|
-
const header = [this.renderTopBorder(innerWidth), ...this.buildPreviewBlock(previewLines, innerWidth)];
|
|
499
|
-
const footer = this.buildFooter(innerWidth);
|
|
500
|
-
const naturalBody = this.buildToggleSections(innerWidth);
|
|
501
|
-
const naturalHeight = header.length + naturalBody.length + footer.length;
|
|
502
|
-
if (maxRows === undefined || naturalHeight <= maxRows) {
|
|
503
|
-
return [...header, ...naturalBody, ...footer].map(line => truncateToWidth(line, boxWidth, ""));
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
const bodyRows = Math.max(0, maxRows - header.length - footer.length);
|
|
507
|
-
const body = this.buildResponsiveToggleSections(innerWidth, bodyRows, naturalBody);
|
|
508
|
-
return [...header, ...body, ...footer].slice(0, maxRows).map(line => truncateToWidth(line, boxWidth, ""));
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
private wrap(left: string, content: string, right: string): string {
|
|
512
|
-
const th = this.theme;
|
|
513
|
-
return th.fg("border", left) + content + th.fg("border", right);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
/** Render a filled horizontal line with optional title. */
|
|
517
|
-
private renderFilledLine(
|
|
518
|
-
left: string,
|
|
519
|
-
right: string,
|
|
520
|
-
fillChar: string,
|
|
521
|
-
innerWidth: number,
|
|
522
|
-
title?: string,
|
|
523
|
-
titlePrefix?: string,
|
|
524
|
-
titleSuffix?: string,
|
|
525
|
-
boldTitle?: boolean,
|
|
526
|
-
): string {
|
|
527
|
-
const th = this.theme;
|
|
528
|
-
if (!title) {
|
|
529
|
-
const content = th.fg("border", fillChar.repeat(innerWidth));
|
|
530
|
-
return this.wrap(left, content, right);
|
|
531
|
-
}
|
|
532
|
-
const prefix = titlePrefix ?? "";
|
|
533
|
-
const suffix = titleSuffix ?? "";
|
|
534
|
-
const maxTitleLen = Math.max(0, innerWidth - prefix.length - suffix.length);
|
|
535
|
-
const shownTitle = title.length > maxTitleLen ? truncateToWidth(title, maxTitleLen) : title;
|
|
536
|
-
const styledTitle = boldTitle ? th.bold(shownTitle) : shownTitle;
|
|
537
|
-
const fillCount = Math.max(0, innerWidth - visibleWidth(prefix + shownTitle + suffix));
|
|
538
|
-
const titleContent = th.fg("text", styledTitle);
|
|
539
|
-
const content = `${th.fg("border", prefix)}${titleContent}${th.fg("border", suffix + fillChar.repeat(fillCount))}`;
|
|
540
|
-
return this.wrap(left, content, right);
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
private renderTopBorder(innerWidth: number): string {
|
|
544
|
-
return this.renderFilledLine("\u2554", "\u2557", "\u2550", innerWidth, this.title, "\u2550[ ", " ]", true);
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
private renderBottomBorder(innerWidth: number): string {
|
|
548
|
-
const counter = `[ ${this.cursor + 1}/${this.flat.length} ]`;
|
|
549
|
-
const fillCount = Math.max(0, innerWidth - counter.length);
|
|
550
|
-
const content = `${"\u2550".repeat(fillCount)}${counter}`;
|
|
551
|
-
return this.wrap("\u255a", this.theme.fg("border", content), "\u255d");
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
private renderSectionDivider(title: string, innerWidth: number): string {
|
|
555
|
-
return this.renderFilledLine("\u255f", "\u2562", "\u2500", innerWidth, title, "\u2500 ", " \u2500");
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
private renderSeparator(innerWidth: number): string {
|
|
559
|
-
return this.renderFilledLine("\u255f", "\u2562", "\u2500", innerWidth);
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
private renderBlankRow(innerWidth: number): string {
|
|
563
|
-
return this.wrap("\u2551", " ".repeat(innerWidth), "\u2551");
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
/** Pads/truncates an already-styled content string to exactly `innerWidth` and wraps it in border chars. */
|
|
567
|
-
private renderContentRow(content: string, innerWidth: number): string {
|
|
568
|
-
const shown = visibleWidth(content) > innerWidth ? truncateToWidth(content, innerWidth) : content;
|
|
569
|
-
const pad = Math.max(0, innerWidth - visibleWidth(shown));
|
|
570
|
-
return this.wrap("\u2551", `${shown}${" ".repeat(pad)}`, "\u2551");
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
private renderHintsRow(innerWidth: number): string {
|
|
574
|
-
const hints = this.editing ? EDIT_HINTS : this.hints;
|
|
575
|
-
const plain = ` ${hints.join(" ")}`;
|
|
576
|
-
return this.renderContentRow(this.theme.fg("dim", plain), innerWidth);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
private renderScrollStatus(hasAbove: boolean, hasBelow: boolean, innerWidth: number): string {
|
|
580
|
-
const parts = [hasAbove ? "↑ more above" : "", hasBelow ? "↓ more below" : ""].filter(Boolean);
|
|
581
|
-
return this.renderContentRow(this.theme.fg("dim", ` ${parts.join(" ")}`), innerWidth);
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
private renderItemRow(item: MenuItem, selected: boolean, innerWidth: number): string {
|
|
585
|
-
const th = this.theme;
|
|
586
|
-
const value = this.values[item.id]!;
|
|
587
|
-
const marker = selected ? ">" : " ";
|
|
588
|
-
const markerColored = selected ? th.fg("accent", marker) : marker;
|
|
589
|
-
const rowBackground = (content: string) =>
|
|
590
|
-
selected ? th.bg("selectedBg", content.replaceAll("\x1b[0m", `\x1b[0m${th.getBgAnsi("selectedBg")}`)) : content;
|
|
591
|
-
|
|
592
|
-
if (item.action) {
|
|
593
|
-
const label = truncateToWidth(item.label, Math.max(0, innerWidth - 5));
|
|
594
|
-
const leftPlain = ` ${marker} ${label}`;
|
|
595
|
-
const pad = Math.max(0, innerWidth - visibleWidth(leftPlain));
|
|
596
|
-
const content = ` ${markerColored} ${th.fg(selected ? "accent" : "muted", label)}${" ".repeat(pad)}`;
|
|
597
|
-
return this.wrap("\u2551", rowBackground(content), "\u2551");
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
if (item.text) {
|
|
601
|
-
const editing = this.editing?.id === item.id ? this.editing : undefined;
|
|
602
|
-
const raw = editing ? editing.buffer : String(value ?? "");
|
|
603
|
-
const labelWidth = Math.min(16, Math.max(0, innerWidth - PLAIN_ROW_PREFIX_LEN - 4));
|
|
604
|
-
const label = truncateToWidth(item.label, labelWidth).padEnd(labelWidth);
|
|
605
|
-
const fieldWidth = Math.max(0, innerWidth - PLAIN_ROW_PREFIX_LEN - labelWidth - 3);
|
|
606
|
-
// Keep the caret in view by showing the buffer's tail once it overflows.
|
|
607
|
-
const shownRaw =
|
|
608
|
-
editing && raw.length > fieldWidth - 1 ? raw.slice(raw.length - (fieldWidth - 1)) : raw;
|
|
609
|
-
const empty = shownRaw.length === 0;
|
|
610
|
-
const fieldText = truncateToWidth(empty ? (item.placeholder ?? "") : shownRaw, fieldWidth);
|
|
611
|
-
const caret = editing ? "\u2588" : "";
|
|
612
|
-
const fieldStyled = empty
|
|
613
|
-
? th.fg("muted", fieldText)
|
|
614
|
-
: th.fg(editing ? "accent" : "text", fieldText);
|
|
615
|
-
const leftPlain = ` ${marker} ${label} ${fieldText}${caret}`;
|
|
616
|
-
const pad = Math.max(0, innerWidth - visibleWidth(leftPlain) - 1);
|
|
617
|
-
const content = ` ${markerColored} ${th.fg("dim", label)} ${fieldStyled}${editing ? th.fg("accent", caret) : ""}${" ".repeat(pad)} `;
|
|
618
|
-
return this.wrap("\u2551", rowBackground(content), "\u2551");
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
if (item.cycleValues) {
|
|
622
|
-
const stateWord = `‹ ${value} ›`;
|
|
623
|
-
// Ungated cycle rows carry no checkbox; gated ones keep it for the space toggle.
|
|
624
|
-
if (!item.cycleEnabledBy) {
|
|
625
|
-
const maxLabelLen = Math.max(0, innerWidth - PLAIN_ROW_PREFIX_LEN - visibleWidth(stateWord) - 1);
|
|
626
|
-
const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
627
|
-
const leftPlain = ` ${marker} ${label}`;
|
|
628
|
-
const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(stateWord) - 2);
|
|
629
|
-
const content = ` ${markerColored} ${th.fg("text", label)}${" ".repeat(gap)}${th.fg("accent", stateWord)} `;
|
|
630
|
-
return this.wrap("\u2551", rowBackground(content), "\u2551");
|
|
631
|
-
}
|
|
632
|
-
const enabled = this.values[item.cycleEnabledBy] as boolean;
|
|
633
|
-
const maxLabelLen = Math.max(0, innerWidth - CHECKBOX_ROW_PREFIX_LEN - visibleWidth(stateWord) - 1);
|
|
634
|
-
const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
635
|
-
const leftPlain = ` ${marker} [${enabled ? "■" : " "}] ${label}`;
|
|
636
|
-
const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(stateWord) - 2);
|
|
637
|
-
const content = ` ${markerColored} [${enabled ? th.fg("success", "■") : th.fg("muted", " ")}] ${th.fg("text", label)}${" ".repeat(gap)}${enabled ? th.fg("accent", stateWord) : th.fg("muted", stateWord)} `;
|
|
638
|
-
return this.wrap("\u2551", rowBackground(content), "\u2551");
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
const enabled = value as boolean;
|
|
642
|
-
const box = enabled ? "\u25a0" : " ";
|
|
643
|
-
const stateWord = enabled ? "ON" : "OFF";
|
|
644
|
-
const rightPlain = `${stateWord} `;
|
|
645
|
-
const maxLabelLen = Math.max(0, innerWidth - CHECKBOX_ROW_PREFIX_LEN - rightPlain.length - 1);
|
|
646
|
-
const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
647
|
-
const leftPlain = ` ${marker} [${box}] ${label}`;
|
|
648
|
-
const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
|
|
649
|
-
const content = ` ${markerColored} [${enabled ? th.fg("success", box) : th.fg("muted", box)}] ${th.fg("text", label)}${" ".repeat(gap)}${enabled ? th.fg("success", stateWord) : th.fg("muted", stateWord)} `;
|
|
650
|
-
return this.wrap("\u2551", rowBackground(content), "\u2551");
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
/**
|
|
655
|
-
* Show a modal box-drawing toggle menu and resolve once the user applies
|
|
656
|
-
* (Enter) or cancels (Escape / Ctrl+C) it.
|
|
657
|
-
*
|
|
658
|
-
* Requires TUI mode; in any other mode this resolves immediately with
|
|
659
|
-
* `applied: false` and the menu's initial values, doing nothing visible.
|
|
660
|
-
*/
|
|
661
|
-
export async function showMenu<T extends Record<string, MenuValue>>(
|
|
662
|
-
ctx: ExtensionCommandContext,
|
|
663
|
-
config: MenuConfig,
|
|
664
|
-
): Promise<MenuResult<T>> {
|
|
665
|
-
const initialValues = buildInitialValues(config) as T;
|
|
666
|
-
|
|
667
|
-
if (ctx.mode !== "tui") {
|
|
668
|
-
return { applied: false, values: initialValues };
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
return ctx.ui.custom<MenuResult<T>>(
|
|
672
|
-
(tui, theme, _keybindings, done) =>
|
|
673
|
-
new MenuComponent(config, theme, done as (result: MenuResult<Record<string, MenuValue>>) => void, tui),
|
|
674
|
-
{ overlay: true, overlayOptions: { width: OVERLAY_WIDTH, maxHeight: "100%" } },
|
|
675
|
-
);
|
|
676
|
-
}
|