@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.
Files changed (50) hide show
  1. package/dist/cli.js +14 -3
  2. package/dist/cli.js.map +1 -1
  3. package/dist/native.d.ts.map +1 -1
  4. package/dist/native.js +3 -2
  5. package/dist/native.js.map +1 -1
  6. package/package.json +6 -4
  7. package/src/auth.ts +211 -0
  8. package/src/cli.ts +1040 -0
  9. package/src/credentials.ts +123 -0
  10. package/src/cursor.ts +558 -0
  11. package/src/graph-types.ts +188 -0
  12. package/src/graph.ts +485 -0
  13. package/src/native-runner.ts +105 -0
  14. package/src/native.ts +938 -0
  15. package/src/pricing.ts +309 -0
  16. package/src/sessions/claudecode.ts +119 -0
  17. package/src/sessions/codex.ts +227 -0
  18. package/src/sessions/gemini.ts +108 -0
  19. package/src/sessions/index.ts +126 -0
  20. package/src/sessions/opencode.ts +94 -0
  21. package/src/sessions/reports.ts +475 -0
  22. package/src/sessions/types.ts +59 -0
  23. package/src/spinner.ts +283 -0
  24. package/src/submit.ts +175 -0
  25. package/src/table.ts +233 -0
  26. package/src/tui/App.tsx +339 -0
  27. package/src/tui/components/BarChart.tsx +198 -0
  28. package/src/tui/components/DailyView.tsx +113 -0
  29. package/src/tui/components/DateBreakdownPanel.tsx +79 -0
  30. package/src/tui/components/Footer.tsx +225 -0
  31. package/src/tui/components/Header.tsx +68 -0
  32. package/src/tui/components/Legend.tsx +39 -0
  33. package/src/tui/components/LoadingSpinner.tsx +82 -0
  34. package/src/tui/components/ModelRow.tsx +47 -0
  35. package/src/tui/components/ModelView.tsx +145 -0
  36. package/src/tui/components/OverviewView.tsx +108 -0
  37. package/src/tui/components/StatsView.tsx +225 -0
  38. package/src/tui/components/TokenBreakdown.tsx +46 -0
  39. package/src/tui/components/index.ts +15 -0
  40. package/src/tui/config/settings.ts +130 -0
  41. package/src/tui/config/themes.ts +115 -0
  42. package/src/tui/hooks/useData.ts +518 -0
  43. package/src/tui/index.tsx +44 -0
  44. package/src/tui/opentui.d.ts +137 -0
  45. package/src/tui/types/index.ts +165 -0
  46. package/src/tui/utils/cleanup.ts +65 -0
  47. package/src/tui/utils/colors.ts +65 -0
  48. package/src/tui/utils/format.ts +36 -0
  49. package/src/tui/utils/responsive.ts +8 -0
  50. 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
+ }