From 70f9441b50568d2f3330b81b316f5907f4f797e0 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 9 Jun 2026 12:09:25 +0200 Subject: [PATCH 1/4] perf(ui): render streamed markdown incrementally --- .changeset/faster-streamed-markdown.md | 5 + packages/ui/src/components/markdown-stream.ts | 4 + packages/ui/src/components/markdown.tsx | 45 +++++-- .../kilocode/markdown-incremental-dom.test.ts | 86 +++++++++++++ .../src/kilocode/markdown-incremental-dom.ts | 117 ++++++++++++++++++ .../kilocode/markdown-stable-blocks.test.ts | 54 ++++++++ .../ui/src/kilocode/markdown-stable-blocks.ts | 25 ++++ 7 files changed, 326 insertions(+), 10 deletions(-) create mode 100644 .changeset/faster-streamed-markdown.md create mode 100644 packages/ui/src/kilocode/markdown-incremental-dom.test.ts create mode 100644 packages/ui/src/kilocode/markdown-incremental-dom.ts create mode 100644 packages/ui/src/kilocode/markdown-stable-blocks.test.ts create mode 100644 packages/ui/src/kilocode/markdown-stable-blocks.ts diff --git a/.changeset/faster-streamed-markdown.md b/.changeset/faster-streamed-markdown.md new file mode 100644 index 00000000000..f1146b9ec6b --- /dev/null +++ b/.changeset/faster-streamed-markdown.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Speed up long streamed responses by preserving completed Markdown blocks and updating only the active tail. diff --git a/packages/ui/src/components/markdown-stream.ts b/packages/ui/src/components/markdown-stream.ts index ea35b0c140d..ae034275c70 100644 --- a/packages/ui/src/components/markdown-stream.ts +++ b/packages/ui/src/components/markdown-stream.ts @@ -1,5 +1,6 @@ import { marked, type Tokens } from "marked" import remend from "remend" +import { stableBlocks } from "../kilocode/markdown-stable-blocks" // kilocode_change export type Block = { raw: string @@ -31,6 +32,9 @@ export function stream(text: string, live: boolean) { const src = heal(text) if (refs(text)) return [{ raw: text, src, mode: "live" }] satisfies Block[] const tokens = marked.lexer(text) + const candidate = tokens.findLast((token) => token.type !== "space") // kilocode_change + const blocks = candidate && !open(candidate.raw) ? stableBlocks(tokens, heal) : undefined // kilocode_change + if (blocks) return blocks // kilocode_change const tail = tokens.findLastIndex((token) => token.type !== "space") if (tail < 0) return [{ raw: text, src, mode: "live" }] satisfies Block[] const last = tokens[tail] diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index cee1ef3bee4..813bc924108 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -9,12 +9,15 @@ import { stream } from "./markdown-stream" import { tryFastRender } from "../kilocode/markdown-fast-path" // kilocode_change import { hasMermaid, preserveMermaid, renderMermaid, type MermaidLabels } from "../kilocode/markdown-mermaid" // kilocode_change import { preserveStreamingHighlight } from "../kilocode/markdown-stream-highlight" // kilocode_change +import { createIncrementalMarkdown, type MarkdownBlock } from "../kilocode/markdown-incremental-dom" // kilocode_change type Entry = { hash: string html: string } +type Rendered = { content: string; blocks: MarkdownBlock[] } // kilocode_change + const max = 200 const cache = new Map() @@ -260,34 +263,34 @@ export function Markdown( key: local.cacheKey, streaming: local.streaming ?? false, }), - async (src) => { - if (isServer) return fallback(src.text) - if (!src.text) return "" + async (src): Promise => { // kilocode_change + if (isServer) return { content: fallback(src.text), blocks: [] } // kilocode_change + if (!src.text) return { content: "", blocks: [] } // kilocode_change const base = src.key ?? checksum(src.text) return Promise.all( stream(src.text, src.streaming).map(async (block, index) => { - const hash = checksum(block.raw) + const hash = checksum(block.raw) ?? "" // kilocode_change const key = base ? `${base}:${index}:${block.mode}` : hash if (key && hash) { const cached = cache.get(key) if (cached && cached.hash === hash) { touch(key, cached) - return cached.html + return { key: `${base}:${index}`, hash, html: cached.html, mode: block.mode } // kilocode_change } } const next = await Promise.resolve(marked.parse(block.src)) const safe = sanitize(next) if (key && hash) touch(key, { hash, html: safe }) - return safe + return { key: `${base}:${index}`, hash, html: safe, mode: block.mode } // kilocode_change }), ) - .then((list) => list.join("")) - .catch(() => fallback(src.text)) + .then((blocks) => ({ content: blocks.map((block) => block.html).join(""), blocks })) // kilocode_change + .catch(() => ({ content: fallback(src.text), blocks: [] })) // kilocode_change }, - { initialValue: fallback(local.text) }, + { initialValue: { content: fallback(local.text), blocks: [] } }, // kilocode_change ) let copyCleanup: (() => void) | undefined @@ -313,10 +316,27 @@ export function Markdown( let pendingContent: string | undefined let pendingLabels: { copy: string; copied: string } | undefined // kilocode_change end + // kilocode_change start + const incremental = createIncrementalMarkdown(decorate, { + cancel: () => { + if (pendingFrame === undefined) return + cancelAnimationFrame(pendingFrame) + pendingFrame = undefined + pendingContent = undefined + pendingLabels = undefined + }, + ready: (container, labels, mermaid) => { + copyCleanup ??= setupCodeCopy(container, () => labels) + kickMermaid(container, true, mermaid) + kickHighlight(container, labels) + }, + }) + // kilocode_change end createEffect(() => { const container = root() - const content = local.text ? (html.latest ?? html() ?? "") : "" + const rendered = html.latest ?? html() ?? { content: "", blocks: [] } // kilocode_change + const content = local.text ? rendered.content : "" // kilocode_change if (!container) return if (isServer) return @@ -330,6 +350,7 @@ export function Markdown( pendingLabels = undefined } // kilocode_change end + incremental.reset() // kilocode_change container.innerHTML = "" // kilocode_change start: Mermaid diagram rendering mermaidState.signal.aborted = true @@ -371,6 +392,7 @@ export function Markdown( pendingContent = undefined pendingLabels = undefined } + incremental.reset() // kilocode_change copyCleanup = fast.copyCleanup kickMermaid(container, local.streaming ?? false, mermaid) kickHighlight(container, labels) @@ -378,6 +400,9 @@ export function Markdown( } // kilocode_change end + if (incremental.render(local.streaming ?? false, container, rendered.blocks, labels, mermaid)) return // kilocode_change + incremental.reset() // kilocode_change + // kilocode_change start: queue the latest content for a single rAF tick. // Further updates before the frame runs simply overwrite pendingContent, // so K rapid updates collapse to 1 parse instead of K. diff --git a/packages/ui/src/kilocode/markdown-incremental-dom.test.ts b/packages/ui/src/kilocode/markdown-incremental-dom.test.ts new file mode 100644 index 00000000000..20e7b921829 --- /dev/null +++ b/packages/ui/src/kilocode/markdown-incremental-dom.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { Window } from "happy-dom" +import { createIncrementalMarkdown, type MarkdownBlock } from "./markdown-incremental-dom" + +const labels = { copy: "Copy", copied: "Copied" } + +function block(key: string, hash: string, html: string, mode: "full" | "live" = "full"): MarkdownBlock { + return { key, hash, html, mode } +} + +describe("incremental markdown DOM", () => { + let window: Window + + beforeEach(() => { + window = new Window() + Object.defineProperty(globalThis, "document", { configurable: true, value: window.document }) + }) + + afterEach(() => { + window.close() + Reflect.deleteProperty(globalThis, "document") + }) + + test("keeps completed block nodes while replacing only the live tail", () => { + const container = document.createElement("div") + document.body.append(container) + const renderer = createIncrementalMarkdown(() => {}) + const initial = [block("0", "heading", "

