Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/utils/widget-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [
{ type: 'context-percentage-usable', create: () => new widgets.ContextPercentageUsableWidget() },
{ type: 'session-clock', create: () => new widgets.SessionClockWidget() },
{ type: 'session-cost', create: () => new widgets.SessionCostWidget() },
{ type: 'total-cost', create: () => new widgets.TotalCostWidget() },
{ type: 'block-timer', create: () => new widgets.BlockTimerWidget() },
{ type: 'terminal-width', create: () => new widgets.TerminalWidthWidget() },
{ type: 'version', create: () => new widgets.VersionWidget() },
Expand Down
77 changes: 77 additions & 0 deletions src/widgets/TotalCost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import * as fs from 'fs';
import * as path from 'path';

import type { RenderContext } from '../types/RenderContext';
import type { Settings } from '../types/Settings';
import type {
Widget,
WidgetEditorDisplay,
WidgetItem
} from '../types/Widget';
import { getConfigPath } from '../utils/config';

function getCostsDir(): string {
return path.join(path.dirname(getConfigPath()), 'costs');
}

function sanitizeId(id: string): string {
return id.replace(/[^a-zA-Z0-9_-]/g, '');
}

function persistCost(costsDir: string, sessionId: string, cost: number): void {
try {
fs.mkdirSync(costsDir, { recursive: true });
fs.writeFileSync(path.join(costsDir, sanitizeId(sessionId)), String(cost), 'utf-8');
} catch {
// Ignore write errors silently
}
}

function sumCosts(costsDir: string): number {
try {
return fs.readdirSync(costsDir).reduce((sum, file) => {
try {
const value = parseFloat(fs.readFileSync(path.join(costsDir, file), 'utf-8').trim());
return sum + (Number.isFinite(value) ? value : 0);
} catch {
return sum;
}
}, 0);
} catch {
return 0;
}
}

export class TotalCostWidget implements Widget {
getDefaultColor(): string { return 'green'; }
getDescription(): string { return 'Shows cumulative total cost across all Claude Code sessions'; }
getDisplayName(): string { return 'Total Cost'; }
getCategory(): string { return 'Session'; }

getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
return { displayText: this.getDisplayName() };
}

render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null {
if (context.isPreview) {
return item.rawValue ? '$12.34' : 'Total: $12.34';
}

const cost = context.data?.cost?.total_cost_usd;
const sessionId = context.data?.session_id;
const costsDir = getCostsDir();

if (sessionId && cost !== undefined && cost > 0) {
persistCost(costsDir, sessionId, cost);
}

const total = sumCosts(costsDir);
if (total === 0) return null;

const formatted = `$${total.toFixed(2)}`;
return item.rawValue ? formatted : `Total: ${formatted}`;
}

supportsRawValue(): boolean { return true; }
supportsColors(_item: WidgetItem): boolean { return true; }
}
126 changes: 126 additions & 0 deletions src/widgets/__tests__/TotalCost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';

import type { RenderContext } from '../../types/RenderContext';
import { DEFAULT_SETTINGS } from '../../types/Settings';
import type { WidgetItem } from '../../types/Widget';
import * as config from '../../utils/config';
import { TotalCostWidget } from '../TotalCost';

function render(widget: TotalCostWidget, item: WidgetItem, context: RenderContext = {}): string | null {
return widget.render(item, context, DEFAULT_SETTINGS);
}

const ITEM: WidgetItem = { id: '1', type: 'total-cost' };

describe('TotalCostWidget', () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsl-totalcost-'));
vi.spyOn(config, 'getConfigPath').mockReturnValue(path.join(tmpDir, 'settings.json'));
});

afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it('returns preview value', () => {
const widget = new TotalCostWidget();
expect(render(widget, ITEM, { isPreview: true })).toBe('Total: $12.34');
});

