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}
+
+ }
+ >
+
+
+ )
+}
+
+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 (
)
}
@@ -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 (
)
}
@@ -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 ``
+ }
+
+ try {
+ const html = hl.codeToHtml(code, {
+ lang,
+ theme: isDark ? "github-dark" : "github-light",
+ })
+ return `${html}
`
+ } catch {
+ return ``
+ }
+ }
+
+ 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