You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
app: markdown projection re-lexes the whole accumulated message on every streaming delta #56202
While a response streams, project() in packages/session-ui/src/components/markdown-stream.ts re-lexes the entire accumulated message text on every delta unless the open tail is a fenced code block. For ordinary prose, lists, and tables the live tail is rebuilt from a full lex, so a stream of n deltas over a message that reaches length L costs O(n·L) ≈ O(L²). Projection work therefore grows quadratically and the UI stalls on long responses.
This is the V2 counterpart of the worker-side projection cost fixed for V1 in #48432 (which closed #36043 and is tracked by #48430). The renderer-side per-delta costs are separate layers: the store walk (#54427, issue #54305) and the DOM re-parse (#54428).
The projection lives in the shared session-ui package, so web and desktop inherit the same cost as the TUI.
Plugins
none
OpenCode version
dev (V2)
Steps to reproduce
Stream a long assistant response — many short paragraphs, one long boundary-less paragraph, or a growing list/table.
Watch the session-ui markdown worker while it streams.
The worker re-lexes the whole accumulated text on every delta. For a single boundary-less paragraph the baseline lexer character count grows O(n²) (197K → 789K → 3.16M → 12.6M for 250 → 2,000 words).
Thanks for the detailed report. I reproduced this on the latest dev (bc5ebf00a4c1).
I streamed synthetic messages through project(previous, text, true) one appended delta at a time, counting the characters passed to marked's lexer:
One paragraph, 250 → 2000 words: one full lex per delta. Characters lexed grow 215K → 861K → 3.4M → 13.8M, about 4× each time the length doubles. Time goes from 15 ms to about 8.4 s.
Many short paragraphs: 221K → 14.2M characters lexed.
Growing list, 250 → 2000 items: 323K → 22.3M characters lexed, 63 ms → 5.1 s.
Open code fence: stays linear (a single lex in total), because only that tail grows in place.
The 2000-word paragraph took far longer than its lexed-character count would suggest, so another superlinear cost may be on that path, possibly the heal/remend step over the tail. I haven't confirmed this.
The script below reproduces this from packages/session-ui. Your open PR #55857 targets this path. #54305 and #54428 cover the separate store and DOM costs for each delta.
Reproduction script (lex-count.ts)
// Reproduces #56202: counts characters fed to marked's lexer while project() streams a message.// Run from /workspace/repo/packages/session-ui: bun /workspace/repro/lex-count.tsconst{ Lexer }=awaitimport(Bun.resolveSync("marked",process.cwd()+"/src/components"))letchars=0,calls=0constorig=Lexer.prototype.lexLexer.prototype.lex=function(src: string){chars+=src.length;calls++;returnorig.call(this,src)}const{ project }=awaitimport(process.cwd()+"/src/components/markdown-stream.ts")functionrun(label: string,deltas: string[]){chars=0;calls=0letp: any,text=""constt=performance.now()for(constdofdeltas){text+=d;p=project(p,text,true)}console.log(`${label.padEnd(34)} len=${String(text.length).padStart(7)} deltas=${String(deltas.length).padStart(5)} lexCalls=${String(calls).padStart(5)} lexChars=${String(chars).padStart(10)} ms=${(performance.now()-t).toFixed(0)}`)}constword=(i: number)=>`word${i%97} `for(constnof[250,500,1000,2000]){run(`paragraph ${n} words`,Array.from({length: n},(_,i)=>word(i)))}for(constnof[250,500,1000,2000]){run(`short paragraphs ${n} words`,Array.from({length: n},(_,i)=>word(i)+(i%10===9 ? "\n\n" : "")))}for(constnof[250,500,1000,2000]){run(`list ${n} items`,Array.from({length: n},(_,i)=>`- item ${i}\n`))}// control: open code fence tail (has a fast path)for(constnof[250,500,1000,2000]){run(`open code fence ${n} lines`,["```ts\n", ...Array.from({length: n},(_,i)=>`const x${i} = ${i}\n`)])}
Fixed by #57016 (merged Oct 20), which replaced the marked + remend re-lex path with @opencode/patchmark. The projection cost was diagnosed and benchmarked here first; the prior implementation is #55857, and the benchmarks and tests that still apply are in #59391.
For the record, the project's own bot reproduced this on Oct 16 — three days before #57016 — and noted "Your open PR #55857 targets this path."
Activity
opencode-agent commentedon Oct 16, 2026
Thanks for the detailed report. I reproduced this on the latest
dev(bc5ebf00a4c1).I streamed synthetic messages through
project(previous, text, true)one appended delta at a time, counting the characters passed to marked's lexer:The 2000-word paragraph took far longer than its lexed-character count would suggest, so another superlinear cost may be on that path, possibly the
heal/remend step over the tail. I haven't confirmed this.The script below reproduces this from
packages/session-ui. Your open PR #55857 targets this path. #54305 and #54428 cover the separate store and DOM costs for each delta.Reproduction script (lex-count.ts)
Run it:
bun lex-count.tsdcerisano commentedon Oct 26, 2026
Fixed by
#57016(merged Oct 20), which replaced themarked+remendre-lex path with@opencode/patchmark. The projection cost was diagnosed and benchmarked here first; the prior implementation is#55857, and the benchmarks and tests that still apply are in#59391.For the record, the project's own bot reproduced this on Oct 16 — three days before
#57016— and noted "Your open PR #55857 targets this path."Please close this against
#57016.Tarquinen commentedon Nov 2, 2026
Thanks for the report! As you noted, #57016 fixed this, so I'm closing this issue.
— from 𝕺𝖕𝖊𝖓𝕮𝖔𝖉𝖊