@bismawy/pi-arnative 0.1.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/LICENSE +21 -0
- package/README.md +108 -0
- package/extensions/footer.ts +627 -0
- package/extensions/section-headers.ts +734 -0
- package/extensions/timestamps.ts +224 -0
- package/extensions/tools.ts +942 -0
- package/extensions/ui-render-tweaks.ts +576 -0
- package/extensions/usage.ts +314 -0
- package/lib/ansi.ts +24 -0
- package/lib/box.ts +59 -0
- package/lib/usage-store.ts +354 -0
- package/package.json +43 -0
- package/themes/arnative-cyberpunk.json +94 -0
- package/themes/arnative-dracula.json +94 -0
- package/themes/arnative-emerald.json +94 -0
- package/themes/arnative-gruvbox.json +94 -0
- package/themes/arnative-matrix.json +94 -0
- package/themes/arnative-nord.json +94 -0
- package/themes/arnative-sun.json +94 -0
- package/themes/arnative-synthwave.json +94 -0
- package/themes/arnative-violet.json +94 -0
- package/themes/arnative-zinc.json +94 -0
- package/themes/arnative.json +94 -0
- package/themes/gen-themes.mjs +272 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arnative Usage Dashboard (/usage): table UI + the /usage command.
|
|
3
|
+
* Data layer (session scan + cache + aggregation) lives in lib/usage-store.ts —
|
|
4
|
+
* shared with the Model tab; the old API is still re-exported here (compatibility).
|
|
5
|
+
*/
|
|
6
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { matchesKey, truncateToWidth, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
8
|
+
import { boxEdge, boxRow } from "../lib/box.ts";
|
|
9
|
+
import { collectUsageSummary, formatTokens, getModelAllTimeUsage, type UsageSummary } from "../lib/usage-store.ts";
|
|
10
|
+
export { collectUsageSummary, formatTokens, getModelAllTimeUsage };
|
|
11
|
+
export type { UsageEntry, UsageModelStats, UsageProviderStats, UsageSummary } from "../lib/usage-store.ts";
|
|
12
|
+
|
|
13
|
+
export function formatCost(n: number): string {
|
|
14
|
+
if (!n || n <= 0) return "-";
|
|
15
|
+
if (n < 0.01) return "<$0.01";
|
|
16
|
+
if (n < 1000) return `$${n.toFixed(2)}`;
|
|
17
|
+
return `$${(n / 1000).toFixed(1)}k`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function formatCount(n: number): string {
|
|
21
|
+
return n > 0 ? n.toLocaleString("en-US") : "-";
|
|
22
|
+
}
|
|
23
|
+
// =============================================================================
|
|
24
|
+
// /usage TUI component
|
|
25
|
+
// =============================================================================
|
|
26
|
+
|
|
27
|
+
export interface RowItem {
|
|
28
|
+
type: "provider" | "model";
|
|
29
|
+
providerName: string;
|
|
30
|
+
modelName?: string;
|
|
31
|
+
label: string;
|
|
32
|
+
sessions: number;
|
|
33
|
+
msgs: number;
|
|
34
|
+
tokens: number;
|
|
35
|
+
input: number;
|
|
36
|
+
output: number;
|
|
37
|
+
cacheRead: number;
|
|
38
|
+
cost: number;
|
|
39
|
+
expanded?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class UsageModalComponent implements Component {
|
|
43
|
+
private expandedProviders = new Set<string>();
|
|
44
|
+
private selectedIndex = 0;
|
|
45
|
+
private summary: UsageSummary;
|
|
46
|
+
|
|
47
|
+
private theme: { fg(c: string, t: string): string; bold?(t: string): string };
|
|
48
|
+
private onDone: () => void;
|
|
49
|
+
private requestRender: () => void;
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
theme: { fg(c: string, t: string): string; bold?(t: string): string },
|
|
53
|
+
onDone: () => void,
|
|
54
|
+
requestRender: () => void,
|
|
55
|
+
) {
|
|
56
|
+
this.theme = theme;
|
|
57
|
+
this.onDone = onDone;
|
|
58
|
+
this.requestRender = requestRender;
|
|
59
|
+
this.summary = collectUsageSummary(true);
|
|
60
|
+
// Default: expand the first provider when there is one
|
|
61
|
+
if (this.summary.providers.length > 0) {
|
|
62
|
+
this.expandedProviders.add(this.summary.providers[0]!.name);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
invalidate(): void {}
|
|
67
|
+
|
|
68
|
+
dispose(): void {}
|
|
69
|
+
|
|
70
|
+
private buildFlatRows(): RowItem[] {
|
|
71
|
+
const rows: RowItem[] = [];
|
|
72
|
+
for (const p of this.summary.providers) {
|
|
73
|
+
const isExpanded = this.expandedProviders.has(p.name);
|
|
74
|
+
rows.push({
|
|
75
|
+
type: "provider",
|
|
76
|
+
providerName: p.name,
|
|
77
|
+
label: `${isExpanded ? "▾" : "▸"} ${p.name}`,
|
|
78
|
+
sessions: p.sessions,
|
|
79
|
+
msgs: p.msgs,
|
|
80
|
+
tokens: p.totalTokens,
|
|
81
|
+
input: p.input,
|
|
82
|
+
output: p.output,
|
|
83
|
+
cacheRead: p.cacheRead,
|
|
84
|
+
cost: p.cost,
|
|
85
|
+
expanded: isExpanded,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
if (isExpanded) {
|
|
89
|
+
for (const m of p.models) {
|
|
90
|
+
rows.push({
|
|
91
|
+
type: "model",
|
|
92
|
+
providerName: p.name,
|
|
93
|
+
modelName: m.name,
|
|
94
|
+
label: ` ${m.name}`,
|
|
95
|
+
sessions: m.sessions,
|
|
96
|
+
msgs: m.msgs,
|
|
97
|
+
tokens: m.totalTokens,
|
|
98
|
+
input: m.input,
|
|
99
|
+
output: m.output,
|
|
100
|
+
cacheRead: m.cacheRead,
|
|
101
|
+
cost: m.cost,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return rows;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
handleInput(keyData: string): void {
|
|
110
|
+
const rows = this.buildFlatRows();
|
|
111
|
+
if (matchesKey(keyData, "q") || matchesKey(keyData, "Q") || matchesKey(keyData, "escape")) {
|
|
112
|
+
this.onDone();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (matchesKey(keyData, "up") || matchesKey(keyData, "k")) {
|
|
116
|
+
// Up
|
|
117
|
+
if (this.selectedIndex > 0) {
|
|
118
|
+
this.selectedIndex--;
|
|
119
|
+
this.requestRender();
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (matchesKey(keyData, "down") || matchesKey(keyData, "j")) {
|
|
124
|
+
// Down
|
|
125
|
+
if (this.selectedIndex < rows.length - 1) {
|
|
126
|
+
this.selectedIndex++;
|
|
127
|
+
this.requestRender();
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (matchesKey(keyData, "enter") || matchesKey(keyData, "space")) {
|
|
132
|
+
// Toggle expand provider
|
|
133
|
+
const row = rows[this.selectedIndex];
|
|
134
|
+
if (row && row.type === "provider") {
|
|
135
|
+
if (this.expandedProviders.has(row.providerName)) {
|
|
136
|
+
this.expandedProviders.delete(row.providerName);
|
|
137
|
+
} else {
|
|
138
|
+
this.expandedProviders.add(row.providerName);
|
|
139
|
+
}
|
|
140
|
+
this.requestRender();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
render(width: number): string[] {
|
|
146
|
+
const th = this.theme;
|
|
147
|
+
const bold = th.bold ?? ((t: string) => `\x1b[1m${t}\x1b[22m`);
|
|
148
|
+
// arnative chat-column box wraps title + table + hint, full width.
|
|
149
|
+
// Round ends (╭─╮ / ╰─╯); sides + divider from lib/box.ts (single definition).
|
|
150
|
+
const boxW = width;
|
|
151
|
+
const tint = (s: string) => th.fg("tint", s);
|
|
152
|
+
const hline = (l: string, r: string) => boxEdge(l, r, boxW, tint);
|
|
153
|
+
const row = (text: string) => boxRow(text, boxW, tint);
|
|
154
|
+
const rule = () => hline("├", "┤");
|
|
155
|
+
|
|
156
|
+
const rows = this.buildFlatRows();
|
|
157
|
+
if (this.selectedIndex >= rows.length) {
|
|
158
|
+
this.selectedIndex = Math.max(0, rows.length - 1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Layout columns (character widths) — 89 characters of table content in total
|
|
162
|
+
const colW = {
|
|
163
|
+
name: 28,
|
|
164
|
+
sessions: 8,
|
|
165
|
+
msgs: 8,
|
|
166
|
+
tokens: 9,
|
|
167
|
+
inp: 9,
|
|
168
|
+
out: 8,
|
|
169
|
+
cache: 8,
|
|
170
|
+
cost: 9,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const formatCol = (text: string, w: number, alignRight = true) => {
|
|
174
|
+
const str = truncateToWidth(text, w);
|
|
175
|
+
const diff = Math.max(0, w - visibleWidth(str));
|
|
176
|
+
return alignRight ? " ".repeat(diff) + str : str + " ".repeat(diff);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Table header
|
|
180
|
+
const headerText =
|
|
181
|
+
" " +
|
|
182
|
+
formatCol(th.fg("dim", "Provider / Model"), colW.name, false) +
|
|
183
|
+
formatCol(th.fg("dim", "Sessions"), colW.sessions) +
|
|
184
|
+
formatCol(th.fg("dim", "Msgs"), colW.msgs) +
|
|
185
|
+
formatCol(th.fg("dim", "Tokens"), colW.tokens) +
|
|
186
|
+
formatCol(th.fg("dim", "↑In"), colW.inp) +
|
|
187
|
+
formatCol(th.fg("dim", "↓Out"), colW.out) +
|
|
188
|
+
formatCol(th.fg("dim", "Cache"), colW.cache) +
|
|
189
|
+
formatCol(th.fg("dim", "Cost"), colW.cost);
|
|
190
|
+
|
|
191
|
+
const out: string[] = [];
|
|
192
|
+
out.push(hline("╭", "╮"));
|
|
193
|
+
out.push(row(th.fg("accent", bold("LLM Usage"))));
|
|
194
|
+
out.push(row(th.fg("muted", "Token, message & cache usage across all local Pi sessions.")));
|
|
195
|
+
out.push(row(""));
|
|
196
|
+
out.push(row(headerText));
|
|
197
|
+
out.push(rule());
|
|
198
|
+
|
|
199
|
+
// Data rows (with a display height cap so it cannot overflow)
|
|
200
|
+
const maxVisibleRows = 16;
|
|
201
|
+
let startIdx = 0;
|
|
202
|
+
if (rows.length > maxVisibleRows) {
|
|
203
|
+
startIdx = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisibleRows / 2), rows.length - maxVisibleRows));
|
|
204
|
+
}
|
|
205
|
+
const visibleRows = rows.slice(startIdx, startIdx + maxVisibleRows);
|
|
206
|
+
|
|
207
|
+
for (let i = 0; i < visibleRows.length; i++) {
|
|
208
|
+
const item = visibleRows[i]!;
|
|
209
|
+
const actualIdx = startIdx + i;
|
|
210
|
+
const isSelected = actualIdx === this.selectedIndex;
|
|
211
|
+
|
|
212
|
+
const nameColor = isSelected ? "accent" : item.type === "provider" ? "tint" : "text";
|
|
213
|
+
const valColor = isSelected ? "accent" : "dim";
|
|
214
|
+
|
|
215
|
+
const cName = formatCol(th.fg(nameColor, isSelected ? bold(item.label) : item.label), colW.name, false);
|
|
216
|
+
const cSess = formatCol(th.fg(valColor, formatCount(item.sessions)), colW.sessions);
|
|
217
|
+
const cMsgs = formatCol(th.fg(valColor, formatCount(item.msgs)), colW.msgs);
|
|
218
|
+
const cToks = formatCol(th.fg(valColor, formatTokens(item.tokens)), colW.tokens);
|
|
219
|
+
const cInp = formatCol(th.fg(valColor, formatTokens(item.input)), colW.inp);
|
|
220
|
+
const cOut = formatCol(th.fg(valColor, formatTokens(item.output)), colW.out);
|
|
221
|
+
const cCache = formatCol(th.fg(valColor, formatTokens(item.cacheRead)), colW.cache);
|
|
222
|
+
const cCost = formatCol(th.fg(valColor, formatCost(item.cost)), colW.cost);
|
|
223
|
+
|
|
224
|
+
const prefix = isSelected ? th.fg("accent", "▸ ") : " ";
|
|
225
|
+
out.push(row(prefix + cName + cSess + cMsgs + cToks + cInp + cOut + cCache + cCost));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
out.push(rule());
|
|
229
|
+
|
|
230
|
+
// Total row
|
|
231
|
+
const t = this.summary.totals;
|
|
232
|
+
const totalName = formatCol(th.fg("tint", bold("Total")), colW.name, false);
|
|
233
|
+
const totalSess = formatCol(th.fg("tint", bold(formatCount(t.sessions))), colW.sessions);
|
|
234
|
+
const totalMsgs = formatCol(th.fg("tint", bold(formatCount(t.msgs))), colW.msgs);
|
|
235
|
+
const totalToks = formatCol(th.fg("tint", bold(formatTokens(t.totalTokens))), colW.tokens);
|
|
236
|
+
const totalInp = formatCol(th.fg("tint", bold(formatTokens(t.input))), colW.inp);
|
|
237
|
+
const totalOut = formatCol(th.fg("tint", bold(formatTokens(t.output))), colW.out);
|
|
238
|
+
const totalCache = formatCol(th.fg("tint", bold(formatTokens(t.cacheRead))), colW.cache);
|
|
239
|
+
const totalCost = formatCol(th.fg("tint", bold(formatCost(t.cost))), colW.cost);
|
|
240
|
+
|
|
241
|
+
out.push(row(" " + totalName + totalSess + totalMsgs + totalToks + totalInp + totalOut + totalCache + totalCost));
|
|
242
|
+
|
|
243
|
+
out.push(row(""));
|
|
244
|
+
out.push(row(th.fg("dim", "[↑↓] Navigation [Enter] Open/close [q/Esc] Exit")));
|
|
245
|
+
out.push(hline("╰", "╯"));
|
|
246
|
+
|
|
247
|
+
return out.map((line) => truncateToWidth(line, width, ""));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export default function (pi: ExtensionAPI) {
|
|
252
|
+
pi.registerCommand("usage", {
|
|
253
|
+
description: "Dashboard penggunaan token & model Arnative",
|
|
254
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
255
|
+
if (!ctx.hasUI) return;
|
|
256
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
257
|
+
return new UsageModalComponent(theme, () => done(), () => tui.requestRender());
|
|
258
|
+
});
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Self-check: `node extensions/usage.ts`
|
|
264
|
+
const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("\\").join("/"));
|
|
265
|
+
if (isMain) {
|
|
266
|
+
const assert = (cond: boolean, msg: string) => {
|
|
267
|
+
if (!cond) {
|
|
268
|
+
console.error(`FAIL: ${msg}`);
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
assert(formatTokens(0) === "-", "formatTokens 0 = -");
|
|
274
|
+
assert(formatTokens(500) === "500", "formatTokens 500 = 500");
|
|
275
|
+
assert(formatTokens(2500) === "2.5k", "formatTokens 2500 = 2.5k");
|
|
276
|
+
assert(formatTokens(2900000) === "2.9M", "formatTokens 2900000 = 2.9M");
|
|
277
|
+
assert(formatTokens(1863163057) === "1.9B", "formatTokens 1.86B = 1.9B");
|
|
278
|
+
assert(formatCost(0) === "-", "formatCost 0 = -");
|
|
279
|
+
assert(formatCost(0.004) === "<$0.01", "formatCost 0.004 = <$0.01");
|
|
280
|
+
assert(formatCost(12.345) === "$12.35", "formatCost 12.345 = $12.35");
|
|
281
|
+
assert(formatCost(1234.5) === "$1.2k", "formatCost 1234.5 = $1.2k");
|
|
282
|
+
|
|
283
|
+
const summary = collectUsageSummary();
|
|
284
|
+
assert(summary.providers.length > 0, "collectUsageSummary mendeteksi providers");
|
|
285
|
+
assert(summary.totals.sessions > 0, "collectUsageSummary mendeteksi total sessions");
|
|
286
|
+
assert(summary.totals.msgs > 0, "collectUsageSummary mendeteksi total msgs");
|
|
287
|
+
assert(summary.totals.cost >= 0, "collectUsageSummary menghitung cost");
|
|
288
|
+
|
|
289
|
+
const modelUsage = getModelAllTimeUsage("gemini-3.8-flash");
|
|
290
|
+
assert(modelUsage.input > 0, "getModelAllTimeUsage mengambil input tokens gemini-3.8-flash");
|
|
291
|
+
|
|
292
|
+
const fakeTheme = {
|
|
293
|
+
fg: (_c: string, t: string) => `\x1b[38;2;100;100;100m${t}\x1b[39m`,
|
|
294
|
+
bold: (t: string) => `\x1b[1m${t}\x1b[22m`,
|
|
295
|
+
};
|
|
296
|
+
const modal = new UsageModalComponent(fakeTheme, () => {}, () => {});
|
|
297
|
+
const lines = modal.render(100);
|
|
298
|
+
assert(lines.length >= 10, "render modal menghasilkan baris-baris tabel");
|
|
299
|
+
assert(lines[0]!.includes("╭") && lines[0]!.includes("╮"), "border atas membulat");
|
|
300
|
+
assert(visibleWidth(lines[0]!) === 100, "border kotak full width (100 kolom)");
|
|
301
|
+
assert(lines[1]!.includes("LLM Usage"), "judul LLM Usage di dalam kotak");
|
|
302
|
+
assert(lines[2]!.includes("sessions"), "deskripsi di bawah judul");
|
|
303
|
+
assert(lines[lines.length - 1]!.includes("╰") && lines[lines.length - 1]!.includes("╯"), "border bawah membulat");
|
|
304
|
+
assert(
|
|
305
|
+
lines.every((l) => visibleWidth(l) <= 100),
|
|
306
|
+
"semua baris muat dalam lebar 100",
|
|
307
|
+
);
|
|
308
|
+
assert(
|
|
309
|
+
lines.every((l) => ["╭", "│", "├", "╰"].some((c) => l.includes(c))),
|
|
310
|
+
"tidak ada baris tanpa sisi kotak (garis aksen hilang)",
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
console.log("usage.ts self-check OK");
|
|
314
|
+
}
|
package/lib/ansi.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Theme bg open-code, stripped of its trailing \x1b[49m reset. */
|
|
2
|
+
export function ansiBgOpen(th: { bg?(color: string, text: string): string } | null, color: string): string {
|
|
3
|
+
if (!th?.bg) return "";
|
|
4
|
+
try {
|
|
5
|
+
const probe = th.bg(color, "");
|
|
6
|
+
const reset = "\x1b[49m";
|
|
7
|
+
return probe.endsWith(reset) ? probe.slice(0, -reset.length) : probe;
|
|
8
|
+
} catch {
|
|
9
|
+
return "";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Self-check: `node lib/ansi.ts`
|
|
14
|
+
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split("\\").join("/"))) {
|
|
15
|
+
const assert = (cond: boolean, msg: string) => {
|
|
16
|
+
if (!cond) {
|
|
17
|
+
console.error(`FAIL: ${msg}`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
assert(ansiBgOpen(null, "selectedBg") === "", "tanpa tema: kosong");
|
|
22
|
+
assert(ansiBgOpen({ bg: (_c, t) => `\x1b[48;2;9;9;9m${t}\x1b[49m` }, "selectedBg") === "\x1b[48;2;9;9;9m", "probe bg tanpa reset");
|
|
23
|
+
console.log("lib/ansi.ts OK");
|
|
24
|
+
}
|
package/lib/box.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single arnative box border (dim, round ends). Used by tools.ts, ui-render-tweaks.ts
|
|
3
|
+
* and usage.ts — edit here, all follow. Lives outside `extensions/*.ts` so pi
|
|
4
|
+
* never loads it as an extension.
|
|
5
|
+
*/
|
|
6
|
+
import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
7
|
+
|
|
8
|
+
export type BoxTheme = { fg(color: string, text: string): string; bg?(color: string, text: string): string };
|
|
9
|
+
export type Dim = (s: string) => string;
|
|
10
|
+
|
|
11
|
+
export const boxEdge = (l: string, r: string, width: number, dim: Dim): string =>
|
|
12
|
+
dim(`${l}${"─".repeat(Math.max(0, width - 2))}${r}`);
|
|
13
|
+
|
|
14
|
+
/** Content row: │ <text> │, exactly `width` wide. */
|
|
15
|
+
export const boxRow = (content: string, width: number, dim: Dim): string => {
|
|
16
|
+
const pad = Math.max(0, width - visibleWidth(content) - 4);
|
|
17
|
+
return `${dim("│")} ${content}${" ".repeat(pad)} ${dim("│")}`;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const applyBg = (line: string, width: number, bgFn: (text: string) => string): string => {
|
|
21
|
+
const pad = Math.max(0, width - visibleWidth(line));
|
|
22
|
+
return bgFn(line + " ".repeat(pad));
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Wrap `rows` (plain or ANSI-colored) into a box. `bgType` = theme bg name (no-op when the theme has no `bg`); `borderColor` = border color. */
|
|
26
|
+
export function renderBoxLines(theme: BoxTheme, width: number, rows: string[], bgType?: string, borderColor = "dim"): string[] {
|
|
27
|
+
const dim = (s: string) => theme.fg(borderColor, s);
|
|
28
|
+
const inner = Math.max(8, width - 4);
|
|
29
|
+
const lines = [boxEdge("╭", "╮", width, dim)];
|
|
30
|
+
for (const row of rows) {
|
|
31
|
+
for (const line of wrapTextWithAnsi(row, inner)) lines.push(boxRow(line, width, dim));
|
|
32
|
+
}
|
|
33
|
+
lines.push(boxEdge("╰", "╯", width, dim));
|
|
34
|
+
if (bgType && theme.bg) {
|
|
35
|
+
try {
|
|
36
|
+
return lines.map((l) => applyBg(l, width, (s) => theme.bg!(bgType, s)));
|
|
37
|
+
} catch {
|
|
38
|
+
return lines;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return lines;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split("\\").join("/"))) {
|
|
45
|
+
const assert = (cond: boolean, msg: string) => {
|
|
46
|
+
if (!cond) {
|
|
47
|
+
console.error(`FAIL: ${msg}`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const th: BoxTheme = { fg: (_c, t) => t };
|
|
52
|
+
const plain = renderBoxLines(th, 24, ["halo", "dunia yang panjang sekali"]);
|
|
53
|
+
assert(plain[0]!.startsWith("╭") && plain[plain.length - 1]!.startsWith("╰"), "bingkai atas/bawah utuh");
|
|
54
|
+
assert(plain.every((l) => visibleWidth(l) === 24), "semua baris selebar 24");
|
|
55
|
+
assert(plain[1] === "│ halo │", "baris isi dipad rata");
|
|
56
|
+
assert(renderBoxLines(th, 24, ["x".repeat(40)]).length === 4, "baris panjang di-wrap");
|
|
57
|
+
assert(renderBoxLines(th, 20, ["hi"], "customMessageBg").length === 3, "bg diabaikan saat tema tanpa bg");
|
|
58
|
+
console.log("lib/box.ts OK");
|
|
59
|
+
}
|