@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
@@ -0,0 +1,201 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import type {
3
+ AgentSessionSnapshotInsert,
4
+ AgentSessionStreamSnapshotRow,
5
+ AgentSessionUpsert,
6
+ UsageEventInsert,
7
+ } from "./types.ts";
8
+
9
+ mock.module("./db.ts", () => ({
10
+ isDatabaseInitialized: () => true,
11
+ }));
12
+
13
+ let upsertCalls: AgentSessionUpsert[] = [];
14
+ let snapshotCalls: Array<{
15
+ snapshot: AgentSessionSnapshotInsert;
16
+ streams: Omit<AgentSessionStreamSnapshotRow, "agentSessionSnapshotId">[];
17
+ }> = [];
18
+ let usageEventCalls: UsageEventInsert[][] = [];
19
+
20
+ let seededSnapshots: Array<{
21
+ agentId: string;
22
+ sessionId: string;
23
+ timestamp: number;
24
+ totalCostUsd: number;
25
+ requestCount: number;
26
+ totalInputTokens: number;
27
+ totalOutputTokens: number;
28
+ }> = [];
29
+
30
+ mock.module("./repos/agentSessions.ts", () => ({
31
+ getLatestSnapshotsForAllSessions: () => seededSnapshots,
32
+ getLatestStreamTotalsForAllSessions: () => [],
33
+ upsertAgentSession: (session: AgentSessionUpsert) => {
34
+ upsertCalls.push(session);
35
+ return upsertCalls.length;
36
+ },
37
+ insertAgentSessionSnapshot: (
38
+ snapshot: AgentSessionSnapshotInsert,
39
+ streams: Omit<AgentSessionStreamSnapshotRow, "agentSessionSnapshotId">[],
40
+ ) => {
41
+ snapshotCalls.push({ snapshot, streams });
42
+ },
43
+ }));
44
+
45
+ mock.module("./repos/usageEvents.ts", () => ({
46
+ insertUsageEventBatch: (events: UsageEventInsert[]) => {
47
+ usageEventCalls.push(events);
48
+ },
49
+ }));
50
+
51
+ const { persistSessions, seedPreviousTotals } = await import("./persistence-service.ts");
52
+
53
+ beforeEach(() => {
54
+ upsertCalls = [];
55
+ snapshotCalls = [];
56
+ usageEventCalls = [];
57
+ seededSnapshots = [];
58
+ (globalThis as { __tokentopPersistence?: unknown }).__tokentopPersistence = undefined;
59
+ });
60
+
61
+ function makePersistData(overrides: {
62
+ agentId?: string;
63
+ sessionId?: string;
64
+ totalCostUsd?: number;
65
+ requestCount?: number;
66
+ totalInputTokens?: number;
67
+ totalOutputTokens?: number;
68
+ timestamp?: number;
69
+ }) {
70
+ const now = overrides.timestamp ?? Date.now();
71
+ return {
72
+ session: {
73
+ agentId: overrides.agentId ?? "claude-code",
74
+ sessionId: overrides.sessionId ?? "s1",
75
+ projectPath: null,
76
+ startedAt: null,
77
+ lastSeenAt: now,
78
+ },
79
+ snapshot: {
80
+ timestamp: now,
81
+ lastActivityAt: now,
82
+ status: null,
83
+ totalInputTokens: overrides.totalInputTokens ?? 1000,
84
+ totalOutputTokens: overrides.totalOutputTokens ?? 500,
85
+ totalCacheReadTokens: 0,
86
+ totalCacheWriteTokens: 0,
87
+ totalCostUsd: overrides.totalCostUsd ?? 1.5,
88
+ requestCount: overrides.requestCount ?? 10,
89
+ },
90
+ streams: [],
91
+ };
92
+ }
93
+
94
+ describe("seedPreviousTotals", () => {
95
+ test("skips persist for session whose fingerprint matches seeded DB snapshot", () => {
96
+ seededSnapshots = [
97
+ {
98
+ agentId: "claude-code",
99
+ sessionId: "s1",
100
+ timestamp: Date.now() - 60_000,
101
+ totalCostUsd: 1.5,
102
+ requestCount: 10,
103
+ totalInputTokens: 1000,
104
+ totalOutputTokens: 500,
105
+ },
106
+ ];
107
+
108
+ seedPreviousTotals();
109
+
110
+ const persistedCount = persistSessions([
111
+ makePersistData({
112
+ agentId: "claude-code",
113
+ sessionId: "s1",
114
+ totalCostUsd: 1.5,
115
+ requestCount: 10,
116
+ totalInputTokens: 1000,
117
+ totalOutputTokens: 500,
118
+ }),
119
+ ]);
120
+
121
+ expect(persistedCount).toBe(0);
122
+ expect(upsertCalls).toHaveLength(0);
123
+ expect(snapshotCalls).toHaveLength(0);
124
+ });
125
+
126
+ test("persists session whose usage changed since seeded snapshot", () => {
127
+ seededSnapshots = [
128
+ {
129
+ agentId: "claude-code",
130
+ sessionId: "s1",
131
+ timestamp: Date.now() - 10 * 60 * 1000,
132
+ totalCostUsd: 1.5,
133
+ requestCount: 10,
134
+ totalInputTokens: 1000,
135
+ totalOutputTokens: 500,
136
+ },
137
+ ];
138
+
139
+ seedPreviousTotals();
140
+
141
+ const persistedCount = persistSessions([
142
+ makePersistData({
143
+ agentId: "claude-code",
144
+ sessionId: "s1",
145
+ totalCostUsd: 2.0,
146
+ requestCount: 12,
147
+ totalInputTokens: 1200,
148
+ totalOutputTokens: 600,
149
+ }),
150
+ ]);
151
+
152
+ expect(persistedCount).toBe(1);
153
+ expect(upsertCalls).toHaveLength(1);
154
+ expect(snapshotCalls).toHaveLength(1);
155
+ });
156
+
157
+ test("persists brand-new session not present in seed", () => {
158
+ seededSnapshots = [];
159
+
160
+ seedPreviousTotals();
161
+
162
+ const persistedCount = persistSessions([
163
+ makePersistData({ agentId: "claude-code", sessionId: "new-session" }),
164
+ ]);
165
+
166
+ expect(persistedCount).toBe(1);
167
+ expect(upsertCalls).toHaveLength(1);
168
+ });
169
+
170
+ test("skips thousands of unchanged sessions after seed (regression for startup freeze)", () => {
171
+ const baseTs = Date.now() - 60_000;
172
+ seededSnapshots = Array.from({ length: 5000 }, (_, i) => ({
173
+ agentId: "claude-code",
174
+ sessionId: `s${i}`,
175
+ timestamp: baseTs + i,
176
+ totalCostUsd: i * 0.01,
177
+ requestCount: i,
178
+ totalInputTokens: i * 100,
179
+ totalOutputTokens: i * 50,
180
+ }));
181
+
182
+ seedPreviousTotals();
183
+
184
+ const persistData = seededSnapshots.map((s) =>
185
+ makePersistData({
186
+ agentId: s.agentId,
187
+ sessionId: s.sessionId,
188
+ totalCostUsd: s.totalCostUsd,
189
+ requestCount: s.requestCount,
190
+ totalInputTokens: s.totalInputTokens,
191
+ totalOutputTokens: s.totalOutputTokens,
192
+ }),
193
+ );
194
+
195
+ const persistedCount = persistSessions(persistData);
196
+
197
+ expect(persistedCount).toBe(0);
198
+ expect(upsertCalls).toHaveLength(0);
199
+ expect(snapshotCalls).toHaveLength(0);
200
+ });
201
+ });
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { isDatabaseInitialized } from "./db.ts";
17
17
  import {
18
+ getLatestSnapshotsForAllSessions,
18
19
  getLatestStreamTotalsForAllSessions,
19
20
  insertAgentSessionSnapshot,
20
21
  upsertAgentSession,
@@ -36,6 +37,12 @@ import { computeStreamDelta } from "./types.ts";
36
37
  /** Minimum interval between DB writes for the same session */
37
38
  const SNAPSHOT_INTERVAL_MS = 300_000; // 5 minutes
38
39
 
40
+ /** Remove tracking entries for sessions not written in this window */
41
+ const STALE_ENTRY_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
42
+
43
+ /** Run eviction at most this often */
44
+ const EVICTION_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
45
+
39
46
  // ---------------------------------------------------------------------------
40
47
  // Global state (survives bun --hot HMR and React remounts)
41
48
  // ---------------------------------------------------------------------------
@@ -49,6 +56,8 @@ interface PersistenceState {
49
56
  previousStreamTotals: Map<string, StreamTotals>;
50
57
  /** Whether startup seeding has completed */
51
58
  seeded: boolean;
59
+ /** Timestamp of last stale entry eviction run */
60
+ lastEvictionAt: number;
52
61
  }
53
62
 
54
63
  declare global {
@@ -63,6 +72,7 @@ function getState(): PersistenceState {
63
72
  sessionFingerprints: new Map(),
64
73
  previousStreamTotals: new Map(),
65
74
  seeded: false,
75
+ lastEvictionAt: 0,
66
76
  };
67
77
  }
68
78
  return globalThis.__tokentopPersistence;
@@ -91,6 +101,27 @@ export interface SessionPersistData {
91
101
  streams: Omit<AgentSessionStreamSnapshotRow, "agentSessionSnapshotId">[];
92
102
  }
93
103
 
104
+ function evictStaleEntries(state: PersistenceState, now: number): void {
105
+ if (now - state.lastEvictionAt < EVICTION_INTERVAL_MS) return;
106
+ state.lastEvictionAt = now;
107
+
108
+ const cutoff = now - STALE_ENTRY_MAX_AGE_MS;
109
+ for (const [key, ts] of state.lastWriteTimestamps) {
110
+ if (ts < cutoff) {
111
+ state.lastWriteTimestamps.delete(key);
112
+ state.sessionFingerprints.delete(key);
113
+ }
114
+ }
115
+
116
+ // Evict stream totals whose parent session was evicted
117
+ for (const streamKey of state.previousStreamTotals.keys()) {
118
+ const sessionKey = streamKey.slice(0, streamKey.indexOf(":", streamKey.indexOf(":") + 1));
119
+ if (!state.lastWriteTimestamps.has(sessionKey)) {
120
+ state.previousStreamTotals.delete(streamKey);
121
+ }
122
+ }
123
+ }
124
+
94
125
  /**
95
126
  * Persist session data to the database with per-session throttling.
96
127
  *
@@ -105,6 +136,7 @@ export function persistSessions(sessions: SessionPersistData[]): number {
105
136
 
106
137
  const state = getState();
107
138
  const now = Date.now();
139
+ evictStaleEntries(state, now);
108
140
  let persistedCount = 0;
109
141
 
110
142
  for (const { session, snapshot, streams } of sessions) {
@@ -177,11 +209,6 @@ export function persistSessions(sessions: SessionPersistData[]): number {
177
209
  return persistedCount;
178
210
  }
179
211
 
180
- /**
181
- * Seed previous stream totals from the database.
182
- * Called once at startup after DB initialization to ensure
183
- * accurate delta computation from the first persistence call.
184
- */
185
212
  export function seedPreviousTotals(): void {
186
213
  const state = getState();
187
214
  if (state.seeded) return;
@@ -199,6 +226,28 @@ export function seedPreviousTotals(): void {
199
226
  requestCount: row.requestCount,
200
227
  });
201
228
  }
