@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,644 @@
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 { useTimeWindow } from '../contexts/TimeWindowContext.tsx';
6
+ import { usePulse } from '../hooks/usePulse.ts';
7
+ import type { AgentSessionAggregate, AgentSessionStream } from '../../agents/types.ts';
8
+
9
+ export type DriverDimension = 'model' | 'project' | 'agent';
10
+
11
+ export type SidebarMode = 'full' | 'compact' | 'minimal' | 'micro' | 'hidden';
12
+
13
+ export interface DriverStats {
14
+ id: string;
15
+ providerId?: string;
16
+ displayName: string;
17
+ cost: number;
18
+ costShare: number;
19
+ tokens: number;
20
+ delta5m: number;
21
+ isHot: boolean;
22
+ }
23
+
24
+ export interface SmartSidebarProps {
25
+ sessions: AgentSessionAggregate[];
26
+ budgetCost: number;
27
+ focusedPanel: 'sessions' | 'sidebar' | 'limits';
28
+ dimension: DriverDimension;
29
+ selectedDriverIndex: number;
30
+ activeDriverFilter: string | null;
31
+ getProviderColor: (id: string) => string;
32
+ }
33
+
34
+ function interpolatePulseColor(intensity: number, baseColor: string, dimColor: string): string {
35
+ const dimAmount = 0.4;
36
+ const t = intensity * dimAmount;
37
+
38
+ const parseHex = (hex: string) => {
39
+ const h = hex.replace('#', '');
40
+ return {
41
+ r: parseInt(h.substring(0, 2), 16),
42
+ g: parseInt(h.substring(2, 4), 16),
43
+ b: parseInt(h.substring(4, 6), 16),
44
+ };
45
+ };
46
+
47
+ const base = parseHex(baseColor);
48
+ const dim = parseHex(dimColor);
49
+
50
+ const r = Math.round(base.r + (dim.r - base.r) * t);
51
+ const g = Math.round(base.g + (dim.g - base.g) * t);
52
+ const b = Math.round(base.b + (dim.b - base.b) * t);
53
+
54
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
55
+ }
56
+
57
+ function formatCurrency(val: number): string {
58
+ if (val >= 1000) return `$${(val / 1000).toFixed(1)}k`;
59
+ if (val >= 100) return `$${Math.round(val)}`;
60
+ if (val >= 10) return `$${val.toFixed(1)}`;
61
+ return `$${val.toFixed(2)}`;
62
+ }
63
+
64
+ /** Always shows 2 decimal places for budget displays where precision matters */
65
+ function formatBudget(val: number): string {
66
+ if (val >= 1000) return `$${(val / 1000).toFixed(2)}k`;
67
+ return `$${val.toFixed(2)}`;
68
+ }
69
+
70
+ function formatDelta(val: number): string {
71
+ if (val < 0.01) return '';
72
+ if (val >= 10) return `+$${val.toFixed(1)}`;
73
+ return `+$${val.toFixed(2)}`;
74
+ }
75
+
76
+ function formatPercent(val: number): string {
77
+ if (val < 1) return '<1%';
78
+ return `${Math.round(val)}%`;
79
+ }
80
+
81
+ function truncateWithEllipsis(str: string, maxLen: number): string {
82
+ if (str.length <= maxLen) return str;
83
+ return str.slice(0, maxLen - 1) + '…';
84
+ }
85
+
86
+ function getSidebarMode(width: number): SidebarMode {
87
+ if (width >= 140) return 'full';
88
+ if (width >= 105) return 'compact';
89
+ if (width >= 95) return 'minimal';
90
+ if (width >= 85) return 'micro';
91
+ return 'hidden';
92
+ }
93
+
94
+ function getSidebarWidth(mode: SidebarMode, terminalWidth: number): number {
95
+ if (mode === 'micro') return 28;
96
+ if (mode === 'minimal') return 32;
97
+ if (mode === 'compact') return 36;
98
+ if (terminalWidth >= 160) return 44;
99
+ if (terminalWidth >= 140) return 40;
100
+ return 38;
101
+ }
102
+
103
+ function extractRepoName(projectPath: string | undefined | null): string {
104
+ if (!projectPath) return 'unknown';
105
+ const normalized = projectPath.replace(/\\/g, '/');
106
+ return normalized.split('/').filter(Boolean).pop() ?? 'unknown';
107
+ }
108
+
109
+ function makeProgressBar(percent: number, width: number, filledChar = '█', emptyChar = '·'): string {
110
+ const clamped = Math.max(0, Math.min(100, percent));
111
+ const filledWidth = Math.round((clamped / 100) * width);
112
+ return filledChar.repeat(filledWidth) + emptyChar.repeat(width - filledWidth);
113
+ }
114
+
115
+ function makeDivider(width: number, char = '─'): string {
116
+ return char.repeat(Math.max(0, width - 2));
117
+ }
118
+
119
+ interface BudgetSectionProps {
120
+ totalCost: number;
121
+ budgetCost: number;
122
+ mode: SidebarMode;
123
+ sidebarWidth: number;
124
+ }
125
+
126
+ function BudgetSection({ totalCost, budgetCost, mode, sidebarWidth }: BudgetSectionProps) {
127
+ const colors = useColors();
128
+ const { config } = useConfig();
129
+ const { budgetType, budgetTypeLabel, budgetLock } = useTimeWindow();
130
+
131
+ const activeBudget = budgetType === 'daily' ? config.budgets.daily
132
+ : budgetType === 'weekly' ? config.budgets.weekly
133
+ : budgetType === 'monthly' ? config.budgets.monthly
134
+ : null;
135
+
136
+ const hasBudgetLimit = activeBudget !== null && budgetType !== 'none';
137
+ const budgetUsedPercent = hasBudgetLimit ? (budgetCost / activeBudget) * 100 : 0;
138
+
139
+ const isCritical = hasBudgetLimit && budgetUsedPercent >= config.alerts.criticalPercent;
140
+ const isWarning = hasBudgetLimit && budgetUsedPercent >= config.alerts.warningPercent && !isCritical;
141
+
142
+ const criticalPulseStep = usePulse({ enabled: isCritical, intervalMs: 80 });
143
+ const warningPulseStep = usePulse({ enabled: isWarning, intervalMs: 200 });
144
+
145
+ const pulseIntensity = isCritical
146
+ ? Math.abs(Math.sin(criticalPulseStep * 0.3))
147
+ : isWarning
148
+ ? Math.abs(Math.sin(warningPulseStep * 0.25))
149
+ : 0;
150
+
151
+ const getStatusColor = () => {
152
+ if (isCritical) {
153
+ return interpolatePulseColor(pulseIntensity, colors.error, colors.background);
154
+ }
155
+ if (isWarning) {
156
+ return interpolatePulseColor(pulseIntensity, colors.warning, colors.background);
157
+ }
158
+ return colors.success;
159
+ };
160
+
161
+ const statusColor = hasBudgetLimit ? getStatusColor() : colors.text;
162
+ const innerWidth = sidebarWidth - 4;
163
+ const barWidth = Math.max(10, innerWidth - 6);
164
+ const bar = makeProgressBar(budgetUsedPercent, barWidth);
165
+
166
+ const isLocked = budgetLock !== 'sync';
167
+ const lockSuffix = isLocked ? ' [locked]' : '';
168
+ const headerLabel = budgetType === 'none' ? 'TOTAL COST' : `${budgetTypeLabel.toUpperCase()} BUDGET${lockSuffix}`;
169
+
170
+ if (budgetType === 'none') {
171
+ if (mode === 'micro') {
172
+ return (
173
+ <box flexDirection="row" height={1} paddingX={1}>
174
+ <text fg={colors.textMuted} height={1}>Total: </text>
175
+ <text fg={colors.text} height={1}><strong>{formatCurrency(totalCost)}</strong></text>
176
+ </box>
177
+ );
178
+ }
179
+
180
+ return (
181
+ <box flexDirection="column" paddingX={1}>
182
+ <text fg={colors.textMuted} height={1}><strong>{headerLabel}</strong></text>
183
+ <text fg={colors.text} height={1}><strong>{formatCurrency(totalCost)}</strong></text>
184
+ </box>
185
+ );
186
+ }
187
+
188
+ if (!activeBudget) {
189
+ if (mode === 'micro') {
190
+ return (
191
+ <box flexDirection="row" height={1} paddingX={1}>
192
+ <text fg={colors.textMuted} height={1}>No {budgetTypeLabel.toLowerCase()} budget</text>
193
+ </box>
194
+ );
195
+ }
196
+
197
+ return (
198
+ <box flexDirection="column" paddingX={1}>
199
+ <text fg={colors.textMuted} height={1}><strong>{headerLabel}</strong></text>
200
+ <text fg={colors.textSubtle} height={1}>Not set</text>
201
+ <text fg={colors.textSubtle} height={1}>(, to configure)</text>
202
+ </box>
203
+ );
204
+ }
205
+
206
+ if (mode === 'micro') {
207
+ return (
208
+ <box flexDirection="column" paddingX={1}>
209
+ <box flexDirection="row" height={1}>
210
+ <text fg={colors.textMuted} height={1}>{budgetTypeLabel} </text>
211
+ <text fg={statusColor} height={1}><strong>{formatPercent(budgetUsedPercent)}</strong></text>
212
+ <text fg={colors.textMuted} height={1}> {formatBudget(budgetCost)}/{formatBudget(activeBudget)}</text>
213
+ </box>
214
+ </box>
215
+ );
216
+ }
217
+
218
+ return (
219
+ <box flexDirection="column" paddingX={1}>
220
+ <box flexDirection="row" height={1}>
221
+ <text fg={colors.textMuted}><strong>{headerLabel}</strong></text>
222
+ <text flexGrow={1}></text>
223
+ <text fg={statusColor}><strong>{formatPercent(budgetUsedPercent)}</strong></text>
224
+ </box>
225
+
226
+ <box flexDirection="row" height={1}>
227
+ <text fg={statusColor}>{bar}</text>
228
+ </box>
229
+
230
+ <box flexDirection="row" height={1}>
231
+ <text fg={colors.text}><strong>{formatBudget(budgetCost)}</strong></text>
232
+ <text fg={colors.textMuted}>/{formatBudget(activeBudget)}</text>
233
+ </box>
234
+ </box>
235
+ );
236
+ }
237
+
238
+ interface TopDriversSectionProps {
239
+ drivers: DriverStats[];
240
+ dimension: DriverDimension;
241
+ selectedIndex: number;
242
+ activeFilter: string | null;
243
+ isFocused: boolean;
244
+ mode: SidebarMode;
245
+ sidebarWidth: number;
246
+ maxDrivers: number;
247
+ getProviderColor: (id: string) => string;
248
+ }
249
+
250
+ const DIMENSION_LABELS: Record<DriverDimension, string> = {
251
+ model: 'MODELS',
252
+ project: 'PROJECTS',
253
+ agent: 'AGENTS',
254
+ };
255
+
256
+ function TopDriversSection({
257
+ drivers,
258
+ dimension,
259
+ selectedIndex,
260
+ activeFilter,
261
+ isFocused,
262
+ mode,
263
+ sidebarWidth,
264
+ maxDrivers,
265
+ getProviderColor,
266
+ }: TopDriversSectionProps) {
267
+ const colors = useColors();
268
+
269
+ const displayDrivers = drivers.slice(0, maxDrivers);
270
+ const innerWidth = sidebarWidth - 4;
271
+
272
+ const showBars = mode === 'full' || mode === 'compact';
273
+ const showDelta = mode === 'full';
274
+ const showPercent = mode !== 'minimal';
275
+
276
+ const prefixWidth = 2;
277
+ const nameWidth = mode === 'full' ? 14 : mode === 'compact' ? 12 : 10;
278
+ const deltaWidth = showDelta ? 7 : 0;
279
+ const percentWidth = showPercent ? 5 : 0;
280
+ const barWidth = showBars ? Math.max(8, innerWidth - prefixWidth - nameWidth - deltaWidth - percentWidth - 2) : 0;
281
+
282
+ const dimensionToggle = ['m', 'p', 'a'].map(k => {
283
+ const isActive = (k === 'm' && dimension === 'model') ||
284
+ (k === 'p' && dimension === 'project') ||
285
+ (k === 'a' && dimension === 'agent');
286
+ return isActive ? k.toUpperCase() : k;
287
+ }).join('/');
288
+
289
+ return (
290
+ <box flexDirection="column" paddingX={1}>
291
+ <box flexDirection="row" height={1}>
292
+ <text fg={colors.textMuted}><strong>TOP {DIMENSION_LABELS[dimension]}</strong></text>
293
+ <text flexGrow={1}></text>
294
+ <text fg={colors.textSubtle}>[{dimensionToggle}]</text>
295
+ </box>
296
+
297
+ {displayDrivers.length === 0 ? (
298
+ <text fg={colors.textSubtle} height={1}>No data</text>
299
+ ) : (
300
+ displayDrivers.map((driver, idx) => {
301
+ const isSelected = isFocused && idx === selectedIndex;
302
+ const isFiltered = activeFilter === driver.id;
303
+
304
+ const hotPrefix = driver.isHot ? '▲' : ' ';
305
+ const selectPrefix = isSelected ? '▸' : isFiltered ? '●' : ' ';
306
+ const displayName = truncateWithEllipsis(driver.displayName, nameWidth);
307
+ const paddedName = displayName.padEnd(nameWidth);
308
+ const delta = showDelta ? formatDelta(driver.delta5m).padStart(deltaWidth) : '';
309
+ const percent = showPercent ? formatPercent(driver.costShare).padStart(percentWidth) : '';
310
+ const bar = showBars ? makeProgressBar(driver.costShare, barWidth) : '';
311
+
312
+ const textColor = isSelected ? colors.text : colors.textSubtle;
313
+ const nameColor = isSelected ? colors.primary : getProviderColor(driver.providerId ?? driver.id);
314
+
315
+ if (showBars) {
316
+ return (
317
+ <box key={driver.id} flexDirection="column" height={2}>
318
+ <box flexDirection="row" height={1}>
319
+ <text fg={driver.isHot ? colors.warning : colors.textSubtle}>{hotPrefix}</text>
320
+ <text fg={textColor}>{selectPrefix}</text>
321
+ <text fg={nameColor}>{paddedName}</text>
322
+ {showDelta && <text fg={colors.success}>{delta}</text>}
323
+ {showPercent && <text fg={colors.textMuted}>{percent}</text>}
324
+ </box>
325
+ <box flexDirection="row" height={1}>
326
+ <text fg={isSelected ? colors.primary : colors.textSubtle}> {bar}</text>
327
+ </box>
328
+ </box>
329
+ );
330
+ }
331
+
332
+ return (
333
+ <box key={driver.id} flexDirection="row" height={1}>
334
+ <text fg={driver.isHot ? colors.warning : colors.textSubtle}>{hotPrefix}</text>
335
+ <text fg={textColor}>{selectPrefix}</text>
336
+ <text fg={nameColor}>{paddedName}</text>
337
+ <text fg={colors.text}> {formatCurrency(driver.cost)}</text>
338
+ </box>
339
+ );
340
+ })
341
+ )}
342
+
343
+ {isFocused && displayDrivers.length > 0 && (
344
+ <text fg={colors.textSubtle} height={1}>↑↓ select Enter filter</text>
345
+ )}
346
+ </box>
347
+ );
348
+ }
349
+
350
+ interface DividerProps {
351
+ width: number;
352
+ }
353
+
354
+ function Divider({ width }: DividerProps) {
355
+ const colors = useColors();
356
+ return (
357
+ <box paddingX={1} height={1}>
358
+ <text fg={colors.border} height={1}>{makeDivider(width)}</text>
359
+ </box>
360
+ );
361
+ }
362
+
363
+ interface EfficiencyInsight {
364
+ type: 'cache' | 'verbosity' | 'chatty' | 'fallback';
365
+ primaryLabel: string;
366
+ primaryValue: string;
367
+ actionLine: string;
368
+ secondaryLabel?: string;
369
+ isWarning: boolean;
370
+ }
371
+
372
+ interface EfficiencySectionProps {
373
+ sessions: AgentSessionAggregate[];
374
+ mode: SidebarMode;
375
+ }
376
+
377
+ function EfficiencySection({ sessions, mode }: EfficiencySectionProps) {
378
+ const colors = useColors();
379
+
380
+ const metrics = useMemo(() => {
381
+ let totalInput = 0, totalOutput = 0, totalCacheRead = 0;
382
+ let totalCost = 0, totalRequests = 0;
383
+ let hasCacheData = false;
384
+
385
+ sessions.forEach(s => {
386
+ s.streams.forEach((st: AgentSessionStream) => {
387
+ totalInput += st.tokens.input;
388
+ totalOutput += st.tokens.output;
389
+ totalCacheRead += st.tokens.cacheRead ?? 0;
390
+ totalCost += st.costUsd ?? 0;
391
+ totalRequests += st.requestCount;
392
+ if (st.tokens.cacheRead != null && st.tokens.cacheRead > 0) hasCacheData = true;
393
+ });
394
+ });
395
+
396
+ const cacheHitRate = (totalInput + totalCacheRead) > 0
397
+ ? (totalCacheRead / (totalInput + totalCacheRead)) * 100 : 0;
398
+ const outputShare = (totalInput + totalOutput) > 0
399
+ ? (totalOutput / (totalInput + totalOutput)) * 100 : 0;
400
+ const costPerReq = totalRequests > 0 ? totalCost / totalRequests : 0;
401
+
402
+ return { totalInput, totalOutput, totalCacheRead, totalCost, totalRequests, hasCacheData, cacheHitRate, outputShare, costPerReq };
403
+ }, [sessions]);
404
+
405
+ const insight: EfficiencyInsight = useMemo(() => {
406
+ // Priority 1: Cache Leverage
407
+ if (metrics.hasCacheData && metrics.totalInput > 1000 && metrics.cacheHitRate < 80) {
408
+ return {
409
+ type: 'cache',
410
+ primaryLabel: 'Cache hit:',
411
+ primaryValue: `${Math.round(metrics.cacheHitRate)}%`,
412
+ actionLine: 'Keep sessions open; avoid resets',
413
+ secondaryLabel: `$/req: ${formatCurrency(metrics.costPerReq)} Out: ${Math.round(metrics.outputShare)}%`,
414
+ isWarning: metrics.cacheHitRate < 20,
415
+ };
416
+ }
417
+ // Priority 2: Output Verbosity
418
+ if (metrics.totalInput + metrics.totalOutput > 1000 && metrics.outputShare > 65) {
419
+ return {
420
+ type: 'verbosity',
421
+ primaryLabel: 'Output share:',
422
+ primaryValue: `${Math.round(metrics.outputShare)}%`,
423
+ actionLine: 'Ask for diffs; less commentary',
424
+ secondaryLabel: `$/req: ${formatCurrency(metrics.costPerReq)}`,
425
+ isWarning: metrics.outputShare > 75,
426
+ };
427
+ }
428
+ // Priority 3: Chatty Usage
429
+ if (metrics.totalRequests > 10 && metrics.costPerReq > 0.15) {
430
+ return {
431
+ type: 'chatty',
432
+ primaryLabel: '$/req:',
433
+ primaryValue: formatCurrency(metrics.costPerReq),
434
+ actionLine: 'Batch asks; fewer back-and-forth',
435
+ secondaryLabel: `${metrics.totalRequests} reqs`,
436
+ isWarning: metrics.costPerReq > 0.25,
437
+ };
438
+ }
439
+ // Priority 4: Fallback
440
+ return {
441
+ type: 'fallback',
442
+ primaryLabel: `$/req: ${formatCurrency(metrics.costPerReq)}`,
443
+ primaryValue: `Out: ${Math.round(metrics.outputShare)}%`,
444
+ actionLine: 'Prefer patches; reduce re-asking',
445
+ isWarning: false,
446
+ };
447
+ }, [metrics]);
448
+
449
+ const isFull = mode === 'full';
450
+ const metricColor = insight.isWarning ? colors.warning : colors.text;
451
+
452
+ if (insight.type === 'fallback') {
453
+ const cacheLabel = metrics.hasCacheData
454
+ ? `Cache: ${Math.round(metrics.cacheHitRate)}%`
455
+ : 'Cache: n/a';
456
+
457
+ return (
458
+ <box flexDirection="column" paddingX={1}>
459
+ <text fg={colors.textMuted} height={1}><strong>EFFICIENCY</strong></text>
460
+ <box flexDirection="row" height={1}>
461
+ <text fg={colors.text} height={1}>{insight.primaryLabel} {insight.primaryValue}</text>
462
+ </box>
463
+ <text fg={colors.textSubtle} height={1}>{'-> '}{insight.actionLine}</text>
464
+ {isFull && (
465
+ <text fg={colors.textMuted} height={1}>Reqs: {metrics.totalRequests} {cacheLabel}</text>
466
+ )}
467
+ </box>
468
+ );
469
+ }
470
+
471
+ return (
472
+ <box flexDirection="column" paddingX={1}>
473
+ <text fg={colors.textMuted} height={1}><strong>EFFICIENCY</strong></text>
474
+ <box flexDirection="row" height={1}>
475
+ <text fg={colors.textMuted} height={1}>{insight.primaryLabel} </text>
476
+ <text fg={metricColor} height={1}>{insight.primaryValue}</text>
477
+ </box>
478
+ <text fg={colors.textSubtle} height={1}>{'-> '}{insight.actionLine}</text>
479
+ {isFull && insight.secondaryLabel && (
480
+ <text fg={colors.textMuted} height={1}>{insight.secondaryLabel}</text>
481
+ )}
482
+ </box>
483
+ );
484
+ }
485
+
486
+ export function SmartSidebar({
487
+ sessions,
488
+ budgetCost,
489
+ focusedPanel,
490
+ dimension,
491
+ selectedDriverIndex,
492
+ activeDriverFilter,
493
+ getProviderColor,
494
+ }: SmartSidebarProps) {
495
+ const colors = useColors();
496
+ const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
497
+
498
+ const mode = getSidebarMode(terminalWidth);
499
+ const sidebarWidth = getSidebarWidth(mode, terminalWidth);
500
+ const isFocused = focusedPanel === 'sidebar';
501
+
502
+ const showBars = mode === 'full' || mode === 'compact';
503
+ const driverRowHeight = showBars ? 2 : 1;
504
+
505
+ const sidebarInnerHeight = terminalHeight - 24;
506
+ const budgetRows = 3;
507
+ const dividerRows = 1;
508
+ const driversHeaderRow = 1;
509
+ const efficiencyRows = 4;
510
+
511
+ const minHeightForEfficiency = budgetRows + dividerRows + driversHeaderRow + (2 * driverRowHeight) + dividerRows + efficiencyRows;
512
+ const showEfficiency = sidebarInnerHeight >= minHeightForEfficiency;
513
+
514
+ const reservedRows = budgetRows + dividerRows + driversHeaderRow + (showEfficiency ? dividerRows + efficiencyRows : 0);
515
+ const availableForDrivers = Math.max(0, sidebarInnerHeight - reservedRows);
516
+ const maxDrivers = Math.max(1, Math.min(10, Math.floor(availableForDrivers / driverRowHeight)));
517
+
518
+ const totalCost = useMemo(() =>
519
+ sessions.reduce((sum, s) => sum + (s.totalCostUsd ?? 0), 0),
520
+ [sessions]
521
+ );
522
+
523
+ const drivers = useMemo((): DriverStats[] => {
524
+ const stats: Record<string, { cost: number; tokens: number; delta5m: number }> = {};
525
+ const providerForKey: Record<string, string> = {};
526
+ const now = Date.now();
527
+ const fiveMinAgo = now - 5 * 60 * 1000;
528
+
529
+ sessions.forEach(s => {
530
+ s.streams.forEach((st: AgentSessionStream) => {
531
+ let key: string;
532
+ switch (dimension) {
533
+ case 'model':
534
+ key = st.modelId;
535
+ if (!providerForKey[key]) providerForKey[key] = st.providerId;
536
+ break;
537
+ case 'project':
538
+ key = extractRepoName(s.projectPath);
539
+ break;
540
+ case 'agent':
541
+ key = s.agentName;
542
+ break;
543
+ }
544
+
545
+ if (!stats[key]) {
546
+ stats[key] = { cost: 0, tokens: 0, delta5m: 0 };
547
+ }
548
+
549
+ const cost = st.costUsd ?? 0;
550
+ const entry = stats[key]!;
551
+ entry.cost += cost;
552
+ entry.tokens += st.tokens.input + st.tokens.output;
553
+
554
+ if (s.lastActivityAt >= fiveMinAgo) {
555
+ const sessionDuration = Math.max(1, s.lastActivityAt - s.startedAt);
556
+ const recentWindow = Math.min(5 * 60 * 1000, now - fiveMinAgo);
557
+ const recentRatio = recentWindow / sessionDuration;
558
+ entry.delta5m += cost * Math.min(recentRatio, 1) * 0.5;
559
+ }
560
+ });
561
+ });
562
+
563
+ return Object.entries(stats)
564
+ .map(([id, data]): DriverStats => {
565
+ const displayName = dimension === 'model'
566
+ ? (id.split('/').pop() ?? id)
567
+ : id;
568
+ return {
569
+ id,
570
+ ...(providerForKey[id] != null ? { providerId: providerForKey[id] } : {}),
571
+ displayName,
572
+ cost: data.cost,
573
+ costShare: totalCost > 0 ? (data.cost / totalCost) * 100 : 0,
574
+ tokens: data.tokens,
575
+ delta5m: data.delta5m,
576
+ isHot: data.delta5m > 0.10,
577
+ };
578
+ })
579
+ .sort((a, b) => b.cost - a.cost);
580
+ }, [sessions, dimension, totalCost]);
581
+
582
+ if (mode === 'hidden') {
583
+ return null;
584
+ }
585
+
586
+ return (
587
+ <box
588
+ flexDirection="column"
589
+ width={sidebarWidth}
590
+ border
591
+ borderStyle={isFocused ? 'double' : 'single'}
592
+ borderColor={isFocused ? colors.primary : colors.border}
593
+ overflow="hidden"
594
+ >
595
+ <box flexDirection="column" flexGrow={1}>
596
+ <BudgetSection
597
+ totalCost={totalCost}
598
+ budgetCost={budgetCost}
599
+ mode={mode}
600
+ sidebarWidth={sidebarWidth}
601
+ />
602
+
603
+ {mode !== 'micro' && <Divider width={sidebarWidth} />}
604
+
605
+ {mode !== 'micro' && (
606
+ <TopDriversSection
607
+ drivers={drivers}
608
+ dimension={dimension}
609
+ selectedIndex={selectedDriverIndex}
610
+ activeFilter={activeDriverFilter}
611
+ isFocused={isFocused}
612
+ mode={mode}
613
+ sidebarWidth={sidebarWidth}
614
+ maxDrivers={maxDrivers}
615
+ getProviderColor={getProviderColor}
616
+ />
617
+ )}
618
+
619
+ {mode === 'micro' && drivers[0] && (
620
+ <box flexDirection="row" paddingX={1} height={1}>
621
+ <text fg={colors.textMuted} height={1}>Top: </text>
622
+ <text fg={getProviderColor(drivers[0].providerId ?? drivers[0].id)} height={1}>
623
+ {truncateWithEllipsis(drivers[0].displayName, 10)}
624
+ </text>
625
+ <text fg={colors.text} height={1}> {formatCurrency(drivers[0].cost)}</text>
626
+ {drivers[0].isHot && <text fg={colors.warning} height={1}>▲</text>}
627
+ </box>
628
+ )}
629
+ </box>
630
+
631
+ {showEfficiency && mode !== 'micro' && (
632
+ <box flexDirection="column">
633
+ <Divider width={sidebarWidth} />
634
+ <EfficiencySection
635
+ sessions={sessions}
636
+ mode={mode}
637
+ />
638
+ </box>
639
+ )}
640
+ </box>
641
+ );
642
+ }
643
+
644
+ export { getSidebarMode };