@tokentop/ttop 0.2.2 → 0.3.0

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 (158) hide show
  1. package/README.md +2 -5
  2. package/package.json +8 -5
  3. package/src/agents/aggregator.ts +39 -19
  4. package/src/agents/costing.ts +11 -9
  5. package/src/agents/index.ts +7 -9
  6. package/src/agents/types.ts +2 -2
  7. package/src/cli.ts +28 -23
  8. package/src/config/schema.test.ts +42 -40
  9. package/src/config/schema.ts +26 -26
  10. package/src/demo/simulator.ts +161 -122
  11. package/src/plugins/agents/index.ts +2 -2
  12. package/src/plugins/auth-sources.ts +42 -38
  13. package/src/plugins/lifecycle.ts +32 -32
  14. package/src/plugins/loader.test.ts +49 -49
  15. package/src/plugins/loader.ts +67 -63
  16. package/src/plugins/notification-bus.ts +21 -16
  17. package/src/plugins/notifications/index.ts +2 -2
  18. package/src/plugins/notifications/terminal-bell.ts +29 -23
  19. package/src/plugins/notifications/visual-flash.ts +21 -19
  20. package/src/plugins/npm-installer.ts +23 -25
  21. package/src/plugins/plugin-context-factory.ts +10 -10
  22. package/src/plugins/plugin-host.ts +12 -5
  23. package/src/plugins/providers/anthropic.ts +64 -57
  24. package/src/plugins/providers/antigravity.ts +81 -69
  25. package/src/plugins/providers/codex.ts +42 -42
  26. package/src/plugins/providers/gemini.ts +106 -87
  27. package/src/plugins/providers/github-copilot.ts +49 -45
  28. package/src/plugins/providers/index.ts +10 -12
  29. package/src/plugins/providers/minimax.ts +50 -44
  30. package/src/plugins/providers/openai-api.ts +82 -62
  31. package/src/plugins/providers/opencode-zen.ts +46 -39
  32. package/src/plugins/providers/perplexity.ts +55 -46
  33. package/src/plugins/providers/zai.ts +66 -57
  34. package/src/plugins/registry.ts +47 -43
  35. package/src/plugins/sandbox-guard.test.ts +45 -48
  36. package/src/plugins/sandbox-guard.ts +20 -25
  37. package/src/plugins/sandbox.ts +26 -48
  38. package/src/plugins/themes/catppuccin-latte.ts +29 -29
  39. package/src/plugins/themes/catppuccin-mocha.ts +29 -29
  40. package/src/plugins/themes/claude-code.ts +29 -29
  41. package/src/plugins/themes/dracula.ts +29 -29
  42. package/src/plugins/themes/github-light.ts +29 -29
  43. package/src/plugins/themes/gruvbox-dark.ts +29 -29
  44. package/src/plugins/themes/gruvbox-light.ts +29 -29
  45. package/src/plugins/themes/index.ts +15 -15
  46. package/src/plugins/themes/kanagawa.ts +29 -29
  47. package/src/plugins/themes/nord.ts +29 -29
  48. package/src/plugins/themes/one-dark.ts +29 -29
  49. package/src/plugins/themes/opencode.ts +31 -31
  50. package/src/plugins/themes/rose-pine-dawn.ts +29 -29
  51. package/src/plugins/themes/rose-pine.ts +29 -29
  52. package/src/plugins/themes/solarized-light.ts +29 -29
  53. package/src/plugins/themes/tokyo-night.ts +29 -29
  54. package/src/plugins/types/agent.ts +5 -5
  55. package/src/plugins/types/base.ts +35 -24
  56. package/src/plugins/types/index.ts +9 -9
  57. package/src/plugins/types/notification.ts +12 -12
  58. package/src/plugins/types/provider.ts +12 -8
  59. package/src/plugins/types/theme.ts +31 -23
  60. package/src/plugins/update-checker.ts +6 -6
  61. package/src/pricing/estimator.test.ts +39 -44
  62. package/src/pricing/estimator.ts +7 -13
  63. package/src/pricing/fallback.test.ts +30 -34
  64. package/src/pricing/fallback.ts +54 -27
  65. package/src/pricing/index.ts +18 -12
  66. package/src/pricing/models-dev.ts +6 -6
  67. package/src/storage/database.ts +56 -46
  68. package/src/storage/db.ts +12 -12
  69. package/src/storage/index.ts +44 -45
  70. package/src/storage/migrations/index.ts +2 -2
  71. package/src/storage/paths.ts +13 -13
  72. package/src/storage/repos/agentSessions.ts +33 -23
  73. package/src/storage/repos/providerSnapshots.ts +5 -5
  74. package/src/storage/repos/usageEvents.ts +40 -28
  75. package/src/storage/types.ts +5 -5
  76. package/src/tui/App.tsx +180 -137
  77. package/src/tui/components/CommandPalette.tsx +46 -34
  78. package/src/tui/components/DebugConsole.tsx +39 -33
  79. package/src/tui/components/DebugPanel.tsx +295 -177
  80. package/src/tui/components/FooterHints.tsx +2 -2
  81. package/src/tui/components/GhostProviderCard.tsx +37 -35
  82. package/src/tui/components/Header.tsx +68 -35
  83. package/src/tui/components/HelpOverlay.tsx +99 -29
  84. package/src/tui/components/InlineGauge.tsx +10 -7
  85. package/src/tui/components/InlineSparkline.tsx +17 -11
  86. package/src/tui/components/KpiStrip.tsx +128 -98
  87. package/src/tui/components/LimitGauge.tsx +96 -59
  88. package/src/tui/components/ModalBackdrop.tsx +4 -4
  89. package/src/tui/components/ProviderAggregateStrip.tsx +69 -38
  90. package/src/tui/components/ProviderCard.tsx +232 -226
  91. package/src/tui/components/ProviderDetailPanel.tsx +133 -58
  92. package/src/tui/components/ProviderLimitsPanel.tsx +126 -89
  93. package/src/tui/components/ProvidersList.tsx +215 -126
  94. package/src/tui/components/SessionDetailsDrawer.tsx +191 -94
  95. package/src/tui/components/SessionsTable.tsx +313 -166
  96. package/src/tui/components/SettingsModal.tsx +231 -214
  97. package/src/tui/components/SidebarBreakdown.tsx +30 -16
  98. package/src/tui/components/Skeleton.tsx +24 -17
  99. package/src/tui/components/SmartSidebar.tsx +360 -258
  100. package/src/tui/components/Sparkline.tsx +64 -52
  101. package/src/tui/components/Spinner.tsx +3 -3
  102. package/src/tui/components/StatusBar.tsx +27 -27
  103. package/src/tui/components/ThemePicker.tsx +67 -66
  104. package/src/tui/components/Toast.tsx +9 -6
  105. package/src/tui/components/UsageGauge.tsx +10 -13
  106. package/src/tui/components/index.ts +5 -5
  107. package/src/tui/contexts/AgentSessionContext.tsx +193 -144
  108. package/src/tui/contexts/ConfigContext.tsx +15 -13
  109. package/src/tui/contexts/DashboardRuntimeContext.tsx +26 -26
  110. package/src/tui/contexts/DemoModeContext.tsx +22 -15
  111. package/src/tui/contexts/DrawerContext.tsx +11 -9
  112. package/src/tui/contexts/InputContext.tsx +2 -2
  113. package/src/tui/contexts/LogContext.tsx +62 -47
  114. package/src/tui/contexts/PluginContext.tsx +232 -183
  115. package/src/tui/contexts/RealTimeActivityContext.tsx +70 -36
  116. package/src/tui/contexts/StorageContext.tsx +151 -117
  117. package/src/tui/contexts/ThemeContext.tsx +21 -16
  118. package/src/tui/contexts/TimeWindowContext.tsx +72 -61
  119. package/src/tui/contexts/ToastContext.tsx +10 -7
  120. package/src/tui/contexts/index.ts +9 -5
  121. package/src/tui/createApp.tsx +8 -12
  122. package/src/tui/debug/captureFrame.ts +25 -28
  123. package/src/tui/debug/snapshot.tsx +179 -162
  124. package/src/tui/driver/assertions.ts +30 -22
  125. package/src/tui/driver/cli.ts +231 -221
  126. package/src/tui/driver/coverage.ts +27 -44
  127. package/src/tui/driver/demo-snapshot.ts +33 -33
  128. package/src/tui/driver/diff.ts +26 -32
  129. package/src/tui/driver/driver.ts +43 -48
  130. package/src/tui/driver/index.ts +36 -40
  131. package/src/tui/driver/recorder.ts +37 -48
  132. package/src/tui/driver/test-workflow.ts +51 -51
  133. package/src/tui/driver/test.ts +25 -25
  134. package/src/tui/hooks/index.ts +24 -20
  135. package/src/tui/hooks/useAnimatedValue.ts +13 -18
  136. package/src/tui/hooks/useDashboardKeyboard.ts +118 -98
  137. package/src/tui/hooks/useDashboardState.ts +26 -24
  138. package/src/tui/hooks/useEmaActivity.ts +82 -74
  139. package/src/tui/hooks/useEntranceAnimation.ts +11 -11
  140. package/src/tui/hooks/useExitAnimation.ts +3 -3
  141. package/src/tui/hooks/usePulse.ts +4 -4
  142. package/src/tui/hooks/useSafeRenderer.ts +5 -5
  143. package/src/tui/hooks/useSessionEnrichedProviders.ts +150 -0
  144. package/src/tui/hooks/useValueFlash.ts +10 -14
  145. package/src/tui/index.tsx +29 -6
  146. package/src/tui/shutdown.ts +19 -0
  147. package/src/tui/utils/providerColor.ts +1 -1
  148. package/src/tui/views/Dashboard.tsx +127 -102
  149. package/src/tui/views/HistoricalTrendsView.tsx +579 -250
  150. package/src/tui/views/ProjectsView.tsx +466 -237
  151. package/src/tui/views/RealTimeDashboard.tsx +150 -111
  152. package/src/tui/views/SettingsView.tsx +222 -191
  153. package/src/tui/views/index.ts +2 -2
  154. package/src/types/opentui-test-utils.d.ts +7 -7
  155. package/src/utils/clipboard.ts +29 -29
  156. package/src/utils/currency.test.ts +53 -53
  157. package/src/utils/currency.ts +35 -32
  158. package/src/version.ts +1 -1
