diff --git a/packages/cli/src/context-build-view.test.ts b/packages/cli/src/context-build-view.test.ts index b126f27c..04248d46 100644 --- a/packages/cli/src/context-build-view.test.ts +++ b/packages/cli/src/context-build-view.test.ts @@ -309,6 +309,41 @@ describe('renderContextBuildView', () => { }); describe('createRepainter', () => { + it('does not leave a stale header when terminal rows wrap differently than reported', () => { + const io = makeIo({ isTTY: true }); + const repainter = createRepainter(io.io); + const state = initViewState([ + { + connectionId: 'postgres-warehouse', + driver: 'postgres', + operation: 'scan', + debugCommand: '', + steps: ['scan'], + }, + { + connectionId: 'dbt-main', + driver: 'dbt', + operation: 'source-ingest', + adapter: 'dbt', + debugCommand: '', + steps: ['source-ingest', 'memory-update'], + }, + ]); + state.primarySources[0].status = 'done'; + state.primarySources[0].summaryText = '7 tables'; + state.primarySources[0].elapsedMs = 18000; + state.contextSources[0].status = 'running'; + state.contextSources[0].elapsedMs = 1000; + state.totalElapsedMs = 1000; + + repainter.paint(renderContextBuildView(state, { styled: false, showHint: true })); + state.contextSources[0].elapsedMs = 5000; + state.totalElapsedMs = 5000; + repainter.paint(renderContextBuildView(state, { styled: false, showHint: true })); + + expect(io.stdout()).toContain('\x1b[14A\r'); + }); + it('moves up visual rows, not just newline count, when content wraps', () => { const io = makeIo({ isTTY: true, columns: 5 }); const repainter = createRepainter(io.io); @@ -342,6 +377,17 @@ describe('createRepainter', () => { const cursorMoves = [...io.stdout().matchAll(/\[(\d+)A/g)].map((m) => Number(m[1])); expect(cursorMoves).toEqual([2]); }); + + it('clears the current frame', () => { + const io = makeIo({ isTTY: true, columns: 80 }); + const repainter = createRepainter(io.io); + + repainter.paint('hello\nworld\n'); + repainter.clear(); + io.io.stdout.write('after\n'); + + expect(io.stdout()).toContain('\x1b[2A\r\x1b[2K\x1b[Jafter'); + }); }); describe('runContextBuild', () => { diff --git a/packages/cli/src/context-build-view.ts b/packages/cli/src/context-build-view.ts index f8014754..0dc0f25e 100644 --- a/packages/cli/src/context-build-view.ts +++ b/packages/cli/src/context-build-view.ts @@ -232,6 +232,7 @@ export function renderContextBuildView( const ESC_K_RE = new RegExp(`${ESC.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\[K`, 'g'); const ANSI_RE = /\x1b\[[0-9;]*m/g; +const DEFAULT_REPAINT_COLUMNS = 40; export function extractProgressMessage(chunk: string): string | null { const cleaned = chunk.replace(/^\r/, '').replace(ESC_K_RE, '').replace(/\n$/, '').trim(); @@ -354,9 +355,11 @@ export function createRepainter(io: KtxCliIo) { const terminalColumns = () => { for (const columns of [io.stdout.columns, process.stdout.columns]) { - if (typeof columns === 'number' && Number.isFinite(columns) && columns > 0) return columns; + if (typeof columns === 'number' && Number.isFinite(columns) && columns > 0) { + return columns; + } } - return 80; + return DEFAULT_REPAINT_COLUMNS; }; const visualRows = (line: string, columns: number) => { @@ -390,6 +393,15 @@ export function createRepainter(io: KtxCliIo) { hasPainted = true; lastCursorUpRows = cursorUpRowsAfterWrite(content); }, + clear() { + if (!hasPainted) return; + if (lastCursorUpRows > 0) { + io.stdout.write(`${ESC}[${lastCursorUpRows}A`); + } + io.stdout.write(`\r${ESC}[2K${ESC}[J`); + hasPainted = false; + lastCursorUpRows = 0; + }, }; } diff --git a/packages/cli/src/setup-context.test.ts b/packages/cli/src/setup-context.test.ts index cfb840ec..11b43fe5 100644 --- a/packages/cli/src/setup-context.test.ts +++ b/packages/cli/src/setup-context.test.ts @@ -11,12 +11,13 @@ import { writeKtxSetupContextState, } from './setup-context.js'; -function makeIo() { +function makeIo(options: { isTTY?: boolean } = {}) { let stdout = ''; let stderr = ''; return { io: { stdout: { + isTTY: options.isTTY, write: (chunk: string) => { stdout += chunk; }, @@ -522,12 +523,21 @@ describe('setup context build state', () => { ], }); }; - const runContextBuildMock = vi.fn(async () => ({ - exitCode: 0, - detached: false, - reportIds: ['docs-report'], - artifactPaths: ['raw-sources/docs/notion/sync-1/ingest-report.json'], - })); + const runContextBuildMock = vi.fn(async () => { + await expect(readKtxSetupContextState(tempDir)).resolves.toMatchObject({ + status: 'running', + sourceProgress: [ + { connectionId: 'warehouse', operation: 'scan', status: 'done', elapsedMs: 120000 }, + { connectionId: 'docs', operation: 'source-ingest', status: 'queued' }, + ], + }); + return { + exitCode: 0, + detached: false, + reportIds: ['docs-report'], + artifactPaths: ['raw-sources/docs/notion/sync-1/ingest-report.json'], + }; + }); const verifyContextReady = vi.fn(async () => ({ ready: true, agentContextReady: true, @@ -568,6 +578,74 @@ describe('setup context build state', () => { ); }); + it('clears the auto-watch progress view before continuing the full context build', async () => { + await writeReadyProject(tempDir); + await writeKtxSetupContextState(tempDir, { + runId: 'setup-context-local-prefetch-clear', + status: 'detached', + startedAt: '2026-05-09T10:00:00.000Z', + updatedAt: '2026-05-09T10:00:00.000Z', + primarySourceConnectionIds: ['warehouse'], + contextSourceConnectionIds: [], + reportIds: [], + artifactPaths: [], + retryableFailedTargets: [], + commands: contextBuildCommands(tempDir, 'setup-context-local-prefetch-clear'), + sourceProgress: [ + { connectionId: 'warehouse', operation: 'scan' as const, status: 'running' as const, startedAtMs: Date.now() }, + ], + }); + const io = makeIo({ isTTY: true }); + const completePrefetch = async () => { + await writeKtxSetupContextState(tempDir, { + runId: 'setup-context-local-prefetch-clear', + status: 'paused', + startedAt: '2026-05-09T10:00:00.000Z', + updatedAt: '2026-05-09T10:02:00.000Z', + primarySourceConnectionIds: ['warehouse'], + contextSourceConnectionIds: [], + reportIds: ['warehouse-report'], + artifactPaths: ['raw-sources/warehouse/live-database/sync-1/scan-report.json'], + retryableFailedTargets: [], + commands: contextBuildCommands(tempDir, 'setup-context-local-prefetch-clear'), + sourceProgress: [ + { connectionId: 'warehouse', operation: 'scan' as const, status: 'done' as const, elapsedMs: 120000 }, + ], + }); + }; + const runContextBuildMock = vi.fn(async () => { + io.io.stdout.write('foreground-build-started\n'); + return { + exitCode: 0, + detached: false, + reportIds: ['docs-report'], + artifactPaths: ['raw-sources/docs/notion/sync-1/ingest-report.json'], + }; + }); + + await expect( + runKtxSetupContextStep( + { projectDir: tempDir, inputMode: 'auto', autoWatch: true }, + io.io, + { + sleep: completePrefetch, + watchIntervalMs: 1, + runIdFactory: () => 'setup-context-local-final-clear', + now: () => new Date('2026-05-09T10:03:00.000Z'), + runContextBuild: runContextBuildMock, + verifyContextReady: vi.fn(async () => ({ + ready: true, + agentContextReady: true, + semanticSearchReady: true, + details: ['ready'], + })), + }, + ), + ).resolves.toEqual({ status: 'ready', projectDir: tempDir, runId: 'setup-context-local-final-clear' }); + + expect(io.stdout()).toMatch(/\x1b\[\d+A\r\x1b\[2K\x1b\[Jforeground-build-started/); + }); + it('shows newly configured context sources while watching an active primary scan prefetch', async () => { await writeReadyProject(tempDir); await writeKtxSetupContextState(tempDir, { diff --git a/packages/cli/src/setup-context.ts b/packages/cli/src/setup-context.ts index 41a4d85b..0865789c 100644 --- a/packages/cli/src/setup-context.ts +++ b/packages/cli/src/setup-context.ts @@ -622,7 +622,8 @@ async function runBuild( const now = deps.now ?? (() => new Date()); const runId = deps.runIdFactory?.() ?? runIdFactory(); const startedAt = now().toISOString(); - const completedSourceProgress = existingState?.sourceProgress?.filter((source) => source.status === 'done') ?? []; + const existingSourceProgress = sourceProgressWithTargets(existingState?.sourceProgress, targets) ?? []; + const completedSourceProgress = existingSourceProgress.filter((source) => source.status === 'done'); const runningState: KtxSetupContextState = { runId, status: 'running', @@ -634,12 +635,12 @@ async function runBuild( artifactPaths: [], retryableFailedTargets: [], commands: contextBuildCommands(args.projectDir, runId), - ...(completedSourceProgress.length > 0 ? { sourceProgress: completedSourceProgress } : {}), + ...(existingSourceProgress.length > 0 ? { sourceProgress: existingSourceProgress } : {}), }; await writeKtxSetupContextState(args.projectDir, runningState); let lastSourceProgress: ContextBuildSourceProgressUpdate[] | undefined = - completedSourceProgress.length > 0 ? completedSourceProgress : undefined; + existingSourceProgress.length > 0 ? existingSourceProgress : undefined; const contextBuild = deps.runContextBuild ?? runContextBuild; const buildResult = await contextBuild( project, @@ -1025,6 +1026,9 @@ async function watchContextStatusWithProgressView( } if (!isActiveStatus(state.status)) { + if (state.status === 'paused') { + repainter?.clear(); + } return { exitCode: watchExitCode(state.status), state }; } if (detached) break;