229
+
230
+ // Seeds Gate 1/2 so the first post-startup tick does not rewrite every
231
+ // historical session. computeFingerprint only reads cost/count/input/output,
232
+ // so the other fields are placeholders to satisfy the snapshot shape.
233
+ const latestSnapshots = getLatestSnapshotsForAllSessions();
234
+ for (const snap of latestSnapshots) {
235
+ const sessionKey = `${snap.agentId}:${snap.sessionId}`;
236
+ const fp = computeFingerprint({
237
+ timestamp: snap.timestamp,
238
+ lastActivityAt: snap.timestamp,
239
+ status: null,
240
+ totalInputTokens: snap.totalInputTokens,
241
+ totalOutputTokens: snap.totalOutputTokens,
242
+ totalCacheReadTokens: 0,
243
+ totalCacheWriteTokens: 0,
244
+ totalCostUsd: snap.totalCostUsd,
245
+ requestCount: snap.requestCount,
246
+ });
247
+ state.sessionFingerprints.set(sessionKey, fp);
248
+ state.lastWriteTimestamps.set(sessionKey, snap.timestamp);
249
+ }
250
+
202
251
  state.seeded = true;
203
252
  } catch (err) {
204
253
  console.error("Failed to seed previous totals from DB:", err);
@@ -172,6 +172,55 @@ export interface LatestStreamTotals {
172
172
  requestCount: number;
173
173
  }
174
174
 
175
+ export interface LatestSessionSnapshot {
176
+ agentId: string;
177
+ sessionId: string;
178
+ timestamp: number;
179
+ totalCostUsd: number;
180
+ requestCount: number;
181
+ totalInputTokens: number;
182
+ totalOutputTokens: number;
183
+ }
184
+
185
+ export function getLatestSnapshotsForAllSessions(): LatestSessionSnapshot[] {
186
+ const db = getDatabase();
187
+
188
+ const rows = db
189
+ .prepare(`
190
+ SELECT
191
+ s.agent_id, s.session_id,
192
+ snap.timestamp,
193
+ snap.total_cost_usd, snap.request_count,
194
+ snap.total_input_tokens, snap.total_output_tokens
195
+ FROM agent_session_snapshots snap
196
+ JOIN agent_sessions s ON s.id = snap.agent_session_id
197
+ WHERE snap.id IN (
198
+ SELECT MAX(snap2.id)
199
+ FROM agent_session_snapshots snap2
200
+ GROUP BY snap2.agent_session_id
201
+ )
202
+ `)
203
+ .all() as Array<{
204
+ agent_id: string;
205
+ session_id: string;
206
+ timestamp: number;
207
+ total_cost_usd: number;
208
+ request_count: number;
209
+ total_input_tokens: number;
210
+ total_output_tokens: number;
211
+ }>;
212
+
213
+ return rows.map((r) => ({
214
+ agentId: r.agent_id,
215
+ sessionId: r.session_id,
216
+ timestamp: r.timestamp,
217
+ totalCostUsd: r.total_cost_usd,
218
+ requestCount: r.request_count,
219
+ totalInputTokens: r.total_input_tokens,
220
+ totalOutputTokens: r.total_output_tokens,
221
+ }));
222
+ }
223
+
175
224
  export function getLatestStreamTotalsForAllSessions(): LatestStreamTotals[] {
176
225
  const db = getDatabase();
177
226
 
package/src/tui/App.tsx CHANGED
@@ -245,12 +245,12 @@ function AppContent() {
245
245
  ]);
246
246
 
247
247
  useKeyboard((key) => {
248
- if (key.ctrl && key.name === "p") {
249
- handleCaptureFrame();
248
+ if ((key.ctrl && key.shift && key.name === "p") || (key.shift && key.name === "p")) {
249
+ handleBurstRecord();
250
250
  return;
251
251
  }
252
- if (key.ctrl && key.shift && key.name === "p") {
253
- handleBurstRecord();
252
+ if (key.ctrl && key.name === "p") {
253
+ handleCaptureFrame();
254
254
  return;
255
255
  }
256
256
 
@@ -158,6 +158,16 @@ export function SessionDetailsDrawer({ session, onClose: _onClose }: SessionDeta
158
158
  };
159
159
  }, [session.streams]);
160
160
 
161
+ const hasAnyLongContext = session.streams.some((s) => s.hasLongContext);
162
+ const totalLongContextRequests = session.streams.reduce(
163
+ (sum, s) => sum + (s.longContextRequestCount ?? 0),
164
+ 0,
165
+ );
166
+ const totalLongContextTokens = session.streams.reduce((sum, s) => {
167
+ if (!s.longContextTokens) return sum;
168
+ return sum + s.longContextTokens.input + s.longContextTokens.output;
169
+ }, 0);
170
+
161
171
  const isMultiDay = session.lastActivityAt - session.startedAt > 24 * 60 * 60 * 1000;
162
172
  const startTimeStr = formatTimelineLabel(session.startedAt, isMultiDay);
163
173
  const endTimeStr = formatTimelineLabel(session.lastActivityAt, isMultiDay);
@@ -338,6 +348,18 @@ export function SessionDetailsDrawer({ session, onClose: _onClose }: SessionDeta
338
348
  </text>
339
349
  </box>
340
350
  )}
351
+
352
+ {hasAnyLongContext && (
353
+ <box flexDirection="row" height={1} marginBottom={0}>
354
+ <text height={1} overflow="hidden">
355
+ <span fg={colors.warning}>↑ Long ctx: </span>
356
+ <span fg={colors.text}>{totalLongContextRequests} reqs</span>
357
+ <span fg={colors.textMuted}> · </span>
358
+ <span fg={colors.text}>{formatTokens(totalLongContextTokens)} tokens</span>
359
+ <span fg={colors.textMuted}> at tiered rate</span>
360
+ </text>
361
+ </box>
362
+ )}
341
363
  </box>
342
364
 
343
365
  <box
@@ -407,11 +429,17 @@ export function SessionDetailsDrawer({ session, onClose: _onClose }: SessionDeta
407
429
  </text>
408
430
  <box width={10} height={1} justifyContent="flex-end">
409
431
  <text fg={colors.textMuted}>
410
- {formatTokens(stream.tokens.input + stream.tokens.output)}
432
+ {formatTokens(
433
+ stream.tokens.input +
434
+ stream.tokens.output +
435
+ (stream.longContextTokens?.input ?? 0) +
436
+ (stream.longContextTokens?.output ?? 0),
437
+ )}
411
438
  </text>
412
439
  </box>
413
440
  <box width={10} height={1} justifyContent="flex-end">
414
441
  <text fg={colors.success}>{formatCost(stream.costUsd)}</text>
442
+ <text fg={colors.textMuted}>{stream.hasLongContext ? "↑" : " "}</text>
415
443
  </box>
416
444
  </box>
417
445
  );
@@ -13,6 +13,8 @@ import { getSortDirectionIndicator, getSortFieldLabel } from "../types/sort.ts";
13
13
  interface SessionsTableProps {
14
14
  sessions: AgentSessionAggregate[];
15
15
  selectedRow: number;
16
+ scrollOffset: number;
17
+ visibleRows: number;
16
18
  isLoading: boolean;
17
19
  isFiltering: boolean;
18
20
  filterQuery: string;
@@ -159,6 +161,7 @@ const SessionRow = memo(function SessionRow({
159
161
  const cacheRead = session.totals.cacheRead ?? 0;
160
162
  const hasCacheData = cacheRead > 0;
161
163
  const isEstimated = session.metadata?.isEstimated === true;
164
+ const hasLongContext = session.streams.some((s) => s.hasLongContext === true);
162
165
  const costUsd = session.totalCostUsd ?? 0;
163
166
 
164
167
  const animatedTokens = useAnimatedValue(effectiveTokens, { durationMs: 300, precision: 0 });
@@ -271,8 +274,9 @@ const SessionRow = memo(function SessionRow({
271
274
  </span>
272
275
  <span fg={textMutedColor}>{hasCacheData ? ` ↯${formatCacheBadge(cacheRead)}` : ""}</span>
273
276
  </text>
274
- <text width={8} height={1} fg={costColor} {...bgProp}>
275
- {costDisplay.padStart(7)}
277
+ <text width={8} height={1} {...bgProp}>
278
+ <span fg={costColor}>{costDisplay.padStart(7)}</span>
279
+ <span fg={textMutedColor}>{hasLongContext ? "↑" : " "}</span>
276
280
  </text>
277
281
  <text width={14} height={1} fg={textSubtleColor} {...bgProp}>
278
282
  {projectDisplay}
@@ -329,8 +333,9 @@ const SessionRow = memo(function SessionRow({
329
333
  {hasCacheData && showCacheBadge ? ` ↯${formatCacheBadge(cacheRead)}` : ""}
330
334
  </span>
331
335
  </text>
332
- <text width={7} height={1} fg={costColor} {...bgProp}>
333
- {formatCostVal(costUsd).padStart(6)}
336
+ <text width={7} height={1} {...bgProp}>
337
+ <span fg={costColor}>{formatCostVal(costUsd).padStart(6)}</span>
338
+ <span fg={textMutedColor}>{hasLongContext ? "↑" : " "}</span>
334
339
  </text>
335
340
  <text flexGrow={1} height={1} fg={textSubtleColor} {...bgProp}>
336
341
  {projectDisplay}
@@ -351,6 +356,8 @@ export const SessionsTable = forwardRef(function SessionsTable(
351
356
  {
352
357
  sessions,
353
358
  selectedRow,
359
+ scrollOffset,
360
+ visibleRows,
354
361
  isLoading,
355
362
  isFiltering,
356
363
  filterQuery,
@@ -398,10 +405,18 @@ export const SessionsTable = forwardRef(function SessionsTable(
398
405
  bulkThreshold: 100,
399
406
  });
400
407
 
401
- let activeIndex = 0;
402
408
  const selectedSession = sessions[selectedRow] ?? null;
403
409
  const inspector = selectedSession ? getInspectorData(selectedSession) : null;
404
410
 
411
+ // Spacer boxes preserve scrollbox inner height so scrollTo(scrollOffset) still maps correctly.
412
+ const RENDER_BUFFER = 4;
413
+ const visibleStart = Math.max(0, scrollOffset - RENDER_BUFFER);
414
+ const visibleEnd = Math.min(animatedSessions.length, scrollOffset + visibleRows + RENDER_BUFFER);
415
+ const visibleSlice =
416
+ animatedSessions.length > 0 ? animatedSessions.slice(visibleStart, visibleEnd) : [];
417
+ const topSpacer = visibleStart;
418
+ const bottomSpacer = Math.max(0, animatedSessions.length - visibleEnd);
419
+
405
420
  // Sort indicator helpers for column headers
406
421
  const sortIndicator = (field: string) =>
407
422
  sortField === field ? (sortDirection === "desc" ? "▼" : "▲") : " ";
@@ -527,8 +542,10 @@ export const SessionsTable = forwardRef(function SessionsTable(
527
542
  </text>
528
543
  </box>
529
544
  )}
530
- {animatedSessions.map((entry) => {
531
- const isSelectedRow = !entry.isExiting && activeIndex++ === selectedRow;
545
+ {topSpacer > 0 && <box height={topSpacer} flexShrink={0} />}
546
+ {visibleSlice.map((entry, i) => {
547
+ const absIndex = visibleStart + i;
548
+ const isSelectedRow = !entry.isExiting && absIndex === selectedRow;
532
549
  return (
533
550
  <SessionRow
534
551
  key={entry.item.sessionId}
@@ -544,6 +561,7 @@ export const SessionsTable = forwardRef(function SessionsTable(
544
561
  />
545
562
  );
546
563
  })}
564
+ {bottomSpacer > 0 && <box height={bottomSpacer} flexShrink={0} />}
547
565
  </box>
548
566
  </scrollbox>
549
567
  {terminalHeight >= 30 && (