@underactive/pi-topping 0.4.0 → 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,70 @@
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 and style, 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
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** — animated spinner (with color choice), randomized activity word, shimmer (with direction and speed), token activity meter (with color, direction, and dim toggle), elapsed timer, and output token display — all arrangeable left to right.
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/media/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. 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.
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
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
30
  ║ [■] Border style ‹ double › ║
31
+ ║ [■] Border color ‹ borderAccent › ║
31
32
  ║ [■] Pi icon ON ║
32
33
  ║ [■] Timestamp ON ║
34
+ ║ [■] Provider ON ║
35
+ ║ [■] Model ON ║
33
36
  ║ ║
34
37
  ╟─ “Working” Loader ─────────────────────────────────────────╢
35
38
  ║ [■] Animated spinner ON ║
36
- ║ ▸ [■] Animated spinner color ‹ accent › ║
37
- ║ [■] Randomize "Working" text ON ║
39
+ ║ ❯ [■] Animated spinner color ‹ accent › ║
40
+ ║ [■] Randomize “Working” text ON ║
38
41
  ║ [■] Text shimmer ON ║
42
+ ║ [■] Invert shimmer OFF ║
39
43
  ║ [■] Text shimmer direction ‹ Left to Right › ║
40
44
  ║ [■] Text shimmer speed ‹ Normal › ║
41
45
  ║ [■] Token activity monitor ON ║
42
46
  ║ [■] Token activity monitor color ‹ accent › ║
43
- ║ [■] Token activity monitor direction ‹ Left to Right › ║
47
+ ║ [■] Token activity monitor direction ‹ Right to Left › ║
44
48
  ║ [■] Token activity monitor dimmed OFF ║
45
49
  ║ [■] Elapsed time since prompt ON ║
46
50
  ║ [■] Show output tokens ON ║
51
+ ║ [■] Token rate ON ║
52
+ ║ [■] Token rate color ‹ warning › ║
53
+ ║ [■] Token rate dimmed OFF ║
47
54
  ║ ║
48
55
  ╟─ Elements Order ───────────────────────────────────────────╢
49
56
  ║ [ ] Animated spinner ║
50
- ║ [■] “Working” text ↑ ↓ ║
57
+ ║ [ ] “Working” text ║
51
58
  ║ [ ] Token activity monitor ║
59
+ ║ [ ] Token rate ║
52
60
  ║ [ ] Elapsed time ║
53
61
  ║ [ ] Output tokens ║
54
62
  ║ ║
55
63
  ╟─ Completion Marker ────────────────────────────────────────╢
56
64
  ║ [■] Show completion marker ON ║
65
+ ║ [■] Border style ‹ none › ║
66
+ ║ [■] Border color ‹ borderAccent › ║
57
67
  ║ [■] Pi icon ON ║
58
- ║ [■] Randomize "Worked" text ON ║
68
+ ║ [■] Randomize “Worked” text ON ║
59
69
  ║ [■] Tokens spent ON ║
60
70
  ║ [■] Mid-turn inputs ON ║
61
71
  ║ ║
@@ -63,13 +73,30 @@ Run `/topping-settings` (TUI only) to customize your toppings:
63
73
  ║ [■] Use NerdFont icons ON ║
64
74
  ╟────────────────────────────────────────────────────────────╢
65
75
  ║ ↑↓ move ←→ select ␣ toggle ⏎ apply esc cancel ║
66
- ╚════════════════════════════════════════════════════┥ 1/28 ┝╝
76
+ ╚════════════════════════════════════════════════════[ 9/37 ]╝
67
77
  ```
68
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
+
69
89
  Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
70
- left or right within the loader. Elapsed time and output tokens share a single
71
- `(3s · ↓ 84 tokens)` parenthetical whenever they end up next to each other, and split
72
- into `(3s)` and `(↓ 84 tokens)` when they do not.
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.
73
100
 
74
101
  ## Install
75
102
 
@@ -92,7 +119,7 @@ npm test
92
119
  npm run typecheck
93
120
  ```
94
121
 
95
- 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.
96
123
 
97
124
  ## Limitations
98
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
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",
@@ -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,4 +1,4 @@
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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -6,26 +6,39 @@ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦",
6
6
  /** Milliseconds per spinner frame, matching pi-tui's Loader cadence. */
7
7
  export const SPINNER_FRAME_MS = 80;