@@ -1,17 +1,19 @@
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';
1
+ import type { ScrollBoxRenderable } from "@opentui/core";
2
+ import { RGBA } from "@opentui/core";
3
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
4
+ import type React from "react";
5
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
6
+ import type { AgentSessionAggregate } from "../../agents/types.ts";
7
+ import { useAgentSessions } from "../contexts/AgentSessionContext.tsx";
8
+ import { useInputFocus } from "../contexts/InputContext.tsx";
9
+ import { useColors } from "../contexts/ThemeContext.tsx";
10
+ import { useTimeWindow } from "../contexts/TimeWindowContext.tsx";
9
11
 
10
12
  // ---------------------------------------------------------------------------
11
13
  // Types
12
14
  // ---------------------------------------------------------------------------
13
15
 
14
- type SortField = 'cost' | 'tokens' | 'sessions' | 'cache' | 'cpr' | 'activity';
16
+ type SortField = "cost" | "tokens" | "sessions" | "cache" | "cpr" | "activity";
15
17
 
16
18
  interface ModelStats {
17
19
  modelId: string;
@@ -54,17 +56,17 @@ const OVERLAY_BG = RGBA.fromValues(0.0, 0.0, 0.0, 0.5);
54
56
 
55
57
  function padRight(str: string, len: number): string {
56
58
  if (str.length >= len) return str.slice(0, len);
57
- return str + ' '.repeat(len - str.length);
59
+ return str + " ".repeat(len - str.length);
58
60
  }
59
61
 
60
62
  function padLeft(str: string, len: number): string {
61
63
  if (str.length >= len) return str.slice(0, len);
62
- return ' '.repeat(len - str.length) + str;
64
+ return " ".repeat(len - str.length) + str;
63
65
  }
64
66
 
65
67
  function formatCost(num: number): string {
66
- if (num === 0) return '$0';
67
- if (num < 0.01) return '<$0.01';
68
+ if (num === 0) return "$0";
69
+ if (num < 0.01) return "<$0.01";
68
70
  if (num < 10) return `$${num.toFixed(2)}`;
69
71
  if (num < 100) return `$${num.toFixed(1)}`;
70
72
  if (num < 1000) return `$${Math.round(num)}`;
@@ -78,7 +80,7 @@ function formatTokensCompact(num: number): string {
78
80
  }
79
81
 
80
82
  function formatPercent(val: number): string {
81
- if (val < 1 && val > 0) return '<1%';
83
+ if (val < 1 && val > 0) return "<1%";
82
84
  return `${Math.round(val)}%`;
83
85
  }
84
86
 
@@ -96,14 +98,23 @@ function formatRelativeTime(timestamp: number): string {
96
98
 
97
99
  function truncate(str: string, maxLen: number): string {
98
100
  if (str.length <= maxLen) return str;
99
- return str.slice(0, maxLen - 1) + '\u2026';
101
+ return str.slice(0, maxLen - 1) + "\u2026";
100
102
  }
101
103
 
102
104
  // Sparkline characters for 5-char mini trend
103
- const SPARK_CHARS = ['\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'];
105
+ const SPARK_CHARS = [
106
+ "\u2581",
107
+ "\u2582",
108
+ "\u2583",
109
+ "\u2584",
110
+ "\u2585",
111
+ "\u2586",
112
+ "\u2587",
113
+ "\u2588",
114
+ ];
104
115
 
105
116
  function miniSparkline(values: number[], width: number): string {
106
- if (values.length === 0) return '\u00B7'.repeat(width);
117
+ if (values.length === 0) return "\u00B7".repeat(width);
107
118
  // Bucket into `width` slots
108
119
  const bucketSize = Math.max(1, Math.ceil(values.length / width));
109
120
  const buckets: number[] = [];
@@ -116,17 +127,17 @@ function miniSparkline(values: number[], width: number): string {
116
127
  const maxVal = Math.max(...buckets, 1);
117
128
  return buckets
118
129
  .map((v) => {
119
- if (v === 0) return '\u00B7';
130
+ if (v === 0) return "\u00B7";
120
131
  const idx = Math.min(Math.floor((v / maxVal) * SPARK_CHARS.length), SPARK_CHARS.length - 1);
121
132
  return SPARK_CHARS[idx]!;
122
133
  })
123
- .join('');
134
+ .join("");
124
135
  }
125
136
 
126
137
  function makeBar(percent: number, width: number): string {
127
138
  const clamped = Math.max(0, Math.min(100, percent));
128
139
  const filled = Math.round((clamped / 100) * width);
129
- return '\u2588'.repeat(filled) + '\u00B7'.repeat(width - filled);
140
+ return "\u2588".repeat(filled) + "\u00B7".repeat(width - filled);
130
141
  }
131
142
 
132
143
  // ---------------------------------------------------------------------------
@@ -139,15 +150,14 @@ function aggregateProjects(
139
150
  ): ProjectStats[] {
140
151
  const statsMap = new Map<string, ProjectStats>();
141
152
 
142
- const filtered = windowStart !== null
143
- ? sessions.filter((s) => s.lastActivityAt >= windowStart)
144
- : sessions;
153
+ const filtered =
154
+ windowStart !== null ? sessions.filter((s) => s.lastActivityAt >= windowStart) : sessions;
145
155
 
146
156
  for (const session of filtered) {
147
- const path = session.projectPath || 'Unknown';
157
+ const path = session.projectPath || "Unknown";
148
158
  const name = session.projectPath
149
- ? session.projectPath.split('/').pop() || session.projectPath
150
- : 'Unknown';
159
+ ? session.projectPath.split("/").pop() || session.projectPath
160
+ : "Unknown";
151
161
 
152
162
  let project = statsMap.get(path);
153
163
  if (!project) {
@@ -172,7 +182,7 @@ function aggregateProjects(
172
182
  }
173
183
 
174
184
  project.sessionCount += 1;
175
- if (session.status === 'active') project.activeSessions += 1;
185
+ if (session.status === "active") project.activeSessions += 1;
176
186
  project.inputTokens += session.totals.input;
177
187
  project.outputTokens += session.totals.output;
178
188
  project.cacheRead += session.totals.cacheRead ?? 0;
@@ -257,37 +267,43 @@ function aggregateProjects(
257
267
  // Sort fields cycle
258
268
  // ---------------------------------------------------------------------------
259
269
 
260
- const SORT_FIELDS: SortField[] = ['cost', 'tokens', 'sessions', 'cache', 'cpr', 'activity'];
270
+ const SORT_FIELDS: SortField[] = ["cost", "tokens", "sessions", "cache", "cpr", "activity"];
261
271
 
262
272
  const SORT_LABELS: Record<SortField, string> = {
263
- cost: 'COST',
264
- tokens: 'TOKENS',
265
- sessions: 'SESSIONS',
266
- cache: 'CACHE',
267
- cpr: '$/REQ',
268
- activity: 'ACTIVITY',
273
+ cost: "COST",
274
+ tokens: "TOKENS",
275
+ sessions: "SESSIONS",
276
+ cache: "CACHE",
277
+ cpr: "$/REQ",
278
+ activity: "ACTIVITY",
269
279
  };
270
280
 
271
- function sortProjects(projects: ProjectStats[], field: SortField, _totalCost?: number): ProjectStats[] {
281
+ function sortProjects(
282
+ projects: ProjectStats[],
283
+ field: SortField,
284
+ _totalCost?: number,
285
+ ): ProjectStats[] {
272
286
  return [...projects].sort((a, b) => {
273
287
  switch (field) {
274
- case 'cost':
288
+ case "cost":
275
289
  return b.cost - a.cost;
276
- case 'tokens':
290
+ case "tokens":
277
291
  return b.tokens - a.tokens;
278
- case 'sessions':
292
+ case "sessions":
279
293
  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;
294
+ case "cache": {
295
+ const cacheA =
296
+ a.inputTokens + a.cacheRead > 0 ? a.cacheRead / (a.inputTokens + a.cacheRead) : 0;
297
+ const cacheB =
298
+ b.inputTokens + b.cacheRead > 0 ? b.cacheRead / (b.inputTokens + b.cacheRead) : 0;
283
299
  return cacheB - cacheA;
284
300
  }
285
- case 'cpr': {
301
+ case "cpr": {
286
302
  const cprA = a.requestCount > 0 ? a.cost / a.requestCount : 0;
287
303
  const cprB = b.requestCount > 0 ? b.cost / b.requestCount : 0;
288
304
  return cprB - cprA;
289
305
  }
290
- case 'activity':
306
+ case "activity":
291
307
  return b.activeSessions - a.activeSessions;
292
308
  default:
293
309
  return 0;
@@ -307,27 +323,27 @@ interface ColumnSpec {
307
323
  }
308
324
 
309
325
  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 },
326
+ { key: "project", label: "PROJECT", width: 20, minTermWidth: 0 },
327
+ { key: "cost", label: "COST", width: 8, minTermWidth: 0 },
328
+ { key: "share", label: "SHARE", width: 6, minTermWidth: 80 },
329
+ { key: "trend", label: "TREND", width: 7, minTermWidth: 90 },
330
+ { key: "sess", label: "SESS", width: 7, minTermWidth: 0 },
331
+ { key: "cache", label: "CACHE", width: 7, minTermWidth: 100 },
332
+ { key: "cpr", label: "$/REQ", width: 8, minTermWidth: 110 },
333
+ { key: "topmodel", label: "TOP MODEL", width: 18, minTermWidth: 120 },
334
+ { key: "activity", label: "ACTIVITY", width: 8, minTermWidth: 70 },
319
335
  ];
320
336
 
321
337
  function getVisibleColumns(termWidth: number): ColumnSpec[] {
322
338
  return COLUMNS.filter((c) => termWidth >= c.minTermWidth).map((c) => {
323
- if (c.key === 'project') {
339
+ if (c.key === "project") {
324
340
  let w = 20;
325
341
  if (termWidth >= 160) w = 40;
326
342
  else if (termWidth >= 120) w = 30;
327
343
  else if (termWidth >= 100) w = 25;
328
344
  return { ...c, width: w };
329
345
  }
330
- if (c.key === 'topmodel' && termWidth >= 160) {
346
+ if (c.key === "topmodel" && termWidth >= 160) {
331
347
  return { ...c, width: 24 };
332
348
  }
333
349
  return c;
@@ -347,7 +363,14 @@ interface AggregateHeaderProps {
347
363
  sortField: SortField;
348
364
  }
349
365
 
350
- function AggregateHeader({ totalCost, globalCost, projectCount, sessionCount, windowLabel, sortField }: AggregateHeaderProps) {
366
+ function AggregateHeader({
367
+ totalCost,
368
+ globalCost,
369
+ projectCount,
370
+ sessionCount,
371
+ windowLabel,
372
+ sortField,
373
+ }: AggregateHeaderProps) {
351
374
  const colors = useColors();
352
375
  const avgPerProject = projectCount > 0 ? totalCost / projectCount : 0;
353
376
  const isFiltered = globalCost !== undefined && globalCost !== totalCost;
@@ -355,14 +378,18 @@ function AggregateHeader({ totalCost, globalCost, projectCount, sessionCount, wi
355
378
  return (
356
379
  <box flexDirection="row" height={1} justifyContent="space-between" paddingX={1}>
357
380
  <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>}
381
+ <span fg={colors.success}>
382
+ <strong>{formatCost(totalCost)}</strong>
383
+ </span>
384
+ {isFiltered ? (
385
+ <span fg={colors.textSubtle}> of {formatCost(globalCost)} </span>
386
+ ) : (
387
+ <span fg={colors.textMuted}> total </span>
388
+ )}
362
389
  <span fg={colors.text}>{projectCount}</span>
363
- <span fg={colors.textMuted}> projects </span>
390
+ <span fg={colors.textMuted}> projects </span>
364
391
  <span fg={colors.text}>{sessionCount}</span>
365
- <span fg={colors.textMuted}> sessions Avg $/proj: </span>
392
+ <span fg={colors.textMuted}> sessions Avg $/proj: </span>
366
393
  <span fg={colors.text}>{formatCost(avgPerProject)}</span>
367
394
  </text>
368
395
  <text height={1} fg={colors.textMuted}>
@@ -384,23 +411,23 @@ function ProjectRow({ project, isSelected, totalCost, visibleColumns }: ProjectR
384
411
  const fg = isSelected ? colors.primary : colors.text;
385
412
  const mutedFg = isSelected ? colors.primary : colors.textMuted;
386
413
  const subtleFg = isSelected ? colors.primary : colors.textSubtle;
387
- const railChar = isSelected ? '\u258C' : ' ';
414
+ const railChar = isSelected ? "\u258C" : " ";
388
415
 
389
416
  const share = totalCost > 0 ? (project.cost / totalCost) * 100 : 0;
390
417
  const cacheTotal = project.inputTokens + project.cacheRead;
391
418
  const cacheRate = cacheTotal > 0 ? (project.cacheRead / cacheTotal) * 100 : 0;
392
419
  const costPerReq = project.requestCount > 0 ? project.cost / project.requestCount : 0;
393
420
  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> = {
421
+ const topModelName = topModel ? (topModel.modelId.split("/").pop() ?? topModel.modelId) : "-";
422
+ const topModelShare =
423
+ topModel && project.cost > 0 ? Math.round((topModel.cost / project.cost) * 100) : 0;
424
+ const activityDots =
425
+ project.activeSessions > 0
426
+ ? "\u25CF".repeat(Math.min(project.activeSessions, 5)) +
427
+ (project.activeSessions > 5 ? `+${project.activeSessions - 5}` : "")
428
+ : "\u25CB";
429
+
430
+ const colRenderers: Record<string, (w: number) => React.ReactNode> = {
404
431
  project: (w) => (
405
432
  <text width={w} height={1} fg={fg}>
406
433
  {padRight(truncate(project.name, w - 1), w)}
@@ -418,7 +445,8 @@ function ProjectRow({ project, isSelected, totalCost, visibleColumns }: ProjectR
418
445
  ),
419
446
  trend: (w) => (
420
447
  <text width={w} height={1} fg={subtleFg}>
421
- {' '}{miniSparkline(project.trend, 5)}{' '}
448
+ {" "}
449
+ {miniSparkline(project.trend, 5)}{" "}
422
450
  </text>
423
451
  ),
424
452
  sess: (w) => (
@@ -427,41 +455,54 @@ function ProjectRow({ project, isSelected, totalCost, visibleColumns }: ProjectR
427
455
  </text>
428
456
  ),
429
457
  cache: (w) => (
430
- <text width={w} height={1} fg={cacheRate >= 50 ? (isSelected ? colors.primary : colors.success) : mutedFg}>
431
- {padLeft(cacheRate > 0 ? formatPercent(cacheRate) : '-', w)}
458
+ <text
459
+ width={w}
460
+ height={1}
461
+ fg={cacheRate >= 50 ? (isSelected ? colors.primary : colors.success) : mutedFg}
462
+ >
463
+ {padLeft(cacheRate > 0 ? formatPercent(cacheRate) : "-", w)}
432
464
  </text>
433
465
  ),
434
466
  cpr: (w) => (
435
467
  <text width={w} height={1} fg={mutedFg}>
436
- {padLeft(costPerReq > 0 ? formatCost(costPerReq) : '-', w)}
468
+ {padLeft(costPerReq > 0 ? formatCost(costPerReq) : "-", w)}
437
469
  </text>
438
470
  ),
439
471
  topmodel: (w) => {
440
472
  const nameW = w - 5;
441
473
  return (
442
474
  <text width={w} height={1} fg={subtleFg}>
443
- {padRight(
444
- topModel
445
- ? `${truncate(topModelName, nameW)} ${topModelShare}%`
446
- : '-',
447
- w,
448
- )}
475
+ {padRight(topModel ? `${truncate(topModelName, nameW)} ${topModelShare}%` : "-", w)}
449
476
  </text>
450
477
  );
451
478
  },
452
479
  activity: (w) => (
453
- <text width={w} height={1} fg={project.activeSessions > 0 ? (isSelected ? colors.primary : colors.success) : mutedFg}>
480
+ <text
481
+ width={w}
482
+ height={1}
483
+ fg={project.activeSessions > 0 ? (isSelected ? colors.primary : colors.success) : mutedFg}
484
+ >
454
485
  {padRight(activityDots, w)}
455
486
  </text>
456
487
  ),
457
488
  };
458
489
 
459
490
  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>
491
+ <box
492
+ flexDirection="row"
493
+ height={1}
494
+ {...(isSelected ? { backgroundColor: colors.borderMuted } : {})}
495
+ >
496
+ <text width={2} height={1} fg={isSelected ? colors.primary : colors.textSubtle}>
497
+ {railChar}{" "}
498
+ </text>
462
499
  {visibleColumns.map((col) => {
463
500
  const renderer = colRenderers[col.key];
464
- return renderer ? <box key={col.key} marginRight={1}>{renderer(col.width)}</box> : null;
501
+ return renderer ? (
502
+ <box key={col.key} marginRight={1}>
503
+ {renderer(col.width)}
504
+ </box>
505
+ ) : null;
465
506
  })}
466
507
  </box>
467
508
  );
@@ -484,10 +525,10 @@ function SummaryFooter({ projects, visibleColumns }: SummaryFooterProps) {
484
525
  const cacheRate = cacheTotal > 0 ? (totalCacheRead / cacheTotal) * 100 : 0;
485
526
  const avgCpr = totalRequests > 0 ? totalCost / totalRequests : 0;
486
527
 
487
- const colRenderers: Record<string, (w: number) => React.ReactElement> = {
528
+ const colRenderers: Record<string, (w: number) => React.ReactNode> = {
488
529
  project: (w) => (
489
530
  <text width={w} height={1} fg={colors.textMuted}>
490
- {padRight('TOTAL', w)}
531
+ {padRight("TOTAL", w)}
491
532
  </text>
492
533
  ),
493
534
  cost: (w) => (
@@ -497,12 +538,12 @@ function SummaryFooter({ projects, visibleColumns }: SummaryFooterProps) {
497
538
  ),
498
539
  share: (w) => (
499
540
  <text width={w} height={1} fg={colors.textMuted}>
500
- {padLeft('100%', w)}
541
+ {padLeft("100%", w)}
501
542
  </text>
502
543
  ),
503
544
  trend: (w) => (
504
545
  <text width={w} height={1} fg={colors.textSubtle}>
505
- {' '.repeat(w)}
546
+ {" ".repeat(w)}
506
547
  </text>
507
548
  ),
508
549
  sess: (w) => (
@@ -522,22 +563,28 @@ function SummaryFooter({ projects, visibleColumns }: SummaryFooterProps) {
522
563
  ),
523
564
  topmodel: (w) => (
524
565
  <text width={w} height={1} fg={colors.textSubtle}>
525
- {' '.repeat(w)}
566
+ {" ".repeat(w)}
526
567
  </text>
527
568
  ),
528
569
  activity: (w) => (
529
570
  <text width={w} height={1} fg={totalActive > 0 ? colors.success : colors.textMuted}>
530
- {padRight(totalActive > 0 ? `${totalActive} live` : 'idle', w)}
571
+ {padRight(totalActive > 0 ? `${totalActive} live` : "idle", w)}
531
572
  </text>
532
573
  ),
533
574
  };
534
575
 
535
576
  return (
536
577
  <box flexDirection="row" height={1}>
537
- <text width={2} height={1} fg={colors.border}>{'\u2500 '}</text>
578
+ <text width={2} height={1} fg={colors.border}>
579
+ {"\u2500 "}
580
+ </text>
538
581
  {visibleColumns.map((col) => {
539
582
  const renderer = colRenderers[col.key];
540
- return renderer ? <box key={col.key} marginRight={1}>{renderer(col.width)}</box> : null;
583
+ return renderer ? (
584
+ <box key={col.key} marginRight={1}>
585
+ {renderer(col.width)}
586
+ </box>
587
+ ) : null;
541
588
  })}
542
589
  </box>
543
590
  );
@@ -560,7 +607,7 @@ function InsightsPanel({ projects, termWidth }: InsightsPanelProps) {
560
607
  const map = new Map<string, { name: string; cost: number }>();
561
608
  for (const p of projects) {
562
609
  for (const m of p.models) {
563
- const name = m.modelId.split('/').pop() ?? m.modelId;
610
+ const name = m.modelId.split("/").pop() ?? m.modelId;
564
611
  const existing = map.get(name);
565
612
  if (existing) {
566
613
  existing.cost += m.cost;
@@ -597,9 +644,9 @@ function InsightsPanel({ projects, termWidth }: InsightsPanelProps) {
597
644
  if (projects.length === 0) return null;
598
645
  const bestCpr = [...projects]
599
646
  .filter((p) => p.requestCount > 0)
600
- .sort((a, b) => (a.cost / a.requestCount) - (b.cost / b.requestCount))[0];
647
+ .sort((a, b) => a.cost / a.requestCount - b.cost / b.requestCount)[0];
601
648
  const bestCache = [...projects]
602
- .filter((p) => (p.inputTokens + p.cacheRead) > 0)
649
+ .filter((p) => p.inputTokens + p.cacheRead > 0)
603
650
  .sort((a, b) => {
604
651
  const rateB = b.cacheRead / (b.inputTokens + b.cacheRead);
605
652
  const rateA = a.cacheRead / (a.inputTokens + a.cacheRead);
@@ -642,15 +689,23 @@ function InsightsPanel({ projects, termWidth }: InsightsPanelProps) {
642
689
  flexShrink={0}
643
690
  >
644
691
  <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>
692
+ <text height={1} fg={colors.textMuted}>
693
+ <strong>CROSS-PROJECT INSIGHTS</strong>
694
+ </text>
695
+ <text flexGrow={1} height={1}>
696
+ {" "}
697
+ </text>
698
+ <text height={1} fg={colors.textSubtle}>
699
+ [v] toggle
700
+ </text>
648
701
  </box>
649
702
 
650
703
  <box flexDirection="row" paddingX={1} gap={2} overflow="hidden">
651
704
  {/* COST BY MODEL */}
652
705
  <box flexDirection="column" flexGrow={1} overflow="hidden">
653
- <text height={1} fg={colors.textMuted}><strong>COST BY MODEL</strong></text>
706
+ <text height={1} fg={colors.textMuted}>
707
+ <strong>COST BY MODEL</strong>
708
+ </text>
654
709
  {modelCosts.map((m: { name: string; cost: number }) => (
655
710
  <box key={m.name} flexDirection="row" height={1}>
656
711
  <text width={nameWidth} height={1} fg={colors.text}>
@@ -665,13 +720,17 @@ function InsightsPanel({ projects, termWidth }: InsightsPanelProps) {
665
720
  </box>
666
721
  ))}
667
722
  {modelCosts.length === 0 && (
668
- <text height={1} fg={colors.textSubtle}>No model data</text>
723
+ <text height={1} fg={colors.textSubtle}>
724
+ No model data
725
+ </text>
669
726
  )}
670
727
  </box>
671
728
 
672
729
  {/* COST BY AGENT */}
673
730
  <box flexDirection="column" flexGrow={1} overflow="hidden">
674
- <text height={1} fg={colors.textMuted}><strong>COST BY AGENT</strong></text>
731
+ <text height={1} fg={colors.textMuted}>
732
+ <strong>COST BY AGENT</strong>
733
+ </text>
675
734
  {agentCosts.map((a: { name: string; cost: number }) => (
676
735
  <box key={a.name} flexDirection="row" height={1}>
677
736
  <text width={nameWidth} height={1} fg={colors.text}>
@@ -686,38 +745,49 @@ function InsightsPanel({ projects, termWidth }: InsightsPanelProps) {
686
745
  </box>
687
746
  ))}
688
747
  {agentCosts.length === 0 && (
689
- <text height={1} fg={colors.textSubtle}>No agent data</text>
748
+ <text height={1} fg={colors.textSubtle}>
749
+ No agent data
750
+ </text>
690
751
  )}
691
752
  </box>
692
753
 
693
754
  {/* EFFICIENCY + BIGGEST MOVER */}
694
755
  {isWide && rankings && (
695
756
  <box flexDirection="column" width={30} overflow="hidden">
696
- <text height={1} fg={colors.textMuted}><strong>RANKINGS</strong></text>
757
+ <text height={1} fg={colors.textMuted}>
758
+ <strong>RANKINGS</strong>
759
+ </text>
697
760
  {rankings.bestCpr && (
698
761
  <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)}
762
+ Best $/req: <span fg={colors.success}>{truncate(rankings.bestCpr.name, 10)}</span>{" "}
763
+ {formatCost(rankings.bestCpr.cost / rankings.bestCpr.requestCount)}
701
764
  </text>
702
765
  )}
703
766
  {rankings.bestCache && (
704
767
  <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)}
768
+ Best cache: <span fg={colors.success}>{truncate(rankings.bestCache.name, 10)}</span>{" "}
769
+ {formatPercent(
770
+ (rankings.bestCache.cacheRead /
771
+ (rankings.bestCache.inputTokens + rankings.bestCache.cacheRead)) *
772
+ 100,
773
+ )}
707
774
  </text>
708
775
  )}
709
776
  {rankings.mostActive && rankings.mostActive.activeSessions > 0 && (
710
777
  <text height={1} fg={colors.textSubtle}>
711
- Most active: <span fg={colors.success}>{truncate(rankings.mostActive.name, 10)}</span>
712
- {' '}{rankings.mostActive.activeSessions} live
778
+ Most active:{" "}
779
+ <span fg={colors.success}>{truncate(rankings.mostActive.name, 10)}</span>{" "}
780
+ {rankings.mostActive.activeSessions} live
713
781
  </text>
714
782
  )}
715
783
  {rankings.biggestMover && (
716
784
  <text height={1} fg={colors.textSubtle}>
717
- Biggest mover: <span fg={rankings.biggestMoverDelta >= 0 ? colors.warning : colors.info}>
785
+ Biggest mover:{" "}
786
+ <span fg={rankings.biggestMoverDelta >= 0 ? colors.warning : colors.info}>
718
787
  {truncate(rankings.biggestMover.name, 8)}
719
- </span>
720
- {' '}{rankings.biggestMoverDelta >= 0 ? '\u25B2' : '\u25BC'}{formatPercent(Math.min(Math.abs(rankings.biggestMoverDelta) * 100, 200))}
788
+ </span>{" "}
789
+ {rankings.biggestMoverDelta >= 0 ? "\u25B2" : "\u25BC"}
790
+ {formatPercent(Math.min(Math.abs(rankings.biggestMoverDelta) * 100, 200))}
721
791
  </text>
722
792
  )}
723
793
  </box>
@@ -749,12 +819,14 @@ function ProjectDetailDrawer({ project, totalCost, onClose: _onClose }: ProjectD
749
819
  const share = totalCost > 0 ? (project.cost / totalCost) * 100 : 0;
750
820
  const cacheTotal = project.inputTokens + project.cacheRead;
751
821
  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;
822
+ const outputShare =
823
+ project.inputTokens + project.outputTokens > 0
824
+ ? (project.outputTokens / (project.inputTokens + project.outputTokens)) * 100
825
+ : 0;
755
826
  const costPerReq = project.requestCount > 0 ? project.cost / project.requestCount : 0;
756
827
  const avgPerSession = project.sessionCount > 0 ? project.cost / project.sessionCount : 0;
757
- const allTokens = project.inputTokens + project.outputTokens + project.cacheRead + project.cacheWrite;
828
+ const allTokens =
829
+ project.inputTokens + project.outputTokens + project.cacheRead + project.cacheWrite;
758
830
 
759
831
  // Token composition percentages
760
832
  const inputPct = allTokens > 0 ? (project.inputTokens / allTokens) * 100 : 0;
@@ -792,10 +864,14 @@ function ProjectDetailDrawer({ project, totalCost, onClose: _onClose }: ProjectD
792
864
  {/* Header */}
793
865
  <box flexDirection="row" justifyContent="space-between" height={1} marginBottom={1}>
794
866
  <text height={1}>
795
- <span fg={colors.primary}><strong>PROJECT: {truncate(project.name, 30)}</strong></span>
867
+ <span fg={colors.primary}>
868
+ <strong>PROJECT: {truncate(project.name, 30)}</strong>
869
+ </span>
796
870
  <span fg={colors.textMuted}> ({formatPercent(share)} of total)</span>
797
871
  </text>
798
- <text height={1} fg={colors.textMuted}>[Esc] Close</text>
872
+ <text height={1} fg={colors.textMuted}>
873
+ [Esc] Close
874
+ </text>
799
875
  </box>
800
876
 
801
877
  {/* Overview stats */}
@@ -811,13 +887,17 @@ function ProjectDetailDrawer({ project, totalCost, onClose: _onClose }: ProjectD
811
887
  <box flexDirection="row" height={1} justifyContent="space-between">
812
888
  <text height={1}>
813
889
  <span fg={colors.textMuted}>Cost: </span>
814
- <span fg={colors.success}><strong>{formatCost(project.cost)}</strong></span>
890
+ <span fg={colors.success}>
891
+ <strong>{formatCost(project.cost)}</strong>
892
+ </span>
815
893
  </text>
816
894
  <text height={1}>
817
895
  <span fg={colors.textMuted}>Sessions: </span>
818
896
  <span fg={colors.text}>{project.sessionCount}</span>
819
897
  <span fg={colors.textMuted}> (</span>
820
- <span fg={project.activeSessions > 0 ? colors.success : colors.textMuted}>{project.activeSessions} active</span>
898
+ <span fg={project.activeSessions > 0 ? colors.success : colors.textMuted}>
899
+ {project.activeSessions} active
900
+ </span>
821
901
  <span fg={colors.textMuted}>)</span>
822
902
  </text>
823
903
  </box>
@@ -825,7 +905,12 @@ function ProjectDetailDrawer({ project, totalCost, onClose: _onClose }: ProjectD
825
905
  <text height={1}>
826
906
  <span fg={colors.textMuted}>Tokens: </span>
827
907
  <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>
908
+ <span fg={colors.textMuted}>
909
+ {" "}
910
+ (In: {formatTokensCompact(project.inputTokens)}
911
+ {project.cacheRead > 0 ? ` + ${formatTokensCompact(project.cacheRead)} cached` : ""}{" "}
912
+ / Out: {formatTokensCompact(project.outputTokens)})
913
+ </span>
829
914
  </text>
830
915
  <text height={1}>
831
916
  <span fg={colors.textMuted}>Requests: </span>
@@ -836,10 +921,24 @@ function ProjectDetailDrawer({ project, totalCost, onClose: _onClose }: ProjectD
836
921
 
837
922
  {/* MODEL BREAKDOWN */}
838
923
  <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>
924
+ <text flexGrow={1} height={1} fg={colors.primary}>
925
+ <strong>MODEL</strong>
926
+ </text>
927
+ <box width={8} justifyContent="flex-end">
928
+ <text fg={colors.primary}>
929
+ <strong>COST</strong>
930
+ </text>
931
+ </box>
932
+ <box width={6} justifyContent="flex-end">
933
+ <text fg={colors.primary}>
934
+ <strong>SHARE</strong>
935
+ </text>
936
+ </box>
937
+ <box width={8} justifyContent="flex-end">
938
+ <text fg={colors.primary}>
939
+ <strong>REQS</strong>
940
+ </text>
941
+ </box>
843
942
  </box>
844
943
  <box
845
944
  flexDirection="column"
@@ -850,46 +949,73 @@ function ProjectDetailDrawer({ project, totalCost, onClose: _onClose }: ProjectD
850
949
  flexShrink={0}
851
950
  >
852
951
  <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>
952
+ {project.models.length > 0 ? (
953
+ project.models.slice(0, 8).map((m, idx) => {
954
+ const modelName = m.modelId.split("/").pop() ?? m.modelId;
955
+ const modelShare = project.cost > 0 ? (m.cost / project.cost) * 100 : 0;
956
+ return (
957
+ <box key={`${m.modelId}-${idx}`} flexDirection="row" height={1} paddingX={1}>
958
+ <text flexGrow={1} height={1} fg={colors.text} overflow="hidden">
959
+ {truncate(modelName, contentWidth - 28)}
960
+ </text>
961
+ <box width={8} height={1} justifyContent="flex-end">
962
+ <text fg={colors.success}>{formatCost(m.cost)}</text>
963
+ </box>
964
+ <box width={6} height={1} justifyContent="flex-end">
965
+ <text fg={colors.textMuted}>{formatPercent(modelShare)}</text>
966
+ </box>
967
+ <box width={8} height={1} justifyContent="flex-end">
968
+ <text fg={colors.textMuted}>{m.requests}</text>
969
+ </box>
869
970
  </box>
870
- </box>
871
- );
872
- }) : (
873
- <text height={1} paddingX={1} fg={colors.textSubtle}>No model data</text>
971
+ );
972
+ })
973
+ ) : (
974
+ <text height={1} paddingX={1} fg={colors.textSubtle}>
975
+ No model data
976
+ </text>
874
977
  )}
875
978
  </scrollbox>
876
979
  </box>
877
980
 
878
981
  {/* SESSIONS */}
879
982
  <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>
983
+ <text flexGrow={1} height={1} fg={colors.primary}>
984
+ <strong>SESSIONS</strong>
985
+ </text>
986
+ <box width={8} justifyContent="flex-end">
987
+ <text fg={colors.primary}>
988
+ <strong>COST</strong>
989
+ </text>
990
+ </box>
991
+ <box width={8} justifyContent="flex-end">
992
+ <text fg={colors.primary}>
993
+ <strong>TOKENS</strong>
994
+ </text>
995
+ </box>
996
+ <box width={6} justifyContent="flex-end">
997
+ <text fg={colors.primary}>
998
+ <strong>AGO</strong>
999
+ </text>
1000
+ </box>
1001
+ <box width={2} justifyContent="flex-end">
1002
+ <text fg={colors.primary}> </text>
1003
+ </box>
885
1004
  </box>
886
- <box flexDirection="column" border borderColor={colors.border} overflow="hidden" flexGrow={1} minHeight={3}>
1005
+ <box
1006
+ flexDirection="column"
1007
+ border
1008
+ borderColor={colors.border}
1009
+ overflow="hidden"
1010
+ flexGrow={1}
1011
+ minHeight={3}
1012
+ >
887
1013
  <scrollbox flexGrow={1}>
888
1014
  {project.sessions
889
1015
  .sort((a, b) => b.lastActivityAt - a.lastActivityAt)
890
1016
  .slice(0, 15)
891
1017
  .map((s) => {
892
- const modelName = s.streams[0]?.modelId.split('/').pop() ?? 'unknown';
1018
+ const modelName = s.streams[0]?.modelId.split("/").pop() ?? "unknown";
893
1019
  return (
894
1020
  <box key={s.sessionId} flexDirection="row" height={1} paddingX={1}>
895
1021
  <text width={10} height={1} fg={colors.textSubtle} overflow="hidden">
@@ -902,76 +1028,130 @@ function ProjectDetailDrawer({ project, totalCost, onClose: _onClose }: ProjectD
902
1028
  <text fg={colors.success}>{formatCost(s.totalCostUsd ?? 0)}</text>
903
1029
  </box>
904
1030
  <box width={8} height={1} justifyContent="flex-end">
905
- <text fg={colors.textMuted}>{formatTokensCompact(s.totals.input + s.totals.output)}</text>
1031
+ <text fg={colors.textMuted}>
1032
+ {formatTokensCompact(s.totals.input + s.totals.output)}
1033
+ </text>
906
1034
  </box>
907
1035
  <box width={6} height={1} justifyContent="flex-end">
908
1036
  <text fg={colors.textMuted}>{formatRelativeTime(s.lastActivityAt)}</text>
909
1037
  </box>
910
1038
  <box width={2} height={1} justifyContent="flex-end">
911
- <text fg={s.status === 'active' ? colors.success : colors.textMuted}>
912
- {s.status === 'active' ? '\u25CF' : '\u25CB'}
1039
+ <text fg={s.status === "active" ? colors.success : colors.textMuted}>
1040
+ {s.status === "active" ? "\u25CF" : "\u25CB"}
913
1041
  </text>
914
1042
  </box>
915
1043
  </box>
916
1044
  );
917
1045
  })}
918
1046
  {project.sessions.length === 0 && (
919
- <text height={1} paddingX={1} fg={colors.textSubtle}>No sessions</text>
1047
+ <text height={1} paddingX={1} fg={colors.textSubtle}>
1048
+ No sessions
1049
+ </text>
920
1050
  )}
921
1051
  </scrollbox>
922
1052
  </box>
923
1053
 
924
1054
  {/* EFFICIENCY + TOKEN COMPOSITION */}
925
1055
  {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>
1056
+ <box flexDirection="row" gap={1} flexShrink={0} marginTop={0}>
1057
+ {/* Efficiency */}
1058
+ <box
1059
+ flexDirection="column"
1060
+ flexGrow={1}
1061
+ border
1062
+ borderColor={colors.border}
1063
+ paddingX={1}
1064
+ overflow="hidden"
1065
+ >
1066
+ <text height={1} fg={colors.primary}>
1067
+ <strong>EFFICIENCY</strong>
1068
+ </text>
1069
+ <text height={1}>
1070
+ <span fg={colors.textMuted}>Cache leverage: </span>
1071
+ <span fg={cacheRate >= 50 ? colors.success : colors.warning}>
1072
+ {formatPercent(cacheRate)}
1073
+ </span>
1074
+ </text>
1075
+ <text height={1}>
1076
+ <span fg={colors.textMuted}>Output share: </span>
1077
+ <span fg={outputShare > 65 ? colors.warning : colors.text}>
1078
+ {formatPercent(outputShare)}
1079
+ </span>
1080
+ </text>
1081
+ <text height={1}>
1082
+ <span fg={colors.textMuted}>$/request: </span>
1083
+ <span fg={colors.text}>{formatCost(costPerReq)}</span>
1084
+ </text>
1085
+ <text height={1}>
1086
+ <span fg={colors.textMuted}>Avg/session: </span>
1087
+ <span fg={colors.text}>{formatCost(avgPerSession)}</span>
1088
+ </text>
965
1089
  </box>
966
- {cacheWritePct > 0 && (
1090
+
1091
+ {/* Token composition */}
1092
+ <box
1093
+ flexDirection="column"
1094
+ flexGrow={1}
1095
+ border
1096
+ borderColor={colors.border}
1097
+ paddingX={1}
1098
+ overflow="hidden"
1099
+ >
1100
+ <text height={1} fg={colors.primary}>
1101
+ <strong>TOKEN COMPOSITION</strong>
1102
+ </text>
967
1103
  <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>
1104
+ <text width={12} height={1} fg={colors.textMuted}>
1105
+ {"Input "}
1106
+ </text>
1107
+ <text width={compBarW} height={1} fg={colors.info}>
1108
+ {makeBar(inputPct, compBarW)}
1109
+ </text>
1110
+ <text height={1} fg={colors.text}>
1111
+ {" "}
1112
+ {formatPercent(inputPct)}
1113
+ </text>
971
1114
  </box>
972
- )}
1115
+ <box flexDirection="row" height={1}>
1116
+ <text width={12} height={1} fg={colors.textMuted}>
1117
+ {"Output "}
1118
+ </text>
1119
+ <text width={compBarW} height={1} fg={colors.warning}>
1120
+ {makeBar(outputPct, compBarW)}
1121
+ </text>
1122
+ <text height={1} fg={colors.text}>
1123
+ {" "}
1124
+ {formatPercent(outputPct)}
1125
+ </text>
1126
+ </box>
1127
+ <box flexDirection="row" height={1}>
1128
+ <text width={12} height={1} fg={colors.textMuted}>
1129
+ {"Cache read"}
1130
+ </text>
1131
+ <text width={compBarW} height={1} fg={colors.success}>
1132
+ {makeBar(cacheReadPct, compBarW)}
1133
+ </text>
1134
+ <text height={1} fg={colors.text}>
1135
+ {" "}
1136
+ {formatPercent(cacheReadPct)}
1137
+ </text>
1138
+ </box>
1139
+ {cacheWritePct > 0 && (
1140
+ <box flexDirection="row" height={1}>
1141
+ <text width={12} height={1} fg={colors.textMuted}>
1142
+ {"Cache writ"}
1143
+ </text>
1144
+ <text width={compBarW} height={1} fg={colors.accent}>
1145
+ {makeBar(cacheWritePct, compBarW)}
1146
+ </text>
1147
+ <text height={1} fg={colors.text}>
1148
+ {" "}
1149
+ {formatPercent(cacheWritePct)}
1150
+ </text>
1151
+ </box>
1152
+ )}
1153
+ </box>
973
1154
  </box>
974
- </box>
975
1155
  )}
976
1156
  </box>
977
1157
  </box>
@@ -1015,11 +1195,13 @@ export function ProjectsView() {
1015
1195
  const { setInputFocused } = useInputFocus();
1016
1196
 
1017
1197
  const [selectedIndex, setSelectedIndex] = useState(0);
1018
- const [sortField, setSortField] = useState<SortField>('cost');
1198
+ const [sortField, setSortField] = useState<SortField>("cost");
1019
1199
  const [showInsights, setShowInsights] = useState(termHeight >= 35);
1020
1200
  const [drawerProject, setDrawerProject] = useState<ProjectStats | null>(null);
1021
- const [filterQuery, setFilterQuery] = useState('');
1201
+ const [filterQuery, setFilterQuery] = useState("");
1022
1202
  const [isFiltering, setIsFiltering] = useState(false);
1203
+ const scrollOffsetRef = useRef(0);
1204
+ const scrollboxRef = useRef<ScrollBoxRenderable>(null);
1023
1205
 
1024
1206
  // Aggregate projects
1025
1207
  const projectStats = useMemo(
@@ -1041,22 +1223,23 @@ export function ProjectsView() {
1041
1223
  if (!filterQuery) return projectStats;
1042
1224
  const q = filterQuery.toLowerCase();
1043
1225
  return projectStats.filter(
1044
- (p: ProjectStats) =>
1045
- p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
1226
+ (p: ProjectStats) => p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
1046
1227
  );
1047
1228
  }, [projectStats, filterQuery]);
1048
1229
 
1049
1230
  const visibleCost = useMemo(
1050
- () => filterQuery
1051
- ? filteredProjects.reduce((s: number, p: ProjectStats) => s + p.cost, 0)
1052
- : totalCost,
1231
+ () =>
1232
+ filterQuery
1233
+ ? filteredProjects.reduce((s: number, p: ProjectStats) => s + p.cost, 0)
1234
+ : totalCost,
1053
1235
  [filterQuery, filteredProjects, totalCost],
1054
1236
  );
1055
1237
 
1056
1238
  const visibleSessions = useMemo(
1057
- () => filterQuery
1058
- ? filteredProjects.reduce((s: number, p: ProjectStats) => s + p.sessionCount, 0)
1059
- : totalSessions,
1239
+ () =>
1240
+ filterQuery
1241
+ ? filteredProjects.reduce((s: number, p: ProjectStats) => s + p.sessionCount, 0)
1242
+ : totalSessions,
1060
1243
  [filterQuery, filteredProjects, totalSessions],
1061
1244
  );
1062
1245
 
@@ -1073,11 +1256,53 @@ export function ProjectsView() {
1073
1256
  useEffect(() => {
1074
1257
  if (sortedProjects.length === 0) {
1075
1258
  if (selectedIndex !== 0) setSelectedIndex(0);
1259
+ scrollOffsetRef.current = 0;
1260
+ scrollboxRef.current?.scrollTo(0);
1076
1261
  } else if (selectedIndex >= sortedProjects.length) {
1077
1262
  setSelectedIndex(sortedProjects.length - 1);
1078
1263
  }
1079
1264
  }, [sortedProjects.length, selectedIndex]);
1080
1265
 
1266
+ // Scroll to keep selected row visible
1267
+ const visibleRows = useMemo(() => {
1268
+ const appChrome = (termHeight >= 35 ? 7 : 1) + 1; // App.tsx: Header (large/small) + StatusBar
1269
+ const outerPadding = 2;
1270
+ const aggregateHeader = 1;
1271
+ const columnHeaders = 1;
1272
+ const tableBorder = 2;
1273
+ const summaryFooter = 1;
1274
+ const footerHints = 1;
1275
+ const filterBar = isFiltering ? 1 : 0;
1276
+ const insightsHeight = showInsights && termHeight >= 26 ? 9 : 0;
1277
+ return Math.max(
1278
+ 1,
1279
+ termHeight -
1280
+ appChrome -
1281
+ outerPadding -
1282
+ aggregateHeader -
1283
+ columnHeaders -
1284
+ tableBorder -
1285
+ summaryFooter -
1286
+ footerHints -
1287
+ filterBar -
1288
+ insightsHeight,
1289
+ );
1290
+ }, [termHeight, isFiltering, showInsights]);
1291
+
1292
+ useLayoutEffect(() => {
1293
+ if (!scrollboxRef.current || sortedProjects.length === 0) return;
1294
+
1295
+ let newOffset = scrollOffsetRef.current;
1296
+ if (selectedIndex < newOffset) {
1297
+ newOffset = selectedIndex;
1298
+ } else if (selectedIndex >= newOffset + visibleRows) {
1299
+ newOffset = selectedIndex - visibleRows + 1;
1300
+ }
1301
+
1302
+ scrollOffsetRef.current = newOffset;
1303
+ scrollboxRef.current.scrollTo(newOffset);
1304
+ }, [selectedIndex, visibleRows, sortedProjects.length]);
1305
+
1081
1306
  // Input focus sync
1082
1307
  useEffect(() => {
1083
1308
  setInputFocused(isFiltering);
@@ -1090,7 +1315,7 @@ export function ProjectsView() {
1090
1315
  (key: { name: string; shift?: boolean; ctrl?: boolean }) => {
1091
1316
  // Drawer open: only Esc closes
1092
1317
  if (drawerProject) {
1093
- if (key.name === 'escape') {
1318
+ if (key.name === "escape") {
1094
1319
  setDrawerProject(null);
1095
1320
  }
1096
1321
  return;
@@ -1098,59 +1323,59 @@ export function ProjectsView() {
1098
1323
 
1099
1324
  // Filter mode
1100
1325
  if (isFiltering) {
1101
- if (key.name === 'escape') {
1326
+ if (key.name === "escape") {
1102
1327
  setIsFiltering(false);
1103
- setFilterQuery('');
1104
- } else if (key.name === 'enter' || key.name === 'return') {
1328
+ setFilterQuery("");
1329
+ } else if (key.name === "enter" || key.name === "return") {
1105
1330
  setIsFiltering(false);
1106
1331
  }
1107
1332
  return;
1108
1333
  }
1109
1334
 
1110
1335
  // Navigation
1111
- if (key.name === 'down' || key.name === 'j') {
1336
+ if (key.name === "down" || key.name === "j") {
1112
1337
  setSelectedIndex((prev: number) => Math.min(prev + 1, sortedProjects.length - 1));
1113
- } else if (key.name === 'up' || key.name === 'k') {
1338
+ } else if (key.name === "up" || key.name === "k") {
1114
1339
  setSelectedIndex((prev: number) => Math.max(prev - 1, 0));
1115
1340
  }
1116
1341
  // Sort cycling
1117
- else if (key.name === 's') {
1342
+ else if (key.name === "s") {
1118
1343
  setSortField((prev: SortField) => {
1119
1344
  const idx = SORT_FIELDS.indexOf(prev);
1120
1345
  return SORT_FIELDS[(idx + 1) % SORT_FIELDS.length]!;
1121
1346
  });
1122
1347
  }
1123
1348
  // Time window
1124
- else if (key.name === 't') {
1349
+ else if (key.name === "t") {
1125
1350
  cycleWindow();
1126
1351
  }
1127
1352
  // Toggle insights panel
1128
- else if (key.name === 'v') {
1353
+ else if (key.name === "v") {
1129
1354
  setShowInsights((prev: boolean) => !prev);
1130
1355
  }
1131
1356
  // Open detail drawer
1132
- else if (key.name === 'enter' || key.name === 'return') {
1357
+ else if (key.name === "enter" || key.name === "return") {
1133
1358
  const project = sortedProjects[selectedIndex];
1134
1359
  if (project) {
1135
1360
  setDrawerProject(project);
1136
1361
  }
1137
1362
  }
1138
1363
  // Filter
1139
- else if (key.name === '/' || key.name === 'f') {
1364
+ else if (key.name === "/" || key.name === "f") {
1140
1365
  setIsFiltering(true);
1141
1366
  }
1142
1367
  // Clear filter
1143
- else if (key.name === 'escape') {
1368
+ else if (key.name === "escape") {
1144
1369
  if (filterQuery) {
1145
- setFilterQuery('');
1370
+ setFilterQuery("");
1146
1371
  }
1147
1372
  }
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)) {
1373
+ // Jump to bottom (Shift+G) / top (g)
1374
+ else if (key.shift && key.name === "g") {
1153
1375
  setSelectedIndex(Math.max(0, sortedProjects.length - 1));
1376
+ } else if (key.name === "g") {
1377
+ setSelectedIndex(0);
1378
+ scrollOffsetRef.current = 0;
1154
1379
  }
1155
1380
  },
1156
1381
  [drawerProject, isFiltering, sortedProjects, selectedIndex, cycleWindow, filterQuery],
@@ -1173,7 +1398,9 @@ export function ProjectsView() {
1173
1398
  <EmptyState hasFilter={filterQuery.length > 0} />
1174
1399
  <box flexDirection="row" height={1} paddingLeft={1}>
1175
1400
  <text fg={colors.textSubtle}>
1176
- {filterQuery ? 'Esc clear filter / edit filter t time window' : 't time window / filter'}
1401
+ {filterQuery
1402
+ ? "Esc clear filter / edit filter t time window"
1403
+ : "t time window / filter"}
1177
1404
  </text>
1178
1405
  </box>
1179
1406
  </box>
@@ -1203,7 +1430,9 @@ export function ProjectsView() {
1203
1430
 
1204
1431
  {/* Column headers */}
1205
1432
  <box flexDirection="row" height={1} marginTop={0}>
1206
- <text width={2} height={1} fg={colors.textMuted}>{' '}</text>
1433
+ <text width={2} height={1} fg={colors.textMuted}>
1434
+ {" "}
1435
+ </text>
1207
1436
  {visibleColumns.map((col: ColumnSpec) => (
1208
1437
  <box key={col.key} marginRight={1}>
1209
1438
  <text width={col.width} height={1} fg={colors.textMuted}>
@@ -1226,7 +1455,7 @@ export function ProjectsView() {
1226
1455
  textColor={colors.text}
1227
1456
  cursorColor={colors.primary}
1228
1457
  />
1229
- <text fg={colors.textSubtle}> (esc clear enter apply)</text>
1458
+ <text fg={colors.textSubtle}> (esc clear enter apply)</text>
1230
1459
  </box>
1231
1460
  )}
1232
1461
 
@@ -1239,7 +1468,7 @@ export function ProjectsView() {
1239
1468
  borderColor={colors.border}
1240
1469
  overflow="hidden"
1241
1470
  >
1242
- <scrollbox flexGrow={1}>
1471
+ <scrollbox ref={scrollboxRef} flexGrow={1}>
1243
1472
  <box flexDirection="column">
1244
1473
  {sortedProjects.map((project: ProjectStats, index: number) => (
1245
1474
  <ProjectRow
@@ -1266,10 +1495,10 @@ export function ProjectsView() {
1266
1495
  <box flexDirection="row" height={1} paddingLeft={1} flexShrink={0}>
1267
1496
  <text fg={colors.textSubtle} height={1}>
1268
1497
  {isFiltering
1269
- ? 'Type to filter Esc cancel Enter apply'
1498
+ ? "Type to filter Esc cancel Enter apply"
1270
1499
  : filterQuery
1271
1500
  ? `Filter: "${filterQuery}" Esc clear / edit`
1272
- : '\u2191\u2193 navigate Enter details s sort t time v insights / filter'}
1501
+ : "\u2191\u2193 navigate Enter details s sort t time v insights / filter"}
1273
1502
  </text>
1274
1503
  </box>
1275
1504
  </box>