@tokentop/ttop 0.4.0 → 0.6.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 (102) hide show
  1. package/README.md +7 -6
  2. package/package.json +12 -5
  3. package/src/agents/aggregator.test.ts +306 -0
  4. package/src/agents/aggregator.ts +44 -2
  5. package/src/agents/types.ts +3 -0
  6. package/src/cli.ts +16 -2
  7. package/src/demo/simulator.ts +2 -2
  8. package/src/plugins/agents/index.ts +3 -0
  9. package/src/plugins/auth-sources.ts +2 -3
  10. package/src/plugins/lifecycle.ts +2 -2
  11. package/src/plugins/loader.test.ts +1 -1
  12. package/src/plugins/loader.ts +15 -7
  13. package/src/plugins/notification-bus.ts +5 -2
  14. package/src/plugins/notifications/terminal-bell.ts +1 -1
  15. package/src/plugins/notifications/visual-flash.ts +1 -1
  16. package/src/plugins/plugin-context-factory.ts +4 -19
  17. package/src/plugins/plugin-host.test.ts +197 -0
  18. package/src/plugins/plugin-host.ts +45 -1
  19. package/src/plugins/providers/anthropic.test.ts +202 -0
  20. package/src/plugins/providers/anthropic.ts +21 -2
  21. package/src/plugins/providers/antigravity.ts +17 -1
  22. package/src/plugins/providers/chutes.ts +205 -0
  23. package/src/plugins/providers/codex.ts +17 -1
  24. package/src/plugins/providers/gemini.ts +17 -1
  25. package/src/plugins/providers/github-copilot.ts +17 -1
  26. package/src/plugins/providers/index.ts +1 -0
  27. package/src/plugins/providers/minimax.ts +17 -1
  28. package/src/plugins/providers/openai-api.ts +53 -1
  29. package/src/plugins/providers/opencode-zen.ts +1 -1
  30. package/src/plugins/providers/perplexity.ts +11 -2
  31. package/src/plugins/providers/zai.ts +17 -1
  32. package/src/plugins/registry.ts +9 -9
  33. package/src/plugins/sandbox-guard.test.ts +1 -1
  34. package/src/plugins/sandbox-guard.ts +1 -1
  35. package/src/plugins/sandbox.ts +1 -1
  36. package/src/plugins/themes/catppuccin-latte.ts +1 -1
  37. package/src/plugins/themes/catppuccin-mocha.ts +1 -1
  38. package/src/plugins/themes/claude-code.ts +1 -1
  39. package/src/plugins/themes/dracula.ts +1 -1
  40. package/src/plugins/themes/github-light.ts +1 -1
  41. package/src/plugins/themes/gruvbox-dark.ts +1 -1
  42. package/src/plugins/themes/gruvbox-light.ts +1 -1
  43. package/src/plugins/themes/kanagawa.ts +1 -1
  44. package/src/plugins/themes/nord.ts +1 -1
  45. package/src/plugins/themes/one-dark.ts +1 -1
  46. package/src/plugins/themes/opencode.ts +1 -1
  47. package/src/plugins/themes/rose-pine-dawn.ts +1 -1
  48. package/src/plugins/themes/rose-pine.ts +1 -1
  49. package/src/plugins/themes/solarized-light.ts +1 -1
  50. package/src/plugins/themes/tokyo-night.ts +1 -1
  51. package/src/plugins/update-checker.ts +2 -2
  52. package/src/pricing/estimator.test.ts +1 -1
  53. package/src/pricing/estimator.ts +1 -1
  54. package/src/pricing/fallback.ts +5 -1
  55. package/src/pricing/index.ts +1 -1
  56. package/src/pricing/models-dev.ts +1 -1
  57. package/src/storage/index.ts +6 -0
  58. package/src/storage/migrations/index.ts +17 -0
  59. package/src/storage/repos/pluginStorage.ts +35 -0
  60. package/src/tui/App.tsx +17 -9
  61. package/src/tui/components/HelpOverlay.tsx +1 -1
  62. package/src/tui/components/LimitGauge.tsx +12 -4
  63. package/src/tui/components/ProviderCard.tsx +39 -23
  64. package/src/tui/components/ProviderDetailPanel.tsx +1 -1
  65. package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
  66. package/src/tui/components/SessionDetailsDrawer.tsx +10 -0
  67. package/src/tui/components/SessionsTable.tsx +62 -37
  68. package/src/tui/components/SettingsModal.tsx +1 -1
  69. package/src/tui/components/Skeleton.tsx +9 -6
  70. package/src/tui/components/SortOverlay.tsx +167 -0
  71. package/src/tui/components/Spinner.tsx +16 -11
  72. package/src/tui/components/ThemePicker.tsx +1 -1
  73. package/src/tui/contexts/AgentSessionContext.tsx +70 -17
  74. package/src/tui/contexts/PluginContext.tsx +72 -14
  75. package/src/tui/contexts/RealTimeActivityContext.tsx +33 -20
  76. package/src/tui/contexts/ThemeContext.tsx +7 -4
  77. package/src/tui/createApp.tsx +2 -0
  78. package/src/tui/hooks/index.ts +1 -0
  79. package/src/tui/hooks/useAnimatedValue.ts +29 -26
  80. package/src/tui/hooks/useAnimationTick.ts +93 -0
  81. package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
  82. package/src/tui/hooks/useEntranceAnimation.ts +16 -10
  83. package/src/tui/hooks/usePulse.ts +13 -7
  84. package/src/tui/hooks/useSessionEnrichedProviders.ts +1 -1
  85. package/src/tui/hooks/useValueFlash.ts +25 -33
  86. package/src/tui/index.tsx +4 -0
  87. package/src/tui/types/projectSort.ts +35 -0
  88. package/src/tui/types/sort.ts +61 -0
  89. package/src/tui/views/ProjectsView.tsx +104 -38
  90. package/src/tui/views/RealTimeDashboard.tsx +91 -8
  91. package/src/tui/views/SettingsView.tsx +1 -1
  92. package/src/utils/__tests__/error-category.test.ts +147 -0
  93. package/src/utils/currency.ts +4 -2
  94. package/src/utils/error-category.ts +91 -0
  95. package/src/version.ts +1 -1
  96. package/src/plugins/types/agent.ts +0 -108
  97. package/src/plugins/types/base.ts +0 -278
  98. package/src/plugins/types/index.ts +0 -19
  99. package/src/plugins/types/notification.ts +0 -51
  100. package/src/plugins/types/provider.ts +0 -237
  101. package/src/plugins/types/theme.ts +0 -78
  102. package/src/storage/database.ts +0 -623