8
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
+
9
21
  /** A user-orderable piece of the working indicator. */
10
- export type LoaderElement = "spinner" | "text" | "meter" | "elapsed" | "tokens";
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"];
11
26
 
12
- /** Left-to-right element order matching pi's stock working indicator. */
13
- export const DEFAULT_LOADER_ORDER: readonly LoaderElement[] = ["spinner", "text", "meter", "elapsed", "tokens"];
27
+ /** Elements share a detail group; separators and non-rate details are dimmed. */
28
+ const DETAIL_ELEMENTS: ReadonlySet<LoaderElement> = new Set(["elapsed", "tokens", "tokenRate"]);
14
29
 
15
- /** Elements rendered inside the dim parenthetical rather than as standalone segments. */
16
- const DETAIL_ELEMENTS: ReadonlySet<LoaderElement> = new Set(["elapsed", "tokens"]);
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;
17
37
 
18
38
  /** Format an output-token count for the working indicator. */
19
39
  export function formatTokens(count: number): string {
20
40
  if (count < 1000) return count.toString();
21
- const UNITS = [
22
- { threshold: 10_000, divisor: 1_000, decimals: 1, suffix: "k" },
23
- { threshold: 999_500, divisor: 1_000, decimals: 0, suffix: "k" },
24
- { threshold: 10_000_000, divisor: 1_000_000, decimals: 1, suffix: "M" },
25
- { threshold: 1_000_000_000, divisor: 1_000_000, decimals: 0, suffix: "M" },
26
- { threshold: 1e15, divisor: 1_000_000_000, decimals: 1, suffix: "B" },
27
- ];
28
- const unit = UNITS.find(u => count < u.threshold);
41
+ const unit = TOKEN_UNITS.find(u => count < u.threshold);
29
42
  if (unit) {
30
43
  const value = unit.decimals > 0
31
44
  ? (count / unit.divisor).toFixed(unit.decimals)
@@ -35,6 +48,50 @@ export function formatTokens(count: number): string {
35
48
  return `${(count / 1_000_000_000).toFixed(1)}B`;
36
49
  }
37
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
+
38
95
  /** Format elapsed milliseconds as a compact human-readable duration, skipping leading zero units. */
39
96
  export function formatElapsed(ms: number): string {
40
97
  let totalSeconds = Math.max(0, Math.floor(ms / 1000));
@@ -55,20 +112,21 @@ export function formatElapsed(ms: number): string {
55
112
  return parts.join(" ");
56
113
  }
57
114
 
58
- /** 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. */
59
116
  export function isFullyDefaultAppearance(features: {
60
117
  substituteDefaultMessage: boolean;
61
118
  elapsedTime: boolean;
62
119
  outputTokens: boolean;
120
+ tokenRate: boolean;
63
121
  }, decorations: { shimmer: boolean; tokenActivityMonitor: boolean }): boolean {
64
- 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;
65
123
  }
66
124
 
67
125
  /**
68
126
  * Assemble the working indicator from already-styled pieces, laid out in `order`.
69
127
  *
70
128
  * Empty/omitted pieces are dropped, and any run of detail elements left adjacent
71
- * after that collapses into a single dim parenthetical.
129
+ * after that renders as one visual group with dim separators.
72
130
  */
73
131
  export function buildWorkingMessage(
74
132
  theme: Pick<Theme, "fg">,
@@ -78,7 +136,7 @@ export function buildWorkingMessage(
78
136
  const segments: string[] = [];
79
137
  let details: string[] = [];
80
138
  const flushDetails = (): void => {
81
- if (details.length) segments.push(theme.fg("dim", `(${details.join(" · ")})`));
139
+ if (details.length) segments.push(details.join(theme.fg("dim", " · ")));
82
140
  details = [];
83
141
  };
84
142
 
@@ -86,7 +144,8 @@ export function buildWorkingMessage(
86
144
  const value = parts[element];
87
145
  if (!value) continue;
88
146
  if (DETAIL_ELEMENTS.has(element)) {
89
- details.push(value);
147
+ // tokenRate arrives pre-colored; dimming it here would replace its configured color.
148
+ details.push(element === "tokenRate" ? value : theme.fg("dim", value));
90
149
  continue;
91
150
  }
92
151
  flushDetails();
@@ -97,20 +156,29 @@ export function buildWorkingMessage(
97
156
  return segments.join(" ");
98
157
  }
99
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
+ */
100
169
  export function shimmerString(
101
170
  text: string,
102
171
  elapsedMs: number,
103
- theme: Pick<Theme, "getFgAnsi">,
172
+ theme: Pick<Theme, "getFgAnsi" | "fg">,
104
173
  direction: "ltr" | "rtl" = "ltr",
105
174
  speed: "slow" | "normal" | "fast" = "normal",
175
+ invert = false,
106
176
  ): string {
107
177
  const chars = [...text];
108
178
  if (chars.length === 0) return "";
109
- const SHIMMER_SWEEP_S = 2.0;
110
- const SHIMMER_BAND_HALF = 5.0;
111
- const SHIMMER_PADDING = 10;
112
- const SHIMMER_BASE = ansiToRgb(theme.getFgAnsi("dim"));
113
- 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);
114
182
  const period = chars.length + SHIMMER_PADDING * 2;
115
183
  const unitsPerS = period / SHIMMER_SWEEP_S;
116
184
  // The band crosses padding at either end while every character is still dim. Scaling only
@@ -128,25 +196,26 @@ export function shimmerString(
128
196
  : litExit + (phase - enterS - litS) * unitsPerS;
129
197
  const pos = direction === "rtl" ? period - linear : linear;
130
198
 
131
- return chars
132
- .map((ch, i) => {
133
- const dist = Math.abs((i + SHIMMER_PADDING) - pos);
134
- const t = dist <= SHIMMER_BAND_HALF
135
- ? 0.5 * (1 + Math.cos(Math.PI * dist / SHIMMER_BAND_HALF))
136
- : 0;
137
- const alpha = t * 0.9;
138
- const r = Math.round(SHIMMER_HIGHLIGHT[0] * alpha + SHIMMER_BASE[0] * (1 - alpha));
139
- const g = Math.round(SHIMMER_HIGHLIGHT[1] * alpha + SHIMMER_BASE[1] * (1 - alpha));
140
- const b = Math.round(SHIMMER_HIGHLIGHT[2] * alpha + SHIMMER_BASE[2] * (1 - alpha));
141
- const bold = t > 0.2 ? "\x1b[1m" : "";
142
- return `${bold}\x1b[38;2;${r};${g};${b}m${ch}\x1b[22m`;
143
- })
144
- .join("") + "\x1b[0m";
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";
145
214
  }
146
215
 
147
- function ansiToRgb(ansi: string): [number, number, number] {
216
+ function ansiToRgb(ansi: string): [number, number, number] | null {
148
217
  const match = ansi.match(/^\x1b\[38;2;(\d+);(\d+);(\d+)m$/);
149
- if (!match) throw new Error(`Shimmer requires truecolor theme colors, received ${JSON.stringify(ansi)}`);
218
+ if (!match) return null;
150
219
  return [Number(match[1]), Number(match[2]), Number(match[3])];
151
220
  }
152
221
 
package/src/menu.ts CHANGED
@@ -10,12 +10,12 @@
10
10
  * preview section driven by a caller-supplied render callback, e.g.:
11
11
  *
12
12
  * ╔═[ Pi Topping: Settings ]════════════════════════╗
13
- * ╟─ Preview ─────────────────────────────────────╢
13
+ * ╟─ Preview ────────────────────────────────────╢
14
14
  * ║ ║
15
- * ║ ⠋ Cerebrating… ⣤⣤⣤⣤ (0m 03s · ↓ 84 tokens) ║
15
+ * ║ ⠋ Cerebrating… ⣤⣤⣤⣤ 28 tok/s · 3s · ↓ 84 tokens ║
16
16
  * ║ ║
17
17
  * ╟─ Decorations ───────────────────────────────────╢
18
- * ║ ▸ [■] Animated spinner ON ║
18
+ * ║ ❯ [■] Animated spinner ON ║
19
19
  * ║ [ ] "Working..." text shimmer OFF ║
20
20
  * ║ ║
21
21
  * ╟──────────────────────────────────────────────────╢
@@ -90,6 +90,7 @@ export interface MenuResult<T> {
90
90
 
91
91
  const DEFAULT_HINTS = ["\u2191\u2193 move", "\u2423 toggle", "\u23ce apply", "esc cancel"];
92
92
  const MAX_WIDTH = 76;
93
+ const ROW_PREFIX_WIDTH = 8; // " " + marker + " " + "[" + box + "]" + " "
93
94
 
94
95
  interface FlatItem {
95
96
  id: string;
@@ -442,7 +443,7 @@ export class MenuComponent implements Component {
442
443
  const prefix = titlePrefix ?? "";
443
444
  const suffix = titleSuffix ?? "";
444
445
  const maxTitleLen = Math.max(0, innerWidth - prefix.length - suffix.length);
445
- const shownTitle = title.length > maxTitleLen ? truncateToWidth(title, maxTitleLen) : title;
446
+ const shownTitle = visibleWidth(title) > maxTitleLen ? truncateToWidth(title, maxTitleLen) : title;
446
447
  const styledTitle = boldTitle ? th.bold(shownTitle) : shownTitle;
447
448
  const fillCount = Math.max(0, innerWidth - visibleWidth(prefix + shownTitle + suffix));
448
449
  const titleContent = th.fg("text", styledTitle);
@@ -493,45 +494,42 @@ export class MenuComponent implements Component {
493
494
  private renderItemRow(item: MenuItem, selected: boolean, innerWidth: number): string {
494
495
  const th = this.theme;
495
496
  const value = this.values[item.id]!;
496
- const marker = selected ? "\u25b8" : " ";
497
+ const marker = selected ? "\u276f" : " ";
497
498
  const markerColored = selected ? th.fg("accent", marker) : marker;
498
499
 
499
500
  if (item.reorderGroup) {
500
501
  const held = value as boolean;
501
502
  const stateWord = held ? "\u2191 \u2193" : "";
502
503
  const rightPlain = `${stateWord} `;
503
- const fixedLeftLen = 8;
504
- const maxLabelLen = Math.max(0, innerWidth - fixedLeftLen - rightPlain.length - 1);
505
- const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
504
+ const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(rightPlain) - 1);
505
+ const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
506
506
  const leftPlain = ` ${marker} [${held ? "\u25a0" : " "}] ${label}`;
507
507
  const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
508
508
  const content = ` ${markerColored} [${held ? th.fg("accent", "\u25a0") : th.fg("muted", " ")}] ${th.fg("text", label)}${" ".repeat(gap)}${held ? th.fg("accent", stateWord) : ""} `;
509
- return this.wrap("\u2551", content, "\u2551");
509
+ return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551");
510
510
  }
511
511
 
512
512
  if (item.cycleValues) {
513
513
  const enabled = item.cycleEnabledBy ? this.values[item.cycleEnabledBy] as boolean : true;
514
514
  const stateWord = `‹ ${value} ›`;
515
- const fixedLeftLen = 8;
516
- const maxLabelLen = Math.max(0, innerWidth - fixedLeftLen - visibleWidth(stateWord) - 1);
517
- const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
515
+ const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(stateWord) - 1);
516
+ const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
518
517
  const leftPlain = ` ${marker} [${enabled ? "■" : " "}] ${label}`;
519
518
  const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(stateWord) - 2);
520
519
  const content = ` ${markerColored} [${enabled ? th.fg("success", "■") : th.fg("muted", " ")}] ${th.fg("text", label)}${" ".repeat(gap)}${enabled ? th.fg("accent", stateWord) : th.fg("muted", stateWord)} `;
521
- return this.wrap("\u2551", content, "\u2551");
520
+ return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551");
522
521
  }
523
522
 
524
523
  const enabled = value as boolean;
525
524
  const box = enabled ? "\u25a0" : " ";
526
525
  const stateWord = enabled ? "ON" : "OFF";
527
526
  const rightPlain = `${stateWord} `;
528
- const fixedLeftLen = 8; // " " + marker(1) + " " + "[" + box(1) + "]" + " "
529
- const maxLabelLen = Math.max(0, innerWidth - fixedLeftLen - rightPlain.length - 1);
530
- const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
527
+ const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(rightPlain) - 1);
528
+ const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
531
529
  const leftPlain = ` ${marker} [${box}] ${label}`;
532
530
  const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
533
531
  const content = ` ${markerColored} [${enabled ? th.fg("success", box) : th.fg("muted", box)}] ${th.fg("text", label)}${" ".repeat(gap)}${enabled ? th.fg("success", stateWord) : th.fg("muted", stateWord)} `;
534
- return this.wrap("\u2551", content, "\u2551");
532
+ return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551");
535
533
  }
536
534
  }
537
535