@tokentop/ttop 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -15
- 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/config/schema.ts +1 -1
- 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/notification-bus.ts +27 -0
- package/src/plugins/notifications/terminal-bell.ts +80 -16
- package/src/plugins/notifications/visual-flash.ts +5 -18
- 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 +22 -8
- package/src/tui/components/HelpOverlay.tsx +1 -1
- package/src/tui/components/LimitGauge.tsx +12 -4
- package/src/tui/components/NotificationFlash.tsx +141 -0
- 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/SettingsModal.tsx +20 -4
- 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/Toast.tsx +1 -1
- package/src/tui/contexts/AgentSessionContext.tsx +69 -16
- package/src/tui/contexts/PluginContext.tsx +72 -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/useNotificationBridge.ts +29 -0
- 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/tui/views/SettingsView.tsx +26 -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
|
@@ -109,6 +109,22 @@ export const chutesPlugin: ProviderPlugin = {
|
|
|
109
109
|
if (!response.ok) {
|
|
110
110
|
log.warn("Failed to fetch Chutes usage", { status: response.status });
|
|
111
111
|
|
|
112
|
+
if (response.status === 429) {
|
|
113
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
114
|
+
const parsedRetryAfter = retryAfterHeader
|
|
115
|
+
? Number.parseInt(retryAfterHeader, 10)
|
|
116
|
+
: Number.NaN;
|
|
117
|
+
const retrySec =
|
|
118
|
+
Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
|
|
119
|
+
log.warn("Rate limited by Chutes API", { retryAfterHeader, retryAfterSec: retrySec });
|
|
120
|
+
return {
|
|
121
|
+
fetchedAt: Date.now(),
|
|
122
|
+
error: `Rate limited. Retry after ${retrySec}s.`,
|
|
123
|
+
rateLimited: true,
|
|
124
|
+
retryAfterMs: retrySec * 1000,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
112
128
|
if (response.status === 401 || response.status === 403) {
|
|
113
129
|
return {
|
|
114
130
|
fetchedAt: Date.now(),
|
|
@@ -178,6 +178,22 @@ export const codexPlugin: ProviderPlugin = {
|
|
|
178
178
|
const bodyText = await response.text().catch(() => "");
|
|
179
179
|
log.warn("Failed to fetch ChatGPT usage", { status: response.status, body: bodyText });
|
|
180
180
|
|
|
181
|
+
if (response.status === 429) {
|
|
182
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
183
|
+
const parsedRetryAfter = retryAfterHeader
|
|
184
|
+
? Number.parseInt(retryAfterHeader, 10)
|
|
185
|
+
: Number.NaN;
|
|
186
|
+
const retrySec =
|
|
187
|
+
Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
|
|
188
|
+
log.warn("Rate limited by ChatGPT API", { retryAfterHeader, retryAfterSec: retrySec });
|
|
189
|
+
return {
|
|
190
|
+
fetchedAt: Date.now(),
|
|
191
|
+
error: `Rate limited. Retry after ${retrySec}s.`,
|
|
192
|
+
rateLimited: true,
|
|
193
|
+
retryAfterMs: retrySec * 1000,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
181
197
|
if (response.status === 401) {
|
|
182
198
|
return {
|
|
183
199
|
fetchedAt: Date.now(),
|
|
@@ -212,6 +212,22 @@ export const geminiPlugin: ProviderPlugin = {
|
|
|
212
212
|
});
|
|
213
213
|
|
|
214
214
|
if (!response.ok) {
|
|
215
|
+
if (response.status === 429) {
|
|
216
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
217
|
+
const parsedRetryAfter = retryAfterHeader
|
|
218
|
+
? Number.parseInt(retryAfterHeader, 10)
|
|
219
|
+
: Number.NaN;
|
|
220
|
+
const retrySec =
|
|
221
|
+
Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
|
|
222
|
+
log.warn("Rate limited by Google API", { retryAfterHeader, retryAfterSec: retrySec });
|
|
223
|
+
return {
|
|
224
|
+
fetchedAt: Date.now(),
|
|
225
|
+
error: `Rate limited. Retry after ${retrySec}s.`,
|
|
226
|
+
rateLimited: true,
|
|
227
|
+
retryAfterMs: retrySec * 1000,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
215
231
|
const errorText = await response.text().catch(() => "");
|
|
216
232
|
if (response.status === 401) {
|
|
217
233
|
return {
|
|
@@ -134,6 +134,22 @@ export const githubCopilotPlugin: ProviderPlugin = {
|
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
if (!response.ok) {
|
|
137
|
+
if (response.status === 429) {
|
|
138
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
139
|
+
const parsedRetryAfter = retryAfterHeader
|
|
140
|
+
? Number.parseInt(retryAfterHeader, 10)
|
|
141
|
+
: Number.NaN;
|
|
142
|
+
const retrySec =
|
|
143
|
+
Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
|
|
144
|
+
log.warn("Rate limited by GitHub API", { retryAfterHeader, retryAfterSec: retrySec });
|
|
145
|
+
return {
|
|
146
|
+
fetchedAt: Date.now(),
|
|
147
|
+
error: `Rate limited. Retry after ${retrySec}s.`,
|
|
148
|
+
rateLimited: true,
|
|
149
|
+
retryAfterMs: retrySec * 1000,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
137
153
|
if (response.status === 404) {
|
|
138
154
|
return {
|
|
139
155
|
planType: "None",
|
|
@@ -151,6 +151,22 @@ function createMinimaxPlugin(id: string, name: string): ProviderPlugin {
|
|
|
151
151
|
if (!response.ok) {
|
|
152
152
|
log.warn("Failed to fetch MiniMax usage", { status: response.status });
|
|
153
153
|
|
|
154
|
+
if (response.status === 429) {
|
|
155
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
156
|
+
const parsedRetryAfter = retryAfterHeader
|
|
157
|
+
? Number.parseInt(retryAfterHeader, 10)
|
|
158
|
+
: Number.NaN;
|
|
159
|
+
const retrySec =
|
|
160
|
+
Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
|
|
161
|
+
log.warn("Rate limited by MiniMax API", { retryAfterHeader, retryAfterSec: retrySec });
|
|
162
|
+
return {
|
|
163
|
+
fetchedAt: Date.now(),
|
|
164
|
+
error: `Rate limited. Retry after ${retrySec}s.`,
|
|
165
|
+
rateLimited: true,
|
|
166
|
+
retryAfterMs: retrySec * 1000,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
154
170
|
if (response.status === 401 || response.status === 403) {
|
|
155
171
|
return {
|
|
156
172
|
fetchedAt: Date.now(),
|
|
@@ -49,6 +49,15 @@ interface CostsResponse {
|
|
|
49
49
|
has_more: boolean;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
class RateLimitError extends Error {
|
|
53
|
+
readonly retryAfterMs: number;
|
|
54
|
+
constructor(retryAfterMs: number) {
|
|
55
|
+
super(`Rate limited. Retry after ${retryAfterMs / 1000}s.`);
|
|
56
|
+
this.name = "RateLimitError";
|
|
57
|
+
this.retryAfterMs = retryAfterMs;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
export const openaiApiPlugin: ProviderPlugin = {
|
|
53
62
|
apiVersion: 2,
|
|
54
63
|
id: "openai-api",
|
|
@@ -145,6 +154,21 @@ export const openaiApiPlugin: ProviderPlugin = {
|
|
|
145
154
|
fetchCosts(http, adminKey, oneDayAgo),
|
|
146
155
|
]);
|
|
147
156
|
|
|
157
|
+
// If either helper hit a 429, surface rate-limit info immediately
|
|
158
|
+
for (const settled of [usageResult, costsResult]) {
|
|
159
|
+
if (settled.status === "rejected" && settled.reason instanceof RateLimitError) {
|
|
160
|
+
log.warn("Rate limited by OpenAI API", { retryAfterMs: settled.reason.retryAfterMs });
|
|
161
|
+
return {
|
|
162
|
+
planType: "API",
|
|
163
|
+
allowed: true,
|
|
164
|
+
fetchedAt: Date.now(),
|
|
165
|
+
error: settled.reason.message,
|
|
166
|
+
rateLimited: true,
|
|
167
|
+
retryAfterMs: settled.reason.retryAfterMs,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
148
172
|
const result: ProviderUsageData = {
|
|
149
173
|
planType: "API",
|
|
150
174
|
allowed: true,
|
|
@@ -208,6 +232,16 @@ export const openaiApiPlugin: ProviderPlugin = {
|
|
|
208
232
|
|
|
209
233
|
return result;
|
|
210
234
|
} catch (err) {
|
|
235
|
+
if (err instanceof RateLimitError) {
|
|
236
|
+
return {
|
|
237
|
+
planType: "API",
|
|
238
|
+
allowed: true,
|
|
239
|
+
fetchedAt: Date.now(),
|
|
240
|
+
error: err.message,
|
|
241
|
+
rateLimited: true,
|
|
242
|
+
retryAfterMs: err.retryAfterMs,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
211
245
|
const msg = err instanceof Error ? err.message : String(err);
|
|
212
246
|
log.error("OpenAI fetch error", { error: msg });
|
|
213
247
|
|
|
@@ -236,6 +270,15 @@ async function fetchCompletionsUsage(
|
|
|
236
270
|
});
|
|
237
271
|
|
|
238
272
|
if (!response.ok) {
|
|
273
|
+
if (response.status === 429) {
|
|
274
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
275
|
+
const parsedRetryAfter = retryAfterHeader
|
|
276
|
+
? Number.parseInt(retryAfterHeader, 10)
|
|
277
|
+
: Number.NaN;
|
|
278
|
+
const retrySec =
|
|
279
|
+
Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
|
|
280
|
+
throw new RateLimitError(retrySec * 1000);
|
|
281
|
+
}
|
|
239
282
|
if (response.status === 403) {
|
|
240
283
|
return null;
|
|
241
284
|
}
|
|
@@ -260,6 +303,15 @@ async function fetchCosts(
|
|
|
260
303
|
});
|
|
261
304
|
|
|
262
305
|
if (!response.ok) {
|
|
306
|
+
if (response.status === 429) {
|
|
307
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
308
|
+
const parsedRetryAfter = retryAfterHeader
|
|
309
|
+
? Number.parseInt(retryAfterHeader, 10)
|
|
310
|
+
: Number.NaN;
|
|
311
|
+
const retrySec =
|
|
312
|
+
Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
|
|
313
|
+
throw new RateLimitError(retrySec * 1000);
|
|
314
|
+
}
|
|
263
315
|
if (response.status === 403) {
|
|
264
316
|
return null;
|
|
265
317
|
}
|
|
@@ -135,11 +135,20 @@ export const perplexityPlugin: ProviderPlugin = {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
if (response.status === 429) {
|
|
138
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
139
|
+
const parsedRetryAfter = retryAfterHeader
|
|
140
|
+
? Number.parseInt(retryAfterHeader, 10)
|
|
141
|
+
: Number.NaN;
|
|
142
|
+
const retrySec =
|
|
143
|
+
Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
|
|
144
|
+
log.warn("Rate limited by Perplexity API", { retryAfterHeader, retryAfterSec: retrySec });
|
|
138
145
|
return {
|
|
139
146
|
planType: "API",
|
|
140
147
|
allowed: true,
|
|
141
148
|
fetchedAt: Date.now(),
|
|
142
|
-
error:
|
|
149
|
+
error: `Rate limited. Retry after ${retrySec}s.`,
|
|
150
|
+
rateLimited: true,
|
|
151
|
+
retryAfterMs: retrySec * 1000,
|
|
143
152
|
};
|
|
144
153
|
}
|
|
145
154
|
|
|
@@ -124,6 +124,22 @@ export const zaiCodingPlanPlugin: ProviderPlugin = {
|
|
|
124
124
|
if (!response.ok) {
|
|
125
125
|
log.warn("Failed to fetch Z.ai usage", { status: response.status });
|
|
126
126
|
|
|
127
|
+
if (response.status === 429) {
|
|
128
|
+
const retryAfterHeader = response.headers.get("retry-after");
|
|
129
|
+
const parsedRetryAfter = retryAfterHeader
|
|
130
|
+
? Number.parseInt(retryAfterHeader, 10)
|
|
131
|
+
: Number.NaN;
|
|
132
|
+
const retrySec =
|
|
133
|
+
Number.isFinite(parsedRetryAfter) && parsedRetryAfter > 0 ? parsedRetryAfter : 300;
|
|
134
|
+
log.warn("Rate limited by Z.ai API", { retryAfterHeader, retryAfterSec: retrySec });
|
|
135
|
+
return {
|
|
136
|
+
fetchedAt: Date.now(),
|
|
137
|
+
error: `Rate limited. Retry after ${retrySec}s.`,
|
|
138
|
+
rateLimited: true,
|
|
139
|
+
retryAfterMs: retrySec * 1000,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
127
143
|
if (response.status === 401 || response.status === 403) {
|
|
128
144
|
return {
|
|
129
145
|
fetchedAt: Date.now(),
|
package/src/pricing/fallback.ts
CHANGED
|
@@ -57,6 +57,10 @@ export const FALLBACK_PRICING: Record<string, Record<string, ModelPricing>> = {
|
|
|
57
57
|
"gemini-2.0-flash-lite": { input: 0.075, output: 0.3, source: "fallback" },
|
|
58
58
|
"gemini-2.5-pro": { input: 1.25, output: 10, source: "fallback" },
|
|
59
59
|
"gemini-2.5-flash": { input: 0.15, output: 0.6, source: "fallback" },
|
|
60
|
+
"gemini-3-flash-preview": { input: 0.5, output: 3, cacheRead: 0.05, source: "fallback" },
|
|
61
|
+
"gemini-3-pro-preview": { input: 2, output: 12, cacheRead: 0.2, source: "fallback" },
|
|
62
|
+
"gemini-3.1-pro-preview": { input: 2, output: 12, cacheRead: 0.2, source: "fallback" },
|
|
63
|
+
"gemini-3.1-flash-lite-preview": { input: 0.25, output: 1.5, source: "fallback" },
|
|
60
64
|
},
|
|
61
65
|
|
|
62
66
|
openrouter: {
|
package/src/tui/App.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import { copyToClipboard } from "@/utils/clipboard.ts";
|
|
|
16
16
|
import { type CommandAction, CommandPalette } from "./components/CommandPalette.tsx";
|
|
17
17
|
import { DebugPanel } from "./components/DebugPanel.tsx";
|
|
18
18
|
import { Header } from "./components/Header.tsx";
|
|
19
|
+
import { NotificationFlash } from "./components/NotificationFlash.tsx";
|
|
19
20
|
import { SessionDetailsDrawer } from "./components/SessionDetailsDrawer.tsx";
|
|
20
21
|
import { SettingsModal } from "./components/SettingsModal.tsx";
|
|
21
22
|
import { StatusBar } from "./components/StatusBar.tsx";
|
|
@@ -35,6 +36,7 @@ import { RealTimeActivityProvider } from "./contexts/RealTimeActivityContext.tsx
|
|
|
35
36
|
import { StorageProvider } from "./contexts/StorageContext.tsx";
|
|
36
37
|
import { TimeWindowProvider } from "./contexts/TimeWindowContext.tsx";
|
|
37
38
|
import { ToastProvider, useToastContext } from "./contexts/ToastContext.tsx";
|
|
39
|
+
import { useNotificationBridge } from "./hooks/useNotificationBridge.ts";
|
|
38
40
|
import { useSafeRenderer } from "./hooks/useSafeRenderer.ts";
|
|
39
41
|
import { triggerShutdown } from "./shutdown.ts";
|
|
40
42
|
import { Dashboard } from "./views/Dashboard.tsx";
|
|
@@ -48,6 +50,7 @@ interface AppProps {
|
|
|
48
50
|
demoSeed?: number;
|
|
49
51
|
demoPreset?: DemoPreset;
|
|
50
52
|
cliPlugins?: string[];
|
|
53
|
+
cliTheme?: string;
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
type View = "dashboard" | "providers" | "trends" | "projects";
|
|
@@ -55,7 +58,7 @@ type View = "dashboard" | "providers" | "trends" | "projects";
|
|
|
55
58
|
function AppContent() {
|
|
56
59
|
const renderer = useSafeRenderer();
|
|
57
60
|
const colors = useColors();
|
|
58
|
-
const { theme, setTheme } = useTheme();
|
|
61
|
+
const { theme, setTheme, cliTheme } = useTheme();
|
|
59
62
|
const { refreshAllProviders, isInitialized, themes } = usePlugins();
|
|
60
63
|
const { info } = useLogs();
|
|
61
64
|
const { toast, showToast, dismissToast } = useToastContext();
|
|
@@ -67,6 +70,8 @@ function AppContent() {
|
|
|
67
70
|
const { debugDataRef, activity, sparkData } = useDashboardRuntime();
|
|
68
71
|
const burstRecorderRef = { current: null as BurstRecorder | null };
|
|
69
72
|
|
|
73
|
+
useNotificationBridge();
|
|
74
|
+
|
|
70
75
|
const refreshInterval = config.refresh.pauseAutoRefresh ? 0 : config.refresh.intervalMs;
|
|
71
76
|
|
|
72
77
|
const [lastRefresh, setLastRefresh] = useState<number | null>(null);
|
|
@@ -292,8 +297,10 @@ function AppContent() {
|
|
|
292
297
|
|
|
293
298
|
useEffect(() => {
|
|
294
299
|
if (themes.length === 0) return;
|
|
300
|
+
// CLI --theme flag takes priority over config
|
|
301
|
+
const themeId = cliTheme ?? config.display.theme;
|
|
295
302
|
const resolved = resolveTheme(
|
|
296
|
-
|
|
303
|
+
themeId,
|
|
297
304
|
config.display.colorScheme,
|
|
298
305
|
themes,
|
|
299
306
|
renderer?.themeMode ?? null,
|
|
@@ -301,7 +308,7 @@ function AppContent() {
|
|
|
301
308
|
if (resolved.id !== theme.id) {
|
|
302
309
|
setTheme(resolved);
|
|
303
310
|
}
|
|
304
|
-
}, [themes, config.display.theme, config.display.colorScheme, renderer]);
|
|
311
|
+
}, [themes, cliTheme, config.display.theme, config.display.colorScheme, renderer]);
|
|
305
312
|
|
|
306
313
|
useEffect(() => {
|
|
307
314
|
if (isInitialized) {
|
|
@@ -360,27 +367,30 @@ function AppContent() {
|
|
|
360
367
|
{isDrawerOpen && selectedSession && (
|
|
361
368
|
<SessionDetailsDrawer session={selectedSession} onClose={hideDrawer} />
|
|
362
369
|
)}
|
|
370
|
+
|
|
371
|
+
<NotificationFlash />
|
|
363
372
|
</box>
|
|
364
373
|
);
|
|
365
374
|
}
|
|
366
375
|
|
|
367
|
-
function ConfiguredApp({ cliPlugins }: { cliPlugins?: string[] }) {
|
|
376
|
+
function ConfiguredApp({ cliPlugins, cliTheme }: { cliPlugins?: string[]; cliTheme?: string }) {
|
|
368
377
|
const { config, isLoading } = useConfig();
|
|
369
378
|
|
|
370
379
|
if (isLoading) {
|
|
371
380
|
return null;
|
|
372
381
|
}
|
|
373
382
|
|
|
374
|
-
const
|
|
383
|
+
const themeId = cliTheme ?? config.display.theme;
|
|
384
|
+
const initialTheme = builtinThemeList.find((t) => t.id === themeId);
|
|
375
385
|
const themeProps = initialTheme ? { initialTheme } : {};
|
|
376
386
|
|
|
377
387
|
return (
|
|
378
|
-
<ThemeProvider {...themeProps}>
|
|
388
|
+
<ThemeProvider {...themeProps} {...(cliTheme ? { cliTheme } : {})}>
|
|
379
389
|
<TimeWindowProvider defaultWindow={config.display.defaultTimeWindow}>
|
|
380
390
|
<ToastProvider>
|
|
381
391
|
<PluginProvider {...(cliPlugins ? { cliPlugins } : {})}>
|
|
382
392
|
<RealTimeActivityProvider>
|
|
383
|
-
<AgentSessionProvider autoRefresh={true}
|
|
393
|
+
<AgentSessionProvider autoRefresh={true}>
|
|
384
394
|
<DashboardRuntimeProvider>
|
|
385
395
|
<DrawerProvider>
|
|
386
396
|
<AppContent />
|
|
@@ -401,6 +411,7 @@ export function App({
|
|
|
401
411
|
demoSeed,
|
|
402
412
|
demoPreset,
|
|
403
413
|
cliPlugins,
|
|
414
|
+
cliTheme,
|
|
404
415
|
}: AppProps) {
|
|
405
416
|
const demoProviderProps: { demoMode: boolean; demoSeed?: number; demoPreset?: DemoPreset } = {
|
|
406
417
|
demoMode,
|
|
@@ -414,7 +425,10 @@ export function App({
|
|
|
414
425
|
<InputProvider>
|
|
415
426
|
<StorageProvider>
|
|
416
427
|
<ConfigProvider>
|
|
417
|
-
<ConfiguredApp
|
|
428
|
+
<ConfiguredApp
|
|
429
|
+
{...(cliPlugins ? { cliPlugins } : {})}
|
|
430
|
+
{...(cliTheme ? { cliTheme } : {})}
|
|
431
|
+
/>
|
|
418
432
|
</ConfigProvider>
|
|
419
433
|
</StorageProvider>
|
|
420
434
|
</InputProvider>
|
|
@@ -59,7 +59,7 @@ export function HelpOverlay() {
|
|
|
59
59
|
<text width={10} height={1} fg={colors.textMuted}>
|
|
60
60
|
s
|
|
61
61
|
</text>
|
|
62
|
-
<text height={1}>Sort</text>
|
|
62
|
+
<text height={1}>Sort menu</text>
|
|
63
63
|
</box>
|
|
64
64
|
<box flexDirection="row" height={1}>
|
|
65
65
|
<text width={10} height={1} fg={colors.textMuted}>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import { getErrorCategoryDisplay, type ProviderErrorCategory } from "@/utils/error-category.ts";
|
|
2
3
|
import { useColors } from "../contexts/ThemeContext.tsx";
|
|
3
4
|
import { usePulse } from "../hooks/usePulse.ts";
|
|
4
5
|
|
|
@@ -8,6 +9,7 @@ interface LimitGaugeProps {
|
|
|
8
9
|
color: string;
|
|
9
10
|
ghost?: boolean;
|
|
10
11
|
error?: string;
|
|
12
|
+
errorCategory?: ProviderErrorCategory;
|
|
11
13
|
resetTime?: string;
|
|
12
14
|
compact?: boolean;
|
|
13
15
|
labelWidth?: number;
|
|
@@ -89,6 +91,7 @@ export function LimitGauge({
|
|
|
89
91
|
color,
|
|
90
92
|
ghost = false,
|
|
91
93
|
error,
|
|
94
|
+
errorCategory,
|
|
92
95
|
resetTime,
|
|
93
96
|
compact = false,
|
|
94
97
|
labelWidth = 12,
|
|
@@ -152,7 +155,12 @@ export function LimitGauge({
|
|
|
152
155
|
|
|
153
156
|
if (error) {
|
|
154
157
|
const displayLabel = truncateLabel(label, labelWidth);
|
|
155
|
-
|
|
158
|
+
|
|
159
|
+
const display = errorCategory ? getErrorCategoryDisplay(errorCategory) : undefined;
|
|
160
|
+
const errColor = display?.isWarning ? colors.warning : colors.error;
|
|
161
|
+
const errLabel = display?.label ?? "ERR";
|
|
162
|
+
const errorIcon = selected ? "\u258C" : (display?.icon ?? "\u2717");
|
|
163
|
+
|
|
156
164
|
return (
|
|
157
165
|
<box
|
|
158
166
|
height={1}
|
|
@@ -160,10 +168,10 @@ export function LimitGauge({
|
|
|
160
168
|
{...(selected ? { backgroundColor: colors.borderMuted } : {})}
|
|
161
169
|
>
|
|
162
170
|
<text height={1}>
|
|
163
|
-
<span fg={selected ? colors.primary :
|
|
171
|
+
<span fg={selected ? colors.primary : errColor}>{errorIcon} </span>
|
|
164
172
|
<span fg={selected ? colors.text : colors.text}>{displayLabel} </span>
|
|
165
|
-
<span fg={
|
|
166
|
-
<span fg={
|
|
173
|
+
<span fg={errColor}>{"\u2500".repeat(barWidth)}</span>
|
|
174
|
+
<span fg={errColor}> {errLabel}</span>
|
|
167
175
|
</text>
|
|
168
176
|
</box>
|
|
169
177
|
);
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { NotificationEvent } from "@tokentop/plugin-sdk";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { notificationBus } from "@/plugins/notification-bus.ts";
|
|
4
|
+
import { useColors } from "../contexts/ThemeContext.tsx";
|
|
5
|
+
import { animationTick } from "../hooks/useAnimationTick.ts";
|
|
6
|
+
import { Z_INDEX } from "./ModalBackdrop.tsx";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Notification flash as an animated border glow.
|
|
10
|
+
*
|
|
11
|
+
* On notification:
|
|
12
|
+
* 0–15% Overbright burst → alert color (double border)
|
|
13
|
+
* 15–40% Alert color fading (double border)
|
|
14
|
+
* 40–100% Continuing fade → background (single border, then unmount)
|
|
15
|
+
*
|
|
16
|
+
* Uses the global 30 Hz animation tick for smooth interpolation.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const GLOW_DURATION_MS = 500;
|
|
20
|
+
|
|
21
|
+
/** Bright, saturated alert colors — vivid regardless of theme */
|
|
22
|
+
const GLOW_COLORS: Record<NotificationEvent["severity"], string> = {
|
|
23
|
+
critical: "#ff3333",
|
|
24
|
+
warning: "#ffaa22",
|
|
25
|
+
info: "#3399ff",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ── Color math ──────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function parseHex(hex: string): [number, number, number] {
|
|
31
|
+
const h = hex.replace("#", "");
|
|
32
|
+
return [
|
|
33
|
+
Number.parseInt(h.slice(0, 2), 16),
|
|
34
|
+
Number.parseInt(h.slice(2, 4), 16),
|
|
35
|
+
Number.parseInt(h.slice(4, 6), 16),
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toHex(r: number, g: number, b: number): string {
|
|
40
|
+
const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
|
|
41
|
+
return `#${[r, g, b].map((v) => clamp(v).toString(16).padStart(2, "0")).join("")}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Linearly interpolate two hex colors. t=0 → from, t=1 → to. */
|
|
45
|
+
function lerpColor(from: string, to: string, t: number): string {
|
|
46
|
+
const [r1, g1, b1] = parseHex(from);
|
|
47
|
+
const [r2, g2, b2] = parseHex(to);
|
|
48
|
+
return toHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Brighten a color by a factor (>1 = brighter). Clamps at 255. */
|
|
52
|
+
function brighten(hex: string, factor = 1.4): string {
|
|
53
|
+
const [r, g, b] = parseHex(hex);
|
|
54
|
+
return toHex(r * factor, g * factor, b * factor);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** easeOutCubic — fast attack, gentle tail */
|
|
58
|
+
function easeOutCubic(t: number): number {
|
|
59
|
+
return 1 - (1 - t) ** 3;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Component ───────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export function NotificationFlash() {
|
|
65
|
+
const colors = useColors();
|
|
66
|
+
const [flash, setFlash] = useState<{
|
|
67
|
+
severity: NotificationEvent["severity"];
|
|
68
|
+
startTime: number;
|
|
69
|
+
} | null>(null);
|
|
70
|
+
const [borderColor, setBorderColor] = useState<string | null>(null);
|
|
71
|
+
const [borderStyle, setBorderStyle] = useState<"double" | "single">("double");
|
|
72
|
+
const unsubRef = useRef<(() => void) | null>(null);
|
|
73
|
+
|
|
74
|
+
// Register flash handler on the notification bus
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
notificationBus.setFlashHandler((severity) => {
|
|
77
|
+
setFlash({ severity, startTime: Date.now() });
|
|
78
|
+
});
|
|
79
|
+
return () => notificationBus.setFlashHandler(null);
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
// Drive the animation via the global 30 Hz tick
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!flash) {
|
|
85
|
+
setBorderColor(null);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
unsubRef.current?.();
|
|
90
|
+
|
|
91
|
+
const alertColor = GLOW_COLORS[flash.severity];
|
|
92
|
+
const brightColor = brighten(alertColor);
|
|
93
|
+
const bgColor = colors.background;
|
|
94
|
+
|
|
95
|
+
unsubRef.current = animationTick.subscribe((now) => {
|
|
96
|
+
const elapsed = now - flash.startTime;
|
|
97
|
+
const progress = Math.min(elapsed / GLOW_DURATION_MS, 1);
|
|
98
|
+
|
|
99
|
+
let currentColor: string;
|
|
100
|
+
if (progress < 0.15) {
|
|
101
|
+
// Burst phase: overbright → alert color
|
|
102
|
+
currentColor = lerpColor(brightColor, alertColor, progress / 0.15);
|
|
103
|
+
} else {
|
|
104
|
+
// Fade phase: alert → background (eased for gentle tail)
|
|
105
|
+
const fadeT = (progress - 0.15) / 0.85;
|
|
106
|
+
currentColor = lerpColor(alertColor, bgColor, easeOutCubic(fadeT));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
setBorderColor(currentColor);
|
|
110
|
+
setBorderStyle(progress < 0.4 ? "double" : "single");
|
|
111
|
+
|
|
112
|
+
if (progress >= 1) {
|
|
113
|
+
unsubRef.current?.();
|
|
114
|
+
unsubRef.current = null;
|
|
115
|
+
setFlash(null);
|
|
116
|
+
setBorderColor(null);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return () => {
|
|
121
|
+
unsubRef.current?.();
|
|
122
|
+
unsubRef.current = null;
|
|
123
|
+
};
|
|
124
|
+
}, [flash, colors.background]);
|
|
125
|
+
|
|
126
|
+
if (!borderColor) return null;
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<box
|
|
130
|
+
position="absolute"
|
|
131
|
+
left={0}
|
|
132
|
+
top={0}
|
|
133
|
+
width="100%"
|
|
134
|
+
height="100%"
|
|
135
|
+
zIndex={Z_INDEX.TOAST + 1}
|
|
136
|
+
border
|
|
137
|
+
borderStyle={borderStyle}
|
|
138
|
+
borderColor={borderColor}
|
|
139
|
+
/>
|
|
140
|
+
);
|
|
141
|
+
}
|