@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,1277 @@
|
|
|
1
|
+
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
|
2
|
+
import { useKeyboard, useTerminalDimensions } from '@opentui/react';
|
|
3
|
+
import { RGBA } from '@opentui/core';
|
|
4
|
+
import { useAgentSessions } from '../contexts/AgentSessionContext.tsx';
|
|
5
|
+
import { useColors } from '../contexts/ThemeContext.tsx';
|
|
6
|
+
import { useTimeWindow } from '../contexts/TimeWindowContext.tsx';
|
|
7
|
+
import { useInputFocus } from '../contexts/InputContext.tsx';
|
|
8
|
+
import type { AgentSessionAggregate } from '../../agents/types.ts';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Types
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
type SortField = 'cost' | 'tokens' | 'sessions' | 'cache' | 'cpr' | 'activity';
|
|
15
|
+
|
|
16
|
+
interface ModelStats {
|
|
17
|
+
modelId: string;
|
|
18
|
+
providerId: string;
|
|
19
|
+
cost: number;
|
|
20
|
+
requests: number;
|
|
21
|
+
tokens: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface AgentStats {
|
|
25
|
+
agentName: string;
|
|
26
|
+
cost: number;
|
|
27
|
+
sessions: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ProjectStats {
|
|
31
|
+
path: string;
|
|
32
|
+
name: string;
|
|
33
|
+
sessionCount: number;
|
|
34
|
+
activeSessions: number;
|
|
35
|
+
tokens: number;
|
|
36
|
+
inputTokens: number;
|
|
37
|
+
outputTokens: number;
|
|
38
|
+
cacheRead: number;
|
|
39
|
+
cacheWrite: number;
|
|
40
|
+
cost: number;
|
|
41
|
+
requestCount: number;
|
|
42
|
+
models: ModelStats[];
|
|
43
|
+
agents: AgentStats[];
|
|
44
|
+
sessions: AgentSessionAggregate[];
|
|
45
|
+
/** 5-char sparkline trend over recent buckets */
|
|
46
|
+
trend: number[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Formatting helpers
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const OVERLAY_BG = RGBA.fromValues(0.0, 0.0, 0.0, 0.5);
|
|
54
|
+
|
|
55
|
+
function padRight(str: string, len: number): string {
|
|
56
|
+
if (str.length >= len) return str.slice(0, len);
|
|
57
|
+
return str + ' '.repeat(len - str.length);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function padLeft(str: string, len: number): string {
|
|
61
|
+
if (str.length >= len) return str.slice(0, len);
|
|
62
|
+
return ' '.repeat(len - str.length) + str;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatCost(num: number): string {
|
|
66
|
+
if (num === 0) return '$0';
|
|
67
|
+
if (num < 0.01) return '<$0.01';
|
|
68
|
+
if (num < 10) return `$${num.toFixed(2)}`;
|
|
69
|
+
if (num < 100) return `$${num.toFixed(1)}`;
|
|
70
|
+
if (num < 1000) return `$${Math.round(num)}`;
|
|
71
|
+
return `$${(num / 1000).toFixed(1)}k`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function formatTokensCompact(num: number): string {
|
|
75
|
+
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
|
|
76
|
+
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
|
|
77
|
+
return `${num}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatPercent(val: number): string {
|
|
81
|
+
if (val < 1 && val > 0) return '<1%';
|
|
82
|
+
return `${Math.round(val)}%`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatRelativeTime(timestamp: number): string {
|
|
86
|
+
const diffMs = Date.now() - timestamp;
|
|
87
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
88
|
+
if (diffSec < 60) return `${diffSec}s`;
|
|
89
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
90
|
+
if (diffMin < 60) return `${diffMin}m`;
|
|
91
|
+
const diffHour = Math.floor(diffMin / 60);
|
|
92
|
+
if (diffHour < 24) return `${diffHour}h`;
|
|
93
|
+
const diffDay = Math.floor(diffHour / 24);
|
|
94
|
+
return `${diffDay}d`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function truncate(str: string, maxLen: number): string {
|
|
98
|
+
if (str.length <= maxLen) return str;
|
|
99
|
+
return str.slice(0, maxLen - 1) + '\u2026';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Sparkline characters for 5-char mini trend
|
|
103
|
+
const SPARK_CHARS = ['\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'];
|
|
104
|
+
|
|
105
|
+
function miniSparkline(values: number[], width: number): string {
|
|
106
|
+
if (values.length === 0) return '\u00B7'.repeat(width);
|
|
107
|
+
// Bucket into `width` slots
|
|
108
|
+
const bucketSize = Math.max(1, Math.ceil(values.length / width));
|
|
109
|
+
const buckets: number[] = [];
|
|
110
|
+
for (let i = 0; i < width; i++) {
|
|
111
|
+
const start = i * bucketSize;
|
|
112
|
+
const end = Math.min(start + bucketSize, values.length);
|
|
113
|
+
const slice = values.slice(start, end);
|
|
114
|
+
buckets.push(slice.length > 0 ? slice.reduce((a, b) => a + b, 0) : 0);
|
|
115
|
+
}
|
|
116
|
+
const maxVal = Math.max(...buckets, 1);
|
|
117
|
+
return buckets
|
|
118
|
+
.map((v) => {
|
|
119
|
+
if (v === 0) return '\u00B7';
|
|
120
|
+
const idx = Math.min(Math.floor((v / maxVal) * SPARK_CHARS.length), SPARK_CHARS.length - 1);
|
|
121
|
+
return SPARK_CHARS[idx]!;
|
|
122
|
+
})
|
|
123
|
+
.join('');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function makeBar(percent: number, width: number): string {
|
|
127
|
+
const clamped = Math.max(0, Math.min(100, percent));
|
|
128
|
+
const filled = Math.round((clamped / 100) * width);
|
|
129
|
+
return '\u2588'.repeat(filled) + '\u00B7'.repeat(width - filled);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Project aggregation
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
function aggregateProjects(
|
|
137
|
+
sessions: AgentSessionAggregate[],
|
|
138
|
+
windowStart: number | null,
|
|
139
|
+
): ProjectStats[] {
|
|
140
|
+
const statsMap = new Map<string, ProjectStats>();
|
|
141
|
+
|
|
142
|
+
const filtered = windowStart !== null
|
|
143
|
+
? sessions.filter((s) => s.lastActivityAt >= windowStart)
|
|
144
|
+
: sessions;
|
|
145
|
+
|
|
146
|
+
for (const session of filtered) {
|
|
147
|
+
const path = session.projectPath || 'Unknown';
|
|
148
|
+
const name = session.projectPath
|
|
149
|
+
? session.projectPath.split('/').pop() || session.projectPath
|
|
150
|
+
: 'Unknown';
|
|
151
|
+
|
|
152
|
+
let project = statsMap.get(path);
|
|
153
|
+
if (!project) {
|
|
154
|
+
project = {
|
|
155
|
+
path,
|
|
156
|
+
name,
|
|
157
|
+
sessionCount: 0,
|
|
158
|
+
activeSessions: 0,
|
|
159
|
+
tokens: 0,
|
|
160
|
+
inputTokens: 0,
|
|
161
|
+
outputTokens: 0,
|
|
162
|
+
cacheRead: 0,
|
|
163
|
+
cacheWrite: 0,
|
|
164
|
+
cost: 0,
|
|
165
|
+
requestCount: 0,
|
|
166
|
+
models: [],
|
|
167
|
+
agents: [],
|
|
168
|
+
sessions: [],
|
|
169
|
+
trend: [],
|
|
170
|
+
};
|
|
171
|
+
statsMap.set(path, project);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
project.sessionCount += 1;
|
|
175
|
+
if (session.status === 'active') project.activeSessions += 1;
|
|
176
|
+
project.inputTokens += session.totals.input;
|
|
177
|
+
project.outputTokens += session.totals.output;
|
|
178
|
+
project.cacheRead += session.totals.cacheRead ?? 0;
|
|
179
|
+
project.cacheWrite += session.totals.cacheWrite ?? 0;
|
|
180
|
+
project.tokens += session.totals.input + session.totals.output;
|
|
181
|
+
project.cost += session.totalCostUsd ?? 0;
|
|
182
|
+
project.requestCount += session.requestCount;
|
|
183
|
+
project.sessions.push(session);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Compute model & agent breakdowns + trend for each project
|
|
187
|
+
for (const project of statsMap.values()) {
|
|
188
|
+
// Model breakdown
|
|
189
|
+
const modelMap = new Map<string, ModelStats>();
|
|
190
|
+
const agentMap = new Map<string, AgentStats>();
|
|
191
|
+
|
|
192
|
+
for (const session of project.sessions) {
|
|
193
|
+
// Agent stats
|
|
194
|
+
const existing = agentMap.get(session.agentName);
|
|
195
|
+
if (existing) {
|
|
196
|
+
existing.cost += session.totalCostUsd ?? 0;
|
|
197
|
+
existing.sessions += 1;
|
|
198
|
+
} else {
|
|
199
|
+
agentMap.set(session.agentName, {
|
|
200
|
+
agentName: session.agentName,
|
|
201
|
+
cost: session.totalCostUsd ?? 0,
|
|
202
|
+
sessions: 1,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Model stats from streams
|
|
207
|
+
for (const stream of session.streams) {
|
|
208
|
+
const key = `${stream.providerId}::${stream.modelId}`;
|
|
209
|
+
const ms = modelMap.get(key);
|
|
210
|
+
if (ms) {
|
|
211
|
+
ms.cost += stream.costUsd ?? 0;
|
|
212
|
+
ms.requests += stream.requestCount;
|
|
213
|
+
ms.tokens += stream.tokens.input + stream.tokens.output;
|
|
214
|
+
} else {
|
|
215
|
+
modelMap.set(key, {
|
|
216
|
+
modelId: stream.modelId,
|
|
217
|
+
providerId: stream.providerId,
|
|
218
|
+
cost: stream.costUsd ?? 0,
|
|
219
|
+
requests: stream.requestCount,
|
|
220
|
+
tokens: stream.tokens.input + stream.tokens.output,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
project.models = Array.from(modelMap.values()).sort((a, b) => b.cost - a.cost);
|
|
227
|
+
project.agents = Array.from(agentMap.values()).sort((a, b) => b.cost - a.cost);
|
|
228
|
+
|
|
229
|
+
if (project.sessions.length > 0) {
|
|
230
|
+
const now = Date.now();
|
|
231
|
+
const windowMin = windowStart ?? Math.min(...project.sessions.map((s) => s.startedAt));
|
|
232
|
+
const range = now - windowMin;
|
|
233
|
+
const bucketCount = 5;
|
|
234
|
+
const trendBuckets = new Array<number>(bucketCount).fill(0);
|
|
235
|
+
if (range > 0) {
|
|
236
|
+
for (const s of project.sessions) {
|
|
237
|
+
const idx = Math.min(
|
|
238
|
+
bucketCount - 1,
|
|
239
|
+
Math.max(0, Math.floor(((s.lastActivityAt - windowMin) / range) * bucketCount)),
|
|
240
|
+
);
|
|
241
|
+
const bucket = trendBuckets[idx];
|
|
242
|
+
if (bucket !== undefined) {
|
|
243
|
+
trendBuckets[idx] = bucket + (s.totalCostUsd ?? 0);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
trendBuckets[bucketCount - 1] = project.cost;
|
|
248
|
+
}
|
|
249
|
+
project.trend = trendBuckets;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return Array.from(statsMap.values());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Sort fields cycle
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
const SORT_FIELDS: SortField[] = ['cost', 'tokens', 'sessions', 'cache', 'cpr', 'activity'];
|
|
261
|
+
|
|
262
|
+
const SORT_LABELS: Record<SortField, string> = {
|
|
263
|
+
cost: 'COST',
|
|
264
|
+
tokens: 'TOKENS',
|
|
265
|
+
sessions: 'SESSIONS',
|
|
266
|
+
cache: 'CACHE',
|
|
267
|
+
cpr: '$/REQ',
|
|
268
|
+
activity: 'ACTIVITY',
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
function sortProjects(projects: ProjectStats[], field: SortField, _totalCost?: number): ProjectStats[] {
|
|
272
|
+
return [...projects].sort((a, b) => {
|
|
273
|
+
switch (field) {
|
|
274
|
+
case 'cost':
|
|
275
|
+
return b.cost - a.cost;
|
|
276
|
+
case 'tokens':
|
|
277
|
+
return b.tokens - a.tokens;
|
|
278
|
+
case 'sessions':
|
|
279
|
+
return b.sessionCount - a.sessionCount;
|
|
280
|
+
case 'cache': {
|
|
281
|
+
const cacheA = (a.inputTokens + a.cacheRead) > 0 ? a.cacheRead / (a.inputTokens + a.cacheRead) : 0;
|
|
282
|
+
const cacheB = (b.inputTokens + b.cacheRead) > 0 ? b.cacheRead / (b.inputTokens + b.cacheRead) : 0;
|
|
283
|
+
return cacheB - cacheA;
|
|
284
|
+
}
|
|
285
|
+
case 'cpr': {
|
|
286
|
+
const cprA = a.requestCount > 0 ? a.cost / a.requestCount : 0;
|
|
287
|
+
const cprB = b.requestCount > 0 ? b.cost / b.requestCount : 0;
|
|
288
|
+
return cprB - cprA;
|
|
289
|
+
}
|
|
290
|
+
case 'activity':
|
|
291
|
+
return b.activeSessions - a.activeSessions;
|
|
292
|
+
default:
|
|
293
|
+
return 0;
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Responsive column layout
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
interface ColumnSpec {
|
|
303
|
+
key: string;
|
|
304
|
+
label: string;
|
|
305
|
+
width: number;
|
|
306
|
+
minTermWidth: number; // hide below this terminal width
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const COLUMNS: ColumnSpec[] = [
|
|
310
|
+
{ key: 'project', label: 'PROJECT', width: 20, minTermWidth: 0 },
|
|
311
|
+
{ key: 'cost', label: 'COST', width: 8, minTermWidth: 0 },
|
|
312
|
+
{ key: 'share', label: 'SHARE', width: 6, minTermWidth: 80 },
|
|
313
|
+
{ key: 'trend', label: 'TREND', width: 7, minTermWidth: 90 },
|
|
314
|
+
{ key: 'sess', label: 'SESS', width: 7, minTermWidth: 0 },
|
|
315
|
+
{ key: 'cache', label: 'CACHE', width: 7, minTermWidth: 100 },
|
|
316
|
+
{ key: 'cpr', label: '$/REQ', width: 8, minTermWidth: 110 },
|
|
317
|
+
{ key: 'topmodel', label: 'TOP MODEL', width: 18, minTermWidth: 120 },
|
|
318
|
+
{ key: 'activity', label: 'ACTIVITY', width: 8, minTermWidth: 70 },
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
function getVisibleColumns(termWidth: number): ColumnSpec[] {
|
|
322
|
+
return COLUMNS.filter((c) => termWidth >= c.minTermWidth).map((c) => {
|
|
323
|
+
if (c.key === 'project') {
|
|
324
|
+
let w = 20;
|
|
325
|
+
if (termWidth >= 160) w = 40;
|
|
326
|
+
else if (termWidth >= 120) w = 30;
|
|
327
|
+
else if (termWidth >= 100) w = 25;
|
|
328
|
+
return { ...c, width: w };
|
|
329
|
+
}
|
|
330
|
+
if (c.key === 'topmodel' && termWidth >= 160) {
|
|
331
|
+
return { ...c, width: 24 };
|
|
332
|
+
}
|
|
333
|
+
return c;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Sub-components
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
interface AggregateHeaderProps {
|
|
342
|
+
totalCost: number;
|
|
343
|
+
globalCost?: number | undefined;
|
|
344
|
+
projectCount: number;
|
|
345
|
+
sessionCount: number;
|
|
346
|
+
windowLabel: string;
|
|
347
|
+
sortField: SortField;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function AggregateHeader({ totalCost, globalCost, projectCount, sessionCount, windowLabel, sortField }: AggregateHeaderProps) {
|
|
351
|
+
const colors = useColors();
|
|
352
|
+
const avgPerProject = projectCount > 0 ? totalCost / projectCount : 0;
|
|
353
|
+
const isFiltered = globalCost !== undefined && globalCost !== totalCost;
|
|
354
|
+
|
|
355
|
+
return (
|
|
356
|
+
<box flexDirection="row" height={1} justifyContent="space-between" paddingX={1}>
|
|
357
|
+
<text height={1}>
|
|
358
|
+
<span fg={colors.success}><strong>{formatCost(totalCost)}</strong></span>
|
|
359
|
+
{isFiltered
|
|
360
|
+
? <span fg={colors.textSubtle}> of {formatCost(globalCost)} </span>
|
|
361
|
+
: <span fg={colors.textMuted}> total </span>}
|
|
362
|
+
<span fg={colors.text}>{projectCount}</span>
|
|
363
|
+
<span fg={colors.textMuted}> projects </span>
|
|
364
|
+
<span fg={colors.text}>{sessionCount}</span>
|
|
365
|
+
<span fg={colors.textMuted}> sessions Avg $/proj: </span>
|
|
366
|
+
<span fg={colors.text}>{formatCost(avgPerProject)}</span>
|
|
367
|
+
</text>
|
|
368
|
+
<text height={1} fg={colors.textMuted}>
|
|
369
|
+
[{windowLabel}] Sort: <span fg={colors.primary}>{SORT_LABELS[sortField]}</span>
|
|
370
|
+
</text>
|
|
371
|
+
</box>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
interface ProjectRowProps {
|
|
376
|
+
project: ProjectStats;
|
|
377
|
+
isSelected: boolean;
|
|
378
|
+
totalCost: number;
|
|
379
|
+
visibleColumns: ColumnSpec[];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function ProjectRow({ project, isSelected, totalCost, visibleColumns }: ProjectRowProps) {
|
|
383
|
+
const colors = useColors();
|
|
384
|
+
const fg = isSelected ? colors.primary : colors.text;
|
|
385
|
+
const mutedFg = isSelected ? colors.primary : colors.textMuted;
|
|
386
|
+
const subtleFg = isSelected ? colors.primary : colors.textSubtle;
|
|
387
|
+
const railChar = isSelected ? '\u258C' : ' ';
|
|
388
|
+
|
|
389
|
+
const share = totalCost > 0 ? (project.cost / totalCost) * 100 : 0;
|
|
390
|
+
const cacheTotal = project.inputTokens + project.cacheRead;
|
|
391
|
+
const cacheRate = cacheTotal > 0 ? (project.cacheRead / cacheTotal) * 100 : 0;
|
|
392
|
+
const costPerReq = project.requestCount > 0 ? project.cost / project.requestCount : 0;
|
|
393
|
+
const topModel = project.models[0];
|
|
394
|
+
const topModelName = topModel ? (topModel.modelId.split('/').pop() ?? topModel.modelId) : '-';
|
|
395
|
+
const topModelShare = topModel && project.cost > 0
|
|
396
|
+
? Math.round((topModel.cost / project.cost) * 100)
|
|
397
|
+
: 0;
|
|
398
|
+
const activityDots = project.activeSessions > 0
|
|
399
|
+
? '\u25CF'.repeat(Math.min(project.activeSessions, 5)) +
|
|
400
|
+
(project.activeSessions > 5 ? `+${project.activeSessions - 5}` : '')
|
|
401
|
+
: '\u25CB';
|
|
402
|
+
|
|
403
|
+
const colRenderers: Record<string, (w: number) => React.ReactElement> = {
|
|
404
|
+
project: (w) => (
|
|
405
|
+
<text width={w} height={1} fg={fg}>
|
|
406
|
+
{padRight(truncate(project.name, w - 1), w)}
|
|
407
|
+
</text>
|
|
408
|
+
),
|
|
409
|
+
cost: (w) => (
|
|
410
|
+
<text width={w} height={1} fg={isSelected ? colors.primary : colors.success}>
|
|
411
|
+
{padLeft(formatCost(project.cost), w)}
|
|
412
|
+
</text>
|
|
413
|
+
),
|
|
414
|
+
share: (w) => (
|
|
415
|
+
<text width={w} height={1} fg={mutedFg}>
|
|
416
|
+
{padLeft(formatPercent(share), w)}
|
|
417
|
+
</text>
|
|
418
|
+
),
|
|
419
|
+
trend: (w) => (
|
|
420
|
+
<text width={w} height={1} fg={subtleFg}>
|
|
421
|
+
{' '}{miniSparkline(project.trend, 5)}{' '}
|
|
422
|
+
</text>
|
|
423
|
+
),
|
|
424
|
+
sess: (w) => (
|
|
425
|
+
<text width={w} height={1} fg={fg}>
|
|
426
|
+
{padLeft(`${project.sessionCount}/${project.activeSessions}`, w)}
|
|
427
|
+
</text>
|
|
428
|
+
),
|
|
429
|
+
cache: (w) => (
|
|
430
|
+
<text width={w} height={1} fg={cacheRate >= 50 ? (isSelected ? colors.primary : colors.success) : mutedFg}>
|
|
431
|
+
{padLeft(cacheRate > 0 ? formatPercent(cacheRate) : '-', w)}
|
|
432
|
+
</text>
|
|
433
|
+
),
|
|
434
|
+
cpr: (w) => (
|
|
435
|
+
<text width={w} height={1} fg={mutedFg}>
|
|
436
|
+
{padLeft(costPerReq > 0 ? formatCost(costPerReq) : '-', w)}
|
|
437
|
+
</text>
|
|
438
|
+
),
|
|
439
|
+
topmodel: (w) => {
|
|
440
|
+
const nameW = w - 5;
|
|
441
|
+
return (
|
|
442
|
+
<text width={w} height={1} fg={subtleFg}>
|
|
443
|
+
{padRight(
|
|
444
|
+
topModel
|
|
445
|
+
? `${truncate(topModelName, nameW)} ${topModelShare}%`
|
|
446
|
+
: '-',
|
|
447
|
+
w,
|
|
448
|
+
)}
|
|
449
|
+
</text>
|
|
450
|
+
);
|
|
451
|
+
},
|
|
452
|
+
activity: (w) => (
|
|
453
|
+
<text width={w} height={1} fg={project.activeSessions > 0 ? (isSelected ? colors.primary : colors.success) : mutedFg}>
|
|
454
|
+
{padRight(activityDots, w)}
|
|
455
|
+
</text>
|
|
456
|
+
),
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
return (
|
|
460
|
+
<box flexDirection="row" height={1} backgroundColor={isSelected ? colors.borderMuted : undefined}>
|
|
461
|
+
<text width={2} height={1} fg={isSelected ? colors.primary : colors.textSubtle}>{railChar} </text>
|
|
462
|
+
{visibleColumns.map((col) => {
|
|
463
|
+
const renderer = colRenderers[col.key];
|
|
464
|
+
return renderer ? <box key={col.key} marginRight={1}>{renderer(col.width)}</box> : null;
|
|
465
|
+
})}
|
|
466
|
+
</box>
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
interface SummaryFooterProps {
|
|
471
|
+
projects: ProjectStats[];
|
|
472
|
+
visibleColumns: ColumnSpec[];
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function SummaryFooter({ projects, visibleColumns }: SummaryFooterProps) {
|
|
476
|
+
const colors = useColors();
|
|
477
|
+
const totalCost = projects.reduce((s, p) => s + p.cost, 0);
|
|
478
|
+
const totalSessions = projects.reduce((s, p) => s + p.sessionCount, 0);
|
|
479
|
+
const totalActive = projects.reduce((s, p) => s + p.activeSessions, 0);
|
|
480
|
+
const totalRequests = projects.reduce((s, p) => s + p.requestCount, 0);
|
|
481
|
+
const totalInput = projects.reduce((s, p) => s + p.inputTokens, 0);
|
|
482
|
+
const totalCacheRead = projects.reduce((s, p) => s + p.cacheRead, 0);
|
|
483
|
+
const cacheTotal = totalInput + totalCacheRead;
|
|
484
|
+
const cacheRate = cacheTotal > 0 ? (totalCacheRead / cacheTotal) * 100 : 0;
|
|
485
|
+
const avgCpr = totalRequests > 0 ? totalCost / totalRequests : 0;
|
|
486
|
+
|
|
487
|
+
const colRenderers: Record<string, (w: number) => React.ReactElement> = {
|
|
488
|
+
project: (w) => (
|
|
489
|
+
<text width={w} height={1} fg={colors.textMuted}>
|
|
490
|
+
{padRight('TOTAL', w)}
|
|
491
|
+
</text>
|
|
492
|
+
),
|
|
493
|
+
cost: (w) => (
|
|
494
|
+
<text width={w} height={1} fg={colors.success}>
|
|
495
|
+
{padLeft(formatCost(totalCost), w)}
|
|
496
|
+
</text>
|
|
497
|
+
),
|
|
498
|
+
share: (w) => (
|
|
499
|
+
<text width={w} height={1} fg={colors.textMuted}>
|
|
500
|
+
{padLeft('100%', w)}
|
|
501
|
+
</text>
|
|
502
|
+
),
|
|
503
|
+
trend: (w) => (
|
|
504
|
+
<text width={w} height={1} fg={colors.textSubtle}>
|
|
505
|
+
{' '.repeat(w)}
|
|
506
|
+
</text>
|
|
507
|
+
),
|
|
508
|
+
sess: (w) => (
|
|
509
|
+
<text width={w} height={1} fg={colors.text}>
|
|
510
|
+
{padLeft(`${totalSessions}/${totalActive}`, w)}
|
|
511
|
+
</text>
|
|
512
|
+
),
|
|
513
|
+
cache: (w) => (
|
|
514
|
+
<text width={w} height={1} fg={cacheRate >= 50 ? colors.success : colors.textMuted}>
|
|
515
|
+
{padLeft(formatPercent(cacheRate), w)}
|
|
516
|
+
</text>
|
|
517
|
+
),
|
|
518
|
+
cpr: (w) => (
|
|
519
|
+
<text width={w} height={1} fg={colors.textMuted}>
|
|
520
|
+
{padLeft(formatCost(avgCpr), w)}
|
|
521
|
+
</text>
|
|
522
|
+
),
|
|
523
|
+
topmodel: (w) => (
|
|
524
|
+
<text width={w} height={1} fg={colors.textSubtle}>
|
|
525
|
+
{' '.repeat(w)}
|
|
526
|
+
</text>
|
|
527
|
+
),
|
|
528
|
+
activity: (w) => (
|
|
529
|
+
<text width={w} height={1} fg={totalActive > 0 ? colors.success : colors.textMuted}>
|
|
530
|
+
{padRight(totalActive > 0 ? `${totalActive} live` : 'idle', w)}
|
|
531
|
+
</text>
|
|
532
|
+
),
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
return (
|
|
536
|
+
<box flexDirection="row" height={1}>
|
|
537
|
+
<text width={2} height={1} fg={colors.border}>{'\u2500 '}</text>
|
|
538
|
+
{visibleColumns.map((col) => {
|
|
539
|
+
const renderer = colRenderers[col.key];
|
|
540
|
+
return renderer ? <box key={col.key} marginRight={1}>{renderer(col.width)}</box> : null;
|
|
541
|
+
})}
|
|
542
|
+
</box>
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
// Cross-Project Insights Panel
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
interface InsightsPanelProps {
|
|
551
|
+
projects: ProjectStats[];
|
|
552
|
+
termWidth: number;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function InsightsPanel({ projects, termWidth }: InsightsPanelProps) {
|
|
556
|
+
const colors = useColors();
|
|
557
|
+
|
|
558
|
+
// Aggregate model costs across all projects
|
|
559
|
+
const modelCosts = useMemo(() => {
|
|
560
|
+
const map = new Map<string, { name: string; cost: number }>();
|
|
561
|
+
for (const p of projects) {
|
|
562
|
+
for (const m of p.models) {
|
|
563
|
+
const name = m.modelId.split('/').pop() ?? m.modelId;
|
|
564
|
+
const existing = map.get(name);
|
|
565
|
+
if (existing) {
|
|
566
|
+
existing.cost += m.cost;
|
|
567
|
+
} else {
|
|
568
|
+
map.set(name, { name, cost: m.cost });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return Array.from(map.values())
|
|
573
|
+
.sort((a, b) => b.cost - a.cost)
|
|
574
|
+
.slice(0, 5);
|
|
575
|
+
}, [projects]);
|
|
576
|
+
|
|
577
|
+
// Aggregate agent costs across all projects
|
|
578
|
+
const agentCosts = useMemo(() => {
|
|
579
|
+
const map = new Map<string, { name: string; cost: number }>();
|
|
580
|
+
for (const p of projects) {
|
|
581
|
+
for (const a of p.agents) {
|
|
582
|
+
const existing = map.get(a.agentName);
|
|
583
|
+
if (existing) {
|
|
584
|
+
existing.cost += a.cost;
|
|
585
|
+
} else {
|
|
586
|
+
map.set(a.agentName, { name: a.agentName, cost: a.cost });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return Array.from(map.values())
|
|
591
|
+
.sort((a, b) => b.cost - a.cost)
|
|
592
|
+
.slice(0, 5);
|
|
593
|
+
}, [projects]);
|
|
594
|
+
|
|
595
|
+
// Efficiency rankings
|
|
596
|
+
const rankings = useMemo(() => {
|
|
597
|
+
if (projects.length === 0) return null;
|
|
598
|
+
const bestCpr = [...projects]
|
|
599
|
+
.filter((p) => p.requestCount > 0)
|
|
600
|
+
.sort((a, b) => (a.cost / a.requestCount) - (b.cost / b.requestCount))[0];
|
|
601
|
+
const bestCache = [...projects]
|
|
602
|
+
.filter((p) => (p.inputTokens + p.cacheRead) > 0)
|
|
603
|
+
.sort((a, b) => {
|
|
604
|
+
const rateB = b.cacheRead / (b.inputTokens + b.cacheRead);
|
|
605
|
+
const rateA = a.cacheRead / (a.inputTokens + a.cacheRead);
|
|
606
|
+
return rateB - rateA;
|
|
607
|
+
})[0];
|
|
608
|
+
const mostActive = [...projects].sort((a, b) => b.activeSessions - a.activeSessions)[0];
|
|
609
|
+
|
|
610
|
+
// Biggest mover: project with highest cost in the latest trend bucket vs average
|
|
611
|
+
let biggestMover: ProjectStats | undefined;
|
|
612
|
+
let biggestMoverDelta = 0;
|
|
613
|
+
for (const p of projects) {
|
|
614
|
+
if (p.trend.length >= 2) {
|
|
615
|
+
const last = p.trend[p.trend.length - 1] ?? 0;
|
|
616
|
+
const avg = p.trend.reduce((s, v) => s + v, 0) / p.trend.length;
|
|
617
|
+
const delta = avg > 0 ? (last - avg) / avg : 0;
|
|
618
|
+
if (Math.abs(delta) > Math.abs(biggestMoverDelta)) {
|
|
619
|
+
biggestMoverDelta = delta;
|
|
620
|
+
biggestMover = p;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return { bestCpr, bestCache, mostActive, biggestMover, biggestMoverDelta };
|
|
626
|
+
}, [projects]);
|
|
627
|
+
|
|
628
|
+
const maxModelCost = modelCosts.length > 0 ? modelCosts[0]!.cost : 1;
|
|
629
|
+
const maxAgentCost = agentCosts.length > 0 ? agentCosts[0]!.cost : 1;
|
|
630
|
+
|
|
631
|
+
const isWide = termWidth >= 100;
|
|
632
|
+
const barWidth = isWide ? 15 : 10;
|
|
633
|
+
const nameWidth = isWide ? 16 : 12;
|
|
634
|
+
|
|
635
|
+
return (
|
|
636
|
+
<box
|
|
637
|
+
flexDirection="column"
|
|
638
|
+
border
|
|
639
|
+
borderStyle="single"
|
|
640
|
+
borderColor={colors.border}
|
|
641
|
+
overflow="hidden"
|
|
642
|
+
flexShrink={0}
|
|
643
|
+
>
|
|
644
|
+
<box flexDirection="row" paddingX={1} height={1}>
|
|
645
|
+
<text height={1} fg={colors.textMuted}><strong>CROSS-PROJECT INSIGHTS</strong></text>
|
|
646
|
+
<text flexGrow={1} height={1}>{' '}</text>
|
|
647
|
+
<text height={1} fg={colors.textSubtle}>[v] toggle</text>
|
|
648
|
+
</box>
|
|
649
|
+
|
|
650
|
+
<box flexDirection="row" paddingX={1} gap={2} overflow="hidden">
|
|
651
|
+
{/* COST BY MODEL */}
|
|
652
|
+
<box flexDirection="column" flexGrow={1} overflow="hidden">
|
|
653
|
+
<text height={1} fg={colors.textMuted}><strong>COST BY MODEL</strong></text>
|
|
654
|
+
{modelCosts.map((m: { name: string; cost: number }) => (
|
|
655
|
+
<box key={m.name} flexDirection="row" height={1}>
|
|
656
|
+
<text width={nameWidth} height={1} fg={colors.text}>
|
|
657
|
+
{padRight(truncate(m.name, nameWidth - 1), nameWidth)}
|
|
658
|
+
</text>
|
|
659
|
+
<text width={barWidth} height={1} fg={colors.primary}>
|
|
660
|
+
{makeBar((m.cost / maxModelCost) * 100, barWidth)}
|
|
661
|
+
</text>
|
|
662
|
+
<text width={8} height={1} fg={colors.textMuted}>
|
|
663
|
+
{padLeft(formatCost(m.cost), 8)}
|
|
664
|
+
</text>
|
|
665
|
+
</box>
|
|
666
|
+
))}
|
|
667
|
+
{modelCosts.length === 0 && (
|
|
668
|
+
<text height={1} fg={colors.textSubtle}>No model data</text>
|
|
669
|
+
)}
|
|
670
|
+
</box>
|
|
671
|
+
|
|
672
|
+
{/* COST BY AGENT */}
|
|
673
|
+
<box flexDirection="column" flexGrow={1} overflow="hidden">
|
|
674
|
+
<text height={1} fg={colors.textMuted}><strong>COST BY AGENT</strong></text>
|
|
675
|
+
{agentCosts.map((a: { name: string; cost: number }) => (
|
|
676
|
+
<box key={a.name} flexDirection="row" height={1}>
|
|
677
|
+
<text width={nameWidth} height={1} fg={colors.text}>
|
|
678
|
+
{padRight(truncate(a.name, nameWidth - 1), nameWidth)}
|
|
679
|
+
</text>
|
|
680
|
+
<text width={barWidth} height={1} fg={colors.accent}>
|
|
681
|
+
{makeBar((a.cost / maxAgentCost) * 100, barWidth)}
|
|
682
|
+
</text>
|
|
683
|
+
<text width={8} height={1} fg={colors.textMuted}>
|
|
684
|
+
{padLeft(formatCost(a.cost), 8)}
|
|
685
|
+
</text>
|
|
686
|
+
</box>
|
|
687
|
+
))}
|
|
688
|
+
{agentCosts.length === 0 && (
|
|
689
|
+
<text height={1} fg={colors.textSubtle}>No agent data</text>
|
|
690
|
+
)}
|
|
691
|
+
</box>
|
|
692
|
+
|
|
693
|
+
{/* EFFICIENCY + BIGGEST MOVER */}
|
|
694
|
+
{isWide && rankings && (
|
|
695
|
+
<box flexDirection="column" width={30} overflow="hidden">
|
|
696
|
+
<text height={1} fg={colors.textMuted}><strong>RANKINGS</strong></text>
|
|
697
|
+
{rankings.bestCpr && (
|
|
698
|
+
<text height={1} fg={colors.textSubtle}>
|
|
699
|
+
Best $/req: <span fg={colors.success}>{truncate(rankings.bestCpr.name, 10)}</span>
|
|
700
|
+
{' '}{formatCost(rankings.bestCpr.cost / rankings.bestCpr.requestCount)}
|
|
701
|
+
</text>
|
|
702
|
+
)}
|
|
703
|
+
{rankings.bestCache && (
|
|
704
|
+
<text height={1} fg={colors.textSubtle}>
|
|
705
|
+
Best cache: <span fg={colors.success}>{truncate(rankings.bestCache.name, 10)}</span>
|
|
706
|
+
{' '}{formatPercent(rankings.bestCache.cacheRead / (rankings.bestCache.inputTokens + rankings.bestCache.cacheRead) * 100)}
|
|
707
|
+
</text>
|
|
708
|
+
)}
|
|
709
|
+
{rankings.mostActive && rankings.mostActive.activeSessions > 0 && (
|
|
710
|
+
<text height={1} fg={colors.textSubtle}>
|
|
711
|
+
Most active: <span fg={colors.success}>{truncate(rankings.mostActive.name, 10)}</span>
|
|
712
|
+
{' '}{rankings.mostActive.activeSessions} live
|
|
713
|
+
</text>
|
|
714
|
+
)}
|
|
715
|
+
{rankings.biggestMover && (
|
|
716
|
+
<text height={1} fg={colors.textSubtle}>
|
|
717
|
+
Biggest mover: <span fg={rankings.biggestMoverDelta >= 0 ? colors.warning : colors.info}>
|
|
718
|
+
{truncate(rankings.biggestMover.name, 8)}
|
|
719
|
+
</span>
|
|
720
|
+
{' '}{rankings.biggestMoverDelta >= 0 ? '\u25B2' : '\u25BC'}{formatPercent(Math.min(Math.abs(rankings.biggestMoverDelta) * 100, 200))}
|
|
721
|
+
</text>
|
|
722
|
+
)}
|
|
723
|
+
</box>
|
|
724
|
+
)}
|
|
725
|
+
</box>
|
|
726
|
+
</box>
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ---------------------------------------------------------------------------
|
|
731
|
+
// Project Detail Drawer
|
|
732
|
+
// ---------------------------------------------------------------------------
|
|
733
|
+
|
|
734
|
+
interface ProjectDetailDrawerProps {
|
|
735
|
+
project: ProjectStats;
|
|
736
|
+
totalCost: number;
|
|
737
|
+
onClose: () => void;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function ProjectDetailDrawer({ project, totalCost, onClose: _onClose }: ProjectDetailDrawerProps) {
|
|
741
|
+
void _onClose;
|
|
742
|
+
const colors = useColors();
|
|
743
|
+
const { width: termWidth, height: termHeight } = useTerminalDimensions();
|
|
744
|
+
|
|
745
|
+
const width = Math.max(70, Math.min(termWidth - 4, 100));
|
|
746
|
+
const height = Math.max(24, Math.min(termHeight - 4, 42));
|
|
747
|
+
const contentWidth = width - 4;
|
|
748
|
+
|
|
749
|
+
const share = totalCost > 0 ? (project.cost / totalCost) * 100 : 0;
|
|
750
|
+
const cacheTotal = project.inputTokens + project.cacheRead;
|
|
751
|
+
const cacheRate = cacheTotal > 0 ? (project.cacheRead / cacheTotal) * 100 : 0;
|
|
752
|
+
const outputShare = (project.inputTokens + project.outputTokens) > 0
|
|
753
|
+
? (project.outputTokens / (project.inputTokens + project.outputTokens)) * 100
|
|
754
|
+
: 0;
|
|
755
|
+
const costPerReq = project.requestCount > 0 ? project.cost / project.requestCount : 0;
|
|
756
|
+
const avgPerSession = project.sessionCount > 0 ? project.cost / project.sessionCount : 0;
|
|
757
|
+
const allTokens = project.inputTokens + project.outputTokens + project.cacheRead + project.cacheWrite;
|
|
758
|
+
|
|
759
|
+
// Token composition percentages
|
|
760
|
+
const inputPct = allTokens > 0 ? (project.inputTokens / allTokens) * 100 : 0;
|
|
761
|
+
const outputPct = allTokens > 0 ? (project.outputTokens / allTokens) * 100 : 0;
|
|
762
|
+
const cacheReadPct = allTokens > 0 ? (project.cacheRead / allTokens) * 100 : 0;
|
|
763
|
+
const cacheWritePct = allTokens > 0 ? (project.cacheWrite / allTokens) * 100 : 0;
|
|
764
|
+
|
|
765
|
+
const compBarW = Math.min(15, Math.max(10, contentWidth - 30));
|
|
766
|
+
|
|
767
|
+
return (
|
|
768
|
+
<box
|
|
769
|
+
position="absolute"
|
|
770
|
+
left={0}
|
|
771
|
+
top={0}
|
|
772
|
+
width="100%"
|
|
773
|
+
height="100%"
|
|
774
|
+
justifyContent="center"
|
|
775
|
+
alignItems="center"
|
|
776
|
+
zIndex={100}
|
|
777
|
+
backgroundColor={OVERLAY_BG}
|
|
778
|
+
>
|
|
779
|
+
<box
|
|
780
|
+
width={width}
|
|
781
|
+
height={height}
|
|
782
|
+
border
|
|
783
|
+
borderStyle="single"
|
|
784
|
+
borderColor={colors.primary}
|
|
785
|
+
flexDirection="column"
|
|
786
|
+
padding={1}
|
|
787
|
+
paddingLeft={2}
|
|
788
|
+
paddingRight={2}
|
|
789
|
+
backgroundColor={colors.background}
|
|
790
|
+
overflow="hidden"
|
|
791
|
+
>
|
|
792
|
+
{/* Header */}
|
|
793
|
+
<box flexDirection="row" justifyContent="space-between" height={1} marginBottom={1}>
|
|
794
|
+
<text height={1}>
|
|
795
|
+
<span fg={colors.primary}><strong>PROJECT: {truncate(project.name, 30)}</strong></span>
|
|
796
|
+
<span fg={colors.textMuted}> ({formatPercent(share)} of total)</span>
|
|
797
|
+
</text>
|
|
798
|
+
<text height={1} fg={colors.textMuted}>[Esc] Close</text>
|
|
799
|
+
</box>
|
|
800
|
+
|
|
801
|
+
{/* Overview stats */}
|
|
802
|
+
<box
|
|
803
|
+
flexDirection="column"
|
|
804
|
+
border
|
|
805
|
+
borderStyle="single"
|
|
806
|
+
borderColor={colors.border}
|
|
807
|
+
paddingX={1}
|
|
808
|
+
paddingY={0}
|
|
809
|
+
flexShrink={0}
|
|
810
|
+
>
|
|
811
|
+
<box flexDirection="row" height={1} justifyContent="space-between">
|
|
812
|
+
<text height={1}>
|
|
813
|
+
<span fg={colors.textMuted}>Cost: </span>
|
|
814
|
+
<span fg={colors.success}><strong>{formatCost(project.cost)}</strong></span>
|
|
815
|
+
</text>
|
|
816
|
+
<text height={1}>
|
|
817
|
+
<span fg={colors.textMuted}>Sessions: </span>
|
|
818
|
+
<span fg={colors.text}>{project.sessionCount}</span>
|
|
819
|
+
<span fg={colors.textMuted}> (</span>
|
|
820
|
+
<span fg={project.activeSessions > 0 ? colors.success : colors.textMuted}>{project.activeSessions} active</span>
|
|
821
|
+
<span fg={colors.textMuted}>)</span>
|
|
822
|
+
</text>
|
|
823
|
+
</box>
|
|
824
|
+
<box flexDirection="row" height={1} justifyContent="space-between">
|
|
825
|
+
<text height={1}>
|
|
826
|
+
<span fg={colors.textMuted}>Tokens: </span>
|
|
827
|
+
<span fg={colors.text}>{formatTokensCompact(allTokens)}</span>
|
|
828
|
+
<span fg={colors.textMuted}> (In: {formatTokensCompact(project.inputTokens)}{project.cacheRead > 0 ? ` + ${formatTokensCompact(project.cacheRead)} cached` : ''} / Out: {formatTokensCompact(project.outputTokens)})</span>
|
|
829
|
+
</text>
|
|
830
|
+
<text height={1}>
|
|
831
|
+
<span fg={colors.textMuted}>Requests: </span>
|
|
832
|
+
<span fg={colors.text}>{project.requestCount}</span>
|
|
833
|
+
</text>
|
|
834
|
+
</box>
|
|
835
|
+
</box>
|
|
836
|
+
|
|
837
|
+
{/* MODEL BREAKDOWN */}
|
|
838
|
+
<box flexDirection="row" height={1} paddingX={1} marginTop={0}>
|
|
839
|
+
<text flexGrow={1} height={1} fg={colors.primary}><strong>MODEL</strong></text>
|
|
840
|
+
<box width={8} justifyContent="flex-end"><text fg={colors.primary}><strong>COST</strong></text></box>
|
|
841
|
+
<box width={6} justifyContent="flex-end"><text fg={colors.primary}><strong>SHARE</strong></text></box>
|
|
842
|
+
<box width={8} justifyContent="flex-end"><text fg={colors.primary}><strong>REQS</strong></text></box>
|
|
843
|
+
</box>
|
|
844
|
+
<box
|
|
845
|
+
flexDirection="column"
|
|
846
|
+
border
|
|
847
|
+
borderColor={colors.border}
|
|
848
|
+
overflow="hidden"
|
|
849
|
+
height={Math.min(Math.max(project.models.length, 1), 8) + 2}
|
|
850
|
+
flexShrink={0}
|
|
851
|
+
>
|
|
852
|
+
<scrollbox>
|
|
853
|
+
{project.models.length > 0 ? project.models.slice(0, 8).map((m, idx) => {
|
|
854
|
+
const modelName = m.modelId.split('/').pop() ?? m.modelId;
|
|
855
|
+
const modelShare = project.cost > 0 ? (m.cost / project.cost) * 100 : 0;
|
|
856
|
+
return (
|
|
857
|
+
<box key={`${m.modelId}-${idx}`} flexDirection="row" height={1} paddingX={1}>
|
|
858
|
+
<text flexGrow={1} height={1} fg={colors.text} overflow="hidden">
|
|
859
|
+
{truncate(modelName, contentWidth - 28)}
|
|
860
|
+
</text>
|
|
861
|
+
<box width={8} height={1} justifyContent="flex-end">
|
|
862
|
+
<text fg={colors.success}>{formatCost(m.cost)}</text>
|
|
863
|
+
</box>
|
|
864
|
+
<box width={6} height={1} justifyContent="flex-end">
|
|
865
|
+
<text fg={colors.textMuted}>{formatPercent(modelShare)}</text>
|
|
866
|
+
</box>
|
|
867
|
+
<box width={8} height={1} justifyContent="flex-end">
|
|
868
|
+
<text fg={colors.textMuted}>{m.requests}</text>
|
|
869
|
+
</box>
|
|
870
|
+
</box>
|
|
871
|
+
);
|
|
872
|
+
}) : (
|
|
873
|
+
<text height={1} paddingX={1} fg={colors.textSubtle}>No model data</text>
|
|
874
|
+
)}
|
|
875
|
+
</scrollbox>
|
|
876
|
+
</box>
|
|
877
|
+
|
|
878
|
+
{/* SESSIONS */}
|
|
879
|
+
<box flexDirection="row" height={1} paddingX={1} marginTop={0}>
|
|
880
|
+
<text flexGrow={1} height={1} fg={colors.primary}><strong>SESSIONS</strong></text>
|
|
881
|
+
<box width={8} justifyContent="flex-end"><text fg={colors.primary}><strong>COST</strong></text></box>
|
|
882
|
+
<box width={8} justifyContent="flex-end"><text fg={colors.primary}><strong>TOKENS</strong></text></box>
|
|
883
|
+
<box width={6} justifyContent="flex-end"><text fg={colors.primary}><strong>AGO</strong></text></box>
|
|
884
|
+
<box width={2} justifyContent="flex-end"><text fg={colors.primary}> </text></box>
|
|
885
|
+
</box>
|
|
886
|
+
<box flexDirection="column" border borderColor={colors.border} overflow="hidden" flexGrow={1} minHeight={3}>
|
|
887
|
+
<scrollbox flexGrow={1}>
|
|
888
|
+
{project.sessions
|
|
889
|
+
.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
|
|
890
|
+
.slice(0, 15)
|
|
891
|
+
.map((s) => {
|
|
892
|
+
const modelName = s.streams[0]?.modelId.split('/').pop() ?? 'unknown';
|
|
893
|
+
return (
|
|
894
|
+
<box key={s.sessionId} flexDirection="row" height={1} paddingX={1}>
|
|
895
|
+
<text width={10} height={1} fg={colors.textSubtle} overflow="hidden">
|
|
896
|
+
{padRight(truncate(s.agentName, 9), 10)}
|
|
897
|
+
</text>
|
|
898
|
+
<text flexGrow={1} height={1} fg={colors.text} overflow="hidden">
|
|
899
|
+
{truncate(modelName, contentWidth - 40)}
|
|
900
|
+
</text>
|
|
901
|
+
<box width={8} height={1} justifyContent="flex-end">
|
|
902
|
+
<text fg={colors.success}>{formatCost(s.totalCostUsd ?? 0)}</text>
|
|
903
|
+
</box>
|
|
904
|
+
<box width={8} height={1} justifyContent="flex-end">
|
|
905
|
+
<text fg={colors.textMuted}>{formatTokensCompact(s.totals.input + s.totals.output)}</text>
|
|
906
|
+
</box>
|
|
907
|
+
<box width={6} height={1} justifyContent="flex-end">
|
|
908
|
+
<text fg={colors.textMuted}>{formatRelativeTime(s.lastActivityAt)}</text>
|
|
909
|
+
</box>
|
|
910
|
+
<box width={2} height={1} justifyContent="flex-end">
|
|
911
|
+
<text fg={s.status === 'active' ? colors.success : colors.textMuted}>
|
|
912
|
+
{s.status === 'active' ? '\u25CF' : '\u25CB'}
|
|
913
|
+
</text>
|
|
914
|
+
</box>
|
|
915
|
+
</box>
|
|
916
|
+
);
|
|
917
|
+
})}
|
|
918
|
+
{project.sessions.length === 0 && (
|
|
919
|
+
<text height={1} paddingX={1} fg={colors.textSubtle}>No sessions</text>
|
|
920
|
+
)}
|
|
921
|
+
</scrollbox>
|
|
922
|
+
</box>
|
|
923
|
+
|
|
924
|
+
{/* EFFICIENCY + TOKEN COMPOSITION */}
|
|
925
|
+
{height >= 30 && (
|
|
926
|
+
<box flexDirection="row" gap={1} flexShrink={0} marginTop={0}>
|
|
927
|
+
{/* Efficiency */}
|
|
928
|
+
<box flexDirection="column" flexGrow={1} border borderColor={colors.border} paddingX={1} overflow="hidden">
|
|
929
|
+
<text height={1} fg={colors.primary}><strong>EFFICIENCY</strong></text>
|
|
930
|
+
<text height={1}>
|
|
931
|
+
<span fg={colors.textMuted}>Cache leverage: </span>
|
|
932
|
+
<span fg={cacheRate >= 50 ? colors.success : colors.warning}>{formatPercent(cacheRate)}</span>
|
|
933
|
+
</text>
|
|
934
|
+
<text height={1}>
|
|
935
|
+
<span fg={colors.textMuted}>Output share: </span>
|
|
936
|
+
<span fg={outputShare > 65 ? colors.warning : colors.text}>{formatPercent(outputShare)}</span>
|
|
937
|
+
</text>
|
|
938
|
+
<text height={1}>
|
|
939
|
+
<span fg={colors.textMuted}>$/request: </span>
|
|
940
|
+
<span fg={colors.text}>{formatCost(costPerReq)}</span>
|
|
941
|
+
</text>
|
|
942
|
+
<text height={1}>
|
|
943
|
+
<span fg={colors.textMuted}>Avg/session: </span>
|
|
944
|
+
<span fg={colors.text}>{formatCost(avgPerSession)}</span>
|
|
945
|
+
</text>
|
|
946
|
+
</box>
|
|
947
|
+
|
|
948
|
+
{/* Token composition */}
|
|
949
|
+
<box flexDirection="column" flexGrow={1} border borderColor={colors.border} paddingX={1} overflow="hidden">
|
|
950
|
+
<text height={1} fg={colors.primary}><strong>TOKEN COMPOSITION</strong></text>
|
|
951
|
+
<box flexDirection="row" height={1}>
|
|
952
|
+
<text width={12} height={1} fg={colors.textMuted}>{'Input '}</text>
|
|
953
|
+
<text width={compBarW} height={1} fg={colors.info}>{makeBar(inputPct, compBarW)}</text>
|
|
954
|
+
<text height={1} fg={colors.text}> {formatPercent(inputPct)}</text>
|
|
955
|
+
</box>
|
|
956
|
+
<box flexDirection="row" height={1}>
|
|
957
|
+
<text width={12} height={1} fg={colors.textMuted}>{'Output '}</text>
|
|
958
|
+
<text width={compBarW} height={1} fg={colors.warning}>{makeBar(outputPct, compBarW)}</text>
|
|
959
|
+
<text height={1} fg={colors.text}> {formatPercent(outputPct)}</text>
|
|
960
|
+
</box>
|
|
961
|
+
<box flexDirection="row" height={1}>
|
|
962
|
+
<text width={12} height={1} fg={colors.textMuted}>{'Cache read'}</text>
|
|
963
|
+
<text width={compBarW} height={1} fg={colors.success}>{makeBar(cacheReadPct, compBarW)}</text>
|
|
964
|
+
<text height={1} fg={colors.text}> {formatPercent(cacheReadPct)}</text>
|
|
965
|
+
</box>
|
|
966
|
+
{cacheWritePct > 0 && (
|
|
967
|
+
<box flexDirection="row" height={1}>
|
|
968
|
+
<text width={12} height={1} fg={colors.textMuted}>{'Cache writ'}</text>
|
|
969
|
+
<text width={compBarW} height={1} fg={colors.accent}>{makeBar(cacheWritePct, compBarW)}</text>
|
|
970
|
+
<text height={1} fg={colors.text}> {formatPercent(cacheWritePct)}</text>
|
|
971
|
+
</box>
|
|
972
|
+
)}
|
|
973
|
+
</box>
|
|
974
|
+
</box>
|
|
975
|
+
)}
|
|
976
|
+
</box>
|
|
977
|
+
</box>
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// ---------------------------------------------------------------------------
|
|
982
|
+
// Empty State
|
|
983
|
+
// ---------------------------------------------------------------------------
|
|
984
|
+
|
|
985
|
+
function EmptyState({ hasFilter }: { hasFilter: boolean }) {
|
|
986
|
+
const colors = useColors();
|
|
987
|
+
|
|
988
|
+
if (hasFilter) {
|
|
989
|
+
return (
|
|
990
|
+
<box flexGrow={1} justifyContent="center" alignItems="center" flexDirection="column" gap={1}>
|
|
991
|
+
<text fg={colors.textMuted}>No projects match your filter.</text>
|
|
992
|
+
<text fg={colors.textSubtle}>Press Esc to clear the filter.</text>
|
|
993
|
+
</box>
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return (
|
|
998
|
+
<box flexGrow={1} justifyContent="center" alignItems="center" flexDirection="column" gap={1}>
|
|
999
|
+
<text fg={colors.textMuted}>No projects found in this time window.</text>
|
|
1000
|
+
<text fg={colors.textSubtle}>Projects appear when coding agents report sessions.</text>
|
|
1001
|
+
<text fg={colors.textSubtle}>Try a wider time window (t) or check the Dashboard (1).</text>
|
|
1002
|
+
</box>
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// ---------------------------------------------------------------------------
|
|
1007
|
+
// Main View
|
|
1008
|
+
// ---------------------------------------------------------------------------
|
|
1009
|
+
|
|
1010
|
+
export function ProjectsView() {
|
|
1011
|
+
const { sessions, isLoading } = useAgentSessions();
|
|
1012
|
+
const colors = useColors();
|
|
1013
|
+
const { windowLabel, getWindowStart, cycleWindow } = useTimeWindow();
|
|
1014
|
+
const { width: termWidth, height: termHeight } = useTerminalDimensions();
|
|
1015
|
+
const { setInputFocused } = useInputFocus();
|
|
1016
|
+
|
|
1017
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
1018
|
+
const [sortField, setSortField] = useState<SortField>('cost');
|
|
1019
|
+
const [showInsights, setShowInsights] = useState(termHeight >= 35);
|
|
1020
|
+
const [drawerProject, setDrawerProject] = useState<ProjectStats | null>(null);
|
|
1021
|
+
const [filterQuery, setFilterQuery] = useState('');
|
|
1022
|
+
const [isFiltering, setIsFiltering] = useState(false);
|
|
1023
|
+
|
|
1024
|
+
// Aggregate projects
|
|
1025
|
+
const projectStats = useMemo(
|
|
1026
|
+
() => aggregateProjects(sessions, getWindowStart()),
|
|
1027
|
+
[sessions, getWindowStart],
|
|
1028
|
+
);
|
|
1029
|
+
|
|
1030
|
+
const totalCost: number = useMemo(
|
|
1031
|
+
() => projectStats.reduce((s: number, p: ProjectStats) => s + p.cost, 0),
|
|
1032
|
+
[projectStats],
|
|
1033
|
+
);
|
|
1034
|
+
|
|
1035
|
+
const totalSessions = useMemo(
|
|
1036
|
+
() => projectStats.reduce((s: number, p: ProjectStats) => s + p.sessionCount, 0),
|
|
1037
|
+
[projectStats],
|
|
1038
|
+
);
|
|
1039
|
+
|
|
1040
|
+
const filteredProjects = useMemo(() => {
|
|
1041
|
+
if (!filterQuery) return projectStats;
|
|
1042
|
+
const q = filterQuery.toLowerCase();
|
|
1043
|
+
return projectStats.filter(
|
|
1044
|
+
(p: ProjectStats) =>
|
|
1045
|
+
p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
|
|
1046
|
+
);
|
|
1047
|
+
}, [projectStats, filterQuery]);
|
|
1048
|
+
|
|
1049
|
+
const visibleCost = useMemo(
|
|
1050
|
+
() => filterQuery
|
|
1051
|
+
? filteredProjects.reduce((s: number, p: ProjectStats) => s + p.cost, 0)
|
|
1052
|
+
: totalCost,
|
|
1053
|
+
[filterQuery, filteredProjects, totalCost],
|
|
1054
|
+
);
|
|
1055
|
+
|
|
1056
|
+
const visibleSessions = useMemo(
|
|
1057
|
+
() => filterQuery
|
|
1058
|
+
? filteredProjects.reduce((s: number, p: ProjectStats) => s + p.sessionCount, 0)
|
|
1059
|
+
: totalSessions,
|
|
1060
|
+
[filterQuery, filteredProjects, totalSessions],
|
|
1061
|
+
);
|
|
1062
|
+
|
|
1063
|
+
// Sort
|
|
1064
|
+
const sortedProjects = useMemo(
|
|
1065
|
+
() => sortProjects(filteredProjects, sortField, totalCost),
|
|
1066
|
+
[filteredProjects, sortField, totalCost],
|
|
1067
|
+
);
|
|
1068
|
+
|
|
1069
|
+
// Visible columns
|
|
1070
|
+
const visibleColumns: ColumnSpec[] = useMemo(() => getVisibleColumns(termWidth), [termWidth]);
|
|
1071
|
+
|
|
1072
|
+
// Clamp selection
|
|
1073
|
+
useEffect(() => {
|
|
1074
|
+
if (sortedProjects.length === 0) {
|
|
1075
|
+
if (selectedIndex !== 0) setSelectedIndex(0);
|
|
1076
|
+
} else if (selectedIndex >= sortedProjects.length) {
|
|
1077
|
+
setSelectedIndex(sortedProjects.length - 1);
|
|
1078
|
+
}
|
|
1079
|
+
}, [sortedProjects.length, selectedIndex]);
|
|
1080
|
+
|
|
1081
|
+
// Input focus sync
|
|
1082
|
+
useEffect(() => {
|
|
1083
|
+
setInputFocused(isFiltering);
|
|
1084
|
+
return () => setInputFocused(false);
|
|
1085
|
+
}, [isFiltering, setInputFocused]);
|
|
1086
|
+
|
|
1087
|
+
// Keyboard handler
|
|
1088
|
+
useKeyboard(
|
|
1089
|
+
useCallback(
|
|
1090
|
+
(key: { name: string; shift?: boolean; ctrl?: boolean }) => {
|
|
1091
|
+
// Drawer open: only Esc closes
|
|
1092
|
+
if (drawerProject) {
|
|
1093
|
+
if (key.name === 'escape') {
|
|
1094
|
+
setDrawerProject(null);
|
|
1095
|
+
}
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// Filter mode
|
|
1100
|
+
if (isFiltering) {
|
|
1101
|
+
if (key.name === 'escape') {
|
|
1102
|
+
setIsFiltering(false);
|
|
1103
|
+
setFilterQuery('');
|
|
1104
|
+
} else if (key.name === 'enter' || key.name === 'return') {
|
|
1105
|
+
setIsFiltering(false);
|
|
1106
|
+
}
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// Navigation
|
|
1111
|
+
if (key.name === 'down' || key.name === 'j') {
|
|
1112
|
+
setSelectedIndex((prev: number) => Math.min(prev + 1, sortedProjects.length - 1));
|
|
1113
|
+
} else if (key.name === 'up' || key.name === 'k') {
|
|
1114
|
+
setSelectedIndex((prev: number) => Math.max(prev - 1, 0));
|
|
1115
|
+
}
|
|
1116
|
+
// Sort cycling
|
|
1117
|
+
else if (key.name === 's') {
|
|
1118
|
+
setSortField((prev: SortField) => {
|
|
1119
|
+
const idx = SORT_FIELDS.indexOf(prev);
|
|
1120
|
+
return SORT_FIELDS[(idx + 1) % SORT_FIELDS.length]!;
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
// Time window
|
|
1124
|
+
else if (key.name === 't') {
|
|
1125
|
+
cycleWindow();
|
|
1126
|
+
}
|
|
1127
|
+
// Toggle insights panel
|
|
1128
|
+
else if (key.name === 'v') {
|
|
1129
|
+
setShowInsights((prev: boolean) => !prev);
|
|
1130
|
+
}
|
|
1131
|
+
// Open detail drawer
|
|
1132
|
+
else if (key.name === 'enter' || key.name === 'return') {
|
|
1133
|
+
const project = sortedProjects[selectedIndex];
|
|
1134
|
+
if (project) {
|
|
1135
|
+
setDrawerProject(project);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
// Filter
|
|
1139
|
+
else if (key.name === '/' || key.name === 'f') {
|
|
1140
|
+
setIsFiltering(true);
|
|
1141
|
+
}
|
|
1142
|
+
// Clear filter
|
|
1143
|
+
else if (key.name === 'escape') {
|
|
1144
|
+
if (filterQuery) {
|
|
1145
|
+
setFilterQuery('');
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
// Jump to top/bottom
|
|
1149
|
+
else if (key.name === 'g') {
|
|
1150
|
+
setSelectedIndex(0);
|
|
1151
|
+
}
|
|
1152
|
+
else if (key.name === 'G' || (key.name === 'g' && key.shift)) {
|
|
1153
|
+
setSelectedIndex(Math.max(0, sortedProjects.length - 1));
|
|
1154
|
+
}
|
|
1155
|
+
},
|
|
1156
|
+
[drawerProject, isFiltering, sortedProjects, selectedIndex, cycleWindow, filterQuery],
|
|
1157
|
+
),
|
|
1158
|
+
);
|
|
1159
|
+
|
|
1160
|
+
// Loading state
|
|
1161
|
+
if (isLoading && projectStats.length === 0) {
|
|
1162
|
+
return (
|
|
1163
|
+
<box flexGrow={1} justifyContent="center" alignItems="center">
|
|
1164
|
+
<text fg={colors.textMuted}>Loading sessions...</text>
|
|
1165
|
+
</box>
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// Empty state
|
|
1170
|
+
if (sortedProjects.length === 0 && !isLoading) {
|
|
1171
|
+
return (
|
|
1172
|
+
<box flexDirection="column" flexGrow={1} padding={1}>
|
|
1173
|
+
<EmptyState hasFilter={filterQuery.length > 0} />
|
|
1174
|
+
<box flexDirection="row" height={1} paddingLeft={1}>
|
|
1175
|
+
<text fg={colors.textSubtle}>
|
|
1176
|
+
{filterQuery ? 'Esc clear filter / edit filter t time window' : 't time window / filter'}
|
|
1177
|
+
</text>
|
|
1178
|
+
</box>
|
|
1179
|
+
</box>
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
return (
|
|
1184
|
+
<box flexDirection="column" flexGrow={1} padding={1} overflow="hidden">
|
|
1185
|
+
{/* Drawer overlay */}
|
|
1186
|
+
{drawerProject && (
|
|
1187
|
+
<ProjectDetailDrawer
|
|
1188
|
+
project={drawerProject}
|
|
1189
|
+
totalCost={totalCost}
|
|
1190
|
+
onClose={() => setDrawerProject(null)}
|
|
1191
|
+
/>
|
|
1192
|
+
)}
|
|
1193
|
+
|
|
1194
|
+
{/* Aggregate header */}
|
|
1195
|
+
<AggregateHeader
|
|
1196
|
+
totalCost={visibleCost}
|
|
1197
|
+
globalCost={filterQuery ? totalCost : undefined}
|
|
1198
|
+
projectCount={filteredProjects.length}
|
|
1199
|
+
sessionCount={visibleSessions}
|
|
1200
|
+
windowLabel={windowLabel}
|
|
1201
|
+
sortField={sortField}
|
|
1202
|
+
/>
|
|
1203
|
+
|
|
1204
|
+
{/* Column headers */}
|
|
1205
|
+
<box flexDirection="row" height={1} marginTop={0}>
|
|
1206
|
+
<text width={2} height={1} fg={colors.textMuted}>{' '}</text>
|
|
1207
|
+
{visibleColumns.map((col: ColumnSpec) => (
|
|
1208
|
+
<box key={col.key} marginRight={1}>
|
|
1209
|
+
<text width={col.width} height={1} fg={colors.textMuted}>
|
|
1210
|
+
{padRight(col.label, col.width)}
|
|
1211
|
+
</text>
|
|
1212
|
+
</box>
|
|
1213
|
+
))}
|
|
1214
|
+
</box>
|
|
1215
|
+
|
|
1216
|
+
{/* Filter bar (when active) */}
|
|
1217
|
+
{isFiltering && (
|
|
1218
|
+
<box flexDirection="row" height={1} paddingX={1}>
|
|
1219
|
+
<text fg={colors.primary}>Filter: </text>
|
|
1220
|
+
<input
|
|
1221
|
+
value={filterQuery}
|
|
1222
|
+
onInput={(value: string) => setFilterQuery(value)}
|
|
1223
|
+
focused={isFiltering}
|
|
1224
|
+
width={30}
|
|
1225
|
+
backgroundColor={colors.background}
|
|
1226
|
+
textColor={colors.text}
|
|
1227
|
+
cursorColor={colors.primary}
|
|
1228
|
+
/>
|
|
1229
|
+
<text fg={colors.textSubtle}> (esc clear enter apply)</text>
|
|
1230
|
+
</box>
|
|
1231
|
+
)}
|
|
1232
|
+
|
|
1233
|
+
{/* Table body */}
|
|
1234
|
+
<box
|
|
1235
|
+
flexDirection="column"
|
|
1236
|
+
flexGrow={1}
|
|
1237
|
+
border
|
|
1238
|
+
borderStyle="single"
|
|
1239
|
+
borderColor={colors.border}
|
|
1240
|
+
overflow="hidden"
|
|
1241
|
+
>
|
|
1242
|
+
<scrollbox flexGrow={1}>
|
|
1243
|
+
<box flexDirection="column">
|
|
1244
|
+
{sortedProjects.map((project: ProjectStats, index: number) => (
|
|
1245
|
+
<ProjectRow
|
|
1246
|
+
key={project.path}
|
|
1247
|
+
project={project}
|
|
1248
|
+
isSelected={index === selectedIndex}
|
|
1249
|
+
totalCost={totalCost}
|
|
1250
|
+
visibleColumns={visibleColumns}
|
|
1251
|
+
/>
|
|
1252
|
+
))}
|
|
1253
|
+
</box>
|
|
1254
|
+
</scrollbox>
|
|
1255
|
+
|
|
1256
|
+
{/* Summary footer */}
|
|
1257
|
+
<SummaryFooter projects={sortedProjects} visibleColumns={visibleColumns} />
|
|
1258
|
+
</box>
|
|
1259
|
+
|
|
1260
|
+
{/* Cross-Project Insights Panel */}
|
|
1261
|
+
{showInsights && termHeight >= 26 && (
|
|
1262
|
+
<InsightsPanel projects={filteredProjects} termWidth={termWidth} />
|
|
1263
|
+
)}
|
|
1264
|
+
|
|
1265
|
+
{/* Footer hints */}
|
|
1266
|
+
<box flexDirection="row" height={1} paddingLeft={1} flexShrink={0}>
|
|
1267
|
+
<text fg={colors.textSubtle} height={1}>
|
|
1268
|
+
{isFiltering
|
|
1269
|
+
? 'Type to filter Esc cancel Enter apply'
|
|
1270
|
+
: filterQuery
|
|
1271
|
+
? `Filter: "${filterQuery}" Esc clear / edit`
|
|
1272
|
+
: '\u2191\u2193 navigate Enter details s sort t time v insights / filter'}
|
|
1273
|
+
</text>
|
|
1274
|
+
</box>
|
|
1275
|
+
</box>
|
|
1276
|
+
);
|
|
1277
|
+
}
|