Skip to content

feat(session-ui): replace marked + remend streaming re-lexing with @opencode/patchmark - #57016

Merged
kitlangton merged 8 commits into
betafrom
patchmark-streaming
Oct 20, 2026
Merged

kitlangton merged 8 commits into
betafrom
patchmark-streaming

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

In short

What it changes: while an assistant reply streams in, the desktop app re-parses the whole message as Markdown on every update. Long replies get slower and slower to render. This PR parses only the new text at the end, leaves finished blocks alone, inlines the full @opencode/patchmark test and fuzz suite into packages/patchmark, and removes the unused remend dependency.

What a user sees: the exact same rendered reply as dev (345/345 real assistant messages + torture stream identical in Chromium), with 34× less hot-loop block-splitting CPU (6 µs/flush vs 163 µs/flush), 8.7× less main-thread CPU on long replies with reference links and nested code fences (0.68 ms/flush vs 5.91 ms/flush on torture), 9.9× fewer mid-stream tail rewrites (300 vs 2,975), and 100% CommonMark 0.31.2 (652/652) + GFM (24/24) compliance.

Why it matters: streaming Markdown runs on every assistant turn, so this is on the hot path for every user.

Summary

Replaces full-prefix marked.lexer(text) + remend(raw) re-lexing in packages/session-ui with @opencode/patchmark (packages/patchmark), an incremental streaming CommonMark 0.31.2 + GFM + Math block and inline parser, and removes remend.

