Press ⌘⏎ in Polycode. Your prompt fans out to every enabled model, the fan-out pills settle one by one, and the primary peer starts writing the synthesis. Watch the reply bubble while it streams. A ## heading takes its size before its line even ends. A fenced code block gets its card while the model is still typing inside it. Tables draw their grid row by row.
Nothing you have already read moves. The cursor sits on the last character that arrived, not on a guess.
That is the visible part. Until Polycode 1.1.0 shipped on July 29, streaming text was plain characters with a blinking cursor. The styling landed all at once when the answer finished. The change came from a library I wrote for this one problem, markdowndelta-swift. This post is about how it works, and about what it cost to get right.
Markdown was designed to be parsed after you have all of it
CommonMark is a whole-document grammar. Two asterisks open bold only if a matching pair shows up later. A line of text is a paragraph until the next line turns out to be ====, which makes it a heading after the fact. A line with pipes in it is a paragraph until the next line is a delimiter row, which makes both lines a table. And [text][ref] is literal text until a [ref]: url definition arrives, maybe pages later.
Every one of those rules assumes the parser can read ahead. A streaming model does not let you read ahead. At 50 to 100 tokens a second, a chunk lands every 5 to 10 milliseconds. It ends wherever the tokenizer cut it, often in the middle of a word.
The common fix is to re-parse the whole string on every chunk and re-render from scratch. This is correct and simple. It also fails in two visible ways. The first is flicker. Some flicker is inherent to markdown: **bold has to render as two asterisks and a plain word until the closer arrives, and then it snaps. A re-parse renderer goes much further. It rebuilds the entire document on every chunk, so anything on screen can restyle or reflow whenever the parse changes, including text you read ten seconds ago. It has no concept of settled text.
The second failure is cost. Each chunk costs the length of the document so far, so a stream of N chunks costs O(N²). On an 8 KB reply that is tolerable. On a 21 KB reply it is not. I will come back to that number, because the library ended up with a quadratic of its own.
Polycode took the common fix first. On April 19, one day after the first commit, I wired in swift-markdown-ui. The design note recorded the decision: re-render the whole markdown on every token, naive and correct, and our streams cap around 8 KB anyway. The next day the streaming bubble fell back to plain text with a cursor, and markdown rendered only after the answer finalized. That is the state most LLM chat apps live in, on every platform. The most popular Swift markdown library had a discussion thread asking for stable streaming that had been open for two years with no answer.
So on April 24 I surveyed what existed, in Swift and elsewhere. The result: nothing, in any ecosystem, that would promise a character will not change once it has been drawn. The question that produced the library is this one.
How do you render a character before you know what it means, and then promise it will never move?
The commit lattice
The answer is to stop treating “parsed” as a boolean. Every character in the document carries one of five states:
// Doc comments abridged.
public enum MarkdownCommitState: String, Sendable, Hashable, CaseIterable {
/// The parser has not yet decided what the character means.
case unknown
/// The tail of the stream, actively being appended to.
case live
/// Styled, but the per-token machine has not yet decided whether
/// the style commits or reverts.
case provisional
/// Blocked from commitment but may recompute its fingerprint as
/// barriers progress toward clearance.
case stable
/// Frozen. Metric-affecting attributes may no longer change.
case committed
}
The states form a line: unknown → live → provisional → stable → committed. A character may skip forward, straight from live to committed, but it may never move back. That single rule is the library’s contract, and the documentation states it in three words: commitment is terminal.
“Terminal” has a precise meaning. Once a character commits, its metric-affecting attributes freeze: font, size, weight, italic, baseline, paragraph style, and the bounds of any inline attachment. Anything that could change the width or height of a laid-out glyph is off limits. Attributes that do not move glyphs may still change. Color, underline, strikethrough, and the link attribute can all update on a committed character, because none of them cause a reflow.
One choke point in the inline coordinator enforces the contract:
mutating func setState(at position: Int,
to newState: MarkdownCommitState,
cause: CommitLatticeEvent.Cause) {
let prior = stateMap[position] ?? .live
guard prior != newState else { return }
guard prior != .committed else { return }
transitionState(at: position, from: prior, to: newState, cause: cause)
}
If a later state machine tries to move a committed character, the coordinator drops the request. When the escape machine commits \* as a literal asterisk, the emphasis machine running after it cannot override that. Precedence disputes resolve in favor of whoever committed first.
Walk **bold** through the lattice. The two asterisks arrive and go provisional, tagged as a possible strong opener. Inside that open construct, the letters arrive live. The library draws them exactly as the model wrote them, asterisks included, because it refuses to show a style it cannot yet prove. Everything before the opener has already committed and will not move.
When the closing pair arrives, the construct resolves in one step: the parser consumes the delimiters, the letters take bold, and the whole span commits. If the closer never comes, the span reverts to literal text at paragraph close and commits as what the model wrote. Either way the span changes once, forward, and nothing outside it moves. The library never promised those letters would stay plain, so it broke no promise. A re-parse renderer promises nothing about any character and can move all of them.
One documented exception exists. A reference link or image, [text][ref], may flip once from literal text to a resolved link when its definition arrives later. The flip consumes the bracket syntax, so glyphs inside that one block shift. The parser flags the block explicitly and the renderer’s validator skips it for that apply, so the exception is bounded and visible rather than a silent contract break.
Two barriers and a fixpoint
A character’s own token resolving is not enough to commit it. Two things above and before it can still change how it renders.
The first is an open span that encloses it. Suppose the text so far is *italic and **bold and neither closer has arrived. When the strong closer lands, the letters inside it change weight and width. When the emphasis closer lands, everything back to the first asterisk re-measures. So the ancestor barrier tracks every open span whose kind can change metrics on resolution: emphasis, strong, code span, inline math, inline image. A character at or after an open span’s start cannot commit.
The second is an unresolved construct earlier in the same paragraph whose resolution changes rendered width. A link whose ](url) has not closed yet is the clearest case. If it resolves, the brackets and the URL disappear from the rendered text, and every character after it shifts to a lower index. The suffix-shift invariant tracks the earliest such open construct. Nothing at or after that position may commit.
Both checks are tiny: q
// AncestorBarrier
func isBlocked(characterAt position: Int) -> Bool {
openSpans.contains { $0.startPosition <= position }
}
// SuffixShiftInvariant
func isBlocked(characterAt position: Int) -> Bool {
guard let earliest = earliestOpenPosition else { return false }
return position >= earliest
}
A character held by either barrier sits in stable. It has a style, it may re-measure as the barriers move, and it waits.
As characters arrive and whenever a construct resolves, the coordinator runs a pass over the whole paragraph. The pass commits every candidate both barriers have released:
for position in candidates {
let state = states[position] ?? .live
guard state == .stable || state == .live else { continue }
if !ancestorBarrier.isBlocked(characterAt: position),
!suffixShift.isBlocked(characterAt: position) {
transitions[position] = .committed
}
}
The pass covers the whole paragraph on purpose. An inner construct closing can free characters that an unrelated outer construct was holding, and a construct-local check would miss them. The coordinator maintains the candidate set incrementally, so the pass costs the number of eligible positions rather than the length of the document. That distinction mattered a great deal in August.
Ambiguity between token kinds resolves by a fixed precedence. Code spans win. Then angle autolinks, then inline HTML (recognized but left literal), then inline links. Emphasis, strong, strikethrough, and math share the lowest tier. While a code span is open, the lower-precedence machines defer, which is why a stray ** inside backticks never opens bold. At paragraph close, any delimiter run that never found a partner reverts to literal text and commits as plain characters. Unclosed markdown at the end of an answer renders as what the model actually wrote.
Blocks: a smaller lattice, driven by lines
The parser decides inline styling per character and block structure per line. Blocks get their own smaller lattice with three states: unknown, provisional(kind), and committed(kind). The contract is the same. Once a line’s kind commits, it does not change.
Containers commit eagerly. A > prefix is a blockquote the moment it appears, and a - prefix is a list item. No later line can make them something else.
Leaves are where the ambiguity lives. An open paragraph stays provisional because the next line might be === and turn it into a setext heading. A line with pipes stays provisional because the next line might be a delimiter row. The parser holds a pipe-containing line back from the inline coordinator until the following line settles the question. Inline characters cannot be re-segmented into table cells once fed.
Some lines commit on sight: ATX headings, fence openers and their contents, thematic breaks, indented code, HTML block lines. Twelve precedence rules decide the kind when more than one could match.
Blocks emit when they close, not when their first line commits. A fenced code block commits each content line as it arrives, so the card and the monospace font appear while the model is still inside the fence. The block itself emits once, at the closing fence. Paragraphs pay a price here. A paragraph cannot close until a blank line or another block interrupts it, so the parser cannot emit a long paragraph as a block until it ends. Its inline styles still stream character by character. That cost belongs to CommonMark, not to the library, and no streaming parser gets around it.
One chunk, end to end
Here is what happens to one chunk of text from the model. The parser’s feed method is short:
// Comments and one bookkeeping line removed.
public func feed(_ chunk: String) {
truncateActiveTailIfNeeded()
let lines = lineSplitter.consume(Self.replacingInsecureCharacters(in: chunk))
for line in lines {
dispatchLine(line)
}
renderActiveTail()
let fragment = emitFragment()
eventsContinuation.yield(fragment)
}
The line splitter only releases complete lines. Whatever follows the last newline stays in a buffer as the active tail. Each complete line goes through the block coordinator first, which classifies it and drains any blocks that just closed. A boundary router turns those block transitions into paragraph-close events for the inline coordinator. The inline coordinator then observes the line’s characters one at a time and runs the machines described above.
At the end of each line, the parser snapshots its entire working state into a ring of sixteen. That ring is what lets a caller rewind. If a caller replaces the tail of the document, the parser restores the nearest snapshot and replays from there instead of starting over.
The active tail is the part you are watching. Its characters never reach the inline coordinator, because they might be half a token. Instead the parser appends them to the output with their state forced to live. A throwaway clone of the coordinator styles any construct that closes inside the tail. A **bold** that arrives whole in one chunk therefore takes its style before it is ever shown plain, while an unclosed **bo renders exactly as written. The next feed truncates the tail and reclassifies it once the line completes.
Two details keep the tail honest. The parser withholds a final grapheme that could still grow, such as an emoji waiting for its joiner or skin tone, so it never flashes as two glyphs. And it suppresses speculation while a table is open, so streaming table rows never flash raw pipes.
Every feed yields one DocumentFragment: an NSAttributedString, a map from character index to commit state, and the block runs, including blocks that are still open. The fragment stream buffers only the newest entry. A renderer that falls behind sees the latest state, not a backlog.
One concurrency note. The parser started life as a Swift actor, but every caller was already on the main actor, and the hop across the boundary introduced sizing races in LazyVStack. It is now a @MainActor final class, and the races left with the hop.
Rendering on TextKit 2
A parser that promises characters will not move needs a layout engine that keeps the promise. SwiftUI Text is out. It ignores PresentationIntent, so block structure would need manual splitting anyway, and AttributedString(markdown:) only works on a whole string.
Before writing the parser I ran a spike against TextKit 2’s NSTextLayoutManager with six streaming-shaped fixtures. Across thousands of comparisons of already-rendered character pairs, the displacement was 0.00 points. Layout cost 0.1 to 1.4 milliseconds per tick. A second paragraph gets its own layout fragment, so appending to it does not touch the first. The verdict was to commit to TextKit 2 on both platforms: one engine, two front doors, NSViewRepresentable on macOS and UIViewRepresentable on iOS.
The renderer does not take the parser’s word for anything. On every fragment it computes a metrics fingerprint per attribute run. The fingerprint covers font family, size, weight, symbolic traits, kerning, baseline offset, paragraph style, and whether an attachment is present. A character that had committed in the previous fragment and whose fingerprint changed is a lattice violation. In tests, a violation is a preconditionFailure. In a shipping app, the renderer logs a fault and repairs the fragment, because a wrong glyph beats a crashed chat. Unknown attribute keys count toward the fingerprint, which biases the check toward false alarms over silent breakage.
The renderer is also where commit events come from. It diffs the commit-state map between two fragments and emits .committed ranges, plus .uncommitted on a rewind and .sanctionedFlip for the reference-link case. The controller publishes the live tail range alongside them, which is all Polycode needs to place a cursor.
Two block kinds needed their own machinery. A custom NSTextLayoutFragment subclass lays out GFM tables, one fragment per row, where each cell owns its own NSTextContainer and NSTextLayoutManager bounded to the column width. The renderer measures column widths from the header and the first body row, then freezes them. A wide cell arriving in row forty does not re-flow the thirty-nine rows above it. Code blocks lost their background attribute. TextKit 2 draws attribute backgrounds hugging the glyph runs, which turns a fenced block into a stack of inline-code chips with blank lines left untinted. The card fill moved to block-level geometry instead: a layer under the text on UIKit, and the view’s drawBackground on AppKit, where any sublayer occludes the text.
One tradeoff sits in plain view. Each apply currently replaces the whole text storage with setAttributedString rather than appending the new tail. An append path exists and would make each apply proportional to the tail. But block content providers and injected code-block headers change storage coordinates mid-cycle, and that breaks the offset math. For a typical chat message the wholesale replace takes well under a millisecond, so the append path stays parked until the regression suite says otherwise. The renderer captures the view’s text selection before the replace and restores it after, so a selection you started in the committed prefix survives the next chunk.
How Polycode wires it
Model output reaches the renderer through a chain that never lets the domain layer see a rendering type. Each provider returns an AsyncThrowingStream of chunks. The consensus engine merges them and emits the primary’s synthesis as text deltas. The chat view model coalesces those deltas on a 33 millisecond flush, targeting 30 to 60 updates a second. It forwards each flush into a plain AsyncStream<String> that it owns. The view model imports nothing from markdowndelta-swift. It hands out a stream of strings, and the view decides what to do with them.
On the view side, a small controller wraps the library’s controller and consumes that stream:
// Character-count bookkeeping removed.
public func startConsuming(stream: AsyncStream<String>,
accumulatedText: @escaping @MainActor () -> String) {
feedTask?.cancel()
feedTask = Task { @MainActor [weak self] in
guard let self else { return }
let currentAccumulated = accumulatedText()
if !currentAccumulated.isEmpty {
await self.inner.feedStaticText(currentAccumulated)
}
for await chunk in stream {
guard !Task.isCancelled else { break }
await self.inner.feed(chunk)
}
}
}
The accumulated-text step matters when a bubble mounts mid-stream, for example when you scroll back to a message the model is still writing. The controller feeds what has already arrived as one static document, then continues from the live stream. A controller renders exactly one stream in its lifetime, so the streaming host takes its identity from the exchange ID and the stream epoch. A new message gets a new controller and a new document. When the message finalizes, the bubble swaps to a static MarkdownView over the finished text and releases the streaming document.
The cursor is the part I like most. Because the library reports the rectangle of any character offset in the view’s own coordinate space, the blinking cursor is a two-point-wide view offset to the end of the live tail:
public func cursorRect() -> CGRect? {
guard let tail = inner.streamingState?.liveTailRange else { return nil }
return inner.insertionPointRect(forFragmentOffset: tail.location + tail.length)
}
No GeometryReader, no text measurement, no guessing where the last glyph landed after a wrap.
Integration taught two lessons the hard way. The first was style identity. Version one built the MarkdownStyle value inline in body, so every SwiftUI re-evaluation minted a fresh style identity. The library treats a new identity as a request to restyle, so every visible bubble restyled on every render, and scrolling cost about a full core. The fix was to build the style once per citation scheme and cache it. Library version 0.2.0 then decoupled the code-block card overlays from restyles, so a stable identity no longer broke the cards.
The second lesson was on my side of the fence. On the integration branch, the coalescing flusher wrote the view model string but never fed the markdown controller’s stream, so progressive rendering silently did nothing. The bug report blamed the library. The library was innocent.
Citations come after the stream ends. While the synthesis is still arriving, a cited peer tag stays exactly as the model wrote it, because a half-arrived tag turned into a chip is its own kind of flicker. When the exchange finalizes, the bubble rewrites each bracketed tag into a link on the polycode://trace/ scheme, and a per-URL style resolver turns those links into chips: tinted background, no underline, slightly smaller type. Clicking one opens the inspector on that peer’s trace.
What it cost, and what it bought
The library is 57,000 lines of Swift across three targets: a Foundation-only core, a TextKit 2 renderer, and a SwiftUI surface. The conformance suite holds more than 3,500 tests, and the CommonMark 0.31.2 block sweep passes 652 of 652 examples against the documented dialect. A separate rendering-fidelity suite runs 27 fixtures through five tiers, from byte-exact storage down to pixel diffs and a differential against cmark-gfm. It exists because six visual bugs shipped past a data-layer suite that could not see them. A release-mode performance suite gates pull requests against recorded baselines. The documented contract is a 16 millisecond p95 per fragment at 50 to 100 tokens per second.
Then the beach ball. On August 1 a report came in from the running app: switching between chats froze Polycode for one and a half to three seconds. A profile put 2.4 seconds of one switch inside the parser, mounting each finished message in the chat as a static document. The freeze itself was a Debug-build symptom, and the same switch took about 0.3 seconds in Release, but the curve underneath was real in both. The one-shot parse was quadratic in message length. Doubling the document quadrupled the time. A 21 KB answer cost roughly 20 seconds in the Debug profile, and a 40 KB answer still cost 1.9 seconds in Release.
Version 0.3.0 removed two quadratic factors from inline parsing, one of them the eligibility pass described above. At 64 KB, prose now parses 17 times faster and emphasis-dense text 62 times faster. The benchmark that mounts that whole chat, dominated by the 21 KB message, went from 20 seconds to 0.64, a 31× improvement with byte-identical output. Polycode 1.2.0 picked it up four days later. The parser is still not linear: the per-line snapshot copies state proportional to the document so far, and a follow-up tracks that.
The dialect is deliberately narrow: CommonMark 0.31.2, the GFM tables, strikethrough, autolinks, and task lists from the 2019 spec, and KaTeX-style inline and display math. It keeps inline HTML as literal text rather than interpreting it, and it does not decode entity references. Footnotes and definition lists are out. The docs record each cut, and the conformance oracle tests the dialect as written rather than vanilla CommonMark.
Where this leaves you
Open Polycode 1.3 and send a prompt. The synthesis styles itself as it arrives, the cursor tracks the last real character, and nothing you have read moves under your eyes. That behavior is a contract with a test suite behind it, not a visual trick.
The library ships under AGPL-3.0, with a commercial license for apps that cannot ship under copyleft. Adopting it in SwiftUI is three lines:
import MarkdownDeltaSwiftUI
MarkdownView(stream: chunks, id: messageID)
.id(messageID)
Get Polycode for my Mac to watch it in a real chat. The source is not public yet. I am cleaning up the markdowndelta-swift repository, and it will be published under AGPL-3.0 when that is done.