@tokscale/cli 1.0.14 → 1.0.15

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/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 ?? "cost");
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 { Show, createSignal, createMemo, onMount, onCleanup } from "solid-js";
2
- import type { SourceType, SortType, TabType, LoadingPhase } from "../types/index.js";
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((Date.now() - timestamp) / 1000);
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
- const showScrollInfo = () =>
46
- props.activeTab === "overview" &&
47
- props.totalItems &&
48
- props.scrollStart !== undefined &&
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 = () => props.totals || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0, cost: 0 };
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 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} />
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 label="Cost" sortType="cost" active={props.sortBy === "cost"} onClick={props.onSortChange} />
65
- <SortButton label="Tokens" sortType="tokens" active={props.sortBy === "tokens"} onClick={props.onSortChange} />
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 dim>{`↓ ${props.scrollStart! + 1}-${props.scrollEnd} of ${props.totalItems}`}</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 when={props.statusMessage} fallback={
84
- <Show when={isVeryNarrowTerminal()} fallback={
85
- <>
86
- <text dim>↑↓ scroll • ←→/tab view • y copy •</text>
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">{`[p:${palette().name}]`}</text>
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:refresh]</text>
198
+ <text fg="yellow">[r]</text>
92
199
  </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>
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 dim>{`Last updated: ${formatTimeAgo(props.cacheTimestamp!)}`}</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 = ["#00FFFF", "#00D7D7", "#00AFAF", "#008787", "#666666", "#666666"];
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
- "idle": "Initializing...",
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
- "complete": "Complete",
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 = forwardFrames + SPINNER_HOLD_END + backwardFrames + SPINNER_HOLD_START;
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 { position: SPINNER_WIDTH - 2 - (normalized - forwardFrames - SPINNER_HOLD_END), forward: false };
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 = () => props.phase ? PHASE_MESSAGES[props.phase] : "Refreshing...";
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 grid = () => props.data.contributionGrid;
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
- return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
55
+ const raw = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
56
+ return validateSettings(raw);
29
57
  }
30
58
  } catch {
31
59
  }
32
- return { colorPalette: "green" };
60
+ return { colorPalette: "blue", autoRefreshEnabled: false, autoRefreshMs: DEFAULT_AUTO_REFRESH_MS };
33
61
  }
34
62
 
35
- export function saveSettings(settings: TUISettings): void {
36
- if (!existsSync(CONFIG_DIR)) {
37
- mkdirSync(CONFIG_DIR, { recursive: true });
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 {
@@ -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) => setError(e.message))
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
 
@@ -31,6 +31,7 @@ export interface DailyEntry {
31
31
  export interface ContributionDay {
32
32
  date: string;
33
33
  cost: number;
34
+ tokens: number;
34
35
  level: number;
35
36
  }
36
37
 
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
- ctx.fillStyle = COLORS.textSecondary;
644
- ctx.font = `${24 * SCALE}px Figtree, sans-serif`;
645
- ctx.fillText(`Tracking since ${formatDate(data.firstDay)}`, PADDING, yPos);
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;