@bismawy/pi-arnative 0.3.9 → 0.4.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/lib/ansi.ts CHANGED
@@ -50,6 +50,39 @@ export function accentSoftOf(th: { fg?(c: string, t: string): string } | null):
50
50
  }
51
51
  }
52
52
 
53
+ /** pi defines one theme slot per thinking level (`thinkingOff`…`thinkingMax`), so a box
54
+ * can follow the model instead of a hardcoded palette — switching theme keeps the
55
+ * meaning of the color. Shared by `usage.ts` and `arnative.ts`. */
56
+ const THINKING_SLOTS: Record<string, string> = {
57
+ minimal: "thinkingMinimal",
58
+ low: "thinkingLow",
59
+ medium: "thinkingMedium",
60
+ high: "thinkingHigh",
61
+ xhigh: "thinkingXhigh",
62
+ max: "thinkingMax",
63
+ };
64
+
65
+ function thinkingColorSlot(level: string | undefined): string {
66
+ return THINKING_SLOTS[(level ?? "off").toLowerCase()] ?? "thinkingOff";
67
+ }
68
+
69
+ /** Painter for a thinking level, probed once; falls back to `dim` when the theme lacks
70
+ * the slot, so a non-arnative theme draws a plain border instead of throwing. */
71
+ export function thinkingColorOf(
72
+ th: { fg?(c: string, t: string): string } | null,
73
+ level: string | undefined,
74
+ ): (s: string) => string {
75
+ const fg = th?.fg?.bind(th);
76
+ if (!fg) return (s) => s;
77
+ const slot = thinkingColorSlot(level);
78
+ try {
79
+ fg(slot, "");
80
+ return (s) => fg(slot, s);
81
+ } catch {
82
+ return (s) => fg("dim", s);
83
+ }
84
+ }
85
+
53
86
  // Self-check: `node lib/ansi.ts`
54
87
  if (isMain(import.meta.url)) {
55
88
  assert(ansiBgOpen(null, "selectedBg") === "", "no theme: empty");
@@ -74,5 +107,22 @@ if (isMain(import.meta.url)) {
74
107
  };
75
108
  assert(accentSoftOf(noSoft)("x") === "<accent>x</accent>", "accentSoftOf falls back to accent without the token");
76
109
  assert(accentSoftOf(null)("x") === "x", "accentSoftOf without a theme is identity");
110
+
111
+ assert(thinkingColorSlot("off") === "thinkingOff", "thinking off maps to thinkingOff");
112
+ assert(thinkingColorSlot(undefined) === "thinkingOff", "missing level falls back to thinkingOff");
113
+ assert(thinkingColorSlot("HIGH") === "thinkingHigh", "matching is case-insensitive");
114
+ assert(thinkingColorSlot("xhigh") === "thinkingXhigh", "xhigh has its own slot");
115
+ assert(thinkingColorSlot("max") === "thinkingMax", "max maps to thinkingMax");
116
+ assert(thinkingColorSlot("nonsense") === "thinkingOff", "an unknown level degrades to thinkingOff");
117
+ assert(thinkingColorOf(tag, "high")("x") === "<thinkingHigh>x</thinkingHigh>", "thinkingColorOf paints the level's slot");
118
+ assert(thinkingColorOf(tag, undefined)("x") === "<thinkingOff>x</thinkingOff>", "thinkingColorOf defaults to thinkingOff");
119
+ assert(thinkingColorOf(null, "high")("x") === "x", "thinkingColorOf without a theme is identity");
120
+ const noThinking = {
121
+ fg: (c: string, t: string) => {
122
+ if (c.startsWith("thinking")) throw new Error(`Unknown theme color: ${c}`);
123
+ return `<${c}>${t}</${c}>`;
124
+ },
125
+ };
126
+ assert(thinkingColorOf(noThinking, "max")("x") === "<dim>x</dim>", "thinkingColorOf falls back to dim without the slot");
77
127
  console.log("lib/ansi.ts OK");
78
128
  }
package/lib/box.ts CHANGED
@@ -3,19 +3,23 @@
3
3
  * and usage.ts — edit here, all follow. Lives outside `extensions/*.ts` so pi
4
4
  * never loads it as an extension.
5
5
  */
