@tokentop/ttop 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +7 -6
  2. package/package.json +12 -5
  3. package/src/agents/aggregator.test.ts +306 -0
  4. package/src/agents/aggregator.ts +44 -2
  5. package/src/agents/types.ts +3 -0
  6. package/src/cli.ts +16 -2
  7. package/src/demo/simulator.ts +2 -2
  8. package/src/plugins/agents/index.ts +3 -0
  9. package/src/plugins/auth-sources.ts +2 -3
  10. package/src/plugins/lifecycle.ts +2 -2
  11. package/src/plugins/loader.test.ts +1 -1
  12. package/src/plugins/loader.ts +15 -7
  13. package/src/plugins/notification-bus.ts +5 -2
  14. package/src/plugins/notifications/terminal-bell.ts +1 -1
  15. package/src/plugins/notifications/visual-flash.ts +1 -1
  16. package/src/plugins/plugin-context-factory.ts +4 -19
  17. package/src/plugins/plugin-host.test.ts +197 -0
  18. package/src/plugins/plugin-host.ts +45 -1
  19. package/src/plugins/providers/anthropic.test.ts +202 -0
  20. package/src/plugins/providers/anthropic.ts +21 -2
  21. package/src/plugins/providers/antigravity.ts +17 -1
  22. package/src/plugins/providers/chutes.ts +205 -0
  23. package/src/plugins/providers/codex.ts +17 -1
  24. package/src/plugins/providers/gemini.ts +17 -1
  25. package/src/plugins/providers/github-copilot.ts +17 -1
  26. package/src/plugins/providers/index.ts +1 -0
  27. package/src/plugins/providers/minimax.ts +17 -1
  28. package/src/plugins/providers/openai-api.ts +53 -1
  29. package/src/plugins/providers/opencode-zen.ts +1 -1
  30. package/src/plugins/providers/perplexity.ts +11 -2
  31. package/src/plugins/providers/zai.ts +17 -1
  32. package/src/plugins/registry.ts +9 -9
  33. package/src/plugins/sandbox-guard.test.ts +1 -1
  34. package/src/plugins/sandbox-guard.ts +1 -1
  35. package/src/plugins/sandbox.ts +1 -1
  36. package/src/plugins/themes/catppuccin-latte.ts +1 -1
  37. package/src/plugins/themes/catppuccin-mocha.ts +1 -1
  38. package/src/plugins/themes/claude-code.ts +1 -1
  39. package/src/plugins/themes/dracula.ts +1 -1
  40. package/src/plugins/themes/github-light.ts +1 -1
  41. package/src/plugins/themes/gruvbox-dark.ts +1 -1
  42. package/src/plugins/themes/gruvbox-light.ts +1 -1
  43. package/src/plugins/themes/kanagawa.ts +1 -1
  44. package/src/plugins/themes/nord.ts +1 -1
  45. package/src/plugins/themes/one-dark.ts +1 -1
  46. package/src/plugins/themes/opencode.ts +1 -1
  47. package/src/plugins/themes/rose-pine-dawn.ts +1 -1
  48. package/src/plugins/themes/rose-pine.ts +1 -1
  49. package/src/plugins/themes/solarized-light.ts +1 -1
  50. package/src/plugins/themes/tokyo-night.ts +1 -1
  51. package/src/plugins/update-checker.ts +2 -2
  52. package/src/pricing/estimator.test.ts +1 -1
  53. package/src/pricing/estimator.ts +1 -1
  54. package/src/pricing/fallback.ts +5 -1
  55. package/src/pricing/index.ts +1 -1
  56. package/src/pricing/models-dev.ts +1 -1
  57. package/src/storage/index.ts +6 -0
  58. package/src/storage/migrations/index.ts +17 -0
  59. package/src/storage/repos/pluginStorage.ts +35 -0
  60. package/src/tui/App.tsx +17 -9
  61. package/src/tui/components/HelpOverlay.tsx +1 -1
  62. package/src/tui/components/LimitGauge.tsx +12 -4
  63. package/src/tui/components/ProviderCard.tsx +39 -23
  64. package/src/tui/components/ProviderDetailPanel.tsx +1 -1
  65. package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
  66. package/src/tui/components/SessionDetailsDrawer.tsx +10 -0
  67. package/src/tui/components/SessionsTable.tsx +62 -37
  68. package/src/tui/components/SettingsModal.tsx +1 -1
  69. package/src/tui/components/Skeleton.tsx +9 -6
  70. package/src/tui/components/SortOverlay.tsx +167 -0
  71. package/src/tui/components/Spinner.tsx +16 -11
  72. package/src/tui/components/ThemePicker.tsx +1 -1
  73. package/src/tui/contexts/AgentSessionContext.tsx +70 -17
  74. package/src/tui/contexts/PluginContext.tsx +72 -14
  75. package/src/tui/contexts/RealTimeActivityContext.tsx +33 -20
  76. package/src/tui/contexts/ThemeContext.tsx +7 -4
  77. package/src/tui/createApp.tsx +2 -0
  78. package/src/tui/hooks/index.ts +1 -0
  79. package/src/tui/hooks/useAnimatedValue.ts +29 -26
  80. package/src/tui/hooks/useAnimationTick.ts +93 -0
  81. package/src/tui/hooks/useDashboardKeyboard.ts +11 -4
  82. package/src/tui/hooks/useEntranceAnimation.ts +16 -10
  83. package/src/tui/hooks/usePulse.ts +13 -7
  84. package/src/tui/hooks/useSessionEnrichedProviders.ts +1 -1
  85. package/src/tui/hooks/useValueFlash.ts +25 -33
  86. package/src/tui/index.tsx +4 -0
  87. package/src/tui/types/projectSort.ts +35 -0
  88. package/src/tui/types/sort.ts +61 -0
  89. package/src/tui/views/ProjectsView.tsx +104 -38
  90. package/src/tui/views/RealTimeDashboard.tsx +91 -8
  91. package/src/tui/views/SettingsView.tsx +1 -1
  92. package/src/utils/__tests__/error-category.test.ts +147 -0
  93. package/src/utils/currency.ts +4 -2
  94. package/src/utils/error-category.ts +91 -0
  95. package/src/version.ts +1 -1
  96. package/src/plugins/types/agent.ts +0 -108
  97. package/src/plugins/types/base.ts +0 -278
  98. package/src/plugins/types/index.ts +0 -19
  99. package/src/plugins/types/notification.ts +0 -51
  100. package/src/plugins/types/provider.ts +0 -237
  101. package/src/plugins/types/theme.ts +0 -78
  102. package/src/storage/database.ts +0 -623
