@tokentop/ttop 0.2.2 → 0.4.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 (160) 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 +42 -38
  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 +36 -23
  19. package/src/plugins/notifications/visual-flash.ts +31 -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 +68 -57
  34. package/src/plugins/registry.ts +80 -71
  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/persistence-service.ts +206 -0
  73. package/src/storage/repos/agentSessions.ts +33 -23
  74. package/src/storage/repos/providerSnapshots.ts +5 -5
  75. package/src/storage/repos/usageEvents.ts +40 -28
  76. package/src/storage/retention.ts +146 -0
  77. package/src/storage/types.ts +5 -5
  78. package/src/tui/App.tsx +180 -137
  79. package/src/tui/components/CommandPalette.tsx +46 -34
  80. package/src/tui/components/DebugConsole.tsx +39 -33
  81. package/src/tui/components/DebugPanel.tsx +314 -183
  82. package/src/tui/components/FooterHints.tsx +2 -2
  83. package/src/tui/components/GhostProviderCard.tsx +37 -35
  84. package/src/tui/components/Header.tsx +68 -35
  85. package/src/tui/components/HelpOverlay.tsx +99 -29
  86. package/src/tui/components/InlineGauge.tsx +10 -7
  87. package/src/tui/components/InlineSparkline.tsx +17 -11
  88. package/src/tui/components/KpiStrip.tsx +128 -98
  89. package/src/tui/components/LimitGauge.tsx +96 -59
  90. package/src/tui/components/ModalBackdrop.tsx +4 -4
  91. package/src/tui/components/ProviderAggregateStrip.tsx +69 -38
  92. package/src/tui/components/ProviderCard.tsx +232 -226
  93. package/src/tui/components/ProviderDetailPanel.tsx +133 -58
  94. package/src/tui/components/ProviderLimitsPanel.tsx +126 -89
  95. package/src/tui/components/ProvidersList.tsx +215 -126
  96. package/src/tui/components/SessionDetailsDrawer.tsx +191 -94
  97. package/src/tui/components/SessionsTable.tsx +320 -169
  98. package/src/tui/components/SettingsModal.tsx +593 -269
  99. package/src/tui/components/SidebarBreakdown.tsx +30 -16
  100. package/src/tui/components/Skeleton.tsx +24 -17
  101. package/src/tui/components/SmartSidebar.tsx +360 -258
  102. package/src/tui/components/Sparkline.tsx +64 -52
  103. package/src/tui/components/Spinner.tsx +3 -3
  104. package/src/tui/components/StatusBar.tsx +27 -27
  105. package/src/tui/components/ThemePicker.tsx +67 -66
  106. package/src/tui/components/Toast.tsx +9 -6
  107. package/src/tui/components/UsageGauge.tsx +10 -13
  108. package/src/tui/components/index.ts +5 -5
  109. package/src/tui/contexts/AgentSessionContext.tsx +164 -151
  110. package/src/tui/contexts/ConfigContext.tsx +15 -13
  111. package/src/tui/contexts/DashboardRuntimeContext.tsx +26 -26
  112. package/src/tui/contexts/DemoModeContext.tsx +22 -15
  113. package/src/tui/contexts/DrawerContext.tsx +11 -9
  114. package/src/tui/contexts/InputContext.tsx +2 -2
  115. package/src/tui/contexts/LogContext.tsx +62 -47
  116. package/src/tui/contexts/PluginContext.tsx +238 -183
  117. package/src/tui/contexts/RealTimeActivityContext.tsx +70 -36
  118. package/src/tui/contexts/StorageContext.tsx +95 -149
  119. package/src/tui/contexts/ThemeContext.tsx +21 -16
  120. package/src/tui/contexts/TimeWindowContext.tsx +72 -61
  121. package/src/tui/contexts/ToastContext.tsx +10 -7
  122. package/src/tui/contexts/index.ts +9 -5
  123. package/src/tui/createApp.tsx +8 -12
  124. package/src/tui/debug/captureFrame.ts +25 -28
  125. package/src/tui/debug/snapshot.tsx +179 -162
  126. package/src/tui/driver/assertions.ts +30 -22
  127. package/src/tui/driver/cli.ts +231 -221
  128. package/src/tui/driver/coverage.ts +27 -44
  129. package/src/tui/driver/demo-snapshot.ts +33 -33
  130. package/src/tui/driver/diff.ts +26 -32
  131. package/src/tui/driver/driver.ts +43 -48
  132. package/src/tui/driver/index.ts +36 -40
  133. package/src/tui/driver/recorder.ts +37 -48
  134. package/src/tui/driver/test-workflow.ts +51 -51
  135. package/src/tui/driver/test.ts +25 -25
  136. package/src/tui/hooks/index.ts +24 -20
  137. package/src/tui/hooks/useAnimatedValue.ts +13 -18
  138. package/src/tui/hooks/useDashboardKeyboard.ts +118 -98
  139. package/src/tui/hooks/useDashboardState.ts +26 -24
  140. package/src/tui/hooks/useEmaActivity.ts +82 -74
  141. package/src/tui/hooks/useEntranceAnimation.ts +11 -11
  142. package/src/tui/hooks/useExitAnimation.ts +7 -3
  143. package/src/tui/hooks/usePulse.ts +4 -4
  144. package/src/tui/hooks/useSafeRenderer.ts +5 -5
  145. package/src/tui/hooks/useSessionEnrichedProviders.ts +150 -0
  146. package/src/tui/hooks/useValueFlash.ts +10 -14
  147. package/src/tui/index.tsx +29 -6
  148. package/src/tui/shutdown.ts +19 -0
  149. package/src/tui/utils/providerColor.ts +1 -1
  150. package/src/tui/views/Dashboard.tsx +164 -102
  151. package/src/tui/views/HistoricalTrendsView.tsx +579 -250
  152. package/src/tui/views/ProjectsView.tsx +495 -243
  153. package/src/tui/views/RealTimeDashboard.tsx +176 -116
  154. package/src/tui/views/SettingsView.tsx +225 -189
  155. package/src/tui/views/index.ts +2 -2
  156. package/src/types/opentui-test-utils.d.ts +7 -7
  157. package/src/utils/clipboard.ts +29 -29
  158. package/src/utils/currency.test.ts +53 -53
  159. package/src/utils/currency.ts +35 -32
  160. 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,14 @@ 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);
