@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,22 +1,21 @@
1
- import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
2
- import { useTerminalDimensions } from '@opentui/react';
3
- import type { ScrollBoxRenderable } from '@opentui/core';
4
- import { useColors } from '../contexts/ThemeContext.tsx';
5
- import { usePlugins } from '../contexts/PluginContext.tsx';
6
- import { useAgentSessions } from '../contexts/AgentSessionContext.tsx';
7
- import { useTimeWindow } from '../contexts/TimeWindowContext.tsx';
8
- import { useConfig } from '../contexts/ConfigContext.tsx';
9
- import { useDashboardRuntime } from '../contexts/DashboardRuntimeContext.tsx';
10
- import { useDrawer } from '../contexts/DrawerContext.tsx';
11
- import { useDashboardKeyboard } from '../hooks/useDashboardKeyboard.ts';
12
- import { getProviderColor } from '../utils/providerColor.ts';
13
- import { notificationBus } from '@/plugins/notification-bus.ts';
14
-
15
- import { KpiStrip } from '../components/KpiStrip.tsx';
16
- import { SessionsTable } from '../components/SessionsTable.tsx';
17
- import { SmartSidebar, getSidebarMode, type DriverDimension } from '../components/SmartSidebar.tsx';
18
- import { ProviderLimitsPanel } from '../components/ProviderLimitsPanel.tsx';
19
- import { HelpOverlay } from '../components/HelpOverlay.tsx';
1
+ import type { ScrollBoxRenderable } from "@opentui/core";
2
+ import { useTerminalDimensions } from "@opentui/react";
3
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
+ import { notificationBus } from "@/plugins/notification-bus.ts";
5
+ import { HelpOverlay } from "../components/HelpOverlay.tsx";
6
+ import { KpiStrip } from "../components/KpiStrip.tsx";
7
+ import { ProviderLimitsPanel } from "../components/ProviderLimitsPanel.tsx";
8
+ import { SessionsTable } from "../components/SessionsTable.tsx";
9
+ import { type DriverDimension, getSidebarMode, SmartSidebar } from "../components/SmartSidebar.tsx";
10
+ import { useAgentSessions } from "../contexts/AgentSessionContext.tsx";
11
+ import { useConfig } from "../contexts/ConfigContext.tsx";
12
+ import { useDashboardRuntime } from "../contexts/DashboardRuntimeContext.tsx";
13
+ import { useDrawer } from "../contexts/DrawerContext.tsx";
14
+ import { usePlugins } from "../contexts/PluginContext.tsx";
15
+ import { useColors } from "../contexts/ThemeContext.tsx";
16
+ import { useTimeWindow } from "../contexts/TimeWindowContext.tsx";
17
+ import { useDashboardKeyboard } from "../hooks/useDashboardKeyboard.ts";
18
+ import { getProviderColor } from "../utils/providerColor.ts";
20
19
 
