stream-diffs
July 17, 2026 ยท View on GitHub
Lightweight, read-only code and diff rendering for AI output. stream-diffs is powered by @pierre/diffs and Shiki, streams appended code without Monaco models or workers, and switches to interactive file or diff surfaces when needed.
Install
pnpm add stream-diffs
For Vue components:
pnpm add stream-diffs vue
Streaming code
import { createCodeStream } from 'stream-diffs'
const output = createCodeStream({
fileName: 'answer.ts',
language: 'typescript',
theme: { dark: 'github-dark', light: 'github-light' },
maxHeight: 600,
lineNumbers: true,
autoScroll: 'near-bottom',
})
await output.mount(document.querySelector('#code')!)
output.append('export const ')
output.append('answer = 42\n')
await output.finalize({ view: 'file' })
Live highlighting is protected by default at 10,000 lines or 1,000,000 characters. Crossing either threshold aborts the growing highlighted DOM, keeps accumulating text, and continues with append-only plain text. Finalization still produces the accurate interactive File or Diff:
const output = createCodeStream({
limits: {
maxStreamingLines: 20_000,
maxStreamingChars: 2_000_000,
overflowBehavior: 'stop-highlighting',
},
onOverflow: stats => console.warn('Highlighting stopped', stats),
})
These are stream-diffs safety defaults, not upstream Diffs limits. Use limits: false when the host already enforces output bounds.
append() is the primary hot path. Cumulative SDK snapshots are also supported:
output.updateSnapshot('export')
output.updateSnapshot('export const')
output.updateSnapshot('export const answer = 42')
You can consume browser streams and async iterables directly:
await output.consume(response.body!)
await output.finalize({
view: 'diff',
original: previousSource,
diffStyle: 'split',
})
File, unified diff, and split diff
import { createDiffSurface } from 'stream-diffs'
const diff = createDiffSurface({
kind: 'diff',
oldFile: { name: 'src/app.ts', contents: before },
newFile: { name: 'src/app.ts', contents: after },
options: {
diffStyle: 'unified', // one column
// diffStyle: 'split', // two columns
diffIndicators: 'bars',
lineDiffType: 'word-alt',
enableLineSelection: true,
},
})
await diff.mount(document.querySelector('#diff')!)
diff.setSelectedLines({ start: 8, end: 12, side: 'additions' })
Use kind: 'file' for a static code file and kind: 'merge-conflict' for Git conflict markers.
Raw Git patches and pre-parsed metadata are accepted without eagerly loading Pierre from the root entry:
const patchView = createDiffSurface({
kind: 'patch',
patch: unifiedPatch,
fileIndex: 0,
options: { diffStyle: 'unified' },
})
markstream-vue
markstream-vue dynamically prefers stream-diffs when it is installed. Existing CodeBlockNode usage does not change:
pnpm add markstream-vue stream-diffs
<script setup lang="ts">
import { MarkdownRender } from 'markstream-vue'
import 'markstream-vue/index.css'
</script>
<template>
<MarkdownRender :content="markdown" :loading="streaming" />
</template>
Advanced Diffs options pass through code-block-props.monacoOptions. The property keeps its existing name for compatibility:
<MarkdownRender
:content="markdown"
:code-block-props="{
monacoOptions: {
diffStyle: 'split',
enableLineSelection: true,
onLineSelected: range => console.log(range),
onTokenEnter: ({ tokenText, tokenElement }) => {
tokenElement.dataset.hovered = tokenText
},
onController: controller => codeController = controller,
},
}"
/>
While loading, markstream keeps its own <pre> and does not create a Diffs controller. After the block is complete and visible, it creates one static File or FileDiff with stream: false, waits for the first stable visual frame, and atomically replaces the fallback. Complete Git conflict markers use the merge conflict resolution UI. If both optional renderers are installed, stream-diffs wins; remove it to select stream-monaco explicitly.
The first cold mount still initializes the shared Shiki highlighter asynchronously, so markstream keeps its <pre> fallback until the Diffs surface is ready. No Monaco worker or model is created. On the measured Chrome benchmark, the median cold fallback was 72.4 ms for stream-diffs versus 913.0 ms for stream-monaco; 1-, 12-, and 24-thread runs had no measured container-height decrease. See Performance and package size for methodology and reproducible commands.
Framework adapters
The root stream-diffs entry is an imperative DOM runtime and imports no framework. React, Svelte, Angular, Vue, and vanilla applications use the same controllers. The optional stream-diffs/vue entry only provides Vue component wrappers.
For streamed Markdown, the framework adapter owns completion and visibility. Keep the framework's <pre> mounted while the block is incomplete or outside the viewport, then mount one final snapshot when both conditions are true:
if (codeBlockComplete && codeBlockVisible) {
const runtime = useMonaco({ stream: false, disableFileHeader: true })
await runtime.createEditor(stagingElement, finalCode, language)
if (await runtime.whenVisualReady?.())
fallbackElement.replaceWith(stagingElement)
}
Build the highlighted surface in a staging element and replace the fallback only after creation and visual readiness resolve. This keeps React effects, Svelte actions, Angular hooks, and Vue lifecycle code outside the runtime while preserving a single pre-to-highlight transition. Dispose the runtime when the host component unmounts, collapses, or changes code-block identity.
Interactive review APIs
Custom headers
const review = createDiffSurface({
kind: 'diff',
oldFile,
newFile,
options: {
renderCustomHeader(fileDiff) {
const header = document.createElement('div')
header.textContent = `Review ${fileDiff.name}`
return header
},
},
})
Token hover
const file = createDiffSurface({
kind: 'file',
file: { name: 'styles.css', contents: css },
options: {
onTokenEnter({ tokenText, lineNumber, tokenElement }) {
showHover({ tokenText, lineNumber, anchor: tokenElement })
},
onTokenLeave() {
hideHover()
},
},
})
Token hooks add token metadata to the DOM and should only be enabled when needed.
Comments and annotations
type Comment = { author: string, body: string }
const review = createDiffSurface<Comment>({
kind: 'diff',
oldFile,
newFile,
annotations: [
{ side: 'additions', lineNumber: 14, metadata: { author: 'You', body: 'Validate this input.' } },
],
options: {
renderAnnotation(annotation) {
const el = document.createElement('article')
el.textContent = `${annotation.metadata.author}: ${annotation.metadata.body}`
return el
},
},
})
review.setAnnotations(nextComments)
Accept or reject changes
review.acceptReject(0, 'accept')
review.acceptReject(1, 'reject')
review.acceptReject(2, { type: 'accept', changeIndex: 0 })
Each call returns the next FileDiffMetadata and rerenders the surface. This is a metadata transformation; persisting or applying the resulting file is controlled by your application.
const resolved = review.getResolvedFile()
await save(resolved?.contents)
// Escape hatch for any upstream API not wrapped by stream-diffs.
const nativeFileDiff = review.getNativeInstance()
Merge conflicts
const merge = createDiffSurface({
kind: 'merge-conflict',
file: { name: 'src/app.ts', contents: conflictedSource },
options: {
mergeConflictActionsType: 'default',
onMergeConflictResolve(file, payload) {
save(file.contents, payload.resolution)
},
},
})
await merge.mount(container)
const resolvedFile = merge.resolveConflict(0, 'incoming')
UnresolvedFile, merge resolution, and token hooks are experimental in the current underlying Diffs release. stream-diffs exposes them without hiding that status.
Vue
<script setup lang="ts">
import { StreamCode, StreamDiff, StreamMergeConflict } from 'stream-diffs/vue'
</script>
<template>
<StreamCode :code="generated" language="typescript" :loading="streaming" />
<StreamDiff
:original="before"
:modified="after"
language="typescript"
diff-style="split"
/>
<StreamMergeConflict :code="conflictedSource" language="typescript" />
</template>
See API reference, performance and package size, markstream-vue integration, and the Chinese guide.
Run the real-browser capability lab with pnpm example. It exercises unified/split review, annotations, token hover, selection, accept/reject, merge actions, and the streaming-to-file transition on one page.
Low-level synchronous Pierre utilities use a separate entry so the root package stays lazy:
import { parseDiffFromFile, parsePatchFiles } from 'stream-diffs/pierre'
Design constraints
- Read-only and append-only while streaming.
- Delta append is faster than cumulative snapshots.
- Streaming uses
FileStream; accurate diffs are generated from the final snapshot. - Oversized streams continue in append-only plain text and can still finalize to an accurate File or Diff.
- No cursor editing, undo stack, Monaco text model, completion, LSP, or diagnostics runtime.
@pierre/diffsis loaded on first mount, so importingstream-diffsremains SSR-safe.
License
MIT