1205
+ const selectedProjectPathRef = useRef<string | null>(null);
1023
1206
 
1024
1207
  // Aggregate projects
1025
1208
  const projectStats = useMemo(
@@ -1041,22 +1224,23 @@ export function ProjectsView() {
1041
1224
  if (!filterQuery) return projectStats;
1042
1225
  const q = filterQuery.toLowerCase();
1043
1226
  return projectStats.filter(
1044
- (p: ProjectStats) =>
1045
- p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
1227
+ (p: ProjectStats) => p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q),
1046
1228
  );
1047
1229
  }, [projectStats, filterQuery]);
1048
1230
 
1049
1231
  const visibleCost = useMemo(
1050
- () => filterQuery
1051
- ? filteredProjects.reduce((s: number, p: ProjectStats) => s + p.cost, 0)
1052
- : totalCost,
1232
+ () =>
1233
+ filterQuery
1234
+ ? filteredProjects.reduce((s: number, p: ProjectStats) => s + p.cost, 0)
1235
+ : totalCost,
1053
1236
  [filterQuery, filteredProjects, totalCost],
1054
1237
  );
1055
1238
 
1056
1239
  const visibleSessions = useMemo(
1057
- () => filterQuery
1058
- ? filteredProjects.reduce((s: number, p: ProjectStats) => s + p.sessionCount, 0)
1059
- : totalSessions,
1240
+ () =>
1241
+ filterQuery
1242
+ ? filteredProjects.reduce((s: number, p: ProjectStats) => s + p.sessionCount, 0)
1243
+ : totalSessions,
1060
1244
  [filterQuery, filteredProjects, totalSessions],
1061
1245
  );
1062
1246
 