Heading

"), block("1", "tail-a", "

Tail A

", "live")] + + expect(renderer.update(container, initial, labels)).toBe(true) + const heading = container.children[0] + const tail = container.children[1] + + expect(renderer.update(container, [initial[0]!, block("1", "tail-b", "

Tail B

", "live")], labels)).toBe( + true, + ) + expect(container.children[0]).toBe(heading) + expect(container.children[1]).not.toBe(tail) + expect(container.textContent).toBe("HeadingTail B") + }) + + test("promotes an unchanged tail and appends the next block without wrappers", () => { + const container = document.createElement("div") + document.body.append(container) + const renderer = createIncrementalMarkdown(() => {}) + const heading = block("0", "heading", "

Heading

") + const paragraph = block("1", "paragraph", "

Stable

", "live") + + renderer.update(container, [heading, paragraph], labels) + const stable = container.children[1] + renderer.update(container, [heading, { ...paragraph, mode: "full" }, block("2", "tail", "
  • Next
", "live")], labels) + + expect(container.children[1]).toBe(stable) + expect(Array.from(container.children).map((child) => child.tagName)).toEqual(["H2", "P", "UL"]) + }) + + test("runs streaming lifecycle hooks only when incremental rendering handles the update", () => { + const container = document.createElement("div") + document.body.append(container) + const calls: string[] = [] + const renderer = createIncrementalMarkdown(() => {}, { + cancel: () => calls.push("cancel"), + ready: (_container, _labels, context) => calls.push(context), + }) + const blocks = [block("0", "stable", "

