mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-28 05:53:34 +00:00
mini: monochrome rendered markdown only (#38656)
This commit is contained in:
parent
00f063b381
commit
4184149b90
7 changed files with 322 additions and 45 deletions
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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" }),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue