ccusage-widget
Version:
A beautiful macOS desktop widget that displays your Claude Code usage statistics in real-time
433 lines • 16.6 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const electron_1 = require("electron");
const path = __importStar(require("path"));
const child_process_1 = require("child_process");
const util_1 = require("util");
const package_json_1 = __importDefault(require("../package.json"));
const execAsync = (0, util_1.promisify)(child_process_1.exec);
// Set app name before app is ready (important for macOS)
// Use productName from package.json if available, otherwise use name
const appName = package_json_1.default.build?.productName || package_json_1.default.name || 'CCUsage Widget';
electron_1.app.setName(appName);
// Helper function to extract session name from sessionId
function extractSessionNameFromId(sessionId) {
if (!sessionId)
return 'session';
// Match pattern: -Users-${whoami}-workspace-${sessionType}-${sessionName}
// Extract the session name (the part after the second dash after workspace)
const match = sessionId.match(/-workspace-[^-]+-([^-]+)/);
if (match && match[1]) {
return match[1];
}
// If that doesn't work, try to get anything after the last dash
const parts = sessionId.split('-');
if (parts.length > 0) {
const lastPart = parts[parts.length - 1];
if (lastPart && lastPart !== 'workspace') {
return lastPart;
}
}
// Fallback to 'session' if pattern doesn't match
return 'session';
}
let mainWindow = null;
let tray = null;
let isQuitting = false;
const isDev = process.argv.includes('--dev');
const config = {
alwaysOnTop: true,
opacity: 0.95,
refreshInterval: 60000, // 1 minute
position: 'top-right'
};
function createWindow() {
const { width: screenWidth, height: screenHeight } = electron_1.screen.getPrimaryDisplay().workAreaSize;
const windowWidth = 400;
const windowHeight = 300;
const minHeight = 200;
const maxHeight = 600;
let x = 0, y = 0;
switch (config.position) {
case 'top-right':
x = screenWidth - windowWidth - 20;
y = 20;
break;
case 'top-left':
x = 20;
y = 20;
break;
case 'bottom-right':
x = screenWidth - windowWidth - 20;
y = screenHeight - windowHeight - 20;
break;
case 'bottom-left':
x = 20;
y = screenHeight - windowHeight - 20;
break;
}
mainWindow = new electron_1.BrowserWindow({
width: windowWidth,
height: windowHeight,
minWidth: windowWidth,
maxWidth: windowWidth,
minHeight: minHeight,
maxHeight: maxHeight,
x,
y,
frame: false,
transparent: true,
alwaysOnTop: config.alwaysOnTop,
resizable: true,
movable: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
},
titleBarStyle: 'customButtonsOnHover',
vibrancy: 'under-window',
visualEffectState: 'active'
});
mainWindow.setOpacity(config.opacity);
const indexPath = isDev
? path.join(__dirname, '..', 'src', 'index.html')
: path.join(__dirname, 'index.html');
mainWindow.loadFile(indexPath);
if (isDev) {
mainWindow.webContents.openDevTools({ mode: 'detach' });
}
mainWindow.on('close', (event) => {
if (!isQuitting) {
event.preventDefault();
mainWindow?.hide();
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
}
function createTray() {
const iconPath = path.join(__dirname, '..', 'assets', 'icon-template.png');
const icon = electron_1.nativeImage.createFromPath(iconPath);
tray = new electron_1.Tray(icon.resize({ width: 16, height: 16 }));
const contextMenu = electron_1.Menu.buildFromTemplate([
{
label: 'Show Widget',
click: () => {
mainWindow?.show();
}
},
{
label: 'Hide Widget',
click: () => {
mainWindow?.hide();
}
},
{ type: 'separator' },
{
label: 'Position',
submenu: [
{
label: 'Top Right',
type: 'radio',
checked: config.position === 'top-right',
click: () => updatePosition('top-right')
},
{
label: 'Top Left',
type: 'radio',
checked: config.position === 'top-left',
click: () => updatePosition('top-left')
},
{
label: 'Bottom Right',
type: 'radio',
checked: config.position === 'bottom-right',
click: () => updatePosition('bottom-right')
},
{
label: 'Bottom Left',
type: 'radio',
checked: config.position === 'bottom-left',
click: () => updatePosition('bottom-left')
}
]
},
{
label: 'Always on Top',
type: 'checkbox',
checked: config.alwaysOnTop,
click: (menuItem) => {
config.alwaysOnTop = menuItem.checked;
mainWindow?.setAlwaysOnTop(config.alwaysOnTop);
}
},
{ type: 'separator' },
{
label: 'Refresh Now',
click: () => {
mainWindow?.webContents.send('refresh-data');
}
},
{ type: 'separator' },
{
label: 'Quit',
click: () => {
isQuitting = true;
electron_1.app.quit();
}
}
]);
tray.setToolTip('CCUsage Widget');
tray.setContextMenu(contextMenu);
tray.on('click', () => {
if (mainWindow?.isVisible()) {
mainWindow.hide();
}
else {
mainWindow?.show();
}
});
}
function updatePosition(position) {
config.position = position;
if (mainWindow) {
const { width: screenWidth, height: screenHeight } = electron_1.screen.getPrimaryDisplay().workAreaSize;
const [width, height] = mainWindow.getSize();
let x = 0, y = 0;
switch (position) {
case 'top-right':
x = screenWidth - width - 20;
y = 20;
break;
case 'top-left':
x = 20;
y = 20;
break;
case 'bottom-right':
x = screenWidth - width - 20;
y = screenHeight - height - 20;
break;
case 'bottom-left':
x = 20;
y = screenHeight - height - 20;
break;
}
mainWindow.setPosition(x, y);
}
}
electron_1.app.whenReady().then(() => {
// Set About panel options for macOS
electron_1.app.setAboutPanelOptions({
applicationName: appName,
applicationVersion: package_json_1.default.version,
version: package_json_1.default.version,
copyright: 'Copyright © 2025 JeongJaeSoon',
authors: package_json_1.default.author ?
[typeof package_json_1.default.author === 'string' ? package_json_1.default.author : package_json_1.default.author.name || 'JeongJaeSoon'] :
['JeongJaeSoon'],
website: package_json_1.default.homepage || 'https://github.com/JeongJaeSoon/ccusage-widget'
});
createWindow();
createTray();
electron_1.app.on('activate', () => {
if (electron_1.BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
electron_1.app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
electron_1.app.quit();
}
});
electron_1.app.on('before-quit', () => {
isQuitting = true;
});
// IPC handlers for fetching usage data
electron_1.ipcMain.handle('get-usage-data', async () => {
try {
// Execute ccusage commands to get data
// Try different ways to execute ccusage
let dailyResult, monthlyResult, sessionResult, blocksResult;
try {
// First try with npx
[dailyResult, monthlyResult, sessionResult, blocksResult] = await Promise.all([
execAsync('npx ccusage daily --json'),
execAsync('npx ccusage monthly --json'),
execAsync('npx ccusage session --json'),
execAsync('npx ccusage blocks --json')
]);
}
catch (error) {
// Try with direct ccusage command
// Try with direct ccusage command
try {
[dailyResult, monthlyResult, sessionResult, blocksResult] = await Promise.all([
execAsync('ccusage daily --json'),
execAsync('ccusage monthly --json'),
execAsync('ccusage session --json'),
execAsync('ccusage blocks --json')
]);
}
catch (error2) {
// Try with local node_modules
// Try with local node_modules
const ccusagePath = path.join(__dirname, '..', 'node_modules', '.bin', 'ccusage');
[dailyResult, monthlyResult, sessionResult, blocksResult] = await Promise.all([
execAsync(`${ccusagePath} daily --json`),
execAsync(`${ccusagePath} monthly --json`),
execAsync(`${ccusagePath} session --json`),
execAsync(`${ccusagePath} blocks --json`)
]);
}
}
let dailyData, monthlyData, sessionData, blocksData;
try {
const dailyParsed = JSON.parse(dailyResult.stdout || '{}');
const monthlyParsed = JSON.parse(monthlyResult.stdout || '{}');
const sessionParsed = JSON.parse(sessionResult.stdout || '{}');
const blocksParsed = JSON.parse(blocksResult.stdout || '{}');
// Adjust to actual ccusage output structure
dailyData = dailyParsed;
monthlyData = monthlyParsed;
sessionData = sessionParsed;
blocksData = blocksParsed;
}
catch (parseError) {
console.error('Failed to parse JSON:', parseError);
console.error('Raw outputs:', {
daily: dailyResult?.stdout,
monthly: monthlyResult?.stdout,
session: sessionResult?.stdout,
blocks: blocksResult?.stdout
});
throw new Error('Failed to parse ccusage output');
}
// Get today's usage (find today's date in the array)
// Use local date to match ccusage output
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const todayDate = `${year}-${month}-${day}`;
const dailyArray = dailyData.daily || dailyData.data || [];
// Find today's data or get the most recent day
let today = dailyArray.find((d) => d.date === todayDate);
// If today's data is not found, get the most recent (last item in array)
if (!today && dailyArray.length > 0) {
today = dailyArray[dailyArray.length - 1];
}
// Get this month's usage
const thisMonthDate = new Date().toISOString().substring(0, 7); // YYYY-MM format
const monthlyArray = monthlyData.monthly || monthlyData.data || [];
const thisMonth = monthlyArray.find((m) => m.month === thisMonthDate) || monthlyArray[0] || null;
// Get total from summary
const totalDaily = dailyData.totals || dailyData.summary || {};
// Get recent sessions
const sessionsArray = sessionData.sessions || sessionData.data || [];
const recentSessions = sessionsArray.slice(0, 5).map((s) => {
// Extract session name from sessionId if available, otherwise use fallback names
let sessionName = s.sessionName || s.session || s.name || 'Unknown Session';
// If we have a sessionId, try to extract the session name from it
if (s.sessionId) {
const extractedName = extractSessionNameFromId(s.sessionId);
sessionName = extractedName;
}
return {
name: sessionName,
tokens: s.totalTokens || 0,
cost: s.totalCost || s.costUSD || s.totalCostUSD || 0,
lastActivity: s.lastActivity || s.lastUpdated || new Date().toISOString()
};
});
// Get blocks data
const blocksArray = Array.isArray(blocksData.blocks) ? blocksData.blocks : [];
// Find current active block
const currentBlock = blocksArray.find((block) => block && typeof block === 'object' && block.isActive === true);
const result = {
today: today ? {
date: today.date,
tokens: today.totalTokens,
cost: today.totalCost || today.costUSD || today.totalCostUSD,
models: today.modelsUsed || today.models || []
} : null,
thisMonth: thisMonth ? {
tokens: thisMonth.totalTokens,
cost: thisMonth.totalCost || thisMonth.costUSD || thisMonth.totalCostUSD
} : null,
total: {
tokens: totalDaily.totalTokens || 0,
cost: totalDaily.totalCost || totalDaily.totalCostUSD || 0
},
currentBlock: currentBlock ? {
tokens: (currentBlock.tokenCounts?.inputTokens || 0) + (currentBlock.tokenCounts?.outputTokens || 0),
cost: currentBlock.costUSD || 0,
startTime: currentBlock.startTime,
endTime: currentBlock.endTime,
isActive: true
} : null,
recentSessions,
lastUpdated: new Date().toISOString()
};
return result;
}
catch (error) {
console.error('Error loading usage data:', error);
// Return empty data instead of throwing
return {
today: null,
thisMonth: null,
total: {
tokens: 0,
cost: 0
},
currentBlock: null,
recentSessions: [],
lastUpdated: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error'
};
}
});
electron_1.ipcMain.handle('update-opacity', async (_, opacity) => {
config.opacity = opacity;
mainWindow?.setOpacity(opacity);
});
//# sourceMappingURL=main.js.map