Stable

"), block("1", "tail", "

Tail

", "live")] + + expect(renderer.render(false, container, blocks, labels, "ready")).toBe(false) + expect(calls).toEqual([]) + expect(renderer.render(true, container, blocks, labels, "ready")).toBe(true) + expect(calls).toEqual(["cancel", "ready"]) + }) + + test("falls back when there is no stable prefix", () => { + const container = document.createElement("div") + const renderer = createIncrementalMarkdown(() => {}) + + expect(renderer.update(container, [block("0", "tail", "

Tail

", "live")], labels)).toBe(false) + expect( + renderer.update( + container, + [block("0", "live-a", "

A

", "live"), block("1", "live-b", "

B

", "live")], + labels, + ), + ).toBe(false) + }) +}) diff --git a/packages/ui/src/kilocode/markdown-incremental-dom.ts b/packages/ui/src/kilocode/markdown-incremental-dom.ts new file mode 100644 index 00000000000..4068de0577f --- /dev/null +++ b/packages/ui/src/kilocode/markdown-incremental-dom.ts @@ -0,0 +1,117 @@ +type Labels = { + copy: string + copied: string +} + +export type MarkdownBlock = { + key: string + hash: string + html: string + mode: "full" | "live" +} + +type Record = { + key: string + hash: string + start: Comment + end: Comment +} + +type Decorate = (root: HTMLDivElement, labels: Labels) => void + +type Hooks = { + cancel: () => void + ready: (container: HTMLDivElement, labels: Labels, context: Context) => void +} + +export function createIncrementalMarkdown(decorate: Decorate, hooks?: Hooks) { + let records: Record[] = [] + + const reset = () => { + records = [] + } + + const parse = (html: string, labels: Labels) => { + const root = document.createElement("div") + root.innerHTML = html + decorate(root, labels) + const fragment = document.createDocumentFragment() + while (root.firstChild) fragment.appendChild(root.firstChild) + return fragment + } + + const remove = (record: Record) => { + let node: ChildNode | null = record.start + while (node) { + const next: ChildNode | null = node.nextSibling + node.parentNode?.removeChild(node) + if (node === record.end) return + node = next + } + } + + const replace = (record: Record, block: MarkdownBlock, labels: Labels) => { + let node = record.start.nextSibling + while (node && node !== record.end) { + const next: ChildNode | null = node.nextSibling + node.parentNode?.removeChild(node) + node = next + } + record.end.parentNode?.insertBefore(parse(block.html, labels), record.end) + record.hash = block.hash + } + + const append = (container: HTMLDivElement, block: MarkdownBlock, labels: Labels) => { + // Comment boundaries preserve the exact Markdown child structure, so existing + // direct-child CSS and copy/highlight behavior do not need wrapper exceptions. + const start = document.createComment(`markdown:${block.key}:start`) + const end = document.createComment(`markdown:${block.key}:end`) + container.appendChild(start) + container.appendChild(parse(block.html, labels)) + container.appendChild(end) + records.push({ key: block.key, hash: block.hash, start, end }) + } + + const update = (container: HTMLDivElement, blocks: MarkdownBlock[], labels: Labels) => { + if (blocks.length < 2) return false + if (blocks.slice(0, -1).some((block) => block.mode !== "full")) return false + + if (records.some((record) => !record.start.isConnected || !record.end.isConnected)) reset() + const shared = Math.min(records.length, blocks.length) + if (records.slice(0, shared).some((record, index) => record.key !== blocks[index]?.key)) reset() + + if (records.length === 0) container.replaceChildren() + + while (records.length > blocks.length) { + const record = records.pop() + if (record) remove(record) + } + + for (let index = 0; index < blocks.length; index++) { + const block = blocks[index]! + const record = records[index] + if (!record) { + append(container, block, labels) + continue + } + if (record.hash === block.hash) continue + replace(record, block, labels) + } + return true + } + + const render = ( + streaming: boolean, + container: HTMLDivElement, + blocks: MarkdownBlock[], + labels: Labels, + context: Context, + ) => { + if (!streaming || !update(container, blocks, labels)) return false + hooks?.cancel() + hooks?.ready(container, labels, context) + return true + } + + return { reset, render, update } +} diff --git a/packages/ui/src/kilocode/markdown-stable-blocks.test.ts b/packages/ui/src/kilocode/markdown-stable-blocks.test.ts new file mode 100644 index 00000000000..3dcf7592477 --- /dev/null +++ b/packages/ui/src/kilocode/markdown-stable-blocks.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" +import { marked } from "marked" +import remend from "remend" +import { stream } from "../components/markdown-stream" +import { stableBlocks } from "./markdown-stable-blocks" + +async function render(text: string) { + const html = await Promise.all(stream(text, true).map((block) => Promise.resolve(marked.parse(block.src)))) + return html.join("") +} + +describe("stable markdown blocks", () => { + test("keeps completed top-level tokens stable and only heals the tail", () => { + expect( + stableBlocks( + [ + { type: "heading", raw: "# Title\n\n" }, + { type: "paragraph", raw: "First" }, + { type: "space", raw: "\n\n" }, + { type: "paragraph", raw: "Second **open" }, + ], + (raw) => `${raw}**`, + ), + ).toEqual([ + { raw: "# Title\n\n", src: "# Title\n\n", mode: "full" }, + { raw: "First\n\n", src: "First\n\n", mode: "full" }, + { raw: "Second **open", src: "Second **open**", mode: "live" }, + ]) + }) + + test("leaves a single mutable token on the existing streaming path", () => { + expect(stableBlocks([{ type: "paragraph", raw: "Still streaming" }], (raw) => raw)).toBeUndefined() + }) + + test("matches canonical streaming HTML for mixed completed blocks", async () => { + const text = [ + "# Report", + "", + "A completed paragraph with **emphasis**.", + "", + "- first item", + "- second item", + "", + "```ts", + "export const value = 1", + "```", + "", + "The final paragraph is *still streaming", + ].join("\n") + + expect(await render(text)).toBe(await marked.parse(remend(text, { linkMode: "text-only" }))) + expect(stream(text, true).map((block) => block.mode)).toEqual(["full", "full", "full", "full", "live"]) + }) +}) diff --git a/packages/ui/src/kilocode/markdown-stable-blocks.ts b/packages/ui/src/kilocode/markdown-stable-blocks.ts new file mode 100644 index 00000000000..1878f9787de --- /dev/null +++ b/packages/ui/src/kilocode/markdown-stable-blocks.ts @@ -0,0 +1,25 @@ +import type { Block } from "../components/markdown-stream" + +type Token = { + type: string + raw: string +} + +export function stableBlocks(tokens: Token[], live: (raw: string) => string): Block[] | undefined { + const indexes = tokens.flatMap((token, index) => (token.type === "space" ? [] : [index])) + if (indexes.length < 2) return + + const raw = (start: number, end = tokens.length) => + tokens + .slice(start, end) + .map((token) => token.raw) + .join("") + // Completed top-level tokens keep stable hashes across stream updates. The + // existing parse and sanitize cache can then reuse them while only the tail changes. + const stable = indexes.slice(0, -1).map((start, index) => { + const value = raw(start, indexes[index + 1]) + return { raw: value, src: value, mode: "full" as const } + }) + const tail = raw(indexes.at(-1)!) + return [...stable, { raw: tail, src: live(tail), mode: "live" }] +} From fe1ac4bcc277ecacfb13287b6010b3b4ab39ab00 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 9 Jun 2026 12:19:46 +0200 Subject: [PATCH 2/4] test(ui): declare markdown DOM test dependency --- bun.lock | 11 +++++++- packages/ui/package.json | 1 + .../kilocode/markdown-incremental-dom.test.ts | 19 ++++++++++++++ .../src/kilocode/markdown-incremental-dom.ts | 25 +++++++++++++------ 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/bun.lock b/bun.lock index 75f50f18efc..4e01e944972 100644 --- a/bun.lock +++ b/bun.lock @@ -665,6 +665,7 @@ "@types/katex": "0.16.7", "@types/luxon": "catalog:", "@typescript/native-preview": "catalog:", + "happy-dom": ">=20.8.9", "tailwindcss": "catalog:", "typescript": "catalog:", "vite": "catalog:", @@ -2266,6 +2267,8 @@ "@types/vscode": ["@types/vscode@1.116.0", "", {}, "sha512-sYHp4MO6BqJ2PD7Hjt0hlIS3tMaYsVPJrd0RUjDJ8HtOYnyJIEej0bLSccM8rE77WrC+Xox/kdBwEFDO8MsxNA=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + "@types/which": ["@types/which@3.0.4", "", {}, "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -2554,6 +2557,8 @@ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="], "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], @@ -3164,6 +3169,8 @@ "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + "happy-dom": ["happy-dom@20.10.1", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-awPoqPjx8CgjapJllyDlgzgVHjBExcitKK5ZJkxwhQJyQpHFkyS2bEcqCm7IeW20cQvuCI0cz2Ifq79CJKqtiw=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], @@ -4444,7 +4451,7 @@ "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], - "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -4878,6 +4885,8 @@ "cheerio/undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], + "cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + "clean-stack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "cli-truncate/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], diff --git a/packages/ui/package.json b/packages/ui/package.json index 7d2e8c46f54..14c09989021 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -37,6 +37,7 @@ "@types/katex": "0.16.7", "@types/luxon": "catalog:", "@typescript/native-preview": "catalog:", + "happy-dom": ">=20.8.9", "tailwindcss": "catalog:", "typescript": "catalog:", "vite": "catalog:", diff --git a/packages/ui/src/kilocode/markdown-incremental-dom.test.ts b/packages/ui/src/kilocode/markdown-incremental-dom.test.ts index 20e7b921829..8a591c890f3 100644 --- a/packages/ui/src/kilocode/markdown-incremental-dom.test.ts +++ b/packages/ui/src/kilocode/markdown-incremental-dom.test.ts @@ -54,6 +54,25 @@ describe("incremental markdown DOM", () => { expect(Array.from(container.children).map((child) => child.tagName)).toEqual(["H2", "P", "UL"]) }) + test("rebuilds safely when an end boundary disappears", () => { + const container = document.createElement("div") + document.body.append(container) + const renderer = createIncrementalMarkdown(() => {}) + const blocks = [ + block("0", "stable", "

Stable

"), + block("1", "middle", "

Middle

"), + block("2", "tail", "

Tail

", "live"), + ] + + renderer.update(container, blocks, labels) + const comments = Array.from(container.childNodes).filter((node) => node.nodeType === 8) + comments.at(-1)?.parentNode?.removeChild(comments.at(-1)!) + + expect(renderer.update(container, [blocks[0]!, { ...blocks[1]!, mode: "live" }], labels)).toBe(true) + expect(container.textContent).toBe("StableMiddle") + expect(Array.from(container.children).map((child) => child.tagName)).toEqual(["P", "P"]) + }) + test("runs streaming lifecycle hooks only when incremental rendering handles the update", () => { const container = document.createElement("div") document.body.append(container) diff --git a/packages/ui/src/kilocode/markdown-incremental-dom.ts b/packages/ui/src/kilocode/markdown-incremental-dom.ts index 4068de0577f..bd44cbf2763 100644 --- a/packages/ui/src/kilocode/markdown-incremental-dom.ts +++ b/packages/ui/src/kilocode/markdown-incremental-dom.ts @@ -41,13 +41,20 @@ export function createIncrementalMarkdown(decorate: Decorate, h } const remove = (record: Record) => { + const parent = record.start.parentNode + if (!parent || record.end.parentNode !== parent) return false + + const nodes: ChildNode[] = [] let node: ChildNode | null = record.start - while (node) { - const next: ChildNode | null = node.nextSibling - node.parentNode?.removeChild(node) - if (node === record.end) return - node = next + while (node && node.parentNode === parent) { + nodes.push(node) + if (node === record.end) { + for (const item of nodes) parent.removeChild(item) + return true + } + node = node.nextSibling } + return false } const replace = (record: Record, block: MarkdownBlock, labels: Labels) => { @@ -83,8 +90,12 @@ export function createIncrementalMarkdown(decorate: Decorate, h if (records.length === 0) container.replaceChildren() while (records.length > blocks.length) { - const record = records.pop() - if (record) remove(record) + const record = records.at(-1) + if (!record || !remove(record)) { + reset() + return false + } + records.pop() } for (let index = 0; index < blocks.length; index++) { From 9c490cfea870ce9f8c98e855e885beb4514f791c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 9 Jun 2026 12:32:12 +0200 Subject: [PATCH 3/4] test(ui): run markdown DOM coverage in browser --- bun.lock | 11 +- .../tests/markdown-incremental-dom.spec.ts | 98 ++++++++++++++++ packages/ui/package.json | 1 - .../kilocode/markdown-incremental-dom.test.ts | 105 ------------------ 4 files changed, 99 insertions(+), 116 deletions(-) create mode 100644 packages/kilo-vscode/tests/markdown-incremental-dom.spec.ts delete mode 100644 packages/ui/src/kilocode/markdown-incremental-dom.test.ts diff --git a/bun.lock b/bun.lock index 4e01e944972..75f50f18efc 100644 --- a/bun.lock +++ b/bun.lock @@ -665,7 +665,6 @@ "@types/katex": "0.16.7", "@types/luxon": "catalog:", "@typescript/native-preview": "catalog:", - "happy-dom": ">=20.8.9", "tailwindcss": "catalog:", "typescript": "catalog:", "vite": "catalog:", @@ -2267,8 +2266,6 @@ "@types/vscode": ["@types/vscode@1.116.0", "", {}, "sha512-sYHp4MO6BqJ2PD7Hjt0hlIS3tMaYsVPJrd0RUjDJ8HtOYnyJIEej0bLSccM8rE77WrC+Xox/kdBwEFDO8MsxNA=="], - "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], - "@types/which": ["@types/which@3.0.4", "", {}, "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -2557,8 +2554,6 @@ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], - "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], - "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="], "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], @@ -3169,8 +3164,6 @@ "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], - "happy-dom": ["happy-dom@20.10.1", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-awPoqPjx8CgjapJllyDlgzgVHjBExcitKK5ZJkxwhQJyQpHFkyS2bEcqCm7IeW20cQvuCI0cz2Ifq79CJKqtiw=="], - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], @@ -4451,7 +4444,7 @@ "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], - "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], @@ -4885,8 +4878,6 @@ "cheerio/undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], - "cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], - "clean-stack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "cli-truncate/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], diff --git a/packages/kilo-vscode/tests/markdown-incremental-dom.spec.ts b/packages/kilo-vscode/tests/markdown-incremental-dom.spec.ts new file mode 100644 index 00000000000..eaccbd4b09d --- /dev/null +++ b/packages/kilo-vscode/tests/markdown-incremental-dom.spec.ts @@ -0,0 +1,98 @@ +import { expect, test } from "@playwright/test" +import { build } from "esbuild" +import { fileURLToPath } from "node:url" + +const source = fileURLToPath(new URL("../../ui/src/kilocode/markdown-incremental-dom.ts", import.meta.url)) +const bundle = await build({ + stdin: { + contents: ` + import { createIncrementalMarkdown } from ${JSON.stringify(source)} + globalThis.createIncrementalMarkdown = createIncrementalMarkdown + `, + resolveDir: fileURLToPath(new URL(".", import.meta.url)), + }, + bundle: true, + format: "iife", + platform: "browser", + write: false, +}) +const script = bundle.outputFiles[0]!.text + +test.beforeEach(async ({ page }) => { + await page.goto("about:blank") + await page.addScriptTag({ content: script }) +}) + +test("keeps completed Markdown nodes while replacing only the live tail", async ({ page }) => { + const result = await page.evaluate(() => { + const create = ( + globalThis as typeof globalThis & { + createIncrementalMarkdown: (decorate: () => void) => { + update: ( + container: HTMLDivElement, + blocks: Array<{ key: string; hash: string; html: string; mode: "full" | "live" }>, + labels: { copy: string; copied: string }, + ) => boolean + } + } + ).createIncrementalMarkdown + const labels = { copy: "Copy", copied: "Copied" } + const container = document.createElement("div") + document.body.append(container) + const renderer = create(() => {}) + const heading = { key: "0", hash: "heading", html: "

