@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.
- package/dist/cli.js +11 -5
- package/dist/cli.js.map +1 -1
- package/dist/native.d.ts +3 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +4 -3
- package/dist/native.js.map +1 -1
- package/dist/sessions/opencode.d.ts +1 -0
- package/dist/sessions/opencode.d.ts.map +1 -1
- package/dist/sessions/opencode.js +16 -1
- package/dist/sessions/opencode.js.map +1 -1
- package/dist/sessions/types.d.ts +2 -4
- package/dist/sessions/types.d.ts.map +1 -1
- package/dist/sessions/types.js +2 -4
- package/dist/sessions/types.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 +8 -0
- package/dist/wrapped.d.ts.map +1 -1
- package/dist/wrapped.js +184 -32
- package/dist/wrapped.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +14 -6
- package/src/native.ts +8 -3
- package/src/sessions/opencode.ts +24 -1
- package/src/sessions/types.ts +6 -6
- 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 +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
|
|
299
|
-
.option("--output <file>", "Output file path (default: tokscale
|
|
300
|
-
.option("--year <year>", "Year to generate
|
|
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;
|
|
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
|
-
|
|
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
|
|
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) {
|
package/src/sessions/opencode.ts
CHANGED
|
@@ -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
|
}
|
package/src/sessions/types.ts
CHANGED
|
@@ -15,10 +15,11 @@ export interface UnifiedMessage {
|
|
|
15
15
|
modelId: string;
|
|
16
16
|
providerId: string;
|
|
17
17
|
sessionId: string;
|
|
18
|
-
timestamp: number;
|
|
19
|
-
date: string;
|
|
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 ?? "
|
|
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}>
|