@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,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);