@pi-unipi/utility 0.2.5 → 0.2.7
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 +54 -1
- package/package.json +7 -2
- package/src/commands.ts +41 -3
- package/src/diff/highlighter.ts +353 -0
- package/src/diff/parser.ts +191 -0
- package/src/diff/renderer.ts +422 -0
- package/src/diff/settings.ts +199 -0
- package/src/diff/theme.ts +319 -0
- package/src/diff/wrapper.ts +287 -0
- package/src/index.ts +68 -8
- package/src/tui/badge-settings.ts +17 -58
- package/src/tui/util-settings-tui.ts +498 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/utility — Unified Settings TUI Overlay
|
|
3
|
+
*
|
|
4
|
+
* Single TUI overlay with two navigable sections:
|
|
5
|
+
* - Badge: autoGen, badgeEnabled, agentTool, generationModel
|
|
6
|
+
* - Diff Rendering: enabled, theme preset, shikiTheme
|
|
7
|
+
*
|
|
8
|
+
* Replaces badge-settings-tui.ts as the primary settings interface.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Component } from "@mariozechner/pi-tui";
|
|
12
|
+
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
13
|
+
import type { CachedModel } from "@pi-unipi/core";
|
|
14
|
+
import { readModelCache } from "@pi-unipi/core";
|
|
15
|
+
import {
|
|
16
|
+
readUtilSettings,
|
|
17
|
+
writeUtilSettings,
|
|
18
|
+
type UtilSettings,
|
|
19
|
+
type BadgeSettingsSection,
|
|
20
|
+
type DiffSettings,
|
|
21
|
+
} from "../diff/settings.js";
|
|
22
|
+
import { getAllPresets } from "../diff/theme.js";
|
|
23
|
+
|
|
24
|
+
/** ANSI escape codes */
|
|
25
|
+
const ansi = {
|
|
26
|
+
reset: "\x1b[0m",
|
|
27
|
+
bold: "\x1b[1m",
|
|
28
|
+
dim: "\x1b[2m",
|
|
29
|
+
cyan: "\x1b[36m",
|
|
30
|
+
green: "\x1b[32m",
|
|
31
|
+
yellow: "\x1b[33m",
|
|
32
|
+
red: "\x1b[31m",
|
|
33
|
+
gray: "\x1b[90m",
|
|
34
|
+
white: "\x1b[37m",
|
|
35
|
+
blue: "\x1b[34m",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Toggle symbols */
|
|
39
|
+
const TOGGLE_ON = `${ansi.green}●${ansi.reset}`;
|
|
40
|
+
const TOGGLE_OFF = `${ansi.dim}○${ansi.reset}`;
|
|
41
|
+
|
|
42
|
+
/** Active mode */
|
|
43
|
+
type Mode = "settings" | "model-picker" | "theme-picker" | "shiki-picker";
|
|
44
|
+
|
|
45
|
+
/** Setting row types */
|
|
46
|
+
interface BooleanSetting {
|
|
47
|
+
type: "boolean";
|
|
48
|
+
section: "badge" | "diff";
|
|
49
|
+
key: string;
|
|
50
|
+
label: string;
|
|
51
|
+
description: string;
|
|
52
|
+
getValue: (s: UtilSettings) => boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface PickerSetting {
|
|
56
|
+
type: "picker";
|
|
57
|
+
section: "badge" | "diff";
|
|
58
|
+
key: string;
|
|
59
|
+
label: string;
|
|
60
|
+
description: string;
|
|
61
|
+
pickerType: "model" | "theme" | "shiki";
|
|
62
|
+
getValue: (s: UtilSettings) => string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface SectionHeader {
|
|
66
|
+
type: "section";
|
|
67
|
+
label: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type SettingItem = BooleanSetting | PickerSetting | SectionHeader;
|
|
71
|
+
|
|
72
|
+
/** All settings items */
|
|
73
|
+
const SETTINGS: SettingItem[] = [
|
|
74
|
+
// Badge section
|
|
75
|
+
{ type: "section", label: "Badge" },
|
|
76
|
+
{
|
|
77
|
+
type: "boolean",
|
|
78
|
+
section: "badge",
|
|
79
|
+
key: "autoGen",
|
|
80
|
+
label: "Auto generate",
|
|
81
|
+
description: "Generate session name on first user message",
|
|
82
|
+
getValue: (s) => s.badge.autoGen,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
type: "boolean",
|
|
86
|
+
section: "badge",
|
|
87
|
+
key: "badgeEnabled",
|
|
88
|
+
label: "Badge enabled",
|
|
89
|
+
description: "Show the name badge overlay",
|
|
90
|
+
getValue: (s) => s.badge.badgeEnabled,
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
type: "boolean",
|
|
94
|
+
section: "badge",
|
|
95
|
+
key: "agentTool",
|
|
96
|
+
label: "Agent tool",
|
|
97
|
+
description: "Allow agents to call set_session_name",
|
|
98
|
+
getValue: (s) => s.badge.agentTool,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
type: "picker",
|
|
102
|
+
section: "badge",
|
|
103
|
+
key: "generationModel",
|
|
104
|
+
label: "Generation model",
|
|
105
|
+
description: "Model for badge name generation",
|
|
106
|
+
pickerType: "model",
|
|
107
|
+
getValue: (s) => s.badge.generationModel,
|
|
108
|
+
},
|
|
109
|
+
// Diff Rendering section
|
|
110
|
+
{ type: "section", label: "Diff Rendering" },
|
|
111
|
+
{
|
|
112
|
+
type: "boolean",
|
|
113
|
+
section: "diff",
|
|
114
|
+
key: "enabled",
|
|
115
|
+
label: "Enabled",
|
|
116
|
+
description: "Shiki-powered syntax-highlighted diffs",
|
|
117
|
+
getValue: (s) => s.diff.enabled,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
type: "picker",
|
|
121
|
+
section: "diff",
|
|
122
|
+
key: "theme",
|
|
123
|
+
label: "Theme",
|
|
124
|
+
description: "Diff color preset",
|
|
125
|
+
pickerType: "theme",
|
|
126
|
+
getValue: (s) => s.diff.theme,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
type: "picker",
|
|
130
|
+
section: "diff",
|
|
131
|
+
key: "shikiTheme",
|
|
132
|
+
label: "Shiki theme",
|
|
133
|
+
description: "Syntax highlighting grammar",
|
|
134
|
+
pickerType: "shiki",
|
|
135
|
+
getValue: (s) => s.diff.shikiTheme,
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
/** List of known Shiki themes */
|
|
140
|
+
const SHIKI_THEMES = [
|
|
141
|
+
"github-dark",
|
|
142
|
+
"github-light",
|
|
143
|
+
"dracula",
|
|
144
|
+
"one-dark-pro",
|
|
145
|
+
"catppuccin-mocha",
|
|
146
|
+
"catppuccin-latte",
|
|
147
|
+
"nord",
|
|
148
|
+
"tokyo-night",
|
|
149
|
+
"tokyo-night-storm",
|
|
150
|
+
"night-owl",
|
|
151
|
+
"material-theme",
|
|
152
|
+
"material-theme-palenight",
|
|
153
|
+
"monokai",
|
|
154
|
+
"solarized-dark",
|
|
155
|
+
"solarized-light",
|
|
156
|
+
"vitesse-dark",
|
|
157
|
+
"vitesse-light",
|
|
158
|
+
"ayu-dark",
|
|
159
|
+
"ayu-mirage",
|
|
160
|
+
"slack-dark",
|
|
161
|
+
"slack-ochin",
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Unified Settings TUI overlay.
|
|
166
|
+
* Combines badge and diff settings in a single navigable interface.
|
|
167
|
+
*/
|
|
168
|
+
export class UtilSettingsTui implements Component {
|
|
169
|
+
private settings: UtilSettings;
|
|
170
|
+
private mode: Mode = "settings";
|
|
171
|
+
private selectedIndex = 0;
|
|
172
|
+
private scrollOffset = 0;
|
|
173
|
+
private models: CachedModel[] = [];
|
|
174
|
+
|
|
175
|
+
/** Callback when overlay should close */
|
|
176
|
+
onClose?: () => void;
|
|
177
|
+
|
|
178
|
+
/** Callback to request a re-render */
|
|
179
|
+
requestRender?: () => void;
|
|
180
|
+
|
|
181
|
+
constructor() {
|
|
182
|
+
this.settings = readUtilSettings();
|
|
183
|
+
this.models = readModelCache();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Invalidate cached render state.
|
|
188
|
+
*/
|
|
189
|
+
invalidate(): void {
|
|
190
|
+
// No cached state to invalidate
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Handle keyboard input.
|
|
195
|
+
*/
|
|
196
|
+
handleInput(data: string): void {
|
|
197
|
+
switch (this.mode) {
|
|
198
|
+
case "settings":
|
|
199
|
+
this.handleSettingsInput(data);
|
|
200
|
+
break;
|
|
201
|
+
case "model-picker":
|
|
202
|
+
this.handlePickerInput(data, this.getModelList(), "model");
|
|
203
|
+
break;
|
|
204
|
+
case "theme-picker":
|
|
205
|
+
this.handlePickerInput(data, this.getThemeList(), "theme");
|
|
206
|
+
break;
|
|
207
|
+
case "shiki-picker":
|
|
208
|
+
this.handlePickerInput(data, this.getShikiList(), "shiki");
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Handle input in settings mode.
|
|
215
|
+
*/
|
|
216
|
+
private handleSettingsInput(data: string): void {
|
|
217
|
+
// Get navigable items (skip section headers)
|
|
218
|
+
const navItems = SETTINGS.filter((s) => s.type !== "section");
|
|
219
|
+
|
|
220
|
+
switch (data) {
|
|
221
|
+
case "\x1b[A": // Up arrow
|
|
222
|
+
case "k":
|
|
223
|
+
this.selectedIndex = (this.selectedIndex - 1 + navItems.length) % navItems.length;
|
|
224
|
+
break;
|
|
225
|
+
case "\x1b[B": // Down arrow
|
|
226
|
+
case "j":
|
|
227
|
+
this.selectedIndex = (this.selectedIndex + 1) % navItems.length;
|
|
228
|
+
break;
|
|
229
|
+
case " ": // Space — toggle boolean settings
|
|
230
|
+
this.toggleCurrentSetting();
|
|
231
|
+
break;
|
|
232
|
+
case "\r": // Enter — open picker or toggle
|
|
233
|
+
if (navItems[this.selectedIndex]?.type === "picker") {
|
|
234
|
+
this.enterPicker(navItems[this.selectedIndex] as PickerSetting);
|
|
235
|
+
} else {
|
|
236
|
+
this.toggleCurrentSetting();
|
|
237
|
+
}
|
|
238
|
+
break;
|
|
239
|
+
case "\x1b": // Escape — save and close
|
|
240
|
+
this.save();
|
|
241
|
+
this.onClose?.();
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Handle input in picker mode.
|
|
248
|
+
*/
|
|
249
|
+
private handlePickerInput(data: string, items: Array<{ id: string; label: string }>, pickerType: string): void {
|
|
250
|
+
switch (data) {
|
|
251
|
+
case "\x1b[A": // Up arrow
|
|
252
|
+
case "k":
|
|
253
|
+
this.scrollOffset = (this.scrollOffset - 1 + items.length) % items.length;
|
|
254
|
+
break;
|
|
255
|
+
case "\x1b[B": // Down arrow
|
|
256
|
+
case "j":
|
|
257
|
+
this.scrollOffset = (this.scrollOffset + 1) % items.length;
|
|
258
|
+
break;
|
|
259
|
+
case "\r": // Enter — select
|
|
260
|
+
this.selectPickerItem(items[this.scrollOffset], pickerType);
|
|
261
|
+
break;
|
|
262
|
+
case "\x1b": // Escape — cancel
|
|
263
|
+
this.mode = "settings";
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Toggle the currently selected boolean setting.
|
|
270
|
+
*/
|
|
271
|
+
private toggleCurrentSetting(): void {
|
|
272
|
+
const navItems = SETTINGS.filter((s) => s.type !== "section");
|
|
273
|
+
const item = navItems[this.selectedIndex];
|
|
274
|
+
if (!item || item.type !== "boolean") return;
|
|
275
|
+
|
|
276
|
+
const current = item.getValue(this.settings);
|
|
277
|
+
if (item.section === "badge") {
|
|
278
|
+
(this.settings.badge as any)[item.key] = !current;
|
|
279
|
+
} else {
|
|
280
|
+
(this.settings.diff as any)[item.key] = !current;
|
|
281
|
+
}
|
|
282
|
+
this.save();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Enter picker mode for a setting.
|
|
287
|
+
*/
|
|
288
|
+
private enterPicker(item: PickerSetting): void {
|
|
289
|
+
switch (item.pickerType) {
|
|
290
|
+
case "model":
|
|
291
|
+
this.mode = "model-picker";
|
|
292
|
+
this.scrollOffset = this.getModelList().findIndex((m) => m.id === this.settings.badge.generationModel);
|
|
293
|
+
if (this.scrollOffset < 0) this.scrollOffset = 0;
|
|
294
|
+
break;
|
|
295
|
+
case "theme":
|
|
296
|
+
this.mode = "theme-picker";
|
|
297
|
+
this.scrollOffset = getAllPresets().findIndex((p) => p.name === this.settings.diff.theme);
|
|
298
|
+
if (this.scrollOffset < 0) this.scrollOffset = 0;
|
|
299
|
+
break;
|
|
300
|
+
case "shiki":
|
|
301
|
+
this.mode = "shiki-picker";
|
|
302
|
+
this.scrollOffset = SHIKI_THEMES.indexOf(this.settings.diff.shikiTheme);
|
|
303
|
+
if (this.scrollOffset < 0) this.scrollOffset = 0;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Select an item in a picker.
|
|
310
|
+
*/
|
|
311
|
+
private selectPickerItem(item: { id: string; label: string }, pickerType: string): void {
|
|
312
|
+
switch (pickerType) {
|
|
313
|
+
case "model":
|
|
314
|
+
this.settings.badge.generationModel = item.id;
|
|
315
|
+
break;
|
|
316
|
+
case "theme":
|
|
317
|
+
this.settings.diff.theme = item.id;
|
|
318
|
+
break;
|
|
319
|
+
case "shiki":
|
|
320
|
+
this.settings.diff.shikiTheme = item.id;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
this.mode = "settings";
|
|
324
|
+
this.save();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Get model list with "inherit" as first entry.
|
|
329
|
+
*/
|
|
330
|
+
private getModelList(): Array<{ id: string; label: string }> {
|
|
331
|
+
const list: Array<{ id: string; label: string }> = [
|
|
332
|
+
{ id: "inherit", label: "inherit (use parent model)" },
|
|
333
|
+
];
|
|
334
|
+
for (const m of this.models) {
|
|
335
|
+
const fullId = `${m.provider}/${m.id}`;
|
|
336
|
+
list.push({
|
|
337
|
+
id: fullId,
|
|
338
|
+
label: m.name ? `${fullId} (${m.name})` : fullId,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return list;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Get diff theme preset list.
|
|
346
|
+
*/
|
|
347
|
+
private getThemeList(): Array<{ id: string; label: string }> {
|
|
348
|
+
return getAllPresets().map((p) => ({
|
|
349
|
+
id: p.name,
|
|
350
|
+
label: `${p.name} — ${p.description}`,
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Get Shiki theme list.
|
|
356
|
+
*/
|
|
357
|
+
private getShikiList(): Array<{ id: string; label: string }> {
|
|
358
|
+
return SHIKI_THEMES.map((t) => ({ id: t, label: t }));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Save settings to disk.
|
|
363
|
+
*/
|
|
364
|
+
private save(): void {
|
|
365
|
+
writeUtilSettings(this.settings);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Render the overlay.
|
|
370
|
+
*/
|
|
371
|
+
render(width: number): string[] {
|
|
372
|
+
const lines: string[] = [];
|
|
373
|
+
const innerWidth = Math.max(52, width - 2);
|
|
374
|
+
|
|
375
|
+
const padVisible = (content: string, targetWidth: number): string => {
|
|
376
|
+
const vw = visibleWidth(content);
|
|
377
|
+
const pad = Math.max(0, targetWidth - vw);
|
|
378
|
+
return content + " ".repeat(pad);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const add = (s: string) =>
|
|
382
|
+
lines.push(
|
|
383
|
+
`${ansi.cyan}│${ansi.reset}` +
|
|
384
|
+
padVisible(truncateToWidth(s, innerWidth), innerWidth) +
|
|
385
|
+
`${ansi.cyan}│${ansi.reset}`,
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
const addEmpty = () =>
|
|
389
|
+
lines.push(
|
|
390
|
+
`${ansi.cyan}│${ansi.reset}` +
|
|
391
|
+
" ".repeat(innerWidth) +
|
|
392
|
+
`${ansi.cyan}│${ansi.reset}`,
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
// Top border
|
|
396
|
+
lines.push(`${ansi.cyan}╭${"─".repeat(innerWidth)}╮${ansi.reset}`);
|
|
397
|
+
|
|
398
|
+
// Header
|
|
399
|
+
add(`${ansi.bold}${ansi.cyan}⚙ Utility Settings${ansi.reset}`);
|
|
400
|
+
add(`${ansi.dim}Configure badge and diff rendering${ansi.reset}`);
|
|
401
|
+
addEmpty();
|
|
402
|
+
|
|
403
|
+
// Settings list
|
|
404
|
+
const navItems = SETTINGS.filter((s) => s.type !== "section");
|
|
405
|
+
let navIndex = 0;
|
|
406
|
+
|
|
407
|
+
for (const item of SETTINGS) {
|
|
408
|
+
if (item.type === "section") {
|
|
409
|
+
addEmpty();
|
|
410
|
+
add(`${ansi.bold}${ansi.blue}── ${item.label} ──${ansi.reset}`);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const isSelected = navIndex === this.selectedIndex && this.mode === "settings";
|
|
415
|
+
const selector = isSelected ? `${ansi.cyan}▸${ansi.reset}` : " ";
|
|
416
|
+
const labelColor = isSelected ? ansi.bold : ansi.dim;
|
|
417
|
+
|
|
418
|
+
if (item.type === "boolean") {
|
|
419
|
+
const value = item.getValue(this.settings);
|
|
420
|
+
const toggle = value ? TOGGLE_ON : TOGGLE_OFF;
|
|
421
|
+
add(`${selector} ${toggle} ${labelColor}${item.label}${ansi.reset}`);
|
|
422
|
+
add(` ${ansi.gray}${item.description}${ansi.reset}`);
|
|
423
|
+
} else if (item.type === "picker") {
|
|
424
|
+
const value = item.getValue(this.settings);
|
|
425
|
+
const icon = item.pickerType === "model" ? "⚙" : "🎨";
|
|
426
|
+
add(
|
|
427
|
+
`${selector} ${ansi.yellow}${icon}${ansi.reset} ${labelColor}${item.label}${ansi.reset}: ${ansi.white}${value}${ansi.reset}`,
|
|
428
|
+
);
|
|
429
|
+
add(` ${ansi.gray}${item.description}${ansi.reset}`);
|
|
430
|
+
if (isSelected) {
|
|
431
|
+
add(` ${ansi.dim}Enter to select${ansi.reset}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
navIndex++;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Picker overlay (inline)
|
|
439
|
+
if (this.mode !== "settings") {
|
|
440
|
+
addEmpty();
|
|
441
|
+
let items: Array<{ id: string; label: string }> = [];
|
|
442
|
+
let title = "";
|
|
443
|
+
|
|
444
|
+
switch (this.mode) {
|
|
445
|
+
case "model-picker":
|
|
446
|
+
items = this.getModelList();
|
|
447
|
+
title = "Available Models";
|
|
448
|
+
break;
|
|
449
|
+
case "theme-picker":
|
|
450
|
+
items = this.getThemeList();
|
|
451
|
+
title = "Diff Theme Presets";
|
|
452
|
+
break;
|
|
453
|
+
case "shiki-picker":
|
|
454
|
+
items = this.getShikiList();
|
|
455
|
+
title = "Shiki Themes";
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
add(`${ansi.bold}${ansi.cyan}── ${title} ──${ansi.reset}`);
|
|
460
|
+
|
|
461
|
+
const visibleLines = 10;
|
|
462
|
+
const start = Math.max(0, Math.min(this.scrollOffset, items.length - visibleLines));
|
|
463
|
+
const end = Math.min(start + visibleLines, items.length);
|
|
464
|
+
|
|
465
|
+
if (start > 0) {
|
|
466
|
+
add(` ${ansi.dim}▲ ${start} more above${ansi.reset}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
for (let i = start; i < end; i++) {
|
|
470
|
+
const m = items[i];
|
|
471
|
+
const isItemSelected = i === this.scrollOffset;
|
|
472
|
+
const itemSelector = isItemSelected ? `${ansi.cyan}▸${ansi.reset}` : " ";
|
|
473
|
+
const itemLabelColor = isItemSelected ? ansi.bold + ansi.white : ansi.dim;
|
|
474
|
+
|
|
475
|
+
add(`${itemSelector} ${itemLabelColor}${m.label}${ansi.reset}`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (end < items.length) {
|
|
479
|
+
add(` ${ansi.dim}▼ ${items.length - end} more below${ansi.reset}`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Footer
|
|
484
|
+
addEmpty();
|
|
485
|
+
|
|
486
|
+
if (this.mode === "settings") {
|
|
487
|
+
add(`${ansi.dim}↑↓ navigate • Space toggle • Enter select • Esc save+close${ansi.reset}`);
|
|
488
|
+
add(`${ansi.dim}Config: .unipi/config/util-settings.json${ansi.reset}`);
|
|
489
|
+
} else {
|
|
490
|
+
add(`${ansi.dim}↑↓ navigate • Enter select • Esc cancel${ansi.reset}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Bottom border
|
|
494
|
+
lines.push(`${ansi.cyan}╰${"─".repeat(innerWidth)}╯${ansi.reset}`);
|
|
495
|
+
|
|
496
|
+
return lines;
|
|
497
|
+
}
|
|
498
|
+
}
|