@tokentop/ttop 0.5.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 (51) hide show
  1. package/README.md +7 -6
  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/demo/simulator.ts +1 -1
  7. package/src/plugins/agents/index.ts +1 -0
  8. package/src/plugins/lifecycle.ts +1 -1
  9. package/src/plugins/plugin-host.test.ts +197 -0
  10. package/src/plugins/plugin-host.ts +45 -1
  11. package/src/plugins/providers/anthropic.test.ts +202 -0
  12. package/src/plugins/providers/anthropic.ts +19 -0
  13. package/src/plugins/providers/antigravity.ts +16 -0
  14. package/src/plugins/providers/chutes.ts +16 -0
  15. package/src/plugins/providers/codex.ts +16 -0
  16. package/src/plugins/providers/gemini.ts +16 -0
  17. package/src/plugins/providers/github-copilot.ts +16 -0
  18. package/src/plugins/providers/minimax.ts +16 -0
  19. package/src/plugins/providers/openai-api.ts +52 -0
  20. package/src/plugins/providers/perplexity.ts +10 -1
  21. package/src/plugins/providers/zai.ts +16 -0
  22. package/src/pricing/fallback.ts +4 -0
  23. package/src/tui/App.tsx +16 -8
  24. package/src/tui/components/HelpOverlay.tsx +1 -1
  25. package/src/tui/components/LimitGauge.tsx +12 -4
  26. package/src/tui/components/ProviderCard.tsx +38 -22
  27. package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
  28. package/src/tui/components/SessionsTable.tsx +54 -36
  29. package/src/tui/components/Skeleton.tsx +9 -6
  30. package/src/tui/components/SortOverlay.tsx +167 -0
  31. package/src/tui/components/Spinner.tsx +16 -11
  32. package/src/tui/contexts/AgentSessionContext.tsx +69 -16
  33. package/src/tui/contexts/PluginContext.tsx +64 -10
  34. package/src/tui/contexts/ThemeContext.tsx +4 -1
  35. package/src/tui/createApp.tsx +2 -0
  36. package/src/tui/hooks/index.ts +1 -0
  37. package/src/tui/hooks/useAnimatedValue.ts +29 -26
  38. package/src/tui/hooks/useAnimationTick.ts +93 -0
  39. package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
  40. package/src/tui/hooks/useEntranceAnimation.ts +16 -10
  41. package/src/tui/hooks/usePulse.ts +13 -7
  42. package/src/tui/hooks/useValueFlash.ts +25 -33
  43. package/src/tui/index.tsx +4 -0
  44. package/src/tui/types/projectSort.ts +35 -0
  45. package/src/tui/types/sort.ts +61 -0
  46. package/src/tui/views/ProjectsView.tsx +104 -38
  47. package/src/tui/views/RealTimeDashboard.tsx +91 -8
  48. package/src/utils/__tests__/error-category.test.ts +147 -0
  49. package/src/utils/currency.ts +4 -2
  50. package/src/utils/error-category.ts +91 -0
  51. package/src/version.ts +1 -1
