@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,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,4 +1,5 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
+ import { animationTick } from "./useAnimationTick.ts";
2
3
 
3
4
  export interface UseValueFlashOptions {
4
5
  durationMs?: number;
@@ -20,12 +21,9 @@ export function useValueFlash(
20
21
 
21
22
  const prevValueRef = useRef<number>(value);
22
23
  const [isFlashing, setIsFlashing] = useState(false);
23
- const [step, setStep] = useState(0);
24
- const flashTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
25
- const animationIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
26
-
27
- const steps = 18;
28
- const stepDuration = durationMs / steps;
24
+ const [intensity, setIntensity] = useState(0);
25
+ const flashStartRef = useRef(0);
26
+ const unsubRef = useRef<(() => void) | null>(null);
29
27
 
30
28
  useEffect(() => {
31
29
  const prevValue = prevValueRef.current;
@@ -34,45 +32,39 @@ export function useValueFlash(
34
32
  const shouldFlash = increaseOnly ? delta > threshold : Math.abs(delta) > threshold;
35
33
 
36
34
  if (shouldFlash && prevValue !== value) {
37
- if (flashTimeoutRef.current) {
38
- clearTimeout(flashTimeoutRef.current);
39
- }
40
- if (animationIntervalRef.current) {
41
- clearInterval(animationIntervalRef.current);
42
- }
35
+ // Clean up any existing subscription
36
+ unsubRef.current?.();
43
37
 
38
+ flashStartRef.current = Date.now();
44
39
  setIsFlashing(true);
45
- setStep(0);
46
-
47
- let currentStep = 0;
48
- animationIntervalRef.current = setInterval(() => {
49
- currentStep++;
50
- if (currentStep >= steps) {
51
- if (animationIntervalRef.current) {
52
- clearInterval(animationIntervalRef.current);
53
- animationIntervalRef.current = null;
54
- }
40
+
41
+ // Subscribe to global tick
42
+ unsubRef.current = animationTick.subscribe((now) => {
43
+ const elapsed = now - flashStartRef.current;
44
+ const progress = elapsed / durationMs;
45
+
46
+ if (progress >= 1) {
55
47
  setIsFlashing(false);
56
- setStep(0);
48
+ setIntensity(0);
49
+ unsubRef.current?.();
50
+ unsubRef.current = null;
57
51
  } else {
58
- setStep(currentStep);
52
+ const currentIntensity = Math.sin(progress * Math.PI);
53
+ setIntensity(currentIntensity);
59
54
  }
60
- }, stepDuration);
55
+ });
61
56
  }
62
57
 
63
58
  prevValueRef.current = value;
64
59
 
65
60
  return () => {
66
- if (flashTimeoutRef.current) {
67
- clearTimeout(flashTimeoutRef.current);
68
- }
69
- if (animationIntervalRef.current) {
70
- clearInterval(animationIntervalRef.current);
71
- }
61
+ unsubRef.current?.();
62
+ unsubRef.current = null;
72
63
  };
73
- }, [value, durationMs, increaseOnly, threshold, steps, stepDuration]);
64
+ }, [value, durationMs, increaseOnly, threshold]);
74
65
 
75
- const intensity = isFlashing ? Math.sin((step / steps) * Math.PI) : 0;
66
+ // step is kept for backward API compatibility
67
+ const step = isFlashing ? Math.round(intensity * 9) : 0;
76
68
 
77
69
  return { isFlashing, intensity, step };
78
70
  }
package/src/tui/index.tsx CHANGED
@@ -13,6 +13,7 @@ export interface TuiOptions {
13
13
  demoSeed?: number;
14
14
  demoPreset?: DemoPreset;
15
15
  cliPlugins?: string[];
16
+ cliTheme?: string;
16
17
  }
17
18
 
18
19
  const FORCE_EXIT_GRACE_MS = 1_000;
@@ -45,6 +46,9 @@ export async function startTui(options: TuiOptions = {}) {
45
46
  if (options.cliPlugins !== undefined) {
46
47
  appOptions.cliPlugins = options.cliPlugins;
47
48
  }
49
+ if (options.cliTheme !== undefined) {
50
+ appOptions.cliTheme = options.cliTheme;
51
+ }
48
52
 
49
53
  root.render(createAppElement(appOptions));
50
54
 
@@ -0,0 +1,35 @@
1
+ import type { SortFieldDef } from "./sort.ts";
2
+ import { buildShortcutMap } from "./sort.ts";
3
+
4
+ export type ProjectSortField =
5
+ | "project"
6
+ | "cost"
7
+ | "tokens"
8
+ | "sessions"
9
+ | "cache"
10
+ | "cpr"
11
+ | "activity";
12
+
13
+ export const PROJECT_SORT_FIELDS: readonly SortFieldDef<ProjectSortField>[] = [
14
+ { id: "project", label: "Project", shortcutKey: "P", defaultDirection: "asc" },
15
+ { id: "cost", label: "Cost", shortcutKey: "C", defaultDirection: "desc" },
16
+ { id: "tokens", label: "Tokens", shortcutKey: "K", defaultDirection: "desc" },
17
+ { id: "sessions", label: "Sessions", shortcutKey: "S", defaultDirection: "desc" },
18
+ { id: "cache", label: "Cache", shortcutKey: "H", defaultDirection: "desc" },
19
+ { id: "cpr", label: "$/Req", shortcutKey: "R", defaultDirection: "desc" },
20
+ { id: "activity", label: "Activity", shortcutKey: "A", defaultDirection: "desc" },
21
+ ] as const;
22
+
23
+ /** Map from lowercase shortcut key → ProjectSortField */
24
+ export const PROJECT_SHORTCUT_TO_FIELD: ReadonlyMap<string, ProjectSortField> =
25
+ buildShortcutMap(PROJECT_SORT_FIELDS);
26
+
27
+ export function getProjectSortFieldDef(field: ProjectSortField): SortFieldDef<ProjectSortField> {
28
+ const def = PROJECT_SORT_FIELDS.find((f) => f.id === field);
29
+ if (!def) throw new Error(`Unknown project sort field: ${field}`);
30
+ return def;
31
+ }
32
+
33
+ export function getProjectSortFieldLabel(field: ProjectSortField): string {
34
+ return getProjectSortFieldDef(field).label;
35
+ }
@@ -0,0 +1,61 @@
1
+ export type SortDirection = "asc" | "desc";
2
+
3
+ export interface SortFieldDef<F extends string = string> {
4
+ readonly id: F;
5
+ readonly label: string;
6
+ /** Uppercase letter shown in overlay + used as Shift+key shortcut */
7
+ readonly shortcutKey: string;
8
+ /** Default direction when switching to this field */
9
+ readonly defaultDirection: SortDirection;
10
+ }
11
+
12
+ /** Build a lowercase-key → field-id map from any fields array */
13
+ export function buildShortcutMap<F extends string>(
14
+ fields: readonly SortFieldDef<F>[],
15
+ ): ReadonlyMap<string, F> {
16
+ return new Map(fields.map((f) => [f.shortcutKey.toLowerCase(), f.id]));
17
+ }
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Dashboard sort fields
21
+ // ---------------------------------------------------------------------------
22
+
23
+ export type SortField =
24
+ | "cost"
25
+ | "tokens"
26
+ | "time"
27
+ | "requests"
28
+ | "duration"
29
+ | "project"
30
+ | "agent"
31
+ | "model"
32
+ | "name";
33
+
34
+ export const SORT_FIELDS: readonly SortFieldDef<SortField>[] = [
35
+ { id: "agent", label: "Agent", shortcutKey: "A", defaultDirection: "desc" },
36
+ { id: "model", label: "Model", shortcutKey: "M", defaultDirection: "desc" },
37
+ { id: "requests", label: "Requests", shortcutKey: "E", defaultDirection: "desc" },
38
+ { id: "tokens", label: "Tokens", shortcutKey: "K", defaultDirection: "desc" },
39
+ { id: "cost", label: "Cost", shortcutKey: "C", defaultDirection: "desc" },
40
+ { id: "project", label: "Project", shortcutKey: "O", defaultDirection: "desc" },
41
+ { id: "name", label: "Name", shortcutKey: "N", defaultDirection: "desc" },
42
+ { id: "duration", label: "Duration", shortcutKey: "U", defaultDirection: "desc" },
43
+ { id: "time", label: "Time", shortcutKey: "T", defaultDirection: "desc" },
44
+ ] as const;
45
+
46
+ /** Map from lowercase shortcut key → SortField (dashboard) */
47
+ export const SHORTCUT_TO_FIELD: ReadonlyMap<string, SortField> = buildShortcutMap(SORT_FIELDS);
48
+
49
+ export function getSortFieldDef(field: SortField): SortFieldDef<SortField> {
50
+ const def = SORT_FIELDS.find((f) => f.id === field);
51
+ if (!def) throw new Error(`Unknown sort field: ${field}`);
52
+ return def;
53
+ }
54
+
55
+ export function getSortFieldLabel(field: SortField): string {
56
+ return getSortFieldDef(field).label;
57
+ }
58
+
59
+ export function getSortDirectionIndicator(dir: SortDirection): string {
60
+ return dir === "desc" ? "▼" : "▲";
61
+ }