pi-openai-usage 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.
package/src/color.ts ADDED
@@ -0,0 +1,391 @@
1
+ import type { ColorConfig, ColorStop, PiThemeColorToken, UsageColor } from "./config";
2
+ import type { ProgressBarSegment } from "./progress-bar";
3
+
4
+ export type UsageColorMode = "truecolor" | "256color";
5
+
6
+ export type UsageColorTheme = {
7
+ name?: string;
8
+ fg(color: PiThemeColorToken, text: string): string;
9
+ getColorMode?(): UsageColorMode;
10
+ };
11
+
12
+ export type ColorizeUsageTextOptions = {
13
+ text: string;
14
+ percent: number | null | undefined;
15
+ colors: ColorConfig;
16
+ theme?: UsageColorTheme;
17
+ isLimited?: boolean;
18
+ };
19
+
20
+ export type ColorizeProgressBarSegmentsOptions = {
21
+ segments: readonly ProgressBarSegment[];
22
+ percent: number | null | undefined;
23
+ colors: ColorConfig;
24
+ theme?: UsageColorTheme;
25
+ isLimited?: boolean;
26
+ formatNeutralSegment?: (text: string) => string;
27
+ };
28
+
29
+ const FOREGROUND_RESET = "\x1b[39m";
30
+
31
+ const PI_THEME_COLOR_TOKENS = new Set<PiThemeColorToken>([
32
+ "success",
33
+ "warning",
34
+ "error",
35
+ "muted",
36
+ "dim",
37
+ "text",
38
+ "accent",
39
+ ]);
40
+
41
+ const TRAFFIC_STOPS: ColorStop[] = [
42
+ { percent: 80, color: "success" },
43
+ { percent: 60, color: "#65a30d" },
44
+ { percent: 40, color: "warning" },
45
+ { percent: 20, color: "#c2410c" },
46
+ { percent: 0, color: "error" },
47
+ ];
48
+
49
+ const CYAN_DARK_STOPS: ColorStop[] = [
50
+ { percent: 100, color: "#67e8f9" },
51
+ { percent: 75, color: "#22d3ee" },
52
+ { percent: 50, color: "#0891b2" },
53
+ { percent: 25, color: "#155e75" },
54
+ { percent: 0, color: "#334155" },
55
+ ];
56
+
57
+ const CYAN_LIGHT_STOPS: ColorStop[] = [
58
+ { percent: 100, color: "#0e7490" },
59
+ { percent: 75, color: "#0891b2" },
60
+ { percent: 50, color: "#155e75" },
61
+ { percent: 25, color: "#164e63" },
62
+ { percent: 0, color: "#334155" },
63
+ ];
64
+
65
+ const GREEN_DARK_STOPS: ColorStop[] = [
66
+ { percent: 100, color: "#86efac" },
67
+ { percent: 75, color: "#22c55e" },
68
+ { percent: 50, color: "#65a30d" },
69
+ { percent: 25, color: "#166534" },
70
+ { percent: 0, color: "#374151" },
71
+ ];
72
+
73
+ const GREEN_LIGHT_STOPS: ColorStop[] = [
74
+ { percent: 100, color: "#15803d" },
75
+ { percent: 75, color: "#16a34a" },
76
+ { percent: 50, color: "#4d7c0f" },
77
+ { percent: 25, color: "#166534" },
78
+ { percent: 0, color: "#374151" },
79
+ ];
80
+
81
+ const MONO_DARK_STOPS: ColorStop[] = [
82
+ { percent: 100, color: "#f8fafc" },
83
+ { percent: 75, color: "#cbd5e1" },
84
+ { percent: 50, color: "#94a3b8" },
85
+ { percent: 25, color: "#64748b" },
86
+ { percent: 0, color: "#475569" },
87
+ ];
88
+
89
+ const MONO_LIGHT_STOPS: ColorStop[] = [
90
+ { percent: 100, color: "#111827" },
91
+ { percent: 75, color: "#374151" },
92
+ { percent: 50, color: "#4b5563" },
93
+ { percent: 25, color: "#6b7280" },
94
+ { percent: 0, color: "#9ca3af" },
95
+ ];
96
+
97
+ export function colorizeUsageText(options: ColorizeUsageTextOptions): string {
98
+ const color = resolveUsageColor(options);
99
+ if (color === undefined) return options.text;
100
+ return applyForegroundColor(options.theme, color, options.text);
101
+ }
102
+
103
+ export function colorizeProgressBarSegments(options: ColorizeProgressBarSegmentsOptions): string {
104
+ const uncoloredText = options.segments.map((segment) => segment.text).join("");
105
+ if (!shouldApplyBarGradient(options.colors)) return uncoloredText;
106
+
107
+ const formatNeutralSegment = options.formatNeutralSegment ?? ((text: string) => text);
108
+ if (options.theme === undefined || !isFiniteNumber(options.percent)) {
109
+ return options.segments.map((segment) => formatNeutralSegment(segment.text)).join("");
110
+ }
111
+
112
+ const cellCount = options.segments.length;
113
+ if (cellCount === 0) return "";
114
+
115
+ return options.segments
116
+ .map((segment, index) => {
117
+ if (segment.kind === "empty") return formatNeutralSegment(segment.text);
118
+
119
+ const cellPercent = gradientCellPercent(
120
+ index,
121
+ cellCount,
122
+ options.colors.barGradient.direction,
123
+ );
124
+ const color = resolveUsageColor({
125
+ percent: cellPercent,
126
+ colors: options.colors,
127
+ theme: options.theme,
128
+ isLimited: options.isLimited,
129
+ });
130
+ return color === undefined
131
+ ? formatNeutralSegment(segment.text)
132
+ : applyForegroundColor(options.theme, color, segment.text);
133
+ })
134
+ .join("");
135
+ }
136
+
137
+ export function resolveUsageColor(options: Omit<ColorizeUsageTextOptions, "text">): UsageColor | undefined {
138
+ const { colors, theme } = options;
139
+ if (theme === undefined || colors.scheme === "none" || colors.target === "none") return undefined;
140
+ if (!isFiniteNumber(options.percent)) return undefined;
141
+
142
+ const percent = options.isLimited === true ? 0 : clampNumber(options.percent, 0, 100);
143
+ switch (colors.scheme) {
144
+ case "traffic":
145
+ return selectStepColor(TRAFFIC_STOPS, percent);
146
+ case "cyan":
147
+ return interpolateColorStops(themeIsLight(theme) ? CYAN_LIGHT_STOPS : CYAN_DARK_STOPS, percent);
148
+ case "green":
149
+ return interpolateColorStops(themeIsLight(theme) ? GREEN_LIGHT_STOPS : GREEN_DARK_STOPS, percent);
150
+ case "mono":
151
+ return interpolateColorStops(themeIsLight(theme) ? MONO_LIGHT_STOPS : MONO_DARK_STOPS, percent);
152
+ case "custom":
153
+ return resolveCustomColor(colors.custom.stops, colors.custom.mode, percent);
154
+ }
155
+ }
156
+
157
+ function resolveCustomColor(
158
+ stops: readonly ColorStop[],
159
+ mode: ColorConfig["custom"]["mode"],
160
+ percent: number,
161
+ ): UsageColor | undefined {
162
+ if (mode === "gradient") return interpolateColorStops(stops, percent);
163
+ return selectStepColor(stops, percent);
164
+ }
165
+
166
+ function shouldApplyBarGradient(colors: ColorConfig): boolean {
167
+ return (
168
+ colors.scheme !== "none" &&
169
+ colors.target !== "none" &&
170
+ colors.target !== "percent" &&
171
+ colors.barGradient.enabled
172
+ );
173
+ }
174
+
175
+ function gradientCellPercent(
176
+ index: number,
177
+ cellCount: number,
178
+ direction: ColorConfig["barGradient"]["direction"],
179
+ ): number {
180
+ const lowToHighPercent = cellCount <= 1 ? 100 : (index / (cellCount - 1)) * 100;
181
+ return direction === "high-to-low" ? 100 - lowToHighPercent : lowToHighPercent;
182
+ }
183
+
184
+ function applyForegroundColor(theme: UsageColorTheme | undefined, color: UsageColor, text: string): string {
185
+ if (theme === undefined || text.length === 0) return text;
186
+
187
+ if (typeof color === "string" && isPiThemeColorToken(color)) {
188
+ return theme.fg(color, text);
189
+ }
190
+
191
+ if (typeof color === "number") {
192
+ return `${xtermForeground(color)}${text}${FOREGROUND_RESET}`;
193
+ }
194
+
195
+ if (typeof color === "string" && isHexColor(color)) {
196
+ const { r, g, b } = hexToRgb(color);
197
+ if ((theme.getColorMode?.() ?? "truecolor") === "256color") {
198
+ return `${xtermForeground(rgbToXterm256(r, g, b))}${text}${FOREGROUND_RESET}`;
199
+ }
200
+ return `\x1b[38;2;${r};${g};${b}m${text}${FOREGROUND_RESET}`;
201
+ }
202
+
203
+ return text;
204
+ }
205
+
206
+ function selectStepColor(stops: readonly ColorStop[], percent: number): UsageColor {
207
+ for (const stop of stops) {
208
+ if (percent >= stop.percent) return stop.color;
209
+ }
210
+ return stops[stops.length - 1]?.color ?? "text";
211
+ }
212
+
213
+ function interpolateColorStops(stops: readonly ColorStop[], percent: number): UsageColor | undefined {
214
+ if (stops.length === 0) return undefined;
215
+ const first = stops[0];
216
+ const last = stops[stops.length - 1];
217
+ if (first === undefined || last === undefined) return undefined;
218
+ if (percent >= first.percent) return first.color;
219
+ if (percent <= last.percent) return last.color;
220
+
221
+ for (let index = 0; index < stops.length - 1; index += 1) {
222
+ const high = stops[index];
223
+ const low = stops[index + 1];
224
+ if (high === undefined || low === undefined) continue;
225
+ if (percent > high.percent || percent < low.percent) continue;
226
+
227
+ const highRgb = usageColorToRgb(high.color);
228
+ const lowRgb = usageColorToRgb(low.color);
229
+ if (highRgb === undefined || lowRgb === undefined) return selectStepColor(stops, percent);
230
+
231
+ const ratio = (percent - low.percent) / (high.percent - low.percent);
232
+ return rgbToHex({
233
+ r: Math.round(lowRgb.r + (highRgb.r - lowRgb.r) * ratio),
234
+ g: Math.round(lowRgb.g + (highRgb.g - lowRgb.g) * ratio),
235
+ b: Math.round(lowRgb.b + (highRgb.b - lowRgb.b) * ratio),
236
+ });
237
+ }
238
+
239
+ return last.color;
240
+ }
241
+
242
+ function usageColorToRgb(color: UsageColor): Rgb | undefined {
243
+ if (typeof color === "string" && isHexColor(color)) return hexToRgb(color);
244
+ if (typeof color === "number") return xterm256ToRgb(color);
245
+ return undefined;
246
+ }
247
+
248
+ type Rgb = {
249
+ r: number;
250
+ g: number;
251
+ b: number;
252
+ };
253
+
254
+ function themeIsLight(theme: UsageColorTheme): boolean {
255
+ return theme.name === "light";
256
+ }
257
+
258
+ function isPiThemeColorToken(value: string): value is PiThemeColorToken {
259
+ return PI_THEME_COLOR_TOKENS.has(value as PiThemeColorToken);
260
+ }
261
+
262
+ function isHexColor(value: string): value is `#${string}` {
263
+ return /^#[0-9a-fA-F]{6}$/u.test(value);
264
+ }
265
+
266
+ function hexToRgb(hex: `#${string}`): Rgb {
267
+ return {
268
+ r: Number.parseInt(hex.slice(1, 3), 16),
269
+ g: Number.parseInt(hex.slice(3, 5), 16),
270
+ b: Number.parseInt(hex.slice(5, 7), 16),
271
+ };
272
+ }
273
+
274
+ function rgbToHex(rgb: Rgb): `#${string}` {
275
+ return `#${toHexByte(rgb.r)}${toHexByte(rgb.g)}${toHexByte(rgb.b)}`;
276
+ }
277
+
278
+ function toHexByte(value: number): string {
279
+ return clampNumber(value, 0, 255).toString(16).padStart(2, "0");
280
+ }
281
+
282
+ function xtermForeground(index: number): string {
283
+ return `\x1b[38;5;${clampInteger(index, 0, 255)}m`;
284
+ }
285
+
286
+ function rgbToXterm256(r: number, g: number, b: number): number {
287
+ const cube = rgbToColorCubeIndex(r, g, b);
288
+ const gray = rgbToGrayRampIndex(r, g, b);
289
+ return cube.distance <= gray.distance ? cube.index : gray.index;
290
+ }
291
+
292
+ function rgbToColorCubeIndex(r: number, g: number, b: number): { index: number; distance: number } {
293
+ const ri = nearestColorCubeChannel(r);
294
+ const gi = nearestColorCubeChannel(g);
295
+ const bi = nearestColorCubeChannel(b);
296
+ const rr = COLOR_CUBE_CHANNELS[ri] ?? 0;
297
+ const gg = COLOR_CUBE_CHANNELS[gi] ?? 0;
298
+ const bb = COLOR_CUBE_CHANNELS[bi] ?? 0;
299
+ return {
300
+ index: 16 + 36 * ri + 6 * gi + bi,
301
+ distance: weightedRgbDistance(r, g, b, rr, gg, bb),
302
+ };
303
+ }
304
+
305
+ function rgbToGrayRampIndex(r: number, g: number, b: number): { index: number; distance: number } {
306
+ const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
307
+ let bestIndex = 0;
308
+ let bestDistance = Number.POSITIVE_INFINITY;
309
+ for (let index = 0; index < GRAY_RAMP_CHANNELS.length; index += 1) {
310
+ const value = GRAY_RAMP_CHANNELS[index] ?? 0;
311
+ const distance = weightedRgbDistance(r, g, b, value, value, value);
312
+ if (distance < bestDistance) {
313
+ bestIndex = index;
314
+ bestDistance = distance;
315
+ }
316
+ }
317
+ return { index: 232 + bestIndex, distance: bestDistance };
318
+ }
319
+
320
+ function xterm256ToRgb(index: number): Rgb {
321
+ const clamped = clampInteger(index, 0, 255);
322
+ if (clamped < 16) return ANSI_16_RGB[clamped] ?? { r: 0, g: 0, b: 0 };
323
+ if (clamped >= 232) {
324
+ const gray = GRAY_RAMP_CHANNELS[clamped - 232] ?? 0;
325
+ return { r: gray, g: gray, b: gray };
326
+ }
327
+
328
+ const cubeIndex = clamped - 16;
329
+ return {
330
+ r: COLOR_CUBE_CHANNELS[Math.floor(cubeIndex / 36)] ?? 0,
331
+ g: COLOR_CUBE_CHANNELS[Math.floor((cubeIndex % 36) / 6)] ?? 0,
332
+ b: COLOR_CUBE_CHANNELS[cubeIndex % 6] ?? 0,
333
+ };
334
+ }
335
+
336
+ function nearestColorCubeChannel(channel: number): number {
337
+ let bestIndex = 0;
338
+ let bestDistance = Number.POSITIVE_INFINITY;
339
+ for (let index = 0; index < COLOR_CUBE_CHANNELS.length; index += 1) {
340
+ const distance = Math.abs(channel - (COLOR_CUBE_CHANNELS[index] ?? 0));
341
+ if (distance < bestDistance) {
342
+ bestIndex = index;
343
+ bestDistance = distance;
344
+ }
345
+ }
346
+ return bestIndex;
347
+ }
348
+
349
+ function weightedRgbDistance(
350
+ r1: number,
351
+ g1: number,
352
+ b1: number,
353
+ r2: number,
354
+ g2: number,
355
+ b2: number,
356
+ ): number {
357
+ return (r1 - r2) ** 2 * 0.299 + (g1 - g2) ** 2 * 0.587 + (b1 - b2) ** 2 * 0.114;
358
+ }
359
+
360
+ function isFiniteNumber(value: number | null | undefined): value is number {
361
+ return typeof value === "number" && Number.isFinite(value);
362
+ }
363
+
364
+ function clampInteger(value: number, min: number, max: number): number {
365
+ return clampNumber(Math.round(value), min, max);
366
+ }
367
+
368
+ function clampNumber(value: number, min: number, max: number): number {
369
+ return Math.max(min, Math.min(max, value));
370
+ }
371
+
372
+ const COLOR_CUBE_CHANNELS = [0, 95, 135, 175, 215, 255] as const;
373
+ const GRAY_RAMP_CHANNELS = Array.from({ length: 24 }, (_, index) => 8 + index * 10);
374
+ const ANSI_16_RGB: readonly Rgb[] = [
375
+ { r: 0, g: 0, b: 0 },
376
+ { r: 128, g: 0, b: 0 },
377
+ { r: 0, g: 128, b: 0 },
378
+ { r: 128, g: 128, b: 0 },
379
+ { r: 0, g: 0, b: 128 },
380
+ { r: 128, g: 0, b: 128 },
381
+ { r: 0, g: 128, b: 128 },
382
+ { r: 192, g: 192, b: 192 },
383
+ { r: 128, g: 128, b: 128 },
384
+ { r: 255, g: 0, b: 0 },
385
+ { r: 0, g: 255, b: 0 },
386
+ { r: 255, g: 255, b: 0 },
387
+ { r: 0, g: 0, b: 255 },
388
+ { r: 255, g: 0, b: 255 },
389
+ { r: 0, g: 255, b: 255 },
390
+ { r: 255, g: 255, b: 255 },
391
+ ];