codeburn/src/dashboard.tsx

939 lines
44 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { homedir } from 'os'
import React, { useState, useCallback, useEffect, useRef } from 'react'
import { render, Box, Text, useInput, useApp, useWindowSize } from 'ink'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { formatCost, formatTokens } from './format.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
import { parseAllSessions, filterProjectsByName } from './parser.js'
import { loadPricing } from './models.js'
import { getAllProviders } from './providers/index.js'
import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js'
import { estimateContextBudget, discoverProjectCwd, type ContextBudget } from './context-budget.js'
import { dateKey } from './day-aggregator.js'
import { CompareView } from './compare.js'
import { getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
import { planDisplayName } from './plans.js'
import { getDateRange, PERIODS, PERIOD_LABELS, type Period, formatDateRangeLabel } from './cli-date.js'
import { join } from 'path'
import { patchStdoutForWindows } from './ink-win.js'
type View = 'dashboard' | 'optimize' | 'compare'
const MIN_WIDE = 90
const ORANGE = '#FF8C42'
const DIM = '#555555'
const GOLD = '#FFD700'
const PLAN_BAR_WIDTH = 10
const LANG_DISPLAY_NAMES: Record<string, string> = {
javascript: 'JavaScript', typescript: 'TypeScript', python: 'Python',
rust: 'Rust', go: 'Go', java: 'Java', cpp: 'C++', c: 'C', csharp: 'C#',
ruby: 'Ruby', php: 'PHP', swift: 'Swift', kotlin: 'Kotlin',
html: 'HTML', css: 'CSS', scss: 'SCSS', json: 'JSON', yaml: 'YAML',
sql: 'SQL', shell: 'Shell', shellscript: 'Shell Script', bash: 'Bash',
typescriptreact: 'TSX', javascriptreact: 'JSX',
markdown: 'Markdown', dockerfile: 'Dockerfile', toml: 'TOML',
}
const PANEL_COLORS = {
overview: '#FF8C42',
daily: '#5B9EF5',
project: '#5BF5A0',
sessions: '#FF6B6B',
model: '#E05BF5',
activity: '#F5C85B',
tools: '#5BF5E0',
mcp: '#F55BE0',
bash: '#F5A05B',
}
const PROVIDER_COLORS: Record<string, string> = {
claude: '#FF8C42',
codex: '#5BF5A0',
cursor: '#00B4D8',
opencode: '#A78BFA',
pi: '#F472B6',
all: '#FF8C42',
}
const CATEGORY_COLORS: Record<TaskCategory, string> = {
coding: '#5B9EF5',
debugging: '#F55B5B',
feature: '#5BF58C',
refactoring: '#F5E05B',
testing: '#E05BF5',
exploration: '#5BF5E0',
planning: '#7B9EF5',
delegation: '#F5C85B',
git: '#CCCCCC',
'build/deploy': '#5BF5A0',
conversation: '#888888',
brainstorming: '#F55BE0',
general: '#666666',
}
const IMPACT_PANEL_COLORS: Record<string, string> = { high: '#F55B5B', medium: ORANGE, low: DIM }
function toHex(r: number, g: number, b: number): string {
return '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')
}
function lerp(a: number, b: number, t: number): number {
return a + t * (b - a)
}
function gradientColor(pct: number): string {
if (pct <= 0.33) {
const t = pct / 0.33
return toHex(lerp(91, 245, t), lerp(158, 200, t), lerp(245, 91, t))
}
if (pct <= 0.66) {
const t = (pct - 0.33) / 0.33
return toHex(lerp(245, 255, t), lerp(200, 140, t), lerp(91, 66, t))
}
const t = (pct - 0.66) / 0.34
return toHex(lerp(255, 245, t), lerp(140, 91, t), lerp(66, 91, t))
}
function getPeriodRange(period: Period): { start: Date; end: Date } {
return getDateRange(period).range
}
type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
function getLayout(columns?: number): Layout {
const termWidth = columns || parseInt(process.env['COLUMNS'] ?? '') || 80
const dashWidth = Math.min(160, termWidth)
const wide = dashWidth >= MIN_WIDE
const halfWidth = wide ? Math.floor(dashWidth / 2) : dashWidth
const inner = halfWidth - 4
const barWidth = Math.max(6, Math.min(10, inner - 30))
return { dashWidth, wide, halfWidth, barWidth }
}
function HBar({ value, max, width }: { value: number; max: number; width: number }) {
if (max === 0) return <Text color={DIM}>{'░'.repeat(width)}</Text>
const filled = Math.round((value / max) * width)
const fillChars: React.ReactNode[] = []
for (let i = 0; i < Math.min(filled, width); i++) {
fillChars.push(<Text key={i} color={gradientColor(i / width)}>{'█'}</Text>)
}
return (
<Text>
{fillChars}
<Text color="#333333">{'░'.repeat(Math.max(width - filled, 0))}</Text>
</Text>
)
}
const PANEL_CHROME = 4
function Panel({ title, color, children, width }: { title: string; color: string; children: React.ReactNode; width: number }) {
return (
<Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} width={width} overflowX="hidden">
<Text bold color={color}>{title}</Text>
{children}
</Box>
)
}
function fit(s: string, n: number): string {
return s.length > n ? s.slice(0, n) : s.padEnd(n)
}
function renderPlanBar(percentUsed: number, width: number): string {
if (percentUsed <= 100) {
const capped = Math.max(0, percentUsed)
const filled = Math.round((capped / 100) * width)
return `${'▓'.repeat(filled)}${'░'.repeat(Math.max(0, width - filled))}`
}
const factor = percentUsed / 100
const chevrons = Math.min(4, Math.max(1, Math.floor(Math.log10(factor)) + 1))
return `${'▓'.repeat(width)}${'▶'.repeat(chevrons)}`
}
function Overview({ projects, label, width, planUsage }: { projects: ProjectSummary[]; label: string; width: number; planUsage?: PlanUsage }) {
const totalCost = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0)
const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
const allSessions = projects.flatMap(p => p.sessions)
const totalInput = allSessions.reduce((s, sess) => s + sess.totalInputTokens, 0)
const totalOutput = allSessions.reduce((s, sess) => s + sess.totalOutputTokens, 0)
const totalCacheRead = allSessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0)
const totalCacheWrite = allSessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0)
const allInputTokens = totalInput + totalCacheRead + totalCacheWrite
const cacheHit = allInputTokens > 0
? (totalCacheRead / allInputTokens) * 100 : 0
const planLabel = planUsage ? `${planDisplayName(planUsage.plan.id)}: ${formatCost(planUsage.spentApiEquivalentUsd)} API-equivalent vs ${formatCost(planUsage.budgetUsd)} plan` : ''
const planPct = planUsage ? `${planUsage.percentUsed.toFixed(1)}%` : ''
const planColor = planUsage
? planUsage.status === 'over'
? '#F55B5B'
: planUsage.status === 'near'
? ORANGE
: '#5BF58C'
: DIM
return (
<Box flexDirection="column" borderStyle="round" borderColor={PANEL_COLORS.overview} paddingX={1} width={width}>
<Text wrap="truncate-end">
<Text bold color={ORANGE}>CodeBurn</Text>
<Text dimColor> {label}</Text>
</Text>
<Text wrap="truncate-end">
<Text bold color={GOLD}>{formatCost(totalCost)}</Text>
<Text dimColor> cost </Text>
<Text bold>{totalCalls.toLocaleString()}</Text>
<Text dimColor> calls </Text>
<Text bold>{String(totalSessions)}</Text>
<Text dimColor> sessions </Text>
<Text bold>{cacheHit.toFixed(1)}%</Text>
<Text dimColor> cache hit</Text>
</Text>
<Text dimColor wrap="truncate-end">
{formatTokens(totalInput)} in {formatTokens(totalOutput)} out {formatTokens(totalCacheRead)} cached {formatTokens(totalCacheWrite)} written
</Text>
{planUsage && (
<>
<Text wrap="truncate-end">
<Text color={planColor}>{planLabel}</Text>
<Text> </Text>
<Text color={planColor}>{renderPlanBar(planUsage.percentUsed, PLAN_BAR_WIDTH)}</Text>
<Text> </Text>
<Text bold color={planColor}>{planPct}</Text>
</Text>
<Text dimColor wrap="truncate-end">
{planUsage.status === 'under'
? `Well within plan. Projected month: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`
: planUsage.status === 'near'
? `Approaching plan limit. Projected month: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`
: `${(planUsage.spentApiEquivalentUsd / Math.max(planUsage.budgetUsd, 1)).toFixed(1)}x your subscription value. Projected month: ${formatCost(planUsage.projectedMonthUsd)} (reset in ${planUsage.daysUntilReset} days).`}
</Text>
</>
)}
</Box>
)
}
function DailyActivity({ projects, days = 14, pw, bw }: { projects: ProjectSummary[]; days?: number; pw: number; bw: number }) {
const dailyCosts: Record<string, number> = {}
const dailyCalls: Record<string, number> = {}
for (const project of projects) {
for (const session of project.sessions) {
for (const turn of session.turns) {
if (!turn.timestamp) continue
const day = dateKey(turn.timestamp)
dailyCosts[day] = (dailyCosts[day] ?? 0) + turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0)
dailyCalls[day] = (dailyCalls[day] ?? 0) + turn.assistantCalls.length
}
}
}
const sortedDays = days !== undefined ? Object.keys(dailyCosts).sort().slice(-days) : Object.keys(dailyCosts).sort()
const maxCost = Math.max(...sortedDays.map(d => dailyCosts[d] ?? 0))
return (
<Panel title="Daily Activity" color={PANEL_COLORS.daily} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(6 + bw)}{'cost'.padStart(8)}{'calls'.padStart(6)}</Text>
{sortedDays.map(day => (
<Text key={day} wrap="truncate-end">
<Text dimColor>{day.slice(5)} </Text>
<HBar value={dailyCosts[day] ?? 0} max={maxCost} width={bw} />
<Text color={GOLD}>{formatCost(dailyCosts[day] ?? 0).padStart(8)}</Text>
<Text>{String(dailyCalls[day] ?? 0).padStart(6)}</Text>
</Text>
))}
</Panel>
)
}
const _home = homedir()
const _homePrefix = _home.endsWith('/') ? _home : _home + '/'
export function shortProject(absPath: string): string {
const normalized = absPath.replace(/\\/g, '/')
let path: string
if (normalized === _home) path = ''
else if (normalized.startsWith(_homePrefix)) path = normalized.slice(_homePrefix.length)
else path = normalized
path = path.replace(/^\/+/, '')
path = path.replace(/^private\/tmp\/[^/]+\/[^/]+\//, '').replace(/^private\/tmp\//, '').replace(/^tmp\//, '')
if (!path) return 'home'
const parts = path.split('/').filter(Boolean)
if (parts.length <= 3) return parts.join('/')
return parts.slice(-3).join('/')
}
const PROJECT_COL_AVG = 7
const PROJECT_COL_BASE_WIDTH = 30
const PROJECT_COL_WITH_OVERHEAD_WIDTH = 40
function ProjectBreakdown({ projects, pw, bw, budgets }: { projects: ProjectSummary[]; pw: number; bw: number; budgets?: Map<string, ContextBudget> }) {
const maxCost = Math.max(...projects.map(p => p.totalCostUSD))
const hasBudgets = budgets && budgets.size > 0
const nw = Math.max(8, pw - bw - (hasBudgets ? PROJECT_COL_WITH_OVERHEAD_WIDTH : PROJECT_COL_BASE_WIDTH))
return (
<Panel title="By Project" color={PANEL_COLORS.project} width={pw}>
<Text dimColor wrap="truncate-end">
{''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'avg/s'.padStart(PROJECT_COL_AVG)}{'sess'.padStart(6)}{hasBudgets ? 'overhead'.padStart(10) : ''}
</Text>
{projects.slice(0, 8).map((project, i) => {
const budget = budgets?.get(project.project)
const avgCost = project.sessions.length > 0
? formatCost(project.totalCostUSD / project.sessions.length)
: '-'
return (
<Text key={`${project.project}-${i}`} wrap="truncate-end">
<HBar value={project.totalCostUSD} max={maxCost} width={bw} />
<Text dimColor> {fit(shortProject(project.projectPath), nw)}</Text>
<Text color={GOLD}>{formatCost(project.totalCostUSD).padStart(8)}</Text>
<Text color={GOLD}>{avgCost.padStart(PROJECT_COL_AVG)}</Text>
<Text>{String(project.sessions.length).padStart(6)}</Text>
{hasBudgets && <Text color="#7B9EF5">{(budget ? formatTokens(budget.total) : '-').padStart(10)}</Text>}
</Text>
)
})}
</Panel>
)
}
const MODEL_COL_COST = 8
const MODEL_COL_CACHE = 7
const MODEL_COL_CALLS = 7
const MODEL_COL_ONESHOT = 7
const MODEL_NAME_WIDTH = 14
const MIN_EDIT_TURNS_FOR_RATE = 5
function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
const modelTotals: Record<string, { calls: number; costUSD: number; freshInput: number; cacheRead: number; cacheWrite: number }> = {}
const modelEfficiency = aggregateModelEfficiency(projects)
for (const project of projects) {
for (const session of project.sessions) {
for (const [model, data] of Object.entries(session.modelBreakdown)) {
if (!modelTotals[model]) modelTotals[model] = { calls: 0, costUSD: 0, freshInput: 0, cacheRead: 0, cacheWrite: 0 }
modelTotals[model].calls += data.calls
modelTotals[model].costUSD += data.costUSD
modelTotals[model].freshInput += data.tokens.inputTokens
modelTotals[model].cacheRead += data.tokens.cacheReadInputTokens
modelTotals[model].cacheWrite += data.tokens.cacheCreationInputTokens
}
}
}
const sorted = Object.entries(modelTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD)
const maxCost = sorted[0]?.[1]?.costUSD ?? 0
return (
<Panel title="By Model" color={PANEL_COLORS.model} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}</Text>
{sorted.map(([model, data], i) => {
const totalInput = data.freshInput + data.cacheRead + data.cacheWrite
const cacheHit = totalInput > 0 ? (data.cacheRead / totalInput) * 100 : 0
const cacheLabel = totalInput > 0 ? `${cacheHit.toFixed(1)}%` : '-'
const efficiency = modelEfficiency.get(model)
const oneShotLabel = efficiency && efficiency.editTurns >= MIN_EDIT_TURNS_FOR_RATE && efficiency.oneShotRate !== null
? `${efficiency.oneShotRate.toFixed(1)}%`
: '-'
return (
<Text key={`${model}-${i}`} wrap="truncate-end">
<HBar value={data.costUSD} max={maxCost} width={bw} />
<Text> {fit(model, MODEL_NAME_WIDTH)}</Text>
<Text color={GOLD}>{formatCost(data.costUSD).padStart(MODEL_COL_COST)}</Text>
<Text>{cacheLabel.padStart(MODEL_COL_CACHE)}</Text>
<Text>{String(data.calls).padStart(MODEL_COL_CALLS)}</Text>
<Text>{oneShotLabel.padStart(MODEL_COL_ONESHOT)}</Text>
</Text>
)
})}
</Panel>
)
}
const SKILL_SUB_ROWS_LIMIT = 5
function ActivityBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
const categoryTotals: Record<string, { turns: number; costUSD: number; editTurns: number; oneShotTurns: number }> = {}
const skillTotals: Record<string, { turns: number; costUSD: number; editTurns: number; oneShotTurns: number }> = {}
for (const project of projects) {
for (const session of project.sessions) {
for (const [cat, data] of Object.entries(session.categoryBreakdown)) {
if (!categoryTotals[cat]) categoryTotals[cat] = { turns: 0, costUSD: 0, editTurns: 0, oneShotTurns: 0 }
categoryTotals[cat].turns += data.turns
categoryTotals[cat].costUSD += data.costUSD
categoryTotals[cat].editTurns += data.editTurns
categoryTotals[cat].oneShotTurns += data.oneShotTurns
}
for (const [skill, data] of Object.entries(session.skillBreakdown ?? {})) {
if (!skillTotals[skill]) skillTotals[skill] = { turns: 0, costUSD: 0, editTurns: 0, oneShotTurns: 0 }
skillTotals[skill].turns += data.turns
skillTotals[skill].costUSD += data.costUSD
skillTotals[skill].editTurns += data.editTurns
skillTotals[skill].oneShotTurns += data.oneShotTurns
}
}
}
const sorted = Object.entries(categoryTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD)
const sortedSkills = Object.entries(skillTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD).slice(0, SKILL_SUB_ROWS_LIMIT)
const maxCost = sorted[0]?.[1]?.costUSD ?? 0
return (
<Panel title="By Activity" color={PANEL_COLORS.activity} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 14)}{'cost'.padStart(8)}{'turns'.padStart(6)}{'1-shot'.padStart(7)}</Text>
{sorted.flatMap(([cat, data]) => {
const oneShotPct = data.editTurns > 0 ? Math.round((data.oneShotTurns / data.editTurns) * 100) + '%' : '-'
const rows = [
<Text key={cat} wrap="truncate-end">
<HBar value={data.costUSD} max={maxCost} width={bw} />
<Text color={CATEGORY_COLORS[cat as TaskCategory] ?? '#666666'}> {fit(CATEGORY_LABELS[cat as TaskCategory] ?? cat, 13)}</Text>
<Text color={GOLD}>{formatCost(data.costUSD).padStart(8)}</Text>
<Text>{String(data.turns).padStart(6)}</Text>
<Text color={data.editTurns === 0 ? DIM : oneShotPct === '100%' ? '#5BF58C' : ORANGE}>{String(oneShotPct).padStart(7)}</Text>
</Text>,
]
if (cat === 'general' && sortedSkills.length > 0) {
for (const [skill, sd] of sortedSkills) {
const subPct = sd.editTurns > 0 ? Math.round((sd.oneShotTurns / sd.editTurns) * 100) + '%' : '-'
rows.push(
<Text key={`${cat}:${skill}`} wrap="truncate-end" dimColor>
<HBar value={sd.costUSD} max={maxCost} width={bw} />
<Text> {fit(` /${skill}`, 13)}</Text>
<Text>{formatCost(sd.costUSD).padStart(8)}</Text>
<Text>{String(sd.turns).padStart(6)}</Text>
<Text>{String(subPct).padStart(7)}</Text>
</Text>,
)
}
}
return rows
})}
</Panel>
)
}
function ToolBreakdown({ projects, pw, bw, title, filterPrefix }: { projects: ProjectSummary[]; pw: number; bw: number; title?: string; filterPrefix?: string }) {
const toolTotals: Record<string, number> = {}
for (const project of projects) {
for (const session of project.sessions) {
for (const [tool, data] of Object.entries(session.toolBreakdown)) {
if (filterPrefix) { if (!tool.startsWith(filterPrefix)) continue } else { if (tool.startsWith('lang:')) continue }
toolTotals[tool] = (toolTotals[tool] ?? 0) + data.calls
}
}
}
const sorted = Object.entries(toolTotals).sort(([, a], [, b]) => b - a)
const maxCalls = sorted[0]?.[1] ?? 0
const nw = Math.max(6, pw - bw - 15)
return (
<Panel title={title ?? 'Core Tools'} color={PANEL_COLORS.tools} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'calls'.padStart(7)}</Text>
{sorted.slice(0, 10).map(([tool, calls]) => {
const raw = filterPrefix ? tool.slice(filterPrefix.length) : tool
const display = filterPrefix ? (LANG_DISPLAY_NAMES[raw] ?? raw) : raw
return (
<Text key={tool} wrap="truncate-end">
<HBar value={calls} max={maxCalls} width={bw} />
<Text> {fit(display, nw)}</Text>
<Text>{String(calls).padStart(7)}</Text>
</Text>
)
})}
</Panel>
)
}
const TOP_SESSIONS_DATE_LEN = 10
const TOP_SESSIONS_COST_COL = 8
const TOP_SESSIONS_CALLS_COL = 6
function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
const allSessions = projects.flatMap(p =>
p.sessions.map(s => ({ ...s, projectPath: p.projectPath }))
)
const top = [...allSessions].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, 5)
if (top.length === 0) {
return <Panel title="Top Sessions" color={PANEL_COLORS.sessions} width={pw}><Text dimColor>No sessions</Text></Panel>
}
const maxCost = top[0].totalCostUSD
const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_CALLS_COL - 1 - PANEL_CHROME)
return (
<Panel title="Top Sessions" color={PANEL_COLORS.sessions} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'cost'.padStart(TOP_SESSIONS_COST_COL)}{'calls'.padStart(TOP_SESSIONS_CALLS_COL)}</Text>
{top.map((session, i) => {
const date = session.firstTimestamp
? session.firstTimestamp.slice(0, TOP_SESSIONS_DATE_LEN)
: '----------'
const label = `${date} ${shortProject(session.projectPath)}`
return (
<Text key={`${session.sessionId}-${i}`} wrap="truncate-end">
<HBar value={session.totalCostUSD} max={maxCost} width={bw} />
<Text dimColor> {fit(label, nw - 1)}</Text>
<Text color={GOLD}>{formatCost(session.totalCostUSD).padStart(TOP_SESSIONS_COST_COL)}</Text>
<Text>{String(session.apiCalls).padStart(TOP_SESSIONS_CALLS_COL)}</Text>
</Text>
)
})}
</Panel>
)
}
function McpBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
const mcpTotals: Record<string, number> = {}
for (const project of projects) { for (const session of project.sessions) { for (const [server, data] of Object.entries(session.mcpBreakdown)) { mcpTotals[server] = (mcpTotals[server] ?? 0) + data.calls } } }
const sorted = Object.entries(mcpTotals).sort(([, a], [, b]) => b - a)
if (sorted.length === 0) return <Panel title="MCP Servers" color={PANEL_COLORS.mcp} width={pw}><Text dimColor>No MCP usage</Text></Panel>
const maxCalls = sorted[0]?.[1] ?? 0
const nw = Math.max(6, pw - bw - 15)
return (
<Panel title="MCP Servers" color={PANEL_COLORS.mcp} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'calls'.padStart(6)}</Text>
{sorted.slice(0, 8).map(([server, calls]) => (
<Text key={server} wrap="truncate-end"><HBar value={calls} max={maxCalls} width={bw} /><Text> {fit(server, nw)}</Text><Text>{String(calls).padStart(6)}</Text></Text>
))}
</Panel>
)
}
function BashBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
const bashTotals: Record<string, number> = {}
for (const project of projects) { for (const session of project.sessions) { for (const [cmd, data] of Object.entries(session.bashBreakdown)) { bashTotals[cmd] = (bashTotals[cmd] ?? 0) + data.calls } } }
const sorted = Object.entries(bashTotals).sort(([, a], [, b]) => b - a)
if (sorted.length === 0) return <Panel title="Shell Commands" color={PANEL_COLORS.bash} width={pw}><Text dimColor>No shell commands</Text></Panel>
const maxCalls = sorted[0]?.[1] ?? 0
const nw = Math.max(6, pw - bw - 15)
return (
<Panel title="Shell Commands" color={PANEL_COLORS.bash} width={pw}>
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'calls'.padStart(7)}</Text>
{sorted.slice(0, 10).map(([cmd, calls]) => (
<Text key={cmd} wrap="truncate-end"><HBar value={calls} max={maxCalls} width={bw} /><Text> {fit(cmd, nw)}</Text><Text>{String(calls).padStart(7)}</Text></Text>
))}
</Panel>
)
}
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
all: 'All',
claude: 'Claude',
codex: 'Codex',
cursor: 'Cursor',
opencode: 'OpenCode',
pi: 'Pi',
}
function getProviderDisplayName(name: string): string { return PROVIDER_DISPLAY_NAMES[name] ?? name }
function PeriodTabs({ active, providerName, showProvider }: { active: Period; providerName?: string; showProvider?: boolean }) {
return (
<Box justifyContent="space-between" paddingX={1}>
<Box gap={1}>
{PERIODS.map(p => (
<Text key={p} bold={active === p} color={active === p ? ORANGE : DIM}>
{active === p ? `[ ${PERIOD_LABELS[p]} ]` : ` ${PERIOD_LABELS[p]} `}
</Text>
))}
</Box>
{showProvider && providerName && (
<Box><Text color={DIM}>| </Text><Text color={ORANGE} bold>[p]</Text><Text bold color={PROVIDER_COLORS[providerName] ?? ORANGE}> {getProviderDisplayName(providerName)}</Text></Box>
)}
</Box>
)
}
/// Header for an action's intended destination. Helps users distinguish a
/// permanent CLAUDE.md rule from a one-time session opener so they don't
/// accidentally bake a single-run constraint into their project's permanent
/// instructions. Issue #277.
function actionDestinationHeader(action: WasteAction): string {
switch (action.type) {
case 'file-content':
return `── Suggested ${action.path} addition `.padEnd(64, '─')
case 'command':
return '── Run this command '.padEnd(64, '─')
case 'paste': {
switch (action.destination) {
case 'claude-md':
return '── Suggested CLAUDE.md addition (permanent rule) '.padEnd(64, '─')
case 'session-opener':
return '── One-time session opener (do not add to CLAUDE.md) '.padEnd(64, '─')
case 'prompt':
return '── Ask Claude in the current session '.padEnd(64, '─')
case 'shell-config':
return '── Add to your shell config '.padEnd(64, '─')
default:
return '── Suggested action '.padEnd(64, '─')
}
}
}
}
function FindingAction({ action }: { action: WasteAction }) {
const lines = action.type === 'file-content' ? action.content.split('\n') : action.type === 'command' ? action.text.split('\n') : [action.text]
const header = actionDestinationHeader(action)
return (
<>
<Text color={ORANGE}>{header}</Text>
<Text dimColor>{action.label}</Text>
{lines.map((line, i) => <Text key={i} color="#5BF5E0"> {line}</Text>)}
</>
)
}
function FindingPanel({ index, finding, costRate, width }: { index: number; finding: WasteFinding; costRate: number; width: number }) {
const costSaved = finding.tokensSaved * costRate
const color = IMPACT_PANEL_COLORS[finding.impact] ?? DIM
const label = finding.impact.charAt(0).toUpperCase() + finding.impact.slice(1)
const trendBadge = finding.trend === 'improving' ? ' improving \u2193' : ''
return (
<Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} width={width}>
<Text wrap="truncate-end">
<Text bold>{index}. {finding.title}</Text>
<Text> </Text>
<Text color={color}>{label}</Text>
{trendBadge && <Text color="#5BF5A0">{trendBadge}</Text>}
</Text>
<Text dimColor wrap="wrap">{finding.explanation}</Text>
<Text color={GOLD}>Savings: ~{formatTokens(finding.tokensSaved)} tokens (~{formatCost(costSaved)})</Text>
<Text> </Text>
<FindingAction action={finding.fix} />
</Box>
)
}
const GRADE_COLORS: Record<string, string> = { A: '#5BF5A0', B: '#5BF5A0', C: GOLD, D: ORANGE, F: '#F55B5B' }
// Each finding panel takes ~6-8 lines. Show 3 at a time so the window fits a
// 30-line terminal alongside the optimize header + status bar; users page
// with j/k. Without this cap, 4 new detectors + 7 originals scrolled findings
// off the alt-buffer top and the user couldn't see the StatusBar at all.
const FINDINGS_WINDOW_SIZE = 3
function OptimizeView({ findings, costRate, projects, label, width, healthScore, healthGrade, cursor }: { findings: WasteFinding[]; costRate: number; projects: ProjectSummary[]; label: string; width: number; healthScore: number; healthGrade: string; cursor: number }) {
const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const totalTokens = findings.reduce((s, f) => s + f.tokensSaved, 0)
const totalCost = totalTokens * costRate
const pctRaw = periodCost > 0 ? (totalCost / periodCost) * 100 : 0
const pct = pctRaw >= 1 ? pctRaw.toFixed(0) : pctRaw.toFixed(1)
const gradeColor = GRADE_COLORS[healthGrade] ?? DIM
const total = findings.length
const start = total === 0 ? 0 : Math.min(cursor, Math.max(0, total - FINDINGS_WINDOW_SIZE))
const end = Math.min(start + FINDINGS_WINDOW_SIZE, total)
const visible = findings.slice(start, end)
return (
<Box flexDirection="column" width={width}>
<Box flexDirection="column" borderStyle="round" borderColor={ORANGE} paddingX={1} width={width}>
<Text wrap="truncate-end">
<Text bold color={ORANGE}>CodeBurn Optimize</Text>
<Text dimColor> {label} Setup: </Text>
<Text bold color={gradeColor}>{healthGrade}</Text>
<Text dimColor> ({healthScore}/100)</Text>
</Text>
<Text color="#5BF5A0" wrap="truncate-end">Savings: ~{formatTokens(totalTokens)} tokens (~{formatCost(totalCost)}, ~{pct}% of spend)</Text>
{total > FINDINGS_WINDOW_SIZE && (
<Text dimColor>Showing {start + 1}{end} of {total} · j/k to scroll</Text>
)}
</Box>
{visible.map((f, i) => <FindingPanel key={start + i} index={start + i + 1} finding={f} costRate={costRate} width={width} />)}
<Box paddingX={1} width={width}><Text dimColor>Token estimates are approximate.</Text></Box>
</Box>
)
}
function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable, compareAvailable, customRange }: { width: number; showProvider?: boolean; view?: View; findingCount?: number; optimizeAvailable?: boolean; compareAvailable?: boolean; customRange?: boolean }) {
const isOptimize = view === 'optimize'
return (
<Box borderStyle="round" borderColor={DIM} width={width} justifyContent="center" paddingX={1}>
<Text>
{isOptimize
? <><Text color={ORANGE} bold>b</Text><Text dimColor> back </Text><Text color={ORANGE} bold>j</Text><Text dimColor>/</Text><Text color={ORANGE} bold>k</Text><Text dimColor> scroll </Text></>
: !customRange
? <><Text color={ORANGE} bold>{'<'}</Text><Text color={ORANGE}>{'>'}</Text><Text dimColor> switch </Text></>
: null}
<Text color={ORANGE} bold>q</Text><Text dimColor> quit</Text>
{!customRange && !isOptimize && (
<>
<Text dimColor> </Text><Text color={ORANGE} bold>1</Text><Text dimColor> today </Text>
<Text color={ORANGE} bold>2</Text><Text dimColor> week </Text>
<Text color={ORANGE} bold>3</Text><Text dimColor> 30 days </Text>
<Text color={ORANGE} bold>4</Text><Text dimColor> month </Text>
<Text color={ORANGE} bold>5</Text><Text dimColor> 6 months</Text>
</>
)}
{!isOptimize && optimizeAvailable && findingCount != null && findingCount > 0 && (
<><Text dimColor> </Text><Text color={ORANGE} bold>o</Text><Text dimColor> optimize</Text><Text color="#F55B5B"> ({findingCount})</Text></>
)}
{!isOptimize && compareAvailable && (
<><Text dimColor> </Text><Text color={ORANGE} bold>c</Text><Text dimColor> compare</Text></>
)}
{showProvider && (<><Text dimColor> </Text><Text color={ORANGE} bold>p</Text><Text dimColor> provider</Text></>)}
</Text>
</Box>
)
}
function Row({ wide, width, children }: { wide: boolean; width: number; children: React.ReactNode }) {
if (wide) return <Box width={width}>{children}</Box>
return <>{children}</>
}
function DashboardContent({ projects, period, columns, activeProvider, budgets, planUsage }: { projects: ProjectSummary[]; period: Period; columns?: number; activeProvider?: string; budgets?: Map<string, ContextBudget>; planUsage?: PlanUsage }) {
const { dashWidth, wide, halfWidth, barWidth } = getLayout(columns)
const isCursor = activeProvider === 'cursor'
if (projects.length === 0) return <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>No usage data found for {PERIOD_LABELS[period]}.</Text></Panel>
const pw = wide ? halfWidth : dashWidth
const days = period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14)
return (
<Box flexDirection="column" width={dashWidth}>
<Overview projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} planUsage={planUsage} />
<Row wide={wide} width={dashWidth}><DailyActivity projects={projects} days={days} pw={pw} bw={barWidth} /><ProjectBreakdown projects={projects} pw={pw} bw={barWidth} budgets={budgets} /></Row>
<TopSessions projects={projects} pw={dashWidth} bw={barWidth} />
<Row wide={wide} width={dashWidth}><ActivityBreakdown projects={projects} pw={pw} bw={barWidth} /><ModelBreakdown projects={projects} pw={pw} bw={barWidth} /></Row>
{isCursor ? (
<ToolBreakdown projects={projects} pw={dashWidth} bw={barWidth} title="Languages" filterPrefix="lang:" />
) : (
<><Row wide={wide} width={dashWidth}><ToolBreakdown projects={projects} pw={pw} bw={barWidth} /><BashBreakdown projects={projects} pw={pw} bw={barWidth} /></Row><McpBreakdown projects={projects} pw={dashWidth} bw={barWidth} /></>
)}
</Box>
)
}
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, initialPlanUsage, refreshSeconds, projectFilter, excludeFilter, customRange, customRangeLabel }: {
initialProjects: ProjectSummary[]
initialPeriod: Period
initialProvider: string
initialPlanUsage?: PlanUsage
refreshSeconds?: number
projectFilter?: string[]
excludeFilter?: string[]
customRange?: DateRange | null
customRangeLabel?: string
}) {
const { exit } = useApp()
const [period, setPeriod] = useState<Period>(initialPeriod)
const [projects, setProjects] = useState<ProjectSummary[]>(initialProjects)
const [loading, setLoading] = useState(false)
const [activeProvider, setActiveProvider] = useState(initialProvider)
const [detectedProviders, setDetectedProviders] = useState<string[]>([])
const [view, setView] = useState<View>('dashboard')
const [optimizeResult, setOptimizeResult] = useState<OptimizeResult | null>(null)
const [projectBudgets, setProjectBudgets] = useState<Map<string, ContextBudget>>(new Map())
const [planUsage, setPlanUsage] = useState<PlanUsage | undefined>(initialPlanUsage)
// Cursor for the OptimizeView's findings window. Reset whenever the user
// leaves the optimize view OR the underlying findings change so a long
// findings list never strands the user past the new array length.
const [findingsCursor, setFindingsCursor] = useState(0)
const isCustomRange = customRange != null
const { columns } = useWindowSize()
const { dashWidth } = getLayout(columns)
const multipleProviders = detectedProviders.length > 1
const optimizeAvailable = activeProvider === 'all' || activeProvider === 'claude'
const modelCount = new Set(
projects.flatMap(p => p.sessions.flatMap(s => Object.keys(s.modelBreakdown)))
).size
const compareAvailable = modelCount >= 2
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reloadGenerationRef = useRef(0)
const findingCount = optimizeResult?.findings.length ?? 0
useEffect(() => {
let cancelled = false
async function detect() {
const found: string[] = []
for (const p of await getAllProviders()) { const s = await p.discoverSessions(); if (s.length > 0) found.push(p.name) }
if (!cancelled) setDetectedProviders(found)
}
detect()
return () => { cancelled = true }
}, [])
useEffect(() => {
let cancelled = false
async function loadBudgets() {
const claudeDir = join(homedir(), '.claude', 'projects')
const budgets = new Map<string, ContextBudget>()
for (const project of projects.slice(0, 8)) {
if (cancelled) return
const cwd = await discoverProjectCwd(join(claudeDir, project.project))
if (!cwd) continue
budgets.set(project.project, await estimateContextBudget(cwd))
}
if (!cancelled) setProjectBudgets(budgets)
}
loadBudgets()
return () => { cancelled = true }
}, [projects])
useEffect(() => {
if (!optimizeAvailable) { setOptimizeResult(null); return }
let cancelled = false
async function scan() {
if (projects.length === 0) { setOptimizeResult(null); return }
const result = await scanAndDetect(projects, getPeriodRange(period))
if (!cancelled) setOptimizeResult(result)
}
scan()
return () => { cancelled = true }
}, [projects, period, optimizeAvailable])
const reloadData = useCallback(async (p: Period, prov: string) => {
const generation = ++reloadGenerationRef.current
setLoading(true)
setOptimizeResult(null)
try {
const range = getPeriodRange(p)
const data = await parseAllSessions(range, prov)
if (reloadGenerationRef.current !== generation) return
const filteredProjects = filterProjectsByName(data, projectFilter, excludeFilter)
if (reloadGenerationRef.current !== generation) return
setProjects(filteredProjects)
const usage = await getPlanUsageOrNull()
if (reloadGenerationRef.current !== generation) return
setPlanUsage(usage ?? undefined)
} catch (error) {
console.error(error)
} finally {
if (reloadGenerationRef.current === generation) {
setLoading(false)
}
}
}, [projectFilter, excludeFilter])
useEffect(() => {
if (!refreshSeconds || refreshSeconds <= 0) return
const id = setInterval(() => { reloadData(period, activeProvider) }, refreshSeconds * 1000)
return () => clearInterval(id)
}, [refreshSeconds, period, activeProvider, reloadData])
const switchPeriod = useCallback((np: Period) => {
if (np === period) return
// Clear projects + flip loading synchronously so the dashboard never
// renders the new period label over the old period's numbers between
// setPeriod() and the reloadData() promise resolving. Without this,
// there's a frame-to-hundreds-of-ms window where users saw wrong
// figures captioned with the new period.
setPeriod(np)
setProjects([])
setLoading(true)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => { reloadData(np, activeProvider) }, 600)
}, [period, activeProvider, reloadData])
const switchPeriodImmediate = useCallback(async (np: Period) => {
if (np === period) return
setPeriod(np)
setProjects([])
setLoading(true)
if (debounceRef.current) clearTimeout(debounceRef.current)
await reloadData(np, activeProvider)
}, [period, activeProvider, reloadData])
useInput((input, key) => {
if (input === 'q') { exit(); return }
if (input === 'o' && findingCount > 0 && view === 'dashboard' && optimizeAvailable) { setView('optimize'); return }
if ((input === 'b' || key.escape) && view === 'optimize') { setView('dashboard'); setFindingsCursor(0); return }
if (view === 'optimize') {
const total = optimizeResult?.findings.length ?? 0
const maxStart = Math.max(0, total - FINDINGS_WINDOW_SIZE)
if (input === 'j' || key.downArrow) { setFindingsCursor(c => Math.min(c + 1, maxStart)); return }
if (input === 'k' || key.upArrow) { setFindingsCursor(c => Math.max(c - 1, 0)); return }
}
if (input === 'c' && compareAvailable && view === 'dashboard') { setView('compare'); return }
if ((input === 'b' || key.escape) && view === 'compare') { setView('dashboard'); return }
if (input === 'p' && multipleProviders && view !== 'compare') {
const opts = ['all', ...detectedProviders]; const next = opts[(opts.indexOf(activeProvider) + 1) % opts.length]
setActiveProvider(next); setView('dashboard')
if (debounceRef.current) clearTimeout(debounceRef.current)
reloadData(period, next); return
}
// Period switches reload the underlying data. Disable them while the
// compare view is mounted; the compare view re-aggregates from
// `projects` and would visibly change underneath the user without any
// affordance back to the dashboard. Press `b` or Esc to return first.
if (view === 'compare') return
// Also disable while a custom --from/--to range is in effect. Switching
// period would silently abandon the user's explicit range and reload
// standard period data; the period tab strip is hidden in this mode so
// users have no expectation that 1-5 should do anything.
if (isCustomRange) return
const idx = PERIODS.indexOf(period)
if (key.leftArrow) switchPeriod(PERIODS[(idx - 1 + PERIODS.length) % PERIODS.length]!)
else if (key.rightArrow || key.tab) switchPeriod(PERIODS[(idx + 1) % PERIODS.length]!)
else if (input === '1') switchPeriodImmediate('today')
else if (input === '2') switchPeriodImmediate('week')
else if (input === '3') switchPeriodImmediate('30days')
else if (input === '4') switchPeriodImmediate('month')
else if (input === '5') switchPeriodImmediate('all')
})
const headerLabel = customRangeLabel ?? PERIOD_LABELS[period]
if (loading) {
return (
<Box flexDirection="column" width={dashWidth}>
{!isCustomRange && <PeriodTabs active={period} providerName={activeProvider} showProvider={view !== 'compare' && multipleProviders} />}
{isCustomRange && <CustomRangeBanner label={headerLabel} width={dashWidth} />}
{view === 'compare'
? <Box flexDirection="column" paddingX={2} paddingY={1}>
<Box flexDirection="column" borderStyle="round" borderColor={ORANGE} paddingX={1}>
<Text bold color={ORANGE}>Model Comparison</Text>
<Text> </Text>
<Text dimColor>Loading {headerLabel} model data...</Text>
</Box>
</Box>
: <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>Loading {headerLabel}...</Text></Panel>}
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={0} optimizeAvailable={false} compareAvailable={false} customRange={isCustomRange} />}
</Box>
)
}
return (
<Box flexDirection="column" width={dashWidth}>
{!isCustomRange && <PeriodTabs active={period} providerName={activeProvider} showProvider={multipleProviders && view !== 'compare'} />}
{isCustomRange && <CustomRangeBanner label={headerLabel} width={dashWidth} />}
{view === 'compare'
? <CompareView projects={projects} onBack={() => setView('dashboard')} />
: view === 'optimize' && optimizeResult
? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={headerLabel} width={dashWidth} healthScore={optimizeResult.healthScore} healthGrade={optimizeResult.healthGrade} cursor={findingsCursor} />
: <DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} budgets={projectBudgets} planUsage={planUsage} />}
{view !== 'compare' && <StatusBar width={dashWidth} showProvider={multipleProviders} view={view} findingCount={findingCount} optimizeAvailable={optimizeAvailable} compareAvailable={compareAvailable} customRange={isCustomRange} />}
</Box>
)
}
function CustomRangeBanner({ label, width }: { label: string; width: number }) {
return (
<Box width={width} paddingX={1} marginBottom={1}>
<Text dimColor>Custom range: </Text>
<Text color={ORANGE} bold>{label}</Text>
</Box>
)
}
function StaticDashboard({ projects, period, activeProvider, planUsage }: { projects: ProjectSummary[]; period: Period; activeProvider?: string; planUsage?: PlanUsage }) {
const { columns } = useWindowSize()
const { dashWidth } = getLayout(columns)
return (
<Box flexDirection="column" width={dashWidth}>
<PeriodTabs active={period} />
<DashboardContent projects={projects} period={period} columns={columns} activeProvider={activeProvider} planUsage={planUsage} />
</Box>
)
}
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null, customRangeLabel?: string): Promise<void> {
await loadPricing()
const range = customRange ?? getPeriodRange(period)
const filteredProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
const planUsage = await getPlanUsageOrNull()
const isTTY = process.stdin.isTTY && process.stdout.isTTY
patchStdoutForWindows()
if (isTTY) {
const { waitUntilExit } = render(
<InteractiveDashboard initialProjects={filteredProjects} initialPeriod={period} initialProvider={provider} initialPlanUsage={planUsage ?? undefined} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} customRange={customRange} customRangeLabel={customRangeLabel} />
)
await waitUntilExit()
} else {
const { unmount } = render(<StaticDashboard projects={filteredProjects} period={period} activeProvider={provider} planUsage={planUsage ?? undefined} />, { patchConsole: false })
unmount()
}
}