fix(tui): highlighted streamed diff scrollback rows
Highlighted completed diff and patch fence lines during transient Markdown rendering so rows enter native scrollback with semantic colors before finalization. Added regression coverage for streamed diff scrollback foreground preservation and diff highlighter chunk parity. Fixes #5126
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container";
|
||||
import type { Component } from "@oh-my-pi/pi-tui";
|
||||
import { type Component, TUI } from "@oh-my-pi/pi-tui";
|
||||
import { Markdown, type MarkdownTheme } from "@oh-my-pi/pi-tui/components/markdown";
|
||||
import { StressRenderScheduler } from "../../tui/test/render-stress-scheduler";
|
||||
import { defaultMarkdownTheme } from "../../tui/test/test-themes.js";
|
||||
import { VirtualTerminal } from "../../tui/test/virtual-terminal";
|
||||
|
||||
class MutableLiveBlock implements Component {
|
||||
#lines: string[];
|
||||
@@ -24,6 +28,63 @@ class MutableLiveBlock implements Component {
|
||||
}
|
||||
}
|
||||
|
||||
const diffMarkdownTheme: MarkdownTheme = {
|
||||
...defaultMarkdownTheme,
|
||||
codeBlock: text => text,
|
||||
codeBlockBorder: text => text,
|
||||
highlightCode: (source, lang) => {
|
||||
const normalizedLang = lang?.trim().toLowerCase();
|
||||
const highlighted: string[] = [];
|
||||
for (const line of source.split("\n")) {
|
||||
if (normalizedLang === "diff" && line.startsWith("+")) highlighted.push(`\x1b[32m${line}\x1b[39m`);
|
||||
else if (normalizedLang === "diff" && line.startsWith("-")) highlighted.push(`\x1b[31m${line}\x1b[39m`);
|
||||
else highlighted.push(line);
|
||||
}
|
||||
return highlighted;
|
||||
},
|
||||
};
|
||||
|
||||
class StreamingMarkdownBlock implements Component {
|
||||
#finalized = false;
|
||||
#markdown = new Markdown("", 0, 0, diffMarkdownTheme);
|
||||
|
||||
setStreamingText(text: string): void {
|
||||
this.#finalized = false;
|
||||
this.#markdown.transientRenderCache = true;
|
||||
this.#markdown.setText(text);
|
||||
}
|
||||
|
||||
finalize(text: string): void {
|
||||
this.#finalized = true;
|
||||
this.#markdown.transientRenderCache = false;
|
||||
this.#markdown.setText(text);
|
||||
}
|
||||
|
||||
render(width: number): readonly string[] {
|
||||
const lines = this.#markdown.render(width);
|
||||
return lines;
|
||||
}
|
||||
|
||||
isTranscriptBlockFinalized(): boolean {
|
||||
const finalized = this.#finalized;
|
||||
return finalized;
|
||||
}
|
||||
|
||||
getTranscriptBlockSettledRows(): number {
|
||||
const rows = this.#markdown.getLastRenderSettledRows();
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
function foregroundColumnsForBufferRow(term: VirtualTerminal, bufferRow: number): number[] {
|
||||
const before = term.getBufferPosition();
|
||||
term.scrollLines(bufferRow - before.viewportY);
|
||||
const columns = term.getViewportRowForegroundColumns(0);
|
||||
const after = term.getBufferPosition();
|
||||
term.scrollLines(before.viewportY - after.viewportY);
|
||||
return columns;
|
||||
}
|
||||
|
||||
describe("transcript streaming commit (assistant text)", () => {
|
||||
it("commits only the declared settled head while the trailing line grows", () => {
|
||||
const chat = new TranscriptContainer();
|
||||
@@ -41,4 +102,50 @@ describe("transcript streaming commit (assistant text)", () => {
|
||||
|
||||
expect(chat.getNativeScrollbackLiveRegionStart()).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps diff foreground on rows committed while a streamed fence is still open", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const rows = 6;
|
||||
const term = new VirtualTerminal(48, 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}changed-${String(index).padStart(2, "0")}`;
|
||||
});
|
||||
const openFence = `\`\`\`diff\n${diffLines.join("\n")}\n`;
|
||||
const closedFence = `${openFence}\`\`\``;
|
||||
chat.addChild(block);
|
||||
tui.addChild(chat);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await scheduler.drain(term);
|
||||
|
||||
block.setStreamingText(openFence);
|
||||
tui.requestRender();
|
||||
await scheduler.drain(term);
|
||||
|
||||
const streamedRows = term.getScrollBuffer().map(row => Bun.stripANSI(row).trimEnd());
|
||||
const streamedRow = streamedRows.findIndex(row => row.includes("+changed-00"));
|
||||
expect(streamedRow).toBeGreaterThanOrEqual(0);
|
||||
expect(streamedRow).toBeLessThan(term.getBufferPosition().baseY);
|
||||
|
||||
block.finalize(closedFence);
|
||||
tui.requestRender();
|
||||
await scheduler.drain(term);
|
||||
|
||||
const finalRows = term.getScrollBuffer().map(row => Bun.stripANSI(row).trimEnd());
|
||||
const finalRow = finalRows.findIndex(row => row.includes("+changed-00"));
|
||||
expect(finalRow).toBe(streamedRow);
|
||||
expect(finalRow).toBeLessThan(term.getBufferPosition().baseY);
|
||||
expect(foregroundColumnsForBufferRow(term, finalRow).length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
tui.stop();
|
||||
await term.flush();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user