@bismawy/pi-arnative 0.3.10 → 0.5.1

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,59 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.1] - 2026-10-13
4
+
5
+ ### Fixed
6
+ - Restored all 11 arnative themes on Pi 1.2.0, which validates the `colors` object with `additionalProperties: false` and rejected the whole theme — "must not have additional properties" followed by "Theme not found" (`lib/ansi.ts`, `themes/gen-themes.mjs`, `themes/*.json`, `extensions/ui-render-tweaks.ts`). Arnative's own colors (`accentSoft`, `primary`, `secondary`, `primarySoft`, `secondarySoft`) moved from `colors` to `vars`.
7
+ - Kept the two-tone soft tier instead of collapsing it to grey: `softOf` replaces `accentSoftOf` and derives the soft accent from the resolved `accent` color when `fg("accentSoft")` throws, so the header logo, tab labels, model line, footer labels, tool titles and the usage dashboard keep their tint (`lib/ansi.ts`).
8
+ - Updated `$schema` in every theme and the generator from the raw GitHub `theme-schema.json` path to the published `https://pi.dev/schemas/theme.schema.json` (`themes/gen-themes.mjs`, `themes/*.json`).
9
+ - Replaced the footer's raw `fg("accentSoft")` probe with the shared painter and mapped `primary`/`secondary` onto the official tokens they carry (`mdHeading`, `borderAccent`), since Pi 1.2 rejects custom `colors` keys (`extensions/footer.ts`).
10
+ - Mapped the usage dashboard's name/value/arrow colors off `accentSoft` onto the shared soft painter and plain `dim`/`text`, keeping provider rows distinguishable (`extensions/usage.ts`).
11
+ - Added a self-check that fails when a theme puts a non-schema key in `colors`, so a rogue token breaks `npm test` instead of the user's startup (`extensions/ui-render-tweaks.ts`).
12
+
13
+ ### Notes
14
+ - `0.5.0` is intentionally skipped on the npm registry. It was tagged and merged, but never published, and its themes are the ones Pi 1.2.0 rejects. `0.5.1` carries everything from v0.5.0 plus this fix. The tag stays in the history; install from npm and you get `0.5.1`.
15
+
16
+ ## [0.5.0] - 2026-10-13
17
+
18
+ ### Added
19
+ - Extended theme token hierarchy in `vars` with `primary`, `primarySoft`, `secondary`, and `secondarySoft` across all 11 themes (`themes/gen-themes.mjs`), ensuring unique hex mappings and full perceptual OKLCH color space discipline.
20
+ - Tri-color multi-palette overhauls for expressive themes (`arnative-cyberpunk`, `arnative-synthwave`, `arnative-dracula`, `arnative-gruvbox`, and `arnative-nord`), mapping `mdHeading` to `primary` and `borderAccent`/`mdCode` to `secondary`.
21
+ - Footer multi-color pairing (`extensions/footer.ts`): paired vibrant `accent` icons with readable `secondarySoft` labels and highlighted the model name with `primary`.
22
+
23
+ ### Changed
24
+ - Increased message bubble background lightness (`surfaceMsg` / `userMessageBg`) on `arnative-cyberpunk`, `arnative-matrix`, and `arnative-synthwave` to 24% (`oklch(24% 0.035 ...)`) to ensure container boundaries remain clearly defined against pitch-black terminal canvases.
25
+
26
+ ### Fixed
27
+ - Fixed parenthetical suffix per-row dimming in `/arnative` → **Animations** so `(system)` on `Pi (system)` renders softly in `dim` even when descriptive frame notes are present (`extensions/arnative.ts`).
28
+ - Fixed inline image preview layout spacing by dropping the redundant spacer gap between the tool execution box and the image payload (`extensions/tools.ts`).
29
+
30
+ ## [0.4.0] - 2026-10-12
31
+
32
+ ### Added
33
+ - `/arnative` → **Animations** picker and working-spinner presets (`lib/spinner.ts`, `extensions/animations.ts`): Diamond Glow (default), Diamond Pulse, Circle Pulse, Circle Wave, Shade Wave, Braille Column, Braille Level, Kanji Count, Suzhou Tally, Pi (system), Gemini (Google brand gradient), and Claude (pulsing mark loop).
34
+ - Automatic token cost estimation for unpriced models via pi's `models.json` declared rates (`lib/usage-store.ts`, `extensions/usage.ts`).
35
+ - Usage dashboard integration into `/arnative` → **Usage** alongside standalone `/usage` command (`extensions/arnative.ts`, `extensions/usage.ts`).
36
+
37
+ ### Changed
38
+ - Active session clock tracks duration while active and freezes after inactivity (`IDLE_PAUSE_MS`), ignoring non-keystroke inputs (`extensions/footer.ts`, `test/footer-minimal.test.ts`).
39
+ - Working spinner frames clear `renderIndicatorVerbatim` to apply theme `accent` color while preserving `accentSoft` for labels (`extensions/footer.ts`, `extensions/animations.ts`).
40
+ - Unified screen chrome and title format via `renderScreen` and `screenTitle` in `lib/box.ts`, with adaptive thinking-level border colors across all `/arnative` screens (`lib/box.ts`, `lib/ansi.ts`, `extensions/arnative.ts`, `extensions/usage.ts`).
41
+ - Back key `←` (`matchesBack`) standard across `/arnative` submenus and usage dashboard, stepping back without losing selection context (`lib/shortcuts.ts`, `extensions/arnative.ts`, `extensions/usage.ts`).
42
+ - Compact usage dashboard layout: removed `↑In`/`↓Out`/`Cache` columns from main table (moved to selected row detail line) and added mouse click toggle for provider rows (`extensions/usage.ts`).
43
+ - Aligned list picker layout to standard `Label · note` format, removing redundant `●` indicator column (`extensions/arnative.ts`).
44
+ - Header brand updated to `Arnative⠙ vX.Y.Z` using shared `BRAND` constant (`extensions/section-headers.ts`).
45
+
46
+ ### Fixed
47
+ - Fixed spinner preset selection in `/arnative` → **Animations** by sharing active UI and theme references across extension module instances via `globalThis` (`extensions/animations.ts`).
48
+ - Fixed missing `animations.ts` in `scripts/preview.mjs` extension launch list (`scripts/preview.mjs`).
49
+ - Fixed box row truncation in `boxRow` to prevent right wall clipping on lines narrower than content (`lib/box.ts`).
50
+ - Fixed `boxRow` zero-column overflow when width is below 4 columns (`lib/box.ts`).
51
+ - Avoided repeated synchronous disk reads during usage summary collection by passing memoized price table (`lib/usage-store.ts`).
52
+ - Cleaned up active clock state initialization on fresh sessions (`extensions/footer.ts`).
53
+ - Bounded tool duration cache (`TIMINGS`) to 200 entries with FIFO eviction and cleared on session boundaries to prevent memory growth (`extensions/tools.ts`).
54
+ - Added short-circuit in `repairBubbleBg` to avoid string split/join allocations on lines without ANSI reset sequences (`extensions/timestamps.ts`).
55
+ - Added fast substring check in `shouldHideReloadBox` to bypass `stripAnsi` and array mapping across general containers (`extensions/ui-render-tweaks.ts`).
56
+
3
57
  ## [0.3.10] - 2026-10-11