6
- import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
6
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
7
+ import { thinkingColorOf } from "./ansi.ts";
7
8
  import { assert, isMain } from "./check.ts";
8
9
 
9
- export type BoxTheme = { fg(color: string, text: string): string; bg?(color: string, text: string): string };
10
+ export type BoxTheme = { fg(color: string, text: string): string; bg?(color: string, text: string): string; bold?(text: string): string };
10
11
  type Dim = (s: string) => string;
11
12
 
12
13
  export const boxEdge = (l: string, r: string, width: number, dim: Dim): string =>
13
14
  dim(`${l}${"─".repeat(Math.max(0, width - 2))}${r}`);
14
15
 
15
- /** Content row: │ <text> │, exactly `width` wide. */
16
+ /** Content row: │ <text> │, exactly `width` wide. Content is truncated to preserve border. */
16
17
  export const boxRow = (content: string, width: number, dim: Dim): string => {
17
- const pad = Math.max(0, width - visibleWidth(content) - 4);
18
- return `${dim("│")} ${content}${" ".repeat(pad)} ${dim("│")}`;
18
+ // Minimum 4 columns required for borders and padding (skill arnative-pi §6 rule 5).
19
+ if (width < 4) return "";
20
+ const fitted = truncateToWidth(content, width - 4, "");
21
+ const pad = Math.max(0, width - visibleWidth(fitted) - 4);
22
+ return `${dim("│")} ${fitted}${" ".repeat(pad)} ${dim("│")}`;
19
23
  };
20
24
 
21
25
  const applyBg = (line: string, width: number, bgFn: (text: string) => string): string => {
@@ -23,6 +27,50 @@ const applyBg = (line: string, width: number, bgFn: (text: string) => string): s
23
27
  return bgFn(line + " ".repeat(pad));
24
28
  };
25
29
 
