From b83608697844b682a8e91cde9325bc347e99bb26 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 23 Oct 2025 10:07:17 +0100 Subject: [PATCH] Add markdown rendering with syntax highlighting and copy buttons - Implement markdown parser using marked with Shiki syntax highlighting - Add CodeBlockInline component for tool call outputs with syntax highlighting - Add Markdown component for assistant message text with code blocks - Add ThemeProvider for light/dark mode support - Add copy buttons to all code blocks (markdown and tool calls) - Support 20+ languages: TypeScript, JavaScript, Python, Bash, JSON, HTML, CSS, C++, Java, C, C#, Rust, Go, PHP, Ruby, Swift, Kotlin, and more - Auto-detect language from file extensions in tool call outputs - Apply consistent styling for code blocks across the application - Fix whitespace handling in markdown-rendered text - Add language labels to all code blocks --- src/components/code-block-inline.tsx | 129 +++++++++ src/components/markdown.tsx | 95 ++++++ src/components/message-part.tsx | 11 +- src/components/tool-call.tsx | 76 +++-- src/index.css | 280 +++++++++++++++++- src/lib/markdown.ts | 98 +++++++ src/lib/theme.tsx | 49 ++++ src/main.tsx | 10 +- tasks/done/012-markdown-rendering.md | 417 +++++++++++++++++++++++++++ 9 files changed, 1130 insertions(+), 35 deletions(-) create mode 100644 src/components/code-block-inline.tsx create mode 100644 src/components/markdown.tsx create mode 100644 src/lib/markdown.ts create mode 100644 src/lib/theme.tsx create mode 100644 tasks/done/012-markdown-rendering.md diff --git a/src/components/code-block-inline.tsx b/src/components/code-block-inline.tsx new file mode 100644 index 00000000..51d144db --- /dev/null +++ b/src/components/code-block-inline.tsx @@ -0,0 +1,129 @@ +import { createSignal, onMount, Show } from "solid-js" +import { getHighlighter, type Highlighter } from "shiki" +import { useTheme } from "../lib/theme" + +interface CodeBlockInlineProps { + code: string + language?: string +} + +let highlighter: Highlighter | null = null + +async function getOrCreateHighlighter() { + if (!highlighter) { + highlighter = await getHighlighter({ + themes: ["github-light", "github-dark"], + langs: [ + "typescript", + "javascript", + "python", + "bash", + "json", + "html", + "css", + "markdown", + "yaml", + "sql", + "rust", + "go", + "cpp", + "c", + "java", + "csharp", + "php", + "ruby", + "swift", + "kotlin", + "diff", + "shell", + ], + }) + } + return highlighter +} + +export function CodeBlockInline(props: CodeBlockInlineProps) { + const { isDark } = useTheme() + const [html, setHtml] = createSignal("") + const [copied, setCopied] = createSignal(false) + const [ready, setReady] = createSignal(false) + + onMount(async () => { + const hl = await getOrCreateHighlighter() + setReady(true) + updateHighlight(hl) + }) + + const updateHighlight = async (hl: Highlighter) => { + if (!props.language) { + setHtml(`
${escapeHtml(props.code)}
`) + return + } + + try { + const highlighted = hl.codeToHtml(props.code, { + lang: props.language, + theme: isDark() ? "github-dark" : "github-light", + }) + setHtml(highlighted) + } catch { + setHtml(`
${escapeHtml(props.code)}
`) + } + } + + const copyCode = async () => { + await navigator.clipboard.writeText(props.code) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + {props.code} + + } + > +
+
+ + {props.language} + + +
+
+
+ + ) +} + +function escapeHtml(text: string): string { + const map: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + } + return text.replace(/[&<>"']/g, (m) => map[m]) +} diff --git a/src/components/markdown.tsx b/src/components/markdown.tsx new file mode 100644 index 00000000..450e0b10 --- /dev/null +++ b/src/components/markdown.tsx @@ -0,0 +1,95 @@ +import { createEffect, createSignal, onMount, Show } from "solid-js" +import { initMarkdown, renderMarkdown } from "../lib/markdown" + +interface MarkdownProps { + content: string + isDark?: boolean +} + +export function Markdown(props: MarkdownProps) { + const [html, setHtml] = createSignal("") + const [ready, setReady] = createSignal(false) + let containerRef: HTMLDivElement | undefined + + onMount(async () => { + await initMarkdown(props.isDark ?? false) + setReady(true) + }) + + createEffect(async () => { + if (ready()) { + const rendered = await renderMarkdown(props.content) + setHtml(rendered) + } + }) + + createEffect(async () => { + if (props.isDark !== undefined) { + await initMarkdown(props.isDark) + if (ready()) { + const rendered = await renderMarkdown(props.content) + setHtml(rendered) + } + } + }) + + createEffect(() => { + const currentHtml = html() + if (containerRef && currentHtml) { + setTimeout(() => { + const codeBlocks = containerRef?.querySelectorAll(".markdown-code-block") + + codeBlocks?.forEach((block) => { + const existing = block.querySelector(".code-block-header") + if (existing) return + + const lang = block.getAttribute("data-language") + const encodedCode = block.getAttribute("data-code") + + const header = document.createElement("div") + header.className = "code-block-header" + + const languageSpan = lang + ? `${lang}` + : '' + + header.innerHTML = ` + ${languageSpan} + + ` + block.insertBefore(header, block.firstChild) + + const button = header.querySelector(".code-block-copy") + if (button) { + button.addEventListener("click", async () => { + const code = button.getAttribute("data-code") + if (code) { + const decodedCode = decodeURIComponent(code) + await navigator.clipboard.writeText(decodedCode) + const copyText = button.querySelector(".copy-text") + if (copyText) { + copyText.textContent = "Copied!" + setTimeout(() => { + copyText.textContent = "Copy" + }, 2000) + } + } + }) + } + }) + }, 0) + } + }) + + return ( + Loading...
}> +
+ + ) +} diff --git a/src/components/message-part.tsx b/src/components/message-part.tsx index 303b949d..b59a2822 100644 --- a/src/components/message-part.tsx +++ b/src/components/message-part.tsx @@ -1,12 +1,15 @@ import { Show, Match, Switch } from "solid-js" import ToolCall from "./tool-call" import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state" +import { Markdown } from "./markdown" +import { useTheme } from "../lib/theme" interface MessagePartProps { part: any } export default function MessagePart(props: MessagePartProps) { + const { isDark } = useTheme() const partType = () => props.part?.type || "" const reasoningId = () => `reasoning-${props.part?.id || ""}` const isReasoningExpanded = () => isItemExpanded(reasoningId()) @@ -20,7 +23,9 @@ export default function MessagePart(props: MessagePartProps) { -
{props.part.text}
+
+ +
@@ -40,7 +45,9 @@ export default function MessagePart(props: MessagePartProps) { Reasoning
-
{props.part.text || ""}
+
+ +
diff --git a/src/components/tool-call.tsx b/src/components/tool-call.tsx index 7237918e..fa250e80 100644 --- a/src/components/tool-call.tsx +++ b/src/components/tool-call.tsx @@ -1,5 +1,6 @@ import { createSignal, Show, For, createEffect } from "solid-js" import { isToolCallExpanded, toggleToolCallExpanded } from "../stores/tool-call-state" +import { CodeBlockInline } from "./code-block-inline" interface ToolCallProps { toolCall: any @@ -59,6 +60,42 @@ function getRelativePath(path: string): string { return parts.slice(-1)[0] || path } +function getLanguageFromPath(path: string): string | undefined { + if (!path) return undefined + const ext = path.split(".").pop()?.toLowerCase() + const langMap: Record = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + py: "python", + sh: "bash", + bash: "bash", + json: "json", + html: "html", + css: "css", + md: "markdown", + yaml: "yaml", + yml: "yaml", + sql: "sql", + rs: "rust", + go: "go", + cpp: "cpp", + cc: "cpp", + cxx: "cpp", + hpp: "cpp", + h: "cpp", + c: "c", + java: "java", + cs: "csharp", + php: "php", + rb: "ruby", + swift: "swift", + kt: "kotlin", + } + return ext ? langMap[ext] : undefined +} + export default function ToolCall(props: ToolCallProps) { const toolCallId = () => props.toolCallId || props.toolCall?.id || "" const expanded = () => isToolCallExpanded(toolCallId()) @@ -263,11 +300,8 @@ export default function ToolCall(props: ToolCallProps) { if (preview && input.filePath) { const lines = preview.split("\n") const truncated = lines.slice(0, 6).join("\n") - return ( -
-          {truncated}
-        
- ) + const language = getLanguageFromPath(input.filePath) + return } return null @@ -281,9 +315,7 @@ export default function ToolCall(props: ToolCallProps) { if (diff) { return (
-
-            {diff}
-          
+
) } @@ -298,11 +330,8 @@ export default function ToolCall(props: ToolCallProps) { if (input.content && input.filePath) { const lines = input.content.split("\n") const truncated = lines.slice(0, 10).join("\n") - return ( -
-          {truncated}
-        
- ) + const language = getLanguageFromPath(input.filePath) + return } return null @@ -315,15 +344,10 @@ export default function ToolCall(props: ToolCallProps) { const output = metadata.output if (input.command) { + const fullOutput = `$ ${input.command}${output ? "\n" + output : ""}` return (
-
-            
-              $ {input.command}
-              {output && "\n"}
-              {output}
-            
-          
+
) } @@ -338,11 +362,7 @@ export default function ToolCall(props: ToolCallProps) { if (output) { const lines = output.split("\n") const truncated = lines.slice(0, 10).join("\n") - return ( -
-          {truncated}
-        
- ) + return } return null @@ -428,11 +448,7 @@ export default function ToolCall(props: ToolCallProps) { if (output) { const lines = output.split("\n") const truncated = lines.slice(0, 10).join("\n") - return ( -
-          {truncated}
-        
- ) + return } return null diff --git a/src/index.css b/src/index.css index f9522b54..477bbdd4 100644 --- a/src/index.css +++ b/src/index.css @@ -195,10 +195,8 @@ body { .message-text { font-size: 14px; line-height: 1.5; - white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; - hyphens: auto; } .message-text pre { @@ -753,3 +751,281 @@ body { font-style: italic; margin-top: 4px; } + +.prose { + color: #1a1a1a; +} + +.prose code { + background-color: #f1f5f9; + color: #1e293b; + padding: 2px 6px; + border-radius: 4px; + font-size: 0.9em; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; +} + +.prose pre { + background-color: transparent; + padding: 0; + margin: 0; + overflow: visible; +} + +.prose pre code { + background: transparent; + padding: 0; + border-radius: 0; + font-size: 0.875em; +} + +.prose a { + color: #0066ff; + text-decoration: none; +} + +.prose a:hover { + text-decoration: underline; +} + +.prose blockquote { + border-left: 4px solid #e0e0e0; + padding-left: 16px; + font-style: italic; + color: #666; + margin: 12px 0; +} + +.prose ul, +.prose ol { + margin: 8px 0; + padding-left: 24px; +} + +.prose ul { + list-style-type: disc; +} + +.prose ol { + list-style-type: decimal; +} + +.prose li { + margin: 4px 0; +} + +.prose h1 { + font-size: 1.5em; + font-weight: 700; + margin: 16px 0 12px 0; + line-height: 1.3; +} + +.prose h2 { + font-size: 1.25em; + font-weight: 700; + margin: 14px 0 10px 0; + line-height: 1.3; +} + +.prose h3 { + font-size: 1.1em; + font-weight: 600; + margin: 12px 0 8px 0; + line-height: 1.3; +} + +.prose table { + border-collapse: collapse; + width: 100%; + margin: 12px 0; + font-size: 0.9em; +} + +.prose th { + border: 1px solid #e0e0e0; + padding: 8px 12px; + background-color: #f5f5f5; + font-weight: 600; + text-align: left; +} + +.prose td { + border: 1px solid #e0e0e0; + padding: 8px 12px; +} + +.prose p { + margin: 8px 0; +} + +.prose hr { + border: none; + border-top: 1px solid #e0e0e0; + margin: 16px 0; +} + +[data-theme="dark"] .prose { + color: #e0e0e0; +} + +[data-theme="dark"] .prose code { + background-color: #2a2a2a; + color: #e0e0e0; +} + +[data-theme="dark"] .prose a { + color: #0080ff; +} + +[data-theme="dark"] .prose blockquote { + border-left-color: #3a3a3a; + color: #999; +} + +[data-theme="dark"] .prose th { + border-color: #3a3a3a; + background-color: #2a2a2a; +} + +[data-theme="dark"] .prose td { + border-color: #3a3a3a; +} + +[data-theme="dark"] .prose hr { + border-top-color: #3a3a3a; +} + +.markdown-code-block { + position: relative; + margin: 12px 0; + border-radius: 6px; + overflow: hidden; + background-color: #f8f9fa; + border: 1px solid #e0e0e0; +} + +[data-theme="dark"] .markdown-code-block { + background-color: #1a1a1a; + border-color: #3a3a3a; +} + +.code-block-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background-color: #f1f5f9; + border-bottom: 1px solid #e0e0e0; +} + +[data-theme="dark"] .code-block-header { + background-color: #2a2a2a; + border-bottom-color: #3a3a3a; +} + +.code-block-language { + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + color: #666; + font-weight: 500; + text-transform: uppercase; +} + +[data-theme="dark"] .code-block-language { + color: #999; +} + +.code-block-copy { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + font-size: 12px; + background-color: transparent; + border: 1px solid #e0e0e0; + border-radius: 4px; + cursor: pointer; + color: #666; + transition: all 150ms ease; +} + +[data-theme="dark"] .code-block-copy { + border-color: #3a3a3a; + color: #999; +} + +.code-block-copy:hover { + background-color: #e0e0e0; + border-color: #ccc; +} + +[data-theme="dark"] .code-block-copy:hover { + background-color: #3a3a3a; + border-color: #4a4a4a; +} + +.code-block-copy .copy-icon { + width: 14px; + height: 14px; +} + +.code-block-copy .copy-text { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; +} + +.markdown-code-block pre { + margin: 0 !important; + padding: 12px !important; + overflow-x: auto; + background-color: transparent !important; +} + +.markdown-code-block code { + background: transparent !important; + padding: 0 !important; + font-size: 13px !important; + line-height: 1.6; +} + +.code-block-inline { + position: relative; + margin: 8px 0; + border-radius: 6px; + overflow: hidden; + background-color: #f8f9fa; + border: 1px solid #e0e0e0; +} + +[data-theme="dark"] .code-block-inline { + background-color: #1a1a1a; + border-color: #3a3a3a; +} + +.code-block-inline .code-block-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + background-color: #f1f5f9; + border-bottom: 1px solid #e0e0e0; +} + +[data-theme="dark"] .code-block-inline .code-block-header { + background-color: #2a2a2a; + border-bottom-color: #3a3a3a; +} + +.code-block-inline pre { + margin: 0 !important; + padding: 10px !important; + overflow-x: auto; + background-color: transparent !important; +} + +.code-block-inline code { + background: transparent !important; + padding: 0 !important; + font-size: 12px !important; + line-height: 1.5; +} diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts new file mode 100644 index 00000000..4d4df5d4 --- /dev/null +++ b/src/lib/markdown.ts @@ -0,0 +1,98 @@ +import { marked } from "marked" +import { getHighlighter, type Highlighter } from "shiki" + +let highlighter: Highlighter | null = null +let currentTheme: "light" | "dark" = "light" + +async function getOrCreateHighlighter() { + if (!highlighter) { + highlighter = await getHighlighter({ + themes: ["github-light", "github-dark"], + langs: [ + "typescript", + "javascript", + "python", + "bash", + "json", + "html", + "css", + "markdown", + "yaml", + "sql", + "rust", + "go", + "cpp", + "c", + "java", + "csharp", + "php", + "ruby", + "swift", + "kotlin", + "diff", + "shell", + ], + }) + } + return highlighter +} + +export async function initMarkdown(isDark: boolean) { + const hl = await getOrCreateHighlighter() + currentTheme = isDark ? "dark" : "light" + + marked.setOptions({ + breaks: true, + gfm: true, + }) + + const renderer = new marked.Renderer() + + renderer.code = (code: string, lang: string | undefined) => { + const encodedCode = encodeURIComponent(code) + const escapedLang = lang ? escapeHtml(lang) : "" + + if (!lang) { + return `
${escapeHtml(code)}
` + } + + try { + const html = hl.codeToHtml(code, { + lang, + theme: isDark ? "github-dark" : "github-light", + }) + return `
${html}
` + } catch { + return `
${escapeHtml(code)}
` + } + } + + renderer.link = (href: string, title: string | null | undefined, text: string) => { + const titleAttr = title ? ` title="${escapeHtml(title)}"` : "" + return `${text}` + } + + renderer.codespan = (code: string) => { + return `${escapeHtml(code)}` + } + + marked.use({ renderer }) +} + +export async function renderMarkdown(content: string): Promise { + if (!highlighter) { + await initMarkdown(currentTheme === "dark") + } + return marked.parse(content) as Promise +} + +function escapeHtml(text: string): string { + const map: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + } + return text.replace(/[&<>"']/g, (m) => map[m]) +} diff --git a/src/lib/theme.tsx b/src/lib/theme.tsx new file mode 100644 index 00000000..8b024721 --- /dev/null +++ b/src/lib/theme.tsx @@ -0,0 +1,49 @@ +import { createContext, createSignal, useContext, onMount, type JSX } from "solid-js" + +interface ThemeContextValue { + isDark: () => boolean + toggleTheme: () => void + setTheme: (dark: boolean) => void +} + +const ThemeContext = createContext() + +export function ThemeProvider(props: { children: JSX.Element }) { + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches + const savedTheme = localStorage.getItem("theme") + const initialDark = savedTheme ? savedTheme === "dark" : prefersDark + + const [isDark, setIsDarkSignal] = createSignal(initialDark) + + onMount(() => { + if (isDark()) { + document.documentElement.setAttribute("data-theme", "dark") + } else { + document.documentElement.removeAttribute("data-theme") + } + }) + + const setTheme = (dark: boolean) => { + setIsDarkSignal(dark) + localStorage.setItem("theme", dark ? "dark" : "light") + if (dark) { + document.documentElement.setAttribute("data-theme", "dark") + } else { + document.documentElement.removeAttribute("data-theme") + } + } + + const toggleTheme = () => { + setTheme(!isDark()) + } + + return {props.children} +} + +export function useTheme() { + const context = useContext(ThemeContext) + if (!context) { + throw new Error("useTheme must be used within ThemeProvider") + } + return context +} diff --git a/src/main.tsx b/src/main.tsx index 470e224a..ff044f22 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,5 +1,6 @@ import { render } from "solid-js/web" import App from "./App" +import { ThemeProvider } from "./lib/theme" import "./index.css" const root = document.getElementById("root") @@ -8,4 +9,11 @@ if (!root) { throw new Error("Root element not found") } -render(() => , root) +render( + () => ( + + + + ), + root, +) diff --git a/tasks/done/012-markdown-rendering.md b/tasks/done/012-markdown-rendering.md new file mode 100644 index 00000000..e3638c8c --- /dev/null +++ b/tasks/done/012-markdown-rendering.md @@ -0,0 +1,417 @@ +# Task 012: Markdown Rendering + +**Status:** Todo +**Estimated Time:** 3-4 hours +**Phase:** 3 - Essential Features +**Dependencies:** 007 (Message Display) + +## Overview + +Implement proper markdown rendering for assistant messages with syntax-highlighted code blocks. Replace basic text display with rich markdown formatting using Marked and Shiki. + +## Context + +Currently messages display as plain text. We need to parse and render markdown content from assistant messages, including: + +- Headings, bold, italic, links +- Code blocks with syntax highlighting +- Inline code +- Lists (ordered and unordered) +- Blockquotes +- Tables (if needed) + +## Requirements + +### Functional Requirements + +1. **Markdown Parser Integration** + - Use `marked` library for markdown parsing + - Configure for safe HTML rendering + - Support GitHub-flavored markdown + +2. **Syntax Highlighting** + - Use `shiki` for code block highlighting + - Support light and dark themes + - Support common languages: TypeScript, JavaScript, Python, Bash, JSON, HTML, CSS, etc. + +3. **Code Block Features** + - Language label displayed + - Copy button on hover + - Line numbers (optional for MVP) + +4. **Inline Code** + - Distinct background color + - Monospace font + - Subtle padding + +5. **Links** + - Open in external browser + - Show external link icon + - Prevent opening in same window + +### Technical Requirements + +1. **Dependencies** + - Install `marked` and `@types/marked` + - Install `shiki` + - Install `marked-highlight` for integration + +2. **Theme Support** + - Light mode: `github-light` theme + - Dark mode: `github-dark` theme + - Respect system theme preference + +3. **Security** + - Sanitize HTML output + - No script execution + - Safe link handling + +4. **Performance** + - Lazy load Shiki highlighter + - Cache highlighter instance + - Don't re-parse unchanged messages + +## Implementation Steps + +### Step 1: Install Dependencies + +```bash +cd packages/opencode-client +npm install marked shiki +npm install -D @types/marked +``` + +### Step 2: Create Markdown Utility + +Create `src/lib/markdown.ts`: + +```typescript +import { marked } from "marked" +import { getHighlighter, type Highlighter } from "shiki" + +let highlighter: Highlighter | null = null + +async function getOrCreateHighlighter() { + if (!highlighter) { + highlighter = await getHighlighter({ + themes: ["github-light", "github-dark"], + langs: ["typescript", "javascript", "python", "bash", "json", "html", "css", "markdown", "yaml", "sql"], + }) + } + return highlighter +} + +export async function initMarkdown(isDark: boolean) { + const hl = await getOrCreateHighlighter() + + marked.use({ + async: false, + breaks: true, + gfm: true, + }) + + const renderer = new marked.Renderer() + + renderer.code = (code: string, language: string | undefined) => { + if (!language) { + return `
${escapeHtml(code)}
` + } + + try { + const html = hl.codeToHtml(code, { + lang: language, + theme: isDark ? "github-dark" : "github-light", + }) + return html + } catch (e) { + return `
${escapeHtml(code)}
` + } + } + + renderer.link = (href: string, title: string | null, text: string) => { + const titleAttr = title ? ` title="${escapeHtml(title)}"` : "" + return `${text}` + } + + marked.use({ renderer }) +} + +export function renderMarkdown(content: string): string { + return marked.parse(content) as string +} + +function escapeHtml(text: string): string { + const map: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + } + return text.replace(/[&<>"']/g, (m) => map[m]) +} +``` + +### Step 3: Create Markdown Component + +Create `src/components/markdown.tsx`: + +```typescript +import { createEffect, createSignal, onMount } from 'solid-js' +import { initMarkdown, renderMarkdown } from '../lib/markdown' + +interface MarkdownProps { + content: string + isDark?: boolean +} + +export function Markdown(props: MarkdownProps) { + const [html, setHtml] = createSignal('') + const [ready, setReady] = createSignal(false) + + onMount(async () => { + await initMarkdown(props.isDark ?? false) + setReady(true) + }) + + createEffect(() => { + if (ready()) { + const rendered = renderMarkdown(props.content) + setHtml(rendered) + } + }) + + createEffect(async () => { + if (props.isDark !== undefined) { + await initMarkdown(props.isDark) + const rendered = renderMarkdown(props.content) + setHtml(rendered) + } + }) + + return ( +
+ ) +} +``` + +### Step 4: Add Copy Button to Code Blocks + +Create `src/components/code-block.tsx`: + +```typescript +import { createSignal, Show } from 'solid-js' + +interface CodeBlockProps { + code: string + language?: string +} + +export function CodeBlockWrapper(props: CodeBlockProps) { + const [copied, setCopied] = createSignal(false) + + const copyCode = async () => { + await navigator.clipboard.writeText(props.code) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( +
+
+ +
+ +
+ {props.language} +
+
+
+
+ ) +} +``` + +### Step 5: Update Message Component + +Update `src/components/message-item.tsx` to use Markdown component: + +```typescript +import { Markdown } from './markdown' + +// In the assistant message rendering: + + {(part) => ( + + )} + +``` + +### Step 6: Add Markdown Styles + +Add to `src/index.css`: + +```css +/* Markdown prose styles */ +.prose { + @apply text-gray-900 dark:text-gray-100; +} + +.prose code { + @apply bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-sm; +} + +.prose pre { + @apply bg-gray-50 dark:bg-gray-900 rounded-lg p-4 overflow-x-auto; +} + +.prose pre code { + @apply bg-transparent p-0; +} + +.prose a { + @apply text-blue-600 dark:text-blue-400 hover:underline; +} + +.prose blockquote { + @apply border-l-4 border-gray-300 dark:border-gray-700 pl-4 italic; +} + +.prose ul { + @apply list-disc list-inside; +} + +.prose ol { + @apply list-decimal list-inside; +} + +.prose h1 { + @apply text-2xl font-bold mb-4; +} + +.prose h2 { + @apply text-xl font-bold mb-3; +} + +.prose h3 { + @apply text-lg font-bold mb-2; +} + +.prose table { + @apply border-collapse w-full; +} + +.prose th { + @apply border border-gray-300 dark:border-gray-700 px-4 py-2 bg-gray-100 dark:bg-gray-800; +} + +.prose td { + @apply border border-gray-300 dark:border-gray-700 px-4 py-2; +} +``` + +### Step 7: Handle Theme Changes + +Create or update theme context to track light/dark mode: + +```typescript +import { createContext, createSignal, useContext } from 'solid-js' + +const ThemeContext = createContext<{ + isDark: () => boolean + toggleTheme: () => void +}>() + +export function ThemeProvider(props: { children: any }) { + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + const [isDark, setIsDark] = createSignal(prefersDark) + + const toggleTheme = () => { + setIsDark(!isDark()) + document.documentElement.classList.toggle('dark') + } + + return ( + + {props.children} + + ) +} + +export function useTheme() { + const context = useContext(ThemeContext) + if (!context) { + throw new Error('useTheme must be used within ThemeProvider') + } + return context +} +``` + +### Step 8: Test Markdown Rendering + +Test with various markdown inputs: + +1. **Headings**: `# Heading 1\n## Heading 2` +2. **Code blocks**: ` ```typescript\nconst x = 1\n``` ` +3. **Inline code**: `` `npm install` `` +4. **Lists**: `- Item 1\n- Item 2` +5. **Links**: `[OpenCode](https://opencode.ai)` +6. **Bold/Italic**: `**bold** and *italic*` +7. **Blockquotes**: `> Quote` + +## Acceptance Criteria + +- [ ] Markdown content renders with proper formatting +- [ ] Code blocks have syntax highlighting +- [ ] Light and dark themes work correctly +- [ ] Copy button appears on code block hover +- [ ] Copy button successfully copies code to clipboard +- [ ] Language label shows for code blocks +- [ ] Inline code has distinct styling +- [ ] Links open in external browser +- [ ] No XSS vulnerabilities (sanitized output) +- [ ] Theme changes update code highlighting +- [ ] Headings, lists, blockquotes render correctly +- [ ] Performance is acceptable (no lag when rendering) + +## Testing Checklist + +- [ ] Test all markdown syntax types +- [ ] Test code blocks with various languages +- [ ] Test switching between light and dark mode +- [ ] Test copy functionality +- [ ] Test external link opening +- [ ] Test very long code blocks (scrolling) +- [ ] Test malformed markdown +- [ ] Test HTML in markdown (should be escaped) + +## Notes + +- Shiki loads language grammars asynchronously, so first render may be slower +- Consider caching rendered markdown if re-rendering same content +- For MVP, don't implement line numbers or advanced code block features +- Keep the language list limited to common ones to reduce bundle size + +## Future Enhancements (Post-MVP) + +- Line numbers in code blocks +- Code block diff highlighting +- Collapsible long code blocks +- Search within code blocks +- More language support +- Custom syntax themes +- LaTeX/Math rendering +- Mermaid diagram support