@@ -1,3 +1,11 @@
1
+ import type {
2
+ AgentPlugin,
3
+ Credentials,
4
+ NotificationPlugin,
5
+ ProviderPlugin,
6
+ ProviderUsageData,
7
+ ThemePlugin,
8
+ } from "@tokentop/plugin-sdk";
1
9
  import {
2
10
  createContext,
3
11
  type ReactNode,
@@ -14,10 +22,6 @@ import { safeInvoke, safeInvokeSync } from "@/plugins/plugin-host.ts";
14
22
  import { pluginRegistry } from "@/plugins/registry.ts";
15
23
  import { createPluginLogger, createSandboxedHttpClient } from "@/plugins/sandbox.ts";
16
24
  import { installGlobalFetchGuard, runInPluginGuard } from "@/plugins/sandbox-guard.ts";
17
- import type { AgentPlugin } from "@/plugins/types/agent.ts";
18
- import type { NotificationPlugin } from "@/plugins/types/notification.ts";
19
- import type { Credentials, ProviderPlugin, ProviderUsageData } from "@/plugins/types/provider.ts";
20
- import type { ThemePlugin } from "@/plugins/types/theme.ts";
21
25
  import { initPricingFromPlugins } from "@/pricing/index.ts";
22
26
  import type { ProviderSnapshotInsert } from "@/storage/types.ts";
23
27
  import { useConfig } from "./ConfigContext.tsx";
@@ -40,6 +44,8 @@ export interface ProviderState {
40
44
  loading: boolean;
41
45
  lastFetchAt: number | null;
42
46
  history: UsageSnapshot[];
47
+ /** Timestamp (ms) until which this provider should be skipped due to rate limiting. 0 = not rate limited. */
48
+ rateLimitUntil: number;
43
49
  }
44
50
 
45
51
  interface PluginContextValue {
@@ -186,6 +192,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
186
192
  loading: false,
187
193
  lastFetchAt: null,
188
194
  history: [],
195
+ rateLimitUntil: 0,
189
196
  });
190
197
 
191
198
  if (configured) {
@@ -260,6 +267,18 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
260
267
  return;
261
268
  }
262
269
 
270
+ // Skip providers that are currently rate-limited (backoff)
271
+ const now = Date.now();
272
+ if (state.rateLimitUntil > now) {
273
+ const remainingSec = Math.round((state.rateLimitUntil - now) / 1000);
274
+ debug(
275
+ `Skipping ${providerId}: rate limited for ${remainingSec}s more`,
276
+ undefined,
277
+ "refresh",
278
+ );
279
+ return;
280
+ }
281
+
263
282
  info(`Refreshing ${providerId}...`, undefined, "refresh");
