feat: add streaming markdown rendering with incremental output

This commit is contained in:
mitchell 2026-04-18 05:04:00 -04:00
parent 27c6447ef2
commit 856a3160fe
4 changed files with 45 additions and 25 deletions

View file

@ -91,8 +91,6 @@ async function main() {
convo.messages.push({ role: "assistant", content: await text }); convo.messages.push({ role: "assistant", content: await text });
await convoFile.write(JSON.stringify(convo)); await convoFile.write(JSON.stringify(convo));
await renderer.renderFinal(text);
await renderer.renderStats(totalUsage, start, stop); await renderer.renderStats(totalUsage, start, stop);
} }

View file

@ -18,6 +18,8 @@ export default class Renderer {
private lineCount = 0; private lineCount = 0;
private charsSinceNL = 0; private charsSinceNL = 0;
private memOk = true; private memOk = true;
private buffer = "";
private unsafeIndex = 0;
constructor( constructor(
private isPlain = false, 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<TextStreamPart<ToolSet>>) { async renderStream(stream: AsyncIterableStream<TextStreamPart<ToolSet>>) {
this.resetCounts(); this.resetCounts();
@ -113,16 +142,19 @@ export default class Renderer {
} }
case "text-delta": { case "text-delta": {
this.write(part.text); this.write(part.text);
if (!this.isPlain) this.checkContainment(part.text); if (!this.isPlain) {
this.checkContainment(part.text);
await this.bufferThenRender(part.text);
}
break; break;
} }
case "text-end": { case "text-end": {
if (!this.isPlain) { if (this.buffer) {
this.moveCursor(-this.lineCount - 1); await this.render(this.buffer);
this.clearScreenDown(process.stderr); this.buffer = "";
this.resetCounts(); this.unsafeIndex = 0;
} else { } else {
this.write("\n\n"); this.write("\n");
} }
break; 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`; const template = `Context: ${context.toFixed(1)}k, Output: ${output.toFixed(1)}k, Time: ${time.toFixed(2)}s\n`;
process.stderr.write(chalkStderr.dim(template)); process.stderr.write(chalkStderr.dim(template));
} }
async renderFinal(text: PromiseLike<string>) {
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, `));
}
} }

View file

@ -47,7 +47,7 @@ import kanagawaLotus from "@shikijs/themes/kanagawa-lotus";
import kanagawaWave from "@shikijs/themes/kanagawa-wave"; import kanagawaWave from "@shikijs/themes/kanagawa-wave";
export async function renderMarkdown( export async function renderMarkdown(
text: PromiseLike<string>, text: PromiseLike<string> | string,
columns: number, columns: number,
) { ) {
return render(await text, { return render(await text, {

6
testRender.ts Normal file
View file

@ -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));