@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,4 +1,4 @@
1
- import { useColors } from '../contexts/ThemeContext.tsx';
1
+ import { useColors } from "../contexts/ThemeContext.tsx";
2
2
 
3
3
  interface UsageGaugeProps {
4
4
  label: string;
@@ -25,23 +25,20 @@ export function UsageGauge({
25
25
  const filledWidth = Math.round((percent / 100) * barWidth);
26
26
  const emptyWidth = barWidth - filledWidth;
27
27
 
28
- const fillColor = percent >= 90 ? colors.gaugeDanger :
29
- percent >= 70 ? colors.gaugeWarning :
30
- providerColor;
28
+ const fillColor =
29
+ percent >= 90 ? colors.gaugeDanger : percent >= 70 ? colors.gaugeWarning : providerColor;
31
30
 
32
- const filledBar = '█'.repeat(filledWidth);
33
- const emptyBar = '·'.repeat(emptyWidth);
31
+ const filledBar = "█".repeat(filledWidth);
32
+ const emptyBar = "·".repeat(emptyWidth);
34
33
 
35
- const resetText = resetsAt ? formatResetTime(resetsAt) : '';
34
+ const resetText = resetsAt ? formatResetTime(resetsAt) : "";
36
35
  const showWindowLabel = windowLabel && !label.toLowerCase().includes(windowLabel.toLowerCase());
37
36
 
38
37
  return (
39
38
  <box flexDirection="column" width={width}>
40
39
  <box flexDirection="row" justifyContent="space-between">
41
40
  <text fg={colors.textMuted}>{label}</text>
42
- <text fg={colors.text}>
43
- {usedPercent !== null ? `${Math.round(percent)}%` : '--'}
44
- </text>
41
+ <text fg={colors.text}>{usedPercent !== null ? `${Math.round(percent)}%` : "--"}</text>
45
42
  </box>
46
43
  <box flexDirection="row">
47
44
  <text>
@@ -51,8 +48,8 @@ export function UsageGauge({
51
48
  </box>
52
49
  {(showWindowLabel || resetText) && (
53
50
  <text fg={colors.textMuted}>
54
- {showWindowLabel ? windowLabel : ''}
55
- {showWindowLabel && resetText ? ' · ' : ''}
51
+ {showWindowLabel ? windowLabel : ""}
52
+ {showWindowLabel && resetText ? " · " : ""}
56
53
  {resetText}
57
54
  </text>
58
55
  )}
@@ -64,7 +61,7 @@ function formatResetTime(timestamp: number): string {
64
61
  const now = Date.now();
65
62
  const diff = timestamp - now;
66
63
 
67
- if (diff <= 0) return 'resets soon';
64
+ if (diff <= 0) return "resets soon";
68
65
 
69
66
  const minutes = Math.floor(diff / 60000);
70
67
  const hours = Math.floor(minutes / 60);
@@ -1,5 +1,5 @@
1
- export { Header } from './Header.tsx';
2
- export { StatusBar } from './StatusBar.tsx';
3
- export { UsageGauge } from './UsageGauge.tsx';
4
- export { ProviderCard } from './ProviderCard.tsx';
5
- export { DebugConsole } from './DebugConsole.tsx';
1
+ export { DebugConsole } from "./DebugConsole.tsx";
2
+ export { Header } from "./Header.tsx";
3
+ export { ProviderCard } from "./ProviderCard.tsx";
4
+ export { StatusBar } from "./StatusBar.tsx";
5
+ export { UsageGauge } from "./UsageGauge.tsx";
@@ -1,17 +1,25 @@
1
- import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
2
- import type { AgentPlugin, SessionParseOptions } from '@/plugins/types/agent.ts';
3
- import type { AgentSessionAggregate, AgentInfo, AgentId } from '@/agents/types.ts';
4
- import { aggregateSessionUsage } from '@/agents/aggregator.ts';
5
- import { priceSessions } from '@/agents/costing.ts';
6
- import { pluginRegistry } from '@/plugins/registry.ts';
7
- import { createSandboxedHttpClient, createPluginLogger } from '@/plugins/sandbox.ts';
8
- import { createPluginContext } from '@/plugins/plugin-context-factory.ts';
9
- import { useLogs } from './LogContext.tsx';
10
- import { usePlugins } from './PluginContext.tsx';
11
- import { useStorage } from './StorageContext.tsx';
12
- import type { PricingSource } from '@/storage/types.ts';
13
- import { useDemoMode } from './DemoModeContext.tsx';
14
- import { useTimeWindow } from './TimeWindowContext.tsx';
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import { aggregateSessionUsage } from "@/agents/aggregator.ts";
11
+ import { priceSessions } from "@/agents/costing.ts";
12
+ import type { AgentId, AgentInfo, AgentSessionAggregate } from "@/agents/types.ts";
13
+ import { createPluginContext } from "@/plugins/plugin-context-factory.ts";
14
+ import { pluginRegistry } from "@/plugins/registry.ts";
15
+ import { createPluginLogger, createSandboxedHttpClient } from "@/plugins/sandbox.ts";
16
+ import type { AgentPlugin, SessionParseOptions } from "@/plugins/types/agent.ts";
17
+ import type { PricingSource } from "@/storage/types.ts";
18
+ import { useDemoMode } from "./DemoModeContext.tsx";
19
+ import { useLogs } from "./LogContext.tsx";
20
+ import { usePlugins } from "./PluginContext.tsx";
21
+ import { useStorage } from "./StorageContext.tsx";
22
+ import { useTimeWindow } from "./TimeWindowContext.tsx";
15
23
 
16
24
  interface AgentSessionContextValue {
17
25
  sessions: AgentSessionAggregate[];
@@ -24,6 +32,7 @@ interface AgentSessionContextValue {
24
32
 
25
33
  const AgentSessionContext = createContext<AgentSessionContextValue | null>(null);
26
34
 
35
+ const MAX_PERSISTENCE_FINGERPRINTS = 500;
27
36
  const sessionPersistenceFingerprints = new Map<string, string>();
28
37
 
29
38
  function sessionFingerprint(session: AgentSessionAggregate): string {
@@ -52,9 +61,10 @@ export function AgentSessionProvider({
52
61
  const { demoMode, simulator } = useDemoMode();
53
62
  const { windowMs } = useTimeWindow();
54
63
  const hasBackfilled = useRef(false);
64
+ const isRefreshingRef = useRef(false);
55
65
 
56
66
  const discoverAgents = useCallback(async (): Promise<AgentInfo[]> => {
57
- const agentPlugins = pluginRegistry.getAll('agent');
67
+ const agentPlugins = pluginRegistry.getAll("agent");
58
68
  const discovered: AgentInfo[] = [];
59
69
 
60
70
  for (const plugin of agentPlugins) {
@@ -78,7 +88,7 @@ export function AgentSessionProvider({
78
88
  installed: false,
79
89
  sessionParsingSupported: false,
80
90
  };
81
- agentInfo.error = err instanceof Error ? err.message : 'Unknown error';
91
+ agentInfo.error = err instanceof Error ? err.message : "Unknown error";
82
92
  discovered.push(agentInfo);
83
93
  }
84
94
  }
@@ -86,144 +96,187 @@ export function AgentSessionProvider({
86
96
  return discovered;
87
97
  }, []);
88
98
 
89
- const fetchAgentSessions = useCallback(async (
90
- plugin: AgentPlugin,
91
- options: SessionParseOptions
92
- ): Promise<AgentSessionAggregate[]> => {
93
- const agentId = plugin.id;
94
- const agentName = plugin.name;
99
+ const fetchAgentSessions = useCallback(
100
+ async (plugin: AgentPlugin, options: SessionParseOptions): Promise<AgentSessionAggregate[]> => {
101
+ const agentId = plugin.id;
102
+ const agentName = plugin.name;
95
103
 
96
- const http = createSandboxedHttpClient(plugin.id, plugin.permissions);
97
- const logger = createPluginLogger(plugin.id);
98
- const ctx = { http, logger, config: {}, signal: AbortSignal.timeout(60_000) };
104
+ const http = createSandboxedHttpClient(plugin.id, plugin.permissions);
105
+ const logger = createPluginLogger(plugin.id);
106
+ const ctx = { http, logger, config: {}, signal: AbortSignal.timeout(60_000) };
99
107
 
100
- const rawSessions = await plugin.parseSessions(options, ctx);
108
+ const rawSessions = await plugin.parseSessions(options, ctx);
101
109
 
102
- if (rawSessions.length === 0) {
103
- return [];
104
- }
110
+ if (rawSessions.length === 0) {
111
+ return [];
112
+ }
105
113
 
106
- const aggregated = aggregateSessionUsage({
107
- agentId,
108
- agentName,
109
- rows: rawSessions,
110
- });
114
+ const aggregated = aggregateSessionUsage({
115
+ agentId,
116
+ agentName,
117
+ rows: rawSessions,
118
+ });
111
119
 
112
- return aggregated;
113
- }, []);
120
+ return aggregated;
121
+ },
122
+ [],
123
+ );
114
124
 
115
- const refreshSessions = useCallback(async (options: SessionParseOptions = {}) => {
116
- const isInitialLoad = sessions.length === 0;
117
- if (isInitialLoad) {
118
- setIsLoading(true);
119
- }
120
- setError(null);
125
+ const refreshSessions = useCallback(
126
+ async (options: SessionParseOptions = {}) => {
127
+ if (isRefreshingRef.current) return;
128
+ isRefreshingRef.current = true;
121
129
 
122
- try {
123
- if (demoMode && simulator) {
124
- const snapshot = simulator.tick();
125
- const demoSessions = snapshot.sessions;
126
- setSessions(demoSessions);
127
- setLastRefreshAt(Date.now());
128
- debug('Demo sessions refreshed', { count: demoSessions.length }, 'agent-sessions');
129
- return;
130
+ const isInitialLoad = sessions.length === 0;
131
+ if (isInitialLoad) {
132
+ setIsLoading(true);
130
133
  }
134
+ setError(null);
131
135
 
132
- debug('Starting session refresh...', undefined, 'agent-sessions');
136
+ try {
137
+ if (demoMode && simulator) {
138
+ const snapshot = simulator.tick();
139
+ const demoSessions = snapshot.sessions;
140
+ setSessions(demoSessions);
141
+ setLastRefreshAt(Date.now());
142
+ debug("Demo sessions refreshed", { count: demoSessions.length }, "agent-sessions");
143
+ return;
144
+ }
133
145
 
134
- const discoveredAgents = await discoverAgents();
135
- setAgents(discoveredAgents);
146
+ debug("Starting session refresh...", undefined, "agent-sessions");
136
147
 
137
- const agentPlugins = pluginRegistry.getAll('agent');
138
- const allAggregates: AgentSessionAggregate[] = [];
148
+ const discoveredAgents = await discoverAgents();
149
+ setAgents(discoveredAgents);
139
150
 
140
- for (const plugin of agentPlugins) {
141
- if (!plugin.capabilities.sessionParsing) {
142
- debug(`Skipping ${plugin.id}: session parsing not supported`, undefined, 'agent-sessions');
143
- continue;
144
- }
151
+ const agentPlugins = pluginRegistry.getAll("agent");
152
+ const allAggregates: AgentSessionAggregate[] = [];
145
153
 
146
- try {
147
- const pluginCtx = createPluginContext(plugin.id, plugin.permissions);
148
- const installed = await plugin.isInstalled(pluginCtx);
149
- if (!installed) {
150
- debug(`Skipping ${plugin.id}: not installed`, undefined, 'agent-sessions');
154
+ for (const plugin of agentPlugins) {
155
+ if (!plugin.capabilities.sessionParsing) {
156
+ debug(
157
+ `Skipping ${plugin.id}: session parsing not supported`,
158
+ undefined,
159
+ "agent-sessions",
160
+ );
151
161
  continue;
152
162
  }
153
163
 
154
- const aggregates = await fetchAgentSessions(plugin, options);
155
- allAggregates.push(...aggregates);
156
- debug(`Fetched ${aggregates.length} sessions from ${plugin.id}`, undefined, 'agent-sessions');
157
- } catch (err) {
158
- const errorMsg = err instanceof Error ? err.message : 'Unknown error';
159
- warn(`Failed to fetch sessions from ${plugin.id}: ${errorMsg}`, undefined, 'agent-sessions');
164
+ try {
165
+ const pluginCtx = createPluginContext(plugin.id, plugin.permissions);
166
+ const installed = await plugin.isInstalled(pluginCtx);
167
+ if (!installed) {
168
+ debug(`Skipping ${plugin.id}: not installed`, undefined, "agent-sessions");
169
+ continue;
170
+ }
171
+
172
+ const aggregates = await fetchAgentSessions(plugin, options);
173
+ allAggregates.push(...aggregates);
174
+ debug(
175
+ `Fetched ${aggregates.length} sessions from ${plugin.id}`,
176
+ undefined,
177
+ "agent-sessions",
178
+ );
179
+ } catch (err) {
180
+ const errorMsg = err instanceof Error ? err.message : "Unknown error";
181
+ warn(
182
+ `Failed to fetch sessions from ${plugin.id}: ${errorMsg}`,
183
+ undefined,
184
+ "agent-sessions",
185
+ );
186
+ }
160
187
  }
161
- }
162
188
 
163
- const pricedSessions = await priceSessions(allAggregates);
164
-
165
- pricedSessions.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
166
-
167
- if (storageReady) {
168
- const now = Date.now();
169
- let persistedCount = 0;
170
- for (const session of pricedSessions) {
171
- const fp = sessionFingerprint(session);
172
- const prevFp = sessionPersistenceFingerprints.get(session.sessionId);
173
- if (prevFp === fp) continue;
174
-
175
- sessionPersistenceFingerprints.set(session.sessionId, fp);
176
- persistedCount++;
177
-
178
- recordAgentSession(
179
- {
180
- agentId: session.agentId,
181
- sessionId: session.sessionId,
182
- projectPath: session.projectPath ?? null,
183
- startedAt: session.startedAt ?? null,
184
- lastSeenAt: now,
185
- },
186
- {
187
- timestamp: now,
188
- lastActivityAt: session.lastActivityAt,
189
- status: session.status,
190
- totalInputTokens: session.totals.input,
191
- totalOutputTokens: session.totals.output,
192
- totalCacheReadTokens: session.totals.cacheRead ?? 0,
193
- totalCacheWriteTokens: session.totals.cacheWrite ?? 0,
194
- totalCostUsd: session.totalCostUsd ?? 0,
195
- requestCount: session.requestCount,
196
- },
197
- session.streams.map(s => ({
198
- provider: s.providerId,
199
- model: s.modelId,
200
- inputTokens: s.tokens.input,
201
- outputTokens: s.tokens.output,
202
- cacheReadTokens: s.tokens.cacheRead ?? 0,
203
- cacheWriteTokens: s.tokens.cacheWrite ?? 0,
204
- costUsd: s.costUsd ?? 0,
205
- requestCount: s.requestCount,
206
- pricingSource: (s.pricingSource as PricingSource) ?? null,
207
- }))
189
+ const pricedSessions = await priceSessions(allAggregates);
190
+
191
+ pricedSessions.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
192
+
193
+ if (storageReady) {
194
+ const now = Date.now();
195
+ let persistedCount = 0;
196
+ for (const session of pricedSessions) {
197
+ const fp = sessionFingerprint(session);
198
+ const prevFp = sessionPersistenceFingerprints.get(session.sessionId);
199
+ if (prevFp === fp) continue;
200
+
201
+ sessionPersistenceFingerprints.set(session.sessionId, fp);
202
+ persistedCount++;
203
+
204
+ recordAgentSession(
205
+ {
206
+ agentId: session.agentId,
207
+ sessionId: session.sessionId,
208
+ projectPath: session.projectPath ?? null,
209
+ startedAt: session.startedAt ?? null,
210
+ lastSeenAt: now,
211
+ },
212
+ {
213
+ timestamp: now,
214
+ lastActivityAt: session.lastActivityAt,
215
+ status: session.status,
216
+ totalInputTokens: session.totals.input,
217
+ totalOutputTokens: session.totals.output,
218
+ totalCacheReadTokens: session.totals.cacheRead ?? 0,
219
+ totalCacheWriteTokens: session.totals.cacheWrite ?? 0,
220
+ totalCostUsd: session.totalCostUsd ?? 0,
221
+ requestCount: session.requestCount,
222
+ },
223
+ session.streams.map((s) => ({
224
+ provider: s.providerId,
225
+ model: s.modelId,
226
+ inputTokens: s.tokens.input,
227
+ outputTokens: s.tokens.output,
228
+ cacheReadTokens: s.tokens.cacheRead ?? 0,
229
+ cacheWriteTokens: s.tokens.cacheWrite ?? 0,
230
+ costUsd: s.costUsd ?? 0,
231
+ requestCount: s.requestCount,
232
+ pricingSource: (s.pricingSource as PricingSource) ?? null,
233
+ })),
234
+ );
235
+ }
236
+ if (sessionPersistenceFingerprints.size > MAX_PERSISTENCE_FINGERPRINTS) {
237
+ const excess = sessionPersistenceFingerprints.size - MAX_PERSISTENCE_FINGERPRINTS;
238
+ const keys = sessionPersistenceFingerprints.keys();
239
+ for (let i = 0; i < excess; i++) {
240
+ const next = keys.next();
241
+ if (next.done) break;
242
+ sessionPersistenceFingerprints.delete(next.value);
243
+ }
244
+ }
245
+
246
+ debug(
247
+ `Persisted ${persistedCount}/${pricedSessions.length} changed sessions to storage`,
248
+ undefined,
249
+ "agent-sessions",
208
250
  );
209
251
  }
210
- debug(`Persisted ${persistedCount}/${pricedSessions.length} changed sessions to storage`, undefined, 'agent-sessions');
211
- }
212
252
 
213
- setSessions(pricedSessions);
214
- setLastRefreshAt(Date.now());
253
+ setSessions(pricedSessions);
254
+ setLastRefreshAt(Date.now());
215
255
 
216
- debug(`Session refresh complete`, { count: pricedSessions.length }, 'agent-sessions');
217
- } catch (err) {
218
- const errorMsg = err instanceof Error ? err.message : 'Unknown error';
219
- logError(`Session refresh failed: ${errorMsg}`, undefined, 'agent-sessions');
220
- setError(errorMsg);
221
- } finally {
222
- if (isInitialLoad) {
223
- setIsLoading(false);
256
+ debug(`Session refresh complete`, { count: pricedSessions.length }, "agent-sessions");
257
+ } catch (err) {
258
+ const errorMsg = err instanceof Error ? err.message : "Unknown error";
259
+ logError(`Session refresh failed: ${errorMsg}`, undefined, "agent-sessions");
260
+ setError(errorMsg);
261
+ } finally {
262
+ isRefreshingRef.current = false;
263
+ if (isInitialLoad) {
264
+ setIsLoading(false);
265
+ }
224
266
  }
225
- }
226
- }, [sessions.length, discoverAgents, fetchAgentSessions, debug, warn, logError, storageReady, recordAgentSession, demoMode]);
267
+ },
268
+ [
269
+ sessions.length,
270
+ discoverAgents,
271
+ fetchAgentSessions,
272
+ debug,
273
+ warn,
274
+ logError,
275
+ storageReady,
276
+ recordAgentSession,
277
+ demoMode,
278
+ ],
279
+ );
227
280
 
228
281
  const refreshSessionsRef = useRef(refreshSessions);
229
282
  refreshSessionsRef.current = refreshSessions;
@@ -243,15 +296,15 @@ export function AgentSessionProvider({
243
296
  useEffect(() => {
244
297
  if (!autoRefresh) return;
245
298
 
246
- debug(`Setting up session refresh interval: ${refreshInterval}ms`, undefined, 'agent-sessions');
247
-
299
+ debug(`Setting up session refresh interval: ${refreshInterval}ms`, undefined, "agent-sessions");
300
+
248
301
  const intervalId = setInterval(() => {
249
- debug('Interval tick - refreshing sessions', undefined, 'agent-sessions');
302
+ debug("Interval tick - refreshing sessions", undefined, "agent-sessions");
250
303
  refreshSessionsRef.current();
251
304
  }, refreshInterval);
252
305
 
253
306
  return () => {
254
- debug('Clearing session refresh interval', undefined, 'agent-sessions');
307
+ debug("Clearing session refresh interval", undefined, "agent-sessions");
255
308
  clearInterval(intervalId);
256
309
  };
257
310
  }, [autoRefresh, refreshInterval, debug]);
@@ -265,29 +318,25 @@ export function AgentSessionProvider({
265
318
  refreshSessions,
266
319
  };
267
320
 
268
- return (
269
- <AgentSessionContext.Provider value={value}>
270
- {children}
271
- </AgentSessionContext.Provider>
272
- );
321
+ return <AgentSessionContext.Provider value={value}>{children}</AgentSessionContext.Provider>;
273
322
  }
274
323
 
275
324
  export function useAgentSessions(): AgentSessionContextValue {
276
325
  const context = useContext(AgentSessionContext);
277
326
  if (!context) {
278
- throw new Error('useAgentSessions must be used within AgentSessionProvider');
327
+ throw new Error("useAgentSessions must be used within AgentSessionProvider");
279
328
  }
280
329
  return context;
281
330
  }
282
331
 
283
332
  export function useActiveSessions(): AgentSessionAggregate[] {
284
333
  const { sessions } = useAgentSessions();
285
- return sessions.filter(s => s.status === 'active');
334
+ return sessions.filter((s) => s.status === "active");
286
335
  }
287
336
 
288
337
  export function useSessionsByAgent(agentId: AgentId): AgentSessionAggregate[] {
289
338
  const { sessions } = useAgentSessions();
290
- return sessions.filter(s => s.agentId === agentId);
339
+ return sessions.filter((s) => s.agentId === agentId);
291
340
  }
292
341
 
293
342
  export function useTotalCost(): number {
@@ -302,6 +351,6 @@ export function useTotalTokens(): { input: number; output: number } {
302
351
  input: acc.input + s.totals.input,
303
352
  output: acc.output + s.totals.output,
304
353
  }),
305
- { input: 0, output: 0 }
354
+ { input: 0, output: 0 },
306
355
  );
307
356
  }
@@ -1,14 +1,14 @@
1
- import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
2
- import { loadConfig, saveConfig, DEFAULT_CONFIG, type AppConfig } from '@/config/schema.ts';
3
- import { useDemoMode } from './DemoModeContext.tsx';
1
+ import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from "react";
2
+ import { type AppConfig, DEFAULT_CONFIG, loadConfig, saveConfig } from "@/config/schema.ts";
3
+ import { useDemoMode } from "./DemoModeContext.tsx";
4
4
 
5
5
  interface ConfigContextValue {
6
6
  config: AppConfig;
7
7
  isLoading: boolean;
8
8
  updateConfig: (updates: Partial<AppConfig>) => void;
9
- updateRefresh: (updates: Partial<AppConfig['refresh']>) => void;
10
- updateDisplay: (updates: Partial<AppConfig['display']>) => void;
11
- updateNotifications: (updates: Partial<AppConfig['notifications']>) => void;
9
+ updateRefresh: (updates: Partial<AppConfig["refresh"]>) => void;
10
+ updateDisplay: (updates: Partial<AppConfig["display"]>) => void;
11
+ updateNotifications: (updates: Partial<AppConfig["notifications"]>) => void;
12
12
  resetToDefaults: () => void;
13
13
  saveNow: () => Promise<void>;
14
14
  }
@@ -32,21 +32,23 @@ export function ConfigProvider({ children, initialConfig }: ConfigProviderProps)
32
32
  },
33
33
  display: {
34
34
  ...DEFAULT_CONFIG.display,
35
- defaultTimeWindow: '1h',
35
+ defaultTimeWindow: "1h",
36
36
  sidebarCollapsed: false,
37
37
  },
38
38
  budgets: {
39
39
  daily: 50,
40
40
  weekly: 200,
41
41
  monthly: 800,
42
- currency: 'USD',
42
+ currency: "USD",
43
43
  },
44
44
  alerts: {
45
45
  ...DEFAULT_CONFIG.alerts,
46
46
  criticalPercent: 90,
47
47
  },
48
48
  };
49
- const [config, setConfig] = useState<AppConfig>(initialConfig ?? (demoMode ? demoConfig : DEFAULT_CONFIG));
49
+ const [config, setConfig] = useState<AppConfig>(
50
+ initialConfig ?? (demoMode ? demoConfig : DEFAULT_CONFIG),
51
+ );
50
52
  const [isLoading, setIsLoading] = useState(!initialConfig);
51
53
  const [pendingSave, setPendingSave] = useState(false);
52
54
 
@@ -79,7 +81,7 @@ export function ConfigProvider({ children, initialConfig }: ConfigProviderProps)
79
81
  setPendingSave(true);
80
82
  }, []);
81
83
 
82
- const updateRefresh = useCallback((updates: Partial<AppConfig['refresh']>) => {
84
+ const updateRefresh = useCallback((updates: Partial<AppConfig["refresh"]>) => {
83
85
  setConfig((prev) => ({
84
86
  ...prev,
85
87
  refresh: { ...prev.refresh, ...updates },
@@ -87,7 +89,7 @@ export function ConfigProvider({ children, initialConfig }: ConfigProviderProps)
87
89
  setPendingSave(true);
88
90
  }, []);
89
91
 
90
- const updateDisplay = useCallback((updates: Partial<AppConfig['display']>) => {
92
+ const updateDisplay = useCallback((updates: Partial<AppConfig["display"]>) => {
91
93
  setConfig((prev) => ({
92
94
  ...prev,
93
95
  display: { ...prev.display, ...updates },
@@ -95,7 +97,7 @@ export function ConfigProvider({ children, initialConfig }: ConfigProviderProps)
95
97
  setPendingSave(true);
96
98
  }, []);
97
99
 
98
- const updateNotifications = useCallback((updates: Partial<AppConfig['notifications']>) => {
100
+ const updateNotifications = useCallback((updates: Partial<AppConfig["notifications"]>) => {
99
101
  setConfig((prev) => ({
100
102
  ...prev,
101
103
  notifications: { ...prev.notifications, ...updates },
@@ -135,7 +137,7 @@ export function ConfigProvider({ children, initialConfig }: ConfigProviderProps)
135
137
  export function useConfig(): ConfigContextValue {
136
138
  const context = useContext(ConfigContext);
137
139
  if (!context) {
138
- throw new Error('useConfig must be used within a ConfigProvider');
140
+ throw new Error("useConfig must be used within a ConfigProvider");
139
141
  }
140
142
  return context;
141
143
  }