264
283
 
265
284
  setProviders((prev) => {
@@ -306,6 +325,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
306
325
  usage,
307
326
  loading: false,
308
327
  lastFetchAt: Date.now(),
328
+ rateLimitUntil: 0,
309
329
  history: addToHistory(currentHistory, snapshotEntry),
310
330
  });
311
331
  return next;
@@ -333,16 +353,24 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
333
353
  const http = createSandboxedHttpClient(providerId, state.plugin.permissions);
334
354
  const logger = createPluginLogger(providerId);
335
355
 
336
- const fetchResult = await safeInvoke(providerId, "fetchUsage", () =>
337
- runInPluginGuard(providerId, state.plugin.permissions, () =>
338
- state.plugin.fetchUsage({
339
- credentials: creds,
340
- http,
341
- logger,
342
- config: {},
343
- signal: AbortSignal.timeout(30_000),
344
- }),
345
- ),
356
+ const fetchResult = await safeInvoke(
357
+ providerId,
358
+ "fetchUsage",
359
+ () =>
360
+ runInPluginGuard(providerId, state.plugin.permissions, () =>
361
+ state.plugin.fetchUsage({
362
+ credentials: creds,
363
+ http,
364
+ logger,
365
+ config: {},
366
+ signal: AbortSignal.timeout(30_000),
367
+ }),
368
+ ),
369
+ {
370
+ // Count non-rate-limit errors as circuit breaker failures.
371
+ // Rate limits are handled separately via backoff scheduling.
372
+ isFailure: (usage) => !!usage.error && !usage.rateLimited,
373
+ },
346
374
  );
347
375
 
348
376
  if (!fetchResult.ok) {
@@ -351,6 +379,34 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
351
379
 
352
380
  const usage = fetchResult.value;
353
381
 
382
+ // Handle rate-limited responses: set backoff, preserve last good data
383
+ if (usage.rateLimited) {
384
+ const backoffMs = usage.retryAfterMs ?? 300_000; // default 5 min
385
+ const backoffUntil = Date.now() + backoffMs;
386
+ warn(
387
+ `${providerId} rate limited, backing off for ${Math.round(backoffMs / 1000)}s`,
388
+ { retryAfterMs: backoffMs, until: new Date(backoffUntil).toISOString() },
389
+ "refresh",
390
+ );
391
+
392
+ setProviders((prev) => {
393
+ const next = new Map(prev);
394
+ const current = next.get(providerId);
395
+ // Preserve the last successful usage data if we have it
396
+ const preservedUsage = current?.usage && !current.usage.error ? current.usage : usage;
397
+ next.set(providerId, {
398
+ ...state,
399
+ usage: preservedUsage,
400
+ loading: false,
401
+ lastFetchAt: Date.now(),
402
+ rateLimitUntil: backoffUntil,
403
+ history: current?.history ?? [],
404
+ });
405
+ return next;
406
+ });
407
+ return;
408
+ }
409
+
354
410
  if (usage.error) {
355
411
  warn(`${providerId} returned error: ${usage.error}`, undefined, "refresh");
356
412
  } else {
@@ -395,6 +451,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
395
451
  usage,
396
452
  loading: false,
397
453
  lastFetchAt: Date.now(),
454
+ rateLimitUntil: 0,
398
455
  history: addToHistory(currentHistory, snapshot),
399
456
  });
400
457
  return next;
@@ -414,6 +471,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
414
471
  },
415
472
  loading: false,
416
473
  lastFetchAt: Date.now(),
474
+ rateLimitUntil: current?.rateLimitUntil ?? 0,
417
475
  history: current?.history ?? [],
418
476
  });
419
477
  return next;
@@ -1,3 +1,4 @@
1
+ import type { ActivityUpdate } from "@tokentop/plugin-sdk";
1
2
  import {
2
3
  createContext,
3
4
  type ReactNode,
@@ -9,7 +10,6 @@ import {
9
10
  } from "react";
10
11
  import { createPluginContext } from "@/plugins/plugin-context-factory.ts";
11
12
  import { pluginRegistry } from "@/plugins/registry.ts";
12
- import type { ActivityUpdate } from "@/plugins/types/agent.ts";
13
13
  import { useDemoMode } from "./DemoModeContext.tsx";
14
14
  import { useLogs } from "./LogContext.tsx";
15
15
  import { usePlugins } from "./PluginContext.tsx";
@@ -50,25 +50,38 @@ export function RealTimeActivityProvider({ children }: { children: ReactNode })
50
50
  };
