@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.
- package/README.md +7 -6
- package/package.json +12 -5
- package/src/agents/aggregator.test.ts +306 -0
- package/src/agents/aggregator.ts +44 -2
- package/src/agents/types.ts +3 -0
- package/src/cli.ts +16 -2
- package/src/demo/simulator.ts +2 -2
- package/src/plugins/agents/index.ts +3 -0
- package/src/plugins/auth-sources.ts +2 -3
- package/src/plugins/lifecycle.ts +2 -2
- package/src/plugins/loader.test.ts +1 -1
- package/src/plugins/loader.ts +15 -7
- package/src/plugins/notification-bus.ts +5 -2
- package/src/plugins/notifications/terminal-bell.ts +1 -1
- package/src/plugins/notifications/visual-flash.ts +1 -1
- package/src/plugins/plugin-context-factory.ts +4 -19
- 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 +21 -2
- package/src/plugins/providers/antigravity.ts +17 -1
- package/src/plugins/providers/chutes.ts +205 -0
- package/src/plugins/providers/codex.ts +17 -1
- package/src/plugins/providers/gemini.ts +17 -1
- package/src/plugins/providers/github-copilot.ts +17 -1
- package/src/plugins/providers/index.ts +1 -0
- package/src/plugins/providers/minimax.ts +17 -1
- package/src/plugins/providers/openai-api.ts +53 -1
- package/src/plugins/providers/opencode-zen.ts +1 -1
- package/src/plugins/providers/perplexity.ts +11 -2
- package/src/plugins/providers/zai.ts +17 -1
- package/src/plugins/registry.ts +9 -9
- package/src/plugins/sandbox-guard.test.ts +1 -1
- package/src/plugins/sandbox-guard.ts +1 -1
- package/src/plugins/sandbox.ts +1 -1
- package/src/plugins/themes/catppuccin-latte.ts +1 -1
- package/src/plugins/themes/catppuccin-mocha.ts +1 -1
- package/src/plugins/themes/claude-code.ts +1 -1
- package/src/plugins/themes/dracula.ts +1 -1
- package/src/plugins/themes/github-light.ts +1 -1
- package/src/plugins/themes/gruvbox-dark.ts +1 -1
- package/src/plugins/themes/gruvbox-light.ts +1 -1
- package/src/plugins/themes/kanagawa.ts +1 -1
- package/src/plugins/themes/nord.ts +1 -1
- package/src/plugins/themes/one-dark.ts +1 -1
- package/src/plugins/themes/opencode.ts +1 -1
- package/src/plugins/themes/rose-pine-dawn.ts +1 -1
- package/src/plugins/themes/rose-pine.ts +1 -1
- package/src/plugins/themes/solarized-light.ts +1 -1
- package/src/plugins/themes/tokyo-night.ts +1 -1
- package/src/plugins/update-checker.ts +2 -2
- package/src/pricing/estimator.test.ts +1 -1
- package/src/pricing/estimator.ts +1 -1
- package/src/pricing/fallback.ts +5 -1
- package/src/pricing/index.ts +1 -1
- package/src/pricing/models-dev.ts +1 -1
- package/src/storage/index.ts +6 -0
- package/src/storage/migrations/index.ts +17 -0
- package/src/storage/repos/pluginStorage.ts +35 -0
- package/src/tui/App.tsx +17 -9
- package/src/tui/components/HelpOverlay.tsx +1 -1
- package/src/tui/components/LimitGauge.tsx +12 -4
- package/src/tui/components/ProviderCard.tsx +39 -23
- package/src/tui/components/ProviderDetailPanel.tsx +1 -1
- package/src/tui/components/ProviderLimitsPanel.tsx +5 -0
- package/src/tui/components/SessionDetailsDrawer.tsx +10 -0
- package/src/tui/components/SessionsTable.tsx +62 -37
- package/src/tui/components/SettingsModal.tsx +1 -1
- 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/ThemePicker.tsx +1 -1
- package/src/tui/contexts/AgentSessionContext.tsx +70 -17
- package/src/tui/contexts/PluginContext.tsx +72 -14
- package/src/tui/contexts/RealTimeActivityContext.tsx +33 -20
- package/src/tui/contexts/ThemeContext.tsx +7 -4
- 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/useSessionEnrichedProviders.ts +1 -1
- 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 +1 -1
- 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
- package/src/plugins/types/agent.ts +0 -108
- package/src/plugins/types/base.ts +0 -278
- package/src/plugins/types/index.ts +0 -19
- package/src/plugins/types/notification.ts +0 -51
- package/src/plugins/types/provider.ts +0 -237
- package/src/plugins/types/theme.ts +0 -78
- package/src/storage/database.ts +0 -623
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { animationTick } from "./useAnimationTick.ts";
|
|
2
3
|
|
|
3
4
|
export interface UseValueFlashOptions {
|
|
4
5
|
durationMs?: number;
|
|
@@ -20,12 +21,9 @@ export function useValueFlash(
|
|
|
20
21
|
|
|
21
22
|
const prevValueRef = useRef<number>(value);
|
|
22
23
|
const [isFlashing, setIsFlashing] = useState(false);
|
|
23
|
-
const [
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
const steps = 18;
|
|
28
|
-
const stepDuration = durationMs / steps;
|
|
24
|
+
const [intensity, setIntensity] = useState(0);
|
|
25
|
+
const flashStartRef = useRef(0);
|
|
26
|
+
const unsubRef = useRef<(() => void) | null>(null);
|
|
29
27
|
|
|
30
28
|
useEffect(() => {
|
|
31
29
|
const prevValue = prevValueRef.current;
|
|
@@ -34,45 +32,39 @@ export function useValueFlash(
|
|
|
34
32
|
const shouldFlash = increaseOnly ? delta > threshold : Math.abs(delta) > threshold;
|
|
35
33
|
|
|
36
34
|
if (shouldFlash && prevValue !== value) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
if (animationIntervalRef.current) {
|
|
41
|
-
clearInterval(animationIntervalRef.current);
|
|
42
|
-
}
|
|
35
|
+
// Clean up any existing subscription
|
|
36
|
+
unsubRef.current?.();
|
|
43
37
|
|
|
38
|
+
flashStartRef.current = Date.now();
|
|
44
39
|
setIsFlashing(true);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
clearInterval(animationIntervalRef.current);
|
|
53
|
-
animationIntervalRef.current = null;
|
|
54
|
-
}
|
|
40
|
+
|
|
41
|
+
// Subscribe to global tick
|
|
42
|
+
unsubRef.current = animationTick.subscribe((now) => {
|
|
43
|
+
const elapsed = now - flashStartRef.current;
|
|
44
|
+
const progress = elapsed / durationMs;
|
|
45
|
+
|
|
46
|
+
if (progress >= 1) {
|
|
55
47
|
setIsFlashing(false);
|
|
56
|
-
|
|
48
|
+
setIntensity(0);
|
|
49
|
+
unsubRef.current?.();
|
|
50
|
+
unsubRef.current = null;
|
|
57
51
|
} else {
|
|
58
|
-
|
|
52
|
+
const currentIntensity = Math.sin(progress * Math.PI);
|
|
53
|
+
setIntensity(currentIntensity);
|
|
59
54
|
}
|
|
60
|
-
}
|
|
55
|
+
});
|
|
61
56
|
}
|
|
62
57
|
|
|
63
58
|
prevValueRef.current = value;
|
|
64
59
|
|
|
65
60
|
return () => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
if (animationIntervalRef.current) {
|
|
70
|
-
clearInterval(animationIntervalRef.current);
|
|
71
|
-
}
|
|
61
|
+
unsubRef.current?.();
|
|
62
|
+
unsubRef.current = null;
|
|
72
63
|
};
|
|
73
|
-
}, [value, durationMs, increaseOnly, threshold
|
|
64
|
+
}, [value, durationMs, increaseOnly, threshold]);
|
|
74
65
|
|
|
75
|
-
|
|
66
|
+
// step is kept for backward API compatibility
|
|
67
|
+
const step = isFlashing ? Math.round(intensity * 9) : 0;
|
|
76
68
|
|
|
77
69
|
return { isFlashing, intensity, step };
|
|
78
70
|
}
|
package/src/tui/index.tsx
CHANGED
|
@@ -13,6 +13,7 @@ export interface TuiOptions {
|
|
|
13
13
|
demoSeed?: number;
|
|
14
14
|
demoPreset?: DemoPreset;
|
|
15
15
|
cliPlugins?: string[];
|
|
16
|
+
cliTheme?: string;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
const FORCE_EXIT_GRACE_MS = 1_000;
|
|
@@ -45,6 +46,9 @@ export async function startTui(options: TuiOptions = {}) {
|
|
|
45
46
|
if (options.cliPlugins !== undefined) {
|
|
46
47
|
appOptions.cliPlugins = options.cliPlugins;
|
|
47
48
|
}
|
|
49
|
+
if (options.cliTheme !== undefined) {
|
|
50
|
+
appOptions.cliTheme = options.cliTheme;
|
|
51
|
+
}
|
|
48
52
|
|
|
49
53
|
root.render(createAppElement(appOptions));
|
|
50
54
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { SortFieldDef } from "./sort.ts";
|
|
2
|
+
import { buildShortcutMap } from "./sort.ts";
|
|
3
|
+
|
|
4
|
+
export type ProjectSortField =
|
|
5
|
+
| "project"
|
|
6
|
+
| "cost"
|
|
7
|
+
| "tokens"
|
|
8
|
+
| "sessions"
|
|
9
|
+
| "cache"
|
|
10
|
+
| "cpr"
|
|
11
|
+
| "activity";
|
|
12
|
+
|
|
13
|
+
export const PROJECT_SORT_FIELDS: readonly SortFieldDef<ProjectSortField>[] = [
|
|
14
|
+
{ id: "project", label: "Project", shortcutKey: "P", defaultDirection: "asc" },
|
|
15
|
+
{ id: "cost", label: "Cost", shortcutKey: "C", defaultDirection: "desc" },
|
|
16
|
+
{ id: "tokens", label: "Tokens", shortcutKey: "K", defaultDirection: "desc" },
|
|
17
|
+
{ id: "sessions", label: "Sessions", shortcutKey: "S", defaultDirection: "desc" },
|
|
18
|
+
{ id: "cache", label: "Cache", shortcutKey: "H", defaultDirection: "desc" },
|
|
19
|
+
{ id: "cpr", label: "$/Req", shortcutKey: "R", defaultDirection: "desc" },
|
|
20
|
+
{ id: "activity", label: "Activity", shortcutKey: "A", defaultDirection: "desc" },
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
/** Map from lowercase shortcut key → ProjectSortField */
|
|
24
|
+
export const PROJECT_SHORTCUT_TO_FIELD: ReadonlyMap<string, ProjectSortField> =
|
|
25
|
+
buildShortcutMap(PROJECT_SORT_FIELDS);
|
|
26
|
+
|
|
27
|
+
export function getProjectSortFieldDef(field: ProjectSortField): SortFieldDef<ProjectSortField> {
|
|
28
|
+
const def = PROJECT_SORT_FIELDS.find((f) => f.id === field);
|
|
29
|
+
if (!def) throw new Error(`Unknown project sort field: ${field}`);
|
|
30
|
+
return def;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getProjectSortFieldLabel(field: ProjectSortField): string {
|
|
34
|
+
return getProjectSortFieldDef(field).label;
|
|
35
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type SortDirection = "asc" | "desc";
|
|
2
|
+
|
|
3
|
+
export interface SortFieldDef<F extends string = string> {
|
|
4
|
+
readonly id: F;
|
|
5
|
+
readonly label: string;
|
|
6
|
+
/** Uppercase letter shown in overlay + used as Shift+key shortcut */
|
|
7
|
+
readonly shortcutKey: string;
|
|
8
|
+
/** Default direction when switching to this field */
|
|
9
|
+
readonly defaultDirection: SortDirection;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Build a lowercase-key → field-id map from any fields array */
|
|
13
|
+
export function buildShortcutMap<F extends string>(
|
|
14
|
+
fields: readonly SortFieldDef<F>[],
|
|
15
|
+
): ReadonlyMap<string, F> {
|
|
16
|
+
return new Map(fields.map((f) => [f.shortcutKey.toLowerCase(), f.id]));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Dashboard sort fields
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
export type SortField =
|
|
24
|
+
| "cost"
|
|
25
|
+
| "tokens"
|
|
26
|
+
| "time"
|
|
27
|
+
| "requests"
|
|
28
|
+
| "duration"
|
|
29
|
+
| "project"
|
|
30
|
+
| "agent"
|
|
31
|
+
| "model"
|
|
32
|
+
| "name";
|
|
33
|
+
|
|
34
|
+
export const SORT_FIELDS: readonly SortFieldDef<SortField>[] = [
|
|
35
|
+
{ id: "agent", label: "Agent", shortcutKey: "A", defaultDirection: "desc" },
|
|
36
|
+
{ id: "model", label: "Model", shortcutKey: "M", defaultDirection: "desc" },
|
|
37
|
+
{ id: "requests", label: "Requests", shortcutKey: "E", defaultDirection: "desc" },
|
|
38
|
+
{ id: "tokens", label: "Tokens", shortcutKey: "K", defaultDirection: "desc" },
|
|
39
|
+
{ id: "cost", label: "Cost", shortcutKey: "C", defaultDirection: "desc" },
|
|
40
|
+
{ id: "project", label: "Project", shortcutKey: "O", defaultDirection: "desc" },
|
|
41
|
+
{ id: "name", label: "Name", shortcutKey: "N", defaultDirection: "desc" },
|
|
42
|
+
{ id: "duration", label: "Duration", shortcutKey: "U", defaultDirection: "desc" },
|
|
43
|
+
{ id: "time", label: "Time", shortcutKey: "T", defaultDirection: "desc" },
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
/** Map from lowercase shortcut key → SortField (dashboard) */
|
|
47
|
+
export const SHORTCUT_TO_FIELD: ReadonlyMap<string, SortField> = buildShortcutMap(SORT_FIELDS);
|
|
48
|
+
|
|
49
|
+
export function getSortFieldDef(field: SortField): SortFieldDef<SortField> {
|
|
50
|
+
const def = SORT_FIELDS.find((f) => f.id === field);
|
|
51
|
+
if (!def) throw new Error(`Unknown sort field: ${field}`);
|
|
52
|
+
return def;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getSortFieldLabel(field: SortField): string {
|
|
56
|
+
return getSortFieldDef(field).label;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getSortDirectionIndicator(dir: SortDirection): string {
|
|
60
|
+
return dir === "desc" ? "▼" : "▲";
|
|
61
|
+
}
|
|
@@ -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"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
2
|
+
import type { ConfigField } from "@tokentop/plugin-sdk";
|
|
2
3
|
import { useCallback, useMemo, useState } from "react";
|
|
3
4
|
import type { AppConfig } from "@/config/schema.ts";
|
|
4
|
-
import type { ConfigField } from "@/plugins/types/base.ts";
|
|
5
5
|
import { useConfig } from "../contexts/ConfigContext.tsx";
|
|
6
6
|
import { useDemoMode } from "../contexts/DemoModeContext.tsx";
|
|
7
7
|
import { usePlugins } from "../contexts/PluginContext.tsx";
|