it('returns raw preview value', () => {
const widget = new TotalCostWidget();
expect(render(widget, { ...ITEM, rawValue: true }, { isPreview: true })).toBe('$12.34');
});

it('returns null when no cost data and no persisted costs', () => {
const widget = new TotalCostWidget();
expect(render(widget, ITEM, { data: undefined })).toBeNull();
});

it('persists session cost and returns total', () => {
const widget = new TotalCostWidget();
const context: RenderContext = {
data: { session_id: 'abc123', cost: { total_cost_usd: 2.5 } } as RenderContext['data']
};
const result = render(widget, ITEM, context);
expect(result).toBe('Total: $2.50');

const costsDir = path.join(tmpDir, 'costs');
expect(fs.existsSync(path.join(costsDir, 'abc123'))).toBe(true);
expect(fs.readFileSync(path.join(costsDir, 'abc123'), 'utf-8')).toBe('2.5');
});

it('sums costs across multiple sessions', () => {
const costsDir = path.join(tmpDir, 'costs');
fs.mkdirSync(costsDir, { recursive: true });
fs.writeFileSync(path.join(costsDir, 'session1'), '1.5');
fs.writeFileSync(path.join(costsDir, 'session2'), '3.25');

const widget = new TotalCostWidget();
expect(render(widget, ITEM, {})).toBe('Total: $4.75');
});

it('updates persisted cost on each render', () => {
const widget = new TotalCostWidget();
const context = (cost: number): RenderContext => ({
data: { session_id: 'sess1', cost: { total_cost_usd: cost } } as RenderContext['data']
});

render(widget, ITEM, context(1.0));
render(widget, ITEM, context(2.0));

const costsDir = path.join(tmpDir, 'costs');
expect(fs.readFileSync(path.join(costsDir, 'sess1'), 'utf-8')).toBe('2');
});

it('does not persist when cost is zero', () => {
const widget = new TotalCostWidget();
render(widget, ITEM, {
data: { session_id: 'sess0', cost: { total_cost_usd: 0 } } as RenderContext['data']
});
const costsDir = path.join(tmpDir, 'costs');
expect(fs.existsSync(path.join(costsDir, 'sess0'))).toBe(false);
});

it('returns raw total value without label', () => {
const costsDir = path.join(tmpDir, 'costs');
fs.mkdirSync(costsDir, { recursive: true });
fs.writeFileSync(path.join(costsDir, 'session1'), '5.0');

const widget = new TotalCostWidget();
expect(render(widget, { ...ITEM, rawValue: true }, {})).toBe('$5.00');
});

it('sanitizes session id before using as filename', () => {
const widget = new TotalCostWidget();
const context: RenderContext = {
data: { session_id: 'abc/../../etc/passwd', cost: { total_cost_usd: 1.0 } } as RenderContext['data']
};
render(widget, ITEM, context);
const costsDir = path.join(tmpDir, 'costs');
expect(fs.existsSync(path.join(costsDir, 'abcetcpasswd'))).toBe(true);
});

it('gracefully handles corrupted cost files', () => {
const costsDir = path.join(tmpDir, 'costs');
fs.mkdirSync(costsDir, { recursive: true });
fs.writeFileSync(path.join(costsDir, 'good'), '2.0');
fs.writeFileSync(path.join(costsDir, 'bad'), 'not-a-number');

const widget = new TotalCostWidget();
expect(render(widget, ITEM, {})).toBe('Total: $2.00');
});
});
1 change: 1 addition & 0 deletions src/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export { ContextPercentageWidget } from './ContextPercentage';
export { ContextPercentageUsableWidget } from './ContextPercentageUsable';
export { SessionClockWidget } from './SessionClock';
export { SessionCostWidget } from './SessionCost';
export { TotalCostWidget } from './TotalCost';
export { TerminalWidthWidget } from './TerminalWidth';
export { VersionWidget } from './Version';
export { CustomTextWidget } from './CustomText';
Expand Down