@tokentop/ttop 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +273 -0
  3. package/bin/ttop.js +49 -0
  4. package/package.json +68 -0
  5. package/src/agents/aggregator.ts +176 -0
  6. package/src/agents/costing.ts +74 -0
  7. package/src/agents/index.ts +12 -0
  8. package/src/agents/types.ts +71 -0
  9. package/src/cli.ts +167 -0
  10. package/src/config/schema.test.ts +166 -0
  11. package/src/config/schema.ts +165 -0
  12. package/src/demo/simulator.ts +745 -0
  13. package/src/plugins/agents/index.ts +2 -0
  14. package/src/plugins/auth-sources.ts +186 -0
  15. package/src/plugins/lifecycle.ts +177 -0
  16. package/src/plugins/loader.test.ts +195 -0
  17. package/src/plugins/loader.ts +282 -0
  18. package/src/plugins/notification-bus.ts +153 -0
  19. package/src/plugins/notifications/index.ts +2 -0
  20. package/src/plugins/notifications/terminal-bell.ts +66 -0
  21. package/src/plugins/notifications/visual-flash.ts +57 -0
  22. package/src/plugins/npm-installer.ts +164 -0
  23. package/src/plugins/plugin-context-factory.ts +67 -0
  24. package/src/plugins/plugin-host.ts +205 -0
  25. package/src/plugins/providers/anthropic.ts +325 -0
  26. package/src/plugins/providers/antigravity.ts +309 -0
  27. package/src/plugins/providers/codex.ts +250 -0
  28. package/src/plugins/providers/gemini.ts +391 -0
  29. package/src/plugins/providers/github-copilot.ts +194 -0
  30. package/src/plugins/providers/index.ts +12 -0
  31. package/src/plugins/providers/minimax.ts +223 -0
  32. package/src/plugins/providers/openai-api.ts +250 -0
  33. package/src/plugins/providers/opencode-zen.ts +125 -0
  34. package/src/plugins/providers/perplexity.ts +177 -0
  35. package/src/plugins/providers/zai.ts +225 -0
  36. package/src/plugins/registry.ts +251 -0
  37. package/src/plugins/sandbox-guard.test.ts +191 -0
  38. package/src/plugins/sandbox-guard.ts +155 -0
  39. package/src/plugins/sandbox.ts +138 -0
  40. package/src/plugins/themes/catppuccin-latte.ts +45 -0
  41. package/src/plugins/themes/catppuccin-mocha.ts +45 -0
  42. package/src/plugins/themes/claude-code.ts +45 -0
  43. package/src/plugins/themes/dracula.ts +45 -0
  44. package/src/plugins/themes/github-light.ts +45 -0
  45. package/src/plugins/themes/gruvbox-dark.ts +45 -0
  46. package/src/plugins/themes/gruvbox-light.ts +45 -0
  47. package/src/plugins/themes/index.ts +15 -0
  48. package/src/plugins/themes/kanagawa.ts +45 -0
  49. package/src/plugins/themes/nord.ts +45 -0
  50. package/src/plugins/themes/one-dark.ts +45 -0
  51. package/src/plugins/themes/opencode.ts +47 -0
  52. package/src/plugins/themes/rose-pine-dawn.ts +45 -0
  53. package/src/plugins/themes/rose-pine.ts +45 -0
  54. package/src/plugins/themes/solarized-light.ts +45 -0
  55. package/src/plugins/themes/tokyo-night.ts +49 -0
  56. package/src/plugins/types/agent.ts +108 -0
  57. package/src/plugins/types/base.ts +267 -0
  58. package/src/plugins/types/index.ts +19 -0
  59. package/src/plugins/types/notification.ts +51 -0
  60. package/src/plugins/types/provider.ts +233 -0
  61. package/src/plugins/types/theme.ts +70 -0
  62. package/src/plugins/update-checker.ts +177 -0
  63. package/src/pricing/estimator.test.ts +181 -0
  64. package/src/pricing/estimator.ts +80 -0
  65. package/src/pricing/fallback.test.ts +71 -0
  66. package/src/pricing/fallback.ts +62 -0
  67. package/src/pricing/index.ts +66 -0
  68. package/src/pricing/models-dev.ts +130 -0
  69. package/src/storage/database.ts +613 -0
  70. package/src/storage/db.ts +64 -0
  71. package/src/storage/index.ts +66 -0
  72. package/src/storage/migrations/index.ts +189 -0
  73. package/src/storage/paths.ts +24 -0
  74. package/src/storage/repos/agentSessions.ts +210 -0
  75. package/src/storage/repos/providerSnapshots.ts +116 -0
  76. package/src/storage/repos/usageEvents.ts +334 -0
  77. package/src/storage/types.ts +288 -0
  78. package/src/tui/App.tsx +381 -0
  79. package/src/tui/components/CommandPalette.tsx +166 -0
  80. package/src/tui/components/DebugConsole.tsx +161 -0
  81. package/src/tui/components/DebugPanel.tsx +660 -0
  82. package/src/tui/components/FooterHints.tsx +22 -0
  83. package/src/tui/components/GhostProviderCard.tsx +45 -0
  84. package/src/tui/components/Header.tsx +125 -0
  85. package/src/tui/components/HelpOverlay.tsx +45 -0
  86. package/src/tui/components/InlineGauge.tsx +34 -0
  87. package/src/tui/components/InlineSparkline.tsx +78 -0
  88. package/src/tui/components/KpiStrip.tsx +246 -0
  89. package/src/tui/components/LimitGauge.tsx +186 -0
  90. package/src/tui/components/ModalBackdrop.tsx +42 -0
  91. package/src/tui/components/ProviderAggregateStrip.tsx +200 -0
  92. package/src/tui/components/ProviderCard.tsx +309 -0
  93. package/src/tui/components/ProviderDetailPanel.tsx +237 -0
  94. package/src/tui/components/ProviderLimitsPanel.tsx +308 -0
  95. package/src/tui/components/ProvidersList.tsx +380 -0
  96. package/src/tui/components/SessionDetailsDrawer.tsx +317 -0
  97. package/src/tui/components/SessionsTable.tsx +401 -0
  98. package/src/tui/components/SettingsModal.tsx +587 -0
  99. package/src/tui/components/SidebarBreakdown.tsx +87 -0
  100. package/src/tui/components/Skeleton.tsx +102 -0
  101. package/src/tui/components/SmartSidebar.tsx +644 -0
  102. package/src/tui/components/Sparkline.tsx +180 -0
  103. package/src/tui/components/Spinner.tsx +36 -0
  104. package/src/tui/components/StatusBar.tsx +96 -0
  105. package/src/tui/components/ThemePicker.tsx +234 -0
  106. package/src/tui/components/Toast.tsx +61 -0
  107. package/src/tui/components/UsageGauge.tsx +80 -0
  108. package/src/tui/components/index.ts +5 -0
  109. package/src/tui/contexts/AgentSessionContext.tsx +307 -0
  110. package/src/tui/contexts/ConfigContext.tsx +141 -0
  111. package/src/tui/contexts/DashboardRuntimeContext.tsx +102 -0
  112. package/src/tui/contexts/DemoModeContext.tsx +60 -0
  113. package/src/tui/contexts/DrawerContext.tsx +42 -0
  114. package/src/tui/contexts/InputContext.tsx +30 -0
  115. package/src/tui/contexts/LogContext.tsx +150 -0
  116. package/src/tui/contexts/PluginContext.tsx +424 -0
  117. package/src/tui/contexts/RealTimeActivityContext.tsx +119 -0
  118. package/src/tui/contexts/StorageContext.tsx +233 -0
  119. package/src/tui/contexts/ThemeContext.tsx +95 -0
  120. package/src/tui/contexts/TimeWindowContext.tsx +158 -0
  121. package/src/tui/contexts/ToastContext.tsx +40 -0
  122. package/src/tui/contexts/index.ts +5 -0
  123. package/src/tui/createApp.tsx +47 -0
  124. package/src/tui/debug/captureFrame.ts +262 -0
  125. package/src/tui/debug/snapshot.tsx +644 -0
  126. package/src/tui/driver/assertions.ts +279 -0
  127. package/src/tui/driver/cli.ts +717 -0
  128. package/src/tui/driver/coverage.ts +204 -0
  129. package/src/tui/driver/demo-snapshot.ts +66 -0
  130. package/src/tui/driver/diff.ts +166 -0
  131. package/src/tui/driver/driver.ts +270 -0
  132. package/src/tui/driver/index.ts +53 -0
  133. package/src/tui/driver/recorder.ts +303 -0
  134. package/src/tui/driver/test-workflow.ts +115 -0
  135. package/src/tui/driver/test.ts +47 -0
  136. package/src/tui/hooks/index.ts +20 -0
  137. package/src/tui/hooks/useAnimatedValue.ts +113 -0
  138. package/src/tui/hooks/useDashboardKeyboard.ts +341 -0
  139. package/src/tui/hooks/useDashboardState.ts +116 -0
  140. package/src/tui/hooks/useEmaActivity.ts +223 -0
  141. package/src/tui/hooks/useEntranceAnimation.ts +62 -0
  142. package/src/tui/hooks/useExitAnimation.ts +137 -0
  143. package/src/tui/hooks/usePulse.ts +138 -0
  144. package/src/tui/hooks/useSafeRenderer.ts +24 -0
  145. package/src/tui/hooks/useValueFlash.ts +111 -0
  146. package/src/tui/index.tsx +50 -0
  147. package/src/tui/utils/providerColor.ts +32 -0
  148. package/src/tui/views/Dashboard.tsx +340 -0
  149. package/src/tui/views/HistoricalTrendsView.tsx +1431 -0
  150. package/src/tui/views/ProjectsView.tsx +1277 -0
  151. package/src/tui/views/RealTimeDashboard.tsx +393 -0
  152. package/src/tui/views/SettingsView.tsx +465 -0
  153. package/src/tui/views/index.ts +2 -0
  154. package/src/types/global.d.ts +5 -0
  155. package/src/types/opentui-test-utils.d.ts +18 -0
  156. package/src/utils/clipboard.ts +118 -0
  157. package/src/utils/currency.test.ts +106 -0
  158. package/src/utils/currency.ts +79 -0
  159. package/src/version.ts +1 -0
@@ -0,0 +1,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
+ }