Heading

", mode: "full" as const } + const tail = { key: "1", hash: "tail-a", html: "

Tail A

", mode: "live" as const } + + renderer.update(container, [heading, tail], labels) + const stable = container.children[0] + const previous = container.children[1] + renderer.update(container, [heading, { ...tail, hash: "tail-b", html: "

Tail B

" }], labels) + + return { + stable: container.children[0] === stable, + replaced: container.children[1] !== previous, + tags: Array.from(container.children).map((child) => child.tagName), + text: container.textContent, + } + }) + + expect(result).toEqual({ stable: true, replaced: true, tags: ["H2", "P"], text: "HeadingTail B" }) +}) + +test("rebuilds safely when a Markdown boundary disappears", async ({ page }) => { + const result = await page.evaluate(() => { + const create = ( + globalThis as typeof globalThis & { + createIncrementalMarkdown: (decorate: () => void) => { + update: ( + container: HTMLDivElement, + blocks: Array<{ key: string; hash: string; html: string; mode: "full" | "live" }>, + labels: { copy: string; copied: string }, + ) => boolean + } + } + ).createIncrementalMarkdown + const labels = { copy: "Copy", copied: "Copied" } + const container = document.createElement("div") + document.body.append(container) + const renderer = create(() => {}) + const blocks = [ + { key: "0", hash: "stable", html: "

