diff --git a/src/cli.ts b/src/cli.ts index e23e24e..7bd91a9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 { +async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[], noCache = false): Promise { 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 & { 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 ', 'Output format: tui, json', 'tui') .option('--project ', 'Show only projects matching name (repeatable)', collect, []) .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) + .option('--no-cache', 'Rebuild the parsed source cache for this run') .option('--refresh ', '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 ', 'Exclude projects matching name (repeatable)', collect, []) .option('--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 ', 'Output format: tui, json', 'tui') .option('--project ', 'Show only projects matching name (repeatable)', collect, []) .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) + .option('--no-cache', 'Rebuild the parsed source cache for this run') .option('--refresh ', '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 ', 'Output format: tui, json', 'tui') .option('--project ', 'Show only projects matching name (repeatable)', collect, []) .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, []) + .option('--no-cache', 'Rebuild the parsed source cache for this run') .option('--refresh ', '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 ', 'Filter by provider: all, claude, codex, cursor', 'all') .option('--project ', 'Show only projects matching name (repeatable)', collect, []) .option('--exclude ', '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 ', 'Analysis period: today, week, 30days, month, all', '30days') .option('--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 ', 'Analysis period: today, week, 30days, month, all', 'all') .option('--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() diff --git a/src/compare.tsx b/src/compare.tsx index 0f1947e..f183e7b 100644 --- a/src/compare.tsx +++ b/src/compare.tsx @@ -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 { +export async function renderCompare(range: DateRange, provider: string, noCache = false): Promise { 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( process.exit(0)} /> ) diff --git a/src/dashboard.tsx b/src/dashboard.tsx index f84254d..2eb5ac4 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -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(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 { +export async function renderDashboard( + period: Period = 'week', + provider: string = 'all', + refreshSeconds?: number, + projectFilter?: string[], + excludeFilter?: string[], + customRange?: DateRange | null, + noCache = false, +): Promise { 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( - + ) await waitUntilExit() } else { diff --git a/src/parse-progress.ts b/src/parse-progress.ts new file mode 100644 index 0000000..8a502c7 --- /dev/null +++ b/src/parse-progress.ts @@ -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 + }, + } +} diff --git a/tests/parse-progress.test.ts b/tests/parse-progress.test.ts new file mode 100644 index 0000000..dbdbcf5 --- /dev/null +++ b/tests/parse-progress.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createTerminalProgressReporter } from '../src/parse-progress.js' + +describe('createTerminalProgressReporter', () => { + it('renders Updating cache progress lines to stderr-compatible streams', () => { + const writes: string[] = [] + const stream = { + isTTY: true, + write: vi.fn((chunk: string) => { + writes.push(chunk) + return true + }), + } as unknown as NodeJS.WriteStream + + const reporter = createTerminalProgressReporter(true, stream) + reporter?.start('Updating cache', 2) + reporter?.advance('claude/session.jsonl') + reporter?.advance('codex/rollout.jsonl') + reporter?.finish() + + expect(writes.join('')).toContain('Updating cache') + expect(writes.join('')).toContain('2/2') + }) +})