@underactive/pi-topping 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/menu.ts DELETED
@@ -1,697 +0,0 @@
1
- /**
2
- * A reusable, dependency-light box-drawing toggle-menu component for Pi
3
- * extensions, built on top of `@earendil-works/pi-tui`'s `Component`
4
- * contract and `ctx.ui.custom()` overlay API.
5
- *
6
- * Renders a titled box containing one or more sections of boolean toggle,
7
- * multi-value cycle (including optional typed whole-number entry), or keyboard-reorderable items, plus an optional live-updating
8
- * preview section driven by a caller-supplied render callback.
9
- *
10
- * Intended to be reused by any extension that needs a simple modal toggle
11
- * menu; it has no dependency on this extension's own settings shape.
12
- */
13
-
14
- import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
15
- import { type Component, decodeKittyPrintable, Key, matchesKey, truncateToWidth, type SizeValue, type TUI, visibleWidth } from "@earendil-works/pi-tui";
16
-
17
- export type MenuValue = boolean | string;
18
-
19
- export interface MenuNumberEntry {
20
- min: number;
21
- max: number;
22
- unit?: string;
23
- }
24
-
25
- /** Lines to display and an optional delay before the preview should refresh. */
26
- export interface PreviewResult {
27
- lines: string[];
28
- nextRefreshInMs?: number;
29
- }
30
-
31
- export interface MenuItem {
32
- id: string;
33
- label: string;
34
- value: MenuValue;
35
- /** Render a blank, non-selectable row immediately before this item. */
36
- spacerBefore?: boolean;
37
- /** IDs for which at least one value must be true before this item can be toggled. */
38
- disabledUnlessAnyOf?: readonly string[];
39
- /** Values cycled with left/right arrows. Omit for a boolean space-toggle. */
40
- cycleValues?: readonly string[];
41
- /** Optional display labels for cycle values; the keys remain the published values. */
42
- cycleValueLabels?: Readonly<Record<string, string>>;
43
- /** ID of the boolean value that gates cycling; space toggles it. */
44
- cycleEnabledBy?: string;
45
- /** Initial enabled state when `cycleEnabledBy` is set (default true). */
46
- cycleEnabled?: boolean;
47
- /** Value snapped to when the gating checkbox is unchecked. */
48
- cycleDisabledValue?: string;
49
- /** Also accept a typed whole number in [min, max]; space opens the editor. Values publish as decimal strings. */
50
- numberEntry?: MenuNumberEntry;
51
- /**
52
- * Marks the item as a reorderable row. Space grabs/releases it; while grabbed,
53
- * up/down move it among the other rows sharing this group instead of moving the
54
- * cursor. The group's current order is published as a comma-joined list of item
55
- * ids under this key in the result values.
56
- */
57
- reorderGroup?: string;
58
- }
59
-
60
- export interface MenuSection {
61
- title: string;
62
- items: MenuItem[];
63
- }
64
-
65
- export interface MenuConfig {
66
- title: string;
67
- sections: MenuSection[];
68
- hints?: string[];
69
- /**
70
- * Optional preview renderer, shown in its own "Preview" section above the
71
- * toggle sections. Called on every render with the menu's current (possibly
72
- * toggled but not-yet-applied) values, the milliseconds elapsed since the menu
73
- * opened, the id of the item under the cursor (undefined for an empty menu),
74
- * and the width available to each line. Lines may contain ANSI styling and
75
- * are truncated/padded to fit automatically.
76
- *
77
- * Omitting `nextRefreshInMs` (or returning a value <= 0) makes the preview static.
78
- */
79
- preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined, width: number) => PreviewResult;
80
- /** Optional heading for the preview block. Defaults to `Preview`. */
81
- previewTitle?: string;
82
- /** Maximum overlay height in rows or as a percentage of the terminal height. */
83
- maxHeight?: SizeValue;
84
- }
85
-
86
- export interface MenuResult<T> {
87
- applied: boolean;
88
- values: T;
89
- }
90
-
91
- const DEFAULT_HINTS = ["\u2191\u2193 move", "PgUp/PgDn page", "\u2423 toggle", "\u23ce apply", "esc cancel"];
92
- const EDIT_HINTS = ["type a whole number", "⌫ delete", "⏎ confirm", "esc cancel edit"];
93
- const MAX_EDIT_CHARS = 8;
94
- const OVERLAY_WIDTH = "86%";
95
- export const DEFAULT_PREVIEW_WIDTH = 76;
96
- const ROW_PREFIX_WIDTH = 8; // " " + marker + " " + "[" + box + "]" + " "
97
-
98
- /** Close OSC 8 links truncated by pi-tui's SGR-only truncation reset. */
99
- function closeTruncatedHyperlink(text: string): string {
100
- const lastOpen = text.lastIndexOf("\x1b]8;;");
101
- const lastClose = text.lastIndexOf("\x1b]8;;\x1b\\");
102
- return lastOpen > lastClose ? `${text}\x1b]8;;\x1b\\` : text;
103
- }
104
-
105
- interface FlatItem {
106
- item: MenuItem;
107
- sectionIndex: number;
108
- }
109
-
110
- function parseWholeNumber(raw: string, spec: MenuNumberEntry): number | undefined {
111
- const trimmed = raw.trim();
112
- if (!/^\d+$/.test(trimmed)) return undefined;
113
- const value = Number(trimmed);
114
- return value >= spec.min && value <= spec.max ? value : undefined;
115
- }
116
-
117
- function buildInitialValues(config: MenuConfig): Record<string, MenuValue> {
118
- const values: Record<string, MenuValue> = {};
119
- const reorderGroups = new Map<string, string[]>();
120
- for (const section of config.sections) {
121
- for (const item of section.items) {
122
- values[item.id] = item.value;
123
- if (item.cycleEnabledBy) values[item.cycleEnabledBy] = item.cycleEnabled ?? true;
124
- if (item.reorderGroup) {
125
- const ids = reorderGroups.get(item.reorderGroup) ?? [];
126
- ids.push(item.id);
127
- reorderGroups.set(item.reorderGroup, ids);
128
- }
129
- }
130
- }
131
- for (const [group, ids] of reorderGroups) values[group] = ids.join(",");
132
- return values;
133
- }
134
-
135
- /** Internal Component implementing the box-drawing toggle menu. */
136
- export class MenuComponent implements Component {
137
- private readonly theme: Theme;
138
- private readonly done: (result: MenuResult<Record<string, MenuValue>>) => void;
139
- private readonly title: string;
140
- private readonly sections: MenuSection[];
141
- private readonly hints: string[];
142
- private readonly previewTitle: string;
143
- private readonly initialValues: Record<string, MenuValue>;
144
- private readonly values: Record<string, MenuValue>;
145
- private readonly flat: FlatItem[];
146
- private readonly previewFn: MenuConfig["preview"];
147
- private readonly previewOrigin: number | undefined;
148
- private readonly tui: TUI | undefined;
149
- private readonly maxHeight: SizeValue | undefined;
150
- private previewTimer: ReturnType<typeof setTimeout> | undefined;
151
- private previewNextRefreshMs: number | undefined;
152
- private disposed = false;
153
- private cursor = 0;
154
- private scrollStart = 0;
155
- private cachedWidth: number | undefined;
156
- private cachedRows: number | undefined;
157
- private cachedLines: string[] | undefined;
158
- private pageItemCount: number | undefined;
159
- private editing: { buffer: string; invalid: boolean } | undefined;
160
-
161
- constructor(
162
- config: MenuConfig,
163
- theme: Theme,
164
- done: (result: MenuResult<Record<string, MenuValue>>) => void,
165
- tui?: TUI,
166
- ) {
167
- this.theme = theme;
168
- this.done = done;
169
- this.title = config.title;
170
- this.sections = config.sections;
171
- this.hints = config.hints ?? DEFAULT_HINTS;
172
- this.previewTitle = config.previewTitle ?? "Preview";
173
- this.tui = tui;
174
- this.maxHeight = config.maxHeight;
175
- this.values = buildInitialValues(config);
176
- this.flat = [];
177
- for (const [sectionIndex, section] of config.sections.entries()) {
178
- for (const item of section.items) {
179
- this.flat.push({ item, sectionIndex });
180
- }
181
- }
182
- this.initialValues = { ...this.values };
183
-
184
- this.previewFn = config.preview;
185
- if (this.previewFn) {
186
- this.previewOrigin = Date.now();
187
- if (tui) {
188
- this.samplePreview();
189
- this.schedulePreview();
190
- }
191
- }
192
- }
193
-
194
- /** Stops the preview animation timer, if any. Called automatically when the overlay closes. */
195
- dispose(): void {
196
- this.disposed = true;
197
- if (this.previewTimer !== undefined) {
198
- clearTimeout(this.previewTimer);
199
- this.previewTimer = undefined;
200
- }
201
- }
202
-
203
- handleInput(data: string): void {
204
- if (this.flat.length === 0) {
205
- if (matchesKey(data, Key.enter)) {
206
- this.done({ applied: true, values: { ...this.values } });
207
- } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
208
- this.done({ applied: false, values: { ...this.initialValues } });
209
- }
210
- return;
211
- }
212
- if (this.editing) {
213
- this.handleEditInput(data);
214
- return;
215
- }
216
-
217
- // Map input to a normalized key name.
218
- let mappedKey: string | undefined;
219
- for (const k of [Key.up, Key.down, Key.pageUp, Key.pageDown, Key.left, Key.right, Key.space, Key.enter, Key.escape]) {
220
- if (matchesKey(data, k)) {
221
- mappedKey = k;
222
- break;
223
- }
224
- }
225
- if (!mappedKey && matchesKey(data, Key.ctrl("c"))) mappedKey = Key.escape;
226
-
227
- const keyActions: Record<string, () => void> = {
228
- [Key.up]: () => {
229
- if (this.moveGrabbedItem(-1)) return;
230
- this.cursor = Math.max(0, this.cursor - 1);
231
- this.invalidate();
232
- },
233
- [Key.down]: () => {
234
- if (this.moveGrabbedItem(1)) return;
235
- this.cursor = Math.min(this.flat.length - 1, this.cursor + 1);
236
- this.invalidate();
237
- },
238
- [Key.pageUp]: () => this.moveByPage(-1),
239
- [Key.pageDown]: () => this.moveByPage(1),
240
- [Key.left]: () => this.cycleCurrentValue(-1),
241
- [Key.right]: () => this.cycleCurrentValue(1),
242
- [Key.space]: () => {
243
- const item = this.flat[this.cursor]!.item;
244
- if (this.isDisabled(item)) return;
245
- if (item.numberEntry) {
246
- this.editing = { buffer: "", invalid: false };
247
- this.invalidate();
248
- return;
249
- }
250
- if (item.cycleValues && item.cycleEnabledBy) {
251
- this.values[item.cycleEnabledBy] = !this.values[item.cycleEnabledBy] as boolean;
252
- if (!this.values[item.cycleEnabledBy] && item.cycleDisabledValue !== undefined) this.values[item.id] = item.cycleDisabledValue;
253
- } else if (!item.cycleValues) this.values[item.id] = !this.values[item.id] as boolean;
254
- this.invalidate();
255
- },
256
- [Key.enter]: () => this.done({ applied: true, values: { ...this.values } }),
257
- [Key.escape]: () => this.done({ applied: false, values: { ...this.initialValues } }),
258
- };
259
-
260
- const handler = mappedKey ? keyActions[mappedKey] : undefined;
261
- if (handler) handler();
262
- }
263
-
264
- invalidate(): void {
265
- this.cachedWidth = undefined;
266
- this.cachedRows = undefined;
267
- this.cachedLines = undefined;
268
- }
269
-
270
- render(width: number): string[] {
271
- const rows = this.availableRows();
272
- if (this.cachedWidth === width && this.cachedRows === rows && this.cachedLines) return this.cachedLines;
273
- const lines = this.buildLines(width, rows);
274
- this.cachedWidth = width;
275
- this.cachedRows = rows;
276
- this.cachedLines = lines;
277
- this.schedulePreview();
278
- return lines;
279
- }
280
-
281
- private handleEditInput(data: string): void {
282
- const editing = this.editing!;
283
- const item = this.flat[this.cursor]!.item;
284
- const spec = item.numberEntry!;
285
- if (matchesKey(data, Key.enter)) {
286
- const value = parseWholeNumber(editing.buffer, spec);
287
- if (value === undefined) editing.invalid = true;
288
- else {
289
- this.values[item.id] = String(value);
290
- this.editing = undefined;
291
- }
292
- } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
293
- this.editing = undefined;
294
- } else if (matchesKey(data, Key.backspace)) {
295
- editing.buffer = [...editing.buffer].slice(0, -1).join("");
296
- editing.invalid = false;
297
- } else {
298
- const text = decodeKittyPrintable(data) ?? data;
299
- if (/^[\x20-\x7e]+$/.test(text) && editing.buffer.length + text.length <= MAX_EDIT_CHARS) {
300
- editing.buffer += text;
301
- editing.invalid = false;
302
- }
303
- }
304
- this.invalidate();
305
- }
306
-
307
- /**
308
- * Move the grabbed reorder row within its group, clamped at the group edges.
309
- * Returns true when the key belongs to the mover and must not also move the
310
- * cursor — which is what keeps a grab from escaping its group.
311
- */
312
- private moveGrabbedItem(delta: number): boolean {
313
- const grabbed = this.flat[this.cursor]!;
314
- const group = grabbed.item.reorderGroup;
315
- if (group === undefined || this.values[grabbed.item.id] !== true) return false;
316
-
317
- let start = this.cursor;
318
- while (start > 0 && this.flat[start - 1]!.item.reorderGroup === group) start--;
319
- let end = this.cursor;
320
- while (end < this.flat.length - 1 && this.flat[end + 1]!.item.reorderGroup === group) end++;
321
-
322
- const target = Math.max(start, Math.min(end, this.cursor + delta));
323
- if (target !== this.cursor) {
324
- this.flat.splice(this.cursor, 1);
325
- this.flat.splice(target, 0, grabbed);
326
- this.cursor = target;
327
- this.values[group] = this.flat.slice(start, end + 1).map(flat => flat.item.id).join(",");
328
- this.invalidate();
329
- }
330
- return true;
331
- }
332
-
333
- private moveByPage(direction: -1 | 1): void {
334
- const pageItemCount = this.getPageItemCount();
335
- if (this.moveGrabbedItem(direction * pageItemCount)) return;
336
- this.cursor = Math.max(0, Math.min(this.flat.length - 1, this.cursor + direction * pageItemCount));
337
- this.invalidate();
338
- }
339
-
340
- private getPageItemCount(): number {
341
- if (this.pageItemCount !== undefined) return this.pageItemCount;
342
- const rows = this.availableRows();
343
- if (rows === undefined) return Math.max(1, Math.min(this.flat.length, 10));
344
- return Math.max(1, rows - 6);
345
- }
346
-
347
- private isDisabled(item: MenuItem): boolean {
348
- const requiredIds = item.disabledUnlessAnyOf;
349
- return requiredIds !== undefined && !requiredIds.some(id => this.values[id] === true);
350
- }
351
-
352
- private cycleCurrentValue(delta: number): void {
353
- const item = this.flat[this.cursor]!.item;
354
- if (this.isDisabled(item) || !item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
355
- const currentValue = this.values[item.id] as string;
356
- const current = item.cycleValues.indexOf(currentValue);
357
- if (item.numberEntry && current === -1) {
358
- const numericCurrent = Number(currentValue);
359
- if (delta > 0) {
360
- for (const preset of item.cycleValues) {
361
- if (Number(preset) > numericCurrent) {
362
- this.values[item.id] = preset;
363
- this.invalidate();
364
- return;
365
- }
366
- }
367
- } else {
368
- for (let i = item.cycleValues.length - 1; i >= 0; i--) {
369
- const preset = item.cycleValues[i]!;
370
- if (Number(preset) < numericCurrent) {
371
- this.values[item.id] = preset;
372
- this.invalidate();
373
- return;
374
- }
375
- }
376
- }
377
- }
378
- const index = (current + delta + item.cycleValues.length) % item.cycleValues.length;
379
- this.values[item.id] = item.cycleValues[index]!;
380
- this.invalidate();
381
- }
382
-
383
- private samplePreview(width: number = DEFAULT_PREVIEW_WIDTH): string[] | undefined {
384
- if (!this.previewFn) return undefined;
385
-
386
- const result = this.previewFn(
387
- this.values,
388
- this.previewOrigin !== undefined ? Date.now() - this.previewOrigin : 0,
389
- this.flat[this.cursor]?.item.id,
390
- width,
391
- );
392
- this.previewNextRefreshMs = result.nextRefreshInMs;
393
- return result.lines;
394
- }
395
-
396
- private schedulePreview(): void {
397
- if (this.previewTimer !== undefined) {
398
- clearTimeout(this.previewTimer);
399
- this.previewTimer = undefined;
400
- }
401
-
402
- const nextRefreshInMs = this.previewNextRefreshMs;
403
- if (this.disposed || !this.tui || nextRefreshInMs === undefined || nextRefreshInMs <= 0) return;
404
-
405
- this.previewTimer = setTimeout(() => {
406
- this.invalidate();
407
- this.tui?.requestRender();
408
- }, nextRefreshInMs);
409
- }
410
-
411
- private buildPreviewBlock(previewLines: string[] | undefined, innerWidth: number): string[] {
412
- if (!previewLines?.length) return [];
413
- return [this.renderSectionDivider(this.previewTitle, innerWidth), this.renderBlankRow(innerWidth), ...previewLines.map(line => this.renderContentRow(` ${line}`, innerWidth)), this.renderBlankRow(innerWidth)];
414
- }
415
-
416
- /** Render every section from `this.flat`, the single source of truth for row order. */
417
- private buildToggleSections(innerWidth: number): string[] {
418
- const lines: string[] = [];
419
- let currentSection = -1;
420
- for (const [index, flat] of this.flat.entries()) {
421
- if (flat.sectionIndex !== currentSection) {
422
- if (currentSection !== -1) lines.push(this.renderBlankRow(innerWidth));
423
- lines.push(this.renderSectionDivider(this.sections[flat.sectionIndex]!.title, innerWidth));
424
- currentSection = flat.sectionIndex;
425
- }
426
- if (flat.item.spacerBefore) lines.push(this.renderBlankRow(innerWidth));
427
- lines.push(this.renderItemRow(flat.item, index === this.cursor, innerWidth));
428
- }
429
- if (lines.length) lines.push(this.renderBlankRow(innerWidth));
430
- return lines;
431
- }
432
-
433
- /** Build a section-aware item window, keeping a divider above each visible section. */
434
- private buildToggleWindow(innerWidth: number, start: number, maxRows: number): { lines: string[]; end: number } {
435
- const lines: string[] = [];
436
- let currentSection = -1;
437
- let end = start - 1;
438
- for (let index = start; index < this.flat.length && lines.length < maxRows; index++) {
439
- const flat = this.flat[index]!;
440
- if (flat.sectionIndex !== currentSection) {
441
- const remaining = maxRows - lines.length;
442
- // A section continued from above needs room for a blank separator
443
- // row, its divider, and at least one item row; otherwise end the
444
- // window here rather than crowd the divider against the previous
445
- // section or fold the section into it. The window's first section
446
- // always renders (a degenerate window may drop its divider).
447
- if (lines.length > 0 && remaining < 3) break;
448
- if (lines.length > 0 && remaining >= 3) lines.push(this.renderBlankRow(innerWidth));
449
- if (maxRows - lines.length >= 2) lines.push(this.renderSectionDivider(this.sections[flat.sectionIndex]!.title, innerWidth));
450
- currentSection = flat.sectionIndex;
451
- }
452
- if (flat.item.spacerBefore && maxRows - lines.length >= 2) lines.push(this.renderBlankRow(innerWidth));
453
- if (lines.length >= maxRows) break;
454
- lines.push(this.renderItemRow(flat.item, index === this.cursor, innerWidth));
455
- end = index;
456
- }
457
- if (end === this.flat.length - 1 && lines.length < maxRows) lines.push(this.renderBlankRow(innerWidth));
458
- return { lines, end };
459
- }
460
-
461
- /** Render the scrolling settings body while keeping the selected item in view. */
462
- private buildResponsiveToggleSections(innerWidth: number, maxRows: number): string[] {
463
- if (maxRows <= 0) {
464
- this.pageItemCount = 1;
465
- return [];
466
- }
467
- if (maxRows === 1) {
468
- this.pageItemCount = 1;
469
- return this.buildToggleWindow(innerWidth, this.cursor, 1).lines;
470
- }
471
-
472
- const contentRows = maxRows - 1; // Reserve one fixed row for scroll status.
473
- if (this.cursor < this.scrollStart) this.scrollStart = this.cursor;
474
- this.scrollStart = Math.max(0, Math.min(this.scrollStart, this.flat.length - 1));
475
-
476
- // Walk backward from cursor to find the lowest scrollStart that fits in contentRows.
477
- {
478
- let rowsUsed = 2 + (this.flat[this.cursor]!.item.spacerBefore ? 1 : 0); // cursor item + first-section divider + optional spacer
479
- let section = this.flat[this.cursor]!.sectionIndex;
480
- this.scrollStart = this.cursor;
481
- for (let i = this.cursor - 1; i >= 0; i--) {
482
- const previous = this.flat[i]!;
483
- const sec = previous.sectionIndex;
484
- const itemRows = 1 + (previous.item.spacerBefore ? 1 : 0);
485
- if (sec !== section) {
486
- if (rowsUsed + itemRows + 2 > contentRows) break;
487
- rowsUsed += itemRows + 2;
488
- section = sec;
489
- } else {
490
- if (rowsUsed + itemRows > contentRows) break;
491
- rowsUsed += itemRows;
492
- }
493
- this.scrollStart = i;
494
- }
495
- }
496
- const window = this.buildToggleWindow(innerWidth, this.scrollStart, contentRows);
497
- this.pageItemCount = Math.max(1, window.end - this.scrollStart + 1);
498
-
499
- const rows = [...window.lines];
500
- while (rows.length < contentRows) rows.push(this.renderBlankRow(innerWidth));
501
- rows.push(this.renderScrollStatus(this.scrollStart > 0, window.end < this.flat.length - 1, innerWidth));
502
- return rows;
503
- }
504
-
505
- private buildFooter(innerWidth: number): string[] {
506
- return [this.renderSeparator(innerWidth), this.renderHintsRow(innerWidth), this.renderBottomBorder(innerWidth)];
507
- }
508
-
509
- private availableRows(): number | undefined {
510
- const rows = this.tui?.terminal?.rows;
511
- if (typeof rows !== "number" || !Number.isFinite(rows) || rows <= 0) return undefined;
512
-
513
- const terminalRows = Math.floor(rows);
514
- if (this.maxHeight === undefined) return terminalRows;
515
- const requestedRows = typeof this.maxHeight === "number"
516
- ? this.maxHeight
517
- : (Number.parseFloat(this.maxHeight) / 100) * terminalRows;
518
- if (!Number.isFinite(requestedRows)) return terminalRows;
519
- return Math.max(1, Math.min(terminalRows, Math.floor(requestedRows)));
520
- }
521
-
522
- private buildLines(maxWidth: number, maxRows?: number): string[] {
523
- const boxWidth = Math.max(0, maxWidth);
524
- const innerWidth = Math.max(0, boxWidth - 2);
525
- // Preview rows are prefixed with a leading space by buildPreviewBlock/renderContentRow.
526
- const previewLines = this.samplePreview(Math.max(0, innerWidth - 1));
527
- const header = [this.renderTopBorder(innerWidth), ...this.buildPreviewBlock(previewLines, innerWidth)];
528
- const footer = this.buildFooter(innerWidth);
529
- const sectionsWithItems = new Set(this.flat.map(f => f.sectionIndex)).size;
530
- const spacerRows = this.flat.filter(flat => flat.item.spacerBefore).length;
531
- const naturalBodyLength = this.flat.length + spacerRows + 2 * sectionsWithItems;
532
- if (maxRows === undefined || header.length + naturalBodyLength + footer.length <= maxRows) {
533
- const naturalBody = this.buildToggleSections(innerWidth);
534
- this.pageItemCount = Math.max(1, this.flat.length);
535
- return [...header, ...naturalBody, ...footer].map(line => truncateToWidth(line, boxWidth, ""));
536
- }
537
-
538
- const bodyRows = Math.max(0, maxRows - header.length - footer.length);
539
- const body = this.buildResponsiveToggleSections(innerWidth, bodyRows);
540
- return [...header, ...body, ...footer].slice(0, maxRows).map(line => truncateToWidth(line, boxWidth, ""));
541
- }
542
-
543
- private wrap(left: string, content: string, right: string): string {
544
- const th = this.theme;
545
- return th.fg("border", left) + content + th.fg("border", right);
546
- }
547
-
548
- /** Render a filled horizontal line with optional title. */
549
- private renderFilledLine(
550
- left: string,
551
- right: string,
552
- fillChar: string,
553
- innerWidth: number,
554
- title?: string,
555
- titlePrefix?: string,
556
- titleSuffix?: string,
557
- boldTitle?: boolean,
558
- ): string {
559
- const th = this.theme;
560
- if (!title) {
561
- const content = th.fg("border", fillChar.repeat(innerWidth));
562
- return this.wrap(left, content, right);
563
- }
564
- const prefix = titlePrefix ?? "";
565
- const suffix = titleSuffix ?? "";
566
- const maxTitleLen = Math.max(0, innerWidth - prefix.length - suffix.length);
567
- const shownTitle = visibleWidth(title) > maxTitleLen ? truncateToWidth(title, maxTitleLen) : title;
568
- const styledTitle = boldTitle ? th.bold(shownTitle) : shownTitle;
569
- const fillCount = Math.max(0, innerWidth - visibleWidth(prefix + shownTitle + suffix));
570
- const titleContent = th.fg("text", styledTitle);
571
- const content = `${th.fg("border", prefix)}${titleContent}${th.fg("border", suffix + fillChar.repeat(fillCount))}`;
572
- return this.wrap(left, content, right);
573
- }
574
-
575
- private renderTopBorder(innerWidth: number): string {
576
- return this.renderFilledLine("\u2554", "\u2557", "\u2550", innerWidth, this.title, "\u2550[ ", " ]", true);
577
- }
578
-
579
- private renderBottomBorder(innerWidth: number): string {
580
- const counter = `[ ${this.cursor + 1}/${this.flat.length} ]`;
581
- const fillCount = Math.max(0, innerWidth - counter.length);
582
- const content = `${"\u2550".repeat(fillCount)}${counter}`;
583
- return this.wrap("\u255a", this.theme.fg("border", content), "\u255d");
584
- }
585
-
586
- private renderSectionDivider(title: string, innerWidth: number): string {
587
- return this.renderFilledLine("\u255f", "\u2562", "\u2500", innerWidth, title, "\u2500 ", " \u2500");
588
- }
589
-
590
- private renderSeparator(innerWidth: number): string {
591
- return this.renderFilledLine("\u255f", "\u2562", "\u2500", innerWidth);
592
- }
593
-
594
- private renderBlankRow(innerWidth: number): string {
595
- return this.wrap("\u2551", " ".repeat(innerWidth), "\u2551");
596
- }
597
-
598
- /** Pads/truncates an already-styled content string to exactly `innerWidth` and wraps it in border chars. */
599
- private renderContentRow(content: string, innerWidth: number): string {
600
- const truncated = visibleWidth(content) > innerWidth;
601
- const shown = truncated ? closeTruncatedHyperlink(truncateToWidth(content, innerWidth)) : content;
602
- const pad = Math.max(0, innerWidth - visibleWidth(shown));
603
- return this.wrap("\u2551", `${shown}${" ".repeat(pad)}`, "\u2551");
604
- }
605
-
606
- private renderHintsRow(innerWidth: number): string {
607
- if (this.editing) {
608
- const item = this.flat[this.cursor]!.item;
609
- const spec = item.numberEntry!;
610
- const unit = spec.unit ? ` ${spec.unit}` : "";
611
- const error = this.editing.invalid ? `${this.theme.fg("error", `✗ Enter a whole number from ${spec.min} to ${spec.max}${unit}`)} ` : "";
612
- return this.renderContentRow(` ${error}${this.theme.fg("dim", EDIT_HINTS.join(" "))}`, innerWidth);
613
- }
614
- const plain = ` ${this.hints.join(" ")}`;
615
- return this.renderContentRow(this.theme.fg("dim", plain), innerWidth);
616
- }
617
-
618
- private renderScrollStatus(hasAbove: boolean, hasBelow: boolean, innerWidth: number): string {
619
- const parts = [hasAbove ? "↑ more above" : "", hasBelow ? "↓ more below" : ""].filter(Boolean);
620
- return this.renderContentRow(this.theme.fg("dim", ` ${parts.join(" ")}`), innerWidth);
621
- }
622
-
623
- private renderItemRow(item: MenuItem, selected: boolean, innerWidth: number): string {
624
- const th = this.theme;
625
- const value = this.values[item.id]!;
626
- const marker = selected ? "\u276f" : " ";
627
- const markerColored = selected ? th.fg("accent", marker) : marker;
628
-
629
- if (item.reorderGroup) {
630
- const held = value as boolean;
631
- const stateWord = held ? "\u2191 \u2193" : "";
632
- const rightPlain = `${stateWord} `;
633
- const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(rightPlain) - 1);
634
- const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
635
- const leftPlain = ` ${marker} [${held ? "\u25a0" : " "}] ${label}`;
636
- const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
637
- const content = ` ${markerColored} [${held ? th.fg("accent", "\u25a0") : th.fg("muted", " ")}] ${th.fg("text", label)}${" ".repeat(gap)}${held ? th.fg("accent", stateWord) : ""} `;
638
- return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551");
639
- }
640
-
641
- if (item.cycleValues) {
642
- const enabled = !this.isDisabled(item) && (item.cycleEnabledBy ? this.values[item.cycleEnabledBy] as boolean : true);
643
- const rawValue = typeof value === "string" ? value : String(value);
644
- const unit = item.numberEntry?.unit ? ` ${item.numberEntry.unit}` : "";
645
- const displayValue = item.numberEntry ? `${rawValue}${unit}` : item.cycleValueLabels?.[rawValue] ?? rawValue;
646
- const editing = selected ? this.editing : undefined;
647
- const stateWord = editing ? `‹ ${editing.buffer}▌${unit} ›` : `‹ ${displayValue} ›`;
648
- const rightPlain = `${stateWord} `;
649
- const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(rightPlain) - 1);
650
- const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
651
- const leftPlain = ` ${marker} [${enabled ? "■" : " "}] ${label}`;
652
- const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(stateWord) - 2);
653
- const state = editing ? th.fg(editing.invalid ? "error" : "accent", stateWord) : enabled ? th.fg("accent", stateWord) : th.fg("muted", stateWord);
654
- const content = ` ${markerColored} [${enabled ? th.fg("success", "■") : th.fg("muted", " ")}] ${th.fg("text", label)}${" ".repeat(gap)}${state} `;
655
- return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551");
656
- }
657
-
658
- const disabled = this.isDisabled(item);
659
- const enabled = disabled || value as boolean;
660
- const box = enabled ? "\u25a0" : " ";
661
- const stateWord = enabled ? "ON" : "OFF";
662
- const rightPlain = `${stateWord} `;
663
- const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(rightPlain) - 1);
664
- const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
665
- const leftPlain = ` ${marker} [${box}] ${label}`;
666
- const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
667
- const labelColor = disabled ? "muted" : "text";
668
- const stateColor = disabled ? "muted" : enabled ? "success" : "muted";
669
- const content = ` ${markerColored} [${th.fg(stateColor, box)}] ${th.fg(labelColor, label)}${" ".repeat(gap)}${th.fg(stateColor, stateWord)} `;
670
- return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551");
671
- }
672
- }
673
-
674
- /**
675
- * Show a modal box-drawing toggle menu and resolve once the user applies
676
- * (Enter) or cancels (Escape / Ctrl+C) it.
677
- *
678
- * Requires TUI mode; in any other mode this resolves immediately with
679
- * `applied: false` and the menu's initial values, doing nothing visible.
680
- */
681
- export async function showMenu<T extends Record<string, MenuValue>>(
682
- ctx: ExtensionCommandContext,
683
- config: MenuConfig,
684
- ): Promise<MenuResult<T>> {
685
- const initialValues = buildInitialValues(config) as T;
686
-
687
- if (ctx.mode !== "tui") {
688
- return { applied: false, values: initialValues };
689
- }
690
-
691
- const maxHeight = config.maxHeight ?? "100%";
692
- return ctx.ui.custom<MenuResult<T>>(
693
- (tui, theme, _keybindings, done) =>
694
- new MenuComponent(config, theme, done as (result: MenuResult<Record<string, MenuValue>>) => void, tui),
695
- { overlay: true, overlayOptions: { width: OVERLAY_WIDTH, maxHeight } },
696
- );
697
- }