Stable

", mode: "full" as const }, + { key: "1", hash: "middle", html: "

Middle

", mode: "full" as const }, + { key: "2", hash: "tail", html: "

Tail

", mode: "live" as const }, + ] + + renderer.update(container, blocks, labels) + const comments = Array.from(container.childNodes).filter((node) => node.nodeType === Node.COMMENT_NODE) + comments.at(-1)?.remove() + const updated = renderer.update(container, [blocks[0]!, { ...blocks[1]!, mode: "live" }], labels) + + return { + updated, + tags: Array.from(container.children).map((child) => child.tagName), + text: container.textContent, + } + }) + + expect(result).toEqual({ updated: true, tags: ["P", "P"], text: "StableMiddle" }) +}) diff --git a/packages/ui/package.json b/packages/ui/package.json index 14c09989021..7d2e8c46f54 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -37,7 +37,6 @@ "@types/katex": "0.16.7", "@types/luxon": "catalog:", "@typescript/native-preview": "catalog:", - "happy-dom": ">=20.8.9", "tailwindcss": "catalog:", "typescript": "catalog:", "vite": "catalog:", diff --git a/packages/ui/src/kilocode/markdown-incremental-dom.test.ts b/packages/ui/src/kilocode/markdown-incremental-dom.test.ts deleted file mode 100644 index 8a591c890f3..00000000000 --- a/packages/ui/src/kilocode/markdown-incremental-dom.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { Window } from "happy-dom" -import { createIncrementalMarkdown, type MarkdownBlock } from "./markdown-incremental-dom" - -const labels = { copy: "Copy", copied: "Copied" } - -function block(key: string, hash: string, html: string, mode: "full" | "live" = "full"): MarkdownBlock { - return { key, hash, html, mode } -} - -describe("incremental markdown DOM", () => { - let window: Window - - beforeEach(() => { - window = new Window() - Object.defineProperty(globalThis, "document", { configurable: true, value: window.document }) - }) - - afterEach(() => { - window.close() - Reflect.deleteProperty(globalThis, "document") - }) - - test("keeps completed block nodes while replacing only the live tail", () => { - const container = document.createElement("div") - document.body.append(container) - const renderer = createIncrementalMarkdown(() => {}) - const initial = [block("0", "heading", "

