@tokscale/cli 1.0.4 → 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.
Files changed (70) hide show
  1. package/dist/cli.js +11 -3
  2. package/dist/cli.js.map +1 -1
  3. package/dist/native.d.ts.map +1 -1
  4. package/dist/native.js +3 -2
  5. package/dist/native.js.map +1 -1
  6. package/dist/tui/{App.jsx → App.js} +3 -35
  7. package/dist/tui/{App.jsx.map → App.js.map} +1 -1
  8. package/dist/tui/components/{BarChart.jsx → BarChart.js} +14 -37
  9. package/dist/tui/components/{BarChart.jsx.map → BarChart.js.map} +1 -1
  10. package/dist/tui/components/{DailyView.jsx → DailyView.js} +7 -23
  11. package/dist/tui/components/{DailyView.jsx.map → DailyView.js.map} +1 -1
  12. package/dist/tui/components/DateBreakdownPanel.js +36 -0
  13. package/dist/tui/components/DateBreakdownPanel.js.map +1 -0
  14. package/dist/tui/components/Footer.js +87 -0
  15. package/dist/tui/components/Footer.js.map +1 -0
  16. package/dist/tui/components/Header.js +20 -0
  17. package/dist/tui/components/Header.js.map +1 -0
  18. package/dist/tui/components/Legend.js +16 -0
  19. package/dist/tui/components/Legend.js.map +1 -0
  20. package/dist/tui/components/{LoadingSpinner.jsx → LoadingSpinner.js} +6 -12
  21. package/dist/tui/components/{LoadingSpinner.jsx.map → LoadingSpinner.js.map} +1 -1
  22. package/dist/tui/components/ModelRow.js +15 -0
  23. package/dist/tui/components/ModelRow.js.map +1 -0
  24. package/dist/tui/components/{ModelView.jsx → ModelView.js} +7 -24
  25. package/dist/tui/components/{ModelView.jsx.map → ModelView.js.map} +1 -1
  26. package/dist/tui/components/{OverviewView.jsx → OverviewView.js} +11 -35
  27. package/dist/tui/components/{OverviewView.jsx.map → OverviewView.js.map} +1 -1
  28. package/dist/tui/components/StatsView.js +86 -0
  29. package/dist/tui/components/StatsView.js.map +1 -0
  30. package/dist/tui/components/TokenBreakdown.js +10 -0
  31. package/dist/tui/components/TokenBreakdown.js.map +1 -0
  32. package/dist/tui/{index.jsx → index.js} +3 -2
  33. package/dist/tui/{index.jsx.map → index.js.map} +1 -1
  34. package/package.json +6 -4
  35. package/src/tui/App.tsx +339 -0
  36. package/src/tui/components/BarChart.tsx +198 -0
  37. package/src/tui/components/DailyView.tsx +113 -0
  38. package/src/tui/components/DateBreakdownPanel.tsx +79 -0
  39. package/src/tui/components/Footer.tsx +225 -0
  40. package/src/tui/components/Header.tsx +68 -0
  41. package/src/tui/components/Legend.tsx +39 -0
  42. package/src/tui/components/LoadingSpinner.tsx +82 -0
  43. package/src/tui/components/ModelRow.tsx +47 -0
  44. package/src/tui/components/ModelView.tsx +145 -0
  45. package/src/tui/components/OverviewView.tsx +108 -0
  46. package/{dist/tui/components/StatsView.jsx → src/tui/components/StatsView.tsx} +128 -83
  47. package/{dist/tui/components/TokenBreakdown.jsx → src/tui/components/TokenBreakdown.tsx} +28 -9
  48. package/src/tui/components/index.ts +15 -0
  49. package/src/tui/config/settings.ts +130 -0
  50. package/src/tui/config/themes.ts +115 -0
  51. package/src/tui/hooks/useData.ts +518 -0
  52. package/src/tui/index.tsx +44 -0
  53. package/src/tui/opentui.d.ts +137 -0
  54. package/src/tui/types/index.ts +165 -0
  55. package/src/tui/utils/cleanup.ts +65 -0
  56. package/src/tui/utils/colors.ts +65 -0
  57. package/src/tui/utils/format.ts +36 -0
  58. package/src/tui/utils/responsive.ts +8 -0
  59. package/dist/tui/components/DateBreakdownPanel.jsx +0 -61
  60. package/dist/tui/components/DateBreakdownPanel.jsx.map +0 -1
  61. package/dist/tui/components/Footer.jsx +0 -158
  62. package/dist/tui/components/Footer.jsx.map +0 -1
  63. package/dist/tui/components/Header.jsx +0 -38
  64. package/dist/tui/components/Header.jsx.map +0 -1
  65. package/dist/tui/components/Legend.jsx +0 -27
  66. package/dist/tui/components/Legend.jsx.map +0 -1
  67. package/dist/tui/components/ModelRow.jsx +0 -28
  68. package/dist/tui/components/ModelRow.jsx.map +0 -1
  69. package/dist/tui/components/StatsView.jsx.map +0 -1
  70. package/dist/tui/components/TokenBreakdown.jsx.map +0 -1
@@ -1,3 +1,4 @@
1
+ import { jsx as _jsx } from "@opentui/solid/jsx-runtime";
1
2
  import { render } from "@opentui/solid";
2
3
  import { App } from "./App.js";
3
4
  import { restoreTerminalState } from "./utils/cleanup.js";
