@tokentop/ttop 0.3.0 → 0.5.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 (83) hide show
  1. package/package.json +9 -4
  2. package/src/agents/aggregator.ts +8 -1
  3. package/src/agents/types.ts +3 -0
  4. package/src/cli.ts +2 -2
  5. package/src/demo/simulator.ts +1 -1
  6. package/src/plugins/agents/index.ts +2 -0
  7. package/src/plugins/auth-sources.ts +2 -3
  8. package/src/plugins/lifecycle.ts +17 -13
  9. package/src/plugins/loader.test.ts +1 -1
  10. package/src/plugins/loader.ts +15 -7
  11. package/src/plugins/notification-bus.ts +5 -2
  12. package/src/plugins/notifications/terminal-bell.ts +9 -2
  13. package/src/plugins/notifications/visual-flash.ts +13 -3
  14. package/src/plugins/plugin-context-factory.ts +4 -19
  15. package/src/plugins/providers/anthropic.ts +2 -2
  16. package/src/plugins/providers/antigravity.ts +1 -1
  17. package/src/plugins/providers/chutes.ts +189 -0
  18. package/src/plugins/providers/codex.ts +1 -1
  19. package/src/plugins/providers/gemini.ts +1 -1
  20. package/src/plugins/providers/github-copilot.ts +1 -1
  21. package/src/plugins/providers/index.ts +1 -0
  22. package/src/plugins/providers/minimax.ts +1 -1
  23. package/src/plugins/providers/openai-api.ts +1 -1
  24. package/src/plugins/providers/opencode-zen.ts +1 -1
  25. package/src/plugins/providers/perplexity.ts +1 -1
  26. package/src/plugins/providers/zai.ts +4 -2
  27. package/src/plugins/registry.ts +51 -46
  28. package/src/plugins/sandbox-guard.test.ts +1 -1
  29. package/src/plugins/sandbox-guard.ts +1 -1
  30. package/src/plugins/sandbox.ts +1 -1
  31. package/src/plugins/themes/catppuccin-latte.ts +1 -1
  32. package/src/plugins/themes/catppuccin-mocha.ts +1 -1
  33. package/src/plugins/themes/claude-code.ts +1 -1
  34. package/src/plugins/themes/dracula.ts +1 -1
  35. package/src/plugins/themes/github-light.ts +1 -1
  36. package/src/plugins/themes/gruvbox-dark.ts +1 -1
  37. package/src/plugins/themes/gruvbox-light.ts +1 -1
  38. package/src/plugins/themes/kanagawa.ts +1 -1
  39. package/src/plugins/themes/nord.ts +1 -1
  40. package/src/plugins/themes/one-dark.ts +1 -1
  41. package/src/plugins/themes/opencode.ts +1 -1
  42. package/src/plugins/themes/rose-pine-dawn.ts +1 -1
  43. package/src/plugins/themes/rose-pine.ts +1 -1
  44. package/src/plugins/themes/solarized-light.ts +1 -1
  45. package/src/plugins/themes/tokyo-night.ts +1 -1
  46. package/src/plugins/update-checker.ts +2 -2
  47. package/src/pricing/estimator.test.ts +1 -1
  48. package/src/pricing/estimator.ts +1 -1
  49. package/src/pricing/fallback.ts +1 -1
  50. package/src/pricing/index.ts +1 -1
  51. package/src/pricing/models-dev.ts +1 -1
  52. package/src/storage/index.ts +6 -0
  53. package/src/storage/migrations/index.ts +17 -0
  54. package/src/storage/persistence-service.ts +206 -0
  55. package/src/storage/repos/pluginStorage.ts +35 -0
  56. package/src/storage/retention.ts +146 -0
  57. package/src/tui/App.tsx +1 -1
  58. package/src/tui/components/DebugPanel.tsx +23 -10
  59. package/src/tui/components/ProviderCard.tsx +1 -1
  60. package/src/tui/components/ProviderDetailPanel.tsx +1 -1
  61. package/src/tui/components/SessionDetailsDrawer.tsx +10 -0
  62. package/src/tui/components/SessionsTable.tsx +15 -4
  63. package/src/tui/components/SettingsModal.tsx +383 -76
  64. package/src/tui/components/ThemePicker.tsx +1 -1
  65. package/src/tui/contexts/AgentSessionContext.tsx +44 -80
  66. package/src/tui/contexts/PluginContext.tsx +13 -3
  67. package/src/tui/contexts/RealTimeActivityContext.tsx +33 -20
  68. package/src/tui/contexts/StorageContext.tsx +42 -130
  69. package/src/tui/contexts/ThemeContext.tsx +3 -3
  70. package/src/tui/hooks/useExitAnimation.ts +4 -0
  71. package/src/tui/hooks/useSessionEnrichedProviders.ts +1 -1
  72. package/src/tui/views/Dashboard.tsx +40 -3
  73. package/src/tui/views/ProjectsView.tsx +29 -6
  74. package/src/tui/views/RealTimeDashboard.tsx +29 -8
  75. package/src/tui/views/SettingsView.tsx +9 -4
  76. package/src/version.ts +1 -1
  77. package/src/plugins/types/agent.ts +0 -108
  78. package/src/plugins/types/base.ts +0 -278
  79. package/src/plugins/types/index.ts +0 -19
  80. package/src/plugins/types/notification.ts +0 -51
  81. package/src/plugins/types/provider.ts +0 -237
  82. package/src/plugins/types/theme.ts +0 -78
  83. package/src/storage/database.ts +0 -623
