@tokentop/ttop 0.2.2

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.
Files changed (159) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +273 -0
  3. package/bin/ttop.js +49 -0
  4. package/package.json +68 -0
  5. package/src/agents/aggregator.ts +176 -0
  6. package/src/agents/costing.ts +74 -0
  7. package/src/agents/index.ts +12 -0
  8. package/src/agents/types.ts +71 -0
  9. package/src/cli.ts +167 -0
  10. package/src/config/schema.test.ts +166 -0
  11. package/src/config/schema.ts +165 -0
  12. package/src/demo/simulator.ts +745 -0
  13. package/src/plugins/agents/index.ts +2 -0
  14. package/src/plugins/auth-sources.ts +186 -0
  15. package/src/plugins/lifecycle.ts +177 -0
  16. package/src/plugins/loader.test.ts +195 -0
  17. package/src/plugins/loader.ts +282 -0
  18. package/src/plugins/notification-bus.ts +153 -0
  19. package/src/plugins/notifications/index.ts +2 -0
  20. package/src/plugins/notifications/terminal-bell.ts +66 -0
  21. package/src/plugins/notifications/visual-flash.ts +57 -0
  22. package/src/plugins/npm-installer.ts +164 -0
  23. package/src/plugins/plugin-context-factory.ts +67 -0
  24. package/src/plugins/plugin-host.ts +205 -0
  25. package/src/plugins/providers/anthropic.ts +325 -0
  26. package/src/plugins/providers/antigravity.ts +309 -0
  27. package/src/plugins/providers/codex.ts +250 -0
  28. package/src/plugins/providers/gemini.ts +391 -0
  29. package/src/plugins/providers/github-copilot.ts +194 -0
  30. package/src/plugins/providers/index.ts +12 -0
  31. package/src/plugins/providers/minimax.ts +223 -0
  32. package/src/plugins/providers/openai-api.ts +250 -0
  33. package/src/plugins/providers/opencode-zen.ts +125 -0
  34. package/src/plugins/providers/perplexity.ts +177 -0
  35. package/src/plugins/providers/zai.ts +225 -0
  36. package/src/plugins/registry.ts +251 -0
  37. package/src/plugins/sandbox-guard.test.ts +191 -0
  38. package/src/plugins/sandbox-guard.ts +155 -0
  39. package/src/plugins/sandbox.ts +138 -0
  40. package/src/plugins/themes/catppuccin-latte.ts +45 -0
  41. package/src/plugins/themes/catppuccin-mocha.ts +45 -0
  42. package/src/plugins/themes/claude-code.ts +45 -0
  43. package/src/plugins/themes/dracula.ts +45 -0
  44. package/src/plugins/themes/github-light.ts +45 -0
  45. package/src/plugins/themes/gruvbox-dark.ts +45 -0
  46. package/src/plugins/themes/gruvbox-light.ts +45 -0
  47. package/src/plugins/themes/index.ts +15 -0
  48. package/src/plugins/themes/kanagawa.ts +45 -0
  49. package/src/plugins/themes/nord.ts +45 -0
  50. package/src/plugins/themes/one-dark.ts +45 -0
  51. package/src/plugins/themes/opencode.ts +47 -0
  52. package/src/plugins/themes/rose-pine-dawn.ts +45 -0
  53. package/src/plugins/themes/rose-pine.ts +45 -0
  54. package/src/plugins/themes/solarized-light.ts +45 -0
  55. package/src/plugins/themes/tokyo-night.ts +49 -0
  56. package/src/plugins/types/agent.ts +108 -0
  57. package/src/plugins/types/base.ts +267 -0
  58. package/src/plugins/types/index.ts +19 -0
  59. package/src/plugins/types/notification.ts +51 -0
  60. package/src/plugins/types/provider.ts +233 -0
  61. package/src/plugins/types/theme.ts +70 -0
  62. package/src/plugins/update-checker.ts +177 -0
  63. package/src/pricing/estimator.test.ts +181 -0
  64. package/src/pricing/estimator.ts +80 -0
  65. package/src/pricing/fallback.test.ts +71 -0
  66. package/src/pricing/fallback.ts +62 -0
  67. package/src/pricing/index.ts +66 -0
  68. package/src/pricing/models-dev.ts +130 -0
  69. package/src/storage/database.ts +613 -0
  70. package/src/storage/db.ts +64 -0
  71. package/src/storage/index.ts +66 -0
  72. package/src/storage/migrations/index.ts +189 -0
  73. package/src/storage/paths.ts +24 -0
  74. package/src/storage/repos/agentSessions.ts +210 -0
  75. package/src/storage/repos/providerSnapshots.ts +116 -0
  76. package/src/storage/repos/usageEvents.ts +334 -0
  77. package/src/storage/types.ts +288 -0
  78. package/src/tui/App.tsx +381 -0
  79. package/src/tui/components/CommandPalette.tsx +166 -0
  80. package/src/tui/components/DebugConsole.tsx +161 -0
  81. package/src/tui/components/DebugPanel.tsx +660 -0
  82. package/src/tui/components/FooterHints.tsx +22 -0
  83. package/src/tui/components/GhostProviderCard.tsx +45 -0
  84. package/src/tui/components/Header.tsx +125 -0
  85. package/src/tui/components/HelpOverlay.tsx +45 -0
  86. package/src/tui/components/InlineGauge.tsx +34 -0
  87. package/src/tui/components/InlineSparkline.tsx +78 -0
  88. package/src/tui/components/KpiStrip.tsx +246 -0
  89. package/src/tui/components/LimitGauge.tsx +186 -0
  90. package/src/tui/components/ModalBackdrop.tsx +42 -0
  91. package/src/tui/components/ProviderAggregateStrip.tsx +200 -0
  92. package/src/tui/components/ProviderCard.tsx +309 -0
  93. package/src/tui/components/ProviderDetailPanel.tsx +237 -0
  94. package/src/tui/components/ProviderLimitsPanel.tsx +308 -0
  95. package/src/tui/components/ProvidersList.tsx +380 -0
  96. package/src/tui/components/SessionDetailsDrawer.tsx +317 -0
  97. package/src/tui/components/SessionsTable.tsx +401 -0
  98. package/src/tui/components/SettingsModal.tsx +587 -0
  99. package/src/tui/components/SidebarBreakdown.tsx +87 -0
  100. package/src/tui/components/Skeleton.tsx +102 -0
  101. package/src/tui/components/SmartSidebar.tsx +644 -0
  102. package/src/tui/components/Sparkline.tsx +180 -0
  103. package/src/tui/components/Spinner.tsx +36 -0
  104. package/src/tui/components/StatusBar.tsx +96 -0
  105. package/src/tui/components/ThemePicker.tsx +234 -0
  106. package/src/tui/components/Toast.tsx +61 -0
  107. package/src/tui/components/UsageGauge.tsx +80 -0
  108. package/src/tui/components/index.ts +5 -0
  109. package/src/tui/contexts/AgentSessionContext.tsx +307 -0
  110. package/src/tui/contexts/ConfigContext.tsx +141 -0
  111. package/src/tui/contexts/DashboardRuntimeContext.tsx +102 -0
  112. package/src/tui/contexts/DemoModeContext.tsx +60 -0
  113. package/src/tui/contexts/DrawerContext.tsx +42 -0
  114. package/src/tui/contexts/InputContext.tsx +30 -0
  115. package/src/tui/contexts/LogContext.tsx +150 -0
  116. package/src/tui/contexts/PluginContext.tsx +424 -0
  117. package/src/tui/contexts/RealTimeActivityContext.tsx +119 -0
  118. package/src/tui/contexts/StorageContext.tsx +233 -0
  119. package/src/tui/contexts/ThemeContext.tsx +95 -0
  120. package/src/tui/contexts/TimeWindowContext.tsx +158 -0
  121. package/src/tui/contexts/ToastContext.tsx +40 -0
  122. package/src/tui/contexts/index.ts +5 -0
  123. package/src/tui/createApp.tsx +47 -0
  124. package/src/tui/debug/captureFrame.ts +262 -0
  125. package/src/tui/debug/snapshot.tsx +644 -0
  126. package/src/tui/driver/assertions.ts +279 -0
  127. package/src/tui/driver/cli.ts +717 -0
  128. package/src/tui/driver/coverage.ts +204 -0
  129. package/src/tui/driver/demo-snapshot.ts +66 -0
  130. package/src/tui/driver/diff.ts +166 -0
  131. package/src/tui/driver/driver.ts +270 -0
  132. package/src/tui/driver/index.ts +53 -0
  133. package/src/tui/driver/recorder.ts +303 -0
  134. package/src/tui/driver/test-workflow.ts +115 -0
  135. package/src/tui/driver/test.ts +47 -0
  136. package/src/tui/hooks/index.ts +20 -0
  137. package/src/tui/hooks/useAnimatedValue.ts +113 -0
  138. package/src/tui/hooks/useDashboardKeyboard.ts +341 -0
  139. package/src/tui/hooks/useDashboardState.ts +116 -0
  140. package/src/tui/hooks/useEmaActivity.ts +223 -0
  141. package/src/tui/hooks/useEntranceAnimation.ts +62 -0
  142. package/src/tui/hooks/useExitAnimation.ts +137 -0
  143. package/src/tui/hooks/usePulse.ts +138 -0
  144. package/src/tui/hooks/useSafeRenderer.ts +24 -0
  145. package/src/tui/hooks/useValueFlash.ts +111 -0
  146. package/src/tui/index.tsx +50 -0
  147. package/src/tui/utils/providerColor.ts +32 -0
  148. package/src/tui/views/Dashboard.tsx +340 -0
  149. package/src/tui/views/HistoricalTrendsView.tsx +1431 -0
  150. package/src/tui/views/ProjectsView.tsx +1277 -0
  151. package/src/tui/views/RealTimeDashboard.tsx +393 -0
  152. package/src/tui/views/SettingsView.tsx +465 -0
  153. package/src/tui/views/index.ts +2 -0
  154. package/src/types/global.d.ts +5 -0
  155. package/src/types/opentui-test-utils.d.ts +18 -0
  156. package/src/utils/clipboard.ts +118 -0
  157. package/src/utils/currency.test.ts +106 -0
  158. package/src/utils/currency.ts +79 -0
  159. package/src/version.ts +1 -0
