@tokentop/ttop 0.2.2 → 0.4.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.
Files changed (160) hide show
  1. package/README.md +2 -5
  2. package/package.json +8 -5
  3. package/src/agents/aggregator.ts +39 -19
  4. package/src/agents/costing.ts +11 -9
  5. package/src/agents/index.ts +7 -9
  6. package/src/agents/types.ts +2 -2
  7. package/src/cli.ts +28 -23
  8. package/src/config/schema.test.ts +42 -40
  9. package/src/config/schema.ts +26 -26
  10. package/src/demo/simulator.ts +161 -122
  11. package/src/plugins/agents/index.ts +2 -2
  12. package/src/plugins/auth-sources.ts +42 -38
  13. package/src/plugins/lifecycle.ts +42 -38
  14. package/src/plugins/loader.test.ts +49 -49
  15. package/src/plugins/loader.ts +67 -63
  16. package/src/plugins/notification-bus.ts +21 -16
  17. package/src/plugins/notifications/index.ts +2 -2
  18. package/src/plugins/notifications/terminal-bell.ts +36 -23
  19. package/src/plugins/notifications/visual-flash.ts +31 -19
  20. package/src/plugins/npm-installer.ts +23 -25
  21. package/src/plugins/plugin-context-factory.ts +10 -10
  22. package/src/plugins/plugin-host.ts +12 -5
  23. package/src/plugins/providers/anthropic.ts +64 -57
  24. package/src/plugins/providers/antigravity.ts +81 -69
  25. package/src/plugins/providers/codex.ts +42 -42
  26. package/src/plugins/providers/gemini.ts +106 -87
  27. package/src/plugins/providers/github-copilot.ts +49 -45
  28. package/src/plugins/providers/index.ts +10 -12
  29. package/src/plugins/providers/minimax.ts +50 -44
  30. package/src/plugins/providers/openai-api.ts +82 -62
  31. package/src/plugins/providers/opencode-zen.ts +46 -39
  32. package/src/plugins/providers/perplexity.ts +55 -46
  33. package/src/plugins/providers/zai.ts +68 -57
  34. package/src/plugins/registry.ts +80 -71
  35. package/src/plugins/sandbox-guard.test.ts +45 -48
  36. package/src/plugins/sandbox-guard.ts +20 -25
  37. package/src/plugins/sandbox.ts +26 -48
  38. package/src/plugins/themes/catppuccin-latte.ts +29 -29
  39. package/src/plugins/themes/catppuccin-mocha.ts +29 -29
  40. package/src/plugins/themes/claude-code.ts +29 -29
  41. package/src/plugins/themes/dracula.ts +29 -29
  42. package/src/plugins/themes/github-light.ts +29 -29
  43. package/src/plugins/themes/gruvbox-dark.ts +29 -29
  44. package/src/plugins/themes/gruvbox-light.ts +29 -29
  45. package/src/plugins/themes/index.ts +15 -15
  46. package/src/plugins/themes/kanagawa.ts +29 -29
  47. package/src/plugins/themes/nord.ts +29 -29
  48. package/src/plugins/themes/one-dark.ts +29 -29
  49. package/src/plugins/themes/opencode.ts +31 -31
  50. package/src/plugins/themes/rose-pine-dawn.ts +29 -29
  51. package/src/plugins/themes/rose-pine.ts +29 -29
  52. package/src/plugins/themes/solarized-light.ts +29 -29
  53. package/src/plugins/themes/tokyo-night.ts +29 -29
  54. package/src/plugins/types/agent.ts +5 -5
  55. package/src/plugins/types/base.ts +35 -24
  56. package/src/plugins/types/index.ts +9 -9
  57. package/src/plugins/types/notification.ts +12 -12
  58. package/src/plugins/types/provider.ts +12 -8
  59. package/src/plugins/types/theme.ts +31 -23
  60. package/src/plugins/update-checker.ts +6 -6
  61. package/src/pricing/estimator.test.ts +39 -44
  62. package/src/pricing/estimator.ts +7 -13
  63. package/src/pricing/fallback.test.ts +30 -34
  64. package/src/pricing/fallback.ts +54 -27
  65. package/src/pricing/index.ts +18 -12
  66. package/src/pricing/models-dev.ts +6 -6
  67. package/src/storage/database.ts +56 -46
  68. package/src/storage/db.ts +12 -12
  69. package/src/storage/index.ts +44 -45
  70. package/src/storage/migrations/index.ts +2 -2
  71. package/src/storage/paths.ts +13 -13
  72. package/src/storage/persistence-service.ts +206 -0
  73. package/src/storage/repos/agentSessions.ts +33 -23
  74. package/src/storage/repos/providerSnapshots.ts +5 -5
  75. package/src/storage/repos/usageEvents.ts +40 -28
  76. package/src/storage/retention.ts +146 -0
  77. package/src/storage/types.ts +5 -5
  78. package/src/tui/App.tsx +180 -137
  79. package/src/tui/components/CommandPalette.tsx +46 -34
  80. package/src/tui/components/DebugConsole.tsx +39 -33
  81. package/src/tui/components/DebugPanel.tsx +314 -183
  82. package/src/tui/components/FooterHints.tsx +2 -2
  83. package/src/tui/components/GhostProviderCard.tsx +37 -35
  84. package/src/tui/components/Header.tsx +68 -35
  85. package/src/tui/components/HelpOverlay.tsx +99 -29
  86. package/src/tui/components/InlineGauge.tsx +10 -7
  87. package/src/tui/components/InlineSparkline.tsx +17 -11
  88. package/src/tui/components/KpiStrip.tsx +128 -98
  89. package/src/tui/components/LimitGauge.tsx +96 -59
  90. package/src/tui/components/ModalBackdrop.tsx +4 -4
  91. package/src/tui/components/ProviderAggregateStrip.tsx +69 -38
  92. package/src/tui/components/ProviderCard.tsx +232 -226
  93. package/src/tui/components/ProviderDetailPanel.tsx +133 -58
  94. package/src/tui/components/ProviderLimitsPanel.tsx +126 -89
  95. package/src/tui/components/ProvidersList.tsx +215 -126
  96. package/src/tui/components/SessionDetailsDrawer.tsx +191 -94
  97. package/src/tui/components/SessionsTable.tsx +320 -169
  98. package/src/tui/components/SettingsModal.tsx +593 -269
  99. package/src/tui/components/SidebarBreakdown.tsx +30 -16
  100. package/src/tui/components/Skeleton.tsx +24 -17
  101. package/src/tui/components/SmartSidebar.tsx +360 -258
  102. package/src/tui/components/Sparkline.tsx +64 -52
  103. package/src/tui/components/Spinner.tsx +3 -3
  104. package/src/tui/components/StatusBar.tsx +27 -27
  105. package/src/tui/components/ThemePicker.tsx +67 -66
  106. package/src/tui/components/Toast.tsx +9 -6
  107. package/src/tui/components/UsageGauge.tsx +10 -13
  108. package/src/tui/components/index.ts +5 -5
  109. package/src/tui/contexts/AgentSessionContext.tsx +164 -151
  110. package/src/tui/contexts/ConfigContext.tsx +15 -13
  111. package/src/tui/contexts/DashboardRuntimeContext.tsx +26 -26
  112. package/src/tui/contexts/DemoModeContext.tsx +22 -15
  113. package/src/tui/contexts/DrawerContext.tsx +11 -9
  114. package/src/tui/contexts/InputContext.tsx +2 -2
  115. package/src/tui/contexts/LogContext.tsx +62 -47
  116. package/src/tui/contexts/PluginContext.tsx +238 -183
  117. package/src/tui/contexts/RealTimeActivityContext.tsx +70 -36
  118. package/src/tui/contexts/StorageContext.tsx +95 -149
  119. package/src/tui/contexts/ThemeContext.tsx +21 -16
  120. package/src/tui/contexts/TimeWindowContext.tsx +72 -61
  121. package/src/tui/contexts/ToastContext.tsx +10 -7
  122. package/src/tui/contexts/index.ts +9 -5
  123. package/src/tui/createApp.tsx +8 -12
  124. package/src/tui/debug/captureFrame.ts +25 -28
  125. package/src/tui/debug/snapshot.tsx +179 -162
  126. package/src/tui/driver/assertions.ts +30 -22
  127. package/src/tui/driver/cli.ts +231 -221
  128. package/src/tui/driver/coverage.ts +27 -44
  129. package/src/tui/driver/demo-snapshot.ts +33 -33
  130. package/src/tui/driver/diff.ts +26 -32
  131. package/src/tui/driver/driver.ts +43 -48
  132. package/src/tui/driver/index.ts +36 -40
  133. package/src/tui/driver/recorder.ts +37 -48
  134. package/src/tui/driver/test-workflow.ts +51 -51
  135. package/src/tui/driver/test.ts +25 -25
  136. package/src/tui/hooks/index.ts +24 -20
  137. package/src/tui/hooks/useAnimatedValue.ts +13 -18
  138. package/src/tui/hooks/useDashboardKeyboard.ts +118 -98
  139. package/src/tui/hooks/useDashboardState.ts +26 -24
  140. package/src/tui/hooks/useEmaActivity.ts +82 -74
  141. package/src/tui/hooks/useEntranceAnimation.ts +11 -11
  142. package/src/tui/hooks/useExitAnimation.ts +7 -3
  143. package/src/tui/hooks/usePulse.ts +4 -4
  144. package/src/tui/hooks/useSafeRenderer.ts +5 -5
  145. package/src/tui/hooks/useSessionEnrichedProviders.ts +150 -0
  146. package/src/tui/hooks/useValueFlash.ts +10 -14
  147. package/src/tui/index.tsx +29 -6
  148. package/src/tui/shutdown.ts +19 -0
  149. package/src/tui/utils/providerColor.ts +1 -1
  150. package/src/tui/views/Dashboard.tsx +164 -102
  151. package/src/tui/views/HistoricalTrendsView.tsx +579 -250
  152. package/src/tui/views/ProjectsView.tsx +495 -243
  153. package/src/tui/views/RealTimeDashboard.tsx +176 -116
  154. package/src/tui/views/SettingsView.tsx +225 -189
  155. package/src/tui/views/index.ts +2 -2
  156. package/src/types/opentui-test-utils.d.ts +7 -7
  157. package/src/utils/clipboard.ts +29 -29
  158. package/src/utils/currency.test.ts +53 -53
  159. package/src/utils/currency.ts +35 -32
  160. package/src/version.ts +1 -1
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useRef, useCallback } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  const BUCKET_COUNT = 60;
4
4
  const AVG_WINDOW = 10;
