@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,734 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arnative header box & horizontal tab system:
|
|
3
|
+
* - Round-corner box, fixed height 5 lines: "pi" 3x6 logo on the left, a vertical
|
|
4
|
+
* divider, then 3 left-aligned info lines (version/shortcuts, tabs, data).
|
|
5
|
+
* - Info line 1: pi version & shortcuts [Esc], [Ctrl+c/d], [/], [!], [Ctrl+o].
|
|
6
|
+
* - Interactive tab menu (mouse click): Model + Context/Skills/Extensions/Themes
|
|
7
|
+
* with item counts. Tab icon = accent, tab text = tint.
|
|
8
|
+
* - Box divider '├────┤'.
|
|
9
|
+
* - Active tab content (left-aligned, neatly wrapped, tint, never stacked).
|
|
10
|
+
* - Hides pi's built-in stacked list in loadedResourcesContainer.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
UserMessageComponent,
|
|
17
|
+
getAgentDir,
|
|
18
|
+
VERSION,
|
|
19
|
+
type ExtensionAPI,
|
|
20
|
+
type ExtensionContext,
|
|
21
|
+
} from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import {
|
|
23
|
+
truncateToWidth,
|
|
24
|
+
visibleWidth,
|
|
25
|
+
Key,
|
|
26
|
+
type Component,
|
|
27
|
+
type TUI,
|
|
28
|
+
type TuiMouseEvent,
|
|
29
|
+
type TuiMouseEventResult,
|
|
30
|
+
} from "@earendil-works/pi-tui";
|
|
31
|
+
import { renderBoxLines } from "../lib/box.ts";
|
|
32
|
+
import { formatTokens, getModelAllTimeUsage } from "../lib/usage-store.ts";
|
|
33
|
+
|
|
34
|
+
export type TabKey = "Model" | "Context" | "Skills" | "Extensions" | "Themes";
|
|
35
|
+
|
|
36
|
+
export const SECTION_ICONS: Record<TabKey, string> = {
|
|
37
|
+
Model: "\uf1b2",
|
|
38
|
+
Context: "\udb84\uddd7",
|
|
39
|
+
Skills: "\uec21",
|
|
40
|
+
Extensions: "\ueea8",
|
|
41
|
+
Themes: "\uee72",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
45
|
+
export const stripAnsi = (s: string) => s.replace(ANSI_RE, "");
|
|
46
|
+
|
|
47
|
+
export type Themeish = { fg?(color: string, text: string): string; bg?(color: string, text: string): string } | null;
|
|
48
|
+
|
|
49
|
+
let activeThemeProxy: Themeish = null;
|
|
50
|
+
|
|
51
|
+
const MODEL_SNAPSHOT_KEY = Symbol.for("pi-arnative.currentModel");
|
|
52
|
+
const THINKING_SNAPSHOT_KEY = Symbol.for("pi-arnative.currentThinking");
|
|
53
|
+
|
|
54
|
+
// First theme color that the theme really defines
|
|
55
|
+
export function fgFirst(th: Themeish, names: string[], text: string): string {
|
|
56
|
+
if (!th?.fg) return text;
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
try {
|
|
59
|
+
const out = th.fg(name, text);
|
|
60
|
+
if (typeof out === "string" && out.includes(text)) return out;
|
|
61
|
+
} catch {
|
|
62
|
+
// try the next color name
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return text;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function sectionNameOf(text: string): "Context" | "Skills" | "Extensions" | "Themes" | null {
|
|
69
|
+
const nl = text.indexOf("\n");
|
|
70
|
+
const first = (nl === -1 ? text : text.slice(0, nl)).replace(ANSI_RE, "").trim();
|
|
71
|
+
const m = /^\[([A-Za-z]+)\]$/.exec(first);
|
|
72
|
+
const name = m ? m[1] : null;
|
|
73
|
+
if (name === "Context" || name === "Skills" || name === "Extensions" || name === "Themes") {
|
|
74
|
+
return name;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function packageNameOf(label: string): string | null {
|
|
80
|
+
const base = label.split(":")[0].trim();
|
|
81
|
+
if (base === "") return null;
|
|
82
|
+
const at = base.lastIndexOf("@");
|
|
83
|
+
if (at > 0 && /^\d/.test(base.slice(at + 1))) return null;
|
|
84
|
+
return /^[\w.@/-]+$/.test(base) ? base : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const versionCache = new Map<string, string | null>();
|
|
88
|
+
|
|
89
|
+
export function readVersion(dir: string): string | null {
|
|
90
|
+
try {
|
|
91
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { version?: unknown };
|
|
92
|
+
return typeof pkg.version === "string" && pkg.version !== "" ? pkg.version : null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function installedVersion(
|
|
99
|
+
label: string,
|
|
100
|
+
root = join(getAgentDir(), "npm", "node_modules"),
|
|
101
|
+
gitRoot = join(getAgentDir(), "git"),
|
|
102
|
+
): string | null {
|
|
103
|
+
const name = packageNameOf(label);
|
|
104
|
+
if (!name) return null;
|
|
105
|
+
if (versionCache.has(name)) return versionCache.get(name) ?? null;
|
|
106
|
+
let version = readVersion(join(root, name));
|
|
107
|
+
if (!version && name.includes("/")) {
|
|
108
|
+
try {
|
|
109
|
+
for (const host of readdirSync(gitRoot)) {
|
|
110
|
+
version = readVersion(join(gitRoot, host, name));
|
|
111
|
+
if (version) break;
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
version = null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
versionCache.set(name, version);
|
|
118
|
+
return version;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function extractItemsFromBody(body: string, isExtensions = false): string[] {
|
|
122
|
+
const plain = stripAnsi(body).trim();
|
|
123
|
+
if (!plain) return [];
|
|
124
|
+
const lines = plain.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
125
|
+
const items: string[] = [];
|
|
126
|
+
for (const line of lines) {
|
|
127
|
+
if (line.includes(", ")) {
|
|
128
|
+
items.push(...line.split(", ").map((s) => s.trim()).filter(Boolean));
|
|
129
|
+
} else {
|
|
130
|
+
items.push(line);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!isExtensions) return items;
|
|
134
|
+
return items.map((item) => {
|
|
135
|
+
const colon = item.indexOf(":");
|
|
136
|
+
const name = (colon === -1 ? item : item.slice(0, colon)).trim();
|
|
137
|
+
const ver = installedVersion(name);
|
|
138
|
+
if (!ver) return item;
|
|
139
|
+
const at = colon === -1 ? item.length : colon;
|
|
140
|
+
return item.slice(0, at) + `@${ver}` + item.slice(at);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function wrapCommaItems(items: string[], maxWidth: number): string[] {
|
|
145
|
+
const lines: string[] = [];
|
|
146
|
+
let cur = "";
|
|
147
|
+
for (const item of items) {
|
|
148
|
+
if (!cur) {
|
|
149
|
+
cur = item;
|
|
150
|
+
} else if (visibleWidth(cur) + 2 + visibleWidth(item) <= maxWidth) {
|
|
151
|
+
cur += ", " + item;
|
|
152
|
+
} else {
|
|
153
|
+
lines.push(cur + ",");
|
|
154
|
+
cur = item;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (cur) lines.push(cur);
|
|
158
|
+
return lines;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function centerLine(text: string, width: number, side = "│"): string {
|
|
162
|
+
const sideW = visibleWidth(side);
|
|
163
|
+
const innerW = Math.max(0, width - sideW * 2);
|
|
164
|
+
const textW = visibleWidth(text);
|
|
165
|
+
if (textW >= innerW) {
|
|
166
|
+
return side + truncateToWidth(text, innerW) + side;
|
|
167
|
+
}
|
|
168
|
+
const left = Math.floor((innerW - textW) / 2);
|
|
169
|
+
const right = innerW - textW - left;
|
|
170
|
+
return side + " ".repeat(left) + text + " ".repeat(right) + side;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// pi.dev ASCII logo: P in the theme accent, i in the theme tint
|
|
174
|
+
export function buildLogoLines(th: Themeish): string[] {
|
|
175
|
+
const p = (s: string) => fgFirst(th, ["accent"], s);
|
|
176
|
+
const i = (s: string) => fgFirst(th, ["tint", "text"], s);
|
|
177
|
+
|
|
178
|
+
return [
|
|
179
|
+
`${p("██████████")} `,
|
|
180
|
+
`${p("██████████")} `,
|
|
181
|
+
`${p("████ ███")} `,
|
|
182
|
+
`${p("███████")} ${i("███")}`,
|
|
183
|
+
`${p("███")} ${i("███")}`,
|
|
184
|
+
`${p("███")} ${i("███")}`,
|
|
185
|
+
];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const ASCII_LOGO_LINES = buildLogoLines(null);
|
|
189
|
+
|
|
190
|
+
// Logo column width, derived from the art so it is not a magic number.
|
|
191
|
+
const LOGO_W = Math.max(...ASCII_LOGO_LINES.map((l) => visibleWidth(l)));
|
|
192
|
+
// Info area starts at column: "│ " (2) + logo (LOGO_W) + " │ " (3).
|
|
193
|
+
const INFO_X = LOGO_W + 5;
|
|
194
|
+
// Columns left for info: width - "│ " - logo - " │ " - info - " │".
|
|
195
|
+
const infoWidth = (width: number) => Math.max(8, width - LOGO_W - 7);
|
|
196
|
+
|
|
197
|
+
// Singleton store for loaded resources
|
|
198
|
+
const STORE_KEY = Symbol.for("pi-arnative.resourceStore");
|
|
199
|
+
export const tabStore: Map<TabKey, string[]> =
|
|
200
|
+
(globalThis as Record<symbol, Map<TabKey, string[]>>)[STORE_KEY] ??
|
|
201
|
+
new Map<TabKey, string[]>([
|
|
202
|
+
["Model", []],
|
|
203
|
+
["Context", []],
|
|
204
|
+
["Skills", []],
|
|
205
|
+
["Extensions", []],
|
|
206
|
+
["Themes", []],
|
|
207
|
+
]);
|
|
208
|
+
(globalThis as Record<symbol, Map<TabKey, string[]>>)[STORE_KEY] = tabStore;
|
|
209
|
+
|
|
210
|
+
let activeHeaderInstance: ArnativeHeader | null = null;
|
|
211
|
+
|
|
212
|
+
function capitalize(s: string): string {
|
|
213
|
+
return s ? s.charAt(0).toUpperCase() + s.slice(1) : "";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Model name formatted exactly like in the footer: Gemini 3.8 Flash (Antigravity), Deepseek V4.1 Flash (Yarz), etc.
|
|
217
|
+
export function formatModelDisplayName(model?: { id?: string; name?: string; provider?: string }): string {
|
|
218
|
+
if (!model) return "No Model";
|
|
219
|
+
let name = model.name || model.id || "Model";
|
|
220
|
+
const provider = model.provider ? model.provider.charAt(0).toUpperCase() + model.provider.slice(1) : "";
|
|
221
|
+
if (name === model.id) {
|
|
222
|
+
name = name
|
|
223
|
+
.split(/[-_]/)
|
|
224
|
+
.map((w) => (w.length > 0 ? w.charAt(0).toUpperCase() + w.slice(1) : ""))
|
|
225
|
+
.join(" ");
|
|
226
|
+
}
|
|
227
|
+
if (provider && name.toLowerCase().endsWith(`(${provider.toLowerCase()})`)) {
|
|
228
|
+
name = name.slice(0, name.lastIndexOf("(")).trim();
|
|
229
|
+
}
|
|
230
|
+
const provStr = provider ? ` (${provider})` : "";
|
|
231
|
+
return `${name}${provStr}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export class ArnativeHeader implements Component {
|
|
235
|
+
public activeTab: TabKey = "Model";
|
|
236
|
+
private renderedTabLineY = -1;
|
|
237
|
+
private renderedTabRegions: Array<{ key: TabKey; startX: number; endX: number }> = [];
|
|
238
|
+
|
|
239
|
+
public tui: TUI;
|
|
240
|
+
public themeProxy: Themeish;
|
|
241
|
+
public ctx?: ExtensionContext;
|
|
242
|
+
|
|
243
|
+
constructor(tui: TUI, themeProxy: Themeish, ctx?: ExtensionContext) {
|
|
244
|
+
this.tui = tui;
|
|
245
|
+
this.themeProxy = themeProxy;
|
|
246
|
+
this.ctx = ctx;
|
|
247
|
+
activeHeaderInstance = this;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
invalidate(): void {}
|
|
251
|
+
|
|
252
|
+
dispose(): void {
|
|
253
|
+
if (activeHeaderInstance === this) {
|
|
254
|
+
activeHeaderInstance = null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
setExpanded(_expanded: boolean): void {}
|
|
259
|
+
|
|
260
|
+
getTabsData(width: number): Array<{ key: TabKey; name: string; icon: string; count: number; label: string }> {
|
|
261
|
+
const modelObj = this.ctx?.model ?? (globalThis as Record<symbol, any>)[MODEL_SNAPSHOT_KEY];
|
|
262
|
+
const fullModelName = formatModelDisplayName(modelObj);
|
|
263
|
+
// Drop bracketed details from the tail of the name ("... Free (1M) [OpenCode] (Freeflow)"
|
|
264
|
+
// -> "Space Bunny Free"); the details still show in the Model tab data line.
|
|
265
|
+
let modelLabel = `Model: ${fullModelName.replace(/(\s*\([^()]*\)|\s*\[[^[\]]*\])+$/, "").trim()}`;
|
|
266
|
+
// Responsive on medium screens: drop the provider when columns < 112
|
|
267
|
+
if (width < 112 && modelLabel.includes(" (")) {
|
|
268
|
+
modelLabel = `Model: ${fullModelName.slice(0, fullModelName.indexOf(" (")).trim()}`;
|
|
269
|
+
}
|
|
270
|
+
// Below 88 columns: shorten the model name
|
|
271
|
+
if (width < 88 && modelLabel.length > 18) {
|
|
272
|
+
modelLabel = `Model: ${modelLabel.slice(7, 18).trim()}…`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return [
|
|
276
|
+
{ key: "Model", name: "Model", icon: SECTION_ICONS.Model, count: 0, label: modelLabel },
|
|
277
|
+
{
|
|
278
|
+
key: "Context",
|
|
279
|
+
name: "Context",
|
|
280
|
+
icon: SECTION_ICONS.Context,
|
|
281
|
+
count: tabStore.get("Context")?.length ?? 0,
|
|
282
|
+
label: `Context [${tabStore.get("Context")?.length ?? 0}]`,
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
key: "Skills",
|
|
286
|
+
name: "Skills",
|
|
287
|
+
icon: SECTION_ICONS.Skills,
|
|
288
|
+
count: tabStore.get("Skills")?.length ?? 0,
|
|
289
|
+
label: `Skills [${tabStore.get("Skills")?.length ?? 0}]`,
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
key: "Extensions",
|
|
293
|
+
name: "Extensions",
|
|
294
|
+
icon: SECTION_ICONS.Extensions,
|
|
295
|
+
count: tabStore.get("Extensions")?.length ?? 0,
|
|
296
|
+
label: `Extensions [${tabStore.get("Extensions")?.length ?? 0}]`,
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
key: "Themes",
|
|
300
|
+
name: "Themes",
|
|
301
|
+
icon: SECTION_ICONS.Themes,
|
|
302
|
+
count: tabStore.get("Themes")?.length ?? 0,
|
|
303
|
+
label: `Themes [${tabStore.get("Themes")?.length ?? 0}]`,
|
|
304
|
+
},
|
|
305
|
+
];
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Shortcuts are registered through pi.registerShortcut (the header is not a keyboard input target).
|
|
309
|
+
|
|
310
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
311
|
+
if (event?.type !== "click" || event?.button !== "left") return undefined;
|
|
312
|
+
if (event.y === this.renderedTabLineY && this.renderedTabRegions.length > 0) {
|
|
313
|
+
let selected: TabKey | null = null;
|
|
314
|
+
for (const r of this.renderedTabRegions) {
|
|
315
|
+
if (event.x >= r.startX && event.x <= r.endX) {
|
|
316
|
+
selected = r.key;
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (!selected) {
|
|
321
|
+
let minDist = Infinity;
|
|
322
|
+
for (const r of this.renderedTabRegions) {
|
|
323
|
+
const dist = Math.min(Math.abs(event.x - r.startX), Math.abs(event.x - r.endX));
|
|
324
|
+
if (dist < minDist) {
|
|
325
|
+
minDist = dist;
|
|
326
|
+
selected = r.key;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (selected && selected !== this.activeTab) {
|
|
331
|
+
this.activeTab = selected;
|
|
332
|
+
this.tui?.requestRender?.();
|
|
333
|
+
return { handled: true, render: true };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
render(width: number): string[] {
|
|
340
|
+
if (width < 36) {
|
|
341
|
+
return [centerLine(`pi v${VERSION}`, width)];
|
|
342
|
+
}
|
|
343
|
+
const th = this.themeProxy || (activeThemeProxy as Themeish);
|
|
344
|
+
const side = fgFirst(th, ["dim"], "│");
|
|
345
|
+
const dash = "─".repeat(Math.max(0, width - 2));
|
|
346
|
+
|
|
347
|
+
// The left column holds the logo + vertical divider, so the info area only
|
|
348
|
+
// gets width - LOGO_W - 7 columns.
|
|
349
|
+
const infoW = infoWidth(width);
|
|
350
|
+
const infoLine = (text: string): string => {
|
|
351
|
+
const t = truncateToWidth(text, infoW);
|
|
352
|
+
return t + " ".repeat(infoW - visibleWidth(t));
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const out: string[] = [fgFirst(th, ["dim"], `╭${dash}╮`)];
|
|
356
|
+
const infoLines: string[] = [];
|
|
357
|
+
// Inner box: the tab menu line is framed, content is (infoW - 2) columns.
|
|
358
|
+
const innerW = Math.max(1, infoW - 2);
|
|
359
|
+
|
|
360
|
+
// 1. pi version + brand line. Always intact.
|
|
361
|
+
const versionStr = VERSION || "0.87.1";
|
|
362
|
+
const piPart = `${fgFirst(th, ["accent", "tint"], "\x1b[1mpi\x1b[22m")} ${fgFirst(th, ["dim"], `v${versionStr}`)}`;
|
|
363
|
+
infoLines.push(`${piPart}${fgFirst(th, ["dim"], " · ")}${fgFirst(th, ["dim"], "Arnative")}`);
|
|
364
|
+
const shortcutItems = [
|
|
365
|
+
`${fgFirst(th, ["tint"], "[Esc]")} ${fgFirst(th, ["dim"], "Interrupt")}`,
|
|
366
|
+
`${fgFirst(th, ["tint"], "[Ctrl+c/d]")} ${fgFirst(th, ["dim"], "Exit")}`,
|
|
367
|
+
`${fgFirst(th, ["tint"], "[/]")} ${fgFirst(th, ["dim"], "Commands")}`,
|
|
368
|
+
`${fgFirst(th, ["tint"], "[!]")} ${fgFirst(th, ["dim"], "Bash")}`,
|
|
369
|
+
`${fgFirst(th, ["tint"], "[Ctrl+o]")} ${fgFirst(th, ["dim"], "More/expand")}`,
|
|
370
|
+
`${fgFirst(th, ["tint"], "[Ctrl+alt+t]")} ${fgFirst(th, ["dim"], "Next tab")}`,
|
|
371
|
+
];
|
|
372
|
+
// Shortcut list is trimmed from the right (longest prefix that still fits) so
|
|
373
|
+
// narrow screens never clip the tabs or the data.
|
|
374
|
+
let scText = "";
|
|
375
|
+
for (let i = 1; i <= shortcutItems.length; i++) {
|
|
376
|
+
const cand = shortcutItems.slice(0, i).join(" ");
|
|
377
|
+
if (visibleWidth(cand) <= infoW) scText = cand;
|
|
378
|
+
}
|
|
379
|
+
infoLines.push(scText);
|
|
380
|
+
|
|
381
|
+
// 2. Tab menu: icon = accent, text = tint (bold when active)
|
|
382
|
+
const tabs = this.getTabsData(width);
|
|
383
|
+
const sepTabPlain = " │ ";
|
|
384
|
+
const sepTab = fgFirst(th, ["dim"], sepTabPlain);
|
|
385
|
+
|
|
386
|
+
const tabParts: Array<{ key: TabKey; plain: string; formatted: string }> = [];
|
|
387
|
+
for (const t of tabs) {
|
|
388
|
+
const plain = `${t.icon} ${t.label}`;
|
|
389
|
+
const isActive = t.key === this.activeTab;
|
|
390
|
+
const iconStyled = isActive
|
|
391
|
+
? fgFirst(th, ["accent"], t.icon)
|
|
392
|
+
: fgFirst(th, ["dim"], t.icon);
|
|
393
|
+
const labelStyled = isActive
|
|
394
|
+
? fgFirst(th, ["tint", "text"], `\x1b[1m${t.label}\x1b[22m`)
|
|
395
|
+
: fgFirst(th, ["dim"], t.label);
|
|
396
|
+
const formatted = `${iconStyled} ${labelStyled}`;
|
|
397
|
+
tabParts.push({ key: t.key, plain, formatted });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Tabs that don't fit on narrow screens are dropped from the right (the active tab always stays).
|
|
401
|
+
const kept: typeof tabParts = [];
|
|
402
|
+
let usedWidth = 0;
|
|
403
|
+
for (const t of tabParts) {
|
|
404
|
+
const w = visibleWidth(t.plain) + (kept.length > 0 ? visibleWidth(sepTabPlain) : 0);
|
|
405
|
+
if (kept.length === 0 || usedWidth + w <= innerW) {
|
|
406
|
+
kept.push(t);
|
|
407
|
+
usedWidth += w;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
this.renderedTabRegions = [];
|
|
411
|
+
let curX = INFO_X;
|
|
412
|
+
for (const t of kept) {
|
|
413
|
+
const w = visibleWidth(t.plain);
|
|
414
|
+
this.renderedTabRegions.push({ key: t.key, startX: curX, endX: curX + w - 1 });
|
|
415
|
+
curX += w + visibleWidth(sepTabPlain);
|
|
416
|
+
}
|
|
417
|
+
// The tab box is as wide as its content, not as wide as the info column.
|
|
418
|
+
const tabText = ` ${kept.map((t) => t.formatted).join(sepTab)}`;
|
|
419
|
+
const tabInnerW = Math.min(innerW, visibleWidth(tabText) + 1);
|
|
420
|
+
const tabRow = (text: string): string => {
|
|
421
|
+
const t = truncateToWidth(text, tabInnerW);
|
|
422
|
+
return `${side}${t}${" ".repeat(tabInnerW - visibleWidth(t))}${side}`;
|
|
423
|
+
};
|
|
424
|
+
const tabRule = (l: string, r: string): string => fgFirst(th, ["dim"], `${l}${"─".repeat(tabInnerW)}${r}`);
|
|
425
|
+
infoLines.push(tabRule("╭", "╮"));
|
|
426
|
+
// One space from the left border, then " │ " between tabs.
|
|
427
|
+
infoLines.push(tabRow(tabText));
|
|
428
|
+
// Tab line = top border (index 0) + the infoLines above + this line.
|
|
429
|
+
this.renderedTabLineY = infoLines.length;
|
|
430
|
+
infoLines.push(tabRule("╰", "╯"));
|
|
431
|
+
|
|
432
|
+
// 3. Active tab data (left-aligned, neatly wrapped, tint)
|
|
433
|
+
let activeItems: string[] = [];
|
|
434
|
+
if (this.activeTab === "Model") {
|
|
435
|
+
const modelObj = this.ctx?.model ?? (globalThis as Record<symbol, any>)[MODEL_SNAPSHOT_KEY];
|
|
436
|
+
const fullName = formatModelDisplayName(modelObj);
|
|
437
|
+
const thLvl = this.ctx?.thinkingLevel ?? (globalThis as Record<symbol, any>)[THINKING_SNAPSHOT_KEY];
|
|
438
|
+
const thinkLevel = thLvl && thLvl !== "off" ? capitalize(thLvl) : "Off";
|
|
439
|
+
const sep = fgFirst(th, ["dim"], " · ");
|
|
440
|
+
// Model name in tint, its "(Provider)" tail dim.
|
|
441
|
+
const provSuffix = fullName.match(/(\s*\([^()]*\))$/)?.[1] ?? "";
|
|
442
|
+
const nameMain = provSuffix ? fullName.slice(0, -provSuffix.length) : fullName;
|
|
443
|
+
let line = `${fgFirst(th, ["tint", "text"], nameMain)}${provSuffix ? fgFirst(th, ["dim"], provSuffix) : ""}${sep}${fgFirst(th, ["tint", "text"], `Thinking: ${thinkLevel}`)}`;
|
|
444
|
+
|
|
445
|
+
// All-time usage of that model (same as /usage): ↑... ↓... ...
|
|
446
|
+
const usage = getModelAllTimeUsage(modelObj?.id);
|
|
447
|
+
const tokenParts: string[] = [];
|
|
448
|
+
if (usage.input > 0) {
|
|
449
|
+
tokenParts.push(`${fgFirst(th, ["accent"], "↑")}${fgFirst(th, ["tint", "text"], formatTokens(usage.input))}`);
|
|
450
|
+
}
|
|
451
|
+
if (usage.output > 0) {
|
|
452
|
+
tokenParts.push(`${fgFirst(th, ["accent"], "↓")}${fgFirst(th, ["tint", "text"], formatTokens(usage.output))}`);
|
|
453
|
+
}
|
|
454
|
+
if (usage.cacheRead > 0) {
|
|
455
|
+
tokenParts.push(`${fgFirst(th, ["accent"], "\uf49b ")}${fgFirst(th, ["tint", "text"], formatTokens(usage.cacheRead))}`);
|
|
456
|
+
}
|
|
457
|
+
if (tokenParts.length > 0) {
|
|
458
|
+
line += `${sep}${tokenParts.join(" ")}`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
activeItems = [line];
|
|
462
|
+
} else {
|
|
463
|
+
activeItems = tabStore.get(this.activeTab) ?? [];
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (activeItems.length === 0) {
|
|
467
|
+
infoLines.push(fgFirst(th, ["tint"], "(kosong)"));
|
|
468
|
+
} else if (this.activeTab === "Model") {
|
|
469
|
+
// The Model tab already carries tint styling + a dim separator
|
|
470
|
+
infoLines.push(...activeItems);
|
|
471
|
+
} else {
|
|
472
|
+
for (const line of wrapCommaItems(activeItems, infoW)) {
|
|
473
|
+
// Point 5: selected menu data uses tint only
|
|
474
|
+
infoLines.push(fgFirst(th, ["tint", "text"], line));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Compose lines: logo in the left column, vertical divider, left-aligned info.
|
|
479
|
+
// The logo is taller than the info -> the rest is left blank.
|
|
480
|
+
const logoLines = buildLogoLines(th);
|
|
481
|
+
const blankLogo = " ".repeat(LOGO_W);
|
|
482
|
+
for (let i = 0; i < Math.max(logoLines.length, infoLines.length); i++) {
|
|
483
|
+
const logo = logoLines[i] ?? blankLogo;
|
|
484
|
+
out.push(`${side} ${logo} ${side} ${infoLine(infoLines[i] ?? "")} ${side}`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Bottom border
|
|
488
|
+
out.push(fgFirst(th, ["dim"], `╰${dash}╯`));
|
|
489
|
+
|
|
490
|
+
return out;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Intercept loadedResourcesContainer: capture data for the tabs and hide pi's own display
|
|
495
|
+
const SECTION_HEADER_KEY = Symbol.for("pi-arnative.sectionHeadersRewritten");
|
|
496
|
+
if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[SECTION_HEADER_KEY]) {
|
|
497
|
+
(globalThis as Record<symbol, boolean>)[SECTION_HEADER_KEY] = true;
|
|
498
|
+
const containerProto = Object.getPrototypeOf(UserMessageComponent.prototype) as {
|
|
499
|
+
addChild?: (child: unknown) => unknown;
|
|
500
|
+
};
|
|
501
|
+
if (typeof containerProto?.addChild === "function") {
|
|
502
|
+
const origAddChild = containerProto.addChild;
|
|
503
|
+
containerProto.addChild = function (child: any): unknown {
|
|
504
|
+
try {
|
|
505
|
+
const sectionName = sectionNameOf(String(child?.getCollapsedText?.() ?? ""));
|
|
506
|
+
const isSection =
|
|
507
|
+
child &&
|
|
508
|
+
typeof child.getCollapsedText === "function" &&
|
|
509
|
+
typeof child.getExpandedText === "function" &&
|
|
510
|
+
typeof child.setText === "function" &&
|
|
511
|
+
(sectionName === null ? false : SECTION_ICONS[sectionName] !== undefined);
|
|
512
|
+
|
|
513
|
+
if (isSection && sectionName) {
|
|
514
|
+
// Mark this container as the loadedResourcesContainer
|
|
515
|
+
(this as Record<string, unknown>)._isLoadedResourcesContainer = true;
|
|
516
|
+
|
|
517
|
+
const origCollapsed = child.getCollapsedText.bind(child) as () => string;
|
|
518
|
+
const rawBody = origCollapsed().split("\n").slice(1).join("\n");
|
|
519
|
+
const items = extractItemsFromBody(rawBody, sectionName === "Extensions");
|
|
520
|
+
tabStore.set(sectionName, items);
|
|
521
|
+
|
|
522
|
+
// Hide this child from the built-in display so it does not stack under the header
|
|
523
|
+
child.render = () => [];
|
|
524
|
+
|
|
525
|
+
// Re-render the header when it is already installed
|
|
526
|
+
if (activeHeaderInstance?.tui) {
|
|
527
|
+
activeHeaderInstance.tui.requestRender();
|
|
528
|
+
}
|
|
529
|
+
} else if ((this as Record<string, unknown>)._isLoadedResourcesContainer) {
|
|
530
|
+
// pi core's "[Extension issues]" -> wrap in an arnative box.
|
|
531
|
+
const text = String(child?.text ?? child?.content ?? (typeof child?.getText === "function" ? child.getText() : ""));
|
|
532
|
+
if (text.includes("[Extension issues]") && typeof child.render === "function") {
|
|
533
|
+
const origRender = child.render.bind(child);
|
|
534
|
+
child.render = (w: number) => renderBoxLines(activeThemeProxy, w, origRender(w), undefined, "warning");
|
|
535
|
+
return origAddChild.call(this, child);
|
|
536
|
+
}
|
|
537
|
+
// Any other element inside loadedResourcesContainer (e.g. Spacer) is hidden too
|
|
538
|
+
if (child && typeof child.render === "function") {
|
|
539
|
+
child.render = () => [];
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
} catch {
|
|
543
|
+
// fail-safe
|
|
544
|
+
}
|
|
545
|
+
return origAddChild.call(this, child);
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export default function (pi: ExtensionAPI) {
|
|
551
|
+
// ctrl+alt+t: next tab.
|
|
552
|
+
pi.registerShortcut(Key.ctrlAlt("t"), {
|
|
553
|
+
description: "Next header tab",
|
|
554
|
+
handler: async () => {
|
|
555
|
+
const hdr = activeHeaderInstance;
|
|
556
|
+
if (!hdr) return;
|
|
557
|
+
const order: TabKey[] = ["Model", "Context", "Skills", "Extensions", "Themes"];
|
|
558
|
+
hdr.activeTab = order[(order.indexOf(hdr.activeTab) + 1) % order.length];
|
|
559
|
+
hdr.tui?.requestRender?.();
|
|
560
|
+
},
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
564
|
+
activeThemeProxy = (ctx as unknown as { ui?: { theme?: typeof activeThemeProxy } }).ui?.theme ?? activeThemeProxy;
|
|
565
|
+
// Daftarkan Custom Header
|
|
566
|
+
ctx.ui?.setHeader?.((tui, theme) => {
|
|
567
|
+
return new ArnativeHeader(tui, theme, ctx);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
pi.on("turn_start", async (_event, ctx) => {
|
|
572
|
+
(globalThis as Record<symbol, any>)[MODEL_SNAPSHOT_KEY] = ctx.model;
|
|
573
|
+
(globalThis as Record<symbol, any>)[THINKING_SNAPSHOT_KEY] = ctx.thinkingLevel;
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
pi.on("turn_end", async () => {
|
|
577
|
+
// Usage is re-read at render time (collectUsageSummary caches 5s), so poking a render is enough.
|
|
578
|
+
if (activeHeaderInstance?.tui) {
|
|
579
|
+
activeHeaderInstance.tui.requestRender();
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Self-check: `node extensions/section-headers.ts`
|
|
585
|
+
const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("\\").join("/"));
|
|
586
|
+
if (isMain) {
|
|
587
|
+
const assert = (cond: boolean, msg: string) => {
|
|
588
|
+
if (!cond) {
|
|
589
|
+
console.error(`FAIL: ${msg}`);
|
|
590
|
+
process.exit(1);
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
assert(sectionNameOf("\x1b[33m[Skills]\x1b[39m\n a, b") === "Skills", "header [Skills] terdeteksi");
|
|
595
|
+
assert(sectionNameOf("pi v0.87.1") === null, "teks non-seksi tak terdeteksi");
|
|
596
|
+
|
|
597
|
+
// Parsing & wrapping
|
|
598
|
+
assert(extractItemsFromBody(" a, b, c").length === 3, "extractItemsFromBody koma = 3");
|
|
599
|
+
assert(extractItemsFromBody(" x\n y\n z").length === 3, "extractItemsFromBody baris = 3");
|
|
600
|
+
|
|
601
|
+
const wrapTest = wrapCommaItems(["item1", "item2", "item3", "item4"], 15);
|
|
602
|
+
assert(wrapTest.length >= 2, "wrapCommaItems membungkus baris");
|
|
603
|
+
assert(visibleWidth(wrapTest[0]!) <= 15, "lebar baris wrap sesuai batas");
|
|
604
|
+
|
|
605
|
+
const cLine = centerLine("test", 20);
|
|
606
|
+
assert(visibleWidth(cLine) === 20, "centerLine tepat 20 kolom");
|
|
607
|
+
assert(cLine.startsWith("│") && cLine.endsWith("│"), "centerLine diawali dan diakhiri │");
|
|
608
|
+
|
|
609
|
+
// Model name formatting
|
|
610
|
+
assert(
|
|
611
|
+
formatModelDisplayName({ name: "Gemini 3.8 Flash", provider: "antigravity" }) ===
|
|
612
|
+
"Gemini 3.8 Flash (Antigravity)",
|
|
613
|
+
"formatModelDisplayName dengan name dan provider",
|
|
614
|
+
);
|
|
615
|
+
assert(
|
|
616
|
+
formatModelDisplayName({ id: "deepseek-v4.1-flash", provider: "yarz" }) ===
|
|
617
|
+
"Deepseek V4.1 Flash (Yarz)",
|
|
618
|
+
"formatModelDisplayName konversi id jadi titlecase",
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
// Package & version resolver
|
|
622
|
+
assert(packageNameOf("@bismawy/pi-agentrouter") === "@bismawy/pi-agentrouter", "label npm polos");
|
|
623
|
+
assert(packageNameOf("footer.ts") === "footer.ts", "label lokal");
|
|
624
|
+
|
|
625
|
+
const gitTmp = mkdtempSync(join(tmpdir(), "pi-ver-"));
|
|
626
|
+
mkdirSync(join(gitTmp, "git", "github.com", "uji/arnative"), { recursive: true });
|
|
627
|
+
writeFileSync(join(gitTmp, "git", "github.com", "uji/arnative", "package.json"), JSON.stringify({ version: "9.9.9" }));
|
|
628
|
+
assert(installedVersion("uji/arnative", join(gitTmp, "npm"), join(gitTmp, "git")) === "9.9.9", "versi dari klon git");
|
|
629
|
+
rmSync(gitTmp, { recursive: true, force: true });
|
|
630
|
+
|
|
631
|
+
// ArnativeHeader component & mouse tab click
|
|
632
|
+
tabStore.set("Context", ["AGENTS.md"]);
|
|
633
|
+
tabStore.set("Skills", ["agents-sdk", "cloudflare"]);
|
|
634
|
+
tabStore.set("Extensions", ["@bismawy/pi-agentrouter@1.6.1", "footer.ts"]);
|
|
635
|
+
tabStore.set("Themes", ["arnative", "dark"]);
|
|
636
|
+
|
|
637
|
+
let renderRequested = false;
|
|
638
|
+
const fakeTui: any = {
|
|
639
|
+
requestRender: () => {
|
|
640
|
+
renderRequested = true;
|
|
641
|
+
},
|
|
642
|
+
};
|
|
643
|
+
const fakeCtx: any = {
|
|
644
|
+
model: { name: "Gemini 3.8 Flash", provider: "antigravity" },
|
|
645
|
+
thinkingLevel: "low",
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
// Only "dim" is colored, so the box line colors can be asserted.
|
|
649
|
+
const ansiTheme = { fg: (c: string, t: string) => (c === "dim" ? `\x1b[2m${t}\x1b[22m` : t) };
|
|
650
|
+
const hdr = new ArnativeHeader(fakeTui, ansiTheme, fakeCtx);
|
|
651
|
+
assert(hdr.activeTab === "Model", "default tab adalah Model");
|
|
652
|
+
|
|
653
|
+
const lines100 = hdr.render(100);
|
|
654
|
+
assert(lines100.length === 8, "render 100 = border + 6 baris logo + border (3 baris info mengisi 3 baris logo)");
|
|
655
|
+
assert(lines100.every((l) => visibleWidth(l) === 100), "semua baris render 100 tepat 100 kolom");
|
|
656
|
+
assert(lines100[0]!.includes("╭") && lines100[0]!.includes("╮"), "border atas rounded");
|
|
657
|
+
assert(lines100[7]!.includes("╰") && lines100[7]!.includes("╯"), "border bawah rounded");
|
|
658
|
+
const logoPlain = buildLogoLines(null);
|
|
659
|
+
assert(logoPlain.length === 6 && visibleWidth(logoPlain[0]!) === 13, "ascii Pi asli utuh (6 baris x 13 kolom)");
|
|
660
|
+
assert(
|
|
661
|
+
lines100.slice(1, 7).every((l, i) => stripAnsi(l).slice(2, 15) === logoPlain[i]),
|
|
662
|
+
"ascii Pi asli di kolom kiri, 6 baris",
|
|
663
|
+
);
|
|
664
|
+
assert(stripAnsi(lines100[1]!).includes("pi v") && stripAnsi(lines100[1]!).includes("Arnative"), "baris versi + merek");
|
|
665
|
+
assert(stripAnsi(lines100[4]!).includes("Model: Gemini 3.8 Flash"), "menu tab Model tampil");
|
|
666
|
+
assert(stripAnsi(lines100[6]!).includes("Gemini 3.8 Flash (Antigravity)"), "data tab aktif Model tampil di baris info terakhir");
|
|
667
|
+
// The inner box lines are one fully dimmed block (the "─" runs are colored too, not just the corners).
|
|
668
|
+
for (const i of [3, 5]) {
|
|
669
|
+
assert((lines100[i]!.match(/\x1b\[/g) ?? []).length === 8, `garis kotak dalam baris ${i} = satu blok dim penuh (garis "─" ikut ter-warnai, bukan hanya sudut)`);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Tab regions: Model, Context, Skills, Extensions, Themes
|
|
673
|
+
const tabY = (hdr as any).renderedTabLineY;
|
|
674
|
+
assert(tabY > 0, "posisi Y baris tab tercatat");
|
|
675
|
+
const tabRegions = (hdr as any).renderedTabRegions as Array<{ key: TabKey; startX: number; endX: number }>;
|
|
676
|
+
assert(tabRegions.length >= 4, "region tab terisi (tab paling kanan bisa terpotong di 100 kolom)");
|
|
677
|
+
|
|
678
|
+
// Click the Extensions tab (when truncated at 100 columns, use the rightmost tab)
|
|
679
|
+
const extRegion = tabRegions.find((r) => r.key === "Extensions") ?? tabRegions[tabRegions.length - 1]!;
|
|
680
|
+
const clickExt = hdr.handleMouse({
|
|
681
|
+
type: "click",
|
|
682
|
+
button: "left",
|
|
683
|
+
x: Math.floor((extRegion.startX + extRegion.endX) / 2),
|
|
684
|
+
y: tabY,
|
|
685
|
+
} as any);
|
|
686
|
+
assert(clickExt?.handled === true, "klik mouse pada tab handled");
|
|
687
|
+
assert(hdr.activeTab === extRegion.key, "activeTab berubah ke tab yang diklik");
|
|
688
|
+
assert(renderRequested === true, "requestRender dipanggil saat tab berganti");
|
|
689
|
+
|
|
690
|
+
const linesExt = hdr.render(100);
|
|
691
|
+
assert(stripAnsi(linesExt[6]!).includes(extRegion.key === "Extensions" ? "@bismawy/pi-agentrouter@1.6.1" : ""), "data tab aktif tampil di baris info terakhir");
|
|
692
|
+
|
|
693
|
+
// Click the Skills tab
|
|
694
|
+
const skillsRegion = tabRegions.find((r) => r.key === "Skills") ?? tabRegions[0]!;
|
|
695
|
+
hdr.handleMouse({
|
|
696
|
+
type: "click",
|
|
697
|
+
button: "left",
|
|
698
|
+
x: Math.floor((skillsRegion.startX + skillsRegion.endX) / 2),
|
|
699
|
+
y: tabY,
|
|
700
|
+
} as any);
|
|
701
|
+
assert(hdr.activeTab === "Skills", "activeTab berubah ke Skills setelah klik");
|
|
702
|
+
|
|
703
|
+
const linesSkills = hdr.render(100);
|
|
704
|
+
assert(linesSkills[6]!.includes("agents-sdk, cloudflare"), "data tab Skills tampil di baris info terakhir");
|
|
705
|
+
assert(!linesSkills[6]!.includes("Gemini 3.8 Flash (Antigravity)"), "data Model tidak tampil di konten tab Skills");
|
|
706
|
+
assert(linesSkills[4]!.includes("Model: Gemini 3.8 Flash"), "menu tab tetap tampil saat tab lain aktif");
|
|
707
|
+
|
|
708
|
+
// The Y of a tab must point at the tab menu line, not the shortcut line above
|
|
709
|
+
const rowsPlain = lines100.map((l) => stripAnsi(l));
|
|
710
|
+
assert(rowsPlain[tabY]!.includes("Model:"), "renderedTabLineY menunjuk baris menu tab");
|
|
711
|
+
assert(tabRegions.every((r) => rowsPlain[tabY]!.slice(r.startX, r.endX + 1).includes(r.key === "Model" ? "Model:" : r.key)), "region tab rata dengan teks di baris itu");
|
|
712
|
+
|
|
713
|
+
// Narrow screen: version + tabs + data stay intact, shortcuts get trimmed
|
|
714
|
+
const narrow = hdr.render(60);
|
|
715
|
+
assert(narrow.every((l) => visibleWidth(l) === 60), "baris 60 kolom tetap 60");
|
|
716
|
+
assert(stripAnsi(narrow[1]!).includes("pi v"), "versi tetap utuh di 60 kolom");
|
|
717
|
+
assert(stripAnsi(narrow[1]!).includes("More/expand") === false, "shortcut panjang dibuang di 60 kolom");
|
|
718
|
+
assert(stripAnsi(narrow[4]!).includes("Model:"), "tab Model tetap tampil di 60 kolom");
|
|
719
|
+
|
|
720
|
+
// Intercept addChild: capture the data and suppress the child
|
|
721
|
+
const proto = Object.getPrototypeOf(UserMessageComponent.prototype) as { addChild?: unknown };
|
|
722
|
+
const container: any = { children: [] as any[], addChild: proto.addChild as (c: any) => unknown };
|
|
723
|
+
const fakeSection = {
|
|
724
|
+
getCollapsedText: () => "[Skills]\n new-skill-1, new-skill-2",
|
|
725
|
+
getExpandedText: () => "[Skills]\n new-skill-1, new-skill-2",
|
|
726
|
+
setText: () => {},
|
|
727
|
+
render: (_w: number) => ["should be hidden"],
|
|
728
|
+
};
|
|
729
|
+
container.addChild(fakeSection);
|
|
730
|
+
assert(fakeSection.render(80).length === 0, "child di loadedResourcesContainer di-suppress (render = [])");
|
|
731
|
+
assert(tabStore.get("Skills")?.includes("new-skill-1") === true, "data baru masuk ke tabStore");
|
|
732
|
+
|
|
733
|
+
console.log("section-headers.ts self-check OK");
|
|
734
|
+
}
|