@underactive/pi-topping 0.3.1 → 0.5.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/README.md CHANGED
@@ -2,60 +2,102 @@
2
2
 
3
3
  We garnish our pies. It seemed rude not to extend Pi the same courtesy. This is a [Pi](https://github.com/earendil-works/pi-coding-agent) extension that adds some quality of life improvements to Pi's UX, while looking good in the process:
4
4
 
5
- **User Prompt** — high-vis bordered prompt box with configurable border color, pi icon toggle, and timestamp.
5
+ **User Prompt** — high-vis bordered prompt box with configurable border color and style, pi icon toggle, timestamp, and provider/model label.
6
6
 
7
- ![Example of pi-topping's decorated user prompt](https://raw.githubusercontent.com/underactive/pi-topping/main/demo_user_prompt.png)
7
+ ![Example of pi-topping's decorated user prompt](https://raw.githubusercontent.com/underactive/pi-topping/main/media/demo_user_prompt.png)
8
8
 
9
- **Working Loader Text** — animated spinner (with color choice), randomized activity word, shimmer (with direction), token activity meter (with color, direction, and dim toggle), elapsed timer, and output token display.
9
+ **“Working” Loader** — animated spinner (with color choice), randomized activity word, shimmer (with direction, speed, and invert option), token activity monitor (with color, direction, and dim toggle), elapsed timer, output token display, and live output-token rate (with color choice and dim toggle) — all arrangeable left to right.
10
10
 
11
- ![Demo of pi-topping's shimmering activity word, scrolling activity meter, elapsed timer, and token count](https://raw.githubusercontent.com/underactive/pi-topping/main/demo.gif)
11
+ ![Demo of pi-topping's shimmering activity word, scrolling activity meter, elapsed timer, token count, and token rate](https://raw.githubusercontent.com/underactive/pi-topping/main/media/demo.gif)
12
12
 
13
- **Completion Marker** — end-of-turn marker with icon, randomized verb, and token consumption display. Hooks into Pi's `agent_settled` event.
13
+ **Completion Marker** — end-of-turn marker with icon, randomized verb, token consumption display, and optional border-style/color decorations. Border style defaults to `none`; `heavy` renders e.g. `┗━━ π Mustered for 4s (↓ 25 tokens) ━━━━━━ ━━━━ ━━ ━`. If you steered or followed up (`Alt+Enter`) while Pi was working, the marker also tallies those, e.g. `π Whisked for 2s (↓ 55 tokens · 2 mid-turn inputs)`. Hooks into Pi's `agent_settled` event.
14
14
 
15
- ![Example of pi-topping's completion marker](https://raw.githubusercontent.com/underactive/pi-topping/main/demo_completion_marker.png)
15
+ ![Example of pi-topping's completion marker](https://raw.githubusercontent.com/underactive/pi-topping/main/media/demo_completion_marker.png)
16
16
 
17
17
  ## Settings
18
18
 
19
- Run `/topping-settings` (TUI only) to customize your toppings:
19
+ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist to
20
+ `~/.pi/agent/pi-topping/settings.json`; hand edits are tolerated on load.
20
21
 
21
22
  ```
22
- ╔═┥ Pi Topping: Settings ┝═══════════════════════════════════╗
23
+ ╔═[ Pi Topping: Settings ]═══════════════════════════════════╗
23
24
  ╟─ Preview ──────────────────────────────────────────────────╢
24
25
  ║ ║
25
- ║ Crafting (0m 03s · ↓ 84 tokens) ║
26
+ ║ ⠋ Crafting… ⣠⣤⣶⣶⣤⣠⣀⢀ 28 tok/s · 3s · ↓ 84 tokens ║
26
27
  ║ ║
27
28
  ╟─ User Prompt ──────────────────────────────────────────────╢
28
29
  ║ [■] High-vis prompt ON ║
29
- ║ [■] Border color ‹ accent › ║
30
+ ║ [■] Border style ‹ double › ║
31
+ ║ [■] Border color ‹ borderAccent › ║
30
32
  ║ [■] Pi icon ON ║
31
33
  ║ [■] Timestamp ON ║
34
+ ║ [■] Provider ON ║
35
+ ║ [■] Model ON ║
32
36
  ║ ║
33
- ╟─ Working Loader Text ──────────────────────────────────────╢
37
+ ╟─ “Working” Loader ─────────────────────────────────────────╢
34
38
  ║ [■] Animated spinner ON ║
35
- ║ ▸ [■] Animated spinner color ‹ accent › ║
36
- ║ [■] Randomize "Working" text ON ║
39
+ ║ ❯ [■] Animated spinner color ‹ accent › ║
40
+ ║ [■] Randomize “Working” text ON ║
37
41
  ║ [■] Text shimmer ON ║
42
+ ║ [■] Invert shimmer OFF ║
38
43
  ║ [■] Text shimmer direction ‹ Left to Right › ║
44
+ ║ [■] Text shimmer speed ‹ Normal › ║
39
45
  ║ [■] Token activity monitor ON ║
40
46
  ║ [■] Token activity monitor color ‹ accent › ║
41
- ║ [■] Token activity monitor direction ‹ Left to Right › ║
47
+ ║ [■] Token activity monitor direction ‹ Right to Left › ║
42
48
  ║ [■] Token activity monitor dimmed OFF ║
43
49
  ║ [■] Elapsed time since prompt ON ║
44
50
  ║ [■] Show output tokens ON ║
51
+ ║ [■] Token rate ON ║
52
+ ║ [■] Token rate color ‹ warning › ║
53
+ ║ [■] Token rate dimmed OFF ║
54
+ ║ ║
55
+ ╟─ Elements Order ───────────────────────────────────────────╢
56
+ ║ [ ] Animated spinner ║
57
+ ║ [ ] “Working” text ║
58
+ ║ [ ] Token activity monitor ║
59
+ ║ [ ] Token rate ║
60
+ ║ [ ] Elapsed time ║
61
+ ║ [ ] Output tokens ║
45
62
  ║ ║
46
63
  ╟─ Completion Marker ────────────────────────────────────────╢
47
64
  ║ [■] Show completion marker ON ║
65
+ ║ [■] Border style ‹ none › ║
66
+ ║ [■] Border color ‹ borderAccent › ║
48
67
  ║ [■] Pi icon ON ║
49
- ║ [■] Randomize "Worked" text ON ║
68
+ ║ [■] Randomize “Worked” text ON ║
50
69
  ║ [■] Tokens spent ON ║
70
+ ║ [■] Mid-turn inputs ON ║
51
71
  ║ ║
52
72
  ╟─ Options ──────────────────────────────────────────────────╢
53
73
  ║ [■] Use NerdFont icons ON ║
54
74
  ╟────────────────────────────────────────────────────────────╢
55
75
  ║ ↑↓ move ←→ select ␣ toggle ⏎ apply esc cancel ║
56
- ╚═════════════════════════════════════════════════════┥ 1/9 ┝╝
76
+ ╚════════════════════════════════════════════════════[ 9/37 ]╝
57
77
  ```
58
78
 
79
+ ❯ marks the keyboard cursor, and the selected row is highlighted. The Provider and Model toggles independently control the lower-right label on decorated prompts.
80
+
81
+ The User Prompt Border style cycles through `double`, `single`, `rounded`, and `heavy` box-drawing sets; the Border color row sits directly beneath it. The Completion Marker Border style offers those same choices plus `none`; `none` leaves the marker undecorated, and its Border color row sits directly beneath it.
82
+
83
+ A cycle row's `[■]` box gates it: `␣` unchecks the box, snaps the setting back to its default, and stops `←`/`→` from cycling. The Completion Marker Border style and Border color rows, and Token rate color, have no gate — their box stays `[■]` and `␣` does nothing.
84
+
85
+ The User Prompt and Completion Marker Border color settings, along with the Animated spinner, Token activity monitor, and Token rate colors, cycle through `accent`, `border`, `borderAccent`, `success`, `error`, and `warning`.
86
+
87
+ Invert shimmer keeps the working text at the default text color and sweeps a dimmed gradient across it.
88
+
89
+ Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
90
+ left or right within the loader. Elapsed time, output tokens, and token rate are joined with
91
+ `·` separators whenever adjacent. When reordering separates them, each adjacent run is rendered
92
+ without a separator before or after it.
93
+
94
+ The `N tok/s` token rate uses the selected theme color and
95
+ remains reorderable. Active rates below 1,000 are padded to three characters so updates do not shift the other
96
+ segments; larger rates may shift later segments. When inactive, it remains as the dim `--- tok/s` placeholder. After its last update it holds
97
+ full brightness for 1.5 seconds, then fades through five theme-aware shades to the dim text color over
98
+ the next 0.25 seconds before returning to the placeholder; a new count restores full brightness and
99
+ restarts the cycle.
100
+
59
101
  ## Install
60
102
 
61
103
  ```bash
@@ -77,7 +119,7 @@ npm test
77
119
  npm run typecheck
78
120
  ```
79
121
 
80
- Tests cover the word/shimmer rendering, activity meter behavior, timer resets, settings persistence, the `menu.ts` component, and the settings menu wiring end to end.
122
+ Tests cover the word/shimmer rendering, prompt box and completion marker rendering, activity meter behavior, timer resets, settings persistence, the `menu.ts` component, and the settings menu wiring end to end.
81
123
 
82
124
  ## Limitations
83
125
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping",
3
- "version": "0.3.1",
4
- "description": "A Pi extension that decorates user prompts and replaces the default \"Working...\" loader with a shimmering, randomized activity word, live elapsed timer, and output-token activity meter",
3
+ "version": "0.5.0",
4
+ "description": "A Pi extension that adds quality of life improvements to Pi's UX: a high-vis user prompt, a shimmering working loader with token activity meter and elapsed timer, and an end-of-turn completion marker",
5
5
  "keywords": ["pi-package", "pi-extension"],
6
6
  "license": "MIT",
7
7
  "author": "Eric Sison",
@@ -30,13 +30,13 @@
30
30
  "@earendil-works/pi-tui": "*"
31
31
  },
32
32
  "devDependencies": {
33
- "@earendil-works/pi-coding-agent": "0.80.6",
34
- "@earendil-works/pi-tui": "0.80.6",
33
+ "@earendil-works/pi-coding-agent": "0.82.1",
34
+ "@earendil-works/pi-tui": "0.82.1",
35
35
  "@types/node": "^24.0.0",
36
36
  "typescript": "^5.9.3"
37
37
  },
38
38
  "pi": {
39
39
  "extensions": ["./index.ts"],
40
- "video": "https://cdn.jsdelivr.net/gh/underactive/pi-topping@main/demo.mp4"
40
+ "image": "https://raw.githubusercontent.com/underactive/pi-topping/main/media/working.png"
41
41
  }
42
42
  }
@@ -1,4 +1,5 @@
1
1
  import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import { dimAttribute } from "./format.ts";
2
3
 
3
4
  export const ActivityMeterLevel = {
4
5
  IDLE: 0,
@@ -25,15 +26,15 @@ const BRAILLE: Record<ActivityMeterLevel, string> = {
25
26
  [ActivityMeterLevel.FULL]: "⣿",
26
27
  };
27
28
  const WIDTH = 8;
29
+ const RATE_THRESHOLDS = [0, 5, 10, 15, 22, 30, 40] as const;
28
30
  type CellColorizer = (level: ActivityMeterLevel, char: string) => string;
29
31
 
30
32
  /** Convert an estimated output-token rate to a display level. */
31
33
  export function rateToLevel(tokensPerSecond: number): ActivityMeterLevel {
32
- const THRESHOLDS = [0, 5, 10, 15, 22, 30, 40];
33
- for (let i = THRESHOLDS.length - 1; i >= 0; i--) {
34
- if (tokensPerSecond > THRESHOLDS[i]) return (i + 1) as ActivityMeterLevel;
34
+ for (let i = RATE_THRESHOLDS.length - 1; i >= 0; i--) {
35
+ if (tokensPerSecond > RATE_THRESHOLDS[i]) return (i + 1) as ActivityMeterLevel;
35
36
  }
36
- return 0;
37
+ return ActivityMeterLevel.IDLE;
37
38
  }
38
39
 
39
40
  /** EMA-smoothed rate tracker for a cumulative output-token estimate. */
@@ -44,6 +45,11 @@ export class TokRateTracker {
44
45
  #hasSample = false;
45
46
  #pendingTokens = 0;
46
47
 
48
+ /** Latest EMA-smoothed output-token rate. */
49
+ get tokenRate(): number {
50
+ return this.#rate;
51
+ }
52
+
47
53
  sample(totalTokens: number, now: number): number {
48
54
  if (!this.#hasSample) {
49
55
  this.#lastTotal = totalTokens;
@@ -127,9 +133,6 @@ export class ActivityMeter {
127
133
  ): string {
128
134
  if (level === ActivityMeterLevel.IDLE) return theme.fg("dim", char);
129
135
  const colored = theme.fg(color, char);
130
- // ANSI dim (SGR 2) reduces color brightness; SGR 22 resets dim/bold.
131
- // This is necessary because theme.fg("dim", ...) sets a gray color instead
132
- // of applying the ANSI dim attribute, so nesting would overwrite the accent color.
133
- return dimmed ? `\x1b[2m${colored}\x1b[22m` : colored;
136
+ return dimmed ? dimAttribute(colored) : colored;
134
137
  }
135
138
  }
package/src/format.ts CHANGED
@@ -1,19 +1,44 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
1
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  /** Pi's default working-indicator frames (same braille spinner as pi-tui's Loader). */
4
4
  export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
5
5
 
6
+ /** Milliseconds per spinner frame, matching pi-tui's Loader cadence. */
7
+ export const SPINNER_FRAME_MS = 80;
8
+
9
+ /** Default "Working…" text shown when text randomization/substitution is off. */
10
+ export const DEFAULT_WORKING_WORD = "Working…";
11
+
12
+ /** Milliseconds between activity meter sample pushes. */
13
+ export const METER_INTERVAL_MS = 100;
14
+
15
+ /** Milliseconds between shimmer re-renders. */
16
+ export const SHIMMER_INTERVAL_MS = 50;
17
+
18
+ /** Milliseconds between elapsed-time display updates. */
19
+ export const ELAPSED_INTERVAL_MS = 1_000;
20
+
21
+ /** A user-orderable piece of the working indicator. */
22
+ export type LoaderElement = "spinner" | "text" | "meter" | "elapsed" | "tokens" | "tokenRate";
23
+
24
+ /** Default first-use working-indicator element order. */
25
+ export const DEFAULT_LOADER_ORDER: readonly LoaderElement[] = ["spinner", "text", "meter", "tokenRate", "elapsed", "tokens"];
26
+
27
+ /** Elements share a detail group; separators and non-rate details are dimmed. */
28
+ const DETAIL_ELEMENTS: ReadonlySet<LoaderElement> = new Set(["elapsed", "tokens", "tokenRate"]);
29
+
30
+ const TOKEN_UNITS = [
31
+ { threshold: 10_000, divisor: 1_000, decimals: 1, suffix: "k" },
32
+ { threshold: 999_500, divisor: 1_000, decimals: 0, suffix: "k" },
33
+ { threshold: 10_000_000, divisor: 1_000_000, decimals: 1, suffix: "M" },
34
+ { threshold: 1_000_000_000, divisor: 1_000_000, decimals: 0, suffix: "M" },
35
+ { threshold: 1e15, divisor: 1_000_000_000, decimals: 1, suffix: "B" },
36
+ ] as const;
37
+
6
38
  /** Format an output-token count for the working indicator. */
7
39
  export function formatTokens(count: number): string {
8
40
  if (count < 1000) return count.toString();
9
- const UNITS = [
10
- { threshold: 10_000, divisor: 1_000, decimals: 1, suffix: "k" },
11
- { threshold: 999_500, divisor: 1_000, decimals: 0, suffix: "k" },
12
- { threshold: 10_000_000, divisor: 1_000_000, decimals: 1, suffix: "M" },
13
- { threshold: 1_000_000_000, divisor: 1_000_000, decimals: 0, suffix: "M" },
14
- { threshold: 1e15, divisor: 1_000_000_000, decimals: 1, suffix: "B" },
15
- ];
16
- const unit = UNITS.find(u => count < u.threshold);
41
+ const unit = TOKEN_UNITS.find(u => count < u.threshold);
17
42
  if (unit) {
18
43
  const value = unit.decimals > 0
19
44
  ? (count / unit.divisor).toFixed(unit.decimals)
@@ -23,6 +48,50 @@ export function formatTokens(count: number): string {
23
48
  return `${(count / 1_000_000_000).toFixed(1)}B`;
24
49
  }
25
50
 
51
+ /** Column width reserved for the numeric portion of the token-rate readout. */
52
+ const TOKEN_RATE_DIGITS = 3;
53
+
54
+ /** Placeholder shown when the working indicator has no active token-rate sample. */
55
+ export const TOKEN_RATE_PLACEHOLDER = `${"-".repeat(TOKEN_RATE_DIGITS)} tok/s`;
56
+
57
+ /** Format an output-token throughput estimate for the working indicator. */
58
+ export function formatTokenRate(rate: number): string {
59
+ const rounded = Math.round(rate);
60
+ return rounded === 0 ? "" : `${rounded.toString().padStart(TOKEN_RATE_DIGITS)} tok/s`;
61
+ }
62
+
63
+ /**
64
+ * Wrap text in ANSI dim (SGR 2 / reset 22).
65
+ *
66
+ * theme.fg("dim", ...) sets a gray color instead of the ANSI dim attribute, so it
67
+ * would overwrite an already-applied accent color rather than darkening it; the raw
68
+ * SGR codes are needed to dim on top of an existing color.
69
+ */
70
+ export function dimAttribute(text: string): string {
71
+ return `\x1b[2m${text}\x1b[22m`;
72
+ }
73
+
74
+ /** Number of discrete, eased warning-to-dim shades used for the token-rate fade. */
75
+ export const TOKEN_RATE_FADE_SHADE_COUNT = 5;
76
+
77
+ /** Render one token-rate fade shade by blending the selected color toward the active theme's dim color. */
78
+ export function fadeThemeColorString(
79
+ text: string,
80
+ shade: number,
81
+ theme: Pick<Theme, "getFgAnsi" | "fg">,
82
+ color: ThemeColor,
83
+ ): string {
84
+ if (!text) return "";
85
+ const source = ansiToRgb(theme.getFgAnsi(color));
86
+ const dim = ansiToRgb(theme.getFgAnsi("dim"));
87
+ if (!source || !dim) return theme.fg(color, text);
88
+ const clampedShade = Math.max(0, Math.min(TOKEN_RATE_FADE_SHADE_COUNT - 1, Math.floor(shade)));
89
+ const progress = (clampedShade + 1) / TOKEN_RATE_FADE_SHADE_COUNT;
90
+ const eased = 0.5 * (1 - Math.cos(Math.PI * progress));
91
+ const blended = source.map((channel, index) => Math.round(channel * (1 - eased) + dim[index]! * eased));
92
+ return `\x1b[38;2;${blended[0]};${blended[1]};${blended[2]}m${text}\x1b[0m`;
93
+ }
94
+
26
95
  /** Format elapsed milliseconds as a compact human-readable duration, skipping leading zero units. */
27
96
  export function formatElapsed(ms: number): string {
28
97
  let totalSeconds = Math.max(0, Math.floor(ms / 1000));
@@ -43,67 +112,110 @@ export function formatElapsed(ms: number): string {
43
112
  return parts.join(" ");
44
113
  }
45
114
 
46
- /** Codex-style light-sweep shimmer using the active Pi theme. */
115
+ /** True when every appearance toggle is off and Pi's stock loader can be restored. */
47
116
  export function isFullyDefaultAppearance(features: {
48
117
  substituteDefaultMessage: boolean;
49
118
  elapsedTime: boolean;
50
119
  outputTokens: boolean;
120
+ tokenRate: boolean;
51
121
  }, decorations: { shimmer: boolean; tokenActivityMonitor: boolean }): boolean {
52
- return !features.substituteDefaultMessage && !decorations.shimmer && !decorations.tokenActivityMonitor && !features.elapsedTime && !features.outputTokens;
122
+ return !features.substituteDefaultMessage && !decorations.shimmer && !decorations.tokenActivityMonitor && !features.elapsedTime && !features.outputTokens && !features.tokenRate;
53
123
  }
54
124
 
55
- /** Assemble the styled word, meter, and optional detail fields. */
125
+ /**
126
+ * Assemble the working indicator from already-styled pieces, laid out in `order`.
127
+ *
128
+ * Empty/omitted pieces are dropped, and any run of detail elements left adjacent
129
+ * after that renders as one visual group with dim separators.
130
+ */
56
131
  export function buildWorkingMessage(
57
132
  theme: Pick<Theme, "fg">,
58
- word: string,
59
- frame: string,
60
- elapsed: string,
61
- tokens: string,
62
- features: { elapsedTime: boolean; outputTokens: boolean },
133
+ parts: Partial<Record<LoaderElement, string>>,
134
+ order: readonly LoaderElement[] = DEFAULT_LOADER_ORDER,
63
135
  ): string {
64
- const parts = [word, frame].filter(Boolean);
65
- const details = [features.elapsedTime ? elapsed : "", features.outputTokens ? `↓ ${tokens} tokens` : ""].filter(Boolean);
66
- if (details.length) parts.push(theme.fg("dim", `(${details.join(" · ")})`));
67
- return parts.join(" ");
136
+ const segments: string[] = [];
137
+ let details: string[] = [];
138
+ const flushDetails = (): void => {
139
+ if (details.length) segments.push(details.join(theme.fg("dim", " · ")));
140
+ details = [];
141
+ };
142
+
143
+ for (const element of order) {
144
+ const value = parts[element];
145
+ if (!value) continue;
146
+ if (DETAIL_ELEMENTS.has(element)) {
147
+ // tokenRate arrives pre-colored; dimming it here would replace its configured color.
148
+ details.push(element === "tokenRate" ? value : theme.fg("dim", value));
149
+ continue;
150
+ }
151
+ flushDetails();
152
+ segments.push(value);
153
+ }
154
+ flushDetails();
155
+
156
+ return segments.join(" ");
68
157
  }
69
158
 
159
+ const SHIMMER_SWEEP_S = 2.0;
160
+ const SHIMMER_BAND_HALF = 5.0;
161
+ const SHIMMER_PADDING = 10;
162
+
163
+ /**
164
+ * Codex-style light-sweep shimmer using the active Pi theme.
165
+ *
166
+ * With `invert=true`, the text color is used at rest and the sweep moves toward
167
+ * the theme's dim color.
168
+ */
70
169
  export function shimmerString(
71
170
  text: string,
72
171
  elapsedMs: number,
73
- theme: Pick<Theme, "getFgAnsi">,
172
+ theme: Pick<Theme, "getFgAnsi" | "fg">,
74
173
  direction: "ltr" | "rtl" = "ltr",
174
+ speed: "slow" | "normal" | "fast" = "normal",
175
+ invert = false,
75
176
  ): string {
76
177
  const chars = [...text];
77
178
  if (chars.length === 0) return "";
78
- const SHIMMER_SWEEP_S = 2.0;
79
- const SHIMMER_BAND_HALF = 5.0;
80
- const SHIMMER_PADDING = 10;
81
- const SHIMMER_BASE = ansiToRgb(theme.getFgAnsi("dim"));
82
- const SHIMMER_HIGHLIGHT = ansiToRgb(theme.getFgAnsi("text"));
179
+ const shimmerBase = ansiToRgb(theme.getFgAnsi(invert ? "text" : "dim"));
180
+ const shimmerHighlight = ansiToRgb(theme.getFgAnsi(invert ? "dim" : "text"));
181
+ if (!shimmerBase || !shimmerHighlight) return theme.fg("text", text);
83
182
  const period = chars.length + SHIMMER_PADDING * 2;
84
- const elapsedS = elapsedMs / 1000;
85
- const sweep = ((elapsedS % SHIMMER_SWEEP_S) / SHIMMER_SWEEP_S) * period;
86
- const pos = direction === "rtl" ? period - sweep : sweep;
87
-
88
- return chars
89
- .map((ch, i) => {
90
- const dist = Math.abs((i + SHIMMER_PADDING) - pos);
91
- const t = dist <= SHIMMER_BAND_HALF
92
- ? 0.5 * (1 + Math.cos(Math.PI * dist / SHIMMER_BAND_HALF))
93
- : 0;
94
- const alpha = t * 0.9;
95
- const r = Math.round(SHIMMER_HIGHLIGHT[0] * alpha + SHIMMER_BASE[0] * (1 - alpha));
96
- const g = Math.round(SHIMMER_HIGHLIGHT[1] * alpha + SHIMMER_BASE[1] * (1 - alpha));
97
- const b = Math.round(SHIMMER_HIGHLIGHT[2] * alpha + SHIMMER_BASE[2] * (1 - alpha));
98
- const bold = t > 0.2 ? "\x1b[1m" : "";
99
- return `${bold}\x1b[38;2;${r};${g};${b}m${ch}\x1b[22m`;
100
- })
101
- .join("") + "\x1b[0m";
183
+ const unitsPerS = period / SHIMMER_SWEEP_S;
184
+ // The band crosses padding at either end while every character is still dim. Scaling only
185
+ // the stretch where it actually overlaps the text keeps that dark pause identical at every
186
+ // speed, so `speed` changes the sweep alone rather than the whole cycle.
187
+ const litEnter = SHIMMER_PADDING - SHIMMER_BAND_HALF;
188
+ const litExit = SHIMMER_PADDING + chars.length - 1 + SHIMMER_BAND_HALF;
189
+ const enterS = litEnter / unitsPerS;
190
+ const litS = (litExit - litEnter) / unitsPerS / (speed === "slow" ? 0.5 : speed === "fast" ? 2 : 1);
191
+ const phase = (elapsedMs / 1000) % (enterS + litS + (period - litExit) / unitsPerS);
192
+ const linear = phase < enterS
193
+ ? phase * unitsPerS
194
+ : phase < enterS + litS
195
+ ? litEnter + ((phase - enterS) / litS) * (litExit - litEnter)
196
+ : litExit + (phase - enterS - litS) * unitsPerS;
197
+ const pos = direction === "rtl" ? period - linear : linear;
198
+
199
+ let out = "";
200
+ for (let i = 0; i < chars.length; i++) {
201
+ const ch = chars[i];
202
+ const dist = Math.abs((i + SHIMMER_PADDING) - pos);
203
+ const t = dist <= SHIMMER_BAND_HALF
204
+ ? 0.5 * (1 + Math.cos(Math.PI * dist / SHIMMER_BAND_HALF))
205
+ : 0;
206
+ const alpha = t * 0.9;
207
+ const r = Math.round(shimmerHighlight[0] * alpha + shimmerBase[0] * (1 - alpha));
208
+ const g = Math.round(shimmerHighlight[1] * alpha + shimmerBase[1] * (1 - alpha));
209
+ const b = Math.round(shimmerHighlight[2] * alpha + shimmerBase[2] * (1 - alpha));
210
+ const bold = !invert && t > 0.2 ? "\x1b[1m" : "";
211
+ out += `${bold}\x1b[38;2;${r};${g};${b}m${ch}\x1b[22m`;
212
+ }
213
+ return out + "\x1b[0m";
102
214
  }
103
215
 
104
- function ansiToRgb(ansi: string): [number, number, number] {
216
+ function ansiToRgb(ansi: string): [number, number, number] | null {
105
217
  const match = ansi.match(/^\x1b\[38;2;(\d+);(\d+);(\d+)m$/);
106
- if (!match) throw new Error(`Shimmer requires truecolor theme colors, received ${JSON.stringify(ansi)}`);
218
+ if (!match) return null;
107
219
  return [Number(match[1]), Number(match[2]), Number(match[3])];
108
220
  }
109
221