From c5b482e3e7f5a5f69c054c72a726e9defbf8d213 Mon Sep 17 00:00:00 2001 From: Rodolfo Carvalho Date: Mon, 26 Jan 2026 11:13:17 -0500 Subject: [PATCH 1/3] feat(tui): add markdown to HTML converter with inline styles Add markdownToHtml() function that converts markdown to HTML with inline styles for compatibility with rich text editors like Google Docs, Notion, and LibreOffice. - Add marked dependency (^17.0.1) for markdown parsing - Implement custom renderer with inline styles for all elements - Support headers, lists, links, code blocks, tables, blockquotes - Proper HTML escaping to prevent XSS - Add copy_as_rich_text config setting (defaults to false) - Add comprehensive test coverage (31 tests, all passing) --- .opencode/bun.lock | 6 +- .opencode/package.json | 2 +- bun.lock | 1 + packages/opencode/package.json | 1 + .../src/cli/cmd/tui/util/markdown-html.ts | 90 ++++++ packages/opencode/src/config/config.ts | 5 + .../test/cli/tui/markdown-html.test.ts | 262 ++++++++++++++++++ packages/opencode/test/config/config.test.ts | 90 ++++++ 8 files changed, 453 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/util/markdown-html.ts create mode 100644 packages/opencode/test/cli/tui/markdown-html.test.ts diff --git a/.opencode/bun.lock b/.opencode/bun.lock index e78ccc941b83..9f1550517b7f 100644 --- a/.opencode/bun.lock +++ b/.opencode/bun.lock @@ -4,14 +4,14 @@ "workspaces": { "": { "dependencies": { - "@opencode-ai/plugin": "0.0.0-dev-202601211610", + "@opencode-ai/plugin": "1.1.36", }, }, }, "packages": { - "@opencode-ai/plugin": ["@opencode-ai/plugin@0.0.0-dev-202601211610", "", { "dependencies": { "@opencode-ai/sdk": "0.0.0-dev-202601211610", "zod": "4.1.8" } }, "sha512-7yBM53Xr7B7fsJlR0kItHi7Rubqyasruj+A167aaXImO3lNczIH9IMizAU+f1O73u0fJYqvs+BGaU/eXOHdaRA=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.36", "", { "dependencies": { "@opencode-ai/sdk": "1.1.36", "zod": "4.1.8" } }, "sha512-b2XWeFZN7UzgwkkzTIi6qSntkpEA9En2zvpqakQzZAGQm6QBdGAlv6r1u5hEnmF12Gzyj5umTMWr5GzVbP/oAA=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@0.0.0-dev-202601211610", "", {}, "sha512-p6hg+eZqz+kVIZqOQYhQwnRfW9s0Fojqb9f+i//cZ8a0Vj5RBwcySkQDA8CwSK1gVWuNwHfy8RLrjGxdxAaS5g=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.36", "", {}, "sha512-feNHWnbxhg03TI2QrWnw3Chc0eYrWSDSmHIy/ejpSVfcKlfXREw1Tpg0L4EjrpeSc4jB1eM673dh+WM/Ko2SFQ=="], "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], } diff --git a/.opencode/package.json b/.opencode/package.json index e4f288dd4659..3ccac9dff5b2 100644 --- a/.opencode/package.json +++ b/.opencode/package.json @@ -1,5 +1,5 @@ { "dependencies": { - "@opencode-ai/plugin": "0.0.0-dev-202601211610" + "@opencode-ai/plugin": "1.1.36" } } diff --git a/bun.lock b/bun.lock index 9d7d15a1d15e..a448a042d298 100644 --- a/bun.lock +++ b/bun.lock @@ -317,6 +317,7 @@ "hono-openapi": "catalog:", "ignore": "7.0.5", "jsonc-parser": "3.3.1", + "marked": "^17.0.1", "minimatch": "10.0.3", "open": "10.1.2", "opentui-spinner": "0.0.6", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 063fa9a6d76e..7e30acd8252d 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -103,6 +103,7 @@ "hono-openapi": "catalog:", "ignore": "7.0.5", "jsonc-parser": "3.3.1", + "marked": "^17.0.1", "minimatch": "10.0.3", "open": "10.1.2", "opentui-spinner": "0.0.6", diff --git a/packages/opencode/src/cli/cmd/tui/util/markdown-html.ts b/packages/opencode/src/cli/cmd/tui/util/markdown-html.ts new file mode 100644 index 000000000000..eb255a13ace0 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/markdown-html.ts @@ -0,0 +1,90 @@ +import { Renderer, marked } from "marked" +import type { Tokens } from "marked" + +class CustomRenderer extends Renderer { + // Inline styles for Google Docs compatibility + override strong({ tokens }: Tokens.Strong): string { + return `${this.parser.parseInline(tokens)}` + } + + override em({ tokens }: Tokens.Em): string { + return `${this.parser.parseInline(tokens)}` + } + + override codespan({ text }: Tokens.Codespan): string { + return `${escapeHtml(text)}` + } + + override code({ text }: Tokens.Code): string { + return `
${escapeHtml(text)}
` + } + + override link({ href, title, tokens }: Tokens.Link): string { + const text = this.parser.parseInline(tokens) + return `${text}` + } + + override heading({ tokens, depth }: Tokens.Heading): string { + const sizes = ["2em", "1.5em", "1.25em", "1em", "0.875em", "0.75em"] + const text = this.parser.parseInline(tokens) + return `${text}` + } + + override list(token: Tokens.List): string { + const tag = token.ordered ? "ol" : "ul" + const body = token.items.map((item) => this.listitem(item)).join("") + return `<${tag} style="margin: 0.5em 0; padding-left: 1.5em;">${body}` + } + + override listitem(item: Tokens.ListItem): string { + const text = this.parser.parse(item.tokens) + return `
  • ${text}
  • ` + } + + override blockquote({ tokens }: Tokens.Blockquote): string { + const text = this.parser.parse(tokens) + return `
    ${text}
    ` + } + + override paragraph({ tokens }: Tokens.Paragraph): string { + const text = this.parser.parseInline(tokens) + return `

    ${text}

    ` + } + + override table(token: Tokens.Table): string { + const header = token.header.map((cell) => this.tablecell(cell)).join("") + const headerRow = `${header}` + const bodyRows = token.rows + .map((row) => { + const cells = row.map((cell) => this.tablecell(cell)).join("") + return `${cells}` + }) + .join("") + return `${headerRow}${bodyRows}
    ` + } + + override tablecell(token: Tokens.TableCell): string { + const tag = token.header ? "th" : "td" + const style = "border: 1px solid #ddd; padding: 8px;" + const text = this.parser.parseInline(token.tokens) + return `<${tag} style="${style}">${text}` + } + + override hr(): string { + return `
    ` + } +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +export function markdownToHtml(markdown: string): string { + const renderer = new CustomRenderer() + return marked(markdown, { renderer, async: false }) as string +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 020e626cba89..d88fcef142ed 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -810,6 +810,11 @@ export namespace Config { .enum(["auto", "stacked"]) .optional() .describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"), + copy_as_rich_text: z + .boolean() + .optional() + .default(false) + .describe("Copy markdown responses as rich text (HTML) instead of plain text"), }) export const Server = z diff --git a/packages/opencode/test/cli/tui/markdown-html.test.ts b/packages/opencode/test/cli/tui/markdown-html.test.ts new file mode 100644 index 000000000000..c6c7499151fd --- /dev/null +++ b/packages/opencode/test/cli/tui/markdown-html.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, test } from "bun:test" +import { markdownToHtml } from "../../../src/cli/cmd/tui/util/markdown-html" + +describe("markdownToHtml", () => { + describe("inline formatting", () => { + test("converts bold text", () => { + const result = markdownToHtml("**bold text**") + expect(result).toContain('bold text') + }) + + test("converts italic text", () => { + const result = markdownToHtml("*italic text*") + expect(result).toContain('italic text') + }) + + test("converts inline code", () => { + const result = markdownToHtml("`code snippet`") + expect(result).toContain( + 'code snippet', + ) + }) + + test("converts combined formatting", () => { + const result = markdownToHtml("**bold** and *italic* and `code`") + expect(result).toContain('bold') + expect(result).toContain('italic') + expect(result).toContain("code") + }) + }) + + describe("headers", () => { + test("converts h1", () => { + const result = markdownToHtml("# Header 1") + expect(result).toContain('

    Header 1

    ') + }) + + test("converts h2", () => { + const result = markdownToHtml("## Header 2") + expect(result).toContain('

    Header 2

    ') + }) + + test("converts h3", () => { + const result = markdownToHtml("### Header 3") + expect(result).toContain('

    Header 3

    ') + }) + + test("converts h4", () => { + const result = markdownToHtml("#### Header 4") + expect(result).toContain('

    Header 4

    ') + }) + + test("converts h5", () => { + const result = markdownToHtml("##### Header 5") + expect(result).toContain('
    Header 5
    ') + }) + + test("converts h6", () => { + const result = markdownToHtml("###### Header 6") + expect(result).toContain('
    Header 6
    ') + }) + }) + + describe("lists", () => { + test("converts unordered list", () => { + const result = markdownToHtml("- Item 1\n- Item 2\n- Item 3") + expect(result).toContain('") + }) + + test("converts ordered list", () => { + const result = markdownToHtml("1. First\n2. Second\n3. Third") + expect(result).toContain('
      ') + expect(result).toContain("
    1. First
    2. ") + expect(result).toContain("
    3. Second
    4. ") + expect(result).toContain("
    5. Third
    6. ") + expect(result).toContain("
    ") + }) + }) + + describe("links", () => { + test("converts link without title", () => { + const result = markdownToHtml("[OpenCode](https://opencode.ai)") + expect(result).toContain( + 'OpenCode', + ) + }) + + test("converts link with title", () => { + const result = markdownToHtml('[OpenCode](https://opencode.ai "OpenCode Website")') + expect(result).toContain('OpenCode") + }) + }) + + describe("blockquotes", () => { + test("converts blockquote", () => { + const result = markdownToHtml("> This is a quote") + expect(result).toContain( + '
    ', + ) + expect(result).toContain("This is a quote") + expect(result).toContain("
    ") + }) + }) + + describe("code blocks", () => { + test("converts code block", () => { + const result = markdownToHtml("```\nconst x = 1\nconsole.log(x)\n```") + expect(result).toContain("") + expect(result).toContain("const x = 1") + expect(result).toContain("console.log(x)") + expect(result).toContain("") + expect(result).toContain("") + }) + + test("converts code block with language", () => { + const result = markdownToHtml("```javascript\nconst x = 1\n```") + expect(result).toContain("") + expect(result).toContain("const x = 1") + }) + }) + + describe("tables", () => { + test("converts table", () => { + const markdown = `| Header 1 | Header 2 | +|----------|----------| +| Cell 1 | Cell 2 | +| Cell 3 | Cell 4 |` + + const result = markdownToHtml(markdown) + expect(result).toContain('') + expect(result).toContain("") + expect(result).toContain("") + expect(result).toContain('') + expect(result).toContain('') + expect(result).toContain('') + expect(result).toContain('') + }) + }) + + describe("horizontal rules", () => { + test("converts horizontal rule", () => { + const result = markdownToHtml("---") + expect(result).toContain('
    ') + }) + }) + + describe("paragraphs", () => { + test("converts paragraph", () => { + const result = markdownToHtml("This is a paragraph.") + expect(result).toContain('

    This is a paragraph.

    ') + }) + + test("converts multiple paragraphs", () => { + const result = markdownToHtml("First paragraph.\n\nSecond paragraph.") + expect(result).toContain("First paragraph") + expect(result).toContain("Second paragraph") + }) + }) + + describe("HTML escaping", () => { + test("escapes HTML in inline code", () => { + const result = markdownToHtml("``") + expect(result).toContain("<script>") + expect(result).toContain("</script>") + expect(result).not.toContain("
    Header 1Header 2Cell 1Cell 2