@tokscale/cli 1.0.13 → 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.
Files changed (51) hide show
  1. package/dist/cli.js +11 -5
  2. package/dist/cli.js.map +1 -1
  3. package/dist/native.d.ts +3 -0
  4. package/dist/native.d.ts.map +1 -1
  5. package/dist/native.js +4 -3
  6. package/dist/native.js.map +1 -1
  7. package/dist/sessions/opencode.d.ts +1 -0
  8. package/dist/sessions/opencode.d.ts.map +1 -1
  9. package/dist/sessions/opencode.js +16 -1
  10. package/dist/sessions/opencode.js.map +1 -1
  11. package/dist/sessions/types.d.ts +2 -4
  12. package/dist/sessions/types.d.ts.map +1 -1
  13. package/dist/sessions/types.js +2 -4
  14. package/dist/sessions/types.js.map +1 -1
  15. package/dist/tui/App.d.ts.map +1 -1
  16. package/dist/tui/App.js +79 -3
  17. package/dist/tui/App.js.map +1 -1
  18. package/dist/tui/components/Footer.d.ts +2 -0
  19. package/dist/tui/components/Footer.d.ts.map +1 -1
  20. package/dist/tui/components/Footer.js +54 -10
  21. package/dist/tui/components/Footer.js.map +1 -1
  22. package/dist/tui/components/StatsView.d.ts +2 -0
  23. package/dist/tui/components/StatsView.d.ts.map +1 -1
  24. package/dist/tui/components/StatsView.js +17 -1
  25. package/dist/tui/components/StatsView.js.map +1 -1
  26. package/dist/tui/config/settings.d.ts +3 -1
  27. package/dist/tui/config/settings.d.ts.map +1 -1
  28. package/dist/tui/config/settings.js +33 -6
  29. package/dist/tui/config/settings.js.map +1 -1
  30. package/dist/tui/hooks/useData.d.ts.map +1 -1
  31. package/dist/tui/hooks/useData.js +28 -1
  32. package/dist/tui/hooks/useData.js.map +1 -1
  33. package/dist/tui/types/index.d.ts +1 -0
  34. package/dist/tui/types/index.d.ts.map +1 -1
  35. package/dist/tui/types/index.js.map +1 -1
  36. package/dist/wrapped.d.ts +8 -0
  37. package/dist/wrapped.d.ts.map +1 -1
  38. package/dist/wrapped.js +184 -32
  39. package/dist/wrapped.js.map +1 -1
  40. package/package.json +1 -1
  41. package/src/cli.ts +14 -6
  42. package/src/native.ts +8 -3
  43. package/src/sessions/opencode.ts +24 -1
  44. package/src/sessions/types.ts +6 -6
  45. package/src/tui/App.tsx +93 -2
  46. package/src/tui/components/Footer.tsx +167 -47
  47. package/src/tui/components/StatsView.tsx +25 -1
  48. package/src/tui/config/settings.ts +39 -6
  49. package/src/tui/hooks/useData.ts +24 -1
  50. package/src/tui/types/index.ts +1 -0
  51. package/src/wrapped.ts +220 -42
