@tokentop/ttop 0.6.1 → 0.8.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 (38) hide show
  1. package/README.md +3 -2
  2. package/package.json +9 -6
  3. package/src/agents/aggregator.test.ts +269 -1
  4. package/src/agents/aggregator.ts +196 -8
  5. package/src/agents/costing.test.ts +584 -0
  6. package/src/agents/costing.ts +144 -30
  7. package/src/agents/types.ts +7 -0
  8. package/src/demo/simulator.test.ts +92 -0
  9. package/src/demo/simulator.ts +579 -211
  10. package/src/plugins/agents/index.ts +1 -0
  11. package/src/plugins/plugin-context-factory.ts +9 -1
  12. package/src/plugins/providers/anthropic.test.ts +145 -0
  13. package/src/plugins/providers/anthropic.ts +18 -12
  14. package/src/plugins/providers/chutes.ts +2 -0
  15. package/src/plugins/providers/github-copilot.ts +3 -0
  16. package/src/plugins/providers/minimax.ts +2 -0
  17. package/src/plugins/providers/openai-api.ts +4 -0
  18. package/src/plugins/providers/perplexity.test.ts +24 -0
  19. package/src/plugins/providers/perplexity.ts +1 -0
  20. package/src/plugins/providers/zai.ts +2 -0
  21. package/src/pricing/estimator.ts +7 -0
  22. package/src/pricing/index.ts +5 -0
  23. package/src/pricing/models-dev.test.ts +117 -0
  24. package/src/pricing/models-dev.ts +22 -0
  25. package/src/storage/persistence-service.test.ts +201 -0
  26. package/src/storage/persistence-service.ts +54 -5
  27. package/src/storage/repos/agentSessions.ts +49 -0
  28. package/src/tui/App.tsx +4 -4
  29. package/src/tui/components/SessionDetailsDrawer.tsx +29 -1
  30. package/src/tui/components/SessionsTable.tsx +25 -7
  31. package/src/tui/contexts/AgentSessionContext.tsx +59 -21
  32. package/src/tui/contexts/PluginContext.tsx +31 -10
  33. package/src/tui/utils/scrollFollow.test.ts +71 -0
  34. package/src/tui/utils/scrollFollow.ts +12 -0
  35. package/src/tui/views/Dashboard.tsx +0 -6
  36. package/src/tui/views/RealTimeDashboard.tsx +105 -38
  37. package/src/version.ts +1 -1
  38. package/tsconfig.json +43 -0
@@ -5,10 +5,17 @@ import {
5
5
  useCallback,
6
6
  useContext,
7
7
  useEffect,
8
+ useMemo,
8
9
  useRef,
9
10
  useState,
10
11
  } from "react";
11
- import { aggregateSessionUsage, deduplicateAggregates } from "@/agents/aggregator.ts";
12
+ import {
13
+ aggregateSessionUsage,
14
+ deduplicateAggregates,
15
+ isCrossAgentShadow,
16
+ markCrossAgentShadows,
17
+ selectEffectiveSessions,
18
+ } from "@/agents/aggregator.ts";
12
19
  import { priceSessions } from "@/agents/costing.ts";
13
20
  import type { AgentId, AgentInfo, AgentSessionAggregate } from "@/agents/types.ts";
14
21
  import { createPluginContext } from "@/plugins/plugin-context-factory.ts";
