@bismawy/pi-arnative 0.2.11 → 0.3.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/CHANGELOG.md +38 -0
- package/README.md +11 -6
- package/extensions/arnative.ts +359 -0
- package/extensions/footer.ts +332 -213
- package/extensions/section-headers.ts +214 -138
- package/extensions/timestamps.ts +5 -2
- package/extensions/tools.ts +30 -40
- package/extensions/ui-render-tweaks.ts +87 -23
- package/extensions/usage.ts +37 -13
- package/lib/ansi.ts +23 -0
- package/lib/color.ts +118 -0
- package/lib/format.ts +6 -0
- package/lib/usage-store.ts +25 -16
- package/package.json +4 -3
- package/themes/arnative-cyberpunk.json +70 -78
- package/themes/arnative-dracula.json +70 -78
- package/themes/arnative-emerald.json +70 -78
- package/themes/arnative-gruvbox.json +70 -78
- package/themes/arnative-matrix.json +70 -78
- package/themes/arnative-nord.json +70 -78
- package/themes/arnative-sun.json +70 -78
- package/themes/arnative-synthwave.json +70 -78
- package/themes/arnative-violet.json +70 -78
- package/themes/arnative-zinc.json +70 -78
- package/themes/arnative.json +70 -78
- package/themes/gen-themes.mjs +301 -224
package/extensions/timestamps.ts
CHANGED
|
@@ -101,9 +101,8 @@ export function placeTimeAtRight(
|
|
|
101
101
|
const leftPart = sliceByColumn(line, 0, targetCol, true);
|
|
102
102
|
const leftW = visibleWidth(leftPart);
|
|
103
103
|
const pad = " ".repeat(Math.max(0, targetCol - leftW));
|
|
104
|
-
const bg = bgOpen ? bgOpen : "";
|
|
105
104
|
const reset = bgOpen ? "\x1b[49m" : "";
|
|
106
|
-
return `${leftPart}${
|
|
105
|
+
return `${leftPart}${bgOpen}${pad}${timeBadge} ${reset}`;
|
|
107
106
|
}
|
|
108
107
|
|
|
109
108
|
if (!tsState.patched) {
|
|
@@ -211,6 +210,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
211
210
|
}
|
|
212
211
|
});
|
|
213
212
|
|
|
213
|
+
// The map is cleared per session (it is refilled from the branch right below),
|
|
214
|
+
// so it is bounded by the current session instead of growing for the process lifetime.
|
|
215
|
+
USER_TIMESTAMPS_MAP.clear();
|
|
214
216
|
// The active theme is re-read per session (ctx.ui.theme); render reads it lazily.
|
|
215
217
|
// History for old user messages is read HERE, never at render time: the ctx is only
|
|
216
218
|
// valid inside a handler. A ctx captured in the module throws after session
|
|
@@ -285,6 +287,7 @@ if (isMain(import.meta.url)) {
|
|
|
285
287
|
},
|
|
286
288
|
);
|
|
287
289
|
assert(USER_TIMESTAMPS_MAP.get("after reload") === 4242, "reloaded module prefills the shared map read by the patched render");
|
|
290
|
+
assert(!USER_TIMESTAMPS_MAP.has("old question"), "session_start clears the map first (bounded growth, current session only)");
|
|
288
291
|
assert(tsState.theme === theme, "reloaded module shares the theme read by the patched render");
|
|
289
292
|
console.log("timestamps.ts self-check OK");
|
|
290
293
|
}
|
package/extensions/tools.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Collapsed summary: bash/write ` first line`; grep/find/read numeric
|
|
12
12
|
* (→ N matches / → N files / N lines); edit ` +N / -M`. Expanded plain output
|
|
13
13
|
* gets no summary line (no duplicated first line).
|
|
14
|
-
* Colors: tool name accent, paths/links
|
|
14
|
+
* Colors: tool name accent, paths/links soft, box lines + dim. Execution is a
|
|
15
15
|
* pure delegate (spread of the built-in tools).
|
|
16
16
|
* "has a result" lives in context.state, read at render() -> restore/reload safe,
|
|
17
17
|
* no stale box. Third-party tools with no renderer of their own (memory_write,
|
|
@@ -32,14 +32,14 @@ import {
|
|
|
32
32
|
import { Markdown, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
33
33
|
import { homedir } from "node:os";
|
|
34
34
|
import { renderBoxLines as box } from "../lib/box.ts";
|
|
35
|
-
import { stripAnsi } from "../lib/ansi.ts";
|
|
35
|
+
import { stripAnsi, accentSoftOf } from "../lib/ansi.ts";
|
|
36
36
|
import { assert, isMain } from "../lib/check.ts";
|
|
37
37
|
import { expandKeyName } from "../lib/format.ts";
|
|
38
38
|
|
|
39
39
|
type Theme = { fg(color: string, text: string): string; bg?(color: string, text: string): string };
|
|
40
40
|
type TResult = { content: Array<{ type: string; text?: string }>; isError?: boolean; details?: any };
|
|
41
41
|
type TCtx = { isError?: boolean; toolCallId?: string; args?: any; state?: Record<string, unknown> };
|
|
42
|
-
type Res = (r: TResult, th: Theme,
|
|
42
|
+
type Res = (r: TResult, th: Theme, soft: (s: string) => string, isErr: boolean, expanded: boolean) => string;
|
|
43
43
|
|
|
44
44
|
const cwd = process.cwd();
|
|
45
45
|
const TIMINGS = new Map<string, number>(); // duration ms per tool call (one run)
|
|
@@ -97,20 +97,10 @@ const countLines = (s: string): number => (s ? s.split("\n").filter(Boolean).len
|
|
|
97
97
|
|
|
98
98
|
// Tint folder paths / URLs; everything else default.
|
|
99
99
|
export const LINK_RE = /(?:https?:\/\/[^\s"'`)}\]]+|(?:~|\.{1,2})?\/[\w.+@~%/-]+|[\w.+@~-]+(?:\/[\w.+@~%/-]+)+)/g;
|
|
100
|
-
export function paintLinks(text: string,
|
|
101
|
-
return text.replace(LINK_RE, (m) =>
|
|
100
|
+
export function paintLinks(text: string, soft: (s: string) => string): string {
|
|
101
|
+
return text.replace(LINK_RE, (m) => soft(m));
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
// "tint" only exists in arnative themes; other themes fall back to accent (probed per render)
|
|
105
|
-
const tintOf = (theme: Theme): ((s: string) => string) => {
|
|
106
|
-
try {
|
|
107
|
-
theme.fg("tint", "");
|
|
108
|
-
return (s) => theme.fg("tint", s);
|
|
109
|
-
} catch {
|
|
110
|
-
return (s) => theme.fg("accent", s);
|
|
111
|
-
}
|
|
112
|
-
};
|
|
113
|
-
|
|
114
104
|
// Word-limited smart title: `maxWords` words + ellipsis; full text when expanded.
|
|
115
105
|
export function smartTitle(cmd: string, maxWords = 6): string {
|
|
116
106
|
const words = cmd.replace(/\r?\n/g, " ").trim().split(/\s+/).filter(Boolean);
|
|
@@ -150,17 +140,17 @@ export function titleRow(
|
|
|
150
140
|
|
|
151
141
|
// Default summary: collapsed ` first output line`, empty when expanded (the
|
|
152
142
|
// full output is right below — no duplicate first line).
|
|
153
|
-
export const resText: Res = (r, th,
|
|
143
|
+
export const resText: Res = (r, th, soft, isErr, expanded) => {
|
|
154
144
|
if (expanded && !isErr) return "";
|
|
155
145
|
const f = firstLine(textOf(r));
|
|
156
|
-
return f ? `${resHead(th)} ${paintLinks(f,
|
|
146
|
+
return f ? `${resHead(th)} ${paintLinks(f, soft)}` : resHead(th);
|
|
157
147
|
};
|
|
158
148
|
|
|
159
149
|
// Numeric summary ` → N matches`; errors fall back to the first line (most important).
|
|
160
150
|
export const numRes =
|
|
161
151
|
(fmt: (n: number) => string): Res =>
|
|
162
|
-
(r, th,
|
|
163
|
-
if (isErr) return resText(r, th,
|
|
152
|
+
(r, th, soft, isErr, expanded) => {
|
|
153
|
+
if (isErr) return resText(r, th, soft, isErr, false);
|
|
164
154
|
if (expanded) return "";
|
|
165
155
|
return `${resHead(th)} ${fmt(countLines(textOf(r)))}`;
|
|
166
156
|
};
|
|
@@ -394,7 +384,7 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
|
|
|
394
384
|
? safeCall(() => own.call(this, { content: result.content, details: result.details }, opts, th, ctx))
|
|
395
385
|
: null;
|
|
396
386
|
return new Lines((width) => {
|
|
397
|
-
const
|
|
387
|
+
const soft = accentSoftOf(th);
|
|
398
388
|
const inner = Math.max(8, width - 4);
|
|
399
389
|
const icon = isErr ? th.fg("error", "x") : th.fg("success", "✓");
|
|
400
390
|
const boxed2 = componentLines(theirs, width - 6);
|
|
@@ -422,7 +412,7 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
|
|
|
422
412
|
const summary = toolSummary(name, text, ctx.args);
|
|
423
413
|
const more = countLines(text) > 1;
|
|
424
414
|
const hint = more && !opts.expanded ? ` ${expandHint(th)}` : "";
|
|
425
|
-
rows.push(`${resHead(th)}${summary ? ` ${paintLinks(summary,
|
|
415
|
+
rows.push(`${resHead(th)}${summary ? ` ${paintLinks(summary, soft)}` : ""}${hint}`);
|
|
426
416
|
if (opts.expanded && more) {
|
|
427
417
|
// MCP args are gone once the call box is replaced by the result.
|
|
428
418
|
if (MCP_TOOL(name)) rows.push(th.fg("dim", `args ${JSON.stringify(ctx.args ?? {})}`));
|
|
@@ -460,8 +450,8 @@ const formatTodoRows = (lines: string[], th: Theme): string[] => {
|
|
|
460
450
|
function minimal(
|
|
461
451
|
pi: ExtensionAPI,
|
|
462
452
|
tool: { execute: (...a: any[]) => Promise<any> } & Record<string, unknown>,
|
|
463
|
-
name: (th: Theme,
|
|
464
|
-
call: (a: any, th: Theme,
|
|
453
|
+
name: (th: Theme, soft: (s: string) => string) => string,
|
|
454
|
+
call: (a: any, th: Theme, soft: (s: string) => string, expanded: boolean) => string,
|
|
465
455
|
res: Res = resText,
|
|
466
456
|
full: (r: TResult, th: Theme) => string[] = fullText,
|
|
467
457
|
): void {
|
|
@@ -485,13 +475,13 @@ function minimal(
|
|
|
485
475
|
return new Lines((width) => {
|
|
486
476
|
// Read context.state live (never capture): renderResult may create it after renderCall.
|
|
487
477
|
if (context.state?.hasResult) return [];
|
|
488
|
-
const
|
|
478
|
+
const soft = accentSoftOf(theme);
|
|
489
479
|
const inner = Math.max(8, width - 4);
|
|
490
480
|
const rows = titleRow(
|
|
491
481
|
theme,
|
|
492
482
|
theme.fg("warning", spinIcon()),
|
|
493
|
-
name(theme,
|
|
494
|
-
call(args, theme,
|
|
483
|
+
name(theme, soft),
|
|
484
|
+
call(args, theme, soft, false),
|
|
495
485
|
inner,
|
|
496
486
|
"",
|
|
497
487
|
false,
|
|
@@ -513,18 +503,18 @@ function minimal(
|
|
|
513
503
|
const dur = ms !== undefined ? `${(ms / 1000).toFixed(1)}s` : "";
|
|
514
504
|
const icon = isErr ? theme.fg("error", "x") : theme.fg("success", "✓");
|
|
515
505
|
return new Lines((width) => {
|
|
516
|
-
const
|
|
506
|
+
const soft = accentSoftOf(theme);
|
|
517
507
|
const inner = Math.max(8, width - 4);
|
|
518
508
|
const rows = titleRow(
|
|
519
509
|
theme,
|
|
520
510
|
icon,
|
|
521
|
-
name(theme,
|
|
522
|
-
call(context.args ?? {}, theme,
|
|
511
|
+
name(theme, soft),
|
|
512
|
+
call(context.args ?? {}, theme, soft, expanded),
|
|
523
513
|
inner,
|
|
524
514
|
dur,
|
|
525
515
|
expanded,
|
|
526
516
|
);
|
|
527
|
-
const resLine = res(result, theme,
|
|
517
|
+
const resLine = res(result, theme, soft, isErr, expanded);
|
|
528
518
|
if (resLine) rows.push(resLine);
|
|
529
519
|
if (expanded) rows.push(...full(result, theme));
|
|
530
520
|
return box(theme, width, rows);
|
|
@@ -559,9 +549,9 @@ function arnativeTools(pi: ExtensionAPI) {
|
|
|
559
549
|
pi,
|
|
560
550
|
createBashTool(cwd),
|
|
561
551
|
(th) => th.fg("accent", "$"),
|
|
562
|
-
(a, _th,
|
|
552
|
+
(a, _th, soft, expanded) => {
|
|
563
553
|
const cmd = (a.command ?? "").replace(/\r?\n/g, " ").trim();
|
|
564
|
-
return paintLinks(expanded ? cmd : smartTitle(cmd),
|
|
554
|
+
return paintLinks(expanded ? cmd : smartTitle(cmd), soft);
|
|
565
555
|
},
|
|
566
556
|
);
|
|
567
557
|
|
|
@@ -570,10 +560,10 @@ function arnativeTools(pi: ExtensionAPI) {
|
|
|
570
560
|
pi,
|
|
571
561
|
createReadTool(cwd),
|
|
572
562
|
(th) => th.fg("accent", "read"),
|
|
573
|
-
(a, _th,
|
|
563
|
+
(a, _th, soft) => {
|
|
574
564
|
const range =
|
|
575
565
|
a.offset || a.limit ? `:${a.offset ?? 1}${a.limit ? `-${(a.offset ?? 1) + a.limit - 1}` : ""}` : "";
|
|
576
|
-
return `${
|
|
566
|
+
return `${soft(shortPath(a.path ?? ""))}${range}`;
|
|
577
567
|
},
|
|
578
568
|
numRes((n) => `${n} lines`),
|
|
579
569
|
);
|
|
@@ -583,8 +573,8 @@ function arnativeTools(pi: ExtensionAPI) {
|
|
|
583
573
|
pi,
|
|
584
574
|
createGrepTool(cwd),
|
|
585
575
|
(th) => th.fg("accent", "grep"),
|
|
586
|
-
(a, _th,
|
|
587
|
-
`/${a.pattern ?? ""}/ in ${
|
|
576
|
+
(a, _th, soft) =>
|
|
577
|
+
`/${a.pattern ?? ""}/ in ${soft(shortPath(a.path ?? "."))}${a.glob ? ` (${a.glob})` : ""}`,
|
|
588
578
|
numRes((n) => `→ ${n} matches`),
|
|
589
579
|
);
|
|
590
580
|
|
|
@@ -593,7 +583,7 @@ function arnativeTools(pi: ExtensionAPI) {
|
|
|
593
583
|
pi,
|
|
594
584
|
createFindTool(cwd),
|
|
595
585
|
(th) => th.fg("accent", "find"),
|
|
596
|
-
(a, _th,
|
|
586
|
+
(a, _th, soft) => `${a.pattern ?? ""} in ${soft(shortPath(a.path ?? "."))}`,
|
|
597
587
|
numRes((n) => `→ ${n} files`),
|
|
598
588
|
);
|
|
599
589
|
|
|
@@ -602,7 +592,7 @@ function arnativeTools(pi: ExtensionAPI) {
|
|
|
602
592
|
pi,
|
|
603
593
|
createWriteTool(cwd),
|
|
604
594
|
(th) => th.fg("accent", "write"),
|
|
605
|
-
(a, _th,
|
|
595
|
+
(a, _th, soft) => soft(shortPath(a.path ?? "")),
|
|
606
596
|
);
|
|
607
597
|
|
|
608
598
|
// edit: `edit path`, ` +N / -M`, expand = diff toolDiff*
|
|
@@ -610,8 +600,8 @@ function arnativeTools(pi: ExtensionAPI) {
|
|
|
610
600
|
pi,
|
|
611
601
|
createEditTool(cwd),
|
|
612
602
|
(th) => th.fg("accent", "edit"),
|
|
613
|
-
(a, _th,
|
|
614
|
-
(r, th,
|
|
603
|
+
(a, _th, soft) => soft(shortPath(a.path ?? "")),
|
|
604
|
+
(r, th, _soft, isErr, expanded) => {
|
|
615
605
|
if (isErr) return resText(r, th, (s) => s, isErr, false);
|
|
616
606
|
const diff: string = r.details?.diff ?? "";
|
|
617
607
|
let add = 0;
|
|
@@ -11,10 +11,13 @@
|
|
|
11
11
|
* box; our renderer shows the content as-is.
|
|
12
12
|
* 5. `↓ Jump to latest message` pill (fullscreen): bg selectedBg (grey) -> accent,
|
|
13
13
|
* text darkened (theme canvas) so contrast stays high (9.6:1).
|
|
14
|
+
* 6. `ctx.ui.select` rows: pi paints the whole selected row accent -> keep accent on
|
|
15
|
+
* the `→` marker and `·` separators, selected text in soft.
|
|
14
16
|
*/
|
|
15
17
|
import {
|
|
16
18
|
CompactionSummaryMessageComponent,
|
|
17
19
|
CustomMessageComponent,
|
|
20
|
+
ExtensionSelectorComponent,
|
|
18
21
|
getMarkdownTheme,
|
|
19
22
|
initTheme,
|
|
20
23
|
InteractiveMode,
|
|
@@ -37,8 +40,9 @@ import {
|
|
|
37
40
|
} from "@earendil-works/pi-tui";
|
|
38
41
|
import { readdirSync, readFileSync } from "node:fs";
|
|
39
42
|
import { renderBoxLines } from "../lib/box.ts";
|
|
40
|
-
import { ansiBgOpen, stripAnsi, themeOf } from "../lib/ansi.ts";
|
|
43
|
+
import { ansiBgOpen, stripAnsi, themeOf, accentSoftOf } from "../lib/ansi.ts";
|
|
41
44
|
import { assert, isMain } from "../lib/check.ts";
|
|
45
|
+
import { contrast, parseOklch, toHex } from "../lib/color.ts";
|
|
42
46
|
import { expandKeyName } from "../lib/format.ts";
|
|
43
47
|
|
|
44
48
|
let activeThemeProxy: { fg(color: string, text: string): string; bg?(color: string, text: string): string } | null = null;
|
|
@@ -155,6 +159,43 @@ if (TuiAltScreen?.prototype && !(globalThis as Record<symbol, boolean>)[PILL_KEY
|
|
|
155
159
|
}
|
|
156
160
|
}
|
|
157
161
|
|
|
162
|
+
// 6. Select menus (ctx.ui.select -> ExtensionSelectorComponent.updateList): pi colors the
|
|
163
|
+
// whole selected row accent. Arnative keeps accent on the `→` marker and `·` separators
|
|
164
|
+
// and moves the selected text to soft (accent on themes without the token).
|
|
165
|
+
const SELECT_ROW_KEY = Symbol.for("pi-arnative.selectRowColors");
|
|
166
|
+
|
|
167
|
+
/** Selected row body: `·` separators accent, the text around them soft. */
|
|
168
|
+
export function selectRowText(row: string, th: { fg(c: string, t: string): string }): string {
|
|
169
|
+
const soft = accentSoftOf(th);
|
|
170
|
+
return row
|
|
171
|
+
.split("·")
|
|
172
|
+
.map((part) => soft(part))
|
|
173
|
+
.join(th.fg("accent", "·"));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (ExtensionSelectorComponent?.prototype && !(globalThis as Record<symbol, boolean>)[SELECT_ROW_KEY]) {
|
|
177
|
+
(globalThis as Record<symbol, boolean>)[SELECT_ROW_KEY] = true;
|
|
178
|
+
const proto = ExtensionSelectorComponent.prototype as unknown as {
|
|
179
|
+
options: string[];
|
|
180
|
+
selectedIndex: number;
|
|
181
|
+
listContainer: { clear(): void; addChild(c: unknown): void };
|
|
182
|
+
updateList(): void;
|
|
183
|
+
};
|
|
184
|
+
const origUpdateList = proto.updateList;
|
|
185
|
+
proto.updateList = function (this: typeof proto) {
|
|
186
|
+
const th = activeThemeProxy;
|
|
187
|
+
if (!th) return origUpdateList.call(this); // no theme yet -> pi's default
|
|
188
|
+
this.listContainer.clear();
|
|
189
|
+
for (let i = 0; i < this.options.length; i++) {
|
|
190
|
+
const text =
|
|
191
|
+
i === this.selectedIndex
|
|
192
|
+
? th.fg("accent", "→ ") + selectRowText(this.options[i]!, th)
|
|
193
|
+
: ` ${th.fg("text", this.options[i]!)}`;
|
|
194
|
+
this.listContainer.addChild(new Text(text, 1, 0));
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
158
199
|
export default function uiRenderTweaks(pi: ExtensionAPI) {
|
|
159
200
|
// The active theme is re-read per session (ctx.ui.theme); render reads it lazily.
|
|
160
201
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -339,10 +380,13 @@ if (isMain(import.meta.url)) {
|
|
|
339
380
|
vars: Record<string, string>;
|
|
340
381
|
colors: Record<string, string>;
|
|
341
382
|
};
|
|
342
|
-
|
|
383
|
+
// Themes store OKLCH; the Theme class shipped in node_modules (0.87.1) only parses
|
|
384
|
+
// hex, so resolve every token through lib/color before constructing it.
|
|
385
|
+
const resolve = (value: string) => toHex(themeJson.vars[value] ?? value);
|
|
386
|
+
const hex = (tok: string) => resolve(themeJson.colors[tok] ?? tok);
|
|
343
387
|
// The built-in theme adds fallbacks (scrollbarTrack<-muted, thinkingMax<-thinkingXhigh),
|
|
344
388
|
// so the fg map must be complete; take the whole palette like pi's loader does.
|
|
345
|
-
const fgPalette = Object.fromEntries(Object.entries(themeJson.colors).map(([k, v]) => [k,
|
|
389
|
+
const fgPalette = Object.fromEntries(Object.entries(themeJson.colors).map(([k, v]) => [k, resolve(v)]));
|
|
346
390
|
const thPill = new Theme(fgPalette, { toolPendingBg: hex("toolPendingBg"), selectedBg: hex("selectedBg") }, "truecolor");
|
|
347
391
|
const pillOriginal = thPill.bg("selectedBg", thPill.fg("text", " ↓ Jump to latest message · End "));
|
|
348
392
|
const rgb = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(";");
|
|
@@ -361,6 +405,14 @@ if (isMain(import.meta.url)) {
|
|
|
361
405
|
assert(accentPill("other text", thPill) === "other text", "pill: non-pill text untouched");
|
|
362
406
|
assert(accentPill(pillOriginal, null) === pillOriginal, "pill: no theme -> passed through");
|
|
363
407
|
|
|
408
|
+
// select rows: `→` + `·` stay accent, selected text moves to accentSoft
|
|
409
|
+
const tagFg = { fg: (c: string, t: string) => `<${c}>${t}</${c}>` };
|
|
410
|
+
assert(
|
|
411
|
+
selectRowText("Routing · off", tagFg) === "<accentSoft>Routing </accentSoft><accent>·</accent><accentSoft> off</accentSoft>",
|
|
412
|
+
"select row: separators accent, text accentSoft",
|
|
413
|
+
);
|
|
414
|
+
assert(selectRowText("plain", tagFg) === "<accentSoft>plain</accentSoft>", "select row without separators: all accentSoft");
|
|
415
|
+
|
|
364
416
|
// Theme variants: every themes/*.json must be valid — name = file name, all
|
|
365
417
|
// required colors present, valid hex, and contrast measured against that theme's
|
|
366
418
|
// own canvas (userMessageBg): content >= 3.0, secondary text >= 2.0, thinking
|
|
@@ -380,16 +432,7 @@ if (isMain(import.meta.url)) {
|
|
|
380
432
|
thinkingOff: 1.3, thinkingMinimal: 1.3, thinkingLow: 2.0,
|
|
381
433
|
thinkingMedium: 2.7, thinkingHigh: 3.6, thinkingXhigh: 4.0,
|
|
382
434
|
};
|
|
383
|
-
const
|
|
384
|
-
const ch = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((c) =>
|
|
385
|
-
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4,
|
|
386
|
-
);
|
|
387
|
-
return 0.2126 * ch[0]! + 0.7152 * ch[1]! + 0.0722 * ch[2]!;
|
|
388
|
-
};
|
|
389
|
-
const contrast = (a: string, b: string) => {
|
|
390
|
-
const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m);
|
|
391
|
-
return (x! + 0.05) / (y! + 0.05);
|
|
392
|
-
};
|
|
435
|
+
const isColor = (v: string) => /^#[0-9a-f]{6}$/i.test(v) || parseOklch(v) !== null;
|
|
393
436
|
for (const f of readdirSync(themesDir).filter((n) => n.endsWith(".json")).sort()) {
|
|
394
437
|
const d = JSON.parse(readFileSync(new URL(f, themesDir), "utf8")) as {
|
|
395
438
|
name: string;
|
|
@@ -399,29 +442,45 @@ if (isMain(import.meta.url)) {
|
|
|
399
442
|
assert(d.name === f.replace(/\.json$/, ""), `theme ${f}: name = file name`);
|
|
400
443
|
const missing = required.filter((k) => !(k in d.colors));
|
|
401
444
|
assert(missing.length === 0, `theme ${f}: required colors complete (missing ${missing.join(",")})`);
|
|
402
|
-
// colors values may only be: hex, 256-index, empty, or a REAL variable name —
|
|
445
|
+
// colors values may only be: hex, oklch, 256-index, empty, or a REAL variable name —
|
|
403
446
|
// a bare color name (e.g. "muted") makes pi reject the theme with no clear reason.
|
|
404
447
|
for (const [tok, value] of Object.entries(d.colors)) {
|
|
405
448
|
if (typeof value !== "string") continue;
|
|
406
|
-
if (value === "" ||
|
|
407
|
-
assert(false, `theme ${f}: colors.${tok} = "${value}" is not hex/var`);
|
|
449
|
+
if (value === "" || isColor(value) || value in d.vars) continue;
|
|
450
|
+
assert(false, `theme ${f}: colors.${tok} = "${value}" is not hex/oklch/var`);
|
|
451
|
+
}
|
|
452
|
+
// Every var must itself be a real color, and no two vars may resolve to the
|
|
453
|
+
// same value — near-identical hand-written hex was the original problem.
|
|
454
|
+
for (const [name, value] of Object.entries(d.vars)) {
|
|
455
|
+
assert(isColor(value), `theme ${f}: vars.${name} is a color (${value})`);
|
|
408
456
|
}
|
|
409
|
-
const
|
|
457
|
+
const seenVar = new Map<string, string>();
|
|
458
|
+
for (const [name, value] of Object.entries(d.vars)) {
|
|
459
|
+
const h = toHex(value);
|
|
460
|
+
assert(!seenVar.has(h), `theme ${f}: vars.${name} duplicates vars.${seenVar.get(h)} (${h})`);
|
|
461
|
+
seenVar.set(h, name);
|
|
462
|
+
}
|
|
463
|
+
const hex = (tok: string) => toHex(d.vars[d.colors[tok] ?? tok] ?? d.colors[tok]!);
|
|
410
464
|
for (const tok of ["accent", "text", "selectedBg", "toolPendingBg"]) {
|
|
411
|
-
assert(
|
|
465
|
+
assert(isColor(d.vars[d.colors[tok] ?? tok] ?? d.colors[tok]!), `theme ${f}: ${tok} is a color (${hex(tok)})`);
|
|
412
466
|
}
|
|
413
467
|
// Accent pill: accent bg + toolPendingBg colored text.
|
|
414
468
|
assert(contrast(hex("accent"), hex("toolPendingBg")) >= 4.5, `theme ${f}: accent vs toolPendingBg contrast >= 4.5`);
|
|
415
|
-
assert(
|
|
469
|
+
assert(isColor(d.vars.accentSoft ?? ""), `theme ${f}: vars.accentSoft is a color`);
|
|
416
470
|
const canvas = hex("userMessageBg");
|
|
417
471
|
for (const tok of Object.keys(d.colors)) {
|
|
418
472
|
if (nonText.has(tok) || tok === "thinkingMax") continue;
|
|
419
473
|
const color = hex(tok);
|
|
420
|
-
if (!/^#[0-9a-f]{6}$/i.test(color)) continue; // 256-color index: let pi judge it
|
|
421
474
|
const limit = ramp[tok] ?? (secondary.has(tok) ? 2.0 : 3.0);
|
|
422
475
|
const c = contrast(color, canvas);
|
|
423
476
|
assert(c >= limit, `theme ${f}: ${tok} vs canvas contrast ${c.toFixed(2)} < ${limit}`);
|
|
424
477
|
}
|
|
478
|
+
// Thinking ramp must rise monotonically (Off < Minimal < … < Xhigh).
|
|
479
|
+
const rampKeys = ["thinkingOff", "thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh", "thinkingXhigh"];
|
|
480
|
+
for (let i = 1; i < rampKeys.length; i++) {
|
|
481
|
+
const [lo, hi] = [hex(rampKeys[i - 1]!), hex(rampKeys[i]!)];
|
|
482
|
+
assert(contrast(lo, canvas) < contrast(hi, canvas), `theme ${f}: ${rampKeys[i]} is brighter than ${rampKeys[i - 1]}`);
|
|
483
|
+
}
|
|
425
484
|
// Tool box text must be readable on the success background AND in errors, and
|
|
426
485
|
// the error text in the error box — three pairs that used to pass (3.48-4.46)
|
|
427
486
|
// without being caught.
|
|
@@ -433,8 +492,13 @@ if (isMain(import.meta.url)) {
|
|
|
433
492
|
assert(c >= 4.5, `theme ${f}: ${fg} on ${bg} contrast ${c.toFixed(2)} < 4.5`);
|
|
434
493
|
}
|
|
435
494
|
// The error box must not be heavier than the success box (matching background luminance).
|
|
436
|
-
const [lErr, lOk] = [
|
|
437
|
-
|
|
495
|
+
const [lErr, lOk] = [hex("toolErrorBg"), hex("toolSuccessBg")].map((h) => {
|
|
496
|
+
const ch = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((c) =>
|
|
497
|
+
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4,
|
|
498
|
+
);
|
|
499
|
+
return 0.2126 * ch[0]! + 0.7152 * ch[1]! + 0.0722 * ch[2]!;
|
|
500
|
+
});
|
|
501
|
+
assert(Math.abs(lErr! - lOk!) / Math.max(lErr!, lOk!) <= 0.25, `theme ${f}: toolErrorBg vs toolSuccessBg weight (${lErr!.toFixed(3)} vs ${lOk!.toFixed(3)})`);
|
|
438
502
|
// Scrollbar thumb & highlight must be distinguishable from track/selection (they used to be identical).
|
|
439
503
|
assert(hex("scrollbarThumb") !== hex("scrollbarTrack"), `theme ${f}: scrollbarThumb still equals scrollbarTrack`);
|
|
440
504
|
assert(hex("searchMatchBg") !== hex("selectedBg"), `theme ${f}: searchMatchBg still equals selectedBg`);
|
|
@@ -447,7 +511,7 @@ if (isMain(import.meta.url)) {
|
|
|
447
511
|
assert(accentPill(pillOriginal, thEmpty) === pillOriginal, "pill: token absent -> passed through");
|
|
448
512
|
const hostPil: { scrollToEndIndicator?: () => string } = { scrollToEndIndicator: () => pillOriginal };
|
|
449
513
|
const inPill = withAccentPill(hostPil, () => hostPil.scrollToEndIndicator?.(), thPill);
|
|
450
|
-
assert(inPill.includes(
|
|
514
|
+
assert(inPill.includes(`\x1b[48;2;${rgb(hex("accent"))}m`), "pill: instance callback injected with accent bg");
|
|
451
515
|
assert(hostPil.scrollToEndIndicator?.() === pillOriginal, "pill: original callback restored after render");
|
|
452
516
|
|
|
453
517
|
// compaction: pi's three lines -> one
|
package/extensions/usage.ts
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* shared with the Model tab.
|
|
5
5
|
*/
|
|
6
6
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
7
10
|
import { matchesKey, truncateToWidth, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
8
11
|
import { boxEdge, boxRow } from "../lib/box.ts";
|
|
9
12
|
import { assert, isMain } from "../lib/check.ts";
|
|
@@ -12,7 +15,11 @@ import { collectUsageSummary, formatTokens, getModelAllTimeUsage, type UsageSumm
|
|
|
12
15
|
export function formatCost(n: number): string {
|
|
13
16
|
if (!n || n <= 0) return "-";
|
|
14
17
|
if (n < 0.01) return "<$0.01";
|
|
15
|
-
if (n < 1000)
|
|
18
|
+
if (n < 1000) {
|
|
19
|
+
// Rounding at the boundary used to print "$1000.00" instead of "$1.0k".
|
|
20
|
+
const s = n.toFixed(2);
|
|
21
|
+
return s === "1000.00" ? "$1.0k" : `$${s}`;
|
|
22
|
+
}
|
|
16
23
|
return `$${(n / 1000).toFixed(1)}k`;
|
|
17
24
|
}
|
|
18
25
|
|
|
@@ -147,9 +154,9 @@ export class UsageModalComponent implements Component {
|
|
|
147
154
|
// arnative chat-column box wraps title + table + hint, full width.
|
|
148
155
|
// Round ends (╭─╮ / ╰─╯); sides + divider from lib/box.ts (single definition).
|
|
149
156
|
const boxW = width;
|
|
150
|
-
const
|
|
151
|
-
const hline = (l: string, r: string) => boxEdge(l, r, boxW,
|
|
152
|
-
const row = (text: string) => boxRow(text, boxW,
|
|
157
|
+
const soft = (s: string) => th.fg("accentSoft", s);
|
|
158
|
+
const hline = (l: string, r: string) => boxEdge(l, r, boxW, soft);
|
|
159
|
+
const row = (text: string) => boxRow(text, boxW, soft);
|
|
153
160
|
const rule = () => hline("├", "┤");
|
|
154
161
|
|
|
155
162
|
const rows = this.buildFlatRows();
|
|
@@ -208,7 +215,7 @@ export class UsageModalComponent implements Component {
|
|
|
208
215
|
const actualIdx = startIdx + i;
|
|
209
216
|
const isSelected = actualIdx === this.selectedIndex;
|
|
210
217
|
|
|
211
|
-
const nameColor = isSelected ? "accent" : item.type === "provider" ? "
|
|
218
|
+
const nameColor = isSelected ? "accent" : item.type === "provider" ? "accentSoft" : "text";
|
|
212
219
|
const valColor = isSelected ? "accent" : "dim";
|
|
213
220
|
|
|
214
221
|
const cName = formatCol(th.fg(nameColor, isSelected ? bold(item.label) : item.label), colW.name, false);
|
|
@@ -228,14 +235,14 @@ export class UsageModalComponent implements Component {
|
|
|
228
235
|
|
|
229
236
|
// Total row
|
|
230
237
|
const t = this.summary.totals;
|
|
231
|
-
const totalName = formatCol(th.fg("
|
|
232
|
-
const totalSess = formatCol(th.fg("
|
|
233
|
-
const totalMsgs = formatCol(th.fg("
|
|
234
|
-
const totalToks = formatCol(th.fg("
|
|
235
|
-
const totalInp = formatCol(th.fg("
|
|
236
|
-
const totalOut = formatCol(th.fg("
|
|
237
|
-
const totalCache = formatCol(th.fg("
|
|
238
|
-
const totalCost = formatCol(th.fg("
|
|
238
|
+
const totalName = formatCol(th.fg("accentSoft", bold("Total")), colW.name, false);
|
|
239
|
+
const totalSess = formatCol(th.fg("accentSoft", bold(formatCount(t.sessions))), colW.sessions);
|
|
240
|
+
const totalMsgs = formatCol(th.fg("accentSoft", bold(formatCount(t.msgs))), colW.msgs);
|
|
241
|
+
const totalToks = formatCol(th.fg("accentSoft", bold(formatTokens(t.totalTokens))), colW.tokens);
|
|
242
|
+
const totalInp = formatCol(th.fg("accentSoft", bold(formatTokens(t.input))), colW.inp);
|
|
243
|
+
const totalOut = formatCol(th.fg("accentSoft", bold(formatTokens(t.output))), colW.out);
|
|
244
|
+
const totalCache = formatCol(th.fg("accentSoft", bold(formatTokens(t.cacheRead))), colW.cache);
|
|
245
|
+
const totalCost = formatCol(th.fg("accentSoft", bold(formatCost(t.cost))), colW.cost);
|
|
239
246
|
|
|
240
247
|
out.push(row(" " + totalName + totalSess + totalMsgs + totalToks + totalInp + totalOut + totalCache + totalCost));
|
|
241
248
|
|
|
@@ -271,6 +278,12 @@ if (isMain(import.meta.url)) {
|
|
|
271
278
|
assert(formatCost(0.004) === "<$0.01", "formatCost 0.004 = <$0.01");
|
|
272
279
|
assert(formatCost(12.345) === "$12.35", "formatCost 12.345 = $12.35");
|
|
273
280
|
assert(formatCost(1234.5) === "$1.2k", "formatCost 1234.5 = $1.2k");
|
|
281
|
+
// Boundary regressions: rounding used to print "1000k" / "$1000.00".
|
|
282
|
+
assert(formatTokens(999_500) === "999.5k", "formatTokens stays k below 1M");
|
|
283
|
+
assert(formatTokens(999_999) === "1M", "formatTokens promotes a k value that rounds to 1000");
|
|
284
|
+
assert(formatTokens(999_999_999) === "1B", "formatTokens promotes an M value that rounds to 1000");
|
|
285
|
+
assert(formatTokens(9_999) === "10k", "formatTokens strips a .0 k tail");
|
|
286
|
+
assert(formatCost(999.996) === "$1.0k", "formatCost rolls over at $1000");
|
|
274
287
|
|
|
275
288
|
const summary = collectUsageSummary();
|
|
276
289
|
assert(summary.providers.length > 0, "collectUsageSummary detects providers");
|
|
@@ -280,6 +293,17 @@ if (isMain(import.meta.url)) {
|
|
|
280
293
|
|
|
281
294
|
const modelUsage = getModelAllTimeUsage("gemini-3.8-flash");
|
|
282
295
|
assert(modelUsage.input > 0, "getModelAllTimeUsage reads input tokens for gemini-3.8-flash");
|
|
296
|
+
assert(
|
|
297
|
+
getModelAllTimeUsage(undefined).input === 0 && getModelAllTimeUsage(undefined).output === 0,
|
|
298
|
+
"getModelAllTimeUsage without a model id matches nothing (no grand total)",
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
// Disk cache pruning: no cache key may point at a session file that is gone.
|
|
302
|
+
const cacheFile = join(getAgentDir(), "arnative-usage-cache.json");
|
|
303
|
+
if (existsSync(cacheFile)) {
|
|
304
|
+
const raw = JSON.parse(readFileSync(cacheFile, "utf8")) as Record<string, unknown>;
|
|
305
|
+
assert(Object.keys(raw).every((k) => existsSync(k)), "usage disk cache prunes deleted session files");
|
|
306
|
+
}
|
|
283
307
|
|
|
284
308
|
const fakeTheme = {
|
|
285
309
|
fg: (_c: string, t: string) => `\x1b[38;2;100;100;100m${t}\x1b[39m`,
|
package/lib/ansi.ts
CHANGED
|
@@ -25,6 +25,18 @@ export function themeOf<T>(ctx: unknown): T | null {
|
|
|
25
25
|
return (ctx as { ui?: { theme?: T } } | null | undefined)?.ui?.theme ?? null;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** Painter for the `accentSoft` token, which only arnative themes define; other themes fall back to accent (probed once). */
|
|
29
|
+
export function accentSoftOf(th: { fg?(c: string, t: string): string } | null): (s: string) => string {
|
|
30
|
+
const fg = th?.fg?.bind(th);
|
|
31
|
+
if (!fg) return (s) => s;
|
|
32
|
+
try {
|
|
33
|
+
fg("accentSoft", "");
|
|
34
|
+
return (s) => fg("accentSoft", s);
|
|
35
|
+
} catch {
|
|
36
|
+
return (s) => fg("accent", s);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
// Self-check: `node lib/ansi.ts`
|
|
29
41
|
if (isMain(import.meta.url)) {
|
|
30
42
|
assert(ansiBgOpen(null, "selectedBg") === "", "no theme: empty");
|
|
@@ -33,5 +45,16 @@ if (isMain(import.meta.url)) {
|
|
|
33
45
|
assert(stripAnsi("x\x1b[K") === "x", "stripAnsi removes non-SGR CSI too");
|
|
34
46
|
assert(themeOf({ ui: { theme: "T" } }) === "T", "themeOf reads ctx.ui.theme");
|
|
35
47
|
assert(themeOf(null) === null, "themeOf tolerates a missing ctx");
|
|
48
|
+
|
|
49
|
+
const tag = { fg: (c: string, t: string) => `<${c}>${t}</${c}>` };
|
|
50
|
+
assert(accentSoftOf(tag)("x") === "<accentSoft>x</accentSoft>", "accentSoftOf uses accentSoft when the theme defines it");
|
|
51
|
+
const noSoft = {
|
|
52
|
+
fg: (c: string, t: string) => {
|
|
53
|
+
if (c === "accentSoft") throw new Error("Unknown theme color: accentSoft");
|
|
54
|
+
return `<${c}>${t}</${c}>`;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
assert(accentSoftOf(noSoft)("x") === "<accent>x</accent>", "accentSoftOf falls back to accent without the token");
|
|
58
|
+
assert(accentSoftOf(null)("x") === "x", "accentSoftOf without a theme is identity");
|
|
36
59
|
console.log("lib/ansi.ts OK");
|
|
37
60
|
}
|