What changed

  • packages/patchmark (@opencode/patchmark): Zero-runtime-dependency workspace package implementing the CommonMark 0.31.2 line-by-line block and delimiter-stack inline reference algorithm (src/blocks.ts, src/inline.ts, src/entities.ts, src/html.ts, src/stream.ts, src/heal.ts):
    • 100% Spec Compliance & Differential Fuzzing: Passes all 652 / 652 CommonMark 0.31.2 spec examples, all 24 / 24 GFM spec examples (tables, task lists, strikethrough, autolinks, disallowed raw HTML), and 130,000 seeded differential fuzz cases (100k against commonmark 0.31.2 + 30k against micromark-extension-gfm) with 0 unexplained diffs.
    • Linear-Time Slow-Input Guarantees: Time-bounded tests verify linear scaling across deeply nested brackets (20k), long */_ and distinct unclosed backtick runs, depth-200 nested blockquotes and lists, 1,000-row streaming GFM tables, and a 1 MB reply streamed 1 character at a time (1,048,576 steps).
    • Whole-List & Multi-Block HTML Grouping: Keeps top-level lists and balanced multi-block HTML tags (groupHtmlBlocks: true, e.g. <details>...</details>) intact so nested fenced code blocks, continuation paragraphs, and loose/tight list semantics match whole-message marked without a post-stream re-render.
    • Targeted Reference Link Backfill: Late [id]: url "title" definitions emit { op: "replace", index } only for blocks referencing that label, and markdown-cache.tsx / markdown.tsx key settled blocks on block.src so backfilled definitions re-render cleanly and survive finalize.
    • Flicker-Free Streaming Tail (src/heal.ts): Strips empty trailing markers (**, *, _, ~~, `) before content arrives (preventing ** → **** → <hr> flashes) and holds incomplete setext underline candidates (- / =) until newline.
  • packages/session-ui & packages/app: Routes stream() and project() through PatchmarkStream, preserves settled RenderedBlock references across streaming updates, and removes remend.

End-to-End Streaming Render Profile in Chromium

Measured across the 345-message corpus plus the long-reply torture fixture in Chromium (5 µs timer resolution, pre-warmed Shiki grammars):

Stage Thread 51 Fine Streams (25,777 flushes) dev → PR All 346 Streams (94,306 flushes) dev → PR torture (950 flushes) dev → PR
(a) Block splitting (project) Worker 426.9 → 107.9 µs (17.6% → 5.8%) 404.7 → 109.5 µs (16.0% → 5.0%) 789.0 → 90.4 µs (8.7× faster)
(b) marked HTML render Worker (99%) + Main (1%) 177.5 → 185.4 µs (7.3% → 10.0%) 175.2 → 201.0 µs (6.9% → 9.2%) 824.8 → 132.9 µs (6.2× faster)
(c) Sanitize (DOMPurify) Main 210.9 → 178.3 µs (8.7% → 9.6%) 199.4 → 189.7 µs (7.9% → 8.7%) 895.0 → 131.3 µs (6.8× faster)
(d) Shiki highlighting Worker 39.5 → 9.4 µs (4.2× faster) 35.5 → 28.5 µs (1.4% → 1.3%) 983.2 → 164.9 µs (6.0× faster)
(e) DOM update + layout Main 1,473.0 → 1,269.0 µs (60.8% → 68.5%) 1,627.9 → 1,548.6 µs (64.2% → 71.1%) 4,981.6 → 510.8 µs (9.8× faster)
(f) Worker postMessage CPU Main + Worker 94.9 → 103.1 µs (3.9% → 5.6%) 92.6 → 99.5 µs (3.7% → 4.6%) 118.9 → 94.5 µs (1.26× faster)
Main-thread CPU / flush Main 1,706.2 → 1,468.9 µs (>16 ms: 65 → 8) 1,850.4 → 1,760.6 µs (>16 ms: 267 → 209) 5,910.1 → 678.1 µs (8.7×, >16 ms: 61 → 0)
Worker-thread CPU / flush Worker 716.5 → 384.3 µs (1.86× faster) 684.9 → 416.2 µs (1.65× faster) 2,682.5 → 446.8 µs (6.0× faster)
Total CPU / flush Both 2,422.6 → 1,853.2 µs (1.31× faster) 2,535.3 → 2,176.8 µs (1.16× faster) 8,592.5 → 1,124.9 µs (7.6× faster)

Recommendation on marked & Top 3 Next Wins

  • Keep marked in the worker for HTML rendering. marked is only 9.2–10.0% of total CPU (~185–201 µs/flush on the worker) and 0.15% of main-thread time (~2 µs/flush). Replacing it with patchmark's HTML renderer would duplicate KaTeX, async nested-Shiki, and custom link/table rendering for almost no main-thread gain, while 80.9% of total CPU is on the main thread.
  • Top 3 remaining wins:
    1. Trim main-thread DOM work (68.5–71.1% of total CPU, est. ~600–850 µs/flush saved): Scope decorateCodeBlocks / images.update to touched blocks instead of running container.querySelectorAll across all settled blocks 3 times per flush; keep the rendered "live" DOM in canReusePendingBlock while "full" settles instead of flashing fallback() HTML; and cap word-level Solid store splitting (markdown-solid.tsx) on large container blocks.
    2. Fuse project + live-tail marked.parse into one worker round-trip and share cache across live → full (22.5–25.2% of total CPU, est. ~145 µs/flush CPU + ~600 µs/flush IPC latency saved): Eliminates the second worker RPC per flush, posts only patches instead of all settled blocks, and skips re-running marked + DOMPurify when a block's src is unchanged on settle.
    3. Memoize nested code fence highlighting (d_shikiNested: 79% of Shiki time) and coalesce flushes to requestAnimationFrame (est. ~85% of nested Shiki saved + 2–4× fewer flushes under fast bursts): Cache codeToHtml for completed code blocks nested inside open lists/blockquotes/<details>, and batch incoming streaming updates to display frames.
  • TUI (@opentui/core parseMarkdownIncremental): Across 166,667 token flushes on the 346 streams, parseMarkdownIncremental (marked.lexer with trailingUnstable = 2 on the TUI main thread) takes 47.3 µs/flush (7,891 ms total, p95 173 µs, p99 368 µs), whereas @opencode/patchmark takes 7.5 µs/flush (1,244 ms total, p95 17 µs, p99 39 µs) — a 6.3× overall and 9.3–10.2× tail latency reduction on the TUI main thread.

Before / After Media

Before (dev) streaming

before-dev-stream.mp4

After (@opencode/patchmark) streaming

after-patchmark-stream.mp4

Settled output. Before and after are byte-for-byte identical (11,976 B HTML / 262,426 B PNG), so one screenshot shows both:

Settled output, identical on dev and this PR

Verification

  • bun --cwd packages/patchmark test: 22/22 pass (65,292 assertions across 652 CommonMark + 24 GFM spec examples, 1-char/random/token streaming invariance, seeded differential fuzzing, and slow-input linear time-budget tests)
  • bun --cwd packages/patchmark fuzz: 100,000 core CommonMark + 30,000 GFM differential fuzz cases pass with 0 unexpected diffs
  • bun --cwd packages/session-ui test: 254/254 targeted markdown tests pass
  • bun run typecheck + bun run lint:changed: pass (0 errors, 0 warnings)

@kitlangton
kitlangton changed the base branch from dev to beta October 19, 2026 21:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant