Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton 840ff9306d fix(tui): paint cached syntax before the first frame 2026-08-31 09:51:29 -04:00
5 changed files with 801 additions and 1 deletions
+1
View File
@@ -1042,6 +1042,7 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@opentui/core@0.5.9": "patches/@opentui%2Fcore@0.5.9.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
+2 -1
View File
@@ -178,6 +178,7 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch"
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
"@opentui/core@0.5.9": "patches/@opentui%2Fcore@0.5.9.patch"
}
}
+75
View File
@@ -1193,6 +1193,81 @@ test.each(["manual", "select"] as const)(
},
)
test.each([80, 120].flatMap((width) => ["fence", "list", "write"].map((kind) => ({ width, kind }))))(
"cached session code is colored on the first tab-return frame: %j",
async (input) => {
await using state = await tmpdir()
const content = `const cachedValue_${input.width}_${input.kind}: number = 42`
const session = {
id: "ses_cached_highlight",
title: "Cached highlight fixture",
projectID: "project",
location: { directory },
agent: "build",
model: { providerID: "fixture", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
}
await using setup = await createAppFixture({
width: input.width,
state: state.path,
config: { animations: false, tabs: { enabled: true }, session: { sidebar: "hide" } },
args: { sessionID: session.id },
fetch: (url) => {
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`)
return json({
data: [
{
id: "msg_cached_highlight",
type: "assistant",
agent: session.agent,
model: session.model,
time: { created: 2, completed: 3 },
content: [
input.kind === "write"
? {
type: "tool",
id: "call_cached_highlight",
name: "write",
time: { created: 2, completed: 3 },
state: {
status: "completed",
input: { path: `${directory}/snippet.ts`, content },
content: [{ type: "text", text: "Wrote file" }],
},
}
: {
type: "text",
text:
input.kind === "list"
? `- Example:\n\n \`\`\`typescript\n ${content}\n \`\`\``
: `\`\`\`typescript\n${content}\n\`\`\``,
},
],
},
],
cursor: {},
})
if (url.pathname === `/api/session/${session.id}/inbox`) return json({ data: [] })
if (url.pathname === `/api/session/${session.id}/permission`) return json({ data: [] })
},
})
await setup.ready
const line = () =>
setup.captureSpans().lines.find((line) => line.spans.some((span) => span.text.includes("cachedValue")))
await setup.waitForFrame(() => (line()?.spans.filter((span) => span.text.trim()).length ?? 0) > 4)
const highlighted = line()
setup.mockInput.pressKey("x", { ctrl: true })
setup.mockInput.pressKey("n")
await setup.waitForFrame((frame) => !frame.includes("cachedValue"))
setup.mockInput.pressKey("1", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("cachedValue"))
expect(line()).toEqual(highlighted)
},
)
async function createAppFixture(
input: {
width?: number
+332
View File
@@ -0,0 +1,332 @@
import { expect, test } from "bun:test"
import {
CodeRenderable,
MarkdownRenderable,
RGBA,
SyntaxStyle,
TreeSitterClient,
t,
type TextChunk,
} from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { render } from "@opentui/solid"
import { tmpdir } from "./fixture/fixture"
const content = "const cachedValue: number = 42"
test.each([false, true])(
"cached code paints colored before the next promise resolves, streaming=%s",
async (streaming) => {
await using app = await fixture()
const result = await app.client.highlightOnce(content, "typescript")
const pending = Promise.withResolvers<typeof result>()
app.client.highlightOnce = () => pending.promise
const code = new CodeRenderable(app.renderer, {
content,
filetype: "typescript",
syntaxStyle: app.syntax,
treeSitterClient: app.client,
streaming,
drawUnstyledText: !streaming,
})
app.renderer.root.add(code)
try {
await app.renderOnce()
expect(code.isHighlighting).toBe(true)
expect(app.captureCharFrame()).toContain(content)
expect(keyword(app)).toEqual(RGBA.fromHex("#ff0000"))
const first = app.captureSpans()
pending.resolve(result)
await code.highlightingDone
await app.renderOnce()
expect(app.captureSpans()).toEqual(first)
} finally {
pending.resolve(result)
}
},
)
test("an uncached code block still paints plaintext before highlighting completes", async () => {
await using app = await fixture()
const pending = Promise.withResolvers<Awaited<ReturnType<TreeSitterClient["highlightOnce"]>>>()
app.client.highlightOnce = () => pending.promise
const code = new CodeRenderable(app.renderer, {
content,
filetype: "typescript",
syntaxStyle: app.syntax,
treeSitterClient: app.client,
fg: "#ffffff",
})
app.renderer.root.add(code)
try {
await app.renderOnce()
expect(code.isHighlighting).toBe(true)
expect(app.captureCharFrame()).toContain(content)
expect(keyword(app)).toEqual(RGBA.fromHex("#ffffff"))
} finally {
pending.resolve({ highlights: [] })
await code.highlightingDone
}
})
test.each([false, true])("cached Markdown fences paint immediately, nested=%s", async (nested) => {
await using app = await fixture()
const result = await app.client.highlightOnce(content, "typescript")
const pending = Promise.withResolvers<typeof result>()
app.client.highlightOnce = () => pending.promise
app.renderer.root.add(
new MarkdownRenderable(app.renderer, {
content: nested
? `- Example:\n\n \`\`\`typescript\n ${content}\n \`\`\``
: `\`\`\`typescript\n${content}\n\`\`\``,
syntaxStyle: app.syntax,
treeSitterClient: app.client,
streaming: false,
internalBlockMode: "top-level",
}),
)
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain(content)
expect(keyword(app)).toEqual(RGBA.fromHex("#ff0000"))
} finally {
pending.resolve(result)
}
})
test("cached paint follows current theme, content and language without a sticky seed", async () => {
await using app = await fixture()
const second = "const secondCachedValue: number = 7"
await app.client.highlightOnce(content, "typescript")
await app.client.highlightOnce(second, "typescript")
const pending = Promise.withResolvers<Awaited<ReturnType<TreeSitterClient["highlightOnce"]>>>()
app.client.highlightOnce = () => pending.promise
const alternate = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" }, keyword: { fg: "#0000ff" } })
const code = new CodeRenderable(app.renderer, {
content,
filetype: "typescript",
syntaxStyle: app.syntax,
treeSitterClient: app.client,
fg: "#ffffff",
})
app.renderer.root.add(code)
try {
code.syntaxStyle = alternate
await app.renderOnce()
expect(keyword(app)).toEqual(RGBA.fromHex("#0000ff"))
code.content = "const miss = 0"
await app.renderOnce()
expect(app.captureCharFrame()).toContain("const miss = 0")
expect(app.captureCharFrame()).not.toContain("cachedValue")
expect(keyword(app)).toEqual(RGBA.fromHex("#ffffff"))
code.content = second
await app.renderOnce()
expect(app.captureCharFrame()).toContain(second)
expect(keyword(app)).toEqual(RGBA.fromHex("#0000ff"))
code.filetype = "text"
await app.renderOnce()
expect(app.captureCharFrame()).toContain(second)
expect(keyword(app)).toEqual(RGBA.fromHex("#ffffff"))
} finally {
pending.resolve({ highlights: [] })
await code.highlightingDone
app.renderer.destroy()
alternate.destroy()
}
})
test("completed highlights use a bounded LRU and disappear when the client is destroyed", async () => {
await using app = await fixture()
const first = await app.client.highlightOnce(content, "typescript")
expect(app.client.getCachedHighlight(content, "typescript")).toEqual(first)
expect(await app.client.highlightOnce(content, "typescript")).toEqual(first)
expect(app.client.getCachedHighlight(content, "javascript")).toBeUndefined()
await Promise.all(
Array.from({ length: 499 }, (_, index) => app.client.highlightOnce(`const value${index} = 1`, "typescript")),
)
expect(app.client.getCachedHighlight(content, "typescript")).toEqual(first)
await app.client.highlightOnce("const extra = 1", "typescript")
expect(app.client.getCachedHighlight("const value0 = 1", "typescript")).toBeUndefined()
expect(app.client.getCachedHighlight(content, "typescript")).toEqual(first)
await app.client.destroy()
expect(app.client.getCachedHighlight(content, "typescript")).toBeUndefined()
})
test("parser changes invalidate completed results and failed highlighting stays retryable", async () => {
await using app = await fixture()
await app.client.highlightOnce(content, "typescript")
app.client.addFiletypeParser({
filetype: "missing-fixture",
wasm: `${app.directory}/missing.wasm`,
queries: { highlights: [] },
})
expect(app.client.getCachedHighlight(content, "typescript")).toBeUndefined()
const failed = await app.client.highlightOnce(content, "missing-fixture")
expect(failed.warning).toBeDefined()
expect(app.client.getCachedHighlight(content, "missing-fixture")).toBeUndefined()
expect(await app.client.highlightOnce(content, "missing-fixture")).toEqual(failed)
await app.client.highlightOnce(content, "typescript")
await app.client.clearCache()
expect(app.client.getCachedHighlight(content, "typescript")).toBeUndefined()
})
test("custom highlight transforms do not mutate cached ranges", async () => {
await using app = await fixture()
const result = await app.client.highlightOnce(content, "typescript")
const expected = structuredClone(result)
result.highlights?.forEach((highlight) => {
highlight[2] = "comment"
})
expect(app.client.getCachedHighlight(content, "typescript")).toEqual(expected)
const code = new CodeRenderable(app.renderer, {
content,
filetype: "typescript",
syntaxStyle: app.syntax,
treeSitterClient: app.client,
onHighlight(highlights) {
highlights.forEach((highlight) => {
highlight[2] = "comment"
})
return highlights
},
})
app.renderer.root.add(code)
await app.renderOnce()
await code.highlightingDone
expect(app.client.getCachedHighlight(content, "typescript")).toEqual(expected)
})
test.each(["callback", "subclass"])("cached paint does not bypass a custom chunk %s", async (kind) => {
await using app = await fixture()
await app.client.highlightOnce(content, "typescript")
const pending = Promise.withResolvers<TextChunk[]>()
class DeferredCode extends CodeRenderable {
protected override transformChunks() {
return pending.promise
}
}
const Render = kind === "subclass" ? DeferredCode : CodeRenderable
const code = new Render(app.renderer, {
content,
filetype: "typescript",
syntaxStyle: app.syntax,
treeSitterClient: app.client,
fg: "#ffffff",
onChunks: kind === "callback" ? () => pending.promise : undefined,
})
app.renderer.root.add(code)
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain(content)
expect(keyword(app)).toEqual(RGBA.fromHex("#ffffff"))
pending.resolve(t`Formatted fixture`.chunks)
await code.highlightingDone
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Formatted fixture")
expect(app.captureCharFrame()).not.toContain(content)
} finally {
pending.resolve(t`Formatted fixture`.chunks)
await code.highlightingDone
}
})
test("an explicit initial styled value takes precedence over cached syntax", async () => {
await using app = await fixture()
const result = await app.client.highlightOnce(content, "typescript")
const pending = Promise.withResolvers<typeof result>()
app.client.highlightOnce = () => pending.promise
const code = new CodeRenderable(app.renderer, {
content,
filetype: "typescript",
syntaxStyle: app.syntax,
treeSitterClient: app.client,
initialStyledText: t`Prepared fixture`,
})
app.renderer.root.add(code)
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("Prepared fixture")
expect(app.captureCharFrame()).not.toContain(content)
} finally {
pending.resolve(result)
await code.highlightingDone
}
})
test("Solid can assign cached content before its syntax style", async () => {
await using app = await fixture()
await app.client.highlightOnce(content, "typescript")
await render(
() => (
<code
{...{
treeSitterClient: app.client,
filetype: "typescript",
content,
syntaxStyle: app.syntax,
}}
/>
),
app.renderer,
)
await app.renderOnce()
expect(app.captureCharFrame()).toContain(content)
expect(keyword(app)).toEqual(RGBA.fromHex("#ff0000"))
})
test.each([false, true])("first layout uses the final conceal setting, cached=%s", async (cached) => {
await using app = await fixture()
if (cached) await app.client.highlightOnce("**abcd**", "markdown")
const pending = Promise.withResolvers<Awaited<ReturnType<TreeSitterClient["highlightOnce"]>>>()
app.client.highlightOnce = () => pending.promise
app.resize(4, 12)
try {
await render(
() => (
<code
{...{
treeSitterClient: app.client,
filetype: "markdown",
syntaxStyle: app.syntax,
content: "**abcd**",
conceal: false,
wrapMode: "char",
width: 4,
}}
/>
),
app.renderer,
)
await app.renderOnce()
expect(app.captureCharFrame()).toContain("**ab\ncd**")
expect(app.renderer.root.getChildren()[0].height).toBe(2)
} finally {
pending.resolve({ highlights: [] })
}
})
function keyword(app: Awaited<ReturnType<typeof fixture>>) {
return app
.captureSpans()
.lines.flatMap((line) => line.spans)
.find((span) => span.text.includes("const"))?.fg
}
async function fixture() {
const directory = await tmpdir()
const client = new TreeSitterClient({ dataPath: directory.path })
const syntax = SyntaxStyle.fromStyles({ default: { fg: "#ffffff" }, keyword: { fg: "#ff0000" } })
const output = await createTestRenderer({ width: 80, height: 12, useThread: false })
return {
...output,
client,
syntax,
directory: directory.path,
async [Symbol.asyncDispose]() {
if (!output.renderer.isDestroyed) output.renderer.destroy()
syntax.destroy()
await client.destroy()
await directory[Symbol.asyncDispose]()
},
}
}
+391
View File
@@ -0,0 +1,391 @@
diff --git a/node_modules/@opentui/core/.bun-tag-1ebf445c0c1c7e45 b/.bun-tag-1ebf445c0c1c7e45
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/node_modules/@opentui/core/.bun-tag-8b2df39a1d17e4c2 b/.bun-tag-8b2df39a1d17e4c2
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/chunk-bun-b0662dgp.js b/chunk-bun-b0662dgp.js
index be58d3ea205d26d3f3a33137a43fa50727e9abe7..cea6b5d507de1115951fc0bb3f53d4ba338b1cc9 100644
--- a/chunk-bun-b0662dgp.js
+++ b/chunk-bun-b0662dgp.js
@@ -8517,6 +8517,7 @@ class TreeSitterClient extends EventEmitter2 {
initializePromise;
initializeResolvers;
messageCallbacks = new Map;
+ highlightCache = new Map;
messageIdCounter = 0;
editQueues = new Map;
debouncer;
@@ -8599,6 +8600,7 @@ class TreeSitterClient extends EventEmitter2 {
if (this.worker !== worker) {
return;
}
+ this.highlightCache = new Map;
worker.onmessage = null;
worker.onerror = null;
this.worker = undefined;
@@ -8655,6 +8657,7 @@ class TreeSitterClient extends EventEmitter2 {
}
}
async handleReset() {
+ this.highlightCache = new Map;
this.buffers.clear();
await this.stopWorker();
this.startWorker();
@@ -8748,6 +8751,7 @@ class TreeSitterClient extends EventEmitter2 {
return path;
}
addFiletypeParser(filetypeParser) {
+ this.highlightCache = new Map;
this.sendWorkerMessage({ type: "ADD_FILETYPE_PARSER", filetypeParser: this.resolveFiletypeParser(filetypeParser) });
}
resolveFiletypeParser(filetypeParser) {
@@ -8773,7 +8777,21 @@ class TreeSitterClient extends EventEmitter2 {
}
});
}
+ getCachedHighlight(content, filetype) {
+ const key = JSON.stringify([filetype, content]);
+ const result = this.highlightCache.get(key);
+ if (result) {
+ this.highlightCache.delete(key);
+ this.highlightCache.set(key, result);
+ }
+ return result === undefined ? undefined : structuredClone(result);
+ }
async highlightOnce(content, filetype) {
+ const cached = this.getCachedHighlight(content, filetype);
+ if (cached)
+ return cached;
+ // Invalidation replaces this map so older requests cannot refill the cache.
+ const cache = this.highlightCache;
if (!this.initialized) {
try {
await this.initialize();
@@ -8783,7 +8801,20 @@ class TreeSitterClient extends EventEmitter2 {
}
const messageId = `oneshot_${this.messageIdCounter++}`;
return new Promise((resolve3, reject) => {
- this.messageCallbacks.set(messageId, { resolve: resolve3, reject });
+ this.messageCallbacks.set(messageId, {
+ resolve: (result) => {
+ if (this.highlightCache === cache && !result.error && !result.warning && result.highlights) {
+ const key = JSON.stringify([filetype, content]);
+ cache.delete(key);
+ cache.set(key, structuredClone(result));
+ if (cache.size > 500) {
+ cache.delete(cache.keys().next().value);
+ }
+ }
+ resolve3(result);
+ },
+ reject
+ });
try {
this.sendWorkerMessage({
type: "ONESHOT_HIGHLIGHT",
@@ -9029,6 +9060,7 @@ class TreeSitterClient extends EventEmitter2 {
if (this.destroyPromise) {
return this.destroyPromise;
}
+ this.highlightCache = new Map;
let resolveDestroy;
let rejectDestroy;
const destroyPromise = new Promise((resolve3, reject) => {
@@ -9094,6 +9126,7 @@ class TreeSitterClient extends EventEmitter2 {
if (this.options.dataPath === dataPath) {
return;
}
+ this.highlightCache = new Map;
this.options.dataPath = dataPath;
if (this.initialized && this.worker) {
const messageId = `update_datapath_${this.messageIdCounter++}`;
@@ -9122,6 +9155,7 @@ class TreeSitterClient extends EventEmitter2 {
}
}
async clearCache() {
+ this.highlightCache = new Map;
if (!this.initialized || !this.worker) {
throw new Error("Cannot clear cache: client is not initialized");
}
diff --git a/chunk-bun-jxfx3h5k.js b/chunk-bun-jxfx3h5k.js
index 5150999349c76c2f632c789e77f4800282691270..edb4f2c951e483c67de802b0512b7bd2676d4770 100644
--- a/chunk-bun-jxfx3h5k.js
+++ b/chunk-bun-jxfx3h5k.js
@@ -3191,13 +3191,16 @@ class CodeRenderable extends TextBufferRenderable {
this._onHighlight = options.onHighlight;
this._onChunks = options.onChunks;
if (this._content.length > 0) {
- if (this._initialStyledText && this._drawUnstyledText) {
- this.textBuffer.setStyledText(this._initialStyledText);
- } else {
- this.textBuffer.setText(this._content);
+ const cached = this.applyCachedHighlights();
+ if (!cached) {
+ if (this._initialStyledText && this._drawUnstyledText) {
+ this.textBuffer.setStyledText(this._initialStyledText);
+ } else {
+ this.textBuffer.setText(this._content);
+ }
}
this.updateTextInfo();
- this._shouldRenderTextBuffer = this._drawUnstyledText || !this._filetype;
+ this._shouldRenderTextBuffer = cached || this._drawUnstyledText || !this._filetype;
}
this._highlightsDirty = this._content.length > 0;
}
@@ -3212,6 +3215,10 @@ class CodeRenderable extends TextBufferRenderable {
if (this._content !== value) {
this._content = value;
this.invalidateHighlights();
+ if (this.applyCachedHighlights()) {
+ this.updateTextInfo();
+ return;
+ }
if (this._streaming && this._filetype && !this._drawUnstyledText) {
this.requestRender();
return;
@@ -3356,9 +3363,32 @@ class CodeRenderable extends TextBufferRenderable {
const modified = await this._onChunks(chunks, context);
return modified ?? chunks;
}
+ applyCachedHighlights() {
+ if (!this._filetype || !this._syntaxStyle || this._initialStyledText || this._onHighlight || this._onChunks || this.transformChunks !== CodeRenderable.prototype.transformChunks)
+ return false;
+ const highlights = this._treeSitterClient.getCachedHighlight(this._content, this._filetype)?.highlights;
+ if (!highlights?.length)
+ return false;
+ const chunks = treeSitterToTextChunks(this._content, highlights, this._syntaxStyle, {
+ enabled: this._conceal,
+ baseHighlight: this._baseHighlight
+ });
+ this.textBuffer.setStyledText(new StyledText(chunks));
+ this.setRenderedLineSources(this.getConcealLinesSourceMap(this._content, highlights));
+ this._shouldRenderTextBuffer = true;
+ return true;
+ }
+ onLifecyclePass = () => {
+ if (this._highlightsDirty && this.applyCachedHighlights())
+ this.updateTextInfo();
+ };
ensureVisibleTextBeforeHighlight() {
if (this.isDestroyed)
return;
+ if (this.applyCachedHighlights()) {
+ this.updateTextInfo();
+ return;
+ }
const content = this._content;
if (!this._filetype) {
this._shouldRenderTextBuffer = true;
diff --git a/chunk-node-dcyj0dm5.js b/chunk-node-dcyj0dm5.js
index 83e49b1de936315dcfcdca5338f56ce7dc3996e6..b1fdcb98dcf3600c4b6640695f1f2ceac004bdf0 100644
--- a/chunk-node-dcyj0dm5.js
+++ b/chunk-node-dcyj0dm5.js
@@ -3190,13 +3190,16 @@ class CodeRenderable extends TextBufferRenderable {
this._onHighlight = options.onHighlight;
this._onChunks = options.onChunks;
if (this._content.length > 0) {
- if (this._initialStyledText && this._drawUnstyledText) {
- this.textBuffer.setStyledText(this._initialStyledText);
- } else {
- this.textBuffer.setText(this._content);
+ const cached = this.applyCachedHighlights();
+ if (!cached) {
+ if (this._initialStyledText && this._drawUnstyledText) {
+ this.textBuffer.setStyledText(this._initialStyledText);
+ } else {
+ this.textBuffer.setText(this._content);
+ }
}
this.updateTextInfo();
- this._shouldRenderTextBuffer = this._drawUnstyledText || !this._filetype;
+ this._shouldRenderTextBuffer = cached || this._drawUnstyledText || !this._filetype;
}
this._highlightsDirty = this._content.length > 0;
}
@@ -3211,6 +3214,10 @@ class CodeRenderable extends TextBufferRenderable {
if (this._content !== value) {
this._content = value;
this.invalidateHighlights();
+ if (this.applyCachedHighlights()) {
+ this.updateTextInfo();
+ return;
+ }
if (this._streaming && this._filetype && !this._drawUnstyledText) {
this.requestRender();
return;
@@ -3355,9 +3362,32 @@ class CodeRenderable extends TextBufferRenderable {
const modified = await this._onChunks(chunks, context);
return modified ?? chunks;
}
+ applyCachedHighlights() {
+ if (!this._filetype || !this._syntaxStyle || this._initialStyledText || this._onHighlight || this._onChunks || this.transformChunks !== CodeRenderable.prototype.transformChunks)
+ return false;
+ const highlights = this._treeSitterClient.getCachedHighlight(this._content, this._filetype)?.highlights;
+ if (!highlights?.length)
+ return false;
+ const chunks = treeSitterToTextChunks(this._content, highlights, this._syntaxStyle, {
+ enabled: this._conceal,
+ baseHighlight: this._baseHighlight
+ });
+ this.textBuffer.setStyledText(new StyledText(chunks));
+ this.setRenderedLineSources(this.getConcealLinesSourceMap(this._content, highlights));
+ this._shouldRenderTextBuffer = true;
+ return true;
+ }
+ onLifecyclePass = () => {
+ if (this._highlightsDirty && this.applyCachedHighlights())
+ this.updateTextInfo();
+ };
ensureVisibleTextBeforeHighlight() {
if (this.isDestroyed)
return;
+ if (this.applyCachedHighlights()) {
+ this.updateTextInfo();
+ return;
+ }
const content = this._content;
if (!this._filetype) {
this._shouldRenderTextBuffer = true;
diff --git a/chunk-node-zcz10bhr.js b/chunk-node-zcz10bhr.js
index ff0f5b6685d8f2fd40f66feaf7984be5cd8413a3..b8227bc3b15383fdae280a710f3548412fa51cac 100644
--- a/chunk-node-zcz10bhr.js
+++ b/chunk-node-zcz10bhr.js
@@ -8496,6 +8496,7 @@ class TreeSitterClient extends EventEmitter2 {
initializePromise;
initializeResolvers;
messageCallbacks = new Map;
+ highlightCache = new Map;
messageIdCounter = 0;
editQueues = new Map;
debouncer;
@@ -8578,6 +8579,7 @@ class TreeSitterClient extends EventEmitter2 {
if (this.worker !== worker) {
return;
}
+ this.highlightCache = new Map;
worker.onmessage = null;
worker.onerror = null;
this.worker = undefined;
@@ -8634,6 +8636,7 @@ class TreeSitterClient extends EventEmitter2 {
}
}
async handleReset() {
+ this.highlightCache = new Map;
this.buffers.clear();
await this.stopWorker();
this.startWorker();
@@ -8727,6 +8730,7 @@ class TreeSitterClient extends EventEmitter2 {
return path;
}
addFiletypeParser(filetypeParser) {
+ this.highlightCache = new Map;
this.sendWorkerMessage({ type: "ADD_FILETYPE_PARSER", filetypeParser: this.resolveFiletypeParser(filetypeParser) });
}
resolveFiletypeParser(filetypeParser) {
@@ -8752,7 +8756,21 @@ class TreeSitterClient extends EventEmitter2 {
}
});
}
+ getCachedHighlight(content, filetype) {
+ const key = JSON.stringify([filetype, content]);
+ const result = this.highlightCache.get(key);
+ if (result) {
+ this.highlightCache.delete(key);
+ this.highlightCache.set(key, result);
+ }
+ return result === undefined ? undefined : structuredClone(result);
+ }
async highlightOnce(content, filetype) {
+ const cached = this.getCachedHighlight(content, filetype);
+ if (cached)
+ return cached;
+ // Invalidation replaces this map so older requests cannot refill the cache.
+ const cache = this.highlightCache;
if (!this.initialized) {
try {
await this.initialize();
@@ -8762,7 +8780,20 @@ class TreeSitterClient extends EventEmitter2 {
}
const messageId = `oneshot_${this.messageIdCounter++}`;
return new Promise((resolve3, reject) => {
- this.messageCallbacks.set(messageId, { resolve: resolve3, reject });
+ this.messageCallbacks.set(messageId, {
+ resolve: (result) => {
+ if (this.highlightCache === cache && !result.error && !result.warning && result.highlights) {
+ const key = JSON.stringify([filetype, content]);
+ cache.delete(key);
+ cache.set(key, structuredClone(result));
+ if (cache.size > 500) {
+ cache.delete(cache.keys().next().value);
+ }
+ }
+ resolve3(result);
+ },
+ reject
+ });
try {
this.sendWorkerMessage({
type: "ONESHOT_HIGHLIGHT",
@@ -9008,6 +9039,7 @@ class TreeSitterClient extends EventEmitter2 {
if (this.destroyPromise) {
return this.destroyPromise;
}
+ this.highlightCache = new Map;
let resolveDestroy;
let rejectDestroy;
const destroyPromise = new Promise((resolve3, reject) => {
@@ -9073,6 +9105,7 @@ class TreeSitterClient extends EventEmitter2 {
if (this.options.dataPath === dataPath) {
return;
}
+ this.highlightCache = new Map;
this.options.dataPath = dataPath;
if (this.initialized && this.worker) {
const messageId = `update_datapath_${this.messageIdCounter++}`;
@@ -9101,6 +9134,7 @@ class TreeSitterClient extends EventEmitter2 {
}
}
async clearCache() {
+ this.highlightCache = new Map;
if (!this.initialized || !this.worker) {
throw new Error("Cannot clear cache: client is not initialized");
}
diff --git a/lib/tree-sitter/client.d.ts b/lib/tree-sitter/client.d.ts
index cb8c63ba847e48b3b9fd053e48daa47e228f0699..942daf13d770d26da4692f10574bc8f698a84ad1 100644
--- a/lib/tree-sitter/client.d.ts
+++ b/lib/tree-sitter/client.d.ts
@@ -14,6 +14,7 @@ export declare class TreeSitterClient extends EventEmitter<TreeSitterClientEvent
private initializePromise;
private initializeResolvers;
private messageCallbacks;
+ private highlightCache;
private messageIdCounter;
private editQueues;
private debouncer;
@@ -43,6 +44,11 @@ export declare class TreeSitterClient extends EventEmitter<TreeSitterClientEvent
addFiletypeParser(filetypeParser: FiletypeParserOptions): void;
private resolveFiletypeParser;
getPerformance(): Promise<PerformanceStats>;
+ getCachedHighlight(content: string, filetype: string): {
+ highlights?: SimpleHighlight[];
+ warning?: string;
+ error?: string;
+ } | undefined;
highlightOnce(content: string, filetype: string): Promise<{
highlights?: SimpleHighlight[];
warning?: string;
diff --git a/renderables/Code.d.ts b/renderables/Code.d.ts
index 9f660f2502da274748ece24a42e613203fb7b500..5d54249be0693a60822ba89f2eb8c93ceced2207 100644
--- a/renderables/Code.d.ts
+++ b/renderables/Code.d.ts
@@ -90,6 +90,8 @@ export declare class CodeRenderable extends TextBufferRenderable {
get isHighlighting(): boolean;
get highlightingDone(): Promise<void>;
protected transformChunks(chunks: TextChunk[], context: ChunkRenderContext): Promise<TextChunk[]>;
+ private applyCachedHighlights;
+ onLifecyclePass: () => void;
private ensureVisibleTextBeforeHighlight;
private startHighlight;
private runHighlights;