@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 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 (`\uf51` -> `\uef04`).
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 `bun run test` instead of shipping silently (`extensions/footer.ts`).
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
- - `Diamond Glow` (default), `Diamond Pulse`, `Circle Pulse`, `Circle Wave`, `Shade Wave`
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
@@ -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 text. The description states the one seam: a preset applies to the next spinner. */
108
- export const ANIMATIONS_PICKER_TEXT = {
109
- title: "Animations - Arnative\u2819",
110
- description: "The working spinner — applies on the next tool call.",
111
- legend: "[↑↓] Preview [Enter] Select [Esc] Close",
112
- } as const;
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("Claude");
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)");
@@ -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
- ANIMATIONS_PICKER_TEXT,
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")) this.hooks.done();
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 note = item.note ? `${th.fg("dim", ROW_DIVIDER)}${th.fg("dim", item.note)}` : "";
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(ANIMATIONS_PICKER_TEXT.title, labels);
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
- ANIMATIONS_PICKER_TEXT,
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
- ANIMATIONS_PICKER_TEXT,
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
  }
@@ -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
- * Five small patches to pi's fullscreen renderer (render layer only, session data
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).
@@ -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: this.hintText(width),
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 freeAsst = (model: string, input: number) =>
449
- JSON.stringify({ message: { role: "assistant", provider: "wally", model, usage: { input, output: 5, cacheRead: 1, cost: { total: 0 } } } });
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
- [freeAsst("gemini-3.8-flash", 100), freeAsst("other-model", 7)].join(String.fromCharCode(10)),
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
- "Diamond Glow",
10
- "Diamond Pulse",
11
- "Circle Pulse",
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
- "Gemini",
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 = "Diamond Glow";
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" | "claude";
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 animation frames, adapting to Ghostty and non-macOS font fallbacks. */
39
- function defaultClaudeCharacters(): readonly string[] {
40
- if (process.env.TERM === "xterm-ghostty") {
41
- return ["\u00b7", "\u2722", "\u2733", "\u2736", "\u273b", "*"];
42
- }
43
- return process.platform === "darwin"
44
- ? ["\u00b7", "\u2722", "\u2733", "\u2736", "\u273b", "\u273d"]
45
- : ["\u00b7", "\u2722", "*", "\u2736", "\u273b", "\u273d"];
46
- }
47
-
48
- const CLAUDE_BASE_FRAMES = defaultClaudeCharacters();
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
- // Claude Code's mark animation: ping-pong loop of dingbats swelling and contracting,
137
- // matching upstream SpinnerAnimationRow (120ms per frame).
138
- Claude: {
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: "claude",
142
- note: "Claude Code's pulsing marks, 120ms",
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 256-colour grey-orange index it brands around. */
158
- const CLAUDE_RGB: readonly [number, number, number] = [215, 119, 87];
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
- /** Painter for a preset's spinner; gradient and claude use fixed branding, accent uses theme. */
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
- return (text) => `${fgRgb(googleGradientRgb(Date.now() / 4000))}${text}\x1b[0m`;
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
- // Claude preset: upstream dingbats in ping-pong order, matching Claude Code.
238
- const claude = SPINNER_PRESET_DEFS.Claude.frames;
239
- assert(claude[0] === "\u00b7", "Claude starts on the interpunct");
240
- assert(claude.length === 12, "Claude uses a 12-frame ping-pong loop");
241
- assert(SPINNER_PRESET_DEFS.Claude.intervalMs === 120, "Claude matches upstream 120ms cadence");
242
- assert(claude.join("") === [...claude].reverse().join(""), "Claude's ping-pong frames are a palindrome");
243
- for (const f of claude) assert(f !== " " && f.length === 1, `Claude frame ${JSON.stringify(f)} is one glyph`);
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
- assert(spinnerPainter("Claude", null)("x").startsWith("\x1b[38;2;215;119;87m"), "Claude uses its own mark colour");
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
  }
@@ -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
- // Match model names across id shapes ("gemini-3.8-flash", "vendor/gemini-…").
540
- const modelMatches = (mNameLower: string, targetLower: string): boolean =>
541
- mNameLower === targetLower ||
542
- mNameLower.endsWith("/" + targetLower) ||
543
- mNameLower.split(":")[0] === targetLower ||
544
- targetLower.endsWith("/" + mNameLower);
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.toLowerCase();
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.toLowerCase(), target)) {
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.5.3",
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",