@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.
- package/README.md +7 -6
- package/package.json +12 -5
- package/src/agents/aggregator.test.ts +306 -0
- package/src/agents/aggregator.ts +44 -2
- package/src/agents/types.ts +3 -0
- package/src/cli.ts +16 -2
- package/src/demo/simulator.ts +2 -2
- package/src/plugins/agents/index.ts +3 -0
- package/src/plugins/auth-sources.ts +2 -3
- package/src/plugins/lifecycle.ts +2 -2
- package/src/plugins/loader.test.ts +1 -1
- package/src/plugins/loader.ts +15 -7
- package/src/plugins/notification-bus.ts +5 -2
- package/src/plugins/notifications/terminal-bell.ts +1 -1
- package/src/plugins/notifications/visual-flash.ts +1 -1
- package/src/plugins/plugin-context-factory.ts +4 -19
- package/src/plugins/plugin-host.test.ts +197 -0
- package/src/plugins/plugin-host.ts +45 -1
- package/src/plugins/providers/anthropic.test.ts +202 -0
- package/src/plugins/providers/anthropic.ts +21 -2
- package/src/plugins/providers/antigravity.ts +17 -1
- package/src/plugins/providers/chutes.ts +205 -0
- package/src/plugins/providers/codex.ts +17 -1
- package/src/plugins/providers/gemini.ts +17 -1
- package/src/plugins/providers/github-copilot.ts +17 -1
- package/src/plugins/providers/index.ts +1 -0
- package/src/plugins/providers/minimax.ts +17 -1
- package/src/plugins/providers/openai-api.ts +53 -1
- package/src/plugins/providers/opencode-zen.ts +1 -1
- package/src/plugins/providers/perplexity.ts +11 -2
- package/src/plugins/providers/zai.ts +17 -1
- package/src/plugins/registry.ts +9 -9
- package/src/plugins/sandbox-guard.test.ts +1 -1
- package/src/plugins/sandbox-guard.ts +1 -1
- package/src/plugins/sandbox.ts +1 -1
- package/src/plugins/themes/catppuccin-latte.ts +1 -1
- package/src/plugins/themes/catppuccin-mocha.ts +1 -1
- package/src/plugins/themes/claude-code.ts +1 -1
- package/src/plugins/themes/dracula.ts +1 -1
- package/src/plugins/themes/github-light.ts +1 -1
- package/src/plugins/themes/gruvbox-dark.ts +1 -1
- package/src/plugins/themes/gruvbox-light.ts +1 -1
- package/src/plugins/themes/kanagawa.ts +1 -1
- package/src/plugins/themes/nord.ts +1 -1
- package/src/plugins/themes/one-dark.ts +1 -1
- package/src/plugins/themes/opencode.ts +1 -1
- package/src/plugins/themes/rose-pine-dawn.ts +1 -1
- package/src/plugins/themes/rose-pine.ts +1 -1
- package/src/plugins/themes/solarized-light.ts +1 -1
- package/src/plugins/themes/tokyo-night.ts +1 -1
- package/src/plugins/update-checker.ts +2 -2
- package/src/pricing/estimator.test.ts +1 -1
- package/src/pricing/estimator.ts +1 -1
- package/src/pricing/fallback.ts +5 -1
- package/src/pricing/index.ts +1 -1
- package/src/pricing/models-dev.ts +1 -1
- package/src/storage/index.ts +6 -0
- package/src/storage/migrations/index.ts +17 -0
- package/src/storage/repos/pluginStorage.ts +35 -0
- package/src/tui/App.tsx +17 -9
- package/src/tui/components/HelpOverlay.tsx +1 -1
- package/src/tui/components/LimitGauge.tsx +12 -4
- package/src/tui/components/ProviderCard.tsx +39 -23
- package/src/tui/components/ProviderDetailPanel.tsx +1 -1
- package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
- package/src/tui/components/SessionDetailsDrawer.tsx +10 -0
- package/src/tui/components/SessionsTable.tsx +62 -37
- package/src/tui/components/SettingsModal.tsx +1 -1
- package/src/tui/components/Skeleton.tsx +9 -6
- package/src/tui/components/SortOverlay.tsx +167 -0
- package/src/tui/components/Spinner.tsx +16 -11
- package/src/tui/components/ThemePicker.tsx +1 -1
- package/src/tui/contexts/AgentSessionContext.tsx +70 -17
- package/src/tui/contexts/PluginContext.tsx +72 -14
- package/src/tui/contexts/RealTimeActivityContext.tsx +33 -20
- package/src/tui/contexts/ThemeContext.tsx +7 -4
- package/src/tui/createApp.tsx +2 -0
- package/src/tui/hooks/index.ts +1 -0
- package/src/tui/hooks/useAnimatedValue.ts +29 -26
- package/src/tui/hooks/useAnimationTick.ts +93 -0
- package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
- package/src/tui/hooks/useEntranceAnimation.ts +16 -10
- package/src/tui/hooks/usePulse.ts +13 -7
- package/src/tui/hooks/useSessionEnrichedProviders.ts +1 -1
- package/src/tui/hooks/useValueFlash.ts +25 -33
- package/src/tui/index.tsx +4 -0
- package/src/tui/types/projectSort.ts +35 -0
- package/src/tui/types/sort.ts +61 -0
- package/src/tui/views/ProjectsView.tsx +104 -38
- package/src/tui/views/RealTimeDashboard.tsx +91 -8
- package/src/tui/views/SettingsView.tsx +1 -1
- package/src/utils/__tests__/error-category.test.ts +147 -0
- package/src/utils/currency.ts +4 -2
- package/src/utils/error-category.ts +91 -0
- package/src/version.ts +1 -1
- package/src/plugins/types/agent.ts +0 -108
- package/src/plugins/types/base.ts +0 -278
- package/src/plugins/types/index.ts +0 -19
- package/src/plugins/types/notification.ts +0 -51
- package/src/plugins/types/provider.ts +0 -237
- package/src/plugins/types/theme.ts +0 -78
- 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(
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
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 "
|
|
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>;
|
package/src/tui/createApp.tsx
CHANGED
|
@@ -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
|
|
package/src/tui/hooks/index.ts
CHANGED
|
@@ -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
|
|
26
|
+
const { durationMs = 300, easing = "easeOutQuad", precision = 2 } = options;
|
|
25
27
|
|
|
26
28
|
const [displayValue, setDisplayValue] = useState(targetValue);
|
|
27
|
-
const
|
|
29
|
+
const displayRef = useRef(targetValue);
|
|
28
30
|
const startValueRef = useRef(targetValue);
|
|
29
|
-
const startTimeRef = useRef
|
|
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
|
-
|
|
38
|
+
// Already at target — nothing to animate
|
|
39
|
+
if (targetRef.current === targetValue && displayRef.current === targetValue) {
|
|
37
40
|
return;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
+
// Clean up any existing subscription
|
|
44
|
+
unsubRef.current?.();
|
|
43
45
|
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
55
|
-
const
|
|
57
|
+
const target = targetRef.current;
|
|
58
|
+
const currentValue = startVal + (target - startVal) * easedProgress;
|
|
59
|
+
const rounded = Number(currentValue.toFixed(precision));
|
|
56
60
|
|
|
57
|
-
|
|
61
|
+
displayRef.current = rounded;
|
|
62
|
+
setDisplayValue(rounded);
|
|
58
63
|
|
|
59
64
|
if (progress >= 1) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
}
|
|
71
|
+
});
|
|
67
72
|
|
|
68
73
|
return () => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
intervalRef.current = null;
|
|
72
|
-
}
|
|
74
|
+
unsubRef.current?.();
|
|
75
|
+
unsubRef.current = null;
|
|
73
76
|
};
|
|
74
|
-
}, [targetValue, durationMs, easingFn, precision
|
|
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:
|
|
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:
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
28
|
-
|
|
28
|
+
if (progress >= 1) {
|
|
29
|
+
setIntensity(1);
|
|
30
|
+
unsubRef.current?.();
|
|
31
|
+
unsubRef.current = null;
|
|
29
32
|
}
|
|
30
|
-
};
|
|
33
|
+
});
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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 {
|