4
58
 
5
59
  ### Fixed
package/README.md CHANGED
@@ -6,21 +6,35 @@ Refined aesthetics. Cohesive tools. Built for Pi.
6
6
  [![badge](https://shieldcn.dev/npm/@bismawy/pi-arnative.svg?variant=outline&size=xs)](https://www.npmjs.com/package/@bismawy/pi-arnative)
7
7
  [![license](https://shieldcn.dev/github/bismawy/pi-arnative/license.svg?variant=outline&size=xs)](https://github.com/bismawy/pi-arnative)
8
8
 
9
- ![Arnative: tabbed header, rounded tool boxes, transcript clock and /usage dashboard](https://raw.githubusercontent.com/bismawy/pi-arnative/main/assets/pi-arnative.webp)
9
+ <img src="https://raw.githubusercontent.com/bismawy/pi-arnative/main/assets/pi-arnative.webp" alt="pi-arnative" width="100%">
10
10
 
11
11
  ## Overview
12
12
 
13
13
  pi-arnative replaces pi’s default terminal chrome with a unified, cohesive visual aesthetic.
14
14
 
15
- - Warm Themes: Cohesive, contrast-checked color palettes generated without drift.
16
- - Tabbed Header: Greeting, version line, and a compact box menu over Directory, Model, Context, Skills, Extensions, and Shortcut.
17
- > The Context/Skills/Extensions counts read pi's startup listing, which `"quietStartup": true` (and `"header"`) hides. Under those settings the counts show `—` rather than a misleading `0`; set `"quietStartup": false` to see the real numbers.
18
- - Rounded Tool Boxes: Clear status, spinner, and duration for every tool call. Built-in `codemode` is boxed and collapsed to two rows (title + first script line) like `bash`/`grep`/`read`; nested calls and output sit behind `[ctrl+o to expand]` (shown whenever the collapsed summary hides more, including `edit` diffs); a failed run always shows its full output. The `chrome_devtools_*` tools box too, replacing their bare "Chrome DevTools: …" lines.
19
- - Enhanced Footer: fixed 3-row grid — directory/git line, then extension status paired with token metrics. The clock keeps the session's real age across `/resume`, and the working tree's diff (`+500 -100`) sits beside it while there is one.
20
- > Three presets via `/arnative` → **Footers**: `Arnative (Full)`, `Pi (system)`, and `Arnative (Minimal)` — a single head row whose two sides expand independently on click (`ctrl+alt+m` toggles both).
21
- > The branch is read straight from `.git/HEAD`, so it shows up without a git install. Tag, dirty count, and ahead/behind need the `git` binary — when it is missing (or the repo is owned by another user, which git refuses to touch) that half is left out rather than shown wrong. Run `git config --global --add safe.directory <repo>` (a local, non-synced setting) to make `git` cooperate with such a repo.
22
- - Transcript Clock: Timestamps for messages with clean bubble backgrounds.
23
- - Usage Dashboard: /usage command to track tokens and session costs.
15
+ - Warm Themes: Cohesive, contrast-checked OKLCH color palettes generated without drift.
16
+ - Tabbed Header: Compact box menu showing Directory, Model, Context, Skills, Extensions, and Shortcuts.
17
+ - Rounded Tool Boxes: Status, spinner, and duration for every tool call with expandable outputs.
18
+ - Enhanced Footer: 3-row status grid tracking branch, active duration (30s idle freeze), and token speed.
19
+ - Transcript Clock: Timestamps for conversation messages with clean bubble backgrounds.
20
+ - Usage Dashboard: Real-time token counts and cost tracking across all local sessions via `/usage`.
21
+
22
+ <details>
23
+ <summary><b>Configuration & Presets</b></summary>
24
+
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)
30
+
31
+ ### Footer Presets
32
+ Configurable via `/arnative` → **Footers**:
33
+ - `Arnative (Full)` (default 3-row grid)
34
+ - `Arnative (Minimal)` (single collapsible head row; click or `ctrl+alt+m` to toggle)
35
+ - `Pi (system)` (built-in footer)
36
+
37
+ </details>
24
38
 
25
39
  ## Install
26
40
 
@@ -28,13 +42,12 @@ pi-arnative replaces pi’s default terminal chrome with a unified, cohesive vis
28
42
  pi install npm:@bismawy/pi-arnative
29
43
  ```
30
44
 
31
- Select a theme via `/settings` → **Theme** → `arnative` (or any `arnative-*` variant).
45
+ Apply a theme via `/settings` → **Theme** → `arnative` (or variant). Requires a [Nerd Font](https://www.nerdfonts.com/font-downloads).
32
46
 
33
- To test locally without installing:
47
+ To test locally:
34
48
  ```bash
35
49
  pi --extension ./extensions/footer.ts
36
50
  ```
37
- > **Prerequisite:** Requires a [Nerd Font](https://www.nerdfonts.com/font-downloads) (e.g. JetBrains Mono Nerd Font) for icons and rounded box borders.
38
51
 
39
52
  ## Shortcuts
40
53
 
@@ -42,75 +55,76 @@ pi --extension ./extensions/footer.ts
42
55
  | --- | --- |
43
56
  | `ctrl+alt+t` · `alt+t` | Cycle header tabs |
44
57
  | `ctrl+alt+r` · `alt+r` | Quick reload (`/reload`) |
45
- | `ctrl+alt+n` · `alt+n` | New session (`/new`) — requires keybinding below |
46
- | `ctrl+alt+m` · `alt+m` | Expand/collapse both sides of the Minimal footer |
47
- | `/usage` | Show session usage & token metrics |
48
- | `/arnative` | Settings menu — Themes, Headers, Footers |
49
- | Click tab | Open selected header tab |
58
+ | `ctrl+alt+n` · `alt+n` | New session (`/new`) |
59
+ | `ctrl+alt+m` · `alt+m` | Toggle Minimal footer |
60
+ | `/usage` | Token & cost dashboard (or `/arnative` → **Usage**) |
61
+ | `/arnative` | Settings menu (Themes, Headers, Footers, Animations, Usage) |
50
62
  | Click tool / `ctrl+o` | Toggle tool output box |
63
+ | `←` | Back — step back from submenus/dashboard to menu |
51
64
 
52
- > **Notes:**
53
- > - **Windows / WSL:** Windows Terminal aliases `Ctrl+Alt` to AltGr; use `alt+…` (or set `"altGrAliasing": false` in WT profile).
54
- > - **macOS:** uses `ctrl+option+…` (plain Option composes special characters).
55
- > - **Herdr / tmux glitch:** if shortcuts feel delayed or type stray letters, set `PI_TUI_ESC_TIMEOUT=100` in your shell profile.
56
-
57
- ### Keybindings
58
-
59
- Add to `~/.pi/agent/keybindings.json` to bind `app.session.new`:
65
+ <details>
66
+ <summary><b>Keybindings & Platform Notes</b></summary>
60
67
 
68
+ To bind `app.session.new` in `~/.pi/agent/keybindings.json`:
61
69
  ```json
62
70
  {
63
71
  "app.session.new": ["ctrl+alt+n", "alt+n"]
64
72
  }
65
73
  ```
66
74
 
75
+ - **Windows / WSL:** Windows Terminal aliases `Ctrl+Alt` to AltGr; use `alt+…` (or disable `altGrAliasing` in WT profile).
76
+ - **macOS:** Uses `ctrl+option+…`.
77
+ - **tmux / Herdr:** If Esc has delay, set `PI_TUI_ESC_TIMEOUT=100` in your environment.
78
+ - **Navigation:** `←` navigates back to parent menus; `Esc` cancels or closes the active screen.
79
+
80
+ </details>
81
+
67
82
  ## Architecture
68
83
 
69
84
  <details>
70
- <summary><b>Extensions</b></summary>
85
+ <summary><b>Extensions & Components</b></summary>
71
86
 
72
87
  | File | Role |
73
88
  | --- | --- |
74
89
  | `extensions/tools.ts` | Rounded tool boxes, spinners, and duration |
75
90
  | `extensions/section-headers.ts` | Tabbed header and resource box (preset: `/arnative headers`) |
76
91
  | `extensions/footer.ts` | 3-row status grid footer and boxed editor (preset: `/arnative footers`) |
77
- | `extensions/arnative.ts` | `/arnative` settings menu — live-preview theme/header/footer pickers |
78
- | `extensions/timestamps.ts` | Message clock and bubble background fixes |
92
+ | `extensions/animations.ts` | Working spinner presets and `/arnative` → Animations picker |
93
+ | `lib/spinner.ts` | Spinner preset catalog: frames, cadence, and painters |
94
+ | `extensions/arnative.ts` | Settings menu with live preview and navigation |
95
+ | `extensions/timestamps.ts` | Message timestamps and bubble backgrounds |
79
96
  | `extensions/ui-render-tweaks.ts` | Contrast tweaks, selection style, and UI polish |
80
- | `extensions/usage.ts` | `/usage` token dashboard |
97
+ | `extensions/usage.ts` | Token and cost dashboard (`/usage`) |
81
98
 
82
99
  </details>
83
100
 
84
101
  <details>
85
- <summary><b>Themes</b></summary>
102
+ <summary><b>Theme Variants</b></summary>
86
103
 
87
- - `themes/gen-themes.mjs` is the single source of truth: it **writes every** `themes/*.json`, including the base `arnative.json`.
88
- - Each theme is a few parameters (accent hue, chroma, canvas lightness, neutral tint) plus optional hue overrides for the strong palettes; all colors are derived as OKLCH from a shared ramp, so lightness/saturation stay consistent and no two `vars` collapse to the same value.
89
- - `lib/color.ts` holds the OKLCH↔sRGB math and WCAG contrast used by both the generator and the self-check.
104
+ Theme definitions are generated via `themes/gen-themes.mjs` using OKLCH color math (`lib/color.ts`).
90
105
 
91
106
  **Variants:** `sun` · `zinc` · `violet` · `emerald` · `matrix` · `cyberpunk` · `synthwave` · `gruvbox` · `nord` · `dracula`
92
107
 
93
108
  </details>
94
109
 
95
110
  <details>
96
- <summary><b>Development</b></summary>
111
+ <summary><b>Development & Verification</b></summary>
97
112
 
98
113
  ```bash
99
- npm test # Run self-checks & theme generator check
114
+ npm test # Run unit tests and self-checks
100
115
  npm run themes # Rebuild all theme variants
116
+ npm run preview # Live preview the working tree in your own Pi (stamps a pushable commit)
101
117
  ```
102
118
 
103
- **CI:** `.github/workflows/ci.yml` runs `npm install` + `npm test` on every push to `main` and every pull request, against Node 22.x (the floor the peer packages require) and 24.x (current LTS). No lockfile is committed, so the workflow installs without a cache key. The suite must pass in a clean checkout — a test that reads `process.cwd()` or a machine-local path will fail there.
104
-
105
- **Releasing:** `scripts/release-check.mjs` is a read-only preflight (never writes, tags, pushes or publishes) with one stage per step of the release:
119
+ Pre-publish verification and tag validation are enforced via `scripts/release-check.mjs`.
106
120
 
107
- | Stage | Run before | Checks |
108
- | :--- | :--- | :--- |
109
- | `pre-tag` | `git tag` | clean tree on `main`, in sync, no open PRs, `CHANGELOG.md` entry, tag free, version unpublished |
110
- | `pre-publish` | `npm publish` | the above, plus the tag exists **and is pushed**, and points at `HEAD` |
111
- | `post-publish` | done | the above, plus the version is live on the registry, `latest` points at it and its tarball URL answers |
121
+ **Local-first push gate:** the global pre-push hook rejects any branch push whose commit
122
+ carries no live-preview stamp, so a fix cannot reach GitHub unseen. `npm run preview`
123
+ launches Pi with this working tree and writes `.git/arnative-preview` for the exact commit
124
+ that was shown; editing `extensions/`, `lib/`, `themes/`, or `package.json` afterwards
125
+ invalidates it and the hook asks for a fresh preview. The preview must run on a clean tree
126
+ — commit or stash first.
112
127
 
113
- `pre-publish` runs automatically through npm's `prepublishOnly` (after `npm test`), so a red build or a missing/unpushed tag aborts the publish (`npm error code 1`) instead of shipping a version whose tag was forgotten. The open-PR check uses `gh` when present and falls back to the public GitHub API otherwise. `npm run release:check` is the `pre-tag` shorthand. Exit codes: `0` pass, `1` a check failed, `2` bad usage.
114
128
  </details>
115
129
 
116
130
  ## License
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Animations — the working-indicator preset, owned outside the footer because it is not a
3
+ * footer concern: pi's loader spins in the boxed editor's top border and in the in-place
4
+ * status row whether or not an arnative footer is installed.
5
+ *
6
+ * `/arnative` → **Animations** picks a preset; this module installs it, repaints the live
7
+ * spinner on the spot, and hands the slot back to pi for `Pi (system)`. The frames, their
8
+ * cadence and their painter live in `lib/spinner.ts`; the picker UI lives here.
9
+ *
10
+ * The Loader's colour hooks are not reachable through pi's public indicator options, so this
11
+ * module reaches the live editor the footer publishes under `EDITOR_KEY`. That is the one
12
+ * coupling between the two, and it is a published symbol, not an import.
13
+ */
14
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import { loadChoice, saveChoice } from "../lib/preset-store.ts";
16
+ import {
17
+ DEFAULT_SPINNER_PRESET,
18
+ SPINNER_PRESETS,
19
+ SPINNER_PRESET_DEFS,
20
+ SPINNER_PRESET_KEY,
21
+ previewFrames,
22
+ spinnerPainter,
23
+ type SpinnerPreset,
24
+ } from "../lib/spinner.ts";
25
+ import { assert, isMain } from "../lib/check.ts";
26
+
27
+ /** The row divider, matching the one `arnative.ts` draws (`Label · note`). Kept local so
28
+ * this module does not reach into the picker just for a separator string. */
29
+ const ROW_DIVIDER = " · ";
30
+
31
+ /** The arnative editor instance, published by `footer.ts` so the live spinner is reachable. */
32
+ export const EDITOR_KEY = Symbol.for("pi-arnative.editor");
33
+ const SPINNER_PRESET_CONFIG_KEY = Symbol.for("pi-arnative.spinnerPreset");
34
+
35
+ /** The `setWorkingMessage`/`setWorkingIndicator` surface of `ctx.ui`. */
36
+ type WorkingUi = { setWorkingIndicator?: (options?: unknown) => void } | null | undefined;
37
+
38
+ /** The live theme, for the accent presets. A gradient preset ignores it. */
39
+ type ThemeLike = { fg?(color: string, text: string): string } | null;
40
+
41
+ // Active UI and theme are kept on globalThis so choices apply across reloads and multi-instance loads.
42
+ const UI_KEY = Symbol.for("pi-arnative.workingUi");
43
+ const THEME_KEY = Symbol.for("pi-arnative.workingTheme");
44
+
45
+ /** Record the session's `ctx.ui`, so a later preset pick can install through it. */
46
+ function workingUi(ui: WorkingUi): void {
47
+ (globalThis as Record<symbol, unknown>)[UI_KEY] = ui ?? undefined;
48
+ }
49
+
50
+ /** The `ctx.ui` recorded at `session_start`, or null before one ran. */
51
+ function activeWorkingUi(): WorkingUi {
52
+ return ((globalThis as Record<symbol, unknown>)[UI_KEY] as WorkingUi) ?? null;
53
+ }
54
+
55
+ /** Record the session's theme, so accent presets follow it instead of a stale colour. */
56
+ function workingTheme(theme: ThemeLike): void {
57
+ (globalThis as Record<symbol, unknown>)[THEME_KEY] = theme ?? undefined;
58
+ }
59
+
60
+ /** The theme recorded at `session_start`; a gradient preset ignores it. */
61
+ function activeWorkingTheme(): ThemeLike {
62
+ return ((globalThis as Record<symbol, unknown>)[THEME_KEY] as ThemeLike) ?? null;
63
+ }
64
+
65
+ export function activeSpinnerPreset(): SpinnerPreset {
66
+ const stored =
67
+ (globalThis as Record<symbol, unknown>)[SPINNER_PRESET_CONFIG_KEY] ??
68
+ loadChoice(SPINNER_PRESET_KEY, SPINNER_PRESETS);
69
+ return SPINNER_PRESETS.includes(stored as SpinnerPreset) ? (stored as SpinnerPreset) : DEFAULT_SPINNER_PRESET;
70
+ }
71
+
72
+ /**
73
+ * Publish the choice for this process, write it to `arnative-config.json`, and install it on
74
+ * the running session right away — the picker previews by moving the cursor, so a choice that
75
+ * only took effect on the next `session_start` would look like the selection did nothing.
76
+ */
77
+ export function setSpinnerPreset(preset: SpinnerPreset): void {
78
+ (globalThis as Record<symbol, unknown>)[SPINNER_PRESET_CONFIG_KEY] = preset;
79
+ saveChoice(SPINNER_PRESET_KEY, preset);
80
+ applySpinnerPreset(activeWorkingUi(), activeWorkingTheme());
81
+ }
82
+
83
+ /** Updates custom painter on live workingStatusIndicator if available. */
84
+ export function paintLiveIndicator(painter: (text: string) => string): void {
85
+ const editor = (globalThis as Record<symbol, any>)[EDITOR_KEY];
86
+ const indicator = editor?.workingStatusIndicator;
87
+ if (!indicator) return;
88
+ indicator.renderIndicatorVerbatim = false;
89
+ indicator.spinnerColorFn = painter;
90
+ indicator.updateDisplay?.();
91
+ }
92
+
93
+ /** Applies preset to working indicator or restores system default for 'Pi (system)'. */
94
+ export function applySpinnerPreset(ui: WorkingUi, theme: ThemeLike): void {
95
+ const preset = activeSpinnerPreset();
96
+ const def = SPINNER_PRESET_DEFS[preset];
97
+ const painter = spinnerPainter(preset, theme);
98
+ try {
99
+ if (def.frames.length === 0) ui?.setWorkingIndicator?.();
100
+ else ui?.setWorkingIndicator?.({ frames: [...def.frames], intervalMs: def.intervalMs });
101
+ } catch {
102
+ // older host without the API -> pi's own frames stay
103
+ }
104
+ paintLiveIndicator(painter);
105
+ }
106
+
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;
113
+
114
+ /** One row per preset: the name, then its note and the frames it actually cycles. The
115
+ * frames belong in the note — a user picking an animation wants to see the glyphs, and this
116
+ * is the screen where that is decided. */
117
+ export function animationRows(): Array<{ label: string; note: string }> {
118
+ return SPINNER_PRESETS.map((preset) => ({
119
+ label: preset,
120
+ note: `${SPINNER_PRESET_DEFS[preset].note} ${previewFrames(preset)}`,
121
+ }));
122
+ }
123
+
124
+ /** The row as one flat string, for the headless fallback and the self-check. */
125
+ export function animationRowText(preset: SpinnerPreset): string {
126
+ return `${preset}${ROW_DIVIDER}${SPINNER_PRESET_DEFS[preset].note} ${previewFrames(preset)}`;
127
+ }
128
+
129
+ /** Register the extension: keep the installed preset in step with the session's theme. */
130
+ export default function registerAnimations(pi: ExtensionAPI): void {
131
+ pi.on("session_start", async (_event, ctx: ExtensionContext) => {
132
+ // A preset is installed per session so it survives `/reload` and a theme switch
133
+ // repaints the accent presets without re-selecting anything. Publish the surfaces first:
134
+ // `/arnative` → Animations re-applies through them the moment a row is chosen.
135
+ workingUi(ctx.ui);
136
+ workingTheme((ctx.ui as unknown as { theme?: ThemeLike })?.theme ?? null);
137
+ applySpinnerPreset(activeWorkingUi(), activeWorkingTheme());
138
+ });
139
+ }
140
+
141
+ // Self-check: `node extensions/animations.ts`. Async because the cross-instance assertion
142
+ // imports a second copy of this module.
143
+ if (isMain(import.meta.url)) {
144
+ await (async () => {
145
+ // The rows describe exactly the presets, and every row carries its own note.
146
+ const rows = animationRows();
147
+ assert(rows.length === SPINNER_PRESETS.length, "one row per preset");
148
+ assert(
149
+ rows.every((r) => r.note.length > 0),
150
+ "every row has a note",
151
+ );
152
+ assert(animationRowText("Diamond Glow").includes("\u2b25"), "the row text previews the frames");
153
+
154
+ // Installing a preset goes through pi's indicator API; `Pi (system)` passes nothing.
155
+ const calls: Array<Record<string, unknown> | undefined> = [];
156
+ const ui = { setWorkingIndicator: (options?: Record<string, unknown>) => calls.push(options) };
157
+ (globalThis as Record<symbol, unknown>)[SPINNER_PRESET_CONFIG_KEY] = "Diamond Glow";
158
+ applySpinnerPreset(ui, null);
159
+ assert(JSON.stringify(calls[0]?.frames) === JSON.stringify(SPINNER_PRESET_DEFS["Diamond Glow"].frames), "frames installed");
160
+ assert(calls[0]?.intervalMs === 200, "interval installed");
161
+ (globalThis as Record<symbol, unknown>)[SPINNER_PRESET_CONFIG_KEY] = "Pi (system)";
162
+ applySpinnerPreset(ui, null);
163
+ assert(calls.at(-1) === undefined, "Pi (system) passes no options -> pi restores its own spinner");
164
+
165
+ // A stored value that names no preset falls back instead of breaking the load.
166
+ (globalThis as Record<symbol, unknown>)[SPINNER_PRESET_CONFIG_KEY] = "Nope";
167
+ assert(activeSpinnerPreset() === DEFAULT_SPINNER_PRESET, "an unknown stored preset falls back to the default");
168
+ delete (globalThis as Record<symbol, unknown>)[SPINNER_PRESET_CONFIG_KEY];
169
+
170
+ // The painter seam: without a published editor there is nothing to paint, and that must
171
+ // be a no-op rather than a throw (the editor registers after the first session_start).
172
+ delete (globalThis as Record<symbol, unknown>)[EDITOR_KEY];
173
+ paintLiveIndicator((t) => t);
174
+
175
+ // Picking a preset must apply it NOW, not on the next session — and it must work when the
176
+ // picker calls a DIFFERENT module instance than the one that ran `session_start`. pi loads
177
+ // every manifest entry through its own loader while `arnative.ts` imports this module too,
178
+ // so there are two copies with separate module scope; only `globalThis` is shared. Keeping
179
+ // the surfaces on globalThis is what this pins, and a same-instance test would not catch it
180
+ // (the original bug passed such a test: the row moved, the spinner did not).
181
+ const pickCalls: Array<Record<string, unknown> | undefined> = [];
182
+ const pickUi = { setWorkingIndicator: (options?: Record<string, unknown>) => pickCalls.push(options) };
183
+ const handlers: Array<(e: unknown, c: unknown) => unknown> = [];
184
+ registerAnimations({ on: (_n: string, fn: (e: unknown, c: unknown) => unknown) => handlers.push(fn) } as never);
185
+ // Instance A runs session_start and records the ui on globalThis.
186
+ handlers[0]!({}, { ui: pickUi, theme: { fg: (_c: string, t: string) => t } });
187
+ pickCalls.length = 0;
188
+ // Instance B — a second copy of the module, like the one `arnative.ts` holds — does the pick.
189
+ const instanceB = await import(`./animations.ts?second=${Date.now()}`);
190
+ instanceB.setSpinnerPreset("Claude");
191
+ assert(pickCalls.length === 1, "a second module instance installs through the shared ui");
192
+ assert(pickCalls[0]?.frames?.[0] === "\u00b7", "the chosen preset's frames are installed, not the old ones");
193
+ assert(pickCalls[0]?.intervalMs === 120, "and its cadence (120ms)");
194
+
195
+ // Pi (system) still hands the slot back through the same path.
196
+ pickCalls.length = 0;
197
+ instanceB.setSpinnerPreset("Pi (system)");
198
+ assert(pickCalls[0] === undefined, "choosing Pi (system) restores pi's own spinner at once");
199
+
200
+ // With one, both the verbatim flag and the painter are set — this is what keeps the
201
+ // colour on a custom frame.
202
+ const indicator = { renderIndicatorVerbatim: true, spinnerColorFn: undefined as unknown, updateDisplay: () => {} };
203
+ let painted = 0;
204
+ const editor = { workingStatusIndicator: { ...indicator, updateDisplay: () => painted++ } };
205
+ (globalThis as Record<symbol, any>)[EDITOR_KEY] = editor;
206
+ paintLiveIndicator((t) => `<a>${t}`);
207
+ assert(editor.workingStatusIndicator.renderIndicatorVerbatim === false, "verbatim cleared, else pi skips the painter");
208
+ assert(painted === 1, "the live indicator repaints at once");
209
+ delete (globalThis as Record<symbol, any>)[EDITOR_KEY];
210
+
211
+ console.log("animations.ts self-check OK");
212
+ })();
213
+ }