@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.
- package/package.json +9 -4
- package/src/agents/aggregator.ts +8 -1
- package/src/agents/types.ts +3 -0
- package/src/cli.ts +2 -2
- package/src/demo/simulator.ts +1 -1
- package/src/plugins/agents/index.ts +2 -0
- package/src/plugins/auth-sources.ts +2 -3
- package/src/plugins/lifecycle.ts +17 -13
- package/src/plugins/loader.test.ts +1 -1
- package/src/plugins/loader.ts +15 -7
- package/src/plugins/notification-bus.ts +5 -2
- package/src/plugins/notifications/terminal-bell.ts +9 -2
- package/src/plugins/notifications/visual-flash.ts +13 -3
- package/src/plugins/plugin-context-factory.ts +4 -19
- package/src/plugins/providers/anthropic.ts +2 -2
- package/src/plugins/providers/antigravity.ts +1 -1
- package/src/plugins/providers/chutes.ts +189 -0
- package/src/plugins/providers/codex.ts +1 -1
- package/src/plugins/providers/gemini.ts +1 -1
- package/src/plugins/providers/github-copilot.ts +1 -1
- package/src/plugins/providers/index.ts +1 -0
- package/src/plugins/providers/minimax.ts +1 -1
- package/src/plugins/providers/openai-api.ts +1 -1
- package/src/plugins/providers/opencode-zen.ts +1 -1
- package/src/plugins/providers/perplexity.ts +1 -1
- package/src/plugins/providers/zai.ts +4 -2
- package/src/plugins/registry.ts +51 -46
- package/src/plugins/sandbox-guard.test.ts +1 -1
- package/src/plugins/sandbox-guard.ts +1 -1
- package/src/plugins/sandbox.ts +1 -1
- package/src/plugins/themes/catppuccin-latte.ts +1 -1
- package/src/plugins/themes/catppuccin-mocha.ts +1 -1
- package/src/plugins/themes/claude-code.ts +1 -1
- package/src/plugins/themes/dracula.ts +1 -1
- package/src/plugins/themes/github-light.ts +1 -1
- package/src/plugins/themes/gruvbox-dark.ts +1 -1
- package/src/plugins/themes/gruvbox-light.ts +1 -1
- package/src/plugins/themes/kanagawa.ts +1 -1
- package/src/plugins/themes/nord.ts +1 -1
- package/src/plugins/themes/one-dark.ts +1 -1
- package/src/plugins/themes/opencode.ts +1 -1
- package/src/plugins/themes/rose-pine-dawn.ts +1 -1
- package/src/plugins/themes/rose-pine.ts +1 -1
- package/src/plugins/themes/solarized-light.ts +1 -1
- package/src/plugins/themes/tokyo-night.ts +1 -1
- package/src/plugins/update-checker.ts +2 -2
- package/src/pricing/estimator.test.ts +1 -1
- package/src/pricing/estimator.ts +1 -1
- package/src/pricing/fallback.ts +1 -1
- package/src/pricing/index.ts +1 -1
- package/src/pricing/models-dev.ts +1 -1
- package/src/storage/index.ts +6 -0
- package/src/storage/migrations/index.ts +17 -0
- package/src/storage/persistence-service.ts +206 -0
- package/src/storage/repos/pluginStorage.ts +35 -0
- package/src/storage/retention.ts +146 -0
- package/src/tui/App.tsx +1 -1
- package/src/tui/components/DebugPanel.tsx +23 -10
- package/src/tui/components/ProviderCard.tsx +1 -1
- package/src/tui/components/ProviderDetailPanel.tsx +1 -1
- package/src/tui/components/SessionDetailsDrawer.tsx +10 -0
- package/src/tui/components/SessionsTable.tsx +15 -4
- package/src/tui/components/SettingsModal.tsx +383 -76
- package/src/tui/components/ThemePicker.tsx +1 -1
- package/src/tui/contexts/AgentSessionContext.tsx +44 -80
- package/src/tui/contexts/PluginContext.tsx +13 -3
- package/src/tui/contexts/RealTimeActivityContext.tsx +33 -20
- package/src/tui/contexts/StorageContext.tsx +42 -130
- package/src/tui/contexts/ThemeContext.tsx +3 -3
- package/src/tui/hooks/useExitAnimation.ts +4 -0
- package/src/tui/hooks/useSessionEnrichedProviders.ts +1 -1
- package/src/tui/views/Dashboard.tsx +40 -3
- package/src/tui/views/ProjectsView.tsx +29 -6
- package/src/tui/views/RealTimeDashboard.tsx +29 -8
- package/src/tui/views/SettingsView.tsx +9 -4
- package/src/version.ts +1 -1
- package/src/plugins/types/agent.ts +0 -108
- package/src/plugins/types/base.ts +0 -278
- package/src/plugins/types/index.ts +0 -19
- package/src/plugins/types/notification.ts +0 -51
- package/src/plugins/types/provider.ts +0 -237
- package/src/plugins/types/theme.ts +0 -78
- 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
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
20
|
-
|
|
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
|
|
45
|
-
const
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 >=
|
|
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
|
|
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 "
|
|
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;
|