@@ -0,0 +1,167 @@
1
+ import { useKeyboard } from "@opentui/react";
2
+ import { useEffect, useState } from "react";
3
+ import { useInputFocus } from "../contexts/InputContext.tsx";
4
+ import { useColors } from "../contexts/ThemeContext.tsx";
5
+ import type { SortDirection, SortFieldDef } from "../types/sort.ts";
6
+ import { getSortDirectionIndicator } from "../types/sort.ts";
7
+ import { ModalBackdrop, Z_INDEX } from "./ModalBackdrop.tsx";
8
+
9
+ interface SortOverlayProps<F extends string = string> {
10
+ fields: readonly SortFieldDef<F>[];
11
+ currentField: F;
12
+ currentDirection: SortDirection;
13
+ onSelect: (field: F, direction: SortDirection) => void;
14
+ onClose: () => void;
15
+ }
16
+
17
+ export function SortOverlay<F extends string>({
18
+ fields,
19
+ currentField,
20
+ currentDirection,
21
+ onSelect,
22
+ onClose,
23
+ }: SortOverlayProps<F>) {
24
+ const colors = useColors();
25
+ const { setInputFocused } = useInputFocus();
26
+
27
+ const [highlightedIndex, setHighlightedIndex] = useState(() => {
28
+ const idx = fields.findIndex((f) => f.id === currentField);
29
+ return idx === -1 ? 0 : idx;
30
+ });
31
+
32
+ const [pendingDirection, setPendingDirection] = useState<SortDirection>(currentDirection);
33
+
34
+ // Capture input focus to block App.tsx and view-level keyboard handlers
35
+ useEffect(() => {
36
+ setInputFocused(true);
37
+ return () => setInputFocused(false);
38
+ }, [setInputFocused]);
39
+
40
+ useKeyboard((key) => {
41
+ // Navigation
42
+ if (key.name === "up" || key.name === "k") {
43
+ setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : fields.length - 1));
44
+ return;
45
+ }
46
+
47
+ if (key.name === "down" || key.name === "j") {
48
+ setHighlightedIndex((prev) => (prev < fields.length - 1 ? prev + 1 : 0));
49
+ return;
50
+ }
51
+
52
+ // Toggle direction in-place
53
+ if (key.name === "space" || key.sequence === " ") {
54
+ setPendingDirection((prev) => (prev === "asc" ? "desc" : "asc"));
55
+ return;
56
+ }
57
+
58
+ // Apply highlighted selection
59
+ if (key.name === "return") {
60
+ const field = fields[highlightedIndex];
61
+ if (field) {
62
+ onSelect(field.id, pendingDirection);
63
+ }
64
+ return;
65
+ }
66
+
67
+ // Cancel
68
+ if (key.name === "escape") {
69
+ onClose();
70
+ return;
71
+ }
72
+
73
+ // Number quick-select: 1-9 selects field by position
74
+ // If already sorting by that field, flip direction instead
75
+ if (key.sequence && key.sequence.length === 1) {
76
+ const digit = key.sequence.charCodeAt(0) - 48; // '0' = 48
77
+ if (digit >= 1 && digit <= fields.length) {
78
+ const field = fields[digit - 1];
79
+ if (field) {
80
+ const direction =
81
+ field.id === currentField
82
+ ? currentDirection === "desc"
83
+ ? "asc"
84
+ : "desc"
85
+ : field.defaultDirection;
86
+ onSelect(field.id, direction);
87
+ }
88
+ return;
89
+ }
90
+ }
91
+ });
92
+
93
+ return (
94
+ <ModalBackdrop zIndex={Z_INDEX.MODAL} onBackdropClick={onClose}>
95
+ <box
96
+ width={34}
97
+ flexDirection="column"
98
+ border
99
+ borderStyle="double"
100
+ borderColor={colors.primary}
101
+ backgroundColor={colors.background}
102
+ overflow="hidden"
103
+ >
104
+ {/* Header */}
105
+ <box height={1} justifyContent="center">
106
+ <text fg={colors.primary} height={1}>
107
+ Sort By
108
+ </text>
109
+ </box>
110
+
111
+ {/* Sort Fields */}
112
+ <box flexDirection="column">
113
+ {fields.map((field, index) => {
114
+ const isHighlighted = index === highlightedIndex;
115
+ const isCurrent = field.id === currentField;
116
+
117
+ const textColor = isHighlighted ? colors.background : colors.text;
118
+ const numberColor = isHighlighted ? colors.background : colors.textMuted;
119
+ const markerColor = isHighlighted ? colors.background : colors.primary;
120
+
121
+ return (
122
+ <box
123
+ key={field.id}
124
+ flexDirection="row"
125
+ height={1}
126
+ paddingLeft={1}
127
+ paddingRight={1}
128
+ justifyContent="space-between"
129
+ {...(isHighlighted ? { backgroundColor: colors.primary } : {})}
130
+ >
131
+ {/* Left: number + marker + label + direction */}
132
+ <text height={1}>
133
+ <span fg={numberColor}>{index + 1} </span>
134
+ <span fg={markerColor}>{isCurrent ? "● " : " "}</span>
135
+ <span fg={textColor}>{field.label}</span>
136
+ {isHighlighted ? (
137
+ <span fg={colors.warning}> {getSortDirectionIndicator(pendingDirection)}</span>
138
+ ) : null}
139
+ </text>
140
+ </box>
141
+ );
142
+ })}
143
+ </box>
144
+
145
+ {/* Footer Hints */}
146
+ <box flexDirection="column" paddingLeft={1} paddingRight={1} marginTop={1}>
147
+ <box flexDirection="row" justifyContent="space-between" height={1}>
148
+ <text fg={colors.textSubtle} height={1}>
149
+ ↑↓ navigate
150
+ </text>
151
+ <text fg={colors.textSubtle} height={1}>
152
+ Space toggle
153
+ </text>
154
+ </box>
155
+ <box flexDirection="row" justifyContent="space-between" height={1}>
156
+ <text fg={colors.textSubtle} height={1}>
157
+ Enter apply
158
+ </text>
159
+ <text fg={colors.textSubtle} height={1}>
160
+ Esc cancel
161
+ </text>
162
+ </box>
163
+ </box>
164
+ </box>
165
+ </ModalBackdrop>
166
+ );
167
+ }
@@ -1,4 +1,5 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { animationTick } from "../hooks/useAnimationTick.ts";
2
3
 
