@tokscale/cli 1.0.5 → 1.0.7
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 +14 -3
- package/dist/cli.js.map +1 -1
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +3 -2
- package/dist/native.js.map +1 -1
- package/package.json +6 -4
- package/src/auth.ts +211 -0
- package/src/cli.ts +1040 -0
- package/src/credentials.ts +123 -0
- package/src/cursor.ts +558 -0
- package/src/graph-types.ts +188 -0
- package/src/graph.ts +485 -0
- package/src/native-runner.ts +105 -0
- package/src/native.ts +938 -0
- package/src/pricing.ts +309 -0
- package/src/sessions/claudecode.ts +119 -0
- package/src/sessions/codex.ts +227 -0
- package/src/sessions/gemini.ts +108 -0
- package/src/sessions/index.ts +126 -0
- package/src/sessions/opencode.ts +94 -0
- package/src/sessions/reports.ts +475 -0
- package/src/sessions/types.ts +59 -0
- package/src/spinner.ts +283 -0
- package/src/submit.ts +175 -0
- package/src/table.ts +233 -0
- package/src/tui/App.tsx +339 -0
- package/src/tui/components/BarChart.tsx +198 -0
- package/src/tui/components/DailyView.tsx +113 -0
- package/src/tui/components/DateBreakdownPanel.tsx +79 -0
- package/src/tui/components/Footer.tsx +225 -0
- package/src/tui/components/Header.tsx +68 -0
- package/src/tui/components/Legend.tsx +39 -0
- package/src/tui/components/LoadingSpinner.tsx +82 -0
- package/src/tui/components/ModelRow.tsx +47 -0
- package/src/tui/components/ModelView.tsx +145 -0
- package/src/tui/components/OverviewView.tsx +108 -0
- package/src/tui/components/StatsView.tsx +225 -0
- package/src/tui/components/TokenBreakdown.tsx +46 -0
- package/src/tui/components/index.ts +15 -0
- package/src/tui/config/settings.ts +130 -0
- package/src/tui/config/themes.ts +115 -0
- package/src/tui/hooks/useData.ts +518 -0
- package/src/tui/index.tsx +44 -0
- package/src/tui/opentui.d.ts +137 -0
- package/src/tui/types/index.ts +165 -0
- package/src/tui/utils/cleanup.ts +65 -0
- package/src/tui/utils/colors.ts +65 -0
- package/src/tui/utils/format.ts +36 -0
- package/src/tui/utils/responsive.ts +8 -0
- package/src/types.d.ts +28 -0
package/src/spinner.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode-style Knight Rider Spinner
|
|
3
|
+
* Accurate port from: https://github.com/sst/opencode/blob/dev/packages/opencode/src/cli/cmd/tui/ui/spinner.ts
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - Bidirectional sweep (left→right→left)
|
|
7
|
+
* - Hold frames at each end (pause effect)
|
|
8
|
+
* - Color gradient trail using ANSI 256 colors
|
|
9
|
+
* - Same characters as OpenCode: ■ (active) / ⬝ (inactive)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// ANSI Color Helpers
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
function ansi256Fg(code: number): string {
|
|
17
|
+
return `\x1b[38;5;${code}m`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const RESET = "\x1b[0m";
|
|
21
|
+
const HIDE_CURSOR = "\x1B[?25l";
|
|
22
|
+
const SHOW_CURSOR = "\x1B[?25h";
|
|
23
|
+
const CLEAR_LINE = "\r\x1B[K";
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// Color Gradients (ANSI 256)
|
|
27
|
+
// Approximates OpenCode's RGBA alpha-based trail fade
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
30
|
+
type ColorName = "cyan" | "green" | "magenta" | "yellow" | "red" | "blue" | "white";
|
|
31
|
+
|
|
32
|
+
const COLOR_GRADIENTS: Record<ColorName, number[]> = {
|
|
33
|
+
// Bright → dim (6 steps for trail)
|
|
34
|
+
cyan: [51, 44, 37, 30, 23, 17],
|
|
35
|
+
green: [46, 40, 34, 28, 22, 22],
|
|
36
|
+
magenta: [201, 165, 129, 93, 57, 53],
|
|
37
|
+
yellow: [226, 220, 214, 178, 136, 94],
|
|
38
|
+
red: [196, 160, 124, 88, 52, 52],
|
|
39
|
+
blue: [33, 27, 21, 18, 17, 17],
|
|
40
|
+
white: [255, 250, 245, 240, 236, 232],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const INACTIVE_COLOR = 240; // Gray for ⬝
|
|
44
|
+
|
|
45
|
+
// =============================================================================
|
|
46
|
+
// Frame Generation (matches OpenCode exactly)
|
|
47
|
+
// =============================================================================
|
|
48
|
+
|
|
49
|
+
interface SpinnerOptions {
|
|
50
|
+
/** Width of the spinner in characters (default: 8) */
|
|
51
|
+
width?: number;
|
|
52
|
+
/** Frames to hold at start position (default: 30) */
|
|
53
|
+
holdStart?: number;
|
|
54
|
+
/** Frames to hold at end position (default: 9) */
|
|
55
|
+
holdEnd?: number;
|
|
56
|
+
/** Trail length - number of colored blocks behind lead (default: 4) */
|
|
57
|
+
trailLength?: number;
|
|
58
|
+
/** Color theme (default: "cyan") */
|
|
59
|
+
color?: ColorName;
|
|
60
|
+
/** Frame interval in ms (default: 40) */
|
|
61
|
+
interval?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface ScannerState {
|
|
65
|
+
activePosition: number;
|
|
66
|
+
isMovingForward: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Calculate scanner state for a given frame index
|
|
71
|
+
* Matches OpenCode's getScannerState() exactly
|
|
72
|
+
*/
|
|
73
|
+
function getScannerState(
|
|
74
|
+
frameIndex: number,
|
|
75
|
+
width: number,
|
|
76
|
+
holdStart: number,
|
|
77
|
+
holdEnd: number
|
|
78
|
+
): ScannerState {
|
|
79
|
+
const forwardFrames = width;
|
|
80
|
+
const backwardFrames = width - 1;
|
|
81
|
+
const totalCycle = forwardFrames + holdEnd + backwardFrames + holdStart;
|
|
82
|
+
|
|
83
|
+
// Normalize frame index to cycle
|
|
84
|
+
const normalizedFrame = frameIndex % totalCycle;
|
|
85
|
+
|
|
86
|
+
if (normalizedFrame < forwardFrames) {
|
|
87
|
+
// Phase 1: Moving forward (0 → width-1)
|
|
88
|
+
return {
|
|
89
|
+
activePosition: normalizedFrame,
|
|
90
|
+
isMovingForward: true,
|
|
91
|
+
};
|
|
92
|
+
} else if (normalizedFrame < forwardFrames + holdEnd) {
|
|
93
|
+
// Phase 2: Holding at end
|
|
94
|
+
return {
|
|
95
|
+
activePosition: width - 1,
|
|
96
|
+
isMovingForward: true,
|
|
97
|
+
};
|
|
98
|
+
} else if (normalizedFrame < forwardFrames + holdEnd + backwardFrames) {
|
|
99
|
+
// Phase 3: Moving backward (width-2 → 0)
|
|
100
|
+
const backwardIndex = normalizedFrame - forwardFrames - holdEnd;
|
|
101
|
+
return {
|
|
102
|
+
activePosition: width - 2 - backwardIndex,
|
|
103
|
+
isMovingForward: false,
|
|
104
|
+
};
|
|
105
|
+
} else {
|
|
106
|
+
// Phase 4: Holding at start
|
|
107
|
+
return {
|
|
108
|
+
activePosition: 0,
|
|
109
|
+
isMovingForward: false,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Generate a single frame string with colors
|
|
116
|
+
*/
|
|
117
|
+
function generateColoredFrame(
|
|
118
|
+
frameIndex: number,
|
|
119
|
+
width: number,
|
|
120
|
+
holdStart: number,
|
|
121
|
+
holdEnd: number,
|
|
122
|
+
trailLength: number,
|
|
123
|
+
gradient: number[]
|
|
124
|
+
): string {
|
|
125
|
+
const state = getScannerState(frameIndex, width, holdStart, holdEnd);
|
|
126
|
+
const { activePosition, isMovingForward } = state;
|
|
127
|
+
|
|
128
|
+
let result = "";
|
|
129
|
+
|
|
130
|
+
for (let i = 0; i < width; i++) {
|
|
131
|
+
// Calculate directional distance (positive = trailing behind)
|
|
132
|
+
const directionalDistance = isMovingForward
|
|
133
|
+
? activePosition - i // Forward: trail is to the left
|
|
134
|
+
: i - activePosition; // Backward: trail is to the right
|
|
135
|
+
|
|
136
|
+
if (directionalDistance >= 0 && directionalDistance < trailLength) {
|
|
137
|
+
// Active position with color gradient
|
|
138
|
+
const colorIdx = Math.min(directionalDistance, gradient.length - 1);
|
|
139
|
+
result += ansi256Fg(gradient[colorIdx]) + "■" + RESET;
|
|
140
|
+
} else {
|
|
141
|
+
// Inactive position
|
|
142
|
+
result += ansi256Fg(INACTIVE_COLOR) + "⬝" + RESET;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Calculate total frames in one complete cycle
|
|
151
|
+
*/
|
|
152
|
+
function getTotalFrames(width: number, holdStart: number, holdEnd: number): number {
|
|
153
|
+
return width + holdEnd + (width - 1) + holdStart;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// =============================================================================
|
|
157
|
+
// Spinner Class
|
|
158
|
+
// =============================================================================
|
|
159
|
+
|
|
160
|
+
export class Spinner {
|
|
161
|
+
private intervalId: NodeJS.Timeout | null = null;
|
|
162
|
+
private frameIndex = 0;
|
|
163
|
+
private width: number;
|
|
164
|
+
private holdStart: number;
|
|
165
|
+
private holdEnd: number;
|
|
166
|
+
private trailLength: number;
|
|
167
|
+
private gradient: number[];
|
|
168
|
+
private interval: number;
|
|
169
|
+
private message: string = "";
|
|
170
|
+
private totalFrames: number;
|
|
171
|
+
|
|
172
|
+
constructor(options: SpinnerOptions = {}) {
|
|
173
|
+
this.width = options.width ?? 8;
|
|
174
|
+
this.holdStart = options.holdStart ?? 30;
|
|
175
|
+
this.holdEnd = options.holdEnd ?? 9;
|
|
176
|
+
this.trailLength = options.trailLength ?? 4;
|
|
177
|
+
this.interval = options.interval ?? 40;
|
|
178
|
+
|
|
179
|
+
const colorName = options.color ?? "cyan";
|
|
180
|
+
this.gradient = COLOR_GRADIENTS[colorName] || COLOR_GRADIENTS.cyan;
|
|
181
|
+
|
|
182
|
+
this.totalFrames = getTotalFrames(this.width, this.holdStart, this.holdEnd);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Start the spinner with a message
|
|
187
|
+
*/
|
|
188
|
+
start(message: string): void {
|
|
189
|
+
this.message = message;
|
|
190
|
+
this.frameIndex = 0;
|
|
191
|
+
|
|
192
|
+
// Hide cursor
|
|
193
|
+
process.stdout.write(HIDE_CURSOR);
|
|
194
|
+
|
|
195
|
+
this.intervalId = setInterval(() => {
|
|
196
|
+
const frame = generateColoredFrame(
|
|
197
|
+
this.frameIndex,
|
|
198
|
+
this.width,
|
|
199
|
+
this.holdStart,
|
|
200
|
+
this.holdEnd,
|
|
201
|
+
this.trailLength,
|
|
202
|
+
this.gradient
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
process.stdout.write(`${CLEAR_LINE} ${frame} ${this.message}`);
|
|
206
|
+
this.frameIndex = (this.frameIndex + 1) % this.totalFrames;
|
|
207
|
+
}, this.interval);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Update the spinner message while running
|
|
212
|
+
*/
|
|
213
|
+
update(message: string): void {
|
|
214
|
+
this.message = message;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Stop the spinner and show a success message
|
|
219
|
+
*/
|
|
220
|
+
success(message: string): void {
|
|
221
|
+
this.stop();
|
|
222
|
+
console.log(` \x1b[32m✓\x1b[0m ${message}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Stop the spinner and show an error message
|
|
227
|
+
*/
|
|
228
|
+
error(message: string): void {
|
|
229
|
+
this.stop();
|
|
230
|
+
console.log(` \x1b[31m✗\x1b[0m ${message}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Stop the spinner without a message
|
|
235
|
+
*/
|
|
236
|
+
stop(): void {
|
|
237
|
+
if (this.intervalId) {
|
|
238
|
+
clearInterval(this.intervalId);
|
|
239
|
+
this.intervalId = null;
|
|
240
|
+
}
|
|
241
|
+
process.stdout.write(CLEAR_LINE);
|
|
242
|
+
process.stdout.write(SHOW_CURSOR);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Check if spinner is currently running
|
|
247
|
+
*/
|
|
248
|
+
isSpinning(): boolean {
|
|
249
|
+
return this.intervalId !== null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// =============================================================================
|
|
254
|
+
// Convenience Functions
|
|
255
|
+
// =============================================================================
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Create a spinner with default OpenCode settings
|
|
259
|
+
*/
|
|
260
|
+
export function createSpinner(options?: SpinnerOptions): Spinner {
|
|
261
|
+
return new Spinner(options);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Run an async function with a spinner
|
|
266
|
+
*/
|
|
267
|
+
export async function withSpinner<T>(
|
|
268
|
+
message: string,
|
|
269
|
+
fn: () => Promise<T>,
|
|
270
|
+
options?: SpinnerOptions & { successMessage?: string; errorMessage?: string }
|
|
271
|
+
): Promise<T> {
|
|
272
|
+
const spinner = new Spinner(options);
|
|
273
|
+
spinner.start(message);
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const result = await fn();
|
|
277
|
+
spinner.success(options?.successMessage ?? message);
|
|
278
|
+
return result;
|
|
279
|
+
} catch (error) {
|
|
280
|
+
spinner.error(options?.errorMessage ?? `Failed: ${message}`);
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
}
|
package/src/submit.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tokscale CLI Submit Command
|
|
3
|
+
* Submits local token usage data to the social platform
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import pc from "picocolors";
|
|
7
|
+
import { loadCredentials, getApiBaseUrl } from "./credentials.js";
|
|
8
|
+
import { PricingFetcher } from "./pricing.js";
|
|
9
|
+
import {
|
|
10
|
+
isNativeAvailable,
|
|
11
|
+
generateGraphWithPricingAsync,
|
|
12
|
+
} from "./native.js";
|
|
13
|
+
import type { TokenContributionData } from "./graph-types.js";
|
|
14
|
+
import { formatCurrency } from "./table.js";
|
|
15
|
+
|
|
16
|
+
interface SubmitOptions {
|
|
17
|
+
opencode?: boolean;
|
|
18
|
+
claude?: boolean;
|
|
19
|
+
codex?: boolean;
|
|
20
|
+
gemini?: boolean;
|
|
21
|
+
cursor?: boolean;
|
|
22
|
+
since?: string;
|
|
23
|
+
until?: string;
|
|
24
|
+
year?: string;
|
|
25
|
+
dryRun?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface SubmitResponse {
|
|
29
|
+
success: boolean;
|
|
30
|
+
submissionId?: string;
|
|
31
|
+
username?: string;
|
|
32
|
+
metrics?: {
|
|
33
|
+
totalTokens: number;
|
|
34
|
+
totalCost: number;
|
|
35
|
+
dateRange: {
|
|
36
|
+
start: string;
|
|
37
|
+
end: string;
|
|
38
|
+
};
|
|
39
|
+
activeDays: number;
|
|
40
|
+
sources: string[];
|
|
41
|
+
};
|
|
42
|
+
warnings?: string[];
|
|
43
|
+
error?: string;
|
|
44
|
+
details?: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type SourceType = "opencode" | "claude" | "codex" | "gemini" | "cursor";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Submit command - sends usage data to the platform
|
|
51
|
+
*/
|
|
52
|
+
export async function submit(options: SubmitOptions = {}): Promise<void> {
|
|
53
|
+
// Step 1: Check if logged in
|
|
54
|
+
const credentials = loadCredentials();
|
|
55
|
+
if (!credentials) {
|
|
56
|
+
console.log(pc.yellow("\n Not logged in."));
|
|
57
|
+
console.log(pc.gray(" Run 'tokscale login' first.\n"));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Step 2: Log native module status (TS fallback available)
|
|
62
|
+
if (!isNativeAvailable()) {
|
|
63
|
+
console.log(pc.yellow("\n Note: Using TypeScript fallback (native module not available)"));
|
|
64
|
+
console.log(pc.gray(" Run 'bun run build:core' for faster processing.\n"));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log(pc.cyan("\n Tokscale - Submit Usage Data\n"));
|
|
68
|
+
|
|
69
|
+
// Step 3: Generate graph data
|
|
70
|
+
console.log(pc.gray(" Scanning local session data..."));
|
|
71
|
+
|
|
72
|
+
const fetcher = new PricingFetcher();
|
|
73
|
+
await fetcher.fetchPricing();
|
|
74
|
+
const pricingEntries = fetcher.toPricingEntries();
|
|
75
|
+
|
|
76
|
+
// Determine sources
|
|
77
|
+
const hasFilter = options.opencode || options.claude || options.codex || options.gemini || options.cursor;
|
|
78
|
+
let sources: SourceType[] | undefined;
|
|
79
|
+
if (hasFilter) {
|
|
80
|
+
sources = [];
|
|
81
|
+
if (options.opencode) sources.push("opencode");
|
|
82
|
+
if (options.claude) sources.push("claude");
|
|
83
|
+
if (options.codex) sources.push("codex");
|
|
84
|
+
if (options.gemini) sources.push("gemini");
|
|
85
|
+
if (options.cursor) sources.push("cursor");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let data: TokenContributionData;
|
|
89
|
+
try {
|
|
90
|
+
data = await generateGraphWithPricingAsync({
|
|
91
|
+
sources,
|
|
92
|
+
pricing: pricingEntries,
|
|
93
|
+
since: options.since,
|
|
94
|
+
until: options.until,
|
|
95
|
+
year: options.year,
|
|
96
|
+
});
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error(pc.red(`\n Error generating data: ${(error as Error).message}\n`));
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Step 4: Show summary
|
|
103
|
+
console.log(pc.white(" Data to submit:"));
|
|
104
|
+
console.log(pc.gray(` Date range: ${data.meta.dateRange.start} to ${data.meta.dateRange.end}`));
|
|
105
|
+
console.log(pc.gray(` Active days: ${data.summary.activeDays}`));
|
|
106
|
+
console.log(pc.gray(` Total tokens: ${data.summary.totalTokens.toLocaleString()}`));
|
|
107
|
+
console.log(pc.gray(` Total cost: ${formatCurrency(data.summary.totalCost)}`));
|
|
108
|
+
console.log(pc.gray(` Sources: ${data.summary.sources.join(", ")}`));
|
|
109
|
+
console.log(pc.gray(` Models: ${data.summary.models.length} models`));
|
|
110
|
+
console.log();
|
|
111
|
+
|
|
112
|
+
if (data.summary.totalTokens === 0) {
|
|
113
|
+
console.log(pc.yellow(" No usage data found to submit.\n"));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Step 5: Dry run check
|
|
118
|
+
if (options.dryRun) {
|
|
119
|
+
console.log(pc.yellow(" Dry run - not submitting data.\n"));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Step 6: Submit to server
|
|
124
|
+
console.log(pc.gray(" Submitting to server..."));
|
|
125
|
+
|
|
126
|
+
const baseUrl = getApiBaseUrl();
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const response = await fetch(`${baseUrl}/api/submit`, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: {
|
|
132
|
+
"Content-Type": "application/json",
|
|
133
|
+
Authorization: `Bearer ${credentials.token}`,
|
|
134
|
+
},
|
|
135
|
+
body: JSON.stringify(data),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const result: SubmitResponse = await response.json();
|
|
139
|
+
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
console.error(pc.red(`\n Error: ${result.error || "Submission failed"}`));
|
|
142
|
+
if (result.details) {
|
|
143
|
+
for (const detail of result.details) {
|
|
144
|
+
console.error(pc.gray(` - ${detail}`));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
console.log();
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Success!
|
|
152
|
+
console.log(pc.green("\n Successfully submitted!"));
|
|
153
|
+
console.log();
|
|
154
|
+
console.log(pc.white(" Summary:"));
|
|
155
|
+
console.log(pc.gray(` Submission ID: ${result.submissionId}`));
|
|
156
|
+
console.log(pc.gray(` Total tokens: ${result.metrics?.totalTokens?.toLocaleString()}`));
|
|
157
|
+
console.log(pc.gray(` Total cost: ${formatCurrency(result.metrics?.totalCost || 0)}`));
|
|
158
|
+
console.log(pc.gray(` Active days: ${result.metrics?.activeDays}`));
|
|
159
|
+
console.log();
|
|
160
|
+
console.log(pc.cyan(` View your profile: ${baseUrl}/u/${credentials.username}`));
|
|
161
|
+
console.log();
|
|
162
|
+
|
|
163
|
+
if (result.warnings && result.warnings.length > 0) {
|
|
164
|
+
console.log(pc.yellow(" Warnings:"));
|
|
165
|
+
for (const warning of result.warnings) {
|
|
166
|
+
console.log(pc.gray(` - ${warning}`));
|
|
167
|
+
}
|
|
168
|
+
console.log();
|
|
169
|
+
}
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.error(pc.red(`\n Error: Failed to connect to server.`));
|
|
172
|
+
console.error(pc.gray(` ${(error as Error).message}\n`));
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
}
|
package/src/table.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic width table rendering (inspired by ccusage)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import Table from "cli-table3";
|
|
6
|
+
import pc from "picocolors";
|
|
7
|
+
import stringWidth from "string-width";
|
|
8
|
+
|
|
9
|
+
export type TableCellAlign = "left" | "right" | "center";
|
|
10
|
+
export type TableRow = (string | number | { content: string; hAlign?: TableCellAlign })[];
|
|
11
|
+
|
|
12
|
+
export interface TableOptions {
|
|
13
|
+
head: string[];
|
|
14
|
+
colAligns?: TableCellAlign[];
|
|
15
|
+
style?: { head?: string[] };
|
|
16
|
+
compactHead?: string[];
|
|
17
|
+
compactColAligns?: TableCellAlign[];
|
|
18
|
+
compactThreshold?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class ResponsiveTable {
|
|
22
|
+
private head: string[];
|
|
23
|
+
private rows: TableRow[] = [];
|
|
24
|
+
private colAligns: TableCellAlign[];
|
|
25
|
+
private style?: { head?: string[] };
|
|
26
|
+
private compactHead?: string[];
|
|
27
|
+
private compactColAligns?: TableCellAlign[];
|
|
28
|
+
private compactThreshold: number;
|
|
29
|
+
private compactMode = false;
|
|
30
|
+
|
|
31
|
+
constructor(options: TableOptions) {
|
|
32
|
+
this.head = options.head;
|
|
33
|
+
this.colAligns = options.colAligns ?? Array.from({ length: this.head.length }, () => "left");
|
|
34
|
+
this.style = options.style;
|
|
35
|
+
this.compactHead = options.compactHead;
|
|
36
|
+
this.compactColAligns = options.compactColAligns;
|
|
37
|
+
this.compactThreshold = options.compactThreshold ?? 100;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
push(row: TableRow): void {
|
|
41
|
+
this.rows.push(row);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private filterRowToCompact(row: TableRow, compactIndices: number[]): TableRow {
|
|
45
|
+
return compactIndices.map((index) => row[index] ?? "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private getCurrentTableConfig(): { head: string[]; colAligns: TableCellAlign[] } {
|
|
49
|
+
if (this.compactMode && this.compactHead && this.compactColAligns) {
|
|
50
|
+
return { head: this.compactHead, colAligns: this.compactColAligns };
|
|
51
|
+
}
|
|
52
|
+
return { head: this.head, colAligns: this.colAligns };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private getCompactIndices(): number[] {
|
|
56
|
+
if (!this.compactHead || !this.compactMode) {
|
|
57
|
+
return Array.from({ length: this.head.length }, (_, i) => i);
|
|
58
|
+
}
|
|
59
|
+
return this.compactHead.map((compactHeader) => {
|
|
60
|
+
const index = this.head.indexOf(compactHeader);
|
|
61
|
+
return index < 0 ? 0 : index;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
toString(): string {
|
|
66
|
+
const terminalWidth =
|
|
67
|
+
Number.parseInt(process.env.COLUMNS ?? "", 10) || process.stdout.columns || 120;
|
|
68
|
+
|
|
69
|
+
this.compactMode = terminalWidth < this.compactThreshold && this.compactHead != null;
|
|
70
|
+
|
|
71
|
+
const { head, colAligns } = this.getCurrentTableConfig();
|
|
72
|
+
const compactIndices = this.getCompactIndices();
|
|
73
|
+
|
|
74
|
+
const processedRows = this.compactMode
|
|
75
|
+
? this.rows.map((row) => this.filterRowToCompact(row, compactIndices))
|
|
76
|
+
: this.rows;
|
|
77
|
+
|
|
78
|
+
const allRows = [
|
|
79
|
+
head.map(String),
|
|
80
|
+
...processedRows.map((row) =>
|
|
81
|
+
row.map((cell) => {
|
|
82
|
+
if (typeof cell === "object" && cell != null && "content" in cell) {
|
|
83
|
+
return String(cell.content);
|
|
84
|
+
}
|
|
85
|
+
return String(cell ?? "");
|
|
86
|
+
})
|
|
87
|
+
),
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
const contentWidths = head.map((_, colIndex) => {
|
|
91
|
+
const maxLength = Math.max(...allRows.map((row) => stringWidth(String(row[colIndex] ?? ""))));
|
|
92
|
+
return maxLength;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const numColumns = head.length;
|
|
96
|
+
const tableOverhead = 3 * numColumns + 1;
|
|
97
|
+
const availableWidth = terminalWidth - tableOverhead;
|
|
98
|
+
|
|
99
|
+
const columnWidths = contentWidths.map((width, index) => {
|
|
100
|
+
const align = colAligns[index];
|
|
101
|
+
if (align === "right") {
|
|
102
|
+
return Math.max(width + 3, 11);
|
|
103
|
+
} else if (index === 1) {
|
|
104
|
+
return Math.max(width + 2, 15);
|
|
105
|
+
}
|
|
106
|
+
return Math.max(width + 2, 10);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const totalRequiredWidth = columnWidths.reduce((sum, width) => sum + width, 0) + tableOverhead;
|
|
110
|
+
|
|
111
|
+
let finalWidths = columnWidths;
|
|
112
|
+
if (totalRequiredWidth > terminalWidth) {
|
|
113
|
+
const scaleFactor = availableWidth / columnWidths.reduce((sum, width) => sum + width, 0);
|
|
114
|
+
finalWidths = columnWidths.map((width, index) => {
|
|
115
|
+
const align = colAligns[index];
|
|
116
|
+
let adjustedWidth = Math.floor(width * scaleFactor);
|
|
117
|
+
if (align === "right") {
|
|
118
|
+
adjustedWidth = Math.max(adjustedWidth, 10);
|
|
119
|
+
} else if (index === 0) {
|
|
120
|
+
adjustedWidth = Math.max(adjustedWidth, 10);
|
|
121
|
+
} else {
|
|
122
|
+
adjustedWidth = Math.max(adjustedWidth, 8);
|
|
123
|
+
}
|
|
124
|
+
return adjustedWidth;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const table = new Table({
|
|
129
|
+
head,
|
|
130
|
+
style: this.style,
|
|
131
|
+
colAligns,
|
|
132
|
+
colWidths: finalWidths,
|
|
133
|
+
wordWrap: true,
|
|
134
|
+
wrapOnWordBoundary: true,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
for (const row of processedRows) {
|
|
138
|
+
table.push(row as any);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return table.toString();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function formatNumber(num: number): string {
|
|
146
|
+
return num.toLocaleString("en-US");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function formatCurrency(amount: number): string {
|
|
150
|
+
return `$${amount.toFixed(2)}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function formatModelName(modelName: string): string {
|
|
154
|
+
// claude-sonnet-4-20250514 -> sonnet-4
|
|
155
|
+
// claude-opus-4-5-20251101 -> opus-4-5
|
|
156
|
+
const match = modelName.match(/claude-(\w+)-([\d-]+)-(\d{8})/);
|
|
157
|
+
if (match) {
|
|
158
|
+
return `${match[1]}-${match[2]}`;
|
|
159
|
+
}
|
|
160
|
+
// Handle OpenCode style: claude-opus-4-5-high -> opus-4-5-high
|
|
161
|
+
const openCodeMatch = modelName.match(/claude-(\w+)-(.+)/);
|
|
162
|
+
if (openCodeMatch) {
|
|
163
|
+
return `${openCodeMatch[1]}-${openCodeMatch[2]}`;
|
|
164
|
+
}
|
|
165
|
+
return modelName;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function formatModelsMultiline(models: string[]): string {
|
|
169
|
+
const unique = [...new Set(models.map(formatModelName))];
|
|
170
|
+
return unique.sort().map((m) => `- ${m}`).join("\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function createUsageTable(firstColumnName: string): ResponsiveTable {
|
|
174
|
+
return new ResponsiveTable({
|
|
175
|
+
head: [
|
|
176
|
+
firstColumnName,
|
|
177
|
+
"Models",
|
|
178
|
+
"Input",
|
|
179
|
+
"Output",
|
|
180
|
+
"Cache Write",
|
|
181
|
+
"Cache Read",
|
|
182
|
+
"Total",
|
|
183
|
+
"Cost",
|
|
184
|
+
],
|
|
185
|
+
style: { head: ["cyan"] },
|
|
186
|
+
colAligns: ["left", "left", "right", "right", "right", "right", "right", "right"],
|
|
187
|
+
compactHead: [firstColumnName, "Models", "Input", "Output", "Cost"],
|
|
188
|
+
compactColAligns: ["left", "left", "right", "right", "right"],
|
|
189
|
+
compactThreshold: 100,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function formatUsageRow(
|
|
194
|
+
firstCol: string,
|
|
195
|
+
models: string[],
|
|
196
|
+
input: number,
|
|
197
|
+
output: number,
|
|
198
|
+
cacheWrite: number,
|
|
199
|
+
cacheRead: number,
|
|
200
|
+
cost: number
|
|
201
|
+
): TableRow {
|
|
202
|
+
const total = input + output + cacheWrite + cacheRead;
|
|
203
|
+
return [
|
|
204
|
+
firstCol,
|
|
205
|
+
formatModelsMultiline(models),
|
|
206
|
+
formatNumber(input),
|
|
207
|
+
formatNumber(output),
|
|
208
|
+
formatNumber(cacheWrite),
|
|
209
|
+
formatNumber(cacheRead),
|
|
210
|
+
formatNumber(total),
|
|
211
|
+
formatCurrency(cost),
|
|
212
|
+
];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function formatTotalsRow(
|
|
216
|
+
input: number,
|
|
217
|
+
output: number,
|
|
218
|
+
cacheWrite: number,
|
|
219
|
+
cacheRead: number,
|
|
220
|
+
cost: number
|
|
221
|
+
): TableRow {
|
|
222
|
+
const total = input + output + cacheWrite + cacheRead;
|
|
223
|
+
return [
|
|
224
|
+
pc.yellow("Total"),
|
|
225
|
+
"",
|
|
226
|
+
pc.yellow(formatNumber(input)),
|
|
227
|
+
pc.yellow(formatNumber(output)),
|
|
228
|
+
pc.yellow(formatNumber(cacheWrite)),
|
|
229
|
+
pc.yellow(formatNumber(cacheRead)),
|
|
230
|
+
pc.yellow(formatNumber(total)),
|
|
231
|
+
pc.yellow(formatCurrency(cost)),
|
|
232
|
+
];
|
|
233
|
+
}
|