From 2914ea66a612db19a4fe99577ae7b62ef61366c6 Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Fri, 10 Jul 2026 00:43:49 +0200 Subject: [PATCH] fix(parser): keep scan-progress out of the interactive dashboard and compare TUIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cold-scan progress line was gated only on stderr.isTTY, which is also true when the interactive dashboard and `codeburn compare` render Ink to the same terminal — so the \r progress line printed over their frame and garbled the screen (confirmed under a PTY). isTTY alone can't tell an Ink UI apart from a plain CLI command. Gate on an explicit 'interactive Ink UI is live' flag instead: the two interactive entrypoints call setInteractiveScanUI() before they render, so their scans (and the dashboard's 30s auto-refresh, including the getPlanUsages -> parseAllSessions path) stay silent, while plain CLI commands (overview, export, status) still show progress. Verified under a PTY: dashboard and compare silent, overview shows progress, piped/non-TTY silent. Adds a gate unit test covering the interactive-active, plain-TTY, non-TTY, threshold, and finish()-clear cases. --- package-lock.json | 4 +-- src/compare.tsx | 6 +++- src/dashboard.tsx | 7 +++- src/parser.ts | 18 ++++++++-- tests/scan-progress.test.ts | 66 +++++++++++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 tests/scan-progress.test.ts diff --git a/package-lock.json b/package-lock.json index d7b693f..e649491 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codeburn", - "version": "0.9.12", + "version": "0.9.15", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codeburn", - "version": "0.9.12", + "version": "0.9.15", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/src/compare.tsx b/src/compare.tsx index 2fd71d4..565c840 100644 --- a/src/compare.tsx +++ b/src/compare.tsx @@ -4,7 +4,7 @@ import { render, Box, Text, useInput, useApp, useStdout } from 'ink' import type { ModelStats, ComparisonRow, CategoryComparison, WorkingStyleRow } from './compare-stats.js' import { aggregateModelStats, computeComparison, computeCategoryComparison, computeWorkingStyle, scanSelfCorrections } from './compare-stats.js' import { formatCost } from './format.js' -import { parseAllSessions } from './parser.js' +import { parseAllSessions, setInteractiveScanUI } from './parser.js' import { getAllProviders } from './providers/index.js' import type { ProjectSummary, DateRange } from './types.js' import { patchStdoutForWindows } from './ink-win.js' @@ -467,6 +467,10 @@ export function CompareView({ projects, onBack }: CompareViewProps) { } export async function renderCompare(range: DateRange, provider: string): Promise { + // Interactive Ink UI: suppress the CLI scan-progress line for the whole + // lifetime so it can't print over the rendered comparison. Plain CLI + // commands still show progress. + setInteractiveScanUI() const isTTY = process.stdin.isTTY && process.stdout.isTTY if (!isTTY) { process.stdout.write('Model comparison requires an interactive terminal.\n') diff --git a/src/dashboard.tsx b/src/dashboard.tsx index a79c9c2..7ba2e7b 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -5,7 +5,7 @@ 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 { parseAllSessions, filterProjectsByName, setInteractiveScanUI } from './parser.js' import { findUnpricedModels, loadPricing } from './models.js' import { getAllProviders } from './providers/index.js' import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js' @@ -1086,6 +1086,11 @@ function StaticDashboard({ projects, period, activeProvider, planUsages, label, } export async function renderDashboard(period: Period = 'week', provider: string = 'all', refreshSeconds?: number, projectFilter?: string[], excludeFilter?: string[], customRange?: DateRange | null, customRangeLabel?: string, initialDay?: string): Promise { + // Interactive Ink UI: it renders to the same terminal and has its own in-frame + // loading state, so the CLI scan-progress line must stay silent for its whole + // lifetime (initial scan and every 30s auto-refresh, including the + // getPlanUsages → parseAllSessions path). Plain CLI commands are unaffected. + setInteractiveScanUI() await loadPricing() const dayRange = initialDay ? getDayRange(initialDay) : null const range = dayRange ?? customRange ?? getPeriodRange(period) diff --git a/src/parser.ts b/src/parser.ts index 90ba82d..4fa95fd 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1986,8 +1986,22 @@ function yieldToEventLoop(): Promise { return new Promise(resolve => setImmediate(resolve)) } -function createScanProgress(label: string, total: number) { - const show = total > 20 && process.stderr.isTTY === true +// Suppress the scan-progress line while an interactive Ink UI is live. The +// dashboard and compare render to stdout on the same terminal, and their scans +// run (dashboard) or re-run every 30s (dashboard auto-refresh, including the +// getPlanUsages → parseAllSessions path) AFTER render() has painted a frame, so +// a `\r` progress line on stderr prints over it and garbles the screen. isTTY +// alone can't tell them apart from a plain CLI command. The interactive +// entrypoints call setInteractiveScanUI() right before render(); a pre-render +// scan (e.g. compare's cold start) still shows progress and finish() clears the +// line before Ink paints. +let interactiveScanUI = false +export function setInteractiveScanUI(active = true): void { + interactiveScanUI = active +} + +export function createScanProgress(label: string, total: number) { + const show = !interactiveScanUI && total > 20 && process.stderr.isTTY === true let lastWrite = 0 return { async tick(done: number): Promise { diff --git a/tests/scan-progress.test.ts b/tests/scan-progress.test.ts new file mode 100644 index 0000000..0ffc7d2 --- /dev/null +++ b/tests/scan-progress.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { createScanProgress, setInteractiveScanUI } from '../src/parser.js' + +// The scan-progress line must be silent while an interactive Ink UI (dashboard, +// compare) is live, because it renders to the same terminal. The end-to-end +// proof (cold dashboard WITH a plan stays silent; cold overview shows progress) +// is documented in the PR and was run under a PTY. +describe('createScanProgress gate', () => { + const origTTY = process.stderr.isTTY + + afterEach(() => { + setInteractiveScanUI(false) + Object.defineProperty(process.stderr, 'isTTY', { value: origTTY, configurable: true }) + vi.restoreAllMocks() + }) + + function forceTTY(v: boolean) { + Object.defineProperty(process.stderr, 'isTTY', { value: v, configurable: true }) + } + + it('writes progress for a plain CLI command in a TTY over the threshold', async () => { + forceTTY(true) + const w = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const p = createScanProgress('scanning', 100) + await p.tick(100) // final tick bypasses the 100ms throttle + expect(w).toHaveBeenCalled() + expect(String(w.mock.calls[0]![0])).toContain('scanning 100/100') + }) + + it('goes silent once an interactive Ink UI is active, even in a TTY', async () => { + forceTTY(true) + setInteractiveScanUI() // dashboard/compare call this right before render() + const w = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const p = createScanProgress('scanning', 100) + for (let i = 1; i <= 100; i++) await p.tick(i) + p.finish() + expect(w).not.toHaveBeenCalled() + }) + + it('finish() clears the line when progress was shown', async () => { + forceTTY(true) + const w = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const p = createScanProgress('scanning', 100) + await p.tick(100) + w.mockClear() + p.finish() + expect(String(w.mock.calls[0]![0])).toBe('\r\x1b[K') + }) + + it('writes nothing when stderr is not a TTY (piped/captured output)', async () => { + forceTTY(false) + const w = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const p = createScanProgress('scanning', 100) + await p.tick(100) + p.finish() + expect(w).not.toHaveBeenCalled() + }) + + it('stays silent below the small-tree threshold', async () => { + forceTTY(true) + const w = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const p = createScanProgress('scanning', 5) + await p.tick(5) + expect(w).not.toHaveBeenCalled() + }) +})