51
51
  }, []);
52
52
 
53
- const handleActivityUpdate = useCallback(
54
- (update: ActivityUpdate) => {
55
- const key = `${update.sessionId}:${update.messageId}`;
56
- const prev = lastSeenRef.current.get(key);
57
- const prevTokens = prev?.tokens ?? 0;
58
- const newTokens = update.tokens.input + update.tokens.output + (update.tokens.reasoning ?? 0);
59
- const delta = Math.max(0, newTokens - prevTokens);
60
-
61
- if (delta > 0) {
62
- lastSeenRef.current.set(key, { tokens: newTokens, updatedAt: Date.now() });
63
- setLastActivityAt(update.timestamp);
64
- debug(`Real-time activity: +${delta} tokens`, { sessionId: update.sessionId }, "realtime");
65
-
66
- for (const listener of listenersRef.current) {
67
- listener(delta, update.timestamp);
68
- }
53
+ // Store the actual handler logic in a ref so it always has access to the
54
+ // latest `debug` without changing callback identity. This prevents the
55
+ // useEffect below from re-running on every render (which would trigger
56
+ // stopActivityWatch/startActivityWatch churn on every plugin).
57
+ const handleActivityUpdateRef = useRef<(update: ActivityUpdate) => void>(() => {});
58
+ handleActivityUpdateRef.current = (update: ActivityUpdate) => {
59
+ const key = `${update.sessionId}:${update.messageId}`;
60
+ const prev = lastSeenRef.current.get(key);
61
+ const prevTokens = prev?.tokens ?? 0;
62
+ const input = update.tokens.input ?? 0;
63
+ const output = update.tokens.output ?? 0;
64
+ const reasoning = update.tokens.reasoning ?? 0;
65
+ const newTokens = input + output + reasoning;
66
+ if (!Number.isFinite(newTokens)) return;
67
+ const delta = Math.max(0, newTokens - prevTokens);
68
+
69
+ if (delta > 0) {
70
+ lastSeenRef.current.set(key, { tokens: newTokens, updatedAt: Date.now() });
71
+ setLastActivityAt(update.timestamp);
72
+ debug(`Real-time activity: +${delta} tokens`, { sessionId: update.sessionId }, "realtime");
73
+
74
+ for (const listener of listenersRef.current) {
75
+ listener(delta, update.timestamp);
69
76
  }
70
- },
71
- [debug],
77
+ }
78
+ };
79
+
80
+ // Stable wrapper — identity never changes, so the useEffect below only
81
+ // runs once per plugin initialization (not on every debug/info change).
82
+ const handleActivityUpdate = useCallback(
83
+ (update: ActivityUpdate) => handleActivityUpdateRef.current(update),
84
+ [],
72
85
  );
73
86
 
74
87
  useEffect(() => {
@@ -106,7 +119,7 @@ export function RealTimeActivityProvider({ children }: { children: ReactNode })
106
119
  cleanupRef.current = null;
107
120
  }
108
121
  };
109
- }, [pluginsInitialized, demoMode, handleActivityUpdate, info]);
122
+ }, [pluginsInitialized, demoMode, handleActivityUpdate]);
110
123
 
