@underactive/pi-topping-statusline 0.1.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.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Live tok/s monitor for the token_rate segment.
3
+ *
4
+ * Upstream oh-my-pi computes the badge from the last assistant message's
5
+ * usage.output / duration, but pi only reports usage at message end, so a
6
+ * live rate needs an estimator. The pipeline (StreamingWordCounter + EMA
7
+ * tracker + hold/fade lifecycle) is copied from pi-topping (same author,
8
+ * ../pi-topping/src/{format,activity-meter,session}.ts):
9
+ *
10
+ * - text/thinking deltas are counted as approximate tokens (word boundaries);
11
+ * - an EMA (alpha 0.4) smooths 100ms samples into a rate;
12
+ * - message_end reconciles the estimate against exact usage.output;
13
+ * - the last rate holds for HOLD_MS, fades over FADE_MS, then goes idle.
14
+ *
15
+ * The segment itself never disappears: idle renders as `--- tok/s`.
16
+ */
17
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import { FADE_SHADE_COUNT } from "./theme.js";
19
+ import type { TokenRateDisplay } from "./types.js";
20
+
21
+ const SAMPLE_INTERVAL_MS = 100;
22
+ /** pi-topping TOKEN_RATE_HOLD_MS — retained value stays at full color. */
23
+ const HOLD_MS = 1_500;
24
+ /** pi-topping fades in 250ms; here the fade is stretched to 0.5s. */
25
+ const FADE_MS = 500;
26
+ const EMA_ALPHA = 0.4;
27
+
28
+ /** Incremental whitespace-boundary word counter (pi-topping StreamingWordCounter). */
29
+ class StreamingWordCounter {
30
+ #inWordByStream = new Map<string, boolean>();
31
+
32
+ count(text: string, stream = "default"): number {
33
+ let inWord = this.#inWordByStream.get(stream) ?? false;
34
+ let count = 0;
35
+ for (let i = 0; i < text.length; i++) {
36
+ const code = text.charCodeAt(i);
37
+ if (isWhitespace(code)) {
38
+ inWord = false;
39
+ } else if (!inWord) {
40
+ count++;
41
+ inWord = true;
42
+ }
43
+ }
44
+ this.#inWordByStream.set(stream, inWord);
45
+ return count;
46
+ }
47
+
48
+ reset(): void {
49
+ this.#inWordByStream.clear();
50
+ }
51
+ }
52
+
53
+ function isWhitespace(code: number): boolean {
54
+ return (
55
+ code === 32 ||
56
+ (code >= 9 && code <= 13) ||
57
+ code === 160 ||
58
+ code === 0x1680 ||
59
+ (code >= 0x2000 && code <= 0x200a) ||
60
+ code === 0x2028 ||
61
+ code === 0x2029 ||
62
+ code === 0x202f ||
63
+ code === 0x205f ||
64
+ code === 0x3000 ||
65
+ code === 0xfeff
66
+ );
67
+ }
68
+
69
+ export class TokenRateMonitor {
70
+ #requestRender: () => void;
71
+ #enabled = false;
72
+ #busy = false;
73
+ #timer: ReturnType<typeof setInterval> | undefined;
74
+
75
+ // Token accounting: exact usage from message_end + live estimate since.
76
+ #confirmTokens = 0;
77
+ #liveTokens = 0;
78
+ #counter = new StreamingWordCounter();
79
+
80
+ // EMA tracker state (pi-topping TokRateTracker).
81
+ #emaRate = 0;
82
+ #emaLastTotal = 0;
83
+ #emaLastTime = 0;
84
+ #emaHasSample = false;
85
+ #emaPendingTokens = 0;
86
+
87
+ // Hold/fade lifecycle (pi-topping session.ts tick).
88
+ #lastSampledAt = 0;
89
+ #lastSampleTotal = 0;
90
+ #heldRate: number | null = null;
91
+ #fadeStartsAt = 0;
92
+ #lastRendered: TokenRateDisplay | undefined;
93
+
94
+ constructor(requestRender: () => void) {
95
+ this.#requestRender = requestRender;
96
+ }
97
+
98
+ attach(pi: ExtensionAPI): void {
99
+ pi.on("agent_start", () => {
100
+ if (!this.#enabled) return;
101
+ this.#busy = true;
102
+ this.#resetTurn();
103
+ this.#startTimer();
104
+ });
105
+ pi.on("agent_settled", () => {
106
+ if (!this.#enabled) return;
107
+ this.#busy = false;
108
+ });
109
+ pi.on("message_start", event => {
110
+ if (!this.#enabled || event.message.role !== "assistant") return;
111
+ this.#liveTokens = 0;
112
+ this.#counter.reset();
113
+ });
114
+ pi.on("message_update", event => {
115
+ if (!this.#enabled) return;
116
+ const assistantEvent = event.assistantMessageEvent;
117
+ if (assistantEvent.type === "text_delta" || assistantEvent.type === "thinking_delta") {
118
+ this.#liveTokens += this.#counter.count(assistantEvent.delta, assistantEvent.type);
119
+ }
120
+ });
121
+ pi.on("message_end", event => {
122
+ if (!this.#enabled || event.message.role !== "assistant") return;
123
+ const exact = event.message.usage?.output;
124
+ this.#confirmTokens += exact ?? this.#liveTokens;
125
+ if (exact !== undefined) {
126
+ // Reconciliation resets the EMA at message boundaries; the held rate
127
+ // bridges the gap until fresh samples rebuild it.
128
+ this.#resetEma();
129
+ this.#lastSampledAt = 0;
130
+ this.#lastSampleTotal = this.#confirmTokens;
131
+ }
132
+ this.#liveTokens = 0;
133
+ this.#counter.reset();
134
+ });
135
+ }
136
+
137
+ /** Events are only processed in TUI sessions; index.ts gates on mode. */
138
+ enable(): void {
139
+ this.#enabled = true;
140
+ }
141
+
142
+ disable(): void {
143
+ this.#enabled = false;
144
+ this.#busy = false;
145
+ this.#stopTimer();
146
+ this.#resetTurn();
147
+ }
148
+
149
+ /** Pure snapshot for the segment render; safe to call on any frame. */
150
+ getDisplay(now = Date.now()): TokenRateDisplay {
151
+ if (this.#heldRate !== null && now < this.#fadeStartsAt) {
152
+ return { rate: this.#heldRate, phase: "active", fadeShade: 0 };
153
+ }
154
+ if (this.#heldRate !== null && now < this.#fadeStartsAt + FADE_MS) {
155
+ const shade = Math.floor((now - this.#fadeStartsAt) / (FADE_MS / FADE_SHADE_COUNT));
156
+ return { rate: this.#heldRate, phase: "fading", fadeShade: shade };
157
+ }
158
+ return { rate: null, phase: "idle", fadeShade: 0 };
159
+ }
160
+
161
+ #resetTurn(): void {
162
+ this.#confirmTokens = 0;
163
+ this.#liveTokens = 0;
164
+ this.#counter.reset();
165
+ this.#resetEma();
166
+ this.#lastSampledAt = 0;
167
+ this.#lastSampleTotal = 0;
168
+ this.#heldRate = null;
169
+ this.#fadeStartsAt = 0;
170
+ }
171
+
172
+ #resetEma(): void {
173
+ this.#emaRate = 0;
174
+ this.#emaLastTotal = 0;
175
+ this.#emaLastTime = 0;
176
+ this.#emaHasSample = false;
177
+ this.#emaPendingTokens = 0;
178
+ }
179
+
180
+ #sampleEma(total: number, now: number): void {
181
+ if (!this.#emaHasSample) {
182
+ this.#emaLastTotal = total;
183
+ this.#emaHasSample = true;
184
+ this.#emaLastTime = now;
185
+ return;
186
+ }
187
+ const elapsedSeconds = (now - this.#emaLastTime) / 1_000;
188
+ if (elapsedSeconds <= 0) {
189
+ this.#emaPendingTokens += Math.max(0, total - this.#emaLastTotal);
190
+ this.#emaLastTotal = total;
191
+ return;
192
+ }
193
+ const totalDelta = this.#emaPendingTokens + Math.max(0, total - this.#emaLastTotal);
194
+ const instantRate = totalDelta / elapsedSeconds;
195
+ this.#emaRate = EMA_ALPHA * instantRate + (1 - EMA_ALPHA) * this.#emaRate;
196
+ this.#emaLastTotal = total;
197
+ this.#emaLastTime = now;
198
+ this.#emaPendingTokens = 0;
199
+ }
200
+
201
+ #tick(): void {
202
+ const now = Date.now();
203
+ if (this.#busy && now - this.#lastSampledAt >= SAMPLE_INTERVAL_MS) {
204
+ const total = this.#confirmTokens + this.#liveTokens;
205
+ // Do not reset the fade for an EMA-only decay while output is quiet.
206
+ const hasNewTokens = total > this.#lastSampleTotal;
207
+ this.#lastSampleTotal = total;
208
+ this.#sampleEma(total, now);
209
+ this.#lastSampledAt = now;
210
+ const rounded = Math.round(this.#emaRate);
211
+ if (rounded > 0 && hasNewTokens) {
212
+ this.#heldRate = rounded;
213
+ this.#fadeStartsAt = now + HOLD_MS;
214
+ }
215
+ }
216
+ if (!this.#busy && now >= this.#fadeStartsAt + FADE_MS) {
217
+ this.#stopTimer();
218
+ }
219
+ const display = this.getDisplay(now);
220
+ if (
221
+ !this.#lastRendered ||
222
+ display.rate !== this.#lastRendered.rate ||
223
+ display.phase !== this.#lastRendered.phase ||
224
+ display.fadeShade !== this.#lastRendered.fadeShade
225
+ ) {
226
+ this.#lastRendered = display;
227
+ this.#requestRender();
228
+ }
229
+ }
230
+
231
+ #startTimer(): void {
232
+ this.#timer ??= setInterval(() => this.#tick(), SAMPLE_INTERVAL_MS);
233
+ this.#timer.unref?.();
234
+ }
235
+
236
+ #stopTimer(): void {
237
+ if (this.#timer) {
238
+ clearInterval(this.#timer);
239
+ this.#timer = undefined;
240
+ }
241
+ }
242
+ }
package/src/types.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Ported from oh-my-pi status-line/types.ts, trimmed to the fields a pi
3
+ * extension can populate. SegmentContext is a plain snapshot; segments are
4
+ * pure functions over it.
5
+ */
6
+ import type { BorderStyle, SymbolPreset } from "./theme.js";
7
+
8
+ export type StatusLineSeparatorStyle = "powerline" | "powerline-thin" | "slash" | "pipe" | "ascii";
9
+
10
+ export type StatusLineSegmentId =
11
+ | "pi"
12
+ | "model"
13
+ | "path"
14
+ | "git"
15
+ | "pr"
16
+ | "session_name"
17
+ | "pi_stats"
18
+ | "token_rate"
19
+ | "feeds"
20
+ | "context_graph"
21
+ | "scroll_hint";
22
+
23
+ /**
24
+ * A feed the statusline subscribes to: custom session entries published by
25
+ * another extension under `customType`, as pi-prompt-cache does with
26
+ * "pi-prompt-cache/savings". `field` names the property of the entry's data
27
+ * object to display, and `prefix` is the literal label drawn before it.
28
+ */
29
+ export interface StatusLineFeed {
30
+ customType: string;
31
+ field: string;
32
+ prefix: string;
33
+ format: FeedFormat;
34
+ }
35
+
36
+ /**
37
+ * currency keeps the rules pi-prompt-cache's author specified for the savings
38
+ * figure: a signed half-cent floor so this segment and that extension's own
39
+ * footer never disagree, two decimals below $100 and whole dollars above.
40
+ */
41
+ export type FeedFormat = "currency" | "number" | "text";
42
+
43
+ export interface StatusLineSegmentToggles {
44
+ pi?: boolean;
45
+ model?: boolean;
46
+ provider?: boolean;
47
+ thinking?: boolean;
48
+ path?: boolean;
49
+ git?: boolean;
50
+ pr?: boolean;
51
+ sessionName?: boolean;
52
+ tokenRateTopRight?: boolean;
53
+ piStats?: boolean;
54
+ tokenRate?: boolean;
55
+ feeds?: boolean;
56
+ feedsBottomLeft?: boolean;
57
+ tokenRateBottomLeft?: boolean;
58
+ contextBar?: boolean;
59
+ contextStats?: boolean;
60
+ scrollHint?: boolean;
61
+ }
62
+
63
+ /** On-disk shape of ~/.pi/agent/pi-topping-statusline/settings.json. */
64
+ export interface StatusLineSettings {
65
+ /** Drop the bar's bg fill and round caps so it inherits the terminal bg. */
66
+ transparent?: boolean;
67
+ separator?: StatusLineSeparatorStyle;
68
+ symbols?: SymbolPreset;
69
+ borderStyle?: BorderStyle;
70
+ segments?: StatusLineSegmentToggles;
71
+ feeds?: StatusLineFeed[];
72
+ /** Animated rainbow border while the thinking level is max. */
73
+ rainbowBorder?: boolean;
74
+ }
75
+
76
+ /** Resolved per-segment render options; resolveEffectiveSettings always fills them. */
77
+ export interface StatusLineSegmentOptions {
78
+ model: { showModel: boolean; showProvider: boolean; showThinking: boolean };
79
+ path: { abbreviate: boolean; maxLength: number; stripWorkPrefix: boolean };
80
+ context: { showBar: boolean; showStats: boolean };
81
+ /** Subscriptions rendered by the feeds segment, in display order. */
82
+ feeds: StatusLineFeed[];
83
+ }
84
+
85
+ /** Which segment groups a build() snapshot needs to populate. */
86
+ export interface SegmentIncludes {
87
+ git: boolean;
88
+ pr: boolean;
89
+ piStats: boolean;
90
+ tokenRate: boolean;
91
+ feeds: readonly string[];
92
+ }
93
+
94
+ export interface EffectiveStatusLineSettings {
95
+ transparent: boolean;
96
+ separator: StatusLineSeparatorStyle;
97
+ symbols: SymbolPreset;
98
+ borderStyle: BorderStyle;
99
+ leftSegments: StatusLineSegmentId[];
100
+ rightSegments: StatusLineSegmentId[];
101
+ bottomLeftSegments: StatusLineSegmentId[];
102
+ bottomRightSegments: StatusLineSegmentId[];
103
+ segmentOptions: StatusLineSegmentOptions;
104
+ rainbowBorder: boolean;
105
+ }
106
+
107
+ // ═══════════════════════════════════════════════════════════════════════════
108
+ // Segment rendering
109
+ // ═══════════════════════════════════════════════════════════════════════════
110
+
111
+ export interface SegmentContext {
112
+ options: StatusLineSegmentOptions;
113
+ model: { name?: string; id: string; provider: string; reasoning: boolean } | undefined;
114
+ thinkingLevel: string;
115
+ cwd: string;
116
+ sessionName: string | undefined;
117
+ /** Context usage percent, or null when unknown (e.g. right after compaction). */
118
+ contextPercent: number | null;
119
+ contextTokens: number;
120
+ contextWindow: number;
121
+ git: {
122
+ branch: string | null;
123
+ status: { staged: number; unstaged: number; untracked: number } | null;
124
+ pr: { number: number; url: string } | null;
125
+ };
126
+ /** Set when cwd is a linked git worktree; path segment collapses to project name. */
127
+ worktree: { projectName: string; worktreeName: string } | null;
128
+ /** Scroll hint extracted from the editor's lower border, if present. */
129
+ scrollHint: string | undefined;
130
+ /** Pi's own footer stats, when this frame needs them. */
131
+ piStats: string | undefined;
132
+ /** Live tok/s display state, when this frame needs it. */
133
+ tokenRate: TokenRateDisplay | undefined;
134
+ /**
135
+ * Newest in-run data payload per subscribed customType. A customType is
136
+ * absent when no extension publishes it, or when it has published nothing
137
+ * since this run began — a resumed session still carries the previous run's
138
+ * entries, and those must not be shown as current.
139
+ */
140
+ feedData: Record<string, unknown> | undefined;
141
+ }
142
+
143
+ export type TokenRatePhase = "active" | "fading" | "idle";
144
+
145
+ export interface TokenRateDisplay {
146
+ /** Smoothed rate at last token arrival; null when idle (segment shows `---`). */
147
+ rate: number | null;
148
+ phase: TokenRatePhase;
149
+ /** Discrete fade step (0..FADE_SHADE_COUNT-1); only meaningful when fading. */
150
+ fadeShade: number;
151
+ }
152
+
153
+ export interface RenderedSegment {
154
+ content: string;
155
+ visible: boolean;
156
+ }
157
+
158
+ export interface StatusLineSegment {
159
+ id: StatusLineSegmentId;
160
+ render(ctx: SegmentContext): RenderedSegment;
161
+ }
162
+
163
+ export interface SeparatorDef {
164
+ left: string;
165
+ right: string;
166
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Formatting helpers and the session-accent color algorithm, ported from
3
+ * oh-my-pi (packages/utils/src/format.ts, packages/coding-agent/src/utils/
4
+ * session-color.ts, tools/render-utils.ts, modes/shared.ts). Bun.color is
5
+ * replaced with local hex math so this runs under any pi runtime.
6
+ */
7
+ import { stripVTControlCharacters } from "node:util";
8
+ import { parseHex } from "./theme.js";
9
+
10
+ export function formatNumber(n: number): string {
11
+ if (n < 1_000) return n.toString();
12
+ if (n < 10_000) return `${(n / 1_000).toFixed(1)}K`;
13
+ if (n < 1_000_000) return `${Math.round(n / 1_000)}K`;
14
+ if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
15
+ if (n < 1_000_000_000) return `${Math.round(n / 1_000_000)}M`;
16
+ if (n < 10_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
17
+ return `${Math.round(n / 1_000_000_000)}B`;
18
+ }
19
+
20
+ export function shortenPath(filePath: string, homeDir?: string): string {
21
+ const home = homeDir ?? process.env.HOME ?? "";
22
+ if (home && filePath.startsWith(home)) {
23
+ const suffix = filePath.slice(home.length);
24
+ if (suffix === "" || suffix.startsWith("/")) return `~${suffix}`;
25
+ }
26
+ return filePath;
27
+ }
28
+
29
+ /**
30
+ * Clamp a path/label to `maxLen` by eliding middle components with `…`.
31
+ * The first folder (after `~` or the root) and the basename survive longest;
32
+ * middle components drop farthest-from-cwd first. Falls back to plain
33
+ * left-truncation once even `…/basename` overflows.
34
+ */
35
+ export function clampPathLength(pwd: string, maxLen: number): string {
36
+ if (pwd.length <= maxLen) return pwd;
37
+
38
+ const parts = pwd.split("/");
39
+ const last = parts.length - 1;
40
+ const firstIdx = parts[0] === "" || parts[0] === "~" ? 1 : 0;
41
+
42
+ for (let dropped = 1; dropped <= last - firstIdx - 1; dropped++) {
43
+ const candidate = [...parts.slice(0, firstIdx + 1), "…", ...parts.slice(firstIdx + 1 + dropped)].join("/");
44
+ if (candidate.length <= maxLen) return candidate;
45
+ }
46
+
47
+ // The first folder no longer fits: keep whatever remains closest to the cwd.
48
+ for (let start = firstIdx + 1; start <= last; start++) {
49
+ const candidate = `…/${parts.slice(start).join("/")}`;
50
+ if (candidate.length <= maxLen) return candidate;
51
+ }
52
+
53
+ return `…${pwd.slice(-Math.max(0, maxLen - 1))}`;
54
+ }
55
+
56
+ /** Strip ANSI/VT escapes, map control chars to spaces, collapse whitespace. */
57
+ export function sanitizeStatusText(text: string): string {
58
+ return stripVTControlCharacters(text)
59
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
60
+ .replace(/ +/g, " ")
61
+ .trim();
62
+ }
63
+
64
+ /**
65
+ * Same safety as {@link sanitizeStatusText} but without trimming, so a label
66
+ * written as "TOK " keeps the space that separates it from its value.
67
+ */
68
+ export function sanitizeLabel(text: string): string {
69
+ return stripVTControlCharacters(text)
70
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
71
+ .replace(/ +/g, " ");
72
+ }
73
+
74
+ export function withIcon(icon: string, text: string): string {
75
+ return icon ? `${icon} ${text}` : text;
76
+ }
77
+
78
+ // ═══════════════════════════════════════════════════════════════════════════
79
+ // Session accent (djb2 hue hash, dark warm band, collision avoidance)
80
+ // ═══════════════════════════════════════════════════════════════════════════
81
+
82
+ function nameToHue(name: string): number {
83
+ let hash = 5381;
84
+ for (let i = 0; i < name.length; i++) {
85
+ hash = ((hash << 5) + hash) ^ name.charCodeAt(i);
86
+ hash = hash >>> 0;
87
+ }
88
+ return hash % 360;
89
+ }
90
+
91
+ const ACCENT_SATURATION = 0.9;
92
+ const ACCENT_DARK_LIGHTNESS = 0.72;
93
+ const MIN_HUE_DISTANCE = 10;
94
+ const MIN_SATURATION_FOR_HUE = 0.1;
95
+ const DARK_HUE_START = 0;
96
+ const DARK_HUE_END = 120;
97
+
98
+ function hueDistance(a: number, b: number): number {
99
+ const d = Math.abs(a - b);
100
+ return Math.min(d, 360 - d);
101
+ }
102
+
103
+ function hexToHue(hex: string): number | undefined {
104
+ const rgb = parseHex(hex);
105
+ if (!rgb) return undefined;
106
+ const r = rgb[0] / 255;
107
+ const g = rgb[1] / 255;
108
+ const b = rgb[2] / 255;
109
+ const max = Math.max(r, g, b);
110
+ const min = Math.min(r, g, b);
111
+ const delta = max - min;
112
+ const s = max === 0 ? 0 : delta / max;
113
+ if (s < MIN_SATURATION_FOR_HUE) return undefined;
114
+ let h: number;
115
+ if (delta === 0) h = 0;
116
+ else if (max === r) h = 60 * (((g - b) / delta) % 6);
117
+ else if (max === g) h = 60 * ((b - r) / delta + 2);
118
+ else h = 60 * ((r - g) / delta + 4);
119
+ return (h + 360) % 360;
120
+ }
121
+
122
+ function hslToHex(h: number, s: number, l: number): string {
123
+ const c = (1 - Math.abs(2 * l - 1)) * s;
124
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
125
+ const m = l - c / 2;
126
+ let r = 0;
127
+ let g = 0;
128
+ let b = 0;
129
+ if (h < 60) [r, g, b] = [c, x, 0];
130
+ else if (h < 120) [r, g, b] = [x, c, 0];
131
+ else if (h < 180) [r, g, b] = [0, c, x];
132
+ else if (h < 240) [r, g, b] = [0, x, c];
133
+ else if (h < 300) [r, g, b] = [x, 0, c];
134
+ else [r, g, b] = [c, 0, x];
135
+ const toHex = (v: number) =>
136
+ Math.round((v + m) * 255)
137
+ .toString(16)
138
+ .padStart(2, "0");
139
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
140
+ }
141
+
142
+ function findSafeHue(target: number, occupied: number[], lo: number, hi: number): number {
143
+ if (occupied.length === 0) return target;
144
+ if (occupied.every(h => hueDistance(target, h) >= MIN_HUE_DISTANCE)) return target;
145
+ for (let d = 1; d <= hi - lo; d++) {
146
+ for (const dir of [1, -1]) {
147
+ const candidate = Math.max(lo, Math.min(hi, target + d * dir));
148
+ if (occupied.every(h => hueDistance(candidate, h) >= MIN_HUE_DISTANCE)) return candidate;
149
+ }
150
+ }
151
+ return target;
152
+ }
153
+
154
+ /** Stable accent hex for a session name (OMP's dark-theme path). */
155
+ export function getSessionAccentHex(name: string, themeColorHexes: string[]): string {
156
+ const range = DARK_HUE_END - DARK_HUE_START;
157
+ let targetHue = DARK_HUE_START + (nameToHue(name) % range);
158
+ const themeHues = themeColorHexes.map(hexToHue).filter((h): h is number => h !== undefined);
159
+ targetHue = findSafeHue(targetHue, themeHues, DARK_HUE_START, DARK_HUE_END);
160
+ return hslToHex(targetHue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS);
161
+ }