@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.
Files changed (60) hide show
  1. package/README.md +38 -15
  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/config/schema.ts +1 -1
  7. package/src/demo/simulator.ts +1 -1
  8. package/src/plugins/agents/index.ts +1 -0
  9. package/src/plugins/lifecycle.ts +1 -1
  10. package/src/plugins/notification-bus.ts +27 -0
  11. package/src/plugins/notifications/terminal-bell.ts +80 -16
  12. package/src/plugins/notifications/visual-flash.ts +5 -18
  13. package/src/plugins/plugin-host.test.ts +197 -0
  14. package/src/plugins/plugin-host.ts +45 -1
  15. package/src/plugins/providers/anthropic.test.ts +202 -0
  16. package/src/plugins/providers/anthropic.ts +19 -0
  17. package/src/plugins/providers/antigravity.ts +16 -0
  18. package/src/plugins/providers/chutes.ts +16 -0
  19. package/src/plugins/providers/codex.ts +16 -0
  20. package/src/plugins/providers/gemini.ts +16 -0
  21. package/src/plugins/providers/github-copilot.ts +16 -0
  22. package/src/plugins/providers/minimax.ts +16 -0
  23. package/src/plugins/providers/openai-api.ts +52 -0
  24. package/src/plugins/providers/perplexity.ts +10 -1
  25. package/src/plugins/providers/zai.ts +16 -0
  26. package/src/pricing/fallback.ts +4 -0
  27. package/src/tui/App.tsx +22 -8
  28. package/src/tui/components/HelpOverlay.tsx +1 -1
  29. package/src/tui/components/LimitGauge.tsx +12 -4
  30. package/src/tui/components/NotificationFlash.tsx +141 -0
  31. package/src/tui/components/ProviderCard.tsx +38 -22
  32. package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
  33. package/src/tui/components/SessionsTable.tsx +54 -36
  34. package/src/tui/components/SettingsModal.tsx +20 -4
  35. package/src/tui/components/Skeleton.tsx +9 -6
  36. package/src/tui/components/SortOverlay.tsx +167 -0
  37. package/src/tui/components/Spinner.tsx +16 -11
  38. package/src/tui/components/Toast.tsx +1 -1
  39. package/src/tui/contexts/AgentSessionContext.tsx +69 -16
  40. package/src/tui/contexts/PluginContext.tsx +72 -10
  41. package/src/tui/contexts/ThemeContext.tsx +4 -1
  42. package/src/tui/createApp.tsx +2 -0
  43. package/src/tui/hooks/index.ts +1 -0
  44. package/src/tui/hooks/useAnimatedValue.ts +29 -26
  45. package/src/tui/hooks/useAnimationTick.ts +93 -0
  46. package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
  47. package/src/tui/hooks/useEntranceAnimation.ts +16 -10
  48. package/src/tui/hooks/useNotificationBridge.ts +29 -0
  49. package/src/tui/hooks/usePulse.ts +13 -7
  50. package/src/tui/hooks/useValueFlash.ts +25 -33
  51. package/src/tui/index.tsx +4 -0
  52. package/src/tui/types/projectSort.ts +35 -0
  53. package/src/tui/types/sort.ts +61 -0
  54. package/src/tui/views/ProjectsView.tsx +104 -38
  55. package/src/tui/views/RealTimeDashboard.tsx +91 -8
  56. package/src/tui/views/SettingsView.tsx +26 -8
  57. package/src/utils/__tests__/error-category.test.ts +147 -0
  58. package/src/utils/currency.ts +4 -2
  59. package/src/utils/error-category.ts +91 -0
  60. 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: "Rate limited",
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(),
@@ -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
- config.display.theme,
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 initialTheme = builtinThemeList.find((t) => t.id === config.display.theme);
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} refreshInterval={1000}>
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 {...(cliPlugins ? { cliPlugins } : {})} />
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
- const errorIcon = selected ? "▌" : "✗";
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 : colors.error}>{errorIcon} </span>
171
+ <span fg={selected ? colors.primary : errColor}>{errorIcon} </span>
164
172
  <span fg={selected ? colors.text : colors.text}>{displayLabel} </span>
165
- <span fg={colors.error}>{"─".repeat(barWidth)}</span>
166
- <span fg={colors.error}> ERR</span>
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
+ }