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/config.ts ADDED
@@ -0,0 +1,767 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ export const CONFIG_BASENAME = "pi-openai-usage.json";
6
+ export const MIN_BAR_WIDTH = 1;
7
+ export const MAX_BAR_WIDTH = 40;
8
+ export const APPROVED_BAR_WIDTHS = [4, 6, 8, 10, 12, 16, 20] as const;
9
+ export const APPROVED_REFRESH_INTERVALS_MS = [
10
+ 15_000,
11
+ 30_000,
12
+ 60_000,
13
+ 120_000,
14
+ 300_000,
15
+ 600_000,
16
+ ] as const;
17
+ export const MIN_REFRESH_INTERVAL_MS = APPROVED_REFRESH_INTERVALS_MS[0];
18
+ export const MAX_REFRESH_INTERVAL_MS = APPROVED_REFRESH_INTERVALS_MS[
19
+ APPROVED_REFRESH_INTERVALS_MS.length - 1
20
+ ];
21
+
22
+ export type ApprovedBarWidth = (typeof APPROVED_BAR_WIDTHS)[number];
23
+ export type ApprovedRefreshIntervalMs = (typeof APPROVED_REFRESH_INTERVALS_MS)[number];
24
+ export const APPROVED_REFRESH_INTERVAL_PRESETS = [
25
+ { label: "15s", value: 15_000 },
26
+ { label: "30s", value: 30_000 },
27
+ { label: "1m", value: 60_000 },
28
+ { label: "2m", value: 120_000 },
29
+ { label: "5m", value: 300_000 },
30
+ { label: "10m", value: 600_000 },
31
+ ] as const satisfies readonly { label: string; value: ApprovedRefreshIntervalMs }[];
32
+
33
+ const WINDOW_WIDGET_MODES = ["percent", "bar", "bar-percent", "hidden"] as const;
34
+ const RESET_WIDGET_MODES = ["countdown", "clock", "both", "hidden"] as const;
35
+ export const BAR_STYLE_PRESETS = ["blocks", "thin", "ascii", "dots", "squares", "braille"] as const;
36
+ export const COLOR_SCHEME_PRESETS = ["traffic", "cyan", "green", "mono", "none"] as const;
37
+ const BAR_STYLES = [...BAR_STYLE_PRESETS, "custom"] as const;
38
+ const COLOR_SCHEMES = [...COLOR_SCHEME_PRESETS, "custom"] as const;
39
+ const COLOR_TARGETS = ["value", "widget", "bar", "percent", "none"] as const;
40
+ const COLOR_SCALE_MODES = ["step", "gradient"] as const;
41
+ const BAR_GRADIENT_DIRECTIONS = ["low-to-high", "high-to-low"] as const;
42
+ const PI_THEME_COLOR_TOKENS = [
43
+ "success",
44
+ "warning",
45
+ "error",
46
+ "muted",
47
+ "dim",
48
+ "text",
49
+ "accent",
50
+ ] as const;
51
+
52
+ export type WindowWidgetMode = (typeof WINDOW_WIDGET_MODES)[number];
53
+ export type ResetWidgetMode = (typeof RESET_WIDGET_MODES)[number];
54
+ export type BarStylePreset = (typeof BAR_STYLE_PRESETS)[number];
55
+ export type ColorSchemePreset = (typeof COLOR_SCHEME_PRESETS)[number];
56
+ export type BarStyleName = (typeof BAR_STYLES)[number];
57
+ export type ColorSchemeName = (typeof COLOR_SCHEMES)[number];
58
+ export type ColorTarget = (typeof COLOR_TARGETS)[number];
59
+ export type ColorScaleMode = (typeof COLOR_SCALE_MODES)[number];
60
+ export type BarGradientDirection = (typeof BAR_GRADIENT_DIRECTIONS)[number];
61
+ export type PiThemeColorToken = (typeof PI_THEME_COLOR_TOKENS)[number];
62
+ export type UsageColor = PiThemeColorToken | `#${string}` | number;
63
+
64
+ export type DisplayConfig = {
65
+ showAlways: boolean;
66
+ showLabel: boolean;
67
+ label: string;
68
+ separator: string;
69
+ };
70
+
71
+ export type WindowWidgetConfig = {
72
+ enabled: boolean;
73
+ label: string;
74
+ mode: WindowWidgetMode;
75
+ };
76
+
77
+ export type ResetWidgetConfig = {
78
+ enabled: boolean;
79
+ label: string;
80
+ mode: ResetWidgetMode;
81
+ };
82
+
83
+ export type WidgetConfig = {
84
+ fiveHour: WindowWidgetConfig;
85
+ sevenDay: WindowWidgetConfig;
86
+ fiveHourReset: ResetWidgetConfig;
87
+ sevenDayReset: ResetWidgetConfig;
88
+ };
89
+
90
+ export type CustomBarStyleConfig = {
91
+ filled: string;
92
+ empty: string;
93
+ partials: string[];
94
+ };
95
+
96
+ export type BarConfig = {
97
+ style: BarStyleName;
98
+ width: number;
99
+ partials: boolean;
100
+ custom: CustomBarStyleConfig;
101
+ };
102
+
103
+ export type ColorStop = {
104
+ percent: number;
105
+ color: UsageColor;
106
+ label?: string;
107
+ };
108
+
109
+ export type CustomColorConfig = {
110
+ mode: ColorScaleMode;
111
+ stops: ColorStop[];
112
+ };
113
+
114
+ export type BarGradientConfig = {
115
+ enabled: boolean;
116
+ direction: BarGradientDirection;
117
+ };
118
+
119
+ export type ColorConfig = {
120
+ scheme: ColorSchemeName;
121
+ target: ColorTarget;
122
+ barGradient: BarGradientConfig;
123
+ custom: CustomColorConfig;
124
+ };
125
+
126
+ export type UsageConfig = {
127
+ enabled: boolean;
128
+ refreshIntervalMs: number;
129
+ display: DisplayConfig;
130
+ widgets: WidgetConfig;
131
+ bar: BarConfig;
132
+ colors: ColorConfig;
133
+ };
134
+
135
+ export type ConfigPaths = {
136
+ project: string;
137
+ global: string;
138
+ };
139
+
140
+ export type LoadUsageConfigOptions = {
141
+ cwd?: string;
142
+ home?: string;
143
+ };
144
+
145
+ export type LoadedUsageConfig = {
146
+ configPath: string;
147
+ projectConfigPath: string;
148
+ globalConfigPath: string;
149
+ projectConfigExists: boolean;
150
+ globalConfigExists: boolean;
151
+ raw: {
152
+ project: Record<string, unknown>;
153
+ global: Record<string, unknown>;
154
+ };
155
+ effective: UsageConfig;
156
+ };
157
+
158
+ export type WindowWidgetConfigPatch = Partial<WindowWidgetConfig>;
159
+ export type ResetWidgetConfigPatch = Partial<ResetWidgetConfig>;
160
+
161
+ export type UsageConfigPatch = {
162
+ enabled?: boolean;
163
+ refreshIntervalMs?: ApprovedRefreshIntervalMs;
164
+ display?: Partial<DisplayConfig>;
165
+ widgets?: Partial<{
166
+ fiveHour: WindowWidgetConfigPatch;
167
+ sevenDay: WindowWidgetConfigPatch;
168
+ fiveHourReset: ResetWidgetConfigPatch;
169
+ sevenDayReset: ResetWidgetConfigPatch;
170
+ }>;
171
+ bar?: Partial<{
172
+ style: BarStyleName;
173
+ width: ApprovedBarWidth;
174
+ partials: boolean;
175
+ custom: Partial<CustomBarStyleConfig>;
176
+ }>;
177
+ colors?: Partial<{
178
+ scheme: ColorSchemeName;
179
+ target: ColorTarget;
180
+ barGradient: Partial<BarGradientConfig>;
181
+ custom: Partial<CustomColorConfig>;
182
+ }>;
183
+ };
184
+
185
+ const DEFAULT_COLOR_STOPS: ColorStop[] = [
186
+ { percent: 80, color: "success", label: "success" },
187
+ { percent: 60, color: "#65a30d", label: "lime/olive" },
188
+ { percent: 40, color: "warning", label: "warning" },
189
+ { percent: 20, color: "#c2410c", label: "orange" },
190
+ { percent: 0, color: "error", label: "error" },
191
+ ];
192
+
193
+ export const DEFAULT_USAGE_CONFIG: UsageConfig = {
194
+ enabled: true,
195
+ refreshIntervalMs: 60_000,
196
+ display: {
197
+ showAlways: false,
198
+ showLabel: true,
199
+ label: "Usage",
200
+ separator: " | ",
201
+ },
202
+ widgets: {
203
+ fiveHour: { enabled: true, label: "5h", mode: "bar-percent" },
204
+ sevenDay: { enabled: true, label: "7d", mode: "bar-percent" },
205
+ fiveHourReset: { enabled: true, label: "5h ↺", mode: "countdown" },
206
+ sevenDayReset: { enabled: true, label: "7d ↺", mode: "countdown" },
207
+ },
208
+ bar: {
209
+ style: "blocks",
210
+ width: 10,
211
+ partials: true,
212
+ custom: {
213
+ filled: "▰",
214
+ empty: "▱",
215
+ partials: [],
216
+ },
217
+ },
218
+ colors: {
219
+ scheme: "traffic",
220
+ target: "value",
221
+ barGradient: {
222
+ enabled: false,
223
+ direction: "low-to-high",
224
+ },
225
+ custom: {
226
+ mode: "step",
227
+ stops: cloneColorStops(DEFAULT_COLOR_STOPS),
228
+ },
229
+ },
230
+ };
231
+
232
+ export function usageConfigPaths(options: LoadUsageConfigOptions = {}): ConfigPaths {
233
+ const cwd = options.cwd ?? process.cwd();
234
+ const home = options.home ?? homedir();
235
+
236
+ return {
237
+ project: join(cwd, ".pi", "extensions", CONFIG_BASENAME),
238
+ global: join(home, ".pi", "agent", "extensions", CONFIG_BASENAME),
239
+ };
240
+ }
241
+
242
+ export function loadUsageConfig(options: LoadUsageConfigOptions = {}): LoadedUsageConfig {
243
+ const paths = usageConfigPaths(options);
244
+ const projectConfigExists = existsSync(paths.project);
245
+ const globalConfigExists = existsSync(paths.global);
246
+ const globalRaw = readRawConfig(paths.global);
247
+ const projectRaw = readRawConfig(paths.project);
248
+
249
+ return {
250
+ configPath: projectConfigExists ? paths.project : paths.global,
251
+ projectConfigPath: paths.project,
252
+ globalConfigPath: paths.global,
253
+ projectConfigExists,
254
+ globalConfigExists,
255
+ raw: {
256
+ project: projectRaw,
257
+ global: globalRaw,
258
+ },
259
+ effective: normalizeUsageConfig(projectRaw, globalRaw),
260
+ };
261
+ }
262
+
263
+ export function patchUsageConfig(configPath: string, patch: UsageConfigPatch): void {
264
+ const current = readRawConfig(configPath);
265
+ const next = mergeKnownConfigPatch(current, patch, "root");
266
+ writeRawConfig(configPath, next);
267
+ }
268
+
269
+ export function readRawConfig(configPath: string): Record<string, unknown> {
270
+ if (!existsSync(configPath)) return {};
271
+
272
+ try {
273
+ const parsed = JSON.parse(readFileSync(configPath, "utf8")) as unknown;
274
+ return isRecord(parsed) ? parsed : {};
275
+ } catch {
276
+ return {};
277
+ }
278
+ }
279
+
280
+ function normalizeUsageConfig(
281
+ projectRaw: Record<string, unknown>,
282
+ globalRaw: Record<string, unknown>,
283
+ ): UsageConfig {
284
+ return {
285
+ enabled: configValue(projectRaw, globalRaw, ["enabled"], isBoolean, DEFAULT_USAGE_CONFIG.enabled),
286
+ refreshIntervalMs: configValue(
287
+ projectRaw,
288
+ globalRaw,
289
+ ["refreshIntervalMs"],
290
+ isApprovedRefreshIntervalMs,
291
+ DEFAULT_USAGE_CONFIG.refreshIntervalMs,
292
+ ),
293
+ display: normalizeDisplayConfig(projectRaw, globalRaw),
294
+ widgets: normalizeWidgetConfig(projectRaw, globalRaw),
295
+ bar: normalizeBarConfig(projectRaw, globalRaw),
296
+ colors: normalizeColorConfig(projectRaw, globalRaw),
297
+ };
298
+ }
299
+
300
+ function normalizeDisplayConfig(
301
+ projectRaw: Record<string, unknown>,
302
+ globalRaw: Record<string, unknown>,
303
+ ): DisplayConfig {
304
+ return {
305
+ showAlways: configValue(
306
+ projectRaw,
307
+ globalRaw,
308
+ ["display", "showAlways"],
309
+ isBoolean,
310
+ DEFAULT_USAGE_CONFIG.display.showAlways,
311
+ ),
312
+ showLabel: configValue(
313
+ projectRaw,
314
+ globalRaw,
315
+ ["display", "showLabel"],
316
+ isBoolean,
317
+ DEFAULT_USAGE_CONFIG.display.showLabel,
318
+ ),
319
+ label: configValue(
320
+ projectRaw,
321
+ globalRaw,
322
+ ["display", "label"],
323
+ isNonEmptyString,
324
+ DEFAULT_USAGE_CONFIG.display.label,
325
+ ),
326
+ separator: configValue(
327
+ projectRaw,
328
+ globalRaw,
329
+ ["display", "separator"],
330
+ isString,
331
+ DEFAULT_USAGE_CONFIG.display.separator,
332
+ ),
333
+ };
334
+ }
335
+
336
+ function normalizeWidgetConfig(
337
+ projectRaw: Record<string, unknown>,
338
+ globalRaw: Record<string, unknown>,
339
+ ): WidgetConfig {
340
+ return {
341
+ fiveHour: normalizeWindowWidgetConfig(projectRaw, globalRaw, "fiveHour"),
342
+ sevenDay: normalizeWindowWidgetConfig(projectRaw, globalRaw, "sevenDay"),
343
+ fiveHourReset: normalizeResetWidgetConfig(projectRaw, globalRaw, "fiveHourReset"),
344
+ sevenDayReset: normalizeResetWidgetConfig(projectRaw, globalRaw, "sevenDayReset"),
345
+ };
346
+ }
347
+
348
+ function normalizeWindowWidgetConfig(
349
+ projectRaw: Record<string, unknown>,
350
+ globalRaw: Record<string, unknown>,
351
+ widgetName: "fiveHour" | "sevenDay",
352
+ ): WindowWidgetConfig {
353
+ const defaults = DEFAULT_USAGE_CONFIG.widgets[widgetName];
354
+
355
+ return {
356
+ enabled: configValue(
357
+ projectRaw,
358
+ globalRaw,
359
+ ["widgets", widgetName, "enabled"],
360
+ isBoolean,
361
+ defaults.enabled,
362
+ ),
363
+ label: configValue(
364
+ projectRaw,
365
+ globalRaw,
366
+ ["widgets", widgetName, "label"],
367
+ isNonEmptyString,
368
+ defaults.label,
369
+ ),
370
+ mode: configValue(
371
+ projectRaw,
372
+ globalRaw,
373
+ ["widgets", widgetName, "mode"],
374
+ isWindowWidgetMode,
375
+ defaults.mode,
376
+ ),
377
+ };
378
+ }
379
+
380
+ function normalizeResetWidgetConfig(
381
+ projectRaw: Record<string, unknown>,
382
+ globalRaw: Record<string, unknown>,
383
+ widgetName: "fiveHourReset" | "sevenDayReset",
384
+ ): ResetWidgetConfig {
385
+ const defaults = DEFAULT_USAGE_CONFIG.widgets[widgetName];
386
+
387
+ return {
388
+ enabled: configValue(
389
+ projectRaw,
390
+ globalRaw,
391
+ ["widgets", widgetName, "enabled"],
392
+ isBoolean,
393
+ defaults.enabled,
394
+ ),
395
+ label: configValue(
396
+ projectRaw,
397
+ globalRaw,
398
+ ["widgets", widgetName, "label"],
399
+ isNonEmptyString,
400
+ defaults.label,
401
+ ),
402
+ mode: configValue(
403
+ projectRaw,
404
+ globalRaw,
405
+ ["widgets", widgetName, "mode"],
406
+ isResetWidgetMode,
407
+ defaults.mode,
408
+ ),
409
+ };
410
+ }
411
+
412
+ function normalizeBarConfig(
413
+ projectRaw: Record<string, unknown>,
414
+ globalRaw: Record<string, unknown>,
415
+ ): BarConfig {
416
+ return {
417
+ style: configValue(
418
+ projectRaw,
419
+ globalRaw,
420
+ ["bar", "style"],
421
+ isBarStyleName,
422
+ DEFAULT_USAGE_CONFIG.bar.style,
423
+ ),
424
+ width: configValue(
425
+ projectRaw,
426
+ globalRaw,
427
+ ["bar", "width"],
428
+ isApprovedBarWidth,
429
+ DEFAULT_USAGE_CONFIG.bar.width,
430
+ ),
431
+ partials: configValue(
432
+ projectRaw,
433
+ globalRaw,
434
+ ["bar", "partials"],
435
+ isBoolean,
436
+ DEFAULT_USAGE_CONFIG.bar.partials,
437
+ ),
438
+ custom: {
439
+ filled: configValue(
440
+ projectRaw,
441
+ globalRaw,
442
+ ["bar", "custom", "filled"],
443
+ isNonEmptyString,
444
+ DEFAULT_USAGE_CONFIG.bar.custom.filled,
445
+ ),
446
+ empty: configValue(
447
+ projectRaw,
448
+ globalRaw,
449
+ ["bar", "custom", "empty"],
450
+ isNonEmptyString,
451
+ DEFAULT_USAGE_CONFIG.bar.custom.empty,
452
+ ),
453
+ partials: normalizeStringArray(
454
+ firstConfigValue(
455
+ projectRaw,
456
+ globalRaw,
457
+ ["bar", "custom", "partials"],
458
+ Array.isArray,
459
+ ),
460
+ DEFAULT_USAGE_CONFIG.bar.custom.partials,
461
+ ),
462
+ },
463
+ };
464
+ }
465
+
466
+ function normalizeColorConfig(
467
+ projectRaw: Record<string, unknown>,
468
+ globalRaw: Record<string, unknown>,
469
+ ): ColorConfig {
470
+ const customMode = configValue(
471
+ projectRaw,
472
+ globalRaw,
473
+ ["colors", "custom", "mode"],
474
+ isColorScaleMode,
475
+ DEFAULT_USAGE_CONFIG.colors.custom.mode,
476
+ );
477
+ const rawStops = firstConfigValue(
478
+ projectRaw,
479
+ globalRaw,
480
+ ["colors", "custom", "stops"],
481
+ Array.isArray,
482
+ );
483
+ const customStops = Array.isArray(rawStops) ? normalizeColorStops(rawStops, customMode) : [];
484
+ const custom =
485
+ customStops.length > 0
486
+ ? { mode: customMode, stops: customStops }
487
+ : cloneDefaultCustomColorConfig();
488
+
489
+ return {
490
+ scheme: configValue(
491
+ projectRaw,
492
+ globalRaw,
493
+ ["colors", "scheme"],
494
+ isColorSchemeName,
495
+ DEFAULT_USAGE_CONFIG.colors.scheme,
496
+ ),
497
+ target: configValue(
498
+ projectRaw,
499
+ globalRaw,
500
+ ["colors", "target"],
501
+ isColorTarget,
502
+ DEFAULT_USAGE_CONFIG.colors.target,
503
+ ),
504
+ barGradient: {
505
+ enabled: configValue(
506
+ projectRaw,
507
+ globalRaw,
508
+ ["colors", "barGradient", "enabled"],
509
+ isBoolean,
510
+ DEFAULT_USAGE_CONFIG.colors.barGradient.enabled,
511
+ ),
512
+ direction: configValue(
513
+ projectRaw,
514
+ globalRaw,
515
+ ["colors", "barGradient", "direction"],
516
+ isBarGradientDirection,
517
+ DEFAULT_USAGE_CONFIG.colors.barGradient.direction,
518
+ ),
519
+ },
520
+ custom,
521
+ };
522
+ }
523
+
524
+ function normalizeColorStops(rawStops: readonly unknown[], mode: ColorScaleMode): ColorStop[] {
525
+ return rawStops
526
+ .map((stop): ColorStop | undefined => normalizeColorStop(stop, mode))
527
+ .filter((stop): stop is ColorStop => stop !== undefined)
528
+ .sort((left, right) => right.percent - left.percent);
529
+ }
530
+
531
+ function normalizeColorStop(rawStop: unknown, mode: ColorScaleMode): ColorStop | undefined {
532
+ if (!isRecord(rawStop)) return undefined;
533
+ if (!isFiniteNumber(rawStop.percent)) return undefined;
534
+ if (!isValidUsageColor(rawStop.color, mode)) return undefined;
535
+
536
+ const color = rawStop.color as UsageColor;
537
+ const label = typeof rawStop.label === "string" ? rawStop.label.trim() : "";
538
+ const normalized: ColorStop = {
539
+ percent: clampNumber(rawStop.percent, 0, 100),
540
+ color,
541
+ };
542
+ if (label.length > 0) normalized.label = label;
543
+ return normalized;
544
+ }
545
+
546
+ function isValidUsageColor(value: unknown, mode: ColorScaleMode): value is UsageColor {
547
+ if (isXtermColor(value) || isHexColor(value)) return true;
548
+ return mode === "step" && isPiThemeColorToken(value);
549
+ }
550
+
551
+ function normalizeStringArray(rawValue: unknown, defaultValue: string[]): string[] {
552
+ if (!Array.isArray(rawValue)) return [...defaultValue];
553
+
554
+ return rawValue.filter((entry): entry is string => isNonEmptyString(entry));
555
+ }
556
+
557
+ function configValue<T>(
558
+ projectRaw: Record<string, unknown>,
559
+ globalRaw: Record<string, unknown>,
560
+ path: readonly string[],
561
+ predicate: (value: unknown) => value is T,
562
+ defaultValue: T,
563
+ ): T {
564
+ return firstConfigValue(projectRaw, globalRaw, path, predicate) ?? defaultValue;
565
+ }
566
+
567
+ function firstConfigValue<T>(
568
+ projectRaw: Record<string, unknown>,
569
+ globalRaw: Record<string, unknown>,
570
+ path: readonly string[],
571
+ predicate: (value: unknown) => value is T,
572
+ ): T | undefined {
573
+ const projectValue = valueAtPath(projectRaw, path);
574
+ if (predicate(projectValue)) return projectValue;
575
+
576
+ const globalValue = valueAtPath(globalRaw, path);
577
+ if (predicate(globalValue)) return globalValue;
578
+
579
+ return undefined;
580
+ }
581
+
582
+ function valueAtPath(record: Record<string, unknown>, path: readonly string[]): unknown {
583
+ let current: unknown = record;
584
+ for (const segment of path) {
585
+ if (!isRecord(current)) return undefined;
586
+ current = current[segment];
587
+ }
588
+ return current;
589
+ }
590
+
591
+ function writeRawConfig(configPath: string, config: Record<string, unknown>): void {
592
+ mkdirSync(dirname(configPath), { recursive: true });
593
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
594
+ }
595
+
596
+ type PatchBranch =
597
+ | "root"
598
+ | "display"
599
+ | "widgets"
600
+ | "windowWidget"
601
+ | "resetWidget"
602
+ | "bar"
603
+ | "customBar"
604
+ | "colors"
605
+ | "barGradient"
606
+ | "customColors";
607
+
608
+ function mergeKnownConfigPatch(
609
+ current: Record<string, unknown>,
610
+ patch: Record<string, unknown>,
611
+ branch: PatchBranch,
612
+ ): Record<string, unknown> {
613
+ const next = { ...current };
614
+ const allowedKeys = knownPatchKeys(branch);
615
+
616
+ for (const [key, patchValue] of Object.entries(patch)) {
617
+ if (patchValue === undefined || !allowedKeys.has(key)) continue;
618
+
619
+ const childBranch = childPatchBranch(branch, key);
620
+ if (childBranch !== undefined && isRecord(patchValue)) {
621
+ const currentChild = isRecord(next[key]) ? next[key] : {};
622
+ next[key] = mergeKnownConfigPatch(currentChild, patchValue, childBranch);
623
+ continue;
624
+ }
625
+
626
+ next[key] = patchValue;
627
+ }
628
+
629
+ return next;
630
+ }
631
+
632
+ function knownPatchKeys(branch: PatchBranch): ReadonlySet<string> {
633
+ switch (branch) {
634
+ case "root":
635
+ return new Set(["enabled", "refreshIntervalMs", "display", "widgets", "bar", "colors"]);
636
+ case "display":
637
+ return new Set(["showAlways", "showLabel", "label", "separator"]);
638
+ case "widgets":
639
+ return new Set(["fiveHour", "sevenDay", "fiveHourReset", "sevenDayReset"]);
640
+ case "windowWidget":
641
+ case "resetWidget":
642
+ return new Set(["enabled", "label", "mode"]);
643
+ case "bar":
644
+ return new Set(["style", "width", "partials", "custom"]);
645
+ case "customBar":
646
+ return new Set(["filled", "empty", "partials"]);
647
+ case "colors":
648
+ return new Set(["scheme", "target", "barGradient", "custom"]);
649
+ case "barGradient":
650
+ return new Set(["enabled", "direction"]);
651
+ case "customColors":
652
+ return new Set(["mode", "stops"]);
653
+ }
654
+ }
655
+
656
+ function childPatchBranch(parent: PatchBranch, key: string): PatchBranch | undefined {
657
+ if (parent === "root" && key === "display") return "display";
658
+ if (parent === "root" && key === "widgets") return "widgets";
659
+ if (parent === "widgets" && (key === "fiveHour" || key === "sevenDay")) return "windowWidget";
660
+ if (parent === "widgets" && (key === "fiveHourReset" || key === "sevenDayReset")) {
661
+ return "resetWidget";
662
+ }
663
+ if (parent === "root" && key === "bar") return "bar";
664
+ if (parent === "bar" && key === "custom") return "customBar";
665
+ if (parent === "root" && key === "colors") return "colors";
666
+ if (parent === "colors" && key === "barGradient") return "barGradient";
667
+ if (parent === "colors" && key === "custom") return "customColors";
668
+ return undefined;
669
+ }
670
+
671
+ function isRecord(value: unknown): value is Record<string, unknown> {
672
+ return typeof value === "object" && value !== null && !Array.isArray(value);
673
+ }
674
+
675
+ function isBoolean(value: unknown): value is boolean {
676
+ return typeof value === "boolean";
677
+ }
678
+
679
+ function isString(value: unknown): value is string {
680
+ return typeof value === "string";
681
+ }
682
+
683
+ function isNonEmptyString(value: unknown): value is string {
684
+ return typeof value === "string" && value.trim().length > 0;
685
+ }
686
+
687
+ function isFiniteNumber(value: unknown): value is number {
688
+ return typeof value === "number" && Number.isFinite(value);
689
+ }
690
+
691
+ function isApprovedBarWidth(value: unknown): value is ApprovedBarWidth {
692
+ return includesNumber(APPROVED_BAR_WIDTHS, value);
693
+ }
694
+
695
+ function isApprovedRefreshIntervalMs(value: unknown): value is ApprovedRefreshIntervalMs {
696
+ return includesNumber(APPROVED_REFRESH_INTERVALS_MS, value);
697
+ }
698
+
699
+ function isWindowWidgetMode(value: unknown): value is WindowWidgetMode {
700
+ return includesString(WINDOW_WIDGET_MODES, value);
701
+ }
702
+
703
+ function isResetWidgetMode(value: unknown): value is ResetWidgetMode {
704
+ return includesString(RESET_WIDGET_MODES, value);
705
+ }
706
+
707
+ function isBarStyleName(value: unknown): value is BarStyleName {
708
+ return includesString(BAR_STYLES, value);
709
+ }
710
+
711
+ function isColorSchemeName(value: unknown): value is ColorSchemeName {
712
+ return includesString(COLOR_SCHEMES, value);
713
+ }
714
+
715
+ function isColorTarget(value: unknown): value is ColorTarget {
716
+ return includesString(COLOR_TARGETS, value);
717
+ }
718
+
719
+ function isColorScaleMode(value: unknown): value is ColorScaleMode {
720
+ return includesString(COLOR_SCALE_MODES, value);
721
+ }
722
+
723
+ function isBarGradientDirection(value: unknown): value is BarGradientDirection {
724
+ return includesString(BAR_GRADIENT_DIRECTIONS, value);
725
+ }
726
+
727
+ function isPiThemeColorToken(value: unknown): value is PiThemeColorToken {
728
+ return includesString(PI_THEME_COLOR_TOKENS, value);
729
+ }
730
+
731
+ function includesString<const T extends readonly string[]>(values: T, value: unknown): value is T[number] {
732
+ return typeof value === "string" && values.includes(value);
733
+ }
734
+
735
+ function includesNumber<const T extends readonly number[]>(
736
+ values: T,
737
+ value: unknown,
738
+ ): value is T[number] {
739
+ return typeof value === "number" && values.some((candidate) => candidate === value);
740
+ }
741
+
742
+ function isHexColor(value: unknown): value is `#${string}` {
743
+ return typeof value === "string" && /^#[0-9a-fA-F]{6}$/u.test(value);
744
+ }
745
+
746
+ function isXtermColor(value: unknown): value is number {
747
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 255;
748
+ }
749
+
750
+ function clampInteger(value: number, min: number, max: number): number {
751
+ return clampNumber(Math.round(value), min, max);
752
+ }
753
+
754
+ function clampNumber(value: number, min: number, max: number): number {
755
+ return Math.max(min, Math.min(max, value));
756
+ }
757
+
758
+ function cloneDefaultCustomColorConfig(): CustomColorConfig {
759
+ return {
760
+ mode: DEFAULT_USAGE_CONFIG.colors.custom.mode,
761
+ stops: cloneColorStops(DEFAULT_COLOR_STOPS),
762
+ };
763
+ }
764
+
765
+ function cloneColorStops(stops: readonly ColorStop[]): ColorStop[] {
766
+ return stops.map((stop) => ({ ...stop }));
767
+ }