Просмотр исходного кода

fix(tui): render mini markdown in mono mode (#38313)

Simon Klee 3 недель назад
Родитель
Сommit
e12ec8681b

+ 1 - 1
packages/tui/src/mini/entry.body.ts

@@ -214,7 +214,7 @@ export function entryBody(commit: StreamCommit, options?: ScrollbackOptions): Ru
       return commit.interrupted ? textBody("assistant interrupted") : RUN_ENTRY_NONE
     }
 
-    return mono ? textBody(raw) : markdownBody(raw)
+    return markdownBody(raw)
   }
 
   if (commit.kind === "reasoning") {

+ 93 - 0
packages/tui/src/mini/mono.ts

@@ -1,3 +1,12 @@
+import {
+  BoxRenderable,
+  RGBA,
+  type BorderCharacters,
+  type CliRendererExternalOutputEvent,
+  type MarkdownOptions,
+  type Renderable,
+} from "@opentui/core"
+
 const prefixes: Record<number, string> = {
   0x2192: "->",
   0x2190: "<-",
@@ -10,6 +19,90 @@ const prefixes: Record<number, string> = {
   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 {
   if (!mono) return value
   const point = value.codePointAt(0)

+ 3 - 0
packages/tui/src/mini/runtime.lifecycle.ts

@@ -11,6 +11,7 @@
 import path from "path"
 import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
 import { isDefaultTitle } from "../util/session"
+import { monoSnapshot } from "./mono"
 import { entrySplash, exitSplash, splashMeta } from "./splash"
 import { resolveRunTheme } from "./theme"
 import type {
@@ -172,6 +173,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
     consoleMode: "disabled",
     clearOnShutdown: false,
   })
+  if (mono) renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
   const setTitle = (title?: string) => {
     if (input.host.platform !== "linux") return
     if (!title || isDefaultTitle(title)) return renderer.setTerminalTitle("OpenCode")
@@ -329,6 +331,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
       await footer.idle().catch(() => {})
       footer.destroy()
       if (input.host.platform === "linux") renderer.setTerminalTitle("")
+      if (mono) renderer.off(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
       shutdown(renderer)
       if (!wroteExit) {
         input.host.stdout.write("\n")

+ 5 - 2
packages/tui/src/mini/scrollback.surface.ts

@@ -14,6 +14,7 @@ import {
   type ScrollbackSurface,
 } from "@opentui/core"
 import { entryBody, entryCanStream, entryDone, entryFlags } from "./entry.body"
+import { monoMarkdown, monoMarkdownRenderNode, monoMarkdownTableOptions } from "./mono"
 import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
 import { turnSummaryCommit } from "./turn-summary"
 import { entryWriter, sameEntryGroup, separatorRows, spacerWriter, turnSummaryWriter } from "./scrollback.writer"
@@ -179,7 +180,8 @@ export class RunScrollbackStream {
               width: "100%",
               streaming: true,
               internalBlockMode: "top-level",
-              tableOptions: { widthMode: "content" },
+              tableOptions: this.mono ? monoMarkdownTableOptions : { widthMode: "content" },
+              renderNode: this.mono ? monoMarkdownRenderNode : undefined,
               fg: entryColor(commit, this.theme),
               treeSitterClient,
             })
@@ -281,7 +283,7 @@ export class RunScrollbackStream {
     }
 
     const renderable = active.renderable
-    renderable.content = active.content
+    renderable.content = monoMarkdown(active.content, this.mono)
     renderable.streaming = !done
     await active.surface.settle()
     this.releasePendingThemes()
@@ -395,6 +397,7 @@ export class RunScrollbackStream {
         commit,
         body: staticBody(commit, body, spaced),
         theme: this.theme,
+        opts: { mono: this.mono },
       }),
     )
     this.markRendered(commit)

+ 4 - 2
packages/tui/src/mini/scrollback.writer.tsx

@@ -2,6 +2,7 @@ import { createScrollbackWriter } from "@opentui/solid"
 import { 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 { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
 import { toolFiletype, toolStructuredFinal } from "./tool"
 import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
@@ -239,9 +240,10 @@ export function RunEntryContent(props: {
           width="100%"
           syntaxStyle={syntax()}
           streaming={streaming()}
-          content={markdown()!.content}
+          content={monoMarkdown(markdown()!.content, props.opts?.mono === true)}
           fg={color()}
-          tableOptions={{ widthMode: "content" }}
+          tableOptions={props.opts?.mono ? monoMarkdownTableOptions : { widthMode: "content" }}
+          renderNode={props.opts?.mono ? monoMarkdownRenderNode : undefined}
         />
       </Match>
     </Switch>

+ 41 - 1
packages/tui/test/mini/scrollback.surface.test.ts

@@ -1,7 +1,8 @@
 import { afterEach, expect, test } from "bun:test"
 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 { monoSnapshot } from "../../src/mini/mono"
 import { RunScrollbackStream } from "../../src/mini/scrollback.surface"
 import { entryGroupKey } from "../../src/mini/scrollback.writer"
 import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
@@ -67,6 +68,7 @@ async function setup(
     wrote?: boolean
     theme?: RunTheme
     onThemeRelease?: (theme: RunTheme) => void
+    mono?: boolean
   } = {},
 ) {
   const out = await createTestRenderer({
@@ -77,6 +79,7 @@ async function setup(
     consoleMode: "disabled",
   })
   active.push(out.renderer)
+  if (input.mono) out.renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
 
   const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
   treeSitterClient.setMockResult({ highlights: [] })
@@ -87,6 +90,7 @@ async function setup(
       treeSitterClient,
       wrote: input.wrote ?? false,
       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 {
   return {
     kind: "user",