3
4
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
4
5
  const FRAME_INTERVAL = 80;
@@ -9,13 +10,15 @@ interface SpinnerProps {
9
10
 
10
11
  export function Spinner({ color }: SpinnerProps) {
11
12
  const [frameIndex, setFrameIndex] = useState(0);
13
+ const lastAdvanceRef = useRef(Date.now());
12
14
 
13
15
  useEffect(() => {
14
- const interval = setInterval(() => {
15
- setFrameIndex((i) => (i + 1) % SPINNER_FRAMES.length);
16
- }, FRAME_INTERVAL);
17
-
18
- return () => clearInterval(interval);
16
+ return animationTick.subscribe((now) => {
17
+ if (now - lastAdvanceRef.current >= FRAME_INTERVAL) {
18
+ lastAdvanceRef.current = now;
19
+ setFrameIndex((i) => (i + 1) % SPINNER_FRAMES.length);
20
+ }
21
+ });
19
22
  }, []);
20
23
 
21
24
  return <text {...(color ? { fg: color } : {})}>{SPINNER_FRAMES[frameIndex]}</text>;
@@ -23,13 +26,15 @@ export function Spinner({ color }: SpinnerProps) {
23
26
 
24
27
  export function useSpinner(): string {
25
28
  const [frameIndex, setFrameIndex] = useState(0);
29
+ const lastAdvanceRef = useRef(Date.now());
26
30
 
27
31
  useEffect(() => {
28
- const interval = setInterval(() => {
29
- setFrameIndex((i) => (i + 1) % SPINNER_FRAMES.length);
30
- }, FRAME_INTERVAL);
31
-
32
- return () => clearInterval(interval);
32
+ return animationTick.subscribe((now) => {
33
+ if (now - lastAdvanceRef.current >= FRAME_INTERVAL) {
34
+ lastAdvanceRef.current = now;
35
+ setFrameIndex((i) => (i + 1) % SPINNER_FRAMES.length);
36
+ }
37
+ });
33
38
  }, []);
34
39
 
35
40
  return SPINNER_FRAMES[frameIndex] ?? "⠋";
@@ -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) {
@@ -264,6 +267,18 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
264
267
  return;
265
268
  }
266
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
+
267
282
  info(`Refreshing ${providerId}...`, undefined, "refresh");
268
283
 
269
284
  setProviders((prev) => {
@@ -310,6 +325,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
310
325
  usage,
311
326
  loading: false,
312
327
  lastFetchAt: Date.now(),
328
+ rateLimitUntil: 0,
313
329
  history: addToHistory(currentHistory, snapshotEntry),
314
330
  });
315
331
  return next;
@@ -337,16 +353,24 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
337
353
  const http = createSandboxedHttpClient(providerId, state.plugin.permissions);
338
354
  const logger = createPluginLogger(providerId);
339
355
 
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
- ),
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
+ },
350
374
  );
351
375
 
352
376
  if (!fetchResult.ok) {
@@ -355,6 +379,34 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
355
379
 
356
380
  const usage = fetchResult.value;
357
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
+
358
410
  if (usage.error) {
359
411
  warn(`${providerId} returned error: ${usage.error}`, undefined, "refresh");
360
412
  } else {
@@ -399,6 +451,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
399
451
  usage,
400
452
  loading: false,
401
453
  lastFetchAt: Date.now(),
454
+ rateLimitUntil: 0,
402
455
  history: addToHistory(currentHistory, snapshot),
403
456
  });
404
457
  return next;
@@ -418,6 +471,7 @@ export function PluginProvider({ children, cliPlugins }: PluginProviderProps) {
418
471
  },
419
472
  loading: false,
420
473
  lastFetchAt: Date.now(),
474
+ rateLimitUntil: current?.rateLimitUntil ?? 0,
421
475
  history: current?.history ?? [],
422
476
  });
423
477
  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
  }