@tokentop/ttop 0.5.0 → 0.6.1

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 (60) hide show
  1. package/README.md +38 -15
  2. package/package.json +8 -6
  3. package/src/agents/aggregator.test.ts +306 -0
  4. package/src/agents/aggregator.ts +40 -5
  5. package/src/cli.ts +14 -0
  6. package/src/config/schema.ts +1 -1
  7. package/src/demo/simulator.ts +1 -1
  8. package/src/plugins/agents/index.ts +1 -0
  9. package/src/plugins/lifecycle.ts +1 -1
  10. package/src/plugins/notification-bus.ts +27 -0
  11. package/src/plugins/notifications/terminal-bell.ts +80 -16
  12. package/src/plugins/notifications/visual-flash.ts +5 -18
  13. package/src/plugins/plugin-host.test.ts +197 -0
  14. package/src/plugins/plugin-host.ts +45 -1
  15. package/src/plugins/providers/anthropic.test.ts +202 -0
  16. package/src/plugins/providers/anthropic.ts +19 -0
  17. package/src/plugins/providers/antigravity.ts +16 -0
  18. package/src/plugins/providers/chutes.ts +16 -0
  19. package/src/plugins/providers/codex.ts +16 -0
  20. package/src/plugins/providers/gemini.ts +16 -0
  21. package/src/plugins/providers/github-copilot.ts +16 -0
  22. package/src/plugins/providers/minimax.ts +16 -0
  23. package/src/plugins/providers/openai-api.ts +52 -0
  24. package/src/plugins/providers/perplexity.ts +10 -1
  25. package/src/plugins/providers/zai.ts +16 -0
  26. package/src/pricing/fallback.ts +4 -0
  27. package/src/tui/App.tsx +22 -8
  28. package/src/tui/components/HelpOverlay.tsx +1 -1
  29. package/src/tui/components/LimitGauge.tsx +12 -4
  30. package/src/tui/components/NotificationFlash.tsx +141 -0
  31. package/src/tui/components/ProviderCard.tsx +38 -22
  32. package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
  33. package/src/tui/components/SessionsTable.tsx +54 -36
  34. package/src/tui/components/SettingsModal.tsx +20 -4
  35. package/src/tui/components/Skeleton.tsx +9 -6
  36. package/src/tui/components/SortOverlay.tsx +167 -0
  37. package/src/tui/components/Spinner.tsx +16 -11
  38. package/src/tui/components/Toast.tsx +1 -1
  39. package/src/tui/contexts/AgentSessionContext.tsx +69 -16
  40. package/src/tui/contexts/PluginContext.tsx +72 -10
  41. package/src/tui/contexts/ThemeContext.tsx +4 -1
  42. package/src/tui/createApp.tsx +2 -0
  43. package/src/tui/hooks/index.ts +1 -0
  44. package/src/tui/hooks/useAnimatedValue.ts +29 -26
  45. package/src/tui/hooks/useAnimationTick.ts +93 -0
  46. package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
  47. package/src/tui/hooks/useEntranceAnimation.ts +16 -10
  48. package/src/tui/hooks/useNotificationBridge.ts +29 -0
  49. package/src/tui/hooks/usePulse.ts +13 -7
  50. package/src/tui/hooks/useValueFlash.ts +25 -33
  51. package/src/tui/index.tsx +4 -0
  52. package/src/tui/types/projectSort.ts +35 -0
  53. package/src/tui/types/sort.ts +61 -0
  54. package/src/tui/views/ProjectsView.tsx +104 -38
  55. package/src/tui/views/RealTimeDashboard.tsx +91 -8
  56. package/src/tui/views/SettingsView.tsx +26 -8
  57. package/src/utils/__tests__/error-category.test.ts +147 -0
  58. package/src/utils/currency.ts +4 -2
  59. package/src/utils/error-category.ts +91 -0
  60. package/src/version.ts +1 -1
@@ -8,7 +8,7 @@ import {
8
8
  useRef,
9
9
  useState,
10
10
  } from "react";
11
- import { aggregateSessionUsage } from "@/agents/aggregator.ts";
11
+ import { aggregateSessionUsage, deduplicateAggregates } from "@/agents/aggregator.ts";
12
12
  import { priceSessions } from "@/agents/costing.ts";