@@ -27,22 +27,24 @@ export interface UseActivityRateResult {
27
27
 
28
28
  export function useEmaActivity(totalTokens: number): UseActivityRateResult {
29
29
  const [buckets, setBuckets] = useState<number[]>(() => Array(BUCKET_COUNT).fill(0));
30
- const [activity, setActivity] = useState<ActivityState>({
31
- instantRate: 0, avgRate: 0, isSpike: false
30
+ const [activity, setActivity] = useState<ActivityState>({
31
+ instantRate: 0,
32
+ avgRate: 0,
33
+ isSpike: false,
32
34
  });
33
-
34
- const stateRef = useRef<{
35
- lastTokens: number;
35
+
36
+ const stateRef = useRef<{
37
+ lastTokens: number;
36
38
  lastTokenTime: number;
37
39
  lastShiftTime: number;
38
40
  initialized: boolean;
39
- }>({
40
- lastTokens: -1,
41
+ }>({
42
+ lastTokens: -1,
41
43
  lastTokenTime: Date.now(),
42
44
  lastShiftTime: Date.now(),
43
45
  initialized: false,
44
46
  });
45
-
47
+
46
48
  const debugDataRef = useRef<ActivityDebugData>({
47
49
  lastDeltaTokens: 0,
48
50
  lastDt: 0,
@@ -54,12 +56,12 @@ export function useEmaActivity(totalTokens: number): UseActivityRateResult {
54
56
 
55
57
  const calculateRates = useCallback((bucketData: number[]) => {
56
58
  const recentBuckets = bucketData.slice(-AVG_WINDOW);
57
-
59
+
58
60
  const instantRate = Math.max(...recentBuckets);
59
61
  const avgRate = recentBuckets.reduce((a, b) => a + b, 0) / AVG_WINDOW;
60
-
62
+
61
63
  const isSpike = instantRate >= 500 && instantRate >= avgRate * 3;
62
-
64
+
63
65
  return { instantRate, avgRate, isSpike };
64
66
  }, []);
65
67
 
@@ -69,7 +71,7 @@ export function useEmaActivity(totalTokens: number): UseActivityRateResult {
69
71
  const shift = Math.min(count, BUCKET_COUNT);
70
72
  const decayFactor = 0.7;
71
73
  const zeroThreshold = 0.5; // Values below this become true zero (shows as dot)
72
-
74
+
73
75
  for (let i = 0; i < BUCKET_COUNT - shift; i++) {
74
76
  const val = bucketData[i + shift] ?? 0;
75
77
  newBuckets[i] = val < zeroThreshold ? 0 : val;
@@ -77,69 +79,75 @@ export function useEmaActivity(totalTokens: number): UseActivityRateResult {
77
79
  for (let i = BUCKET_COUNT - shift; i < BUCKET_COUNT; i++) {
78
80
  const prevValue = bucketData[BUCKET_COUNT - shift - 1] ?? 0;
79
81
  const decaySteps = i - (BUCKET_COUNT - shift) + 1;
80
- const decayed = prevValue * Math.pow(decayFactor, decaySteps);
82
+ const decayed = prevValue * decayFactor ** decaySteps;
81
83
  newBuckets[i] = decayed < zeroThreshold ? 0 : decayed;
82
84
  }
83
85
  return newBuckets;
84
86
  }, []);
85
87
 
86
- const applyActivityRamp = useCallback((bucketData: number[], peakRate: number, fillCount: number): number[] => {
87
- const newBuckets = [...bucketData];
88
- const rampWidth = Math.min(fillCount + 2, 6);
89
-
90
- for (let i = 0; i < rampWidth; i++) {
91
- const bucketIdx = BUCKET_COUNT - 1 - i;
92
- if (bucketIdx < 0) break;
93
-
94
- const distanceFromPeak = i;
95
- const rampMultiplier = Math.max(0.15, 1 - (distanceFromPeak * 0.25));
96
- const rampedValue = peakRate * rampMultiplier;
97
-
98
- newBuckets[bucketIdx] = Math.max(newBuckets[bucketIdx] ?? 0, rampedValue);
99
- }
100
- return newBuckets;
101
- }, []);
88
+ const applyActivityRamp = useCallback(
89
+ (bucketData: number[], peakRate: number, fillCount: number): number[] => {
90
+ const newBuckets = [...bucketData];
91
+ const rampWidth = Math.min(fillCount + 2, 6);
102
92
 
103
- const injectDelta = useCallback((delta: number) => {
104
- if (delta <= 0 || delta >= 1000000) return;
105
-
106
- const now = Date.now();
107
- const rate = delta;
108
-
109
- debugDataRef.current.lastDeltaTokens = delta;
110
- debugDataRef.current.lastDt = 1;
111
- debugDataRef.current.currentBucketValue = rate;
112
-
113
- setBuckets(prev => {
114
- const secSinceShift = (now - stateRef.current.lastShiftTime) / 1000;
115
- const bucketsToShift = Math.floor(secSinceShift);
116
-
117
- let newBuckets = shiftBucketsWithDecay(prev, bucketsToShift);
118
- if (bucketsToShift > 0) {
119
- stateRef.current.lastShiftTime = now;
120
- debugDataRef.current.bucketsShifted = bucketsToShift;
93
+ for (let i = 0; i < rampWidth; i++) {
94
+ const bucketIdx = BUCKET_COUNT - 1 - i;
95
+ if (bucketIdx < 0) break;
96
+
97
+ const distanceFromPeak = i;
98
+ const rampMultiplier = Math.max(0.15, 1 - distanceFromPeak * 0.25);
99
+ const rampedValue = peakRate * rampMultiplier;
100
+
101
+ newBuckets[bucketIdx] = Math.max(newBuckets[bucketIdx] ?? 0, rampedValue);
121
102
  }
122
-
123
- newBuckets = applyActivityRamp(newBuckets, rate, bucketsToShift);
124
-
125
- const rates = calculateRates(newBuckets);
126
- setActivity(rates);
127
103
  return newBuckets;
128
- });
129
- }, [calculateRates, shiftBucketsWithDecay, applyActivityRamp]);
104
+ },
105
+ [],
106
+ );
107
+
108
+ const injectDelta = useCallback(
109
+ (delta: number) => {
110
+ if (delta <= 0 || delta >= 1000000) return;
111
+
112
+ const now = Date.now();
113
+ const rate = delta;
114
+
115
+ debugDataRef.current.lastDeltaTokens = delta;
116
+ debugDataRef.current.lastDt = 1;
117
+ debugDataRef.current.currentBucketValue = rate;
118
+
119
+ setBuckets((prev) => {
120
+ const secSinceShift = (now - stateRef.current.lastShiftTime) / 1000;
121
+ const bucketsToShift = Math.floor(secSinceShift);
122
+
123
+ let newBuckets = shiftBucketsWithDecay(prev, bucketsToShift);
124
+ if (bucketsToShift > 0) {
125
+ stateRef.current.lastShiftTime = now;
126
+ debugDataRef.current.bucketsShifted = bucketsToShift;
127
+ }
128
+
129
+ newBuckets = applyActivityRamp(newBuckets, rate, bucketsToShift);
130
+
131
+ const rates = calculateRates(newBuckets);
132
+ setActivity(rates);
133
+ return newBuckets;
134
+ });
135
+ },
136
+ [calculateRates, shiftBucketsWithDecay, applyActivityRamp],
137
+ );
130
138
 
131
139
  useEffect(() => {
132
140
  const now = Date.now();
133
-
141
+
134
142
  debugDataRef.current.refreshCount++;
135
143
  debugDataRef.current.lastRefreshTime = now;
136
144
 
137
145
  const prevTokens = stateRef.current.lastTokens;
138
146
  const wasInitialized = stateRef.current.initialized;
139
-
147
+
140
148
  if (prevTokens === -1 || prevTokens === 0 || !wasInitialized) {
141
- stateRef.current = {
142
- lastTokens: totalTokens,
149
+ stateRef.current = {
150
+ lastTokens: totalTokens,
143
151
  lastTokenTime: now,
144
152
  lastShiftTime: now,
145
153
  initialized: totalTokens > 0,
@@ -149,7 +157,7 @@ export function useEmaActivity(totalTokens: number): UseActivityRateResult {
149
157
 
150
158
  const deltaTokens = Math.max(0, totalTokens - prevTokens);
151
159
  const rawDt = (now - stateRef.current.lastTokenTime) / 1000;
152
-
160
+
153
161
  // Require minimum 0.5s between rate calculations
154
162
  // DON'T update lastTokens here — that would silently consume the delta
155
163
  // without ever writing it to buckets. Let the delta accumulate until
@@ -157,35 +165,35 @@ export function useEmaActivity(totalTokens: number): UseActivityRateResult {
157
165
  if (rawDt < 0.5) {
158
166
  return;
159
167
  }
160
-
168
+
161
169
  // Cap dt at 10s so long idle gaps don't dilute the rate when tokens finally arrive
162
170
  // This shows activity as a "burst" rather than averaged over idle time
163
171
  const dt = Math.min(Math.max(rawDt, 0.5), 10);
164
-
172
+
165
173
  debugDataRef.current.lastDeltaTokens = deltaTokens;
166
174
  debugDataRef.current.lastDt = dt;
167
175
 
168
176
  if (deltaTokens > 0 && deltaTokens < 1000000) {
169
177
  const rate = deltaTokens / dt;
170
178
  debugDataRef.current.currentBucketValue = rate;
171
-
172
- setBuckets(prev => {
179
+
180
+ setBuckets((prev) => {
173
181
  const secSinceShift = (now - stateRef.current.lastShiftTime) / 1000;
174
182
  const bucketsToShift = Math.floor(secSinceShift);
175
-
183
+
176
184
  let newBuckets = shiftBucketsWithDecay(prev, bucketsToShift);
177
185
  if (bucketsToShift > 0) {
178
186
  stateRef.current.lastShiftTime = now;
179
187
  debugDataRef.current.bucketsShifted = bucketsToShift;
180
188
  }
181
-
189
+
182
190
  newBuckets = applyActivityRamp(newBuckets, rate, bucketsToShift);
183
-
191
+
184
192
  const rates = calculateRates(newBuckets);
185
193
  setActivity(rates);
186
194
  return newBuckets;
187
195
  });
188
-
196
+
189
197
  stateRef.current.lastTokens = totalTokens;
190
198
  stateRef.current.lastTokenTime = now;
191
199
  }
@@ -195,15 +203,15 @@ export function useEmaActivity(totalTokens: number): UseActivityRateResult {
195
203
  const interval = setInterval(() => {
196
204
  const now = Date.now();
197
205
  const secSinceShift = (now - stateRef.current.lastShiftTime) / 1000;
198
-
206
+
199
207
  if (secSinceShift >= 1) {
200
208
  const bucketsToShift = Math.floor(secSinceShift);
201
209
  debugDataRef.current.bucketsShifted = bucketsToShift;
202
-
203
- setBuckets(prev => {
210
+
211
+ setBuckets((prev) => {
204
212
  const newBuckets = shiftBucketsWithDecay(prev, bucketsToShift);
205
213
  stateRef.current.lastShiftTime = now;
206
-
214
+
207
215
  const rates = calculateRates(newBuckets);
208
216
  setActivity(rates);
209
217
  return newBuckets;
@@ -214,8 +222,8 @@ export function useEmaActivity(totalTokens: number): UseActivityRateResult {
214
222
  return () => clearInterval(interval);
215
223
  }, [calculateRates, shiftBucketsWithDecay]);
216
224
 
217
- return {
218
- activity,
225
+ return {
226
+ activity,
219
227
  sparkData: buckets,
220
228
  debugDataRef,
221
229
  injectDelta,
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useRef } from 'react';
1
+ import { useEffect, useRef, useState } from "react";
2
2
 
3
3
  export interface UseEntranceAnimationOptions {
4
4
  durationMs?: number;
@@ -17,18 +17,18 @@ export function useEntranceAnimation(options: UseEntranceAnimationOptions = {}):
17
17
 
18
18
  useEffect(() => {
19
19
  const intervalMs = 1000 / fps;
20
-
20
+
21
21
  const tick = () => {
22
22
  const elapsed = Date.now() - mountTime.current;
23
23
  const progress = Math.min(1, elapsed / durationMs);
24
- const eased = 1 - Math.pow(1 - progress, 2);
24
+ const eased = 1 - (1 - progress) ** 2;
25
25
  setIntensity(eased);
26
-
26
+
27
27
  if (progress < 1) {
28
28
  setTimeout(tick, intervalMs);
29
29
  }
30
30
  };
31
-
31
+
32
32
  tick();
33
33
  }, [durationMs, fps]);
34
34
 
@@ -41,22 +41,22 @@ export function useEntranceAnimation(options: UseEntranceAnimationOptions = {}):
41
41
  export function applyEntranceFade(intensity: number, color: string, dimColor: string): string {
42
42
  if (intensity >= 1) return color;
43
43
  if (intensity <= 0) return dimColor;
44
-
44
+
45
45
  const parseHex = (hex: string): [number, number, number] => {
46
- const clean = hex.replace('#', '');
46
+ const clean = hex.replace("#", "");
47
47
  return [
48
48
  parseInt(clean.slice(0, 2), 16),
49
49
  parseInt(clean.slice(2, 4), 16),
50
50
  parseInt(clean.slice(4, 6), 16),
51
51
  ];
52
52
  };
53
-
53
+
54
54
  const [r1, g1, b1] = parseHex(dimColor);
55
55
  const [r2, g2, b2] = parseHex(color);
56
-
56
+
57
57
  const r = Math.round(r1 + (r2 - r1) * intensity);
58
58
  const g = Math.round(g1 + (g2 - g1) * intensity);
59
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')}`;
60
+
61
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
62
62
  }
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useRef, useCallback } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  export interface ExitAnimationItem<T> {
4
4
  item: T;
@@ -27,7 +27,7 @@ interface ExitingEntry<T> {
27
27
 
28
28
  export function useExitAnimation<T>(
29
29
  items: T[],
30
- options: UseExitAnimationOptions<T>
30
+ options: UseExitAnimationOptions<T>,
31
31
  ): UseExitAnimationResult<T> {
32
32
  const { durationMs = 500, fps = 30, getKey, bulkThreshold = 10 } = options;
33
33
  const prevKeysRef = useRef<Set<string>>(new Set());
@@ -43,6 +43,10 @@ export function useExitAnimation<T>(
43
43
  for (const [key, entry] of exitingRef.current) {
44
44
  if (now - entry.startTime >= durationMs) {
45
45
  exitingRef.current.delete(key);
46
+ // Also clean from prev refs so the render-time diff
47
+ // doesn't re-add them on the next forceUpdate render.
48
+ prevKeysRef.current.delete(key);
49
+ prevItemsRef.current.delete(key);
46
50
  } else {
47
51
  anyActive = true;
48
52
  }
@@ -123,7 +127,7 @@ export function useExitAnimation<T>(
123
127
  for (const [, entry] of exitingRef.current) {
124
128
  const elapsed = now - entry.startTime;
125
129
  const progress = Math.min(1, elapsed / durationMs);
126
- const eased = Math.pow(progress, 2);
130
+ const eased = progress ** 2;
127
131
 
128
132
  result.push({
129
133
  item: entry.item,
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from 'react';
1
+ import { useEffect, useState } from "react";
2
2
 
3
3
  /**
4
4
  * Options for the usePulse hook.
@@ -20,7 +20,7 @@ export interface UsePulseOptions {
20
20
  * @returns RGB tuple [r, g, b] or null if invalid
21
21
  */
22
22
  function parseHexColor(hex: string): [number, number, number] | null {
23
- const cleanHex = hex.startsWith('#') ? hex.slice(1) : hex;
23
+ const cleanHex = hex.startsWith("#") ? hex.slice(1) : hex;
24
24
 
25
25
  let expandedHex: string;
26
26
 
@@ -57,7 +57,7 @@ function parseHexColor(hex: string): [number, number, number] | null {
57
57
  function rgbToHex(r: number, g: number, b: number): string {
58
58
  const toHex = (n: number) => {
59
59
  const clamped = Math.max(0, Math.min(255, Math.round(n)));
60
- return clamped.toString(16).padStart(2, '0');
60
+ return clamped.toString(16).padStart(2, "0");
61
61
  };
62
62
  return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
63
63
  }
@@ -81,7 +81,7 @@ export function getPulseColor(
81
81
  step: number,
82
82
  baseColor: string,
83
83
  peakColor: string,
84
- steps: number = 12
84
+ steps: number = 12,
85
85
  ): string {
86
86
  const baseRgb = parseHexColor(baseColor);
87
87
  const peakRgb = parseHexColor(peakColor);
@@ -1,6 +1,6 @@
1
- import type { CliRenderer } from '@opentui/core';
2
- import { useRenderer } from '@opentui/react';
3
- import { createContext, useContext } from 'react';
1
+ import type { CliRenderer } from "@opentui/core";
2
+ import { useRenderer } from "@opentui/react";
3
+ import { createContext, useContext } from "react";
4
4
 
5
5
  /**
6
6
  * Context to signal we're in test/headless mode where renderer may not be available.
@@ -15,10 +15,10 @@ export const TestModeContext = createContext<boolean>(false);
15
15
  */
16
16
  export function useSafeRenderer(): CliRenderer | null {
17
17
  const isTestMode = useContext(TestModeContext);
18
-
18
+
19
19
  if (isTestMode) {
20
20
  return null;
21
21
  }
22
-
22
+
23
23
  return useRenderer();
24
24
  }
@@ -0,0 +1,150 @@
1
+ import { useMemo } from "react";
2
+ import type { AgentSessionAggregate } from "@/agents/types.ts";
3
+ import type { ProviderUsageData } from "@/plugins/types/provider.ts";
4
+ import type { ProviderState } from "../contexts/PluginContext.tsx";
5
+
6
+ interface ProviderSessionTotals {
7
+ input: number;
8
+ output: number;
9
+ cacheRead: number;
10
+ cacheWrite: number;
11
+ costToday: number;
12
+ costMonth: number;
13
+ }
14
+
15
+ function aggregateSessionsByProvider(
16
+ sessions: AgentSessionAggregate[],
17
+ ): Map<string, ProviderSessionTotals> {
18
+ const totals = new Map<string, ProviderSessionTotals>();
19
+
20
+ for (const session of sessions) {
21
+ for (const stream of session.streams) {
22
+ const pid = stream.providerId;
23
+ let entry = totals.get(pid);
24
+ if (!entry) {
25
+ entry = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costToday: 0, costMonth: 0 };
26
+ totals.set(pid, entry);
27
+ }
28
+ entry.input += stream.tokens.input;
29
+ entry.output += stream.tokens.output;
30
+ entry.cacheRead += stream.tokens.cacheRead ?? 0;
31
+ entry.cacheWrite += stream.tokens.cacheWrite ?? 0;
32
+ }
33
+
34
+ // Cost is session-level — attribute to providers proportionally by token share
35
+ if (session.streams.length > 0) {
36
+ const totalSessionTokens = session.streams.reduce(
37
+ (s, st) =>
38
+ s +
39
+ st.tokens.input +
40
+ st.tokens.output +
41
+ (st.tokens.cacheRead ?? 0) +
42
+ (st.tokens.cacheWrite ?? 0),
43
+ 0,
44
+ );
45
+
46
+ for (const stream of session.streams) {
47
+ const pid = stream.providerId;
48
+ const entry = totals.get(pid)!;
49
+ const streamTokens =
50
+ stream.tokens.input +
51
+ stream.tokens.output +
52
+ (stream.tokens.cacheRead ?? 0) +
53
+ (stream.tokens.cacheWrite ?? 0);
54
+ const ratio = totalSessionTokens > 0 ? streamTokens / totalSessionTokens : 0;
55
+ entry.costToday += session.costInDay * ratio;
56
+ entry.costMonth += session.costInMonth * ratio;
57
+ }
58
+ }
59
+ }
60
+
61
+ return totals;
62
+ }
63
+
64
+ function enrichUsageData(
65
+ usage: ProviderUsageData,
66
+ sessionTotals: ProviderSessionTotals,
67
+ ): ProviderUsageData {
68
+ const enriched = { ...usage };
69
+
70
+ if (!enriched.tokens && (sessionTotals.input > 0 || sessionTotals.output > 0)) {
71
+ enriched.tokens = {
72
+ input: sessionTotals.input,
73
+ output: sessionTotals.output,
74
+ ...(sessionTotals.cacheRead > 0 ? { cacheRead: sessionTotals.cacheRead } : {}),
75
+ ...(sessionTotals.cacheWrite > 0 ? { cacheWrite: sessionTotals.cacheWrite } : {}),
76
+ };
77
+ }
78
+
79
+ const hasSessionCost = sessionTotals.costToday > 0 || sessionTotals.costMonth > 0;
80
+ if (hasSessionCost) {
81
+ const costObj: NonNullable<ProviderUsageData["cost"]> = {
82
+ source: enriched.cost?.source ?? "estimated",
83
+ };
84
+ if (enriched.cost?.actual) costObj.actual = enriched.cost.actual;
85
+ if (enriched.cost?.estimated) {
86
+ costObj.estimated = enriched.cost.estimated;
87
+ } else if (sessionTotals.costToday > 0) {
88
+ costObj.estimated = { total: sessionTotals.costToday, currency: "USD" };
89
+ }
90
+ if (sessionTotals.costToday > 0) {
91
+ costObj.estimatedDaily = { total: sessionTotals.costToday, currency: "USD" };
92
+ }
93
+ if (sessionTotals.costMonth > 0) {
94
+ costObj.estimatedMonthly = { total: sessionTotals.costMonth, currency: "USD" };
95
+ }
96
+ enriched.cost = costObj;
97
+ }
98
+
99
+ return enriched;
100
+ }
101
+
102
+ function findProviderMatch(
103
+ providerId: string,
104
+ providers: Map<string, ProviderState>,
105
+ ): string | null {
106
+ if (providers.has(providerId)) return providerId;
107
+
108
+ for (const [pluginId, state] of providers) {
109
+ const aliases = state.plugin.meta?.providerAliases ?? [];
110
+ if (aliases.includes(providerId)) return pluginId;
111
+ if (pluginId.includes(providerId) || providerId.includes(pluginId)) return pluginId;
112
+ }
113
+ return null;
114
+ }
115
+
116
+ export function useSessionEnrichedProviders(
117
+ providers: Map<string, ProviderState>,
118
+ sessions: AgentSessionAggregate[],
119
+ ): Map<string, ProviderState> {
120
+ return useMemo(() => {
121
+ if (sessions.length === 0) return providers;
122
+
123
+ const sessionTotals = aggregateSessionsByProvider(sessions);
124
+ if (sessionTotals.size === 0) return providers;
125
+
126
+ const enriched = new Map(providers);
127
+ let changed = false;
128
+
129
+ for (const [sessionProviderId, totals] of sessionTotals) {
130
+ const pluginId = findProviderMatch(sessionProviderId, providers);
131
+ if (!pluginId) continue;
132
+
133
+ const state = enriched.get(pluginId);
134
+ if (!state?.usage) continue;
135
+ const alreadyHasAll =
136
+ state.usage.tokens &&
137
+ state.usage.cost?.estimatedDaily &&
138
+ state.usage.cost?.estimatedMonthly;
139
+ if (alreadyHasAll) continue;
140
+
141
+ const enrichedUsage = enrichUsageData(state.usage, totals);
142
+ if (enrichedUsage !== state.usage) {
143
+ enriched.set(pluginId, { ...state, usage: enrichedUsage });
144
+ changed = true;
145
+ }
146
+ }
147
+
148
+ return changed ? enriched : providers;
149
+ }, [providers, sessions]);
150
+ }
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useRef } from 'react';
1
+ import { useEffect, useRef, useState } from "react";
2
2
 
3
3
  export interface UseValueFlashOptions {
4
4
  durationMs?: number;
@@ -14,10 +14,10 @@ export interface UseValueFlashResult {
14
14
 
15
15
  export function useValueFlash(
16
16
  value: number,
17
- options: UseValueFlashOptions = {}
17
+ options: UseValueFlashOptions = {},
18
18
  ): UseValueFlashResult {
19
19
  const { durationMs = 600, increaseOnly = true, threshold = 0 } = options;
20
-
20
+
21
21
  const prevValueRef = useRef<number>(value);
22
22
  const [isFlashing, setIsFlashing] = useState(false);
23
23
  const [step, setStep] = useState(0);
@@ -30,10 +30,8 @@ export function useValueFlash(
30
30
  useEffect(() => {
31
31
  const prevValue = prevValueRef.current;
32
32
  const delta = value - prevValue;
33
-
34
- const shouldFlash = increaseOnly
35
- ? delta > threshold
36
- : Math.abs(delta) > threshold;
33
+
34
+ const shouldFlash = increaseOnly ? delta > threshold : Math.abs(delta) > threshold;
37
35
 
38
36
  if (shouldFlash && prevValue !== value) {
39
37
  if (flashTimeoutRef.current) {
@@ -74,18 +72,16 @@ export function useValueFlash(
74
72
  };
75
73
  }, [value, durationMs, increaseOnly, threshold, steps, stepDuration]);
76
74
 
77
- const intensity = isFlashing
78
- ? Math.sin((step / steps) * Math.PI)
79
- : 0;
75
+ const intensity = isFlashing ? Math.sin((step / steps) * Math.PI) : 0;
80
76
 
81
77
  return { isFlashing, intensity, step };
82
78
  }
83
79
 
84
80
  export function interpolateColor(factor: number, colorA: string, colorB: string): string {
85
81
  const parseHex = (hex: string): [number, number, number] | null => {
86
- const clean = hex.startsWith('#') ? hex.slice(1) : hex;
82
+ const clean = hex.startsWith("#") ? hex.slice(1) : hex;
87
83
  if (clean.length === 3) {
88
- const [r, g, b] = clean.split('').map(c => parseInt(c + c, 16));
84
+ const [r, g, b] = clean.split("").map((c) => parseInt(c + c, 16));
89
85
  return [r!, g!, b!];
90
86
  }
91
87
  if (clean.length === 6) {
@@ -100,12 +96,12 @@ export function interpolateColor(factor: number, colorA: string, colorB: string)
100
96
 
101
97
  const a = parseHex(colorA);
102
98
  const b = parseHex(colorB);
103
-
99
+
104
100
  if (!a || !b) return colorA;
105
101
 
106
102
  const r = Math.round(a[0] + (b[0] - a[0]) * factor);
107
103
  const g = Math.round(a[1] + (b[1] - a[1]) * factor);
108
104
  const bl = Math.round(a[2] + (b[2] - a[2]) * factor);
109
105
 
110
- return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${bl.toString(16).padStart(2, '0')}`;
106
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${bl.toString(16).padStart(2, "0")}`;
111
107
  }
package/src/tui/index.tsx CHANGED
@@ -1,7 +1,10 @@
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';
1
+ import { createCliRenderer } from "@opentui/core";
2
+ import { createRoot } from "@opentui/react";
3
+ import type { DemoPreset } from "@/demo/simulator.ts";
4
+ import { pluginLifecycle } from "@/plugins/lifecycle.ts";
5
+ import { notificationBus } from "@/plugins/notification-bus.ts";
6
+ import { type CreateAppOptions, createAppElement } from "./createApp.tsx";
7
+ import { registerShutdown } from "./shutdown.ts";
5
8
 
6
9
  export interface TuiOptions {
7
10
  refreshInterval?: number;
@@ -12,6 +15,8 @@ export interface TuiOptions {
12
15
  cliPlugins?: string[];
13
16
  }
14
17
 
18
+ const FORCE_EXIT_GRACE_MS = 1_000;
19
+
15
20
  export async function startTui(options: TuiOptions = {}) {
16
21
  const renderer = await createCliRenderer({
17
22
  exitOnCtrlC: false,
@@ -43,8 +48,26 @@ export async function startTui(options: TuiOptions = {}) {
43
48
 
44
49
  root.render(createAppElement(appOptions));
45
50
 
51
+ const shutdown = async () => {
52
+ try {
53
+ await pluginLifecycle.stopAll();
54
+ await pluginLifecycle.destroyAll();
55
+ } catch {
56
+ /* best-effort */
57
+ }
58
+ notificationBus.destroy();
59
+ renderer.destroy();
60
+
61
+ const forceExit = setTimeout(() => process.exit(0), FORCE_EXIT_GRACE_MS);
62
+ if (typeof forceExit === "object" && "unref" in forceExit) {
63
+ forceExit.unref();
64
+ }
65
+ };
66
+
67
+ registerShutdown(shutdown);
68
+
46
69
  return renderer;
47
70
  }
48
71
 
49
- export { App } from './App.tsx';
50
- export { createAppElement, type CreateAppOptions } from './createApp.tsx';
72
+ export { App } from "./App.tsx";
73
+ export { type CreateAppOptions, createAppElement } from "./createApp.tsx";