@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
|
@@ -2,11 +2,13 @@ import type { ScrollBoxRenderable } from "@opentui/core";
|
|
|
2
2
|
import { useTerminalDimensions } from "@opentui/react";
|
|
3
3
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { notificationBus } from "@/plugins/notification-bus.ts";
|
|
5
|
+
import { classifyProviderError } from "@/utils/error-category.ts";
|
|
5
6
|
import { HelpOverlay } from "../components/HelpOverlay.tsx";
|
|
6
7
|
import { KpiStrip } from "../components/KpiStrip.tsx";
|
|
7
8
|
import { ProviderLimitsPanel } from "../components/ProviderLimitsPanel.tsx";
|
|
8
9
|
import { SessionsTable } from "../components/SessionsTable.tsx";
|
|
9
10
|
import { type DriverDimension, getSidebarMode, SmartSidebar } from "../components/SmartSidebar.tsx";
|
|
11
|
+
import { SortOverlay } from "../components/SortOverlay.tsx";
|
|
10
12
|
import { useAgentSessions } from "../contexts/AgentSessionContext.tsx";
|
|
11
13
|
import { useConfig } from "../contexts/ConfigContext.tsx";
|
|
12
14
|
import { useDashboardRuntime } from "../contexts/DashboardRuntimeContext.tsx";
|
|
@@ -15,6 +17,8 @@ import { usePlugins } from "../contexts/PluginContext.tsx";
|
|
|
15
17
|
import { useColors } from "../contexts/ThemeContext.tsx";
|
|
16
18
|
import { useTimeWindow } from "../contexts/TimeWindowContext.tsx";
|
|
17
19
|
import { useDashboardKeyboard } from "../hooks/useDashboardKeyboard.ts";
|
|
20
|
+
import type { SortDirection, SortField } from "../types/sort.ts";
|
|
21
|
+
import { SORT_FIELDS } from "../types/sort.ts";
|
|
18
22
|
import { getProviderColor } from "../utils/providerColor.ts";
|
|
19
23
|
|
|
20
24
|
export function RealTimeDashboard() {
|
|
@@ -30,6 +34,23 @@ export function RealTimeDashboard() {
|
|
|
30
34
|
const showLargeHeader = terminalHeight >= 35;
|
|
31
35
|
const showProviderLimitsPanel = terminalHeight >= 24;
|
|
32
36
|
|
|
37
|
+
// SessionsTable hides REQ, NAME, DUR, TIME columns in narrow mode (<140).
|
|
38
|
+
// Filter sort fields to match visible columns.
|
|
39
|
+
const SESSIONS_WIDE_THRESHOLD = 140;
|
|
40
|
+
const NARROW_HIDDEN_FIELDS: ReadonlySet<SortField> = new Set([
|
|
41
|
+
"requests",
|
|
42
|
+
"name",
|
|
43
|
+
"duration",
|
|
44
|
+
"time",
|
|
45
|
+
]);
|
|
46
|
+
const visibleSortFields = useMemo(
|
|
47
|
+
() =>
|
|
48
|
+
terminalWidth >= SESSIONS_WIDE_THRESHOLD
|
|
49
|
+
? SORT_FIELDS
|
|
50
|
+
: SORT_FIELDS.filter((f) => !NARROW_HIDDEN_FIELDS.has(f.id)),
|
|
51
|
+
[terminalWidth],
|
|
52
|
+
);
|
|
53
|
+
|
|
33
54
|
// Visible-row count for scroll tracking — every non-session-data line must be counted.
|
|
34
55
|
const visibleRows = (() => {
|
|
35
56
|
const appChrome = (showLargeHeader ? 7 : 1) + 1; // App.tsx: Header + StatusBar
|
|
@@ -61,7 +82,9 @@ export function RealTimeDashboard() {
|
|
|
61
82
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(config.display.sidebarCollapsed);
|
|
62
83
|
const [filterQuery, setFilterQuery] = useState("");
|
|
63
84
|
const [isFiltering, setIsFiltering] = useState(false);
|
|
64
|
-
const [sortField, setSortField] = useState<
|
|
85
|
+
const [sortField, setSortField] = useState<SortField>("time");
|
|
86
|
+
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
|
|
87
|
+
const [showSortOverlay, setShowSortOverlay] = useState(false);
|
|
65
88
|
const [pendingG, setPendingG] = useState(false);
|
|
66
89
|
const scrollOffsetRef = useRef(0);
|
|
67
90
|
const selectedSessionIdRef = useRef<string | null>(null);
|
|
@@ -184,14 +207,53 @@ export function RealTimeDashboard() {
|
|
|
184
207
|
const bActive = b.status === "active" ? 1 : 0;
|
|
185
208
|
if (bActive !== aActive) return bActive - aActive;
|
|
186
209
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
210
|
+
let cmp = 0;
|
|
211
|
+
switch (sortField) {
|
|
212
|
+
case "cost":
|
|
213
|
+
cmp = (a.totalCostUsd ?? 0) - (b.totalCostUsd ?? 0);
|
|
214
|
+
break;
|
|
215
|
+
case "tokens":
|
|
216
|
+
cmp = a.totals.input + a.totals.output - (b.totals.input + b.totals.output);
|
|
217
|
+
break;
|
|
218
|
+
case "time":
|
|
219
|
+
cmp = a.lastActivityAt - b.lastActivityAt;
|
|
220
|
+
break;
|
|
221
|
+
case "requests":
|
|
222
|
+
cmp = a.requestCount - b.requestCount;
|
|
223
|
+
break;
|
|
224
|
+
case "duration": {
|
|
225
|
+
const aDur = (a.endedAt ?? Date.now()) - a.startedAt;
|
|
226
|
+
const bDur = (b.endedAt ?? Date.now()) - b.startedAt;
|
|
227
|
+
cmp = aDur - bDur;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
case "project": {
|
|
231
|
+
const aProj = a.projectPath?.split("/").pop() ?? "";
|
|
232
|
+
const bProj = b.projectPath?.split("/").pop() ?? "";
|
|
233
|
+
cmp = aProj.localeCompare(bProj);
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case "agent":
|
|
237
|
+
cmp = a.agentName.localeCompare(b.agentName);
|
|
238
|
+
break;
|
|
239
|
+
case "model": {
|
|
240
|
+
const aModel = a.streams[0]?.modelId ?? "";
|
|
241
|
+
const bModel = b.streams[0]?.modelId ?? "";
|
|
242
|
+
cmp = aModel.localeCompare(bModel);
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case "name": {
|
|
246
|
+
const aName = a.sessionName ?? "";
|
|
247
|
+
const bName = b.sessionName ?? "";
|
|
248
|
+
cmp = aName.localeCompare(bName);
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return sortDirection === "desc" ? -cmp : cmp;
|
|
191
253
|
});
|
|
192
254
|
|
|
193
255
|
return result;
|
|
194
|
-
}, [baseFilteredSessions, activeDriverFilter, driverDimension, sortField]);
|
|
256
|
+
}, [baseFilteredSessions, activeDriverFilter, driverDimension, sortField, sortDirection]);
|
|
195
257
|
|
|
196
258
|
// Stabilize selection across re-sorts: track selected session by ID
|
|
197
259
|
// so the highlight follows the same session when live data causes reordering.
|
|
@@ -261,6 +323,7 @@ export function RealTimeDashboard() {
|
|
|
261
323
|
filterQuery,
|
|
262
324
|
isFiltering,
|
|
263
325
|
sortField,
|
|
326
|
+
sortDirection,
|
|
264
327
|
pendingG,
|
|
265
328
|
scrollOffset: scrollOffsetRef.current,
|
|
266
329
|
limitSelectedIndex,
|
|
@@ -269,6 +332,7 @@ export function RealTimeDashboard() {
|
|
|
269
332
|
selectedDriverIndex,
|
|
270
333
|
activeDriverFilter,
|
|
271
334
|
sidebarMode,
|
|
335
|
+
showSortOverlay,
|
|
272
336
|
},
|
|
273
337
|
actions: {
|
|
274
338
|
setShowHelp,
|
|
@@ -280,12 +344,14 @@ export function RealTimeDashboard() {
|
|
|
280
344
|
setFilterQuery,
|
|
281
345
|
setIsFiltering,
|
|
282
346
|
setSortField,
|
|
347
|
+
setSortDirection,
|
|
283
348
|
setPendingG,
|
|
284
349
|
setScrollOffset,
|
|
285
350
|
setLimitSelectedIndex,
|
|
286
351
|
setDriverDimension,
|
|
287
352
|
setSelectedDriverIndex,
|
|
288
353
|
setActiveDriverFilter,
|
|
354
|
+
setShowSortOverlay,
|
|
289
355
|
},
|
|
290
356
|
processedSessions,
|
|
291
357
|
});
|
|
@@ -348,6 +414,19 @@ export function RealTimeDashboard() {
|
|
|
348
414
|
return (
|
|
349
415
|
<box flexDirection="column" flexGrow={1} padding={1} gap={1} overflow="hidden">
|
|
350
416
|
{showHelp && <HelpOverlay />}
|
|
417
|
+
{showSortOverlay && (
|
|
418
|
+
<SortOverlay
|
|
419
|
+
fields={visibleSortFields}
|
|
420
|
+
currentField={sortField}
|
|
421
|
+
currentDirection={sortDirection}
|
|
422
|
+
onSelect={(field, direction) => {
|
|
423
|
+
setSortField(() => field);
|
|
424
|
+
setSortDirection(() => direction);
|
|
425
|
+
setShowSortOverlay(false);
|
|
426
|
+
}}
|
|
427
|
+
onClose={() => setShowSortOverlay(false)}
|
|
428
|
+
/>
|
|
429
|
+
)}
|
|
351
430
|
|
|
352
431
|
<KpiStrip
|
|
353
432
|
totalCost={windowedKpis.cost}
|
|
@@ -382,7 +461,9 @@ export function RealTimeDashboard() {
|
|
|
382
461
|
name: p.plugin.name,
|
|
383
462
|
usedPercent: getMaxUsedPercent(p),
|
|
384
463
|
color: providerColorOf(p.plugin.id),
|
|
385
|
-
...(p.usage?.error
|
|
464
|
+
...(p.usage?.error
|
|
465
|
+
? { error: p.usage.error, errorCategory: classifyProviderError(p.usage.error) }
|
|
466
|
+
: {}),
|
|
386
467
|
}))}
|
|
387
468
|
focused={focusedPanel === "limits"}
|
|
388
469
|
selectedIndex={limitSelectedIndex}
|
|
@@ -416,6 +497,8 @@ export function RealTimeDashboard() {
|
|
|
416
497
|
focusedPanel={focusedPanel}
|
|
417
498
|
windowLabel={windowLabel}
|
|
418
499
|
getProviderColor={providerColorOf}
|
|
500
|
+
sortField={sortField}
|
|
501
|
+
sortDirection={sortDirection}
|
|
419
502
|
/>
|
|
420
503
|
|
|
421
504
|
{!effectiveSidebarCollapsed && (
|
|
@@ -440,7 +523,7 @@ export function RealTimeDashboard() {
|
|
|
440
523
|
: activeDriverFilter
|
|
441
524
|
? `Filter: ${activeDriverFilter} Esc clear`
|
|
442
525
|
: focusedPanel === "sessions"
|
|
443
|
-
? "/ filter ↑↓ navigate Enter details s sort l limits Tab next
|
|
526
|
+
? "/ filter ↑↓ navigate Enter details s sort l limits Tab next"
|
|
444
527
|
: focusedPanel === "limits"
|
|
445
528
|
? "←→ select provider Tab next ⇧Tab prev Esc back"
|
|
446
529
|
: focusedPanel === "sidebar"
|
|
@@ -2,6 +2,7 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
|
2
2
|
import type { ConfigField } from "@tokentop/plugin-sdk";
|
|
3
3
|
import { useCallback, useMemo, useState } from "react";
|
|
4
4
|
import type { AppConfig } from "@/config/schema.ts";
|
|
5
|
+
import { notificationBus } from "@/plugins/notification-bus.ts";
|
|
5
6
|
import { useConfig } from "../contexts/ConfigContext.tsx";
|
|
6
7
|
import { useDemoMode } from "../contexts/DemoModeContext.tsx";
|
|
7
8
|
import { usePlugins } from "../contexts/PluginContext.tsx";
|
|
@@ -14,10 +15,11 @@ interface SettingItem {
|
|
|
14
15
|
key: string;
|
|
15
16
|
label: string;
|
|
16
17
|
category: SettingCategory;
|
|
17
|
-
type: "toggle" | "select" | "number";
|
|
18
|
+
type: "toggle" | "select" | "number" | "action";
|
|
18
19
|
options?: string[];
|
|
19
20
|
getValue: (config: AppConfig) => string | number | boolean;
|
|
20
21
|
setValue: (config: AppConfig, value: string | number | boolean) => AppConfig;
|
|
22
|
+
onAction?: () => void;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
const SETTINGS: SettingItem[] = [
|
|
@@ -192,6 +194,15 @@ const SETTINGS: SettingItem[] = [
|
|
|
192
194
|
notifications: { ...c.notifications, soundEnabled: v as boolean },
|
|
193
195
|
}),
|
|
194
196
|
},
|
|
197
|
+
{
|
|
198
|
+
key: "testNotification",
|
|
199
|
+
label: "Test Notifications",
|
|
200
|
+
category: "notifications",
|
|
201
|
+
type: "action",
|
|
202
|
+
onAction: () => void notificationBus.testNotification(),
|
|
203
|
+
getValue: () => "▸ Run",
|
|
204
|
+
setValue: (c) => c,
|
|
205
|
+
},
|
|
195
206
|
];
|
|
196
207
|
|
|
197
208
|
const CATEGORIES: { id: SettingCategory; label: string }[] = [
|
|
@@ -384,6 +395,11 @@ export function SettingsView() {
|
|
|
384
395
|
} else if (key.name === "up" || key.name === "k") {
|
|
385
396
|
setSelectedIndex((i) => Math.max(i - 1, 0));
|
|
386
397
|
} else if (key.name === "return" || key.name === "space") {
|
|
398
|
+
const setting = categorySettings[selectedIndex];
|
|
399
|
+
if (setting?.type === "action" && setting.onAction) {
|
|
400
|
+
setting.onAction();
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
387
403
|
toggleCurrentSetting();
|
|
388
404
|
} else if (key.name === "left" || key.name === "h") {
|
|
389
405
|
const setting = categorySettings[selectedIndex];
|
|
@@ -479,13 +495,15 @@ export function SettingsView() {
|
|
|
479
495
|
>
|
|
480
496
|
<text fg={isSelected ? colors.background : colors.text}>{setting.label}</text>
|
|
481
497
|
<text fg={isSelected ? colors.background : colors.textMuted}>
|
|
482
|
-
{setting.type === "
|
|
483
|
-
?
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
:
|
|
498
|
+
{setting.type === "action"
|
|
499
|
+
? "▸ Run"
|
|
500
|
+
: setting.type === "toggle"
|
|
501
|
+
? value
|
|
502
|
+
? "● ON"
|
|
503
|
+
: "○ OFF"
|
|
504
|
+
: setting.type === "select"
|
|
505
|
+
? `◂ ${value} ▸`
|
|
506
|
+
: String(value)}
|
|
489
507
|
</text>
|
|
490
508
|
</box>
|
|
491
509
|
);
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { classifyProviderError } from "../error-category.ts";
|
|
3
|
+
|
|
4
|
+
describe("classifyProviderError", () => {
|
|
5
|
+
describe("auth errors", () => {
|
|
6
|
+
test("classifies expired token errors", () => {
|
|
7
|
+
expect(classifyProviderError("Token expired. Run any command in OpenCode to refresh.")).toBe(
|
|
8
|
+
"auth",
|
|
9
|
+
);
|
|
10
|
+
expect(
|
|
11
|
+
classifyProviderError(
|
|
12
|
+
"Token expired or invalid. Re-authenticate in OpenCode or Claude Code.",
|
|
13
|
+
),
|
|
14
|
+
).toBe("auth");
|
|
15
|
+
expect(classifyProviderError("OAuth token expired. Re-authenticate in OpenCode.")).toBe(
|
|
16
|
+
"auth",
|
|
17
|
+
);
|
|
18
|
+
expect(
|
|
19
|
+
classifyProviderError("OAuth token expired or invalid. Re-authenticate in OpenCode."),
|
|
20
|
+
).toBe("auth");
|
|
21
|
+
expect(
|
|
22
|
+
classifyProviderError("OAuth token expired or invalid. Re-authenticate via Gemini CLI."),
|
|
23
|
+
).toBe("auth");
|
|
24
|
+
expect(classifyProviderError("Claude Code token expired")).toBe("auth");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("classifies scope errors", () => {
|
|
28
|
+
expect(
|
|
29
|
+
classifyProviderError(
|
|
30
|
+
"Token lacks required scope. Re-authenticate in OpenCode or Claude Code.",
|
|
31
|
+
),
|
|
32
|
+
).toBe("auth");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("classifies missing credential errors", () => {
|
|
36
|
+
expect(
|
|
37
|
+
classifyProviderError(
|
|
38
|
+
"OAuth token or API key required. Authenticate via OpenCode or Claude Code.",
|
|
39
|
+
),
|
|
40
|
+
).toBe("auth");
|
|
41
|
+
expect(
|
|
42
|
+
classifyProviderError(
|
|
43
|
+
"OAuth token required. Sign in via OpenCode with ChatGPT Pro account.",
|
|
44
|
+
),
|
|
45
|
+
).toBe("auth");
|
|
46
|
+
expect(
|
|
47
|
+
classifyProviderError("OAuth token required. Sign in via OpenCode with Google account."),
|
|
48
|
+
).toBe("auth");
|
|
49
|
+
expect(
|
|
50
|
+
classifyProviderError("OAuth token required. Login via Gemini CLI with Google account."),
|
|
51
|
+
).toBe("auth");
|
|
52
|
+
expect(
|
|
53
|
+
classifyProviderError("API key required. Set OPENAI_API_KEY environment variable."),
|
|
54
|
+
).toBe("auth");
|
|
55
|
+
expect(
|
|
56
|
+
classifyProviderError("API key required. Set PERPLEXITY_API_KEY environment variable."),
|
|
57
|
+
).toBe("auth");
|
|
58
|
+
expect(classifyProviderError("API key required. Configure MiniMax in OpenCode.")).toBe(
|
|
59
|
+
"auth",
|
|
60
|
+
);
|
|
61
|
+
expect(classifyProviderError("API key required. Configure Chutes in OpenCode.")).toBe("auth");
|
|
62
|
+
expect(classifyProviderError("API key required. Configure Z.ai in OpenCode.")).toBe("auth");
|
|
63
|
+
expect(
|
|
64
|
+
classifyProviderError("API key required. Run /connect in OpenCode to set up Zen."),
|
|
65
|
+
).toBe("auth");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("classifies authorization failure errors", () => {
|
|
69
|
+
expect(classifyProviderError("Authorization failed. Check your MiniMax API key.")).toBe(
|
|
70
|
+
"auth",
|
|
71
|
+
);
|
|
72
|
+
expect(classifyProviderError("Authorization failed. Check your Chutes API key.")).toBe(
|
|
73
|
+
"auth",
|
|
74
|
+
);
|
|
75
|
+
expect(classifyProviderError("Authorization failed. Check your Z.ai API key.")).toBe("auth");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("classifies invalid key errors", () => {
|
|
79
|
+
expect(classifyProviderError("Invalid API key")).toBe("auth");
|
|
80
|
+
expect(classifyProviderError("Unauthorized – check API key")).toBe("auth");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("classifies HTTP status code auth errors", () => {
|
|
84
|
+
expect(classifyProviderError("API error: 401 Unauthorized")).toBe("auth");
|
|
85
|
+
expect(classifyProviderError("API error: 403 Forbidden")).toBe("auth");
|
|
86
|
+
expect(classifyProviderError("Authentication failed (401). Check API key.")).toBe("auth");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("classifies not-configured errors", () => {
|
|
90
|
+
expect(classifyProviderError("Not configured: missing GitHub token")).toBe("auth");
|
|
91
|
+
expect(classifyProviderError("Copilot not enabled for this account")).toBe("auth");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("classifies refresh failure errors", () => {
|
|
95
|
+
expect(classifyProviderError("No valid access token and refresh failed.")).toBe("auth");
|
|
96
|
+
expect(
|
|
97
|
+
classifyProviderError("ChatGPT account ID required. Re-authenticate in OpenCode."),
|
|
98
|
+
).toBe("auth");
|
|
99
|
+
expect(
|
|
100
|
+
classifyProviderError(
|
|
101
|
+
"MiniMax groupId required. Set MINIMAX_GROUP_ID or configure in OpenCode.",
|
|
102
|
+
),
|
|
103
|
+
).toBe("auth");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("rate limit errors", () => {
|
|
108
|
+
test("classifies rate limit errors", () => {
|
|
109
|
+
expect(classifyProviderError("Rate limited")).toBe("rate_limit");
|
|
110
|
+
expect(classifyProviderError("API error: 429 Too Many Requests")).toBe("rate_limit");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("insufficient credits errors", () => {
|
|
115
|
+
test("classifies insufficient credits errors", () => {
|
|
116
|
+
expect(classifyProviderError("Insufficient credits")).toBe("insufficient_credits");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("timeout errors", () => {
|
|
121
|
+
test("classifies timeout errors", () => {
|
|
122
|
+
expect(classifyProviderError("The operation timed out")).toBe("timeout");
|
|
123
|
+
expect(classifyProviderError("Request timeout")).toBe("timeout");
|
|
124
|
+
expect(classifyProviderError("ETIMEDOUT")).toBe("timeout");
|
|
125
|
+
expect(classifyProviderError("Request aborted")).toBe("timeout");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("network errors", () => {
|
|
130
|
+
test("classifies network errors", () => {
|
|
131
|
+
expect(classifyProviderError("fetch failed")).toBe("network");
|
|
132
|
+
expect(classifyProviderError("ECONNREFUSED")).toBe("network");
|
|
133
|
+
expect(classifyProviderError("ENOTFOUND")).toBe("network");
|
|
134
|
+
expect(classifyProviderError("ECONNRESET")).toBe("network");
|
|
135
|
+
expect(classifyProviderError("DNS resolution failed")).toBe("network");
|
|
136
|
+
expect(classifyProviderError("Network error")).toBe("network");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("unknown errors", () => {
|
|
141
|
+
test("classifies unrecognized errors as unknown", () => {
|
|
142
|
+
expect(classifyProviderError("Unknown error")).toBe("unknown");
|
|
143
|
+
expect(classifyProviderError("API error: 500 Internal Server Error")).toBe("unknown");
|
|
144
|
+
expect(classifyProviderError("Something went wrong")).toBe("unknown");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
package/src/utils/currency.ts
CHANGED
|
@@ -26,7 +26,9 @@ export function parseCurrencyInput(input: string): number | null {
|
|
|
26
26
|
} else if (hasComma) {
|
|
27
27
|
const parts = normalized.split(",");
|
|
28
28
|
// 3 digits after comma = thousands separator (1,234), otherwise decimal (10,50)
|
|
29
|
-
|
|
29
|
+
const afterComma = parts[1]?.length ?? 0;
|
|
30
|
+
const beforeComma = parts[0]?.length ?? 0;
|
|
31
|
+
if (parts.length === 2 && afterComma === 3 && beforeComma > 0) {
|
|
30
32
|
normalized = normalized.replace(",", "");
|
|
31
33
|
} else {
|
|
32
34
|
normalized = normalized.replace(",", ".");
|
|
@@ -35,7 +37,7 @@ export function parseCurrencyInput(input: string): number | null {
|
|
|
35
37
|
|
|
36
38
|
const parsed = parseFloat(normalized);
|
|
37
39
|
|
|
38
|
-
if (isNaN(parsed) || !isFinite(parsed)) {
|
|
40
|
+
if (Number.isNaN(parsed) || !Number.isFinite(parsed)) {
|
|
39
41
|
return null;
|
|
40
42
|
}
|
|
41
43
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export type ProviderErrorCategory =
|
|
2
|
+
| "auth"
|
|
3
|
+
| "rate_limit"
|
|
4
|
+
| "timeout"
|
|
5
|
+
| "network"
|
|
6
|
+
| "insufficient_credits"
|
|
7
|
+
| "unknown";
|
|
8
|
+
|
|
9
|
+
export function classifyProviderError(error: string): ProviderErrorCategory {
|
|
10
|
+
const lower = error.toLowerCase();
|
|
11
|
+
|
|
12
|
+
// Auth errors: expired tokens, invalid credentials, scope issues
|
|
13
|
+
if (
|
|
14
|
+
lower.includes("expired") ||
|
|
15
|
+
lower.includes("re-authenticate") ||
|
|
16
|
+
lower.includes("invalid") ||
|
|
17
|
+
lower.includes("unauthorized") ||
|
|
18
|
+
lower.includes("scope") ||
|
|
19
|
+
lower.includes("authentication failed") ||
|
|
20
|
+
lower.includes("authorization failed") ||
|
|
21
|
+
lower.includes("check api key") ||
|
|
22
|
+
lower.includes("api key required") ||
|
|
23
|
+
lower.includes("token required") ||
|
|
24
|
+
lower.includes("not configured") ||
|
|
25
|
+
lower.includes("sign in") ||
|
|
26
|
+
lower.includes("refresh failed") ||
|
|
27
|
+
lower.includes("not enabled") ||
|
|
28
|
+
lower.includes("configure") ||
|
|
29
|
+
/\b401\b/.test(lower) ||
|
|
30
|
+
/\b403\b/.test(lower)
|
|
31
|
+
) {
|
|
32
|
+
return "auth";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Rate limits
|
|
36
|
+
if (
|
|
37
|
+
lower.includes("rate limit") ||
|
|
38
|
+
lower.includes("too many requests") ||
|
|
39
|
+
/\b429\b/.test(lower)
|
|
40
|
+
) {
|
|
41
|
+
return "rate_limit";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Insufficient credits
|
|
45
|
+
if (lower.includes("insufficient") || lower.includes("credits") || /\b402\b/.test(lower)) {
|
|
46
|
+
return "insufficient_credits";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Timeouts
|
|
50
|
+
if (
|
|
51
|
+
lower.includes("timeout") ||
|
|
52
|
+
lower.includes("timed out") ||
|
|
53
|
+
lower.includes("etimedout") ||
|
|
54
|
+
lower.includes("aborted")
|
|
55
|
+
) {
|
|
56
|
+
return "timeout";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Network errors
|
|
60
|
+
if (
|
|
61
|
+
lower.includes("econnrefused") ||
|
|
62
|
+
lower.includes("enotfound") ||
|
|
63
|
+
lower.includes("network") ||
|
|
64
|
+
lower.includes("fetch failed") ||
|
|
65
|
+
lower.includes("dns") ||
|
|
66
|
+
lower.includes("econnreset")
|
|
67
|
+
) {
|
|
68
|
+
return "network";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return "unknown";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface ErrorCategoryDisplay {
|
|
75
|
+
label: string;
|
|
76
|
+
icon: string;
|
|
77
|
+
isWarning: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const CATEGORY_DISPLAY: Record<ProviderErrorCategory, ErrorCategoryDisplay> = {
|
|
81
|
+
auth: { label: "AUTH", icon: "\u26B7", isWarning: true },
|
|
82
|
+
rate_limit: { label: "RATE", icon: "\u29D7", isWarning: true },
|
|
83
|
+
timeout: { label: "TIME", icon: "\u29D7", isWarning: true },
|
|
84
|
+
insufficient_credits: { label: "CRED", icon: "\u2717", isWarning: true },
|
|
85
|
+
network: { label: "NET", icon: "\u2717", isWarning: false },
|
|
86
|
+
unknown: { label: "ERR", icon: "\u2717", isWarning: false },
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export function getErrorCategoryDisplay(category: ProviderErrorCategory): ErrorCategoryDisplay {
|
|
90
|
+
return CATEGORY_DISPLAY[category];
|
|
91
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.6.1"; // x-release-please-version
|