@tokentop/ttop 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/plugins/lifecycle.ts +17 -13
- package/src/plugins/notifications/terminal-bell.ts +8 -1
- package/src/plugins/notifications/visual-flash.ts +12 -2
- package/src/plugins/providers/zai.ts +3 -1
- package/src/plugins/registry.ts +42 -37
- package/src/storage/persistence-service.ts +206 -0
- package/src/storage/retention.ts +146 -0
- package/src/tui/components/DebugPanel.tsx +23 -10
- package/src/tui/components/SessionsTable.tsx +6 -2
- package/src/tui/components/SettingsModal.tsx +383 -76
- package/src/tui/contexts/AgentSessionContext.tsx +43 -79
- package/src/tui/contexts/PluginContext.tsx +6 -0
- package/src/tui/contexts/StorageContext.tsx +42 -130
- package/src/tui/hooks/useExitAnimation.ts +4 -0
- 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 +8 -3
- package/src/version.ts +1 -1
|
@@ -14,11 +14,11 @@ import { createPluginContext } from "@/plugins/plugin-context-factory.ts";
|
|
|
14
14
|
import { pluginRegistry } from "@/plugins/registry.ts";
|
|
15
15
|
import { createPluginLogger, createSandboxedHttpClient } from "@/plugins/sandbox.ts";
|
|
16
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
|
-
|
|
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);
|
|
@@ -14,6 +14,7 @@ import { safeInvoke, safeInvokeSync } from "@/plugins/plugin-host.ts";
|
|
|
14
14
|
import { pluginRegistry } from "@/plugins/registry.ts";
|
|
15
15
|
import { createPluginLogger, createSandboxedHttpClient } from "@/plugins/sandbox.ts";
|
|
16
16
|
import { installGlobalFetchGuard, runInPluginGuard } from "@/plugins/sandbox-guard.ts";
|
|
17
|
+
import type { AgentPlugin } from "@/plugins/types/agent.ts";
|
|
17
18
|
import type { NotificationPlugin } from "@/plugins/types/notification.ts";
|
|
18
19
|
import type { Credentials, ProviderPlugin, ProviderUsageData } from "@/plugins/types/provider.ts";
|
|
19
20
|
import type { ThemePlugin } from "@/plugins/types/theme.ts";
|
|
@@ -43,6 +44,7 @@ export interface ProviderState {
|
|
|
43
44
|
|
|
44
45
|
interface PluginContextValue {
|
|
45
46
|
providers: Map<string, ProviderState>;
|
|
47
|
+
agents: AgentPlugin[];
|
|
46
48
|
themes: ThemePlugin[];
|
|
47
49
|
notifications: NotificationPlugin[];
|
|
48
50
|
isInitialized: boolean;
|
|
@@ -87,6 +89,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
87
89
|
const [isInitialized, setIsInitialized] = useState(false);
|
|
88
90
|
const [providers, setProviders] = useState<Map<string, ProviderState>>(new Map());
|
|
89
91
|
const [themes, setThemes] = useState<ThemePlugin[]>([]);
|
|
92
|
+
const [agents, setAgents] = useState<AgentPlugin[]>([]);
|
|
90
93
|
const [notifications, setNotifications] = useState<NotificationPlugin[]>([]);
|
|
91
94
|
const { demoMode, simulator } = useDemoMode();
|
|
92
95
|
const { debug, info, warn, error: logError } = useLogs();
|
|
@@ -116,6 +119,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
const providerPlugins = pluginRegistry.getAll("provider");
|
|
122
|
+
const agentPlugins = pluginRegistry.getAll("agent");
|
|
119
123
|
const themePlugins = pluginRegistry.getAll("theme");
|
|
120
124
|
const notificationPlugins = pluginRegistry.getAll("notification");
|
|
121
125
|
|
|
@@ -221,6 +225,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
221
225
|
notificationBus.registerPlugins(notificationPlugins);
|
|
222
226
|
|
|
223
227
|
setProviders(providerStates);
|
|
228
|
+
setAgents(agentPlugins);
|
|
224
229
|
setThemes(themePlugins);
|
|
225
230
|
setNotifications(notificationPlugins);
|
|
226
231
|
setIsInitialized(true);
|
|
@@ -444,6 +449,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
|
|
|
444
449
|
|
|
445
450
|
const value: PluginContextValue = {
|
|
446
451
|
providers,
|
|
452
|
+
agents,
|
|
447
453
|
themes,
|
|
448
454
|
notifications,
|
|
449
455
|
isInitialized,
|
|
@@ -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>;
|
|
@@ -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
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;
|
|
@@ -1202,6 +1202,7 @@ export function ProjectsView() {
|
|
|
1202
1202
|
const [isFiltering, setIsFiltering] = useState(false);
|
|
1203
1203
|
const scrollOffsetRef = useRef(0);
|
|
1204
1204
|
const scrollboxRef = useRef<ScrollBoxRenderable>(null);
|
|
1205
|
+
const selectedProjectPathRef = useRef<string | null>(null);
|
|
1205
1206
|
|
|
1206
1207
|
// Aggregate projects
|
|
1207
1208
|
const projectStats = useMemo(
|
|
@@ -1252,16 +1253,38 @@ export function ProjectsView() {
|
|
|
1252
1253
|
// Visible columns
|
|
1253
1254
|
const visibleColumns: ColumnSpec[] = useMemo(() => getVisibleColumns(termWidth), [termWidth]);
|
|
1254
1255
|
|
|
1255
|
-
//
|
|
1256
|
-
|
|
1256
|
+
// Stabilize selection across re-sorts: track selected project by path
|
|
1257
|
+
// so the highlight follows the same project when live data causes reordering.
|
|
1258
|
+
useLayoutEffect(() => {
|
|
1257
1259
|
if (sortedProjects.length === 0) {
|
|
1258
|
-
|
|
1260
|
+
setSelectedIndex(0);
|
|
1259
1261
|
scrollOffsetRef.current = 0;
|
|
1260
1262
|
scrollboxRef.current?.scrollTo(0);
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
+
selectedProjectPathRef.current = null;
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
if (selectedProjectPathRef.current) {
|
|
1268
|
+
const newIndex = sortedProjects.findIndex((p) => p.path === selectedProjectPathRef.current);
|
|
1269
|
+
if (newIndex !== -1) {
|
|
1270
|
+
setSelectedIndex(newIndex);
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
// Tracked project was filtered out — fall through to clamp
|
|
1274
|
+
selectedProjectPathRef.current = null;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// Clamp to valid range (functional update avoids selectedIndex in deps)
|
|
1278
|
+
setSelectedIndex((prev) => Math.min(prev, sortedProjects.length - 1));
|
|
1279
|
+
}, [sortedProjects]);
|
|
1280
|
+
|
|
1281
|
+
// Record which project the user selected
|
|
1282
|
+
useEffect(() => {
|
|
1283
|
+
const project = sortedProjects[selectedIndex];
|
|
1284
|
+
if (project) {
|
|
1285
|
+
selectedProjectPathRef.current = project.path;
|
|
1263
1286
|
}
|
|
1264
|
-
}, [
|
|
1287
|
+
}, [selectedIndex, sortedProjects]);
|
|
1265
1288
|
|
|
1266
1289
|
// Scroll to keep selected row visible
|
|
1267
1290
|
const visibleRows = useMemo(() => {
|
|
@@ -64,6 +64,7 @@ export function RealTimeDashboard() {
|
|
|
64
64
|
const [sortField, setSortField] = useState<"cost" | "tokens" | "time">("cost");
|
|
65
65
|
const [pendingG, setPendingG] = useState(false);
|
|
66
66
|
const scrollOffsetRef = useRef(0);
|
|
67
|
+
const selectedSessionIdRef = useRef<string | null>(null);
|
|
67
68
|
const setScrollOffset = useCallback((val: number) => {
|
|
68
69
|
scrollOffsetRef.current = val;
|
|
69
70
|
sessionsScrollboxRef.current?.scrollTo(val);
|
|
@@ -192,19 +193,39 @@ export function RealTimeDashboard() {
|
|
|
192
193
|
return result;
|
|
193
194
|
}, [baseFilteredSessions, activeDriverFilter, driverDimension, sortField]);
|
|
194
195
|
|
|
195
|
-
|
|
196
|
+
// Stabilize selection across re-sorts: track selected session by ID
|
|
197
|
+
// so the highlight follows the same session when live data causes reordering.
|
|
198
|
+
useLayoutEffect(() => {
|
|
196
199
|
if (processedSessions.length === 0) {
|
|
197
|
-
|
|
198
|
-
|
|
200
|
+
setSelectedRow(0);
|
|
201
|
+
setScrollOffset(0);
|
|
202
|
+
selectedSessionIdRef.current = null;
|
|
199
203
|
return;
|
|
200
204
|
}
|
|
201
205
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
+
if (selectedSessionIdRef.current) {
|
|
207
|
+
const newIndex = processedSessions.findIndex(
|
|
208
|
+
(s) => s.sessionId === selectedSessionIdRef.current,
|
|
209
|
+
);
|
|
210
|
+
if (newIndex !== -1) {
|
|
211
|
+
setSelectedRow(newIndex);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// Tracked session was filtered out — fall through to clamp
|
|
215
|
+
selectedSessionIdRef.current = null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Clamp to valid range (functional update avoids selectedRow in deps)
|
|
219
|
+
setSelectedRow((prev) => Math.min(prev, processedSessions.length - 1));
|
|
220
|
+
}, [processedSessions, setScrollOffset]);
|
|
221
|
+
|
|
222
|
+
// Record which session the user selected (runs after navigation and stabilization)
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
const session = processedSessions[selectedRow];
|
|
225
|
+
if (session) {
|
|
226
|
+
selectedSessionIdRef.current = session.sessionId;
|
|
206
227
|
}
|
|
207
|
-
}, [
|
|
228
|
+
}, [selectedRow, processedSessions]);
|
|
208
229
|
|
|
209
230
|
useLayoutEffect(() => {
|
|
210
231
|
if (!sessionsScrollboxRef.current || processedSessions.length === 0) return;
|
|
@@ -284,7 +284,7 @@ export function SettingsView() {
|
|
|
284
284
|
const colors = useColors();
|
|
285
285
|
const { showToast } = useToastContext();
|
|
286
286
|
const { config, isLoading, updateConfig, resetToDefaults, saveNow } = useConfig();
|
|
287
|
-
const { providers, notifications } = usePlugins();
|
|
287
|
+
const { providers, agents, themes, notifications } = usePlugins();
|
|
288
288
|
const { demoMode, seed, preset } = useDemoMode();
|
|
289
289
|
const { width: terminalWidth } = useTerminalDimensions();
|
|
290
290
|
|
|
@@ -293,9 +293,14 @@ export function SettingsView() {
|
|
|
293
293
|
const [focusedPane, setFocusedPane] = useState<"categories" | "settings">("settings");
|
|
294
294
|
|
|
295
295
|
const pluginSettings = useMemo(() => {
|
|
296
|
-
const allPlugins = [
|
|
296
|
+
const allPlugins = [
|
|
297
|
+
...Array.from(providers.values()).map((p) => p.plugin),
|
|
298
|
+
...agents,
|
|
299
|
+
...themes,
|
|
300
|
+
...notifications,
|
|
301
|
+
];
|
|
297
302
|
return buildPluginSettings(allPlugins);
|
|
298
|
-
}, [providers,
|
|
303
|
+
}, [providers, agents, themes, notifications]);
|
|
299
304
|
|
|
300
305
|
const allSettings = useMemo(() => [...SETTINGS, ...pluginSettings], [pluginSettings]);
|
|
301
306
|
const categorySettings = allSettings.filter((s) => s.category === selectedCategory);
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.4.0"; // x-release-please-version
|