Heading

"), block("1", "tail-a", "

Tail A

", "live")] - - expect(renderer.update(container, initial, labels)).toBe(true) - const heading = container.children[0] - const tail = container.children[1] - - expect(renderer.update(container, [initial[0]!, block("1", "tail-b", "

Tail B

", "live")], labels)).toBe( - true, - ) - expect(container.children[0]).toBe(heading) - expect(container.children[1]).not.toBe(tail) - expect(container.textContent).toBe("HeadingTail B") - }) - - test("promotes an unchanged tail and appends the next block without wrappers", () => { - const container = document.createElement("div") - document.body.append(container) - const renderer = createIncrementalMarkdown(() => {}) - const heading = block("0", "heading", "

Heading

") - const paragraph = block("1", "paragraph", "

Stable

", "live") - - renderer.update(container, [heading, paragraph], labels) - const stable = container.children[1] - renderer.update(container, [heading, { ...paragraph, mode: "full" }, block("2", "tail", "
  • Next
", "live")], labels) - - expect(container.children[1]).toBe(stable) - expect(Array.from(container.children).map((child) => child.tagName)).toEqual(["H2", "P", "UL"]) - }) - - test("rebuilds safely when an end boundary disappears", () => { - const container = document.createElement("div") - document.body.append(container) - const renderer = createIncrementalMarkdown(() => {}) - const blocks = [ - block("0", "stable", "

