@tokscale/cli 1.0.14 → 1.0.16
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 +7 -5
- package/dist/cli.js.map +1 -1
- package/dist/tui/App.d.ts.map +1 -1
- package/dist/tui/App.js +79 -3
- package/dist/tui/App.js.map +1 -1
- package/dist/tui/components/Footer.d.ts +2 -0
- package/dist/tui/components/Footer.d.ts.map +1 -1
- package/dist/tui/components/Footer.js +54 -10
- package/dist/tui/components/Footer.js.map +1 -1
- package/dist/tui/components/StatsView.d.ts +2 -0
- package/dist/tui/components/StatsView.d.ts.map +1 -1
- package/dist/tui/components/StatsView.js +17 -1
- package/dist/tui/components/StatsView.js.map +1 -1
- package/dist/tui/config/settings.d.ts +3 -1
- package/dist/tui/config/settings.d.ts.map +1 -1
- package/dist/tui/config/settings.js +33 -6
- package/dist/tui/config/settings.js.map +1 -1
- package/dist/tui/hooks/useData.d.ts.map +1 -1
- package/dist/tui/hooks/useData.js +28 -1
- package/dist/tui/hooks/useData.js.map +1 -1
- package/dist/tui/types/index.d.ts +1 -0
- package/dist/tui/types/index.d.ts.map +1 -1
- package/dist/tui/types/index.js.map +1 -1
- package/dist/wrapped.d.ts.map +1 -1
- package/dist/wrapped.js +14 -3
- package/dist/wrapped.js.map +1 -1
- package/package.json +2 -2
- package/src/cli.ts +7 -5
- package/src/tui/App.tsx +93 -2
- package/src/tui/components/Footer.tsx +167 -47
- package/src/tui/components/StatsView.tsx +25 -1
- package/src/tui/config/settings.ts +39 -6
- package/src/tui/hooks/useData.ts +24 -1
- package/src/tui/types/index.ts +1 -0
- package/src/wrapped.ts +14 -3
package/src/tui/App.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createSignal, Switch, Match, onCleanup } from "solid-js";
|
|
1
|
+
import { createEffect, createSignal, Switch, Match, onCleanup } from "solid-js";
|
|
2
2
|
import { useKeyboard, useTerminalDimensions, useRenderer } from "@opentui/solid";
|
|
3
3
|
import clipboardy from "clipboardy";
|
|
4
4
|
import { Header } from "./components/Header.js";
|
|
@@ -39,7 +39,7 @@ export function App(props: AppProps) {
|
|
|
39
39
|
const [enabledSources, setEnabledSources] = createSignal<Set<SourceType>>(
|
|
40
40
|
new Set(props.enabledSources ?? ALL_SOURCES)
|
|
41
41
|
);
|
|
42
|
-
const [sortBy, setSortBy] = createSignal<SortType>(props.sortBy ?? "
|
|
42
|
+
const [sortBy, setSortBy] = createSignal<SortType>(props.sortBy ?? "tokens");
|
|
43
43
|
const [sortDesc, setSortDesc] = createSignal(props.sortDesc ?? true);
|
|
44
44
|
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
|
45
45
|
const [scrollOffset, setScrollOffset] = createSignal(0);
|
|
@@ -61,6 +61,8 @@ export function App(props: AppProps) {
|
|
|
61
61
|
|
|
62
62
|
const [statusMessage, setStatusMessage] = createSignal<string | null>(null);
|
|
63
63
|
let statusTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
64
|
+
const [autoRefreshEnabled, setAutoRefreshEnabled] = createSignal(settings.autoRefreshEnabled ?? false);
|
|
65
|
+
const [autoRefreshMs, setAutoRefreshMs] = createSignal(settings.autoRefreshMs ?? 60000);
|
|
64
66
|
|
|
65
67
|
const showStatus = (msg: string, duration = 2000) => {
|
|
66
68
|
if (statusTimeout) clearTimeout(statusTimeout);
|
|
@@ -72,6 +74,68 @@ export function App(props: AppProps) {
|
|
|
72
74
|
if (statusTimeout) clearTimeout(statusTimeout);
|
|
73
75
|
});
|
|
74
76
|
|
|
77
|
+
const MIN_AUTO_REFRESH_MS = 30000;
|
|
78
|
+
const MAX_AUTO_REFRESH_MS = 3600000;
|
|
79
|
+
const AUTO_REFRESH_STEPS_MS = [
|
|
80
|
+
30000,
|
|
81
|
+
60000,
|
|
82
|
+
120000,
|
|
83
|
+
300000,
|
|
84
|
+
600000,
|
|
85
|
+
];
|
|
86
|
+
const AUTO_REFRESH_AFTER_MAX_STEP_MS = 600000;
|
|
87
|
+
|
|
88
|
+
const clampAutoRefresh = (value: number) =>
|
|
89
|
+
Math.min(MAX_AUTO_REFRESH_MS, Math.max(MIN_AUTO_REFRESH_MS, value));
|
|
90
|
+
|
|
91
|
+
const formatIntervalSeconds = (ms: number) => {
|
|
92
|
+
const seconds = Math.round(ms / 1000);
|
|
93
|
+
if (seconds < 60) return `${seconds}s`;
|
|
94
|
+
const minutes = Math.round(seconds / 60);
|
|
95
|
+
return `${minutes}m`;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const getAutoRefreshIntervalStep = (current: number, direction: "up" | "down") => {
|
|
99
|
+
const value = clampAutoRefresh(current);
|
|
100
|
+
const cappedSteps = AUTO_REFRESH_STEPS_MS;
|
|
101
|
+
const maxStep = cappedSteps[cappedSteps.length - 1];
|
|
102
|
+
|
|
103
|
+
if (value > maxStep) {
|
|
104
|
+
const delta = direction === "up" ? AUTO_REFRESH_AFTER_MAX_STEP_MS : -AUTO_REFRESH_AFTER_MAX_STEP_MS;
|
|
105
|
+
return clampAutoRefresh(value + delta);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (value === maxStep && direction === "up") {
|
|
109
|
+
return clampAutoRefresh(value + AUTO_REFRESH_AFTER_MAX_STEP_MS);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (value === maxStep && direction === "down") {
|
|
113
|
+
return cappedSteps[cappedSteps.length - 2];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let idx = cappedSteps.findIndex((step) => step >= value);
|
|
117
|
+
if (idx === -1) idx = cappedSteps.length - 1;
|
|
118
|
+
|
|
119
|
+
if (direction === "up") {
|
|
120
|
+
const nextIndex = value === cappedSteps[idx] ? idx + 1 : idx;
|
|
121
|
+
return clampAutoRefresh(cappedSteps[Math.min(nextIndex, cappedSteps.length - 1)]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const prevIndex = value === cappedSteps[idx] ? idx - 1 : idx - 1;
|
|
125
|
+
return clampAutoRefresh(cappedSteps[Math.max(prevIndex, 0)]);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
createEffect(() => {
|
|
129
|
+
if (!autoRefreshEnabled()) return;
|
|
130
|
+
const ms = autoRefreshMs();
|
|
131
|
+
const interval = setInterval(() => {
|
|
132
|
+
if (!loading() && !isRefreshing()) {
|
|
133
|
+
refresh();
|
|
134
|
+
}
|
|
135
|
+
}, ms);
|
|
136
|
+
onCleanup(() => clearInterval(interval));
|
|
137
|
+
});
|
|
138
|
+
|
|
75
139
|
const contentHeight = () => Math.max(rows() - 4, 12);
|
|
76
140
|
const overviewChartHeight = () => Math.max(5, Math.floor(contentHeight() * 0.35));
|
|
77
141
|
const overviewListHeight = () => Math.max(4, contentHeight() - overviewChartHeight() - 4);
|
|
@@ -108,6 +172,30 @@ export function App(props: AppProps) {
|
|
|
108
172
|
return;
|
|
109
173
|
}
|
|
110
174
|
|
|
175
|
+
if (key.name === "r" && key.shift) {
|
|
176
|
+
const next = !autoRefreshEnabled();
|
|
177
|
+
setAutoRefreshEnabled(next);
|
|
178
|
+
saveSettings({ autoRefreshEnabled: next });
|
|
179
|
+
showStatus(`Auto update: ${next ? "ON" : "OFF"} (${formatIntervalSeconds(autoRefreshMs())})`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if ((key.name === "+") || (key.name === "=" && key.shift)) {
|
|
184
|
+
const next = getAutoRefreshIntervalStep(autoRefreshMs(), "up");
|
|
185
|
+
setAutoRefreshMs(next);
|
|
186
|
+
saveSettings({ autoRefreshMs: next });
|
|
187
|
+
showStatus(`Auto update interval: ${formatIntervalSeconds(next)}`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (key.name === "-" || key.name === "_") {
|
|
192
|
+
const next = getAutoRefreshIntervalStep(autoRefreshMs(), "down");
|
|
193
|
+
setAutoRefreshMs(next);
|
|
194
|
+
saveSettings({ autoRefreshMs: next });
|
|
195
|
+
showStatus(`Auto update interval: ${formatIntervalSeconds(next)}`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
111
199
|
if (key.name === "r") {
|
|
112
200
|
refresh();
|
|
113
201
|
return;
|
|
@@ -307,6 +395,7 @@ export function App(props: AppProps) {
|
|
|
307
395
|
colorPalette={colorPalette()}
|
|
308
396
|
width={columns()}
|
|
309
397
|
selectedDate={selectedDate()}
|
|
398
|
+
sortBy={sortBy()}
|
|
310
399
|
/>
|
|
311
400
|
</Match>
|
|
312
401
|
</Switch>
|
|
@@ -328,6 +417,8 @@ export function App(props: AppProps) {
|
|
|
328
417
|
isRefreshing={isRefreshing()}
|
|
329
418
|
loadingPhase={loadingPhase()}
|
|
330
419
|
cacheTimestamp={cacheTimestamp()}
|
|
420
|
+
autoRefreshEnabled={autoRefreshEnabled()}
|
|
421
|
+
autoRefreshMs={autoRefreshMs()}
|
|
331
422
|
width={columns()}
|
|
332
423
|
onSourceToggle={handleSourceToggle}
|
|
333
424
|
onSortChange={handleSortChange}
|
|
@@ -1,5 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
Show,
|
|
3
|
+
createSignal,
|
|
4
|
+
createEffect,
|
|
5
|
+
onMount,
|
|
6
|
+
onCleanup,
|
|
7
|
+
} from "solid-js";
|
|
8
|
+
import type {
|
|
9
|
+
SourceType,
|
|
10
|
+
SortType,
|
|
11
|
+
TabType,
|
|
12
|
+
LoadingPhase,
|
|
13
|
+
} from "../types/index.js";
|
|
3
14
|
import type { ColorPaletteName } from "../config/themes.js";
|
|
4
15
|
import type { TotalBreakdown } from "../hooks/useData.js";
|
|
5
16
|
import { getPalette } from "../config/themes.js";
|
|
@@ -20,6 +31,8 @@ interface FooterProps {
|
|
|
20
31
|
isRefreshing?: boolean;
|
|
21
32
|
loadingPhase?: LoadingPhase;
|
|
22
33
|
cacheTimestamp?: number | null;
|
|
34
|
+
autoRefreshEnabled?: boolean;
|
|
35
|
+
autoRefreshMs?: number;
|
|
23
36
|
width?: number;
|
|
24
37
|
onSourceToggle?: (source: SourceType) => void;
|
|
25
38
|
onSortChange?: (sort: SortType) => void;
|
|
@@ -27,8 +40,8 @@ interface FooterProps {
|
|
|
27
40
|
onRefresh?: () => void;
|
|
28
41
|
}
|
|
29
42
|
|
|
30
|
-
function formatTimeAgo(timestamp: number): string {
|
|
31
|
-
const seconds = Math.floor((
|
|
43
|
+
function formatTimeAgo(timestamp: number, now: number): string {
|
|
44
|
+
const seconds = Math.max(Math.floor((now - timestamp) / 1000), 0);
|
|
32
45
|
if (seconds < 60) return `${seconds}s ago`;
|
|
33
46
|
const minutes = Math.floor(seconds / 60);
|
|
34
47
|
if (minutes < 60) return `${minutes}m ago`;
|
|
@@ -38,35 +51,106 @@ function formatTimeAgo(timestamp: number): string {
|
|
|
38
51
|
return `${days}d ago`;
|
|
39
52
|
}
|
|
40
53
|
|
|
54
|
+
function formatIntervalSeconds(ms: number | undefined): string {
|
|
55
|
+
if (!ms || ms <= 0) return "0s";
|
|
56
|
+
const seconds = Math.round(ms / 1000);
|
|
57
|
+
if (seconds < 60) return `${seconds}s`;
|
|
58
|
+
const minutes = Math.round(seconds / 60);
|
|
59
|
+
return `${minutes}m`;
|
|
60
|
+
}
|
|
61
|
+
|
|
41
62
|
export function Footer(props: FooterProps) {
|
|
42
63
|
const palette = () => getPalette(props.colorPalette);
|
|
43
64
|
const isVeryNarrowTerminal = () => isVeryNarrow(props.width);
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
props.
|
|
65
|
+
const [now, setNow] = createSignal(Date.now());
|
|
66
|
+
let nowInterval: ReturnType<typeof setInterval> | null = null;
|
|
67
|
+
|
|
68
|
+
createEffect(() => {
|
|
69
|
+
if (props.cacheTimestamp) {
|
|
70
|
+
if (!nowInterval) {
|
|
71
|
+
nowInterval = setInterval(() => setNow(Date.now()), 1000);
|
|
72
|
+
}
|
|
73
|
+
} else if (nowInterval) {
|
|
74
|
+
clearInterval(nowInterval);
|
|
75
|
+
nowInterval = null;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
onCleanup(() => {
|
|
80
|
+
if (nowInterval) clearInterval(nowInterval);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const showScrollInfo = () =>
|
|
84
|
+
props.activeTab === "overview" &&
|
|
85
|
+
props.totalItems &&
|
|
86
|
+
props.scrollStart !== undefined &&
|
|
49
87
|
props.scrollEnd !== undefined;
|
|
50
88
|
|
|
51
|
-
const totals = () =>
|
|
89
|
+
const totals = () =>
|
|
90
|
+
props.totals || {
|
|
91
|
+
input: 0,
|
|
92
|
+
output: 0,
|
|
93
|
+
cacheRead: 0,
|
|
94
|
+
cacheWrite: 0,
|
|
95
|
+
reasoning: 0,
|
|
96
|
+
total: 0,
|
|
97
|
+
cost: 0,
|
|
98
|
+
};
|
|
52
99
|
|
|
53
100
|
return (
|
|
54
101
|
<box flexDirection="column" paddingX={1}>
|
|
55
102
|
<box flexDirection="row" justifyContent="space-between">
|
|
56
103
|
<box flexDirection="row" gap={1}>
|
|
57
|
-
<SourceBadge
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
104
|
+
<SourceBadge
|
|
105
|
+
name={isVeryNarrowTerminal() ? "1" : "1:OC"}
|
|
106
|
+
source="opencode"
|
|
107
|
+
enabled={props.enabledSources.has("opencode")}
|
|
108
|
+
onToggle={props.onSourceToggle}
|
|
109
|
+
/>
|
|
110
|
+
<SourceBadge
|
|
111
|
+
name={isVeryNarrowTerminal() ? "2" : "2:CC"}
|
|
112
|
+
source="claude"
|
|
113
|
+
enabled={props.enabledSources.has("claude")}
|
|
114
|
+
onToggle={props.onSourceToggle}
|
|
115
|
+
/>
|
|
116
|
+
<SourceBadge
|
|
117
|
+
name={isVeryNarrowTerminal() ? "3" : "3:CX"}
|
|
118
|
+
source="codex"
|
|
119
|
+
enabled={props.enabledSources.has("codex")}
|
|
120
|
+
onToggle={props.onSourceToggle}
|
|
121
|
+
/>
|
|
122
|
+
<SourceBadge
|
|
123
|
+
name={isVeryNarrowTerminal() ? "4" : "4:CR"}
|
|
124
|
+
source="cursor"
|
|
125
|
+
enabled={props.enabledSources.has("cursor")}
|
|
126
|
+
onToggle={props.onSourceToggle}
|
|
127
|
+
/>
|
|
128
|
+
<SourceBadge
|
|
129
|
+
name={isVeryNarrowTerminal() ? "5" : "5:GM"}
|
|
130
|
+
source="gemini"
|
|
131
|
+
enabled={props.enabledSources.has("gemini")}
|
|
132
|
+
onToggle={props.onSourceToggle}
|
|
133
|
+
/>
|
|
62
134
|
<Show when={!isVeryNarrowTerminal()}>
|
|
63
135
|
<text dim>|</text>
|
|
64
|
-
<SortButton
|
|
65
|
-
|
|
136
|
+
<SortButton
|
|
137
|
+
label="Cost"
|
|
138
|
+
sortType="cost"
|
|
139
|
+
active={props.sortBy === "cost"}
|
|
140
|
+
onClick={props.onSortChange}
|
|
141
|
+
/>
|
|
142
|
+
<SortButton
|
|
143
|
+
label="Tokens"
|
|
144
|
+
sortType="tokens"
|
|
145
|
+
active={props.sortBy === "tokens"}
|
|
146
|
+
onClick={props.onSortChange}
|
|
147
|
+
/>
|
|
66
148
|
</Show>
|
|
67
149
|
<Show when={showScrollInfo() && !isVeryNarrowTerminal()}>
|
|
68
150
|
<text dim>|</text>
|
|
69
|
-
<text
|
|
151
|
+
<text
|
|
152
|
+
dim
|
|
153
|
+
>{`↓ ${props.scrollStart! + 1}-${props.scrollEnd} of ${props.totalItems}`}</text>
|
|
70
154
|
</Show>
|
|
71
155
|
</box>
|
|
72
156
|
<box flexDirection="row" gap={1}>
|
|
@@ -80,38 +164,61 @@ export function Footer(props: FooterProps) {
|
|
|
80
164
|
</box>
|
|
81
165
|
</box>
|
|
82
166
|
<box flexDirection="row" gap={1}>
|
|
83
|
-
<Show
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
167
|
+
<Show
|
|
168
|
+
when={props.statusMessage}
|
|
169
|
+
fallback={
|
|
170
|
+
<Show
|
|
171
|
+
when={isVeryNarrowTerminal()}
|
|
172
|
+
fallback={
|
|
173
|
+
<>
|
|
174
|
+
<text dim>↑↓ scroll • ←→/tab view • y copy •</text>
|
|
175
|
+
<box onMouseDown={props.onPaletteChange}>
|
|
176
|
+
<text fg="magenta">{`[p:${palette().name}]`}</text>
|
|
177
|
+
</box>
|
|
178
|
+
<text fg={props.autoRefreshEnabled ? "green" : "gray"}>
|
|
179
|
+
{`[Shift+R:auto update ${formatIntervalSeconds(props.autoRefreshMs)}]`}
|
|
180
|
+
</text>
|
|
181
|
+
<text dim>[-/+ interval]•</text>
|
|
182
|
+
<box onMouseDown={props.onRefresh}>
|
|
183
|
+
<text fg="yellow">[r:refresh]</text>
|
|
184
|
+
</box>
|
|
185
|
+
<text dim>• e export • q quit</text>
|
|
186
|
+
</>
|
|
187
|
+
}
|
|
188
|
+
>
|
|
189
|
+
<text dim>↑↓•←→•y•</text>
|
|
87
190
|
<box onMouseDown={props.onPaletteChange}>
|
|
88
|
-
<text fg="magenta">
|
|
191
|
+
<text fg="magenta">[p]</text>
|
|
89
192
|
</box>
|
|
193
|
+
<text fg={props.autoRefreshEnabled ? "green" : "gray"}>
|
|
194
|
+
{`[Shift+R:auto update ${formatIntervalSeconds(props.autoRefreshMs)}]`}
|
|
195
|
+
</text>
|
|
196
|
+
<text dim>-+•</text>
|
|
90
197
|
<box onMouseDown={props.onRefresh}>
|
|
91
|
-
<text fg="yellow">[r
|
|
198
|
+
<text fg="yellow">[r]</text>
|
|
92
199
|
</box>
|
|
93
|
-
<text dim>•
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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>
|
|
200
|
+
<text dim>•e•q</text>
|
|
201
|
+
</Show>
|
|
202
|
+
}
|
|
203
|
+
>
|
|
204
|
+
<text fg="green" bold>
|
|
205
|
+
{props.statusMessage}
|
|
206
|
+
</text>
|
|
107
207
|
</Show>
|
|
108
208
|
</box>
|
|
109
209
|
<Show when={props.isRefreshing}>
|
|
110
210
|
<LoadingStatusLine phase={props.loadingPhase} />
|
|
111
211
|
</Show>
|
|
112
212
|
<Show when={!props.isRefreshing && props.cacheTimestamp}>
|
|
113
|
-
<box flexDirection="row">
|
|
114
|
-
<text
|
|
213
|
+
<box flexDirection="row" gap={1}>
|
|
214
|
+
<text
|
|
215
|
+
dim
|
|
216
|
+
>{`Last updated: ${formatTimeAgo(props.cacheTimestamp!, now())}`}</text>
|
|
217
|
+
<Show when={props.autoRefreshEnabled}>
|
|
218
|
+
<text
|
|
219
|
+
dim
|
|
220
|
+
>{`• Auto: ${formatIntervalSeconds(props.autoRefreshMs)}`}</text>
|
|
221
|
+
</Show>
|
|
115
222
|
</box>
|
|
116
223
|
</Show>
|
|
117
224
|
</box>
|
|
@@ -156,7 +263,14 @@ function SortButton(props: SortButtonProps) {
|
|
|
156
263
|
);
|
|
157
264
|
}
|
|
158
265
|
|
|
159
|
-
const SPINNER_COLORS = [
|
|
266
|
+
const SPINNER_COLORS = [
|
|
267
|
+
"#00FFFF",
|
|
268
|
+
"#00D7D7",
|
|
269
|
+
"#00AFAF",
|
|
270
|
+
"#008787",
|
|
271
|
+
"#666666",
|
|
272
|
+
"#666666",
|
|
273
|
+
];
|
|
160
274
|
const SPINNER_WIDTH = 6;
|
|
161
275
|
const SPINNER_HOLD_START = 20;
|
|
162
276
|
const SPINNER_HOLD_END = 6;
|
|
@@ -164,12 +278,12 @@ const SPINNER_TRAIL = 3;
|
|
|
164
278
|
const SPINNER_INTERVAL = 40;
|
|
165
279
|
|
|
166
280
|
const PHASE_MESSAGES: Record<LoadingPhase, string> = {
|
|
167
|
-
|
|
281
|
+
idle: "Initializing...",
|
|
168
282
|
"loading-pricing": "Loading pricing data...",
|
|
169
283
|
"syncing-cursor": "Syncing Cursor data...",
|
|
170
284
|
"parsing-sources": "Parsing session files...",
|
|
171
285
|
"finalizing-report": "Finalizing report...",
|
|
172
|
-
|
|
286
|
+
complete: "Complete",
|
|
173
287
|
};
|
|
174
288
|
|
|
175
289
|
interface LoadingStatusLineProps {
|
|
@@ -180,14 +294,15 @@ function LoadingStatusLine(props: LoadingStatusLineProps) {
|
|
|
180
294
|
const [frame, setFrame] = createSignal(0);
|
|
181
295
|
|
|
182
296
|
onMount(() => {
|
|
183
|
-
const id = setInterval(() => setFrame(f => f + 1), SPINNER_INTERVAL);
|
|
297
|
+
const id = setInterval(() => setFrame((f) => f + 1), SPINNER_INTERVAL);
|
|
184
298
|
onCleanup(() => clearInterval(id));
|
|
185
299
|
});
|
|
186
300
|
|
|
187
301
|
const getSpinnerState = () => {
|
|
188
302
|
const forwardFrames = SPINNER_WIDTH;
|
|
189
303
|
const backwardFrames = SPINNER_WIDTH - 1;
|
|
190
|
-
const totalCycle =
|
|
304
|
+
const totalCycle =
|
|
305
|
+
forwardFrames + SPINNER_HOLD_END + backwardFrames + SPINNER_HOLD_START;
|
|
191
306
|
const normalized = frame() % totalCycle;
|
|
192
307
|
|
|
193
308
|
if (normalized < forwardFrames) {
|
|
@@ -195,7 +310,11 @@ function LoadingStatusLine(props: LoadingStatusLineProps) {
|
|
|
195
310
|
} else if (normalized < forwardFrames + SPINNER_HOLD_END) {
|
|
196
311
|
return { position: SPINNER_WIDTH - 1, forward: true };
|
|
197
312
|
} else if (normalized < forwardFrames + SPINNER_HOLD_END + backwardFrames) {
|
|
198
|
-
return {
|
|
313
|
+
return {
|
|
314
|
+
position:
|
|
315
|
+
SPINNER_WIDTH - 2 - (normalized - forwardFrames - SPINNER_HOLD_END),
|
|
316
|
+
forward: false,
|
|
317
|
+
};
|
|
199
318
|
}
|
|
200
319
|
return { position: 0, forward: false };
|
|
201
320
|
};
|
|
@@ -209,7 +328,8 @@ function LoadingStatusLine(props: LoadingStatusLineProps) {
|
|
|
209
328
|
return { char: "⬝", color: "#444444" };
|
|
210
329
|
};
|
|
211
330
|
|
|
212
|
-
const message = () =>
|
|
331
|
+
const message = () =>
|
|
332
|
+
props.phase ? PHASE_MESSAGES[props.phase] : "Refreshing...";
|
|
213
333
|
|
|
214
334
|
return (
|
|
215
335
|
<box flexDirection="row" gap={1}>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { For, Show, createMemo, createSignal } from "solid-js";
|
|
2
2
|
import type { TUIData } from "../hooks/useData.js";
|
|
3
3
|
import type { ColorPaletteName } from "../config/themes.js";
|
|
4
|
+
import type { SortType, GridCell } from "../types/index.js";
|
|
4
5
|
import { getPalette, getGradeColor } from "../config/themes.js";
|
|
5
6
|
import { getModelColor } from "../utils/colors.js";
|
|
6
7
|
import { formatTokens } from "../utils/format.js";
|
|
@@ -13,6 +14,7 @@ interface StatsViewProps {
|
|
|
13
14
|
colorPalette: ColorPaletteName;
|
|
14
15
|
width?: number;
|
|
15
16
|
selectedDate?: string | null;
|
|
17
|
+
sortBy?: SortType;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
@@ -27,8 +29,30 @@ interface MonthLabel {
|
|
|
27
29
|
export function StatsView(props: StatsViewProps) {
|
|
28
30
|
const palette = () => getPalette(props.colorPalette);
|
|
29
31
|
const isNarrowTerminal = () => isNarrow(props.width);
|
|
30
|
-
const
|
|
32
|
+
const metric = () => props.sortBy ?? "tokens";
|
|
31
33
|
const cellWidth = 2;
|
|
34
|
+
|
|
35
|
+
const grid = createMemo((): GridCell[][] => {
|
|
36
|
+
const contributions = props.data.contributions;
|
|
37
|
+
const baseGrid = props.data.contributionGrid;
|
|
38
|
+
|
|
39
|
+
const values = contributions.map(c => metric() === "tokens" ? c.tokens : c.cost);
|
|
40
|
+
const maxValue = Math.max(1, ...values);
|
|
41
|
+
|
|
42
|
+
const levelMap = new Map<string, number>();
|
|
43
|
+
for (const c of contributions) {
|
|
44
|
+
const value = metric() === "tokens" ? c.tokens : c.cost;
|
|
45
|
+
const level = value === 0 ? 0 : Math.min(4, Math.ceil((value / maxValue) * 4));
|
|
46
|
+
levelMap.set(c.date, level);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return baseGrid.map(row =>
|
|
50
|
+
row.map(cell => ({
|
|
51
|
+
date: cell.date,
|
|
52
|
+
level: cell.date ? (levelMap.get(cell.date) ?? 0) : 0,
|
|
53
|
+
}))
|
|
54
|
+
);
|
|
55
|
+
});
|
|
32
56
|
|
|
33
57
|
const [clickedCell, setClickedCell] = createSignal<string | null>(null);
|
|
34
58
|
|
|
@@ -9,9 +9,36 @@ const CONFIG_FILE = join(CONFIG_DIR, "tui-settings.json");
|
|
|
9
9
|
const CACHE_FILE = join(CACHE_DIR, "tui-data-cache.json");
|
|
10
10
|
|
|
11
11
|
const CACHE_STALE_THRESHOLD_MS = 60 * 1000;
|
|
12
|
+
const MIN_AUTO_REFRESH_MS = 30000;
|
|
13
|
+
const MAX_AUTO_REFRESH_MS = 3600000;
|
|
14
|
+
const DEFAULT_AUTO_REFRESH_MS = 60000;
|
|
12
15
|
|
|
13
16
|
interface TUISettings {
|
|
14
17
|
colorPalette: string;
|
|
18
|
+
autoRefreshEnabled?: boolean;
|
|
19
|
+
autoRefreshMs?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function validateSettings(raw: unknown): TUISettings {
|
|
23
|
+
const defaults: TUISettings = {
|
|
24
|
+
colorPalette: "blue",
|
|
25
|
+
autoRefreshEnabled: false,
|
|
26
|
+
autoRefreshMs: DEFAULT_AUTO_REFRESH_MS
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
if (!raw || typeof raw !== "object") return defaults;
|
|
30
|
+
|
|
31
|
+
const obj = raw as Record<string, unknown>;
|
|
32
|
+
|
|
33
|
+
const colorPalette = typeof obj.colorPalette === "string" ? obj.colorPalette : defaults.colorPalette;
|
|
34
|
+
const autoRefreshEnabled = typeof obj.autoRefreshEnabled === "boolean" ? obj.autoRefreshEnabled : defaults.autoRefreshEnabled;
|
|
35
|
+
|
|
36
|
+
let autoRefreshMs = defaults.autoRefreshMs;
|
|
37
|
+
if (typeof obj.autoRefreshMs === "number" && Number.isFinite(obj.autoRefreshMs)) {
|
|
38
|
+
autoRefreshMs = Math.min(MAX_AUTO_REFRESH_MS, Math.max(MIN_AUTO_REFRESH_MS, obj.autoRefreshMs));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { colorPalette, autoRefreshEnabled, autoRefreshMs };
|
|
15
42
|
}
|
|
16
43
|
|
|
17
44
|
interface CachedTUIData {
|
|
@@ -25,18 +52,24 @@ interface CachedTUIData {
|
|
|
25
52
|
export function loadSettings(): TUISettings {
|
|
26
53
|
try {
|
|
27
54
|
if (existsSync(CONFIG_FILE)) {
|
|
28
|
-
|
|
55
|
+
const raw = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
56
|
+
return validateSettings(raw);
|
|
29
57
|
}
|
|
30
58
|
} catch {
|
|
31
59
|
}
|
|
32
|
-
return { colorPalette: "
|
|
60
|
+
return { colorPalette: "blue", autoRefreshEnabled: false, autoRefreshMs: DEFAULT_AUTO_REFRESH_MS };
|
|
33
61
|
}
|
|
34
62
|
|
|
35
|
-
export function saveSettings(
|
|
36
|
-
|
|
37
|
-
|
|
63
|
+
export function saveSettings(updates: Partial<TUISettings>): void {
|
|
64
|
+
try {
|
|
65
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
66
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
const current = loadSettings();
|
|
69
|
+
const merged = { ...current, ...updates };
|
|
70
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
|
|
71
|
+
} catch {
|
|
38
72
|
}
|
|
39
|
-
writeFileSync(CONFIG_FILE, JSON.stringify(settings, null, 2));
|
|
40
73
|
}
|
|
41
74
|
|
|
42
75
|
function sourcesMatch(enabledSources: Set<string>, cachedSources: string[]): boolean {
|
package/src/tui/hooks/useData.ts
CHANGED
|
@@ -245,9 +245,14 @@ async function loadData(enabledSources: Set<SourceType>, dateFilters?: DateFilte
|
|
|
245
245
|
for (const d of dailyEntries) {
|
|
246
246
|
if (d.cost > maxCost) maxCost = d.cost;
|
|
247
247
|
}
|
|
248
|
+
let maxTokens = 1;
|
|
249
|
+
for (const d of dailyEntries) {
|
|
250
|
+
if (d.total > maxTokens) maxTokens = d.total;
|
|
251
|
+
}
|
|
248
252
|
const contributions: ContributionDay[] = dailyEntries.map(d => ({
|
|
249
253
|
date: d.date,
|
|
250
254
|
cost: d.cost,
|
|
255
|
+
tokens: d.total,
|
|
251
256
|
level: d.cost === 0 ? 0 : (Math.min(4, Math.ceil((d.cost / maxCost) * 4)) as 0 | 1 | 2 | 3 | 4),
|
|
252
257
|
}));
|
|
253
258
|
|
|
@@ -452,13 +457,21 @@ export function useData(enabledSources: Accessor<Set<SourceType>>, dateFilters?:
|
|
|
452
457
|
const [isRefreshing, setIsRefreshing] = createSignal(initialCachedData ? initialCacheIsStale : false);
|
|
453
458
|
|
|
454
459
|
const [forceRefresh, setForceRefresh] = createSignal(false);
|
|
460
|
+
let pendingRefresh = false;
|
|
461
|
+
let currentRequestId = 0;
|
|
455
462
|
|
|
456
463
|
const refresh = () => {
|
|
464
|
+
if (isRefreshing() || loading()) {
|
|
465
|
+
pendingRefresh = true;
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
setIsRefreshing(true);
|
|
457
469
|
setForceRefresh(true);
|
|
458
470
|
setRefreshTrigger(prev => prev + 1);
|
|
459
471
|
};
|
|
460
472
|
|
|
461
473
|
const doLoad = (sources: Set<SourceType>, skipCacheCheck = false) => {
|
|
474
|
+
++currentRequestId; // Invalidate any in-flight requests immediately
|
|
462
475
|
const shouldSkipCache = skipCacheCheck || forceRefresh();
|
|
463
476
|
|
|
464
477
|
if (!shouldSkipCache) {
|
|
@@ -488,17 +501,27 @@ export function useData(enabledSources: Accessor<Set<SourceType>>, dateFilters?:
|
|
|
488
501
|
setForceRefresh(false);
|
|
489
502
|
}
|
|
490
503
|
|
|
504
|
+
const requestId = currentRequestId;
|
|
491
505
|
setError(null);
|
|
492
506
|
loadData(sources, dateFilters)
|
|
493
507
|
.then((freshData) => {
|
|
508
|
+
if (requestId !== currentRequestId) return;
|
|
494
509
|
setData(freshData);
|
|
495
510
|
saveCachedData(freshData, sources);
|
|
496
511
|
})
|
|
497
|
-
.catch((e) =>
|
|
512
|
+
.catch((e: unknown) => {
|
|
513
|
+
if (requestId !== currentRequestId) return;
|
|
514
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
515
|
+
})
|
|
498
516
|
.finally(() => {
|
|
517
|
+
if (requestId !== currentRequestId) return;
|
|
499
518
|
setLoading(false);
|
|
500
519
|
setIsRefreshing(false);
|
|
501
520
|
setLoadingPhase("complete");
|
|
521
|
+
if (pendingRefresh) {
|
|
522
|
+
pendingRefresh = false;
|
|
523
|
+
refresh();
|
|
524
|
+
}
|
|
502
525
|
});
|
|
503
526
|
};
|
|
504
527
|
|
package/src/tui/types/index.ts
CHANGED
package/src/wrapped.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "./native.js";
|
|
12
12
|
import { PricingFetcher } from "./pricing.js";
|
|
13
13
|
import { syncCursorCache, loadCursorCredentials } from "./cursor.js";
|
|
14
|
+
import { loadCredentials } from "./credentials.js";
|
|
14
15
|
import type { SourceType } from "./graph-types.js";
|
|
15
16
|
|
|
16
17
|
interface WrappedData {
|
|
@@ -640,9 +641,19 @@ async function generateWrappedImage(data: WrappedData, options: { short?: boolea
|
|
|
640
641
|
|
|
641
642
|
let yPos = PADDING + 24 * SCALE;
|
|
642
643
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
644
|
+
const credentials = loadCredentials();
|
|
645
|
+
const MAX_USERNAME_LENGTH = 30; // GitHub max is 39, but leave room for layout
|
|
646
|
+
const displayUsername = credentials?.username
|
|
647
|
+
? credentials.username.length > MAX_USERNAME_LENGTH
|
|
648
|
+
? credentials.username.substring(0, MAX_USERNAME_LENGTH - 1) + '…'
|
|
649
|
+
: credentials.username
|
|
650
|
+
: null;
|
|
651
|
+
const titleText = displayUsername
|
|
652
|
+
? `@${displayUsername}'s Wrapped ${data.year}`
|
|
653
|
+
: `My Wrapped ${data.year}`;
|
|
654
|
+
ctx.fillStyle = COLORS.textPrimary;
|
|
655
|
+
ctx.font = `bold ${28 * SCALE}px Figtree, sans-serif`;
|
|
656
|
+
ctx.fillText(titleText, PADDING, yPos);
|
|
646
657
|
yPos += 60 * SCALE;
|
|
647
658
|
|
|
648
659
|
ctx.fillStyle = COLORS.textSecondary;
|