From 4184149b907b7ad2035a95281ea71be4cd54b287 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 24 Jul 2026 11:14:48 +0200 Subject: [PATCH] mini: monochrome rendered markdown only (#38656) --- packages/tui/src/mini/entry.body.ts | 4 +- packages/tui/src/mini/mono.ts | 135 ++++++++++++++++-- packages/tui/src/mini/scrollback.surface.ts | 8 +- packages/tui/src/mini/scrollback.writer.tsx | 16 ++- packages/tui/test/mini/entry.body.test.ts | 41 +++--- packages/tui/test/mini/footer.view.test.tsx | 77 ++++++++++ .../tui/test/mini/scrollback.surface.test.ts | 86 ++++++++++- 7 files changed, 322 insertions(+), 45 deletions(-) diff --git a/packages/tui/src/mini/entry.body.ts b/packages/tui/src/mini/entry.body.ts index 499ed091da6..6cd23187499 100644 --- a/packages/tui/src/mini/entry.body.ts +++ b/packages/tui/src/mini/entry.body.ts @@ -81,8 +81,8 @@ function systemBody(raw: string, phase: StreamCommit["phase"]): RunEntryBody { } function monoBody(body: RunEntryBody): RunEntryBody { - if (body.type === "none" || body.type === "text") return body - if (body.type === "code" || body.type === "markdown") return textBody(body.content) + if (body.type === "none" || body.type === "text" || body.type === "markdown") return body + if (body.type === "code") return textBody(body.content) const snapshot = body.snapshot if (snapshot.kind === "code") return textBody(`${snapshot.title}\n${snapshot.content}`) if (snapshot.kind === "diff") { diff --git a/packages/tui/src/mini/mono.ts b/packages/tui/src/mini/mono.ts index 109daa0a4bd..62205eba662 100644 --- a/packages/tui/src/mini/mono.ts +++ b/packages/tui/src/mini/mono.ts @@ -1,10 +1,17 @@ import { BoxRenderable, + CodeRenderable, + MarkdownRenderable, RGBA, + Renderable, + StyledText, + TextRenderable, + TextTableRenderable, + isStyledText, + stringToStyledText, type BorderCharacters, type CliRendererExternalOutputEvent, - type MarkdownOptions, - type Renderable, + type TreeSitterClient, } from "@opentui/core" const prefixes: Record = { @@ -52,27 +59,129 @@ const asciiBorder: BorderCharacters = { cross: "+", } +const hooked = new WeakSet() + 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 +export function monoMarkdownRenderable(renderable: MarkdownRenderable): void { + monoRenderable(renderable) } -function monoBorders(renderable: Renderable): void { +function monoRenderable(renderable: Renderable): void { + if (hooked.has(renderable)) return + hooked.add(renderable) + // Markdown reconciles nested lists and tables without calling renderNode. + // Hook the actual tree so future descendants are transformed before layout. + const add = renderable.add.bind(renderable) + renderable.add = (child, index) => { + if (child instanceof Renderable) monoRenderable(child) + return add(child, index) + } + if (renderable instanceof BoxRenderable) renderable.customBorderChars = asciiBorder - renderable.getChildren().forEach(monoBorders) + if (renderable instanceof CodeRenderable) monoCode(renderable) + if (renderable instanceof TextRenderable) renderable.content = monoStyledText(renderable.content) + if (renderable instanceof TextTableRenderable) monoTable(renderable) + renderable.getChildren().forEach(monoRenderable) } -export function monoMarkdown(value: string, mono: boolean): string { - if (!mono) return value +function monoCode(renderable: CodeRenderable): void { + const onChunks = renderable.onChunks + const prose = renderable.filetype === "markdown" && onChunks !== undefined + renderable.onChunks = async (chunks, context) => monoChunks((await onChunks?.(chunks, context)) ?? chunks) + renderable.treeSitterClient = monoTreeSitter(renderable.treeSitterClient) + + const initialDescriptor = Object.getOwnPropertyDescriptor(CodeRenderable.prototype, "initialStyledText") + const contentDescriptor = Object.getOwnPropertyDescriptor(CodeRenderable.prototype, "content") + if (!initialDescriptor?.set || !contentDescriptor?.get || !contentDescriptor.set) return + const initialSetter = initialDescriptor.set.bind(renderable) + const contentGetter = contentDescriptor.get.bind(renderable) + const contentSetter = contentDescriptor.set.bind(renderable) + const initial = Reflect.get(renderable, "_initialStyledText") + Object.defineProperty(renderable, "initialStyledText", { + configurable: true, + set(value: StyledText | undefined) { + initialSetter(value ? monoStyledText(value) : value) + }, + }) + Object.defineProperty(renderable, "content", { + configurable: true, + get: contentGetter, + set(value: string) { + if (!prose || !isStyledText(Reflect.get(renderable, "_initialStyledText"))) { + renderable.drawUnstyledText = true + renderable.initialStyledText = stringToStyledText(value) + } + contentSetter(value) + }, + }) + + if (isStyledText(initial)) { + renderable.initialStyledText = initial + } else { + renderable.drawUnstyledText = true + renderable.initialStyledText = stringToStyledText(renderable.content) + } + if (!renderable.drawUnstyledText) return + + // Refresh the eager buffer with the transformed initial text. Highlighted + // chunks continue through onChunks without changing the Markdown source. + const content = renderable.content + renderable.content = "" + renderable.content = content +} + +function monoTreeSitter(client: TreeSitterClient): TreeSitterClient { + return new Proxy(client, { + get(target, property) { + if (property !== "highlightOnce") return Reflect.get(target, property, target) + // Keep parser failures on the chunk path instead of OpenTUI's raw-text fallback. + return (...args: Parameters) => + target.highlightOnce(...args).catch(() => ({ highlights: [] })) + }, + }) +} + +function monoTable(renderable: TextTableRenderable): void { + const descriptor = Object.getOwnPropertyDescriptor(TextTableRenderable.prototype, "content") + if (!descriptor?.get || !descriptor.set) return + const cells = new WeakMap() + const content = renderable.content + Object.defineProperty(renderable, "content", { + configurable: true, + get: () => descriptor.get!.call(renderable), + set: (value: TextTableRenderable["content"]) => { + descriptor.set!.call( + renderable, + value.map((row) => + row.map((cell) => { + if (!cell) return cell + const cached = cells.get(cell) + if (cached) return cached + const next = monoChunks(cell) + cells.set(cell, next) + return next + }), + ), + ) + }, + }) + renderable.content = content +} + +function monoStyledText(value: StyledText): StyledText { + return new StyledText(monoChunks(value.chunks)) +} + +function monoChunks(value: StyledText["chunks"]): StyledText["chunks"] { + return value.map((chunk) => ({ ...chunk, text: monoText(chunk.text) })) +} + +function monoText(value: string): string { return value.replace(/[^\t\n\x20-\x7e]/gu, (char) => markdown[char.codePointAt(0)!] ?? "?") } @@ -80,7 +189,7 @@ 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]! + const point = chars[index] if (point <= 0x7f) continue const offset = index * 4 event.snapshot.setCell( diff --git a/packages/tui/src/mini/scrollback.surface.ts b/packages/tui/src/mini/scrollback.surface.ts index 9d81c2bb987..e5d4d01b129 100644 --- a/packages/tui/src/mini/scrollback.surface.ts +++ b/packages/tui/src/mini/scrollback.surface.ts @@ -14,7 +14,7 @@ import { type ScrollbackSurface, } from "@opentui/core" import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body" -import { monoMarkdown, monoMarkdownRenderNode, monoMarkdownTableOptions } from "./mono" +import { monoMarkdownRenderable, monoMarkdownTableOptions } from "./mono" import { entryColor, entryLook, entrySyntax } from "./scrollback.shared" import { turnSummaryCommit } from "./turn-summary" import { entryWriter, sameEntryGroup, separatorRows, spacerWriter, turnSummaryWriter } from "./scrollback.writer" @@ -181,11 +181,11 @@ export class RunScrollbackStream { streaming: true, internalBlockMode: "top-level", tableOptions: this.mono ? monoMarkdownTableOptions : { widthMode: "content" }, - renderNode: this.mono ? monoMarkdownRenderNode : undefined, fg: entryColor(commit, this.theme), treeSitterClient, }) + if (this.mono && renderable instanceof MarkdownRenderable) monoMarkdownRenderable(renderable) surface.root.add(renderable) const rows = separatorRows(this.rendered, commit, body) @@ -283,7 +283,7 @@ export class RunScrollbackStream { } const renderable = active.renderable - renderable.content = monoMarkdown(active.content, this.mono) + renderable.content = active.content renderable.streaming = !done await active.surface.settle() this.releasePendingThemes() @@ -378,7 +378,7 @@ export class RunScrollbackStream { ) { await this.writeStreaming(commit, body) if (entryDone(commit)) { - this.markRendered(await this.finishActive(false)) + this.markRendered(await this.finishActive(entryFlags(commit).trailingNewline)) } this.tail = commit return diff --git a/packages/tui/src/mini/scrollback.writer.tsx b/packages/tui/src/mini/scrollback.writer.tsx index c74f09168b6..907abe0b74c 100644 --- a/packages/tui/src/mini/scrollback.writer.tsx +++ b/packages/tui/src/mini/scrollback.writer.tsx @@ -1,8 +1,14 @@ import { createScrollbackWriter } from "@opentui/solid" -import { TextRenderable, type ColorInput, type ScrollbackRenderContext, type ScrollbackWriter } from "@opentui/core" +import { + MarkdownRenderable, + 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 { monoMarkdownRenderable, monoMarkdownTableOptions } from "./mono" import { entryColor, entryLook, entrySyntax } from "./scrollback.shared" import { toolFiletype, toolStructuredFinal } from "./tool" import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme" @@ -237,13 +243,15 @@ export function RunEntryContent(props: { { + if (props.opts?.mono) monoMarkdownRenderable(renderable) + }} width="100%" syntaxStyle={syntax()} streaming={streaming()} - content={monoMarkdown(markdown()!.content, props.opts?.mono === true)} + content={markdown()!.content} fg={color()} tableOptions={props.opts?.mono ? monoMarkdownTableOptions : { widthMode: "content" }} - renderNode={props.opts?.mono ? monoMarkdownRenderNode : undefined} /> diff --git a/packages/tui/test/mini/entry.body.test.ts b/packages/tui/test/mini/entry.body.test.ts index 14b1a035b91..717466de3c6 100644 --- a/packages/tui/test/mini/entry.body.test.ts +++ b/packages/tui/test/mini/entry.body.test.ts @@ -231,29 +231,28 @@ describe("run entry body", () => { }) test("promotes subagent results to markdown and falls back to structured summaries", () => { - expect( - entryBody( - toolCommit({ - tool: "subagent", - state: { - status: "completed", - input: { - description: "Inspect reducer", - agent: "explore", - }, - content: [{ type: "text", text: "# Findings\n\n- Footer stays live" }], - metadata: { - sessionID: "ses-child-1", - status: "completed", - output: "# Findings\n\n- Footer stays live", - }, - }, - }), - ), - ).toEqual({ + const result = toolCommit({ + tool: "subagent", + state: { + status: "completed", + input: { + description: "Inspect reducer", + agent: "explore", + }, + content: [{ type: "text", text: "# Findings\n\n- Footer stays live" }], + metadata: { + sessionID: "ses-child-1", + status: "completed", + output: "# Findings\n\n- Footer stays live", + }, + }, + }) + const markdown = { type: "markdown", content: "# Findings\n\n- Footer stays live", - }) + } as const + expect(entryBody(result)).toEqual(markdown) + expect(entryBody(result, { mono: true })).toEqual(markdown) expect( structured( diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index 881cf0e6257..ae1c8322baa 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -381,6 +381,83 @@ test("run entry content updates when live commit text changes", async () => { } }) +test("run entry content preserves monochrome markdown grammar", async () => { + const [commit, setCommit] = createSignal({ + kind: "assistant", + text: "• literal\n\n———\n\narrow →", + phase: "progress", + source: "assistant", + messageID: "msg-1", + partID: "part-1", + }) + const app = await testRender( + () => ( + + + + ), + { width: 60, height: 8 }, + ) + + try { + await app.renderOnce() + const rows = app + .captureCharFrame() + .split("\n") + .map((row) => row.trimEnd()) + expect(rows).toContain("* literal") + expect(rows).toContain("------") + expect(rows).toContain("arrow ->") + expect(rows.join("\n")).not.toMatch(/[^\x00-\x7f]/) + + setCommit({ ...commit(), text: "- Café\n- arrow →\n- third …" }) + await app.renderOnce() + expect(app.captureCharFrame()).toContain("- arrow ->") + expect(app.captureCharFrame()).toContain("- third ...") + + setCommit({ ...commit(), text: "| A | B |\n| - | - |\n| Café | → |" }) + await app.renderOnce() + expect(app.captureCharFrame()).toContain("Caf?") + expect(app.captureCharFrame()).toContain("->") + setCommit({ ...commit(), text: "| A | B |\n| - | - |\n| Café | … |" }) + await app.renderOnce() + expect(app.captureCharFrame()).toContain("...") + + setCommit({ ...commit(), text: "```\nCafé → …\n```" }) + await app.renderOnce() + expect(app.captureCharFrame()).toContain("Caf? -> ...") + expect(app.captureCharFrame()).not.toMatch(/[^\x00-\x7f]/) + } finally { + app.renderer.destroy() + } +}) + +test("run entry content eagerly renders final monochrome markdown", async () => { + const app = await testRender( + () => ( + + + + ), + { width: 60, height: 6 }, + ) + + try { + await app.renderOnce() + const frame = app.captureCharFrame() + expect(frame).toContain("# Caf? ->") + expect(frame).toContain("Caf? ->") + expect(frame).not.toMatch(/[^\x00-\x7f]/) + } finally { + app.renderer.destroy() + } +}) + test("direct command panel renders grouped actions without catalog commands", async () => { const [commands] = createSignal([ command({ name: "review", description: "Review code" }), diff --git a/packages/tui/test/mini/scrollback.surface.test.ts b/packages/tui/test/mini/scrollback.surface.test.ts index e2fe8226819..88507be52fc 100644 --- a/packages/tui/test/mini/scrollback.surface.test.ts +++ b/packages/tui/test/mini/scrollback.surface.test.ts @@ -69,6 +69,7 @@ async function setup( theme?: RunTheme onThemeRelease?: (theme: RunTheme) => void mono?: boolean + failHighlight?: boolean } = {}, ) { const out = await createTestRenderer({ @@ -83,6 +84,11 @@ async function setup( const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 }) treeSitterClient.setMockResult({ highlights: [] }) + if (input.failHighlight) { + treeSitterClient.highlightOnce = async () => { + throw new Error("highlight failed") + } + } return { renderer: out.renderer, @@ -217,7 +223,25 @@ test("renders monochrome scrollback as ASCII markdown", async () => { 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.append( + assistant( + "éading →\n\n> “quote”\n\n---\n\n| A | B |\n| - | - |\n| α | β |\n\n• literal\n\n———\n\n[café](https://example.com/café)", + ), + ) + const active: unknown = Reflect.get(out.scrollback, "active") + const renderable = + active && typeof active === "object" && "renderable" in active && active.renderable instanceof MarkdownRenderable + ? active.renderable + : undefined + expect(renderable?._blockStates.slice(-3).map((state) => state.token.type)).toEqual([ + "paragraph", + "paragraph", + "paragraph", + ]) + const link = renderable?._blockStates.at(-1)?.token + const tokens = link && "tokens" in link && Array.isArray(link.tokens) ? link.tokens : [] + const href = tokens.find((token) => "href" in token) + expect(href && "href" in href ? href.href : undefined).toBe("https://example.com/café") await out.scrollback.complete() out.renderer.writeToScrollback((ctx) => ({ root: new TextRenderable(ctx.renderContext, { @@ -235,6 +259,8 @@ test("renders monochrome scrollback as ASCII markdown", async () => { expect(rendered).toContain('| "quote"') expect(rendered).toContain("------------------------------------------------------------") expect(rendered).toContain("? ?") + expect(rendered).toContain("* literal") + expect(rendered).toContain("------") expect(rendered).toContain("plain ? emoji ?") expect(rendered).not.toMatch(/[^\x00-\x7f]/) } finally { @@ -243,6 +269,64 @@ test("renders monochrome scrollback as ASCII markdown", async () => { } }) +test("renders completed subagent markdown in monochrome mode", async () => { + const out = await setup({ mono: true, width: 60 }) + + try { + await out.scrollback.append( + toolCommit({ + tool: "subagent", + phase: "final", + toolState: "completed", + state: { + status: "completed", + input: { description: "Inspect reducer", agent: "explore" }, + content: [{ type: "text", text: "# Findings\n\n- Café → stable" }], + metadata: { + sessionID: "ses-child-1", + status: "completed", + output: "# Findings\n\n- Café → stable", + }, + }, + }), + ) + + const commits = claim(out.renderer) + try { + expect(commits).toHaveLength(1) + expect(commits[0]?.trailingNewline).toBe(true) + const output = render(commits) + expect(output).toContain("# Findings") + expect(output).toContain("- Caf? -> stable") + expect(output).not.toMatch(/[^\x00-\x7f]/) + } finally { + destroy(commits) + } + } finally { + out.scrollback.destroy() + } +}) + +test("keeps fenced code monochrome when highlighting fails", async () => { + const out = await setup({ mono: true, failHighlight: true }) + + try { + await out.scrollback.append(assistant("```ts\nCafé → …\n```")) + await out.scrollback.complete() + + const commits = claim(out.renderer) + try { + const output = render(commits) + expect(output).toContain("Caf? -> ...") + expect(output).not.toMatch(/[^\x00-\x7f]/) + } finally { + destroy(commits) + } + } finally { + out.scrollback.destroy() + } +}) + function user(text: string): StreamCommit { return { kind: "user",