@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,1431 @@
1
+ import { useState, useMemo, useEffect } from 'react';
2
+ import { useKeyboard, useTerminalDimensions } from '@opentui/react';
3
+ import { useColors } from '../contexts/ThemeContext.tsx';
4
+ import { useStorageReady } from '../contexts/StorageContext.tsx';
5
+ import { useDemoMode } from '../contexts/DemoModeContext.tsx';
6
+ import { queryUsageTimeSeries, queryProviderDailyCosts, queryModelDailyCosts, queryProjectDailyCosts, isDatabaseInitialized } from '@/storage/index.ts';
7
+
8
+ // ============================================================================
9
+ // Types
10
+ // ============================================================================
11
+
12
+ type TimePeriod = '7d' | '30d' | '90d';
13
+ type MetricType = 'cost' | 'tokens' | 'requests';
14
+ type BreakdownDimension = 'provider' | 'model' | 'project' | 'off';
15
+
16
+ interface ChartPoint {
17
+ label: string;
18
+ dayName: string;
19
+ costUsd: number;
20
+ tokens: number;
21
+ requestCount: number;
22
+ }
23
+
24
+ interface ProviderContribution {
25
+ provider: string;
26
+ cost: number;
27
+ costShare: number;
28
+ tokens: number;
29
+ requests: number;
30
+ dailyCosts: number[];
31
+ }
32
+
33
+ interface PeriodSummary {
34
+ totalCost: number;
35
+ avgPerDay: number;
36
+ peakDay: { label: string; value: number } | null;
37
+ lowDay: { label: string; value: number } | null;
38
+ totalRequests: number;
39
+ totalTokens: number;
40
+ }
41
+
42
+ // ============================================================================
43
+ // Constants
44
+ // ============================================================================
45
+
46
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
47
+
48
+ const METRIC_LABELS: Record<MetricType, string> = {
49
+ cost: 'COST',
50
+ tokens: 'TOKENS',
51
+ requests: 'REQUESTS',
52
+ };
53
+
54
+ const CHART_CHARS = {
55
+ h: '─', v: '│', tl: '╭', tr: '╮', bl: '╰', br: '╯', cross: '┼', t_l: '┤',
56
+ };
57
+
58
+ const GHOST_CHARS = {
59
+ h: '╌', v: '╎', tl: '╭', tr: '╮', bl: '╰', br: '╯',
60
+ };
61
+
62
+ // ============================================================================
63
+ // Formatting utilities
64
+ // ============================================================================
65
+
66
+ function fmtCurrency(val: number): string {
67
+ if (val >= 1000) return `$${(val / 1000).toFixed(1)}k`;
68
+ if (val >= 100) return `$${Math.round(val)}`;
69
+ if (val >= 10) return `$${val.toFixed(1)}`;
70
+ return `$${val.toFixed(2)}`;
71
+ }
72
+
73
+ function fmtTokens(val: number): string {
74
+ if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`;
75
+ if (val >= 1000) return `${(val / 1000).toFixed(1)}K`;
76
+ return `${val}`;
77
+ }
78
+
79
+ function fmtNumber(val: number): string {
80
+ if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`;
81
+ if (val >= 1000) return `${(val / 1000).toFixed(1)}K`;
82
+ return `${val}`;
83
+ }
84
+
85
+ function fmtMetricValue(val: number, metric: MetricType): string {
86
+ switch (metric) {
87
+ case 'cost': return fmtCurrency(val);
88
+ case 'tokens': return fmtTokens(val);
89
+ case 'requests': return fmtNumber(val);
90
+ }
91
+ }
92
+
93
+ function fmtYAxisLabel(val: number, metric: MetricType): string {
94
+ switch (metric) {
95
+ case 'cost': return `$${Math.round(val)}`.padStart(5);
96
+ case 'tokens': return fmtTokens(val).padStart(5);
97
+ case 'requests': return `${Math.round(val)}`.padStart(5);
98
+ }
99
+ }
100
+
101
+ function fmtDeltaPct(current: number, previous: number, hasPreviousData = true): { text: string; positive: boolean } {
102
+ if (!hasPreviousData) {
103
+ return { text: '—', positive: true };
104
+ }
105
+ if (previous === 0) {
106
+ return current > 0 ? { text: '▲ new', positive: false } : { text: '── 0%', positive: true };
107
+ }
108
+ const pct = ((current - previous) / previous) * 100;
109
+ const sign = pct >= 0 ? '▲' : '▼';
110
+ return { text: `${sign} ${Math.abs(pct).toFixed(0)}%`, positive: pct <= 0 };
111
+ }
112
+
113
+ function miniSparkline(values: number[], width: number): string {
114
+ if (values.length === 0) return ' '.repeat(width);
115
+ const max = Math.max(...values, 0.001);
116
+ const blocks = '▁▂▃▄▅▆▇█';
117
+ const step = Math.max(1, values.length / width);
118
+ let result = '';
119
+ for (let i = 0; i < width; i++) {
120
+ const idx = Math.min(Math.floor(i * step), values.length - 1);
121
+ const v = values[idx] ?? 0;
122
+ const normalized = Math.min(7, Math.floor((v / max) * 8));
123
+ result += v <= 0 ? '▁' : (blocks[normalized] ?? '▁');
124
+ }
125
+ return result;
126
+ }
127
+
128
+ function padRight(str: string, len: number): string {
129
+ return str.length >= len ? str.slice(0, len) : str + ' '.repeat(len - str.length);
130
+ }
131
+
132
+ function truncate(str: string, maxLen: number): string {
133
+ return str.length <= maxLen ? str : str.slice(0, maxLen - 1) + '…';
134
+ }
135
+
136
+ // ============================================================================
137
+ // Data helpers
138
+ // ============================================================================
139
+
140
+ function getDaysBack(period: TimePeriod): number {
141
+ return period === '30d' ? 30 : period === '90d' ? 90 : 7;
142
+ }
143
+
144
+ function formatDateLabel(date: Date, period: TimePeriod, index: number): string {
145
+ if (period === '7d') return date.toLocaleDateString('en-US', { weekday: 'short' });
146
+ if (period === '30d') return index % 5 === 0 ? date.getUTCDate().toString() : '';
147
+ return index % 15 === 0 ? `${date.getUTCMonth() + 1}/${date.getUTCDate()}` : '';
148
+ }
149
+
150
+ function getDayName(date: Date): string {
151
+ return date.toLocaleDateString('en-US', { weekday: 'short' });
152
+ }
153
+
154
+ function getMetricValue(point: ChartPoint, metric: MetricType): number {
155
+ switch (metric) {
156
+ case 'cost': return point.costUsd;
157
+ case 'tokens': return point.tokens;
158
+ case 'requests': return point.requestCount;
159
+ }
160
+ }
161
+
162
+ function computeSummary(points: ChartPoint[], metric: MetricType): PeriodSummary {
163
+ const totalCost = points.reduce((sum, p) => sum + p.costUsd, 0);
164
+ const totalTokens = points.reduce((sum, p) => sum + p.tokens, 0);
165
+ const totalRequests = points.reduce((sum, p) => sum + p.requestCount, 0);
166
+ const metricTotal = metric === 'tokens' ? totalTokens : metric === 'requests' ? totalRequests : totalCost;
167
+ const avgPerDay = points.length > 0 ? metricTotal / points.length : 0;
168
+
169
+ let peakDay: { label: string; value: number } | null = null;
170
+ let lowDay: { label: string; value: number } | null = null;
171
+
172
+ for (const p of points) {
173
+ const v = getMetricValue(p, metric);
174
+ if (!peakDay || v > peakDay.value) peakDay = { label: p.dayName || p.label, value: v };
175
+ if (!lowDay || v < lowDay.value) lowDay = { label: p.dayName || p.label, value: v };
176
+ }
177
+
178
+ return { totalCost, avgPerDay, peakDay, lowDay, totalRequests, totalTokens };
179
+ }
180
+
181
+ // ============================================================================
182
+ // Data fetching — Real mode
183
+ // ============================================================================
184
+
185
+ function fetchTimeSeries(period: TimePeriod, offsetPeriods = 0): ChartPoint[] {
186
+ if (!isDatabaseInitialized()) return [];
187
+
188
+ const now = Date.now();
189
+ const daysBack = getDaysBack(period);
190
+ const endMs = now - offsetPeriods * daysBack * MS_PER_DAY;
191
+ const startMs = endMs - daysBack * MS_PER_DAY;
192
+
193
+ try {
194
+ const timeSeries = queryUsageTimeSeries(startMs, endMs, MS_PER_DAY);
195
+ const byBucket = new Map<number, { costUsd: number; tokens: number; requests: number }>();
196
+ for (const point of timeSeries) {
197
+ byBucket.set(point.bucketStart, {
198
+ costUsd: point.costUsd,
199
+ tokens: point.tokens,
200
+ requests: point.requestCount,
201
+ });
202
+ }
203
+
204
+ const points: ChartPoint[] = [];
205
+ for (let i = daysBack - 1; i >= 0; i--) {
206
+ const dayTs = endMs - i * MS_PER_DAY;
207
+ const bucket = Math.floor(dayTs / MS_PER_DAY) * MS_PER_DAY;
208
+ const data = byBucket.get(bucket);
209
+ const d = new Date(bucket);
210
+ points.push({
211
+ label: formatDateLabel(d, period, i),
212
+ dayName: getDayName(d),
213
+ costUsd: data?.costUsd ?? 0,
214
+ tokens: data?.tokens ?? 0,
215
+ requestCount: data?.requests ?? 0,
216
+ });
217
+ }
218
+ return points;
219
+ } catch {
220
+ return [];
221
+ }
222
+ }
223
+
224
+ function fetchProviderBreakdown(period: TimePeriod): ProviderContribution[] {
225
+ if (!isDatabaseInitialized()) return [];
226
+
227
+ const now = Date.now();
228
+ const daysBack = getDaysBack(period);
229
+ const startMs = now - daysBack * MS_PER_DAY;
230
+
231
+ try {
232
+ const rows = queryProviderDailyCosts(startMs, now, MS_PER_DAY);
233
+
234
+ const providerMap = new Map<string, {
235
+ cost: number; tokens: number; requests: number;
236
+ dailyCosts: Map<number, number>;
237
+ }>();
238
+
239
+ for (const row of rows) {
240
+ const entry = providerMap.get(row.provider) ?? {
241
+ cost: 0, tokens: 0, requests: 0, dailyCosts: new Map(),
242
+ };
243
+ entry.cost += row.costUsd;
244
+ entry.tokens += row.tokens;
245
+ entry.requests += row.requestCount;
246
+ entry.dailyCosts.set(row.bucketStart, (entry.dailyCosts.get(row.bucketStart) ?? 0) + row.costUsd);
247
+ providerMap.set(row.provider, entry);
248
+ }
249
+
250
+ const totalCost = Array.from(providerMap.values()).reduce((sum, p) => sum + p.cost, 0);
251
+
252
+ return Array.from(providerMap.entries())
253
+ .map(([provider, d]) => {
254
+ const dailyCosts: number[] = [];
255
+ for (let i = daysBack - 1; i >= 0; i--) {
256
+ const dayTs = Date.now() - i * MS_PER_DAY;
257
+ const bucket = Math.floor(dayTs / MS_PER_DAY) * MS_PER_DAY;
258
+ dailyCosts.push(d.dailyCosts.get(bucket) ?? 0);
259
+ }
260
+ return {
261
+ provider,
262
+ cost: d.cost,
263
+ costShare: totalCost > 0 ? (d.cost / totalCost) * 100 : 0,
264
+ tokens: d.tokens,
265
+ requests: d.requests,
266
+ dailyCosts,
267
+ };
268
+ })
269
+ .sort((a, b) => b.cost - a.cost)
270
+ .slice(0, 4);
271
+ } catch {
272
+ return [];
273
+ }
274
+ }
275
+
276
+ function fetchModelBreakdown(period: TimePeriod): ProviderContribution[] {
277
+ if (!isDatabaseInitialized()) return [];
278
+
279
+ const now = Date.now();
280
+ const daysBack = getDaysBack(period);
281
+ const startMs = now - daysBack * MS_PER_DAY;
282
+
283
+ try {
284
+ const rows = queryModelDailyCosts(startMs, now, MS_PER_DAY);
285
+
286
+ const modelMap = new Map<string, {
287
+ cost: number; tokens: number; requests: number;
288
+ dailyCosts: Map<number, number>;
289
+ }>();
290
+
291
+ for (const row of rows) {
292
+ const entry = modelMap.get(row.model) ?? {
293
+ cost: 0, tokens: 0, requests: 0, dailyCosts: new Map(),
294
+ };
295
+ entry.cost += row.costUsd;
296
+ entry.tokens += row.tokens;
297
+ entry.requests += row.requestCount;
298
+ entry.dailyCosts.set(row.bucketStart, (entry.dailyCosts.get(row.bucketStart) ?? 0) + row.costUsd);
299
+ modelMap.set(row.model, entry);
300
+ }
301
+
302
+ const totalCost = Array.from(modelMap.values()).reduce((sum, p) => sum + p.cost, 0);
303
+
304
+ return Array.from(modelMap.entries())
305
+ .map(([model, d]) => {
306
+ const dailyCosts: number[] = [];
307
+ for (let i = daysBack - 1; i >= 0; i--) {
308
+ const dayTs = Date.now() - i * MS_PER_DAY;
309
+ const bucket = Math.floor(dayTs / MS_PER_DAY) * MS_PER_DAY;
310
+ dailyCosts.push(d.dailyCosts.get(bucket) ?? 0);
311
+ }
312
+ return {
313
+ provider: model,
314
+ cost: d.cost,
315
+ costShare: totalCost > 0 ? (d.cost / totalCost) * 100 : 0,
316
+ tokens: d.tokens,
317
+ requests: d.requests,
318
+ dailyCosts,
319
+ };
320
+ })
321
+ .sort((a, b) => b.cost - a.cost)
322
+ .slice(0, 4);
323
+ } catch {
324
+ return [];
325
+ }
326
+ }
327
+
328
+ function fetchProjectBreakdown(period: TimePeriod): ProviderContribution[] {
329
+ if (!isDatabaseInitialized()) return [];
330
+
331
+ const now = Date.now();
332
+ const daysBack = getDaysBack(period);
333
+ const startMs = now - daysBack * MS_PER_DAY;
334
+
335
+ try {
336
+ const rows = queryProjectDailyCosts(startMs, now, MS_PER_DAY);
337
+
338
+ const projectMap = new Map<string, {
339
+ cost: number; tokens: number; requests: number;
340
+ dailyCosts: Map<number, number>;
341
+ }>();
342
+
343
+ for (const row of rows) {
344
+ const displayName = row.projectPath.split('/').pop() ?? row.projectPath;
345
+ const entry = projectMap.get(displayName) ?? {
346
+ cost: 0, tokens: 0, requests: 0, dailyCosts: new Map(),
347
+ };
348
+ entry.cost += row.costUsd;
349
+ entry.tokens += row.tokens;
350
+ entry.requests += row.requestCount;
351
+ entry.dailyCosts.set(row.bucketStart, (entry.dailyCosts.get(row.bucketStart) ?? 0) + row.costUsd);
352
+ projectMap.set(displayName, entry);
353
+ }
354
+
355
+ const totalCost = Array.from(projectMap.values()).reduce((sum, p) => sum + p.cost, 0);
356
+
357
+ return Array.from(projectMap.entries())
358
+ .map(([project, d]) => {
359
+ const dailyCosts: number[] = [];
360
+ for (let i = daysBack - 1; i >= 0; i--) {
361
+ const dayTs = Date.now() - i * MS_PER_DAY;
362
+ const bucket = Math.floor(dayTs / MS_PER_DAY) * MS_PER_DAY;
363
+ dailyCosts.push(d.dailyCosts.get(bucket) ?? 0);
364
+ }
365
+ return {
366
+ provider: project,
367
+ cost: d.cost,
368
+ costShare: totalCost > 0 ? (d.cost / totalCost) * 100 : 0,
369
+ tokens: d.tokens,
370
+ requests: d.requests,
371
+ dailyCosts,
372
+ };
373
+ })
374
+ .sort((a, b) => b.cost - a.cost)
375
+ .slice(0, 4);
376
+ } catch {
377
+ return [];
378
+ }
379
+ }
380
+
381
+ // ============================================================================
382
+ // Data fetching — Demo mode
383
+ // ============================================================================
384
+
385
+ function fetchDemoTimeSeries(
386
+ simulator: NonNullable<ReturnType<typeof useDemoMode>['simulator']>,
387
+ period: TimePeriod,
388
+ offsetPeriods = 0
389
+ ): ChartPoint[] {
390
+ const daysBack = getDaysBack(period);
391
+ const totalDays = daysBack * (1 + offsetPeriods);
392
+ const historicalData = simulator.generateHistoricalCostData(totalDays);
393
+
394
+ const sliceEnd = historicalData.length - offsetPeriods * daysBack;
395
+ const periodData = historicalData.slice(
396
+ Math.max(0, sliceEnd - daysBack),
397
+ sliceEnd
398
+ );
399
+
400
+ return periodData.map((item, index) => {
401
+ const d = new Date(item.date);
402
+ const tokens = Math.floor(item.cost * 18000);
403
+ const requests = Math.floor(item.cost * 10);
404
+ return {
405
+ label: formatDateLabel(d, period, daysBack - 1 - index),
406
+ dayName: getDayName(d),
407
+ costUsd: item.cost,
408
+ tokens,
409
+ requestCount: requests,
410
+ };
411
+ });
412
+ }
413
+
414
+ function fetchDemoProviderBreakdown(
415
+ simulator: NonNullable<ReturnType<typeof useDemoMode>['simulator']>,
416
+ period: TimePeriod
417
+ ): ProviderContribution[] {
418
+ const daysBack = getDaysBack(period);
419
+ const byProvider = simulator.generateHistoricalCostDataByProvider(daysBack);
420
+
421
+ const providerMap = new Map<string, {
422
+ cost: number; tokens: number; requests: number;
423
+ }>();
424
+ const providerDayMap = new Map<string, Map<number, number>>();
425
+
426
+ for (const entry of byProvider) {
427
+ if (!providerDayMap.has(entry.provider)) {
428
+ providerDayMap.set(entry.provider, new Map());
429
+ }
430
+ providerDayMap.get(entry.provider)!.set(entry.date, entry.cost);
431
+
432
+ const existing = providerMap.get(entry.provider) ?? {
433
+ cost: 0, tokens: 0, requests: 0,
434
+ };
435
+ existing.cost += entry.cost;
436
+ existing.tokens += entry.tokens;
437
+ existing.requests += entry.requests;
438
+ providerMap.set(entry.provider, existing);
439
+ }
440
+
441
+ const totalCost = Array.from(providerMap.values()).reduce((sum, p) => sum + p.cost, 0);
442
+ const allDates = [...new Set(byProvider.map(e => e.date))].sort((a, b) => a - b);
443
+
444
+ return Array.from(providerMap.entries())
445
+ .map(([provider, d]) => {
446
+ const dayMap = providerDayMap.get(provider) ?? new Map();
447
+ const dailyCosts = allDates.map(date => dayMap.get(date) ?? 0);
448
+ return {
449
+ provider,
450
+ cost: d.cost,
451
+ costShare: totalCost > 0 ? (d.cost / totalCost) * 100 : 0,
452
+ tokens: d.tokens,
453
+ requests: d.requests,
454
+ dailyCosts,
455
+ };
456
+ })
457
+ .sort((a, b) => b.cost - a.cost)
458
+ .slice(0, 4);
459
+ }
460
+
461
+ function fetchDemoModelBreakdown(
462
+ simulator: NonNullable<ReturnType<typeof useDemoMode>['simulator']>,
463
+ period: TimePeriod
464
+ ): ProviderContribution[] {
465
+ const daysBack = getDaysBack(period);
466
+ const byModel = simulator.generateHistoricalCostDataByModel(daysBack);
467
+
468
+ const modelMap = new Map<string, { cost: number; tokens: number; requests: number }>();
469
+ const modelDayMap = new Map<string, Map<number, number>>();
470
+
471
+ for (const entry of byModel) {
472
+ if (!modelDayMap.has(entry.model)) modelDayMap.set(entry.model, new Map());
473
+ modelDayMap.get(entry.model)!.set(entry.date, entry.cost);
474
+
475
+ const existing = modelMap.get(entry.model) ?? { cost: 0, tokens: 0, requests: 0 };
476
+ existing.cost += entry.cost;
477
+ existing.tokens += entry.tokens;
478
+ existing.requests += entry.requests;
479
+ modelMap.set(entry.model, existing);
480
+ }
481
+
482
+ const totalCost = Array.from(modelMap.values()).reduce((sum, p) => sum + p.cost, 0);
483
+ const allDates = [...new Set(byModel.map(e => e.date))].sort((a, b) => a - b);
484
+
485
+ return Array.from(modelMap.entries())
486
+ .map(([model, d]) => {
487
+ const dayMap = modelDayMap.get(model) ?? new Map();
488
+ const dailyCosts = allDates.map(date => dayMap.get(date) ?? 0);
489
+ return {
490
+ provider: model,
491
+ cost: d.cost,
492
+ costShare: totalCost > 0 ? (d.cost / totalCost) * 100 : 0,
493
+ tokens: d.tokens,
494
+ requests: d.requests,
495
+ dailyCosts,
496
+ };
497
+ })
498
+ .sort((a, b) => b.cost - a.cost)
499
+ .slice(0, 4);
500
+ }
501
+
502
+ function fetchDemoProjectBreakdown(
503
+ simulator: NonNullable<ReturnType<typeof useDemoMode>['simulator']>,
504
+ period: TimePeriod
505
+ ): ProviderContribution[] {
506
+ const daysBack = getDaysBack(period);
507
+ const byProject = simulator.generateHistoricalCostDataByProject(daysBack);
508
+
509
+ const projectMap = new Map<string, { cost: number; tokens: number; requests: number }>();
510
+ const projectDayMap = new Map<string, Map<number, number>>();
511
+
512
+ for (const entry of byProject) {
513
+ const displayName = entry.projectPath.split('/').pop() ?? entry.projectPath;
514
+ if (!projectDayMap.has(displayName)) projectDayMap.set(displayName, new Map());
515
+ projectDayMap.get(displayName)!.set(entry.date, (projectDayMap.get(displayName)!.get(entry.date) ?? 0) + entry.cost);
516
+
517
+ const existing = projectMap.get(displayName) ?? { cost: 0, tokens: 0, requests: 0 };
518
+ existing.cost += entry.cost;
519
+ existing.tokens += entry.tokens;
520
+ existing.requests += entry.requests;
521
+ projectMap.set(displayName, existing);
522
+ }
523
+
524
+ const totalCost = Array.from(projectMap.values()).reduce((sum, p) => sum + p.cost, 0);
525
+ const allDates = [...new Set(byProject.map(e => e.date))].sort((a, b) => a - b);
526
+
527
+ return Array.from(projectMap.entries())
528
+ .map(([project, d]) => {
529
+ const dayMap = projectDayMap.get(project) ?? new Map();
530
+ const dailyCosts = allDates.map(date => dayMap.get(date) ?? 0);
531
+ return {
532
+ provider: project,
533
+ cost: d.cost,
534
+ costShare: totalCost > 0 ? (d.cost / totalCost) * 100 : 0,
535
+ tokens: d.tokens,
536
+ requests: d.requests,
537
+ dailyCosts,
538
+ };
539
+ })
540
+ .sort((a, b) => b.cost - a.cost)
541
+ .slice(0, 4);
542
+ }
543
+
544
+ function fetchDemoPrevContributors(
545
+ simulator: NonNullable<ReturnType<typeof useDemoMode>['simulator']>,
546
+ period: TimePeriod
547
+ ): ProviderContribution[] {
548
+ const daysBack = getDaysBack(period);
549
+ const allByProvider = simulator.generateHistoricalCostDataByProvider(daysBack * 2);
550
+ const halfLen = Math.floor(allByProvider.length / 2);
551
+ const prevEntries = allByProvider.slice(0, halfLen);
552
+
553
+ const providerMap = new Map<string, number>();
554
+ for (const entry of prevEntries) {
555
+ providerMap.set(entry.provider, (providerMap.get(entry.provider) ?? 0) + entry.cost);
556
+ }
557
+ const totalCost = Array.from(providerMap.values()).reduce((s, v) => s + v, 0);
558
+
559
+ return Array.from(providerMap.entries())
560
+ .map(([provider, cost]) => ({
561
+ provider,
562
+ cost,
563
+ costShare: totalCost > 0 ? (cost / totalCost) * 100 : 0,
564
+ tokens: Math.floor(cost * 18000),
565
+ requests: Math.floor(cost * 10),
566
+ dailyCosts: [],
567
+ }))
568
+ .sort((a, b) => b.cost - a.cost);
569
+ }
570
+
571
+ // ============================================================================
572
+ // AsciiChart component (enhanced with comparison + cursor)
573
+ // ============================================================================
574
+
575
+ type CellType = 'empty' | 'main' | 'ghost' | 'cursor';
576
+
577
+ interface AsciiChartProps {
578
+ data: ChartPoint[];
579
+ comparisonData?: ChartPoint[] | undefined;
580
+ metric: MetricType;
581
+ cursorIndex: number | null;
582
+ height: number;
583
+ width: number;
584
+ color: string;
585
+ ghostColor: string;
586
+ cursorColor: string;
587
+ labelColor: string;
588
+ gridColor: string;
589
+ }
590
+
591
+ const AsciiChart = ({
592
+ data, comparisonData, metric, cursorIndex,
593
+ height, width, color, ghostColor, cursorColor, labelColor, gridColor,
594
+ }: AsciiChartProps) => {
595
+ const chartHeight = height - 2;
596
+ const yAxisWidth = 6;
597
+ const chartWidth = width - yAxisWidth;
598
+
599
+ if (data.length < 2 || chartHeight < 3 || chartWidth < 10) {
600
+ return <box><text fg={labelColor}>Chart too small</text></box>;
601
+ }
602
+
603
+ const values = data.map(d => getMetricValue(d, metric));
604
+ const compValues = comparisonData?.map(d => getMetricValue(d, metric)) ?? [];
605
+ const allValues = [...values, ...compValues];
606
+ const maxVal = Math.max(...allValues, metric === 'cost' ? 10 : 100) * 1.1;
607
+ const minVal = 0;
608
+
609
+ const normalize = (v: number) =>
610
+ Math.min(chartHeight - 1, Math.max(0, Math.floor(((v - minVal) / (maxVal - minVal)) * chartHeight)));
611
+
612
+ const gridChars: string[][] = Array.from({ length: chartHeight }, () => Array(chartWidth).fill(' '));
613
+ const gridTypes: CellType[][] = Array.from({ length: chartHeight }, () =>
614
+ Array<CellType>(chartWidth).fill('empty')
615
+ );
616
+
617
+ const plotLine = (
618
+ vals: number[],
619
+ chars: { h: string; v: string; tl: string; tr: string; bl: string; br: string },
620
+ cellType: CellType
621
+ ) => {
622
+ if (vals.length < 2) return;
623
+ const normalized = vals.map(normalize);
624
+ const stepsPerPoint = chartWidth / (vals.length - 1);
625
+ let currentX = 0;
626
+
627
+ for (let i = 0; i < vals.length - 1; i++) {
628
+ const y1 = normalized[i] ?? 0;
629
+ const y2 = normalized[i + 1] ?? 0;
630
+ const xStart = Math.round(currentX);
631
+ const xEnd = Math.round(currentX + stepsPerPoint);
632
+ const xMid = Math.floor((xStart + xEnd) / 2);
633
+
634
+ for (let x = xStart; x < xMid; x++) {
635
+ if (x < chartWidth && gridChars[y1]) {
636
+ gridChars[y1]![x] = chars.h;
637
+ gridTypes[y1]![x] = cellType;
638
+ }
639
+ }
640
+
641
+ if (y2 > y1) {
642
+ if (xMid < chartWidth && gridChars[y1]) { gridChars[y1]![xMid] = chars.br; gridTypes[y1]![xMid] = cellType; }
643
+ for (let y = y1 + 1; y < y2; y++) {
644
+ if (xMid < chartWidth && gridChars[y]) { gridChars[y]![xMid] = chars.v; gridTypes[y]![xMid] = cellType; }
645
+ }
646
+ if (xMid < chartWidth && gridChars[y2]) { gridChars[y2]![xMid] = chars.tl; gridTypes[y2]![xMid] = cellType; }
647
+ } else if (y2 < y1) {
648
+ if (xMid < chartWidth && gridChars[y1]) { gridChars[y1]![xMid] = chars.tr; gridTypes[y1]![xMid] = cellType; }
649
+ for (let y = y1 - 1; y > y2; y--) {
650
+ if (xMid < chartWidth && gridChars[y]) { gridChars[y]![xMid] = chars.v; gridTypes[y]![xMid] = cellType; }
651
+ }
652
+ if (xMid < chartWidth && gridChars[y2]) { gridChars[y2]![xMid] = chars.bl; gridTypes[y2]![xMid] = cellType; }
653
+ } else {
654
+ if (xMid < chartWidth && gridChars[y1]) { gridChars[y1]![xMid] = chars.h; gridTypes[y1]![xMid] = cellType; }
655
+ }
656
+
657
+ for (let x = xMid + 1; x < xEnd; x++) {
658
+ if (x < chartWidth && gridChars[y2]) { gridChars[y2]![x] = chars.h; gridTypes[y2]![x] = cellType; }
659
+ }
660
+
661
+ currentX += stepsPerPoint;
662
+ }
663
+
664
+ const lastY = normalized[vals.length - 1] ?? 0;
665
+ const lastX = Math.round(currentX);
666
+ if (lastX < chartWidth && gridChars[lastY]) {
667
+ gridChars[lastY]![lastX] = chars.h;
668
+ gridTypes[lastY]![lastX] = cellType;
669
+ }
670
+ };
671
+
672
+ // Plot ghost first (comparison), then main on top
673
+ if (compValues.length >= 2) {
674
+ plotLine(compValues, GHOST_CHARS, 'ghost');
675
+ }
676
+ plotLine(values, CHART_CHARS, 'main');
677
+
678
+ // Cursor overlay — final pass, draws on top of chart data
679
+ let cursorCol: number | null = null;
680
+ if (cursorIndex !== null && cursorIndex >= 0 && cursorIndex < data.length) {
681
+ const stepsPerPoint = chartWidth / (data.length - 1);
682
+ cursorCol = Math.round(cursorIndex * stepsPerPoint);
683
+ if (cursorCol >= 0 && cursorCol < chartWidth) {
684
+ for (let y = 0; y < chartHeight; y++) {
685
+ const cellType = gridTypes[y]?.[cursorCol];
686
+ if (cellType === 'empty') {
687
+ gridChars[y]![cursorCol] = '┆';
688
+ } else if (cellType === 'main' || cellType === 'ghost') {
689
+ gridChars[y]![cursorCol] = '╋';
690
+ }
691
+ gridTypes[y]![cursorCol] = 'cursor';
692
+ }
693
+ }
694
+ }
695
+
696
+ // Render rows
697
+ const rows = [];
698
+ for (let r = chartHeight - 1; r >= 0; r--) {
699
+ const rowVal = minVal + (r / (chartHeight - 1)) * (maxVal - minVal);
700
+ const labelCount = Math.max(3, Math.min(7, Math.floor(chartHeight / 5) + 1));
701
+ const labelStep = (chartHeight - 1) / (labelCount - 1);
702
+ const showLabel = Array.from({ length: labelCount }, (_, i) => Math.round(i * labelStep)).includes(r);
703
+ const label = showLabel ? fmtYAxisLabel(rowVal, metric) : ' ';
704
+ const sep = r === 0 ? CHART_CHARS.cross : CHART_CHARS.t_l;
705
+
706
+ rows.push(
707
+ <box key={`row-${r}`} flexDirection="row" height={1}>
708
+ <text width={5} height={1} fg={labelColor}>{label}</text>
709
+ <text width={1} height={1} fg={gridColor}>{sep}</text>
710
+ <text flexGrow={1} height={1}>
711
+ {(gridChars[r] ?? []).map((char, cx) => {
712
+ const type = gridTypes[r]?.[cx] ?? 'empty';
713
+ let fg: string | undefined;
714
+ if (type === 'main') fg = color;
715
+ else if (type === 'ghost') fg = ghostColor;
716
+ else if (type === 'cursor') fg = cursorColor;
717
+ return <span key={cx} {...(fg ? { fg } : {})}>{char}</span>;
718
+ })}
719
+ </text>
720
+ </box>
721
+ );
722
+ }
723
+
724
+ // Cursor indicator above chart
725
+ let cursorIndicator = null;
726
+ if (cursorIndex !== null && cursorCol !== null) {
727
+ const cursorDay = data[cursorIndex];
728
+ if (cursorDay) {
729
+ const indicatorText = `▼ ${cursorDay.dayName}`;
730
+ const padLen = Math.max(0, cursorCol + yAxisWidth - 1);
731
+ cursorIndicator = (
732
+ <box height={1}>
733
+ <text height={1} fg={cursorColor}>{' '.repeat(padLen)}{indicatorText}</text>
734
+ </box>
735
+ );
736
+ }
737
+ }
738
+
739
+ // X-axis labels
740
+ const labelParts = data.map((d, i) => {
741
+ if (!d.label) return '';
742
+ if (cursorIndex === i) return `[${d.label}]`;
743
+ return d.label;
744
+ }).filter(l => l).join(' ').slice(0, chartWidth);
745
+
746
+ const labelRow = (
747
+ <box flexDirection="row" height={1} paddingLeft={yAxisWidth}>
748
+ <text height={1} fg={labelColor}>{labelParts}</text>
749
+ </box>
750
+ );
751
+
752
+ return (
753
+ <box flexDirection="column">
754
+ {cursorIndicator}
755
+ {rows}
756
+ {labelRow}
757
+ </box>
758
+ );
759
+ };
760
+
761
+ // ============================================================================
762
+ // InsightPanel — Period Summary
763
+ // ============================================================================
764
+
765
+ function PeriodSummarySection({ summary, metric }: { summary: PeriodSummary; metric: MetricType }) {
766
+ const colors = useColors();
767
+
768
+ return (
769
+ <box flexDirection="column" paddingX={1}>
770
+ <text fg={colors.textMuted} height={1}><strong>PERIOD SUMMARY</strong></text>
771
+ <box flexDirection="row" justifyContent="space-between" height={1}>
772
+ <text fg={colors.textMuted} height={1}>Total</text>
773
+ <text fg={colors.success} height={1}><strong>{fmtMetricValue(metric === 'cost' ? summary.totalCost : metric === 'tokens' ? summary.totalTokens : summary.totalRequests, metric)}</strong></text>
774
+ </box>
775
+ <box flexDirection="row" justifyContent="space-between" height={1}>
776
+ <text fg={colors.textMuted} height={1}>Avg/day</text>
777
+ <text fg={colors.text} height={1}>{fmtMetricValue(summary.avgPerDay, metric)}</text>
778
+ </box>
779
+ {summary.peakDay && (
780
+ <box flexDirection="row" justifyContent="space-between" height={1}>
781
+ <text fg={colors.textMuted} height={1}>Peak</text>
782
+ <text fg={colors.warning} height={1}>{summary.peakDay.label} {fmtMetricValue(summary.peakDay.value, metric)}</text>
783
+ </box>
784
+ )}
785
+ {summary.lowDay && (
786
+ <box flexDirection="row" justifyContent="space-between" height={1}>
787
+ <text fg={colors.textMuted} height={1}>Low</text>
788
+ <text fg={colors.info} height={1}>{summary.lowDay.label} {fmtMetricValue(summary.lowDay.value, metric)}</text>
789
+ </box>
790
+ )}
791
+ <box flexDirection="row" justifyContent="space-between" height={1}>
792
+ <text fg={colors.textMuted} height={1}>Requests</text>
793
+ <text fg={colors.text} height={1}>{fmtNumber(summary.totalRequests)}</text>
794
+ </box>
795
+ <box flexDirection="row" justifyContent="space-between" height={1}>
796
+ <text fg={colors.textMuted} height={1}>Tokens</text>
797
+ <text fg={colors.text} height={1}>{fmtTokens(summary.totalTokens)}</text>
798
+ </box>
799
+ </box>
800
+ );
801
+ }
802
+
803
+ // ============================================================================
804
+ // InsightPanel — vs Previous Period
805
+ // ============================================================================
806
+
807
+ function DeltaSection({ current, previous, hasPrevData }: { current: PeriodSummary; previous: PeriodSummary; hasPrevData: boolean }) {
808
+ const colors = useColors();
809
+ const costDelta = fmtDeltaPct(current.totalCost, previous.totalCost, hasPrevData);
810
+ const tokensDelta = fmtDeltaPct(current.totalTokens, previous.totalTokens, hasPrevData);
811
+ const reqDelta = fmtDeltaPct(current.totalRequests, previous.totalRequests, hasPrevData);
812
+
813
+ return (
814
+ <box flexDirection="column" paddingX={1}>
815
+ <text fg={colors.textMuted} height={1}><strong>vs PREVIOUS</strong></text>
816
+ <box flexDirection="row" justifyContent="space-between" height={1}>
817
+ <text fg={colors.textMuted} height={1}>Cost</text>
818
+ <text fg={costDelta.positive ? colors.success : colors.error} height={1}>{costDelta.text}</text>
819
+ </box>
820
+ <box flexDirection="row" justifyContent="space-between" height={1}>
821
+ <text fg={colors.textMuted} height={1}>Tokens</text>
822
+ <text fg={tokensDelta.positive ? colors.success : colors.error} height={1}>{tokensDelta.text}</text>
823
+ </box>
824
+ <box flexDirection="row" justifyContent="space-between" height={1}>
825
+ <text fg={colors.textMuted} height={1}>Requests</text>
826
+ <text fg={reqDelta.positive ? colors.success : colors.error} height={1}>{reqDelta.text}</text>
827
+ </box>
828
+ </box>
829
+ );
830
+ }
831
+
832
+ // ============================================================================
833
+ // InsightPanel — Comparison Table (Phase 2)
834
+ // ============================================================================
835
+
836
+ function ComparisonTable({
837
+ current, previous, currentContributors, previousContributors,
838
+ }: {
839
+ current: PeriodSummary;
840
+ previous: PeriodSummary;
841
+ currentContributors: ProviderContribution[];
842
+ previousContributors: ProviderContribution[];
843
+ }) {
844
+ const colors = useColors();
845
+ const costDelta = fmtDeltaPct(current.totalCost, previous.totalCost);
846
+ const tokensDelta = fmtDeltaPct(current.totalTokens, previous.totalTokens);
847
+
848
+ const providerDeltas: Array<{ name: string; delta: ReturnType<typeof fmtDeltaPct> }> = [];
849
+ for (const curr of currentContributors) {
850
+ const prev = previousContributors.find(p => p.provider === curr.provider);
851
+ providerDeltas.push({
852
+ name: curr.provider,
853
+ delta: fmtDeltaPct(curr.cost, prev?.cost ?? 0),
854
+ });
855
+ }
856
+
857
+ return (
858
+ <box flexDirection="column" paddingX={1}>
859
+ <text fg={colors.textMuted} height={1}><strong>COMPARISON</strong></text>
860
+ <box flexDirection="row" height={1}>
861
+ <text fg={colors.textMuted} width={10} height={1}>{padRight('', 10)}</text>
862
+ <text fg={colors.textSubtle} width={8} height={1}>{'This'.padStart(8)}</text>
863
+ <text fg={colors.textSubtle} width={8} height={1}>{'Prev'.padStart(8)}</text>
864
+ </box>
865
+ <box flexDirection="row" height={1}>
866
+ <text fg={colors.textMuted} width={10} height={1}>{padRight('Cost', 10)}</text>
867
+ <text fg={colors.text} width={8} height={1}>{fmtCurrency(current.totalCost).padStart(8)}</text>
868
+ <text fg={colors.textSubtle} width={8} height={1}>{fmtCurrency(previous.totalCost).padStart(8)}</text>
869
+ </box>
870
+ <box flexDirection="row" height={1}>
871
+ <text fg={colors.textMuted} width={10} height={1}>{padRight('Tokens', 10)}</text>
872
+ <text fg={colors.text} width={8} height={1}>{fmtTokens(current.totalTokens).padStart(8)}</text>
873
+ <text fg={colors.textSubtle} width={8} height={1}>{fmtTokens(previous.totalTokens).padStart(8)}</text>
874
+ </box>
875
+
876
+ <box paddingX={0} height={1}>
877
+ <text fg={colors.border} height={1}>{'─'.repeat(26)}</text>
878
+ </box>
879
+
880
+ <box flexDirection="row" justifyContent="space-between" height={1}>
881
+ <text fg={colors.textMuted} height={1}>Cost Δ</text>
882
+ <text fg={costDelta.positive ? colors.success : colors.error} height={1}>{costDelta.text}</text>
883
+ </box>
884
+ <box flexDirection="row" justifyContent="space-between" height={1}>
885
+ <text fg={colors.textMuted} height={1}>Tokens Δ</text>
886
+ <text fg={tokensDelta.positive ? colors.success : colors.error} height={1}>{tokensDelta.text}</text>
887
+ </box>
888
+
889
+ {providerDeltas.length > 0 && (
890
+ <box flexDirection="column">
891
+ <box paddingX={0} height={1}>
892
+ <text fg={colors.border} height={1}>{'─'.repeat(26)}</text>
893
+ </box>
894
+ <text fg={colors.textMuted} height={1}><strong>BIGGEST CHANGES</strong></text>
895
+ {providerDeltas.slice(0, 3).map(pd => (
896
+ <box key={pd.name} flexDirection="row" justifyContent="space-between" height={1}>
897
+ <text fg={colors.text} height={1}>{truncate(pd.name, 12)}</text>
898
+ <text fg={pd.delta.positive ? colors.success : colors.error} height={1}>{pd.delta.text}</text>
899
+ </box>
900
+ ))}
901
+ </box>
902
+ )}
903
+ </box>
904
+ );
905
+ }
906
+
907
+ // ============================================================================
908
+ // InsightPanel — Top Contributors
909
+ // ============================================================================
910
+
911
+ function ContributorsSection({
912
+ contributors, breakdown, panelWidth,
913
+ }: {
914
+ contributors: ProviderContribution[];
915
+ breakdown: BreakdownDimension;
916
+ panelWidth: number;
917
+ }) {
918
+ const colors = useColors();
919
+
920
+ if (breakdown === 'off' || contributors.length === 0) return null;
921
+
922
+ // panelWidth includes border(2) + paddingX(2) from InsightPanel = 4 cols overhead
923
+ const innerWidth = panelWidth - 4;
924
+ const sparkWidth = 7;
925
+ const costColWidth = 8;
926
+ const nameWidth = Math.max(6, innerWidth - costColWidth - sparkWidth - 2);
927
+
928
+ return (
929
+ <box flexDirection="column" paddingX={1}>
930
+ <text fg={colors.textMuted} height={1}><strong>TOP {breakdown === 'model' ? 'MODELS' : breakdown === 'project' ? 'PROJECTS' : 'PROVIDERS'}</strong></text>
931
+ {contributors.map((c) => (
932
+ <box key={c.provider} flexDirection="column">
933
+ <box flexDirection="row" height={1}>
934
+ <text fg={colors.primary} width={nameWidth} height={1}>
935
+ {padRight(truncate(c.provider, nameWidth), nameWidth)}
936
+ </text>
937
+ <text fg={colors.text} height={1}>{fmtCurrency(c.cost).padStart(costColWidth)}</text>
938
+ </box>
939
+ <box flexDirection="row" height={1}>
940
+ <text fg={colors.textSubtle} width={5} height={1}>
941
+ {padRight(`${Math.round(c.costShare)}%`, 5)}
942
+ </text>
943
+ <text fg={colors.accent} height={1}>
944
+ {miniSparkline(c.dailyCosts, sparkWidth)}
945
+ </text>
946
+ </box>
947
+ </box>
948
+ ))}
949
+ </box>
950
+ );
951
+ }
952
+
953
+ // ============================================================================
954
+ // InsightPanel — Cursor Day Detail (Phase 3)
955
+ // ============================================================================
956
+
957
+ function CursorDayDetail({
958
+ currentDay, previousDay, metric, contributors, cursorIndex, breakdown,
959
+ }: {
960
+ currentDay: ChartPoint;
961
+ previousDay: ChartPoint | null;
962
+ metric: MetricType;
963
+ contributors: ProviderContribution[];
964
+ cursorIndex: number;
965
+ breakdown: BreakdownDimension;
966
+ }) {
967
+ const colors = useColors();
968
+
969
+ const dayValue = getMetricValue(currentDay, metric);
970
+ const prevDayValue = previousDay ? getMetricValue(previousDay, metric) : null;
971
+ const dayDelta = prevDayValue !== null ? fmtDeltaPct(dayValue, prevDayValue) : null;
972
+
973
+ return (
974
+ <box flexDirection="column" paddingX={1}>
975
+ <text fg={colors.primary} height={1}><strong>{currentDay.dayName} DETAIL</strong></text>
976
+ <box flexDirection="row" justifyContent="space-between" height={1}>
977
+ <text fg={colors.textMuted} height={1}>Cost</text>
978
+ <text fg={colors.success} height={1}><strong>{fmtCurrency(currentDay.costUsd)}</strong></text>
979
+ </box>
980
+ <box flexDirection="row" justifyContent="space-between" height={1}>
981
+ <text fg={colors.textMuted} height={1}>Tokens</text>
982
+ <text fg={colors.text} height={1}>{fmtTokens(currentDay.tokens)}</text>
983
+ </box>
984
+ <box flexDirection="row" justifyContent="space-between" height={1}>
985
+ <text fg={colors.textMuted} height={1}>Requests</text>
986
+ <text fg={colors.text} height={1}>{fmtNumber(currentDay.requestCount)}</text>
987
+ </box>
988
+ {dayDelta && (
989
+ <box flexDirection="row" justifyContent="space-between" height={1}>
990
+ <text fg={colors.textMuted} height={1}>vs prev day</text>
991
+ <text fg={dayDelta.positive ? colors.success : colors.error} height={1}>{dayDelta.text}</text>
992
+ </box>
993
+ )}
994
+
995
+ {contributors.length > 0 && (
996
+ <box flexDirection="column">
997
+ <box height={1}>
998
+ <text fg={colors.border} height={1}>{'─'.repeat(20)}</text>
999
+ </box>
1000
+ <text fg={colors.textMuted} height={1}><strong>{breakdown === 'model' ? 'TOP MODELS' : breakdown === 'project' ? 'TOP PROJECTS' : 'PROVIDERS'}</strong></text>
1001
+ {contributors.map((c) => {
1002
+ const dayCost = c.dailyCosts[cursorIndex] ?? 0;
1003
+ return (
1004
+ <box key={c.provider} flexDirection="row" justifyContent="space-between" height={1}>
1005
+ <text fg={colors.primary} height={1}>{truncate(c.provider, 12)}</text>
1006
+ <text fg={colors.text} height={1}>{fmtCurrency(dayCost)}</text>
1007
+ </box>
1008
+ );
1009
+ })}
1010
+ </box>
1011
+ )}
1012
+ </box>
1013
+ );
1014
+ }
1015
+
1016
+ // ============================================================================
1017
+ // Condensed horizontal strip (narrow terminals ≤100 cols)
1018
+ // ============================================================================
1019
+
1020
+ function CondensedStrip({
1021
+ summary, prevSummary, contributors,
1022
+ }: {
1023
+ summary: PeriodSummary;
1024
+ prevSummary: PeriodSummary;
1025
+ contributors: ProviderContribution[];
1026
+ }) {
1027
+ const colors = useColors();
1028
+ const costDelta = fmtDeltaPct(summary.totalCost, prevSummary.totalCost);
1029
+
1030
+ return (
1031
+ <box flexDirection="row" height={2} paddingX={1} gap={2}>
1032
+ <box flexDirection="column">
1033
+ <text fg={colors.textMuted} height={1}>Total: <span fg={colors.success}><strong>{fmtCurrency(summary.totalCost)}</strong></span></text>
1034
+ <text fg={colors.textMuted} height={1}>
1035
+ {'Avg: '}{fmtCurrency(summary.avgPerDay)}{'/d '}
1036
+ <span fg={costDelta.positive ? colors.success : colors.error}>{costDelta.text}</span>
1037
+ </text>
1038
+ </box>
1039
+ {contributors.length > 0 && (
1040
+ <box flexDirection="column">
1041
+ <text fg={colors.textMuted} height={1}>Top:</text>
1042
+ <text height={1}>
1043
+ {contributors.slice(0, 3).map((c, i) => (
1044
+ <span key={c.provider}>
1045
+ {i > 0 ? ' ' : ''}
1046
+ <span fg={colors.primary}>{truncate(c.provider, 8)}</span>
1047
+ <span fg={colors.text}>{' '}{fmtCurrency(c.cost)}</span>
1048
+ </span>
1049
+ ))}
1050
+ </text>
1051
+ </box>
1052
+ )}
1053
+ </box>
1054
+ );
1055
+ }
1056
+
1057
+ // ============================================================================
1058
+ // Full Insight Panel
1059
+ // ============================================================================
1060
+
1061
+ function InsightPanel({
1062
+ summary, prevSummary, contributors, previousContributors,
1063
+ metric, breakdown, comparisonMode, cursorMode, cursorIndex, data, panelWidth, hasPrevData,
1064
+ }: {
1065
+ summary: PeriodSummary;
1066
+ prevSummary: PeriodSummary;
1067
+ contributors: ProviderContribution[];
1068
+ previousContributors: ProviderContribution[];
1069
+ metric: MetricType;
1070
+ breakdown: BreakdownDimension;
1071
+ comparisonMode: boolean;
1072
+ cursorMode: boolean;
1073
+ cursorIndex: number;
1074
+ data: ChartPoint[];
1075
+ panelWidth: number;
1076
+ hasPrevData: boolean;
1077
+ }) {
1078
+ const colors = useColors();
1079
+
1080
+ // Cursor mode: show day detail
1081
+ if (cursorMode && data[cursorIndex]) {
1082
+ const currentDay = data[cursorIndex]!;
1083
+ const previousDay = cursorIndex > 0 ? (data[cursorIndex - 1] ?? null) : null;
1084
+
1085
+ return (
1086
+ <box
1087
+ flexDirection="column"
1088
+ width={panelWidth}
1089
+ border
1090
+ borderStyle="single"
1091
+ borderColor={colors.border}
1092
+ overflow="hidden"
1093
+ gap={1}
1094
+ >
1095
+ <CursorDayDetail
1096
+ currentDay={currentDay}
1097
+ previousDay={previousDay}
1098
+ metric={metric}
1099
+ contributors={contributors}
1100
+ cursorIndex={cursorIndex}
1101
+ breakdown={breakdown}
1102
+ />
1103
+ </box>
1104
+ );
1105
+ }
1106
+
1107
+ // Comparison mode: show comparison table
1108
+ if (comparisonMode) {
1109
+ return (
1110
+ <box
1111
+ flexDirection="column"
1112
+ width={panelWidth}
1113
+ border
1114
+ borderStyle="single"
1115
+ borderColor={colors.accent}
1116
+ overflow="hidden"
1117
+ gap={1}
1118
+ >
1119
+ <ComparisonTable
1120
+ current={summary}
1121
+ previous={prevSummary}
1122
+ currentContributors={contributors}
1123
+ previousContributors={previousContributors}
1124
+ />
1125
+ </box>
1126
+ );
1127
+ }
1128
+
1129
+ // Default: summary + deltas + contributors
1130
+ return (
1131
+ <box
1132
+ flexDirection="column"
1133
+ width={panelWidth}
1134
+ border
1135
+ borderStyle="single"
1136
+ borderColor={colors.border}
1137
+ overflow="hidden"
1138
+ gap={1}
1139
+ >
1140
+ <PeriodSummarySection summary={summary} metric={metric} />
1141
+
1142
+ <box paddingX={1} height={1}>
1143
+ <text fg={colors.border} height={1}>{'─'.repeat(Math.max(0, panelWidth - 4))}</text>
1144
+ </box>
1145
+
1146
+ <DeltaSection current={summary} previous={prevSummary} hasPrevData={hasPrevData} />
1147
+
1148
+ <box paddingX={1} height={1}>
1149
+ <text fg={colors.border} height={1}>{'─'.repeat(Math.max(0, panelWidth - 4))}</text>
1150
+ </box>
1151
+
1152
+ <ContributorsSection
1153
+ contributors={contributors}
1154
+ breakdown={breakdown}
1155
+ panelWidth={panelWidth}
1156
+ />
1157
+ </box>
1158
+ );
1159
+ }
1160
+
1161
+ // ============================================================================
1162
+ // Main View
1163
+ // ============================================================================
1164
+
1165
+ export function HistoricalTrendsView() {
1166
+ const colors = useColors();
1167
+ const isStorageReady = useStorageReady();
1168
+ const { demoMode, simulator } = useDemoMode();
1169
+ const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
1170
+
1171
+ // State
1172
+ const [period, setPeriod] = useState<TimePeriod>('7d');
1173
+ const [metric, setMetric] = useState<MetricType>('cost');
1174
+ const [breakdown, setBreakdown] = useState<BreakdownDimension>('provider');
1175
+ const [showInsight, setShowInsight] = useState(true);
1176
+ const [comparisonMode, setComparisonMode] = useState(false);
1177
+ const [cursorMode, setCursorMode] = useState(false);
1178
+ const [cursorIndex, setCursorIndex] = useState(0);
1179
+
1180
+ // Data
1181
+ const [data, setData] = useState<ChartPoint[]>([]);
1182
+ const [prevData, setPrevData] = useState<ChartPoint[]>([]);
1183
+ const [contributors, setContributors] = useState<ProviderContribution[]>([]);
1184
+ const [prevContributors, setPrevContributors] = useState<ProviderContribution[]>([]);
1185
+
1186
+ // Layout
1187
+ const isNarrow = terminalWidth <= 100;
1188
+ const panelVisible = showInsight && !isNarrow;
1189
+ const panelWidth = panelVisible ? Math.min(40, Math.max(28, Math.floor(terminalWidth * 0.3))) : 0;
1190
+ const chartWidth = panelVisible
1191
+ ? Math.max(40, terminalWidth - panelWidth - 8)
1192
+ : Math.max(40, terminalWidth - 6);
1193
+
1194
+ // Chart height: budget from actual chrome rows to prevent overflow
1195
+ // Header: 7 rows (large logo at ≥35) or 1 row (compact) + StatusBar: 1 row
1196
+ const appOverhead = (terminalHeight >= 35 ? 7 : 1) + 1;
1197
+ // View chrome: border(2) + padding(2) + view header(1) + marginBottom(1) + footer(1) + marginTop(1)
1198
+ const viewChrome = 8;
1199
+ // Narrow mode has condensed strip (2 rows) + vertical gap (1 row)
1200
+ const stripOverhead = isNarrow ? 3 : 0;
1201
+ const chartHeight = Math.max(isNarrow ? 6 : 8, terminalHeight - appOverhead - viewChrome - stripOverhead);
1202
+
1203
+ // Fetch time series data
1204
+ useEffect(() => {
1205
+ if (demoMode && simulator) {
1206
+ setData(fetchDemoTimeSeries(simulator, period, 0));
1207
+ setPrevData(fetchDemoTimeSeries(simulator, period, 1));
1208
+ setPrevContributors(fetchDemoPrevContributors(simulator, period));
1209
+ } else if (isStorageReady) {
1210
+ setData(fetchTimeSeries(period, 0));
1211
+ setPrevData(fetchTimeSeries(period, 1));
1212
+ }
1213
+ }, [isStorageReady, period, demoMode, simulator]);
1214
+
1215
+ // Fetch breakdown contributors based on dimension
1216
+ useEffect(() => {
1217
+ if (breakdown === 'off') {
1218
+ setContributors([]);
1219
+ return;
1220
+ }
1221
+ if (demoMode && simulator) {
1222
+ if (breakdown === 'model') setContributors(fetchDemoModelBreakdown(simulator, period));
1223
+ else if (breakdown === 'project') setContributors(fetchDemoProjectBreakdown(simulator, period));
1224
+ else setContributors(fetchDemoProviderBreakdown(simulator, period));
1225
+ } else if (isStorageReady) {
1226
+ if (breakdown === 'model') setContributors(fetchModelBreakdown(period));
1227
+ else if (breakdown === 'project') setContributors(fetchProjectBreakdown(period));
1228
+ else setContributors(fetchProviderBreakdown(period));
1229
+ }
1230
+ }, [isStorageReady, period, breakdown, demoMode, simulator]);
1231
+
1232
+ // Fetch previous period contributors for live mode comparison
1233
+ useEffect(() => {
1234
+ if (demoMode && simulator) return;
1235
+ if (!isStorageReady) return;
1236
+ const now = Date.now();
1237
+ const daysBack = getDaysBack(period);
1238
+ const prevStart = now - daysBack * 2 * MS_PER_DAY;
1239
+ const prevEnd = now - daysBack * MS_PER_DAY;
1240
+ try {
1241
+ const rows = queryProviderDailyCosts(prevStart, prevEnd, MS_PER_DAY);
1242
+ const providerMap = new Map<string, number>();
1243
+ for (const row of rows) {
1244
+ providerMap.set(row.provider, (providerMap.get(row.provider) ?? 0) + row.costUsd);
1245
+ }
1246
+ const totalCost = Array.from(providerMap.values()).reduce((s, v) => s + v, 0);
1247
+ setPrevContributors(
1248
+ Array.from(providerMap.entries())
1249
+ .map(([provider, cost]) => ({
1250
+ provider,
1251
+ cost,
1252
+ costShare: totalCost > 0 ? (cost / totalCost) * 100 : 0,
1253
+ tokens: 0,
1254
+ requests: 0,
1255
+ dailyCosts: [],
1256
+ }))
1257
+ .sort((a, b) => b.cost - a.cost)
1258
+ );
1259
+ } catch {
1260
+ setPrevContributors([]);
1261
+ }
1262
+ }, [isStorageReady, period, demoMode, simulator]);
1263
+
1264
+ // Reset cursor when data changes
1265
+ useEffect(() => {
1266
+ setCursorIndex(Math.max(0, data.length - 1));
1267
+ }, [data.length]);
1268
+
1269
+ // Computed values
1270
+ const summary = useMemo(() => computeSummary(data, metric), [data, metric]);
1271
+ const prevSummary = useMemo(() => computeSummary(prevData, metric), [prevData, metric]);
1272
+ const hasData = data.some(p => p.costUsd > 0 || p.tokens > 0 || p.requestCount > 0);
1273
+ const hasPrevData = prevData.some(p => p.costUsd > 0 || p.tokens > 0 || p.requestCount > 0);
1274
+ const totalForMetric = useMemo(() => {
1275
+ switch (metric) {
1276
+ case 'cost': return data.reduce((acc, p) => acc + p.costUsd, 0);
1277
+ case 'tokens': return data.reduce((acc, p) => acc + p.tokens, 0);
1278
+ case 'requests': return data.reduce((acc, p) => acc + p.requestCount, 0);
1279
+ }
1280
+ }, [data, metric]);
1281
+
1282
+ // Keyboard
1283
+ useKeyboard((key) => {
1284
+ if (key.name === 'left' || key.name === 'h') {
1285
+ if (cursorMode) {
1286
+ setCursorIndex(prev => Math.max(0, prev - 1));
1287
+ } else {
1288
+ setPeriod(prev => prev === '90d' ? '30d' : prev === '30d' ? '7d' : '7d');
1289
+ }
1290
+ }
1291
+ if (key.name === 'right' || key.name === 'l') {
1292
+ if (cursorMode) {
1293
+ setCursorIndex(prev => Math.min(data.length - 1, prev + 1));
1294
+ } else {
1295
+ setPeriod(prev => prev === '7d' ? '30d' : prev === '30d' ? '90d' : '90d');
1296
+ }
1297
+ }
1298
+
1299
+ if (key.name === 'down' || key.name === 'j') {
1300
+ if (!cursorMode) {
1301
+ setCursorMode(true);
1302
+ setCursorIndex(0);
1303
+ } else {
1304
+ setCursorIndex(prev => Math.min(data.length - 1, prev + 1));
1305
+ }
1306
+ }
1307
+ if (key.name === 'up' || key.name === 'k') {
1308
+ if (!cursorMode) {
1309
+ setCursorMode(true);
1310
+ setCursorIndex(0);
1311
+ } else {
1312
+ setCursorIndex(prev => Math.max(0, prev - 1));
1313
+ }
1314
+ }
1315
+
1316
+ if (key.name === 'escape') {
1317
+ if (cursorMode) setCursorMode(false);
1318
+ else if (comparisonMode) setComparisonMode(false);
1319
+ }
1320
+
1321
+ if (key.name === 'c') setComparisonMode(prev => !prev);
1322
+ if (key.name === 'm') setMetric(prev => prev === 'cost' ? 'tokens' : prev === 'tokens' ? 'requests' : 'cost');
1323
+ if (key.name === 'b') setBreakdown(prev => prev === 'provider' ? 'model' : prev === 'model' ? 'project' : prev === 'project' ? 'off' : 'provider');
1324
+ if (key.name === 'i') setShowInsight(prev => !prev);
1325
+ });
1326
+
1327
+ const periodLabel = period === '7d' ? '7 days' : period === '30d' ? '30 days' : '90 days';
1328
+ const modeIndicators: string[] = [];
1329
+ if (comparisonMode) modeIndicators.push('CMP');
1330
+ if (cursorMode) modeIndicators.push('CURSOR');
1331
+ if (metric !== 'cost') modeIndicators.push(METRIC_LABELS[metric]);
1332
+
1333
+ return (
1334
+ <box flexDirection="column" flexGrow={1} padding={1} border borderStyle="single" borderColor={colors.border}>
1335
+ {/* Header */}
1336
+ <box flexDirection="row" justifyContent="space-between" height={1} marginBottom={1}>
1337
+ <text height={1}>
1338
+ <span fg={colors.primary}><strong>{' '}{METRIC_LABELS[metric]} TREND{' '}</strong></span>
1339
+ <span fg={colors.textMuted}>({periodLabel})</span>
1340
+ {modeIndicators.length > 0 && (
1341
+ <span fg={colors.accent}>{' '}[{modeIndicators.join(' | ')}]</span>
1342
+ )}
1343
+ </text>
1344
+ <text height={1}>
1345
+ <span fg={colors.textMuted}>Total: </span>
1346
+ <span fg={colors.success}><strong>{fmtMetricValue(totalForMetric, metric)}</strong></span>
1347
+ </text>
1348
+ </box>
1349
+
1350
+ {/* Main content */}
1351
+ <box flexGrow={1} flexDirection={isNarrow ? 'column' : 'row'} gap={1}>
1352
+ {/* Chart pane */}
1353
+ <box flexGrow={1} flexDirection="column" justifyContent="center" overflow="hidden">
1354
+ {!isStorageReady && !demoMode ? (
1355
+ <text fg={colors.textMuted}>Loading storage...</text>
1356
+ ) : !hasData ? (
1357
+ <box flexDirection="column" alignItems="center" gap={1}>
1358
+ <text fg={colors.textMuted}>No usage data recorded yet.</text>
1359
+ <text fg={colors.textSubtle}>Data will appear as you use AI providers.</text>
1360
+ </box>
1361
+ ) : (
1362
+ <AsciiChart
1363
+ data={data}
1364
+ comparisonData={comparisonMode && hasPrevData ? prevData : undefined}
1365
+ metric={metric}
1366
+ cursorIndex={cursorMode ? cursorIndex : null}
1367
+ height={chartHeight}
1368
+ width={chartWidth}
1369
+ color={colors.primary}
1370
+ ghostColor={colors.borderMuted}
1371
+ cursorColor={colors.accent}
1372
+ labelColor={colors.textMuted}
1373
+ gridColor={colors.border}
1374
+ />
1375
+ )}
1376
+ </box>
1377
+
1378
+ {/* Insight panel */}
1379
+ {showInsight && hasData && (
1380
+ isNarrow ? (
1381
+ <CondensedStrip
1382
+ summary={summary}
1383
+ prevSummary={prevSummary}
1384
+ contributors={contributors}
1385
+ />
1386
+ ) : (
1387
+ <InsightPanel
1388
+ summary={summary}
1389
+ prevSummary={prevSummary}
1390
+ contributors={contributors}
1391
+ previousContributors={prevContributors}
1392
+ metric={metric}
1393
+ breakdown={breakdown}
1394
+ comparisonMode={comparisonMode}
1395
+ cursorMode={cursorMode}
1396
+ cursorIndex={cursorIndex}
1397
+ data={data}
1398
+ panelWidth={panelWidth}
1399
+ hasPrevData={hasPrevData}
1400
+ />
1401
+ )
1402
+ )}
1403
+ </box>
1404
+
1405
+ {/* Footer */}
1406
+ <box flexDirection="row" marginTop={1} height={1}>
1407
+ <text height={1} fg={colors.textSubtle}>
1408
+ {cursorMode ? (
1409
+ <span>
1410
+ <span fg={colors.accent}>CURSOR</span>
1411
+ {isNarrow
1412
+ ? ' ←→ move Esc exit c cmp m met'
1413
+ : ' ←→ move Esc exit c compare m metric i panel'}
1414
+ </span>
1415
+ ) : (
1416
+ <span>
1417
+ <span fg={period === '7d' ? colors.primary : colors.textSubtle}>7d</span>
1418
+ {' '}
1419
+ <span fg={period === '30d' ? colors.primary : colors.textSubtle}>30d</span>
1420
+ {' '}
1421
+ <span fg={period === '90d' ? colors.primary : colors.textSubtle}>90d</span>
1422
+ {isNarrow
1423
+ ? ' h/l per j/k cur c cmp m met b brk i pan'
1424
+ : ' h/l period ↑↓ cursor c compare m metric b breakdown i panel'}
1425
+ </span>
1426
+ )}
1427
+ </text>
1428
+ </box>
1429
+ </box>
1430
+ );
1431
+ }