111
124
  useEffect(() => {
112
125
  const interval = setInterval(() => {
@@ -1,12 +1,12 @@
1
- import { createContext, type ReactNode, useContext, useState } from "react";
2
- import { tokyoNightTheme } from "@/plugins/themes/tokyo-night.ts";
3
1
  import type {
4
2
  ColorScheme,
5
3
  ColorSchemePreference,
6
4
  ThemeColors,
7
5
  ThemeComponents,
8
6
  ThemePlugin,
9
- } from "@/plugins/types/theme.ts";
7
+ } from "@tokentop/plugin-sdk";
8
+ import { createContext, type ReactNode, useContext, useState } from "react";
9
+ import { tokyoNightTheme } from "@/plugins/themes/tokyo-night.ts";
10
10
 
11
11
  interface ThemeContextValue {
12
12
  theme: ThemePlugin;
@@ -16,6 +16,7 @@ interface ThemeContextValue {
16
16
  setTheme: (theme: ThemePlugin) => void;
17
17
  previewTheme: ThemePlugin | null;
18
18
  setPreviewTheme: (theme: ThemePlugin | null) => void;
19
+ cliTheme: string | undefined;
19
20
  }
20
21
 
21
22
  const defaultComponents: ThemeComponents = {
@@ -29,10 +30,11 @@ const ThemeContext = createContext<ThemeContextValue | null>(null);
29
30
 
30
31
  interface ThemeProviderProps {
31
32
  initialTheme?: ThemePlugin;
33
+ cliTheme?: string;
32
34
  children: ReactNode;
33
35
  }
34
36
 
35
- export function ThemeProvider({ initialTheme, children }: ThemeProviderProps) {
37
+ export function ThemeProvider({ initialTheme, cliTheme, children }: ThemeProviderProps) {
36
38
  const [theme, setTheme] = useState<ThemePlugin>(initialTheme ?? tokyoNightTheme);
37
39
  const [previewTheme, setPreviewTheme] = useState<ThemePlugin | null>(null);
38
40
 
@@ -46,6 +48,7 @@ export function ThemeProvider({ initialTheme, children }: ThemeProviderProps) {
46
48
  setTheme,
47
49
  previewTheme,
48
50
  setPreviewTheme,
51
+ cliTheme,
49
52
  };
50
53
 
51
54
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
@@ -13,6 +13,7 @@ export interface CreateAppOptions {
13
13
  demoSeed?: number;
14
14
  demoPreset?: DemoPreset;
15
15
  cliPlugins?: string[];
16
+ cliTheme?: string;
16
17
  }
17
18
 
18
19
  export const DEFAULT_APP_OPTIONS = {
@@ -32,6 +33,7 @@ export function createAppElement(options: CreateAppOptions = {}): ReactNode {
32
33
  {...(options.demoSeed !== undefined ? { demoSeed: options.demoSeed } : {})}
33
34
  {...(options.demoPreset !== undefined ? { demoPreset: options.demoPreset } : {})}
34
35
  {...(options.cliPlugins ? { cliPlugins: options.cliPlugins } : {})}
36
+ {...(options.cliTheme ? { cliTheme: options.cliTheme } : {})}
35
37
  />
36
38
  );
37
39
 
@@ -6,6 +6,7 @@ export {
6
6
  useAnimatedTokens,
7
7
  useAnimatedValue,
8
8
  } from "./useAnimatedValue.ts";
9
+ export { animationTick, useAnimationTick } from "./useAnimationTick.ts";
9
10
  export type { DashboardDeltas, UseDashboardStateResult } from "./useDashboardState.ts";
10
11
  export { useDashboardState } from "./useDashboardState.ts";
11
12
  export type { ActivityDebugData, ActivityState, UseActivityRateResult } from "./useEmaActivity.ts";
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
+ import { animationTick } from "./useAnimationTick.ts";
2
3
 
3
4
  export type EasingFunction = (t: number) => number;
4
5
 
@@ -14,6 +15,7 @@ export interface UseAnimatedValueOptions {
14
15
  durationMs?: number;
15
16
  easing?: keyof typeof easings | EasingFunction;
16
17
  precision?: number;
18
+ /** @deprecated Ignored — animations now use the global 30 Hz tick. */
17
19
  frameRate?: number;
18
20
  }
19
21
 
@@ -21,57 +23,58 @@ export function useAnimatedValue(
21
23
  targetValue: number,
22
24
  options: UseAnimatedValueOptions = {},
23
25
  ): number {
24
- const { durationMs = 300, easing = "easeOutQuad", precision = 2, frameRate = 60 } = options;
26
+ const { durationMs = 300, easing = "easeOutQuad", precision = 2 } = options;
25
27
 
26
28
  const [displayValue, setDisplayValue] = useState(targetValue);
27
- const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
29
+ const displayRef = useRef(targetValue);
28
30
  const startValueRef = useRef(targetValue);
29
- const startTimeRef = useRef<number>(Date.now());
31
+ const startTimeRef = useRef(0);
30
32
  const targetRef = useRef(targetValue);
33
+ const unsubRef = useRef<(() => void) | null>(null);
31
34
 
32
35
  const easingFn = typeof easing === "function" ? easing : easings[easing];
33
- const frameInterval = Math.round(1000 / frameRate);
34
36
 
35
37
  useEffect(() => {
36
- if (targetRef.current === targetValue && displayValue === targetValue) {
38
+ // Already at target — nothing to animate
39
+ if (targetRef.current === targetValue && displayRef.current === targetValue) {
37
40
  return;
38
41
  }
39
42
 
40
- if (intervalRef.current !== null) {
41
- clearInterval(intervalRef.current);
42
- }
43
+ // Clean up any existing subscription
44
+ unsubRef.current?.();
43
45
 
44
- startValueRef.current = displayValue;
46
+ // Set up animation from current display value to new target
47
+ startValueRef.current = displayRef.current;
45
48
  startTimeRef.current = Date.now();
46
49
  targetRef.current = targetValue;
47
50
 
48
- intervalRef.current = setInterval(() => {
49
- const elapsed = Date.now() - startTimeRef.current;
51
+ // Subscribe to global tick — auto-starts the shared timer
52
+ unsubRef.current = animationTick.subscribe((now) => {
53
+ const elapsed = now - startTimeRef.current;
50
54
  const progress = Math.min(elapsed / durationMs, 1);
51
55
  const easedProgress = easingFn(progress);
52
-
53
56
  const startVal = startValueRef.current;
54
- const currentValue = startVal + (targetRef.current - startVal) * easedProgress;
55
- const roundedValue = Number(currentValue.toFixed(precision));
57
+ const target = targetRef.current;
58
+ const currentValue = startVal + (target - startVal) * easedProgress;
59
+ const rounded = Number(currentValue.toFixed(precision));
56
60
 
57
- setDisplayValue(roundedValue);
61
+ displayRef.current = rounded;
62
+ setDisplayValue(rounded);
58
63
 
59
64
  if (progress >= 1) {
60
- if (intervalRef.current !== null) {
61
- clearInterval(intervalRef.current);
62
- intervalRef.current = null;
63
- }
64
- setDisplayValue(targetRef.current);
65
+ displayRef.current = target;
66
+ setDisplayValue(target);
67
+ // Unsubscribe — if no other animations are active, the global timer stops
68
+ unsubRef.current?.();
69
+ unsubRef.current = null;
65
70
  }
66
- }, frameInterval);
71
+ });
67
72
 
68
73
  return () => {
69
- if (intervalRef.current !== null) {
70
- clearInterval(intervalRef.current);
71
- intervalRef.current = null;
72
- }
74
+ unsubRef.current?.();
75
+ unsubRef.current = null;
73
76
  };
74
- }, [targetValue, durationMs, easingFn, precision, frameInterval, displayValue]);
77
+ }, [targetValue, durationMs, easingFn, precision]);
75
78
 
76
79
  return displayValue;
77
80
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Global animation tick manager.
3
+ *
4
+ * Replaces per-component setInterval timers with a single shared 30 Hz tick.
5
+ * This eliminates the ~90 concurrent timers that were causing ~2,400 state
6
+ * updates/sec and ~60% CPU usage at idle.
7
+ *
8
+ * The tick automatically starts when the first subscriber joins and stops
9
+ * when the last subscriber leaves, consuming zero CPU when no animations
10
+ * are active.
11
+ *
12
+ * @see https://github.com/tokentopapp/tokentop/issues/55
13
+ */
14
+ import { useEffect, useRef } from "react";
15
+
16
+ type TickCallback = (now: number) => void;
17
+
18
+ const TICK_RATE_HZ = 30;
19
+ const TICK_INTERVAL_MS = Math.round(1000 / TICK_RATE_HZ);
20
+
21
+ class AnimationTickManager {
22
+ private callbacks = new Set<TickCallback>();
23
+ private interval: ReturnType<typeof setInterval> | null = null;
24
+
25
+ /**
26
+ * Subscribe a callback to the global tick.
27
+ * Returns an unsubscribe function. When the last subscriber leaves,
28
+ * the underlying setInterval is cleared automatically.
29
+ */
30
+ subscribe(callback: TickCallback): () => void {
31
+ this.callbacks.add(callback);
32
+ if (this.callbacks.size === 1) {
33
+ this.start();
34
+ }
35
+ return () => {
36
+ this.callbacks.delete(callback);
37
+ if (this.callbacks.size === 0) {
38
+ this.stop();
39
+ }
40
+ };
41
+ }
42
+
43
+ /** Number of active subscribers (useful for debugging). */
44
+ get subscriberCount(): number {
45
+ return this.callbacks.size;
46
+ }
47
+
48
+ /** Whether the global interval is currently running. */
49
+ get isRunning(): boolean {
50
+ return this.interval !== null;
51
+ }
52
+
53
+ private start(): void {
54
+ if (this.interval !== null) return;
55
+ this.interval = setInterval(() => {
56
+ const now = Date.now();
57
+ for (const cb of this.callbacks) {
58
+ cb(now);
59
+ }
60
+ }, TICK_INTERVAL_MS);
61
+ }
62
+
63
+ private stop(): void {
64
+ if (this.interval !== null) {
65
+ clearInterval(this.interval);
66
+ this.interval = null;
67
+ }
68
+ }
69
+ }
70
+
71
+ /** Global animation tick singleton — single 30 Hz timer shared by all hooks. */
72
+ export const animationTick = new AnimationTickManager();
73
+
74
+ /**
75
+ * React hook to subscribe to the global animation tick.
76
+ *
77
+ * The callback fires at ~30 Hz while `active` is true.
78
+ * Uses a ref to always call the latest callback (no stale closures).
79
+ *
80
+ * @param callback - Called with current timestamp (Date.now()) on each tick
81
+ * @param active - Whether to subscribe (default: true). When false, the
82
+ * subscription is paused and the global timer may stop if no other
83
+ * subscribers remain.
84
+ */
85
+ export function useAnimationTick(callback: TickCallback, active = true): void {
86
+ const callbackRef = useRef(callback);
87
+ callbackRef.current = callback;
88
+
89
+ useEffect(() => {
90
+ if (!active) return;
91
+ return animationTick.subscribe((now) => callbackRef.current(now));
92
+ }, [active]);
93
+ }
@@ -7,6 +7,7 @@ import { useAgentSessions } from "../contexts/AgentSessionContext.tsx";
7
7
  import { useInputFocus } from "../contexts/InputContext.tsx";
8
8
  import { useTimeWindow } from "../contexts/TimeWindowContext.tsx";
9
9
  import { useToastContext } from "../contexts/ToastContext.tsx";
10
+ import type { SortDirection, SortField } from "../types/sort.ts";
10
11
 
11
12
  function formatSessionSummary(session: AgentSessionAggregate): string {
12
13
  const effectiveTokens = session.totals.input + session.totals.output;
@@ -54,7 +55,9 @@ interface DashboardKeyboardState {
54
55
  sidebarCollapsed: boolean;
55
56
  filterQuery: string;
56
57
  isFiltering: boolean;
57
- sortField: "cost" | "tokens" | "time";
58
+ sortField: SortField;
59
+ sortDirection: SortDirection;
60
+ showSortOverlay: boolean;
58
61
  pendingG: boolean;
59
62
  scrollOffset: number;
60
63
  limitSelectedIndex: number;
@@ -76,13 +79,15 @@ interface DashboardKeyboardActions {
76
79
  setSidebarCollapsed: (fn: (prev: boolean) => boolean) => void;
77
80
  setFilterQuery: (fn: (prev: string) => string) => void;
78
81
  setIsFiltering: (val: boolean) => void;
79
- setSortField: (fn: (prev: "cost" | "tokens" | "time") => "cost" | "tokens" | "time") => void;
82
+ setSortField: (fn: (prev: SortField) => SortField) => void;
83
+ setSortDirection: (fn: (prev: SortDirection) => SortDirection) => void;
80
84
  setPendingG: (val: boolean) => void;
81
85
  setScrollOffset: (val: number) => void;
82
86
  setLimitSelectedIndex: (fn: (prev: number) => number) => void;
83
87
  setDriverDimension: (fn: (prev: DriverDimension) => DriverDimension) => void;
84
88
  setSelectedDriverIndex: (fn: (prev: number) => number) => void;
85
89
  setActiveDriverFilter: (val: string | null) => void;
90
+ setShowSortOverlay: (val: boolean) => void;
86
91
  }
87
92
 
88
93
  interface UseDashboardKeyboardProps {
@@ -115,7 +120,7 @@ export function useDashboardKeyboard({
115
120
 
116
121
  useEffect(() => {
117
122
  isFilteringRef.current = state.isFiltering;
118
- modalOpenRef.current = state.showHelp || state.showSessionDrawer;
123
+ modalOpenRef.current = state.showHelp || state.showSessionDrawer || state.showSortOverlay;
119
124
  pendingGRef.current = state.pendingG;
120
125
  sessionsRef.current = processedSessions;
121
126
  focusedPanelRef.current = state.focusedPanel;
@@ -129,6 +134,7 @@ export function useDashboardKeyboard({
129
134
  state.isFiltering,
130
135
  state.showHelp,
131
136
  state.showSessionDrawer,
137
+ state.showSortOverlay,
132
138
  state.pendingG,
133
139
  processedSessions,
134
140
  state.focusedPanel,
@@ -242,8 +248,9 @@ export function useDashboardKeyboard({
242
248
  return;
243
249
  }
244
250
 
251
+ // Open sort overlay menu
245
252
  if (key.name === "s") {
246
- actions.setSortField((curr) => (curr === "cost" ? "tokens" : "cost"));
253
+ actions.setShowSortOverlay(true);
247
254
  return;
248
255
  }
249
256
 
@@ -1,7 +1,9 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
+ import { animationTick } from "./useAnimationTick.ts";
2
3
 
3
4
  export interface UseEntranceAnimationOptions {
4
5
  durationMs?: number;
6
+ /** @deprecated Ignored — animations now use the global 30 Hz tick. */
5
7
  fps?: number;
6
8
  }
7
9
 
@@ -11,26 +13,30 @@ export interface UseEntranceAnimationOptions {
11
13
  * Use this to fade in new items in a list.
12
14
  */
13
15
  export function useEntranceAnimation(options: UseEntranceAnimationOptions = {}): number {
14
- const { durationMs = 400, fps = 30 } = options;
16
+ const { durationMs = 400 } = options;
15
17
  const mountTime = useRef(Date.now());
16
18
  const [intensity, setIntensity] = useState(0);
19
+ const unsubRef = useRef<(() => void) | null>(null);
17
20
 
18
21
  useEffect(() => {
19
- const intervalMs = 1000 / fps;
20
-
21
- const tick = () => {
22
- const elapsed = Date.now() - mountTime.current;
22
+ unsubRef.current = animationTick.subscribe((now) => {
23
+ const elapsed = now - mountTime.current;
23
24
  const progress = Math.min(1, elapsed / durationMs);
24
25
  const eased = 1 - (1 - progress) ** 2;
25
26
  setIntensity(eased);
26
27
 
27
- if (progress < 1) {
28
- setTimeout(tick, intervalMs);
28
+ if (progress >= 1) {
29
+ setIntensity(1);
30
+ unsubRef.current?.();
31
+ unsubRef.current = null;
29
32
  }
30
- };
33
+ });
31
34
 
32
- tick();
33
- }, [durationMs, fps]);
35
+ return () => {
36
+ unsubRef.current?.();
37
+ unsubRef.current = null;
38
+ };
39
+ }, [durationMs]);
34
40
 
35
41
  return intensity;
36
42
  }
@@ -1,4 +1,5 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { animationTick } from "./useAnimationTick.ts";
2
3
 
3
4
  /**
4
5
  * Options for the usePulse hook.
@@ -39,7 +40,7 @@ function parseHexColor(hex: string): [number, number, number] | null {
39
40
  const g = parseInt(expandedHex.slice(2, 4), 16);
40
41
  const b = parseInt(expandedHex.slice(4, 6), 16);
41
42
 
42
- if (isNaN(r) || isNaN(g) || isNaN(b)) {
43
+ if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {
43
44
  return null;
44
45
  }
45
46
 
@@ -104,6 +105,9 @@ export function getPulseColor(
104
105
  * Returns the current step in the pulse cycle, which can be used
105
106
  * with getPulseColor to create smooth color transitions.
106
107
  *
108
+ * Subscribes to the global animation tick when enabled and unsubscribes
109
+ * when disabled, so the global timer stops when no pulses are active.
110
+ *
107
111
  * @param options - Configuration options for the pulse animation
108
112
  * @returns Current step in the pulse cycle (0 to steps-1), or 0 when disabled
109
113
  *
@@ -120,6 +124,7 @@ export function getPulseColor(
120
124
  export function usePulse(options: UsePulseOptions): number {
121
125
  const { enabled, intervalMs = 200, steps = 12 } = options;
122
126
  const [step, setStep] = useState(0);
127
+ const lastAdvanceRef = useRef(Date.now());
123
128
 
124
129
  useEffect(() => {
125
130
  if (!enabled) {
@@ -127,11 +132,12 @@ export function usePulse(options: UsePulseOptions): number {
127
132
  return;
128
133
  }
129
134
 
130
- const interval = setInterval(() => {
131
- setStep((prev) => (prev + 1) % steps);
132
- }, intervalMs);
133
-
134
- return () => clearInterval(interval);
135
+ return animationTick.subscribe((now) => {
136
+ if (now - lastAdvanceRef.current >= intervalMs) {
137
+ lastAdvanceRef.current = now;
138
+ setStep((prev) => (prev + 1) % steps);
139
+ }
140
+ });
135
141
  }, [enabled, intervalMs, steps]);
136
142
 
137
143
  return step;
@@ -1,6 +1,6 @@
1
+ import type { ProviderUsageData } from "@tokentop/plugin-sdk";
1
2
  import { useMemo } from "react";
2
3
  import type { AgentSessionAggregate } from "@/agents/types.ts";
3
- import type { ProviderUsageData } from "@/plugins/types/provider.ts";
4
4
  import type { ProviderState } from "../contexts/PluginContext.tsx";
5
5
 
6
6
  interface ProviderSessionTotals {