@bismawy/pi-arnative 0.4.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +8 -0
- package/extensions/arnative.ts +14 -3
- package/extensions/footer.ts +25 -26
- package/extensions/section-headers.ts +39 -11
- package/extensions/tools.ts +12 -10
- package/extensions/ui-render-tweaks.ts +53 -4
- package/extensions/usage.ts +21 -16
- package/lib/ansi.ts +83 -6
- package/package.json +1 -1
- package/themes/arnative-cyberpunk.json +35 -33
- package/themes/arnative-dracula.json +27 -25
- package/themes/arnative-emerald.json +8 -6
- package/themes/arnative-gruvbox.json +27 -25
- package/themes/arnative-matrix.json +24 -22
- package/themes/arnative-nord.json +24 -22
- package/themes/arnative-sun.json +9 -7
- package/themes/arnative-synthwave.json +35 -33
- package/themes/arnative-violet.json +9 -7
- package/themes/arnative-zinc.json +8 -6
- package/themes/arnative.json +9 -7
- package/themes/gen-themes.mjs +145 -54
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.2] - 2026-10-13
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- Restored the footer's two-tone labels. `0.5.1` moved the label colour onto `softOf(accent)`, which softens the *accent* hue, so cwd/branch/clock/status text inherited the accent (pink) instead of the border-accent (blue) tier and the footer collapsed into a single hue (`extensions/footer.ts`).
|
|
7
|
+
- Added `vars.secondarySoft` to all 11 themes, softened from the same value the `borderAccent` token resolves to (L 0.82, chroma × 0.18, hue kept), and `softSecondaryOf` in `lib/ansi.ts` to derive the identical value at runtime — `vars` names are not addressable via `fg()`, and Pi 1.2 rejects custom `colors` keys, so the declared and derived values must be produced by one recipe.
|
|
8
|
+
- Routed the header's model/thinking/token text, tool bodies, list rows and the usage dashboard onto the label tier. Icons, marks and the logo's second tone stay on the accent tier, so each line keeps its two tones (`extensions/section-headers.ts`, `extensions/tools.ts`, `extensions/usage.ts`, `extensions/arnative.ts`, `extensions/ui-render-tweaks.ts`).
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- `soften()` is now shared by the generator and the runtime, and `extensions/ui-render-tweaks.ts` asserts every declared `*Soft` var equals its derived value — a drifting or rogue soft colour fails `npm test` instead of shipping (`themes/gen-themes.mjs`, `lib/ansi.ts`).
|
|
12
|
+
- Behavioral test pinning the two-tone rule: labels take the border-accent hue, icons keep the accent hue (`test/footer-minimal.test.ts`).
|
|
13
|
+
|
|
14
|
+
### Notes
|
|
15
|
+
- Label colour is deliberately softer than the pre-0.5.1 `#05dee7`. `SOFT_CHROMA_SCALE` in `lib/ansi.ts` is the single knob: `0.18` gives `#b2c9ca` on cyberpunk, `0.30` gives `#b2dadc`, `0.50` gives `#9adfe3`. Changing it regenerates all 11 themes and the guard forces them to stay in sync.
|
|
16
|
+
|
|
17
|
+
## [0.5.1] - 2026-10-13
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- Restored all 11 arnative themes on Pi 1.2.0, which validates the `colors` object with `additionalProperties: false` and rejected the whole theme — "must not have additional properties" followed by "Theme not found" (`lib/ansi.ts`, `themes/gen-themes.mjs`, `themes/*.json`, `extensions/ui-render-tweaks.ts`). Arnative's own colors (`accentSoft`, `primary`, `secondary`, `primarySoft`, `secondarySoft`) moved from `colors` to `vars`.
|
|
21
|
+
- Kept the two-tone soft tier instead of collapsing it to grey: `softOf` replaces `accentSoftOf` and derives the soft accent from the resolved `accent` color when `fg("accentSoft")` throws, so the header logo, tab labels, model line, footer labels, tool titles and the usage dashboard keep their tint (`lib/ansi.ts`).
|
|
22
|
+
- Updated `$schema` in every theme and the generator from the raw GitHub `theme-schema.json` path to the published `https://pi.dev/schemas/theme.schema.json` (`themes/gen-themes.mjs`, `themes/*.json`).
|
|
23
|
+
- Replaced the footer's raw `fg("accentSoft")` probe with the shared painter and mapped `primary`/`secondary` onto the official tokens they carry (`mdHeading`, `borderAccent`), since Pi 1.2 rejects custom `colors` keys (`extensions/footer.ts`).
|
|
24
|
+
- Mapped the usage dashboard's name/value/arrow colors off `accentSoft` onto the shared soft painter and plain `dim`/`text`, keeping provider rows distinguishable (`extensions/usage.ts`).
|
|
25
|
+
- Added a self-check that fails when a theme puts a non-schema key in `colors`, so a rogue token breaks `npm test` instead of the user's startup (`extensions/ui-render-tweaks.ts`).
|
|
26
|
+
|
|
27
|
+
### Notes
|
|
28
|
+
- `0.5.0` is intentionally skipped on the npm registry. It was tagged and merged, but never published, and its themes are the ones Pi 1.2.0 rejects. `0.5.1` carries everything from v0.5.0 plus this fix. The tag stays in the history; install from npm and you get `0.5.1`.
|
|
29
|
+
|
|
30
|
+
## [0.5.0] - 2026-10-13
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- Extended theme token hierarchy in `vars` with `primary`, `primarySoft`, `secondary`, and `secondarySoft` across all 11 themes (`themes/gen-themes.mjs`), ensuring unique hex mappings and full perceptual OKLCH color space discipline.
|
|
34
|
+
- Tri-color multi-palette overhauls for expressive themes (`arnative-cyberpunk`, `arnative-synthwave`, `arnative-dracula`, `arnative-gruvbox`, and `arnative-nord`), mapping `mdHeading` to `primary` and `borderAccent`/`mdCode` to `secondary`.
|
|
35
|
+
- Footer multi-color pairing (`extensions/footer.ts`): paired vibrant `accent` icons with readable `secondarySoft` labels and highlighted the model name with `primary`.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
- Increased message bubble background lightness (`surfaceMsg` / `userMessageBg`) on `arnative-cyberpunk`, `arnative-matrix`, and `arnative-synthwave` to 24% (`oklch(24% 0.035 ...)`) to ensure container boundaries remain clearly defined against pitch-black terminal canvases.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
- Fixed parenthetical suffix per-row dimming in `/arnative` → **Animations** so `(system)` on `Pi (system)` renders softly in `dim` even when descriptive frame notes are present (`extensions/arnative.ts`).
|
|
42
|
+
- Fixed inline image preview layout spacing by dropping the redundant spacer gap between the tool execution box and the image payload (`extensions/tools.ts`).
|
|
43
|
+
|
|
3
44
|
## [0.4.0] - 2026-10-12
|
|
4
45
|
|
|
5
46
|
### Added
|
package/README.md
CHANGED
|
@@ -113,10 +113,18 @@ Theme definitions are generated via `themes/gen-themes.mjs` using OKLCH color ma
|
|
|
113
113
|
```bash
|
|
114
114
|
npm test # Run unit tests and self-checks
|
|
115
115
|
npm run themes # Rebuild all theme variants
|
|
116
|
+
npm run preview # Live preview the working tree in your own Pi (stamps a pushable commit)
|
|
116
117
|
```
|
|
117
118
|
|
|
118
119
|
Pre-publish verification and tag validation are enforced via `scripts/release-check.mjs`.
|
|
119
120
|
|
|
121
|
+
**Local-first push gate:** the global pre-push hook rejects any branch push whose commit
|
|
122
|
+
carries no live-preview stamp, so a fix cannot reach GitHub unseen. `npm run preview`
|
|
123
|
+
launches Pi with this working tree and writes `.git/arnative-preview` for the exact commit
|
|
124
|
+
that was shown; editing `extensions/`, `lib/`, `themes/`, or `package.json` afterwards
|
|
125
|
+
invalidates it and the hook asks for a fresh preview. The preview must run on a clean tree
|
|
126
|
+
— commit or stash first.
|
|
127
|
+
|
|
120
128
|
</details>
|
|
121
129
|
|
|
122
130
|
## License
|
package/extensions/arnative.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { matchesKey, truncateToWidth, visibleWidth, type AutocompleteItem, type Component } from "@earendil-works/pi-tui";
|
|
10
|
-
import {
|
|
10
|
+
import { softSecondaryOf, stripAnsi, thinkingColorOf } from "../lib/ansi.ts";
|
|
11
11
|
import { renderScreen, screenTitle, type ScreenText } from "../lib/box.ts";
|
|
12
12
|
import { assert, isMain } from "../lib/check.ts";
|
|
13
13
|
import { markedLabels } from "../lib/format.ts";
|
|
@@ -172,7 +172,7 @@ export class ListPicker implements Component {
|
|
|
172
172
|
render(width: number): string[] {
|
|
173
173
|
const th = this.theme;
|
|
174
174
|
const bold = th.bold ?? ((t: string) => `\x1b[1m${t}\x1b[22m`);
|
|
175
|
-
const soft =
|
|
175
|
+
const soft = softSecondaryOf(th);
|
|
176
176
|
const edge = (text: string) => this.edge(text);
|
|
177
177
|
const column = Math.max(0, ...this.rows.map((r) => visibleWidth(r.label)));
|
|
178
178
|
const body: string[] = [];
|
|
@@ -183,7 +183,7 @@ export class ListPicker implements Component {
|
|
|
183
183
|
const pad = " ".repeat(Math.max(0, column - visibleWidth(item.label)));
|
|
184
184
|
const note = item.note ? `${th.fg("dim", ROW_DIVIDER)}${th.fg("dim", item.note)}` : "";
|
|
185
185
|
// Extract parenthetical suffix to render softly
|
|
186
|
-
const suffix =
|
|
186
|
+
const suffix = /(\(.*\))$/.exec(item.label)?.[1] ?? "";
|
|
187
187
|
const head = suffix ? item.label.slice(0, item.label.length - suffix.length) : item.label;
|
|
188
188
|
const tail = suffix ? th.fg("dim", suffix) : "";
|
|
189
189
|
const content = `${isSelected ? th.fg("accent", "→ ") : " "}${paint(head)}${tail}${pad}${note}`;
|
|
@@ -673,5 +673,16 @@ if (isMain(import.meta.url)) {
|
|
|
673
673
|
assert(footerLines.some((l) => l.includes("Arnative (Minimal)")), "footers picker lists the minimal preset");
|
|
674
674
|
assert(footerLines.filter((l) => l.includes("Arnative (")).length === 2, "both arnative footer presets are selectable");
|
|
675
675
|
|
|
676
|
+
// Animations picker: "Pi (system)" carries a note AND renders "(system)" in dim
|
|
677
|
+
const animPicker = new ListPicker(
|
|
678
|
+
{ fg: (c, t) => `<${c}>${t}</${c}>`, bold: (t) => `<bold>${t}</bold>` },
|
|
679
|
+
animationRows(),
|
|
680
|
+
"Pi (system)",
|
|
681
|
+
{ preview: () => {}, revert: () => {}, done: () => {}, requestRender: () => {} },
|
|
682
|
+
ANIMATIONS_PICKER_TEXT,
|
|
683
|
+
);
|
|
684
|
+
const animLines = animPicker.render(120);
|
|
685
|
+
assert(animLines.some((l) => l.includes("Pi ") && l.includes("<dim>(system)</dim>")), "animations picker dims (system)");
|
|
686
|
+
|
|
676
687
|
console.log("arnative.ts self-check OK");
|
|
677
688
|
}
|
package/extensions/footer.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { execFile } from "node:child_process";
|
|
|
11
11
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { tmpdir } from "node:os";
|
|
13
13
|
import { dirname, join, resolve } from "node:path";
|
|
14
|
-
import { stripAnsi } from "../lib/ansi.ts";
|
|
14
|
+
import { softOf, softSecondaryOf, stripAnsi } from "../lib/ansi.ts";
|
|
15
15
|
import { assert, isMain } from "../lib/check.ts";
|
|
16
16
|
import { capitalize, markedLabels, modelDisplayParts } from "../lib/format.ts";
|
|
17
17
|
import { IS_WINDOWS_LIKE, SHORTCUT_RELOAD, SHORTCUT_TOGGLE_FOOTER } from "../lib/shortcuts.ts";
|
|
@@ -398,6 +398,7 @@ function formatModelName(
|
|
|
398
398
|
thinkingLevel: string | undefined,
|
|
399
399
|
acc: (t: string) => string,
|
|
400
400
|
soft: (t: string) => string,
|
|
401
|
+
primary: (t: string) => string,
|
|
401
402
|
dim: (t: string) => string,
|
|
402
403
|
): string {
|
|
403
404
|
if (!model) return dim("No Model");
|
|
@@ -405,7 +406,7 @@ function formatModelName(
|
|
|
405
406
|
const provStr = provider ? ` ${dim(`(${provider})`)}` : "";
|
|
406
407
|
const capThinking = thinkingLevel && thinkingLevel !== "off" ? capitalize(thinkingLevel) : "";
|
|
407
408
|
const thinkStr = capThinking ? `${acc("\uee9c")} ${soft(capThinking)} ${dim("·")} ` : "";
|
|
408
|
-
return `${thinkStr}${
|
|
409
|
+
return `${thinkStr}${primary(name)}${provStr}`;
|
|
409
410
|
}
|
|
410
411
|
|
|
411
412
|
// Footer is a single line: a deep cwd (`/Users/x/Workspace/personal/proj`)
|
|
@@ -888,14 +889,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
888
889
|
const acc = (text: string) => theme.fg("accent", text);
|
|
889
890
|
const dim = (text: string) => theme.fg("dim", text);
|
|
890
891
|
const fgAny = theme.fg.bind(theme) as (color: string, text: string) => string;
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
}
|
|
898
|
-
const soft = (text: string) => fgAny(softName, text);
|
|
892
|
+
// `accentSoft`/`secondarySoft` are vars-only tokens on Pi 1.2+, so both soft tiers
|
|
893
|
+
// are resolved through the shared painters instead of a raw fg() probe. Icons keep
|
|
894
|
+
// `accent`; the cwd/branch/clock/status text takes the border-accent tier, so the
|
|
895
|
+
// footer stays two-toned instead of the text inheriting the accent hue.
|
|
896
|
+
const soft = softOf(theme as never);
|
|
897
|
+
const softLabel = softSecondaryOf(theme as never);
|
|
899
898
|
// Diff colors: the same theme slots pi uses for a diff body, with the
|
|
900
899
|
// plain soft color as fallback for a theme that omits them.
|
|
901
900
|
const colorOr = (name: string, fallback: (t: string) => string) => (t: string) => {
|
|
@@ -905,6 +904,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
905
904
|
return fallback(t);
|
|
906
905
|
}
|
|
907
906
|
};
|
|
907
|
+
// `primary` is a var, not a token (Pi 1.2+ rejects custom `colors` keys); its
|
|
908
|
+
// arnative carrier is the official token it maps to.
|
|
909
|
+
const primary = colorOr("mdHeading", acc);
|
|
910
|
+
const secondarySoft = softLabel;
|
|
908
911
|
const diffAdd = colorOr("toolDiffAdded", soft);
|
|
909
912
|
const diffDel = colorOr("toolDiffRemoved", soft);
|
|
910
913
|
const sep = dim(" | ");
|
|
@@ -914,7 +917,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
914
917
|
const paused = clock.idle();
|
|
915
918
|
idlePauseShown = paused;
|
|
916
919
|
const clockIcon = paused ? dim("\uf017") : acc("\uf017");
|
|
917
|
-
const clockText = paused ? dim(durationStr) :
|
|
920
|
+
const clockText = paused ? dim(durationStr) : secondarySoft(durationStr);
|
|
918
921
|
const pDuration = `${clockIcon} ${clockText}${diffStat ? `${sep}${diffStat}` : ""}`;
|
|
919
922
|
const cwdShort = shortenCwd(cwd);
|
|
920
923
|
const isMinimal = activeFooterPreset() === "Arnative (Minimal)";
|
|
@@ -922,10 +925,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
922
925
|
const arrow = (expanded: boolean) => dim(expanded ? " ▼" : " ▲");
|
|
923
926
|
let left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}`;
|
|
924
927
|
if (git) {
|
|
925
|
-
left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}${gitParts(git, acc,
|
|
928
|
+
left1 = `${acc("\uf07b")} ${dim(cwdShort)}${sep}${pDuration}${gitParts(git, acc, secondarySoft).map((p) => `${sep}${p}`).join("")}`;
|
|
926
929
|
}
|
|
927
930
|
if (isMinimal) left1 += arrow(open.left);
|
|
928
|
-
const right1 = formatModelName(currentModel, currentThinkingLevel, acc,
|
|
931
|
+
const right1 = formatModelName(currentModel, currentThinkingLevel, acc, secondarySoft, primary, dim) + (isMinimal ? arrow(open.right) : "");
|
|
929
932
|
|
|
930
933
|
const statuses: ReadonlyMap<string, string> = (() => {
|
|
931
934
|
try {
|
|
@@ -942,9 +945,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
942
945
|
let rest = (idx >= 0 ? clean.slice(idx) : clean).replace(/\uFFFD/g, "").trim();
|
|
943
946
|
rest = rest.replace(/[\p{Extended_Pictographic}\uFE0F\u200D\u2800-\u28FF]/gu, "").replace(/\s{2,}/g, " ").trim();
|
|
944
947
|
const m = rest.match(/MCP:\s*\d+\s*servers?\s*enabled/i);
|
|
945
|
-
return `${acc("\uf233")} ${
|
|
948
|
+
return `${acc("\uf233")} ${secondarySoft(m ? m[0] : rest || "MCP")}`;
|
|
946
949
|
}
|
|
947
|
-
if (s.includes("ponytail")) return ponytailStatusLine(s, acc, dim,
|
|
950
|
+
if (s.includes("ponytail")) return ponytailStatusLine(s, acc, dim, secondarySoft);
|
|
948
951
|
if (s.includes("jev-eye")) {
|
|
949
952
|
const isOff = s.includes("OFF") || s.includes("○");
|
|
950
953
|
const isReview = /REVIEW/i.test(s);
|
|
@@ -959,11 +962,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
959
962
|
const label = isOff ? "OFF" : isReview ? "REVIEW" : "ON";
|
|
960
963
|
// Two spaces: \uedcf's ink is wider than MCP's \uf233 or ponytail's \uef04,
|
|
961
964
|
// so it needs the extra cell to clear the label.
|
|
962
|
-
return `${acc("\uedcf")} ${
|
|
965
|
+
return `${acc("\uedcf")} ${secondarySoft("Jev:")} ${bullet} ${secondarySoft(label)}`;
|
|
963
966
|
}
|
|
964
967
|
let clean = stripAnsi(s);
|
|
965
968
|
clean = clean.replace(/\uFFFD/g, "").replace(/\?{1,2}\s*/g, "");
|
|
966
|
-
return
|
|
969
|
+
return secondarySoft(clean.trim());
|
|
967
970
|
};
|
|
968
971
|
|
|
969
972
|
const groups: string[] = [];
|
|
@@ -976,12 +979,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
976
979
|
const opt = parseOptimizer(rawCache);
|
|
977
980
|
let usageStr = "";
|
|
978
981
|
try {
|
|
979
|
-
usageStr = getUsage(ctx, acc,
|
|
982
|
+
usageStr = getUsage(ctx, acc, secondarySoft);
|
|
980
983
|
} catch {
|
|
981
984
|
// fallback
|
|
982
985
|
}
|
|
983
|
-
const speedStr = latestSpeed !== null && latestSpeed > 0 ? `${acc("\udb81\udcc5")} ${
|
|
984
|
-
const metricCells = [speedStr, opt ? `${acc("\udb80\udf5b")} ${
|
|
986
|
+
const speedStr = latestSpeed !== null && latestSpeed > 0 ? `${acc("\udb81\udcc5")} ${secondarySoft(`${latestSpeed.toFixed(1)} tok/s`)}` : "";
|
|
987
|
+
const metricCells = [speedStr, opt ? `${acc("\udb80\udf5b")} ${secondarySoft(opt)}` : "", usageStr].filter(Boolean);
|
|
985
988
|
|
|
986
989
|
// Fixed 3-row grid: head (cwd/model), then statuses and metrics each
|
|
987
990
|
// wrapped inside their half of the width. Arnative (Minimal) starts collapsed
|
|
@@ -1042,11 +1045,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1042
1045
|
try {
|
|
1043
1046
|
const th = this.getActiveTheme();
|
|
1044
1047
|
if (!th) return text;
|
|
1045
|
-
|
|
1046
|
-
return th.fg("accentSoft", text);
|
|
1047
|
-
} catch {
|
|
1048
|
-
return th.fg("muted", text);
|
|
1049
|
-
}
|
|
1048
|
+
return softSecondaryOf(th as never)(text);
|
|
1050
1049
|
} catch {
|
|
1051
1050
|
return text;
|
|
1052
1051
|
}
|
|
@@ -1428,7 +1427,7 @@ if (isMain(import.meta.url)) {
|
|
|
1428
1427
|
resetPresetCache();
|
|
1429
1428
|
|
|
1430
1429
|
// Self-check: formatModelName thinking icon
|
|
1431
|
-
const fmnRes = formatModelName({ id: "claude-3-7-sonnet", name: "Sonnet" }, "low", (s) => s, (s) => s, (s) => s);
|
|
1430
|
+
const fmnRes = formatModelName({ id: "claude-3-7-sonnet", name: "Sonnet" }, "low", (s) => s, (s) => s, (s) => s, (s) => s);
|
|
1432
1431
|
assert(fmnRes.includes("\uee9c Low"), "formatModelName includes \\uee9c Low thinking indicator");
|
|
1433
1432
|
|
|
1434
1433
|
// Self-check: the ponytail glyph. \uef51 carries no outline in the Windows Nerd Font
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
} from "@earendil-works/pi-tui";
|
|
34
34
|
import type { BoxTheme } from "../lib/box.ts";
|
|
35
35
|
import { BRAND, renderBoxLines } from "../lib/box.ts";
|
|
36
|
-
import { getActiveTheme, setActiveTheme, stripAnsi, themeOf } from "../lib/ansi.ts";
|
|
36
|
+
import { getActiveTheme, setActiveTheme, softOf, softSecondaryOf, stripAnsi, themeOf } from "../lib/ansi.ts";
|
|
37
37
|
import { capitalize, expandKeyName, modelDisplayParts } from "../lib/format.ts";
|
|
38
38
|
import { SHORTCUT_NEW_SESSION, SHORTCUT_NEXT_TAB, SHORTCUT_RELOAD } from "../lib/shortcuts.ts";
|
|
39
39
|
import { HEADER_PRESET_KEY as HEADER_PRESET_CONFIG_KEY, loadChoice, saveChoice } from "../lib/preset-store.ts";
|
|
@@ -78,6 +78,34 @@ export function fgFirst(th: Themeish, names: string[], text: string): string {
|
|
|
78
78
|
return text;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Painter for the header's soft *accent* tier — the logo mark's second tone, paired with
|
|
83
|
+
* the accent `P`. Keeps the mark in one hue family instead of blue.
|
|
84
|
+
*
|
|
85
|
+
* Pi 1.2.0 rejects a custom `accentSoft` key in `colors`, so `fgFirst(["accentSoft", …])`
|
|
86
|
+
* silently degrades to plain text there and the two-tone look flattens. `softOf` derives
|
|
87
|
+
* the same tint from the resolved accent; a theme that genuinely defines `accentSoft`
|
|
88
|
+
* still wins first.
|
|
89
|
+
*/
|
|
90
|
+
export function softFirst(th: Themeish, fallbacks: string[], text: string): string {
|
|
91
|
+
return softOnTier(softOf(th as never), th, fallbacks, text);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Painter for the soft *label* tier of header text (model name, thinking level, token
|
|
96
|
+
* counts). Same colour language as the footer: marks take `accent`, their text takes the
|
|
97
|
+
* soft border-accent tier, so a header line and the footer under it agree.
|
|
98
|
+
*/
|
|
99
|
+
export function softLabelFirst(th: Themeish, fallbacks: string[], text: string): string {
|
|
100
|
+
return softOnTier(softSecondaryOf(th as never), th, fallbacks, text);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Shared tail of the two soft painters: use the resolved soft tone, else the fallback tokens. */
|
|
104
|
+
function softOnTier(paint: (s: string) => string, th: Themeish, fallbacks: string[], text: string): string {
|
|
105
|
+
const out = paint(text);
|
|
106
|
+
return out !== text ? out : fgFirst(th, fallbacks, text);
|
|
107
|
+
}
|
|
108
|
+
|
|
81
109
|
/**
|
|
82
110
|
* Collapsed body of a pi startup section ("[Skills]\n a, b"). pi 0.99 dropped
|
|
83
111
|
* ExpandableText's own getCollapsedText/getExpandedText fields for a `build` callback
|
|
@@ -279,7 +307,7 @@ export function centerLine(text: string, width: number, side = "│"): string {
|
|
|
279
307
|
// bar, column 3) in the theme soft — same P/i split as the old wordmark.
|
|
280
308
|
export function buildLogoLines(th: Themeish): string[] {
|
|
281
309
|
const a = (s: string) => fgFirst(th, ["accent"], s);
|
|
282
|
-
const t = (s: string) =>
|
|
310
|
+
const t = (s: string) => softFirst(th, ["accentSoft", "text"], s);
|
|
283
311
|
return [
|
|
284
312
|
`${a("██████")} `,
|
|
285
313
|
`${a("██ ██")} `,
|
|
@@ -483,7 +511,7 @@ export class ArnativeHeader implements Component {
|
|
|
483
511
|
const side = fgFirst(th, ["dim"], "│");
|
|
484
512
|
|
|
485
513
|
const infoLines: string[] = [];
|
|
486
|
-
const brand = (name: string) => fgFirst(th, ["accent"
|
|
514
|
+
const brand = (name: string) => fgFirst(th, ["accent"], `\x1b[1m${name}\x1b[22m`);
|
|
487
515
|
const dim = (text: string) => fgFirst(th, ["dim"], text);
|
|
488
516
|
|
|
489
517
|
// Left column: the mark with the pi version under it (no gap). The column is as
|
|
@@ -529,7 +557,7 @@ export class ArnativeHeader implements Component {
|
|
|
529
557
|
const isActive = t.key === this.activeTab;
|
|
530
558
|
const iconStyled = isActive ? fgFirst(th, ["accent"], t.icon) : fgFirst(th, ["dim"], t.icon);
|
|
531
559
|
const labelStyled = isActive
|
|
532
|
-
?
|
|
560
|
+
? softLabelFirst(th, ["accentSoft", "text"], `\x1b[1m${label}\x1b[22m`)
|
|
533
561
|
: fgFirst(th, ["dim"], label);
|
|
534
562
|
return { key: t.key, plain, formatted: `${iconStyled} ${labelStyled}` };
|
|
535
563
|
};
|
|
@@ -593,19 +621,19 @@ export class ArnativeHeader implements Component {
|
|
|
593
621
|
// Model name in soft, its "(Provider)" tail dim.
|
|
594
622
|
const provSuffix = fullName.match(/(\s*\([^()]*\))$/)?.[1] ?? "";
|
|
595
623
|
const nameMain = provSuffix ? fullName.slice(0, -provSuffix.length) : fullName;
|
|
596
|
-
let line = `${
|
|
624
|
+
let line = `${softLabelFirst(th, ["accentSoft", "text"], nameMain)}${provSuffix ? fgFirst(th, ["dim"], provSuffix) : ""}${sep}${softLabelFirst(th, ["accentSoft", "text"], `Thinking: ${thinkLevel}`)}`;
|
|
597
625
|
|
|
598
626
|
// All-time usage of that model (same as /usage): ↑... ↓... ...
|
|
599
627
|
const usage = getModelAllTimeUsage(modelObj?.id);
|
|
600
628
|
const tokenParts: string[] = [];
|
|
601
629
|
if (usage.input > 0) {
|
|
602
|
-
tokenParts.push(`${fgFirst(th, ["accent"], "↑")}${
|
|
630
|
+
tokenParts.push(`${fgFirst(th, ["accent"], "↑")}${softLabelFirst(th, ["accentSoft", "text"], formatTokens(usage.input))}`);
|
|
603
631
|
}
|
|
604
632
|
if (usage.output > 0) {
|
|
605
|
-
tokenParts.push(`${fgFirst(th, ["accent"], "↓")}${
|
|
633
|
+
tokenParts.push(`${fgFirst(th, ["accent"], "↓")}${softLabelFirst(th, ["accentSoft", "text"], formatTokens(usage.output))}`);
|
|
606
634
|
}
|
|
607
635
|
if (usage.cacheRead > 0) {
|
|
608
|
-
tokenParts.push(`${fgFirst(th, ["accent"], "\uf49b ")}${
|
|
636
|
+
tokenParts.push(`${fgFirst(th, ["accent"], "\uf49b ")}${softLabelFirst(th, ["accentSoft", "text"], formatTokens(usage.cacheRead))}`);
|
|
609
637
|
}
|
|
610
638
|
if (tokenParts.length > 0) {
|
|
611
639
|
line += `${sep}${tokenParts.join(" ")}`;
|
|
@@ -618,11 +646,11 @@ export class ArnativeHeader implements Component {
|
|
|
618
646
|
|
|
619
647
|
if (this.activeTab === "Directory") {
|
|
620
648
|
for (const line of wrapPath(getCwd(this.ctx), infoW)) {
|
|
621
|
-
infoLines.push(
|
|
649
|
+
infoLines.push(softLabelFirst(th, ["accentSoft", "text"], line));
|
|
622
650
|
}
|
|
623
651
|
} else if (this.activeTab === "Shortcut") {
|
|
624
652
|
for (const line of wrapTokens(SHORTCUTS, infoW)) {
|
|
625
|
-
infoLines.push(
|
|
653
|
+
infoLines.push(softLabelFirst(th, ["accentSoft", "text"], line));
|
|
626
654
|
}
|
|
627
655
|
} else if (activeItems.length === 0) {
|
|
628
656
|
infoLines.push(
|
|
@@ -634,7 +662,7 @@ export class ArnativeHeader implements Component {
|
|
|
634
662
|
} else {
|
|
635
663
|
for (const line of wrapCommaItems(activeItems, infoW)) {
|
|
636
664
|
// Point 5: selected menu data uses soft only
|
|
637
|
-
infoLines.push(
|
|
665
|
+
infoLines.push(softLabelFirst(th, ["accentSoft", "text"], line));
|
|
638
666
|
}
|
|
639
667
|
}
|
|
640
668
|
|
package/extensions/tools.ts
CHANGED
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
import { Markdown, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
40
40
|
import { homedir } from "node:os";
|
|
41
41
|
import { renderBoxLines as box } from "../lib/box.ts";
|
|
42
|
-
import { stripAnsi,
|
|
42
|
+
import { stripAnsi, softSecondaryOf } from "../lib/ansi.ts";
|
|
43
43
|
import { assert, isMain } from "../lib/check.ts";
|
|
44
44
|
import { expandKeyName } from "../lib/format.ts";
|
|
45
45
|
|
|
@@ -233,15 +233,17 @@ const hasOwnRendererDef = (self: { toolDefinition?: { renderCall?: unknown; rend
|
|
|
233
233
|
// sequence. Not exported by pi-tui, so match both prefixes.
|
|
234
234
|
export const isImageLine = (line: string): boolean => line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
|
|
235
235
|
|
|
236
|
-
/** Drop the leading Spacer
|
|
237
|
-
* are the image's vertical space
|
|
238
|
-
*
|
|
236
|
+
/** Drop the leading Spacer and the gap between box and image preview:
|
|
237
|
+
* its blank tail rows are kept as the image's vertical space so the
|
|
238
|
+
* terminal draws the image with balanced spacing above and below. */
|
|
239
239
|
export function stripEdgesKeepingImages(lines: string[]): string[] {
|
|
240
240
|
const firstImage = lines.findIndex(isImageLine);
|
|
241
241
|
if (firstImage < 0) return stripBlankEdges(lines);
|
|
242
242
|
let s = 0;
|
|
243
243
|
while (s < firstImage && isBlankLine(lines[s])) s++;
|
|
244
|
-
|
|
244
|
+
const head = lines.slice(s, firstImage);
|
|
245
|
+
while (head.length > 0 && isBlankLine(head[head.length - 1]!)) head.pop();
|
|
246
|
+
return [...head, ...lines.slice(firstImage)];
|
|
245
247
|
}
|
|
246
248
|
|
|
247
249
|
const GAP_KEY = Symbol.for("pi-arnative.toolGapStripped");
|
|
@@ -441,7 +443,7 @@ if (ToolExecutionComponent?.prototype?.render && !(globalThis as Record<symbol,
|
|
|
441
443
|
? safeCall(() => own.call(this, { content: result.content, details: result.details }, opts, th, ctx))
|
|
442
444
|
: null;
|
|
443
445
|
return new Lines((width) => {
|
|
444
|
-
const soft =
|
|
446
|
+
const soft = softSecondaryOf(th);
|
|
445
447
|
const inner = Math.max(8, width - 4);
|
|
446
448
|
const icon = isErr ? th.fg("error", "x") : th.fg("success", "✓");
|
|
447
449
|
const boxed2 = componentLines(theirs, width - 6);
|
|
@@ -604,7 +606,7 @@ function minimal(
|
|
|
604
606
|
return new Lines((width) => {
|
|
605
607
|
// Read context.state live (never capture): renderResult may create it after renderCall.
|
|
606
608
|
if (context.state?.hasResult) return [];
|
|
607
|
-
const soft =
|
|
609
|
+
const soft = softSecondaryOf(theme);
|
|
608
610
|
const inner = Math.max(8, width - 4);
|
|
609
611
|
const rows = titleRow(
|
|
610
612
|
theme,
|
|
@@ -633,7 +635,7 @@ function minimal(
|
|
|
633
635
|
const dur = fmtMs(context.toolCallId ? TIMINGS.get(context.toolCallId) : undefined);
|
|
634
636
|
const icon = isErr ? theme.fg("error", "x") : theme.fg("success", "✓");
|
|
635
637
|
return new Lines((width) => {
|
|
636
|
-
const soft =
|
|
638
|
+
const soft = softSecondaryOf(theme);
|
|
637
639
|
const inner = Math.max(8, width - 4);
|
|
638
640
|
const rows = titleRow(
|
|
639
641
|
theme,
|
|
@@ -893,9 +895,9 @@ if (isMain(import.meta.url)) {
|
|
|
893
895
|
const itermSeq = "\x1b]1337;File=inline=1:AAAA\x07";
|
|
894
896
|
const withImage = ["", "╭─╮", "│ ✓ read form_crop.png │", "╰─╯", "", kittySeq, "", ""];
|
|
895
897
|
const kept = stripEdgesKeepingImages(withImage);
|
|
896
|
-
assert(kept.length ===
|
|
898
|
+
assert(kept.length === 6, "image: leading gap and box-image spacer dropped, blank tail kept");
|
|
897
899
|
assert(kept[0] === "╭─╮", "image: leading Spacer removed");
|
|
898
|
-
assert(kept.indexOf(kittySeq) ===
|
|
900
|
+
assert(kept.indexOf(kittySeq) === 3 && kept.length - 4 === 2, "image: rows after the sequence intact");
|
|
899
901
|
assert(
|
|
900
902
|
stripEdgesKeepingImages(["", "a", "", "b", " ", ""]).join() === "a,,b",
|
|
901
903
|
"no image: falls back to plain edge stripping",
|
|
@@ -39,9 +39,9 @@ import {
|
|
|
39
39
|
} from "@earendil-works/pi-tui";
|
|
40
40
|
import { readdirSync, readFileSync } from "node:fs";
|
|
41
41
|
import { renderBoxLines } from "../lib/box.ts";
|
|
42
|
-
import { ansiBgOpen, getActiveTheme, setActiveTheme, stripAnsi, themeOf,
|
|
42
|
+
import { ansiBgOpen, getActiveTheme, setActiveTheme, stripAnsi, themeOf, softSecondaryOf } from "../lib/ansi.ts";
|
|
43
43
|
import { assert, isMain } from "../lib/check.ts";
|
|
44
|
-
import { contrast, parseOklch, toHex } from "../lib/color.ts";
|
|
44
|
+
import { contrast, oklchToRgb, parseOklch, toHex } from "../lib/color.ts";
|
|
45
45
|
import { expandKeyName } from "../lib/format.ts";
|
|
46
46
|
|
|
47
47
|
type ActiveTheme = { fg(color: string, text: string): string; bg?(color: string, text: string): string } | null;
|
|
@@ -174,7 +174,7 @@ const SELECT_ROW_KEY = Symbol.for("pi-arnative.selectRowColors");
|
|
|
174
174
|
|
|
175
175
|
/** Selected row body: `·` separators accent, the text around them soft. */
|
|
176
176
|
export function selectRowText(row: string, th: { fg(c: string, t: string): string }): string {
|
|
177
|
-
const soft =
|
|
177
|
+
const soft = softSecondaryOf(th);
|
|
178
178
|
return row
|
|
179
179
|
.split("·")
|
|
180
180
|
.map((part) => soft(part))
|
|
@@ -396,9 +396,21 @@ if (isMain(import.meta.url)) {
|
|
|
396
396
|
// hex, so resolve every token through lib/color before constructing it.
|
|
397
397
|
const resolve = (value: string) => toHex(themeJson.vars[value] ?? value);
|
|
398
398
|
const hex = (tok: string) => resolve(themeJson.colors[tok] ?? tok);
|
|
399
|
+
// Mirrors `soften()` (themes/gen-themes.mjs) and `softenColor` (lib/ansi.ts): the soft
|
|
400
|
+
// tier is the base color at L 0.82 with a fifth of its chroma. Both sides read the
|
|
401
|
+
// declared OKLCH string (not the 8-bit hex), so the comparison is not rounded twice.
|
|
402
|
+
const softenToHex = (base: string) => {
|
|
403
|
+
const o = parseOklch(base);
|
|
404
|
+
assert(o !== null, `cannot soften a non-oklch color: ${base}`);
|
|
405
|
+
return toHex(`oklch(82% ${oklchToRgb(0.82, o.C * 0.18, o.H).chroma} ${o.H})`);
|
|
406
|
+
};
|
|
399
407
|
// The built-in theme adds fallbacks (scrollbarTrack<-muted, thinkingMax<-thinkingXhigh),
|
|
400
408
|
// so the fg map must be complete; take the whole palette like pi's loader does.
|
|
401
|
-
const fgPalette = Object.fromEntries(
|
|
409
|
+
const fgPalette = Object.fromEntries(
|
|
410
|
+
Object.entries(themeJson.colors)
|
|
411
|
+
.filter(([k]) => k !== "accentSoft")
|
|
412
|
+
.map(([k, v]) => [k, resolve(v)]),
|
|
413
|
+
);
|
|
402
414
|
const thPill = new Theme(fgPalette, { toolPendingBg: hex("toolPendingBg"), selectedBg: hex("selectedBg") }, "truecolor");
|
|
403
415
|
const pillOriginal = thPill.bg("selectedBg", thPill.fg("text", " ↓ Jump to latest message · End "));
|
|
404
416
|
const rgb = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)).join(";");
|
|
@@ -445,13 +457,38 @@ if (isMain(import.meta.url)) {
|
|
|
445
457
|
thinkingMedium: 2.7, thinkingHigh: 3.6, thinkingXhigh: 4.0,
|
|
446
458
|
};
|
|
447
459
|
const isColor = (v: string) => /^#[0-9a-f]{6}$/i.test(v) || parseOklch(v) !== null;
|
|
460
|
+
// The tokens pi's published theme schema allows as keys in `colors`. Pi 1.2.0 validates
|
|
461
|
+
// with `additionalProperties: false`, so ANY other key (arnative's `accentSoft` was the
|
|
462
|
+
// offender) fails the whole theme at startup with "must not have additional properties"
|
|
463
|
+
// and "Theme not found". Arnative's own colors belong in `vars`, referenced from these.
|
|
464
|
+
const SCHEMA_TOKENS = new Set([
|
|
465
|
+
"accent", "border", "borderAccent", "borderMuted", "success", "error", "warning",
|
|
466
|
+
"muted", "dim", "text", "thinkingText", "scrollbarTrack", "scrollbarThumb",
|
|
467
|
+
"selectedBg", "searchMatchBg", "searchMatchText", "userMessageBg", "userMessageText",
|
|
468
|
+
"customMessageBg", "customMessageText", "customMessageLabel", "toolPendingBg",
|
|
469
|
+
"toolSuccessBg", "toolErrorBg", "toolTitle", "toolOutput", "mdHeading", "mdLink",
|
|
470
|
+
"mdLinkUrl", "mdCode", "mdCodeBlock", "mdCodeBlockBorder", "mdQuote", "mdQuoteBorder",
|
|
471
|
+
"mdHr", "mdListBullet", "toolDiffAdded", "toolDiffRemoved", "toolDiffContext",
|
|
472
|
+
"syntaxComment", "syntaxKeyword", "syntaxFunction", "syntaxVariable", "syntaxString",
|
|
473
|
+
"syntaxNumber", "syntaxType", "syntaxOperator", "syntaxPunctuation", "thinkingOff",
|
|
474
|
+
"thinkingMinimal", "thinkingLow", "thinkingMedium", "thinkingHigh", "thinkingXhigh",
|
|
475
|
+
"thinkingMax", "bashMode",
|
|
476
|
+
]);
|
|
448
477
|
for (const f of readdirSync(themesDir).filter((n) => n.endsWith(".json")).sort()) {
|
|
449
478
|
const d = JSON.parse(readFileSync(new URL(f, themesDir), "utf8")) as {
|
|
479
|
+
$schema?: string;
|
|
450
480
|
name: string;
|
|
451
481
|
vars: Record<string, string>;
|
|
452
482
|
colors: Record<string, string>;
|
|
453
483
|
};
|
|
454
484
|
assert(d.name === f.replace(/\.json$/, ""), `theme ${f}: name = file name`);
|
|
485
|
+
// Pi 1.2.0 validates `colors` strictly; an extra key rejects the theme at startup.
|
|
486
|
+
const rogue = Object.keys(d.colors).filter((k) => !SCHEMA_TOKENS.has(k));
|
|
487
|
+
assert(rogue.length === 0, `theme ${f}: colors has non-schema keys [${rogue.join(", ")}] — move them to vars`);
|
|
488
|
+
assert(
|
|
489
|
+
d.$schema === "https://pi.dev/schemas/theme.schema.json",
|
|
490
|
+
`theme ${f}: $schema must be the published pi.dev theme schema (got ${d.$schema})`,
|
|
491
|
+
);
|
|
455
492
|
const missing = required.filter((k) => !(k in d.colors));
|
|
456
493
|
assert(missing.length === 0, `theme ${f}: required colors complete (missing ${missing.join(",")})`);
|
|
457
494
|
// colors values may only be: hex, oklch, 256-index, empty, or a REAL variable name —
|
|
@@ -479,6 +516,18 @@ if (isMain(import.meta.url)) {
|
|
|
479
516
|
// Accent pill: accent bg + toolPendingBg colored text.
|
|
480
517
|
assert(contrast(hex("accent"), hex("toolPendingBg")) >= 4.5, `theme ${f}: accent vs toolPendingBg contrast >= 4.5`);
|
|
481
518
|
assert(isColor(d.vars.accentSoft ?? ""), `theme ${f}: vars.accentSoft is a color`);
|
|
519
|
+
assert(isColor(d.vars.secondarySoft ?? ""), `theme ${f}: vars.secondarySoft is a color`);
|
|
520
|
+
assert(isColor(d.vars.primary ?? ""), `theme ${f}: vars.primary is a color`);
|
|
521
|
+
assert(isColor(d.vars.secondary ?? ""), `theme ${f}: vars.secondary is a color`);
|
|
522
|
+
// The declared soft vars must equal what the extensions derive at runtime, or the
|
|
523
|
+
// footer label colour drifts from the theme file. `softenToHex` mirrors
|
|
524
|
+
// `soften()` in gen-themes.mjs and `softenColor` in lib/ansi.ts.
|
|
525
|
+
for (const [softName, baseName] of [["accentSoft", "accent"], ["secondarySoft", "borderAccent"]] as const) {
|
|
526
|
+
const base = d.vars[d.colors[baseName] ?? baseName]!;
|
|
527
|
+
const declared = toHex(d.vars[softName]!);
|
|
528
|
+
const derived = softenToHex(base);
|
|
529
|
+
assert(declared === derived, `theme ${f}: vars.${softName} ${declared} != runtime ${derived} (derived from ${baseName})`);
|
|
530
|
+
}
|
|
482
531
|
const canvas = hex("userMessageBg");
|
|
483
532
|
for (const tok of Object.keys(d.colors)) {
|
|
484
533
|
if (nonText.has(tok) || tok === "thinkingMax") continue;
|
package/extensions/usage.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { matchesKey, truncateToWidth, visibleWidth, type Component, type TUI, ty
|
|
|
11
11
|
import { renderScreen, screenTitle } from "../lib/box.ts";
|
|
12
12
|
import { assert, isMain } from "../lib/check.ts";
|
|
13
13
|
import { capitalize } from "../lib/format.ts";
|
|
14
|
-
import { thinkingColorOf } from "../lib/ansi.ts";
|
|
14
|
+
import { thinkingColorOf, softSecondaryOf } from "../lib/ansi.ts";
|
|
15
15
|
import { legendUsage, matchesBack } from "../lib/shortcuts.ts";
|
|
16
16
|
import { collectUsageSummary, estimateCost, formatTokens, getModelAllTimeUsage, hasPrice, loadPriceTable, type UsageSummary } from "../lib/usage-store.ts";
|
|
17
17
|
|
|
@@ -196,7 +196,12 @@ export class UsageModalComponent implements Component {
|
|
|
196
196
|
// dashboard reads as belonging to the model that produced the usage.
|
|
197
197
|
const edge = (t: string) => this.edgeOf(t);
|
|
198
198
|
const boxW = width;
|
|
199
|
-
|
|
199
|
+
// `secondarySoft` is a vars-only token on Pi 1.2+; softSecondaryOf resolves the soft
|
|
200
|
+
// label tier (the border-accent hue) and falls back to the accent tier.
|
|
201
|
+
const soft = softSecondaryOf(th);
|
|
202
|
+
const acc = (s: string) => th.fg("accent", s);
|
|
203
|
+
const dim = (s: string) => th.fg("dim", s);
|
|
204
|
+
const plain = (s: string) => th.fg("text", s);
|
|
200
205
|
|
|
201
206
|
const rows = this.buildFlatRows();
|
|
202
207
|
if (this.selectedIndex >= rows.length) {
|
|
@@ -255,21 +260,21 @@ export class UsageModalComponent implements Component {
|
|
|
255
260
|
const actualIdx = startIdx + i;
|
|
256
261
|
const isSelected = actualIdx === this.selectedIndex;
|
|
257
262
|
|
|
258
|
-
const
|
|
259
|
-
const
|
|
263
|
+
const paintName = isSelected ? acc : item.type === "provider" ? soft : plain;
|
|
264
|
+
const paintVal = isSelected ? acc : dim;
|
|
260
265
|
|
|
261
|
-
const cName = formatCol(
|
|
262
|
-
const cSess = formatCol(
|
|
263
|
-
const cMsgs = formatCol(
|
|
264
|
-
const cToks = formatCol(
|
|
266
|
+
const cName = formatCol(paintName(isSelected ? bold(item.label) : item.label), rowNameW, false);
|
|
267
|
+
const cSess = formatCol(paintVal(formatCount(item.sessions)), sessionW);
|
|
268
|
+
const cMsgs = formatCol(paintVal(formatCount(item.msgs)), messagesW);
|
|
269
|
+
const cToks = formatCol(paintVal(formatTokens(item.tokens)), colW.tokens);
|
|
265
270
|
// A row with no reported cost but a rate shows `~$x` in place of `-`, so the guess is
|
|
266
271
|
// visible exactly where it is one; the report line still keeps it out of the headline.
|
|
267
272
|
const costCell = item.cost > 0 ? formatCost(item.cost) : item.estimatedCost > 0 ? `~${formatCost(item.estimatedCost)}` : "-";
|
|
268
|
-
const cCost = formatCol(
|
|
273
|
+
const cCost = formatCol(paintVal(costCell), colW.cost);
|
|
269
274
|
|
|
270
275
|
// Gutter: expand arrow for provider or blank indent for model.
|
|
271
276
|
const gutterText =
|
|
272
|
-
item.type === "provider" ?
|
|
277
|
+
item.type === "provider" ? (item.expanded ? soft("▾") : dim("▸")) : " ";
|
|
273
278
|
const line = gutterText + " " + cName + cSess + cMsgs + cToks + cCost;
|
|
274
279
|
this.rowHitboxes.push({ line: CHROME_ROWS + body.length, index: actualIdx });
|
|
275
280
|
body.push(line);
|
|
@@ -277,7 +282,7 @@ export class UsageModalComponent implements Component {
|
|
|
277
282
|
|
|
278
283
|
// The selected row's detail sits under the table, where the eye lands after scanning it.
|
|
279
284
|
body.push("");
|
|
280
|
-
body.push(this.selectedDetail(rows[this.selectedIndex],
|
|
285
|
+
body.push(this.selectedDetail(rows[this.selectedIndex], acc, soft));
|
|
281
286
|
|
|
282
287
|
// Table-width separator above Total band (skill arnative-pi §6 rule 7).
|
|
283
288
|
const tableW = colW.name + sessionW + messagesW + colW.tokens + colW.cost;
|
|
@@ -285,11 +290,11 @@ export class UsageModalComponent implements Component {
|
|
|
285
290
|
|
|
286
291
|
// Total row
|
|
287
292
|
const t = this.summary.totals;
|
|
288
|
-
const totalName = formatCol(
|
|
289
|
-
const totalSess = formatCol(
|
|
290
|
-
const totalMsgs = formatCol(
|
|
291
|
-
const totalToks = formatCol(
|
|
292
|
-
const totalCost = formatCol(
|
|
293
|
+
const totalName = formatCol(soft(bold("Total")), colW.name, false);
|
|
294
|
+
const totalSess = formatCol(soft(bold(formatCount(t.sessions))), sessionW);
|
|
295
|
+
const totalMsgs = formatCol(soft(bold(formatCount(t.msgs))), messagesW);
|
|
296
|
+
const totalToks = formatCol(soft(bold(formatTokens(t.totalTokens))), colW.tokens);
|
|
297
|
+
const totalCost = formatCol(soft(bold(formatCost(t.cost))), colW.cost);
|
|
293
298
|
|
|
294
299
|
// Same left edge as the header: one table, three bands, one column grid.
|
|
295
300
|
body.push(totalName + totalSess + totalMsgs + totalToks + totalCost);
|