Stable

"), - block("1", "middle", "

Middle

"), - block("2", "tail", "

Tail

", "live"), - ] - - renderer.update(container, blocks, labels) - const comments = Array.from(container.childNodes).filter((node) => node.nodeType === 8) - comments.at(-1)?.parentNode?.removeChild(comments.at(-1)!) - - expect(renderer.update(container, [blocks[0]!, { ...blocks[1]!, mode: "live" }], labels)).toBe(true) - expect(container.textContent).toBe("StableMiddle") - expect(Array.from(container.children).map((child) => child.tagName)).toEqual(["P", "P"]) - }) - - test("runs streaming lifecycle hooks only when incremental rendering handles the update", () => { - const container = document.createElement("div") - document.body.append(container) - const calls: string[] = [] - const renderer = createIncrementalMarkdown(() => {}, { - cancel: () => calls.push("cancel"), - ready: (_container, _labels, context) => calls.push(context), - }) - const blocks = [block("0", "stable", "

Stable

"), block("1", "tail", "

Tail

", "live")] - - expect(renderer.render(false, container, blocks, labels, "ready")).toBe(false) - expect(calls).toEqual([]) - expect(renderer.render(true, container, blocks, labels, "ready")).toBe(true) - expect(calls).toEqual(["cancel", "ready"]) - }) - - test("falls back when there is no stable prefix", () => { - const container = document.createElement("div") - const renderer = createIncrementalMarkdown(() => {}) - - expect(renderer.update(container, [block("0", "tail", "

Tail

", "live")], labels)).toBe(false) - expect( - renderer.update( - container, - [block("0", "live-a", "

A

", "live"), block("1", "live-b", "

B