@@ -24,7 +25,7 @@ export async function launchTUI(options) {
24
25
  process.exit(0);
25
26
  });
26
27
  process.on('beforeExit', cleanup);
27
- await render(() => <App {...(options ?? {})}/>, {
28
+ await render(() => _jsx(App, { ...(options ?? {}) }), {
28
29
  exitOnCtrlC: false,
29
30
  useAlternateScreen: true,
30
31
  useMouse: true,
@@ -32,4 +33,4 @@ export async function launchTUI(options) {
32
33
  useKittyKeyboard: {},
33
34
  });
34
35
  }
35
- //# sourceMappingURL=index.jsx.map
36
+ //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.jsx","sourceRoot":"","sources":["../../src/tui/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AACxC,OAAO,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAE/B,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAI1D,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,OAAoB;IAClD,MAAM,OAAO,GAAG,GAAG,EAAE;QACnB,oBAAoB,EAAE,CAAC;IACzB,CAAC,CAAC;IAEF,OAAO,CAAC,EAAE,CAAC,mBAAmB,EAAE,CAAC,KAAK,EAAE,EAAE;QACxC,OAAO,EAAE,CAAC;QACV,OAAO,CAAC,KAAK,CAAC,qBAAqB,EAAE,KAAK,CAAC,CAAC;QAC5C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;IAEH,OAAO,CAAC,EAAE,CAAC,oBAAoB,EAAE,CAAC,MAAM,EAAE,EAAE;QAC1C,OAAO,EAAE,CAAC;QACV,OAAO,CAAC,KAAK,CAAC,sBAAsB,EAAE,MAAM,CAAC,CAAC;QAC9C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;IAEH,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,GAAG,EAAE;QACxB,OAAO,EAAE,CAAC;QACV,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;IAEH,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE;QACzB,OAAO,EAAE,CAAC;QACV,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;IAEH,OAAO,CAAC,EAAE,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC;IAElC,MAAM,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,EAAG,EAAE;QAC/C,WAAW,EAAE,KAAK;QAClB,kBAAkB,EAAE,IAAI;QACxB,QAAQ,EAAE,IAAI;QACd,SAAS,EAAE,EAAE;QACb,gBAAgB,EAAE,EAAE;KACd,CAAC,CAAC;AACZ,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/tui/index.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AACxC,OAAO,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAE/B,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAI1D,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,OAAoB;IAClD,MAAM,OAAO,GAAG,GAAG,EAAE;QACnB,oBAAoB,EAAE,CAAC;IACzB,CAAC,CAAC;IAEF,OAAO,CAAC,EAAE,CAAC,mBAAmB,EAAE,CAAC,KAAK,EAAE,EAAE;QACxC,OAAO,EAAE,CAAC;QACV,OAAO,CAAC,KAAK,CAAC,qBAAqB,EAAE,KAAK,CAAC,CAAC;QAC5C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;IAEH,OAAO,CAAC,EAAE,CAAC,oBAAoB,EAAE,CAAC,MAAM,EAAE,EAAE;QAC1C,OAAO,EAAE,CAAC;QACV,OAAO,CAAC,KAAK,CAAC,sBAAsB,EAAE,MAAM,CAAC,CAAC;QAC9C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;IAEH,OAAO,CAAC,EAAE,CAAC,QAAQ,EAAE,GAAG,EAAE;QACxB,OAAO,EAAE,CAAC;QACV,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;IAEH,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE;QACzB,OAAO,EAAE,CAAC;QACV,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC,CAAC,CAAC;IAEH,OAAO,CAAC,EAAE,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC;IAElC,MAAM,MAAM,CAAC,GAAG,EAAE,CAAC,KAAC,GAAG,OAAK,CAAC,OAAO,IAAI,EAAE,CAAC,GAAI,EAAE;QAC/C,WAAW,EAAE,KAAK;QAClB,kBAAkB,EAAE,IAAI;QACxB,QAAQ,EAAE,IAAI;QACd,SAAS,EAAE,EAAE;QACb,gBAAgB,EAAE,EAAE;KACd,CAAC,CAAC;AACZ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokscale/cli",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "A high-performance CLI tool and visualization dashboard for tracking AI coding assistant token usage and costs across multiple platforms.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,7 +9,8 @@
9
9
  "tokscale": "./dist/cli.js"
10
10
  },
11
11
  "files": [
12
- "dist/**/*"
12
+ "dist/**/*",
13
+ "src/tui/**/*"
13
14
  ],
14
15
  "publishConfig": {
15
16
  "registry": "https://registry.npmjs.org/",
@@ -21,7 +22,8 @@
21
22
  "directory": "packages/cli"
22
23
  },
23
24
  "scripts": {
24
- "build": "tsc",
25
+ "build": "tsc && bun run build:tui",
26
+ "build:tui": "echo 'TUI bundling skipped - OpenTUI requires Bun runtime'",
25
27
  "dev": "bun src/cli.ts",
26
28
  "dev:node": "tsx src/cli.ts",
27
29
  "tui": "bun src/cli.ts tui",
@@ -29,7 +31,7 @@
29
31
  "prepublishOnly": "bun run build"
30
32
  },
31
33
  "dependencies": {
32
- "@tokscale/core": "1.0.4",
34
+ "@tokscale/core": "1.0.6",
33
35
  "@opentui/core": "0.1.60",
34
36
  "@opentui/react": "^0.1.60",
35
37
  "@opentui/solid": "^0.1.60",
@@ -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
+ }