package/src/cli.ts CHANGED
@@ -295,9 +295,9 @@ async function main() {
295
295
 
296
296
  program
297
297
  .command("wrapped")
298
- .description("Generate 2025 Wrapped shareable image")
299
- .option("--output <file>", "Output file path (default: tokscale-2025-wrapped.png)")
300
- .option("--year <year>", "Year to generate wrapped for (default: current year)")
298
+ .description("Generate Wrapped shareable image")
299
+ .option("--output <file>", "Output file path (default: tokscale-<year>-wrapped.png)")
300
+ .option("--year <year>", "Year to generate (default: current year)")
301
301
  .option("--opencode", "Include only OpenCode data")
302
302
  .option("--claude", "Include only Claude Code data")
303
303
  .option("--codex", "Include only Codex CLI data")
@@ -305,6 +305,8 @@ async function main() {
305
305
  .option("--cursor", "Include only Cursor IDE data")
306
306
  .option("--no-spinner", "Disable loading spinner (for scripting)")
307
307
  .option("--short", "Display total tokens in abbreviated format (e.g., 7.14B)")
308
+ .option("--agents", "Show Top OpenCode Agents instead of Top Clients")
309
+ .option("--pin-sisyphus", "Pin Sisyphus and Planner-Sisyphus at top of agents list")
308
310
  .action(async (options) => {
309
311
  await handleWrappedCommand(options);
310
312
  });
@@ -924,22 +926,28 @@ async function handleGraphCommand(options: GraphCommandOptions) {
924
926
  interface WrappedCommandOptions extends FilterOptions {
925
927
  output?: string;
926
928
  year?: string;
927
- spinner?: boolean; // --no-spinner sets this to false
929
+ spinner?: boolean;
928
930
  short?: boolean;
931
+ agents?: boolean;
932
+ pinSisyphus?: boolean;
929
933
  }
930
934
 
931
935
  async function handleWrappedCommand(options: WrappedCommandOptions) {
932
936
  const useSpinner = options.spinner !== false;
933
937
  const spinner = useSpinner ? createSpinner({ color: "cyan" }) : null;
934
- spinner?.start(pc.gray("Generating your 2025 Wrapped..."));
938
+ const currentYear = new Date().getFullYear().toString();
939
+ const year = options.year || currentYear;
940
+ spinner?.start(pc.gray(`Generating your ${year} Wrapped...`));
935
941
 
936
942
  try {
937
943
  const enabledSources = getEnabledSources(options);
938
944
  const outputPath = await generateWrapped({
939
945
  output: options.output,
940
- year: options.year || "2025",
946
+ year,
941
947
  sources: enabledSources,
942
948
  short: options.short,
949
+ includeAgents: options.agents,
950
+ pinSisyphus: options.pinSisyphus,
943
951
  });
944
952
 
945
953
  spinner?.stop();
package/src/native.ts CHANGED
@@ -183,6 +183,7 @@ interface NativeParsedMessage {
183
183
  cacheWrite: number;
184
184
  reasoning: number;
185
185
  sessionId: string;
186
+ agent?: string;
186
187
  }
187
188
 
188
189
  interface NativeParsedMessages {
@@ -435,6 +436,7 @@ export interface ParsedMessages {
435
436
  cacheWrite: number;
436
437
  reasoning: number;
437
438
  sessionId: string;
439
+ agent?: string;
438
440
  }>;
439
441
  opencodeCount: number;
440
442
  claudeCount: number;
@@ -448,6 +450,8 @@ export interface LocalParseOptions {
448
450
  since?: string;
449
451
  until?: string;
450
452
  year?: string;
453
+ /** Force TypeScript fallback even when native module is available (needed for agent field) */
454
+ forceTypescript?: boolean;
451
455
  }
452
456
 
453
457
  export interface FinalizeOptions {
@@ -637,8 +641,8 @@ async function runInSubprocess<T>(method: string, args: unknown[]): Promise<T> {
637
641
  }
638
642
 
639
643
  export async function parseLocalSourcesAsync(options: LocalParseOptions): Promise<ParsedMessages> {
640
- // Use TypeScript fallback when native module is not available
641
- if (!isNativeAvailable()) {
644
+ // Use TypeScript fallback when native module is not available or when explicitly requested
645
+ if (!isNativeAvailable() || options.forceTypescript) {
642
646
  const result = parseLocalSourcesTS({
643
647
  sources: options.sources,
644
648
  since: options.since,
@@ -646,7 +650,6 @@ export async function parseLocalSourcesAsync(options: LocalParseOptions): Promis
646
650
  year: options.year,
647
651
  });
648
652
 
649
- // Convert TypeScript ParsedMessages to native format
650
653
  return {
651
654
  messages: result.messages.map((msg) => ({
652
655
  source: msg.source,
@@ -660,6 +663,7 @@ export async function parseLocalSourcesAsync(options: LocalParseOptions): Promis
660
663
  cacheWrite: msg.tokens.cacheWrite,
661
664
  reasoning: msg.tokens.reasoning,
662
665
  sessionId: msg.sessionId,
666
+ agent: msg.agent,
663
667
  })),
664
668
  opencodeCount: result.opencodeCount,
665
669
  claudeCount: result.claudeCount,
@@ -696,6 +700,7 @@ function buildMessagesForFallback(options: FinalizeOptions): UnifiedMessage[] {
696
700
  reasoning: msg.reasoning,
697
701
  },
698
702
  cost: 0,
703
+ agent: msg.agent,
699
704
  }));
700
705
 
701
706
  if (options.includeCursor) {
@@ -28,6 +28,25 @@ interface OpenCodeMessageFile {
28
28
  created: number;
29
29
  completed?: number;
30
30
  };
31
+ agent?: string;
32
+ mode?: string;
33
+ }
34
+
35
+ export function normalizeAgentName(agent: string): string {
36
+ const agentLower = agent.toLowerCase();
37
+
38
+ if (agentLower.includes("plan")) {
39
+ if (agentLower.includes("omo") || agentLower.includes("sisyphus")) {
40
+ return "Planner-Sisyphus";
41
+ }
42
+ return agent;
43
+ }
44
+
45
+ if (agentLower === "omo" || agentLower === "sisyphus") {
46
+ return "Sisyphus";
47
+ }
48
+
49
+ return agent;
31
50
  }
32
51
 
33
52
  export function getOpenCodeStoragePath(): string {
@@ -69,6 +88,9 @@ export function parseOpenCodeMessages(): UnifiedMessage[] {
69
88
  reasoning: msg.tokens.reasoning || 0,
70
89
  };
71
90
 
91
+ const agentOrMode = msg.mode || msg.agent;
92
+ const agent = agentOrMode ? normalizeAgentName(agentOrMode) : undefined;
93
+
72
94
  messages.push(
73
95
  createUnifiedMessage(
74
96
  "opencode",
@@ -77,7 +99,8 @@ export function parseOpenCodeMessages(): UnifiedMessage[] {
77
99
  sessionId,
78
100
  msg.time.created,
79
101
  tokens,
80
- msg.cost || 0
102
+ msg.cost || 0,
103
+ agent
81
104
  )
82
105
  );
83
106
  }
@@ -15,10 +15,11 @@ export interface UnifiedMessage {
15
15
  modelId: string;
16
16
  providerId: string;
17
17
  sessionId: string;
18
- timestamp: number; // Unix milliseconds
19
- date: string; // YYYY-MM-DD
18
+ timestamp: number;
19
+ date: string;
20
20
  tokens: TokenBreakdown;
21
21
  cost: number;
22
+ agent?: string;
22
23
  }
23
24
 
24
25
  export type SourceType = "opencode" | "claude" | "codex" | "gemini" | "cursor";
@@ -34,9 +35,6 @@ export function timestampToDate(timestampMs: number): string {
34
35
  return `${year}-${month}-${day}`;
35
36
  }
36
37
 
37
- /**
38
- * Create a unified message
39
- */
40
38
  export function createUnifiedMessage(
41
39
  source: string,
42
40
  modelId: string,
@@ -44,7 +42,8 @@ export function createUnifiedMessage(
44
42
  sessionId: string,
45
43
  timestamp: number,
46
44
  tokens: TokenBreakdown,
47
- cost: number = 0
45
+ cost: number = 0,
46
+ agent?: string
48
47
  ): UnifiedMessage {
49
48
  return {
50
49
  source,
@@ -55,5 +54,6 @@ export function createUnifiedMessage(
55
54
  date: timestampToDate(timestamp),
56
55
  tokens,
57
56
  cost,
57
+ agent,
58
58
  };
59
59
  }
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}>