Skip to content

feat(chat): render markdown progressively during streaming#16

Merged
DoyleDev merged 5 commits into
mainfrom
feat/progressive-markdown-streaming
Jun 2, 2026
Merged

feat(chat): render markdown progressively during streaming#16
DoyleDev merged 5 commits into
mainfrom
feat/progressive-markdown-streaming

Conversation

@DoyleDev

@DoyleDev DoyleDev commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

The streaming bubble used to show raw markdown (literal `bold`, `## headings`, fenced code with backticks) until the stream finished, then flip to formatted markdown all at once. Jarring, and the bubble looked unfinished mid-stream.

Now the typewriter renders markdown on every tick. `marked` + DOMPurify both tolerate partial input — incomplete tokens stay as text until their closing syntax arrives, then upgrade on the next tick. Same 12ms cadence, same XSS posture (sanitization runs every tick), just the right output shape from character one.

Also drops `whiteSpace = "pre-wrap"` from the streaming bubble's initial setup and the matching `""` resets at the finalize sites — rendered markdown handles its own whitespace.

Bumps to 1.4.3.

Test plan

  • Ask for a response with headings + bold/italic + a code block + a list + a hyperlink. Watch each formatting element "pop in" as its closing syntax arrives during streaming.
  • Long code block (~200 lines of Python) — confirm streaming stays smooth. If stutter shows up, follow-up PR can throttle the render to ~60ms.
  • Tool-call path — preamble renders correctly, "Calling tool: …" still announces, tool result flows.
  • Stop mid-stream — partial markdown stays rendered (no flip back to raw text).
  • Code-block copy button still works on a code block that landed mid-stream.

This pull request and its description were written by Isaac.

DoyleDev added 5 commits June 2, 2026 11:21
The streaming bubble used to show raw markdown text (literal **bold**,
## headings, --- dividers, code-fence backticks) until the stream
finished, then suddenly flipped to formatted markdown. Jarring, and
made responses look unfinished while the model was still typing.

Now the typewriter renders markdown on every tick. marked + DOMPurify
both tolerate partial input — incomplete tokens stay as text until
their closing syntax arrives, then upgrade naturally on the next tick.
Same 12ms cadence, same XSS posture (DOMPurify still runs each tick),
just the right output shape from character one.

Also drops streamingEl.style.whiteSpace = "pre-wrap" from first-chunk
setup and the matching "" resets at the various finalize sites —
rendered markdown handles its own whitespace via <p> / <pre> blocks
and pre-wrap was interfering.

If long code-block-heavy responses ever lag, the cheapest fix is to
throttle the markdown render to ~60ms while leaving the typewriter
cadence alone. Not implementing preemptively.

Co-authored-by: Isaac
Co-authored-by: Isaac
PR #16 (just-shipped) rendered markdown every typewriter tick, which
collapsed almost all visible raw markdown to a 1-5ms flicker as
block-level tokens (## headings, ---, ``` fences, list markers)
briefly painted as literal text before their newline arrived and
marked re-rendered them as proper elements.

Token-aware holdback eliminates the last bit. New safeMarkdownPos()
helper walks the partial stream and returns the largest position
where all open markdown tokens have closed. The typewriter renders
up to that position only.

Rules:
- Unclosed ``` fence wins everything — hold back from start of its
  opening line, so a multi-line code block stays hidden until the
  closing fence lands.
- Otherwise content above the last \n is block-complete; trailing
  in-progress line gets an inline scan.
- Inline scan: stack-based walk of ** * ` [ ![ ; advance lastSafe
  only when the stack is empty.

Net behavior:
- Plain text streams char-by-char as before.
- Inline tokens stream up to their opener, hold one tick, then flip
  into formatted form when the closer arrives. Never see raw `**`.
- Block tokens (#, lists, hr, fences) are held until newline /
  closing fence — they appear in final form, never as literal.

~80 line addition, contained to src/chat.ts. No dependencies, no
config knobs. Tables and autolinks are out of scope and continue
to use marked's default behavior; can extend the scanner later if
specific patterns produce artifacts.

Co-authored-by: Isaac
The indicator showed a static "Building..." label. Now picks from a
configurable list of construction-themed phrases (Thinking,
Mixing mortar, Laying bricks, Drawing up plans, ...) randomly per
showThinking() call — adds personality without state machinery.

Once the first content chunk arrives the response text IS the
progress indicator, so we add a `streaming` class to the thinking
div and a single CSS rule hides the label. The bricks keep
stacking below the streamed text exactly as before.

Adding more phrases: edit THINKING_PHRASES at the top of
src/messages.ts. Keep entries short (~3 words) so the indicator
stays compact.

Co-authored-by: Isaac
@DoyleDev DoyleDev merged commit 2021a0e into main Jun 2, 2026
0 of 3 checks passed
@DoyleDev DoyleDev deleted the feat/progressive-markdown-streaming branch June 2, 2026 17:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant