mini: monochrome rendered markdown only (#38656)

This commit is contained in:
Simon Klee 2026-07-24 11:14:48 +02:00 committed by GitHub
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>