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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -35,3 +35,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
|
|
||||||
# Asset generator (not needed for users)
|
# Asset generator (not needed for users)
|
||||||
assets/gen-screenshots.py
|
assets/gen-screenshots.py
|
||||||
|
|
||||||
|
# Reference repos
|
||||||
|
_reference/
|
||||||
|
|||||||
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 { loadSessions } from "./data/sessions"
|
||||||
import { generateMockProjects, generateMockSessions, generateMockBranches, generateMockBusySessions } from "./data/mock"
|
import { generateMockProjects, generateMockSessions, generateMockBranches, generateMockBusySessions } from "./data/mock"
|
||||||
import { detectActiveSessions, updateProjectSessions, generateMockActiveSessions, focusTerminalByPath, checkTransitions, snapshotBusy, playDoneSound, bounceDock, getSessionStatus, populateMockSessionStatus, getIdleSessions } from "./data/monitor"
|
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 { launchSelections } from "./actions/launcher"
|
||||||
import type { Project, DisplayRow } from "./lib/types"
|
import type { Project, DisplayRow } from "./lib/types"
|
||||||
import { timeAgo, formatSize, elapsedCompact } from "./lib/time"
|
import { timeAgo, formatSize, elapsedCompact } from "./lib/time"
|
||||||
@@ -55,9 +56,12 @@ let renderer: CliRenderer
|
|||||||
let headerText: TextRenderable
|
let headerText: TextRenderable
|
||||||
let colHeaderText: TextRenderable
|
let colHeaderText: TextRenderable
|
||||||
let listBox: ScrollBoxRenderable
|
let listBox: ScrollBoxRenderable
|
||||||
|
let bottomRow: BoxRenderable
|
||||||
let previewBox: BoxRenderable
|
let previewBox: BoxRenderable
|
||||||
let previewText: TextRenderable
|
let previewText: TextRenderable
|
||||||
|
let usageBox: BoxRenderable
|
||||||
let footerText: TextRenderable
|
let footerText: TextRenderable
|
||||||
|
let cachedUsage: UsageSummary | null = null
|
||||||
|
|
||||||
// ─── Display Rows ───────────────────────────────────────────────────
|
// ─── Display Rows ───────────────────────────────────────────────────
|
||||||
function rebuildDisplayRows() {
|
function rebuildDisplayRows() {
|
||||||
@@ -303,18 +307,64 @@ function updateIdlePanel() {
|
|||||||
|
|
||||||
function updateBottomPanel() {
|
function updateBottomPanel() {
|
||||||
if (bottomPanelMode === "idle") {
|
if (bottomPanelMode === "idle") {
|
||||||
previewBox.height = 12
|
bottomRow.height = 14
|
||||||
updateIdlePanel()
|
updateIdlePanel()
|
||||||
} else {
|
} else {
|
||||||
// Restore previewText as sole child
|
// Restore previewText as sole child
|
||||||
for (const child of previewBox.getChildren()) previewBox.remove(child.id)
|
for (const child of previewBox.getChildren()) previewBox.remove(child.id)
|
||||||
previewBox.add(previewText)
|
previewBox.add(previewText)
|
||||||
previewBox.height = 7
|
bottomRow.height = 10
|
||||||
previewBox.title = " Preview "
|
previewBox.title = " Preview "
|
||||||
updatePreview()
|
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() {
|
function updateFooter() {
|
||||||
if (bottomPanelMode === "idle" && cachedIdleSessions.length > 0) {
|
if (bottomPanelMode === "idle" && cachedIdleSessions.length > 0) {
|
||||||
footerText.content = t` ${dim(
|
footerText.content = t` ${dim(
|
||||||
@@ -449,6 +499,7 @@ function ensureCursorVisible() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateAll() {
|
function updateAll() {
|
||||||
|
if (destroyed) return
|
||||||
updateHeader()
|
updateHeader()
|
||||||
rebuildList()
|
rebuildList()
|
||||||
updateBottomPanel()
|
updateBottomPanel()
|
||||||
@@ -705,6 +756,10 @@ async function main() {
|
|||||||
exitOnCtrlC: true,
|
exitOnCtrlC: true,
|
||||||
useAlternateScreen: true,
|
useAlternateScreen: true,
|
||||||
useMouse: true,
|
useMouse: true,
|
||||||
|
onDestroy: () => {
|
||||||
|
destroyed = true
|
||||||
|
if (monitorInterval) { clearInterval(monitorInterval); monitorInterval = null }
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Build layout
|
// Build layout
|
||||||
@@ -732,10 +787,16 @@ async function main() {
|
|||||||
viewportCulling: true,
|
viewportCulling: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
previewBox = new BoxRenderable(renderer, {
|
bottomRow = new BoxRenderable(renderer, {
|
||||||
height: 7,
|
flexDirection: "row",
|
||||||
|
height: 10,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
})
|
||||||
|
|
||||||
|
previewBox = new BoxRenderable(renderer, {
|
||||||
|
flexGrow: 1,
|
||||||
|
height: "100%",
|
||||||
borderStyle: "single",
|
borderStyle: "single",
|
||||||
border: ["top"],
|
border: ["top"],
|
||||||
borderColor: DIM_CLR,
|
borderColor: DIM_CLR,
|
||||||
@@ -752,6 +813,23 @@ async function main() {
|
|||||||
})
|
})
|
||||||
previewBox.add(previewText)
|
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, {
|
footerText = new TextRenderable(renderer, {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: 1,
|
height: 1,
|
||||||
@@ -761,7 +839,7 @@ async function main() {
|
|||||||
mainBox.add(headerText)
|
mainBox.add(headerText)
|
||||||
mainBox.add(colHeaderText)
|
mainBox.add(colHeaderText)
|
||||||
mainBox.add(listBox)
|
mainBox.add(listBox)
|
||||||
mainBox.add(previewBox)
|
mainBox.add(bottomRow)
|
||||||
mainBox.add(footerText)
|
mainBox.add(footerText)
|
||||||
|
|
||||||
renderer.root.add(mainBox)
|
renderer.root.add(mainBox)
|
||||||
@@ -770,8 +848,15 @@ async function main() {
|
|||||||
updateColumnHeaders()
|
updateColumnHeaders()
|
||||||
rebuildList()
|
rebuildList()
|
||||||
updateBottomPanel()
|
updateBottomPanel()
|
||||||
|
updateUsagePanel()
|
||||||
updateFooter()
|
updateFooter()
|
||||||
|
|
||||||
|
// Load initial usage data
|
||||||
|
getUsageSummary().then(u => {
|
||||||
|
cachedUsage = u
|
||||||
|
updateUsagePanel()
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
renderer.keyInput.on("keypress", handleKeypress)
|
renderer.keyInput.on("keypress", handleKeypress)
|
||||||
|
|
||||||
// Live session monitoring
|
// Live session monitoring
|
||||||
@@ -794,8 +879,19 @@ async function main() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let usageTick = 0
|
||||||
monitorInterval = setInterval(async () => {
|
monitorInterval = setInterval(async () => {
|
||||||
if (destroyed) return
|
if (destroyed) return
|
||||||
|
|
||||||
|
// Refresh usage every ~30s (6 ticks of 5s)
|
||||||
|
usageTick++
|
||||||
|
if (usageTick % 6 === 0) {
|
||||||
|
try {
|
||||||
|
cachedUsage = await getUsageSummary()
|
||||||
|
updateUsagePanel()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
if (demoMode) {
|
if (demoMode) {
|
||||||
for (const p of projects) { p.activeSessions = 0; p.busySessions = 0 }
|
for (const p of projects) { p.activeSessions = 0; p.busySessions = 0 }
|
||||||
generateMockActiveSessions(projects)
|
generateMockActiveSessions(projects)
|
||||||
|
|||||||
Reference in New Issue
Block a user