claude-usage-monitor
Version:
Real-time CLI dashboard for monitoring Claude AI token usage with 5-hour session tracking
362 lines (361 loc) • 16.5 kB
JavaScript
import { exec } from "child_process";
import { promisify } from "util";
import * as path from "path";
import { readdir, readFile } from "fs/promises";
import { homedir } from "os";
const execAsync = promisify(exec);
async function fetchUsage() {
try {
// Calculate date for 2 days ago to ensure we capture yesterday and today
const twoDaysAgo = new Date();
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
const dateStr = twoDaysAgo.toISOString().slice(0, 10).replace(/-/g, "");
const { stdout } = await execAsync(`npx ccusage@latest session -s ${dateStr} -j`);
return JSON.parse(stdout);
}
catch (error) {
console.error("Error fetching usage:", error);
return null;
}
}
function formatNumber(num) {
return num.toLocaleString();
}
async function analyzeLocalSessions() {
const claudeDir = path.join(homedir(), ".claude", "projects");
const allSessions = [];
try {
const projects = await readdir(claudeDir);
for (const project of projects) {
if (project.startsWith("."))
continue;
const projectDir = path.join(claudeDir, project);
const files = await readdir(projectDir);
for (const file of files) {
if (!file.endsWith(".jsonl"))
continue;
const filePath = path.join(projectDir, file);
const content = await readFile(filePath, "utf-8");
const lines = content.trim().split("\n");
if (lines.length === 0)
continue;
const entries = lines
.map((line) => {
try {
return JSON.parse(line);
}
catch {
return null;
}
})
.filter(Boolean);
if (entries.length === 0)
continue;
const timestamps = entries
.filter((e) => e.timestamp)
.map((e) => new Date(e.timestamp))
.sort((a, b) => a.getTime() - b.getTime());
if (timestamps.length === 0)
continue;
const startTime = timestamps[0];
const endTime = timestamps[timestamps.length - 1];
const userMessages = entries.filter((e) => e.type === "user").length;
const assistantMessages = entries.filter((e) => e.type === "assistant").length;
let estimatedTokens = 0;
entries.forEach((entry) => {
if (entry.message?.content) {
const content = typeof entry.message.content === "string"
? entry.message.content
: JSON.stringify(entry.message.content);
estimatedTokens += Math.ceil(content.length / 4);
}
});
const durationMs = endTime.getTime() - startTime.getTime();
const hours = Math.floor(durationMs / (1000 * 60 * 60));
const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60));
allSessions.push({
sessionId: file.replace(".jsonl", ""),
projectPath: project.replace(/-/g, "/"),
startTime,
endTime,
userMessages,
assistantMessages,
totalMessages: userMessages + assistantMessages,
duration: `${hours}h ${minutes}m`,
estimatedTokens,
});
}
}
return allSessions.sort((a, b) => b.startTime.getTime() - a.startTime.getTime());
}
catch (error) {
console.error("Error analyzing local sessions:", error);
return [];
}
}
function getTerminalHeight() {
return process.stdout.rows || 24; // Default to 24 if not available
}
async function gatherUsageData() {
const data = await fetchUsage();
if (!data) {
return null;
}
const localSessions = await analyzeLocalSessions();
return { data, localSessions };
}
async function displayUsage(cachedData) {
// Use cached data if provided, otherwise fetch new data
const usageData = cachedData || (await gatherUsageData());
if (!usageData) {
console.log("Failed to fetch usage data");
return;
}
const { data, localSessions } = usageData;
const terminalHeight = getTerminalHeight();
let currentLine = 0;
console.log(`\x1b[32mCLAUDE USAGE\x1b[0m`);
console.log();
currentLine += 2;
// Analyze local sessions
const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000);
const recentLocalSessions = localSessions.filter((s) => s.endTime >= fiveHoursAgo);
if (recentLocalSessions.length > 0) {
console.log("\x1b[36m5-Hour Session Window\x1b[0m");
console.log();
currentLine += 2;
let totalRecentTokens = 0;
// Reserve at least 8 lines for: title(2) + 5hr header(2) + 1 session(1) + separator(1) + total(1) + footer(3)
const minLinesNeeded = 10;
const availableFor5Hr = Math.max(0, terminalHeight - currentLine - minLinesNeeded);
const maxRecentSessions = Math.min(5, Math.max(1, Math.floor(availableFor5Hr)));
recentLocalSessions.slice(0, maxRecentSessions).forEach((session) => {
totalRecentTokens += session.estimatedTokens;
// Check if session is still within 5-hour window
const sessionAge = Date.now() - session.startTime.getTime();
const fiveHoursMs = 5 * 60 * 60 * 1000;
let timeInfo = "";
if (sessionAge < fiveHoursMs) {
const timeRemaining = fiveHoursMs - sessionAge;
const hoursRemaining = Math.floor(timeRemaining / (1000 * 60 * 60));
const minutesRemaining = Math.floor((timeRemaining % (1000 * 60 * 60)) / (1000 * 60));
timeInfo = `${hoursRemaining}h ${minutesRemaining}m left`;
}
else {
timeInfo = "Expired";
}
const projectName = session.projectPath.split("/").pop() || session.projectPath;
const startTime = session.startTime.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const endTime = session.endTime.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
console.log(` ${projectName.padEnd(20)} ` +
`\x1b[90m${startTime} - ${endTime}\x1b[0m ` +
`\x1b[90m(${session.duration})\x1b[0m ` +
`\x1b[36m${formatNumber(session.estimatedTokens).padStart(9)}\x1b[0m ` +
`\x1b[90m${timeInfo}\x1b[0m`);
currentLine++;
});
console.log("\x1b[90m─────────────────────────────────────────────────────────────────\x1b[0m");
console.log(` \x1b[36mTotal (5hr)\x1b[0m: ${formatNumber(totalRecentTokens)} tokens`);
currentLine += 2;
}
// Show sessions from today and yesterday
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const todaySessions = localSessions.filter((s) => s.startTime >= today);
const yesterdaySessions = localSessions.filter((s) => s.startTime >= yesterday && s.startTime < today);
// Calculate remaining space for session listings
const remainingLines = terminalHeight - currentLine - 3; // Reserve 3 lines for footer
// Only show detailed sessions if we have enough space
if (remainingLines > 8 &&
(todaySessions.length > 0 || yesterdaySessions.length > 0)) {
console.log();
currentLine++;
// Calculate how many sessions we can show
const headerLines = 4; // Headers and separators per section
const availableForSessions = Math.max(0, remainingLines - headerLines * 2); // Account for both sections
// If very limited space, show only today's sessions
let maxTodaySessions = 0;
let maxYesterdaySessions = 0;
if (availableForSessions > 0) {
if (availableForSessions < 4) {
// Very small screen - only show today's sessions
maxTodaySessions = availableForSessions;
maxYesterdaySessions = 0;
}
else {
// Normal allocation
maxTodaySessions = Math.floor(availableForSessions * 0.7); // 70% for today
maxYesterdaySessions = Math.floor(availableForSessions * 0.3); // 30% for yesterday
}
}
// Today's sessions
if (todaySessions.length > 0 && maxTodaySessions > 0) {
console.log("\n\x1b[32mToday\x1b[0m\n");
console.log(` ${"Project".padEnd(20)} ` +
`${"Time Range".padEnd(13)} ` +
`${"Duration".padEnd(12)} ` +
`${"Est. Tokens".padStart(11)}`);
console.log("\x1b[90m─────────────────────────────────────────────────────────────────\x1b[0m");
currentLine += 4;
todaySessions.slice(0, maxTodaySessions).forEach((session) => {
const projectName = session.projectPath.split("/").pop() || session.projectPath;
const startTime = session.startTime.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const endTime = session.endTime.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
console.log(` ${projectName.padEnd(20)} ` +
`\x1b[90m${startTime} - ${endTime}\x1b[0m ` +
`\x1b[90m(${session.duration.padStart(7)})\x1b[0m ` +
`\x1b[36m${formatNumber(session.estimatedTokens).padStart(9)}\x1b[0m tokens`);
currentLine++;
});
// Show count of hidden today's sessions if any
const hiddenToday = todaySessions.length - maxTodaySessions;
if (hiddenToday > 0) {
console.log();
console.log(` \x1b[90m... and ${hiddenToday} more today's sessions\x1b[0m`);
currentLine++;
}
}
// Yesterday's sessions
if (yesterdaySessions.length > 0 && maxYesterdaySessions > 0) {
console.log("\n\x1b[32mYesterday\x1b[0m\n");
console.log(` ${"Project".padEnd(20)} ` +
`${"Time Range".padEnd(13)} ` +
`${"Duration".padEnd(12)} ` +
`${"Est. Tokens".padStart(11)}`);
console.log("\x1b[90m─────────────────────────────────────────────────────────────────\x1b[0m");
currentLine += 4;
yesterdaySessions.slice(0, maxYesterdaySessions).forEach((session) => {
const projectName = session.projectPath.split("/").pop() || session.projectPath;
const startTime = session.startTime.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const endTime = session.endTime.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
console.log(` ${projectName.padEnd(20)} ` +
`\x1b[90m${startTime} - ${endTime}\x1b[0m ` +
`\x1b[90m(${session.duration.padStart(7)})\x1b[0m ` +
`\x1b[36m${formatNumber(session.estimatedTokens).padStart(9)}\x1b[0m tokens`);
currentLine++;
});
// Show count of hidden yesterday's sessions if any
const hiddenYesterday = yesterdaySessions.length - maxYesterdaySessions;
if (hiddenYesterday > 0) {
console.log();
console.log(` \x1b[90m... and ${hiddenYesterday} more yesterday's sessions\x1b[0m`);
}
}
}
else if (remainingLines <= 8 &&
(todaySessions.length > 0 || yesterdaySessions.length > 0)) {
// For very small screens, show a summary instead
console.log();
const todayTokens = todaySessions.reduce((sum, s) => sum + s.estimatedTokens, 0);
const yesterdayTokens = yesterdaySessions.reduce((sum, s) => sum + s.estimatedTokens, 0);
if (todaySessions.length > 0) {
console.log(` \x1b[32mToday\x1b[0m: ${todaySessions.length} sessions, ${formatNumber(todayTokens)} tokens`);
}
if (yesterdaySessions.length > 0) {
console.log(` \x1b[33mYesterday\x1b[0m: ${yesterdaySessions.length} sessions, ${formatNumber(yesterdayTokens)} tokens`);
}
}
console.log();
console.log("\x1b[90m─────────────────────────────────────────────────────────────────\x1b[0m");
}
async function main() {
let secondsUntilRefresh = 60;
let countdownInterval;
let refreshInterval;
let cachedData = null;
let isRefreshing = false;
// Function to update the countdown
const updateCountdown = () => {
// Move cursor to the last line
process.stdout.write("\x1b[?25l"); // Hide cursor
process.stdout.write("\x1b[s"); // Save cursor position
process.stdout.write(`\x1b[${process.stdout.rows};1H`); // Move to last line
process.stdout.write("\x1b[K"); // Clear line
process.stdout.write(`\x1b[90mNext refresh in ${secondsUntilRefresh}s${isRefreshing ? " (updating...)" : ""}\x1b[0m`);
process.stdout.write("\x1b[u"); // Restore cursor position
process.stdout.write("\x1b[?25h"); // Show cursor
};
// Function to refresh display with smooth update
const refresh = async (forceRedraw = false) => {
if (forceRedraw || !cachedData) {
console.clear();
}
else {
// Use cursor positioning to overwrite content without clearing
process.stdout.write("\x1b[H"); // Move cursor to home position
}
await displayUsage(cachedData || undefined);
secondsUntilRefresh = 60;
updateCountdown();
};
// Function to fetch data in background
const fetchInBackground = async () => {
isRefreshing = true;
updateCountdown();
try {
const newData = await gatherUsageData();
if (newData) {
cachedData = newData;
// Only update display if we have new data
await refresh(false);
}
}
finally {
isRefreshing = false;
}
};
// Initial display
console.clear();
await fetchInBackground();
// Update countdown every second
countdownInterval = setInterval(() => {
secondsUntilRefresh--;
if (secondsUntilRefresh <= 0) {
secondsUntilRefresh = 60;
}
updateCountdown();
}, 1000);
// Refresh data every 60 seconds in background
refreshInterval = setInterval(() => {
fetchInBackground();
}, 60000);
// Handle terminal resize
process.stdout.on("resize", () => {
refresh(true); // Force redraw on resize
});
}
// Handle graceful exit
process.on("SIGINT", () => {
console.clear();
console.log("\x1b[32mStopped monitoring Claude usage\x1b[0m");
process.exit(0);
});
// Main
main().catch(console.error);