@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
|
@@ -4,16 +4,21 @@ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
|
4
4
|
import type React from "react";
|
|
5
5
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
6
6
|
import type { AgentSessionAggregate } from "../../agents/types.ts";
|
|
7
|
+
import { SortOverlay } from "../components/SortOverlay.tsx";
|
|
7
8
|
import { useAgentSessions } from "../contexts/AgentSessionContext.tsx";
|
|
8
9
|
import { useInputFocus } from "../contexts/InputContext.tsx";
|
|
9
10
|
import { useColors } from "../contexts/ThemeContext.tsx";
|
|
10
11
|
import { useTimeWindow } from "../contexts/TimeWindowContext.tsx";
|
|
12
|
+
import type { ProjectSortField } from "../types/projectSort.ts";
|
|
13
|
+
import { getProjectSortFieldLabel, PROJECT_SORT_FIELDS } from "../types/projectSort.ts";
|
|
14
|
+
import type { SortDirection } from "../types/sort.ts";
|
|
15
|
+
import { getSortDirectionIndicator } from "../types/sort.ts";
|
|
11
16
|
|
|
12
17
|
// ---------------------------------------------------------------------------
|
|
13
18
|
// Types
|
|
14
19
|
// ---------------------------------------------------------------------------
|
|
15
20
|
|
|
16
|
-
|
|
21
|
+
// ProjectSortField imported from ../types/projectSort.ts
|
|
17
22
|
|
|
18
23
|
interface ModelStats {
|
|
19
24
|
modelId: string;
|
|
@@ -264,50 +269,48 @@ function aggregateProjects(
|
|
|
264
269
|
}
|
|
265
270
|
|
|
266
271
|
// ---------------------------------------------------------------------------
|
|
267
|
-
//
|
|
272
|
+
// Sorting
|
|
268
273
|
// ---------------------------------------------------------------------------
|
|
269
274
|
|
|
270
|
-
const SORT_FIELDS: SortField[] = ["cost", "tokens", "sessions", "cache", "cpr", "activity"];
|
|
271
|
-
|
|
272
|
-
const SORT_LABELS: Record<SortField, string> = {
|
|
273
|
-
cost: "COST",
|
|
274
|
-
tokens: "TOKENS",
|
|
275
|
-
sessions: "SESSIONS",
|
|
276
|
-
cache: "CACHE",
|
|
277
|
-
cpr: "$/REQ",
|
|
278
|
-
activity: "ACTIVITY",
|
|
279
|
-
};
|
|
280
|
-
|
|
281
275
|
function sortProjects(
|
|
282
276
|
projects: ProjectStats[],
|
|
283
|
-
field:
|
|
284
|
-
|
|
277
|
+
field: ProjectSortField,
|
|
278
|
+
direction: SortDirection,
|
|
285
279
|
): ProjectStats[] {
|
|
286
280
|
return [...projects].sort((a, b) => {
|
|
281
|
+
let cmp = 0;
|
|
287
282
|
switch (field) {
|
|
288
283
|
case "cost":
|
|
289
|
-
|
|
284
|
+
cmp = a.cost - b.cost;
|
|
285
|
+
break;
|
|
290
286
|
case "tokens":
|
|
291
|
-
|
|
287
|
+
cmp = a.tokens - b.tokens;
|
|
288
|
+
break;
|
|
292
289
|
case "sessions":
|
|
293
|
-
|
|
290
|
+
cmp = a.sessionCount - b.sessionCount;
|
|
291
|
+
break;
|
|
294
292
|
case "cache": {
|
|
295
293
|
const cacheA =
|
|
296
294
|
a.inputTokens + a.cacheRead > 0 ? a.cacheRead / (a.inputTokens + a.cacheRead) : 0;
|
|
297
295
|
const cacheB =
|
|
298
296
|
b.inputTokens + b.cacheRead > 0 ? b.cacheRead / (b.inputTokens + b.cacheRead) : 0;
|
|
299
|
-
|
|
297
|
+
cmp = cacheA - cacheB;
|
|
298
|
+
break;
|
|
300
299
|
}
|
|
301
300
|
case "cpr": {
|
|
302
301
|
const cprA = a.requestCount > 0 ? a.cost / a.requestCount : 0;
|
|
303
302
|
const cprB = b.requestCount > 0 ? b.cost / b.requestCount : 0;
|
|
304
|
-
|
|
303
|
+
cmp = cprA - cprB;
|
|
304
|
+
break;
|
|
305
305
|
}
|
|
306
306
|
case "activity":
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
307
|
+
cmp = a.activeSessions - b.activeSessions;
|
|
308
|
+
break;
|
|
309
|
+
case "project":
|
|
310
|
+
cmp = a.name.localeCompare(b.name);
|
|
311
|
+
break;
|
|
310
312
|
}
|
|
313
|
+
return direction === "desc" ? -cmp : cmp;
|
|
311
314
|
});
|
|
312
315
|
}
|
|
313
316
|
|
|
@@ -360,7 +363,8 @@ interface AggregateHeaderProps {
|
|
|
360
363
|
projectCount: number;
|
|
361
364
|
sessionCount: number;
|
|
362
365
|
windowLabel: string;
|
|
363
|
-
sortField:
|
|
366
|
+
sortField: ProjectSortField;
|
|
367
|
+
sortDirection: SortDirection;
|
|
364
368
|
}
|
|
365
369
|
|
|
366
370
|
function AggregateHeader({
|
|
@@ -370,6 +374,7 @@ function AggregateHeader({
|
|
|
370
374
|
sessionCount,
|
|
371
375
|
windowLabel,
|
|
372
376
|
sortField,
|
|
377
|
+
sortDirection,
|
|
373
378
|
}: AggregateHeaderProps) {
|
|
374
379
|
const colors = useColors();
|
|
375
380
|
const avgPerProject = projectCount > 0 ? totalCost / projectCount : 0;
|
|
@@ -393,7 +398,10 @@ function AggregateHeader({
|
|
|
393
398
|
<span fg={colors.text}>{formatCost(avgPerProject)}</span>
|
|
394
399
|
</text>
|
|
395
400
|
<text height={1} fg={colors.textMuted}>
|
|
396
|
-
[{windowLabel}] Sort:
|
|
401
|
+
[{windowLabel}] Sort:{" "}
|
|
402
|
+
<span fg={colors.primary}>
|
|
403
|
+
{getProjectSortFieldLabel(sortField)} {getSortDirectionIndicator(sortDirection)}
|
|
404
|
+
</span>
|
|
397
405
|
</text>
|
|
398
406
|
</box>
|
|
399
407
|
);
|
|
@@ -1195,7 +1203,9 @@ export function ProjectsView() {
|
|
|
1195
1203
|
const { setInputFocused } = useInputFocus();
|
|
1196
1204
|
|
|
1197
1205
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
1198
|
-
const [sortField, setSortField] = useState<
|
|
1206
|
+
const [sortField, setSortField] = useState<ProjectSortField>("cost");
|
|
1207
|
+
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
|
|
1208
|
+
const [showSortOverlay, setShowSortOverlay] = useState(false);
|
|
1199
1209
|
const [showInsights, setShowInsights] = useState(termHeight >= 35);
|
|
1200
1210
|
const [drawerProject, setDrawerProject] = useState<ProjectStats | null>(null);
|
|
1201
1211
|
const [filterQuery, setFilterQuery] = useState("");
|
|
@@ -1246,13 +1256,44 @@ export function ProjectsView() {
|
|
|
1246
1256
|
|
|
1247
1257
|
// Sort
|
|
1248
1258
|
const sortedProjects = useMemo(
|
|
1249
|
-
() => sortProjects(filteredProjects, sortField,
|
|
1250
|
-
[filteredProjects, sortField,
|
|
1259
|
+
() => sortProjects(filteredProjects, sortField, sortDirection),
|
|
1260
|
+
[filteredProjects, sortField, sortDirection],
|
|
1251
1261
|
);
|
|
1252
1262
|
|
|
1253
1263
|
// Visible columns
|
|
1254
1264
|
const visibleColumns: ColumnSpec[] = useMemo(() => getVisibleColumns(termWidth), [termWidth]);
|
|
1255
1265
|
|
|
1266
|
+
// Map column keys → sort field IDs so we can hide sort fields for hidden columns.
|
|
1267
|
+
// "tokens" has no dedicated column but is always available (related to cost data).
|
|
1268
|
+
const COLUMN_TO_SORT_FIELD: Record<string, ProjectSortField> = {
|
|
1269
|
+
project: "project",
|
|
1270
|
+
cost: "cost",
|
|
1271
|
+
sess: "sessions",
|
|
1272
|
+
cache: "cache",
|
|
1273
|
+
cpr: "cpr",
|
|
1274
|
+
activity: "activity",
|
|
1275
|
+
};
|
|
1276
|
+
|
|
1277
|
+
// Sort indicator helpers for column headers (matches SessionsTable pattern)
|
|
1278
|
+
const sortIndicatorForCol = (colKey: string) => {
|
|
1279
|
+
const field = COLUMN_TO_SORT_FIELD[colKey];
|
|
1280
|
+
return field && sortField === field ? (sortDirection === "desc" ? "▼" : "▲") : " ";
|
|
1281
|
+
};
|
|
1282
|
+
const sortColorForCol = (colKey: string) => {
|
|
1283
|
+
const field = COLUMN_TO_SORT_FIELD[colKey];
|
|
1284
|
+
return field && sortField === field ? colors.primary : colors.textMuted;
|
|
1285
|
+
};
|
|
1286
|
+
const visibleSortFields = useMemo(() => {
|
|
1287
|
+
const visibleFieldIds = new Set<ProjectSortField>(
|
|
1288
|
+
visibleColumns
|
|
1289
|
+
.map((c) => COLUMN_TO_SORT_FIELD[c.key])
|
|
1290
|
+
.filter((id): id is ProjectSortField => id !== undefined),
|
|
1291
|
+
);
|
|
1292
|
+
// Always include tokens — no dedicated column but universally useful
|
|
1293
|
+
visibleFieldIds.add("tokens");
|
|
1294
|
+
return PROJECT_SORT_FIELDS.filter((f) => visibleFieldIds.has(f.id));
|
|
1295
|
+
}, [visibleColumns]);
|
|
1296
|
+
|
|
1256
1297
|
// Stabilize selection across re-sorts: track selected project by path
|
|
1257
1298
|
// so the highlight follows the same project when live data causes reordering.
|
|
1258
1299
|
useLayoutEffect(() => {
|
|
@@ -1336,6 +1377,9 @@ export function ProjectsView() {
|
|
|
1336
1377
|
useKeyboard(
|
|
1337
1378
|
useCallback(
|
|
1338
1379
|
(key: { name: string; shift?: boolean; ctrl?: boolean }) => {
|
|
1380
|
+
// Sort overlay is open — let SortOverlay handle keys
|
|
1381
|
+
if (showSortOverlay) return;
|
|
1382
|
+
|
|
1339
1383
|
// Drawer open: only Esc closes
|
|
1340
1384
|
if (drawerProject) {
|
|
1341
1385
|
if (key.name === "escape") {
|
|
@@ -1355,19 +1399,18 @@ export function ProjectsView() {
|
|
|
1355
1399
|
return;
|
|
1356
1400
|
}
|
|
1357
1401
|
|
|
1402
|
+
// Open sort overlay menu
|
|
1403
|
+
if (key.name === "s") {
|
|
1404
|
+
setShowSortOverlay(true);
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1358
1408
|
// Navigation
|
|
1359
1409
|
if (key.name === "down" || key.name === "j") {
|
|
1360
1410
|
setSelectedIndex((prev: number) => Math.min(prev + 1, sortedProjects.length - 1));
|
|
1361
1411
|
} else if (key.name === "up" || key.name === "k") {
|
|
1362
1412
|
setSelectedIndex((prev: number) => Math.max(prev - 1, 0));
|
|
1363
1413
|
}
|
|
1364
|
-
// Sort cycling
|
|
1365
|
-
else if (key.name === "s") {
|
|
1366
|
-
setSortField((prev: SortField) => {
|
|
1367
|
-
const idx = SORT_FIELDS.indexOf(prev);
|
|
1368
|
-
return SORT_FIELDS[(idx + 1) % SORT_FIELDS.length]!;
|
|
1369
|
-
});
|
|
1370
|
-
}
|
|
1371
1414
|
// Time window
|
|
1372
1415
|
else if (key.name === "t") {
|
|
1373
1416
|
cycleWindow();
|
|
@@ -1401,7 +1444,15 @@ export function ProjectsView() {
|
|
|
1401
1444
|
scrollOffsetRef.current = 0;
|
|
1402
1445
|
}
|
|
1403
1446
|
},
|
|
1404
|
-
[
|
|
1447
|
+
[
|
|
1448
|
+
drawerProject,
|
|
1449
|
+
isFiltering,
|
|
1450
|
+
sortedProjects,
|
|
1451
|
+
selectedIndex,
|
|
1452
|
+
cycleWindow,
|
|
1453
|
+
filterQuery,
|
|
1454
|
+
showSortOverlay,
|
|
1455
|
+
],
|
|
1405
1456
|
),
|
|
1406
1457
|
);
|
|
1407
1458
|
|
|
@@ -1441,6 +1492,20 @@ export function ProjectsView() {
|
|
|
1441
1492
|
/>
|
|
1442
1493
|
)}
|
|
1443
1494
|
|
|
1495
|
+
{/* Sort overlay */}
|
|
1496
|
+
{showSortOverlay && (
|
|
1497
|
+
<SortOverlay
|
|
1498
|
+
fields={visibleSortFields}
|
|
1499
|
+
currentField={sortField}
|
|
1500
|
+
currentDirection={sortDirection}
|
|
1501
|
+
onSelect={(field, direction) => {
|
|
1502
|
+
setSortField(() => field);
|
|
1503
|
+
setSortDirection(() => direction);
|
|
1504
|
+
setShowSortOverlay(false);
|
|
1505
|
+
}}
|
|
1506
|
+
onClose={() => setShowSortOverlay(false)}
|
|
1507
|
+
/>
|
|
1508
|
+
)}
|
|
1444
1509
|
{/* Aggregate header */}
|
|
1445
1510
|
<AggregateHeader
|
|
1446
1511
|
totalCost={visibleCost}
|
|
@@ -1449,6 +1514,7 @@ export function ProjectsView() {
|
|
|
1449
1514
|
sessionCount={visibleSessions}
|
|
1450
1515
|
windowLabel={windowLabel}
|
|
1451
1516
|
sortField={sortField}
|
|
1517
|
+
sortDirection={sortDirection}
|
|
1452
1518
|
/>
|
|
1453
1519
|
|
|
1454
1520
|
{/* Column headers */}
|
|
@@ -1458,8 +1524,8 @@ export function ProjectsView() {
|
|
|
1458
1524
|
</text>
|
|
1459
1525
|
{visibleColumns.map((col: ColumnSpec) => (
|
|
1460
1526
|
<box key={col.key} marginRight={1}>
|
|
1461
|
-
<text width={col.width} height={1} fg={
|
|
1462
|
-
{padRight(col.label
|
|
1527
|
+
<text width={col.width} height={1} fg={sortColorForCol(col.key)}>
|
|
1528
|
+
{padRight(`${col.label}${sortIndicatorForCol(col.key)}`, col.width)}
|
|
1463
1529
|
</text>
|
|
1464
1530
|
</box>
|
|
1465
1531
|
))}
|
|
@@ -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"
|
|
@@ -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
|
|