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.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
Reference in New Issue
Block a user