mini: monochrome rendered markdown only (#38656)

This commit is contained in:
Simon Klee 2026-07-24 11:14:48 +02:00 committed by GitHub
parent 00f063b381
commit 4184149b90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 322 additions and 45 deletions

View file

@ -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") {

View file

@ -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<number, string> = {
@ -52,27 +59,129 @@ const asciiBorder: BorderCharacters = {
cross: "+",
}
const hooked = new WeakSet<Renderable>()
export const monoMarkdownTableOptions = {
style: "columns" as const,
widthMode: "content" as const,
borders: false,
}
export const monoMarkdownRenderNode: NonNullable<MarkdownOptions["renderNode"]> = (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<TreeSitterClient["highlightOnce"]>) =>
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<StyledText["chunks"], StyledText["chunks"]>()
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(

View file

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

View file

@ -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: {
</Match>
<Match when={markdown()}>
<markdown
ref={(renderable: MarkdownRenderable) => {
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}
/>
</Match>
</Switch>

View file

@ -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(

View file

@ -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<StreamCommit>({
kind: "assistant",
text: "• literal\n\n———\n\narrow →",
phase: "progress",
source: "assistant",
messageID: "msg-1",
partID: "part-1",
})
const app = await testRender(
() => (
<box width={60} height={8}>
<RunEntryContent commit={commit()} theme={RUN_THEME_FALLBACK} opts={{ mono: true }} />
</box>
),
{ 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(
() => (
<box width={60} height={6}>
<RunEntryContent
commit={{ kind: "tool", text: "", phase: "final", source: "tool", tool: "subagent" }}
body={{ type: "markdown", content: "# Café →\n\n```markdown\nCafé →\n```" }}
theme={RUN_THEME_FALLBACK}
opts={{ mono: true }}
/>
</box>
),
{ 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<RunCommand[] | undefined>([
command({ name: "review", description: "Review code" }),

View file

@ -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",