@@ -1,3 +1,4 @@
1
+ import type { AgentPlugin, SessionParseOptions } from "@tokentop/plugin-sdk";
1
2
  import {
2
3
  createContext,
3
4
  type ReactNode,
@@ -13,12 +14,11 @@ import type { AgentId, AgentInfo, AgentSessionAggregate } from "@/agents/types.t
13
14
  import { createPluginContext } from "@/plugins/plugin-context-factory.ts";
14
15
  import { pluginRegistry } from "@/plugins/registry.ts";
15
16
  import { createPluginLogger, createSandboxedHttpClient } from "@/plugins/sandbox.ts";
16
- import type { AgentPlugin, SessionParseOptions } from "@/plugins/types/agent.ts";
17
+ import { persistSessions } from "@/storage/persistence-service.ts";
17
18
  import type { PricingSource } from "@/storage/types.ts";
18
19
  import { useDemoMode } from "./DemoModeContext.tsx";
19
20
  import { useLogs } from "./LogContext.tsx";
20
21
  import { usePlugins } from "./PluginContext.tsx";
21
- import { useStorage } from "./StorageContext.tsx";
22
22
  import { useTimeWindow } from "./TimeWindowContext.tsx";
23
23
 
24
24
  interface AgentSessionContextValue {
@@ -32,13 +32,6 @@ interface AgentSessionContextValue {
32
32
 
33
33
  const AgentSessionContext = createContext<AgentSessionContextValue | null>(null);
34
34
 
35
- const MAX_PERSISTENCE_FINGERPRINTS = 500;
36
- const sessionPersistenceFingerprints = new Map<string, string>();
37
-
38
- function sessionFingerprint(session: AgentSessionAggregate): string {
39
- return `${session.lastActivityAt}:${session.totalCostUsd ?? 0}:${session.requestCount}`;
40
- }
41
-
42
35
  interface AgentSessionProviderProps {
43
36
  children: ReactNode;
44
37
  autoRefresh?: boolean;
@@ -57,7 +50,6 @@ export function AgentSessionProvider({
57
50
  const [error, setError] = useState<string | null>(null);
58
51
  const { debug, warn, error: logError } = useLogs();
59
52
  const { isInitialized: pluginsInitialized } = usePlugins();
60
- const { isReady: storageReady, recordAgentSession } = useStorage();
61
53
  const { demoMode, simulator } = useDemoMode();
62
54
  const { windowMs } = useTimeWindow();
63
55
  const hasBackfilled = useRef(false);
@@ -190,65 +182,47 @@ export function AgentSessionProvider({
190
182
 
191
183
  pricedSessions.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
192
184
 
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",
250
- );
251
- }
185
+ // Persist to storage — the persistence service handles throttling
186
+ // internally (fingerprint gate + 5-minute per-session throttle).
187
+ // Safe to call every tick; state lives on globalThis, survives HMR.
188
+ const now = Date.now();
189
+ const persistData = pricedSessions.map((session) => ({
190
+ session: {
191
+ agentId: session.agentId,
192
+ sessionId: session.sessionId,
193
+ projectPath: session.projectPath ?? null,
194
+ startedAt: session.startedAt ?? null,
195
+ lastSeenAt: now,
196
+ },
197
+ snapshot: {
198
+ timestamp: now,
199
+ lastActivityAt: session.lastActivityAt,
200
+ status: session.status,
201
+ totalInputTokens: session.totals.input,
202
+ totalOutputTokens: session.totals.output,
203
+ totalCacheReadTokens: session.totals.cacheRead ?? 0,
204
+ totalCacheWriteTokens: session.totals.cacheWrite ?? 0,
205
+ totalCostUsd: session.totalCostUsd ?? 0,
206
+ requestCount: session.requestCount,
207
+ },
208
+ streams: session.streams.map((s) => ({
209
+ provider: s.providerId,
210
+ model: s.modelId,
211
+ inputTokens: s.tokens.input,
212
+ outputTokens: s.tokens.output,
213
+ cacheReadTokens: s.tokens.cacheRead ?? 0,
214
+ cacheWriteTokens: s.tokens.cacheWrite ?? 0,
215
+ costUsd: s.costUsd ?? 0,
216
+ requestCount: s.requestCount,
217
+ pricingSource: (s.pricingSource as PricingSource) ?? null,
218
+ })),
219
+ }));
220
+ const persistedCount = persistSessions(persistData);
221
+ debug(
222
+ `Persisted ${persistedCount}/${pricedSessions.length} sessions to storage`,
223
+ undefined,
224
+ "agent-sessions",
225
+ );
252
226
 
253
227
  setSessions(pricedSessions);
254
228
  setLastRefreshAt(Date.now());
@@ -265,17 +239,7 @@ export function AgentSessionProvider({
265
239
  }
266
240
  }
267
241
  },
268
- [
269
- sessions.length,
270
- discoverAgents,
271
- fetchAgentSessions,
272
- debug,
273
- warn,
274
- logError,
275
- storageReady,
276
- recordAgentSession,
277
- demoMode,
278
- ],
242
+ [sessions.length, discoverAgents, fetchAgentSessions, debug, warn, logError, demoMode],
279
243
  );
280
244
 
281
245
  const refreshSessionsRef = useRef(refreshSessions);
@@ -1,3 +1,11 @@
1
+ import type {
2
+ AgentPlugin,
3
+ Credentials,
4
+ NotificationPlugin,
5
+ ProviderPlugin,
6
+ ProviderUsageData,
7
+ ThemePlugin,
8
+ } from "@tokentop/plugin-sdk";
1
9
  import {
2
10
  createContext,
3
11
  type ReactNode,
@@ -14,9 +22,6 @@ import { safeInvoke, safeInvokeSync } from "@/plugins/plugin-host.ts";
14
22
  import { pluginRegistry } from "@/plugins/registry.ts";
15
23
  import { createPluginLogger, createSandboxedHttpClient } from "@/plugins/sandbox.ts";
16
24
  import { installGlobalFetchGuard, runInPluginGuard } from "@/plugins/sandbox-guard.ts";
17
- import type { NotificationPlugin } from "@/plugins/types/notification.ts";
18
- import type { Credentials, ProviderPlugin, ProviderUsageData } from "@/plugins/types/provider.ts";
19
- import type { ThemePlugin } from "@/plugins/types/theme.ts";
20
25
  import { initPricingFromPlugins } from "@/pricing/index.ts";
21
26
  import type { ProviderSnapshotInsert } from "@/storage/types.ts";
22
27
  import { useConfig } from "./ConfigContext.tsx";
@@ -43,6 +48,7 @@ export interface ProviderState {
43
48
 
44
49
  interface PluginContextValue {
45
50
  providers: Map<string, ProviderState>;
51
+ agents: AgentPlugin[];
46
52
  themes: ThemePlugin[];
47
53
  notifications: NotificationPlugin[];
48
54
  isInitialized: boolean;
@@ -87,6 +93,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
87
93
  const [isInitialized, setIsInitialized] = useState(false);
88
94
  const [providers, setProviders] = useState<Map<string, ProviderState>>(new Map());
89
95
  const [themes, setThemes] = useState<ThemePlugin[]>([]);
96
+ const [agents, setAgents] = useState<AgentPlugin[]>([]);
90
97
  const [notifications, setNotifications] = useState<NotificationPlugin[]>([]);
91
98
  const { demoMode, simulator } = useDemoMode();
92
99
  const { debug, info, warn, error: logError } = useLogs();
@@ -116,6 +123,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
116
123
  }
117
124
 
118
125
  const providerPlugins = pluginRegistry.getAll("provider");
126
+ const agentPlugins = pluginRegistry.getAll("agent");
119
127
  const themePlugins = pluginRegistry.getAll("theme");
120
128
  const notificationPlugins = pluginRegistry.getAll("notification");
121
129
 
@@ -221,6 +229,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
221
229
  notificationBus.registerPlugins(notificationPlugins);
222
230
 
223
231
  setProviders(providerStates);
232
+ setAgents(agentPlugins);
224
233
  setThemes(themePlugins);
225
234
  setNotifications(notificationPlugins);
226
235
  setIsInitialized(true);
@@ -444,6 +453,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
444
453
 
445
454
  const value: PluginContextValue = {
446
455
  providers,
456
+ agents,
447
457
  themes,
448
458
  notifications,
449
459
  isInitialized,
@@ -1,3 +1,4 @@
1
+ import type { ActivityUpdate } from "@tokentop/plugin-sdk";
1
2
  import {
2
3
  createContext,
3
4
  type ReactNode,
@@ -9,7 +10,6 @@ import {
9
10
  } from "react";
10
11
  import { createPluginContext } from "@/plugins/plugin-context-factory.ts";
11
12
  import { pluginRegistry } from "@/plugins/registry.ts";
12
- import type { ActivityUpdate } from "@/plugins/types/agent.ts";
13
13
  import { useDemoMode } from "./DemoModeContext.tsx";
14
14
  import { useLogs } from "./LogContext.tsx";
15
15
  import { usePlugins } from "./PluginContext.tsx";
@@ -50,25 +50,38 @@ export function RealTimeActivityProvider({ children }: { children: ReactNode })
50
50
  };
51
51
  }, []);
52
52
 
53
- const handleActivityUpdate = useCallback(
54
- (update: ActivityUpdate) => {
55
- const key = `${update.sessionId}:${update.messageId}`;
56
- const prev = lastSeenRef.current.get(key);
57
- const prevTokens = prev?.tokens ?? 0;
58
- const newTokens = update.tokens.input + update.tokens.output + (update.tokens.reasoning ?? 0);
59
- const delta = Math.max(0, newTokens - prevTokens);
60
-
61
- if (delta > 0) {
62
- lastSeenRef.current.set(key, { tokens: newTokens, updatedAt: Date.now() });
63
- setLastActivityAt(update.timestamp);
64
- debug(`Real-time activity: +${delta} tokens`, { sessionId: update.sessionId }, "realtime");
65
-
66
- for (const listener of listenersRef.current) {
67
- listener(delta, update.timestamp);
68
- }
53
+ // Store the actual handler logic in a ref so it always has access to the
54
+ // latest `debug` without changing callback identity. This prevents the
55
+ // useEffect below from re-running on every render (which would trigger
56
+ // stopActivityWatch/startActivityWatch churn on every plugin).
57
+ const handleActivityUpdateRef = useRef<(update: ActivityUpdate) => void>(() => {});
58
+ handleActivityUpdateRef.current = (update: ActivityUpdate) => {
59
+ const key = `${update.sessionId}:${update.messageId}`;
60
+ const prev = lastSeenRef.current.get(key);
61
+ const prevTokens = prev?.tokens ?? 0;
62
+ const input = update.tokens.input ?? 0;
63
+ const output = update.tokens.output ?? 0;
64
+ const reasoning = update.tokens.reasoning ?? 0;
65
+ const newTokens = input + output + reasoning;
66
+ if (!Number.isFinite(newTokens)) return;
67
+ const delta = Math.max(0, newTokens - prevTokens);
68
+
69
+ if (delta > 0) {
70
+ lastSeenRef.current.set(key, { tokens: newTokens, updatedAt: Date.now() });
71
+ setLastActivityAt(update.timestamp);
72
+ debug(`Real-time activity: +${delta} tokens`, { sessionId: update.sessionId }, "realtime");
73
+
74
+ for (const listener of listenersRef.current) {
75
+ listener(delta, update.timestamp);
69
76
  }
70
- },
71
- [debug],
77
+ }
78
+ };
79
+
80
+ // Stable wrapper — identity never changes, so the useEffect below only
81
+ // runs once per plugin initialization (not on every debug/info change).
82
+ const handleActivityUpdate = useCallback(
83
+ (update: ActivityUpdate) => handleActivityUpdateRef.current(update),
84
+ [],
72
85
  );
73
86
 
74
87
  useEffect(() => {
@@ -106,7 +119,7 @@ export function RealTimeActivityProvider({ children }: { children: ReactNode })
106
119
  cleanupRef.current = null;
107
120
  }
108
121
  };
109
- }, [pluginsInitialized, demoMode, handleActivityUpdate, info]);
122
+ }, [pluginsInitialized, demoMode, handleActivityUpdate]);
110
123
 
111
124
  useEffect(() => {
112
125
  const interval = setInterval(() => {
@@ -9,22 +9,11 @@ import {
9
9
  useState,
10
10
  } from "react";
11
11
  import { closeDatabase, getAppRunId, initDatabase, isDatabaseInitialized } from "@/storage/db.ts";
12
- import {
13
- getLatestStreamTotalsForAllSessions,
14
- insertAgentSessionSnapshot,
15
- upsertAgentSession,
16
- } from "@/storage/repos/agentSessions.ts";
12
+ import { seedPreviousTotals } from "@/storage/persistence-service.ts";
17
13
  import { insertProviderSnapshotBatch } from "@/storage/repos/providerSnapshots.ts";
18
14
  import { insertUsageEventBatch } from "@/storage/repos/usageEvents.ts";
19
- import type {
20
- AgentSessionSnapshotInsert,
21
- AgentSessionStreamSnapshotRow,
22
- AgentSessionUpsert,
23
- ProviderSnapshotInsert,
24
- StreamTotals,
25
- UsageEventInsert,
26
- } from "@/storage/types.ts";
27
- import { computeStreamDelta } from "@/storage/types.ts";
15
+ import { incrementalVacuum, pruneOldData } from "@/storage/retention.ts";
16
+ import type { ProviderSnapshotInsert, UsageEventInsert } from "@/storage/types.ts";
28
17
  import { useDemoMode } from "./DemoModeContext.tsx";
29
18
 
30
19
  interface StorageContextValue {
@@ -32,29 +21,12 @@ interface StorageContextValue {
32
21
  appRunId: number | null;
33
22
  recordProviderSnapshots: (snapshots: ProviderSnapshotInsert[]) => void;
34
23
  recordUsageEvents: (events: UsageEventInsert[]) => void;
35
- recordAgentSession: (
36
- session: AgentSessionUpsert,
37
- snapshot: Omit<AgentSessionSnapshotInsert, "agentSessionId">,
38
- streams: Omit<AgentSessionStreamSnapshotRow, "agentSessionSnapshotId">[],
39
- ) => number | null;
40
24
  }
41
25
 
42
26
  const StorageContext = createContext<StorageContextValue | null>(null);
43
27
 
44
- const SNAPSHOT_INTERVAL_MS = 60_000;
45
- const MAX_TRACKED_SESSIONS = 1_000;
46
- const MAX_TRACKED_STREAMS = 5_000;
47
-
48
- function capMapSize<K, V>(map: Map<K, V>, max: number): void {
49
- if (map.size <= max) return;
50
- const excess = map.size - max;
51
- const keys = map.keys();
52
- for (let i = 0; i < excess; i++) {
53
- const next = keys.next();
54
- if (next.done) break;
55
- map.delete(next.value);
56
- }
57
- }
28
+ const PROVIDER_SNAPSHOT_INTERVAL_MS = 300_000;
29
+ const PRUNE_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
58
30
 
59
31
  interface StorageProviderProps {
60
32
  children: ReactNode;
@@ -64,8 +36,6 @@ export function StorageProvider({ children }: StorageProviderProps) {
64
36
  const [isReady, setIsReady] = useState(false);
65
37
  const [appRunId, setAppRunId] = useState<number | null>(null);
66
38
  const lastProviderSnapshotRef = useRef<Map<string, number>>(new Map());
67
- const lastSessionSnapshotRef = useRef<Map<string, number>>(new Map());
68
- const previousTotalsRef = useRef<Map<string, StreamTotals>>(new Map());
69
39
  const { demoMode, simulator } = useDemoMode();
70
40
 
71
41
  useEffect(() => {
@@ -81,23 +51,24 @@ export function StorageProvider({ children }: StorageProviderProps) {
81
51
  async function init() {
82
52
  try {
83
53
  await initDatabase();
84
- if (mounted) {
85
- try {
86
- const latestTotals = getLatestStreamTotalsForAllSessions();
87
- for (const row of latestTotals) {
88
- const streamKey = `${row.agentId}:${row.sessionId}:${row.provider}:${row.model}`;
89
- previousTotalsRef.current.set(streamKey, {
90
- inputTokens: row.inputTokens,
91
- outputTokens: row.outputTokens,
92
- cacheReadTokens: row.cacheReadTokens,
93
- cacheWriteTokens: row.cacheWriteTokens,
94
- costUsd: row.costUsd,
95
- requestCount: row.requestCount,
96
- });
97
- }
98
- } catch (err) {
99
- console.error("Failed to seed previous totals from DB:", err);
54
+
55
+ // Prune expired snapshots before seeding — keeps startup fast
56
+ try {
57
+ const pruneResult = pruneOldData();
58
+ if (pruneResult.agentSessionSnapshots > 0 || pruneResult.providerSnapshots > 0) {
59
+ console.log(
60
+ `[retention] Pruned ${pruneResult.agentSessionSnapshots} session snapshots, ` +
61
+ `${pruneResult.providerSnapshots} provider snapshots in ${pruneResult.durationMs}ms`,
62
+ );
63
+ incrementalVacuum();
100
64
  }
65
+ } catch (err) {
66
+ console.error("Failed to prune old data:", err);
67
+ }
68
+
69
+ if (mounted) {
70
+ // Seed the persistence service with previous stream totals from DB
71
+ seedPreviousTotals();
101
72
  setIsReady(true);
102
73
  setAppRunId(getAppRunId());
103
74
  }
@@ -123,7 +94,7 @@ export function StorageProvider({ children }: StorageProviderProps) {
123
94
  const now = Date.now();
124
95
  const filtered = snapshots.filter((s) => {
125
96
  const lastTs = lastProviderSnapshotRef.current.get(s.provider) ?? 0;
126
- return now - lastTs >= SNAPSHOT_INTERVAL_MS;
97
+ return now - lastTs >= PROVIDER_SNAPSHOT_INTERVAL_MS;
127
98
  });
128
99
 
129
100
  if (filtered.length === 0) return;
@@ -153,82 +124,6 @@ export function StorageProvider({ children }: StorageProviderProps) {
153
124
  [isReady, demoMode],
154
125
  );
155
126
 
156
- const recordAgentSession = useCallback(
157
- (
158
- session: AgentSessionUpsert,
159
- snapshot: Omit<AgentSessionSnapshotInsert, "agentSessionId">,
160
- streams: Omit<AgentSessionStreamSnapshotRow, "agentSessionSnapshotId">[],
161
- ): number | null => {
162
- if (!isReady || demoMode) return null;
163
-
164
- const sessionKey = `${session.agentId}:${session.sessionId}`;
165
- const now = Date.now();
166
- const lastTs = lastSessionSnapshotRef.current.get(sessionKey) ?? 0;
167
-
168
- if (now - lastTs < SNAPSHOT_INTERVAL_MS) {
169
- return null;
170
- }
171
-
172
- try {
173
- const agentSessionId = upsertAgentSession(session);
174
-
175
- const snapshotId = insertAgentSessionSnapshot({ ...snapshot, agentSessionId }, streams);
176
-
177
- lastSessionSnapshotRef.current.set(sessionKey, now);
178
-
179
- const usageEvents: UsageEventInsert[] = [];
180
- for (const stream of streams) {
181
- const streamKey = `${sessionKey}:${stream.provider}:${stream.model}`;
182
- const current: StreamTotals = {
183
- inputTokens: stream.inputTokens,
184
- outputTokens: stream.outputTokens,
185
- cacheReadTokens: stream.cacheReadTokens,
186
- cacheWriteTokens: stream.cacheWriteTokens,
187
- costUsd: stream.costUsd,
188
- requestCount: stream.requestCount,
189
- };
190
-
191
- const previous = previousTotalsRef.current.get(streamKey);
192
- const delta = computeStreamDelta(current, previous);
193
-
194
- if (delta) {
195
- usageEvents.push({
196
- timestamp: now,
197
- source: "agent",
198
- provider: stream.provider,
199
- model: stream.model,
200
- agentId: session.agentId,
201
- sessionId: session.sessionId,
202
- projectPath: session.projectPath ?? null,
203
- inputTokens: delta.inputTokens,
204
- outputTokens: delta.outputTokens,
205
- cacheReadTokens: delta.cacheReadTokens,
206
- cacheWriteTokens: delta.cacheWriteTokens,
207
- costUsd: delta.costUsd,
208
- requestCount: delta.requestCount,
209
- pricingSource: stream.pricingSource ?? null,
210
- });
211
- }
212
-
213
- previousTotalsRef.current.set(streamKey, current);
214
- }
215
-
216
- if (usageEvents.length > 0) {
217
- insertUsageEventBatch(usageEvents);
218
- }
219
-
220
- capMapSize(lastSessionSnapshotRef.current, MAX_TRACKED_SESSIONS);
221
- capMapSize(previousTotalsRef.current, MAX_TRACKED_STREAMS);
222
-
223
- return snapshotId;
224
- } catch (err) {
225
- console.error("Failed to record agent session:", err);
226
- return null;
227
- }
228
- },
229
- [isReady, demoMode],
230
- );
231
-
232
127
  useEffect(() => {
233
128
  if (!demoMode || !isReady || !simulator) return;
234
129
 
@@ -239,15 +134,32 @@ export function StorageProvider({ children }: StorageProviderProps) {
239
134
  return () => clearInterval(interval);
240
135
  }, [demoMode, isReady, simulator]);
241
136
 
137
+ // Periodic data retention pruning (every hour)
138
+ useEffect(() => {
139
+ if (demoMode || !isReady) return;
140
+
141
+ const intervalId = setInterval(() => {
142
+ try {
143
+ const result = pruneOldData();
144
+ if (result.agentSessionSnapshots > 0 || result.providerSnapshots > 0) {
145
+ incrementalVacuum();
146
+ }
147
+ } catch {
148
+ // Silently ignore periodic prune failures
149
+ }
150
+ }, PRUNE_INTERVAL_MS);
151
+
152
+ return () => clearInterval(intervalId);
153
+ }, [demoMode, isReady]);
154
+
242
155
  const value: StorageContextValue = useMemo(
243
156
  () => ({
244
157
  isReady,
245
158
  appRunId,
246
159
  recordProviderSnapshots,
247
160
  recordUsageEvents,
248
- recordAgentSession,
249
161
  }),
250
- [isReady, appRunId, recordProviderSnapshots, recordUsageEvents, recordAgentSession],
162
+ [isReady, appRunId, recordProviderSnapshots, recordUsageEvents],
251
163
  );
252
164
 
253
165
  return <StorageContext.Provider value={value}>{children}</StorageContext.Provider>;
@@ -1,12 +1,12 @@
1
- import { createContext, type ReactNode, useContext, useState } from "react";
2
- import { tokyoNightTheme } from "@/plugins/themes/tokyo-night.ts";
3
1
  import type {
4
2
  ColorScheme,
5
3
  ColorSchemePreference,
6
4
  ThemeColors,
7
5
  ThemeComponents,
8
6
  ThemePlugin,
9
- } from "@/plugins/types/theme.ts";
7
+ } from "@tokentop/plugin-sdk";
8
+ import { createContext, type ReactNode, useContext, useState } from "react";
9
+ import { tokyoNightTheme } from "@/plugins/themes/tokyo-night.ts";
10
10
 
11
11
  interface ThemeContextValue {
12
12
  theme: ThemePlugin;
@@ -43,6 +43,10 @@ export function useExitAnimation<T>(
43
43
  for (const [key, entry] of exitingRef.current) {
44
44
  if (now - entry.startTime >= durationMs) {
45
45
  exitingRef.current.delete(key);
46
+ // Also clean from prev refs so the render-time diff
47
+ // doesn't re-add them on the next forceUpdate render.
48
+ prevKeysRef.current.delete(key);
49
+ prevItemsRef.current.delete(key);
46
50
  } else {
47
51
  anyActive = true;
48
52
  }
@@ -1,6 +1,6 @@
1
+ import type { ProviderUsageData } from "@tokentop/plugin-sdk";
1
2
  import { useMemo } from "react";
2
3
  import type { AgentSessionAggregate } from "@/agents/types.ts";
3
- import type { ProviderUsageData } from "@/plugins/types/provider.ts";
4
4
  import type { ProviderState } from "../contexts/PluginContext.tsx";
5
5
 
6
6
  interface ProviderSessionTotals {
@@ -1,6 +1,6 @@
1
1
  import type { BoxRenderable, InputRenderable, ScrollBoxRenderable } from "@opentui/core";
2
2
  import { useKeyboard, useTerminalDimensions } from "@opentui/react";
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
4
  import { GhostProviderCard } from "../components/GhostProviderCard.tsx";
5
5
  import { ProviderAggregateStrip } from "../components/ProviderAggregateStrip.tsx";
6
6
  import { ProviderCard } from "../components/ProviderCard.tsx";
@@ -39,6 +39,7 @@ export function Dashboard() {
39
39
  const isFilteringRef = useRef(isFiltering);
40
40
  const expandedIndexRef = useRef(expandedIndex);
41
41
  const focusedIndexRef = useRef(focusedIndex);
42
+ const selectedProviderIdRef = useRef<string | null>(null);
42
43
 
43
44
  useEffect(() => {
44
45
  isFilteringRef.current = isFiltering;
@@ -49,7 +50,6 @@ export function Dashboard() {
49
50
  useEffect(() => {
50
51
  focusedIndexRef.current = focusedIndex;
51
52
  }, [focusedIndex]);
52
-
53
53
  useEffect(() => {
54
54
  setInputFocused(isFiltering);
55
55
  return () => setInputFocused(false);
@@ -80,7 +80,7 @@ export function Dashboard() {
80
80
  return Math.max(primary, secondary);
81
81
  }, []);
82
82
 
83
- const providerList = Array.from(providers.values());
83
+ const providerList = useMemo(() => Array.from(providers.values()), [providers]);
84
84
 
85
85
  const filteredAndSortedProviders = useMemo(() => {
86
86
  let result = providerList.filter((p) => p.configured);
@@ -127,6 +127,43 @@ export function Dashboard() {
127
127
  const configured = filteredAndSortedProviders;
128
128
  const unconfigured = useMemo(() => providerList.filter((p) => !p.configured), [providerList]);
129
129
 
130
+ // Stabilize selection across re-sorts: track selected provider by ID
131
+ // so the highlight follows the same provider when live data causes reordering.
132
+ useLayoutEffect(() => {
133
+ if (focusedIndex === null) return;
134
+
135
+ if (selectedProviderIdRef.current && focusedIndex < configured.length) {
136
+ // Early return if current index already points to tracked provider
137
+ // (prevents infinite re-render loop when providerList creates new array refs)
138
+ if (configured[focusedIndex]?.plugin.id === selectedProviderIdRef.current) return;
139
+
140
+ const newIndex = configured.findIndex((p) => p.plugin.id === selectedProviderIdRef.current);
141
+ if (newIndex !== -1) {
142
+ if (newIndex !== focusedIndex) setFocusedIndex(newIndex);
143
+ return;
144
+ }
145
+ selectedProviderIdRef.current = null;
146
+ }
147
+
148
+ const includeUnconfigured = viewMode === "cards" && showUnconfigured;
149
+ const total = configured.length + (includeUnconfigured ? unconfigured.length : 0);
150
+ if (total === 0) {
151
+ setFocusedIndex(null);
152
+ } else if (focusedIndex >= total) {
153
+ setFocusedIndex(total - 1);
154
+ }
155
+ }, [filteredAndSortedProviders, unconfigured.length, showUnconfigured, viewMode]);
156
+
157
+ // Record which provider the user selected
158
+ useEffect(() => {
159
+ if (focusedIndex !== null && focusedIndex < configured.length) {
160
+ const provider = configured[focusedIndex];
161
+ if (provider) selectedProviderIdRef.current = provider.plugin.id;
162
+ } else {
163
+ selectedProviderIdRef.current = null;
164
+ }
165
+ }, [focusedIndex, configured]);
166
+
130
167
  const cycleFocus = useCallback(
131
168
  (direction: 1 | -1) => {
132
169
  const includeUnconfigured = viewMode === "cards" && showUnconfigured;