@@ -1,4 +1,4 @@
1
- import type { ThemePlugin } from "../types/theme.ts";
1
+ import type { ThemePlugin } from "@tokentop/plugin-sdk";
2
2
 
3
3
  export const tokyoNightTheme: ThemePlugin = {
4
4
  apiVersion: 2,
@@ -9,7 +9,7 @@
9
9
  * skipped because they have no registry entry to compare against.
10
10
  */
11
11
 
12
- import type { PluginType } from "./types/base.ts";
12
+ import type { PluginType } from "@tokentop/plugin-sdk";
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // Types
@@ -60,7 +60,7 @@ function isNewer(current: string, latest: string): boolean {
60
60
  * Uses the abbreviated metadata endpoint for minimal payload.
61
61
  */
62
62
  async function fetchLatestVersion(packageName: string): Promise<string> {
63
- const url = `https://registry.npmjs.org/${encodeURIComponent(packageName).replace("%40", "@")}`;
63
+ const url = `https://registry.npmjs.org/${encodeURIComponent(packageName).replaceAll("%40", "@")}`;
64
64
  const res = await fetch(url, {
65
65
  headers: { Accept: "application/vnd.npm.install-v1+json" },
66
66
  signal: AbortSignal.timeout(8_000),
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import type { ModelPricing } from "@/plugins/types/provider.ts";
2
+ import type { ModelPricing } from "@tokentop/plugin-sdk";
3
3
  import { estimateCost, estimateSessionCost, formatCost, formatTokenCount } from "./estimator.ts";
4
4
 
5
5
  describe("estimateCost()", () => {
@@ -1,4 +1,4 @@
1
- import type { CostBreakdown, ModelPricing } from "@/plugins/types/provider.ts";
1
+ import type { CostBreakdown, ModelPricing } from "@tokentop/plugin-sdk";
2
2
 
3
3
  export interface TokenUsage {
4
4
  input: number;
@@ -1,4 +1,4 @@
1
- import type { ModelPricing } from "@/plugins/types/provider.ts";
1
+ import type { ModelPricing } from "@tokentop/plugin-sdk";
2
2
 
3
3
  //TODO: get more up to date fallback pricing
4
4
  export const FALLBACK_PRICING: Record<string, Record<string, ModelPricing>> = {
@@ -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: {
@@ -1,4 +1,4 @@
1
- import type { ModelPricing, ProviderPlugin, ProviderPricing } from "@/plugins/types/provider.ts";
1
+ import type { ModelPricing, ProviderPlugin, ProviderPricing } from "@tokentop/plugin-sdk";
2
2
  import { getFallbackPricing, getFallbackProviderPricing } from "./fallback.ts";
3
3
  import {
4
4
  clearCache as clearModelsDevCache,
@@ -1,4 +1,4 @@
1
- import type { ModelPricing } from "@/plugins/types/provider.ts";
1
+ import type { ModelPricing } from "@tokentop/plugin-sdk";
2
2
 
3
3
  const MODELS_DEV_API = "https://models.dev/api.json";
4
4
  const CACHE_TTL_MS = 3600000;
@@ -15,6 +15,12 @@ export {
15
15
  insertAgentSessionSnapshot,
16
16
  upsertAgentSession,
17
17
  } from "./repos/agentSessions.ts";
18
+ export {
19
+ pluginStorageDelete,
20
+ pluginStorageGet,
21
+ pluginStorageHas,
22
+ pluginStorageSet,
23
+ } from "./repos/pluginStorage.ts";
18
24
  export {
19
25
  getLatestProviderSnapshot,
20
26
  insertProviderSnapshot,
@@ -164,6 +164,23 @@ export const migrations: Migration[] = [
164
164
  `);
165
165
  },
166
166
  },
167
+ {
168
+ version: 2,
169
+ up: (db: Database) => {
170
+ db.exec(`
171
+ CREATE TABLE IF NOT EXISTS plugin_storage (
172
+ plugin_id TEXT NOT NULL,
173
+ key TEXT NOT NULL,
174
+ value TEXT NOT NULL,
175
+ updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
176
+ PRIMARY KEY (plugin_id, key)
177
+ );
178
+
179
+ CREATE INDEX IF NOT EXISTS idx_plugin_storage_plugin
180
+ ON plugin_storage(plugin_id);
181
+ `);
182
+ },
183
+ },
167
184
  ];
168
185
 
169
186
  export function getCurrentVersion(db: Database): number {
@@ -0,0 +1,35 @@
1
+ import { getDatabase } from "../db.ts";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Plugin KV storage
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export function pluginStorageGet(pluginId: string, key: string): string | null {
8
+ const db = getDatabase();
9
+ const row = db
10
+ .prepare("SELECT value FROM plugin_storage WHERE plugin_id = ? AND key = ?")
11
+ .get(pluginId, key) as { value: string } | null;
12
+ return row?.value ?? null;
13
+ }
14
+
15
+ export function pluginStorageSet(pluginId: string, key: string, value: string): void {
16
+ const db = getDatabase();
17
+ db.prepare(
18
+ `INSERT INTO plugin_storage (plugin_id, key, value, updated_at)
19
+ VALUES (?, ?, ?, ?)
20
+ ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
21
+ ).run(pluginId, key, value, Math.floor(Date.now() / 1000));
22
+ }
23
+
24
+ export function pluginStorageDelete(pluginId: string, key: string): void {
25
+ const db = getDatabase();
26
+ db.prepare("DELETE FROM plugin_storage WHERE plugin_id = ? AND key = ?").run(pluginId, key);
27
+ }
28
+
29
+ export function pluginStorageHas(pluginId: string, key: string): boolean {
30
+ const db = getDatabase();
31
+ const row = db
32
+ .prepare("SELECT 1 FROM plugin_storage WHERE plugin_id = ? AND key = ?")
33
+ .get(pluginId, key);
34
+ return row !== null && row !== undefined;
35
+ }
package/src/tui/App.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { useKeyboard } from "@opentui/react";
2
+ import type { ThemePlugin } from "@tokentop/plugin-sdk";
2
3
  import { useCallback, useEffect, useMemo, useState } from "react";
3
4
  import * as builtinThemeExports from "@/plugins/themes/index.ts";
4
- import type { ThemePlugin } from "@/plugins/types/theme.ts";
5
5
  import { resolveTheme, ThemeProvider, useColors, useTheme } from "./contexts/ThemeContext.tsx";
6
6
  import {
7
7
  type BurstRecorder,
@@ -48,6 +48,7 @@ interface AppProps {
48
48
  demoSeed?: number;
49
49
  demoPreset?: DemoPreset;
50
50
  cliPlugins?: string[];
51
+ cliTheme?: string;
51
52
  }
52
53
 
53
54
  type View = "dashboard" | "providers" | "trends" | "projects";
@@ -55,7 +56,7 @@ type View = "dashboard" | "providers" | "trends" | "projects";
55
56
  function AppContent() {
56
57
  const renderer = useSafeRenderer();
57
58
  const colors = useColors();
58
- const { theme, setTheme } = useTheme();
59
+ const { theme, setTheme, cliTheme } = useTheme();
59
60
  const { refreshAllProviders, isInitialized, themes } = usePlugins();
60
61
  const { info } = useLogs();
61
62
  const { toast, showToast, dismissToast } = useToastContext();
@@ -292,8 +293,10 @@ function AppContent() {
292
293
 
293
294
  useEffect(() => {
294
295
  if (themes.length === 0) return;
296
+ // CLI --theme flag takes priority over config
297
+ const themeId = cliTheme ?? config.display.theme;
295
298
  const resolved = resolveTheme(
296
- config.display.theme,
299
+ themeId,
297
300
  config.display.colorScheme,
298
301
  themes,
299
302
  renderer?.themeMode ?? null,
@@ -301,7 +304,7 @@ function AppContent() {
301
304
  if (resolved.id !== theme.id) {
302
305
  setTheme(resolved);
303
306
  }
304
- }, [themes, config.display.theme, config.display.colorScheme, renderer]);
307
+ }, [themes, cliTheme, config.display.theme, config.display.colorScheme, renderer]);
305
308
 
306
309
  useEffect(() => {
307
310
  if (isInitialized) {
@@ -364,23 +367,24 @@ function AppContent() {
364
367
  );
365
368
  }
366
369
 
367
- function ConfiguredApp({ cliPlugins }: { cliPlugins?: string[] }) {
370
+ function ConfiguredApp({ cliPlugins, cliTheme }: { cliPlugins?: string[]; cliTheme?: string }) {
368
371
  const { config, isLoading } = useConfig();
369
372
 
370
373
  if (isLoading) {
371
374
  return null;
372
375
  }
373
376
 
374
- const initialTheme = builtinThemeList.find((t) => t.id === config.display.theme);
377
+ const themeId = cliTheme ?? config.display.theme;
378
+ const initialTheme = builtinThemeList.find((t) => t.id === themeId);
375
379
  const themeProps = initialTheme ? { initialTheme } : {};
376
380
 
377
381
  return (
378
- <ThemeProvider {...themeProps}>
382
+ <ThemeProvider {...themeProps} {...(cliTheme ? { cliTheme } : {})}>
379
383
  <TimeWindowProvider defaultWindow={config.display.defaultTimeWindow}>
380
384
  <ToastProvider>
381
385
  <PluginProvider {...(cliPlugins ? { cliPlugins } : {})}>
382
386
  <RealTimeActivityProvider>
383
- <AgentSessionProvider autoRefresh={true} refreshInterval={1000}>
387
+ <AgentSessionProvider autoRefresh={true}>
384
388
  <DashboardRuntimeProvider>
385
389
  <DrawerProvider>
386
390
  <AppContent />
@@ -401,6 +405,7 @@ export function App({
401
405
  demoSeed,
402
406
  demoPreset,
403
407
  cliPlugins,
408
+ cliTheme,
404
409
  }: AppProps) {
405
410
  const demoProviderProps: { demoMode: boolean; demoSeed?: number; demoPreset?: DemoPreset } = {
406
411
  demoMode,
@@ -414,7 +419,10 @@ export function App({
414
419
  <InputProvider>
415
420
  <StorageProvider>
416
421
  <ConfigProvider>
417
- <ConfiguredApp {...(cliPlugins ? { cliPlugins } : {})} />
422
+ <ConfiguredApp
423
+ {...(cliPlugins ? { cliPlugins } : {})}
424
+ {...(cliTheme ? { cliTheme } : {})}
425
+ />
418
426
  </ConfigProvider>
419
427
  </StorageProvider>
420
428
  </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
  );
@@ -1,10 +1,12 @@
1
1
  import type { BoxRenderable } from "@opentui/core";
2
2
  import { useTerminalDimensions } from "@opentui/react";
3
- import { forwardRef, useCallback, useEffect, useState } from "react";
4
- import type { ProviderUsageData } from "@/plugins/types/provider.ts";
3
+ import type { ProviderUsageData } from "@tokentop/plugin-sdk";
4
+ import { forwardRef, useCallback } from "react";
5
+ import { classifyProviderError, getErrorCategoryDisplay } from "@/utils/error-category.ts";
5
6
  import { useColors } from "../contexts/ThemeContext.tsx";
7
+ import { usePulse } from "../hooks/usePulse.ts";
6
8
  import { SkeletonProviderContent } from "./Skeleton.tsx";
7
- import { useSpinner } from "./Spinner.tsx";
9
+ import { Spinner } from "./Spinner.tsx";
8
10
  import { UsageGauge } from "./UsageGauge.tsx";
9
11
 
10
12
  interface CompactGaugeProps {
@@ -60,9 +62,11 @@ export const ProviderCard = forwardRef<BoxRenderable, ProviderCardProps>(
60
62
  const colors = useColors();
61
63
  const { width: termWidth } = useTerminalDimensions();
62
64
  const providerColor = color ?? colors.primary;
63
- const spinnerFrame = useSpinner();
64
-
65
- const [pulseStep, setPulseStep] = useState(0);
65
+ const pulseStep = usePulse({
66
+ enabled: usage?.limitReached ?? false,
67
+ intervalMs: 150,
68
+ steps: 12,
69
+ });
66
70
 
67
71
  const rawItems = usage?.limits?.items;
68
72
  const hasItems = rawItems && rawItems.length > 0;
@@ -78,36 +82,34 @@ export const ProviderCard = forwardRef<BoxRenderable, ProviderCardProps>(
78
82
  sortedItems.length > maxVisibleGauges ? sortedItems.slice(0, maxVisibleGauges) : sortedItems;
79
83
  const useCompactMode = visibleItems.length > 3;
80
84
 
81
- useEffect(() => {
82
- if (!usage?.limitReached) {
83
- setPulseStep(0);
84
- return;
85
- }
86
-
87
- const timer = setInterval(() => {
88
- setPulseStep((p) => (p + 1) % 12);
89
- }, 150);
90
-
91
- return () => clearInterval(timer);
92
- }, [usage?.limitReached]);
93
-
94
85
  const handleClick = useCallback(() => {
95
86
  if (onFocus) {
96
87
  onFocus();
97
88
  }
98
89
  }, [onFocus]);
99
90
 
91
+ const errorCategory = usage?.error ? classifyProviderError(usage.error) : undefined;
92
+ const errorDisplay = errorCategory ? getErrorCategoryDisplay(errorCategory) : undefined;
93
+
100
94
  const statusColor = !configured
101
95
  ? colors.textSubtle
102
96
  : loading
103
97
  ? colors.info
104
98
  : usage?.error
105
- ? colors.error
99
+ ? errorDisplay?.isWarning
100
+ ? colors.warning
101
+ : colors.error
106
102
  : usage?.limitReached
107
103
  ? colors.warning
108
104
  : colors.success;
109
105
 
110
- const statusIcon = !configured ? "○" : usage?.error ? "✗" : usage?.limitReached ? "!" : "●";
106
+ const statusIcon = !configured
107
+ ? "○"
108
+ : usage?.error
109
+ ? (errorDisplay?.icon ?? "✗")
110
+ : usage?.limitReached
111
+ ? "!"
112
+ : "●";
111
113
 
112
114
  const isInitialLoad = loading && !usage;
113
115
 
@@ -154,7 +156,11 @@ export const ProviderCard = forwardRef<BoxRenderable, ProviderCardProps>(
154
156
  <strong>{name}</strong>
155
157
  </span>
156
158
  </text>
157
- <text fg={statusColor}>{configured && loading ? spinnerFrame : statusIcon}</text>
159
+ {configured && loading ? (
160
+ <Spinner color={statusColor} />
161
+ ) : (
162
+ <text fg={statusColor}>{statusIcon}</text>
163
+ )}
158
164
  </box>
159
165
 
160
166
  {!configured && <text fg={colors.textSubtle}>Not configured</text>}
@@ -162,7 +168,17 @@ export const ProviderCard = forwardRef<BoxRenderable, ProviderCardProps>(
162
168
  {configured && isInitialLoad && <SkeletonProviderContent />}
163
169
 
164
170
  {configured && !isInitialLoad && usage?.error && (
165
- <text fg={colors.error}>{usage.error}</text>
171
+ <box flexDirection="column" gap={0}>
172
+ <text fg={errorDisplay?.isWarning ? colors.warning : colors.error}>
173
+ <span>
174
+ <bold>{errorDisplay?.label ?? "ERR"}</bold>
175
+ </span>
176
+ <span fg={colors.textMuted}> — </span>
177
+ <span fg={errorDisplay?.isWarning ? colors.warning : colors.error}>
178
+ {usage.error}
179
+ </span>
180
+ </text>
181
+ </box>
166
182
  )}
167
183
 
168
184
  {configured && !isInitialLoad && usage && !usage.error && (
@@ -1,4 +1,4 @@
1
- import type { UsageLimit } from "@/plugins/types/provider.ts";
1
+ import type { UsageLimit } from "@tokentop/plugin-sdk";
2
2
  import type { ProviderState } from "../contexts/PluginContext.tsx";
3
3
  import { useColors } from "../contexts/ThemeContext.tsx";
4
4
  import { Sparkline } from "./Sparkline.tsx";
@@ -1,5 +1,6 @@
1
1
  import { useTerminalDimensions } from "@opentui/react";
2
2
  import { useMemo } from "react";
3
+ import type { ProviderErrorCategory } from "@/utils/error-category.ts";
3
4
  import { useConfig } from "../contexts/ConfigContext.tsx";
4
5
  import { useColors } from "../contexts/ThemeContext.tsx";
5
6
  import { usePulse } from "../hooks/usePulse.ts";
@@ -34,6 +35,7 @@ interface ProviderData {
34
35
  usedPercent: number;
35
36
  color: string;
36
37
  error?: string;
38
+ errorCategory?: ProviderErrorCategory;
37
39
  resetTime?: string;
38
40
  used?: number;
39
41
  limit?: number;
@@ -186,6 +188,7 @@ export function ProviderLimitsPanel({
186
188
  usedPercent={p.usedPercent}
187
189
  color={p.color}
188
190
  {...(p.error ? { error: p.error } : {})}
191
+ {...(p.errorCategory ? { errorCategory: p.errorCategory } : {})}
189
192
  compact={true}
190
193
  selected={focused && startIndex + idx === safeSelectedIndex}
191
194
  warningThreshold={warningThreshold}
@@ -269,6 +272,7 @@ export function ProviderLimitsPanel({
269
272
  usedPercent={p.usedPercent}
270
273
  color={p.color}
271
274
  {...(p.error ? { error: p.error } : {})}
275
+ {...(p.errorCategory ? { errorCategory: p.errorCategory } : {})}
272
276
  {...(p.resetTime ? { resetTime: p.resetTime } : {})}
273
277
  labelWidth={14}
274
278
  barWidth={12}
@@ -326,6 +330,7 @@ export function ProviderLimitsPanel({
326
330
  usedPercent={p.usedPercent}
327
331
  color={p.color}
328
332
  {...(p.error ? { error: p.error } : {})}
333
+ {...(p.errorCategory ? { errorCategory: p.errorCategory } : {})}
329
334
  {...(p.resetTime ? { resetTime: p.resetTime } : {})}
330
335
  labelWidth={12}
331
336
  barWidth={10}
@@ -314,6 +314,16 @@ export function SessionDetailsDrawer({ session, onClose: _onClose }: SessionDeta
314
314
  </text>
315
315
  </box>
316
316
 
317
+ {session.metadata?.isEstimated === true && (
318
+ <box flexDirection="row" height={1} marginBottom={0}>
319
+ <text height={1} overflow="hidden">
320
+ <span fg={colors.warning}>
321
+ ≈ Token data is estimated — server enrichment pending
322
+ </span>
323
+ </text>
324
+ </box>
325
+ )}
326
+
317
327
  {hasCacheData && (
318
328
  <box flexDirection="row" height={1} marginBottom={0}>
319
329
  <text height={1} overflow="hidden">