fix CodeRenderable streaming mode with drawUnstyledText=false (jitter) (#459)

This commit is contained in:
Sebastian
2025-12-30 15:19:44 +01:00
committed by GitHub
parent 5d204e02f2
commit c4f48d4e97
3 changed files with 157 additions and 73 deletions
+142 -14
View File
@@ -1083,13 +1083,13 @@ test("CodeRenderable - streaming mode respects drawUnstyledText only for initial
expect(codeRenderable.content).toBe("const updated = 'world';")
})
test("CodeRenderable - streaming mode uses cached highlights for partial styling", async () => {
test("CodeRenderable - streaming mode with drawUnstyledText=false waits for new highlights", async () => {
const syntaxStyle = SyntaxStyle.fromStyles({
default: { fg: RGBA.fromValues(1, 1, 1, 1) },
keyword: { fg: RGBA.fromValues(0, 0, 1, 1) },
})
const mockClient = new MockTreeSitterClient()
const mockClient = new MockTreeSitterClient({ autoResolveTimeout: 10 })
mockClient.setMockResult({
highlights: [[0, 5, "keyword"]] as SimpleHighlight[],
})
@@ -1107,22 +1107,20 @@ test("CodeRenderable - streaming mode uses cached highlights for partial styling
})
currentRenderer.root.add(codeRenderable)
currentRenderer.start()
mockClient.resolveHighlightOnce(0)
await new Promise((resolve) => setTimeout(resolve, 10))
await renderOnce()
await Bun.sleep(30)
expect(codeRenderable.plainText).toBe("const initial = 'hello';")
codeRenderable.content = "const updated = 'world';"
await new Promise((resolve) => queueMicrotask(resolve))
await renderOnce()
expect(codeRenderable.plainText).toBe("const initial = 'hello';")
await Bun.sleep(30)
expect(codeRenderable.plainText).toBe("const updated = 'world';")
mockClient.resolveHighlightOnce(0)
await new Promise((resolve) => setTimeout(resolve, 10))
await renderOnce()
expect(codeRenderable.plainText).toBe("const updated = 'world';")
currentRenderer.stop()
})
test("CodeRenderable - streaming mode caches highlights between updates", async () => {
@@ -1649,12 +1647,15 @@ test("CodeRenderable - streaming mode with drawUnstyledText=false has correct li
mockClient.resolveHighlightOnce(0)
await new Promise((resolve) => setTimeout(resolve, 10))
await renderOnce()
expect(codeRenderable.lineCount).toBe(2)
codeRenderable.content = "line1\nline2\nline3\nline4"
expect(codeRenderable.lineCount).toBe(4)
expect(codeRenderable.lineCount).toBe(2)
codeRenderable.content = "line1\nline2\nline3\nline4\nline5\nline6"
expect(codeRenderable.lineCount).toBe(6)
expect(codeRenderable.lineCount).toBe(2)
await renderOnce()
mockClient.resolveAllHighlightOnce()
@@ -1665,3 +1666,130 @@ test("CodeRenderable - streaming mode with drawUnstyledText=false has correct li
const finalFrame = captureFrame()
expect(finalFrame).toContain("line1")
})
test("CodeRenderable - streaming with conceal and drawUnstyledText=false should not jump when fenced code blocks are concealed", async () => {
resize(80, 20)
const syntaxStyle = SyntaxStyle.fromStyles({
default: { fg: RGBA.fromValues(1, 1, 1, 1) },
keyword: { fg: RGBA.fromValues(0, 0, 1, 1) },
string: { fg: RGBA.fromValues(0, 1, 0, 1) },
"markup.heading.1": { fg: RGBA.fromValues(0, 0, 1, 1) },
"markup.raw.block": { fg: RGBA.fromValues(0.5, 0.5, 0.5, 1) },
})
const codeRenderable = new CodeRenderable(currentRenderer, {
id: "test-markdown",
content: "# Example",
filetype: "markdown",
syntaxStyle,
streaming: true,
conceal: true,
drawUnstyledText: false,
left: 0,
top: 0,
})
currentRenderer.root.add(codeRenderable)
// Use TestRecorder to capture frames
const { TestRecorder } = await import("../testing/test-recorder")
const recorder = new TestRecorder(currentRenderer)
// Start renderer and recorder
currentRenderer.start()
recorder.rec()
// Wait for initial highlighting to complete
await new Promise((resolve) => setTimeout(resolve, 100))
// Now simulate streaming: add more content including fenced code block
codeRenderable.content = `# Example\n\nHere's some code:\n\n\`\`\`typescript\nconst x = 1;\n\`\`\``
// Wait for highlighting to process the update
await new Promise((resolve) => setTimeout(resolve, 150))
// Stop everything
currentRenderer.stop()
recorder.stop()
const frames = recorder.recordedFrames
// Analyze frames to detect the presence of backticks
const frameAnalysis: Array<{ hasBackticks: boolean; lineCount: number; isEmpty: boolean }> = []
for (const recordedFrame of frames) {
const frame = recordedFrame.frame
const hasBackticks = frame.includes("```")
const lines = frame.split("\n").filter((line) => line.trim().length > 0)
const isEmpty = frame.trim().length === 0
frameAnalysis.push({
hasBackticks,
lineCount: lines.length,
isEmpty,
})
}
let hasFlickering = false
for (let i = 2; i < frameAnalysis.length; i++) {
const prev = frameAnalysis[i - 1]
const curr = frameAnalysis[i]
if (!prev.isEmpty && curr.isEmpty) {
hasFlickering = true
}
}
const framesWithBackticks = frameAnalysis.filter((f) => f.hasBackticks && !f.isEmpty)
expect(framesWithBackticks.length).toBe(0)
expect(hasFlickering).toBe(false)
const finalFrame = frameAnalysis[frameAnalysis.length - 1]
expect(finalFrame.isEmpty).toBe(false)
expect(finalFrame.hasBackticks).toBe(false)
expect(finalFrame.lineCount).toBe(3)
const finalFrameText = frames[frames.length - 1].frame
expect(finalFrameText).toContain("Example")
expect(finalFrameText).toContain("Here's some code")
expect(finalFrameText).toContain("const x = 1")
expect(finalFrameText).not.toContain("```")
})
test("CodeRenderable - streaming with drawUnstyledText=false falls back to unstyled text when highlights fail", async () => {
const syntaxStyle = SyntaxStyle.fromStyles({
default: { fg: RGBA.fromValues(1, 1, 1, 1) },
})
const mockClient = new MockTreeSitterClient({ autoResolveTimeout: 10 })
const codeRenderable = new CodeRenderable(currentRenderer, {
id: "test-code",
content: "const initial = 'hello';",
filetype: "javascript",
syntaxStyle,
treeSitterClient: mockClient,
streaming: true,
drawUnstyledText: false,
left: 0,
top: 0,
})
currentRenderer.root.add(codeRenderable)
currentRenderer.start()
await Bun.sleep(30)
mockClient.highlightOnce = async () => {
throw new Error("Highlighting failed")
}
codeRenderable.content = "const updated = 'world';"
await Bun.sleep(30)
expect(codeRenderable.plainText).toBe("const updated = 'world';")
currentRenderer.stop()
})
+5 -11
View File
@@ -69,6 +69,10 @@ export class CodeRenderable extends TextBufferRenderable {
this._highlightsDirty = true
this._highlightSnapshotId++
if (this._streaming && !this._drawUnstyledText && this._filetype) {
return
}
this.textBuffer.setText(value)
this.updateTextInfo()
}
@@ -160,17 +164,7 @@ export class CodeRenderable extends TextBufferRenderable {
const shouldDrawUnstyledNow = this._streaming ? isInitialContent && this._drawUnstyledText : this._drawUnstyledText
if (this._streaming && !isInitialContent) {
if (this._lastHighlights.length > 0) {
const chunks = treeSitterToTextChunks(content, this._lastHighlights, this._syntaxStyle, {
enabled: this._conceal,
})
const partialStyledText = new StyledText(chunks)
this.textBuffer.setStyledText(partialStyledText)
this._shouldRenderTextBuffer = true
this.updateTextInfo()
} else {
this._shouldRenderTextBuffer = true
}
this._shouldRenderTextBuffer = true
} else if (shouldDrawUnstyledNow) {
this.textBuffer.setText(content)
this._shouldRenderTextBuffer = true
@@ -1,39 +1,15 @@
import { test, expect, describe } from "bun:test"
import { Readable } from "node:stream"
import { createCliRenderer } from "../renderer"
import tty from "tty"
const MOUSE_ENABLE_SEQUENCES = ["\x1b[?1000h", "\x1b[?1002h", "\x1b[?1003h", "\x1b[?1006h"]
function createMockStreams() {
const mockStdin = new Readable({ read() {} }) as tty.ReadStream
mockStdin.isTTY = true
mockStdin.setRawMode = () => mockStdin
mockStdin.resume = () => mockStdin
mockStdin.pause = () => mockStdin
mockStdin.setEncoding = () => mockStdin
const writes: string[] = []
const mockStdout = {
isTTY: true,
columns: 80,
rows: 24,
write: (data: string | Buffer) => {
writes.push(data.toString())
return true
},
} as any
return { mockStdin, mockStdout, writes }
}
import { createTestRenderer } from "../testing/test-renderer"
// NOTE: These tests are not running the mouse activation sequences,
// only verifying that the configuration is applied correctly.
// Tests avoid actually outputting to the terminal during test runs,
// to not mess up the terminal state.
// What actually gets written can be tested properly when
// https://github.com/sst/opentui/pull/238 is merged.
describe("useMouse configuration", () => {
test("useMouse: true sets renderer.useMouse to true", async () => {
const { mockStdin, mockStdout } = createMockStreams()
const renderer = await createCliRenderer({
stdin: mockStdin,
stdout: mockStdout,
const { renderer } = await createTestRenderer({
useMouse: true,
exitOnCtrlC: false,
useAlternateScreen: false,
@@ -44,32 +20,18 @@ describe("useMouse configuration", () => {
})
test("useMouse: false disables mouse tracking", async () => {
const { mockStdin, mockStdout, writes } = createMockStreams()
const renderer = await createCliRenderer({
stdin: mockStdin,
stdout: mockStdout,
const { renderer } = await createTestRenderer({
useMouse: false,
exitOnCtrlC: false,
useAlternateScreen: false,
})
expect(renderer.useMouse).toBe(false)
const allOutput = writes.join("")
for (const seq of MOUSE_ENABLE_SEQUENCES) {
expect(allOutput.includes(seq)).toBe(false)
}
renderer.destroy()
})
test("toggling useMouse property updates renderer state", async () => {
const { mockStdin, mockStdout } = createMockStreams()
const renderer = await createCliRenderer({
stdin: mockStdin,
stdout: mockStdout,
const { renderer } = await createTestRenderer({
useMouse: false,
exitOnCtrlC: false,
useAlternateScreen: false,