@bismawy/pi-arnative 0.5.3 → 0.6.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 +25 -2
- package/README.md +4 -4
- package/extensions/animations.ts +55 -8
- package/extensions/arnative.ts +97 -9
- package/extensions/footer.ts +11 -11
- package/extensions/ui-render-tweaks.ts +1 -1
- package/extensions/usage.ts +24 -10
- package/lib/spinner.ts +250 -41
- package/lib/usage-store.ts +28 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.0] - 2026-10-17
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **A model id no longer reports another route's tokens.** The match rule also accepted a stored name the target merely *ends* with (`target.endsWith("/" + name)`), so a first run under `river/deepseek-ai/DeepSeek-V4.1-Flash` showed wally+yarz's `deepseek-v4.1-flash` history — 29.5M in / 3.2M out / 360.5M cache reads — as its own. Matching is now exact after `canonicalModel` (trimmed, case-folded, `:route` tag dropped), so the header's Model tab and `/usage` state the tokens of the id they name. Measured on the operator's own history: River reports 148.5k in / 26.5k out / 1.6M cache reads, and the old totals stay reachable under `deepseek-v4.1-flash` (`lib/usage-store.ts`).
|
|
7
|
+
- Self-check pinning per-id totals: a bare id does not absorb a longer id that ends with it, a path id counts its own provider only, case does not split one model's history, and a `:free` tag still folds into the model it tags. Proved negative: restoring the suffix rule fails `npm test` (`extensions/usage.ts`).
|
|
8
|
+
- **The `[g]` sweep no longer strobes.** One cycle now takes 20s (`GRADIENT_CYCLE_MS`) instead of 4s. The frame interval is fixed per preset, so a fast cycle forces a huge colour jump between frames — measured worst-case step was 64.9 RGB units at 200ms and 26.2 at 80ms, which the eye reads as flashing rather than a sweep. 20s brings that to 13.2 and 5.7; the self-check pins those ceilings so a later speed-up cannot silently reintroduce the strobe (`lib/spinner.ts`).
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **The Animations picker previews the live frame, and lists presets in a stable order.** The selected row's note now ends in the preset's *current* frame — the picker repaints every 80ms and reads the frame from the clock (`previewFrameAt`), so a dropped repaint costs one frame and can never desynchronise the animation. The glyph is padded to the widest frame of that preset, so the row does not shift as it moves. Rows are `Pi (system)`, `Arnative`, then the rest alphabetically: the escape hatch and the package's own spinner are the two a user reaches for and must not move when a preset is added. The self-check pins the order, name uniqueness, and the clock arithmetic; the picker was rendered at 80/100/120 columns with 0 width mismatches (`lib/spinner.ts`, `extensions/animations.ts`, `extensions/arnative.ts`).
|
|
12
|
+
- **`Dot Spine` preset.** A dot body growing outward from a two-cell core — `⠂⠐` → `⠺ ⠂⠐ ⠗` → `⠺ ⠪ ⠂⠐ ⠕ ⠗` at 200ms, one cell added at each end per frame. All three frames are 10 columns so the label never shifts, and the self-check pins the frames plus two properties the animation depends on: the core `⠂⠐` stays in columns 4–5 in every frame, and the dot count grows `2,4,6`. Named off the `Braille *` prefix so it does not read as a third variant of `Braille Column`/`Braille Level`; the note still says braille, which is what the glyphs are (`lib/spinner.ts`).
|
|
13
|
+
- **`Bagua Trigram` preset.** Three I Ching trigrams — `☶` (U+2636, mountain) → `☵` (U+2635, water) → `☳` (U+2633, thunder) at 200ms. Like `Yin Yang Lines`, none of the three exists in the terminal face (JetBrainsMono Nerd Font) but all three are served by the same fallback face at the same 1.50 cells, so the row keeps its place. Three of the eight trigrams are used, not all eight: a spinner is a short loop. Frames are pinned by codepoint in the operator's order, and the self-check asserts all three are distinct (`lib/spinner.ts`).
|
|
14
|
+
- **`Yin Yang Lines` preset.** The four I Ching digrams — `⚌` (U+268C, both lines whole) → `⚎` (U+268E, lower line broken) → `⚍` (U+268D, break moves up) → `⚏` (U+268F, both broken) at 200ms, so a single broken yin line travels through the pair. All four glyphs are absent from the terminal face (JetBrainsMono Nerd Font) but every one is served by the same fallback face at the same 1.50 cells, which is the property that matters: a constant width means no sideways shift, unlike a set that mixes native and fallback glyphs. Frames are pinned by codepoint in the operator's order, and the self-check asserts all four are distinct (`lib/spinner.ts`).
|
|
15
|
+
- **`Pacman` preset.** Pacman eating his way across the row, one cell per frame — `ᗧ + − ×` → `~ ᗧ − ×` → `~ ~ ᗧ ×` → `~ ~ ~ ᗧ` at 200ms, with the eaten pellets left as a ghost trail. `ᗧ` is U+15E7 (Canadian Syllabics `ye`), the glyph the pacman CLI spinners use because it reads as an open mouth facing right. The self-check pins the exact frames and asserts the mouth sits in column `2 × frameIndex`, which is the animation's whole point (`lib/spinner.ts`).
|
|
16
|
+
- **The package now has its own spinner and ships it as the default: `Arnative`.** A spark (`✧` / `✦`) and a Braille cell trading places around the centre — `✧⠙` → `✧⣠` → `⣄✧` → `⠋✦` at 200ms, painted with the theme accent (or the brand sweep under `[g]`). `DEFAULT_SPINNER_PRESET` moved from `Diamond Glow` to `Arnative`, so a fresh install shows it without opening a menu; an existing install keeps whatever it already stored. Two frames carry a trailing space; it is load-bearing, because both cells are one column wide and dropping it would shift the label sideways on every second frame. The self-check pins the exact frames by codepoint, asserts every frame of a multi-glyph preset has one length, and pins the default (`lib/spinner.ts`).
|
|
17
|
+
- **The `arnative` theme is applied on a fresh install.** `session_start` checks pi's own theme setting and calls `ctx.ui.setTheme("arnative")` only when no setting exists and the theme actually loaded. A theme you chose is never overridden, and because `setTheme` writes the setting, the check is a no-op on every later session (`extensions/arnative.ts`).
|
|
18
|
+
- **`Arrow Orbit` preset.** A four-arrow ring turning clockwise one position per frame — `▴▸▾◂` → `▸▾◂▴` → `▾◂▴▸` → `◂▴▸▾` at 200ms, one full turn per 0.8s, the same rate as `Diamond Glow`. Frames are pinned by codepoint in the self-check (U+25B4, U+25B8, U+25BE, U+25C2) so a font fallback cannot swap a glyph unnoticed (`lib/spinner.ts`).
|
|
19
|
+
- **`[g]` in `/arnative` → Animations paints accent presets with the Google brand sweep.** The switch sits on the Animations screen only, states its current mode in the legend (`[g] Gradient on`), saves as `spinnerGradient` in `arnative-config.json`, and repaints the live spinner the moment it is pressed. Esc and `←` undo it together with a previewed preset, so leaving the screen restores what it opened with. `Gemini` already sweeps and `Claudeon` carries its own mark colour, so the override leaves both untouched (`lib/spinner.ts`, `extensions/animations.ts`, `extensions/arnative.ts`).
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- **`UsageModalComponent.hintText` is gone: the render path calls `legendUsage(width)` directly.** A private one-line method that returned another function added a name with no behaviour, so the call site now states what it wants (`extensions/usage.ts`).
|
|
23
|
+
- **`Claudeon`: the `Claude` preset, renamed and restyled — inspired by Claude.** The preset was called `Claude` and reused Claude Code's own dingbats; it is now `Claudeon`, a mark of this package's own that reads as the Claude-style pulse without borrowing Claude's name for a glyph set Claude does not use. The attribution lives in the row's note (`inspired by Claude — pulsing marks, 120ms`) rather than only in the code, and the paint mode and colour constant follow (`claude` → `claudeon`, `CLAUDE_RGB` → `CLAUDEON_RGB`); the grey-orange mark colour is kept, because that colour is what makes the pulse read as the Claude-style spinner. The preset had not shipped yet, so no stored config can reference the old name (`lib/spinner.ts`, `extensions/animations.ts`).
|
|
24
|
+
- **`Claudeon`'s frames are native-only, so the row cannot walk sideways.** The old set (`· ✢ ✳ * ✶ ✻ ✽`) mixed font-fallback widths: `✢` `✻` `✽` are absent from most monospace faces, so the terminal served them from a fallback font — measured in the terminal face (JetBrainsMono Nerd Font) `✢`/`✤`/`✻`/`✽`/`❋` advance **1.48** cells and `✳` **2.29**, against a **1.00** native cell, so the label shifted on every tick that used one and `✳` sheared the row. The set is now `· * ⋆ ✶` (U+00B7, U+002A, U+22C6, U+2736) — four marks that every monospace face checked (JetBrainsMono Nerd Font, Consolas, Cascadia, Lucida, Courier) reports at exactly 1.00 — giving an 8-frame there-and-back loop (`· * ⋆ ✶ ✶ ⋆ * ·`, the peak held for two frames as elsewhere in the package) at 120ms, so the displacement is 0.00 cells. There is no fifth mark to add: a scan of all 86 installed families found none covering the Dingbats block at ~1 em (`lib/spinner.ts`).
|
|
25
|
+
|
|
3
26
|
## [0.5.3] - 2026-10-14
|
|
4
27
|
|
|
5
28
|
### Fixed
|
|
@@ -8,7 +31,7 @@
|
|
|
8
31
|
- **One space after the glyph, not two.** The two spaces existed because the previous glyph's ink overran its cell (`\uedcf`); with `\uf21b` in place they read as a gap in ` Eye: ● ON (Clef Flash)`. Pinned in pi-jev-eye's render check as one space, with an explicit assert that the glyph is not followed by two.
|
|
9
32
|
|
|
10
33
|
### Changed
|
|
11
|
-
- **The cell glyph is U+F21B (`fa-user_secret`), chosen by the operator.** U+EDCF (`fa-user_shield`) was the previous one; both exist in JetBrainsMono Nerd Font, and the codepoint is pinned by number in pi-jev-eye's render check because a Nerd glyph that renders blank on another platform is the bug the ponytail cell had (`\
|
|
34
|
+
- **The cell glyph is U+F21B (`fa-user_secret`), chosen by the operator.** U+EDCF (`fa-user_shield`) was the previous one; both exist in JetBrainsMono Nerd Font, and the codepoint is pinned by number in pi-jev-eye's render check because a Nerd glyph that renders blank on another platform is the bug the ponytail cell had (`\uef51` -> `\uef04`).
|
|
12
35
|
- **The pi-jev-eye status cell renders `Eye: ● ON (Backend)`, parsed from the value instead of hardcoded.** The format is `Eye: ● ON (Clef Flash)` when a Clef model answers and `Eye: ● ON (Jev)` when TypeSafe or OpenRouter does. `jevEyeStatusLine()` reads the label before the first colon and the backend from the trailing parentheses — so nothing about the cell is a literal in this repo, and an older pi-jev-eye value with no suffix gains no guessed one.
|
|
13
36
|
- **The backend suffix takes `dim`, while `Eye:` and the state keep their tiers.** A backend painted like the label would bury `Eye: ● ON`, which is the case this cell exists for; the spaced `Clef Flash` (not a nested `clef (flash)`) reads as one label on a single line.
|
|
14
37
|
- **The status line moved out of the render closure**, next to `ponytailStatusLine`, so the self-check in `node extensions/footer.ts` drives the exact path the footer uses instead of a copy of it.
|
|
@@ -88,7 +111,7 @@
|
|
|
88
111
|
## [0.3.10] - 2026-10-11
|
|
89
112
|
|
|
90
113
|
### Fixed
|
|
91
|
-
- The ponytail icon rendered as a blank cell on Windows. The status drew `U+EF51` (`fa-circle_radiation`), a codepoint the Windows JetBrainsMono Nerd Font build lists in its cmap but rasterizes with no outline, so the footer left an empty gap before `ponytail:`. It now draws `U+EF04` (`fa-horse`), which renders on both platforms. The cell also spends one space after the icon instead of two, matching `MCP:`. A self-check pins the codepoint, so an edit back to an invisible glyph fails `
|
|
114
|
+
- The ponytail icon rendered as a blank cell on Windows. The status drew `U+EF51` (`fa-circle_radiation`), a codepoint the Windows JetBrainsMono Nerd Font build lists in its cmap but rasterizes with no outline, so the footer left an empty gap before `ponytail:`. It now draws `U+EF04` (`fa-horse`), which renders on both platforms. The cell also spends one space after the icon instead of two, matching `MCP:`. A self-check pins the codepoint, so an edit back to an invisible glyph fails `npm test` instead of shipping silently (`extensions/footer.ts`).
|
|
92
115
|
|
|
93
116
|
## [0.3.9] - 2026-10-08
|
|
94
117
|
|
package/README.md
CHANGED
|
@@ -23,10 +23,8 @@ pi-arnative replaces pi’s default terminal chrome with a unified, cohesive vis
|
|
|
23
23
|
<summary><b>Configuration & Presets</b></summary>
|
|
24
24
|
|
|
25
25
|
### Working Spinner Presets
|
|
26
|
-
Configurable via `/arnative` → **Animations** (saved to `arnative-config.json`)
|
|
27
|
-
|
|
28
|
-
- `Braille Column`, `Braille Level`, `Kanji Count`, `Suzhou Tally`
|
|
29
|
-
- `Pi (system)`, `Gemini` (Google gradient sweep), and `Claude` (pulsing marks)
|
|
26
|
+
Configurable via `/arnative` → **Animations** (saved to `arnative-config.json`).
|
|
27
|
+
Features 18 presets with live animated preview in the picker (`Arnative` as default, `Pi (system)`, and alphabetical variants). Press `[g]` in the picker to toggle Google brand gradient sweep on accent presets.
|
|
30
28
|
|
|
31
29
|
### Footer Presets
|
|
32
30
|
Configurable via `/arnative` → **Footers**:
|
|
@@ -43,6 +41,8 @@ pi install npm:@bismawy/pi-arnative
|
|
|
43
41
|
```
|
|
44
42
|
|
|
45
43
|
Apply a theme via `/settings` → **Theme** → `arnative` (or variant). Requires a [Nerd Font](https://www.nerdfonts.com/font-downloads).
|
|
44
|
+
On a fresh install the `arnative` theme is applied automatically when pi has no theme of its
|
|
45
|
+
own; a theme you already chose is never overridden.
|
|
46
46
|
|
|
47
47
|
To test locally:
|
|
48
48
|
```bash
|
package/extensions/animations.ts
CHANGED
|
@@ -15,9 +15,11 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
15
15
|
import { loadChoice, saveChoice } from "../lib/preset-store.ts";
|
|
16
16
|
import {
|
|
17
17
|
DEFAULT_SPINNER_PRESET,
|
|
18
|
+
SPINNER_GRADIENT_KEY,
|
|
18
19
|
SPINNER_PRESETS,
|
|
19
20
|
SPINNER_PRESET_DEFS,
|
|
20
21
|
SPINNER_PRESET_KEY,
|
|
22
|
+
previewFrameAt,
|
|
21
23
|
previewFrames,
|
|
22
24
|
spinnerPainter,
|
|
23
25
|
type SpinnerPreset,
|
|
@@ -31,6 +33,7 @@ const ROW_DIVIDER = " · ";
|
|
|
31
33
|
/** The arnative editor instance, published by `footer.ts` so the live spinner is reachable. */
|
|
32
34
|
export const EDITOR_KEY = Symbol.for("pi-arnative.editor");
|
|
33
35
|
const SPINNER_PRESET_CONFIG_KEY = Symbol.for("pi-arnative.spinnerPreset");
|
|
36
|
+
const SPINNER_GRADIENT_CONFIG_KEY = Symbol.for("pi-arnative.spinnerGradient");
|
|
34
37
|
|
|
35
38
|
/** The `setWorkingMessage`/`setWorkingIndicator` surface of `ctx.ui`. */
|
|
36
39
|
type WorkingUi = { setWorkingIndicator?: (options?: unknown) => void } | null | undefined;
|
|
@@ -69,6 +72,22 @@ export function activeSpinnerPreset(): SpinnerPreset {
|
|
|
69
72
|
return SPINNER_PRESETS.includes(stored as SpinnerPreset) ? (stored as SpinnerPreset) : DEFAULT_SPINNER_PRESET;
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
/** Is the `accent` → gradient override on? Stored as `"on"`/`"off"` so an absent key reads as
|
|
76
|
+
* off through the same allow-list path every other choice uses. */
|
|
77
|
+
export function activeSpinnerGradient(): boolean {
|
|
78
|
+
const stored =
|
|
79
|
+
(globalThis as Record<symbol, unknown>)[SPINNER_GRADIENT_CONFIG_KEY] ??
|
|
80
|
+
loadChoice(SPINNER_GRADIENT_KEY, ["on", "off"] as const);
|
|
81
|
+
return stored === "on";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Flip the gradient override, persist it, and repaint the live spinner at once. */
|
|
85
|
+
export function setSpinnerGradient(on: boolean): void {
|
|
86
|
+
(globalThis as Record<symbol, unknown>)[SPINNER_GRADIENT_CONFIG_KEY] = on ? "on" : "off";
|
|
87
|
+
saveChoice(SPINNER_GRADIENT_KEY, on ? "on" : "off");
|
|
88
|
+
applySpinnerPreset(activeWorkingUi(), activeWorkingTheme());
|
|
89
|
+
}
|
|
90
|
+
|
|
72
91
|
/**
|
|
73
92
|
* Publish the choice for this process, write it to `arnative-config.json`, and install it on
|
|
74
93
|
* the running session right away — the picker previews by moving the cursor, so a choice that
|
|
@@ -94,7 +113,7 @@ export function paintLiveIndicator(painter: (text: string) => string): void {
|
|
|
94
113
|
export function applySpinnerPreset(ui: WorkingUi, theme: ThemeLike): void {
|
|
95
114
|
const preset = activeSpinnerPreset();
|
|
96
115
|
const def = SPINNER_PRESET_DEFS[preset];
|
|
97
|
-
const painter = spinnerPainter(preset, theme);
|
|
116
|
+
const painter = spinnerPainter(preset, theme, activeSpinnerGradient());
|
|
98
117
|
try {
|
|
99
118
|
if (def.frames.length === 0) ui?.setWorkingIndicator?.();
|
|
100
119
|
else ui?.setWorkingIndicator?.({ frames: [...def.frames], intervalMs: def.intervalMs });
|
|
@@ -104,12 +123,16 @@ export function applySpinnerPreset(ui: WorkingUi, theme: ThemeLike): void {
|
|
|
104
123
|
paintLiveIndicator(painter);
|
|
105
124
|
}
|
|
106
125
|
|
|
107
|
-
/** Picker
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
126
|
+
/** Picker copy. The description states the one seam: a preset applies to the next spinner.
|
|
127
|
+
* The legend is built per call because its `[g]` row states the current mode — a constant
|
|
128
|
+
* would keep saying `off` after the user turned it on. */
|
|
129
|
+
export function animationsPickerText(): { title: string; description: string; legend: string } {
|
|
130
|
+
return {
|
|
131
|
+
title: "Animations - Arnative\u2819",
|
|
132
|
+
description: "The working spinner — applies on the next tool call.",
|
|
133
|
+
legend: `[↑↓] Preview [g] Gradient ${activeSpinnerGradient() ? "on" : "off"} [Enter] Select [Esc] Close`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
113
136
|
|
|
114
137
|
/** One row per preset: the name, then its note and the frames it actually cycles. The
|
|
115
138
|
* frames belong in the note — a user picking an animation wants to see the glyphs, and this
|
|
@@ -126,6 +149,19 @@ export function animationRowText(preset: SpinnerPreset): string {
|
|
|
126
149
|
return `${preset}${ROW_DIVIDER}${SPINNER_PRESET_DEFS[preset].note} ${previewFrames(preset)}`;
|
|
127
150
|
}
|
|
128
151
|
|
|
152
|
+
/** Repaint cadence for the picker's animated row. Fast enough to read as motion at every
|
|
153
|
+
* preset's own interval (the slowest is 200ms, the fastest 80ms), cheap because the row only
|
|
154
|
+
* redraws one line. */
|
|
155
|
+
export const PREVIEW_TICK_MS = 80;
|
|
156
|
+
|
|
157
|
+
/** Note for one row with the frame that is live *now*, padded to the widest frame so the
|
|
158
|
+
* text after it does not shift as the glyph changes. */
|
|
159
|
+
export function animationRowNote(preset: SpinnerPreset, now = Date.now()): string {
|
|
160
|
+
const { text, width } = previewFrameAt(preset, now);
|
|
161
|
+
const pad = " ".repeat(Math.max(0, width - [...text].length));
|
|
162
|
+
return `${SPINNER_PRESET_DEFS[preset].note} ${text}${pad}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
129
165
|
/** Register the extension: keep the installed preset in step with the session's theme. */
|
|
130
166
|
export default function registerAnimations(pi: ExtensionAPI): void {
|
|
131
167
|
pi.on("session_start", async (_event, ctx: ExtensionContext) => {
|
|
@@ -151,6 +187,17 @@ if (isMain(import.meta.url)) {
|
|
|
151
187
|
);
|
|
152
188
|
assert(animationRowText("Diamond Glow").includes("\u2b25"), "the row text previews the frames");
|
|
153
189
|
|
|
190
|
+
// The picker's animated note shows exactly one live frame, padded to the widest one so the
|
|
191
|
+
// row does not shift. `animationRowNote` takes `now`, so the assertion does not race a clock.
|
|
192
|
+
assert(animationRowNote("Diamond Glow", 0).endsWith("\u2b25"), "the live note shows frame 0");
|
|
193
|
+
assert(animationRowNote("Diamond Glow", 200).endsWith("\u25c8"), "the live note advances with the clock");
|
|
194
|
+
assert(animationRowNote("Pacman", 0).endsWith("\u15e7 + \u2212 \u00d7"), "a wide frame is shown whole");
|
|
195
|
+
assert(
|
|
196
|
+
animationRowNote("Pacman", 0).length === animationRowNote("Pacman", 600).length,
|
|
197
|
+
"every frame pads to the same width, so the note never shifts",
|
|
198
|
+
);
|
|
199
|
+
assert(PREVIEW_TICK_MS <= 80, "the preview ticks at least as fast as the quickest preset");
|
|
200
|
+
|
|
154
201
|
// Installing a preset goes through pi's indicator API; `Pi (system)` passes nothing.
|
|
155
202
|
const calls: Array<Record<string, unknown> | undefined> = [];
|
|
156
203
|
const ui = { setWorkingIndicator: (options?: Record<string, unknown>) => calls.push(options) };
|
|
@@ -187,7 +234,7 @@ if (isMain(import.meta.url)) {
|
|
|
187
234
|
pickCalls.length = 0;
|
|
188
235
|
// Instance B — a second copy of the module, like the one `arnative.ts` holds — does the pick.
|
|
189
236
|
const instanceB = await import(`./animations.ts?second=${Date.now()}`);
|
|
190
|
-
instanceB.setSpinnerPreset("
|
|
237
|
+
instanceB.setSpinnerPreset("Claudeon");
|
|
191
238
|
assert(pickCalls.length === 1, "a second module instance installs through the shared ui");
|
|
192
239
|
assert(pickCalls[0]?.frames?.[0] === "\u00b7", "the chosen preset's frames are installed, not the old ones");
|
|
193
240
|
assert(pickCalls[0]?.intervalMs === 120, "and its cadence (120ms)");
|
package/extensions/arnative.ts
CHANGED
|
@@ -14,9 +14,13 @@ import { markedLabels } from "../lib/format.ts";
|
|
|
14
14
|
import { LEGEND_LIVE, LEGEND_MOVE, matchesBack } from "../lib/shortcuts.ts";
|
|
15
15
|
import { SPINNER_PRESETS, type SpinnerPreset } from "../lib/spinner.ts";
|
|
16
16
|
import {
|
|
17
|
-
|
|
17
|
+
activeSpinnerGradient,
|
|
18
|
+
animationsPickerText,
|
|
18
19
|
activeSpinnerPreset,
|
|
20
|
+
animationRowNote,
|
|
19
21
|
animationRows,
|
|
22
|
+
PREVIEW_TICK_MS,
|
|
23
|
+
setSpinnerGradient,
|
|
20
24
|
setSpinnerPreset,
|
|
21
25
|
} from "./animations.ts";
|
|
22
26
|
import { FOOTER_PRESETS, activeFooterPreset, setFooterPreset, type FooterPreset } from "./footer.ts";
|
|
@@ -76,6 +80,18 @@ interface ListPickerHooks {
|
|
|
76
80
|
/** `←`: leave this screen for its parent (the menu, or the chat when there is none).
|
|
77
81
|
* Distinct from `cancel`/Esc, which closes or reverts the screen it is on. */
|
|
78
82
|
back?: () => void;
|
|
83
|
+
/** `g`: toggle a screen-level switch (Animations → gradient). Only a screen that passes
|
|
84
|
+
* this hook reacts to the key, so `g` stays a plain letter everywhere else. */
|
|
85
|
+
toggleGradient?: () => void;
|
|
86
|
+
/** Note re-read on every render, so a row can animate (Animations → a moving frame).
|
|
87
|
+
* Returning undefined keeps the row's static note. */
|
|
88
|
+
note?: (label: string) => string | undefined;
|
|
89
|
+
/** Repaint cadence for an animated note. The frame itself is read from the clock, so a
|
|
90
|
+
* missed repaint costs one frame and never desynchronises the animation. */
|
|
91
|
+
tickMs?: number;
|
|
92
|
+
/** Legend re-read on every render, for a key row that states live state
|
|
93
|
+
* (`[g] Gradient on`). A constant legend would keep advertising the value it opened with. */
|
|
94
|
+
legend?: () => string;
|
|
79
95
|
}
|
|
80
96
|
|
|
81
97
|
/** Box copy, so one picker serves every menu ("Themes - Arnative⠙", "Headers - Arnative⠙", …).
|
|
@@ -94,6 +110,7 @@ export class ListPicker implements Component {
|
|
|
94
110
|
private applied: string | undefined;
|
|
95
111
|
private readonly livePreview: boolean;
|
|
96
112
|
private readonly opened: string | undefined;
|
|
113
|
+
private readonly tick: ReturnType<typeof setInterval> | undefined;
|
|
97
114
|
/** Border painter, from the session's thinking level — the box follows the model. */
|
|
98
115
|
private readonly edge: (s: string) => string;
|
|
99
116
|
|
|
@@ -117,10 +134,13 @@ export class ListPicker implements Component {
|
|
|
117
134
|
this.applied = current;
|
|
118
135
|
this.selected = Math.max(0, rows.findIndex((r) => r.label === current));
|
|
119
136
|
this.edge = thinkingColorOf(theme, thinkingLevel);
|
|
137
|
+
this.tick = hooks.tickMs ? setInterval(() => hooks.requestRender(), hooks.tickMs) : undefined;
|
|
120
138
|
}
|
|
121
139
|
|
|
122
140
|
invalidate(): void {}
|
|
123
|
-
dispose(): void {
|
|
141
|
+
dispose(): void {
|
|
142
|
+
if (this.tick) clearInterval(this.tick);
|
|
143
|
+
}
|
|
124
144
|
|
|
125
145
|
/** Selected row, for callers that need the choice after `done()` resolves. */
|
|
126
146
|
get index(): number {
|
|
@@ -166,7 +186,11 @@ export class ListPicker implements Component {
|
|
|
166
186
|
this.move(1);
|
|
167
187
|
return;
|
|
168
188
|
}
|
|
169
|
-
if (matchesKey(keyData, "enter"))
|
|
189
|
+
if (matchesKey(keyData, "enter")) {
|
|
190
|
+
this.hooks.done();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (this.hooks.toggleGradient && matchesKey(keyData, "g")) this.hooks.toggleGradient();
|
|
170
194
|
}
|
|
171
195
|
|
|
172
196
|
render(width: number): string[] {
|
|
@@ -181,7 +205,8 @@ export class ListPicker implements Component {
|
|
|
181
205
|
const isSelected = i === this.selected;
|
|
182
206
|
const paint = isSelected ? (s: string) => soft(bold(s)) : (s: string) => th.fg(item.note ? "text" : "muted", s);
|
|
183
207
|
const pad = " ".repeat(Math.max(0, column - visibleWidth(item.label)));
|
|
184
|
-
const
|
|
208
|
+
const noteText = this.hooks.note?.(item.label) ?? item.note;
|
|
209
|
+
const note = noteText ? `${th.fg("dim", ROW_DIVIDER)}${th.fg("dim", noteText)}` : "";
|
|
185
210
|
// Extract parenthetical suffix to render softly
|
|
186
211
|
const suffix = /(\(.*\))$/.exec(item.label)?.[1] ?? "";
|
|
187
212
|
const head = suffix ? item.label.slice(0, item.label.length - suffix.length) : item.label;
|
|
@@ -189,7 +214,7 @@ export class ListPicker implements Component {
|
|
|
189
214
|
const content = `${isSelected ? th.fg("accent", "→ ") : " "}${paint(head)}${tail}${pad}${note}`;
|
|
190
215
|
body.push(truncateToWidth(content, width - 4, ""));
|
|
191
216
|
}
|
|
192
|
-
return renderScreen(th, width, this.text, body, edge);
|
|
217
|
+
return renderScreen(th, width, this.hooks.legend ? { ...this.text, legend: this.hooks.legend() } : this.text, body, edge);
|
|
193
218
|
}
|
|
194
219
|
}
|
|
195
220
|
|
|
@@ -342,9 +367,10 @@ const openAnimations = async (ctx: ExtensionCommandContext): Promise<boolean | v
|
|
|
342
367
|
const rows: ListRow[] = animationRows();
|
|
343
368
|
|
|
344
369
|
if (ctx.mode !== "tui") {
|
|
345
|
-
// No custom components (RPC/headless): fall back to the blocking picker.
|
|
370
|
+
// No custom components (RPC/headless): fall back to the blocking picker. The gradient
|
|
371
|
+
// switch has no row here, so it is reported rather than silently unreachable.
|
|
346
372
|
const labels = markedLabels(SPINNER_PRESETS, opened);
|
|
347
|
-
const pick = await ctx.ui.select(
|
|
373
|
+
const pick = await ctx.ui.select(animationsPickerText().title, labels);
|
|
348
374
|
if (pick === undefined) return;
|
|
349
375
|
setSpinnerPreset(SPINNER_PRESETS[labels.indexOf(pick)]!);
|
|
350
376
|
ctx.ui.notify(`[arnative] Animation: ${SPINNER_PRESETS[labels.indexOf(pick)]}`, "info");
|
|
@@ -365,9 +391,20 @@ const openAnimations = async (ctx: ExtensionCommandContext): Promise<boolean | v
|
|
|
365
391
|
if (activeSpinnerPreset() !== opened) setSpinnerPreset(opened);
|
|
366
392
|
done(true);
|
|
367
393
|
},
|
|
394
|
+
// `[g]` is a switch, not a preview: it is a command the user asked for and the legend
|
|
395
|
+
// states its state, so Esc and `←` leave it on. Only the preset preview reverts.
|
|
396
|
+
toggleGradient: () => {
|
|
397
|
+
setSpinnerGradient(!activeSpinnerGradient());
|
|
398
|
+
tui.requestRender();
|
|
399
|
+
},
|
|
400
|
+
legend: () => animationsPickerText().legend,
|
|
401
|
+
// The row animates: its note is re-read each tick and the frame comes from the
|
|
402
|
+
// clock, so the picker shows motion instead of a static glyph list.
|
|
403
|
+
note: (label) => animationRowNote(label as SpinnerPreset),
|
|
404
|
+
tickMs: PREVIEW_TICK_MS,
|
|
368
405
|
requestRender: () => tui.requestRender(),
|
|
369
406
|
},
|
|
370
|
-
|
|
407
|
+
animationsPickerText(),
|
|
371
408
|
true,
|
|
372
409
|
ctx.thinkingLevel,
|
|
373
410
|
),
|
|
@@ -470,7 +507,26 @@ export function arnativeCompletions(prefix: string): AutocompleteItem[] | null {
|
|
|
470
507
|
return items.length > 0 ? items : null;
|
|
471
508
|
}
|
|
472
509
|
|
|
510
|
+
/** The theme this package ships. Applied only when pi has no theme setting of its own, so a
|
|
511
|
+
* fresh install lands on Arnative without ever overriding a choice someone made. */
|
|
512
|
+
export const DEFAULT_THEME = "arnative";
|
|
513
|
+
|
|
514
|
+
/** Should the shipped theme be installed? Only when no theme is set at all (pi would fall back
|
|
515
|
+
* to `system`) and the theme actually loaded, which a headless or filtered load may not do. */
|
|
516
|
+
export function shouldApplyDefaultTheme(
|
|
517
|
+
themeSetting: string | undefined,
|
|
518
|
+
themes: readonly { name: string }[],
|
|
519
|
+
): boolean {
|
|
520
|
+
return themeSetting === undefined && themes.some((t) => t.name === DEFAULT_THEME);
|
|
521
|
+
}
|
|
522
|
+
|
|
473
523
|
export default function (pi: ExtensionAPI) {
|
|
524
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
525
|
+
// `setTheme` writes the choice into pi's settings, so this runs once per install and the
|
|
526
|
+
// second session already sees an explicit setting.
|
|
527
|
+
if (!shouldApplyDefaultTheme(pi.getSettings()?.theme, ctx.ui.getAllThemes())) return;
|
|
528
|
+
ctx.ui.setTheme(DEFAULT_THEME);
|
|
529
|
+
});
|
|
474
530
|
pi.registerCommand("arnative", {
|
|
475
531
|
description: "Arnative settings menu: themes, headers, footers, usage",
|
|
476
532
|
getArgumentCompletions: arnativeCompletions,
|
|
@@ -509,6 +565,12 @@ if (isMain(import.meta.url)) {
|
|
|
509
565
|
assert(MENUS.some((m) => m.value === "usage"), "usage menu registered");
|
|
510
566
|
assert(arnativeCompletions("us")![0]!.value === "usage", "usage menu has completions");
|
|
511
567
|
assert(typeof MENUS.find((m) => m.value === "usage")!.open === "function", "the usage row opens the dashboard");
|
|
568
|
+
|
|
569
|
+
// The shipped theme is applied only when pi has no theme setting of its own.
|
|
570
|
+
assert(shouldApplyDefaultTheme(undefined, [{ name: "arnative" }]), "an unset theme installs Arnative");
|
|
571
|
+
assert(!shouldApplyDefaultTheme("arnative", [{ name: "arnative" }]), "an explicit Arnative setting is left alone");
|
|
572
|
+
assert(!shouldApplyDefaultTheme("gruvbox", [{ name: "arnative" }]), "a chosen theme is never overridden");
|
|
573
|
+
assert(!shouldApplyDefaultTheme(undefined, [{ name: "dark" }]), "no install when the theme did not load");
|
|
512
574
|
assert(arnativeCompletions("zz") === null, "completions null when nothing matches");
|
|
513
575
|
|
|
514
576
|
// Live-preview picker (shared by Themes and Headers): move = preview, Enter = keep, Esc = revert.
|
|
@@ -679,10 +741,36 @@ if (isMain(import.meta.url)) {
|
|
|
679
741
|
animationRows(),
|
|
680
742
|
"Pi (system)",
|
|
681
743
|
{ preview: () => {}, revert: () => {}, done: () => {}, requestRender: () => {} },
|
|
682
|
-
|
|
744
|
+
animationsPickerText(),
|
|
683
745
|
);
|
|
684
746
|
const animLines = animPicker.render(120);
|
|
685
747
|
assert(animLines.some((l) => l.includes("Pi ") && l.includes("<dim>(system)</dim>")), "animations picker dims (system)");
|
|
686
748
|
|
|
749
|
+
// `g` reaches only a screen that passes the hook, and its legend follows the toggle.
|
|
750
|
+
const quiet = (): void => undefined;
|
|
751
|
+
let gradientOn = false;
|
|
752
|
+
const gradientPicker = new ListPicker(
|
|
753
|
+
fakeTheme,
|
|
754
|
+
animationRows(),
|
|
755
|
+
"Pi (system)",
|
|
756
|
+
{
|
|
757
|
+
preview: quiet,
|
|
758
|
+
revert: quiet,
|
|
759
|
+
done: quiet,
|
|
760
|
+
requestRender: quiet,
|
|
761
|
+
toggleGradient: () => {
|
|
762
|
+
gradientOn = !gradientOn;
|
|
763
|
+
},
|
|
764
|
+
legend: () => `[g] Gradient ${gradientOn ? "on" : "off"}`,
|
|
765
|
+
},
|
|
766
|
+
animationsPickerText(),
|
|
767
|
+
);
|
|
768
|
+
gradientPicker.handleInput("g");
|
|
769
|
+
assert(gradientOn, "g reaches the screen's own toggle");
|
|
770
|
+
assert(gradientPicker.render(120).some((l) => l.includes("[g] Gradient on")), "the legend follows the toggle");
|
|
771
|
+
assert(gradientPicker.index === 0, "g is not a row key: the cursor stays put");
|
|
772
|
+
mainPicker.handleInput("g");
|
|
773
|
+
assert(gradientOn, "a screen without the hook ignores g");
|
|
774
|
+
|
|
687
775
|
console.log("arnative.ts self-check OK");
|
|
688
776
|
}
|
package/extensions/footer.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { capitalize, markedLabels, modelDisplayParts } from "../lib/format.ts";
|
|
|
18
18
|
import { IS_WINDOWS_LIKE, SHORTCUT_RELOAD, SHORTCUT_TOGGLE_FOOTER } from "../lib/shortcuts.ts";
|
|
19
19
|
import { formatTokens } from "../lib/usage-store.ts";
|
|
20
20
|
import { FOOTER_PRESET_KEY as FOOTER_PRESET_CONFIG_KEY, loadChoice, resetPresetCache, saveChoice } from "../lib/preset-store.ts";
|
|
21
|
-
import { EDITOR_KEY, activeSpinnerPreset } from "./animations.ts";
|
|
21
|
+
import { EDITOR_KEY, activeSpinnerGradient, activeSpinnerPreset } from "./animations.ts";
|
|
22
22
|
import { spinnerPainter } from "../lib/spinner.ts";
|
|
23
23
|
|
|
24
24
|
// ctrl+alt+r (alt+r on Windows/WSL — see lib/shortcuts.ts).
|
|
@@ -211,15 +211,6 @@ export function currentToolVerb(active: ReadonlyMap<string, string>): string {
|
|
|
211
211
|
return verb;
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
-
/**
|
|
215
|
-
* The pi-jev-eye status cell: `Eye: ● MODE (Backend)`. pi-jev-eye writes the value plain because a status
|
|
216
|
-
* is a fact, not a layout; this decides the painting.
|
|
217
|
-
*
|
|
218
|
-
* The trailing parenthesised backend is a modifier, so it takes `dim` while the `Eye:` label and the state
|
|
219
|
-
* keep the soft and state tiers — that is what makes `(Clef Flash)` recede and `Eye: ● ON` stay readable
|
|
220
|
-
* at a glance. The backend name itself is read out of the value, never hardcoded here: the extension
|
|
221
|
-
* reports the active backend from `/jev-eye models`, so the footer cannot know it in advance.
|
|
222
|
-
*/
|
|
223
214
|
/**
|
|
224
215
|
* Is this status value pi-jev-eye's own cell? It is matched on the LABEL the value carries (`Eye:`), not on
|
|
225
216
|
* the internal key name: the value is `Eye: ● ON (Clef Flash)` and contains no `jev-eye` anywhere, so the
|
|
@@ -231,6 +222,15 @@ export function currentToolVerb(active: ReadonlyMap<string, string>): string {
|
|
|
231
222
|
*/
|
|
232
223
|
export const isJevEyeStatus = (status: string): boolean => /^\s*Eye\s*:/i.test(stripAnsi(status));
|
|
233
224
|
|
|
225
|
+
/**
|
|
226
|
+
* The pi-jev-eye status cell: `Eye: ● MODE (Backend)`. pi-jev-eye writes the value plain because a status
|
|
227
|
+
* is a fact, not a layout; this decides the painting.
|
|
228
|
+
*
|
|
229
|
+
* The trailing parenthesised backend is a modifier, so it takes `dim` while the `Eye:` label and the state
|
|
230
|
+
* keep the soft and state tiers — that is what makes `(Clef Flash)` recede and `Eye: ● ON` stay readable
|
|
231
|
+
* at a glance. The backend name itself is read out of the value, never hardcoded here: the extension
|
|
232
|
+
* reports the active backend from `/jev-eye models`, so the footer cannot know it in advance.
|
|
233
|
+
*/
|
|
234
234
|
export function jevEyeStatusLine(
|
|
235
235
|
status: string,
|
|
236
236
|
acc: (text: string) => string,
|
|
@@ -1079,7 +1079,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1079
1079
|
if (!indicator) return;
|
|
1080
1080
|
// Clear renderIndicatorVerbatim so custom frames pass through spinnerColorFn.
|
|
1081
1081
|
indicator.renderIndicatorVerbatim = false;
|
|
1082
|
-
indicator.spinnerColorFn = spinnerPainter(activeSpinnerPreset(), this.getActiveTheme());
|
|
1082
|
+
indicator.spinnerColorFn = spinnerPainter(activeSpinnerPreset(), this.getActiveTheme(), activeSpinnerGradient());
|
|
1083
1083
|
indicator.messageColorFn = (text: string) => {
|
|
1084
1084
|
try {
|
|
1085
1085
|
const th = this.getActiveTheme();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Six small patches to pi's fullscreen renderer (render layer only, session data
|
|
3
3
|
* untouched):
|
|
4
4
|
* 1. Hide pi's built-in "Reloading keybindings, extensions, skills..." box
|
|
5
5
|
* (hardcoded in handleReloadCommand, no auto-hide).
|
package/extensions/usage.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { assert, isMain } from "../lib/check.ts";
|
|
|
13
13
|
import { capitalize } from "../lib/format.ts";
|
|
14
14
|
import { thinkingColorOf, softSecondaryOf } from "../lib/ansi.ts";
|
|
15
15
|
import { legendUsage, matchesBack } from "../lib/shortcuts.ts";
|
|
16
|
-
import { collectUsageSummary, estimateCost, formatTokens, getModelAllTimeUsage, hasPrice, loadPriceTable, type UsageSummary } from "../lib/usage-store.ts";
|
|
16
|
+
import { collectUsageSummary, estimateCost, formatTokens, getModelAllTimeUsage, hasPrice, loadPriceTable, resetUsageCache, type UsageSummary } from "../lib/usage-store.ts";
|
|
17
17
|
|
|
18
18
|
export function formatCost(n: number): string {
|
|
19
19
|
if (!n || n <= 0) return "-";
|
|
@@ -309,7 +309,7 @@ export class UsageModalComponent implements Component {
|
|
|
309
309
|
{
|
|
310
310
|
title: screenTitle("Usage"),
|
|
311
311
|
description: width >= 70 ? "Token, message & cache usage across all local Pi sessions." : "Usage across all local sessions.",
|
|
312
|
-
legend:
|
|
312
|
+
legend: legendUsage(width),
|
|
313
313
|
},
|
|
314
314
|
body,
|
|
315
315
|
edge,
|
|
@@ -334,10 +334,6 @@ export class UsageModalComponent implements Component {
|
|
|
334
334
|
const divider = soft(" · ");
|
|
335
335
|
return `${icon}${soft(capitalize(title))}${divider}${soft(parts.join(" · "))}`;
|
|
336
336
|
}
|
|
337
|
-
|
|
338
|
-
private hintText(width: number): string {
|
|
339
|
-
return legendUsage(width);
|
|
340
|
-
}
|
|
341
337
|
}
|
|
342
338
|
|
|
343
339
|
/** Open the dashboard. Shared by `/usage` and `/arnative` → Usage, so the two cannot drift. */
|
|
@@ -386,7 +382,7 @@ if (isMain(import.meta.url)) {
|
|
|
386
382
|
const asst = (model: string, input: number) => JSON.stringify({ message: { role: "assistant", provider: "google", model, usage: { input, output: 5, cacheRead: 1, cost: { total: 0.02 } } } });
|
|
387
383
|
writeFileSync(
|
|
388
384
|
join(fixture, "sessions", "proj", "a.jsonl"),
|
|
389
|
-
[JSON.stringify({ message: { role: "user", content: "hi" } }), asst("gemini-3.8-flash", 100), asst("gemini-3.8-flash", 50), asst("other-model", 7)].join(String.fromCharCode(10)),
|
|
385
|
+
[JSON.stringify({ message: { role: "user", content: "hi" } }), asst("gemini-3.8-flash", 100), asst("gemini-3.8-flash", 50), asst("other-model", 7)].join(String.fromCharCode(10)) + String.fromCharCode(10),
|
|
390
386
|
);
|
|
391
387
|
|
|
392
388
|
const summary = collectUsageSummary(true);
|
|
@@ -419,6 +415,9 @@ if (isMain(import.meta.url)) {
|
|
|
419
415
|
const incremental = JSON.stringify(getModelAllTimeUsage("gemini-3.8-flash"));
|
|
420
416
|
const cacheRaw = JSON.parse(readFileSync(join(fixture, "arnative-usage-cache.json"), "utf8")) as Record<string, unknown>;
|
|
421
417
|
// Drop the cache (as a fresh machine, or a version bump, would) and rescan the whole file.
|
|
418
|
+
// Writing `{}` is not enough on its own: the disk cache is memoized per process, so the
|
|
419
|
+
// memo would answer and this assert would compare a value against itself, never failing.
|
|
420
|
+
resetUsageCache();
|
|
422
421
|
writeFileSync(join(fixture, "arnative-usage-cache.json"), "{}", "utf8");
|
|
423
422
|
collectUsageSummary(true);
|
|
424
423
|
assert(
|
|
@@ -445,14 +444,29 @@ if (isMain(import.meta.url)) {
|
|
|
445
444
|
// These use a provider that reports NO cost (cost.total = 0), which is the case the estimate
|
|
446
445
|
// exists for; `asst` above reports 0.02 per message and would be priced by the provider.
|
|
447
446
|
mkdirSync(join(fixture, "sessions", "ui"), { recursive: true });
|
|
448
|
-
const
|
|
449
|
-
JSON.stringify({ message: { role: "assistant", provider
|
|
447
|
+
const freeAsstOf = (provider: string, model: string, input: number) =>
|
|
448
|
+
JSON.stringify({ message: { role: "assistant", provider, model, usage: { input, output: 5, cacheRead: 1, cost: { total: 0 } } } });
|
|
449
|
+
const freeAsst = (model: string, input: number) => freeAsstOf("wally", model, input);
|
|
450
450
|
writeFileSync(
|
|
451
451
|
join(fixture, "sessions", "ui", "rows.jsonl"),
|
|
452
|
-
[
|
|
452
|
+
[
|
|
453
|
+
freeAsst("gemini-3.8-flash", 100),
|
|
454
|
+
freeAsst("other-model", 7),
|
|
455
|
+
// The shipped shape of the collision: a bare id and, on another provider, a path id
|
|
456
|
+
// that merely ENDS with it. Suffix matching folded the second into the first, so a first
|
|
457
|
+
// run under `river/…` showed wally+yarz's 29.5M/3.2M/360.5M as if it were its own.
|
|
458
|
+
freeAsst("deepseek-v4.1-flash", 300),
|
|
459
|
+
freeAsstOf("9router", "river/deepseek-ai/DeepSeek-V4.1-Flash", 200),
|
|
460
|
+
].join(String.fromCharCode(10)),
|
|
453
461
|
);
|
|
454
462
|
collectUsageSummary(true);
|
|
455
463
|
|
|
464
|
+
// Per-id totals: a model id reports its own tokens only, whatever it is a suffix of.
|
|
465
|
+
assert(getModelAllTimeUsage("deepseek-v4.1-flash").input === 300, "a bare id does not absorb a longer id that ends with it");
|
|
466
|
+
assert(getModelAllTimeUsage("river/deepseek-ai/DeepSeek-V4.1-Flash").input === 200, "a path id counts its own provider's tokens only");
|
|
467
|
+
assert(getModelAllTimeUsage("RIVER/DeepSeek-AI/deepseek-V4.1-Flash").input === 200, "case still does not split one model's history");
|
|
468
|
+
assert(getModelAllTimeUsage("deepseek-v4.1-flash:free").input === 300, "a route tag folds into the model it tags");
|
|
469
|
+
|
|
456
470
|
// Cost estimation: unpriced pairs use rates declared automatically in models.json.
|
|
457
471
|
assert(loadPriceTable() !== null && Object.keys(loadPriceTable()).length === 0, "no models.json rates means an empty table");
|
|
458
472
|
assert(!hasPrice("wally", "gemini-3.8-flash"), "a pair absent from models.json has no price");
|
package/lib/spinner.ts
CHANGED
|
@@ -3,26 +3,38 @@
|
|
|
3
3
|
* Single source of truth for frames, cadence, and colours.
|
|
4
4
|
*/
|
|
5
5
|
import { assert, isMain } from "./check.ts";
|
|
6
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
6
7
|
|
|
8
|
+
/** Picker order: the escape hatch first, this package's own spinner second, then the rest
|
|
9
|
+
* alphabetically. `Pi (system)` and `Arnative` are not sorted in — they are the two rows a
|
|
10
|
+
* user is most likely to want and they should never move. */
|
|
7
11
|
export const SPINNER_PRESETS = [
|
|
8
12
|
"Pi (system)",
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"Circle Wave",
|
|
13
|
-
"Shade Wave",
|
|
13
|
+
"Arnative",
|
|
14
|
+
"Arrow Orbit",
|
|
15
|
+
"Bagua Trigram",
|
|
14
16
|
"Braille Column",
|
|
15
17
|
"Braille Level",
|
|
18
|
+
"Circle Pulse",
|
|
19
|
+
"Circle Wave",
|
|
20
|
+
"Claudeon",
|
|
21
|
+
"Diamond Glow",
|
|
22
|
+
"Diamond Pulse",
|
|
23
|
+
"Dot Spine",
|
|
24
|
+
"Gemini",
|
|
16
25
|
"Kanji Count",
|
|
26
|
+
"Pacman",
|
|
27
|
+
"Shade Wave",
|
|
17
28
|
"Suzhou Tally",
|
|
18
|
-
"
|
|
19
|
-
"Claude",
|
|
29
|
+
"Yin Yang Lines",
|
|
20
30
|
] as const;
|
|
21
31
|
export type SpinnerPreset = (typeof SPINNER_PRESETS)[number];
|
|
22
|
-
export const DEFAULT_SPINNER_PRESET: SpinnerPreset = "
|
|
32
|
+
export const DEFAULT_SPINNER_PRESET: SpinnerPreset = "Arnative";
|
|
23
33
|
|
|
24
34
|
/** Persistence key in `arnative-config.json` (see `lib/preset-store.ts`). */
|
|
25
35
|
export const SPINNER_PRESET_KEY = "spinnerPreset";
|
|
36
|
+
/** Persistence key for the gradient override on `accent` presets. Values: `"on"` / `"off"`. */
|
|
37
|
+
export const SPINNER_GRADIENT_KEY = "spinnerGradient";
|
|
26
38
|
|
|
27
39
|
interface Preset {
|
|
28
40
|
/** Animation frames. Empty leaves pi's indicator alone. */
|
|
@@ -30,22 +42,24 @@ interface Preset {
|
|
|
30
42
|
/** Frame delay in ms. `0` keeps pi's interval. */
|
|
31
43
|
intervalMs: number;
|
|
32
44
|
/** How the frame is painted. */
|
|
33
|
-
paint: "accent" | "gradient" | "
|
|
45
|
+
paint: "accent" | "gradient" | "claudeon";
|
|
34
46
|
/** Shown in the picker row after `·`. */
|
|
35
47
|
note: string;
|
|
36
48
|
}
|
|
37
49
|
|
|
38
|
-
/** Claude Code mark
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
/** The Claude Code mark, restyled: the four marks Claudeon pulses through.
|
|
51
|
+
*
|
|
52
|
+
* Only one rule decides the set: every frame must be a glyph the terminal font *itself* carries,
|
|
53
|
+
* so it advances exactly one cell. A glyph the font lacks is served by a fallback font, and
|
|
54
|
+
* fallbacks are not monospaced — measured in the terminal face (JetBrainsMono Nerd Font), the
|
|
55
|
+
* Dingbats marks `✢` `✤` `✻` `✽` `❋` advance **1.48** cells and `✳` **2.29**, against a **1.00**
|
|
56
|
+
* native cell, which walks the label sideways on every tick that uses one. `·` `*` `⋆` `✶` all
|
|
57
|
+
* advance 1.00 in that face (and in Consolas, Cascadia, Lucida, Courier), so the four-mark swell
|
|
58
|
+
* `· → * → ⋆ → ✶` is smooth on all of them. A scan of all 86 installed families found none that
|
|
59
|
+
* covers the Dingbats block at ~1 em, so a fifth mark cannot be added by picking a nicer glyph —
|
|
60
|
+
* only by finding one the monospace faces already ship. Frames are pinned by codepoint in the
|
|
61
|
+
* self-check, so a silent swap cannot creep back in. */
|
|
62
|
+
const CLAUDE_BASE_FRAMES = ["\u00b7", "\u002a", "\u22c6", "\u2736"] as const;
|
|
49
63
|
const CLAUDE_PING_PONG_FRAMES = [...CLAUDE_BASE_FRAMES, ...[...CLAUDE_BASE_FRAMES].reverse()] as const;
|
|
50
64
|
|
|
51
65
|
/**
|
|
@@ -110,6 +124,17 @@ export const SPINNER_PRESET_DEFS: Record<SpinnerPreset, Preset> = {
|
|
|
110
124
|
paint: "accent",
|
|
111
125
|
note: "rising braille dot level fill",
|
|
112
126
|
},
|
|
127
|
+
// A dot body growing outward from a two-cell core: each frame adds one dot cell on both ends
|
|
128
|
+
// (⠂⠐ → ⠺ ⠂⠐ ⠗ → ⠺ ⠪ ⠂⠐ ⠕ ⠗). Every frame is 10 columns so the label stays put; the
|
|
129
|
+
// spacing between the cells is what makes the growth read as a body rather than a row. Named
|
|
130
|
+
// off the `Braille *` prefix so it does not read as a third variant of the two above; the
|
|
131
|
+
// note still says braille, which is what the glyphs are.
|
|
132
|
+
"Dot Spine": {
|
|
133
|
+
frames: [" \u2802\u2810 ", " \u283a \u2802\u2810 \u2817 ", "\u283a \u282a \u2802\u2810 \u2815 \u2817"],
|
|
134
|
+
intervalMs: 200,
|
|
135
|
+
paint: "accent",
|
|
136
|
+
note: "braille dot body growing out from the centre, 3 frames",
|
|
137
|
+
},
|
|
113
138
|
// Kanji stroke count: one, two, three lines (一 二 三).
|
|
114
139
|
"Kanji Count": {
|
|
115
140
|
frames: ["\u4e00", "\u4e8c", "\u4e09"],
|
|
@@ -124,6 +149,54 @@ export const SPINNER_PRESET_DEFS: Record<SpinnerPreset, Preset> = {
|
|
|
124
149
|
paint: "accent",
|
|
125
150
|
note: "vertical tally bars, one to three",
|
|
126
151
|
},
|
|
152
|
+
// Bagua trigrams (three stacked lines): mountain, water, thunder (☶ ☵ ☳). Only three of the
|
|
153
|
+
// eight are used — a spinner is a short loop, not a catalogue — and all three arrive from the
|
|
154
|
+
// same fallback face at the same width, so the row holds its place even though the terminal
|
|
155
|
+
// font itself has none of them.
|
|
156
|
+
"Bagua Trigram": {
|
|
157
|
+
frames: ["\u2636", "\u2635", "\u2633"],
|
|
158
|
+
intervalMs: 200,
|
|
159
|
+
paint: "accent",
|
|
160
|
+
note: "bagua trigrams — mountain, water, thunder",
|
|
161
|
+
},
|
|
162
|
+
// I Ching digrams: two stacked lines that go solid, then break one at a time (⚌ ⚎ ⚍ ⚏).
|
|
163
|
+
// Read as a movement, the broken (yin) line travels downward — both lines whole, the lower
|
|
164
|
+
// one breaks, the break moves up, then both are broken — and the loop restarts. All four
|
|
165
|
+
// glyphs come from one fallback face at one width, so the row is constant even though the
|
|
166
|
+
// terminal font itself has none of them.
|
|
167
|
+
"Yin Yang Lines": {
|
|
168
|
+
frames: ["\u268c", "\u268e", "\u268d", "\u268f"],
|
|
169
|
+
intervalMs: 200,
|
|
170
|
+
paint: "accent",
|
|
171
|
+
note: "I Ching digrams, a broken line travelling through two",
|
|
172
|
+
},
|
|
173
|
+
// A four-arrow ring turning clockwise one position per frame (▴▸▾◂ ▸▾◂▴ ▾◂▴▸ ◂▴▸▾).
|
|
174
|
+
// 200ms × 4 matches Diamond Glow: one full turn per 0.8s.
|
|
175
|
+
"Arrow Orbit": {
|
|
176
|
+
frames: ["\u25b4\u25b8\u25be\u25c2", "\u25b8\u25be\u25c2\u25b4", "\u25be\u25c2\u25b4\u25b8", "\u25c2\u25b4\u25b8\u25be"],
|
|
177
|
+
intervalMs: 200,
|
|
178
|
+
paint: "accent",
|
|
179
|
+
note: "four-arrow ring, turning clockwise",
|
|
180
|
+
},
|
|
181
|
+
// The package's own spinner, and the default: a spark and a braille cell trading places
|
|
182
|
+
// around the centre. The trailing space in two frames is load-bearing: both cells are one
|
|
183
|
+
// column wide, so dropping it would shift the label by a column every second frame.
|
|
184
|
+
"Arnative": {
|
|
185
|
+
frames: [" \u2727\u2819", " \u2727\u28e0", "\u28c4\u2727 ", "\u280b\u2726 "],
|
|
186
|
+
intervalMs: 200,
|
|
187
|
+
paint: "accent",
|
|
188
|
+
note: "spark and dot cell circling, the Arnative mark",
|
|
189
|
+
},
|
|
190
|
+
// Pacman eating his way across the row, one cell per frame, with the trail behind him
|
|
191
|
+
// (`~` = ghost tail, `+ − ×` = the pellets still ahead). Every frame is 7 columns so the
|
|
192
|
+
// label stays put; `ᗧ` is U+15E7, the Canadian Syllabics `ye`, the glyph the pacman CLI
|
|
193
|
+
// spinners use because it reads as an open mouth facing right.
|
|
194
|
+
Pacman: {
|
|
195
|
+
frames: ["\u15e7 + \u2212 \u00d7", "~ \u15e7 \u2212 \u00d7", "~ ~ \u15e7 \u00d7", "~ ~ ~ \u15e7"],
|
|
196
|
+
intervalMs: 200,
|
|
197
|
+
paint: "accent",
|
|
198
|
+
note: "pacman eating the pellets, 4 frames",
|
|
199
|
+
},
|
|
127
200
|
// Gemini CLI's spinner is the `dots` entry from the `cli-spinners` package, the same
|
|
128
201
|
// Braille set pi ships; what makes it Gemini is the Google brand gradient sweeping the
|
|
129
202
|
// glyph on a 4s cycle (GeminiSpinner.tsx paints `CliSpinner` through `tinygradient`).
|
|
@@ -133,13 +206,13 @@ export const SPINNER_PRESET_DEFS: Record<SpinnerPreset, Preset> = {
|
|
|
133
206
|
paint: "gradient",
|
|
134
207
|
note: "pi's Braille + Google gradient",
|
|
135
208
|
},
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
209
|
+
// Claudeon: a mark of our own, inspired by Claude Code's pulsing dingbats. The shape is
|
|
210
|
+
// Claude's; the glyphs are not — they are picked to be one cell wide in the terminal font.
|
|
211
|
+
Claudeon: {
|
|
139
212
|
frames: CLAUDE_PING_PONG_FRAMES,
|
|
140
213
|
intervalMs: 120,
|
|
141
|
-
paint: "
|
|
142
|
-
note: "Claude
|
|
214
|
+
paint: "claudeon",
|
|
215
|
+
note: "inspired by Claude — pulsing marks, 120ms",
|
|
143
216
|
},
|
|
144
217
|
};
|
|
145
218
|
|
|
@@ -154,8 +227,9 @@ const GOOGLE_BRAND: ReadonlyArray<readonly [number, number, number]> = [
|
|
|
154
227
|
[168, 130, 234], // back to purple
|
|
155
228
|
];
|
|
156
229
|
|
|
157
|
-
/** Claude Code's own spinner colour: the
|
|
158
|
-
|
|
230
|
+
/** Claude Code's own spinner colour: the grey-orange it brands around. Claudeon keeps it,
|
|
231
|
+
* because the colour is what makes the mark read as "the Claude-style spinner". */
|
|
232
|
+
const CLAUDEON_RGB: readonly [number, number, number] = [215, 119, 87];
|
|
159
233
|
|
|
160
234
|
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
|
|
161
235
|
|
|
@@ -174,18 +248,36 @@ export function googleGradientRgb(t: number): [number, number, number] {
|
|
|
174
248
|
/** Truecolor SGR for one glyph. pi's own `fg` cannot take a raw RGB, so the escape is ours. */
|
|
175
249
|
const fgRgb = ([r, g, b]: readonly [number, number, number]): string => `\x1b[38;2;${r};${g};${b}m`;
|
|
176
250
|
|
|
177
|
-
/**
|
|
251
|
+
/**
|
|
252
|
+
* How long one full sweep of the brand gradient takes, in ms.
|
|
253
|
+
*
|
|
254
|
+
* The frame interval decides how many steps the sweep gets, so the step size — what the eye reads
|
|
255
|
+
* as smooth or as stepping — is `cycle / interval`. At 4s a 200ms preset moves up to 65 RGB units
|
|
256
|
+
* per frame, which reads as a strobe rather than a sweep; 20s brings that to 13 (measured:
|
|
257
|
+
* `200ms` 64.9 → 13.2, `80ms` 26.2 → 5.7 max delta). 20s is also slow enough to stay readable on
|
|
258
|
+
* `Pi (system)`, where the same sweep would otherwise finish in one blink.
|
|
259
|
+
*/
|
|
260
|
+
export const GRADIENT_CYCLE_MS = 20000;
|
|
261
|
+
|
|
262
|
+
/** The Google sweep, shared by the `gradient` presets and the `accent` override. */
|
|
263
|
+
const gradientPainter = (text: string): string => `${fgRgb(googleGradientRgb(Date.now() / GRADIENT_CYCLE_MS))}${text}\x1b[0m`;
|
|
264
|
+
|
|
265
|
+
/** Painter for a preset's spinner; gradient and claudeon use fixed branding, accent uses theme.
|
|
266
|
+
*
|
|
267
|
+
* `gradient` is the `/arnative` → Animations `[g]` override. It only reaches presets whose
|
|
268
|
+
* own paint mode is `accent`: `Gemini` is already the brand sweep, and `Claudeon` carries its
|
|
269
|
+
* own mark colour, so overriding either would erase what the preset exists to be. */
|
|
178
270
|
export function spinnerPainter(
|
|
179
271
|
preset: SpinnerPreset,
|
|
180
272
|
theme: { fg?(color: string, text: string): string } | null,
|
|
273
|
+
gradient = false,
|
|
181
274
|
): (text: string) => string {
|
|
182
275
|
const def = SPINNER_PRESET_DEFS[preset];
|
|
183
|
-
if (def.paint === "gradient")
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (def.paint === "claude") {
|
|
187
|
-
return (text) => `${fgRgb(CLAUDE_RGB)}${text}\x1b[0m`;
|
|
276
|
+
if (def.paint === "gradient") return gradientPainter;
|
|
277
|
+
if (def.paint === "claudeon") {
|
|
278
|
+
return (text) => `${fgRgb(CLAUDEON_RGB)}${text}\x1b[0m`;
|
|
188
279
|
}
|
|
280
|
+
if (gradient) return gradientPainter;
|
|
189
281
|
const fg = theme?.fg?.bind(theme);
|
|
190
282
|
if (!fg) return (text) => text;
|
|
191
283
|
return (text) => fg("accent", text);
|
|
@@ -198,6 +290,19 @@ export function previewFrames(preset: SpinnerPreset): string {
|
|
|
198
290
|
return frames.join(" ");
|
|
199
291
|
}
|
|
200
292
|
|
|
293
|
+
/** The frame a live preview should show at `now`, and the width to reserve for it.
|
|
294
|
+
*
|
|
295
|
+
* The picker animates the selected row from the wall clock instead of a stored counter, so a
|
|
296
|
+
* dropped repaint cannot desynchronise it — the index is a function of `now`. The width is the
|
|
297
|
+
* widest frame, so the row does not jump as the cell changes. */
|
|
298
|
+
export function previewFrameAt(preset: SpinnerPreset, now = Date.now()): { text: string; width: number } {
|
|
299
|
+
const def = SPINNER_PRESET_DEFS[preset];
|
|
300
|
+
const frames = def.frames.length > 0 ? def.frames : SPINNER_PRESET_DEFS.Gemini.frames;
|
|
301
|
+
const intervalMs = def.frames.length > 0 ? def.intervalMs : 80;
|
|
302
|
+
const index = Math.floor(now / intervalMs) % frames.length;
|
|
303
|
+
return { text: frames[index]!, width: Math.max(...frames.map((f) => [...f].length)) };
|
|
304
|
+
}
|
|
305
|
+
|
|
201
306
|
|
|
202
307
|
// Self-check: `node lib/spinner.ts`
|
|
203
308
|
if (isMain(import.meta.url)) {
|
|
@@ -234,13 +339,68 @@ if (isMain(import.meta.url)) {
|
|
|
234
339
|
assert(/^[\u25a0-\u25ff\u2b00-\u2bff]$/.test(f), `Diamond Glow frame ${f} is a geometric shape`);
|
|
235
340
|
}
|
|
236
341
|
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
assert(
|
|
241
|
-
assert(
|
|
242
|
-
assert(
|
|
243
|
-
|
|
342
|
+
// Claudeon preset: a mark of our own, inspired by Claude Code's pulsing dingbats. The
|
|
343
|
+
// attribution lives in the preset's note; the type and colour keep the `claudeon` name.
|
|
344
|
+
const claudeon = SPINNER_PRESET_DEFS.Claudeon.frames;
|
|
345
|
+
assert(claudeon[0] === "\u00b7", "Claudeon starts on the interpunct");
|
|
346
|
+
assert(claudeon.length === 8, "Claudeon uses an 8-frame there-and-back loop over 4 base marks");
|
|
347
|
+
assert(SPINNER_PRESET_DEFS.Claudeon.intervalMs === 120, "Claudeon matches upstream 120ms cadence");
|
|
348
|
+
assert(claudeon.join("") === [...claudeon].reverse().join(""), "Claudeon's ping-pong frames are a palindrome");
|
|
349
|
+
assert(
|
|
350
|
+
claudeon.join("") === "\u00b7\u002a\u22c6\u2736\u2736\u22c6\u002a\u00b7",
|
|
351
|
+
"Claudeon's base marks are pinned by codepoint",
|
|
352
|
+
);
|
|
353
|
+
// Every frame must be one column by the Unicode width tables — that catches a fullwidth or
|
|
354
|
+
// CJK mark slipped in. It cannot catch the *real* failure mode (a glyph the terminal font
|
|
355
|
+
// lacks, served ~1.5 cells wide by a fallback font), because that width is a font property
|
|
356
|
+
// and is invisible from here. Those frames are pinned by codepoint above instead, so the set
|
|
357
|
+
// cannot change silently.
|
|
358
|
+
assert(
|
|
359
|
+
claudeon.every((f) => visibleWidth(f) === 1),
|
|
360
|
+
`Claudeon frames must be single-column, got ${claudeon.filter((f) => visibleWidth(f) !== 1).join(" ")}`,
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
// Arrow Orbit, Arnative and Pacman each draw several glyphs per frame, so every frame must
|
|
364
|
+
// be the same width or the label jumps sideways each tick. Pinned by codepoint: a font
|
|
365
|
+
// fallback that swaps one arrow/star for a double-width glyph is exactly what this catches.
|
|
366
|
+
const arrow = SPINNER_PRESET_DEFS["Arrow Orbit"].frames;
|
|
367
|
+
assert(arrow.join("|") === "\u25b4\u25b8\u25be\u25c2|\u25b8\u25be\u25c2\u25b4|\u25be\u25c2\u25b4\u25b8|\u25c2\u25b4\u25b8\u25be", "Arrow Orbit turns clockwise one position per frame");
|
|
368
|
+
const spark = SPINNER_PRESET_DEFS.Arnative.frames;
|
|
369
|
+
assert(spark.join("|") === " \u2727\u2819| \u2727\u28e0|\u28c4\u2727 |\u280b\u2726 ", "Arnative frames as specified");
|
|
370
|
+
const pacman = SPINNER_PRESET_DEFS.Pacman.frames;
|
|
371
|
+
assert(
|
|
372
|
+
pacman.join("|") === "\u15e7 + \u2212 \u00d7|~ \u15e7 \u2212 \u00d7|~ ~ \u15e7 \u00d7|~ ~ ~ \u15e7",
|
|
373
|
+
"Pacman eats one cell per frame",
|
|
374
|
+
);
|
|
375
|
+
// The mouth moves one cell right each frame, so the glyph's column is the frame index.
|
|
376
|
+
for (const [i, frame] of pacman.entries()) {
|
|
377
|
+
assert([...frame].indexOf("\u15e7") === i * 2, `Pacman frame ${i} puts the mouth in column ${i * 2}`);
|
|
378
|
+
}
|
|
379
|
+
assert(DEFAULT_SPINNER_PRESET === "Arnative", "a fresh install gets the Arnative spinner");
|
|
380
|
+
// The four I Ching digrams, pinned by codepoint, in the order the operator gave them.
|
|
381
|
+
const yinYang = SPINNER_PRESET_DEFS["Yin Yang Lines"].frames;
|
|
382
|
+
assert(
|
|
383
|
+
yinYang.join("|") === "\u268c|\u268e|\u268d|\u268f",
|
|
384
|
+
"Yin Yang Lines frames as specified (all whole, lower breaks, break rises, both break)",
|
|
385
|
+
);
|
|
386
|
+
assert(new Set(yinYang).size === 4, "Yin Yang Lines shows four distinct digrams");
|
|
387
|
+
// The three bagua trigrams, pinned by codepoint, in the order the operator gave them.
|
|
388
|
+
const bagua = SPINNER_PRESET_DEFS["Bagua Trigram"].frames;
|
|
389
|
+
assert(bagua.join("|") === "\u2636|\u2635|\u2633", "Bagua Trigram frames as specified (mountain, water, thunder)");
|
|
390
|
+
assert(new Set(bagua).size === 3, "Bagua Trigram shows three distinct trigrams");
|
|
391
|
+
const worm = SPINNER_PRESET_DEFS["Dot Spine"].frames;
|
|
392
|
+
assert(
|
|
393
|
+
worm.join("|") === " \u2802\u2810 | \u283a \u2802\u2810 \u2817 |\u283a \u282a \u2802\u2810 \u2815 \u2817",
|
|
394
|
+
"Dot Spine frames as specified",
|
|
395
|
+
);
|
|
396
|
+
// The core `⠂⠐` sits at columns 4-5 in every frame and the body grows outward around it, so
|
|
397
|
+
// the centre never drifts; the glyph count grows 2 → 4 → 6.
|
|
398
|
+
for (const frame of worm) assert([...frame].slice(4, 6).join("") === "\u2802\u2810", `Dot Spine keeps its core centred: ${JSON.stringify(frame)}`);
|
|
399
|
+
assert(worm.map((f) => [...f].filter((c) => c !== " ").length).join() === "2,4,6", "Dot Spine adds one cell at each end per frame");
|
|
400
|
+
for (const frames of [arrow, spark, pacman, worm]) {
|
|
401
|
+
const widths = new Set(frames.map((f) => [...f].length));
|
|
402
|
+
assert(widths.size === 1, `every frame of a multi-glyph preset is the same length: ${[...widths].join()}`);
|
|
403
|
+
}
|
|
244
404
|
|
|
245
405
|
// The gradient closes: t=1 must equal t=0, or the 4s sweep would jump at the seam.
|
|
246
406
|
assert(
|
|
@@ -259,12 +419,61 @@ if (isMain(import.meta.url)) {
|
|
|
259
419
|
"accent presets paint through the theme token",
|
|
260
420
|
);
|
|
261
421
|
assert(spinnerPainter("Diamond Glow", null)("x") === "x", "no theme -> identity painter, never a crash");
|
|
422
|
+
|
|
423
|
+
// The `[g]` override reaches accent presets only: the two branded presets keep their identity.
|
|
424
|
+
const override = spinnerPainter("Diamond Glow", { fg: (c, t) => `<${c}>${t}` }, true)("x");
|
|
425
|
+
assert(override.startsWith("\x1b[38;2;") && override.endsWith("x\x1b[0m"), "gradient overrides an accent preset");
|
|
426
|
+
assert(spinnerPainter("Diamond Glow", null, true)("x").startsWith("\x1b[38;2;"), "the override needs no theme");
|
|
427
|
+
assert(
|
|
428
|
+
spinnerPainter("Claudeon", { fg: (c, t) => `<${c}>${t}` }, true)("x").startsWith("\x1b[38;2;215;119;87m"),
|
|
429
|
+
"gradient leaves Claudeon's own mark colour alone",
|
|
430
|
+
);
|
|
431
|
+
// Gemini already sweeps and Claudeon owns its mark colour, so `[g]` must not repaint either.
|
|
432
|
+
assert(
|
|
433
|
+
spinnerPainter("Gemini", null, true) === spinnerPainter("Gemini", null),
|
|
434
|
+
"the flag is ignored on a preset that already sweeps",
|
|
435
|
+
);
|
|
436
|
+
assert(
|
|
437
|
+
spinnerPainter("Claudeon", null, true)("x") === spinnerPainter("Claudeon", null)("x"),
|
|
438
|
+
"the flag is ignored on the preset that carries a mark colour",
|
|
439
|
+
);
|
|
262
440
|
const grad = spinnerPainter("Gemini", null)("x");
|
|
263
441
|
assert(grad.startsWith("\x1b[38;2;") && grad.endsWith("x\x1b[0m"), "the gradient painter emits truecolor + reset");
|
|
264
|
-
|
|
442
|
+
|
|
443
|
+
// The sweep must be slow enough to read as motion: the frame interval is fixed per preset, so
|
|
444
|
+
// the step size is what the cycle decides. Pin the measured ceiling rather than a taste.
|
|
445
|
+
const stepCeiling = (intervalMs: number): number => {
|
|
446
|
+
const steps = GRADIENT_CYCLE_MS / intervalMs;
|
|
447
|
+
let worst = 0;
|
|
448
|
+
for (let i = 0; i < steps; i++) {
|
|
449
|
+
const a = googleGradientRgb(i / steps);
|
|
450
|
+
const b = googleGradientRgb((i + 1) / steps);
|
|
451
|
+
worst = Math.max(worst, Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]));
|
|
452
|
+
}
|
|
453
|
+
return worst;
|
|
454
|
+
};
|
|
455
|
+
assert(stepCeiling(200) <= 15, `the slowest preset steps <= 15 RGB units (got ${stepCeiling(200).toFixed(1)})`);
|
|
456
|
+
assert(stepCeiling(80) <= 8, `the fastest preset steps <= 8 RGB units (got ${stepCeiling(80).toFixed(1)})`);
|
|
457
|
+
assert(spinnerPainter("Claudeon", null)("x").startsWith("\x1b[38;2;215;119;87m"), "Claudeon uses its own mark colour");
|
|
265
458
|
|
|
266
459
|
assert(previewFrames("Diamond Glow").split(" ").length === 4, "the picker preview shows every Diamond frame");
|
|
267
460
|
assert(previewFrames("Pi (system)").length > 0, "Pi (system) still previews pi's Braille set");
|
|
268
461
|
|
|
462
|
+
// Picker order: the two fixed rows first, then everything else alphabetically. Pinned
|
|
463
|
+
// because a new preset must not land wherever the edit happened to be.
|
|
464
|
+
assert(SPINNER_PRESETS[0] === "Pi (system)" && SPINNER_PRESETS[1] === "Arnative", "the two fixed rows lead the picker");
|
|
465
|
+
const rest = SPINNER_PRESETS.slice(2);
|
|
466
|
+
assert(
|
|
467
|
+
rest.join() === [...rest].sort((a, b) => a.localeCompare(b)).join(),
|
|
468
|
+
`the rest is alphabetical, got ${rest.join()}`,
|
|
469
|
+
);
|
|
470
|
+
assert(new Set(SPINNER_PRESETS).size === SPINNER_PRESETS.length, "every preset name is unique");
|
|
471
|
+
|
|
472
|
+
// The live preview reads the clock, so it advances without any stored counter.
|
|
473
|
+
assert(previewFrameAt("Diamond Glow", 0).text === "\u2b25", "the preview starts on frame 0");
|
|
474
|
+
assert(previewFrameAt("Diamond Glow", 200).text === "\u25c8", "the preview advances one frame per interval");
|
|
475
|
+
assert(previewFrameAt("Diamond Glow", 800).text === "\u2b25", "the preview wraps at the end of the loop");
|
|
476
|
+
assert(previewFrameAt("Pacman", 0).width === 7, "the preview reserves the widest frame");
|
|
477
|
+
|
|
269
478
|
console.log("spinner.ts self-check OK");
|
|
270
479
|
}
|
package/lib/usage-store.ts
CHANGED
|
@@ -348,6 +348,16 @@ function readTail(filePath: string, fromByte: number): Buffer | null {
|
|
|
348
348
|
let cachedSummary: UsageSummary | null = null;
|
|
349
349
|
let lastScanMs = 0;
|
|
350
350
|
|
|
351
|
+
/** Drop every in-memory memo so the next scan reads the cache file and the session files
|
|
352
|
+
* again. The self-check needs this: `forceScan` skips the 5s summary cache but still
|
|
353
|
+
* trusts the memoized disk cache, so without it a "cold parse" test asserts a value
|
|
354
|
+
* against itself and can never fail. */
|
|
355
|
+
export function resetUsageCache(): void {
|
|
356
|
+
diskMemo = null;
|
|
357
|
+
cachedSummary = null;
|
|
358
|
+
lastScanMs = 0;
|
|
359
|
+
}
|
|
360
|
+
|
|
351
361
|
export function collectUsageSummary(forceScan = false): UsageSummary {
|
|
352
362
|
const now = Date.now();
|
|
353
363
|
if (!forceScan && cachedSummary && now - lastScanMs < 5000) {
|
|
@@ -536,12 +546,22 @@ export function collectUsageSummary(forceScan = false): UsageSummary {
|
|
|
536
546
|
return cachedSummary;
|
|
537
547
|
}
|
|
538
548
|
|
|
539
|
-
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
549
|
+
/** One model id in its canonical form: trimmed, case-folded, route tag (`:free`) dropped. */
|
|
550
|
+
const canonicalModel = (id: string): string => {
|
|
551
|
+
const lower = id.trim().toLowerCase();
|
|
552
|
+
const tag = lower.indexOf(":");
|
|
553
|
+
return tag === -1 ? lower : lower.slice(0, tag);
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
/** Do these two ids name the same model? Exact after `canonicalModel` — deliberately not fuzzy.
|
|
557
|
+
*
|
|
558
|
+
* The rule used to also match a bare tail (`target.endsWith("/" + name)`), so the first run
|
|
559
|
+
* under a new provider inherited the history of every other provider whose id ended the same
|
|
560
|
+
* way: `river/deepseek-ai/DeepSeek-V4.1-Flash` reported wally+yarz's deepseek totals
|
|
561
|
+
* (29.5M in / 3.2M out / 360.5M cache) as its own (2026-10-17). Two ids that differ are two
|
|
562
|
+
* routes — billed, rate-limited and counted apart — and only the vendor knows whether a short
|
|
563
|
+
* id and a path id are the same model, so the tab shows the tokens of the id it names. */
|
|
564
|
+
const modelMatches = (mName: string, target: string): boolean => canonicalModel(mName) === canonicalModel(target);
|
|
545
565
|
|
|
546
566
|
/** All-time usage for one model (same numbers as /usage; the active session is already in its own file). */
|
|
547
567
|
export function getModelAllTimeUsage(modelId?: string): { input: number; output: number; cacheRead: number } {
|
|
@@ -552,10 +572,10 @@ export function getModelAllTimeUsage(modelId?: string): { input: number; output:
|
|
|
552
572
|
let out = 0;
|
|
553
573
|
let read = 0;
|
|
554
574
|
|
|
555
|
-
const target = modelId
|
|
575
|
+
const target = modelId;
|
|
556
576
|
for (const p of summary.providers) {
|
|
557
577
|
for (const m of p.models) {
|
|
558
|
-
if (modelMatches(m.name
|
|
578
|
+
if (modelMatches(m.name, target)) {
|
|
559
579
|
inp += m.input;
|
|
560
580
|
out += m.output;
|
|
561
581
|
read += m.cacheRead;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bismawy/pi-arnative",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Refined aesthetics. Cohesive tools. Built for Pi. Warm themes, rounded tool boxes, tabbed header, compact footer and a /usage dashboard.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|