mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
fix(parser): keep scan-progress out of the interactive dashboard and compare TUIs
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.
This commit is contained in:
parent
dd0039ab88
commit
2914ea66a6
5 changed files with 95 additions and 6 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
// 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')
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
// 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)
|
||||
|
|
|
|||
|
|
@ -1986,8 +1986,22 @@ function yieldToEventLoop(): Promise<void> {
|
|||
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<void> {
|
||||
|
|
|
|||
66
tests/scan-progress.test.ts
Normal file
66
tests/scan-progress.test.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue