@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,308 @@
1
+ import { useMemo } from 'react';
2
+ import { useTerminalDimensions } from '@opentui/react';
3
+ import { useColors } from '../contexts/ThemeContext.tsx';
4
+ import { useConfig } from '../contexts/ConfigContext.tsx';
5
+ import { LimitGauge } from './LimitGauge.tsx';
6
+ import { usePulse } from '../hooks/usePulse.ts';
7
+
8
+ function interpolatePulseColor(intensity: number, baseColor: string, dimColor: string): string {
9
+ const dimAmount = 0.4;
10
+ const t = intensity * dimAmount;
11
+
12
+ const parseHex = (hex: string) => {
13
+ const h = hex.replace('#', '');
14
+ return {
15
+ r: parseInt(h.substring(0, 2), 16),
16
+ g: parseInt(h.substring(2, 4), 16),
17
+ b: parseInt(h.substring(4, 6), 16),
18
+ };
19
+ };
20
+
21
+ const base = parseHex(baseColor);
22
+ const dim = parseHex(dimColor);
23
+
24
+ const r = Math.round(base.r + (dim.r - base.r) * t);
25
+ const g = Math.round(base.g + (dim.g - base.g) * t);
26
+ const b = Math.round(base.b + (dim.b - base.b) * t);
27
+
28
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
29
+ }
30
+
31
+ interface ProviderData {
32
+ id: string;
33
+ name: string;
34
+ usedPercent: number;
35
+ color: string;
36
+ error?: string;
37
+ resetTime?: string;
38
+ used?: number;
39
+ limit?: number;
40
+ limitType?: string;
41
+ }
42
+
43
+ interface BudgetData {
44
+ totalCost: number;
45
+ budgetCost: number;
46
+ limit: number | null;
47
+ budgetType: 'daily' | 'weekly' | 'monthly' | 'none';
48
+ budgetTypeLabel: string;
49
+ warningPercent: number;
50
+ criticalPercent: number;
51
+ }
52
+
53
+ interface ProviderLimitsPanelProps {
54
+ providers: ProviderData[];
55
+ focused?: boolean;
56
+ selectedIndex?: number;
57
+ budget?: BudgetData;
58
+ showBudgetBar?: boolean;
59
+ }
60
+
61
+ type LayoutMode = 'hidden' | 'compact' | 'normal' | 'wide';
62
+
63
+ function getLayoutMode(width: number, height: number): LayoutMode {
64
+ if (height < 24) return 'hidden';
65
+ if (height < 30) return 'compact';
66
+ if (width >= 140) return 'wide';
67
+ return 'normal';
68
+ }
69
+
70
+ function sortByUrgency(
71
+ providers: ProviderData[],
72
+ warningThreshold: number,
73
+ criticalThreshold: number
74
+ ): ProviderData[] {
75
+ return [...providers].sort((a, b) => {
76
+ const aIsCritical = a.usedPercent >= criticalThreshold;
77
+ const bIsCritical = b.usedPercent >= criticalThreshold;
78
+ const aIsWarning = a.usedPercent >= warningThreshold;
79
+ const bIsWarning = b.usedPercent >= warningThreshold;
80
+
81
+ if (aIsCritical && !bIsCritical) return -1;
82
+ if (!aIsCritical && bIsCritical) return 1;
83
+ if (aIsWarning && !bIsWarning) return -1;
84
+ if (!aIsWarning && bIsWarning) return 1;
85
+
86
+ return b.usedPercent - a.usedPercent;
87
+ });
88
+ }
89
+
90
+ function formatCurrency(val: number): string {
91
+ if (val >= 1000) return `$${(val / 1000).toFixed(1)}k`;
92
+ if (val >= 100) return `$${Math.round(val)}`;
93
+ if (val >= 10) return `$${val.toFixed(1)}`;
94
+ return `$${val.toFixed(2)}`;
95
+ }
96
+
97
+ function makeProgressBar(percent: number, width: number): string {
98
+ const clamped = Math.max(0, Math.min(100, percent));
99
+ const filledWidth = Math.round((clamped / 100) * width);
100
+ return '█'.repeat(filledWidth) + '·'.repeat(width - filledWidth);
101
+ }
102
+
103
+ export function ProviderLimitsPanel({
104
+ providers,
105
+ focused = false,
106
+ selectedIndex = 0,
107
+ budget,
108
+ showBudgetBar = false,
109
+ }: ProviderLimitsPanelProps) {
110
+ const colors = useColors();
111
+ const { config } = useConfig();
112
+ const { width: termWidth, height: termHeight } = useTerminalDimensions();
113
+
114
+ const warningThreshold = config.alerts.warningPercent;
115
+ const criticalThreshold = config.alerts.criticalPercent;
116
+
117
+ const hasBudgetLimit = !!(budget?.limit && budget.budgetType !== 'none');
118
+ const budgetPercent = hasBudgetLimit && budget?.limit ? (budget.budgetCost / budget.limit) * 100 : 0;
119
+ const isBudgetCritical = hasBudgetLimit && budget && budgetPercent >= budget.criticalPercent;
120
+ const isBudgetWarning = hasBudgetLimit && budget && budgetPercent >= budget.warningPercent && !isBudgetCritical;
121
+
122
+ const criticalPulseStep = usePulse({ enabled: !!isBudgetCritical, intervalMs: 80 });
123
+ const warningPulseStep = usePulse({ enabled: !!isBudgetWarning, intervalMs: 200 });
124
+
125
+ const pulseIntensity = isBudgetCritical
126
+ ? Math.abs(Math.sin(criticalPulseStep * 0.3))
127
+ : isBudgetWarning
128
+ ? Math.abs(Math.sin(warningPulseStep * 0.25))
129
+ : 0;
130
+
131
+ const getBudgetColor = () => {
132
+ if (!budget?.limit) return colors.textMuted;
133
+ if (isBudgetCritical) {
134
+ return interpolatePulseColor(pulseIntensity, colors.error, colors.background);
135
+ }
136
+ if (isBudgetWarning) {
137
+ return interpolatePulseColor(pulseIntensity, colors.warning, colors.background);
138
+ }
139
+ return colors.success;
140
+ };
141
+
142
+ const budgetColor = getBudgetColor();
143
+
144
+ const layoutMode = getLayoutMode(termWidth, termHeight);
145
+ const sortedProviders = useMemo(
146
+ () => sortByUrgency(providers, warningThreshold, criticalThreshold),
147
+ [providers, warningThreshold, criticalThreshold]
148
+ );
149
+
150
+ if (layoutMode === 'hidden' || sortedProviders.length === 0) {
151
+ return null;
152
+ }
153
+
154
+ const safeSelectedIndex = Math.min(selectedIndex, sortedProviders.length - 1);
155
+
156
+ if (layoutMode === 'compact') {
157
+ const baseMaxShow = 4;
158
+ const hasMore = sortedProviders.length > baseMaxShow;
159
+ const maxShow = hasMore ? baseMaxShow - 1 : baseMaxShow;
160
+ const startIndex = Math.max(0, Math.min(safeSelectedIndex - maxShow + 1, sortedProviders.length - maxShow));
161
+ const showLeftArrow = hasMore && startIndex > 0;
162
+ const showRightArrow = hasMore && startIndex + maxShow < sortedProviders.length;
163
+ const shown = sortedProviders.slice(startIndex, startIndex + maxShow);
164
+ const remaining = sortedProviders.length - startIndex - maxShow;
165
+
166
+ return (
167
+ <box flexDirection="column" height={showBudgetBar && budget ? 2 : 1} overflow="hidden">
168
+ <box flexDirection="row" height={1} paddingX={1} gap={1} overflow="hidden">
169
+ <text fg={colors.textMuted} height={1}>LIMITS:</text>
170
+ {showLeftArrow && (
171
+ <text fg={colors.primary} height={1}>◀</text>
172
+ )}
173
+ {shown.map((p, idx) => (
174
+ <LimitGauge
175
+ key={p.id}
176
+ label={p.name}
177
+ usedPercent={p.usedPercent}
178
+ color={p.color}
179
+ {...(p.error ? { error: p.error } : {})}
180
+ compact={true}
181
+ selected={focused && (startIndex + idx) === safeSelectedIndex}
182
+ warningThreshold={warningThreshold}
183
+ criticalThreshold={criticalThreshold}
184
+ />
185
+ ))}
186
+ {showRightArrow && (
187
+ <text fg={colors.primary} height={1}>▶ +{remaining}</text>
188
+ )}
189
+ </box>
190
+ {showBudgetBar && budget && (
191
+ <box flexDirection="row" height={1} paddingX={1} gap={1} overflow="hidden">
192
+ <text fg={colors.textMuted} height={1}>{budget.budgetType === 'none' ? 'TOTAL:' : `${budget.budgetTypeLabel.toUpperCase()}:`}</text>
193
+ {hasBudgetLimit ? (
194
+ <>
195
+ <text fg={budgetColor} height={1}>{makeProgressBar(budgetPercent, 12)}</text>
196
+ <text fg={budgetColor} height={1}>{Math.round(budgetPercent)}%</text>
197
+ <text fg={colors.text} height={1}>{formatCurrency(budget.budgetCost)}</text>
198
+ <text fg={colors.textMuted} height={1}>/{formatCurrency(budget.limit!)}</text>
199
+ </>
200
+ ) : (
201
+ <text fg={colors.text} height={1}>{formatCurrency(budget.totalCost)}</text>
202
+ )}
203
+ </box>
204
+ )}
205
+ </box>
206
+ );
207
+ }
208
+
209
+ if (layoutMode === 'wide') {
210
+ const baseMaxShow = 5;
211
+ const hasMore = sortedProviders.length > baseMaxShow;
212
+ const maxShow = hasMore ? baseMaxShow - 1 : baseMaxShow;
213
+ const startIndex = Math.max(0, Math.min(safeSelectedIndex - maxShow + 1, sortedProviders.length - maxShow));
214
+ const showLeftArrow = hasMore && startIndex > 0;
215
+ const showRightArrow = hasMore && startIndex + maxShow < sortedProviders.length;
216
+ const shown = sortedProviders.slice(startIndex, startIndex + maxShow);
217
+
218
+ return (
219
+ <box
220
+ flexDirection="column"
221
+ border
222
+ borderStyle={focused ? 'double' : 'single'}
223
+ borderColor={focused ? colors.primary : colors.border}
224
+ overflow="hidden"
225
+ height={4}
226
+ flexShrink={0}
227
+ >
228
+ <box flexDirection="row" paddingLeft={2} height={1}>
229
+ <text fg={colors.textMuted} height={1}>
230
+ PROVIDER LIMITS {focused ? '(←→ navigate, Tab exit)' : ''}
231
+ </text>
232
+ </box>
233
+ <box flexDirection="row" gap={3} overflow="hidden" height={1} paddingLeft={2}>
234
+ {showLeftArrow && (
235
+ <text fg={colors.primary} height={1}>◀</text>
236
+ )}
237
+ {shown.map((p, idx) => (
238
+ <LimitGauge
239
+ key={p.id}
240
+ label={p.name}
241
+ usedPercent={p.usedPercent}
242
+ color={p.color}
243
+ {...(p.error ? { error: p.error } : {})}
244
+ {...(p.resetTime ? { resetTime: p.resetTime } : {})}
245
+ labelWidth={14}
246
+ barWidth={12}
247
+ selected={focused && (startIndex + idx) === safeSelectedIndex}
248
+ warningThreshold={warningThreshold}
249
+ criticalThreshold={criticalThreshold}
250
+ />
251
+ ))}
252
+ {showRightArrow && (
253
+ <text fg={colors.primary} height={1}>▶</text>
254
+ )}
255
+ </box>
256
+ </box>
257
+ );
258
+ }
259
+
260
+ const baseMaxShow = 4;
261
+ const hasMore = sortedProviders.length > baseMaxShow;
262
+ const maxShow = hasMore ? baseMaxShow - 1 : baseMaxShow;
263
+ const startIndex = Math.max(0, Math.min(safeSelectedIndex - maxShow + 1, sortedProviders.length - maxShow));
264
+ const showLeftArrow = hasMore && startIndex > 0;
265
+ const showRightArrow = hasMore && startIndex + maxShow < sortedProviders.length;
266
+ const shown = sortedProviders.slice(startIndex, startIndex + maxShow);
267
+
268
+ return (
269
+ <box
270
+ flexDirection="column"
271
+ border
272
+ borderStyle={focused ? 'double' : 'single'}
273
+ borderColor={focused ? colors.primary : colors.border}
274
+ overflow="hidden"
275
+ height={4}
276
+ flexShrink={0}
277
+ >
278
+ <box flexDirection="row" paddingLeft={2} height={1}>
279
+ <text fg={colors.textMuted} height={1}>
280
+ PROVIDER LIMITS {focused ? '(←→ navigate, Tab exit)' : ''}
281
+ </text>
282
+ </box>
283
+ <box flexDirection="row" gap={2} overflow="hidden" height={1} paddingLeft={2}>
284
+ {showLeftArrow && (
285
+ <text fg={colors.primary} height={1}>◀</text>
286
+ )}
287
+ {shown.map((p, idx) => (
288
+ <LimitGauge
289
+ key={p.id}
290
+ label={p.name}
291
+ usedPercent={p.usedPercent}
292
+ color={p.color}
293
+ {...(p.error ? { error: p.error } : {})}
294
+ {...(p.resetTime ? { resetTime: p.resetTime } : {})}
295
+ labelWidth={12}
296
+ barWidth={10}
297
+ selected={focused && (startIndex + idx) === safeSelectedIndex}
298
+ warningThreshold={warningThreshold}
299
+ criticalThreshold={criticalThreshold}
300
+ />
301
+ ))}
302
+ {showRightArrow && (
303
+ <text fg={colors.primary} height={1}>▶</text>
304
+ )}
305
+ </box>
306
+ </box>
307
+ );
308
+ }
@@ -0,0 +1,380 @@
1
+ import { useMemo } from 'react';
2
+ import { useTerminalDimensions } from '@opentui/react';
3
+ import { useColors } from '../contexts/ThemeContext.tsx';
4
+ import type { ProviderState } from '../contexts/PluginContext.tsx';
5
+ import { InlineGauge } from './InlineGauge.tsx';
6
+ import { InlineSparkline } from './InlineSparkline.tsx';
7
+ import { ProviderDetailPanel } from './ProviderDetailPanel.tsx';
8
+
9
+ type FieldState = 'actual' | 'estimated' | 'unavailable';
10
+
11
+ interface FieldValue {
12
+ text: string;
13
+ state: FieldState;
14
+ }
15
+
16
+ function pad(str: string, len: number): string {
17
+ return str.length >= len ? str.slice(0, len) : str + ' '.repeat(len - str.length);
18
+ }
19
+
20
+ function padStart(str: string, len: number): string {
21
+ return str.length >= len ? str.slice(0, len) : ' '.repeat(len - str.length) + str;
22
+ }
23
+
24
+ function formatTokens(val: number | undefined | null): FieldValue {
25
+ if (val === undefined || val === null) return { text: '—', state: 'unavailable' };
26
+ const formatted = val >= 1_000_000 ? `${(val / 1_000_000).toFixed(1)}M` :
27
+ val >= 1_000 ? `${(val / 1_000).toFixed(1)}K` :
28
+ String(Math.round(val));
29
+ return { text: formatted, state: 'actual' };
30
+ }
31
+
32
+ type StatusGroup = 'error' | 'warn' | 'ok';
33
+
34
+ function getStatusInfo(state: ProviderState, colors: ReturnType<typeof useColors>): {
35
+ icon: string;
36
+ color: string;
37
+ group: StatusGroup;
38
+ } {
39
+ if (state.usage?.error) {
40
+ return { icon: '✗', color: colors.error, group: 'error' };
41
+ }
42
+ if (state.usage?.limitReached) {
43
+ return { icon: '⚠', color: colors.warning, group: 'warn' };
44
+ }
45
+ const maxUsage = getMaxUsage(state);
46
+ if (maxUsage >= 80) {
47
+ return { icon: '!', color: colors.warning, group: 'warn' };
48
+ }
49
+ if (state.loading) {
50
+ return { icon: '◌', color: colors.info, group: 'ok' };
51
+ }
52
+ return { icon: '●', color: colors.success, group: 'ok' };
53
+ }
54
+
55
+ function getMaxUsage(state: ProviderState): number {
56
+ if (!state.usage?.limits) return 0;
57
+ const items = state.usage.limits.items ?? [];
58
+ if (items.length > 0) {
59
+ return Math.max(...items.map((item) => item.usedPercent ?? 0));
60
+ }
61
+ const primary = state.usage.limits.primary?.usedPercent || 0;
62
+ const secondary = state.usage.limits.secondary?.usedPercent || 0;
63
+ return Math.max(primary, secondary);
64
+ }
65
+
66
+ function getNextReset(state: ProviderState): string {
67
+ if (!state.usage?.limits) return '—';
68
+
69
+ const items = state.usage.limits.items ?? [];
70
+ let resetsAt: Date | null = null;
71
+
72
+ if (items.length > 0) {
73
+ const withReset = items.filter(i => i.resetsAt);
74
+ if (withReset.length > 0) {
75
+ resetsAt = new Date(Math.min(...withReset.map(i => new Date(i.resetsAt!).getTime())));
76
+ }
77
+ } else if (state.usage.limits.primary?.resetsAt) {
78
+ resetsAt = new Date(state.usage.limits.primary.resetsAt);
79
+ }
80
+
81
+ if (!resetsAt) return '—';
82
+
83
+ const now = Date.now();
84
+ const diff = resetsAt.getTime() - now;
85
+ if (diff <= 0) return 'now';
86
+
87
+ const hours = Math.floor(diff / (1000 * 60 * 60));
88
+ const mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
89
+
90
+ if (hours >= 24) {
91
+ const days = Math.floor(hours / 24);
92
+ return `${days}d ${hours % 24}h`;
93
+ }
94
+ return `${hours}h ${mins}m`;
95
+ }
96
+
97
+ function getCostToday(state: ProviderState): FieldValue {
98
+ if (!state.usage?.cost) return { text: '—', state: 'unavailable' };
99
+ const cost = state.usage.cost;
100
+ if (cost.actual) {
101
+ return { text: `$${cost.actual.total.toFixed(2)}`, state: 'actual' };
102
+ }
103
+ if (cost.estimated) {
104
+ return { text: `~$${cost.estimated.total.toFixed(2)}`, state: 'estimated' };
105
+ }
106
+ return { text: '—', state: 'unavailable' };
107
+ }
108
+
109
+ function getCostMtd(state: ProviderState): FieldValue {
110
+ if (!state.usage?.cost) return { text: '—', state: 'unavailable' };
111
+ const cost = state.usage.cost;
112
+ if (cost.actual) {
113
+ return { text: `$${cost.actual.total.toFixed(2)}`, state: 'actual' };
114
+ }
115
+ if (cost.estimated) {
116
+ return { text: `~$${cost.estimated.total.toFixed(2)}`, state: 'estimated' };
117
+ }
118
+ return { text: '—', state: 'unavailable' };
119
+ }
120
+
121
+ function getTotalTokens(state: ProviderState): FieldValue {
122
+ if (!state.usage?.tokens) return { text: '—', state: 'unavailable' };
123
+ const t = state.usage.tokens;
124
+ const total = t.input + t.output + (t.cacheRead ?? 0) + (t.cacheWrite ?? 0);
125
+ return formatTokens(total);
126
+ }
127
+
128
+ /** Responsive layout tiers based on terminal width */
129
+ interface LayoutTier {
130
+ nameWidth: number;
131
+ planWidth: number;
132
+ gaugeWidth: number;
133
+ showPlan: boolean;
134
+ showToday: boolean;
135
+ showMtd: boolean;
136
+ showTokens: boolean;
137
+ showReset: boolean;
138
+ showTrend: boolean;
139
+ sparkWidth: number;
140
+ }
141
+
142
+ function getLayoutTier(termWidth: number): LayoutTier {
143
+ if (termWidth >= 140) {
144
+ return {
145
+ nameWidth: 20, planWidth: 18, gaugeWidth: 28,
146
+ showPlan: true, showToday: true, showMtd: true,
147
+ showTokens: true, showReset: true, showTrend: true, sparkWidth: 12,
148
+ };
149
+ }
150
+ if (termWidth >= 120) {
151
+ return {
152
+ nameWidth: 18, planWidth: 16, gaugeWidth: 24,
153
+ showPlan: true, showToday: true, showMtd: true,
154
+ showTokens: true, showReset: true, showTrend: true, sparkWidth: 10,
155
+ };
156
+ }
157
+ if (termWidth >= 100) {
158
+ return {
159
+ nameWidth: 16, planWidth: 12, gaugeWidth: 20,
160
+ showPlan: true, showToday: true, showMtd: true,
161
+ showTokens: true, showReset: true, showTrend: true, sparkWidth: 8,
162
+ };
163
+ }
164
+ if (termWidth >= 80) {
165
+ return {
166
+ nameWidth: 14, planWidth: 10, gaugeWidth: 18,
167
+ showPlan: true, showToday: true, showMtd: false,
168
+ showTokens: false, showReset: true, showTrend: false, sparkWidth: 0,
169
+ };
170
+ }
171
+ return {
172
+ nameWidth: 12, planWidth: 0, gaugeWidth: 14,
173
+ showPlan: false, showToday: true, showMtd: false,
174
+ showTokens: false, showReset: false, showTrend: false, sparkWidth: 0,
175
+ };
176
+ }
177
+
178
+ interface GroupedProvider {
179
+ state: ProviderState;
180
+ status: ReturnType<typeof getStatusInfo>;
181
+ originalIndex: number;
182
+ }
183
+
184
+ export interface ProvidersListProps {
185
+ providers: ProviderState[];
186
+ selectedIndex: number | null;
187
+ onSelect: (index: number) => void;
188
+ expandedIndex: number | null;
189
+ }
190
+
191
+ export function ProvidersList({ providers, selectedIndex, onSelect, expandedIndex }: ProvidersListProps) {
192
+ const colors = useColors();
193
+ const { width: termWidth } = useTerminalDimensions();
194
+
195
+ const layout = getLayoutTier(termWidth);
196
+ const {
197
+ nameWidth, planWidth, gaugeWidth, sparkWidth,
198
+ showPlan, showToday, showMtd, showTokens, showReset, showTrend,
199
+ } = layout;
200
+
201
+ const grouped = useMemo(() => {
202
+ const groups: Record<StatusGroup, GroupedProvider[]> = { error: [], warn: [], ok: [] };
203
+
204
+ providers.forEach((state, idx) => {
205
+ const status = getStatusInfo(state, colors);
206
+ groups[status.group].push({ state, status, originalIndex: idx });
207
+ });
208
+
209
+ return groups;
210
+ }, [providers, colors]);
211
+
212
+ const groupOrder: StatusGroup[] = ['error', 'warn', 'ok'];
213
+
214
+ const fieldColor = (fv: FieldValue): string => {
215
+ if (fv.state === 'estimated') return colors.textMuted;
216
+ if (fv.state === 'unavailable') return colors.textSubtle;
217
+ return colors.text;
218
+ };
219
+
220
+ const selBg = colors.selection;
221
+
222
+ const renderRow = (item: GroupedProvider) => {
223
+ const { state, status, originalIndex } = item;
224
+ const isSelected = originalIndex === selectedIndex;
225
+ const providerColor = state.plugin.meta?.brandColor ?? colors.primary;
226
+ const costToday = getCostToday(state);
227
+ const costMtd = getCostMtd(state);
228
+ const tokens = getTotalTokens(state);
229
+ const maxUsage = getMaxUsage(state);
230
+ const plan = state.usage?.planType ?? '—';
231
+ const reset = getNextReset(state);
232
+
233
+ const rowBg = isSelected ? selBg : undefined;
234
+ const bgProp = rowBg ? { bg: rowBg } : {};
235
+ const railChar = isSelected ? '▌' : ' ';
236
+
237
+ return (
238
+ <box
239
+ key={state.plugin.id}
240
+ flexDirection="row"
241
+ height={1}
242
+ flexGrow={1}
243
+ focusable
244
+ onMouseDown={() => onSelect(originalIndex)}
245
+ {...(rowBg ? { backgroundColor: rowBg } : {})}
246
+ >
247
+ <text width={2} fg={isSelected ? colors.primary : colors.textSubtle} height={1} {...bgProp}>
248
+ {railChar + ' '}
249
+ </text>
250
+ <text width={3} fg={status.color} height={1} {...bgProp}>
251
+ {pad(`${status.icon} `, 3)}
252
+ </text>
253
+ <text width={nameWidth} fg={providerColor} height={1} {...bgProp}>
254
+ {pad(state.plugin.name, nameWidth)}
255
+ </text>
256
+ {showPlan && (
257
+ <text width={planWidth} fg={colors.textMuted} height={1} {...bgProp}>
258
+ {pad(plan, planWidth)}
259
+ </text>
260
+ )}
261
+ <box width={gaugeWidth + 5} flexDirection="row" height={1}>
262
+ {isSelected ? (
263
+ <text width={gaugeWidth + 5} fg={colors.text} height={1} {...bgProp}>
264
+ {pad(
265
+ '█'.repeat(Math.round((maxUsage / 100) * gaugeWidth)) +
266
+ '·'.repeat(gaugeWidth - Math.round((maxUsage / 100) * gaugeWidth)) +
267
+ ' ' + (maxUsage > 0 ? `${Math.round(maxUsage)}%` : '—'),
268
+ gaugeWidth + 5,
269
+ )}
270
+ </text>
271
+ ) : (
272
+ <>
273
+ <InlineGauge percent={maxUsage > 0 ? maxUsage : null} width={gaugeWidth} color={providerColor} />
274
+ <text width={5} fg={maxUsage >= 80 ? colors.warning : colors.text} height={1}>
275
+ {padStart(maxUsage > 0 ? `${Math.round(maxUsage)}%` : '—', 5)}
276
+ </text>
277
+ </>
278
+ )}
279
+ </box>
280
+ {showToday && (
281
+ <text width={9} fg={fieldColor(costToday)} height={1} {...bgProp}>
282
+ {padStart(costToday.text, 9)}
283
+ </text>
284
+ )}
285
+ {showMtd && (
286
+ <text width={9} fg={fieldColor(costMtd)} height={1} {...bgProp}>
287
+ {padStart(costMtd.text, 9)}
288
+ </text>
289
+ )}
290
+ {showTokens && (
291
+ <text width={8} fg={fieldColor(tokens)} height={1} {...bgProp}>
292
+ {padStart(tokens.text, 8)}
293
+ </text>
294
+ )}
295
+ {showReset && (
296
+ <text width={9} fg={colors.textMuted} height={1} {...bgProp}>
297
+ {padStart(reset, 9)}
298
+ </text>
299
+ )}
300
+ {showTrend && (
301
+ <box width={sparkWidth + 1} height={1} overflow="hidden">
302
+ {isSelected ? (
303
+ <text width={sparkWidth + 1} fg={colors.textMuted} height={1} {...bgProp}>
304
+ {' ' + '⣀'.repeat(sparkWidth)}
305
+ </text>
306
+ ) : (
307
+ <>
308
+ <text width={1} height={1}>{' '}</text>
309
+ <InlineSparkline history={state.history} width={sparkWidth} />
310
+ </>
311
+ )}
312
+ </box>
313
+ )}
314
+ </box>
315
+ );
316
+ };
317
+
318
+ const renderSeparator = (label: string) => (
319
+ <box height={1} paddingX={1} key={`sep-${label}`}>
320
+ <text fg={colors.textSubtle} height={1}>
321
+ {'─'.repeat(3)} {label} {'─'.repeat(Math.max(0, termWidth - label.length - 10))}
322
+ </text>
323
+ </box>
324
+ );
325
+
326
+ const renderHeader = () => (
327
+ <box flexDirection="row" height={1}>
328
+ <text width={2} fg={colors.textMuted} height={1}>{pad('', 2)}</text>
329
+ <text width={3} fg={colors.textMuted} height={1}>{pad('', 3)}</text>
330
+ <text width={nameWidth} fg={colors.textMuted} height={1}>{pad('PROVIDER', nameWidth)}</text>
331
+ {showPlan && <text width={planWidth} fg={colors.textMuted} height={1}>{pad('PLAN', planWidth)}</text>}
332
+ <text width={gaugeWidth + 5} fg={colors.textMuted} height={1}>{pad('HEADROOM', gaugeWidth + 5)}</text>
333
+ {showToday && <text width={9} fg={colors.textMuted} height={1}>{padStart('$TODAY', 9)}</text>}
334
+ {showMtd && <text width={9} fg={colors.textMuted} height={1}>{padStart('$MTD', 9)}</text>}
335
+ {showTokens && <text width={8} fg={colors.textMuted} height={1}>{padStart('TOKENS', 8)}</text>}
336
+ {showReset && <text width={9} fg={colors.textMuted} height={1}>{padStart('RESET', 9)}</text>}
337
+ {showTrend && <text width={sparkWidth + 1} fg={colors.textMuted} height={1}>{' ' + pad('TREND', sparkWidth)}</text>}
338
+ </box>
339
+ );
340
+
341
+ const rows: JSX.Element[] = [];
342
+ let isFirst = true;
343
+
344
+ for (const group of groupOrder) {
345
+ const items = grouped[group];
346
+ if (items.length === 0) continue;
347
+
348
+ if (!isFirst) {
349
+ const label = group === 'error' ? 'ERRORS' : group === 'warn' ? 'WARNINGS' : 'OK';
350
+ rows.push(renderSeparator(label));
351
+ } else if (group !== 'ok' && items.length > 0) {
352
+ const label = group === 'error' ? 'ERRORS' : 'WARNINGS';
353
+ rows.push(renderSeparator(label));
354
+ }
355
+
356
+ for (const item of items) {
357
+ rows.push(renderRow(item));
358
+ if (expandedIndex !== null && item.originalIndex === expandedIndex) {
359
+ rows.push(
360
+ <box key={`detail-${item.state.plugin.id}`} paddingX={1}>
361
+ <ProviderDetailPanel provider={item.state} />
362
+ </box>
363
+ );
364
+ }
365
+ }
366
+
367
+ isFirst = false;
368
+ }
369
+
370
+ return (
371
+ <box flexDirection="column" flexGrow={1}>
372
+ {renderHeader()}
373
+ <scrollbox flexGrow={1}>
374
+ <box flexDirection="column">
375
+ {rows}
376
+ </box>
377
+ </scrollbox>
378
+ </box>
379
+ );
380
+ }