feat: add cache rebuild flag and progress

This commit is contained in:
Sharada Mohanty 2026-04-20 17:55:02 +02:00
parent 1b8e0f8289
commit 2a9daec0ea
5 changed files with 160 additions and 36 deletions

View file

@ -12,6 +12,7 @@ import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './d
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { renderDashboard } from './dashboard.js'
import { parseDateRangeFlags } from './cli-date.js'
import { createTerminalProgressReporter } from './parse-progress.js'
import { runOptimize, scanAndDetect } from './optimize.js'
import { renderCompare } from './compare.js'
import { getAllProviders } from './providers/index.js'
@ -120,10 +121,14 @@ function toJsonPlanSummary(planUsage: PlanUsage): JsonPlanSummary {
}
}
async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise<void> {
async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[], noCache = false): Promise<void> {
await loadPricing()
const { range, label } = getDateRange(period)
const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude)
const projects = filterProjectsByName(
await parseAllSessions(range, provider, { noCache, progress: null }),
project,
exclude,
)
const report: ReturnType<typeof buildJsonReport> & { plan?: JsonPlanSummary } = buildJsonReport(projects, label, period)
const planUsage = await getPlanUsageOrNull()
if (planUsage) {
@ -132,6 +137,17 @@ async function runJsonReport(period: Period, provider: string, project: string[]
console.log(JSON.stringify(report, null, 2))
}
function noCacheRequested(opts: { cache?: boolean }): boolean {
return opts.cache === false
}
function buildParseOptions(noCache: boolean, enableProgress: boolean) {
return {
noCache,
progress: createTerminalProgressReporter(enableProgress),
}
}
const program = new Command()
.name('codeburn')
.description('See where your AI coding tokens go - by task, tool, model, and project')
@ -288,8 +304,10 @@ program
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--no-cache', 'Rebuild the parsed source cache for this run')
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInt, 30)
.action(async (opts) => {
const noCache = noCacheRequested(opts)
let customRange: DateRange | null = null
try {
customRange = parseDateRangeFlags(opts.from, opts.to)
@ -305,17 +323,17 @@ program
if (customRange) {
const label = `${opts.from ?? 'all'} to ${opts.to ?? 'today'}`
const projects = filterProjectsByName(
await parseAllSessions(customRange, opts.provider),
await parseAllSessions(customRange, opts.provider, { noCache, progress: null }),
opts.project,
opts.exclude,
)
console.log(JSON.stringify(buildJsonReport(projects, label, 'custom'), null, 2))
} else {
await runJsonReport(period, opts.provider, opts.project, opts.exclude)
await runJsonReport(period, opts.provider, opts.project, opts.exclude, noCache)
}
return
}
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange)
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, noCache)
})
function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
@ -367,8 +385,11 @@ program
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--period <period>', 'Primary period for menubar-json: today, week, 30days, month, all', 'today')
.option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
.option('--no-cache', 'Rebuild the parsed source cache for this run')
.action(async (opts) => {
await loadPricing()
const noCache = noCacheRequested(opts)
const parseOptions = buildParseOptions(noCache, opts.format === 'terminal')
const pf = opts.provider
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
if (opts.format === 'menubar-json') {
@ -403,7 +424,7 @@ program
if (gapStart.getTime() <= yesterdayEnd.getTime()) {
const gapRange: DateRange = { start: gapStart, end: yesterdayEnd }
const gapProjects = filterProjectsByName(await parseAllSessions(gapRange, 'all'), opts.project, opts.exclude)
const gapProjects = filterProjectsByName(await parseAllSessions(gapRange, 'all', { noCache, progress: null }), opts.project, opts.exclude)
const gapDays = aggregateProjectsIntoDays(gapProjects)
c = addNewDays(c, gapDays, yesterdayStr)
await saveDailyCache(c)
@ -420,7 +441,7 @@ program
if (isAllProviders) {
const todayRange: DateRange = { start: todayStart, end: now }
const todayProjects = fp(await parseAllSessions(todayRange, 'all'))
const todayProjects = fp(await parseAllSessions(todayRange, 'all', { noCache, progress: null }))
const todayDays = aggregateProjectsIntoDays(todayProjects)
const rangeStartStr = toDateString(periodInfo.range.start)
const rangeEndStr = toDateString(periodInfo.range.end)
@ -431,7 +452,7 @@ program
scanProjects = todayProjects
scanRange = todayRange
} else {
const projects = fp(await parseAllSessions(periodInfo.range, pf))
const projects = fp(await parseAllSessions(periodInfo.range, pf, { noCache, progress: null }))
currentData = buildPeriodData(periodInfo.label, projects)
scanProjects = projects
scanRange = periodInfo.range
@ -445,7 +466,7 @@ program
const providers: ProviderCost[] = []
if (isAllProviders) {
const todayRangeForProviders: DateRange = { start: todayStart, end: now }
const todayDaysForProviders = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForProviders, 'all')))
const todayDaysForProviders = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForProviders, 'all', { noCache, progress: null })))
const rangeStartStr = toDateString(periodInfo.range.start)
const allDaysForProviders = [
...getDaysInRange(cache, rangeStartStr, yesterdayStr),
@ -476,7 +497,7 @@ program
// in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost).
const historyStartStr = toDateString(new Date(todayStart.getTime() - BACKFILL_DAYS * MS_PER_DAY))
const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr)
const allTodayDaysForHistory = aggregateProjectsIntoDays(fp(await parseAllSessions({ start: todayStart, end: now }, 'all')))
const allTodayDaysForHistory = aggregateProjectsIntoDays(fp(await parseAllSessions({ start: todayStart, end: now }, 'all', { noCache, progress: null })))
const fullHistory = [...allCacheDays, ...allTodayDaysForHistory]
const dailyHistory = fullHistory.map(d => {
if (isAllProviders) {
@ -521,8 +542,8 @@ program
}
if (opts.format === 'json') {
const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf)))
const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf)))
const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf, { noCache, progress: null })))
const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf, { noCache, progress: null })))
const { code, rate } = getCurrency()
const payload: {
currency: string
@ -542,7 +563,7 @@ program
return
}
const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf))
const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf, parseOptions))
console.log(renderStatusBar(monthProjects))
})
@ -553,13 +574,15 @@ program
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--no-cache', 'Rebuild the parsed source cache for this run')
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInt, 30)
.action(async (opts) => {
const noCache = noCacheRequested(opts)
if (opts.format === 'json') {
await runJsonReport('today', opts.provider, opts.project, opts.exclude)
await runJsonReport('today', opts.provider, opts.project, opts.exclude, noCache)
return
}
await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude)
await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude, null, noCache)
})
program
@ -569,13 +592,15 @@ program
.option('--format <format>', 'Output format: tui, json', 'tui')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--no-cache', 'Rebuild the parsed source cache for this run')
.option('--refresh <seconds>', 'Auto-refresh interval in seconds (0 to disable)', parseInt, 30)
.action(async (opts) => {
const noCache = noCacheRequested(opts)
if (opts.format === 'json') {
await runJsonReport('month', opts.provider, opts.project, opts.exclude)
await runJsonReport('month', opts.provider, opts.project, opts.exclude, noCache)
return
}
await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude)
await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude, null, noCache)
})
program
@ -586,14 +611,16 @@ program
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--project <name>', 'Show only projects matching name (repeatable)', collect, [])
.option('--exclude <name>', 'Exclude projects matching name (repeatable)', collect, [])
.option('--no-cache', 'Rebuild the parsed source cache for this run')
.action(async (opts) => {
await loadPricing()
const parseOptions = buildParseOptions(noCacheRequested(opts), true)
const pf = opts.provider
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
const periods: PeriodExport[] = [
{ label: 'Today', projects: fp(await parseAllSessions(getDateRange('today').range, pf)) },
{ label: '7 Days', projects: fp(await parseAllSessions(getDateRange('week').range, pf)) },
{ label: '30 Days', projects: fp(await parseAllSessions(getDateRange('30days').range, pf)) },
{ label: 'Today', projects: fp(await parseAllSessions(getDateRange('today').range, pf, parseOptions)) },
{ label: '7 Days', projects: fp(await parseAllSessions(getDateRange('week').range, pf, parseOptions)) },
{ label: '30 Days', projects: fp(await parseAllSessions(getDateRange('30days').range, pf, parseOptions)) },
]
if (periods.every(p => p.projects.length === 0)) {
@ -813,10 +840,11 @@ program
.description('Find token waste and get exact fixes')
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', '30days')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--no-cache', 'Rebuild the parsed source cache for this run')
.action(async (opts) => {
await loadPricing()
const { range, label } = getDateRange(opts.period)
const projects = await parseAllSessions(range, opts.provider)
const projects = await parseAllSessions(range, opts.provider, buildParseOptions(noCacheRequested(opts), true))
await runOptimize(projects, label, range)
})
@ -825,10 +853,11 @@ program
.description('Compare two AI models side-by-side')
.option('-p, --period <period>', 'Analysis period: today, week, 30days, month, all', 'all')
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
.option('--no-cache', 'Rebuild the parsed source cache for this run')
.action(async (opts) => {
await loadPricing()
const { range } = getDateRange(opts.period)
await renderCompare(range, opts.provider)
await renderCompare(range, opts.provider, noCacheRequested(opts))
})
program.parse()

View file

@ -5,6 +5,7 @@ import type { ModelStats, ComparisonRow, CategoryComparison, WorkingStyleRow } f
import { aggregateModelStats, computeComparison, computeCategoryComparison, computeWorkingStyle, scanSelfCorrections } from './compare-stats.js'
import { formatCost } from './format.js'
import { parseAllSessions } from './parser.js'
import { createTerminalProgressReporter } from './parse-progress.js'
import { getAllProviders } from './providers/index.js'
import type { ProjectSummary, DateRange } from './types.js'
@ -441,14 +442,17 @@ export function CompareView({ projects, onBack }: CompareViewProps) {
)
}
export async function renderCompare(range: DateRange, provider: string): Promise<void> {
export async function renderCompare(range: DateRange, provider: string, noCache = false): Promise<void> {
const isTTY = process.stdin.isTTY && process.stdout.isTTY
if (!isTTY) {
process.stdout.write('Model comparison requires an interactive terminal.\n')
return
}
const projects = await parseAllSessions(range, provider)
const projects = await parseAllSessions(range, provider, {
noCache,
progress: createTerminalProgressReporter(true),
})
const { waitUntilExit } = render(
<CompareView projects={projects} onBack={() => process.exit(0)} />
)

View file

@ -10,6 +10,7 @@ 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 { createTerminalProgressReporter } from './parse-progress.js'
import { CompareView } from './compare.js'
import { getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
import { planDisplayName } from './plans.js'
@ -620,7 +621,7 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets,
)
}
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, initialPlanUsage, refreshSeconds, projectFilter, excludeFilter }: {
function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider, initialPlanUsage, refreshSeconds, projectFilter, excludeFilter, noCache }: {
initialProjects: ProjectSummary[]
initialPeriod: Period
initialProvider: string
@ -628,6 +629,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
refreshSeconds?: number
projectFilter?: string[]
excludeFilter?: string[]
noCache?: boolean
}) {
const { exit } = useApp()
const [period, setPeriod] = useState<Period>(initialPeriod)
@ -697,13 +699,14 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
setOptimizeResult(null)
try {
const range = getDateRange(p)
const data = await parseAllSessions(range, prov)
const data = filterProjectsByName(
await parseAllSessions(range, prov, { noCache: noCache ?? false, progress: null }),
projectFilter,
excludeFilter,
)
if (reloadGenerationRef.current !== generation) return
const filteredProjects = filterProjectsByName(data, projectFilter, excludeFilter)
if (reloadGenerationRef.current !== generation) return
setProjects(filteredProjects)
setProjects(data)
const usage = await getPlanUsageOrNull()
if (reloadGenerationRef.current !== generation) return
setPlanUsage(usage ?? undefined)
@ -714,7 +717,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
setLoading(false)
}
}
}, [projectFilter, excludeFilter])
}, [excludeFilter, noCache, projectFilter])
useEffect(() => {
if (!refreshSeconds || refreshSeconds <= 0) return
@ -799,15 +802,36 @@ function StaticDashboard({ projects, period, activeProvider, planUsage }: { proj
)
}
export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null): Promise<void> {
export async function renderDashboard(
period: Period = 'week',
provider: string = 'all',
refreshSeconds?: number,
projectFilter?: string[],
excludeFilter?: string[],
customRange?: DateRange | null,
noCache = false,
): Promise<void> {
await loadPricing()
const range = customRange ?? getDateRange(period)
const filteredProjects = filterProjectsByName(await parseAllSessions(range, provider), projectFilter, excludeFilter)
const planUsage = await getPlanUsageOrNull()
const isTTY = process.stdin.isTTY && process.stdout.isTTY
const range = customRange ?? getDateRange(period)
const filteredProjects = filterProjectsByName(
await parseAllSessions(range, provider, { noCache, progress: createTerminalProgressReporter(isTTY) }),
projectFilter,
excludeFilter,
)
const planUsage = await getPlanUsageOrNull()
if (isTTY) {
const { waitUntilExit } = render(
<InteractiveDashboard initialProjects={filteredProjects} initialPeriod={period} initialProvider={provider} initialPlanUsage={planUsage ?? undefined} refreshSeconds={refreshSeconds} projectFilter={projectFilter} excludeFilter={excludeFilter} />
<InteractiveDashboard
initialProjects={filteredProjects}
initialPeriod={period}
initialProvider={provider}
initialPlanUsage={planUsage ?? undefined}
refreshSeconds={refreshSeconds}
projectFilter={projectFilter}
excludeFilter={excludeFilter}
noCache={noCache}
/>
)
await waitUntilExit()
} else {

42
src/parse-progress.ts Normal file
View file

@ -0,0 +1,42 @@
import type { SourceProgressReporter } from './parser.js'
export function createTerminalProgressReporter(
enabled: boolean,
stream: NodeJS.WriteStream = process.stderr,
): SourceProgressReporter | null {
if (!enabled || !stream.isTTY) return null
let total = 0
let current = 0
let lastLineLength = 0
let active = false
function writeLine(line: string, done = false) {
const pad = lastLineLength > line.length ? ' '.repeat(lastLineLength - line.length) : ''
lastLineLength = Math.max(lastLineLength, line.length)
stream.write(`${line}${pad}${done ? '\n' : '\r'}`)
}
return {
start(label: string, nextTotal: number) {
total = nextTotal
current = 0
lastLineLength = 0
active = nextTotal > 0
if (active) writeLine(`${label} 0/${total}`)
},
advance(itemLabel: string) {
if (!active) return
current += 1
writeLine(`Updating cache ${current}/${total}${itemLabel ? ` ${itemLabel}` : ''}`)
},
finish() {
if (!active) return
writeLine(`Updating cache ${current}/${total}`, true)
active = false
total = 0
current = 0
lastLineLength = 0
},
}
}