@tokscale/cli 1.0.5 → 1.0.6

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.
@@ -0,0 +1,339 @@
1
+ import { createSignal, Switch, Match, onCleanup } from "solid-js";
2
+ import { useKeyboard, useTerminalDimensions, useRenderer } from "@opentui/solid";
3
+ import clipboardy from "clipboardy";
4
+ import { Header } from "./components/Header.js";
5
+ import { Footer } from "./components/Footer.js";
6
+ import { ModelView } from "./components/ModelView.js";
7
+ import { DailyView } from "./components/DailyView.js";
8
+ import { StatsView } from "./components/StatsView.js";
9
+ import { OverviewView } from "./components/OverviewView.js";
10
+ import { LoadingSpinner } from "./components/LoadingSpinner.js";
11
+ import { useData, type DateFilters } from "./hooks/useData.js";
12
+ import type { ColorPaletteName } from "./config/themes.js";
13
+ import { DEFAULT_PALETTE, getPaletteNames } from "./config/themes.js";
14
+ import { loadSettings, saveSettings, getCacheTimestamp } from "./config/settings.js";
15
+ import { TABS, ALL_SOURCES, type TUIOptions, type TabType, type SortType, type SourceType } from "./types/index.js";
16
+
17
+ export type AppProps = TUIOptions;
18
+
19
+ const PALETTE_NAMES = getPaletteNames();
20
+
21
+ function cycleTabForward(current: TabType): TabType {
22
+ const idx = TABS.indexOf(current);
23
+ return TABS[(idx + 1) % TABS.length];
24
+ }
25
+
26
+ function cycleTabBackward(current: TabType): TabType {
27
+ const idx = TABS.indexOf(current);
28
+ return TABS[(idx - 1 + TABS.length) % TABS.length];
29
+ }
30
+
31
+ export function App(props: AppProps) {
32
+ const renderer = useRenderer();
33
+ const terminalDimensions = useTerminalDimensions();
34
+ const columns = () => terminalDimensions().width;
35
+ const rows = () => terminalDimensions().height;
36
+
37
+ const settings = loadSettings();
38
+ const [activeTab, setActiveTab] = createSignal<TabType>(props.initialTab ?? "overview");
39
+ const [enabledSources, setEnabledSources] = createSignal<Set<SourceType>>(
40
+ new Set(props.enabledSources ?? ALL_SOURCES)
41
+ );
42
+ const [sortBy, setSortBy] = createSignal<SortType>(props.sortBy ?? "cost");
43
+ const [sortDesc, setSortDesc] = createSignal(props.sortDesc ?? true);
44
+ const [selectedIndex, setSelectedIndex] = createSignal(0);
45
+ const [scrollOffset, setScrollOffset] = createSignal(0);
46
+ const [colorPalette, setColorPalette] = createSignal<ColorPaletteName>(
47
+ props.colorPalette ?? (settings.colorPalette as ColorPaletteName) ?? DEFAULT_PALETTE
48
+ );
49
+
50
+ const dateFilters: DateFilters = {
51
+ since: props.since,
52
+ until: props.until,
53
+ year: props.year,
54
+ };
55
+
56
+ const { data, loading, error, refresh, loadingPhase, isRefreshing } = useData(() => enabledSources(), dateFilters);
57
+
58
+ const cacheTimestamp = () => !isRefreshing() && !loading() ? getCacheTimestamp() : null;
59
+
60
+ const [selectedDate, setSelectedDate] = createSignal<string | null>(null);
61
+
62
+ const [statusMessage, setStatusMessage] = createSignal<string | null>(null);
63
+ let statusTimeout: ReturnType<typeof setTimeout> | null = null;
64
+
65
+ const showStatus = (msg: string, duration = 2000) => {
66
+ if (statusTimeout) clearTimeout(statusTimeout);
67
+ setStatusMessage(msg);
68
+ statusTimeout = setTimeout(() => setStatusMessage(null), duration);
69
+ };
70
+
71
+ onCleanup(() => {
72
+ if (statusTimeout) clearTimeout(statusTimeout);
73
+ });
74
+
75
+ const contentHeight = () => Math.max(rows() - 6, 12);
76
+ const overviewChartHeight = () => Math.max(5, Math.floor(contentHeight() * 0.35));
77
+ const overviewListHeight = () => Math.max(4, contentHeight() - overviewChartHeight() - 4);
78
+ const overviewItemsPerPage = () => Math.max(1, Math.floor(overviewListHeight() / 2));
79
+
80
+ const handleSourceToggle = (source: SourceType) => {
81
+ const newSources = new Set(enabledSources());
82
+ if (newSources.has(source)) {
83
+ if (newSources.size > 1) {
84
+ newSources.delete(source);
85
+ }
86
+ } else {
87
+ newSources.add(source);
88
+ }
89
+ setEnabledSources(newSources);
90
+ };
91
+
92
+ const handlePaletteChange = () => {
93
+ const currentIdx = PALETTE_NAMES.indexOf(colorPalette());
94
+ const nextIdx = (currentIdx + 1) % PALETTE_NAMES.length;
95
+ const newPalette = PALETTE_NAMES[nextIdx];
96
+ saveSettings({ colorPalette: newPalette });
97
+ setColorPalette(newPalette);
98
+ };
99
+
100
+ const handleSortChange = (sort: SortType) => {
101
+ setSortBy(sort);
102
+ setSortDesc(true);
103
+ };
104
+
105
+ useKeyboard((key) => {
106
+ if (key.name === "q") {
107
+ renderer.destroy();
108
+ return;
109
+ }
110
+
111
+ if (key.name === "r") {
112
+ refresh();
113
+ return;
114
+ }
115
+
116
+ if (key.name === "tab" || key.name === "d" || key.name === "right") {
117
+ setActiveTab(cycleTabForward(activeTab()));
118
+ setSelectedIndex(0);
119
+ setScrollOffset(0);
120
+ return;
121
+ }
122
+
123
+ if (key.name === "left") {
124
+ setActiveTab(cycleTabBackward(activeTab()));
125
+ setSelectedIndex(0);
126
+ setScrollOffset(0);
127
+ return;
128
+ }
129
+
130
+ if (key.name === "c" && !key.meta && !key.ctrl) {
131
+ setSortBy("cost");
132
+ setSortDesc(true);
133
+ return;
134
+ }
135
+
136
+ if (key.name === "y") {
137
+ const d = data();
138
+ if (!d) return;
139
+
140
+ let textToCopy = "";
141
+ const tab = activeTab();
142
+
143
+ if (tab === "model") {
144
+ const sorted = [...d.modelEntries].sort((a, b) => {
145
+ if (sortBy() === "cost") return sortDesc() ? b.cost - a.cost : a.cost - b.cost;
146
+ if (sortBy() === "tokens") return sortDesc() ? b.total - a.total : a.total - b.total;
147
+ return sortDesc() ? b.model.localeCompare(a.model) : a.model.localeCompare(b.model);
148
+ });
149
+ const entry = sorted[selectedIndex()];
150
+ if (entry) {
151
+ textToCopy = `${entry.source} ${entry.model}: ${entry.total.toLocaleString()} tokens, $${entry.cost.toFixed(2)}`;
152
+ }
153
+ } else if (tab === "daily") {
154
+ const sorted = [...d.dailyEntries].sort((a, b) => {
155
+ if (sortBy() === "cost") return sortDesc() ? b.cost - a.cost : a.cost - b.cost;
156
+ if (sortBy() === "tokens") return sortDesc() ? b.total - a.total : a.total - b.total;
157
+ return sortDesc() ? b.date.localeCompare(a.date) : a.date.localeCompare(b.date);
158
+ });
159
+ const entry = sorted[selectedIndex()];
160
+ if (entry) {
161
+ textToCopy = `${entry.date}: ${entry.total.toLocaleString()} tokens, $${entry.cost.toFixed(2)}`;
162
+ }
163
+ } else if (tab === "overview") {
164
+ const model = d.topModels[scrollOffset() + selectedIndex()];
165
+ if (model) {
166
+ textToCopy = `${model.modelId}: ${model.totalTokens.toLocaleString()} tokens, $${model.cost.toFixed(2)}`;
167
+ }
168
+ }
169
+
170
+ if (textToCopy) {
171
+ clipboardy.write(textToCopy)
172
+ .then(() => showStatus("Copied to clipboard"))
173
+ .catch(() => showStatus("Failed to copy"));
174
+ }
175
+ return;
176
+ }
177
+ if (key.name === "t") {
178
+ setSortBy("tokens");
179
+ setSortDesc(true);
180
+ return;
181
+ }
182
+
183
+ if (key.name === "p") {
184
+ handlePaletteChange();
185
+ return;
186
+ }
187
+
188
+ if (key.name === "1") { handleSourceToggle("opencode"); return; }
189
+ if (key.name === "2") { handleSourceToggle("claude"); return; }
190
+ if (key.name === "3") { handleSourceToggle("codex"); return; }
191
+ if (key.name === "4") { handleSourceToggle("cursor"); return; }
192
+ if (key.name === "5") { handleSourceToggle("gemini"); return; }
193
+
194
+ if (key.name === "up") {
195
+ if (activeTab() === "overview") {
196
+ if (selectedIndex() > 0) {
197
+ setSelectedIndex(selectedIndex() - 1);
198
+ } else if (scrollOffset() > 0) {
199
+ setScrollOffset(scrollOffset() - 1);
200
+ }
201
+ } else {
202
+ setSelectedIndex(Math.max(0, selectedIndex() - 1));
203
+ }
204
+ return;
205
+ }
206
+
207
+ if (key.name === "down") {
208
+ if (activeTab() === "overview") {
209
+ const maxVisible = Math.min(overviewItemsPerPage(), (data()?.topModels.length ?? 0) - scrollOffset());
210
+ const maxOffset = Math.max(0, (data()?.topModels.length ?? 0) - overviewItemsPerPage());
211
+ if (selectedIndex() < maxVisible - 1) {
212
+ setSelectedIndex(selectedIndex() + 1);
213
+ } else if (scrollOffset() < maxOffset) {
214
+ setScrollOffset(scrollOffset() + 1);
215
+ }
216
+ } else {
217
+ const d = data();
218
+ const maxIndex = activeTab() === "model"
219
+ ? (d?.modelEntries.length ?? 0)
220
+ : (d?.dailyEntries.length ?? 0);
221
+ if (maxIndex > 0) {
222
+ setSelectedIndex(Math.min(selectedIndex() + 1, maxIndex - 1));
223
+ }
224
+ }
225
+ return;
226
+ }
227
+
228
+ if (key.name === "e" && data()) {
229
+ const d = data()!;
230
+ const exportData = {
231
+ exportedAt: new Date().toISOString(),
232
+ totalCost: d.totalCost,
233
+ modelCount: d.modelCount,
234
+ models: d.modelEntries,
235
+ daily: d.dailyEntries,
236
+ stats: d.stats,
237
+ };
238
+ const filename = `tokscale-export-${new Date().toISOString().split("T")[0]}.json`;
239
+ import("node:fs")
240
+ .then((fs) => {
241
+ fs.writeFileSync(filename, JSON.stringify(exportData, null, 2));
242
+ showStatus(`Exported to ${filename}`);
243
+ })
244
+ .catch(() => showStatus("Export failed"));
245
+ return;
246
+ }
247
+ });
248
+
249
+ const handleTabClick = (tab: TabType) => {
250
+ setActiveTab(tab);
251
+ setSelectedIndex(0);
252
+ setScrollOffset(0);
253
+ setSelectedDate(null);
254
+ };
255
+
256
+ return (
257
+ <box flexDirection="column" width={columns()} height={rows()}>
258
+ <Header activeTab={activeTab()} onTabClick={handleTabClick} width={columns()} />
259
+
260
+ <box flexDirection="column" flexGrow={1} paddingX={1}>
261
+ <Switch>
262
+ <Match when={loading()}>
263
+ <LoadingSpinner phase={loadingPhase()} />
264
+ </Match>
265
+ <Match when={error()}>
266
+ <box justifyContent="center" alignItems="center" flexGrow={1}>
267
+ <text fg="red">{`Error: ${error()}`}</text>
268
+ </box>
269
+ </Match>
270
+ <Match when={data()}>
271
+ <Switch>
272
+ <Match when={activeTab() === "overview"}>
273
+ <OverviewView
274
+ data={data()!}
275
+ sortBy={sortBy()}
276
+ sortDesc={sortDesc()}
277
+ selectedIndex={selectedIndex}
278
+ scrollOffset={scrollOffset}
279
+ height={contentHeight()}
280
+ width={columns()}
281
+ />
282
+ </Match>
283
+ <Match when={activeTab() === "model"}>
284
+ <ModelView
285
+ data={data()!}
286
+ sortBy={sortBy()}
287
+ sortDesc={sortDesc()}
288
+ selectedIndex={selectedIndex}
289
+ height={contentHeight()}
290
+ width={columns()}
291
+ />
292
+ </Match>
293
+ <Match when={activeTab() === "daily"}>
294
+ <DailyView
295
+ data={data()!}
296
+ sortBy={sortBy()}
297
+ sortDesc={sortDesc()}
298
+ selectedIndex={selectedIndex}
299
+ height={contentHeight()}
300
+ width={columns()}
301
+ />
302
+ </Match>
303
+ <Match when={activeTab() === "stats"}>
304
+ <StatsView
305
+ data={data()!}
306
+ height={contentHeight()}
307
+ colorPalette={colorPalette()}
308
+ width={columns()}
309
+ selectedDate={selectedDate()}
310
+ />
311
+ </Match>
312
+ </Switch>
313
+ </Match>
314
+ </Switch>
315
+ </box>
316
+
317
+ <Footer
318
+ enabledSources={enabledSources()}
319
+ sortBy={sortBy()}
320
+ totals={data()?.totals}
321
+ modelCount={data()?.modelCount ?? 0}
322
+ activeTab={activeTab()}
323
+ scrollStart={scrollOffset()}
324
+ scrollEnd={Math.min(scrollOffset() + overviewItemsPerPage(), data()?.topModels.length ?? 0)}
325
+ totalItems={data()?.topModels.length}
326
+ colorPalette={colorPalette()}
327
+ statusMessage={statusMessage()}
328
+ isRefreshing={isRefreshing()}
329
+ loadingPhase={loadingPhase()}
330
+ cacheTimestamp={cacheTimestamp()}
331
+ width={columns()}
332
+ onSourceToggle={handleSourceToggle}
333
+ onSortChange={handleSortChange}
334
+ onPaletteChange={handlePaletteChange}
335
+ onRefresh={refresh}
336
+ />
337
+ </box>
338
+ );
339
+ }
@@ -0,0 +1,198 @@
1
+ import { For, Show, createMemo } from "solid-js";
2
+ import { formatTokensCompact } from "../utils/format.js";
3
+ import { isNarrow, isVeryNarrow } from "../utils/responsive.js";
4
+
5
+ export interface ChartDataPoint {
6
+ date: string;
7
+ models: { modelId: string; tokens: number; color: string }[];
8
+ total: number;
9
+ }
10
+
11
+ interface BarChartProps {
12
+ data: ChartDataPoint[];
13
+ width: number;
14
+ height: number;
15
+ }
16
+
17
+ const BLOCKS = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
18
+ const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
19
+ const REPEAT_CACHE_MAX_SIZE = 256;
20
+
21
+ const repeatCache = new Map<string, string>();
22
+ function getRepeatedString(char: string, count: number): string {
23
+ const key = `${char}:${count}`;
24
+ let cached = repeatCache.get(key);
25
+ if (!cached) {
26
+ if (repeatCache.size >= REPEAT_CACHE_MAX_SIZE) {
27
+ const firstKey = repeatCache.keys().next().value;
28
+ if (firstKey) repeatCache.delete(firstKey);
29
+ }
30
+ cached = char.repeat(count);
31
+ repeatCache.set(key, cached);
32
+ }
33
+ return cached;
34
+ }
35
+
36
+ export function BarChart(props: BarChartProps) {
37
+ const data = () => props.data;
38
+ const width = () => props.width;
39
+ const height = () => props.height;
40
+
41
+ const isNarrowTerminal = () => isNarrow(width());
42
+ const isVeryNarrowTerminal = () => isVeryNarrow(width());
43
+
44
+ const safeHeight = () => Math.max(height(), 1);
45
+
46
+ const maxTotal = createMemo(() => {
47
+ const arr = data();
48
+ if (arr.length === 0) return 1;
49
+ let max = arr[0].total;
50
+ for (let i = 1; i < arr.length; i++) {
51
+ if (arr[i].total > max) max = arr[i].total;
52
+ }
53
+ return Math.max(max, 1);
54
+ });
55
+
56
+ const chartWidth = () => Math.max(width() - 8, 20);
57
+ const barWidth = () => Math.max(1, Math.floor(chartWidth() / Math.min(data().length, 52)));
58
+ const visibleBars = () => Math.min(data().length, Math.floor(chartWidth() / barWidth()));
59
+ const visibleData = createMemo(() => data().slice(-visibleBars()));
60
+
61
+ const sortedModelsMap = createMemo(() => {
62
+ const vd = visibleData();
63
+ const map = new Map<string, { modelId: string; tokens: number; color: string }[]>();
64
+ for (const point of vd) {
65
+ const models = point.models ?? [];
66
+ const sorted = [...models].sort((a, b) => a.modelId.localeCompare(b.modelId));
67
+ map.set(point.date, sorted);
68
+ }
69
+ return map;
70
+ });
71
+
72
+ const rowIndices = createMemo(() => {
73
+ const sh = safeHeight();
74
+ const indices: number[] = new Array(sh);
75
+ for (let i = 0; i < sh; i++) {
76
+ indices[i] = sh - 1 - i;
77
+ }
78
+ return indices;
79
+ });
80
+
81
+ const dateLabels = createMemo(() => {
82
+ const vd = visibleData();
83
+ if (vd.length === 0) return [];
84
+
85
+ const labelInterval = Math.max(1, Math.floor(vd.length / (isVeryNarrowTerminal() ? 2 : 3)));
86
+ const labels: string[] = [];
87
+
88
+ for (let i = 0; i < vd.length; i += labelInterval) {
89
+ const dateStr = vd[i].date;
90
+ const parts = dateStr.split("-");
91
+ if (parts.length === 3) {
92
+ const month = parseInt(parts[1], 10);
93
+ const day = parseInt(parts[2], 10);
94
+ labels.push(isVeryNarrowTerminal() ? `${month}/${day}` : `${MONTH_NAMES[month - 1]} ${day}`);
95
+ } else {
96
+ labels.push(dateStr.slice(5));
97
+ }
98
+ }
99
+ return labels;
100
+ });
101
+
102
+ const axisWidth = () => Math.min(chartWidth(), visibleBars() * barWidth());
103
+ const labelPadding = () => {
104
+ const labels = dateLabels();
105
+ return labels.length > 0 ? Math.floor(axisWidth() / labels.length) : 0;
106
+ };
107
+
108
+ const chartTitle = () => isVeryNarrowTerminal() ? "Tokens" : "Tokens per Day";
109
+
110
+ const getBarContent = (point: ChartDataPoint, row: number): { char: string; color: string } => {
111
+ const mt = maxTotal();
112
+ const sh = safeHeight();
113
+ const rowThreshold = ((row + 1) / sh) * mt;
114
+ const prevThreshold = (row / sh) * mt;
115
+ const thresholdDiff = rowThreshold - prevThreshold;
116
+ const bw = barWidth();
117
+
118
+ if (point.total <= prevThreshold) {
119
+ return { char: getRepeatedString(" ", bw), color: "dim" };
120
+ }
121
+
122
+ const sortedModels = sortedModelsMap().get(point.date) ?? [];
123
+ if (sortedModels.length === 0) {
124
+ return { char: getRepeatedString(" ", bw), color: "dim" };
125
+ }
126
+
127
+ let currentHeight = 0;
128
+ let maxOverlap = 0;
129
+ let color = sortedModels[0].color;
130
+
131
+ const rowStart = prevThreshold;
132
+ const rowEnd = rowThreshold;
133
+
134
+ for (const m of sortedModels) {
135
+ const mStart = currentHeight;
136
+ const mEnd = currentHeight + m.tokens;
137
+ currentHeight += m.tokens;
138
+
139
+ const overlapStart = Math.max(mStart, rowStart);
140
+ const overlapEnd = Math.min(mEnd, rowEnd);
141
+ const overlap = Math.max(0, overlapEnd - overlapStart);
142
+
143
+ if (overlap > maxOverlap) {
144
+ maxOverlap = overlap;
145
+ color = m.color;
146
+ }
147
+ }
148
+
149
+ if (point.total >= rowThreshold) {
150
+ return { char: getRepeatedString("█", bw), color };
151
+ }
152
+
153
+ const ratio = thresholdDiff > 0 ? (point.total - prevThreshold) / thresholdDiff : 1;
154
+ const blockIndex = Math.min(8, Math.max(1, Math.floor(ratio * 8)));
155
+ return { char: getRepeatedString(BLOCKS[blockIndex], bw), color };
156
+ };
157
+
158
+ return (
159
+ <Show when={data().length > 0} fallback={<text dim>No chart data</text>}>
160
+ <box flexDirection="column">
161
+ <text bold>{chartTitle()}</text>
162
+ <For each={rowIndices()}>
163
+ {(row) => {
164
+ const yLabelWidth = isVeryNarrowTerminal() ? 5 : 6;
165
+ const yLabel = row === safeHeight() - 1
166
+ ? formatTokensCompact(maxTotal()).padStart(yLabelWidth)
167
+ : " ".repeat(yLabelWidth);
168
+ return (
169
+ <box flexDirection="row">
170
+ <text dim>{yLabel}│</text>
171
+ <For each={visibleData()}>
172
+ {(point) => {
173
+ const bar = getBarContent(point, row);
174
+ return bar.color === "dim"
175
+ ? <text dim>{bar.char}</text>
176
+ : <text fg={bar.color}>{bar.char}</text>;
177
+ }}
178
+ </For>
179
+ </box>
180
+ );
181
+ }}
182
+ </For>
183
+ <box flexDirection="row">
184
+ <text dim>{isVeryNarrowTerminal() ? " 0│" : " 0│"}</text>
185
+ <text dim>{getRepeatedString("─", axisWidth())}</text>
186
+ </box>
187
+ <Show when={dateLabels().length > 0}>
188
+ <box flexDirection="row">
189
+ <text dim>{isVeryNarrowTerminal() ? " " : " "}</text>
190
+ <text dim>
191
+ {dateLabels().map((l) => l.padEnd(labelPadding())).join("")}
192
+ </text>
193
+ </box>
194
+ </Show>
195
+ </box>
196
+ </Show>
197
+ );
198
+ }
@@ -0,0 +1,113 @@
1
+ import { For, createMemo, type Accessor } from "solid-js";
2
+ import type { TUIData, SortType } from "../hooks/useData.js";
3
+ import { formatTokensCompact, formatCostFull } from "../utils/format.js";
4
+ import { isNarrow } from "../utils/responsive.js";
5
+
6
+ const STRIPE_BG = "#232328";
7
+
8
+ const INPUT_COL_WIDTH = 12;
9
+ const OUTPUT_COL_WIDTH = 12;
10
+ const CACHE_COL_WIDTH = 12;
11
+ const TOTAL_COL_WIDTH = 14;
12
+ const COST_COL_WIDTH = 12;
13
+ const METRIC_COLUMNS_WIDTH_FULL = INPUT_COL_WIDTH + OUTPUT_COL_WIDTH + CACHE_COL_WIDTH + TOTAL_COL_WIDTH + COST_COL_WIDTH;
14
+ const METRIC_COLUMNS_WIDTH_NARROW = TOTAL_COL_WIDTH + COST_COL_WIDTH;
15
+ const SIDE_PADDING = 0;
16
+ const MIN_DATE_COLUMN = 14;
17
+
18
+ interface DailyViewProps {
19
+ data: TUIData;
20
+ sortBy: SortType;
21
+ sortDesc: boolean;
22
+ selectedIndex: Accessor<number>;
23
+ height: number;
24
+ width?: number;
25
+ }
26
+
27
+ export function DailyView(props: DailyViewProps) {
28
+ const isNarrowTerminal = () => isNarrow(props.width);
29
+ const terminalWidth = () => props.width ?? process.stdout.columns ?? 80;
30
+
31
+ const dateColumnWidths = createMemo(() => {
32
+ const metricWidth = isNarrowTerminal() ? METRIC_COLUMNS_WIDTH_NARROW : METRIC_COLUMNS_WIDTH_FULL;
33
+ const minDate = MIN_DATE_COLUMN;
34
+ const available = Math.max(terminalWidth() - SIDE_PADDING - metricWidth, minDate);
35
+ const dateColumn = Math.max(minDate, available);
36
+
37
+ return {
38
+ column: dateColumn,
39
+ text: dateColumn,
40
+ };
41
+ });
42
+
43
+ const sortedEntries = createMemo(() => {
44
+ const entries = props.data.dailyEntries;
45
+ const sortBy = props.sortBy;
46
+ const sortDesc = props.sortDesc;
47
+
48
+ return [...entries].sort((a, b) => {
49
+ let cmp = 0;
50
+ if (sortBy === "cost") cmp = a.cost - b.cost;
51
+ else if (sortBy === "tokens") cmp = a.total - b.total;
52
+ else cmp = a.date.localeCompare(b.date);
53
+ return sortDesc ? -cmp : cmp;
54
+ });
55
+ });
56
+
57
+ const visibleEntries = createMemo(() => sortedEntries().slice(0, props.height - 3));
58
+
59
+ const sortArrow = () => (props.sortDesc ? "▼" : "▲");
60
+ const dateHeader = () => "Date";
61
+ const totalHeader = () => (props.sortBy === "tokens" ? `${sortArrow()} Total` : "Total");
62
+ const costHeader = () => (props.sortBy === "cost" ? `${sortArrow()} Cost` : "Cost");
63
+
64
+ const renderHeader = () => {
65
+ const dateColWidth = dateColumnWidths().column;
66
+ if (isNarrowTerminal()) {
67
+ return `${"Date".padEnd(dateColWidth)}${totalHeader().padStart(TOTAL_COL_WIDTH)}${costHeader().padStart(COST_COL_WIDTH)}`;
68
+ }
69
+ return `${(" " + dateHeader()).padEnd(dateColWidth)}${"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)}`;
70
+ };
71
+
72
+ const renderRow = (entry: typeof visibleEntries extends () => (infer T)[] ? T : never) => {
73
+ const dateColWidth = dateColumnWidths().column;
74
+ if (isNarrowTerminal()) {
75
+ return `${entry.date.padEnd(dateColWidth)}${formatTokensCompact(entry.total).padStart(TOTAL_COL_WIDTH)}`;
76
+ }
77
+ return `${entry.date.padEnd(dateColWidth)}${formatTokensCompact(entry.input).padStart(INPUT_COL_WIDTH)}${formatTokensCompact(entry.output).padStart(OUTPUT_COL_WIDTH)}${formatTokensCompact(entry.cache).padStart(CACHE_COL_WIDTH)}${formatTokensCompact(entry.total).padStart(TOTAL_COL_WIDTH)}`;
78
+ };
79
+
80
+ return (
81
+ <box flexDirection="column">
82
+ <box flexDirection="row">
83
+ <text fg="cyan" bold>
84
+ {renderHeader()}
85
+ </text>
86
+ </box>
87
+
88
+ <For each={visibleEntries()}>
89
+ {(entry, i) => {
90
+ const isActive = createMemo(() => i() === props.selectedIndex());
91
+ const rowBg = createMemo(() => isActive() ? "blue" : (i() % 2 === 1 ? STRIPE_BG : undefined));
92
+
93
+ return (
94
+ <box flexDirection="row">
95
+ <text
96
+ bg={rowBg()}
97
+ fg={isActive() ? "white" : undefined}
98
+ >
99
+ {renderRow(entry)}
100
+ </text>
101
+ <text
102
+ fg="green"
103
+ bg={rowBg()}
104
+ >
105
+ {formatCostFull(entry.cost).padStart(COST_COL_WIDTH)}
106
+ </text>
107
+ </box>
108
+ );
109
+ }}
110
+ </For>
111
+ </box>
112
+ );
113
+ }