From 856a3160fe01d90b04ee6711084d308663687f18 Mon Sep 17 00:00:00 2001 From: mitchell Date: Sat, 18 Apr 2026 05:04:00 -0400 Subject: [PATCH] feat: add streaming markdown rendering with incremental output --- index.ts | 2 -- src/render/index.ts | 60 ++++++++++++++++++++++++++---------------- src/render/markdown.ts | 2 +- testRender.ts | 6 +++++ 4 files changed, 45 insertions(+), 25 deletions(-) create mode 100644 testRender.ts diff --git a/index.ts b/index.ts index d84a514..2366afb 100644 --- a/index.ts +++ b/index.ts @@ -91,8 +91,6 @@ async function main() { convo.messages.push({ role: "assistant", content: await text }); await convoFile.write(JSON.stringify(convo)); - await renderer.renderFinal(text); - await renderer.renderStats(totalUsage, start, stop); } diff --git a/src/render/index.ts b/src/render/index.ts index 63bb318..6e3a17d 100644 --- a/src/render/index.ts +++ b/src/render/index.ts @@ -18,6 +18,8 @@ export default class Renderer { private lineCount = 0; private charsSinceNL = 0; private memOk = true; + private buffer = ""; + private unsafeIndex = 0; constructor( private isPlain = false, @@ -66,6 +68,33 @@ export default class Renderer { } } + private async bufferThenRender(text: string, pipe = process.stdout) { + this.buffer += text; + + const idx = this.buffer.lastIndexOf("\n#"); + + const blockDelims = this.buffer.match(/```/g)?.length; + if (blockDelims && blockDelims % 2 !== 0) this.unsafeIndex = idx; + + if (idx > this.unsafeIndex) { + const str = this.buffer.substring(0, idx) ?? ""; + await this.render(str, pipe); + this.buffer = this.buffer.substring(idx); + this.unsafeIndex = 0; + } + } + + private async render(str: string, pipe = process.stdout) { + this.moveCursor(-this.lineCount, pipe); + this.clearScreenDown(pipe); + this.resetCounts(); + + const [columns] = pipe.getWindowSize(); + const rendered = await renderMarkdown(str, columns); + pipe.write(rendered); + pipe.write("\n"); + } + async renderStream(stream: AsyncIterableStream>) { this.resetCounts(); @@ -113,16 +142,19 @@ export default class Renderer { } case "text-delta": { this.write(part.text); - if (!this.isPlain) this.checkContainment(part.text); + if (!this.isPlain) { + this.checkContainment(part.text); + await this.bufferThenRender(part.text); + } break; } case "text-end": { - if (!this.isPlain) { - this.moveCursor(-this.lineCount - 1); - this.clearScreenDown(process.stderr); - this.resetCounts(); + if (this.buffer) { + await this.render(this.buffer); + this.buffer = ""; + this.unsafeIndex = 0; } else { - this.write("\n\n"); + this.write("\n"); } break; } @@ -144,20 +176,4 @@ export default class Renderer { const template = `Context: ${context.toFixed(1)}k, Output: ${output.toFixed(1)}k, Time: ${time.toFixed(2)}s\n`; process.stderr.write(chalkStderr.dim(template)); } - - async renderFinal(text: PromiseLike) { - if (this.isPlain) return; - - const [columns] = process.stdout.getWindowSize(); - - const start = Date.now(); - const rendered = await renderMarkdown(text, columns); - const stop = Date.now(); - - this.write("\n"); - this.write(rendered); - this.write("\n"); - - process.stderr.write(chalkStderr.dim(`Markdown: ${stop - start}ms, `)); - } } diff --git a/src/render/markdown.ts b/src/render/markdown.ts index fa9d4b5..dbf5713 100644 --- a/src/render/markdown.ts +++ b/src/render/markdown.ts @@ -47,7 +47,7 @@ import kanagawaLotus from "@shikijs/themes/kanagawa-lotus"; import kanagawaWave from "@shikijs/themes/kanagawa-wave"; export async function renderMarkdown( - text: PromiseLike, + text: PromiseLike | string, columns: number, ) { return render(await text, { diff --git a/testRender.ts b/testRender.ts new file mode 100644 index 0000000..c06fda2 --- /dev/null +++ b/testRender.ts @@ -0,0 +1,6 @@ +import { renderMarkdown } from "./src/render/markdown.ts"; + +const text = await Bun.file("output.md").text(); + +const [columns] = process.stdout.getWindowSize(); +process.stdout.write(await renderMarkdown(text, columns));