From 936e83e3deb9e097153345d9cc41abf3128ea3df Mon Sep 17 00:00:00 2001 From: roboomp Date: Sat, 11 Jul 2026 00:28:36 +0000 Subject: [PATCH] fix(tui): highlighted closed streamed diff tails Detected closed transient diff fences and highlighted their full code token, including the final row before the closing fence, before rows can enter native scrollback. Added regression coverage for a closed diff followed by streaming prose pushing the final diff row into history. --- .../transcript-streaming-commit-repro.test.ts | 42 ++++++++++++++++++ packages/tui/src/components/markdown.ts | 44 ++++++++++++++++--- 2 files changed, 81 insertions(+), 5 deletions(-) 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 [];