@tokentop/ttop 0.2.2 → 0.3.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 (158) 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 +32 -32
  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 +29 -23
  19. package/src/plugins/notifications/visual-flash.ts +21 -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 +66 -57
  34. package/src/plugins/registry.ts +47 -43
  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/repos/agentSessions.ts +33 -23
  73. package/src/storage/repos/providerSnapshots.ts +5 -5
  74. package/src/storage/repos/usageEvents.ts +40 -28
  75. package/src/storage/types.ts +5 -5
  76. package/src/tui/App.tsx +180 -137
  77. package/src/tui/components/CommandPalette.tsx +46 -34
  78. package/src/tui/components/DebugConsole.tsx +39 -33
  79. package/src/tui/components/DebugPanel.tsx +295 -177
  80. package/src/tui/components/FooterHints.tsx +2 -2
  81. package/src/tui/components/GhostProviderCard.tsx +37 -35
  82. package/src/tui/components/Header.tsx +68 -35
  83. package/src/tui/components/HelpOverlay.tsx +99 -29
  84. package/src/tui/components/InlineGauge.tsx +10 -7
  85. package/src/tui/components/InlineSparkline.tsx +17 -11
  86. package/src/tui/components/KpiStrip.tsx +128 -98
  87. package/src/tui/components/LimitGauge.tsx +96 -59
  88. package/src/tui/components/ModalBackdrop.tsx +4 -4
  89. package/src/tui/components/ProviderAggregateStrip.tsx +69 -38
  90. package/src/tui/components/ProviderCard.tsx +232 -226
  91. package/src/tui/components/ProviderDetailPanel.tsx +133 -58
  92. package/src/tui/components/ProviderLimitsPanel.tsx +126 -89
  93. package/src/tui/components/ProvidersList.tsx +215 -126
  94. package/src/tui/components/SessionDetailsDrawer.tsx +191 -94
  95. package/src/tui/components/SessionsTable.tsx +313 -166
  96. package/src/tui/components/SettingsModal.tsx +231 -214
  97. package/src/tui/components/SidebarBreakdown.tsx +30 -16
  98. package/src/tui/components/Skeleton.tsx +24 -17
  99. package/src/tui/components/SmartSidebar.tsx +360 -258
  100. package/src/tui/components/Sparkline.tsx +64 -52
  101. package/src/tui/components/Spinner.tsx +3 -3
  102. package/src/tui/components/StatusBar.tsx +27 -27
  103. package/src/tui/components/ThemePicker.tsx +67 -66
  104. package/src/tui/components/Toast.tsx +9 -6
  105. package/src/tui/components/UsageGauge.tsx +10 -13
  106. package/src/tui/components/index.ts +5 -5
  107. package/src/tui/contexts/AgentSessionContext.tsx +193 -144
  108. package/src/tui/contexts/ConfigContext.tsx +15 -13
  109. package/src/tui/contexts/DashboardRuntimeContext.tsx +26 -26
  110. package/src/tui/contexts/DemoModeContext.tsx +22 -15
  111. package/src/tui/contexts/DrawerContext.tsx +11 -9
  112. package/src/tui/contexts/InputContext.tsx +2 -2
  113. package/src/tui/contexts/LogContext.tsx +62 -47
  114. package/src/tui/contexts/PluginContext.tsx +232 -183
  115. package/src/tui/contexts/RealTimeActivityContext.tsx +70 -36
  116. package/src/tui/contexts/StorageContext.tsx +151 -117
  117. package/src/tui/contexts/ThemeContext.tsx +21 -16
  118. package/src/tui/contexts/TimeWindowContext.tsx +72 -61
  119. package/src/tui/contexts/ToastContext.tsx +10 -7
  120. package/src/tui/contexts/index.ts +9 -5
  121. package/src/tui/createApp.tsx +8 -12
  122. package/src/tui/debug/captureFrame.ts +25 -28
  123. package/src/tui/debug/snapshot.tsx +179 -162
  124. package/src/tui/driver/assertions.ts +30 -22
  125. package/src/tui/driver/cli.ts +231 -221
  126. package/src/tui/driver/coverage.ts +27 -44
  127. package/src/tui/driver/demo-snapshot.ts +33 -33
  128. package/src/tui/driver/diff.ts +26 -32
  129. package/src/tui/driver/driver.ts +43 -48
  130. package/src/tui/driver/index.ts +36 -40
  131. package/src/tui/driver/recorder.ts +37 -48
  132. package/src/tui/driver/test-workflow.ts +51 -51
  133. package/src/tui/driver/test.ts +25 -25
  134. package/src/tui/hooks/index.ts +24 -20
  135. package/src/tui/hooks/useAnimatedValue.ts +13 -18
  136. package/src/tui/hooks/useDashboardKeyboard.ts +118 -98
  137. package/src/tui/hooks/useDashboardState.ts +26 -24
  138. package/src/tui/hooks/useEmaActivity.ts +82 -74
  139. package/src/tui/hooks/useEntranceAnimation.ts +11 -11
  140. package/src/tui/hooks/useExitAnimation.ts +3 -3
  141. package/src/tui/hooks/usePulse.ts +4 -4
  142. package/src/tui/hooks/useSafeRenderer.ts +5 -5
  143. package/src/tui/hooks/useSessionEnrichedProviders.ts +150 -0
  144. package/src/tui/hooks/useValueFlash.ts +10 -14
  145. package/src/tui/index.tsx +29 -6
  146. package/src/tui/shutdown.ts +19 -0
  147. package/src/tui/utils/providerColor.ts +1 -1
  148. package/src/tui/views/Dashboard.tsx +127 -102
  149. package/src/tui/views/HistoricalTrendsView.tsx +579 -250
  150. package/src/tui/views/ProjectsView.tsx +466 -237
  151. package/src/tui/views/RealTimeDashboard.tsx +150 -111
  152. package/src/tui/views/SettingsView.tsx +222 -191
  153. package/src/tui/views/index.ts +2 -2
  154. package/src/types/opentui-test-utils.d.ts +7 -7
  155. package/src/utils/clipboard.ts +29 -29
  156. package/src/utils/currency.test.ts +53 -53
  157. package/src/utils/currency.ts +35 -32
  158. package/src/version.ts +1 -1
