@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
|
@@ -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
|
@@ -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
|
-
|
|
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
|
|
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}
|
|
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
|
|
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
|
-
|
|
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
|
);
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { BoxRenderable } from "@opentui/core";
|
|
2
2
|
import { useTerminalDimensions } from "@opentui/react";
|
|
3
3
|
import type { ProviderUsageData } from "@tokentop/plugin-sdk";
|
|
4
|
-
import { forwardRef, useCallback
|
|
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 {
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
?
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
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,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}
|
|
@@ -7,6 +7,8 @@ import { useAnimatedValue } from "../hooks/useAnimatedValue.ts";
|
|
|
7
7
|
import { applyEntranceFade, useEntranceAnimation } from "../hooks/useEntranceAnimation.ts";
|
|
8
8
|
import { useExitAnimation } from "../hooks/useExitAnimation.ts";
|
|
9
9
|
import { interpolateColor, useValueFlash } from "../hooks/useValueFlash.ts";
|
|
10
|
+
import type { SortDirection, SortField } from "../types/sort.ts";
|
|
11
|
+
import { getSortDirectionIndicator, getSortFieldLabel } from "../types/sort.ts";
|
|
10
12
|
|
|
11
13
|
interface SessionsTableProps {
|
|
12
14
|
sessions: AgentSessionAggregate[];
|
|
@@ -17,6 +19,8 @@ interface SessionsTableProps {
|
|
|
17
19
|
focusedPanel: "sessions" | "sidebar" | "limits";
|
|
18
20
|
windowLabel: string;
|
|
19
21
|
getProviderColor: (id: string) => string;
|
|
22
|
+
sortField: SortField;
|
|
23
|
+
sortDirection: SortDirection;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
function extractRepoName(projectPath: string | null): string {
|
|
@@ -265,9 +269,7 @@ const SessionRow = memo(function SessionRow({
|
|
|
265
269
|
{isEstimated ? "≈" : " "}
|
|
266
270
|
{formatTokensVal(animatedTokens).padStart(7)}
|
|
267
271
|
</span>
|
|
268
|
-
<span fg={
|
|
269
|
-
{hasCacheData ? ` ↯${formatCacheBadge(cacheRead)}` : ""}
|
|
270
|
-
</span>
|
|
272
|
+
<span fg={textMutedColor}>{hasCacheData ? ` ↯${formatCacheBadge(cacheRead)}` : ""}</span>
|
|
271
273
|
</text>
|
|
272
274
|
<text width={8} height={1} fg={costColor} {...bgProp}>
|
|
273
275
|
{costDisplay.padStart(7)}
|
|
@@ -323,7 +325,7 @@ const SessionRow = memo(function SessionRow({
|
|
|
323
325
|
{isEstimated ? "≈" : " "}
|
|
324
326
|
{formatTokensVal(animatedTokens).padStart(7)}
|
|
325
327
|
</span>
|
|
326
|
-
<span fg={
|
|
328
|
+
<span fg={textMutedColor}>
|
|
327
329
|
{hasCacheData && showCacheBadge ? ` ↯${formatCacheBadge(cacheRead)}` : ""}
|
|
328
330
|
</span>
|
|
329
331
|
</text>
|
|
@@ -355,6 +357,8 @@ export const SessionsTable = forwardRef(function SessionsTable(
|
|
|
355
357
|
focusedPanel,
|
|
356
358
|
windowLabel,
|
|
357
359
|
getProviderColor,
|
|
360
|
+
sortField,
|
|
361
|
+
sortDirection,
|
|
358
362
|
}: SessionsTableProps,
|
|
359
363
|
ref: Ref<ScrollBoxRenderable>,
|
|
360
364
|
) {
|
|
@@ -391,16 +395,18 @@ export const SessionsTable = forwardRef(function SessionsTable(
|
|
|
391
395
|
const { items: animatedSessions, isBulkChange } = useExitAnimation(sessions, {
|
|
392
396
|
durationMs: 500,
|
|
393
397
|
getKey: getSessionKey,
|
|
398
|
+
bulkThreshold: 100,
|
|
394
399
|
});
|
|
395
400
|
|
|
396
|
-
// Track bulk changes to force scrollbox remount (clears ghost text)
|
|
397
|
-
const bulkCountRef = useRef(0);
|
|
398
|
-
if (isBulkChange) bulkCountRef.current++;
|
|
399
|
-
|
|
400
401
|
let activeIndex = 0;
|
|
401
402
|
const selectedSession = sessions[selectedRow] ?? null;
|
|
402
403
|
const inspector = selectedSession ? getInspectorData(selectedSession) : null;
|
|
403
404
|
|
|
405
|
+
// Sort indicator helpers for column headers
|
|
406
|
+
const sortIndicator = (field: string) =>
|
|
407
|
+
sortField === field ? (sortDirection === "desc" ? "▼" : "▲") : " ";
|
|
408
|
+
const sortColor = (field: string) => (sortField === field ? colors.primary : colors.textMuted);
|
|
409
|
+
|
|
404
410
|
return (
|
|
405
411
|
<box
|
|
406
412
|
flexDirection="column"
|
|
@@ -418,9 +424,15 @@ export const SessionsTable = forwardRef(function SessionsTable(
|
|
|
418
424
|
justifyContent="space-between"
|
|
419
425
|
overflow="hidden"
|
|
420
426
|
>
|
|
421
|
-
<text height={1}
|
|
422
|
-
|
|
423
|
-
{
|
|
427
|
+
<text height={1}>
|
|
428
|
+
<span fg={colors.textMuted}>SESSIONS </span>
|
|
429
|
+
<span fg={colors.primary}>
|
|
430
|
+
Sort: {getSortFieldLabel(sortField)} {getSortDirectionIndicator(sortDirection)}
|
|
431
|
+
</span>
|
|
432
|
+
<span fg={colors.textMuted}>
|
|
433
|
+
{filterQuery ? ` [${isFiltering ? "Filter: " : ""}${filterQuery}]` : ""}
|
|
434
|
+
{isLoading ? " ⟳" : " "}
|
|
435
|
+
</span>
|
|
424
436
|
</text>
|
|
425
437
|
<text height={1} fg={colors.textMuted}>
|
|
426
438
|
[{windowLabel}] {sessions.length} sessions
|
|
@@ -435,36 +447,36 @@ export const SessionsTable = forwardRef(function SessionsTable(
|
|
|
435
447
|
<text width={8} height={1} fg={colors.textMuted}>
|
|
436
448
|
ID{" "}
|
|
437
449
|
</text>
|
|
438
|
-
<text width={12} height={1} fg={
|
|
439
|
-
AGENT{"
|
|
450
|
+
<text width={12} height={1} fg={sortColor("agent")}>
|
|
451
|
+
AGENT{sortIndicator("agent")}
|
|
440
452
|
</text>
|
|
441
453
|
<text width={18} height={1} fg={colors.textMuted}>
|
|
442
454
|
MODEL{" "}
|
|
443
455
|
</text>
|
|
444
|
-
<text width={5} height={1} fg={
|
|
445
|
-
{"
|
|
456
|
+
<text width={5} height={1} fg={sortColor("requests")}>
|
|
457
|
+
{sortIndicator("requests")}
|
|
446
458
|
REQ{" "}
|
|
447
459
|
</text>
|
|
448
|
-
<text width={14} height={1} fg={
|
|
449
|
-
{"
|
|
460
|
+
<text width={14} height={1} fg={sortColor("tokens")}>
|
|
461
|
+
{sortIndicator("tokens")}
|
|
450
462
|
TOKENS{" "}
|
|
451
463
|
</text>
|
|
452
|
-
<text width={8} height={1} fg={
|
|
453
|
-
{"
|
|
464
|
+
<text width={8} height={1} fg={sortColor("cost")}>
|
|
465
|
+
{sortIndicator("cost")}
|
|
454
466
|
COST{" "}
|
|
455
467
|
</text>
|
|
456
|
-
<text width={14} height={1} fg={
|
|
457
|
-
PROJECT{"
|
|
468
|
+
<text width={14} height={1} fg={sortColor("project")}>
|
|
469
|
+
PROJECT{sortIndicator("project")}
|
|
458
470
|
</text>
|
|
459
471
|
<text flexGrow={1} height={1} fg={colors.textMuted}>
|
|
460
472
|
NAME
|
|
461
473
|
</text>
|
|
462
|
-
<text width={6} height={1} fg={
|
|
463
|
-
{"
|
|
474
|
+
<text width={6} height={1} fg={sortColor("duration")}>
|
|
475
|
+
{sortIndicator("duration")}
|
|
464
476
|
DUR{" "}
|
|
465
477
|
</text>
|
|
466
|
-
<text width={5} height={1} fg={
|
|
467
|
-
LAST{"
|
|
478
|
+
<text width={5} height={1} fg={sortColor("time")}>
|
|
479
|
+
LAST{sortIndicator("time")}
|
|
468
480
|
</text>
|
|
469
481
|
<text width={2} height={1} fg={colors.textMuted}>
|
|
470
482
|
{" "}
|
|
@@ -475,24 +487,30 @@ export const SessionsTable = forwardRef(function SessionsTable(
|
|
|
475
487
|
<text width={2} height={1} fg={colors.textMuted}>
|
|
476
488
|
{" "}
|
|
477
489
|
</text>
|
|
478
|
-
<text width={9} height={1} fg={
|
|
479
|
-
AGENT{"
|
|
490
|
+
<text width={9} height={1} fg={sortColor("agent")}>
|
|
491
|
+
AGENT{sortIndicator("agent")}
|
|
480
492
|
</text>
|
|
481
493
|
<text width={isTight ? 13 : 16} height={1} fg={colors.textMuted}>
|
|
482
494
|
{isTight ? "MODEL " : "MODEL "}
|
|
483
495
|
</text>
|
|
484
|
-
<text width={isTight ? 9 : 14} height={1} fg={
|
|
485
|
-
{isTight
|
|
496
|
+
<text width={isTight ? 9 : 14} height={1} fg={sortColor("tokens")}>
|
|
497
|
+
{isTight
|
|
498
|
+
? `${sortIndicator("tokens")} TOKENS `
|
|
499
|
+
: `${sortIndicator("tokens")}TOKENS `}
|
|
486
500
|
</text>
|
|
487
|
-
<text width={7} height={1} fg={
|
|
488
|
-
{"
|
|
501
|
+
<text width={7} height={1} fg={sortColor("cost")}>
|
|
502
|
+
{sortIndicator("cost")}
|
|
489
503
|
COST{" "}
|
|
490
504
|
</text>
|
|
491
|
-
<text flexGrow={1} height={1} fg={
|
|
492
|
-
{nameMaxWidth >= 7
|
|
505
|
+
<text flexGrow={1} height={1} fg={sortColor("project")}>
|
|
506
|
+
{nameMaxWidth >= 7
|
|
507
|
+
? `PROJECT${sortIndicator("project")}`
|
|
508
|
+
: nameMaxWidth >= 4
|
|
509
|
+
? "PROJ"
|
|
510
|
+
: ""}
|
|
493
511
|
</text>
|
|
494
|
-
<text width={5} height={1} fg={
|
|
495
|
-
LAST{"
|
|
512
|
+
<text width={5} height={1} fg={sortColor("time")}>
|
|
513
|
+
LAST{sortIndicator("time")}
|
|
496
514
|
</text>
|
|
497
515
|
<text width={2} height={1} fg={colors.textMuted}>
|
|
498
516
|
{" "}
|
|
@@ -500,7 +518,7 @@ export const SessionsTable = forwardRef(function SessionsTable(
|
|
|
500
518
|
</box>
|
|
501
519
|
)}
|
|
502
520
|
|
|
503
|
-
<scrollbox
|
|
521
|
+
<scrollbox ref={ref} flexGrow={1}>
|
|
504
522
|
<box flexDirection="column" flexGrow={1}>
|
|
505
523
|
{sessions.length === 0 && animatedSessions.length === 0 && (
|
|
506
524
|
<box paddingLeft={2}>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
import { useColors } from "../contexts/ThemeContext.tsx";
|
|
3
|
+
import { animationTick } from "../hooks/useAnimationTick.ts";
|
|
3
4
|
import { interpolateColor } from "../hooks/useValueFlash.ts";
|
|
4
5
|
|
|
5
6
|
const SHIMMER_INTERVAL = 50;
|
|
@@ -12,13 +13,15 @@ interface ShimmerSegment {
|
|
|
12
13
|
|
|
13
14
|
function useShimmer(length: number, baseColor: string, highlightColor: string): ShimmerSegment[] {
|
|
14
15
|
const [phase, setPhase] = useState(0);
|
|
16
|
+
const lastAdvanceRef = useRef(Date.now());
|
|
15
17
|
|
|
16
18
|
useEffect(() => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
return animationTick.subscribe((now) => {
|
|
20
|
+
if (now - lastAdvanceRef.current >= SHIMMER_INTERVAL) {
|
|
21
|
+
lastAdvanceRef.current = now;
|
|
22
|
+
setPhase((p) => (p + 1) % (length + SHIMMER_WAVE_LENGTH));
|
|
23
|
+
}
|
|
24
|
+
});
|
|
22
25
|
}, [length]);
|
|
23
26
|
|
|
24
27
|
const segments: ShimmerSegment[] = [];
|