@tokscale/cli 1.0.5 → 1.0.7
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/dist/cli.js +14 -3
- package/dist/cli.js.map +1 -1
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +3 -2
- package/dist/native.js.map +1 -1
- package/package.json +6 -4
- package/src/auth.ts +211 -0
- package/src/cli.ts +1040 -0
- package/src/credentials.ts +123 -0
- package/src/cursor.ts +558 -0
- package/src/graph-types.ts +188 -0
- package/src/graph.ts +485 -0
- package/src/native-runner.ts +105 -0
- package/src/native.ts +938 -0
- package/src/pricing.ts +309 -0
- package/src/sessions/claudecode.ts +119 -0
- package/src/sessions/codex.ts +227 -0
- package/src/sessions/gemini.ts +108 -0
- package/src/sessions/index.ts +126 -0
- package/src/sessions/opencode.ts +94 -0
- package/src/sessions/reports.ts +475 -0
- package/src/sessions/types.ts +59 -0
- package/src/spinner.ts +283 -0
- package/src/submit.ts +175 -0
- package/src/table.ts +233 -0
- package/src/tui/App.tsx +339 -0
- package/src/tui/components/BarChart.tsx +198 -0
- package/src/tui/components/DailyView.tsx +113 -0
- package/src/tui/components/DateBreakdownPanel.tsx +79 -0
- package/src/tui/components/Footer.tsx +225 -0
- package/src/tui/components/Header.tsx +68 -0
- package/src/tui/components/Legend.tsx +39 -0
- package/src/tui/components/LoadingSpinner.tsx +82 -0
- package/src/tui/components/ModelRow.tsx +47 -0
- package/src/tui/components/ModelView.tsx +145 -0
- package/src/tui/components/OverviewView.tsx +108 -0
- package/src/tui/components/StatsView.tsx +225 -0
- package/src/tui/components/TokenBreakdown.tsx +46 -0
- package/src/tui/components/index.ts +15 -0
- package/src/tui/config/settings.ts +130 -0
- package/src/tui/config/themes.ts +115 -0
- package/src/tui/hooks/useData.ts +518 -0
- package/src/tui/index.tsx +44 -0
- package/src/tui/opentui.d.ts +137 -0
- package/src/tui/types/index.ts +165 -0
- package/src/tui/utils/cleanup.ts +65 -0
- package/src/tui/utils/colors.ts +65 -0
- package/src/tui/utils/format.ts +36 -0
- package/src/tui/utils/responsive.ts +8 -0
- package/src/types.d.ts +28 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { For, createMemo } from "solid-js";
|
|
2
|
+
import type { DailyModelBreakdown } from "../types/index.js";
|
|
3
|
+
import { getSourceColor } from "../utils/colors.js";
|
|
4
|
+
import { formatTokens, formatCost } from "../utils/format.js";
|
|
5
|
+
import { ModelRow } from "./ModelRow.js";
|
|
6
|
+
|
|
7
|
+
function formatDateDisplay(dateStr: string): string {
|
|
8
|
+
const date = new Date(dateStr + "T00:00:00");
|
|
9
|
+
return date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DateBreakdownPanelProps {
|
|
13
|
+
breakdown: DailyModelBreakdown;
|
|
14
|
+
isNarrow: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function DateBreakdownPanel(props: DateBreakdownPanelProps) {
|
|
18
|
+
const groupedBySource = createMemo(() => {
|
|
19
|
+
if (!props.breakdown?.models) return new Map();
|
|
20
|
+
const groups = new Map<string, typeof props.breakdown.models>();
|
|
21
|
+
for (const model of props.breakdown.models) {
|
|
22
|
+
const existing = groups.get(model.source) || [];
|
|
23
|
+
existing.push(model);
|
|
24
|
+
groups.set(model.source, existing);
|
|
25
|
+
}
|
|
26
|
+
for (const [, models] of groups) {
|
|
27
|
+
models.sort((a, b) => {
|
|
28
|
+
const totalA = a.tokens.input + a.tokens.output + (a.tokens.cacheRead || 0) + (a.tokens.cacheWrite || 0);
|
|
29
|
+
const totalB = b.tokens.input + b.tokens.output + (b.tokens.cacheRead || 0) + (b.tokens.cacheWrite || 0);
|
|
30
|
+
return totalB - totalA;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return groups;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<box flexDirection="column" marginTop={1} paddingX={1}>
|
|
38
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
39
|
+
<text bold fg="white">{formatDateDisplay(props.breakdown.date)}</text>
|
|
40
|
+
<box flexDirection="row" gap={2}>
|
|
41
|
+
<text fg="cyan">{formatTokens(props.breakdown.totalTokens)}</text>
|
|
42
|
+
<text fg="green" bold>{formatCost(props.breakdown.cost)}</text>
|
|
43
|
+
</box>
|
|
44
|
+
</box>
|
|
45
|
+
|
|
46
|
+
<box flexDirection="column" marginTop={1}>
|
|
47
|
+
<For each={Array.from(groupedBySource().entries())}>
|
|
48
|
+
{([source, models]) => (
|
|
49
|
+
<box flexDirection="column">
|
|
50
|
+
<box flexDirection="row" gap={1}>
|
|
51
|
+
<text fg={getSourceColor(source)} bold>{`● ${source.toUpperCase()}`}</text>
|
|
52
|
+
<text dim>{`(${models.length} model${models.length > 1 ? "s" : ""})`}</text>
|
|
53
|
+
</box>
|
|
54
|
+
<For each={models}>
|
|
55
|
+
{(model) => (
|
|
56
|
+
<ModelRow
|
|
57
|
+
modelId={model.modelId}
|
|
58
|
+
tokens={{
|
|
59
|
+
input: model.tokens.input,
|
|
60
|
+
output: model.tokens.output,
|
|
61
|
+
cacheRead: model.tokens.cacheRead,
|
|
62
|
+
cacheWrite: model.tokens.cacheWrite,
|
|
63
|
+
}}
|
|
64
|
+
compact={props.isNarrow}
|
|
65
|
+
indent={2}
|
|
66
|
+
/>
|
|
67
|
+
)}
|
|
68
|
+
</For>
|
|
69
|
+
</box>
|
|
70
|
+
)}
|
|
71
|
+
</For>
|
|
72
|
+
</box>
|
|
73
|
+
|
|
74
|
+
<box flexDirection="row" marginTop={1}>
|
|
75
|
+
<text dim>Click another day or same day to close</text>
|
|
76
|
+
</box>
|
|
77
|
+
</box>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { Show, createSignal, createMemo, onMount, onCleanup } from "solid-js";
|
|
2
|
+
import type { SourceType, SortType, TabType, LoadingPhase } from "../types/index.js";
|
|
3
|
+
import type { ColorPaletteName } from "../config/themes.js";
|
|
4
|
+
import type { TotalBreakdown } from "../hooks/useData.js";
|
|
5
|
+
import { getPalette } from "../config/themes.js";
|
|
6
|
+
import { formatTokens } from "../utils/format.js";
|
|
7
|
+
import { isVeryNarrow } from "../utils/responsive.js";
|
|
8
|
+
|
|
9
|
+
interface FooterProps {
|
|
10
|
+
enabledSources: Set<SourceType>;
|
|
11
|
+
sortBy: SortType;
|
|
12
|
+
totals?: TotalBreakdown;
|
|
13
|
+
modelCount: number;
|
|
14
|
+
activeTab: TabType;
|
|
15
|
+
scrollStart?: number;
|
|
16
|
+
scrollEnd?: number;
|
|
17
|
+
totalItems?: number;
|
|
18
|
+
colorPalette: ColorPaletteName;
|
|
19
|
+
statusMessage?: string | null;
|
|
20
|
+
isRefreshing?: boolean;
|
|
21
|
+
loadingPhase?: LoadingPhase;
|
|
22
|
+
cacheTimestamp?: number | null;
|
|
23
|
+
width?: number;
|
|
24
|
+
onSourceToggle?: (source: SourceType) => void;
|
|
25
|
+
onSortChange?: (sort: SortType) => void;
|
|
26
|
+
onPaletteChange?: () => void;
|
|
27
|
+
onRefresh?: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatTimeAgo(timestamp: number): string {
|
|
31
|
+
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
32
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
33
|
+
const minutes = Math.floor(seconds / 60);
|
|
34
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
35
|
+
const hours = Math.floor(minutes / 60);
|
|
36
|
+
if (hours < 24) return `${hours}h ago`;
|
|
37
|
+
const days = Math.floor(hours / 24);
|
|
38
|
+
return `${days}d ago`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function Footer(props: FooterProps) {
|
|
42
|
+
const palette = () => getPalette(props.colorPalette);
|
|
43
|
+
const isVeryNarrowTerminal = () => isVeryNarrow(props.width);
|
|
44
|
+
|
|
45
|
+
const showScrollInfo = () =>
|
|
46
|
+
props.activeTab === "overview" &&
|
|
47
|
+
props.totalItems &&
|
|
48
|
+
props.scrollStart !== undefined &&
|
|
49
|
+
props.scrollEnd !== undefined;
|
|
50
|
+
|
|
51
|
+
const totals = () => props.totals || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0, cost: 0 };
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<box flexDirection="column" paddingX={1}>
|
|
55
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
56
|
+
<box flexDirection="row" gap={1}>
|
|
57
|
+
<SourceBadge name={isVeryNarrowTerminal() ? "1" : "1:OC"} source="opencode" enabled={props.enabledSources.has("opencode")} onToggle={props.onSourceToggle} />
|
|
58
|
+
<SourceBadge name={isVeryNarrowTerminal() ? "2" : "2:CC"} source="claude" enabled={props.enabledSources.has("claude")} onToggle={props.onSourceToggle} />
|
|
59
|
+
<SourceBadge name={isVeryNarrowTerminal() ? "3" : "3:CX"} source="codex" enabled={props.enabledSources.has("codex")} onToggle={props.onSourceToggle} />
|
|
60
|
+
<SourceBadge name={isVeryNarrowTerminal() ? "4" : "4:CR"} source="cursor" enabled={props.enabledSources.has("cursor")} onToggle={props.onSourceToggle} />
|
|
61
|
+
<SourceBadge name={isVeryNarrowTerminal() ? "5" : "5:GM"} source="gemini" enabled={props.enabledSources.has("gemini")} onToggle={props.onSourceToggle} />
|
|
62
|
+
<Show when={!isVeryNarrowTerminal()}>
|
|
63
|
+
<text dim>|</text>
|
|
64
|
+
<SortButton label="Cost" sortType="cost" active={props.sortBy === "cost"} onClick={props.onSortChange} />
|
|
65
|
+
<SortButton label="Tokens" sortType="tokens" active={props.sortBy === "tokens"} onClick={props.onSortChange} />
|
|
66
|
+
</Show>
|
|
67
|
+
<Show when={showScrollInfo() && !isVeryNarrowTerminal()}>
|
|
68
|
+
<text dim>|</text>
|
|
69
|
+
<text dim>{`↓ ${props.scrollStart! + 1}-${props.scrollEnd} of ${props.totalItems}`}</text>
|
|
70
|
+
</Show>
|
|
71
|
+
</box>
|
|
72
|
+
<box flexDirection="row" gap={1}>
|
|
73
|
+
<text fg="cyan">{formatTokens(totals().total)}</text>
|
|
74
|
+
<text dim>tokens</text>
|
|
75
|
+
<text dim>|</text>
|
|
76
|
+
<text fg="green" bold>{`$${totals().cost.toFixed(2)}`}</text>
|
|
77
|
+
<Show when={!isVeryNarrowTerminal()}>
|
|
78
|
+
<text dim>({props.modelCount} models)</text>
|
|
79
|
+
</Show>
|
|
80
|
+
</box>
|
|
81
|
+
</box>
|
|
82
|
+
<box flexDirection="row" gap={1}>
|
|
83
|
+
<Show when={props.statusMessage} fallback={
|
|
84
|
+
<Show when={isVeryNarrowTerminal()} fallback={
|
|
85
|
+
<>
|
|
86
|
+
<text dim>↑↓ scroll • ←→/tab view • y copy •</text>
|
|
87
|
+
<box onMouseDown={props.onPaletteChange}>
|
|
88
|
+
<text fg="magenta">{`[p:${palette().name}]`}</text>
|
|
89
|
+
</box>
|
|
90
|
+
<box onMouseDown={props.onRefresh}>
|
|
91
|
+
<text fg="yellow">[r:refresh]</text>
|
|
92
|
+
</box>
|
|
93
|
+
<text dim>• e export • q quit</text>
|
|
94
|
+
</>
|
|
95
|
+
}>
|
|
96
|
+
<text dim>↑↓•←→•y•</text>
|
|
97
|
+
<box onMouseDown={props.onPaletteChange}>
|
|
98
|
+
<text fg="magenta">[p]</text>
|
|
99
|
+
</box>
|
|
100
|
+
<box onMouseDown={props.onRefresh}>
|
|
101
|
+
<text fg="yellow">[r]</text>
|
|
102
|
+
</box>
|
|
103
|
+
<text dim>•e•q</text>
|
|
104
|
+
</Show>
|
|
105
|
+
}>
|
|
106
|
+
<text fg="green" bold>{props.statusMessage}</text>
|
|
107
|
+
</Show>
|
|
108
|
+
</box>
|
|
109
|
+
<Show when={props.isRefreshing}>
|
|
110
|
+
<LoadingStatusLine phase={props.loadingPhase} />
|
|
111
|
+
</Show>
|
|
112
|
+
<Show when={!props.isRefreshing && props.cacheTimestamp}>
|
|
113
|
+
<box flexDirection="row">
|
|
114
|
+
<text dim>{`Last updated: ${formatTimeAgo(props.cacheTimestamp!)}`}</text>
|
|
115
|
+
</box>
|
|
116
|
+
</Show>
|
|
117
|
+
</box>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface SourceBadgeProps {
|
|
122
|
+
name: string;
|
|
123
|
+
source: SourceType;
|
|
124
|
+
enabled: boolean;
|
|
125
|
+
onToggle?: (source: SourceType) => void;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function SourceBadge(props: SourceBadgeProps) {
|
|
129
|
+
const handleClick = () => props.onToggle?.(props.source);
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<box onMouseDown={handleClick}>
|
|
133
|
+
<text fg={props.enabled ? "green" : "gray"}>
|
|
134
|
+
{`[${props.enabled ? "●" : "○"}${props.name}]`}
|
|
135
|
+
</text>
|
|
136
|
+
</box>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface SortButtonProps {
|
|
141
|
+
label: string;
|
|
142
|
+
sortType: SortType;
|
|
143
|
+
active: boolean;
|
|
144
|
+
onClick?: (sort: SortType) => void;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function SortButton(props: SortButtonProps) {
|
|
148
|
+
const handleClick = () => props.onClick?.(props.sortType);
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<box onMouseDown={handleClick}>
|
|
152
|
+
<text fg={props.active ? "white" : "gray"} bold={props.active}>
|
|
153
|
+
{props.label}
|
|
154
|
+
</text>
|
|
155
|
+
</box>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const SPINNER_COLORS = ["#00FFFF", "#00D7D7", "#00AFAF", "#008787", "#666666", "#666666"];
|
|
160
|
+
const SPINNER_WIDTH = 6;
|
|
161
|
+
const SPINNER_HOLD_START = 20;
|
|
162
|
+
const SPINNER_HOLD_END = 6;
|
|
163
|
+
const SPINNER_TRAIL = 3;
|
|
164
|
+
const SPINNER_INTERVAL = 40;
|
|
165
|
+
|
|
166
|
+
const PHASE_MESSAGES: Record<LoadingPhase, string> = {
|
|
167
|
+
"idle": "Initializing...",
|
|
168
|
+
"loading-pricing": "Loading pricing data...",
|
|
169
|
+
"syncing-cursor": "Syncing Cursor data...",
|
|
170
|
+
"parsing-sources": "Parsing session files...",
|
|
171
|
+
"finalizing-report": "Finalizing report...",
|
|
172
|
+
"complete": "Complete",
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
interface LoadingStatusLineProps {
|
|
176
|
+
phase?: LoadingPhase;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function LoadingStatusLine(props: LoadingStatusLineProps) {
|
|
180
|
+
const [frame, setFrame] = createSignal(0);
|
|
181
|
+
|
|
182
|
+
onMount(() => {
|
|
183
|
+
const id = setInterval(() => setFrame(f => f + 1), SPINNER_INTERVAL);
|
|
184
|
+
onCleanup(() => clearInterval(id));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const getSpinnerState = () => {
|
|
188
|
+
const forwardFrames = SPINNER_WIDTH;
|
|
189
|
+
const backwardFrames = SPINNER_WIDTH - 1;
|
|
190
|
+
const totalCycle = forwardFrames + SPINNER_HOLD_END + backwardFrames + SPINNER_HOLD_START;
|
|
191
|
+
const normalized = frame() % totalCycle;
|
|
192
|
+
|
|
193
|
+
if (normalized < forwardFrames) {
|
|
194
|
+
return { position: normalized, forward: true };
|
|
195
|
+
} else if (normalized < forwardFrames + SPINNER_HOLD_END) {
|
|
196
|
+
return { position: SPINNER_WIDTH - 1, forward: true };
|
|
197
|
+
} else if (normalized < forwardFrames + SPINNER_HOLD_END + backwardFrames) {
|
|
198
|
+
return { position: SPINNER_WIDTH - 2 - (normalized - forwardFrames - SPINNER_HOLD_END), forward: false };
|
|
199
|
+
}
|
|
200
|
+
return { position: 0, forward: false };
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const getCharProps = (index: number) => {
|
|
204
|
+
const { position, forward } = getSpinnerState();
|
|
205
|
+
const distance = forward ? position - index : index - position;
|
|
206
|
+
if (distance >= 0 && distance < SPINNER_TRAIL) {
|
|
207
|
+
return { char: "■", color: SPINNER_COLORS[distance] };
|
|
208
|
+
}
|
|
209
|
+
return { char: "⬝", color: "#444444" };
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const message = () => props.phase ? PHASE_MESSAGES[props.phase] : "Refreshing...";
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<box flexDirection="row" gap={1}>
|
|
216
|
+
<box flexDirection="row" gap={0}>
|
|
217
|
+
{Array.from({ length: SPINNER_WIDTH }, (_, i) => {
|
|
218
|
+
const { char, color } = getCharProps(i);
|
|
219
|
+
return <text fg={color}>{char}</text>;
|
|
220
|
+
})}
|
|
221
|
+
</box>
|
|
222
|
+
<text dim>{message()}</text>
|
|
223
|
+
</box>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Show } from "solid-js";
|
|
2
|
+
import { exec } from "child_process";
|
|
3
|
+
import type { TabType } from "../types/index.js";
|
|
4
|
+
import { isNarrow, isVeryNarrow } from "../utils/responsive.js";
|
|
5
|
+
|
|
6
|
+
const REPO_URL = "https://github.com/junhoyeo/tokscale";
|
|
7
|
+
|
|
8
|
+
function openUrl(url: string) {
|
|
9
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
10
|
+
exec(`${cmd} ${url}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface HeaderProps {
|
|
14
|
+
activeTab: TabType;
|
|
15
|
+
onTabClick?: (tab: TabType) => void;
|
|
16
|
+
width?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Header(props: HeaderProps) {
|
|
20
|
+
const isNarrowTerminal = () => isNarrow(props.width);
|
|
21
|
+
const isVeryNarrowTerminal = () => isVeryNarrow(props.width);
|
|
22
|
+
|
|
23
|
+
const getTabName = (fullName: string, shortName: string) =>
|
|
24
|
+
isVeryNarrowTerminal() ? shortName : fullName;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<box flexDirection="row" paddingX={1} paddingY={0} justifyContent="space-between">
|
|
28
|
+
<box flexDirection="row" gap={isVeryNarrowTerminal() ? 1 : 2}>
|
|
29
|
+
<Tab name={getTabName("Overview", "Ovw")} tabId="overview" active={props.activeTab === "overview"} onClick={props.onTabClick} />
|
|
30
|
+
<Tab name={getTabName("Models", "Mod")} tabId="model" active={props.activeTab === "model"} onClick={props.onTabClick} />
|
|
31
|
+
<Tab name={getTabName("Daily", "Day")} tabId="daily" active={props.activeTab === "daily"} onClick={props.onTabClick} />
|
|
32
|
+
<Tab name={getTabName("Stats", "Sta")} tabId="stats" active={props.activeTab === "stats"} onClick={props.onTabClick} />
|
|
33
|
+
</box>
|
|
34
|
+
<Show when={!isNarrowTerminal()}>
|
|
35
|
+
<box flexDirection="row" onMouseDown={() => openUrl(REPO_URL)}>
|
|
36
|
+
<text fg="cyan" bold>tokscale</text>
|
|
37
|
+
<text fg="#666666">{" | GitHub"}</text>
|
|
38
|
+
</box>
|
|
39
|
+
</Show>
|
|
40
|
+
</box>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface TabProps {
|
|
45
|
+
name: string;
|
|
46
|
+
tabId: TabType;
|
|
47
|
+
active: boolean;
|
|
48
|
+
onClick?: (tab: TabType) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function Tab(props: TabProps) {
|
|
52
|
+
const handleClick = () => props.onClick?.(props.tabId);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Show
|
|
56
|
+
when={props.active}
|
|
57
|
+
fallback={
|
|
58
|
+
<box onMouseDown={handleClick}>
|
|
59
|
+
<text dim>{props.name}</text>
|
|
60
|
+
</box>
|
|
61
|
+
}
|
|
62
|
+
>
|
|
63
|
+
<box onMouseDown={handleClick}>
|
|
64
|
+
<text bg="cyan" fg="black" bold>{` ${props.name} `}</text>
|
|
65
|
+
</box>
|
|
66
|
+
</Show>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { For, Show } from "solid-js";
|
|
2
|
+
import { getModelColor } from "../utils/colors.js";
|
|
3
|
+
import { isNarrow, isVeryNarrow } from "../utils/responsive.js";
|
|
4
|
+
|
|
5
|
+
interface LegendProps {
|
|
6
|
+
models: string[];
|
|
7
|
+
width?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Legend(props: LegendProps) {
|
|
11
|
+
const isNarrowTerminal = () => isNarrow(props.width);
|
|
12
|
+
const isVeryNarrowTerminal = () => isVeryNarrow(props.width);
|
|
13
|
+
|
|
14
|
+
const maxModelNameWidth = () => isVeryNarrowTerminal() ? 12 : isNarrowTerminal() ? 18 : 30;
|
|
15
|
+
const truncateModelName = (name: string) => {
|
|
16
|
+
const max = maxModelNameWidth();
|
|
17
|
+
return name.length > max ? name.slice(0, max - 1) + "…" : name;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const models = () => props.models;
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Show when={models().length > 0}>
|
|
24
|
+
<box flexDirection="row" gap={1} flexWrap="wrap">
|
|
25
|
+
<For each={models()}>
|
|
26
|
+
{(modelId, i) => (
|
|
27
|
+
<box flexDirection="row" gap={0}>
|
|
28
|
+
<text fg={getModelColor(modelId)}>●</text>
|
|
29
|
+
<text>{` ${truncateModelName(modelId)}`}</text>
|
|
30
|
+
<Show when={i() < models().length - 1}>
|
|
31
|
+
<text dim>{isVeryNarrowTerminal() ? " " : " ·"}</text>
|
|
32
|
+
</Show>
|
|
33
|
+
</box>
|
|
34
|
+
)}
|
|
35
|
+
</For>
|
|
36
|
+
</box>
|
|
37
|
+
</Show>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createSignal, onMount, onCleanup } from "solid-js";
|
|
2
|
+
import type { LoadingPhase } from "../types/index.js";
|
|
3
|
+
|
|
4
|
+
const COLORS = ["#00FFFF", "#00D7D7", "#00AFAF", "#008787", "#666666", "#666666", "#666666", "#666666"];
|
|
5
|
+
const WIDTH = 8;
|
|
6
|
+
const HOLD_START = 30;
|
|
7
|
+
const HOLD_END = 9;
|
|
8
|
+
const TRAIL_LENGTH = 4;
|
|
9
|
+
const INTERVAL = 40;
|
|
10
|
+
|
|
11
|
+
interface SpinnerState {
|
|
12
|
+
position: number;
|
|
13
|
+
forward: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getScannerState(frame: number): SpinnerState {
|
|
17
|
+
const forwardFrames = WIDTH;
|
|
18
|
+
const backwardFrames = WIDTH - 1;
|
|
19
|
+
const totalCycle = forwardFrames + HOLD_END + backwardFrames + HOLD_START;
|
|
20
|
+
const normalized = frame % totalCycle;
|
|
21
|
+
|
|
22
|
+
if (normalized < forwardFrames) {
|
|
23
|
+
return { position: normalized, forward: true };
|
|
24
|
+
} else if (normalized < forwardFrames + HOLD_END) {
|
|
25
|
+
return { position: WIDTH - 1, forward: true };
|
|
26
|
+
} else if (normalized < forwardFrames + HOLD_END + backwardFrames) {
|
|
27
|
+
return { position: WIDTH - 2 - (normalized - forwardFrames - HOLD_END), forward: false };
|
|
28
|
+
}
|
|
29
|
+
return { position: 0, forward: false };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const PHASE_MESSAGES: Record<LoadingPhase, string> = {
|
|
33
|
+
"idle": "Initializing...",
|
|
34
|
+
"loading-pricing": "Loading pricing data...",
|
|
35
|
+
"syncing-cursor": "Syncing Cursor data...",
|
|
36
|
+
"parsing-sources": "Parsing session files...",
|
|
37
|
+
"finalizing-report": "Finalizing report...",
|
|
38
|
+
"complete": "Complete",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
interface LoadingSpinnerProps {
|
|
42
|
+
message?: string;
|
|
43
|
+
phase?: LoadingPhase;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function LoadingSpinner(props: LoadingSpinnerProps) {
|
|
47
|
+
const [frame, setFrame] = createSignal(0);
|
|
48
|
+
|
|
49
|
+
onMount(() => {
|
|
50
|
+
const id = setInterval(() => {
|
|
51
|
+
setFrame((f) => f + 1);
|
|
52
|
+
}, INTERVAL);
|
|
53
|
+
onCleanup(() => clearInterval(id));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const state = () => getScannerState(frame());
|
|
57
|
+
const displayMessage = () => props.message || (props.phase ? PHASE_MESSAGES[props.phase] : "Loading data...");
|
|
58
|
+
|
|
59
|
+
const getCharProps = (index: number) => {
|
|
60
|
+
const { position, forward } = state();
|
|
61
|
+
const distance = forward ? position - index : index - position;
|
|
62
|
+
|
|
63
|
+
if (distance >= 0 && distance < TRAIL_LENGTH) {
|
|
64
|
+
return { char: "■", color: COLORS[distance] };
|
|
65
|
+
}
|
|
66
|
+
return { char: "⬝", color: "#444444" };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<box flexDirection="column" justifyContent="center" alignItems="center" flexGrow={1}>
|
|
71
|
+
<box flexDirection="row" gap={0}>
|
|
72
|
+
{Array.from({ length: WIDTH }, (_, i) => {
|
|
73
|
+
const { char, color } = getCharProps(i);
|
|
74
|
+
return <text fg={color}>{char}</text>;
|
|
75
|
+
})}
|
|
76
|
+
</box>
|
|
77
|
+
<box marginTop={1}>
|
|
78
|
+
<text dim>{displayMessage()}</text>
|
|
79
|
+
</box>
|
|
80
|
+
</box>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Show, createMemo } from "solid-js";
|
|
2
|
+
import { TokenBreakdown, type TokenBreakdownData } from "./TokenBreakdown.js";
|
|
3
|
+
import { getModelColor } from "../utils/colors.js";
|
|
4
|
+
|
|
5
|
+
interface ModelRowProps {
|
|
6
|
+
modelId: string;
|
|
7
|
+
tokens: TokenBreakdownData;
|
|
8
|
+
percentage?: number;
|
|
9
|
+
isActive?: boolean;
|
|
10
|
+
compact?: boolean;
|
|
11
|
+
indent?: number;
|
|
12
|
+
maxNameWidth?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ModelRow(props: ModelRowProps) {
|
|
16
|
+
const color = () => getModelColor(props.modelId);
|
|
17
|
+
const bgColor = createMemo(() => props.isActive ? "blue" : undefined);
|
|
18
|
+
|
|
19
|
+
const truncateName = (name: string) => {
|
|
20
|
+
const max = props.maxNameWidth ?? 50;
|
|
21
|
+
return name.length > max ? name.slice(0, max - 1) + "…" : name;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const indentStr = () => " ".repeat(props.indent ?? 0);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<box flexDirection="column">
|
|
28
|
+
<box flexDirection="row" backgroundColor={bgColor()}>
|
|
29
|
+
<Show when={props.indent}>
|
|
30
|
+
<text>{indentStr()}</text>
|
|
31
|
+
</Show>
|
|
32
|
+
<text fg={color()} bg={bgColor()}>●</text>
|
|
33
|
+
<text fg={props.isActive ? "white" : undefined} bg={bgColor()}>{` ${truncateName(props.modelId)}`}</text>
|
|
34
|
+
<Show when={props.percentage !== undefined}>
|
|
35
|
+
<text dim bg={bgColor()}>{` (${props.percentage!.toFixed(1)}%)`}</text>
|
|
36
|
+
</Show>
|
|
37
|
+
</box>
|
|
38
|
+
<box flexDirection="row">
|
|
39
|
+
<TokenBreakdown
|
|
40
|
+
tokens={props.tokens}
|
|
41
|
+
compact={props.compact}
|
|
42
|
+
indent={(props.indent ?? 0) + 2}
|
|
43
|
+
/>
|
|
44
|
+
</box>
|
|
45
|
+
</box>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { For, createMemo, type Accessor } from "solid-js";
|
|
2
|
+
import type { TUIData, SortType } from "../hooks/useData.js";
|
|
3
|
+
import { getModelColor } from "../utils/colors.js";
|
|
4
|
+
import { formatTokensCompact, formatCostFull } from "../utils/format.js";
|
|
5
|
+
import { isNarrow, isVeryNarrow } from "../utils/responsive.js";
|
|
6
|
+
|
|
7
|
+
const STRIPE_BG = "#232328";
|
|
8
|
+
|
|
9
|
+
const INPUT_COL_WIDTH = 12;
|
|
10
|
+
const OUTPUT_COL_WIDTH = 12;
|
|
11
|
+
const CACHE_COL_WIDTH = 12;
|
|
12
|
+
const TOTAL_COL_WIDTH = 14;
|
|
13
|
+
const COST_COL_WIDTH = 12;
|
|
14
|
+
const METRIC_COLUMNS_WIDTH_FULL = INPUT_COL_WIDTH + OUTPUT_COL_WIDTH + CACHE_COL_WIDTH + TOTAL_COL_WIDTH + COST_COL_WIDTH;
|
|
15
|
+
const METRIC_COLUMNS_WIDTH_NARROW = TOTAL_COL_WIDTH + COST_COL_WIDTH;
|
|
16
|
+
const SIDE_PADDING = 2;
|
|
17
|
+
const MIN_NAME_COLUMN = 16;
|
|
18
|
+
const MIN_NAME_COLUMN_NARROW = 12;
|
|
19
|
+
|
|
20
|
+
interface ModelViewProps {
|
|
21
|
+
data: TUIData;
|
|
22
|
+
sortBy: SortType;
|
|
23
|
+
sortDesc: boolean;
|
|
24
|
+
selectedIndex: Accessor<number>;
|
|
25
|
+
height: number;
|
|
26
|
+
width: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ModelView(props: ModelViewProps) {
|
|
30
|
+
const sortedEntries = createMemo(() => {
|
|
31
|
+
const entries = props.data.modelEntries;
|
|
32
|
+
const sortBy = props.sortBy;
|
|
33
|
+
const sortDesc = props.sortDesc;
|
|
34
|
+
|
|
35
|
+
return [...entries].sort((a, b) => {
|
|
36
|
+
let cmp = 0;
|
|
37
|
+
if (sortBy === "cost") cmp = a.cost - b.cost;
|
|
38
|
+
else if (sortBy === "tokens") cmp = a.total - b.total;
|
|
39
|
+
else cmp = a.model.localeCompare(b.model);
|
|
40
|
+
return sortDesc ? -cmp : cmp;
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const isNarrowTerminal = () => isNarrow(props.width);
|
|
45
|
+
const isVeryNarrowTerminal = () => isVeryNarrow(props.width);
|
|
46
|
+
|
|
47
|
+
const nameColumnWidths = createMemo(() => {
|
|
48
|
+
const metricWidth = isNarrowTerminal() ? METRIC_COLUMNS_WIDTH_NARROW : METRIC_COLUMNS_WIDTH_FULL;
|
|
49
|
+
const minName = isNarrowTerminal() ? MIN_NAME_COLUMN_NARROW : MIN_NAME_COLUMN;
|
|
50
|
+
const available = Math.max(props.width - SIDE_PADDING - metricWidth, minName);
|
|
51
|
+
const nameColumn = Math.max(minName, available);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
column: nameColumn,
|
|
55
|
+
text: Math.max(nameColumn - 1, 1),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const visibleEntries = createMemo(() => {
|
|
60
|
+
const maxRows = Math.max(props.height - 3, 0);
|
|
61
|
+
return sortedEntries().slice(0, maxRows);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const formattedRows = createMemo(() => {
|
|
65
|
+
const nameWidth = nameColumnWidths().text;
|
|
66
|
+
return visibleEntries().map((entry) => {
|
|
67
|
+
const sourceLabel = entry.source.charAt(0).toUpperCase() + entry.source.slice(1);
|
|
68
|
+
const fullName = `${sourceLabel} ${entry.model}`;
|
|
69
|
+
let displayName = fullName;
|
|
70
|
+
if (fullName.length > nameWidth) {
|
|
71
|
+
displayName = nameWidth > 1 ? `${fullName.slice(0, nameWidth - 1)}…` : fullName.slice(0, 1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
entry,
|
|
76
|
+
displayName,
|
|
77
|
+
nameWidth,
|
|
78
|
+
input: formatTokensCompact(entry.input),
|
|
79
|
+
output: formatTokensCompact(entry.output),
|
|
80
|
+
cache: formatTokensCompact(entry.cacheRead),
|
|
81
|
+
total: formatTokensCompact(entry.total),
|
|
82
|
+
cost: formatCostFull(entry.cost),
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const sortArrow = () => (props.sortDesc ? "▼" : "▲");
|
|
88
|
+
const nameHeader = () => isVeryNarrowTerminal()
|
|
89
|
+
? ` Model`
|
|
90
|
+
: ` Source/Model`;
|
|
91
|
+
const totalHeader = () => (props.sortBy === "tokens" ? `${sortArrow()} Total` : "Total");
|
|
92
|
+
const costHeader = () => (props.sortBy === "cost" ? `${sortArrow()} Cost` : "Cost");
|
|
93
|
+
|
|
94
|
+
const renderHeader = () => {
|
|
95
|
+
if (isNarrowTerminal()) {
|
|
96
|
+
return `${nameHeader().padEnd(nameColumnWidths().column)}${totalHeader().padStart(TOTAL_COL_WIDTH)}${costHeader().padStart(COST_COL_WIDTH)}`;
|
|
97
|
+
}
|
|
98
|
+
return `${nameHeader().padEnd(nameColumnWidths().column)}${"Input".padStart(INPUT_COL_WIDTH)}${"Output".padStart(OUTPUT_COL_WIDTH)}${"Cache".padStart(CACHE_COL_WIDTH)}${totalHeader().padStart(TOTAL_COL_WIDTH)}${costHeader().padStart(COST_COL_WIDTH)}`;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const renderRowData = (row: typeof formattedRows extends () => (infer T)[] ? T : never) => {
|
|
102
|
+
if (isNarrowTerminal()) {
|
|
103
|
+
return `${row.displayName.padEnd(row.nameWidth)}${row.total.padStart(TOTAL_COL_WIDTH)}`;
|
|
104
|
+
}
|
|
105
|
+
return `${row.displayName.padEnd(row.nameWidth)}${row.input.padStart(INPUT_COL_WIDTH)}${row.output.padStart(OUTPUT_COL_WIDTH)}${row.cache.padStart(CACHE_COL_WIDTH)}${row.total.padStart(TOTAL_COL_WIDTH)}`;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<box flexDirection="column">
|
|
110
|
+
<box flexDirection="row">
|
|
111
|
+
<text fg="cyan" bold>
|
|
112
|
+
{renderHeader()}
|
|
113
|
+
</text>
|
|
114
|
+
</box>
|
|
115
|
+
|
|
116
|
+
<For each={formattedRows()}>
|
|
117
|
+
{(row, i) => {
|
|
118
|
+
const isActive = createMemo(() => i() === props.selectedIndex());
|
|
119
|
+
const rowBg = createMemo(() => isActive() ? "blue" : (i() % 2 === 1 ? STRIPE_BG : undefined));
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<box flexDirection="row">
|
|
123
|
+
<text
|
|
124
|
+
fg={getModelColor(row.entry.model)}
|
|
125
|
+
bg={rowBg()}
|
|
126
|
+
>●</text>
|
|
127
|
+
<text
|
|
128
|
+
bg={rowBg()}
|
|
129
|
+
fg={isActive() ? "white" : undefined}
|
|
130
|
+
>
|
|
131
|
+
{renderRowData(row)}
|
|
132
|
+
</text>
|
|
133
|
+
<text
|
|
134
|
+
fg="green"
|
|
135
|
+
bg={rowBg()}
|
|
136
|
+
>
|
|
137
|
+
{row.cost.padStart(COST_COL_WIDTH)}
|
|
138
|
+
</text>
|
|
139
|
+
</box>
|
|
140
|
+
);
|
|
141
|
+
}}
|
|
142
|
+
</For>
|
|
143
|
+
</box>
|
|
144
|
+
);
|
|
145
|
+
}
|