@@ -48,10 +55,12 @@ interface AgentSessionProviderProps {
48
55
  */
49
56
  function computeSessionFingerprint(sessions: AgentSessionAggregate[]): string {
50
57
  // Build a compact string from the fields that actually affect the UI.
51
- // This is intentionally simple — no crypto hashing needed, just string comparison.
58
+ // Shadow bit is included so that flipping a session's dedup status
59
+ // (gaining or losing its cross-agent shadow mark) triggers a re-render.
52
60
  let fp = `${sessions.length}:`;
53
61
  for (const s of sessions) {
54
- fp += `${s.sessionId},${s.status},${s.lastActivityAt},${s.totals.input},${s.totals.output},${s.totalCostUsd ?? 0},${s.requestCount};`;
62
+ const shadow = isCrossAgentShadow(s) ? "1" : "0";
63
+ fp += `${s.sessionId},${s.status},${s.lastActivityAt},${s.totals.input},${s.totals.output},${s.totalCostUsd ?? 0},${s.requestCount},${shadow};`;
55
64
  }
56
65
  return fp;
57
66
  }
@@ -68,6 +77,9 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
68
77
  const { windowMs } = useTimeWindow();
69
78
  const hasBackfilled = useRef(false);
70
79
  const isRefreshingRef = useRef(false);
80
+ // Bugfix: track first completed load separately so an empty 1h window does not
81
+ // keep re-entering the initial loading state forever.
82
+ const hasLoadedOnceRef = useRef(false);
71
83
  const lastFingerprintRef = useRef<string>("");
72
84
 
73
85
  const discoverAgents = useCallback(async (): Promise<AgentInfo[]> => {
@@ -110,7 +122,17 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
110
122
 
111
123
  const http = createSandboxedHttpClient(plugin.id, plugin.permissions);
112
124
  const logger = createPluginLogger(plugin.id);
113
- const ctx = { http, logger, config: {}, signal: AbortSignal.timeout(60_000) };
125
+ // Lazy signal: only create the 120s timer if the plugin actually reads ctx.signal.
126
+ let parseSignal: AbortSignal | undefined;
127
+ const ctx = {
128
+ http,
129
+ logger,
130
+ config: {},
131
+ get signal(): AbortSignal {
132
+ if (!parseSignal) parseSignal = AbortSignal.timeout(120_000);
133
+ return parseSignal;
134
+ },
135
+ };
114
136
 
115
137
  const rawSessions = await plugin.parseSessions(options, ctx);
116
138
 
@@ -134,7 +156,7 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
134
156
  if (isRefreshingRef.current) return;
135
157
  isRefreshingRef.current = true;
136
158
 
137
- const isInitialLoad = sessions.length === 0;
159
+ const isInitialLoad = !hasLoadedOnceRef.current;
138
160
  if (isInitialLoad) {
139
161
  setIsLoading(true);
140
162
  }
@@ -193,7 +215,6 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
193
215
  }
194
216
  }
195
217
 
196
- // Deduplicate sessions claimed by multiple agents scanning the same directory.
197
218
  const dedupedAggregates = deduplicateAggregates(allAggregates);
198
219
  if (dedupedAggregates.length < allAggregates.length) {
199
220
  debug(
@@ -203,6 +224,8 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
203
224
  );
204
225
  }
205
226
 
227
+ markCrossAgentShadows(dedupedAggregates);
228
+
206
229
  const pricedSessions = await priceSessions(dedupedAggregates);
207
230
 
208
231
  pricedSessions.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
@@ -221,11 +244,13 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
221
244
  return;
222
245
  }
223
246
 
247
+ const effectiveSessions = selectEffectiveSessions(pricedSessions);
248
+
224
249
  // Persist to storage — the persistence service handles throttling
225
250
  // internally (fingerprint gate + 5-minute per-session throttle).
226
251
  // Safe to call every tick; state lives on globalThis, survives HMR.
227
252
  const now = Date.now();
