@xynogen/pix-pretty 1.7.1 → 1.7.2
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/package.json +3 -2
- package/src/confirm.ts +50 -49
- package/src/gate-overlay.ts +99 -75
- package/src/modal-frame.ts +115 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-pretty",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.2",
|
|
4
4
|
"description": "Enhanced tool output rendering with syntax highlighting, file icons, tree views, FFF search, and paste chip formatting",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"./utils": "./src/utils.ts",
|
|
22
22
|
"./resize": "./src/resize.ts",
|
|
23
23
|
"./context": "./src/tools/context.ts",
|
|
24
|
-
"./gate-overlay": "./src/gate-overlay.ts"
|
|
24
|
+
"./gate-overlay": "./src/gate-overlay.ts",
|
|
25
|
+
"./modal-frame": "./src/modal-frame.ts"
|
|
25
26
|
},
|
|
26
27
|
"scripts": {
|
|
27
28
|
"test": "bun test"
|
package/src/confirm.ts
CHANGED
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pix-pretty/confirm — reusable Yes/No confirmation overlay.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* so any extension can get the same look without re-implementing it.
|
|
7
|
-
*
|
|
8
|
-
* Returns true on confirm, false on deny / cancel / timeout.
|
|
4
|
+
* Rounded modal frame (╭─╮╰─╯), solid bg, accent border — same visual style
|
|
5
|
+
* as gate-overlay and pix-ask. Returns true on confirm, false on deny/timeout.
|
|
9
6
|
*/
|
|
10
7
|
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
8
|
+
import { type SelectItem, SelectList } from "@earendil-works/pi-tui";
|
|
9
|
+
import { frameLines, modalWidth, selectListTheme } from "./modal-frame.js";
|
|
13
10
|
|
|
14
|
-
// Minimal structural type for the `ctx.ui.custom` host call.
|
|
15
|
-
// component has no hard dependency on a specific Pi context shape.
|
|
11
|
+
// Minimal structural type for the `ctx.ui.custom` host call.
|
|
16
12
|
interface CustomTheme {
|
|
17
13
|
fg(color: string, text: string): string;
|
|
18
14
|
bg(color: string, text: string): string;
|
|
@@ -76,6 +72,7 @@ export function confirmOverlay(
|
|
|
76
72
|
ui.custom<boolean>(
|
|
77
73
|
(tui, theme, _kb, done) => {
|
|
78
74
|
let ticker: ReturnType<typeof setInterval> | undefined;
|
|
75
|
+
let countdownLine: string | undefined;
|
|
79
76
|
|
|
80
77
|
const choices: SelectItem[] = [
|
|
81
78
|
{
|
|
@@ -90,62 +87,33 @@ export function confirmOverlay(
|
|
|
90
87
|
},
|
|
91
88
|
];
|
|
92
89
|
|
|
93
|
-
const selectList = new SelectList(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
scrollInfo: (t) => theme.fg("dim", t),
|
|
98
|
-
noMatch: (t) => theme.fg("warning", t),
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
const container = new Box(0, 0, (s) => theme.bg("customMessageBg", s));
|
|
102
|
-
container.addChild(
|
|
103
|
-
new DynamicBorder((s: string) => theme.fg(accent, s)),
|
|
104
|
-
);
|
|
105
|
-
container.addChild(
|
|
106
|
-
new Text(theme.fg(accent, theme.bold(opts.title)), 1, 0),
|
|
90
|
+
const selectList = new SelectList(
|
|
91
|
+
choices,
|
|
92
|
+
choices.length,
|
|
93
|
+
selectListTheme(theme, accent),
|
|
107
94
|
);
|
|
108
95
|
|
|
109
|
-
for (const line of opts.body ?? []) {
|
|
110
|
-
container.addChild(new Text(theme.fg("text", line), 1, 0));
|
|
111
|
-
}
|
|
112
|
-
|
|
113
96
|
if (timeoutMs > 0) {
|
|
114
97
|
const deadlineMs = Date.now() + timeoutMs;
|
|
115
|
-
const countdownText = new Text("", 1, 0);
|
|
116
98
|
const updateCountdown = () => {
|
|
117
99
|
const remaining = Math.max(
|
|
118
100
|
0,
|
|
119
101
|
Math.ceil((deadlineMs - Date.now()) / SECOND_MS),
|
|
120
102
|
);
|
|
121
|
-
|
|
103
|
+
countdownLine =
|
|
122
104
|
theme.fg("dim", "Auto-cancel in ") +
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
);
|
|
105
|
+
theme.fg(
|
|
106
|
+
remaining <= COUNTDOWN_WARN_S ? accent : "muted",
|
|
107
|
+
`${remaining}s`,
|
|
108
|
+
);
|
|
128
109
|
};
|
|
129
110
|
updateCountdown();
|
|
130
111
|
ticker = setInterval(() => {
|
|
131
112
|
updateCountdown();
|
|
132
113
|
tui.requestRender();
|
|
133
114
|
}, SECOND_MS);
|
|
134
|
-
container.addChild(countdownText);
|
|
135
115
|
}
|
|
136
116
|
|
|
137
|
-
container.addChild(selectList);
|
|
138
|
-
container.addChild(
|
|
139
|
-
new Text(
|
|
140
|
-
theme.fg("dim", "↑↓ navigate • enter select • esc cancel"),
|
|
141
|
-
1,
|
|
142
|
-
0,
|
|
143
|
-
),
|
|
144
|
-
);
|
|
145
|
-
container.addChild(
|
|
146
|
-
new DynamicBorder((s: string) => theme.fg(accent, s)),
|
|
147
|
-
);
|
|
148
|
-
|
|
149
117
|
const finish = (value: boolean) => {
|
|
150
118
|
if (timer !== undefined) clearTimeout(timer);
|
|
151
119
|
if (ticker !== undefined) clearInterval(ticker);
|
|
@@ -157,8 +125,41 @@ export function confirmOverlay(
|
|
|
157
125
|
controller.signal.addEventListener("abort", () => finish(false));
|
|
158
126
|
|
|
159
127
|
return {
|
|
160
|
-
render: (w: number) =>
|
|
161
|
-
|
|
128
|
+
render: (w: number) => {
|
|
129
|
+
const mw = modalWidth(w);
|
|
130
|
+
const inner = mw - 4;
|
|
131
|
+
const lines: string[] = [];
|
|
132
|
+
|
|
133
|
+
// Title
|
|
134
|
+
lines.push(theme.fg(accent, theme.bold(opts.title)));
|
|
135
|
+
|
|
136
|
+
// Body
|
|
137
|
+
for (const line of opts.body ?? []) {
|
|
138
|
+
lines.push(theme.fg("text", line));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Divider
|
|
142
|
+
lines.push(theme.fg("dim", "─".repeat(inner)));
|
|
143
|
+
|
|
144
|
+
// Countdown
|
|
145
|
+
if (countdownLine !== undefined) lines.push(countdownLine);
|
|
146
|
+
|
|
147
|
+
// Select list
|
|
148
|
+
for (const l of selectList.render(inner)) lines.push(l);
|
|
149
|
+
|
|
150
|
+
lines.push("");
|
|
151
|
+
lines.push(
|
|
152
|
+
theme.fg("dim", "↑↓ navigate • enter select • esc cancel"),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return frameLines({
|
|
156
|
+
width: mw,
|
|
157
|
+
lines,
|
|
158
|
+
color: (s) => theme.fg(accent, s),
|
|
159
|
+
bg: (s) => theme.bg("customMessageBg", s),
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
invalidate: () => {},
|
|
162
163
|
handleInput: (data: string) => {
|
|
163
164
|
selectList.handleInput(data);
|
|
164
165
|
tui.requestRender();
|
package/src/gate-overlay.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* "confirm" — SelectList only. Used by pix-gate for command gating.
|
|
6
6
|
* "sudo" — SelectList → masked password input. Used by pix-sudo.
|
|
7
7
|
*
|
|
8
|
-
* Both modes share:
|
|
8
|
+
* Both modes share: rounded modal frame (╭─╮╰─╯), solid bg, accent border,
|
|
9
|
+
* title, body lines, optional countdown. Same visual style as pix-ask.
|
|
9
10
|
*
|
|
10
11
|
* Design goals:
|
|
11
12
|
* - Pure function — no side effects, no global state.
|
|
@@ -13,14 +14,8 @@
|
|
|
13
14
|
* - Single source of truth for the overlay look across pix-gate and pix-sudo.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
Box,
|
|
19
|
-
Input,
|
|
20
|
-
type SelectItem,
|
|
21
|
-
SelectList,
|
|
22
|
-
Text,
|
|
23
|
-
} from "@earendil-works/pi-tui";
|
|
17
|
+
import { Input, type SelectItem, SelectList } from "@earendil-works/pi-tui";
|
|
18
|
+
import { frameLines, modalWidth, selectListTheme } from "./modal-frame.js";
|
|
24
19
|
|
|
25
20
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
26
21
|
|
|
@@ -122,6 +117,67 @@ class MaskedInput extends Input {
|
|
|
122
117
|
}
|
|
123
118
|
}
|
|
124
119
|
|
|
120
|
+
// ── Renderer ──────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/** Build body lines for the current stage, rendered into frameLines. */
|
|
123
|
+
function buildLines(opts: {
|
|
124
|
+
theme: OverlayTheme;
|
|
125
|
+
accent: string;
|
|
126
|
+
config: OverlayConfig;
|
|
127
|
+
stage: "select" | "password";
|
|
128
|
+
selectList: SelectList;
|
|
129
|
+
maskedInput: MaskedInput;
|
|
130
|
+
countdownLine: string | undefined;
|
|
131
|
+
width: number;
|
|
132
|
+
}): string[] {
|
|
133
|
+
const {
|
|
134
|
+
theme,
|
|
135
|
+
accent,
|
|
136
|
+
config,
|
|
137
|
+
stage,
|
|
138
|
+
selectList,
|
|
139
|
+
maskedInput,
|
|
140
|
+
countdownLine,
|
|
141
|
+
width,
|
|
142
|
+
} = opts;
|
|
143
|
+
const inner = width - 4; // CHROME = 2 border + 2 padding
|
|
144
|
+
const lines: string[] = [];
|
|
145
|
+
|
|
146
|
+
// Title
|
|
147
|
+
lines.push(theme.fg(accent, theme.bold(config.title)));
|
|
148
|
+
|
|
149
|
+
// Body
|
|
150
|
+
for (const line of config.body ?? []) {
|
|
151
|
+
lines.push(theme.fg("text", line));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Divider after title/body
|
|
155
|
+
lines.push(theme.fg("dim", "─".repeat(inner)));
|
|
156
|
+
|
|
157
|
+
// Countdown
|
|
158
|
+
if (countdownLine !== undefined) lines.push(countdownLine);
|
|
159
|
+
|
|
160
|
+
// Select or password stage
|
|
161
|
+
if (stage === "select") {
|
|
162
|
+
const listLines = selectList.render(inner);
|
|
163
|
+
for (const l of listLines) lines.push(l);
|
|
164
|
+
lines.push("");
|
|
165
|
+
lines.push(theme.fg("dim", "↑↓ navigate • enter select • esc deny"));
|
|
166
|
+
} else {
|
|
167
|
+
const label =
|
|
168
|
+
config.mode === "sudo"
|
|
169
|
+
? (config.passwordLabel ?? "Sudo password:")
|
|
170
|
+
: "Password:";
|
|
171
|
+
lines.push(theme.fg("muted", label));
|
|
172
|
+
const inputLines = maskedInput.render(inner);
|
|
173
|
+
for (const l of inputLines) lines.push(l);
|
|
174
|
+
lines.push("");
|
|
175
|
+
lines.push(theme.fg("dim", "enter confirm • esc cancel"));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return lines;
|
|
179
|
+
}
|
|
180
|
+
|
|
125
181
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
126
182
|
|
|
127
183
|
/**
|
|
@@ -171,9 +227,9 @@ export function showOverlay(
|
|
|
171
227
|
|
|
172
228
|
ui.custom<OverlayResult>(
|
|
173
229
|
(tui, theme, _kb, done) => {
|
|
174
|
-
// ── state ───────────────────────────────────────────────────────
|
|
175
230
|
type Stage = "select" | "password";
|
|
176
231
|
let stage: Stage = "select";
|
|
232
|
+
let countdownLine: string | undefined;
|
|
177
233
|
|
|
178
234
|
// ── components ──────────────────────────────────────────────────
|
|
179
235
|
const selectItems: SelectItem[] = choices.map((c) => ({
|
|
@@ -182,32 +238,12 @@ export function showOverlay(
|
|
|
182
238
|
description: c.description,
|
|
183
239
|
}));
|
|
184
240
|
|
|
185
|
-
const selectList = new SelectList(
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
scrollInfo: (t) => theme.fg("dim", t),
|
|
190
|
-
noMatch: (t) => theme.fg("warning", t),
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
const maskedInput = new MaskedInput();
|
|
194
|
-
const passwordHint = new Text("", 1, 0);
|
|
195
|
-
const helpText = new Text("", 1, 0);
|
|
196
|
-
const countdownText = new Text("", 1, 0);
|
|
197
|
-
|
|
198
|
-
// ── container ───────────────────────────────────────────────────
|
|
199
|
-
// padding: 2 cols horizontal, 1 row vertical around all dialog content
|
|
200
|
-
const container = new Box(2, 1, (s) => theme.bg("customMessageBg", s));
|
|
201
|
-
container.addChild(
|
|
202
|
-
new DynamicBorder((s: string) => theme.fg(accent, s)),
|
|
241
|
+
const selectList = new SelectList(
|
|
242
|
+
selectItems,
|
|
243
|
+
selectItems.length,
|
|
244
|
+
selectListTheme(theme, accent),
|
|
203
245
|
);
|
|
204
|
-
|
|
205
|
-
new Text(theme.fg(accent, theme.bold(config.title)), 1, 0),
|
|
206
|
-
);
|
|
207
|
-
|
|
208
|
-
for (const line of config.body ?? []) {
|
|
209
|
-
container.addChild(new Text(theme.fg("text", line), 1, 0));
|
|
210
|
-
}
|
|
246
|
+
const maskedInput = new MaskedInput();
|
|
211
247
|
|
|
212
248
|
// ── countdown ───────────────────────────────────────────────────
|
|
213
249
|
let ticker: ReturnType<typeof setInterval> | undefined;
|
|
@@ -218,64 +254,34 @@ export function showOverlay(
|
|
|
218
254
|
0,
|
|
219
255
|
Math.ceil((deadlineMs - Date.now()) / SECOND_MS),
|
|
220
256
|
);
|
|
221
|
-
|
|
257
|
+
countdownLine =
|
|
222
258
|
theme.fg("dim", "Auto-deny in ") +
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
);
|
|
259
|
+
theme.fg(
|
|
260
|
+
remaining <= COUNTDOWN_WARN_S ? accent : "muted",
|
|
261
|
+
`${remaining}s`,
|
|
262
|
+
);
|
|
228
263
|
};
|
|
229
264
|
updateCountdown();
|
|
230
265
|
ticker = setInterval(() => {
|
|
231
266
|
updateCountdown();
|
|
232
267
|
tui.requestRender();
|
|
233
268
|
}, SECOND_MS);
|
|
234
|
-
container.addChild(countdownText);
|
|
235
269
|
}
|
|
236
270
|
|
|
237
|
-
// ──
|
|
238
|
-
helpText.setText(
|
|
239
|
-
theme.fg("dim", "↑↓ navigate • enter select • esc deny"),
|
|
240
|
-
);
|
|
241
|
-
container.addChild(selectList);
|
|
242
|
-
container.addChild(helpText);
|
|
243
|
-
container.addChild(
|
|
244
|
-
new DynamicBorder((s: string) => theme.fg(accent, s)),
|
|
245
|
-
);
|
|
246
|
-
|
|
247
|
-
// ── finish helper ────────────────────────────────────────────────
|
|
271
|
+
// ── finish ───────────────────────────────────────────────────────
|
|
248
272
|
const finish = (result: OverlayResult) => {
|
|
249
273
|
if (timer !== undefined) clearTimeout(timer);
|
|
250
274
|
if (ticker !== undefined) clearInterval(ticker);
|
|
251
275
|
done(result);
|
|
252
276
|
};
|
|
253
277
|
|
|
254
|
-
// ── stage: password (sudo mode only) ────────────────────────────
|
|
255
|
-
const switchToPassword = () => {
|
|
256
|
-
stage = "password";
|
|
257
|
-
container.removeChild(selectList);
|
|
258
|
-
container.removeChild(helpText);
|
|
259
|
-
|
|
260
|
-
const label =
|
|
261
|
-
config.mode === "sudo"
|
|
262
|
-
? (config.passwordLabel ?? "Sudo password:")
|
|
263
|
-
: "Password:";
|
|
264
|
-
passwordHint.setText(theme.fg("muted", label));
|
|
265
|
-
container.addChild(passwordHint);
|
|
266
|
-
container.addChild(maskedInput);
|
|
267
|
-
container.addChild(
|
|
268
|
-
new Text(theme.fg("dim", "enter confirm • esc cancel"), 1, 0),
|
|
269
|
-
);
|
|
270
|
-
tui.requestRender();
|
|
271
|
-
};
|
|
272
|
-
|
|
273
278
|
// ── event wiring ─────────────────────────────────────────────────
|
|
274
279
|
selectList.onSelect = (item) => {
|
|
275
280
|
if (item.value !== approveVal) {
|
|
276
281
|
finish({ action: "denied" });
|
|
277
282
|
} else if (config.mode === "sudo") {
|
|
278
|
-
|
|
283
|
+
stage = "password";
|
|
284
|
+
tui.requestRender();
|
|
279
285
|
} else {
|
|
280
286
|
finish({ action: "approved" });
|
|
281
287
|
}
|
|
@@ -292,8 +298,26 @@ export function showOverlay(
|
|
|
292
298
|
|
|
293
299
|
// ── component interface ──────────────────────────────────────────
|
|
294
300
|
return {
|
|
295
|
-
render: (w) =>
|
|
296
|
-
|
|
301
|
+
render: (w) => {
|
|
302
|
+
const mw = modalWidth(w);
|
|
303
|
+
const lines = buildLines({
|
|
304
|
+
theme,
|
|
305
|
+
accent,
|
|
306
|
+
config,
|
|
307
|
+
stage,
|
|
308
|
+
selectList,
|
|
309
|
+
maskedInput,
|
|
310
|
+
countdownLine,
|
|
311
|
+
width: mw,
|
|
312
|
+
});
|
|
313
|
+
return frameLines({
|
|
314
|
+
width: mw,
|
|
315
|
+
lines,
|
|
316
|
+
color: (s) => theme.fg(accent, s),
|
|
317
|
+
bg: (s) => theme.bg("customMessageBg", s),
|
|
318
|
+
});
|
|
319
|
+
},
|
|
320
|
+
invalidate: () => {},
|
|
297
321
|
handleInput: (data) => {
|
|
298
322
|
if (stage === "select") selectList.handleInput(data);
|
|
299
323
|
else maskedInput.handleInput(data);
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pix-pretty/modal-frame — shared primitives for interactive overlay UIs.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* frameLines() — render a rounded bordered modal box (╭─╮╰─╯)
|
|
6
|
+
* modalWidth() — clamp terminal width to a sane modal width
|
|
7
|
+
* selectListTheme() — canonical SelectList theme config (accent + muted + dim)
|
|
8
|
+
*
|
|
9
|
+
* Used by: gate-overlay, confirm, and (via re-export) pix-ask.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
13
|
+
|
|
14
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const MIN_WIDTH = 40;
|
|
17
|
+
const MAX_WIDTH = 96;
|
|
18
|
+
const MARGIN = 4;
|
|
19
|
+
/** 2 border cols + 2 padding spaces */
|
|
20
|
+
const CHROME = 4;
|
|
21
|
+
|
|
22
|
+
// ── Width ─────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/** Clamp terminal width to a sane modal width (40–96 cols). */
|
|
25
|
+
export function modalWidth(termWidth: number): number {
|
|
26
|
+
return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, termWidth - MARGIN));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Frame ─────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export interface FrameOptions {
|
|
32
|
+
width: number;
|
|
33
|
+
lines: string[];
|
|
34
|
+
/** Color function for border glyphs — e.g. `(s) => theme.fg("accent", s)` */
|
|
35
|
+
color: (s: string) => string;
|
|
36
|
+
/** Background fill function — e.g. `(s) => theme.bg("customMessageBg", s)` */
|
|
37
|
+
bg?: (s: string) => string;
|
|
38
|
+
/** Optional pre-styled string rendered as the first content row (tab bar etc.) */
|
|
39
|
+
top?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Render a rounded modal box.
|
|
44
|
+
*
|
|
45
|
+
* Returns an array of full-width ANSI strings:
|
|
46
|
+
* ╭──────────────────╮
|
|
47
|
+
* │ [top row] │ ← only if top is set
|
|
48
|
+
* │ content line 1 │
|
|
49
|
+
* │ content line 2 │
|
|
50
|
+
* ╰──────────────────╯
|
|
51
|
+
*
|
|
52
|
+
* Solid background fill — theme fg/bold spans that emit \x1b[0m are patched
|
|
53
|
+
* so the background colour is re-asserted, preventing transparent holes.
|
|
54
|
+
*/
|
|
55
|
+
export function frameLines(opts: FrameOptions): string[] {
|
|
56
|
+
const { width, lines, color, top } = opts;
|
|
57
|
+
const bg = opts.bg ?? ((s: string) => s);
|
|
58
|
+
const inner = Math.max(1, width - CHROME);
|
|
59
|
+
const dashes = "─".repeat(width - 2);
|
|
60
|
+
|
|
61
|
+
// Derive the bg OPEN sequence so we can re-assert it after any full reset
|
|
62
|
+
// (\x1b[0m) or bg reset (\x1b[49m) embedded in content.
|
|
63
|
+
const SENTINEL = "\x00";
|
|
64
|
+
const bgOpen = bg(SENTINEL).split(SENTINEL)[0] ?? "";
|
|
65
|
+
const reassert = (s: string): string =>
|
|
66
|
+
bgOpen
|
|
67
|
+
? s.replace(/\x1b\[([0-9;]*)m/g, (seq, p: string) =>
|
|
68
|
+
p === "0" || p.split(";").includes("49") ? `${seq}${bgOpen}` : seq,
|
|
69
|
+
)
|
|
70
|
+
: s;
|
|
71
|
+
|
|
72
|
+
const row = (content: string): string => {
|
|
73
|
+
const pad = inner - visibleWidth(content);
|
|
74
|
+
const padded =
|
|
75
|
+
pad > 0 ? content + " ".repeat(pad) : truncateToWidth(content, inner);
|
|
76
|
+
return bg(`${color("│")} ${reassert(padded)} ${color("│")}`);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const out: string[] = [bg(color(`╭${dashes}╮`))];
|
|
80
|
+
if (top !== undefined) out.push(row(top));
|
|
81
|
+
for (const line of lines) out.push(row(line));
|
|
82
|
+
out.push(bg(color(`╰${dashes}╯`)));
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── SelectList theme ──────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
export interface SelectListThemeConfig {
|
|
89
|
+
selectedPrefix: (t: string) => string;
|
|
90
|
+
selectedText: (t: string) => string;
|
|
91
|
+
description: (t: string) => string;
|
|
92
|
+
scrollInfo: (t: string) => string;
|
|
93
|
+
noMatch: (t: string) => string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface FgTheme {
|
|
97
|
+
fg(color: string, text: string): string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Canonical SelectList theme for interactive overlays.
|
|
102
|
+
* accent = active/selected, muted = descriptions, dim = scroll/hints, warning = no-match.
|
|
103
|
+
*/
|
|
104
|
+
export function selectListTheme(
|
|
105
|
+
theme: FgTheme,
|
|
106
|
+
accent = "accent",
|
|
107
|
+
): SelectListThemeConfig {
|
|
108
|
+
return {
|
|
109
|
+
selectedPrefix: (t) => theme.fg(accent, t),
|
|
110
|
+
selectedText: (t) => theme.fg(accent, t),
|
|
111
|
+
description: (t) => theme.fg("muted", t),
|
|
112
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
113
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
114
|
+
};
|
|
115
|
+
}
|