@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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global animation tick manager.
|
|
3
|
+
*
|
|
4
|
+
* Replaces per-component setInterval timers with a single shared 30 Hz tick.
|
|
5
|
+
* This eliminates the ~90 concurrent timers that were causing ~2,400 state
|
|
6
|
+
* updates/sec and ~60% CPU usage at idle.
|
|
7
|
+
*
|
|
8
|
+
* The tick automatically starts when the first subscriber joins and stops
|
|
9
|
+
* when the last subscriber leaves, consuming zero CPU when no animations
|
|
10
|
+
* are active.
|
|
11
|
+
*
|
|
12
|
+
* @see https://github.com/tokentopapp/tokentop/issues/55
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useRef } from "react";
|
|
15
|
+
|
|
16
|
+
type TickCallback = (now: number) => void;
|
|
17
|
+
|
|
18
|
+
const TICK_RATE_HZ = 30;
|
|
19
|
+
const TICK_INTERVAL_MS = Math.round(1000 / TICK_RATE_HZ);
|
|
20
|
+
|
|
21
|
+
class AnimationTickManager {
|
|
22
|
+
private callbacks = new Set<TickCallback>();
|
|
23
|
+
private interval: ReturnType<typeof setInterval> | null = null;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Subscribe a callback to the global tick.
|
|
27
|
+
* Returns an unsubscribe function. When the last subscriber leaves,
|
|
28
|
+
* the underlying setInterval is cleared automatically.
|
|
29
|
+
*/
|
|
30
|
+
subscribe(callback: TickCallback): () => void {
|
|
31
|
+
this.callbacks.add(callback);
|
|
32
|
+
if (this.callbacks.size === 1) {
|
|
33
|
+
this.start();
|
|
34
|
+
}
|
|
35
|
+
return () => {
|
|
36
|
+
this.callbacks.delete(callback);
|
|
37
|
+
if (this.callbacks.size === 0) {
|
|
38
|
+
this.stop();
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Number of active subscribers (useful for debugging). */
|
|
44
|
+
get subscriberCount(): number {
|
|
45
|
+
return this.callbacks.size;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Whether the global interval is currently running. */
|
|
49
|
+
get isRunning(): boolean {
|
|
50
|
+
return this.interval !== null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private start(): void {
|
|
54
|
+
if (this.interval !== null) return;
|
|
55
|
+
this.interval = setInterval(() => {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
for (const cb of this.callbacks) {
|
|
58
|
+
cb(now);
|
|
59
|
+
}
|
|
60
|
+
}, TICK_INTERVAL_MS);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private stop(): void {
|
|
64
|
+
if (this.interval !== null) {
|
|
65
|
+
clearInterval(this.interval);
|
|
66
|
+
this.interval = null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Global animation tick singleton — single 30 Hz timer shared by all hooks. */
|
|
72
|
+
export const animationTick = new AnimationTickManager();
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* React hook to subscribe to the global animation tick.
|
|
76
|
+
*
|
|
77
|
+
* The callback fires at ~30 Hz while `active` is true.
|
|
78
|
+
* Uses a ref to always call the latest callback (no stale closures).
|
|
79
|
+
*
|
|
80
|
+
* @param callback - Called with current timestamp (Date.now()) on each tick
|
|
81
|
+
* @param active - Whether to subscribe (default: true). When false, the
|
|
82
|
+
* subscription is paused and the global timer may stop if no other
|
|
83
|
+
* subscribers remain.
|
|
84
|
+
*/
|
|
85
|
+
export function useAnimationTick(callback: TickCallback, active = true): void {
|
|
86
|
+
const callbackRef = useRef(callback);
|
|
87
|
+
callbackRef.current = callback;
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!active) return;
|
|
91
|
+
return animationTick.subscribe((now) => callbackRef.current(now));
|
|
92
|
+
}, [active]);
|
|
93
|
+
}
|
|
@@ -7,6 +7,7 @@ import { useAgentSessions } from "../contexts/AgentSessionContext.tsx";
|
|
|
7
7
|
import { useInputFocus } from "../contexts/InputContext.tsx";
|
|
8
8
|
import { useTimeWindow } from "../contexts/TimeWindowContext.tsx";
|
|
9
9
|
import { useToastContext } from "../contexts/ToastContext.tsx";
|
|
10
|
+
import type { SortDirection, SortField } from "../types/sort.ts";
|
|
10
11
|
|
|
11
12
|
function formatSessionSummary(session: AgentSessionAggregate): string {
|
|
12
13
|
const effectiveTokens = session.totals.input + session.totals.output;
|
|
@@ -54,7 +55,9 @@ interface DashboardKeyboardState {
|
|
|
54
55
|
sidebarCollapsed: boolean;
|
|
55
56
|
filterQuery: string;
|
|
56
57
|
isFiltering: boolean;
|
|
57
|
-
sortField:
|
|
58
|
+
sortField: SortField;
|
|
59
|
+
sortDirection: SortDirection;
|
|
60
|
+
showSortOverlay: boolean;
|
|
58
61
|
pendingG: boolean;
|
|
59
62
|
scrollOffset: number;
|
|
60
63
|
limitSelectedIndex: number;
|
|
@@ -76,13 +79,15 @@ interface DashboardKeyboardActions {
|
|
|
76
79
|
setSidebarCollapsed: (fn: (prev: boolean) => boolean) => void;
|
|
77
80
|
setFilterQuery: (fn: (prev: string) => string) => void;
|
|
78
81
|
setIsFiltering: (val: boolean) => void;
|
|
79
|
-
setSortField: (fn: (prev:
|
|
82
|
+
setSortField: (fn: (prev: SortField) => SortField) => void;
|
|
83
|
+
setSortDirection: (fn: (prev: SortDirection) => SortDirection) => void;
|
|
80
84
|
setPendingG: (val: boolean) => void;
|
|
81
85
|
setScrollOffset: (val: number) => void;
|
|
82
86
|
setLimitSelectedIndex: (fn: (prev: number) => number) => void;
|
|
83
87
|
setDriverDimension: (fn: (prev: DriverDimension) => DriverDimension) => void;
|
|
84
88
|
setSelectedDriverIndex: (fn: (prev: number) => number) => void;
|
|
85
89
|
setActiveDriverFilter: (val: string | null) => void;
|
|
90
|
+
setShowSortOverlay: (val: boolean) => void;
|
|
86
91
|
}
|
|
87
92
|
|
|
88
93
|
interface UseDashboardKeyboardProps {
|
|
@@ -115,7 +120,7 @@ export function useDashboardKeyboard({
|
|
|
115
120
|
|
|
116
121
|
useEffect(() => {
|
|
117
122
|
isFilteringRef.current = state.isFiltering;
|
|
118
|
-
modalOpenRef.current = state.showHelp || state.showSessionDrawer;
|
|
123
|
+
modalOpenRef.current = state.showHelp || state.showSessionDrawer || state.showSortOverlay;
|
|
119
124
|
pendingGRef.current = state.pendingG;
|
|
120
125
|
sessionsRef.current = processedSessions;
|
|
121
126
|
focusedPanelRef.current = state.focusedPanel;
|
|
@@ -129,6 +134,7 @@ export function useDashboardKeyboard({
|
|
|
129
134
|
state.isFiltering,
|
|
130
135
|
state.showHelp,
|
|
131
136
|
state.showSessionDrawer,
|
|
137
|
+
state.showSortOverlay,
|
|
132
138
|
state.pendingG,
|
|
133
139
|
processedSessions,
|
|
134
140
|
state.focusedPanel,
|
|
@@ -242,8 +248,9 @@ export function useDashboardKeyboard({
|
|
|
242
248
|
return;
|
|
243
249
|
}
|
|
244
250
|
|
|
251
|
+
// Open sort overlay menu
|
|
245
252
|
if (key.name === "s") {
|
|
246
|
-
actions.
|
|
253
|
+
actions.setShowSortOverlay(true);
|
|
247
254
|
return;
|
|
248
255
|
}
|
|
249
256
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { animationTick } from "./useAnimationTick.ts";
|
|
2
3
|
|
|
3
4
|
export interface UseEntranceAnimationOptions {
|
|
4
5
|
durationMs?: number;
|
|
6
|
+
/** @deprecated Ignored — animations now use the global 30 Hz tick. */
|
|
5
7
|
fps?: number;
|
|
6
8
|
}
|
|
7
9
|
|
|
@@ -11,26 +13,30 @@ export interface UseEntranceAnimationOptions {
|
|
|
11
13
|
* Use this to fade in new items in a list.
|
|
12
14
|
*/
|
|
13
15
|
export function useEntranceAnimation(options: UseEntranceAnimationOptions = {}): number {
|
|
14
|
-
const { durationMs = 400
|
|
16
|
+
const { durationMs = 400 } = options;
|
|
15
17
|
const mountTime = useRef(Date.now());
|
|
16
18
|
const [intensity, setIntensity] = useState(0);
|
|
19
|
+
const unsubRef = useRef<(() => void) | null>(null);
|
|
17
20
|
|
|
18
21
|
useEffect(() => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const tick = () => {
|
|
22
|
-
const elapsed = Date.now() - mountTime.current;
|
|
22
|
+
unsubRef.current = animationTick.subscribe((now) => {
|
|
23
|
+
const elapsed = now - mountTime.current;
|
|
23
24
|
const progress = Math.min(1, elapsed / durationMs);
|
|
24
25
|
const eased = 1 - (1 - progress) ** 2;
|
|
25
26
|
setIntensity(eased);
|
|
26
27
|
|
|
27
|
-
if (progress
|
|
28
|
-
|
|
28
|
+
if (progress >= 1) {
|
|
29
|
+
setIntensity(1);
|
|
30
|
+
unsubRef.current?.();
|
|
31
|
+
unsubRef.current = null;
|
|
29
32
|
}
|
|
30
|
-
};
|
|
33
|
+
});
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
return () => {
|
|
36
|
+
unsubRef.current?.();
|
|
37
|
+
unsubRef.current = null;
|
|
38
|
+
};
|
|
39
|
+
}, [durationMs]);
|
|
34
40
|
|
|
35
41
|
return intensity;
|
|
36
42
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { animationTick } from "./useAnimationTick.ts";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Options for the usePulse hook.
|
|
@@ -39,7 +40,7 @@ function parseHexColor(hex: string): [number, number, number] | null {
|
|
|
39
40
|
const g = parseInt(expandedHex.slice(2, 4), 16);
|
|
40
41
|
const b = parseInt(expandedHex.slice(4, 6), 16);
|
|
41
42
|
|
|
42
|
-
if (isNaN(r) || isNaN(g) || isNaN(b)) {
|
|
43
|
+
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {
|
|
43
44
|
return null;
|
|
44
45
|
}
|
|
45
46
|
|
|
@@ -104,6 +105,9 @@ export function getPulseColor(
|
|
|
104
105
|
* Returns the current step in the pulse cycle, which can be used
|
|
105
106
|
* with getPulseColor to create smooth color transitions.
|
|
106
107
|
*
|
|
108
|
+
* Subscribes to the global animation tick when enabled and unsubscribes
|
|
109
|
+
* when disabled, so the global timer stops when no pulses are active.
|
|
110
|
+
*
|
|
107
111
|
* @param options - Configuration options for the pulse animation
|
|
108
112
|
* @returns Current step in the pulse cycle (0 to steps-1), or 0 when disabled
|
|
109
113
|
*
|
|
@@ -120,6 +124,7 @@ export function getPulseColor(
|
|
|
120
124
|
export function usePulse(options: UsePulseOptions): number {
|
|
121
125
|
const { enabled, intervalMs = 200, steps = 12 } = options;
|
|
122
126
|
const [step, setStep] = useState(0);
|
|
127
|
+
const lastAdvanceRef = useRef(Date.now());
|
|
123
128
|
|
|
124
129
|
useEffect(() => {
|
|
125
130
|
if (!enabled) {
|
|
@@ -127,11 +132,12 @@ export function usePulse(options: UsePulseOptions): number {
|
|
|
127
132
|
return;
|
|
128
133
|
}
|
|
129
134
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
+
return animationTick.subscribe((now) => {
|
|
136
|
+
if (now - lastAdvanceRef.current >= intervalMs) {
|
|
137
|
+
lastAdvanceRef.current = now;
|
|
138
|
+
setStep((prev) => (prev + 1) % steps);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
135
141
|
}, [enabled, intervalMs, steps]);
|
|
136
142
|
|
|
137
143
|
return step;
|
|
@@ -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
|
+
}
|