228
- const persistData = pricedSessions.map((session) => ({
253
+ const persistData = effectiveSessions.map((session) => ({
229
254
  session: {
230
255
  agentId: session.agentId,
231
256
  sessionId: session.sessionId,
@@ -258,7 +283,7 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
258
283
  }));
259
284
  const persistedCount = persistSessions(persistData);
260
285
  debug(
261
- `Persisted ${persistedCount}/${pricedSessions.length} sessions to storage`,
286
+ `Persisted ${persistedCount}/${effectiveSessions.length} sessions to storage`,
262
287
  undefined,
263
288
  "agent-sessions",
264
289
  );
@@ -266,7 +291,11 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
266
291
  setSessions(pricedSessions);
267
292
  setLastRefreshAt(Date.now());
268
293
 
269
- debug(`Session refresh complete`, { count: pricedSessions.length }, "agent-sessions");
294
+ debug(
295
+ `Session refresh complete`,
296
+ { count: pricedSessions.length, effective: effectiveSessions.length },
297
+ "agent-sessions",
298
+ );
270
299
  } catch (err) {
271
300
  const errorMsg = err instanceof Error ? err.message : "Unknown error";
272
301
  logError(`Session refresh failed: ${errorMsg}`, undefined, "agent-sessions");
@@ -275,15 +304,19 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
275
304
  isRefreshingRef.current = false;
276
305
  if (isInitialLoad) {
277
306
  setIsLoading(false);
307
+ hasLoadedOnceRef.current = true;
278
308
  }
279
309
  }
280
310
  },
281
- [sessions.length, discoverAgents, fetchAgentSessions, debug, warn, logError, demoMode],
311
+ [discoverAgents, fetchAgentSessions, debug, warn, logError, demoMode, simulator],
282
312
  );
283
313
 
284
314
  const refreshSessionsRef = useRef(refreshSessions);
285
315
  refreshSessionsRef.current = refreshSessions;
286
316
 
317
+ const sessionsRef = useRef(sessions);
318
+ sessionsRef.current = sessions;
319
+
287
320
  useEffect(() => {
288
321
  if (!(demoMode || pluginsInitialized)) return;
289
322
 
@@ -304,7 +337,7 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
304
337
  let timerId: ReturnType<typeof setTimeout> | null = null;
305
338
 
306
339
  const scheduleNext = () => {
307
- const hasActive = sessions.some((s) => s.status === "active");
340
+ const hasActive = sessionsRef.current.some((s) => s.status === "active");
308
341
  const interval = hasActive ? ACTIVE_INTERVAL_MS : IDLE_INTERVAL_MS;
309
342
 
310
343
  timerId = setTimeout(() => {
@@ -324,16 +357,21 @@ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSes
324
357
  clearTimeout(timerId);
325
358
  }
326
359
  };
327
- }, [autoRefresh, sessions, debug]);
328
-
329
- const value: AgentSessionContextValue = {
330
- sessions,
331
- agents,
332
- isLoading,
333
- lastRefreshAt,
334
- error,
335
- refreshSessions,
336
- };
360
+ }, [autoRefresh, debug]);
361
+
362
+ const effectiveSessions = useMemo(() => selectEffectiveSessions(sessions), [sessions]);
363
+
364
+ const value: AgentSessionContextValue = useMemo(
365
+ () => ({
366
+ sessions: effectiveSessions,
367
+ agents,
368
+ isLoading,
369
+ lastRefreshAt,
370
+ error,
371
+ refreshSessions,
372
+ }),
373
+ [effectiveSessions, agents, isLoading, lastRefreshAt, error, refreshSessions],
374
+ );
337
375
 
338
376
  return <AgentSessionContext.Provider value={value}>{children}</AgentSessionContext.Provider>;
339
377
  }
@@ -275,7 +275,11 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
275
275
  return;
276
276
  }
277
277
 
278
- // Skip providers that are currently rate-limited (backoff)
278
+ if (state.loading) {
279
+ debug(`Skipping refresh for ${providerId}: already in flight`, undefined, "refresh");
280
+ return;
281
+ }
282
+
279
283
  const now = Date.now();