@@ -1069,14 +1253,78 @@ export function ProjectsView() {
1069
1253
  // Visible columns
1070
1254
  const visibleColumns: ColumnSpec[] = useMemo(() => getVisibleColumns(termWidth), [termWidth]);
1071
1255
 
1072
- // Clamp selection
1073
- useEffect(() => {
1256
+ // Stabilize selection across re-sorts: track selected project by path
1257
+ // so the highlight follows the same project when live data causes reordering.
1258
+ useLayoutEffect(() => {
1074
1259
  if (sortedProjects.length === 0) {
1075
- if (selectedIndex !== 0) setSelectedIndex(0);
1076
- } else if (selectedIndex >= sortedProjects.length) {
1077
- setSelectedIndex(sortedProjects.length - 1);
1260
+ setSelectedIndex(0);
1261
+ scrollOffsetRef.current = 0;
1262
+ scrollboxRef.current?.scrollTo(0);
1263
+ selectedProjectPathRef.current = null;
1264
+ return;
1265
+ }
1266
+
1267
+ if (selectedProjectPathRef.current) {
1268
+ const newIndex = sortedProjects.findIndex((p) => p.path === selectedProjectPathRef.current);
1269
+ if (newIndex !== -1) {
1270
+ setSelectedIndex(newIndex);
1271
+ return;
1272
+ }
1273
+ // Tracked project was filtered out — fall through to clamp
1274
+ selectedProjectPathRef.current = null;
1078
1275
  }
1079
- }, [sortedProjects.length, selectedIndex]);
1276
+
1277
+ // Clamp to valid range (functional update avoids selectedIndex in deps)
1278
+ setSelectedIndex((prev) => Math.min(prev, sortedProjects.length - 1));
1279
+ }, [sortedProjects]);
1280
+
1281
+ // Record which project the user selected
1282
+ useEffect(() => {
1283
+ const project = sortedProjects[selectedIndex];
1284
+ if (project) {
1285
+ selectedProjectPathRef.current = project.path;
1286
+ }
1287
+ }, [selectedIndex, sortedProjects]);
1288
+
1289
+ // Scroll to keep selected row visible
1290
+ const visibleRows = useMemo(() => {
1291
+ const appChrome = (termHeight >= 35 ? 7 : 1) + 1; // App.tsx: Header (large/small) + StatusBar
1292
+ const outerPadding = 2;
1293
+ const aggregateHeader = 1;
1294
+ const columnHeaders = 1;
1295
+ const tableBorder = 2;
1296
+ const summaryFooter = 1;
1297
+ const footerHints = 1;
1298
+ const filterBar = isFiltering ? 1 : 0;
1299
+ const insightsHeight = showInsights && termHeight >= 26 ? 9 : 0;
1300
+ return Math.max(
1301
+ 1,
1302
+ termHeight -
1303
+ appChrome -
1304
+ outerPadding -
1305
+ aggregateHeader -
1306
+ columnHeaders -
1307
+ tableBorder -
1308
+ summaryFooter -
1309
+ footerHints -
1310
+ filterBar -
1311
+ insightsHeight,
1312
+ );
1313
+ }, [termHeight, isFiltering, showInsights]);
1314
+
1315
+ useLayoutEffect(() => {
1316
+ if (!scrollboxRef.current || sortedProjects.length === 0) return;
1317
+
1318
+ let newOffset = scrollOffsetRef.current;
1319
+ if (selectedIndex < newOffset) {
1320
+ newOffset = selectedIndex;
1321
+ } else if (selectedIndex >= newOffset + visibleRows) {
1322
+ newOffset = selectedIndex - visibleRows + 1;
1323
+ }
1324
+
1325
+ scrollOffsetRef.current = newOffset;
1326
+ scrollboxRef.current.scrollTo(newOffset);
1327
+ }, [selectedIndex, visibleRows, sortedProjects.length]);
1080
1328
 
1081
1329
  // Input focus sync
1082
1330
  useEffect(() => {
@@ -1090,7 +1338,7 @@ export function ProjectsView() {
1090
1338
  (key: { name: string; shift?: boolean; ctrl?: boolean }) => {
1091
1339
  // Drawer open: only Esc closes
1092
1340
  if (drawerProject) {
1093
- if (key.name === 'escape') {
1341
+ if (key.name === "escape") {
1094
1342
  setDrawerProject(null);
1095
1343
  }
1096
1344
  return;
@@ -1098,59 +1346,59 @@ export function ProjectsView() {
1098
1346
 
1099
1347
  // Filter mode
1100
1348
  if (isFiltering) {
1101
- if (key.name === 'escape') {
1349
+ if (key.name === "escape") {
1102
1350
  setIsFiltering(false);
1103
- setFilterQuery('');
1104
- } else if (key.name === 'enter' || key.name === 'return') {
1351
+ setFilterQuery("");
1352
+ } else if (key.name === "enter" || key.name === "return") {
1105
1353
  setIsFiltering(false);
1106
1354
  }
1107
1355
  return;
1108
1356
  }
1109
1357
 
1110
1358
  // Navigation
1111
- if (key.name === 'down' || key.name === 'j') {
1359
+ if (key.name === "down" || key.name === "j") {
1112
1360
  setSelectedIndex((prev: number) => Math.min(prev + 1, sortedProjects.length - 1));
1113
- } else if (key.name === 'up' || key.name === 'k') {
1361
+ } else if (key.name === "up" || key.name === "k") {
1114
1362
  setSelectedIndex((prev: number) => Math.max(prev - 1, 0));
1115
1363
  }
1116
1364
  // Sort cycling
1117
- else if (key.name === 's') {
1365
+ else if (key.name === "s") {
1118
1366
  setSortField((prev: SortField) => {
1119
1367
  const idx = SORT_FIELDS.indexOf(prev);
1120
1368
  return SORT_FIELDS[(idx + 1) % SORT_FIELDS.length]!;
1121
1369
  });
1122
1370
  }
1123
1371
  // Time window
1124
- else if (key.name === 't') {
1372
+ else if (key.name === "t") {
1125
1373
  cycleWindow();
1126
1374
  }
1127
1375
  // Toggle insights panel
1128
- else if (key.name === 'v') {
1376
+ else if (key.name === "v") {
1129
1377
  setShowInsights((prev: boolean) => !prev);
1130
1378
  }
1131
1379
  // Open detail drawer
1132
- else if (key.name === 'enter' || key.name === 'return') {
1380
+ else if (key.name === "enter" || key.name === "return") {
1133
1381
  const project = sortedProjects[selectedIndex];
1134
1382
  if (project) {
1135
1383
  setDrawerProject(project);
1136
1384
  }
1137
1385
  }
1138
1386
  // Filter
1139
- else if (key.name === '/' || key.name === 'f') {
1387
+ else if (key.name === "/" || key.name === "f") {
1140
1388
  setIsFiltering(true);
1141
1389
  }
1142
1390
  // Clear filter
1143
- else if (key.name === 'escape') {
1391
+ else if (key.name === "escape") {
1144
1392
  if (filterQuery) {
1145
- setFilterQuery('');
1393
+ setFilterQuery("");
1146
1394
  }
1147
1395
  }
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)) {
1396
+ // Jump to bottom (Shift+G) / top (g)
1397
+ else if (key.shift && key.name === "g") {
1153
1398
  setSelectedIndex(Math.max(0, sortedProjects.length - 1));
1399
+ } else if (key.name === "g") {
1400
+ setSelectedIndex(0);
1401
+ scrollOffsetRef.current = 0;
1154
1402
  }
1155
1403
  },
1156
1404
  [drawerProject, isFiltering, sortedProjects, selectedIndex, cycleWindow, filterQuery],
@@ -1173,7 +1421,9 @@ export function ProjectsView() {
1173
1421
  <EmptyState hasFilter={filterQuery.length > 0} />
1174
1422
  <box flexDirection="row" height={1} paddingLeft={1}>
1175
1423
  <text fg={colors.textSubtle}>
1176
- {filterQuery ? 'Esc clear filter / edit filter t time window' : 't time window / filter'}
1424
+ {filterQuery
1425
+ ? "Esc clear filter / edit filter t time window"
1426
+ : "t time window / filter"}
1177
1427
  </text>
1178
1428
  </box>
1179
1429
  </box>
@@ -1203,7 +1453,9 @@ export function ProjectsView() {
1203
1453
 
1204
1454
  {/* Column headers */}
1205
1455
  <box flexDirection="row" height={1} marginTop={0}>
1206
- <text width={2} height={1} fg={colors.textMuted}>{' '}</text>
1456
+ <text width={2} height={1} fg={colors.textMuted}>
1457
+ {" "}
1458
+ </text>
1207
1459
  {visibleColumns.map((col: ColumnSpec) => (
1208
1460
  <box key={col.key} marginRight={1}>
1209
1461
  <text width={col.width} height={1} fg={colors.textMuted}>
@@ -1226,7 +1478,7 @@ export function ProjectsView() {
1226
1478
  textColor={colors.text}
1227
1479
  cursorColor={colors.primary}
1228
1480
  />
1229
- <text fg={colors.textSubtle}> (esc clear enter apply)</text>
1481
+ <text fg={colors.textSubtle}> (esc clear enter apply)</text>
1230
1482
  </box>
1231
1483
  )}
1232
1484
 
@@ -1239,7 +1491,7 @@ export function ProjectsView() {
1239
1491
  borderColor={colors.border}
1240
1492
  overflow="hidden"
1241
1493
  >
1242
- <scrollbox flexGrow={1}>
1494
+ <scrollbox ref={scrollboxRef} flexGrow={1}>
1243
1495
  <box flexDirection="column">
1244
1496
  {sortedProjects.map((project: ProjectStats, index: number) => (
1245
1497
  <ProjectRow
@@ -1266,10 +1518,10 @@ export function ProjectsView() {
1266
1518
  <box flexDirection="row" height={1} paddingLeft={1} flexShrink={0}>
1267
1519
  <text fg={colors.textSubtle} height={1}>
1268
1520
  {isFiltering
1269
- ? 'Type to filter Esc cancel Enter apply'
1521
+ ? "Type to filter Esc cancel Enter apply"
1270
1522
  : filterQuery
1271
1523
  ? `Filter: "${filterQuery}" Esc clear / edit`
1272
- : '\u2191\u2193 navigate Enter details s sort t time v insights / filter'}
1524
+ : "\u2191\u2193 navigate Enter details s sort t time v insights / filter"}
1273
1525
  </text>
1274
1526
  </box>
1275
1527
  </box>