From e12ec8681bc834ab76e1318bb294dfe7ef647887 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Wed, 22 Jul 2026 15:32:43 +0200 Subject: [PATCH] fix(tui): render mini markdown in mono mode (#38313) --- packages/tui/src/mini/entry.body.ts | 2 +- packages/tui/src/mini/mono.ts | 93 +++++++++++++++++++ packages/tui/src/mini/runtime.lifecycle.ts | 3 + packages/tui/src/mini/scrollback.surface.ts | 7 +- packages/tui/src/mini/scrollback.writer.tsx | 6 +- .../tui/test/mini/scrollback.surface.test.ts | 42 ++++++++- 6 files changed, 147 insertions(+), 6 deletions(-) diff --git a/packages/tui/src/mini/entry.body.ts b/packages/tui/src/mini/entry.body.ts index 2d4a03f7f6..499ed091da 100644 --- a/packages/tui/src/mini/entry.body.ts +++ b/packages/tui/src/mini/entry.body.ts @@ -214,7 +214,7 @@ export function entryBody(commit: StreamCommit, options?: ScrollbackOptions): Ru return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE } - return mono ? textBody(raw) : markdownBody(raw) + return markdownBody(raw) } if (commit.kind === "reasoning") { diff --git a/packages/tui/src/mini/mono.ts b/packages/tui/src/mini/mono.ts index 389cc9d702..109daa0a4b 100644 --- a/packages/tui/src/mini/mono.ts +++ b/packages/tui/src/mini/mono.ts @@ -1,3 +1,12 @@ +import { + BoxRenderable, + RGBA, + type BorderCharacters, + type CliRendererExternalOutputEvent, + type MarkdownOptions, + type Renderable, +} from "@opentui/core" + const prefixes: Record = { 0x2192: "->", 0x2190: "<-", @@ -10,6 +19,90 @@ const prefixes: Record = { 0x27f3: "*", } +const markdown: Record = { + ...prefixes, + 0x00a0: " ", + 0x00b7: "-", + 0x2010: "-", + 0x2011: "-", + 0x2012: "-", + 0x2013: "-", + 0x2014: "--", + 0x2018: "'", + 0x2019: "'", + 0x201c: '"', + 0x201d: '"', + 0x2022: "*", + 0x2026: "...", + 0x2191: "up", + 0x2193: "down", +} + +const asciiBorder: BorderCharacters = { + topLeft: "+", + topRight: "+", + bottomLeft: "+", + bottomRight: "+", + horizontal: "-", + vertical: "|", + topT: "+", + bottomT: "+", + leftT: "+", + rightT: "+", + cross: "+", +} + +export const monoMarkdownTableOptions = { + style: "columns" as const, + widthMode: "content" as const, + borders: false, +} + +export const monoMarkdownRenderNode: NonNullable = (token, context) => { + if (token.type !== "blockquote" && token.type !== "hr" && token.type !== "list") return + const renderable = context.defaultRender() + if (!renderable) return renderable + monoBorders(renderable) + return renderable +} + +function monoBorders(renderable: Renderable): void { + if (renderable instanceof BoxRenderable) renderable.customBorderChars = asciiBorder + renderable.getChildren().forEach(monoBorders) +} + +export function monoMarkdown(value: string, mono: boolean): string { + if (!mono) return value + return value.replace(/[^\t\n\x20-\x7e]/gu, (char) => markdown[char.codePointAt(0)!] ?? "?") +} + +export function monoSnapshot(event: CliRendererExternalOutputEvent): void { + const buffers = event.snapshot.buffers + const chars = buffers.char + for (let index = 0; index < chars.length; index += 1) { + const point = chars[index]! + if (point <= 0x7f) continue + const offset = index * 4 + event.snapshot.setCell( + index % event.snapshot.width, + Math.floor(index / event.snapshot.width), + monoCell(point), + RGBA.fromArray(buffers.fg.subarray(offset, offset + 4)), + RGBA.fromArray(buffers.bg.subarray(offset, offset + 4)), + buffers.attributes[index], + ) + } +} + +function monoCell(point: number): string { + const kind = point >>> 30 + if (kind === 2) return "?" + if (kind === 3) return " " + if (point === 0x2500) return "-" + if (point === 0x2502) return "|" + return markdown[point]?.[0] ?? "?" +} + export function monoPrefix(value: string, mono: boolean): string { if (!mono) return value const point = value.codePointAt(0) diff --git a/packages/tui/src/mini/runtime.lifecycle.ts b/packages/tui/src/mini/runtime.lifecycle.ts index 8bc6806689..4d715add83 100644 --- a/packages/tui/src/mini/runtime.lifecycle.ts +++ b/packages/tui/src/mini/runtime.lifecycle.ts @@ -11,6 +11,7 @@ import path from "path" import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core" import { isDefaultTitle } from "../util/session" +import { monoSnapshot } from "./mono" import { entrySplash, exitSplash, splashMeta } from "./splash" import { resolveRunTheme } from "./theme" import type { @@ -172,6 +173,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise { if (input.host.platform !== "linux") return if (!title || isDefaultTitle(title)) return renderer.setTerminalTitle("OpenCode") @@ -329,6 +331,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise {}) footer.destroy() if (input.host.platform === "linux") renderer.setTerminalTitle("") + if (mono) renderer.off(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot) shutdown(renderer) if (!wroteExit) { input.host.stdout.write("\n") diff --git a/packages/tui/src/mini/scrollback.surface.ts b/packages/tui/src/mini/scrollback.surface.ts index 293335238f..9d81c2bb98 100644 --- a/packages/tui/src/mini/scrollback.surface.ts +++ b/packages/tui/src/mini/scrollback.surface.ts @@ -14,6 +14,7 @@ import { type ScrollbackSurface, } from "@opentui/core" import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body" +import { monoMarkdown, monoMarkdownRenderNode, monoMarkdownTableOptions } from "./mono" import { entryColor, entryLook, entrySyntax } from "./scrollback.shared" import { turnSummaryCommit } from "./turn-summary" import { entryWriter, sameEntryGroup, separatorRows, spacerWriter, turnSummaryWriter } from "./scrollback.writer" @@ -179,7 +180,8 @@ export class RunScrollbackStream { width: "100%", streaming: true, internalBlockMode: "top-level", - tableOptions: { widthMode: "content" }, + tableOptions: this.mono ? monoMarkdownTableOptions : { widthMode: "content" }, + renderNode: this.mono ? monoMarkdownRenderNode : undefined, fg: entryColor(commit, this.theme), treeSitterClient, }) @@ -281,7 +283,7 @@ export class RunScrollbackStream { } const renderable = active.renderable - renderable.content = active.content + renderable.content = monoMarkdown(active.content, this.mono) renderable.streaming = !done await active.surface.settle() this.releasePendingThemes() @@ -395,6 +397,7 @@ export class RunScrollbackStream { commit, body: staticBody(commit, body, spaced), theme: this.theme, + opts: { mono: this.mono }, }), ) this.markRendered(commit) diff --git a/packages/tui/src/mini/scrollback.writer.tsx b/packages/tui/src/mini/scrollback.writer.tsx index 6b141e28e8..c74f09168b 100644 --- a/packages/tui/src/mini/scrollback.writer.tsx +++ b/packages/tui/src/mini/scrollback.writer.tsx @@ -2,6 +2,7 @@ import { createScrollbackWriter } from "@opentui/solid" import { TextRenderable, type ColorInput, type ScrollbackRenderContext, type ScrollbackWriter } from "@opentui/core" import { Match, Switch, createMemo } from "solid-js" import { entryBody, entryFlags } from "./entry.body" +import { monoMarkdown, monoMarkdownRenderNode, monoMarkdownTableOptions } from "./mono" import { entryColor, entryLook, entrySyntax } from "./scrollback.shared" import { toolFiletype, toolStructuredFinal } from "./tool" import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme" @@ -239,9 +240,10 @@ export function RunEntryContent(props: { width="100%" syntaxStyle={syntax()} streaming={streaming()} - content={markdown()!.content} + content={monoMarkdown(markdown()!.content, props.opts?.mono === true)} fg={color()} - tableOptions={{ widthMode: "content" }} + tableOptions={props.opts?.mono ? monoMarkdownTableOptions : { widthMode: "content" }} + renderNode={props.opts?.mono ? monoMarkdownRenderNode : undefined} /> diff --git a/packages/tui/test/mini/scrollback.surface.test.ts b/packages/tui/test/mini/scrollback.surface.test.ts index 98182c7eda..5db71b1045 100644 --- a/packages/tui/test/mini/scrollback.surface.test.ts +++ b/packages/tui/test/mini/scrollback.surface.test.ts @@ -1,7 +1,8 @@ import { afterEach, expect, test } from "bun:test" import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise" -import { RGBA, SyntaxStyle } from "@opentui/core" +import { CliRenderEvents, MarkdownRenderable, RGBA, SyntaxStyle, TextRenderable } from "@opentui/core" import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing" +import { monoSnapshot } from "../../src/mini/mono" import { RunScrollbackStream } from "../../src/mini/scrollback.surface" import { entryGroupKey } from "../../src/mini/scrollback.writer" import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme" @@ -67,6 +68,7 @@ async function setup( wrote?: boolean theme?: RunTheme onThemeRelease?: (theme: RunTheme) => void + mono?: boolean } = {}, ) { const out = await createTestRenderer({ @@ -77,6 +79,7 @@ async function setup( consoleMode: "disabled", }) active.push(out.renderer) + if (input.mono) out.renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot) const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 }) treeSitterClient.setMockResult({ highlights: [] }) @@ -87,6 +90,7 @@ async function setup( treeSitterClient, wrote: input.wrote ?? false, onThemeRelease: input.onThemeRelease, + mono: input.mono, }), } } @@ -203,6 +207,42 @@ test("theme swaps preserve streamed markdown parser state", async () => { } }) +test("renders monochrome scrollback as ASCII markdown", async () => { + const out = await setup({ mono: true, width: 60 }) + const output: string[] = [] + out.renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, (event) => { + output.push(decoder.decode(event.snapshot.getRealCharBytes(true))) + }) + + try { + await out.scrollback.append(assistant("# H")) + expect(Reflect.get(out.scrollback, "active")?.renderable).toBeInstanceOf(MarkdownRenderable) + await out.scrollback.append(assistant('éading →\n\n> “quote”\n\n---\n\n| A | B |\n| - | - |\n| α | β |')) + await out.scrollback.complete() + out.renderer.writeToScrollback((ctx) => ({ + root: new TextRenderable(ctx.renderContext, { + content: "plain │ emoji 🙂", + width: ctx.width, + height: 1, + }), + width: ctx.width, + height: 1, + trailingNewline: false, + })) + + const rendered = output.join("").replace(/ +\n/g, "\n") + expect(rendered).toContain("# H?ading ->") + expect(rendered).toContain('| "quote"') + expect(rendered).toContain("------------------------------------------------------------") + expect(rendered).toContain("? ?") + expect(rendered).toContain("plain ? emoji ?") + expect(rendered).not.toMatch(/[^\x00-\x7f]/) + } finally { + out.scrollback.destroy() + destroy(claim(out.renderer)) + } +}) + function user(text: string): StreamCommit { return { kind: "user",