@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.
@@ -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
- 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);
@@ -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 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>;
@@ -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
- // Clamp selection
1256
- useEffect(() => {
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
- if (selectedIndex !== 0) setSelectedIndex(0);
1260
+ setSelectedIndex(0);
1259
1261
  scrollOffsetRef.current = 0;
1260
1262
  scrollboxRef.current?.scrollTo(0);
1261
- } else if (selectedIndex >= sortedProjects.length) {
1262
- setSelectedIndex(sortedProjects.length - 1);
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
- }, [sortedProjects.length, selectedIndex]);
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
- useEffect(() => {
196
+ // Stabilize selection across re-sorts: track selected session by ID
197
+ // so the highlight follows the same session when live data causes reordering.
198
+ useLayoutEffect(() => {
196
199
  if (processedSessions.length === 0) {
197
- if (selectedRow !== 0) setSelectedRow(0);
198
- if (scrollOffsetRef.current !== 0) setScrollOffset(0);
200
+ setSelectedRow(0);
201
+ setScrollOffset(0);
202
+ selectedSessionIdRef.current = null;
199
203
  return;
200
204
  }
201
205
 
202
- const maxRow = processedSessions.length - 1;
203
- if (selectedRow > maxRow) {
204
- setSelectedRow(maxRow);
205
- return;
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
- }, [processedSessions.length, selectedRow, setScrollOffset]);
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 = [...Array.from(providers.values()).map((p) => p.plugin), ...notifications];
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, notifications, config]);
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.3.0"; // x-release-please-version
1
+ export const VERSION = "0.4.0"; // x-release-please-version