feat(chat): render markdown progressively during streaming#16
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
This pull request and its description were written by Isaac.