@@ -1,11 +1,11 @@
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';
1
+ import type { BoxRenderable } from "@opentui/core";
2
+ import { useTerminalDimensions } from "@opentui/react";
3
+ import { forwardRef, useCallback, useEffect, useState } from "react";
4
+ import type { ProviderUsageData } from "@/plugins/types/provider.ts";
5
+ import { useColors } from "../contexts/ThemeContext.tsx";
6
+ import { SkeletonProviderContent } from "./Skeleton.tsx";
7
+ import { useSpinner } from "./Spinner.tsx";
8
+ import { UsageGauge } from "./UsageGauge.tsx";
9
9
 
10
10
  interface CompactGaugeProps {
11
11
  label: string;
@@ -19,27 +19,26 @@ function CompactGauge({ label, usedPercent, resetsAt, color }: CompactGaugeProps
19
19
  const colors = useColors();
20
20
  const providerColor = color ?? colors.primary;
21
21
  const percent = usedPercent ?? 0;
22
-
22
+
23
23
  const barWidth = 20;
24
24
  const filledWidth = Math.round((percent / 100) * barWidth);
25
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
-
26
+
27
+ const fillColor =
28
+ percent >= 90 ? colors.gaugeDanger : percent >= 70 ? colors.gaugeWarning : providerColor;
29
+
30
+ const filledBar = "█".repeat(filledWidth);
31
+ const emptyBar = "·".repeat(emptyWidth);
32
+
33
+ const resetText = resetsAt ? formatResetTime(resetsAt) : "";
34
+
36
35
  return (
37
36
  <box flexDirection="column">
38
37
  <text fg={providerColor}>{label}</text>
39
38
  <text>
40
39
  <span fg={fillColor}>{filledBar}</span>
41
40
  <span fg={colors.gaugeBackground}>{emptyBar}</span>
42
- <span fg={colors.text}> {percent !== null ? `${Math.round(percent)}%` : '--'}</span>
41
+ <span fg={colors.text}> {percent !== null ? `${Math.round(percent)}%` : "--"}</span>
43
42
  </text>
44
43
  {resetText && <text fg={colors.textMuted}>{resetText}</text>}
45
44
  </box>
@@ -56,216 +55,223 @@ interface ProviderCardProps {
56
55
  onFocus?: (() => void) | undefined;
57
56
  }
58
57
 
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
- };
58
+ export const ProviderCard = forwardRef<BoxRenderable, ProviderCardProps>(
59
+ ({ name, configured, loading, usage, color, focused = false, onFocus }, ref) => {
60
+ const colors = useColors();
61
+ const { width: termWidth } = useTerminalDimensions();
62
+ const providerColor = color ?? colors.primary;
63
+ const spinnerFrame = useSpinner();
129
64
 
130
- const baseBorderColor = focused ? providerColor : colors.border;
131
- const borderColor = usage?.limitReached
132
- ? getWarningBorderColor(pulseStep, baseBorderColor)
133
- : baseBorderColor;
65
+ const [pulseStep, setPulseStep] = useState(0);
134
66
 
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)));
67
+ const rawItems = usage?.limits?.items;
68
+ const hasItems = rawItems && rawItems.length > 0;
139
69
 
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>
70
+ const sortedItems = hasItems
71
+ ? [...rawItems].sort((a, b) => (b.usedPercent ?? 0) - (a.usedPercent ?? 0))
72
+ : [];
163
73
 
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>
74
+ const maxVisibleGauges = 3;
75
+ const hiddenGaugeCount =
76
+ sortedItems.length > maxVisibleGauges ? sortedItems.length - maxVisibleGauges : 0;
77
+ const visibleItems =
78
+ sortedItems.length > maxVisibleGauges ? sortedItems.slice(0, maxVisibleGauges) : sortedItems;
79
+ const useCompactMode = visibleItems.length > 3;
80
+
81
+ useEffect(() => {
82
+ if (!usage?.limitReached) {
83
+ setPulseStep(0);
84
+ return;
85
+ }
86
+
87
+ const timer = setInterval(() => {
88
+ setPulseStep((p) => (p + 1) % 12);
89
+ }, 150);
90
+
91
+ return () => clearInterval(timer);
92
+ }, [usage?.limitReached]);
93
+
94
+ const handleClick = useCallback(() => {
95
+ if (onFocus) {
96
+ onFocus();
97
+ }
98
+ }, [onFocus]);
99
+
100
+ const statusColor = !configured
101
+ ? colors.textSubtle
102
+ : loading
103
+ ? colors.info
104
+ : usage?.error
105
+ ? colors.error
106
+ : usage?.limitReached
107
+ ? colors.warning
108
+ : colors.success;
109
+
110
+ const statusIcon = !configured ? "○" : usage?.error ? "✗" : usage?.limitReached ? "!" : "●";
111
+
112
+ const isInitialLoad = loading && !usage;
113
+
114
+ const getWarningBorderColor = (step: number, baseColor: string): string => {
115
+ const intensity = Math.sin((step / 12) * Math.PI);
116
+ const base = hexToRgb(baseColor);
117
+ const warn = hexToRgb(colors.warning);
118
+ if (!base || !warn) return baseColor;
119
+ const r = Math.round(base.r + (warn.r - base.r) * intensity);
120
+ const g = Math.round(base.g + (warn.g - base.g) * intensity);
121
+ const b = Math.round(base.b + (warn.b - base.b) * intensity);
122
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
123
+ };
124
+
125
+ const baseBorderColor = focused ? providerColor : colors.border;
126
+ const borderColor = usage?.limitReached
127
+ ? getWarningBorderColor(pulseStep, baseBorderColor)
128
+ : baseBorderColor;
129
+
130
+ const numColumns = termWidth >= 160 ? 3 : termWidth >= 100 ? 2 : 1;
131
+ const cardPadding = 4;
132
+ const gaps = numColumns - 1;
133
+ const cardWidth = Math.max(
134
+ 40,
135
+ Math.min(60, Math.floor((termWidth - cardPadding - gaps) / numColumns)),
136
+ );
137
+
138
+ return (
139
+ <box
140
+ ref={ref}
141
+ border
142
+ borderStyle="rounded"
143
+ borderColor={borderColor}
144
+ padding={1}
145
+ flexDirection="column"
146
+ gap={1}
147
+ width={cardWidth}
148
+ focusable
149
+ onMouseDown={handleClick}
150
+ >
151
+ <box flexDirection="row" justifyContent="space-between" alignItems="center">
152
+ <text>
153
+ <span fg={providerColor}>
154
+ <strong>{name}</strong>
155
+ </span>
156
+ </text>
157
+ <text fg={statusColor}>{configured && loading ? spinnerFrame : statusIcon}</text>
263
158
  </box>
264
- )}
265
- </box>
266
- );
267
- });
268
- ProviderCard.displayName = 'ProviderCard';
159
+
160
+ {!configured && <text fg={colors.textSubtle}>Not configured</text>}
161
+
162
+ {configured && isInitialLoad && <SkeletonProviderContent />}
163
+
164
+ {configured && !isInitialLoad && usage?.error && (
165
+ <text fg={colors.error}>{usage.error}</text>
166
+ )}
167
+
168
+ {configured && !isInitialLoad && usage && !usage.error && (
169
+ <box flexDirection="column" flexGrow={1} justifyContent="space-between">
170
+ <box flexDirection="column" gap={1}>
171
+ {usage.planType && <text fg={colors.textMuted}>{usage.planType}</text>}
172
+
173
+ {hasItems ? (
174
+ <>
175
+ {useCompactMode
176
+ ? visibleItems.map((limit, idx) => (
177
+ <CompactGauge
178
+ key={idx}
179
+ label={limit.label ?? "Usage"}
180
+ usedPercent={limit.usedPercent}
181
+ color={providerColor}
182
+ {...(limit.windowMinutes ? { windowMinutes: limit.windowMinutes } : {})}
183
+ {...(limit.resetsAt ? { resetsAt: limit.resetsAt } : {})}
184
+ />
185
+ ))
186
+ : visibleItems.map((limit, idx) => {
187
+ const labelText = limit.label ?? "Usage";
188
+ const labelHasWindow =
189
+ labelText.toLowerCase().includes("window") ||
190
+ labelText.toLowerCase().includes("hour") ||
191
+ labelText.toLowerCase().includes("day");
192
+ return (
193
+ <UsageGauge
194
+ key={idx}
195
+ label={labelText}
196
+ usedPercent={limit.usedPercent}
197
+ color={providerColor}
198
+ {...(limit.windowMinutes && !labelHasWindow
199
+ ? { windowLabel: formatWindow(limit.windowMinutes) }
200
+ : {})}
201
+ {...(limit.resetsAt ? { resetsAt: limit.resetsAt } : {})}
202
+ />
203
+ );
204
+ })}
205
+ {hiddenGaugeCount > 0 && (
206
+ <text fg={colors.textSubtle}>
207
+ +{hiddenGaugeCount} more limit{hiddenGaugeCount > 1 ? "s" : ""}
208
+ </text>
209
+ )}
210
+ </>
211
+ ) : (
212
+ <>
213
+ {usage.limits?.primary &&
214
+ (() => {
215
+ const labelText = usage.limits.primary.label ?? "Usage";
216
+ const labelHasWindow =
217
+ labelText.toLowerCase().includes("window") ||
218
+ labelText.toLowerCase().includes("hour") ||
219
+ labelText.toLowerCase().includes("day");
220
+ return (
221
+ <UsageGauge
222
+ label={labelText}
223
+ usedPercent={usage.limits.primary.usedPercent}
224
+ color={providerColor}
225
+ {...(usage.limits.primary.windowMinutes && !labelHasWindow
226
+ ? { windowLabel: formatWindow(usage.limits.primary.windowMinutes) }
227
+ : {})}
228
+ {...(usage.limits.primary.resetsAt
229
+ ? { resetsAt: usage.limits.primary.resetsAt }
230
+ : {})}
231
+ />
232
+ );
233
+ })()}
234
+
235
+ {usage.limits?.secondary &&
236
+ (() => {
237
+ const labelText = usage.limits.secondary.label ?? "Secondary";
238
+ const labelHasWindow =
239
+ labelText.toLowerCase().includes("window") ||
240
+ labelText.toLowerCase().includes("hour") ||
241
+ labelText.toLowerCase().includes("day");
242
+ return (
243
+ <UsageGauge
244
+ label={labelText}
245
+ usedPercent={usage.limits.secondary.usedPercent}
246
+ color={providerColor}
247
+ {...(usage.limits.secondary.windowMinutes && !labelHasWindow
248
+ ? { windowLabel: formatWindow(usage.limits.secondary.windowMinutes) }
249
+ : {})}
250
+ {...(usage.limits.secondary.resetsAt
251
+ ? { resetsAt: usage.limits.secondary.resetsAt }
252
+ : {})}
253
+ />
254
+ );
255
+ })()}
256
+ </>
257
+ )}
258
+
259
+ {usage.credits && (
260
+ <box flexDirection="row" gap={1}>
261
+ <text fg={colors.textMuted}>Credits:</text>
262
+ <text fg={usage.credits.unlimited ? colors.success : colors.text}>
263
+ {usage.credits.unlimited ? "Unlimited" : (usage.credits.balance ?? "Unknown")}
264
+ </text>
265
+ </box>
266
+ )}
267
+ </box>
268
+ </box>
269
+ )}
270
+ </box>
271
+ );
272
+ },
273
+ );
274
+ ProviderCard.displayName = "ProviderCard";
269
275
 
270
276
  function formatWindow(minutes: number): string {
271
277
  if (minutes >= 1440) {
@@ -283,7 +289,7 @@ function formatResetTime(timestamp: number): string {
283
289
  const now = Date.now();
284
290
  const diff = timestamp - now;
285
291
 
286
- if (diff <= 0) return 'soon';
292
+ if (diff <= 0) return "soon";
287
293
 
288
294
  const minutes = Math.floor(diff / 60000);
289
295
  const hours = Math.floor(minutes / 60);