@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.
- package/LICENSE +21 -0
- package/README.md +273 -0
- package/bin/ttop.js +49 -0
- package/package.json +68 -0
- package/src/agents/aggregator.ts +176 -0
- package/src/agents/costing.ts +74 -0
- package/src/agents/index.ts +12 -0
- package/src/agents/types.ts +71 -0
- package/src/cli.ts +167 -0
- package/src/config/schema.test.ts +166 -0
- package/src/config/schema.ts +165 -0
- package/src/demo/simulator.ts +745 -0
- package/src/plugins/agents/index.ts +2 -0
- package/src/plugins/auth-sources.ts +186 -0
- package/src/plugins/lifecycle.ts +177 -0
- package/src/plugins/loader.test.ts +195 -0
- package/src/plugins/loader.ts +282 -0
- package/src/plugins/notification-bus.ts +153 -0
- package/src/plugins/notifications/index.ts +2 -0
- package/src/plugins/notifications/terminal-bell.ts +66 -0
- package/src/plugins/notifications/visual-flash.ts +57 -0
- package/src/plugins/npm-installer.ts +164 -0
- package/src/plugins/plugin-context-factory.ts +67 -0
- package/src/plugins/plugin-host.ts +205 -0
- package/src/plugins/providers/anthropic.ts +325 -0
- package/src/plugins/providers/antigravity.ts +309 -0
- package/src/plugins/providers/codex.ts +250 -0
- package/src/plugins/providers/gemini.ts +391 -0
- package/src/plugins/providers/github-copilot.ts +194 -0
- package/src/plugins/providers/index.ts +12 -0
- package/src/plugins/providers/minimax.ts +223 -0
- package/src/plugins/providers/openai-api.ts +250 -0
- package/src/plugins/providers/opencode-zen.ts +125 -0
- package/src/plugins/providers/perplexity.ts +177 -0
- package/src/plugins/providers/zai.ts +225 -0
- package/src/plugins/registry.ts +251 -0
- package/src/plugins/sandbox-guard.test.ts +191 -0
- package/src/plugins/sandbox-guard.ts +155 -0
- package/src/plugins/sandbox.ts +138 -0
- package/src/plugins/themes/catppuccin-latte.ts +45 -0
- package/src/plugins/themes/catppuccin-mocha.ts +45 -0
- package/src/plugins/themes/claude-code.ts +45 -0
- package/src/plugins/themes/dracula.ts +45 -0
- package/src/plugins/themes/github-light.ts +45 -0
- package/src/plugins/themes/gruvbox-dark.ts +45 -0
- package/src/plugins/themes/gruvbox-light.ts +45 -0
- package/src/plugins/themes/index.ts +15 -0
- package/src/plugins/themes/kanagawa.ts +45 -0
- package/src/plugins/themes/nord.ts +45 -0
- package/src/plugins/themes/one-dark.ts +45 -0
- package/src/plugins/themes/opencode.ts +47 -0
- package/src/plugins/themes/rose-pine-dawn.ts +45 -0
- package/src/plugins/themes/rose-pine.ts +45 -0
- package/src/plugins/themes/solarized-light.ts +45 -0
- package/src/plugins/themes/tokyo-night.ts +49 -0
- package/src/plugins/types/agent.ts +108 -0
- package/src/plugins/types/base.ts +267 -0
- package/src/plugins/types/index.ts +19 -0
- package/src/plugins/types/notification.ts +51 -0
- package/src/plugins/types/provider.ts +233 -0
- package/src/plugins/types/theme.ts +70 -0
- package/src/plugins/update-checker.ts +177 -0
- package/src/pricing/estimator.test.ts +181 -0
- package/src/pricing/estimator.ts +80 -0
- package/src/pricing/fallback.test.ts +71 -0
- package/src/pricing/fallback.ts +62 -0
- package/src/pricing/index.ts +66 -0
- package/src/pricing/models-dev.ts +130 -0
- package/src/storage/database.ts +613 -0
- package/src/storage/db.ts +64 -0
- package/src/storage/index.ts +66 -0
- package/src/storage/migrations/index.ts +189 -0
- package/src/storage/paths.ts +24 -0
- package/src/storage/repos/agentSessions.ts +210 -0
- package/src/storage/repos/providerSnapshots.ts +116 -0
- package/src/storage/repos/usageEvents.ts +334 -0
- package/src/storage/types.ts +288 -0
- package/src/tui/App.tsx +381 -0
- package/src/tui/components/CommandPalette.tsx +166 -0
- package/src/tui/components/DebugConsole.tsx +161 -0
- package/src/tui/components/DebugPanel.tsx +660 -0
- package/src/tui/components/FooterHints.tsx +22 -0
- package/src/tui/components/GhostProviderCard.tsx +45 -0
- package/src/tui/components/Header.tsx +125 -0
- package/src/tui/components/HelpOverlay.tsx +45 -0
- package/src/tui/components/InlineGauge.tsx +34 -0
- package/src/tui/components/InlineSparkline.tsx +78 -0
- package/src/tui/components/KpiStrip.tsx +246 -0
- package/src/tui/components/LimitGauge.tsx +186 -0
- package/src/tui/components/ModalBackdrop.tsx +42 -0
- package/src/tui/components/ProviderAggregateStrip.tsx +200 -0
- package/src/tui/components/ProviderCard.tsx +309 -0
- package/src/tui/components/ProviderDetailPanel.tsx +237 -0
- package/src/tui/components/ProviderLimitsPanel.tsx +308 -0
- package/src/tui/components/ProvidersList.tsx +380 -0
- package/src/tui/components/SessionDetailsDrawer.tsx +317 -0
- package/src/tui/components/SessionsTable.tsx +401 -0
- package/src/tui/components/SettingsModal.tsx +587 -0
- package/src/tui/components/SidebarBreakdown.tsx +87 -0
- package/src/tui/components/Skeleton.tsx +102 -0
- package/src/tui/components/SmartSidebar.tsx +644 -0
- package/src/tui/components/Sparkline.tsx +180 -0
- package/src/tui/components/Spinner.tsx +36 -0
- package/src/tui/components/StatusBar.tsx +96 -0
- package/src/tui/components/ThemePicker.tsx +234 -0
- package/src/tui/components/Toast.tsx +61 -0
- package/src/tui/components/UsageGauge.tsx +80 -0
- package/src/tui/components/index.ts +5 -0
- package/src/tui/contexts/AgentSessionContext.tsx +307 -0
- package/src/tui/contexts/ConfigContext.tsx +141 -0
- package/src/tui/contexts/DashboardRuntimeContext.tsx +102 -0
- package/src/tui/contexts/DemoModeContext.tsx +60 -0
- package/src/tui/contexts/DrawerContext.tsx +42 -0
- package/src/tui/contexts/InputContext.tsx +30 -0
- package/src/tui/contexts/LogContext.tsx +150 -0
- package/src/tui/contexts/PluginContext.tsx +424 -0
- package/src/tui/contexts/RealTimeActivityContext.tsx +119 -0
- package/src/tui/contexts/StorageContext.tsx +233 -0
- package/src/tui/contexts/ThemeContext.tsx +95 -0
- package/src/tui/contexts/TimeWindowContext.tsx +158 -0
- package/src/tui/contexts/ToastContext.tsx +40 -0
- package/src/tui/contexts/index.ts +5 -0
- package/src/tui/createApp.tsx +47 -0
- package/src/tui/debug/captureFrame.ts +262 -0
- package/src/tui/debug/snapshot.tsx +644 -0
- package/src/tui/driver/assertions.ts +279 -0
- package/src/tui/driver/cli.ts +717 -0
- package/src/tui/driver/coverage.ts +204 -0
- package/src/tui/driver/demo-snapshot.ts +66 -0
- package/src/tui/driver/diff.ts +166 -0
- package/src/tui/driver/driver.ts +270 -0
- package/src/tui/driver/index.ts +53 -0
- package/src/tui/driver/recorder.ts +303 -0
- package/src/tui/driver/test-workflow.ts +115 -0
- package/src/tui/driver/test.ts +47 -0
- package/src/tui/hooks/index.ts +20 -0
- package/src/tui/hooks/useAnimatedValue.ts +113 -0
- package/src/tui/hooks/useDashboardKeyboard.ts +341 -0
- package/src/tui/hooks/useDashboardState.ts +116 -0
- package/src/tui/hooks/useEmaActivity.ts +223 -0
- package/src/tui/hooks/useEntranceAnimation.ts +62 -0
- package/src/tui/hooks/useExitAnimation.ts +137 -0
- package/src/tui/hooks/usePulse.ts +138 -0
- package/src/tui/hooks/useSafeRenderer.ts +24 -0
- package/src/tui/hooks/useValueFlash.ts +111 -0
- package/src/tui/index.tsx +50 -0
- package/src/tui/utils/providerColor.ts +32 -0
- package/src/tui/views/Dashboard.tsx +340 -0
- package/src/tui/views/HistoricalTrendsView.tsx +1431 -0
- package/src/tui/views/ProjectsView.tsx +1277 -0
- package/src/tui/views/RealTimeDashboard.tsx +393 -0
- package/src/tui/views/SettingsView.tsx +465 -0
- package/src/tui/views/index.ts +2 -0
- package/src/types/global.d.ts +5 -0
- package/src/types/opentui-test-utils.d.ts +18 -0
- package/src/utils/clipboard.ts +118 -0
- package/src/utils/currency.test.ts +106 -0
- package/src/utils/currency.ts +79 -0
- 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
|
+
}
|