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:
Alejandro Gutiérrez
2026-02-24 19:27:43 +00:00
parent e56ed9bc95
commit 972269ef20
3 changed files with 431 additions and 5 deletions

View File

@@ -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)