feat: add usage panel with session, weekly, and monthly cost bars
Scan ~/.claude/projects JSONL files to compute API cost usage. Shows 4 bars matching Claude's usage screen: current session (5h window), weekly all models, weekly sonnet-only, and monthly total. Configurable plan limits in PLAN_LIMITS. Refreshes every 30s. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
327
src/data/usage.ts
Normal file
327
src/data/usage.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { readdirSync, statSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
const PROJECTS_DIR = `${Bun.env.HOME}/.claude/projects`
|
||||
const WINDOW_MS = 5 * 60 * 60 * 1000 // 5 hours
|
||||
|
||||
// Configurable plan limits (cost-based estimates)
|
||||
// Adjust these to match your Claude plan's actual limits
|
||||
export const PLAN_LIMITS = {
|
||||
session: 750, // $ per 5h window (Max plan opus-heavy estimate)
|
||||
weeklyAll: 10000, // $ per week all models
|
||||
weeklySonnet: 2000, // $ per week sonnet only
|
||||
extraMonthly: 20, // € per month extra usage cap
|
||||
}
|
||||
|
||||
interface TokenUsage {
|
||||
input: number
|
||||
output: number
|
||||
cacheCreation: number
|
||||
cacheRead: number
|
||||
}
|
||||
|
||||
interface ModelUsage extends TokenUsage {
|
||||
cost: number
|
||||
requests: number
|
||||
}
|
||||
|
||||
export interface DayCost {
|
||||
label: string // "Mon", "Tue", etc.
|
||||
date: string // "2026-02-24"
|
||||
cost: number
|
||||
requests: number
|
||||
}
|
||||
|
||||
export interface UsageSummary {
|
||||
windowMs: number
|
||||
sessionResetMs: number
|
||||
totalCost: number
|
||||
totalInput: number
|
||||
totalOutput: number
|
||||
totalCacheCreation: number
|
||||
totalCacheRead: number
|
||||
totalRequests: number
|
||||
costPerHour: number
|
||||
byModel: Map<string, ModelUsage>
|
||||
weekDays: DayCost[] // last 7 days, oldest first
|
||||
weekTotal: number
|
||||
weeklySonnetCost: number
|
||||
monthlyTotalCost: number
|
||||
}
|
||||
|
||||
// Per-million-token pricing
|
||||
const PRICING: Record<string, { input: number; output: number; cacheCreation: number; cacheRead: number }> = {
|
||||
"opus": { input: 15.0, output: 75.0, cacheCreation: 18.75, cacheRead: 1.50 },
|
||||
"sonnet": { input: 3.0, output: 15.0, cacheCreation: 3.75, cacheRead: 0.30 },
|
||||
"haiku": { input: 0.80, output: 4.0, cacheCreation: 1.00, cacheRead: 0.08 },
|
||||
}
|
||||
|
||||
function normalizeModel(model: string): string {
|
||||
const l = model.toLowerCase()
|
||||
if (l.includes("opus")) return "opus"
|
||||
if (l.includes("haiku")) return "haiku"
|
||||
if (l.includes("sonnet")) return "sonnet"
|
||||
return "sonnet"
|
||||
}
|
||||
|
||||
function modelLabel(model: string): string {
|
||||
const l = model.toLowerCase()
|
||||
if (l.includes("opus")) {
|
||||
if (l.includes("4-6") || l.includes("4.6")) return "opus-4.6"
|
||||
if (l.includes("4-5") || l.includes("4.5")) return "opus-4.5"
|
||||
return "opus"
|
||||
}
|
||||
if (l.includes("sonnet")) {
|
||||
if (l.includes("4-6") || l.includes("4.6")) return "sonnet-4.6"
|
||||
if (l.includes("4-5") || l.includes("4.5")) return "sonnet-4.5"
|
||||
if (l.includes("3-5") || l.includes("3.5")) return "sonnet-3.5"
|
||||
return "sonnet"
|
||||
}
|
||||
if (l.includes("haiku")) {
|
||||
if (l.includes("4-5") || l.includes("4.5")) return "haiku-4.5"
|
||||
if (l.includes("3-5") || l.includes("3.5")) return "haiku-3.5"
|
||||
return "haiku"
|
||||
}
|
||||
return model.slice(0, 12)
|
||||
}
|
||||
|
||||
function calcCost(normalized: string, tokens: TokenUsage): number {
|
||||
const p = PRICING[normalized] || PRICING["sonnet"]
|
||||
return (
|
||||
(tokens.input / 1_000_000) * p.input +
|
||||
(tokens.output / 1_000_000) * p.output +
|
||||
(tokens.cacheCreation / 1_000_000) * p.cacheCreation +
|
||||
(tokens.cacheRead / 1_000_000) * p.cacheRead
|
||||
)
|
||||
}
|
||||
|
||||
// Cache: file path → { mtime, entries }
|
||||
const fileCache = new Map<string, { mtime: number; entries: Array<{ model: string; tokens: TokenUsage; ts: number }> }>()
|
||||
|
||||
async function scanFile(filePath: string, cutoff: number): Promise<Array<{ model: string; tokens: TokenUsage; ts: number }>> {
|
||||
let mtime: number
|
||||
try {
|
||||
mtime = statSync(filePath).mtimeMs
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
// Skip files not modified since cutoff
|
||||
if (mtime < cutoff) return []
|
||||
|
||||
// Use cache if file unchanged
|
||||
const cached = fileCache.get(filePath)
|
||||
if (cached && cached.mtime === mtime) {
|
||||
return cached.entries.filter(e => e.ts >= cutoff)
|
||||
}
|
||||
|
||||
// Parse file
|
||||
const entries: Array<{ model: string; tokens: TokenUsage; ts: number }> = []
|
||||
try {
|
||||
const text = await Bun.file(filePath).text()
|
||||
for (const line of text.split("\n")) {
|
||||
if (!line.includes('"usage"')) continue
|
||||
try {
|
||||
const d = JSON.parse(line)
|
||||
const msg = d.message
|
||||
if (!msg?.usage) continue
|
||||
const u = msg.usage
|
||||
const input = u.input_tokens || 0
|
||||
const output = u.output_tokens || 0
|
||||
if (!input && !output) continue
|
||||
|
||||
const ts = d.timestamp ? new Date(d.timestamp).getTime() : mtime
|
||||
entries.push({
|
||||
model: msg.model || "unknown",
|
||||
tokens: {
|
||||
input,
|
||||
output,
|
||||
cacheCreation: u.cache_creation_input_tokens || 0,
|
||||
cacheRead: u.cache_read_input_tokens || 0,
|
||||
},
|
||||
ts,
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
fileCache.set(filePath, { mtime, entries })
|
||||
return entries.filter(e => e.ts >= cutoff)
|
||||
}
|
||||
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
function dateKey(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
function dayLabel(ts: number): string {
|
||||
return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][new Date(ts).getDay()]
|
||||
}
|
||||
|
||||
export async function getUsageSummary(): Promise<UsageSummary> {
|
||||
const now = Date.now()
|
||||
const sessionCutoff = now - WINDOW_MS
|
||||
const weekCutoff = now - WEEK_MS
|
||||
|
||||
// Monthly cutoff: 1st of current month
|
||||
const monthStart = new Date(now)
|
||||
monthStart.setDate(1)
|
||||
monthStart.setHours(0, 0, 0, 0)
|
||||
const monthlyCutoff = monthStart.getTime()
|
||||
|
||||
// Use the earliest cutoff for scanning
|
||||
const scanCutoff = Math.min(weekCutoff, monthlyCutoff)
|
||||
|
||||
const allEntries: Array<{ model: string; tokens: TokenUsage; ts: number }> = []
|
||||
|
||||
try {
|
||||
const dirs = readdirSync(PROJECTS_DIR)
|
||||
const filePromises: Promise<Array<{ model: string; tokens: TokenUsage; ts: number }>>[] = []
|
||||
|
||||
for (const dir of dirs) {
|
||||
const dirPath = join(PROJECTS_DIR, dir)
|
||||
try {
|
||||
const files = readdirSync(dirPath).filter(f => f.endsWith(".jsonl"))
|
||||
for (const f of files) {
|
||||
filePromises.push(scanFile(join(dirPath, f), scanCutoff))
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const results = await Promise.all(filePromises)
|
||||
for (const entries of results) {
|
||||
allEntries.push(...entries)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Deduplicate
|
||||
const seen = new Set<string>()
|
||||
type Deduped = { model: string; tokens: TokenUsage; ts: number; normalized: string; label: string; cost: number }
|
||||
const deduped: Deduped[] = []
|
||||
|
||||
for (const entry of allEntries) {
|
||||
const dedupeKey = `${entry.ts}:${entry.model}:${entry.tokens.output}`
|
||||
if (seen.has(dedupeKey)) continue
|
||||
seen.add(dedupeKey)
|
||||
const normalized = normalizeModel(entry.model)
|
||||
const label = modelLabel(entry.model)
|
||||
const cost = calcCost(normalized, entry.tokens)
|
||||
deduped.push({ ...entry, normalized, label, cost })
|
||||
}
|
||||
|
||||
// Session aggregation (5h window)
|
||||
const byModel = new Map<string, ModelUsage>()
|
||||
let totalCost = 0, totalInput = 0, totalOutput = 0
|
||||
let totalCacheCreation = 0, totalCacheRead = 0, totalRequests = 0
|
||||
let earliestTs = now
|
||||
|
||||
for (const e of deduped) {
|
||||
if (e.ts < sessionCutoff) continue
|
||||
totalCost += e.cost
|
||||
totalInput += e.tokens.input
|
||||
totalOutput += e.tokens.output
|
||||
totalCacheCreation += e.tokens.cacheCreation
|
||||
totalCacheRead += e.tokens.cacheRead
|
||||
totalRequests++
|
||||
if (e.ts < earliestTs) earliestTs = e.ts
|
||||
|
||||
let m = byModel.get(e.label)
|
||||
if (!m) m = { input: 0, output: 0, cacheCreation: 0, cacheRead: 0, cost: 0, requests: 0 }
|
||||
m.input += e.tokens.input
|
||||
m.output += e.tokens.output
|
||||
m.cacheCreation += e.tokens.cacheCreation
|
||||
m.cacheRead += e.tokens.cacheRead
|
||||
m.cost += e.cost
|
||||
m.requests++
|
||||
byModel.set(e.label, m)
|
||||
}
|
||||
|
||||
// Weekly per-day aggregation + sonnet weekly cost
|
||||
const dayMap = new Map<string, { cost: number; requests: number; ts: number }>()
|
||||
let weeklySonnetCost = 0
|
||||
for (const e of deduped) {
|
||||
if (e.ts < weekCutoff) continue
|
||||
const dk = dateKey(e.ts)
|
||||
let day = dayMap.get(dk)
|
||||
if (!day) { day = { cost: 0, requests: 0, ts: e.ts }; dayMap.set(dk, day) }
|
||||
day.cost += e.cost
|
||||
day.requests += 1
|
||||
if (e.normalized === "sonnet") weeklySonnetCost += e.cost
|
||||
}
|
||||
|
||||
// Build 7-day array (oldest to newest)
|
||||
const weekDays: DayCost[] = []
|
||||
let weekTotal = 0
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const dayTs = now - i * 24 * 60 * 60 * 1000
|
||||
const dk = dateKey(dayTs)
|
||||
const day = dayMap.get(dk)
|
||||
weekDays.push({
|
||||
label: dayLabel(dayTs),
|
||||
date: dk,
|
||||
cost: day?.cost || 0,
|
||||
requests: day?.requests || 0,
|
||||
})
|
||||
weekTotal += day?.cost || 0
|
||||
}
|
||||
|
||||
// Monthly total cost
|
||||
let monthlyTotalCost = 0
|
||||
for (const e of deduped) {
|
||||
if (e.ts >= monthlyCutoff) monthlyTotalCost += e.cost
|
||||
}
|
||||
|
||||
const windowMs = totalRequests > 0 ? now - earliestTs : 0
|
||||
const sessionResetMs = totalRequests > 0 ? Math.max(0, WINDOW_MS - windowMs) : 0
|
||||
const hours = windowMs / 3_600_000 || 1
|
||||
|
||||
return {
|
||||
windowMs,
|
||||
sessionResetMs,
|
||||
totalCost,
|
||||
totalInput,
|
||||
totalOutput,
|
||||
totalCacheCreation,
|
||||
totalCacheRead,
|
||||
totalRequests,
|
||||
costPerHour: totalCost / hours,
|
||||
byModel,
|
||||
weekDays,
|
||||
weekTotal,
|
||||
weeklySonnetCost,
|
||||
monthlyTotalCost,
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(0) + "K"
|
||||
return String(n)
|
||||
}
|
||||
|
||||
export function formatCost(n: number): string {
|
||||
if (n >= 10) return "$" + n.toFixed(1)
|
||||
if (n >= 1) return "$" + n.toFixed(2)
|
||||
return "$" + n.toFixed(3)
|
||||
}
|
||||
|
||||
export function formatWindow(ms: number): string {
|
||||
const h = Math.floor(ms / 3_600_000)
|
||||
const m = Math.floor((ms % 3_600_000) / 60_000)
|
||||
if (h > 0) return `${h}h ${m > 0 ? m + "m" : ""}`
|
||||
return `${m}m`
|
||||
}
|
||||
|
||||
export function makeBar(value: number, max: number, width: number): string {
|
||||
if (max <= 0) return "░".repeat(width)
|
||||
const filled = Math.round((value / max) * width)
|
||||
const clamped = Math.min(filled, width)
|
||||
return "█".repeat(clamped) + "░".repeat(width - clamped)
|
||||
}
|
||||
|
||||
export function pct(value: number, max: number): number {
|
||||
if (max <= 0) return 0
|
||||
return Math.min(Math.round((value / max) * 100), 100)
|
||||
}
|
||||
106
src/index.ts
106
src/index.ts
@@ -22,6 +22,7 @@ import { loadGitMetadata, loadBranches } from "./data/git"
|
||||
import { loadSessions } from "./data/sessions"
|
||||
import { generateMockProjects, generateMockSessions, generateMockBranches, generateMockBusySessions } from "./data/mock"
|
||||
import { detectActiveSessions, updateProjectSessions, generateMockActiveSessions, focusTerminalByPath, checkTransitions, snapshotBusy, playDoneSound, bounceDock, getSessionStatus, populateMockSessionStatus, getIdleSessions } from "./data/monitor"
|
||||
import { getUsageSummary, formatCost, formatWindow, makeBar, pct, PLAN_LIMITS, type UsageSummary } from "./data/usage"
|
||||
import { launchSelections } from "./actions/launcher"
|
||||
import type { Project, DisplayRow } from "./lib/types"
|
||||
import { timeAgo, formatSize, elapsedCompact } from "./lib/time"
|
||||
@@ -55,9 +56,12 @@ let renderer: CliRenderer
|
||||
let headerText: TextRenderable
|
||||
let colHeaderText: TextRenderable
|
||||
let listBox: ScrollBoxRenderable
|
||||
let bottomRow: BoxRenderable
|
||||
let previewBox: BoxRenderable
|
||||
let previewText: TextRenderable
|
||||
let usageBox: BoxRenderable
|
||||
let footerText: TextRenderable
|
||||
let cachedUsage: UsageSummary | null = null
|
||||
|
||||
// ─── Display Rows ───────────────────────────────────────────────────
|
||||
function rebuildDisplayRows() {
|
||||
@@ -303,18 +307,64 @@ function updateIdlePanel() {
|
||||
|
||||
function updateBottomPanel() {
|
||||
if (bottomPanelMode === "idle") {
|
||||
previewBox.height = 12
|
||||
bottomRow.height = 14
|
||||
updateIdlePanel()
|
||||
} else {
|
||||
// Restore previewText as sole child
|
||||
for (const child of previewBox.getChildren()) previewBox.remove(child.id)
|
||||
previewBox.add(previewText)
|
||||
previewBox.height = 7
|
||||
bottomRow.height = 10
|
||||
previewBox.title = " Preview "
|
||||
updatePreview()
|
||||
}
|
||||
}
|
||||
|
||||
function usageBarColor(p: number) {
|
||||
return p >= 80 ? yellow : p >= 50 ? cyan : green
|
||||
}
|
||||
|
||||
function updateUsagePanel() {
|
||||
if (destroyed) return
|
||||
for (const child of usageBox.getChildren()) usageBox.remove(child.id)
|
||||
|
||||
if (!cachedUsage) {
|
||||
usageBox.title = " Usage "
|
||||
usageBox.add(Text({ content: t`${dim("Loading...")}`, width: "100%", height: 1 }))
|
||||
return
|
||||
}
|
||||
|
||||
const u = cachedUsage
|
||||
const BAR_W = 18
|
||||
|
||||
// ── Current session ──
|
||||
const sPct = pct(u.totalCost, PLAN_LIMITS.session)
|
||||
const sBar = makeBar(u.totalCost, PLAN_LIMITS.session, BAR_W)
|
||||
const sReset = u.sessionResetMs > 0 ? formatWindow(u.sessionResetMs) : ""
|
||||
usageBox.title = " Usage "
|
||||
usageBox.add(Text({ content: t`${bold("Session")}`, width: "100%", height: 1 }))
|
||||
usageBox.add(Text({ content: t`${usageBarColor(sPct)(sBar)} ${bold(String(sPct) + "%")}`, width: "100%", height: 1 }))
|
||||
usageBox.add(Text({ content: t`${dim(sReset ? "resets " + sReset : "")} ${dim(formatCost(u.costPerHour) + "/h")}`, width: "100%", height: 1 }))
|
||||
|
||||
// ── Weekly all models ──
|
||||
const wPct = pct(u.weekTotal, PLAN_LIMITS.weeklyAll)
|
||||
const wBar = makeBar(u.weekTotal, PLAN_LIMITS.weeklyAll, BAR_W)
|
||||
usageBox.add(Text({ content: t`${bold("All models")} ${dim(formatCost(u.weekTotal))}`, width: "100%", height: 1 }))
|
||||
usageBox.add(Text({ content: t`${usageBarColor(wPct)(wBar)} ${bold(String(wPct) + "%")}`, width: "100%", height: 1 }))
|
||||
|
||||
// ── Weekly sonnet only ──
|
||||
const snPct = pct(u.weeklySonnetCost, PLAN_LIMITS.weeklySonnet)
|
||||
const snBar = makeBar(u.weeklySonnetCost, PLAN_LIMITS.weeklySonnet, BAR_W)
|
||||
usageBox.add(Text({ content: t`${bold("Sonnet")} ${dim(formatCost(u.weeklySonnetCost))}`, width: "100%", height: 1 }))
|
||||
usageBox.add(Text({ content: t`${usageBarColor(snPct)(snBar)} ${bold(String(snPct) + "%")}`, width: "100%", height: 1 }))
|
||||
|
||||
// ── Monthly total ──
|
||||
const monthLabel = new Date().toLocaleString("en", { month: "short" })
|
||||
usageBox.add(Text({ content: t`${bold(monthLabel + " total")} ${dim(formatCost(u.monthlyTotalCost))}`, width: "100%", height: 1 }))
|
||||
usageBox.add(Text({ content: t`${dim(formatCost(u.costPerHour) + "/h avg · " + u.totalRequests + " reqs")}`, width: "100%", height: 1 }))
|
||||
|
||||
renderer.requestRender()
|
||||
}
|
||||
|
||||
function updateFooter() {
|
||||
if (bottomPanelMode === "idle" && cachedIdleSessions.length > 0) {
|
||||
footerText.content = t` ${dim(
|
||||
@@ -449,6 +499,7 @@ function ensureCursorVisible() {
|
||||
}
|
||||
|
||||
function updateAll() {
|
||||
if (destroyed) return
|
||||
updateHeader()
|
||||
rebuildList()
|
||||
updateBottomPanel()
|
||||
@@ -705,6 +756,10 @@ async function main() {
|
||||
exitOnCtrlC: true,
|
||||
useAlternateScreen: true,
|
||||
useMouse: true,
|
||||
onDestroy: () => {
|
||||
destroyed = true
|
||||
if (monitorInterval) { clearInterval(monitorInterval); monitorInterval = null }
|
||||
},
|
||||
})
|
||||
|
||||
// Build layout
|
||||
@@ -732,10 +787,16 @@ async function main() {
|
||||
viewportCulling: true,
|
||||
})
|
||||
|
||||
previewBox = new BoxRenderable(renderer, {
|
||||
height: 7,
|
||||
bottomRow = new BoxRenderable(renderer, {
|
||||
flexDirection: "row",
|
||||
height: 10,
|
||||
flexShrink: 0,
|
||||
width: "100%",
|
||||
})
|
||||
|
||||
previewBox = new BoxRenderable(renderer, {
|
||||
flexGrow: 1,
|
||||
height: "100%",
|
||||
borderStyle: "single",
|
||||
border: ["top"],
|
||||
borderColor: DIM_CLR,
|
||||
@@ -752,6 +813,23 @@ async function main() {
|
||||
})
|
||||
previewBox.add(previewText)
|
||||
|
||||
usageBox = new BoxRenderable(renderer, {
|
||||
width: 34,
|
||||
height: "100%",
|
||||
flexShrink: 0,
|
||||
borderStyle: "single",
|
||||
border: ["top", "left"],
|
||||
borderColor: DIM_CLR,
|
||||
title: " Usage (5h) ",
|
||||
titleAlignment: "left",
|
||||
flexDirection: "column",
|
||||
paddingLeft: 1,
|
||||
paddingRight: 1,
|
||||
})
|
||||
|
||||
bottomRow.add(previewBox)
|
||||
bottomRow.add(usageBox)
|
||||
|
||||
footerText = new TextRenderable(renderer, {
|
||||
width: "100%",
|
||||
height: 1,
|
||||
@@ -761,7 +839,7 @@ async function main() {
|
||||
mainBox.add(headerText)
|
||||
mainBox.add(colHeaderText)
|
||||
mainBox.add(listBox)
|
||||
mainBox.add(previewBox)
|
||||
mainBox.add(bottomRow)
|
||||
mainBox.add(footerText)
|
||||
|
||||
renderer.root.add(mainBox)
|
||||
@@ -770,8 +848,15 @@ async function main() {
|
||||
updateColumnHeaders()
|
||||
rebuildList()
|
||||
updateBottomPanel()
|
||||
updateUsagePanel()
|
||||
updateFooter()
|
||||
|
||||
// Load initial usage data
|
||||
getUsageSummary().then(u => {
|
||||
cachedUsage = u
|
||||
updateUsagePanel()
|
||||
}).catch(() => {})
|
||||
|
||||
renderer.keyInput.on("keypress", handleKeypress)
|
||||
|
||||
// Live session monitoring
|
||||
@@ -794,8 +879,19 @@ async function main() {
|
||||
})
|
||||
}
|
||||
|
||||
let usageTick = 0
|
||||
monitorInterval = setInterval(async () => {
|
||||
if (destroyed) return
|
||||
|
||||
// Refresh usage every ~30s (6 ticks of 5s)
|
||||
usageTick++
|
||||
if (usageTick % 6 === 0) {
|
||||
try {
|
||||
cachedUsage = await getUsageSummary()
|
||||
updateUsagePanel()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (demoMode) {
|
||||
for (const p of projects) { p.activeSessions = 0; p.busySessions = 0 }
|
||||
generateMockActiveSessions(projects)
|
||||
|
||||
Reference in New Issue
Block a user