fix(tui): render mini markdown in mono mode (#38313)
This commit is contained in:
parent
a3e2cc0dcd
commit
e12ec8681b
6 changed files with 147 additions and 6 deletions
|
|
@ -214,7 +214,7 @@ export function entryBody(commit: StreamCommit, options?: ScrollbackOptions): Ru
|
||||||
return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE
|
return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE
|
||||||
}
|
}
|
||||||
|
|
||||||
return mono ? textBody(raw) : markdownBody(raw)
|
return markdownBody(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (commit.kind === "reasoning") {
|
if (commit.kind === "reasoning") {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,12 @@
|
||||||
|
import {
|
||||||
|
BoxRenderable,
|
||||||
|
RGBA,
|
||||||
|
type BorderCharacters,
|
||||||
|
type CliRendererExternalOutputEvent,
|
||||||
|
type MarkdownOptions,
|
||||||
|
type Renderable,
|
||||||
|
} from "@opentui/core"
|
||||||
|
|
||||||
const prefixes: Record<number, string> = {
|
const prefixes: Record<number, string> = {
|
||||||
0x2192: "->",
|
0x2192: "->",
|
||||||
0x2190: "<-",
|
0x2190: "<-",
|
||||||
|
|
@ -10,6 +19,90 @@ const prefixes: Record<number, string> = {
|
||||||
0x27f3: "*",
|
0x27f3: "*",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markdown: Record<number, string> = {
|
||||||
|
...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<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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
export function monoPrefix(value: string, mono: boolean): string {
|
||||||
if (!mono) return value
|
if (!mono) return value
|
||||||
const point = value.codePointAt(0)
|
const point = value.codePointAt(0)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||||
import { isDefaultTitle } from "../util/session"
|
import { isDefaultTitle } from "../util/session"
|
||||||
|
import { monoSnapshot } from "./mono"
|
||||||
import { entrySplash, exitSplash, splashMeta } from "./splash"
|
import { entrySplash, exitSplash, splashMeta } from "./splash"
|
||||||
import { resolveRunTheme } from "./theme"
|
import { resolveRunTheme } from "./theme"
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -172,6 +173,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||||
consoleMode: "disabled",
|
consoleMode: "disabled",
|
||||||
clearOnShutdown: false,
|
clearOnShutdown: false,
|
||||||
})
|
})
|
||||||
|
if (mono) renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
|
||||||
const setTitle = (title?: string) => {
|
const setTitle = (title?: string) => {
|
||||||
if (input.host.platform !== "linux") return
|
if (input.host.platform !== "linux") return
|
||||||
if (!title || isDefaultTitle(title)) return renderer.setTerminalTitle("OpenCode")
|
if (!title || isDefaultTitle(title)) return renderer.setTerminalTitle("OpenCode")
|
||||||
|
|
@ -329,6 +331,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||||
await footer.idle().catch(() => {})
|
await footer.idle().catch(() => {})
|
||||||
footer.destroy()
|
footer.destroy()
|
||||||
if (input.host.platform === "linux") renderer.setTerminalTitle("")
|
if (input.host.platform === "linux") renderer.setTerminalTitle("")
|
||||||
|
if (mono) renderer.off(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
|
||||||
shutdown(renderer)
|
shutdown(renderer)
|
||||||
if (!wroteExit) {
|
if (!wroteExit) {
|
||||||
input.host.stdout.write("\n")
|
input.host.stdout.write("\n")
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
type ScrollbackSurface,
|
type ScrollbackSurface,
|
||||||
} from "@opentui/core"
|
} from "@opentui/core"
|
||||||
import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
|
import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
|
||||||
|
import { monoMarkdown, monoMarkdownRenderNode, monoMarkdownTableOptions } from "./mono"
|
||||||
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
||||||
import { turnSummaryCommit } from "./turn-summary"
|
import { turnSummaryCommit } from "./turn-summary"
|
||||||
import { entryWriter, sameEntryGroup, separatorRows, spacerWriter, turnSummaryWriter } from "./scrollback.writer"
|
import { entryWriter, sameEntryGroup, separatorRows, spacerWriter, turnSummaryWriter } from "./scrollback.writer"
|
||||||
|
|
@ -179,7 +180,8 @@ export class RunScrollbackStream {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
streaming: true,
|
streaming: true,
|
||||||
internalBlockMode: "top-level",
|
internalBlockMode: "top-level",
|
||||||
tableOptions: { widthMode: "content" },
|
tableOptions: this.mono ? monoMarkdownTableOptions : { widthMode: "content" },
|
||||||
|
renderNode: this.mono ? monoMarkdownRenderNode : undefined,
|
||||||
fg: entryColor(commit, this.theme),
|
fg: entryColor(commit, this.theme),
|
||||||
treeSitterClient,
|
treeSitterClient,
|
||||||
})
|
})
|
||||||
|
|
@ -281,7 +283,7 @@ export class RunScrollbackStream {
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderable = active.renderable
|
const renderable = active.renderable
|
||||||
renderable.content = active.content
|
renderable.content = monoMarkdown(active.content, this.mono)
|
||||||
renderable.streaming = !done
|
renderable.streaming = !done
|
||||||
await active.surface.settle()
|
await active.surface.settle()
|
||||||
this.releasePendingThemes()
|
this.releasePendingThemes()
|
||||||
|
|
@ -395,6 +397,7 @@ export class RunScrollbackStream {
|
||||||
commit,
|
commit,
|
||||||
body: staticBody(commit, body, spaced),
|
body: staticBody(commit, body, spaced),
|
||||||
theme: this.theme,
|
theme: this.theme,
|
||||||
|
opts: { mono: this.mono },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
this.markRendered(commit)
|
this.markRendered(commit)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { createScrollbackWriter } from "@opentui/solid"
|
||||||
import { TextRenderable, type ColorInput, type ScrollbackRenderContext, type ScrollbackWriter } from "@opentui/core"
|
import { TextRenderable, type ColorInput, type ScrollbackRenderContext, type ScrollbackWriter } from "@opentui/core"
|
||||||
import { Match, Switch, createMemo } from "solid-js"
|
import { Match, Switch, createMemo } from "solid-js"
|
||||||
import { entryBody, entryFlags } from "./entry.body"
|
import { entryBody, entryFlags } from "./entry.body"
|
||||||
|
import { monoMarkdown, monoMarkdownRenderNode, monoMarkdownTableOptions } from "./mono"
|
||||||
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
||||||
import { toolFiletype, toolStructuredFinal } from "./tool"
|
import { toolFiletype, toolStructuredFinal } from "./tool"
|
||||||
import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
|
import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
|
||||||
|
|
@ -239,9 +240,10 @@ export function RunEntryContent(props: {
|
||||||
width="100%"
|
width="100%"
|
||||||
syntaxStyle={syntax()}
|
syntaxStyle={syntax()}
|
||||||
streaming={streaming()}
|
streaming={streaming()}
|
||||||
content={markdown()!.content}
|
content={monoMarkdown(markdown()!.content, props.opts?.mono === true)}
|
||||||
fg={color()}
|
fg={color()}
|
||||||
tableOptions={{ widthMode: "content" }}
|
tableOptions={props.opts?.mono ? monoMarkdownTableOptions : { widthMode: "content" }}
|
||||||
|
renderNode={props.opts?.mono ? monoMarkdownRenderNode : undefined}
|
||||||
/>
|
/>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { afterEach, expect, test } from "bun:test"
|
import { afterEach, expect, test } from "bun:test"
|
||||||
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
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 { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
|
||||||
|
import { monoSnapshot } from "../../src/mini/mono"
|
||||||
import { RunScrollbackStream } from "../../src/mini/scrollback.surface"
|
import { RunScrollbackStream } from "../../src/mini/scrollback.surface"
|
||||||
import { entryGroupKey } from "../../src/mini/scrollback.writer"
|
import { entryGroupKey } from "../../src/mini/scrollback.writer"
|
||||||
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
|
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
|
||||||
|
|
@ -67,6 +68,7 @@ async function setup(
|
||||||
wrote?: boolean
|
wrote?: boolean
|
||||||
theme?: RunTheme
|
theme?: RunTheme
|
||||||
onThemeRelease?: (theme: RunTheme) => void
|
onThemeRelease?: (theme: RunTheme) => void
|
||||||
|
mono?: boolean
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const out = await createTestRenderer({
|
const out = await createTestRenderer({
|
||||||
|
|
@ -77,6 +79,7 @@ async function setup(
|
||||||
consoleMode: "disabled",
|
consoleMode: "disabled",
|
||||||
})
|
})
|
||||||
active.push(out.renderer)
|
active.push(out.renderer)
|
||||||
|
if (input.mono) out.renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
|
||||||
|
|
||||||
const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
|
const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
|
||||||
treeSitterClient.setMockResult({ highlights: [] })
|
treeSitterClient.setMockResult({ highlights: [] })
|
||||||
|
|
@ -87,6 +90,7 @@ async function setup(
|
||||||
treeSitterClient,
|
treeSitterClient,
|
||||||
wrote: input.wrote ?? false,
|
wrote: input.wrote ?? false,
|
||||||
onThemeRelease: input.onThemeRelease,
|
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 {
|
function user(text: string): StreamCommit {
|
||||||
return {
|
return {
|
||||||
kind: "user",
|
kind: "user",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue