diff --git a/CHANGELOG.md b/CHANGELOG.md
index d6f4662..a484183 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,11 @@
workspace subtree) and never enumerate blocked system roots. (`api/routes.py`,
`api/workspace.py`, `static/panels.js`, `static/style.css`) (partial for #616)
+## [v0.50.180] — 2026-04-23
+
+### Added
+- **Incremental streaming markdown via `streaming-markdown`** — replaces the per-animation-frame full `innerHTML` re-render with an incremental DOM-building parser. During streaming, only new character deltas are fed to the parser per frame (`_smdWrite()`), eliminating DOM thrashing and improving rendering smoothness. Prism.js / KaTeX state no longer gets reset mid-stream. Falls back to the existing `renderMd()` path when the library is unavailable. (`static/messages.js`, `static/index.html`) Co-authored by @bsgdigital.
+
## [v0.50.179] — 2026-04-23
### Fixed
diff --git a/static/index.html b/static/index.html
index 873fcea..fbbd5b0 100644
--- a/static/index.html
+++ b/static/index.html
@@ -21,6 +21,13 @@
+
+
diff --git a/static/messages.js b/static/messages.js
index 276ab0e..4c8c93b 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -189,6 +189,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
let assistantBody=null;
let segmentStart=0; // char offset in assistantText where current segment begins
let _freshSegment=false; // true after a tool call — forces a new DOM segment
+ // streaming-markdown state: incremental DOM-building parser per segment
+ let _smdParser=null; // current smd parser instance (null until first content)
+ let _smdWrittenLen=0; // how many chars of displayText have been fed to smd parser
+ // On reconnect, the assistantBody already has partial smd-rendered content.
+ // We clear it on first new token and restart the parser from the reconnect point.
+ let _smdReconnect=reconnecting;
// Thinking tag patterns for streaming display
const _thinkPairs=[
{open:'',close:''},
@@ -366,6 +372,31 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// removeThinking() won't find it anyway, but guard explicitly.
if(!reasoningText) removeThinking();
}
+ // Helper: create (or recreate) the smd parser bound to a given DOM element.
+ // Called when assistantBody is first created and after each tool-call segment reset.
+ function _smdNewParser(el){
+ _smdWrittenLen=0;
+ if(!window.smd){_smdParser=null;return;}
+ const renderer=window.smd.default_renderer(el);
+ _smdParser=window.smd.parser(renderer);
+ }
+ // Helper: end the current smd parser (flushes remaining state) and null it out.
+ function _smdEndParser(){
+ if(_smdParser&&window.smd){
+ try{window.smd.parser_end(_smdParser);}catch(_){}
+ }
+ _smdParser=null;
+ _smdWrittenLen=0;
+ }
+ // Helper: feed new displayText delta to the smd parser.
+ // Only feeds chars beyond what has already been written (_smdWrittenLen).
+ function _smdWrite(displayText){
+ if(!_smdParser||!window.smd) return;
+ const delta=displayText.slice(_smdWrittenLen);
+ if(!delta) return;
+ try{window.smd.parser_write(_smdParser,delta);}catch(_){}
+ _smdWrittenLen=displayText.length;
+ }
function _scheduleRender(){
if(_renderPending) return;
if(_streamFinalized) return; // Bug A: don't schedule new rAF after stream finalized
@@ -376,12 +407,23 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const parsed=_parseStreamState();
_renderLiveThinking(parsed);
if(assistantBody){
- // Render only the text belonging to the current segment (after the last tool call).
- // segmentStart=0 for the first segment, or assistantText.length-at-last-tool for later ones.
- const segText = segmentStart===0
- ? parsed.displayText // first segment: use full display (handles think-tag stripping)
- : renderMd ? renderMd(assistantText.slice(segmentStart)) : assistantText.slice(segmentStart);
- assistantBody.innerHTML = segText || '';
+ const displayText = segmentStart===0
+ ? parsed.displayText // first segment: uses think-tag stripping
+ : _stripXmlToolCalls(assistantText.slice(segmentStart));
+ if(!_smdParser&&window.smd){
+ // On reconnect: prior content in assistantBody came from a different smd parser run.
+ // Clear it and start fresh — renderMessages() on done will restore the full content.
+ if(_smdReconnect){assistantBody.innerHTML='';_smdReconnect=false;}
+ _smdNewParser(assistantBody);
+ }
+ if(_smdParser){
+ _smdWrite(displayText);
+ } else {
+ // Fallback: smd not loaded yet, reconnect session, or smd unavailable — use renderMd
+ assistantBody.innerHTML = (segmentStart===0
+ ? parsed.displayText
+ : renderMd ? renderMd(assistantText.slice(segmentStart)) : assistantText.slice(segmentStart)) || '';
+ }
}
scrollIfPinned();
});
@@ -461,6 +503,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
assistantBody=null;
segmentStart=assistantText.length; // new segment starts at current text length
_freshSegment=true; // prevent reuse of old DOM node
+ _smdEndParser(); // finalize current smd parser; new one created on next token
scrollIfPinned();
});
@@ -551,6 +594,19 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_streamFinalized=true;
if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
+ // Finalize smd parser — flushes any remaining buffered markdown state
+ // and runs Prism + copy buttons on the live segment before the DOM is replaced
+ if(assistantBody){
+ const _finBody=assistantBody;
+ _smdEndParser();
+ requestAnimationFrame(()=>{
+ if(typeof highlightCode==='function') highlightCode(_finBody);
+ if(typeof addCopyButtons==='function') addCopyButtons(_finBody);
+ if(typeof renderKatexBlocks==='function') renderKatexBlocks();
+ });
+ } else {
+ _smdEndParser();
+ }
const d=JSON.parse(e.data);
delete INFLIGHT[activeSid];
clearInflight();clearInflightState(activeSid);
@@ -617,6 +673,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_terminalStateReached=true;
_streamFinalized=true;
if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
+ _smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
// Application-level error sent explicitly by the server (rate limit, crash, etc.)
// This is distinct from the SSE network 'error' event below.
@@ -694,6 +751,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_terminalStateReached=true;
_streamFinalized=true;
if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
+ _smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
source.close();
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
diff --git a/tests/test_regressions.py b/tests/test_regressions.py
index b503b1a..915591b 100644
--- a/tests/test_regressions.py
+++ b/tests/test_regressions.py
@@ -433,7 +433,7 @@ def test_done_handler_sets_busy_false_before_renderMessages(cleanup_test_session
if done_idx < 0:
done_idx = src.find("es.addEventListener('done'")
assert done_idx >= 0
- done_block = src[done_idx:done_idx+2900]
+ done_block = src[done_idx:done_idx+3300]
# S.busy=false must appear before renderMessages() within the done handler
busy_pos = done_block.find("S.busy=false;")
render_pos = done_block.find("renderMessages()")
diff --git a/tests/test_streaming_markdown.py b/tests/test_streaming_markdown.py
new file mode 100644
index 0000000..0e8aac0
--- /dev/null
+++ b/tests/test_streaming_markdown.py
@@ -0,0 +1,451 @@
+"""Tests for incremental streaming-markdown (smd) integration in messages.js.
+
+PR: feat: use streaming-markdown for incremental live rendering
+
+The change replaces the per-rAF `assistantBody.innerHTML = renderMd(...)` call
+with an incremental DOM-building approach powered by the streaming-markdown
+library (https://github.com/nicholasgasior/streaming-markdown):
+
+ - During streaming: smd.parser_write() feeds new text deltas into a live DOM
+ tree — no full re-render per frame, no innerHTML thrash.
+ - On done/apperror/cancel: smd.parser_end() flushes remaining parser state,
+ then Prism / copy buttons / KaTeX are run on the live segment.
+ - On tool event: smd.parser_end() finalises the current segment; the next
+ token after the tool creates a fresh parser bound to the new assistantBody.
+ - Fallback: when window.smd is not yet loaded, the old renderMd path is used.
+ - Reconnect: _smdReconnect flag clears stale DOM from the previous parser run
+ and restarts the smd parser from the reconnect point.
+
+Tests are static (regex / AST-level) — no browser required.
+"""
+
+import pathlib
+import re
+
+REPO = pathlib.Path(__file__).parent.parent
+MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
+INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+def extract_fn(src, name, *, brace_depth=1):
+ """Return the text of a JS function starting from `function ` to its
+ closing brace. Works for both standalone and closure-local functions.
+ Does a simple brace-counting walk so it handles nested blocks correctly.
+ """
+ pattern = rf"function {re.escape(name)}\s*\("
+ m = re.search(pattern, src)
+ if not m:
+ return None
+ start = m.start()
+ # Find the opening brace
+ brace_pos = src.index("{", m.end())
+ depth = 1
+ pos = brace_pos + 1
+ while pos < len(src) and depth > 0:
+ ch = src[pos]
+ if ch == "{":
+ depth += 1
+ elif ch == "}":
+ depth -= 1
+ pos += 1
+ return src[start:pos]
+
+
+def extract_event_handler(src, event_name):
+ """Return the text of a source.addEventListener('', ...) block."""
+ pattern = rf"source\.addEventListener\('{re.escape(event_name)}'"
+ m = re.search(pattern, src)
+ if not m:
+ return None
+ # Walk forward to collect the matching parenthesis
+ paren_depth = 0
+ start = m.start()
+ pos = m.end()
+ # Count back to the opening paren
+ paren_depth = 1
+ while pos < len(src) and paren_depth > 0:
+ ch = src[pos]
+ if ch == "(":
+ paren_depth += 1
+ elif ch == ")":
+ paren_depth -= 1
+ pos += 1
+ return src[start:pos]
+
+
+def extract_attach_live_stream_prelude(src):
+ """Return the text from attachLiveStream opening to the first nested fn."""
+ m = re.search(r"function attachLiveStream\(", src)
+ if not m:
+ return None
+ # Find the first nested function definition inside the closure
+ inner = re.search(r"\bfunction _isActiveSession\b", src[m.start():])
+ if not inner:
+ return src[m.start(): m.start() + 5000]
+ return src[m.start(): m.start() + inner.start()]
+
+
+# ── 1. index.html: smd script tag ─────────────────────────────────────────────
+
+class TestIndexHtmlSmdScript:
+ """streaming-markdown must be loaded in index.html before messages.js uses it."""
+
+ def test_smd_cdn_url_present(self):
+ assert "streaming-markdown" in INDEX_HTML, (
+ "index.html must include a