280
284
  if (state.rateLimitUntil > now) {
281
285
  const remainingSec = Math.round((state.rateLimitUntil - now) / 1000);
@@ -360,19 +364,25 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
360
364
 
361
365
  const http = createSandboxedHttpClient(providerId, state.plugin.permissions);
362
366
  const logger = createPluginLogger(providerId);
367
+ // Lazy signal: only create the 30s timer if a plugin actually reads ctx.signal.
368
+ let fetchSignal: AbortSignal | undefined;
369
+ const fetchCtx = {
370
+ credentials: creds,
371
+ http,
372
+ logger,
373
+ config: {},
374
+ get signal(): AbortSignal {
375
+ if (!fetchSignal) fetchSignal = AbortSignal.timeout(30_000);
376
+ return fetchSignal;
377
+ },
378
+ };
363
379
 
364
380
  const fetchResult = await safeInvoke(
365
381
  providerId,
366
382
  "fetchUsage",
367
383
  () =>
368
384
  runInPluginGuard(providerId, state.plugin.permissions, () =>
369
- state.plugin.fetchUsage({
370
- credentials: creds,
371
- http,
372
- logger,
373
- config: {},
374
- signal: AbortSignal.timeout(30_000),
375
- }),
385
+ state.plugin.fetchUsage(fetchCtx),
376
386
  ),
377
387
  {
378
388
  // Count non-rate-limit errors as circuit breaker failures.
@@ -513,14 +523,25 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
513
523
  await Promise.all(configuredProviders.map(refreshProvider));
514
524
  }, [providers, refreshProvider, info]);
515
525
 
526
+ const refreshProviderRef = useRef(refreshProvider);
527
+ const refreshAllProvidersRef = useRef(refreshAllProviders);
528
+ refreshProviderRef.current = refreshProvider;
529
+ refreshAllProvidersRef.current = refreshAllProviders;
530
+
531
+ const stableRefreshProvider = useCallback(
532
+ (providerId: string) => refreshProviderRef.current(providerId),
533
+ [],
534
+ );
535
+ const stableRefreshAllProviders = useCallback(() => refreshAllProvidersRef.current(), []);
536
+
516
537
  const value: PluginContextValue = {
517
538
  providers,
518
539
  agents,
519
540
  themes,
520
541
  notifications,
521
542
  isInitialized,
522
- refreshProvider,
523
- refreshAllProviders,
543
+ refreshProvider: stableRefreshProvider,
544
+ refreshAllProviders: stableRefreshAllProviders,
524
545
  };
525
546
 
526
547
  return <PluginContext.Provider value={value}>{children}</PluginContext.Provider>;
@@ -0,0 +1,71 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { computeScrollOffset } from "./scrollFollow.ts";
3
+
4
+ describe("computeScrollOffset", () => {
5
+ test("cursor inside viewport leaves offset unchanged", () => {
6
+ expect(computeScrollOffset(5, 0, 10, 100)).toBe(0);
7
+ });
8
+
9
+ test("cursor at bottom edge leaves offset unchanged", () => {
10
+ expect(computeScrollOffset(9, 0, 10, 100)).toBe(0);
11
+ });
12
+
13
+ test("cursor past bottom pulls offset down by one row", () => {
14
+ expect(computeScrollOffset(10, 0, 10, 100)).toBe(1);
15
+ });
16
+
17
+ test("cursor past top pulls offset up", () => {
18
+ expect(computeScrollOffset(3, 5, 10, 100)).toBe(3);
19
+ });
20
+
21
+ test("clamps offset to non-negative", () => {
22
+ expect(computeScrollOffset(0, 0, 10, 100)).toBe(0);
23
+ });
24
+
25
+ test("clamps offset to total - visibleRows", () => {
26
+ expect(computeScrollOffset(99, 0, 10, 100)).toBe(90);
27
+ });
28
+
29
+ test("offset stays 0 when content fits in viewport", () => {
30
+ expect(computeScrollOffset(3, 0, 10, 4)).toBe(0);
31
+ });
32
+
33
+ // Regression test for cursor-disappears-then-jump bug: rapid j presses
34
+ // that React batches into a single render must still land with the cursor
35
+ // visible at the bottom edge. Simulating N sequential functional updates
36
+ // (the reducer runs each one in order) verifies the invariant.
37
+ test("batched rapid j-presses keep cursor at bottom edge each step", () => {
38
+ const vis = 10;
39
+ const total = 1000;
40
+ let state = { row: 0, offset: 0 };
41
+ for (let i = 0; i < 50; i++) {
42
+ const nextRow = Math.min(state.row + 1, total - 1);
43
+ const nextOffset = computeScrollOffset(nextRow, state.offset, vis, total);
44
+ state = { row: nextRow, offset: nextOffset };
45
+ expect(state.row).toBeGreaterThanOrEqual(state.offset);
46
+ expect(state.row).toBeLessThan(state.offset + vis);
47
+ }
48
+ expect(state.row).toBe(50);
49
+ expect(state.offset).toBe(41);
50
+ });
51
+
52
+ test("batched rapid k-presses at top keep cursor at top edge", () => {
53
+ const vis = 10;
54
+ const total = 1000;
55
+ let state = { row: 50, offset: 41 };
56
+ for (let i = 0; i < 50; i++) {
57
+ const nextRow = Math.max(state.row - 1, 0);
58
+ const nextOffset = computeScrollOffset(nextRow, state.offset, vis, total);
59
+ state = { row: nextRow, offset: nextOffset };
60
+ expect(state.row).toBeGreaterThanOrEqual(state.offset);
61
+ expect(state.row).toBeLessThan(state.offset + vis);
62
+ }
63
+ expect(state.row).toBe(0);
64
+ expect(state.offset).toBe(0);
65
+ });
66
+
67
+ test("re-clamp when list shrinks below current offset", () => {
68
+ expect(computeScrollOffset(5, 50, 10, 20)).toBe(5);
69
+ expect(computeScrollOffset(19, 50, 10, 20)).toBe(10);
70
+ });
71
+ });
@@ -0,0 +1,12 @@
1
+ export function computeScrollOffset(
2
+ row: number,
3
+ prevOffset: number,
4
+ visibleRows: number,
5
+ total: number,
6
+ ): number {
7
+ let next = prevOffset;
8
+ if (row < next) next = row;
9
+ else if (row >= next + visibleRows) next = row - visibleRows + 1;
10
+ const max = Math.max(0, total - visibleRows);
11
+ return Math.max(0, Math.min(next, max));
12
+ }
@@ -60,12 +60,6 @@ export function Dashboard() {
60
60
  const cardRefs = useRef<(BoxRenderable | null)[]>([]);
61
61
  const inputRef = useRef<InputRenderable>(null);
62
62
 
63
- useEffect(() => {
64
- if (isInitialized) {
65
- refreshAllProviders();
66
- }
67
- }, [isInitialized]);
68
-
69
63
  const getMaxUsage = useCallback((state: ProviderState) => {
70
64
  if (!state.usage?.limits) return 0;
71
65
 
@@ -1,6 +1,15 @@
1
1
  import type { ScrollBoxRenderable } from "@opentui/core";
2
2
  import { useTerminalDimensions } from "@opentui/react";
3
- import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
+ import {
4
+ type Dispatch,
5
+ type SetStateAction,
6
+ useCallback,
7
+ useEffect,
8
+ useLayoutEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ } from "react";
4
13
  import { notificationBus } from "@/plugins/notification-bus.ts";
5
14
  import { classifyProviderError } from "@/utils/error-category.ts";
6
15
  import { HelpOverlay } from "../components/HelpOverlay.tsx";
@@ -20,6 +29,7 @@ import { useDashboardKeyboard } from "../hooks/useDashboardKeyboard.ts";
20
29
  import type { SortDirection, SortField } from "../types/sort.ts";
21
30
  import { SORT_FIELDS } from "../types/sort.ts";
22
31
  import { getProviderColor } from "../utils/providerColor.ts";
32
+ import { computeScrollOffset } from "../utils/scrollFollow.ts";
23
33
 
24
34
  export function RealTimeDashboard() {
25
35
  const colors = useColors();
@@ -77,7 +87,13 @@ export function RealTimeDashboard() {
77
87
  const { showDrawer, isOpen: showSessionDrawer } = useDrawer();
78
88
 
79
89
  const [showHelp, setShowHelp] = useState(false);
80
- const [selectedRow, setSelectedRow] = useState(0);
90
+ // Cursor and scroll offset share one state so batched j-presses advance
91
+ // both in lockstep — see setSelectedRow below.
92
+ const [nav, setNav] = useState<{ selectedRow: number; scrollOffset: number }>({
93
+ selectedRow: 0,
94
+ scrollOffset: 0,
95
+ });
96
+ const { selectedRow, scrollOffset } = nav;
81
97
  const [focusedPanel, setFocusedPanel] = useState<"sessions" | "sidebar" | "limits">("sessions");
82
98
  const [sidebarCollapsed, setSidebarCollapsed] = useState(config.display.sidebarCollapsed);
83
99
  const [filterQuery, setFilterQuery] = useState("");
@@ -86,12 +102,7 @@ export function RealTimeDashboard() {
86
102
  const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
87
103
  const [showSortOverlay, setShowSortOverlay] = useState(false);
88
104
  const [pendingG, setPendingG] = useState(false);
89
- const scrollOffsetRef = useRef(0);
90
105
  const selectedSessionIdRef = useRef<string | null>(null);
91
- const setScrollOffset = useCallback((val: number) => {
92
- scrollOffsetRef.current = val;
93
- sessionsScrollboxRef.current?.scrollTo(val);
94
- }, []);
95
106
  const [limitSelectedIndex, setLimitSelectedIndex] = useState(0);
96
107
  const [driverDimension, setDriverDimension] = useState<DriverDimension>("model");
97
108
  const [selectedDriverIndex, setSelectedDriverIndex] = useState(0);
@@ -102,6 +113,32 @@ export function RealTimeDashboard() {
102
113
 
103
114
  const sessionsScrollboxRef = useRef<ScrollBoxRenderable>(null);
104
115
 
116
+ const visibleRowsRef = useRef(visibleRows);
117
+ visibleRowsRef.current = visibleRows;
118
+ const sessionsLengthRef = useRef(0);
119
+
120
+ const setSelectedRow = useCallback<Dispatch<SetStateAction<number>>>((arg) => {
121
+ setNav((prev) => {
122
+ const next = typeof arg === "function" ? arg(prev.selectedRow) : arg;
123
+ const clamped = Math.max(0, next);
124
+ const newOffset = computeScrollOffset(
125
+ clamped,
126
+ prev.scrollOffset,
127
+ visibleRowsRef.current,
128
+ sessionsLengthRef.current,
129
+ );
130
+ if (clamped === prev.selectedRow && newOffset === prev.scrollOffset) return prev;
131
+ return { selectedRow: clamped, scrollOffset: newOffset };
132
+ });
133
+ }, []);
134
+
135
+ const setScrollOffset = useCallback((val: number) => {
136
+ setNav((prev) => {
137
+ if (val === prev.scrollOffset) return prev;
138
+ return { ...prev, scrollOffset: val };
139
+ });
140
+ }, []);
141
+
105
142
  const configuredProviders = useMemo(() => {
106
143
  return Array.from(providers.values())
107
144
  .filter((p) => p.configured)
@@ -123,7 +160,9 @@ export function RealTimeDashboard() {
123
160
  );
124
161
 
125
162
  const baseFilteredSessions = useMemo(() => {
126
- let result = [...agentSessions];
163
+ if (windowMs === null && !filterQuery) return agentSessions;
164
+
165
+ let result = agentSessions;
127
166
 
128
167
  if (windowMs !== null) {
129
168
  const cutoff = Date.now() - windowMs;
@@ -255,8 +294,12 @@ export function RealTimeDashboard() {
255
294
  return result;
256
295
  }, [baseFilteredSessions, activeDriverFilter, driverDimension, sortField, sortDirection]);
257
296
 
258
- // Stabilize selection across re-sorts: track selected session by ID
259
- // so the highlight follows the same session when live data causes reordering.
297
+ sessionsLengthRef.current = processedSessions.length;
298
+
299
+ // A keypress and a refresh tick can batch into one render; if they do,
300
+ // `prev` may be the user's new row while `selectedSessionIdRef` is still
301
+ // the session they moved *from*. Trusting `prev` when it resolves to a
302
+ // valid session prevents a snap-back bounce.
260
303
  useLayoutEffect(() => {
261
304
  if (processedSessions.length === 0) {
262
305
  setSelectedRow(0);
@@ -265,24 +308,40 @@ export function RealTimeDashboard() {
265
308
  return;
266
309
  }
267
310
 
268
- if (selectedSessionIdRef.current) {
269
- const newIndex = processedSessions.findIndex(
270
- (s) => s.sessionId === selectedSessionIdRef.current,
271
- );
272
- if (newIndex !== -1) {
273
- setSelectedRow(newIndex);
274
- return;
311
+ setSelectedRow((prev) => {
312
+ const currentSession = processedSessions[prev];
313
+
314
+ if (currentSession) {
315
+ if (currentSession.sessionId === selectedSessionIdRef.current) {
316
+ return prev;
317
+ }
318
+ if (selectedSessionIdRef.current) {
319
+ const trackedIndex = processedSessions.findIndex(
320
+ (s) => s.sessionId === selectedSessionIdRef.current,
321
+ );
322
+ if (trackedIndex !== -1 && trackedIndex !== prev) {
323
+ return trackedIndex;
324
+ }
325
+ }
326
+ selectedSessionIdRef.current = currentSession.sessionId;
327
+ return prev;
328
+ }
329
+
330
+ if (selectedSessionIdRef.current) {
331
+ const trackedIndex = processedSessions.findIndex(
332
+ (s) => s.sessionId === selectedSessionIdRef.current,
333
+ );
334
+ if (trackedIndex !== -1) return trackedIndex;
335
+ selectedSessionIdRef.current = null;
275
336
  }
276
- // Tracked session was filtered out — fall through to clamp
277
- selectedSessionIdRef.current = null;
278
- }
279
337
 
280
- // Clamp to valid range (functional update avoids selectedRow in deps)
281
- setSelectedRow((prev) => Math.min(prev, processedSessions.length - 1));
338
+ const clamped = Math.min(prev, processedSessions.length - 1);
339
+ selectedSessionIdRef.current = processedSessions[clamped]?.sessionId ?? null;
340
+ return clamped;
341
+ });
282
342
  }, [processedSessions, setScrollOffset]);
283
343
 
284
- // Record which session the user selected (runs after navigation and stabilization)
285
- useEffect(() => {
344
+ useLayoutEffect(() => {
286
345
  const session = processedSessions[selectedRow];
287
346
  if (session) {
288
347
  selectedSessionIdRef.current = session.sessionId;
@@ -290,19 +349,22 @@ export function RealTimeDashboard() {
290
349
  }, [selectedRow, processedSessions]);
291
350
 
292
351
  useLayoutEffect(() => {
293
- if (!sessionsScrollboxRef.current || processedSessions.length === 0) return;
294
-
295
- let newOffset = scrollOffsetRef.current;
296
-
297
- if (selectedRow < newOffset) {
298
- newOffset = selectedRow;
299
- } else if (selectedRow >= newOffset + visibleRows) {
300
- newOffset = selectedRow - visibleRows + 1;
301
- }
352
+ if (processedSessions.length === 0) return;
353
+ setNav((prev) => {
354
+ const newOffset = computeScrollOffset(
355
+ prev.selectedRow,
356
+ prev.scrollOffset,
357
+ visibleRows,
358
+ processedSessions.length,
359
+ );
360
+ if (newOffset === prev.scrollOffset) return prev;
361
+ return { ...prev, scrollOffset: newOffset };
362
+ });
363
+ }, [processedSessions.length, visibleRows]);
302
364
 
303
- scrollOffsetRef.current = newOffset;
304
- sessionsScrollboxRef.current.scrollTo(newOffset);
305
- }, [selectedRow, processedSessions.length, visibleRows]);
365
+ useLayoutEffect(() => {
366
+ sessionsScrollboxRef.current?.scrollTo(scrollOffset);
367
+ }, [scrollOffset]);
306
368
 
307
369
  const openSessionDrawer = useCallback(() => {
308
370
  const session = processedSessions[selectedRow];
@@ -325,7 +387,7 @@ export function RealTimeDashboard() {
325
387
  sortField,
326
388
  sortDirection,
327
389
  pendingG,
328
- scrollOffset: scrollOffsetRef.current,
390
+ scrollOffset,
329
391
  limitSelectedIndex,
330
392
  providerCount: configuredProviders.length,
331
393
  driverDimension,
@@ -409,7 +471,10 @@ export function RealTimeDashboard() {
409
471
  }
410
472
  };
411
473
 
412
- const activeCount = agentSessions.filter((s) => s.status === "active").length;
474
+ const activeCount = useMemo(
475
+ () => agentSessions.reduce((n, s) => (s.status === "active" ? n + 1 : n), 0),
476
+ [agentSessions],
477
+ );
413
478
 
414
479
  return (
415
480
  <box flexDirection="column" flexGrow={1} padding={1} gap={1} overflow="hidden">
@@ -491,6 +556,8 @@ export function RealTimeDashboard() {
491
556
  ref={sessionsScrollboxRef}
492
557
  sessions={processedSessions}
493
558
  selectedRow={selectedRow}
559
+ scrollOffset={scrollOffset}
560
+ visibleRows={visibleRows}
494
561
  isLoading={isLoading}
495
562
  isFiltering={isFiltering}
496
563
  filterQuery={filterQuery}
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.6.1"; // x-release-please-version
1
+ export const VERSION = "0.8.0"; // x-release-please-version
package/tsconfig.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "ESNext",
7
+ "moduleDetection": "force",
8
+
9
+ // OpenTUI React JSX configuration
10
+ "jsx": "react-jsx",
11
+ "jsxImportSource": "@opentui/react",
12
+
13
+ // Bundler mode
14
+ "moduleResolution": "bundler",
15
+ "allowImportingTsExtensions": true,
16
+ "verbatimModuleSyntax": true,
17
+ "noEmit": true,
18
+ "allowJs": true,
19
+
20
+ // Strict mode
21
+ "strict": true,
22
+ "skipLibCheck": true,
23
+ "noFallthroughCasesInSwitch": true,
24
+ "noUncheckedIndexedAccess": true,
25
+ "noImplicitOverride": true,
26
+
27
+ // Path mapping
28
+ "paths": {
29
+ "@/*": ["./src/*"],
30
+ "@plugins/*": ["./src/plugins/*"],
31
+ "@tui/*": ["./src/tui/*"],
32
+ "@utils/*": ["./src/utils/*"]
33
+ },
34
+
35
+ // Additional checks
36
+ "noUnusedLocals": true,
37
+ "noUnusedParameters": true,
38
+ "exactOptionalPropertyTypes": true,
39
+ "noPropertyAccessFromIndexSignature": false
40
+ },
41
+ "include": ["src/**/*", "templates/**/*"],
42
+ "exclude": ["dist", "node_modules"]
43
+ }