21
20
  export function RealTimeDashboard() {
22
21
  const colors = useColors();
@@ -27,51 +26,62 @@ export function RealTimeDashboard() {
27
26
  const { config } = useConfig();
28
27
  const { activity, sparkData, deltas } = useDashboardRuntime();
29
28
 
30
-
31
29
  const sidebarMode = getSidebarMode(terminalWidth);
32
30
  const showLargeHeader = terminalHeight >= 35;
33
31
  const showProviderLimitsPanel = terminalHeight >= 24;
34
32
 
35
33
  // Visible-row count for scroll tracking — every non-session-data line must be counted.
36
34
  const visibleRows = (() => {
37
- const appChrome = (showLargeHeader ? 7 : 1) + 1; // App.tsx: Header + StatusBar
38
- const outerPadding = 2; // padding={1} top + bottom
39
- const kpi = 5; // KpiStrip fragment: h4 cards + h1 rule
40
- const limits = !showProviderLimitsPanel ? 0 // ProviderLimitsPanel
41
- : terminalHeight < 30 ? (sidebarMode === 'hidden' ? 2 : 1) // compact (+budget bar)
42
- : 4; // normal/wide bordered box
35
+ const appChrome = (showLargeHeader ? 7 : 1) + 1; // App.tsx: Header + StatusBar
36
+ const outerPadding = 2; // padding={1} top + bottom
37
+ const kpi = 5; // KpiStrip fragment: h4 cards + h1 rule
38
+ const limits = !showProviderLimitsPanel
39
+ ? 0 // ProviderLimitsPanel
40
+ : terminalHeight < 30
41
+ ? sidebarMode === "hidden"
42
+ ? 2
43
+ : 1 // compact (+budget bar)
44
+ : 4; // normal/wide bordered box
43
45
  const tableChrome = 4 + (terminalHeight >= 30 ? 1 : 0); // borders + header + columns + inspector
44
- const footer = 1; // keyboard shortcut bar
46
+ const footer = 1; // keyboard shortcut bar
45
47
  // gap={1} between rendered children; KpiStrip fragment injects 2 children
46
48
  const children = 2 + (showProviderLimitsPanel ? 1 : 0) + 1 + 1;
47
49
  const gaps = children - 1;
48
- return Math.max(1, terminalHeight - appChrome - outerPadding - kpi - limits - tableChrome - footer - gaps);
50
+ return Math.max(
51
+ 1,
52
+ terminalHeight - appChrome - outerPadding - kpi - limits - tableChrome - footer - gaps,
53
+ );
49
54
  })();
50
55
 
51
56
  const { showDrawer, isOpen: showSessionDrawer } = useDrawer();
52
-
57
+
53
58
  const [showHelp, setShowHelp] = useState(false);
54
59
  const [selectedRow, setSelectedRow] = useState(0);
55
- const [focusedPanel, setFocusedPanel] = useState<'sessions' | 'sidebar' | 'limits'>('sessions');
60
+ const [focusedPanel, setFocusedPanel] = useState<"sessions" | "sidebar" | "limits">("sessions");
56
61
  const [sidebarCollapsed, setSidebarCollapsed] = useState(config.display.sidebarCollapsed);
57
- const [filterQuery, setFilterQuery] = useState('');
62
+ const [filterQuery, setFilterQuery] = useState("");
58
63
  const [isFiltering, setIsFiltering] = useState(false);
59
- const [sortField, setSortField] = useState<'cost' | 'tokens' | 'time'>('cost');
64
+ const [sortField, setSortField] = useState<"cost" | "tokens" | "time">("cost");
60
65
  const [pendingG, setPendingG] = useState(false);
61
- const [scrollOffset, setScrollOffset] = useState(0);
66
+ const scrollOffsetRef = useRef(0);
67
+ const selectedSessionIdRef = useRef<string | null>(null);
68
+ const setScrollOffset = useCallback((val: number) => {
69
+ scrollOffsetRef.current = val;
70
+ sessionsScrollboxRef.current?.scrollTo(val);
71
+ }, []);
62
72
  const [limitSelectedIndex, setLimitSelectedIndex] = useState(0);
63
- const [driverDimension, setDriverDimension] = useState<DriverDimension>('model');
73
+ const [driverDimension, setDriverDimension] = useState<DriverDimension>("model");
64
74
  const [selectedDriverIndex, setSelectedDriverIndex] = useState(0);
65
75
  const [activeDriverFilter, setActiveDriverFilter] = useState<string | null>(null);
66
76
 
67
- const effectiveSidebarCollapsed = sidebarCollapsed || sidebarMode === 'hidden';
68
- const showBudgetInLimits = sidebarMode === 'hidden';
77
+ const effectiveSidebarCollapsed = sidebarCollapsed || sidebarMode === "hidden";
78
+ const showBudgetInLimits = sidebarMode === "hidden";
69
79
 
70
80
  const sessionsScrollboxRef = useRef<ScrollBoxRenderable>(null);
71
81
 
72
82
  const configuredProviders = useMemo(() => {
73
83
  return Array.from(providers.values())
74
- .filter(p => p.configured)
84
+ .filter((p) => p.configured)
75
85
  .sort((a, b) => getMaxUsedPercent(b) - getMaxUsedPercent(a));
76
86
  }, [providers]);
77
87
 
@@ -91,19 +101,20 @@ export function RealTimeDashboard() {
91
101
 
92
102
  const baseFilteredSessions = useMemo(() => {
93
103
  let result = [...agentSessions];
94
-
104
+
95
105
  if (windowMs !== null) {
96
106
  const cutoff = Date.now() - windowMs;
97
- result = result.filter(s => s.lastActivityAt >= cutoff);
107
+ result = result.filter((s) => s.lastActivityAt >= cutoff);
98
108
  }
99
-
109
+
100
110
  if (filterQuery) {
101
111
  const q = filterQuery.toLowerCase();
102
- result = result.filter(s =>
103
- s.agentName.toLowerCase().includes(q) ||
104
- s.streams.some(st => st.modelId.toLowerCase().includes(q)) ||
105
- (s.projectPath?.toLowerCase().includes(q) ?? false) ||
106
- (s.sessionName?.toLowerCase().includes(q) ?? false)
112
+ result = result.filter(
113
+ (s) =>
114
+ s.agentName.toLowerCase().includes(q) ||
115
+ s.streams.some((st) => st.modelId.toLowerCase().includes(q)) ||
116
+ (s.projectPath?.toLowerCase().includes(q) ?? false) ||
117
+ (s.sessionName?.toLowerCase().includes(q) ?? false),
107
118
  );
108
119
  }
109
120
 
@@ -112,13 +123,19 @@ export function RealTimeDashboard() {
112
123
 
113
124
  const drivers = useMemo(() => {
114
125
  const stats: Record<string, { cost: number }> = {};
115
- baseFilteredSessions.forEach(s => {
116
- s.streams.forEach(st => {
126
+ baseFilteredSessions.forEach((s) => {
127
+ s.streams.forEach((st) => {
117
128
  let key: string;
118
129
  switch (driverDimension) {
119
- case 'model': key = st.modelId; break;
120
- case 'project': key = s.projectPath?.split('/').pop() ?? 'unknown'; break;
121
- case 'agent': key = s.agentName; break;
130
+ case "model":
131
+ key = st.modelId;
132
+ break;
133
+ case "project":
134
+ key = s.projectPath?.split("/").pop() ?? "unknown";
135
+ break;
136
+ case "agent":
137
+ key = s.agentName;
138
+ break;
122
139
  }
123
140
  if (!stats[key]) stats[key] = { cost: 0 };
124
141
  stats[key]!.cost += st.costUsd ?? 0;
@@ -138,7 +155,7 @@ export function RealTimeDashboard() {
138
155
  }, [drivers.length, selectedDriverIndex]);
139
156
 
140
157
  useEffect(() => {
141
- if (activeDriverFilter === '__TOGGLE_SELECTED__') {
158
+ if (activeDriverFilter === "__TOGGLE_SELECTED__") {
142
159
  const driver = drivers[selectedDriverIndex];
143
160
  setActiveDriverFilter(driver?.id ?? null);
144
161
  }
@@ -146,64 +163,84 @@ export function RealTimeDashboard() {
146
163
 
147
164
  const processedSessions = useMemo(() => {
148
165
  let result = [...baseFilteredSessions];
149
-
150
- if (activeDriverFilter && activeDriverFilter !== '__TOGGLE_SELECTED__') {
151
- result = result.filter(s => {
166
+
167
+ if (activeDriverFilter && activeDriverFilter !== "__TOGGLE_SELECTED__") {
168
+ result = result.filter((s) => {
152
169
  switch (driverDimension) {
153
- case 'model':
154
- return s.streams.some(st => st.modelId === activeDriverFilter);
155
- case 'project':
156
- return (s.projectPath?.split('/').pop() ?? 'unknown') === activeDriverFilter;
157
- case 'agent':
170
+ case "model":
171
+ return s.streams.some((st) => st.modelId === activeDriverFilter);
172
+ case "project":
173
+ return (s.projectPath?.split("/").pop() ?? "unknown") === activeDriverFilter;
174
+ case "agent":
158
175
  return s.agentName === activeDriverFilter;
176
+ default:
177
+ return true;
159
178
  }
160
179
  });
161
180
  }
162
181
 
163
182
  result.sort((a, b) => {
164
- const aActive = a.status === 'active' ? 1 : 0;
165
- const bActive = b.status === 'active' ? 1 : 0;
183
+ const aActive = a.status === "active" ? 1 : 0;
184
+ const bActive = b.status === "active" ? 1 : 0;
166
185
  if (bActive !== aActive) return bActive - aActive;
167
-
168
- if (sortField === 'cost') return (b.totalCostUsd ?? 0) - (a.totalCostUsd ?? 0);
169
- if (sortField === 'tokens') return (b.totals.input + b.totals.output) - (a.totals.input + a.totals.output);
186
+
187
+ if (sortField === "cost") return (b.totalCostUsd ?? 0) - (a.totalCostUsd ?? 0);
188
+ if (sortField === "tokens")
189
+ return b.totals.input + b.totals.output - (a.totals.input + a.totals.output);
170
190
  return b.lastActivityAt - a.lastActivityAt;
171
191
  });
172
192
 
173
193
  return result;
174
194
  }, [baseFilteredSessions, activeDriverFilter, driverDimension, sortField]);
175
195
 
176
- useEffect(() => {
196
+ // Stabilize selection across re-sorts: track selected session by ID
197
+ // so the highlight follows the same session when live data causes reordering.
198
+ useLayoutEffect(() => {
177
199
  if (processedSessions.length === 0) {
178
- if (selectedRow !== 0) setSelectedRow(0);
179
- if (scrollOffset !== 0) setScrollOffset(0);
200
+ setSelectedRow(0);
201
+ setScrollOffset(0);
202
+ selectedSessionIdRef.current = null;
180
203
  return;
181
204
  }
182
-
183
- const maxRow = processedSessions.length - 1;
184
- if (selectedRow > maxRow) {
185
- setSelectedRow(maxRow);
186
- return;
205
+
206
+ if (selectedSessionIdRef.current) {
207
+ const newIndex = processedSessions.findIndex(
208
+ (s) => s.sessionId === selectedSessionIdRef.current,
209
+ );
210
+ if (newIndex !== -1) {
211
+ setSelectedRow(newIndex);
212
+ return;
213
+ }
214
+ // Tracked session was filtered out — fall through to clamp
215
+ selectedSessionIdRef.current = null;
187
216
  }
188
- }, [processedSessions.length, selectedRow, scrollOffset]);
189
217
 
218
+ // Clamp to valid range (functional update avoids selectedRow in deps)
219
+ setSelectedRow((prev) => Math.min(prev, processedSessions.length - 1));
220
+ }, [processedSessions, setScrollOffset]);
221
+
222
+ // Record which session the user selected (runs after navigation and stabilization)
190
223
  useEffect(() => {
224
+ const session = processedSessions[selectedRow];
225
+ if (session) {
226
+ selectedSessionIdRef.current = session.sessionId;
227
+ }
228
+ }, [selectedRow, processedSessions]);
229
+
230
+ useLayoutEffect(() => {
191
231
  if (!sessionsScrollboxRef.current || processedSessions.length === 0) return;
192
232
 
193
- let newOffset = scrollOffset;
233
+ let newOffset = scrollOffsetRef.current;
194
234
 
195
- if (selectedRow < scrollOffset) {
235
+ if (selectedRow < newOffset) {
196
236
  newOffset = selectedRow;
197
- } else if (selectedRow >= scrollOffset + visibleRows) {
237
+ } else if (selectedRow >= newOffset + visibleRows) {
198
238
  newOffset = selectedRow - visibleRows + 1;
199
239
  }
200
240
 
201
- if (newOffset !== scrollOffset) {
202
- setScrollOffset(newOffset);
203
- }
204
-
241
+ scrollOffsetRef.current = newOffset;
205
242
  sessionsScrollboxRef.current.scrollTo(newOffset);
206
- }, [selectedRow, processedSessions.length, scrollOffset, visibleRows]);
243
+ }, [selectedRow, processedSessions.length, visibleRows]);
207
244
 
208
245
  const openSessionDrawer = useCallback(() => {
209
246
  const session = processedSessions[selectedRow];
@@ -225,7 +262,7 @@ export function RealTimeDashboard() {
225
262
  isFiltering,
226
263
  sortField,
227
264
  pendingG,
228
- scrollOffset,
265
+ scrollOffset: scrollOffsetRef.current,
229
266
  limitSelectedIndex,
230
267
  providerCount: configuredProviders.length,
231
268
  driverDimension,
@@ -253,14 +290,16 @@ export function RealTimeDashboard() {
253
290
  processedSessions,
254
291
  });
255
292
 
256
- const windowedKpis = useMemo(() => ({
257
- cost: processedSessions.reduce((acc, s) => acc + (s.totalCostUsd ?? 0), 0),
258
- tokens: processedSessions.reduce((acc, s) => acc + s.totals.input + s.totals.output, 0),
259
- requests: processedSessions.reduce((acc, s) => acc + s.requestCount, 0),
260
- }), [processedSessions]);
293
+ const windowedKpis = useMemo(
294
+ () => ({
295
+ cost: processedSessions.reduce((acc, s) => acc + (s.totalCostUsd ?? 0), 0),
296
+ tokens: processedSessions.reduce((acc, s) => acc + s.totals.input + s.totals.output, 0),
297
+ requests: processedSessions.reduce((acc, s) => acc + s.requestCount, 0),
298
+ }),
299
+ [processedSessions],
300
+ );
261
301
 
262
302
  const budgetPeriodCost = useMemo(() => {
263
-
264
303
  let dailyCost = 0;
265
304
  let weeklyCost = 0;
266
305
  let monthlyCost = 0;
@@ -275,10 +314,14 @@ export function RealTimeDashboard() {
275
314
  }, [agentSessions]);
276
315
 
277
316
  useEffect(() => {
278
- const checks: Array<{ cost: number; limit: number | null; type: 'daily' | 'weekly' | 'monthly' }> = [
279
- { cost: budgetPeriodCost.daily, limit: config.budgets.daily, type: 'daily' },
280
- { cost: budgetPeriodCost.weekly, limit: config.budgets.weekly, type: 'weekly' },
281
- { cost: budgetPeriodCost.monthly, limit: config.budgets.monthly, type: 'monthly' },
317
+ const checks: Array<{
318
+ cost: number;
319
+ limit: number | null;
320
+ type: "daily" | "weekly" | "monthly";
321
+ }> = [
322
+ { cost: budgetPeriodCost.daily, limit: config.budgets.daily, type: "daily" },
323
+ { cost: budgetPeriodCost.weekly, limit: config.budgets.weekly, type: "weekly" },
324
+ { cost: budgetPeriodCost.monthly, limit: config.budgets.monthly, type: "monthly" },
282
325
  ];
283
326
  for (const { cost, limit, type } of checks) {
284
327
  if (limit !== null && limit > 0) {
@@ -289,20 +332,23 @@ export function RealTimeDashboard() {
289
332
 
290
333
  const getBudgetCost = () => {
291
334
  switch (budgetType) {
292
- case 'daily': return budgetPeriodCost.daily;
293
- case 'weekly': return budgetPeriodCost.weekly;
294
- case 'monthly': return budgetPeriodCost.monthly;
295
- default: return windowedKpis.cost;
335
+ case "daily":
336
+ return budgetPeriodCost.daily;
337
+ case "weekly":
338
+ return budgetPeriodCost.weekly;
339
+ case "monthly":
340
+ return budgetPeriodCost.monthly;
341
+ default:
342
+ return windowedKpis.cost;
296
343
  }
297
344
  };
298
345
 
299
- const activeCount = agentSessions.filter(s => s.status === 'active').length;
346
+ const activeCount = agentSessions.filter((s) => s.status === "active").length;
300
347
 
301
348
  return (
302
349
  <box flexDirection="column" flexGrow={1} padding={1} gap={1} overflow="hidden">
303
350
  {showHelp && <HelpOverlay />}
304
351
 
305
-
306
352
  <KpiStrip
307
353
  totalCost={windowedKpis.cost}
308
354
  totalTokens={windowedKpis.tokens}
@@ -314,10 +360,14 @@ export function RealTimeDashboard() {
314
360
  activity={activity}
315
361
  sparkData={sparkData}
316
362
  budget={{
317
- limit: budgetType === 'daily' ? config.budgets.daily
318
- : budgetType === 'weekly' ? config.budgets.weekly
319
- : budgetType === 'monthly' ? config.budgets.monthly
320
- : null,
363
+ limit:
364
+ budgetType === "daily"
365
+ ? config.budgets.daily
366
+ : budgetType === "weekly"
367
+ ? config.budgets.weekly
368
+ : budgetType === "monthly"
369
+ ? config.budgets.monthly
370
+ : null,
321
371
  budgetCost: getBudgetCost(),
322
372
  budgetType,
323
373
  budgetTypeLabel,
@@ -327,23 +377,27 @@ export function RealTimeDashboard() {
327
377
  />
328
378
 
329
379
  <ProviderLimitsPanel
330
- providers={configuredProviders.map(p => ({
380
+ providers={configuredProviders.map((p) => ({
331
381
  id: p.plugin.id,
332
382
  name: p.plugin.name,
333
383
  usedPercent: getMaxUsedPercent(p),
334
384
  color: providerColorOf(p.plugin.id),
335
385
  ...(p.usage?.error ? { error: p.usage.error } : {}),
336
386
  }))}
337
- focused={focusedPanel === 'limits'}
387
+ focused={focusedPanel === "limits"}
338
388
  selectedIndex={limitSelectedIndex}
339
389
  showBudgetBar={showBudgetInLimits}
340
390
  budget={{
341
391
  totalCost: windowedKpis.cost,
342
392
  budgetCost: getBudgetCost(),
343
- limit: budgetType === 'daily' ? config.budgets.daily
344
- : budgetType === 'weekly' ? config.budgets.weekly
345
- : budgetType === 'monthly' ? config.budgets.monthly
346
- : null,
393
+ limit:
394
+ budgetType === "daily"
395
+ ? config.budgets.daily
396
+ : budgetType === "weekly"
397
+ ? config.budgets.weekly
398
+ : budgetType === "monthly"
399
+ ? config.budgets.monthly
400
+ : null,
347
401
  budgetType,
348
402
  budgetTypeLabel,
349
403
  warningPercent: config.alerts.warningPercent,
@@ -376,16 +430,22 @@ export function RealTimeDashboard() {
376
430
  />
377
431
  )}
378
432
  </box>
379
-
433
+
380
434
  <box flexDirection="row" paddingLeft={1} height={1} flexShrink={0}>
381
435
  <text fg={colors.textSubtle} height={1} flexGrow={1}>
382
- {isFiltering ? 'Type to filter Esc cancel Enter apply' :
383
- filterQuery ? `Esc clear / edit filter ↑↓ navigate s sort` :
384
- activeDriverFilter ? `Filter: ${activeDriverFilter} Esc clear` :
385
- focusedPanel === 'sessions' ? '/ filter ↑↓ navigate Enter details s sort l limits Tab next ⇧Tab prev' :
386
- focusedPanel === 'limits' ? '←→ select provider Tab next ⇧Tab prev Esc back' :
387
- focusedPanel === 'sidebar' ? '↑↓ select Enter filter m/p/a dimension b budget Tab next ⇧Tab prev' :
388
- '/ filter i sidebar Tab/⇧Tab switch ? help'}
436
+ {isFiltering
437
+ ? "Type to filter Esc cancel Enter apply"
438
+ : filterQuery
439
+ ? `Esc clear / edit filter ↑↓ navigate s sort`
440
+ : activeDriverFilter
441
+ ? `Filter: ${activeDriverFilter} Esc clear`
442
+ : focusedPanel === "sessions"
443
+ ? "/ filter ↑↓ navigate Enter details s sort l limits Tab next ⇧Tab prev"
444
+ : focusedPanel === "limits"
445
+ ? "←→ select provider Tab next ⇧Tab prev Esc back"
446
+ : focusedPanel === "sidebar"
447
+ ? "↑↓ select Enter filter m/p/a dimension b budget Tab next ⇧Tab prev"
448
+ : "/ filter i sidebar Tab/⇧Tab switch ? help"}
389
449
  </text>
390
450
  </box>
391
451
  </box>