13
13
  import type { AgentId, AgentInfo, AgentSessionAggregate } from "@/agents/types.ts";
14
14
  import { createPluginContext } from "@/plugins/plugin-context-factory.ts";
@@ -21,6 +21,10 @@ import { useLogs } from "./LogContext.tsx";
21
21
  import { usePlugins } from "./PluginContext.tsx";
22
22
  import { useTimeWindow } from "./TimeWindowContext.tsx";
23
23
 
24
+ /** Polling interval when at least one session is active. */
25
+ const ACTIVE_INTERVAL_MS = 3_000;
26
+ /** Polling interval when all sessions are idle/historical. */
27
+ const IDLE_INTERVAL_MS = 15_000;
24
28
  interface AgentSessionContextValue {
25
29
  sessions: AgentSessionAggregate[];
26
30
  agents: AgentInfo[];
@@ -35,14 +39,24 @@ const AgentSessionContext = createContext<AgentSessionContextValue | null>(null)
35
39
  interface AgentSessionProviderProps {
36
40
  children: ReactNode;
37
41
  autoRefresh?: boolean;
38
- refreshInterval?: number;
39
42
  }
40
43
 
41
- export function AgentSessionProvider({
42
- children,
43
- autoRefresh = false,
44
- refreshInterval = 30000,
45
- }: AgentSessionProviderProps) {
44
+ /**
45
+ * Compute a lightweight fingerprint of session state.
46
+ * If the fingerprint hasn't changed, we skip setSessions() to avoid
47
+ * unnecessary React re-renders of the entire session list.
48
+ */
49
+ function computeSessionFingerprint(sessions: AgentSessionAggregate[]): string {
50
+ // Build a compact string from the fields that actually affect the UI.
51
+ // This is intentionally simple — no crypto hashing needed, just string comparison.
52
+ let fp = `${sessions.length}:`;
53
+ for (const s of sessions) {
54
+ fp += `${s.sessionId},${s.status},${s.lastActivityAt},${s.totals.input},${s.totals.output},${s.totalCostUsd ?? 0},${s.requestCount};`;
55
+ }
56
+ return fp;
57
+ }
58
+
59
+ export function AgentSessionProvider({ children, autoRefresh = false }: AgentSessionProviderProps) {
46
60
  const [sessions, setSessions] = useState<AgentSessionAggregate[]>([]);
47
61
  const [agents, setAgents] = useState<AgentInfo[]>([]);
48
62
  const [isLoading, setIsLoading] = useState(false);
@@ -54,6 +68,7 @@ export function AgentSessionProvider({
54
68
  const { windowMs } = useTimeWindow();
55
69
  const hasBackfilled = useRef(false);
56
70
  const isRefreshingRef = useRef(false);
71
+ const lastFingerprintRef = useRef<string>("");
57
72
 
58
73
  const discoverAgents = useCallback(async (): Promise<AgentInfo[]> => {
59
74
  const agentPlugins = pluginRegistry.getAll("agent");
@@ -178,10 +193,34 @@ export function AgentSessionProvider({
178
193
  }
179
194
  }
180
195
 
181
- const pricedSessions = await priceSessions(allAggregates);
196
+ // Deduplicate sessions claimed by multiple agents scanning the same directory.
197
+ const dedupedAggregates = deduplicateAggregates(allAggregates);
198
+ if (dedupedAggregates.length < allAggregates.length) {
199
+ debug(
200
+ `Deduplicated sessions: ${allAggregates.length} \u2192 ${dedupedAggregates.length}`,
201
+ undefined,
202
+ "agent-sessions",
203
+ );
204
+ }
205
+
206
+ const pricedSessions = await priceSessions(dedupedAggregates);
182
207
 
183
208
  pricedSessions.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
184
209
 
210
+ // --- Change detection: skip React update if nothing changed ---
211
+ const newFingerprint = computeSessionFingerprint(pricedSessions);
212
+ const changed = newFingerprint !== lastFingerprintRef.current;
213
+ lastFingerprintRef.current = newFingerprint;
214
+
215
+ if (!changed) {
216
+ debug(
217
+ "Session refresh: no changes detected, skipping render",
218
+ undefined,
219
+ "agent-sessions",
220
+ );
221
+ return;
222
+ }
223
+
185
224
  // Persist to storage — the persistence service handles throttling
186
225
  // internally (fingerprint gate + 5-minute per-session throttle).
187
226
  // Safe to call every tick; state lives on globalThis, survives HMR.
@@ -260,18 +299,32 @@ export function AgentSessionProvider({
260
299
  useEffect(() => {
261
300
  if (!autoRefresh) return;
262
301
 
263
- debug(`Setting up session refresh interval: ${refreshInterval}ms`, undefined, "agent-sessions");
302
+ // Adaptive polling: poll faster when sessions are active, slower when idle.
303
+ // The interval is re-evaluated after each tick based on current session state.
304
+ let timerId: ReturnType<typeof setTimeout> | null = null;
264
305
 
265
- const intervalId = setInterval(() => {
266
- debug("Interval tick - refreshing sessions", undefined, "agent-sessions");
267
- refreshSessionsRef.current();
268
- }, refreshInterval);
306
+ const scheduleNext = () => {
307
+ const hasActive = sessions.some((s) => s.status === "active");
308
+ const interval = hasActive ? ACTIVE_INTERVAL_MS : IDLE_INTERVAL_MS;
309
+
310
+ timerId = setTimeout(() => {
311
+ debug(
312
+ `Adaptive tick (${hasActive ? "active" : "idle"}, ${interval}ms)`,
313
+ undefined,
314
+ "agent-sessions",
315
+ );
316
+ refreshSessionsRef.current().then(scheduleNext);
317
+ }, interval);
318
+ };
319
+
320
+ scheduleNext();
269
321
 
270
322
  return () => {
271
- debug("Clearing session refresh interval", undefined, "agent-sessions");
272
- clearInterval(intervalId);
323
+ if (timerId !== null) {
324
+ clearTimeout(timerId);
325
+ }
273
326
  };
274
- }, [autoRefresh, refreshInterval, debug]);
327
+ }, [autoRefresh, sessions, debug]);
275
328
 
276
329
  const value: AgentSessionContextValue = {
277
330
  sessions,
@@ -44,6 +44,8 @@ export interface ProviderState {
44
44
  loading: boolean;
45
45
  lastFetchAt: number | null;
46
46
  history: UsageSnapshot[];
47
+ /** Timestamp (ms) until which this provider should be skipped due to rate limiting. 0 = not rate limited. */
48
+ rateLimitUntil: number;
47
49
  }
48
50
 
49
51
  interface PluginContextValue {
@@ -190,6 +192,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
190
192
  loading: false,
191
193
  lastFetchAt: null,
192
194
  history: [],
195
+ rateLimitUntil: 0,
193
196
  });
194
197
 
195
198
  if (configured) {
@@ -238,6 +241,14 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
238
241
  initialize();
239
242
  }, []);
240
243
 
244
+ useEffect(() => {
245
+ if (!isInitialized) return;
246
+ notificationBus.setPluginConfig("terminal-bell", {
247
+ ...((config.pluginConfig ?? {})["terminal-bell"] ?? {}),
248
+ enabled: config.notifications.soundEnabled,
249
+ });
250
+ }, [config.notifications.soundEnabled, isInitialized]);
251
+
241
252
  const prevPluginConfigRef = useRef<Record<string, Record<string, unknown>>>({});
242
253
  useEffect(() => {
243
254
  if (!isInitialized) return;
@@ -264,6 +275,18 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
264
275
  return;
265
276
  }
266
277
 
278
+ // Skip providers that are currently rate-limited (backoff)
279
+ const now = Date.now();
280
+ if (state.rateLimitUntil > now) {
281
+ const remainingSec = Math.round((state.rateLimitUntil - now) / 1000);
282
+ debug(
283
+ `Skipping ${providerId}: rate limited for ${remainingSec}s more`,
284
+ undefined,
285
+ "refresh",
286
+ );
287
+ return;
288
+ }
289
+
267
290
  info(`Refreshing ${providerId}...`, undefined, "refresh");
268
291
 
269
292
  setProviders((prev) => {
@@ -310,6 +333,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
310
333
  usage,
311
334
  loading: false,
312
335
  lastFetchAt: Date.now(),
336
+ rateLimitUntil: 0,
313
337
  history: addToHistory(currentHistory, snapshotEntry),
314
338
  });
315
339
  return next;
@@ -337,16 +361,24 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
337
361
  const http = createSandboxedHttpClient(providerId, state.plugin.permissions);
338
362
  const logger = createPluginLogger(providerId);
339
363
 
340
- const fetchResult = await safeInvoke(providerId, "fetchUsage", () =>
341
- runInPluginGuard(providerId, state.plugin.permissions, () =>
342
- state.plugin.fetchUsage({
343
- credentials: creds,
344
- http,
345
- logger,
346
- config: {},
347
- signal: AbortSignal.timeout(30_000),
348
- }),
349
- ),
364
+ const fetchResult = await safeInvoke(
365
+ providerId,
366
+ "fetchUsage",
367
+ () =>
368
+ 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
+ }),
376
+ ),
377
+ {
378
+ // Count non-rate-limit errors as circuit breaker failures.
379
+ // Rate limits are handled separately via backoff scheduling.
380
+ isFailure: (usage) => !!usage.error && !usage.rateLimited,
381
+ },
350
382
  );
351
383
 
352
384
  if (!fetchResult.ok) {
@@ -355,6 +387,34 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
355
387
 
356
388
  const usage = fetchResult.value;
357
389
 
390
+ // Handle rate-limited responses: set backoff, preserve last good data
391
+ if (usage.rateLimited) {
392
+ const backoffMs = usage.retryAfterMs ?? 300_000; // default 5 min
393
+ const backoffUntil = Date.now() + backoffMs;
394
+ warn(
395
+ `${providerId} rate limited, backing off for ${Math.round(backoffMs / 1000)}s`,
396
+ { retryAfterMs: backoffMs, until: new Date(backoffUntil).toISOString() },
397
+ "refresh",
398
+ );
399
+
400
+ setProviders((prev) => {
401
+ const next = new Map(prev);
402
+ const current = next.get(providerId);
403
+ // Preserve the last successful usage data if we have it
404
+ const preservedUsage = current?.usage && !current.usage.error ? current.usage : usage;
405
+ next.set(providerId, {
406
+ ...state,
407
+ usage: preservedUsage,
408
+ loading: false,
409
+ lastFetchAt: Date.now(),
410
+ rateLimitUntil: backoffUntil,
411
+ history: current?.history ?? [],
412
+ });
413
+ return next;
414
+ });
415
+ return;
416
+ }
417
+
358
418
  if (usage.error) {
359
419
  warn(`${providerId} returned error: ${usage.error}`, undefined, "refresh");
360
420
  } else {
@@ -399,6 +459,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
399
459
  usage,
400
460
  loading: false,
401
461
  lastFetchAt: Date.now(),
462
+ rateLimitUntil: 0,
402
463
  history: addToHistory(currentHistory, snapshot),
403
464
  });
404
465
  return next;
@@ -418,6 +479,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
418
479
  },
419
480
  loading: false,
420
481
  lastFetchAt: Date.now(),
482
+ rateLimitUntil: current?.rateLimitUntil ?? 0,
421
483
  history: current?.history ?? [],
422
484
  });
423
485
  return next;
@@ -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
  }
@@ -0,0 +1,29 @@
1
+ import type { NotificationEvent } from "@tokentop/plugin-sdk";
2
+ import { useEffect } from "react";
3
+ import { notificationBus } from "@/plugins/notification-bus.ts";
4
+ import { useToastContext } from "../contexts/ToastContext.tsx";
5
+
6
+ function severityToToastType(
7
+ severity: NotificationEvent["severity"],
8
+ ): "info" | "warning" | "error" {
9
+ switch (severity) {
10
+ case "critical":
11
+ return "error";
12
+ case "warning":
13
+ return "warning";
14
+ default:
15
+ return "info";
16
+ }
17
+ }
18
+
19
+ export function useNotificationBridge(): void {
20
+ const { showToast } = useToastContext();
21
+
22
+ useEffect(() => {
23
+ const handler = (event: NotificationEvent) => {
24
+ showToast(event.title, severityToToastType(event.severity));
25
+ };
26
+ notificationBus.setToastHandler(handler);
27
+ return () => notificationBus.setToastHandler(null);
28
+ }, [showToast]);
29
+ }