@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,309 @@
1
+ import { useState, useEffect, useCallback, forwardRef } from 'react';
2
+ import type { BoxRenderable } from '@opentui/core';
3
+ import { useTerminalDimensions } from '@opentui/react';
4
+ import { useColors } from '../contexts/ThemeContext.tsx';
5
+ import { UsageGauge } from './UsageGauge.tsx';
6
+ import { useSpinner } from './Spinner.tsx';
7
+ import { SkeletonProviderContent } from './Skeleton.tsx';
8
+ import type { ProviderUsageData } from '@/plugins/types/provider.ts';
9
+
10
+ interface CompactGaugeProps {
11
+ label: string;
12
+ usedPercent: number | null;
13
+ windowMinutes?: number | undefined;
14
+ resetsAt?: number | undefined;
15
+ color?: string | undefined;
16
+ }
17
+
18
+ function CompactGauge({ label, usedPercent, resetsAt, color }: CompactGaugeProps) {
19
+ const colors = useColors();
20
+ const providerColor = color ?? colors.primary;
21
+ const percent = usedPercent ?? 0;
22
+
23
+ const barWidth = 20;
24
+ const filledWidth = Math.round((percent / 100) * barWidth);
25
+ const emptyWidth = barWidth - filledWidth;
26
+
27
+ const fillColor = percent >= 90 ? colors.gaugeDanger :
28
+ percent >= 70 ? colors.gaugeWarning :
29
+ providerColor;
30
+
31
+ const filledBar = '█'.repeat(filledWidth);
32
+ const emptyBar = '·'.repeat(emptyWidth);
33
+
34
+ const resetText = resetsAt ? formatResetTime(resetsAt) : '';
35
+
36
+ return (
37
+ <box flexDirection="column">
38
+ <text fg={providerColor}>{label}</text>
39
+ <text>
40
+ <span fg={fillColor}>{filledBar}</span>
41
+ <span fg={colors.gaugeBackground}>{emptyBar}</span>
42
+ <span fg={colors.text}> {percent !== null ? `${Math.round(percent)}%` : '--'}</span>
43
+ </text>
44
+ {resetText && <text fg={colors.textMuted}>{resetText}</text>}
45
+ </box>
46
+ );
47
+ }
48
+
49
+ interface ProviderCardProps {
50
+ name: string;
51
+ configured: boolean;
52
+ loading: boolean;
53
+ usage: ProviderUsageData | null;
54
+ color?: string | undefined;
55
+ focused?: boolean | undefined;
56
+ onFocus?: (() => void) | undefined;
57
+ }
58
+
59
+ export const ProviderCard = forwardRef<BoxRenderable, ProviderCardProps>(({
60
+ name,
61
+ configured,
62
+ loading,
63
+ usage,
64
+ color,
65
+ focused = false,
66
+ onFocus,
67
+ }, ref) => {
68
+ const colors = useColors();
69
+ const { width: termWidth } = useTerminalDimensions();
70
+ const providerColor = color ?? colors.primary;
71
+ const spinnerFrame = useSpinner();
72
+
73
+ const [pulseStep, setPulseStep] = useState(0);
74
+
75
+ const rawItems = usage?.limits?.items;
76
+ const hasItems = rawItems && rawItems.length > 0;
77
+
78
+ const sortedItems = hasItems
79
+ ? [...rawItems].sort((a, b) => (b.usedPercent ?? 0) - (a.usedPercent ?? 0))
80
+ : [];
81
+
82
+ const maxVisibleGauges = 3;
83
+ const hiddenGaugeCount = sortedItems.length > maxVisibleGauges ? sortedItems.length - maxVisibleGauges : 0;
84
+ const visibleItems = sortedItems.length > maxVisibleGauges ? sortedItems.slice(0, maxVisibleGauges) : sortedItems;
85
+ const useCompactMode = visibleItems.length > 3;
86
+
87
+ useEffect(() => {
88
+ if (!usage?.limitReached) {
89
+ setPulseStep(0);
90
+ return;
91
+ }
92
+
93
+ const timer = setInterval(() => {
94
+ setPulseStep((p) => (p + 1) % 12);
95
+ }, 150);
96
+
97
+ return () => clearInterval(timer);
98
+ }, [usage?.limitReached]);
99
+
100
+ const handleClick = useCallback(() => {
101
+ if (onFocus) {
102
+ onFocus();
103
+ }
104
+ }, [onFocus]);
105
+
106
+ const statusColor = !configured ? colors.textSubtle :
107
+ loading ? colors.info :
108
+ usage?.error ? colors.error :
109
+ usage?.limitReached ? colors.warning :
110
+ colors.success;
111
+
112
+ const statusIcon = !configured ? '○' :
113
+ usage?.error ? '✗' :
114
+ usage?.limitReached ? '!' :
115
+ '●';
116
+
117
+ const isInitialLoad = loading && !usage;
118
+
119
+ const getWarningBorderColor = (step: number, baseColor: string): string => {
120
+ const intensity = Math.sin((step / 12) * Math.PI);
121
+ const base = hexToRgb(baseColor);
122
+ const warn = hexToRgb(colors.warning);
123
+ if (!base || !warn) return baseColor;
124
+ const r = Math.round(base.r + (warn.r - base.r) * intensity);
125
+ const g = Math.round(base.g + (warn.g - base.g) * intensity);
126
+ const b = Math.round(base.b + (warn.b - base.b) * intensity);
127
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
128
+ };
129
+
130
+ const baseBorderColor = focused ? providerColor : colors.border;
131
+ const borderColor = usage?.limitReached
132
+ ? getWarningBorderColor(pulseStep, baseBorderColor)
133
+ : baseBorderColor;
134
+
135
+ const numColumns = termWidth >= 160 ? 3 : termWidth >= 100 ? 2 : 1;
136
+ const cardPadding = 4;
137
+ const gaps = numColumns - 1;
138
+ const cardWidth = Math.max(40, Math.min(60, Math.floor((termWidth - cardPadding - gaps) / numColumns)));
139
+
140
+ return (
141
+ <box
142
+ ref={ref}
143
+ border
144
+ borderStyle="rounded"
145
+ borderColor={borderColor}
146
+ padding={1}
147
+ flexDirection="column"
148
+ gap={1}
149
+ width={cardWidth}
150
+ focusable
151
+ onMouseDown={handleClick}
152
+ >
153
+ <box flexDirection="row" justifyContent="space-between" alignItems="center">
154
+ <text>
155
+ <span fg={providerColor}>
156
+ <strong>{name}</strong>
157
+ </span>
158
+ </text>
159
+ <text fg={statusColor}>
160
+ {configured && loading ? spinnerFrame : statusIcon}
161
+ </text>
162
+ </box>
163
+
164
+ {!configured && (
165
+ <text fg={colors.textSubtle}>Not configured</text>
166
+ )}
167
+
168
+ {configured && isInitialLoad && (
169
+ <SkeletonProviderContent />
170
+ )}
171
+
172
+ {configured && !isInitialLoad && usage?.error && (
173
+ <text fg={colors.error}>{usage.error}</text>
174
+ )}
175
+
176
+ {configured && !isInitialLoad && usage && !usage.error && (
177
+ <box flexDirection="column" flexGrow={1} justifyContent="space-between">
178
+ <box flexDirection="column" gap={1}>
179
+ {usage.planType && (
180
+ <text fg={colors.textMuted}>{usage.planType}</text>
181
+ )}
182
+
183
+ {hasItems ? (
184
+ <>
185
+ {useCompactMode ? (
186
+ visibleItems.map((limit, idx) => (
187
+ <CompactGauge
188
+ key={idx}
189
+ label={limit.label ?? 'Usage'}
190
+ usedPercent={limit.usedPercent}
191
+ color={providerColor}
192
+ {...(limit.windowMinutes ? { windowMinutes: limit.windowMinutes } : {})}
193
+ {...(limit.resetsAt ? { resetsAt: limit.resetsAt } : {})}
194
+ />
195
+ ))
196
+ ) : (
197
+ visibleItems.map((limit, idx) => {
198
+ const labelText = limit.label ?? 'Usage';
199
+ const labelHasWindow = labelText.toLowerCase().includes('window') ||
200
+ labelText.toLowerCase().includes('hour') ||
201
+ labelText.toLowerCase().includes('day');
202
+ return (
203
+ <UsageGauge
204
+ key={idx}
205
+ label={labelText}
206
+ usedPercent={limit.usedPercent}
207
+ color={providerColor}
208
+ {...(limit.windowMinutes && !labelHasWindow ? { windowLabel: formatWindow(limit.windowMinutes) } : {})}
209
+ {...(limit.resetsAt ? { resetsAt: limit.resetsAt } : {})}
210
+ />
211
+ );
212
+ })
213
+ )}
214
+ {hiddenGaugeCount > 0 && (
215
+ <text fg={colors.textSubtle}>+{hiddenGaugeCount} more limit{hiddenGaugeCount > 1 ? 's' : ''}</text>
216
+ )}
217
+ </>
218
+ ) : (
219
+ <>
220
+ {usage.limits?.primary && (() => {
221
+ const labelText = usage.limits.primary.label ?? 'Usage';
222
+ const labelHasWindow = labelText.toLowerCase().includes('window') ||
223
+ labelText.toLowerCase().includes('hour') ||
224
+ labelText.toLowerCase().includes('day');
225
+ return (
226
+ <UsageGauge
227
+ label={labelText}
228
+ usedPercent={usage.limits.primary.usedPercent}
229
+ color={providerColor}
230
+ {...(usage.limits.primary.windowMinutes && !labelHasWindow ? { windowLabel: formatWindow(usage.limits.primary.windowMinutes) } : {})}
231
+ {...(usage.limits.primary.resetsAt ? { resetsAt: usage.limits.primary.resetsAt } : {})}
232
+ />
233
+ );
234
+ })()}
235
+
236
+ {usage.limits?.secondary && (() => {
237
+ const labelText = usage.limits.secondary.label ?? 'Secondary';
238
+ const labelHasWindow = labelText.toLowerCase().includes('window') ||
239
+ labelText.toLowerCase().includes('hour') ||
240
+ labelText.toLowerCase().includes('day');
241
+ return (
242
+ <UsageGauge
243
+ label={labelText}
244
+ usedPercent={usage.limits.secondary.usedPercent}
245
+ color={providerColor}
246
+ {...(usage.limits.secondary.windowMinutes && !labelHasWindow ? { windowLabel: formatWindow(usage.limits.secondary.windowMinutes) } : {})}
247
+ {...(usage.limits.secondary.resetsAt ? { resetsAt: usage.limits.secondary.resetsAt } : {})}
248
+ />
249
+ );
250
+ })()}
251
+ </>
252
+ )}
253
+
254
+ {usage.credits && (
255
+ <box flexDirection="row" gap={1}>
256
+ <text fg={colors.textMuted}>Credits:</text>
257
+ <text fg={usage.credits.unlimited ? colors.success : colors.text}>
258
+ {usage.credits.unlimited ? 'Unlimited' : usage.credits.balance ?? 'Unknown'}
259
+ </text>
260
+ </box>
261
+ )}
262
+ </box>
263
+ </box>
264
+ )}
265
+ </box>
266
+ );
267
+ });
268
+ ProviderCard.displayName = 'ProviderCard';
269
+
270
+ function formatWindow(minutes: number): string {
271
+ if (minutes >= 1440) {
272
+ const days = Math.round(minutes / 1440);
273
+ return `${days}-day window`;
274
+ }
275
+ if (minutes >= 60) {
276
+ const hours = Math.round(minutes / 60);
277
+ return `${hours}-hour window`;
278
+ }
279
+ return `${minutes}-minute window`;
280
+ }
281
+
282
+ function formatResetTime(timestamp: number): string {
283
+ const now = Date.now();
284
+ const diff = timestamp - now;
285
+
286
+ if (diff <= 0) return 'soon';
287
+
288
+ const minutes = Math.floor(diff / 60000);
289
+ const hours = Math.floor(minutes / 60);
290
+ const days = Math.floor(hours / 24);
291
+
292
+ if (days > 0) {
293
+ return `${days}d ${hours % 24}h`;
294
+ }
295
+ if (hours > 0) {
296
+ return `${hours}h ${minutes % 60}m`;
297
+ }
298
+ return `${minutes}m`;
299
+ }
300
+
301
+ function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
302
+ const match = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
303
+ if (!match) return null;
304
+ return {
305
+ r: parseInt(match[1]!, 16),
306
+ g: parseInt(match[2]!, 16),
307
+ b: parseInt(match[3]!, 16),
308
+ };
309
+ }
@@ -0,0 +1,237 @@
1
+ import { useColors } from '../contexts/ThemeContext.tsx';
2
+ import { UsageGauge } from './UsageGauge.tsx';
3
+ import { Sparkline } from './Sparkline.tsx';
4
+ import type { ProviderState } from '../contexts/PluginContext.tsx';
5
+ import type { UsageLimit } from '@/plugins/types/provider.ts';
6
+
7
+ function pad(str: string, len: number): string {
8
+ return str.length >= len ? str.slice(0, len) : str + ' '.repeat(len - str.length);
9
+ }
10
+
11
+ function padStart(str: string, len: number): string {
12
+ return str.length >= len ? str.slice(0, len) : ' '.repeat(len - str.length) + str;
13
+ }
14
+
15
+ function formatTokenCount(val: number): string {
16
+ if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(2)}M`;
17
+ if (val >= 1_000) return `${(val / 1_000).toFixed(1)}K`;
18
+ return String(Math.round(val));
19
+ }
20
+
21
+ function formatCost(val: number): string {
22
+ if (val >= 100) return `$${Math.round(val)}`;
23
+ if (val >= 1) return `$${val.toFixed(2)}`;
24
+ return `$${val.toFixed(4)}`;
25
+ }
26
+
27
+ function formatTimestamp(ts: number): string {
28
+ const d = new Date(ts);
29
+ const h = d.getHours().toString().padStart(2, '0');
30
+ const m = d.getMinutes().toString().padStart(2, '0');
31
+ const s = d.getSeconds().toString().padStart(2, '0');
32
+ return `${h}:${m}:${s}`;
33
+ }
34
+
35
+ function formatWindow(minutes: number): string {
36
+ if (minutes >= 1440) {
37
+ const days = Math.round(minutes / 1440);
38
+ return `${days}d window`;
39
+ }
40
+ if (minutes >= 60) {
41
+ const hours = Math.round(minutes / 60);
42
+ return `${hours}h window`;
43
+ }
44
+ return `${minutes}m window`;
45
+ }
46
+
47
+ interface ProviderDetailPanelProps {
48
+ provider: ProviderState;
49
+ }
50
+
51
+ export function ProviderDetailPanel({ provider }: ProviderDetailPanelProps) {
52
+ const colors = useColors();
53
+ const usage = provider.usage;
54
+ const providerColor = provider.plugin.meta?.brandColor ?? colors.primary;
55
+
56
+ if (!usage) {
57
+ return (
58
+ <box border borderColor={colors.border} borderStyle="rounded" padding={1} flexShrink={0}>
59
+ <text fg={colors.textMuted}>No usage data available</text>
60
+ </box>
61
+ );
62
+ }
63
+
64
+ const allLimits: UsageLimit[] = [];
65
+ if (usage.limits?.items && usage.limits.items.length > 0) {
66
+ allLimits.push(...usage.limits.items);
67
+ } else {
68
+ if (usage.limits?.primary) allLimits.push(usage.limits.primary);
69
+ if (usage.limits?.secondary) allLimits.push(usage.limits.secondary);
70
+ }
71
+
72
+ const historyValues = provider.history.map(s => s.usedPercent ?? 0);
73
+ const peak = historyValues.length > 0 ? Math.max(...historyValues) : 0;
74
+ const avg = historyValues.length > 0
75
+ ? historyValues.reduce((a, b) => a + b, 0) / historyValues.length
76
+ : 0;
77
+
78
+ const tokens = usage.tokens;
79
+ const cost = usage.cost;
80
+ const credits = usage.credits;
81
+ const lastRefreshed = usage.fetchedAt;
82
+
83
+ return (
84
+ <box
85
+ border
86
+ borderColor={providerColor}
87
+ borderStyle="rounded"
88
+ flexDirection="column"
89
+ paddingX={1}
90
+ gap={0}
91
+ flexShrink={0}
92
+ >
93
+ <box flexDirection="row" justifyContent="space-between" height={1}>
94
+ <text fg={providerColor} height={1}>
95
+ <strong>{provider.plugin.name}</strong>
96
+ {usage.planType ? ` · ${usage.planType}` : ''}
97
+ </text>
98
+ <text fg={colors.textMuted} height={1}>
99
+ {`Last: ${formatTimestamp(lastRefreshed)}`}
100
+ </text>
101
+ </box>
102
+
103
+ <box flexDirection="row" gap={2} paddingTop={1}>
104
+ <box flexDirection="column" flexGrow={1} gap={1}>
105
+ {allLimits.length > 0 && (
106
+ <box flexDirection="column" gap={1}>
107
+ {allLimits.map((limit, idx) => {
108
+ const label = limit.label ?? 'Usage';
109
+ const windowStr = limit.windowMinutes ? formatWindow(limit.windowMinutes) : undefined;
110
+ const labelHasWindow = label.toLowerCase().includes('window') ||
111
+ label.toLowerCase().includes('hour') ||
112
+ label.toLowerCase().includes('day');
113
+ return (
114
+ <box key={idx} flexDirection="column">
115
+ <UsageGauge
116
+ label={label}
117
+ usedPercent={limit.usedPercent}
118
+ color={providerColor}
119
+ width={40}
120
+ {...(windowStr && !labelHasWindow ? { windowLabel: windowStr } : {})}
121
+ {...(limit.resetsAt ? { resetsAt: limit.resetsAt } : {})}
122
+ />
123
+ </box>
124
+ );
125
+ })}
126
+ </box>
127
+ )}
128
+
129
+ {tokens && (
130
+ <box flexDirection="column">
131
+ <text fg={colors.textMuted} height={1}>TOKEN BREAKDOWN</text>
132
+ <box flexDirection="column">
133
+ <text height={1}>
134
+ <span fg={colors.textMuted}>{pad('Input:', 14)}</span>
135
+ <span fg={colors.text}>{padStart(formatTokenCount(tokens.input), 10)}</span>
136
+ </text>
137
+ <text height={1}>
138
+ <span fg={colors.textMuted}>{pad('Output:', 14)}</span>
139
+ <span fg={colors.text}>{padStart(formatTokenCount(tokens.output), 10)}</span>
140
+ </text>
141
+ {tokens.cacheRead !== undefined && tokens.cacheRead > 0 && (
142
+ <text height={1}>
143
+ <span fg={colors.textMuted}>{pad('Cache Read:', 14)}</span>
144
+ <span fg={colors.text}>{padStart(formatTokenCount(tokens.cacheRead), 10)}</span>
145
+ </text>
146
+ )}
147
+ {tokens.cacheWrite !== undefined && tokens.cacheWrite > 0 && (
148
+ <text height={1}>
149
+ <span fg={colors.textMuted}>{pad('Cache Write:', 14)}</span>
150
+ <span fg={colors.text}>{padStart(formatTokenCount(tokens.cacheWrite), 10)}</span>
151
+ </text>
152
+ )}
153
+ </box>
154
+ </box>
155
+ )}
156
+ </box>
157
+
158
+ <box flexDirection="column" flexGrow={1} gap={1}>
159
+ {cost && (
160
+ <box flexDirection="column">
161
+ <text fg={colors.textMuted} height={1}>COST BREAKDOWN</text>
162
+ {cost.actual && (
163
+ <box flexDirection="column">
164
+ <text height={1}>
165
+ <span fg={colors.textMuted}>{pad('Total:', 14)}</span>
166
+ <span fg={colors.success}>{padStart(formatCost(cost.actual.total), 10)}</span>
167
+ </text>
168
+ {cost.actual.input !== undefined && (
169
+ <text height={1}>
170
+ <span fg={colors.textMuted}>{pad('Input:', 14)}</span>
171
+ <span fg={colors.text}>{padStart(formatCost(cost.actual.input), 10)}</span>
172
+ </text>
173
+ )}
174
+ {cost.actual.output !== undefined && (
175
+ <text height={1}>
176
+ <span fg={colors.textMuted}>{pad('Output:', 14)}</span>
177
+ <span fg={colors.text}>{padStart(formatCost(cost.actual.output), 10)}</span>
178
+ </text>
179
+ )}
180
+ </box>
181
+ )}
182
+ {!cost.actual && cost.estimated && (
183
+ <box flexDirection="column">
184
+ <text height={1}>
185
+ <span fg={colors.textMuted}>{pad('Total:', 14)}</span>
186
+ <span fg={colors.textMuted}>{padStart(`~${formatCost(cost.estimated.total)}`, 10)}</span>
187
+ </text>
188
+ {cost.estimated.input !== undefined && (
189
+ <text height={1}>
190
+ <span fg={colors.textMuted}>{pad('Input:', 14)}</span>
191
+ <span fg={colors.textMuted}>{padStart(`~${formatCost(cost.estimated.input)}`, 10)}</span>
192
+ </text>
193
+ )}
194
+ {cost.estimated.output !== undefined && (
195
+ <text height={1}>
196
+ <span fg={colors.textMuted}>{pad('Output:', 14)}</span>
197
+ <span fg={colors.textMuted}>{padStart(`~${formatCost(cost.estimated.output)}`, 10)}</span>
198
+ </text>
199
+ )}
200
+ </box>
201
+ )}
202
+ </box>
203
+ )}
204
+
205
+ {credits && (
206
+ <box flexDirection="row" gap={1} height={1}>
207
+ <text fg={colors.textMuted} height={1}>Credits:</text>
208
+ <text fg={credits.unlimited ? colors.success : colors.text} height={1}>
209
+ {credits.unlimited ? 'Unlimited' : credits.balance ?? '—'}
210
+ </text>
211
+ </box>
212
+ )}
213
+ </box>
214
+ </box>
215
+
216
+ {historyValues.length >= 2 && (
217
+ <box flexDirection="column" paddingTop={1}>
218
+ <box flexDirection="row" justifyContent="space-between" height={1}>
219
+ <text fg={colors.textMuted} height={1}>USAGE TREND</text>
220
+ <text fg={colors.textSubtle} height={1}>
221
+ {`peak:${Math.round(peak)}% avg:${Math.round(avg)}%`}
222
+ </text>
223
+ </box>
224
+ <Sparkline
225
+ data={historyValues}
226
+ width={30}
227
+ fixedMax={100}
228
+ thresholds={{ warning: 70, error: 90 }}
229
+ style="braille"
230
+ orientation="up"
231
+ showBaseline={true}
232
+ />
233
+ </box>
234
+ )}
235
+ </box>
236
+ );
237
+ }