", "live")], - labels, - ), - ).toBe(false) - }) -}) From d363965df9c951d84b1dd89790a6d31f7ed834d4 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 9 Jun 2026 12:46:28 +0200 Subject: [PATCH 4/4] test(ui): restore incremental markdown coverage --- .../tests/markdown-incremental-dom.spec.ts | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/packages/kilo-vscode/tests/markdown-incremental-dom.spec.ts b/packages/kilo-vscode/tests/markdown-incremental-dom.spec.ts index eaccbd4b09d..afc75f77b45 100644 --- a/packages/kilo-vscode/tests/markdown-incremental-dom.spec.ts +++ b/packages/kilo-vscode/tests/markdown-incremental-dom.spec.ts @@ -59,6 +59,116 @@ test("keeps completed Markdown nodes while replacing only the live tail", async expect(result).toEqual({ stable: true, replaced: true, tags: ["H2", "P"], text: "HeadingTail B" }) }) +test("promotes an unchanged tail and appends the next block without wrappers", async ({ page }) => { + const result = await page.evaluate(() => { + const create = ( + globalThis as typeof globalThis & { + createIncrementalMarkdown: (decorate: () => void) => { + update: ( + container: HTMLDivElement, + blocks: Array<{ key: string; hash: string; html: string; mode: "full" | "live" }>, + labels: { copy: string; copied: string }, + ) => boolean + } + } + ).createIncrementalMarkdown + const labels = { copy: "Copy", copied: "Copied" } + const container = document.createElement("div") + document.body.append(container) + const renderer = create(() => {}) + const heading = { key: "0", hash: "heading", html: "

Heading

", mode: "full" as const } + const paragraph = { key: "1", hash: "paragraph", html: "

Stable

", mode: "live" as const } + + renderer.update(container, [heading, paragraph], labels) + const stable = container.children[1] + renderer.update( + container, + [ + heading, + { ...paragraph, mode: "full" }, + { key: "2", hash: "tail", html: "
  • Next
", mode: "live" as const }, + ], + labels, + ) + + return { + stable: container.children[1] === stable, + tags: Array.from(container.children).map((child) => child.tagName), + } + }) + + expect(result).toEqual({ stable: true, tags: ["H2", "P", "UL"] }) +}) + +test("runs streaming hooks only when incremental rendering handles the update", async ({ page }) => { + const result = await page.evaluate(() => { + const create = ( + globalThis as typeof globalThis & { + createIncrementalMarkdown: ( + decorate: () => void, + hooks: { + cancel: () => void + ready: (container: HTMLDivElement, labels: { copy: string; copied: string }, context: string) => void + }, + ) => { + render: ( + streaming: boolean, + container: HTMLDivElement, + blocks: Array<{ key: string; hash: string; html: string; mode: "full" | "live" }>, + labels: { copy: string; copied: string }, + context: string, + ) => boolean + } + } + ).createIncrementalMarkdown + const labels = { copy: "Copy", copied: "Copied" } + const container = document.createElement("div") + document.body.append(container) + const calls: string[] = [] + const renderer = create(() => {}, { + cancel: () => calls.push("cancel"), + ready: (_container, _labels, context) => calls.push(context), + }) + const stable = { key: "0", hash: "stable", html: "

Stable

", mode: "full" as const } + const tail = { key: "1", hash: "tail", html: "

Tail

", mode: "live" as const } + + const completed = renderer.render(false, container, [stable, tail], labels, "ready") + const unsupported = renderer.render(true, container, [tail], labels, "unsupported") + const streaming = renderer.render(true, container, [stable, tail], labels, "ready") + return { calls, completed, streaming, unsupported } + }) + + expect(result).toEqual({ calls: ["cancel", "ready"], completed: false, streaming: true, unsupported: false }) +}) + +test("falls back when there is no stable prefix", async ({ page }) => { + const result = await page.evaluate(() => { + const create = ( + globalThis as typeof globalThis & { + createIncrementalMarkdown: (decorate: () => void) => { + update: ( + container: HTMLDivElement, + blocks: Array<{ key: string; hash: string; html: string; mode: "full" | "live" }>, + labels: { copy: string; copied: string }, + ) => boolean + } + } + ).createIncrementalMarkdown + const labels = { copy: "Copy", copied: "Copied" } + const container = document.createElement("div") + const renderer = create(() => {}) + const first = { key: "0", hash: "first", html: "

First

", mode: "live" as const } + const second = { key: "1", hash: "second", html: "

Second

", mode: "live" as const } + + return { + multiple: renderer.update(container, [first, second], labels), + single: renderer.update(container, [first], labels), + } + }) + + expect(result).toEqual({ multiple: false, single: false }) +}) + test("rebuilds safely when a Markdown boundary disappears", async ({ page }) => { const result = await page.evaluate(() => { const create = (