30
+ /** What every arnative screen says about itself. `title` follows `<setting> - <extension>`
31
+ * (see {@link screenTitle}); `description` is the one-liner under it. */
32
+ export interface ScreenText {
33
+ title: string;
34
+ description: string;
35
+ /** Keybind legend, drawn as the last row above the bottom edge. */
36
+ legend: string;
37
+ }
38
+
39
+ /** `Arnative⠙` — the braille mark that brands every arnative screen. */
40
+ export const BRAND = "\u2819";
41
+
42
+ /** The one title shape for an arnative screen: `<setting> - <extension>⠙`. */
43
+ export function screenTitle(setting: string, extension = `Arnative${BRAND}`): string {
44
+ return `${setting} - ${extension}`;
45
+ }
46
+
47
+ /** The chrome every arnative screen shares: frame, title in `accent`+bold, description in
48
+ * `muted`, blank, the screen's `body`, blank, legend in `dim`. One function so the screens
49
+ * cannot drift apart; `body` rows arrive already painted and truncated. */
50
+ export function renderScreen(
51
+ theme: BoxTheme | null,
52
+ width: number,
53
+ text: ScreenText,
54
+ body: string[],
55
+ /** Border painter. Defaults to the thinking slot (skill arnative-pi §6 rule 1: the frame
56
+ * follows the session's thinking level); pass the real level so the box tracks the model. */
57
+ border: Dim = thinkingColorOf(theme, undefined),
58
+ ): string[] {
59
+ const fg = (color: string, s: string) => (theme ? theme.fg(color, s) : s);
60
+ const bold = theme?.bold?.bind(theme) ?? ((s: string) => s);
61
+ const row = (content: string) => boxRow(content, width, border);
62
+ return [
63
+ boxEdge("╭", "╮", width, border),
64
+ row(fg("accent", bold(text.title))),
65
+ row(fg("muted", text.description)),
66
+ row(""),
67
+ ...body.map(row),
68
+ row(""),
69
+ row(fg("dim", text.legend)),
70
+ boxEdge("╰", "╯", width, border),
71
+ ].map((l) => truncateToWidth(l, width, ""));
72
+ }
73
+
26
74
  /** Wrap `rows` (plain or ANSI-colored) into a box. `bgType` = theme bg name (no-op when the theme has no `bg`); `borderColor` = border color.
27
75
  * `theme` may be null: a box is patched in at module load, before `session_start`
28
76
  * delivers the active theme. An uncolored box for that first frame beats a null
@@ -53,8 +101,45 @@ if (isMain(import.meta.url)) {
53
101
  assert(plain[1] === "│ hello │", "content line padded flush");
54
102
  assert(renderBoxLines(th, 24, ["x".repeat(40)]).length === 4, "long line wrapped");
55
103
  assert(renderBoxLines(th, 20, ["hi"], "customMessageBg").length === 3, "bg ignored when the theme has no bg");
104
+ // A row wider than the box must lose its own tail, never its right wall: `boxRow` used to
105
+ // pad only, so the wall overflowed `width` and the caller's `truncateToWidth` cut the wall.
106
+ const overlong = boxRow("x".repeat(60), 20, (s) => s);
107
+ assert(visibleWidth(overlong) === 20, `an overlong row is still exactly the width, got ${visibleWidth(overlong)}`);
108
+ assert(overlong.endsWith("│"), `an overlong row keeps its right wall, got ${JSON.stringify(overlong)}`);
109
+ assert(!overlong.includes("x".repeat(17)), "an overlong row truncates its own content");
56
110
  const nullTheme = renderBoxLines(null, 24, ["hello"], "customMessageBg");
57
111
  assert(nullTheme.every((l) => visibleWidth(l) === 24), "null theme renders an uncolored box instead of throwing");
58
112
  assert(nullTheme[0]!.startsWith("╭") && nullTheme[1] === "│ hello │", "null-theme frame and padding intact");
113
+ // Rule 5: too narrow for `│ │`, a row is empty rather than 4 columns wide — pi may hand a
114
+ // component the width of a sidebar, and a 4-wide row would overflow it.
115
+ for (const w of [1, 2, 3]) assert(boxRow("hello", w, (s) => s) === "", `a row narrower than four columns is empty at width ${w}`);
116
+
117
+ // The shared screen chrome: one sequence for every arnative screen.
118
+ assert(screenTitle("Settings") === `Settings - Arnative${BRAND}`, "screenTitle pins the <setting> - <extension> shape");
119
+ assert(screenTitle("Usage") === "Usage - Arnative\u2819", "the brand is the braille mark, not a Nerd Font glyph");
120
+ const screen = renderScreen(th, 60, { title: "Usage - Arnative\u2819", description: "Token & cost", legend: "[Esc] Close" }, ["a row", ""]);
121
+ assert(screen[0]!.startsWith("╭") && screen[screen.length - 1]!.startsWith("╰"), "screen frame intact");
122
+ assert(screen.every((l) => visibleWidth(l) === 60), "every screen line is exactly the width");
123
+ assert(screen[1]!.includes("Usage - Arnative\u2819"), "the title sits on the first row inside the frame");
124
+ assert(screen[2]!.includes("Token & cost"), "the description sits under the title");
125
+ assert(screen[screen.length - 2]!.includes("[Esc] Close"), "the legend is the last row above the bottom edge");
126
+ // No full-width rule inside the frame (skill rule 7): the chrome is the whole chrome.
127
+ assert(!screen.some((l) => /[├┤]/.test(l)), "the shared chrome draws no inner rule");
128
+ const taggedBox = { fg: (c: string, t: string) => `<${c}>${t}</>`, bold: (t: string) => `<bold>${t}</bold>` };
129
+ const taggedScreen = renderScreen(taggedBox, 60, { title: "T", description: "d", legend: "l" }, []);
130
+ assert(taggedScreen[1]!.includes("<accent><bold>T"), "the title is accent + bold");
131
+ assert(taggedScreen[2]!.includes("<muted>d"), "the description is muted");
132
+ assert(taggedScreen[taggedScreen.length - 2]!.includes("<dim>l"), "the legend is dim");
133
+ assert(renderScreen(null, 40, { title: "t", description: "d", legend: "l" }, ["x"]).every((l) => visibleWidth(l) === 40), "a null theme still frames the screen");
134
+ // The header copy is longer than a narrow terminal: the wall must win over the text.
135
+ const longCopy = "Selection previews live — same list as /settings → Theme.";
136
+ for (const w of [40, 50, 56, 60]) {
137
+ const narrow = renderScreen(th, w, { title: screenTitle("Themes"), description: longCopy, legend: "[Esc] Revert" }, []);
138
+ assert(narrow.every((l) => visibleWidth(l) === w), `a long description keeps every line at width ${w}`);
139
+ assert(
140
+ narrow.filter((l) => l.startsWith("│")).every((l) => l.endsWith("│")),
141
+ `a long description keeps the right wall at width ${w}`,
142
+ );
143
+ }
59
144
  console.log("lib/box.ts OK");
60
145
  }
package/lib/shortcuts.ts CHANGED
@@ -1,16 +1,6 @@
1
- // Platform-correct key ids for the package's single-letter shortcuts (t / n / r).
2
- //
3
- // Windows Terminal >= 1.24 aliases every Ctrl+Alt combo to AltGr (microsoft/terminal
4
- // PR #20052), so the legacy "ESC + control byte" pair (ctrl+alt+r = \x1b\x12) that
5
- // matchesKey() looks for is never emitted — the keystroke dies inside the terminal.
6
- // A WSL pane inherits that same input translation, so it counts as Windows-like too,
7
- // mirroring pi's own useWindowsKeybindings() (not exported from the package root).
8
- // pi falls back to alt+<letter> for this class of problem (app.clipboard.pasteImage ->
9
- // alt+v, app.model.cycleBackward -> alt+p) and every Windows terminal prefixes
10
- // alt+<letter> with ESC (libuv win/tty.c), so do the same here. macOS keeps ctrl+alt
11
- // only: plain Option there composes characters (Option+r = ®) unless "Use Option as
12
- // Meta key" is enabled, while ctrl+alt transmits in every macOS terminal.
13
- import { Key, matchesKey } from "@earendil-works/pi-tui";
1
+ // Platform-aware single-letter shortcuts. Windows/WSL uses Alt+<char> to avoid AltGr
2
+ // aliasing; macOS displays Option; other platforms use Ctrl+Alt.
3
+ import { Key, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
14
4
  import { assert, isMain } from "./check.ts";
15
5
 
16
6
  /** Windows Terminal's AltGr aliasing hits native Windows and WSL panes alike. */
