@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.
- package/README.md +7 -6
- package/package.json +8 -6
- package/src/agents/aggregator.test.ts +306 -0
- package/src/agents/aggregator.ts +40 -5
- package/src/cli.ts +14 -0
- package/src/demo/simulator.ts +1 -1
- package/src/plugins/agents/index.ts +1 -0
- package/src/plugins/lifecycle.ts +1 -1
- 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 +19 -0
- package/src/plugins/providers/antigravity.ts +16 -0
- package/src/plugins/providers/chutes.ts +16 -0
- package/src/plugins/providers/codex.ts +16 -0
- package/src/plugins/providers/gemini.ts +16 -0
- package/src/plugins/providers/github-copilot.ts +16 -0
- package/src/plugins/providers/minimax.ts +16 -0
- package/src/plugins/providers/openai-api.ts +52 -0
- package/src/plugins/providers/perplexity.ts +10 -1
- package/src/plugins/providers/zai.ts +16 -0
- package/src/pricing/fallback.ts +4 -0
- package/src/tui/App.tsx +16 -8
- package/src/tui/components/HelpOverlay.tsx +1 -1
- package/src/tui/components/LimitGauge.tsx +12 -4
- package/src/tui/components/ProviderCard.tsx +38 -22
- package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
- package/src/tui/components/SessionsTable.tsx +54 -36
- 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/contexts/AgentSessionContext.tsx +69 -16
- package/src/tui/contexts/PluginContext.tsx +64 -10
- package/src/tui/contexts/ThemeContext.tsx +4 -1
- 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/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/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
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
272
|
-
|
|
323
|
+
if (timerId !== null) {
|
|
324
|
+
clearTimeout(timerId);
|
|
325
|
+
}
|
|
273
326
|
};
|
|
274
|
-
}, [autoRefresh,
|
|
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(
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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>;
|
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
|
}
|