diff --git a/packages/coding-agent/test/transcript-streaming-commit-repro.test.ts b/packages/coding-agent/test/transcript-streaming-commit-repro.test.ts index 404f1598c..c92ca031b 100644 --- a/packages/coding-agent/test/transcript-streaming-commit-repro.test.ts +++ b/packages/coding-agent/test/transcript-streaming-commit-repro.test.ts @@ -148,4 +148,46 @@ describe("transcript streaming commit (assistant text)", () => { await term.flush(); } }); + + it("keeps the final closed diff row highlighted while following prose streams", async () => { + if (process.platform === "win32") return; + const rows = 6; + const term = new VirtualTerminal(52, rows); + Object.defineProperty(term, "isNativeViewportAtBottom", { configurable: true, value: () => undefined }); + const scheduler = new StressRenderScheduler(); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); + const chat = new TranscriptContainer(); + const block = new StreamingMarkdownBlock(); + const diffLines = Array.from({ length: 18 }, (_value, index) => { + const sign = index % 2 === 0 ? "-" : "+"; + return `${sign}closed-${String(index).padStart(2, "0")}`; + }); + const finalDiffLine = "+closed-tail-no-extra-newline"; + const codeBlockSource = [...diffLines, finalDiffLine].join("\n"); + const streamingProse = Array.from( + { length: 12 }, + (_value, index) => `still streaming prose line ${String(index).padStart(2, "0")}`, + ).join("\n"); + const closedFenceWithLiveTail = `\`\`\`diff\n${codeBlockSource}\n\`\`\`\n${streamingProse}`; + chat.addChild(block); + tui.addChild(chat); + + try { + tui.start(); + await scheduler.drain(term); + + block.setStreamingText(closedFenceWithLiveTail); + tui.requestRender(); + await scheduler.drain(term); + + const streamedRows = term.getScrollBuffer().map(row => Bun.stripANSI(row).trimEnd()); + const finalDiffRow = streamedRows.findIndex(row => row.includes(finalDiffLine)); + expect(finalDiffRow).toBeGreaterThanOrEqual(0); + expect(finalDiffRow).toBeLessThan(term.getBufferPosition().baseY); + expect(foregroundColumnsForBufferRow(term, finalDiffRow).length).toBeGreaterThan(0); + } finally { + tui.stop(); + await term.flush(); + } + }); }); diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index dd3f642ed..6f1ebd40d 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -1421,16 +1421,19 @@ export class Markdown implements Component { } if (canStreamDiff) { + const closedFence = this.#codeTokenHasClosingFence(token); const lineEnd = tokenText.lastIndexOf("\n"); - if (lineEnd >= 0) { - const completedText = tokenText.slice(0, lineEnd); - if (completedText.length > 0) { + if (closedFence || lineEnd >= 0) { + const completedText = closedFence ? tokenText : tokenText.slice(0, lineEnd); + if (closedFence || completedText.length > 0) { for (const hlLine of this.#highlightStreamingDiffLines(completedText, lang)) { bodyLines.push(`${codeIndent}${hlLine}`); } } - for (const codeLine of tokenText.slice(lineEnd + 1).split("\n")) { - bodyLines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`); + if (!closedFence) { + for (const codeLine of tokenText.slice(lineEnd + 1).split("\n")) { + bodyLines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`); + } } return bodyLines; } @@ -1442,6 +1445,37 @@ export class Markdown implements Component { return bodyLines; } + #codeTokenHasClosingFence(token: Token): boolean { + const raw = "raw" in token && typeof token.raw === "string" ? token.raw : ""; + const firstLineEnd = raw.indexOf("\n"); + if (firstLineEnd < 0) return false; + const openingLine = raw.slice(0, firstLineEnd); + const openingTrimmed = openingLine.trimStart(); + const openingIndent = openingLine.length - openingTrimmed.length; + if (openingIndent > 3) return false; + const fenceChar = openingTrimmed.charAt(0); + if (fenceChar !== "`" && fenceChar !== "~") return false; + let fenceLength = 0; + while (openingTrimmed.charAt(fenceLength) === fenceChar) fenceLength++; + if (fenceLength < 3) return false; + + let lineStart = firstLineEnd + 1; + while (lineStart <= raw.length) { + const lineEnd = raw.indexOf("\n", lineStart); + const line = lineEnd >= 0 ? raw.slice(lineStart, lineEnd) : raw.slice(lineStart); + const trimmed = line.trimStart(); + const indent = line.length - trimmed.length; + let closingLength = 0; + while (trimmed.charAt(closingLength) === fenceChar) closingLength++; + if (indent <= 3 && closingLength >= fenceLength && trimmed.slice(closingLength).trim().length === 0) { + return true; + } + if (lineEnd < 0) break; + lineStart = lineEnd + 1; + } + return false; + } + #highlightStreamingDiffLines(completedText: string, lang: string | undefined): readonly string[] { const highlightCode = this.#theme.highlightCode; if (!highlightCode) return [];