@aliou/pi-utils-settings 0.3.0 → 0.5.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/components/fuzzy-selector.ts +2 -0
- package/components/sectioned-settings.ts +7 -1
- package/components/wizard.ts +254 -0
- package/index.ts +7 -0
- package/package.json +7 -1
- package/settings-command.ts +76 -11
- package/skills/pi-utils-settings/SKILL.md +290 -0
- package/skills/pi-utils-settings/references/example-extension/commands/settings.ts +268 -0
- package/skills/pi-utils-settings/references/example-extension/commands/setup.ts +276 -0
- package/skills/pi-utils-settings/references/example-extension/config.ts +97 -0
- package/skills/pi-utils-settings/references/example-extension/index.ts +36 -0
|
@@ -165,6 +165,8 @@ export class FuzzySelector implements Component {
|
|
|
165
165
|
this.selectedIndex === this.filteredItems.length - 1
|
|
166
166
|
? 0
|
|
167
167
|
: this.selectedIndex + 1;
|
|
168
|
+
} else if (matchesKey(data, Key.enter)) {
|
|
169
|
+
this.selectCurrent();
|
|
168
170
|
} else if (matchesKey(data, Key.escape)) {
|
|
169
171
|
this.onDone();
|
|
170
172
|
} else {
|
|
@@ -27,6 +27,8 @@ export interface SectionedSettingsOptions {
|
|
|
27
27
|
enableSearch?: boolean;
|
|
28
28
|
/** Extra text appended to the hint line (e.g. "Ctrl+S to save"). */
|
|
29
29
|
hintSuffix?: string;
|
|
30
|
+
/** Hide the built-in hint line (when the parent renders its own controls). */
|
|
31
|
+
hideHint?: boolean;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
interface FlatEntry {
|
|
@@ -47,6 +49,7 @@ export class SectionedSettings implements Component {
|
|
|
47
49
|
private searchInput?: Input;
|
|
48
50
|
private searchEnabled: boolean;
|
|
49
51
|
private hintSuffix: string;
|
|
52
|
+
private hideHint: boolean;
|
|
50
53
|
private submenuComponent: Component | null = null;
|
|
51
54
|
private submenuItemIndex: number | null = null;
|
|
52
55
|
|
|
@@ -65,6 +68,7 @@ export class SectionedSettings implements Component {
|
|
|
65
68
|
this.onCancel = onCancel;
|
|
66
69
|
this.searchEnabled = options.enableSearch ?? false;
|
|
67
70
|
this.hintSuffix = options.hintSuffix ?? "";
|
|
71
|
+
this.hideHint = options.hideHint ?? false;
|
|
68
72
|
this.selectedIndex = 0;
|
|
69
73
|
|
|
70
74
|
if (this.searchEnabled) {
|
|
@@ -259,7 +263,9 @@ export class SectionedSettings implements Component {
|
|
|
259
263
|
}
|
|
260
264
|
}
|
|
261
265
|
|
|
262
|
-
this.
|
|
266
|
+
if (!this.hideHint) {
|
|
267
|
+
this.addHintLine(lines);
|
|
268
|
+
}
|
|
263
269
|
return lines;
|
|
264
270
|
}
|
|
265
271
|
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A multi-step wizard component with tabbed navigation and bordered frame.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Box-drawing border around the entire wizard (╭╮╰╯ style)
|
|
6
|
+
* - Step tabs at the top with progress indicators (●/○)
|
|
7
|
+
* - Tab/Shift+Tab navigation between steps
|
|
8
|
+
* - Each step renders its own inner Component
|
|
9
|
+
* - Ctrl+S or Enter on the last step submits
|
|
10
|
+
* - Esc cancels the entire wizard
|
|
11
|
+
*
|
|
12
|
+
* Use for first-time setup flows, multi-step configuration, or any
|
|
13
|
+
* sequential data collection that benefits from back-and-forth navigation.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
17
|
+
import type { Component } from "@mariozechner/pi-tui";
|
|
18
|
+
import {
|
|
19
|
+
Key,
|
|
20
|
+
matchesKey,
|
|
21
|
+
truncateToWidth,
|
|
22
|
+
visibleWidth,
|
|
23
|
+
} from "@mariozechner/pi-tui";
|
|
24
|
+
|
|
25
|
+
export interface WizardStep {
|
|
26
|
+
/** Tab label shown at the top. Keep it short (1-2 words). */
|
|
27
|
+
label: string;
|
|
28
|
+
/**
|
|
29
|
+
* Build the inner component for this step.
|
|
30
|
+
*
|
|
31
|
+
* The component controls its own rendering and input handling.
|
|
32
|
+
* Call `markComplete()` to mark the step as done (shows ● in tabs).
|
|
33
|
+
* Call `markIncomplete()` to revert it (shows ○).
|
|
34
|
+
*
|
|
35
|
+
* The wizard handles borders, tabs, and step navigation externally.
|
|
36
|
+
* The inner component should NOT handle Tab/Shift+Tab/Esc/Ctrl+S.
|
|
37
|
+
*/
|
|
38
|
+
build: (ctx: WizardStepContext) => Component;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WizardStepContext {
|
|
42
|
+
/** Mark this step as complete (filled ● in progress). */
|
|
43
|
+
markComplete: () => void;
|
|
44
|
+
/** Mark this step as incomplete (empty ○ in progress). */
|
|
45
|
+
markIncomplete: () => void;
|
|
46
|
+
/** Advance to the next step (wraps around). */
|
|
47
|
+
goNext: () => void;
|
|
48
|
+
/** Go back to the previous step (wraps around). */
|
|
49
|
+
goPrev: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface WizardOptions {
|
|
53
|
+
/** Title shown at the top of the wizard. */
|
|
54
|
+
title: string;
|
|
55
|
+
/** Ordered list of steps. */
|
|
56
|
+
steps: WizardStep[];
|
|
57
|
+
/** Theme for rendering borders and chrome. */
|
|
58
|
+
theme: Theme;
|
|
59
|
+
/** Called when user submits (Ctrl+S). */
|
|
60
|
+
onComplete: () => void;
|
|
61
|
+
/** Called when user cancels (Esc). */
|
|
62
|
+
onCancel: () => void;
|
|
63
|
+
/** Hint text appended to the controls line. */
|
|
64
|
+
hintSuffix?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class Wizard implements Component {
|
|
68
|
+
private steps: WizardStep[];
|
|
69
|
+
private theme: Theme;
|
|
70
|
+
private onComplete: () => void;
|
|
71
|
+
private onCancel: () => void;
|
|
72
|
+
private title: string;
|
|
73
|
+
private hintSuffix: string;
|
|
74
|
+
|
|
75
|
+
private activeIndex = 0;
|
|
76
|
+
private completed: boolean[];
|
|
77
|
+
private components: Component[];
|
|
78
|
+
|
|
79
|
+
constructor(options: WizardOptions) {
|
|
80
|
+
this.steps = options.steps;
|
|
81
|
+
this.theme = options.theme;
|
|
82
|
+
this.onComplete = options.onComplete;
|
|
83
|
+
this.onCancel = options.onCancel;
|
|
84
|
+
this.title = options.title;
|
|
85
|
+
this.hintSuffix = options.hintSuffix ?? "";
|
|
86
|
+
|
|
87
|
+
this.completed = new Array(options.steps.length).fill(false) as boolean[];
|
|
88
|
+
this.components = options.steps.map((step, i) =>
|
|
89
|
+
step.build({
|
|
90
|
+
markComplete: () => {
|
|
91
|
+
this.completed[i] = true;
|
|
92
|
+
},
|
|
93
|
+
markIncomplete: () => {
|
|
94
|
+
this.completed[i] = false;
|
|
95
|
+
},
|
|
96
|
+
goNext: () => {
|
|
97
|
+
this.activeIndex = (this.activeIndex + 1) % this.steps.length;
|
|
98
|
+
},
|
|
99
|
+
goPrev: () => {
|
|
100
|
+
this.activeIndex =
|
|
101
|
+
(this.activeIndex - 1 + this.steps.length) % this.steps.length;
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Returns the index of the currently active step. */
|
|
108
|
+
getActiveIndex(): number {
|
|
109
|
+
return this.activeIndex;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Returns whether all steps are marked complete. */
|
|
113
|
+
isAllComplete(): boolean {
|
|
114
|
+
return this.completed.every(Boolean);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
invalidate(): void {
|
|
118
|
+
for (const component of this.components) {
|
|
119
|
+
component.invalidate?.();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
render(width: number): string[] {
|
|
124
|
+
const lines: string[] = [];
|
|
125
|
+
const t = this.theme;
|
|
126
|
+
const contentWidth = Math.max(1, width - 2);
|
|
127
|
+
|
|
128
|
+
// --- Top border with title ---
|
|
129
|
+
const titleText = ` ${this.title} `;
|
|
130
|
+
const titleLen = visibleWidth(titleText);
|
|
131
|
+
const topRuleLen = Math.max(1, width - titleLen - 3);
|
|
132
|
+
lines.push(
|
|
133
|
+
t.fg("border", "╭─") +
|
|
134
|
+
t.fg("accent", t.bold(titleText)) +
|
|
135
|
+
t.fg("border", "─".repeat(topRuleLen)) +
|
|
136
|
+
t.fg("border", "╮"),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// --- Step tabs with progress ---
|
|
140
|
+
const tabLine = this.renderStepTabs(contentWidth);
|
|
141
|
+
lines.push(this.padLine(tabLine, contentWidth));
|
|
142
|
+
lines.push(this.padLine("", contentWidth));
|
|
143
|
+
|
|
144
|
+
// --- Inner component ---
|
|
145
|
+
const innerComponent = this.components[this.activeIndex];
|
|
146
|
+
if (innerComponent) {
|
|
147
|
+
const innerLines = innerComponent.render(contentWidth);
|
|
148
|
+
for (const line of innerLines) {
|
|
149
|
+
lines.push(this.padLine(line, contentWidth));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- Separator ---
|
|
154
|
+
lines.push(
|
|
155
|
+
t.fg("border", "├") +
|
|
156
|
+
t.fg("border", "─".repeat(contentWidth)) +
|
|
157
|
+
t.fg("border", "┤"),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// --- Controls ---
|
|
161
|
+
const controlsText = this.buildControlsText();
|
|
162
|
+
lines.push(this.padLine(controlsText, contentWidth));
|
|
163
|
+
|
|
164
|
+
// --- Bottom border ---
|
|
165
|
+
lines.push(
|
|
166
|
+
t.fg("border", "╰") +
|
|
167
|
+
t.fg("border", "─".repeat(contentWidth)) +
|
|
168
|
+
t.fg("border", "╯"),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
return lines;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
handleInput(data: string): void {
|
|
175
|
+
// Ctrl+S: submit if all steps complete
|
|
176
|
+
if (matchesKey(data, Key.ctrl("s"))) {
|
|
177
|
+
this.onComplete();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Esc: cancel
|
|
182
|
+
if (matchesKey(data, Key.escape)) {
|
|
183
|
+
this.onCancel();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Tab / Shift+Tab: navigate steps
|
|
188
|
+
if (matchesKey(data, Key.tab)) {
|
|
189
|
+
this.activeIndex = (this.activeIndex + 1) % this.steps.length;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (matchesKey(data, Key.shift("tab"))) {
|
|
193
|
+
this.activeIndex =
|
|
194
|
+
(this.activeIndex - 1 + this.steps.length) % this.steps.length;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Delegate to inner component
|
|
199
|
+
const innerComponent = this.components[this.activeIndex];
|
|
200
|
+
innerComponent?.handleInput?.(data);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// --- Private rendering helpers ---
|
|
204
|
+
|
|
205
|
+
private renderStepTabs(_contentWidth: number): string {
|
|
206
|
+
const t = this.theme;
|
|
207
|
+
const parts: string[] = [];
|
|
208
|
+
|
|
209
|
+
for (let i = 0; i < this.steps.length; i++) {
|
|
210
|
+
const step = this.steps[i];
|
|
211
|
+
if (!step) continue;
|
|
212
|
+
|
|
213
|
+
const dot = this.completed[i]
|
|
214
|
+
? t.fg("success", "●")
|
|
215
|
+
: i === this.activeIndex
|
|
216
|
+
? t.fg("accent", "●")
|
|
217
|
+
: t.fg("dim", "○");
|
|
218
|
+
|
|
219
|
+
const label =
|
|
220
|
+
i === this.activeIndex
|
|
221
|
+
? t.bg("selectedBg", t.fg("accent", ` ${step.label} `))
|
|
222
|
+
: t.fg("dim", ` ${step.label} `);
|
|
223
|
+
|
|
224
|
+
parts.push(`${dot} ${label}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return parts.join(" ");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private padLine(content: string, contentWidth: number): string {
|
|
231
|
+
const t = this.theme;
|
|
232
|
+
const len = visibleWidth(content);
|
|
233
|
+
const padding = Math.max(0, contentWidth - len);
|
|
234
|
+
return (
|
|
235
|
+
t.fg("border", "│") +
|
|
236
|
+
truncateToWidth(content, contentWidth) +
|
|
237
|
+
" ".repeat(padding) +
|
|
238
|
+
t.fg("border", "│")
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private buildControlsText(): string {
|
|
243
|
+
const t = this.theme;
|
|
244
|
+
const parts: string[] = [
|
|
245
|
+
"Tab/Shift+Tab navigate",
|
|
246
|
+
"Ctrl+S submit",
|
|
247
|
+
"Esc cancel",
|
|
248
|
+
];
|
|
249
|
+
if (this.hintSuffix) {
|
|
250
|
+
parts.push(this.hintSuffix);
|
|
251
|
+
}
|
|
252
|
+
return t.fg("dim", ` ${parts.join(" · ")}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
package/index.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Shared settings infrastructure for pi extensions:
|
|
5
5
|
* - ConfigLoader: load/save/merge JSON configs from global + project paths
|
|
6
6
|
* - registerSettingsCommand: create a settings command with Local/Global tabs
|
|
7
|
+
* - Wizard: multi-step wizard component with tabbed navigation and borders
|
|
7
8
|
* - SectionedSettings: sectioned settings list component
|
|
8
9
|
* - ArrayEditor: string array editor submenu component
|
|
9
10
|
* - Helpers: nested value access, display-to-storage value mapping
|
|
@@ -26,6 +27,12 @@ export {
|
|
|
26
27
|
type SectionedSettingsOptions,
|
|
27
28
|
type SettingsSection,
|
|
28
29
|
} from "./components/sectioned-settings";
|
|
30
|
+
export {
|
|
31
|
+
Wizard,
|
|
32
|
+
type WizardOptions,
|
|
33
|
+
type WizardStep,
|
|
34
|
+
type WizardStepContext,
|
|
35
|
+
} from "./components/wizard";
|
|
29
36
|
export {
|
|
30
37
|
ConfigLoader,
|
|
31
38
|
type ConfigStore,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aliou/pi-utils-settings",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,8 +19,14 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"*.ts",
|
|
21
21
|
"components",
|
|
22
|
+
"skills",
|
|
22
23
|
"README.md"
|
|
23
24
|
],
|
|
25
|
+
"pi": {
|
|
26
|
+
"skills": [
|
|
27
|
+
"skills"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
24
30
|
"peerDependencies": {
|
|
25
31
|
"@mariozechner/pi-coding-agent": ">=0.51.0"
|
|
26
32
|
}
|
package/settings-command.ts
CHANGED
|
@@ -5,9 +5,17 @@
|
|
|
5
5
|
* Changes are tracked in memory. Ctrl+S saves, Esc exits without saving.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
ExtensionAPI,
|
|
10
|
+
ExtensionCommandContext,
|
|
11
|
+
} from "@mariozechner/pi-coding-agent";
|
|
9
12
|
import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
|
|
10
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
Key,
|
|
15
|
+
matchesKey,
|
|
16
|
+
truncateToWidth,
|
|
17
|
+
visibleWidth,
|
|
18
|
+
} from "@mariozechner/pi-tui";
|
|
11
19
|
import {
|
|
12
20
|
SectionedSettings,
|
|
13
21
|
type SettingsSection,
|
|
@@ -76,7 +84,7 @@ export interface SettingsCommandOptions<
|
|
|
76
84
|
* Called after save succeeds. Use this to reload runtime state
|
|
77
85
|
* that was captured at extension init time.
|
|
78
86
|
*/
|
|
79
|
-
onSave?: () => void | Promise<void>;
|
|
87
|
+
onSave?: (ctx: ExtensionCommandContext) => void | Promise<void>;
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
function defaultChangeHandler<TConfig extends object>(
|
|
@@ -197,7 +205,7 @@ export function registerSettingsCommand<
|
|
|
197
205
|
handleChange(scope, id, newValue);
|
|
198
206
|
},
|
|
199
207
|
() => done(undefined),
|
|
200
|
-
{ enableSearch: true,
|
|
208
|
+
{ enableSearch: true, hideHint: true },
|
|
201
209
|
);
|
|
202
210
|
}
|
|
203
211
|
|
|
@@ -256,7 +264,7 @@ export function registerSettingsCommand<
|
|
|
256
264
|
|
|
257
265
|
if (saved) {
|
|
258
266
|
ctx.ui.notify(`${extensionLabel}: saved`, "info");
|
|
259
|
-
if (onSave) await onSave();
|
|
267
|
+
if (onSave) await onSave(ctx);
|
|
260
268
|
// Rebuild with fresh data.
|
|
261
269
|
settings = buildSettingsComponent(activeScope);
|
|
262
270
|
}
|
|
@@ -266,10 +274,10 @@ export function registerSettingsCommand<
|
|
|
266
274
|
|
|
267
275
|
// --- Tab rendering ---
|
|
268
276
|
|
|
269
|
-
function renderTabs(): string
|
|
277
|
+
function renderTabs(_contentWidth: number): string {
|
|
270
278
|
// Single scope = no tabs needed
|
|
271
279
|
if (enabledScopes.length === 1) {
|
|
272
|
-
return
|
|
280
|
+
return "";
|
|
273
281
|
}
|
|
274
282
|
|
|
275
283
|
const tabLabels = enabledScopes.map((scope) => {
|
|
@@ -283,7 +291,18 @@ export function registerSettingsCommand<
|
|
|
283
291
|
return theme.fg("dim", fullLabel);
|
|
284
292
|
});
|
|
285
293
|
|
|
286
|
-
return
|
|
294
|
+
return tabLabels.join(" ");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function padLine(content: string, contentWidth: number): string {
|
|
298
|
+
const len = visibleWidth(content);
|
|
299
|
+
const padding = Math.max(0, contentWidth - len);
|
|
300
|
+
return (
|
|
301
|
+
theme.fg("border", "│") +
|
|
302
|
+
truncateToWidth(content, contentWidth) +
|
|
303
|
+
" ".repeat(padding) +
|
|
304
|
+
theme.fg("border", "│")
|
|
305
|
+
);
|
|
287
306
|
}
|
|
288
307
|
|
|
289
308
|
function handleTabSwitch(data: string): boolean {
|
|
@@ -311,9 +330,55 @@ export function registerSettingsCommand<
|
|
|
311
330
|
return {
|
|
312
331
|
render(width: number) {
|
|
313
332
|
const lines: string[] = [];
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
333
|
+
const contentWidth = Math.max(1, width - 2);
|
|
334
|
+
|
|
335
|
+
// Top border with title
|
|
336
|
+
const titleText = ` ${title} `;
|
|
337
|
+
const titleLen = visibleWidth(titleText);
|
|
338
|
+
const topRuleLen = Math.max(1, width - titleLen - 3);
|
|
339
|
+
lines.push(
|
|
340
|
+
theme.fg("border", "╭─") +
|
|
341
|
+
theme.fg("accent", theme.bold(titleText)) +
|
|
342
|
+
theme.fg("border", "─".repeat(topRuleLen)) +
|
|
343
|
+
theme.fg("border", "╮"),
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
// Scope tabs
|
|
347
|
+
const tabs = renderTabs(contentWidth);
|
|
348
|
+
if (tabs) {
|
|
349
|
+
lines.push(padLine(tabs, contentWidth));
|
|
350
|
+
}
|
|
351
|
+
lines.push(padLine("", contentWidth));
|
|
352
|
+
|
|
353
|
+
// Settings content
|
|
354
|
+
const innerLines = settings?.render(contentWidth) ?? [];
|
|
355
|
+
for (const line of innerLines) {
|
|
356
|
+
lines.push(padLine(line, contentWidth));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Separator
|
|
360
|
+
lines.push(
|
|
361
|
+
theme.fg("border", "├") +
|
|
362
|
+
theme.fg("border", "─".repeat(contentWidth)) +
|
|
363
|
+
theme.fg("border", "┤"),
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
// Controls
|
|
367
|
+
const parts = ["Enter/Space change"];
|
|
368
|
+
if (enabledScopes.length > 1) {
|
|
369
|
+
parts.push("Tab/Shift+Tab scope");
|
|
370
|
+
}
|
|
371
|
+
parts.push("Ctrl+S save", "Esc close");
|
|
372
|
+
const controlsText = theme.fg("dim", ` ${parts.join(" · ")}`);
|
|
373
|
+
lines.push(padLine(controlsText, contentWidth));
|
|
374
|
+
|
|
375
|
+
// Bottom border
|
|
376
|
+
lines.push(
|
|
377
|
+
theme.fg("border", "╰") +
|
|
378
|
+
theme.fg("border", "─".repeat(contentWidth)) +
|
|
379
|
+
theme.fg("border", "╯"),
|
|
380
|
+
);
|
|
381
|
+
|
|
317
382
|
return lines;
|
|
318
383
|
},
|
|
319
384
|
invalidate() {
|