@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,576 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Five small patches to pi's fullscreen renderer (render layer only, session data
|
|
3
|
+
* untouched):
|
|
4
|
+
* 1. Hide pi's built-in "Reloading keybindings, extensions, skills..." box
|
|
5
|
+
* (hardcoded in handleReloadCommand, no auto-hide).
|
|
6
|
+
* 2. Drag selection: pi only wraps the selection in reverse video (\x1b[7m), which
|
|
7
|
+
* is unreadable in some terminals/themes. Replaced with explicit colors
|
|
8
|
+
* (bg selectedBg + fg text) so text always stays visible.
|
|
9
|
+
* 3. Compaction message: pi's built-in is label + blank line + text (3 lines);
|
|
10
|
+
* we squeeze it into one line: `[compaction] Compacted from N tokens (ctrl+o to expand)`.
|
|
11
|
+
* 4. Custom `pi-jev-eye-review` message: pi adds a `[pi-jev-eye-review]` label +
|
|
12
|
+
* blank line inside a purple box; our renderer shows the content as-is.
|
|
13
|
+
* 5. `↓ Jump to latest message` pill (fullscreen): bg selectedBg (grey) -> accent,
|
|
14
|
+
* text darkened (theme canvas) so contrast stays high (9.6:1).
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
CompactionSummaryMessageComponent,
|
|
18
|
+
CustomMessageComponent,
|
|
19
|
+
getMarkdownTheme,
|
|
20
|
+
initTheme,
|
|
21
|
+
InteractiveMode,
|
|
22
|
+
keyText,
|
|
23
|
+
Theme,
|
|
24
|
+
UserMessageComponent,
|
|
25
|
+
type ExtensionAPI,
|
|
26
|
+
} from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import {
|
|
28
|
+
Container,
|
|
29
|
+
Markdown,
|
|
30
|
+
MouseRegion,
|
|
31
|
+
Spacer,
|
|
32
|
+
Text,
|
|
33
|
+
TuiAltScreen,
|
|
34
|
+
visibleWidth,
|
|
35
|
+
type Component,
|
|
36
|
+
type TuiMouseEvent,
|
|
37
|
+
type TuiMouseEventResult,
|
|
38
|
+
} from "@earendil-works/pi-tui";
|
|
39
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
40
|
+
import { renderBoxLines } from "../lib/box.ts";
|
|
41
|
+
import { ansiBgOpen } from "../lib/ansi.ts";
|
|
42
|
+
export { ansiBgOpen };
|
|
43
|
+
|
|
44
|
+
let activeThemeProxy: { fg(color: string, text: string): string; bg?(color: string, text: string): string } | null = null;
|
|
45
|
+
|
|
46
|
+
// Hide pi's built-in "Reloading keybindings, extensions, skills..." box
|
|
47
|
+
// (hardcoded in handleReloadCommand, no auto-hide). Patches Container.render
|
|
48
|
+
// through the prototype chain (Container is not exported) and drops the whole box on match.
|
|
49
|
+
export function shouldHideReloadBox(lines: readonly string[]): boolean {
|
|
50
|
+
return lines.some((l) => l.includes("Reloading keybindings"));
|
|
51
|
+
}
|
|
52
|
+
const RELOAD_BOX_KEY = Symbol.for("pi-arnative.reloadBoxHidden");
|
|
53
|
+
if (UserMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[RELOAD_BOX_KEY]) {
|
|
54
|
+
(globalThis as Record<symbol, boolean>)[RELOAD_BOX_KEY] = true;
|
|
55
|
+
const containerProto = Object.getPrototypeOf(UserMessageComponent.prototype) as { render?: (width: number) => string[] };
|
|
56
|
+
if (typeof containerProto?.render === "function") {
|
|
57
|
+
const origContainerRender = containerProto.render;
|
|
58
|
+
containerProto.render = function (width: number): string[] {
|
|
59
|
+
const lines = origContainerRender.call(this, width);
|
|
60
|
+
return shouldHideReloadBox(lines) ? [] : lines;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// bg open code (no closing reset) -> lib/ansi.ts (single definition).
|
|
66
|
+
|
|
67
|
+
// Drag selection in pi fullscreen: pi only wraps the selection in reverse video
|
|
68
|
+
// (\x1b[7m), unreadable in some terminals/themes. Replaced with explicit colors
|
|
69
|
+
// (bg selectedBg + fg text) so text always stays visible.
|
|
70
|
+
const SELECTION_COLOR_KEY = Symbol.for("pi-arnative.selectionColorsPatched");
|
|
71
|
+
|
|
72
|
+
export function recolorSelection(highlighted: string, fgOpen: string, bgOpen: string): string {
|
|
73
|
+
return highlighted.split("\x1b[7m").join(fgOpen + bgOpen).split("\x1b[27m").join("\x1b[39m\x1b[49m");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function ansiFgOpen(th: { fg?(c: string, t: string): string } | null, color = "text"): string {
|
|
77
|
+
if (!th?.fg) return "";
|
|
78
|
+
try {
|
|
79
|
+
const probe = th.fg(color, "");
|
|
80
|
+
const reset = "\x1b[39m";
|
|
81
|
+
return probe.endsWith(reset) ? probe.slice(0, -reset.length) : probe;
|
|
82
|
+
} catch {
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// `↓ Jump to latest message` pill (fullscreen): pi colors it with bg `selectedBg`
|
|
88
|
+
// (grey). Switched to accent bg + darkened canvas text (contrast 9.6:1, WCAG AA).
|
|
89
|
+
// Note: pi only has 7 background tokens (selectedBg/userMessageBg/customMessageBg/
|
|
90
|
+
// toolPendingBg/toolSuccessBg/toolErrorBg/searchMatchBg) — `accent` is a text
|
|
91
|
+
// token, so the SGR escapes are swapped. `bg("accent")` would throw "Unknown theme
|
|
92
|
+
// background color" and the pill would silently stay unchanged.
|
|
93
|
+
const PILL_KEY = Symbol.for("pi-arnative.accentPill");
|
|
94
|
+
const PILL_MARK = "↓ Jump to latest message";
|
|
95
|
+
const PILL_INK = "toolPendingBg";
|
|
96
|
+
|
|
97
|
+
const asBg = (seq: string) => seq.replace(/\x1b\[38;/g, "\x1b[48;");
|
|
98
|
+
const asFg = (seq: string) => seq.replace(/\x1b\[48;/g, "\x1b[38;");
|
|
99
|
+
|
|
100
|
+
export function accentPill(
|
|
101
|
+
text: string,
|
|
102
|
+
th: { fg?(c: string, t: string): string; bg?(c: string, t: string): string } | null,
|
|
103
|
+
): string {
|
|
104
|
+
if (!text.includes(PILL_MARK) || !th?.fg || !th?.bg) return text;
|
|
105
|
+
const accentBg = asBg(ansiFgOpen(th, "accent"));
|
|
106
|
+
const inkFg = asFg(ansiBgOpen(th, PILL_INK));
|
|
107
|
+
if (!accentBg || !inkFg) return text; // token tak ada di tema -> biarkan bawaan pi
|
|
108
|
+
return `${accentBg}${inkFg}${text.replace(/\x1b\[[0-9;]*m/g, "")}\x1b[39m\x1b[49m`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// TuiAltScreen builds the pill via the per-instance `scrollToEndIndicator` callback
|
|
112
|
+
// (an instance field, so the prototype cannot be patched). Swap in our callback
|
|
113
|
+
// only while the composite runs, then restore the original.
|
|
114
|
+
export function withAccentPill<T>(
|
|
115
|
+
host: { scrollToEndIndicator?: (() => string) | undefined },
|
|
116
|
+
fn: () => T,
|
|
117
|
+
th: { fg?(c: string, t: string): string; bg?(c: string, t: string): string } | null,
|
|
118
|
+
): T {
|
|
119
|
+
const orig = host.scrollToEndIndicator;
|
|
120
|
+
if (typeof orig !== "function") return fn();
|
|
121
|
+
host.scrollToEndIndicator = () => accentPill(orig(), th);
|
|
122
|
+
try {
|
|
123
|
+
return fn();
|
|
124
|
+
} finally {
|
|
125
|
+
host.scrollToEndIndicator = orig;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (TuiAltScreen?.prototype && !(globalThis as Record<symbol, boolean>)[SELECTION_COLOR_KEY]) {
|
|
130
|
+
(globalThis as Record<symbol, boolean>)[SELECTION_COLOR_KEY] = true;
|
|
131
|
+
const origHighlight = TuiAltScreen.prototype.applySelectionHighlight;
|
|
132
|
+
if (typeof origHighlight === "function") {
|
|
133
|
+
TuiAltScreen.prototype.applySelectionHighlight = function (text: string): string {
|
|
134
|
+
const highlighted = origHighlight.call(this, text);
|
|
135
|
+
const fgOpen = ansiFgOpen(activeThemeProxy);
|
|
136
|
+
const bgOpen = ansiBgOpen(activeThemeProxy, "selectedBg");
|
|
137
|
+
return fgOpen && bgOpen ? recolorSelection(highlighted, fgOpen, bgOpen) : highlighted;
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (TuiAltScreen?.prototype && !(globalThis as Record<symbol, boolean>)[PILL_KEY]) {
|
|
143
|
+
(globalThis as Record<symbol, boolean>)[PILL_KEY] = true;
|
|
144
|
+
const proto = TuiAltScreen.prototype as unknown as {
|
|
145
|
+
compositeScrollToEndIndicator?: (screen: string[], layout: unknown, width: number) => string[];
|
|
146
|
+
};
|
|
147
|
+
const origComposite = proto.compositeScrollToEndIndicator;
|
|
148
|
+
if (typeof origComposite === "function") {
|
|
149
|
+
proto.compositeScrollToEndIndicator = function (
|
|
150
|
+
this: { scrollToEndIndicator?: (() => string) | undefined },
|
|
151
|
+
screen: string[],
|
|
152
|
+
layout: unknown,
|
|
153
|
+
width: number,
|
|
154
|
+
) {
|
|
155
|
+
return withAccentPill(this, () => origComposite.call(this, screen, layout, width), activeThemeProxy);
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export default function uiRenderTweaks(pi: ExtensionAPI) {
|
|
161
|
+
// The active theme is re-read per session (ctx.ui.theme); render reads it lazily.
|
|
162
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
163
|
+
activeThemeProxy = ((ctx as unknown as { ui?: { theme?: typeof activeThemeProxy } }).ui?.theme) ?? activeThemeProxy;
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// 4. `pi-jev-eye-review` contract message: use our own border box.
|
|
167
|
+
// Collapsed by default: [pi-jev-eye] Reviewed turn contract. [click to expand]
|
|
168
|
+
// On click: the full contract content appears inside the box.
|
|
169
|
+
pi.registerMessageRenderer("pi-jev-eye-review", (message, { expanded }, th) => {
|
|
170
|
+
const raw = customMessageText(message.content);
|
|
171
|
+
return new JevReviewBoxComponent(raw, Boolean(expanded), th);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// "Package Updates Available" notification: pi wraps the text in a DynamicBorder
|
|
176
|
+
// (full lines, not a box). Replaced with an arnative box (╭─╮ │ ╰─╯) through an
|
|
177
|
+
// InteractiveMode prototype patch; the yellow (warning) title and border stay, the
|
|
178
|
+
// body text is unchanged.
|
|
179
|
+
export class PackageUpdateBoxComponent implements Component {
|
|
180
|
+
private packages: string[];
|
|
181
|
+
private th: { fg(c: string, t: string): string };
|
|
182
|
+
|
|
183
|
+
constructor(packages: string[], th: { fg(c: string, t: string): string }) {
|
|
184
|
+
this.packages = packages;
|
|
185
|
+
this.th = th;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
invalidate(): void {}
|
|
189
|
+
|
|
190
|
+
render(width: number): string[] {
|
|
191
|
+
const th = this.th;
|
|
192
|
+
const rows = [
|
|
193
|
+
th.fg("warning", "\x1b[1m\uf449 Package Updates Available\x1b[22m"),
|
|
194
|
+
`${th.fg("muted", "Package updates are available. Run ")}${th.fg("accent", "pi update --extensions")}`,
|
|
195
|
+
th.fg("muted", "Packages:"),
|
|
196
|
+
...this.packages.map((pkg) => `- ${pkg}`),
|
|
197
|
+
];
|
|
198
|
+
return renderBoxLines(th, width, rows, undefined, "warning");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const PACKAGE_BOX_KEY = Symbol.for("pi-arnative.packageUpdateBox");
|
|
203
|
+
if (InteractiveMode?.prototype && !(globalThis as Record<symbol, boolean>)[PACKAGE_BOX_KEY]) {
|
|
204
|
+
(globalThis as Record<symbol, boolean>)[PACKAGE_BOX_KEY] = true;
|
|
205
|
+
const proto = InteractiveMode.prototype as unknown as {
|
|
206
|
+
showPackageUpdateNotification(packages: string[]): void;
|
|
207
|
+
};
|
|
208
|
+
proto.showPackageUpdateNotification = function (packages: string[]) {
|
|
209
|
+
const self = this as unknown as {
|
|
210
|
+
chatContainer: { addChild(c: unknown): void };
|
|
211
|
+
ui: { requestRender(): void };
|
|
212
|
+
};
|
|
213
|
+
self.chatContainer.addChild(new Spacer(1));
|
|
214
|
+
self.chatContainer.addChild(new PackageUpdateBoxComponent(packages, activeThemeProxy ?? Theme));
|
|
215
|
+
self.ui.requestRender();
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// arnative box border helper -> lib/box.ts (one definition for every box)
|
|
220
|
+
|
|
221
|
+
export class JevReviewBoxComponent implements Component {
|
|
222
|
+
private text: string;
|
|
223
|
+
private th: { fg(c: string, t: string): string; bg?(c: string, t: string): string };
|
|
224
|
+
public isExpanded: boolean;
|
|
225
|
+
|
|
226
|
+
constructor(text: string, isExpanded: boolean, th: { fg(c: string, t: string): string; bg?(c: string, t: string): string }) {
|
|
227
|
+
this.text = text;
|
|
228
|
+
this.isExpanded = isExpanded;
|
|
229
|
+
this.th = th;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
render(width: number): string[] {
|
|
233
|
+
const tag = this.th.fg("customMessageLabel", "\x1b[1m[pi-jev-eye]\x1b[22m");
|
|
234
|
+
if (!this.isExpanded) {
|
|
235
|
+
const title = this.th.fg("customMessageText", "Reviewed turn contract.");
|
|
236
|
+
const hint = this.th.fg("dim", "[click to expand]");
|
|
237
|
+
return renderBoxLines(this.th, width, [`${tag} ${title} ${hint}`]);
|
|
238
|
+
}
|
|
239
|
+
// Expanded: show every content line
|
|
240
|
+
const lines = this.text.split("\n");
|
|
241
|
+
const formattedRows = lines.map((line, idx) => {
|
|
242
|
+
if (idx === 0 && line.startsWith("[pi-jev-eye]")) {
|
|
243
|
+
const rest = line.slice("[pi-jev-eye]".length).trim();
|
|
244
|
+
return `${tag} ${this.th.fg("customMessageText", rest)}`;
|
|
245
|
+
}
|
|
246
|
+
return this.th.fg("customMessageText", line);
|
|
247
|
+
});
|
|
248
|
+
return renderBoxLines(this.th, width, formattedRows);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
252
|
+
if (event.type === "click" && event.button === "left") {
|
|
253
|
+
this.isExpanded = !this.isExpanded;
|
|
254
|
+
return { handled: true };
|
|
255
|
+
}
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
invalidate(): void {}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 3. Compaction message: one line instead of three (label, spacer, text).
|
|
263
|
+
// The theme is used so colors match pi's own box; before the session starts (no
|
|
264
|
+
// proxy yet) the built-in renderer keeps working.
|
|
265
|
+
export function compactionLine(th: { fg(c: string, t: string): string }, tokenStr: string): string {
|
|
266
|
+
const label = th.fg("customMessageLabel", "\x1b[1m[compaction]\x1b[22m");
|
|
267
|
+
return `${label} ${th.fg("customMessageText", `Compacted from ${tokenStr} tokens (`)}${th.fg("dim", expandKey())}${th.fg(
|
|
268
|
+
"customMessageText",
|
|
269
|
+
" to expand)",
|
|
270
|
+
)}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Button text follows the active keybinding; outside a pi session keyText() is empty.
|
|
274
|
+
function expandKey(): string {
|
|
275
|
+
try {
|
|
276
|
+
return keyText("app.tools.expand") || "ctrl+o";
|
|
277
|
+
} catch {
|
|
278
|
+
return "ctrl+o";
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Custom message content: a plain string, or concatenated text blocks (image blocks dropped).
|
|
283
|
+
export function customMessageText(content: unknown): string {
|
|
284
|
+
if (typeof content === "string") return content;
|
|
285
|
+
if (Array.isArray(content))
|
|
286
|
+
return content
|
|
287
|
+
.filter((c) => (c as { type?: string })?.type === "text")
|
|
288
|
+
.map((c) => (c as { text?: string }).text ?? "")
|
|
289
|
+
.join("\n");
|
|
290
|
+
return "";
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const COMPACTION_KEY = Symbol.for("pi-arnative.compactionOneLine");
|
|
294
|
+
if (CompactionSummaryMessageComponent?.prototype && !(globalThis as Record<symbol, boolean>)[COMPACTION_KEY]) {
|
|
295
|
+
(globalThis as Record<symbol, boolean>)[COMPACTION_KEY] = true;
|
|
296
|
+
const proto = CompactionSummaryMessageComponent.prototype as any;
|
|
297
|
+
const origUpdateDisplay = proto.updateDisplay;
|
|
298
|
+
proto.updateDisplay = function (): void {
|
|
299
|
+
const th = activeThemeProxy;
|
|
300
|
+
if (!th) return origUpdateDisplay.call(this);
|
|
301
|
+
const message = this.message as { tokensBefore?: number; summary?: string } | undefined;
|
|
302
|
+
const tokenStr = Number(message?.tokensBefore ?? 0).toLocaleString();
|
|
303
|
+
this.clear();
|
|
304
|
+
const content = new Container();
|
|
305
|
+
if (this.expanded) {
|
|
306
|
+
content.addChild(new Text(th.fg("customMessageLabel", "\x1b[1m[compaction]\x1b[22m"), 0, 0));
|
|
307
|
+
content.addChild(new Spacer(1));
|
|
308
|
+
content.addChild(
|
|
309
|
+
new Markdown(
|
|
310
|
+
`**Compacted from ${tokenStr} tokens**\n\n${message?.summary ?? ""}`,
|
|
311
|
+
0,
|
|
312
|
+
0,
|
|
313
|
+
this.markdownTheme ?? getMarkdownTheme(),
|
|
314
|
+
{ color: (t: string) => th.fg("customMessageText", t) },
|
|
315
|
+
),
|
|
316
|
+
);
|
|
317
|
+
} else {
|
|
318
|
+
content.addChild(new Text(compactionLine(th, tokenStr), 0, 0));
|
|
319
|
+
}
|
|
320
|
+
this.addChild(
|
|
321
|
+
new MouseRegion(content, (event: any) => {
|
|
322
|
+
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
323
|
+
this.setExpanded(!this.expanded);
|
|
324
|
+
return { handled: true };
|
|
325
|
+
}),
|
|
326
|
+
);
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Self-check: `node extensions/ui-render-tweaks.ts`
|
|
331
|
+
const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split("\\").join("/"));
|
|
332
|
+
if (isMain) {
|
|
333
|
+
const assert = (cond: boolean, msg: string) => {
|
|
334
|
+
if (!cond) {
|
|
335
|
+
console.error(`FAIL: ${msg}`);
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
// reload box fully hidden (not line by line)
|
|
340
|
+
assert(shouldHideReloadBox(["╭──╮", "│ Reloading keybindings, extensions, skills... │"]) === true, "box reload terdeteksi");
|
|
341
|
+
assert(shouldHideReloadBox(["hello", "world"]) === false, "baris biasa lolos");
|
|
342
|
+
|
|
343
|
+
// selection: reverse video replaced with explicit colors (bg selectedBg + fg text)
|
|
344
|
+
assert(
|
|
345
|
+
recolorSelection("\x1b[7mhi\x1b[27m", "<F>", "<B>") === "<F><B>hi\x1b[39m\x1b[49m",
|
|
346
|
+
"recolorSelection: buka dgn fg+bg, tutup dgn reset fg+bg",
|
|
347
|
+
);
|
|
348
|
+
assert(recolorSelection("plain", "<F>", "<B>") === "plain", "tanpa reverse video: tak berubah");
|
|
349
|
+
assert(ansiFgOpen(null) === "", "tanpa tema: fgOpen kosong");
|
|
350
|
+
assert(ansiFgOpen({ fg: (_c, t) => `\x1b[38;2;1;2;3m${t}\x1b[39m` }) === "\x1b[38;2;1;2;3m", "fgOpen terambil dari probe tema");
|
|
351
|
+
assert(ansiBgOpen({ bg: (_c, t) => `\x1b[48;2;9;9;9m${t}\x1b[49m` }, "selectedBg") === "\x1b[48;2;9;9;9m", "bgOpen selectedBg terambil");
|
|
352
|
+
|
|
353
|
+
// "Jump to latest message" pill: accent bg + canvas text (not grey selectedBg).
|
|
354
|
+
// Use the REAL theme: the permissive stub would let `bg("accent")` through,
|
|
355
|
+
// which pi throws on.
|
|
356
|
+
const temaJson = JSON.parse(readFileSync(new URL("../themes/arnative.json", import.meta.url), "utf8")) as {
|
|
357
|
+
vars: Record<string, string>;
|
|
358
|
+
colors: Record<string, string>;
|
|
359
|
+
};
|
|
360
|
+
const hex = (tok: string) => temaJson.vars[temaJson.colors[tok] ?? tok];
|
|
361
|
+
// The built-in theme adds fallbacks (scrollbarTrack<-muted, thinkingMax<-thinkingXhigh),
|
|
362
|
+
// so the fg map must be complete; take the whole palette like pi's loader does.
|
|
363
|
+
const fgPalet = Object.fromEntries(Object.entries(temaJson.colors).map(([k, v]) => [k, temaJson.vars[v] ?? v]));
|
|
364
|
+
const thPil = new Theme(fgPalet, { toolPendingBg: hex("toolPendingBg"), selectedBg: hex("selectedBg") }, "truecolor");
|
|
365
|
+
const pilAsli = thPil.bg("selectedBg", thPil.fg("text", " ↓ Jump to latest message · End "));
|
|
366
|
+
const rgb = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(";");
|
|
367
|
+
assert(
|
|
368
|
+
accentPill(pilAsli, thPil) ===
|
|
369
|
+
`\x1b[48;2;${rgb(hex("accent"))}m\x1b[38;2;${rgb(hex("toolPendingBg"))}m ↓ Jump to latest message · End \x1b[39m\x1b[49m`,
|
|
370
|
+
"pil: bg aksen + teks kanvas, ANSI lama dibuang",
|
|
371
|
+
);
|
|
372
|
+
let ditolak = false;
|
|
373
|
+
try {
|
|
374
|
+
thPil.bg("accent", "x");
|
|
375
|
+
} catch {
|
|
376
|
+
ditolak = true;
|
|
377
|
+
}
|
|
378
|
+
assert(ditolak, "pi memang menolak bg('accent') -> arah SGR harus ditukar");
|
|
379
|
+
assert(accentPill("teks lain", thPil) === "teks lain", "pil: teks non-pil tak disentuh");
|
|
380
|
+
assert(accentPill(pilAsli, null) === pilAsli, "pil: tanpa tema -> apa adanya");
|
|
381
|
+
|
|
382
|
+
// Theme variants: every themes/*.json must be valid — name = file name, all
|
|
383
|
+
// required colors present, valid hex, and contrast measured against that theme's
|
|
384
|
+
// own canvas (userMessageBg): content >= 3.0, secondary text >= 2.0, thinking
|
|
385
|
+
// ramp 1.3-4.0, accent pill >= 4.5 — the thresholds that already pass in the
|
|
386
|
+
// base `arnative` (not invented numbers).
|
|
387
|
+
const dirTema = new URL("../themes/", import.meta.url);
|
|
388
|
+
const wajib = Object.keys(temaJson.colors);
|
|
389
|
+
// Non-text keys (backgrounds/borders/scrollbar) — contrast is not measured.
|
|
390
|
+
const bukanTeks = new Set([
|
|
391
|
+
"selectedBg", "userMessageBg", "customMessageBg", "toolPendingBg", "toolSuccessBg", "toolErrorBg",
|
|
392
|
+
"searchMatchBg", "border", "borderAccent", "borderMuted", "mdCodeBlockBorder", "mdQuoteBorder",
|
|
393
|
+
"scrollbarTrack", "scrollbarThumb",
|
|
394
|
+
]);
|
|
395
|
+
const sekunder = new Set(["muted", "dim", "mdQuote", "mdHr", "mdLinkUrl", "toolOutput", "toolDiffContext", "thinkingText", "syntaxComment"]);
|
|
396
|
+
// Thinking ramp: the higher the more readable; thinkingMax = accent (measured on the pill path).
|
|
397
|
+
const ramp: Record<string, number> = {
|
|
398
|
+
thinkingOff: 1.3, thinkingMinimal: 1.3, thinkingLow: 2.0,
|
|
399
|
+
thinkingMedium: 2.7, thinkingHigh: 3.6, thinkingXhigh: 4.0,
|
|
400
|
+
};
|
|
401
|
+
const luminansi = (h: string) => {
|
|
402
|
+
const ch = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((c) =>
|
|
403
|
+
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4,
|
|
404
|
+
);
|
|
405
|
+
return 0.2126 * ch[0]! + 0.7152 * ch[1]! + 0.0722 * ch[2]!;
|
|
406
|
+
};
|
|
407
|
+
const kontras = (a: string, b: string) => {
|
|
408
|
+
const [x, y] = [luminansi(a), luminansi(b)].sort((m, n) => n - m);
|
|
409
|
+
return (x! + 0.05) / (y! + 0.05);
|
|
410
|
+
};
|
|
411
|
+
for (const f of readdirSync(dirTema).filter((n) => n.endsWith(".json")).sort()) {
|
|
412
|
+
const d = JSON.parse(readFileSync(new URL(f, dirTema), "utf8")) as {
|
|
413
|
+
name: string;
|
|
414
|
+
vars: Record<string, string>;
|
|
415
|
+
colors: Record<string, string>;
|
|
416
|
+
};
|
|
417
|
+
assert(d.name === f.replace(/\.json$/, ""), `tema ${f}: name = nama berkas`);
|
|
418
|
+
const kurang = wajib.filter((k) => !(k in d.colors));
|
|
419
|
+
assert(kurang.length === 0, `tema ${f}: warna wajib lengkap (kurang ${kurang.join(",")})`);
|
|
420
|
+
// colors values may only be: hex, 256-index, empty, or a REAL variable name —
|
|
421
|
+
// a bare color name (e.g. "muted") makes pi reject the theme with no clear reason.
|
|
422
|
+
for (const [tok, nilai] of Object.entries(d.colors)) {
|
|
423
|
+
if (typeof nilai !== "string") continue;
|
|
424
|
+
if (nilai === "" || /^#[0-9a-f]{6}$/i.test(nilai) || nilai in d.vars) continue;
|
|
425
|
+
assert(false, `tema ${f}: colors.${tok} = "${nilai}" bukan hex/var`);
|
|
426
|
+
}
|
|
427
|
+
const hex = (tok: string) => d.vars[d.colors[tok] ?? tok] ?? d.colors[tok];
|
|
428
|
+
for (const tok of ["accent", "text", "selectedBg", "toolPendingBg"]) {
|
|
429
|
+
assert(/^#[0-9a-f]{6}$/i.test(hex(tok)), `tema ${f}: ${tok} hex (${hex(tok)})`);
|
|
430
|
+
}
|
|
431
|
+
// Accent pill: accent bg + toolPendingBg colored text.
|
|
432
|
+
assert(kontras(hex("accent"), hex("toolPendingBg")) >= 4.5, `tema ${f}: kontras accent vs toolPendingBg >= 4.5`);
|
|
433
|
+
assert(/^#[0-9a-f]{6}$/i.test(d.vars.softCyan ?? ""), `tema ${f}: vars.softCyan (tint) hex`);
|
|
434
|
+
const kanvas = hex("userMessageBg");
|
|
435
|
+
for (const tok of Object.keys(d.colors)) {
|
|
436
|
+
if (bukanTeks.has(tok) || tok === "thinkingMax") continue;
|
|
437
|
+
const warna = hex(tok);
|
|
438
|
+
if (!/^#[0-9a-f]{6}$/i.test(warna)) continue; // indeks 256 warna: biarkan pi yang menilai
|
|
439
|
+
const batas = ramp[tok] ?? (sekunder.has(tok) ? 2.0 : 3.0);
|
|
440
|
+
const c = kontras(warna, kanvas);
|
|
441
|
+
assert(c >= batas, `tema ${f}: kontras ${tok} vs kanvas ${c.toFixed(2)} < ${batas}`);
|
|
442
|
+
}
|
|
443
|
+
// Tool box text must be readable on the success background AND in errors, and
|
|
444
|
+
// the error text in the error box — three pairs that used to pass (3.48-4.46)
|
|
445
|
+
// without being caught.
|
|
446
|
+
for (const [fg, bg] of [
|
|
447
|
+
["toolOutput", "toolSuccessBg"], ["toolOutput", "toolErrorBg"],
|
|
448
|
+
["error", "toolErrorBg"], ["toolDiffRemoved", "toolErrorBg"],
|
|
449
|
+
] as const) {
|
|
450
|
+
const c = kontras(hex(fg), hex(bg));
|
|
451
|
+
assert(c >= 4.5, `tema ${f}: kontras ${fg} di ${bg} ${c.toFixed(2)} < 4.5`);
|
|
452
|
+
}
|
|
453
|
+
// The error box must not be heavier than the success box (matching background luminance).
|
|
454
|
+
const [lErr, lOk] = [luminansi(hex("toolErrorBg")), luminansi(hex("toolSuccessBg"))];
|
|
455
|
+
assert(Math.abs(lErr - lOk) / Math.max(lErr, lOk) <= 0.25, `tema ${f}: bobot toolErrorBg vs toolSuccessBg (${lErr.toFixed(3)} vs ${lOk.toFixed(3)})`);
|
|
456
|
+
// Scrollbar thumb & highlight must be distinguishable from track/selection (they used to be identical).
|
|
457
|
+
assert(hex("scrollbarThumb") !== hex("scrollbarTrack"), `tema ${f}: scrollbarThumb masih sama dengan scrollbarTrack`);
|
|
458
|
+
assert(hex("searchMatchBg") !== hex("selectedBg"), `tema ${f}: searchMatchBg masih sama dengan selectedBg`);
|
|
459
|
+
}
|
|
460
|
+
const thKosong = new Theme(
|
|
461
|
+
{ muted: "#808080", text: "#d4d4d4", thinkingXhigh: "#20caee" },
|
|
462
|
+
{ selectedBg: "#3a3a4a" },
|
|
463
|
+
"truecolor",
|
|
464
|
+
);
|
|
465
|
+
assert(accentPill(pilAsli, thKosong) === pilAsli, "pil: token absen -> apa adanya");
|
|
466
|
+
const hostPil: { scrollToEndIndicator?: () => string } = { scrollToEndIndicator: () => pilAsli };
|
|
467
|
+
const dalamPil = withAccentPill(hostPil, () => hostPil.scrollToEndIndicator?.(), thPil);
|
|
468
|
+
assert(dalamPil.includes("\x1b[48;2;0;215;255m"), "pil: callback instance disisipi bg aksen");
|
|
469
|
+
assert(hostPil.scrollToEndIndicator?.() === pilAsli, "pil: callback asli dipulihkan setelah render");
|
|
470
|
+
|
|
471
|
+
// compaction: pi's three lines -> one
|
|
472
|
+
assert(
|
|
473
|
+
compactionLine({ fg: (_c, t) => t }, "112,247").replace(/\x1b\[[0-9;]*m/g, "") ===
|
|
474
|
+
"[compaction] Compacted from 112,247 tokens (ctrl+o to expand)",
|
|
475
|
+
"compaction: label + teks + petunjuk expand dalam satu baris (tanpa baris kosong)",
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
// custom message: content as-is, no extra label/lines
|
|
479
|
+
assert(
|
|
480
|
+
customMessageText("[pi-jev-eye] Reviewed turn contract:\n- Bahasa ikut user") ===
|
|
481
|
+
"[pi-jev-eye] Reviewed turn contract:\n- Bahasa ikut user",
|
|
482
|
+
"isi pesan custom utuh",
|
|
483
|
+
);
|
|
484
|
+
assert(
|
|
485
|
+
customMessageText([
|
|
486
|
+
{ type: "text", text: "a" },
|
|
487
|
+
{ type: "image", data: "x" },
|
|
488
|
+
{ type: "text", text: "b" },
|
|
489
|
+
]) === "a\nb",
|
|
490
|
+
"blok non-teks dibuang",
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
// Integration: run the factory like pi, then call updateDisplay / the original renderer
|
|
494
|
+
const fakePi = {
|
|
495
|
+
handlers: [] as any[],
|
|
496
|
+
renderers: {} as Record<string, any>,
|
|
497
|
+
on(_e: string, h: any) {
|
|
498
|
+
this.handlers.push(h);
|
|
499
|
+
return () => {};
|
|
500
|
+
},
|
|
501
|
+
registerMessageRenderer(t: string, r: any) {
|
|
502
|
+
this.renderers[t] = r;
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
initTheme(); // markdown + tema internal pi (di sesi nyata sudah aktif)
|
|
506
|
+
uiRenderTweaks(fakePi as any);
|
|
507
|
+
await fakePi.handlers[0]({}, { ui: { theme: { fg: (_c: string, t: string) => t } } });
|
|
508
|
+
|
|
509
|
+
const kids: any[] = [];
|
|
510
|
+
const fake = {
|
|
511
|
+
expanded: false,
|
|
512
|
+
message: { tokensBefore: 112247, summary: "**Ringkasan** panjang\n\n- satu" },
|
|
513
|
+
clear() {
|
|
514
|
+
kids.length = 0;
|
|
515
|
+
},
|
|
516
|
+
addChild(c: any) {
|
|
517
|
+
kids.push(c);
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
CompactionSummaryMessageComponent.prototype.updateDisplay.call(fake as any);
|
|
521
|
+
const collapsed = kids[0].render(120);
|
|
522
|
+
assert(
|
|
523
|
+
collapsed.length === 1 && collapsed[0].trimEnd().endsWith("Compacted from 112,247 tokens (ctrl+o to expand)"),
|
|
524
|
+
"compaction collapsed: tepat satu baris",
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
fake.expanded = true;
|
|
528
|
+
CompactionSummaryMessageComponent.prototype.updateDisplay.call(fake as any);
|
|
529
|
+
const expanded = kids[0].render(120);
|
|
530
|
+
assert(
|
|
531
|
+
expanded.length > 1 && expanded.some((l: string) => l.includes("[compaction]")) && expanded.join(" ").includes("Ringkasan"),
|
|
532
|
+
"compaction expanded: label + ringkasan markdown tetap ada",
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
const contractCollapsed = fakePi.renderers["pi-jev-eye-review"](
|
|
536
|
+
{ customType: "pi-jev-eye-review", content: "[pi-jev-eye] Reviewed turn contract:\n- Bahasa ikut user" },
|
|
537
|
+
{ expanded: false, outputPad: 1 },
|
|
538
|
+
{ fg: (_c: string, t: string) => t },
|
|
539
|
+
);
|
|
540
|
+
const cLines = contractCollapsed.render(120).map((l: string) => l.trimEnd());
|
|
541
|
+
assert(
|
|
542
|
+
cLines[0].startsWith("╭") && cLines[cLines.length - 1].startsWith("╰") && cLines[1].includes("[click to expand]"),
|
|
543
|
+
"pesan kontrak collapsed: kotak border dengan teks [click to expand]",
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
// Package update notification: arnative box, body and yellow kept
|
|
547
|
+
const upd = new PackageUpdateBoxComponent(["github.com/bismawy/pi-arnative", "pkg-dua"], {
|
|
548
|
+
fg: (c: string, t: string) => (c === "warning" ? `\x1b[33m${t}\x1b[39m` : t),
|
|
549
|
+
}).render(60);
|
|
550
|
+
assert(upd[0]!.includes("\x1b[33m") && upd[0]!.includes("╭"), "kotak pembaruan: bingkai bulat + warna warning");
|
|
551
|
+
assert(upd[upd.length - 1]!.includes("╰"), "kotak pembaruan: bingkai bawah");
|
|
552
|
+
assert(upd[1]!.includes("Package Updates Available"), "judul pembaruan di dalam kotak");
|
|
553
|
+
assert(upd[1]!.includes("\uf449"), "ikon \uf449 pada judul pembaruan");
|
|
554
|
+
assert(upd.some((l: string) => l.includes("- github.com/bismawy/pi-arnative")), "daftar paket di dalam kotak");
|
|
555
|
+
assert(upd.every((l: string) => visibleWidth(l) === 60), "kotak pembaruan selebar 60");
|
|
556
|
+
|
|
557
|
+
// Test invalidate and the handleMouse click toggle
|
|
558
|
+
contractCollapsed.invalidate();
|
|
559
|
+
const mouseRes = contractCollapsed.handleMouse({ type: "click", button: "left" });
|
|
560
|
+
assert(mouseRes?.handled === true, "klik kiri handled");
|
|
561
|
+
assert(contractCollapsed.isExpanded === true, "klik kiri toggle expanded");
|
|
562
|
+
const toggledLines = contractCollapsed.render(120).map((l: string) => l.trimEnd());
|
|
563
|
+
assert(toggledLines.some((l: string) => l.includes("Bahasa ikut user")), "setelah klik: isi lengkap tampil");
|
|
564
|
+
|
|
565
|
+
const contractExpanded = fakePi.renderers["pi-jev-eye-review"](
|
|
566
|
+
{ customType: "pi-jev-eye-review", content: "[pi-jev-eye] Reviewed turn contract:\n- Bahasa ikut user" },
|
|
567
|
+
{ expanded: true, outputPad: 1 },
|
|
568
|
+
{ fg: (_c: string, t: string) => t },
|
|
569
|
+
);
|
|
570
|
+
const expLines = contractExpanded.render(120).map((l: string) => l.trimEnd());
|
|
571
|
+
assert(
|
|
572
|
+
expLines[0].startsWith("╭") && expLines[expLines.length - 1].startsWith("╰") && expLines.some((l: string) => l.includes("Bahasa ikut user")),
|
|
573
|
+
"pesan kontrak expanded: kotak border dengan isi lengkap",
|
|
574
|
+
);
|
|
575
|
+
console.log("ui-render-tweaks.ts self-check OK");
|
|
576
|
+
}
|