@@ -46,6 +36,44 @@ export const SHORTCUT_RELOAD = build("r");
46
36
  * (same platform split as the shortcuts above). */
47
37
  export const SHORTCUT_TOGGLE_FOOTER = build("m");
48
38
 
39
+ /** The one "go back" key: `←` leaves a screen for its parent, while Esc closes or reverts
40
+ * it. Defined here so no screen can advertise a key another screen does not handle. */
41
+ export const BACK_KEY_LABEL = "←";
42
+ export function matchesBack(data: string): boolean {
43
+ return matchesKey(data, "left");
44
+ }
45
+
46
+ /** A key a screen advertises: its label plus the matcher the handler acts on. A legend
47
+ * built from these cannot drift from the handler — the same `matches` decides both. */
48
+ export interface KeySet {
49
+ label: string;
50
+ matches: (data: string) => boolean;
51
+ }
52
+
53
+ const keySet = (label: string, ...keyIds: string[]): KeySet => ({
54
+ label,
55
+ matches: (data) => keyIds.some((keyId) => matchesKey(data, keyId)),
56
+ });
57
+
58
+ /** One label for the pair — the handler moves on either arrow. */
59
+ const MOVE = keySet("↑↓", "up", "down");
60
+ const CHOOSE = keySet("Enter", "enter");
61
+ const BACK = keySet(BACK_KEY_LABEL, "left");
62
+ const CANCEL = keySet("Esc", "escape");
63
+ const QUIT = keySet("q/Esc", "q", "escape");
64
+
65
+ const legend = (...pairs: [string, string][]): string => pairs.map(([key, action]) => `[${key}] ${action}`).join(" ");
66
+
67
+ /** Root menu: move and open. No `[←]` — the menu has no screen behind it. */
68
+ export const LEGEND_MOVE = legend([MOVE.label, "Move"], [CHOOSE.label, "Open"], [CANCEL.label, "Close"]);
69
+ /** Live-preview list: moving applies, Enter keeps, `←` steps out, Esc reverts. */
70
+ export const LEGEND_LIVE = legend([MOVE.label, "Preview"], [CHOOSE.label, "Select"], [BACK.label, "Back"], [CANCEL.label, "Revert"]);
71
+ /** The read-only usage dashboard. The full legend needs the room, so a narrow box degrades. */
72
+ export function legendUsage(width: number): string {
73
+ const full = legend([MOVE.label, "Move"], ["Click", "Open/close"], [BACK.label, "Back"], [QUIT.label, "Exit"]);
74
+ return width >= visibleWidth(full) ? full : legend([QUIT.label, "Exit"]);
75
+ }
76
+
49
77
  if (isMain(import.meta.url)) {
50
78
  assert(SHORTCUT_RELOAD.keys[0] === Key.ctrlAlt("r"), "ctrl+alt+r is registered on every platform");
51
79
  assert(
@@ -72,5 +100,26 @@ if (isMain(import.meta.url)) {
72
100
  "alt+m is registered only where Ctrl+Alt is aliased away",
73
101
  );
74
102
  assert(!SHORTCUT_TOGGLE_FOOTER.matches("\x1b\x12"), "ctrl+alt+r does not match the footer toggle");
103
+ assert(matchesBack("\x1b[D"), "← (CSI) matches the back key");
104
+ assert(matchesBack("\x1bOD"), "← (SS3, application cursor mode) matches the back key");
105
+ assert(!matchesBack("\x1b[C"), "→ is not the back key");
106
+ assert(!matchesBack("\x1b"), "Esc is not the back key: Esc closes, ← steps back");
107
+ assert(!matchesBack("h"), "a bare letter is not the back key");
108
+ // The legends are built from the same keys the handlers match, so a key cannot be
109
+ // advertised on one screen and handled on none.
110
+ assert(LEGEND_MOVE === "[↑↓] Move [Enter] Open [Esc] Close", `root menu legend pinned, got ${LEGEND_MOVE}`);
111
+ assert(!LEGEND_MOVE.includes(BACK_KEY_LABEL), "the root menu does not advertise ←");
112
+ assert(
113
+ LEGEND_LIVE === `[↑↓] Preview [Enter] Select [${BACK_KEY_LABEL}] Back [Esc] Revert`,
114
+ `live legend pinned, got ${LEGEND_LIVE}`,
115
+ );
116
+ assert(MOVE.matches("\x1b[A") && MOVE.matches("\x1b[B"), "the ↑↓ key matches both arrows");
117
+ assert(CHOOSE.matches("\r"), "[Enter] matches carriage return");
118
+ assert(BACK.matches("\x1b[D"), "[←] in the legend is the same key matchesBack accepts");
119
+ assert(CANCEL.matches("\x1b"), "[Esc] matches escape");
120
+ assert(!CANCEL.matches("\x1b[D"), "[Esc] is not the back key");
121
+ assert(legendUsage(120).includes(`[${BACK_KEY_LABEL}] Back`), "a wide usage legend advertises back");
122
+ assert(legendUsage(120).includes("[Click] Open/close"), "a wide usage legend advertises the mouse");
123
+ assert(legendUsage(20) === "[q/Esc] Exit", "a narrow usage legend degrades to the one key that still fits");
75
124
  console.log("shortcuts.ts self-check OK");
76
125
  }
package/lib/spinner.ts ADDED
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Working-indicator presets for `/arnative` → **Animations** and their painters.
3
+ * Single source of truth for frames, cadence, and colours.
4
+ */
5
+ import { assert, isMain } from "./check.ts";
6
+
7
+ export const SPINNER_PRESETS = [
8
+ "Pi (system)",
9
+ "Diamond Glow",
10
+ "Diamond Pulse",
11
+ "Circle Pulse",
12
+ "Circle Wave",
13
+ "Shade Wave",
14
+ "Braille Column",
15
+ "Braille Level",
16
+ "Kanji Count",
17
+ "Suzhou Tally",
18
+ "Gemini",
19
+ "Claude",
20
+ ] as const;
21
+ export type SpinnerPreset = (typeof SPINNER_PRESETS)[number];
22
+ export const DEFAULT_SPINNER_PRESET: SpinnerPreset = "Diamond Glow";
23
+
24
+ /** Persistence key in `arnative-config.json` (see `lib/preset-store.ts`). */
25
+ export const SPINNER_PRESET_KEY = "spinnerPreset";
26
+
27
+ interface Preset {
28
+ /** Animation frames. Empty leaves pi's indicator alone. */
29
+ frames: readonly string[];
30
+ /** Frame delay in ms. `0` keeps pi's interval. */
31
+ intervalMs: number;
32
+ /** How the frame is painted. */
33
+ paint: "accent" | "gradient" | "claude";
34
+ /** Shown in the picker row after `·`. */
35
+ note: string;
36
+ }
37
+
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();
49
+ const CLAUDE_PING_PONG_FRAMES = [...CLAUDE_BASE_FRAMES, ...[...CLAUDE_BASE_FRAMES].reverse()] as const;
50
+
51
+ /**
52
+ * `Pi (system)` exists to hand the slot back: empty frames and a zero interval mean the
53
+ * extension installs nothing, so pi's own Braille spinner and colour are untouched. It is
54
+ * also the only way to get the default Braille dots once a preset is stored.
55
+ */
56
+ export const SPINNER_PRESET_DEFS: Record<SpinnerPreset, Preset> = {
57
+ "Pi (system)": {
58
+ frames: [],
59
+ intervalMs: 0,
60
+ paint: "accent",
61
+ note: "pi's own Braille spinner",
62
+ },
63
+ // The diamond closing from outlined to filled and back; 200ms × 4 is one turn per 0.8s,
64
+ // the rate pi's Braille default turns at (80ms × 10).
65
+ "Diamond Glow": {
66
+ frames: ["\u2b25", "\u25c8", "\u25c6", "\u25c7"],
67
+ intervalMs: 200,
68
+ paint: "accent",
69
+ note: "diamond closing, 0.8s",
70
+ },
71
+ // Three-diamond travelling pulse: a filled diamond moving across hollow ones.
72
+ "Diamond Pulse": {
73
+ frames: ["\u25c6\u25c7\u25c7", "\u25c7\u25c6\u25c7", "\u25c7\u25c7\u25c6"],
74
+ intervalMs: 200,
75
+ paint: "accent",
76
+ note: "travelling diamond pulse",
77
+ },
78
+ // Three-circle fill ladder: filled dots filling from left to right (● ◌ ◌ → ● ● ◌ → ● ● ●).
79
+ "Circle Pulse": {
80
+ frames: ["\u25cf \u25cc \u25cc", "\u25cf \u25cf \u25cc", "\u25cf \u25cf \u25cf"],
81
+ intervalMs: 200,
82
+ paint: "accent",
83
+ note: "filling circles, one to three",
84
+ },
85
+ // Three-circle travelling wave: a filled dot moving across hollow dots (● ◌ ◌ → ◌ ● ◌ → ◌ ◌ ●).
86
+ "Circle Wave": {
87
+ frames: ["\u25cf \u25cc \u25cc", "\u25cc \u25cf \u25cc", "\u25cc \u25cc \u25cf"],
88
+ intervalMs: 200,
89
+ paint: "accent",
90
+ note: "travelling circle wave",
91
+ },
92
+ // Three-block travelling shade wave from light to dark shading.
93
+ "Shade Wave": {
94
+ frames: ["\u2591\u2592\u2593", "\u2593\u2591\u2592", "\u2592\u2593\u2591"],
95
+ intervalMs: 200,
96
+ paint: "accent",
97
+ note: "travelling block shade wave",
98
+ },
99
+ // Braille stepping dots: rotating/tumbling dot pairs (⠖ ⠲ ⠴ ⠦).
100
+ "Braille Column": {
101
+ frames: ["\u2816", "\u2832", "\u2834", "\u2826"],
102
+ intervalMs: 160,
103
+ paint: "accent",
104
+ note: "stepping braille dot pattern",
105
+ },
106
+ // Braille fill levels: rising horizontal dot lines from bottom to top (⣀ ⣤ ⣶ ⣿).
107
+ "Braille Level": {
108
+ frames: ["\u28c0", "\u28e4", "\u28f6", "\u28ff", "\u28f6", "\u28e4"],
109
+ intervalMs: 140,
110
+ paint: "accent",
111
+ note: "rising braille dot level fill",
112
+ },
113
+ // Kanji stroke count: one, two, three lines (一 二 三).
114
+ "Kanji Count": {
115
+ frames: ["\u4e00", "\u4e8c", "\u4e09"],
116
+ intervalMs: 200,
117
+ paint: "accent",
118
+ note: "kanji numerals, one to three",
119
+ },
120
+ // Suzhou numerals (tally marks): vertical bars one to three (〡 〢 〣).
121
+ "Suzhou Tally": {
122
+ frames: ["\u3021", "\u3022", "\u3023"],
123
+ intervalMs: 200,
124
+ paint: "accent",
125
+ note: "vertical tally bars, one to three",
126
+ },
127
+ // Gemini CLI's spinner is the `dots` entry from the `cli-spinners` package, the same
128
+ // Braille set pi ships; what makes it Gemini is the Google brand gradient sweeping the
129
+ // glyph on a 4s cycle (GeminiSpinner.tsx paints `CliSpinner` through `tinygradient`).
130
+ Gemini: {
131
+ frames: ["\u280b", "\u2819", "\u2839", "\u2838", "\u283c", "\u2834", "\u2826", "\u2827", "\u2807", "\u280f"],
132
+ intervalMs: 80,
133
+ paint: "gradient",
134
+ note: "pi's Braille + Google gradient",
135
+ },
136
+ // Claude Code's mark animation: ping-pong loop of dingbats swelling and contracting,
137
+ // matching upstream SpinnerAnimationRow (120ms per frame).
138
+ Claude: {
139
+ frames: CLAUDE_PING_PONG_FRAMES,
140
+ intervalMs: 120,
141
+ paint: "claude",
142
+ note: "Claude Code's pulsing marks, 120ms",
143
+ },
144
+ };
145
+
146
+ /** Google brand gradient midpoints (sRGB), seamless 4s loop. */
147
+ const GOOGLE_BRAND: ReadonlyArray<readonly [number, number, number]> = [
148
+ [168, 130, 234], // purple
149
+ [66, 133, 244], // blue
150
+ [52, 168, 172], // cyan
151
+ [52, 168, 83], // green
152
+ [251, 188, 4], // yellow
153
+ [234, 67, 53], // red
154
+ [168, 130, 234], // back to purple
155
+ ];
156
+
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];
159
+
160
+ const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
161
+
162
+ /** The brand gradient at `t` in [0, 1), as sRGB. Wraps, so any t is valid. */
163
+ export function googleGradientRgb(t: number): [number, number, number] {
164
+ const wrapped = t - Math.floor(t);
165
+ const spans = GOOGLE_BRAND.length - 1;
166
+ const scaled = wrapped * spans;
167
+ const i = Math.min(Math.floor(scaled), spans - 1);
168
+ const f = scaled - i;
169
+ const a = GOOGLE_BRAND[i]!;
170
+ const b = GOOGLE_BRAND[i + 1]!;
171
+ return [Math.round(lerp(a[0], b[0], f)), Math.round(lerp(a[1], b[1], f)), Math.round(lerp(a[2], b[2], f))];
172
+ }
173
+
174
+ /** Truecolor SGR for one glyph. pi's own `fg` cannot take a raw RGB, so the escape is ours. */
175
+ const fgRgb = ([r, g, b]: readonly [number, number, number]): string => `\x1b[38;2;${r};${g};${b}m`;
176
+
177
+ /** Painter for a preset's spinner; gradient and claude use fixed branding, accent uses theme. */
178
+ export function spinnerPainter(
179
+ preset: SpinnerPreset,
180
+ theme: { fg?(color: string, text: string): string } | null,
181
+ ): (text: string) => string {
182
+ 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`;
188
+ }
189
+ const fg = theme?.fg?.bind(theme);
190
+ if (!fg) return (text) => text;
191
+ return (text) => fg("accent", text);
192
+ }
193
+
194
+ /** Frames for the picker preview row, e.g. `⬥ ◈ ◆ ◇`. `Pi (system)` reports its own set. */
195
+ export function previewFrames(preset: SpinnerPreset): string {
196
+ const def = SPINNER_PRESET_DEFS[preset];
197
+ const frames = def.frames.length > 0 ? def.frames : SPINNER_PRESET_DEFS.Gemini.frames;
198
+ return frames.join(" ");
199
+ }
200
+
201
+
202
+ // Self-check: `node lib/spinner.ts`
203
+ if (isMain(import.meta.url)) {
204
+ for (const p of SPINNER_PRESETS) {
205
+ const def = SPINNER_PRESET_DEFS[p];
206
+ assert(def.note.length > 0, `${p} carries a picker note`);
207
+ for (const frame of def.frames) {
208
+ assert(!/\ufffd|\ue000-\uf8ff/.test(frame), `${p}: frame ${frame} is not a blank private-use cell`);
209
+ }
210
+ if (def.frames.length > 0) {
211
+ assert(def.intervalMs > 0, `${p} with frames must set an interval`);
212
+ assert(new Set(def.frames).size > 1, `${p} must animate, not repeat one frame`);
213
+ }
214
+ }
215
+
216
+ // `Pi (system)` is the escape hatch: it must install nothing at all.
217
+ assert(SPINNER_PRESET_DEFS["Pi (system)"].frames.length === 0, "Pi (system) leaves pi's frames alone");
218
+ assert(SPINNER_PRESET_DEFS["Pi (system)"].intervalMs === 0, "Pi (system) leaves pi's interval alone");
219
+
220
+ // Gemini's Braille is the one sanctioned exception: Braille Patterns is a block every
221
+ // terminal font covers (it is pi's own default set, so a terminal that shows pi at all has
222
+ // it), and the ten cells share one face because U+2800-U+28FF is a single contiguous run.
223
+ const braille = /^[\u2800-\u28ff]+$/;
224
+ assert(SPINNER_PRESET_DEFS.Gemini.frames.every((f) => braille.test(f)), "Gemini frames are Braille cells");
225
+ assert(SPINNER_PRESET_DEFS.Gemini.frames.length === 10, "Gemini uses the ten-frame dots spinner");
226
+ assert(
227
+ SPINNER_PRESET_DEFS.Gemini.intervalMs === 80,
228
+ "Gemini keeps the cli-spinners cadence (80ms)",
229
+ );
230
+
231
+ // Diamond Glow uses the Geometric Shapes block; `⬥ ◈ ◆ ◇` all live in one contiguous run
232
+ // and are hosted by the terminal face on the platforms this package targets.
233
+ for (const f of SPINNER_PRESET_DEFS["Diamond Glow"].frames) {
234
+ assert(/^[\u25a0-\u25ff\u2b00-\u2bff]$/.test(f), `Diamond Glow frame ${f} is a geometric shape`);
235
+ }
236
+
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`);
244
+
245
+ // The gradient closes: t=1 must equal t=0, or the 4s sweep would jump at the seam.
246
+ assert(
247
+ googleGradientRgb(0).join() === googleGradientRgb(1).join(),
248
+ "the Google gradient wraps without a seam",
249
+ );
250
+ assert(googleGradientRgb(0.9999).join() !== googleGradientRgb(0.5).join(), "the gradient actually moves");
251
+ assert(googleGradientRgb(-0.25).join() === googleGradientRgb(0.75).join(), "a negative t wraps like a positive one");
252
+ for (const rgb of [googleGradientRgb(0), googleGradientRgb(0.37), googleGradientRgb(0.91)]) {
253
+ assert(rgb.every((c) => Number.isInteger(c) && c >= 0 && c <= 255), `gradient stays in sRGB: ${rgb.join()}`);
254
+ }
255
+
256
+ // The accent painter must go through the theme, not a hardcoded colour.
257
+ assert(
258
+ spinnerPainter("Diamond Glow", { fg: (c, t) => `<${c}>${t}` })("x") === "<accent>x",
259
+ "accent presets paint through the theme token",
260
+ );
261
+ assert(spinnerPainter("Diamond Glow", null)("x") === "x", "no theme -> identity painter, never a crash");
262
+ const grad = spinnerPainter("Gemini", null)("x");
263
+ 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");
265
+
266
+ assert(previewFrames("Diamond Glow").split(" ").length === 4, "the picker preview shows every Diamond frame");
267
+ assert(previewFrames("Pi (system)").length > 0, "Pi (system) still previews pi's Braille set");
268
+
269
+ console.log("spinner.ts self-check OK");
270
+ }