@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,401 @@
1
+ import { forwardRef, memo, useCallback, useRef, type Ref } from 'react';
2
+ import type { ScrollBoxRenderable } from '@opentui/core';
3
+ import { useTerminalDimensions } from '@opentui/react';
4
+ import { useColors } from '../contexts/ThemeContext.tsx';
5
+ import type { AgentSessionAggregate } from '../../agents/types.ts';
6
+ import { useValueFlash, interpolateColor } from '../hooks/useValueFlash.ts';
7
+ import { useAnimatedValue } from '../hooks/useAnimatedValue.ts';
8
+ import { useEntranceAnimation, applyEntranceFade } from '../hooks/useEntranceAnimation.ts';
9
+ import { useExitAnimation } from '../hooks/useExitAnimation.ts';
10
+
11
+ interface SessionsTableProps {
12
+ sessions: AgentSessionAggregate[];
13
+ selectedRow: number;
14
+ isLoading: boolean;
15
+ isFiltering: boolean;
16
+ filterQuery: string;
17
+ focusedPanel: 'sessions' | 'sidebar' | 'limits';
18
+ windowLabel: string;
19
+ getProviderColor: (id: string) => string;
20
+ }
21
+
22
+ function extractRepoName(projectPath: string | null): string {
23
+ if (!projectPath || projectPath === '—') return '—';
24
+ const normalized = projectPath.replace(/\\/g, '/');
25
+ return normalized.split('/').filter(Boolean).pop() ?? projectPath;
26
+ }
27
+
28
+ function formatRelativeTime(timestamp: number): string {
29
+ const now = Date.now();
30
+ const diffMs = now - timestamp;
31
+ const diffSec = Math.floor(diffMs / 1000);
32
+
33
+ if (diffSec < 60) return `${diffSec}s`;
34
+ const diffMin = Math.floor(diffSec / 60);
35
+ if (diffMin < 60) return `${diffMin}m`;
36
+ const diffHour = Math.floor(diffMin / 60);
37
+ if (diffHour < 24) return `${diffHour}h`;
38
+ const diffDay = Math.floor(diffHour / 24);
39
+ return `${diffDay}d`;
40
+ }
41
+
42
+ function formatDuration(startedAt: number, endedAt?: number): string {
43
+ const end = endedAt ?? Date.now();
44
+ const diffMs = end - startedAt;
45
+ const diffSec = Math.floor(diffMs / 1000);
46
+
47
+ if (diffSec < 60) return `${diffSec}s`;
48
+ const diffMin = Math.floor(diffSec / 60);
49
+ if (diffMin < 60) return `${diffMin}m`;
50
+ const diffHour = Math.floor(diffMin / 60);
51
+ if (diffHour < 24) return `${diffHour}h${diffMin % 60}m`;
52
+ const diffDay = Math.floor(diffHour / 24);
53
+ return `${diffDay}d${diffHour % 24}h`;
54
+ }
55
+
56
+ function formatCost(costUsd: number): string {
57
+ if (costUsd === 0) return '$0';
58
+ if (costUsd < 0.01) return '$0.00';
59
+ if (costUsd < 10) return `$${costUsd.toFixed(2)}`;
60
+ if (costUsd < 100) return `$${costUsd.toFixed(1)}`;
61
+ if (costUsd < 1000) return `$${Math.floor(costUsd)}`;
62
+ return `$${(costUsd / 1000).toFixed(1)}k`;
63
+ }
64
+
65
+ function truncateMiddle(str: string, maxLength: number): string {
66
+ if (str.length <= maxLength) return str;
67
+ const side = Math.floor((maxLength - 1) / 2);
68
+ return str.slice(0, side) + '…' + str.slice(-side);
69
+ }
70
+
71
+ function formatCostVal(cost: number): string {
72
+ if (cost === 0) return '$0';
73
+ if (cost < 0.01) return '<$0.01';
74
+ if (cost < 10) return `$${cost.toFixed(2)}`;
75
+ if (cost < 100) return `$${cost.toFixed(1)}`;
76
+ if (cost < 1000) return `$${Math.round(cost)}`;
77
+ return `$${(cost / 1000).toFixed(1)}k`;
78
+ }
79
+
80
+ function formatTokensCompact(val: number): string {
81
+ if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`;
82
+ if (val >= 1_000) return `${(val / 1_000).toFixed(1)}K`;
83
+ return `${val}`;
84
+ }
85
+
86
+ function formatCacheBadge(val: number): string {
87
+ if (val >= 10_000_000) return `${Math.round(val / 1_000_000)}M`;
88
+ if (val >= 1_000_000) return `${(val / 1_000_000).toFixed(1)}M`;
89
+ if (val >= 100_000) return `${Math.round(val / 1_000)}K`;
90
+ if (val >= 1_000) return `${(val / 1_000).toFixed(1)}K`;
91
+ return `${val}`;
92
+ }
93
+
94
+ function getInspectorData(session: AgentSessionAggregate) {
95
+ const cacheRead = session.totals.cacheRead ?? 0;
96
+ const cacheWrite = session.totals.cacheWrite ?? 0;
97
+ if (cacheRead === 0 && cacheWrite === 0) return null;
98
+
99
+ let readCost = 0, writeCost = 0;
100
+ for (const stream of session.streams) {
101
+ if (!stream.costBreakdown) continue;
102
+ readCost += stream.costBreakdown.cacheRead ?? 0;
103
+ writeCost += stream.costBreakdown.cacheWrite ?? 0;
104
+ }
105
+
106
+ const costPerReq = session.requestCount > 0
107
+ ? (session.totalCostUsd ?? 0) / session.requestCount
108
+ : 0;
109
+
110
+ return { cacheRead, cacheWrite, readCost, writeCost, costPerReq };
111
+ }
112
+
113
+ interface SessionRowProps {
114
+ session: AgentSessionAggregate;
115
+ isSelected: boolean;
116
+ isWide: boolean;
117
+ nameMaxWidth: number;
118
+ getProviderColor: (id: string) => string;
119
+ isExiting?: boolean;
120
+ exitIntensity?: number;
121
+ skipEntrance?: boolean;
122
+ terminalWidth: number;
123
+ }
124
+
125
+ function getActivityFadeColor(lastActivityAt: number, baseColor: string, dimColor: string): string {
126
+ const secSinceActivity = (Date.now() - lastActivityAt) / 1000;
127
+ if (secSinceActivity < 5) return baseColor;
128
+ if (secSinceActivity > 60) return dimColor;
129
+ const t = (secSinceActivity - 5) / 55;
130
+ return interpolateColor(t, baseColor, dimColor);
131
+ }
132
+
133
+ const SessionRow = memo(function SessionRow({ session, isSelected, isWide, nameMaxWidth, getProviderColor, isExiting, exitIntensity, skipEntrance, terminalWidth }: SessionRowProps) {
134
+ const colors = useColors();
135
+ const isActive = session.status === 'active';
136
+ const entranceIntensity = useEntranceAnimation({ durationMs: 500 });
137
+ const skipRef = useRef(skipEntrance);
138
+ const currentIntensity = isExiting ? (exitIntensity ?? 0) : (skipRef.current ? 1 : entranceIntensity);
139
+
140
+ const effectiveTokens = session.totals.input + session.totals.output;
141
+ const cacheRead = session.totals.cacheRead ?? 0;
142
+ const hasCacheData = cacheRead > 0;
143
+ const costUsd = session.totalCostUsd ?? 0;
144
+
145
+ const animatedTokens = useAnimatedValue(effectiveTokens, { durationMs: 300, precision: 0 });
146
+ const animatedCost = useAnimatedValue(costUsd, { durationMs: 300, precision: 4 });
147
+
148
+ const { intensity: tokenFlash } = useValueFlash(effectiveTokens, { durationMs: 400, threshold: 10 });
149
+ const { intensity: costFlash } = useValueFlash(costUsd, { durationMs: 400, threshold: 0.001 });
150
+
151
+ const primaryStream = session.streams[0];
152
+ const providerId = primaryStream?.providerId ?? 'unknown';
153
+ const modelId = primaryStream?.modelId ?? 'unknown';
154
+ const baseProviderColor = getProviderColor(providerId);
155
+ const repoName = extractRepoName(session.projectPath ?? '—');
156
+ // In wide mode, project is fixed width (14), so truncate to 13 chars + ellipsis if needed
157
+ // In narrow mode, project is flex, so use nameMaxWidth (which is actually projectMaxWidth in narrow mode context)
158
+ const projectDisplay = isWide
159
+ ? (repoName.length > 13 ? repoName.slice(0, 12) + '…' : repoName.padEnd(13))
160
+ : (repoName.length > nameMaxWidth ? repoName.slice(0, nameMaxWidth - 1) + '…' : repoName);
161
+
162
+ const dimColor = colors.background;
163
+ const fade = (color: string) => applyEntranceFade(currentIntensity, color, dimColor);
164
+
165
+ const baseStatusColor = isActive
166
+ ? getActivityFadeColor(session.lastActivityAt, colors.success, colors.textMuted)
167
+ : colors.textMuted;
168
+ const statusColor = fade(baseStatusColor);
169
+
170
+ const baseTokenColor = colors.text;
171
+ const tokenColorBeforeFade = tokenFlash > 0
172
+ ? interpolateColor(tokenFlash, baseTokenColor, '#ffffff')
173
+ : baseTokenColor;
174
+ const tokenColor = fade(tokenColorBeforeFade);
175
+
176
+ const providerColor = fade(baseProviderColor);
177
+ const textColor = fade(colors.text);
178
+ const textSubtleColor = fade(colors.textSubtle);
179
+ const textMutedColor = fade(colors.textMuted);
180
+ const baseCostColor = colors.warning;
181
+ const costColorBeforeFade = costFlash > 0
182
+ ? interpolateColor(costFlash, baseCostColor, '#ffffff')
183
+ : baseCostColor;
184
+ const costColor = fade(costColorBeforeFade);
185
+
186
+ const formatTokensVal = (val: number): string => {
187
+ if (val >= 1000000) return `${(val / 1000000).toFixed(1)}M`;
188
+ if (val >= 1000) return `${(val / 1000).toFixed(1)}K`;
189
+ return `${val}`;
190
+ };
191
+
192
+ const railChar = isSelected ? '▌' : ' ';
193
+ const railColor = fade(colors.primary);
194
+ const rowBg = isSelected ? colors.borderMuted : undefined;
195
+
196
+ const lastActivity = formatRelativeTime(session.lastActivityAt);
197
+ const duration = formatDuration(session.startedAt, session.endedAt);
198
+ const costDisplay = formatCost(animatedCost);
199
+
200
+ const modelDisplay = modelId.split('/').pop()?.slice(0, 15) ?? modelId;
201
+ const streamCount = session.streams.length;
202
+ const modelWithCount = streamCount > 1 ? `${modelDisplay.slice(0, 12)}+${streamCount - 1}` : modelDisplay;
203
+
204
+ const baseLastColor = isActive
205
+ ? getActivityFadeColor(session.lastActivityAt, colors.text, colors.textMuted)
206
+ : colors.textMuted;
207
+ const lastColor = fade(baseLastColor);
208
+
209
+ const bgProp = rowBg ? { bg: rowBg } : {};
210
+
211
+ const sessionIdShort = session.sessionId.slice(-7);
212
+ // In wide mode, name is flex, so use nameMaxWidth
213
+ // In narrow mode, name is not shown
214
+ const sessionNameDisplay = session.sessionName ? truncateMiddle(session.sessionName, isWide ? nameMaxWidth : 25) : '—';
215
+
216
+ if (isWide) {
217
+ return (
218
+ <box flexDirection="row" paddingRight={1} height={1} gap={1} {...(rowBg ? { backgroundColor: rowBg } : {})}>
219
+ <text width={2} height={1} fg={railColor} {...bgProp}>{railChar}</text>
220
+ <text width={8} height={1} fg={textMutedColor} {...bgProp}>{sessionIdShort}</text>
221
+ <text width={12} height={1} fg={isSelected ? textColor : textSubtleColor} {...bgProp}>{session.agentName.padEnd(11)}</text>
222
+ <text width={18} height={1} fg={providerColor} {...bgProp}>{modelWithCount.padEnd(17)}</text>
223
+ <text width={5} height={1} fg={textMutedColor} {...bgProp}>{String(session.requestCount).padStart(4)}</text>
224
+ <text width={14} height={1} {...bgProp}>
225
+ <span fg={tokenColor}>{formatTokensVal(animatedTokens).padStart(7)}</span>
226
+ <span fg={colors.textMuted}>{hasCacheData ? ` ↯${formatCacheBadge(cacheRead)}` : ''}</span>
227
+ </text>
228
+ <text width={8} height={1} fg={costColor} {...bgProp}>{costDisplay.padStart(7)}</text>
229
+ <text width={14} height={1} fg={textSubtleColor} {...bgProp}>{projectDisplay}</text>
230
+ <text flexGrow={1} height={1} fg={textMutedColor} {...bgProp}>{sessionNameDisplay}</text>
231
+ <text width={6} height={1} fg={textMutedColor} {...bgProp}>{duration.padStart(5)}</text>
232
+ <text width={5} height={1} fg={lastColor} {...bgProp}>{lastActivity.padStart(4)}</text>
233
+ <text width={2} height={1} fg={statusColor} {...bgProp}>{isActive ? '●' : '○'}</text>
234
+ </box>
235
+ );
236
+ }
237
+
238
+ const showCacheBadge = terminalWidth >= 105;
239
+ const hasSidebar = terminalWidth >= 85;
240
+ const isTight = !isWide && hasSidebar && terminalWidth < 105;
241
+ const narrowModelW = isTight ? 13 : 16;
242
+ const narrowTokenW = isTight ? 9 : 14;
243
+ const narrowModelMaxChars = narrowModelW - 1;
244
+
245
+ return (
246
+ <box flexDirection="row" paddingRight={1} height={1} {...(rowBg ? { backgroundColor: rowBg } : {})}>
247
+ <text width={2} height={1} fg={railColor} {...bgProp}>{railChar}</text>
248
+ <text width={9} height={1} fg={isSelected ? textColor : textSubtleColor} {...bgProp}>{session.agentName.length > 8 ? session.agentName.slice(0, 8) + '…' : session.agentName.padEnd(8)}</text>
249
+ <text width={narrowModelW} height={1} fg={providerColor} {...bgProp}>{modelWithCount.length > narrowModelMaxChars ? modelWithCount.slice(0, narrowModelMaxChars - 1) + '…' : modelWithCount.padEnd(narrowModelMaxChars)}</text>
250
+ <text width={narrowTokenW} height={1} {...bgProp}>
251
+ <span fg={tokenColor}>{formatTokensVal(animatedTokens).padStart(7)}</span>
252
+ <span fg={colors.textMuted}>{hasCacheData && showCacheBadge ? ` ↯${formatCacheBadge(cacheRead)}` : ''}</span>
253
+ </text>
254
+ <text width={7} height={1} fg={costColor} {...bgProp}>{formatCostVal(costUsd).padStart(6)}</text>
255
+ <text flexGrow={1} height={1} fg={textSubtleColor} {...bgProp}>{projectDisplay}</text>
256
+ <text width={5} height={1} fg={lastColor} {...bgProp}>{lastActivity.padStart(4)}</text>
257
+ <text width={2} height={1} fg={statusColor} {...bgProp}>{isActive ? '●' : '○'}</text>
258
+ </box>
259
+ );
260
+ });
261
+
262
+ const WIDE_THRESHOLD = 140;
263
+
264
+ export const SessionsTable = forwardRef(function SessionsTable(
265
+ {
266
+ sessions,
267
+ selectedRow,
268
+ isLoading,
269
+ isFiltering,
270
+ filterQuery,
271
+ focusedPanel,
272
+ windowLabel,
273
+ getProviderColor,
274
+ }: SessionsTableProps,
275
+ ref: Ref<ScrollBoxRenderable>
276
+ ) {
277
+ const colors = useColors();
278
+ const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
279
+ const isWide = terminalWidth >= WIDE_THRESHOLD;
280
+
281
+ // Compute max name column width dynamically based on terminal width.
282
+ // Sidebar width depends on terminal width (see SmartSidebar.getSidebarWidth).
283
+ // Table inner width = terminalWidth - sidebarWidth - borders(4) - outerMargins(2).
284
+ // Name column = tableInnerWidth - fixedColumnsTotal.
285
+ const sidebarW = terminalWidth >= 160 ? 44 : terminalWidth >= 140 ? 40 : terminalWidth >= 105 ? 36 : terminalWidth >= 95 ? 32 : terminalWidth >= 85 ? 28 : 0;
286
+ const WIDE_FIXED = 106;
287
+ const hasSidebar = sidebarW > 0;
288
+ const isTight = !isWide && hasSidebar && terminalWidth < 105;
289
+ const narrowFixed = isTight ? 49 : 57;
290
+ const tableInner = terminalWidth - sidebarW - 6;
291
+ const nameMaxWidth = isWide ? Math.max(8, tableInner - WIDE_FIXED) : Math.max(1, tableInner - narrowFixed);
292
+
293
+ const getSessionKey = useCallback((s: AgentSessionAggregate) => s.sessionId, []);
294
+ const { items: animatedSessions, isBulkChange } = useExitAnimation(sessions, {
295
+ durationMs: 500,
296
+ getKey: getSessionKey,
297
+ });
298
+
299
+ let activeIndex = 0;
300
+ const selectedSession = sessions[selectedRow] ?? null;
301
+ const inspector = selectedSession ? getInspectorData(selectedSession) : null;
302
+
303
+ return (
304
+ <box
305
+ flexDirection="column"
306
+ flexGrow={2}
307
+ border
308
+ borderStyle={focusedPanel === 'sessions' ? 'double' : 'single'}
309
+ borderColor={focusedPanel === 'sessions' ? colors.primary : colors.border}
310
+ overflow="hidden"
311
+ >
312
+ <box flexDirection="row" paddingLeft={2} paddingRight={1} height={1} justifyContent="space-between" overflow="hidden">
313
+ <text height={1} fg={colors.textMuted}>
314
+ SESSIONS{filterQuery ? ` [${isFiltering ? 'Filter: ' : ''}${filterQuery}]` : ''}{isLoading ? ' ⟳' : ' '}
315
+ </text>
316
+ <text height={1} fg={colors.textMuted}>[{windowLabel}] {sessions.length} sessions</text>
317
+ </box>
318
+
319
+ {isWide ? (
320
+ <box flexDirection="row" paddingRight={1} height={1} gap={1}>
321
+ <text width={2} height={1} fg={colors.textMuted}> </text>
322
+ <text width={8} height={1} fg={colors.textMuted}>ID </text>
323
+ <text width={12} height={1} fg={colors.textMuted}>AGENT </text>
324
+ <text width={18} height={1} fg={colors.textMuted}>MODEL </text>
325
+ <text width={5} height={1} fg={colors.textMuted}> REQ </text>
326
+ <text width={14} height={1} fg={colors.textMuted}> TOKENS </text>
327
+ <text width={8} height={1} fg={colors.textMuted}> COST </text>
328
+ <text width={14} height={1} fg={colors.textMuted}>PROJECT </text>
329
+ <text flexGrow={1} height={1} fg={colors.textMuted}>NAME</text>
330
+ <text width={6} height={1} fg={colors.textMuted}> DUR </text>
331
+ <text width={5} height={1} fg={colors.textMuted}>LAST </text>
332
+ <text width={2} height={1} fg={colors.textMuted}> </text>
333
+ </box>
334
+ ) : (
335
+ <box flexDirection="row" paddingRight={1} height={1}>
336
+ <text width={2} height={1} fg={colors.textMuted}> </text>
337
+ <text width={9} height={1} fg={colors.textMuted}>AGENT </text>
338
+ <text width={isTight ? 13 : 16} height={1} fg={colors.textMuted}>{isTight ? 'MODEL ' : 'MODEL '}</text>
339
+ <text width={isTight ? 9 : 14} height={1} fg={colors.textMuted}>{isTight ? ' TOKENS ' : ' TOKENS '}</text>
340
+ <text width={7} height={1} fg={colors.textMuted}> COST </text>
341
+ <text flexGrow={1} height={1} fg={colors.textMuted}>{nameMaxWidth >= 7 ? 'PROJECT' : nameMaxWidth >= 4 ? 'PROJ' : ''}</text>
342
+ <text width={5} height={1} fg={colors.textMuted}>LAST </text>
343
+ <text width={2} height={1} fg={colors.textMuted}> </text>
344
+ </box>
345
+ )}
346
+
347
+ <scrollbox ref={ref} flexGrow={1}>
348
+ <box flexDirection="column">
349
+ {sessions.length === 0 && animatedSessions.length === 0 && (
350
+ <box paddingLeft={2}>
351
+ <text fg={colors.textMuted}>{isLoading ? 'Loading sessions...' : 'No sessions found'}</text>
352
+ </box>
353
+ )}
354
+ {animatedSessions.map((entry) => {
355
+ const isSelectedRow = !entry.isExiting && activeIndex++ === selectedRow;
356
+ return (
357
+ <SessionRow
358
+ key={entry.item.sessionId}
359
+ session={entry.item}
360
+ isSelected={isSelectedRow}
361
+ isWide={isWide}
362
+ nameMaxWidth={nameMaxWidth}
363
+ getProviderColor={getProviderColor}
364
+ isExiting={entry.isExiting}
365
+ exitIntensity={entry.exitIntensity}
366
+ skipEntrance={isBulkChange}
367
+ terminalWidth={terminalWidth}
368
+ />
369
+ );
370
+ })}
371
+ </box>
372
+ </scrollbox>
373
+ {terminalHeight >= 30 && (
374
+ <box height={1} paddingX={2} flexShrink={0}>
375
+ {inspector && focusedPanel === 'sessions' ? (
376
+ <text height={1} overflow="hidden">
377
+ <span fg={colors.textMuted}>↯ Cache read </span>
378
+ <span fg={colors.text}>{formatTokensCompact(inspector.cacheRead)}</span>
379
+ <span fg={colors.textMuted}> (</span>
380
+ <span fg={colors.warning}>{formatCostVal(inspector.readCost)}</span>
381
+ <span fg={colors.textMuted}>)</span>
382
+ {inspector.cacheWrite > 0 && (
383
+ <>
384
+ <span fg={colors.textMuted}> • Write </span>
385
+ <span fg={colors.text}>{formatTokensCompact(inspector.cacheWrite)}</span>
386
+ <span fg={colors.textMuted}> (</span>
387
+ <span fg={colors.warning}>{formatCostVal(inspector.writeCost)}</span>
388
+ <span fg={colors.textMuted}>)</span>
389
+ </>
390
+ )}
391
+ <span fg={colors.textMuted}> • $/req </span>
392
+ <span fg={colors.warning}>{formatCostVal(inspector.costPerReq)}</span>
393
+ </text>
394
+ ) : (
395
+ <text height={1}> </text>
396
+ )}
397
+ </box>
398
+ )}
399
+ </box>
400
+ );
401
+ });