@@ -0,0 +1,62 @@
1
+ import { useState, useEffect, useRef } from 'react';
2
+
3
+ export interface UseEntranceAnimationOptions {
4
+ durationMs?: number;
5
+ fps?: number;
6
+ }
7
+
8
+ /**
9
+ * Hook that provides a fade-in animation when a component mounts.
10
+ * Returns an intensity from 0 (just appeared) to 1 (fully visible).
11
+ * Use this to fade in new items in a list.
12
+ */
13
+ export function useEntranceAnimation(options: UseEntranceAnimationOptions = {}): number {
14
+ const { durationMs = 400, fps = 30 } = options;
15
+ const mountTime = useRef(Date.now());
16
+ const [intensity, setIntensity] = useState(0);
17
+
18
+ useEffect(() => {
19
+ const intervalMs = 1000 / fps;
20
+
21
+ const tick = () => {
22
+ const elapsed = Date.now() - mountTime.current;
23
+ const progress = Math.min(1, elapsed / durationMs);
24
+ const eased = 1 - Math.pow(1 - progress, 2);
25
+ setIntensity(eased);
26
+
27
+ if (progress < 1) {
28
+ setTimeout(tick, intervalMs);
29
+ }
30
+ };
31
+
32
+ tick();
33
+ }, [durationMs, fps]);
34
+
35
+ return intensity;
36
+ }
37
+
38
+ /**
39
+ * Apply entrance fade to a color - starts dim and brightens to full color.
40
+ */
41
+ export function applyEntranceFade(intensity: number, color: string, dimColor: string): string {
42
+ if (intensity >= 1) return color;
43
+ if (intensity <= 0) return dimColor;
44
+
45
+ const parseHex = (hex: string): [number, number, number] => {
46
+ const clean = hex.replace('#', '');
47
+ return [
48
+ parseInt(clean.slice(0, 2), 16),
49
+ parseInt(clean.slice(2, 4), 16),
50
+ parseInt(clean.slice(4, 6), 16),
51
+ ];
52
+ };
53
+
54
+ const [r1, g1, b1] = parseHex(dimColor);
55
+ const [r2, g2, b2] = parseHex(color);
56
+
57
+ const r = Math.round(r1 + (r2 - r1) * intensity);
58
+ const g = Math.round(g1 + (g2 - g1) * intensity);
59
+ const b = Math.round(b1 + (b2 - b1) * intensity);
60
+
61
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
62
+ }
@@ -0,0 +1,137 @@
1
+ import { useState, useEffect, useRef, useCallback } from 'react';
2
+
3
+ export interface ExitAnimationItem<T> {
4
+ item: T;
5
+ isExiting: boolean;
6
+ exitIntensity: number;
7
+ }
8
+
9
+ export interface UseExitAnimationOptions<T> {
10
+ durationMs?: number;
11
+ fps?: number;
12
+ getKey: (item: T) => string;
13
+ /** Absolute number of changed items to trigger a bulk skip (default 10) */
14
+ bulkThreshold?: number;
15
+ }
16
+
17
+ export interface UseExitAnimationResult<T> {
18
+ items: ExitAnimationItem<T>[];
19
+ /** True when a bulk change just happened (skip entrance animations) */
20
+ isBulkChange: boolean;
21
+ }
22
+
23
+ interface ExitingEntry<T> {
24
+ item: T;
25
+ startTime: number;
26
+ }
27
+
28
+ export function useExitAnimation<T>(
29
+ items: T[],
30
+ options: UseExitAnimationOptions<T>
31
+ ): UseExitAnimationResult<T> {
32
+ const { durationMs = 500, fps = 30, getKey, bulkThreshold = 10 } = options;
33
+ const prevKeysRef = useRef<Set<string>>(new Set());
34
+ const prevItemsRef = useRef<Map<string, T>>(new Map());
35
+ const exitingRef = useRef<Map<string, ExitingEntry<T>>>(new Map());
36
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
37
+ const [, forceUpdate] = useState(0);
38
+
39
+ const tick = useCallback(() => {
40
+ const now = Date.now();
41
+ let anyActive = false;
42
+
43
+ for (const [key, entry] of exitingRef.current) {
44
+ if (now - entry.startTime >= durationMs) {
45
+ exitingRef.current.delete(key);
46
+ } else {
47
+ anyActive = true;
48
+ }
49
+ }
50
+
51
+ forceUpdate((c: number) => c + 1);
52
+
53
+ if (anyActive) {
54
+ timerRef.current = setTimeout(tick, 1000 / fps);
55
+ } else {
56
+ timerRef.current = null;
57
+ }
58
+ }, [durationMs, fps]);
59
+
60
+ // Diff runs synchronously during render so isBulkChange is available
61
+ // in the same pass that mounts new rows (effects run too late).
62
+ const currentKeys = new Set(items.map(getKey));
63
+ const prevKeys = prevKeysRef.current;
64
+
65
+ let removedCount = 0;
66
+ for (const key of prevKeys) {
67
+ if (!currentKeys.has(key)) removedCount++;
68
+ }
69
+ let addedCount = 0;
70
+ for (const key of currentKeys) {
71
+ if (!prevKeys.has(key)) addedCount++;
72
+ }
73
+
74
+ const totalAffected = removedCount + addedCount;
75
+ const isBulkChange = prevKeys.size > 0 && totalAffected >= bulkThreshold;
76
+
77
+ // Queue exiting items during render so they appear in the same frame
78
+ // they leave `items` — avoids a one-frame gap (flash).
79
+ if (isBulkChange) {
80
+ exitingRef.current.clear();
81
+ } else {
82
+ const prevItems = prevItemsRef.current;
83
+ for (const [key, item] of prevItems) {
84
+ if (!currentKeys.has(key) && !exitingRef.current.has(key)) {
85
+ exitingRef.current.set(key, { item, startTime: Date.now() });
86
+ }
87
+ }
88
+ }
89
+
90
+ useEffect(() => {
91
+ if (isBulkChange && timerRef.current !== null) {
92
+ clearTimeout(timerRef.current);
93
+ timerRef.current = null;
94
+ } else if (exitingRef.current.size > 0 && timerRef.current === null) {
95
+ timerRef.current = setTimeout(tick, 1000 / fps);
96
+ }
97
+
98
+ prevKeysRef.current = currentKeys;
99
+ const newPrevItems = new Map<string, T>();
100
+ for (const item of items) {
101
+ newPrevItems.set(getKey(item), item);
102
+ }
103
+ prevItemsRef.current = newPrevItems;
104
+ }, [items, getKey, tick, fps, isBulkChange, currentKeys]);
105
+
106
+ useEffect(() => {
107
+ return () => {
108
+ if (timerRef.current !== null) {
109
+ clearTimeout(timerRef.current);
110
+ timerRef.current = null;
111
+ }
112
+ };
113
+ }, []);
114
+
115
+ const now = Date.now();
116
+ const result: ExitAnimationItem<T>[] = [];
117
+
118
+ for (const item of items) {
119
+ result.push({ item, isExiting: false, exitIntensity: 1 });
120
+ }
121
+
122
+ if (!isBulkChange) {
123
+ for (const [, entry] of exitingRef.current) {
124
+ const elapsed = now - entry.startTime;
125
+ const progress = Math.min(1, elapsed / durationMs);
126
+ const eased = Math.pow(progress, 2);
127
+
128
+ result.push({
129
+ item: entry.item,
130
+ isExiting: true,
131
+ exitIntensity: Math.max(0, 1 - eased),
132
+ });
133
+ }
134
+ }
135
+
136
+ return { items: result, isBulkChange };
137
+ }
@@ -0,0 +1,138 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ /**
4
+ * Options for the usePulse hook.
5
+ */
6
+ export interface UsePulseOptions {
7
+ /** Whether the pulse animation is enabled */
8
+ enabled: boolean;
9
+ /** Interval between steps in milliseconds (default: 200) */
10
+ intervalMs?: number;
11
+ /** Number of steps in the pulse cycle (default: 12) */
12
+ steps?: number;
13
+ }
14
+
15
+ /**
16
+ * Parses a hex color string to RGB components.
17
+ * Supports both 3-digit (#RGB) and 6-digit (#RRGGBB) formats.
18
+ *
19
+ * @param hex - Hex color string (e.g., "#ff0000" or "#f00")
20
+ * @returns RGB tuple [r, g, b] or null if invalid
21
+ */
22
+ function parseHexColor(hex: string): [number, number, number] | null {
23
+ const cleanHex = hex.startsWith('#') ? hex.slice(1) : hex;
24
+
25
+ let expandedHex: string;
26
+
27
+ if (cleanHex.length === 3) {
28
+ const c0 = cleanHex.slice(0, 1);
29
+ const c1 = cleanHex.slice(1, 2);
30
+ const c2 = cleanHex.slice(2, 3);
31
+ expandedHex = c0 + c0 + c1 + c1 + c2 + c2;
32
+ } else if (cleanHex.length === 6) {
33
+ expandedHex = cleanHex;
34
+ } else {
35
+ return null;
36
+ }
37
+
38
+ const r = parseInt(expandedHex.slice(0, 2), 16);
39
+ const g = parseInt(expandedHex.slice(2, 4), 16);
40
+ const b = parseInt(expandedHex.slice(4, 6), 16);
41
+
42
+ if (isNaN(r) || isNaN(g) || isNaN(b)) {
43
+ return null;
44
+ }
45
+
46
+ return [r, g, b];
47
+ }
48
+
49
+ /**
50
+ * Converts RGB components to a hex color string.
51
+ *
52
+ * @param r - Red component (0-255)
53
+ * @param g - Green component (0-255)
54
+ * @param b - Blue component (0-255)
55
+ * @returns Hex color string (e.g., "#ff0000")
56
+ */
57
+ function rgbToHex(r: number, g: number, b: number): string {
58
+ const toHex = (n: number) => {
59
+ const clamped = Math.max(0, Math.min(255, Math.round(n)));
60
+ return clamped.toString(16).padStart(2, '0');
61
+ };
62
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
63
+ }
64
+
65
+ /**
66
+ * Interpolates between two colors based on a pulse step.
67
+ * Uses sine wave for smooth pulsing effect.
68
+ *
69
+ * @param step - Current pulse step (0 to steps-1)
70
+ * @param baseColor - Base color hex string (e.g., "#333333")
71
+ * @param peakColor - Peak color hex string (e.g., "#ff0000")
72
+ * @param steps - Total number of steps in the pulse cycle (default: 12)
73
+ * @returns Interpolated hex color string
74
+ *
75
+ * @example
76
+ * ```ts
77
+ * const color = getPulseColor(3, "#333333", "#ff0000", 12);
78
+ * ```
79
+ */
80
+ export function getPulseColor(
81
+ step: number,
82
+ baseColor: string,
83
+ peakColor: string,
84
+ steps: number = 12
85
+ ): string {
86
+ const baseRgb = parseHexColor(baseColor);
87
+ const peakRgb = parseHexColor(peakColor);
88
+
89
+ if (!baseRgb || !peakRgb) {
90
+ return baseColor;
91
+ }
92
+
93
+ const t = Math.sin((step / steps) * Math.PI);
94
+
95
+ const r = baseRgb[0] + (peakRgb[0] - baseRgb[0]) * t;
96
+ const g = baseRgb[1] + (peakRgb[1] - baseRgb[1]) * t;
97
+ const b = baseRgb[2] + (peakRgb[2] - baseRgb[2]) * t;
98
+
99
+ return rgbToHex(r, g, b);
100
+ }
101
+
102
+ /**
103
+ * Hook for creating pulse animations.
104
+ * Returns the current step in the pulse cycle, which can be used
105
+ * with getPulseColor to create smooth color transitions.
106
+ *
107
+ * @param options - Configuration options for the pulse animation
108
+ * @returns Current step in the pulse cycle (0 to steps-1), or 0 when disabled
109
+ *
110
+ * @example
111
+ * ```tsx
112
+ * function WarningIndicator({ isWarning }: { isWarning: boolean }) {
113
+ * const step = usePulse({ enabled: isWarning, intervalMs: 150, steps: 12 });
114
+ * const color = getPulseColor(step, "#333333", "#ff6600", 12);
115
+ *
116
+ * return <Box borderColor={color}>Warning!</Box>;
117
+ * }
118
+ * ```
119
+ */
120
+ export function usePulse(options: UsePulseOptions): number {
121
+ const { enabled, intervalMs = 200, steps = 12 } = options;
122
+ const [step, setStep] = useState(0);
123
+
124
+ useEffect(() => {
125
+ if (!enabled) {
126
+ setStep(0);
127
+ return;
128
+ }
129
+
130
+ const interval = setInterval(() => {
131
+ setStep((prev) => (prev + 1) % steps);
132
+ }, intervalMs);
133
+
134
+ return () => clearInterval(interval);
135
+ }, [enabled, intervalMs, steps]);
136
+
137
+ return step;
138
+ }
@@ -0,0 +1,24 @@
1
+ import type { CliRenderer } from '@opentui/core';
2
+ import { useRenderer } from '@opentui/react';
3
+ import { createContext, useContext } from 'react';
4
+
5
+ /**
6
+ * Context to signal we're in test/headless mode where renderer may not be available.
7
+ * When TestModeContext is true, useSafeRenderer returns null instead of throwing.
8
+ */
9
+ export const TestModeContext = createContext<boolean>(false);
10
+
11
+ /**
12
+ * Hook that safely gets the renderer, returning null in test mode.
13
+ * Use this instead of useRenderer() directly when the component needs to work
14
+ * in both production (real renderer) and test/headless mode (no renderer).
15
+ */
16
+ export function useSafeRenderer(): CliRenderer | null {
17
+ const isTestMode = useContext(TestModeContext);
18
+
19
+ if (isTestMode) {
20
+ return null;
21
+ }
22
+
23
+ return useRenderer();
24
+ }
@@ -0,0 +1,111 @@
1
+ import { useState, useEffect, useRef } from 'react';
2
+
3
+ export interface UseValueFlashOptions {
4
+ durationMs?: number;
5
+ increaseOnly?: boolean;
6
+ threshold?: number;
7
+ }
8
+
9
+ export interface UseValueFlashResult {
10
+ isFlashing: boolean;
11
+ intensity: number;
12
+ step: number;
13
+ }
14
+
15
+ export function useValueFlash(
16
+ value: number,
17
+ options: UseValueFlashOptions = {}
18
+ ): UseValueFlashResult {
19
+ const { durationMs = 600, increaseOnly = true, threshold = 0 } = options;
20
+
21
+ const prevValueRef = useRef<number>(value);
22
+ const [isFlashing, setIsFlashing] = useState(false);
23
+ const [step, setStep] = useState(0);
24
+ const flashTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
25
+ const animationIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
26
+
27
+ const steps = 18;
28
+ const stepDuration = durationMs / steps;
29
+
30
+ useEffect(() => {
31
+ const prevValue = prevValueRef.current;
32
+ const delta = value - prevValue;
33
+
34
+ const shouldFlash = increaseOnly
35
+ ? delta > threshold
36
+ : Math.abs(delta) > threshold;
37
+
38
+ if (shouldFlash && prevValue !== value) {
39
+ if (flashTimeoutRef.current) {
40
+ clearTimeout(flashTimeoutRef.current);
41
+ }
42
+ if (animationIntervalRef.current) {
43
+ clearInterval(animationIntervalRef.current);
44
+ }
45
+
46
+ setIsFlashing(true);
47
+ setStep(0);
48
+
49
+ let currentStep = 0;
50
+ animationIntervalRef.current = setInterval(() => {
51
+ currentStep++;
52
+ if (currentStep >= steps) {
53
+ if (animationIntervalRef.current) {
54
+ clearInterval(animationIntervalRef.current);
55
+ animationIntervalRef.current = null;
56
+ }
57
+ setIsFlashing(false);
58
+ setStep(0);
59
+ } else {
60
+ setStep(currentStep);
61
+ }
62
+ }, stepDuration);
63
+ }
64
+
65
+ prevValueRef.current = value;
66
+
67
+ return () => {
68
+ if (flashTimeoutRef.current) {
69
+ clearTimeout(flashTimeoutRef.current);
70
+ }
71
+ if (animationIntervalRef.current) {
72
+ clearInterval(animationIntervalRef.current);
73
+ }
74
+ };
75
+ }, [value, durationMs, increaseOnly, threshold, steps, stepDuration]);
76
+
77
+ const intensity = isFlashing
78
+ ? Math.sin((step / steps) * Math.PI)
79
+ : 0;
80
+
81
+ return { isFlashing, intensity, step };
82
+ }
83
+
84
+ export function interpolateColor(factor: number, colorA: string, colorB: string): string {
85
+ const parseHex = (hex: string): [number, number, number] | null => {
86
+ const clean = hex.startsWith('#') ? hex.slice(1) : hex;
87
+ if (clean.length === 3) {
88
+ const [r, g, b] = clean.split('').map(c => parseInt(c + c, 16));
89
+ return [r!, g!, b!];
90
+ }
91
+ if (clean.length === 6) {
92
+ return [
93
+ parseInt(clean.slice(0, 2), 16),
94
+ parseInt(clean.slice(2, 4), 16),
95
+ parseInt(clean.slice(4, 6), 16),
96
+ ];
97
+ }
98
+ return null;
99
+ };
100
+
101
+ const a = parseHex(colorA);
102
+ const b = parseHex(colorB);
103
+
104
+ if (!a || !b) return colorA;
105
+
106
+ const r = Math.round(a[0] + (b[0] - a[0]) * factor);
107
+ const g = Math.round(a[1] + (b[1] - a[1]) * factor);
108
+ const bl = Math.round(a[2] + (b[2] - a[2]) * factor);
109
+
110
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${bl.toString(16).padStart(2, '0')}`;
111
+ }
@@ -0,0 +1,50 @@
1
+ import { createCliRenderer } from '@opentui/core';
2
+ import { createRoot } from '@opentui/react';
3
+ import { createAppElement, type CreateAppOptions } from './createApp.tsx';
4
+ import type { DemoPreset } from '@/demo/simulator.ts';
5
+
6
+ export interface TuiOptions {
7
+ refreshInterval?: number;
8
+ debug?: boolean;
9
+ demo?: boolean;
10
+ demoSeed?: number;
11
+ demoPreset?: DemoPreset;
12
+ cliPlugins?: string[];
13
+ }
14
+
15
+ export async function startTui(options: TuiOptions = {}) {
16
+ const renderer = await createCliRenderer({
17
+ exitOnCtrlC: false,
18
+ autoFocus: true,
19
+ });
20
+
21
+ const root = createRoot(renderer);
22
+
23
+ const appOptions: CreateAppOptions = {};
24
+
25
+ if (options.refreshInterval !== undefined) {
26
+ appOptions.refreshInterval = options.refreshInterval;
27
+ }
28
+ if (options.debug !== undefined) {
29
+ appOptions.debug = options.debug;
30
+ }
31
+ if (options.demo !== undefined) {
32
+ appOptions.demoMode = options.demo;
33
+ }
34
+ if (options.demoSeed !== undefined) {
35
+ appOptions.demoSeed = options.demoSeed;
36
+ }
37
+ if (options.demoPreset !== undefined) {
38
+ appOptions.demoPreset = options.demoPreset;
39
+ }
40
+ if (options.cliPlugins !== undefined) {
41
+ appOptions.cliPlugins = options.cliPlugins;
42
+ }
43
+
44
+ root.render(createAppElement(appOptions));
45
+
46
+ return renderer;
47
+ }
48
+
49
+ export { App } from './App.tsx';
50
+ export { createAppElement, type CreateAppOptions } from './createApp.tsx';
@@ -0,0 +1,32 @@
1
+ import type { ProviderState } from '../contexts/PluginContext.tsx';
2
+
3
+ let cachedAliases: Map<string, string> | null = null;
4
+ let cachedProviderRef: Map<string, ProviderState> | null = null;
5
+
6
+ function getAliasIndex(providers: Map<string, ProviderState>): Map<string, string> {
7
+ if (cachedAliases && cachedProviderRef === providers) return cachedAliases;
8
+
9
+ const aliases = new Map<string, string>();
10
+ for (const [id, state] of providers) {
11
+ for (const alias of state.plugin.meta?.providerAliases ?? []) {
12
+ aliases.set(alias, id);
13
+ }
14
+ }
15
+ cachedAliases = aliases;
16
+ cachedProviderRef = providers;
17
+ return aliases;
18
+ }
19
+
20
+ export function getProviderColor(
21
+ providerId: string,
22
+ providers: Map<string, ProviderState>,
23
+ fallback: string,
24
+ ): string {
25
+ const direct = providers.get(providerId);
26
+ if (direct) return direct.plugin.meta?.brandColor ?? fallback;
27
+
28
+ const aliasTarget = getAliasIndex(providers).get(providerId);
29
+ if (aliasTarget) return providers.get(aliasTarget)?.plugin.meta?.brandColor ?? fallback;
30
+
31
+ return fallback;
32
+ }