@tokentop/ttop 0.6.1 → 0.7.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 (33) hide show
  1. package/package.json +4 -4
  2. package/src/agents/aggregator.test.ts +269 -1
  3. package/src/agents/aggregator.ts +196 -8
  4. package/src/agents/costing.test.ts +584 -0
  5. package/src/agents/costing.ts +144 -30
  6. package/src/agents/types.ts +7 -0
  7. package/src/demo/simulator.test.ts +92 -0
  8. package/src/demo/simulator.ts +579 -211
  9. package/src/plugins/plugin-context-factory.ts +9 -1
  10. package/src/plugins/providers/anthropic.test.ts +145 -0
  11. package/src/plugins/providers/anthropic.ts +18 -12
  12. package/src/plugins/providers/chutes.ts +2 -0
  13. package/src/plugins/providers/github-copilot.ts +3 -0
  14. package/src/plugins/providers/minimax.ts +2 -0
  15. package/src/plugins/providers/openai-api.ts +4 -0
  16. package/src/plugins/providers/zai.ts +2 -0
  17. package/src/pricing/estimator.ts +7 -0
  18. package/src/pricing/index.ts +5 -0
  19. package/src/pricing/models-dev.test.ts +117 -0
  20. package/src/pricing/models-dev.ts +22 -0
  21. package/src/storage/persistence-service.test.ts +201 -0
  22. package/src/storage/persistence-service.ts +54 -5
  23. package/src/storage/repos/agentSessions.ts +49 -0
  24. package/src/tui/App.tsx +4 -4
  25. package/src/tui/components/SessionDetailsDrawer.tsx +29 -1
  26. package/src/tui/components/SessionsTable.tsx +25 -7
  27. package/src/tui/contexts/AgentSessionContext.tsx +59 -21
  28. package/src/tui/contexts/PluginContext.tsx +31 -10
  29. package/src/tui/utils/scrollFollow.test.ts +71 -0
  30. package/src/tui/utils/scrollFollow.ts +12 -0
  31. package/src/tui/views/Dashboard.tsx +0 -6
  32. package/src/tui/views/RealTimeDashboard.tsx +105 -38
  33. package/src/version.ts +1 -1
@@ -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.7.0"; // x-release-please-version