Restored from origin/main (b4663fb): - .github/ workflows and issue templates - .gitignore (proper exclusions) - .opencode/agent/web_developer.md - AGENTS.md, BUILD.md, PROGRESS.md - dev-docs/ (9 architecture/implementation docs) - docs/screenshots/ (4 UI screenshots) - images/ (CodeNomad icons) - package-lock.json (dependency lockfile) - tasks/ (25+ project task files) Also restored original source files that were modified: - packages/ui/src/App.tsx - packages/ui/src/lib/logger.ts - packages/ui/src/stores/instances.ts - packages/server/src/server/routes/workspaces.ts - packages/server/src/workspaces/manager.ts - packages/server/src/workspaces/runtime.ts - packages/server/package.json Kept new additions: - Install-*.bat/.sh (enhanced installers) - Launch-*.bat/.sh (new launchers) - README.md (SEO optimized with GLM 4.7)
9.7 KiB
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
-
Markdown Parser Integration
- Use
markedlibrary for markdown parsing - Configure for safe HTML rendering
- Support GitHub-flavored markdown
- Use
-
Syntax Highlighting
- Use
shikifor code block highlighting - Support light and dark themes
- Support common languages: TypeScript, JavaScript, Python, Bash, JSON, HTML, CSS, etc.
- Use
-
Code Block Features
- Language label displayed
- Copy button on hover
- Line numbers (optional for MVP)
-
Inline Code
- Distinct background color
- Monospace font
- Subtle padding
-
Links
- Open in external browser
- Show external link icon
- Prevent opening in same window
Technical Requirements
-
Dependencies
- Install
markedand@types/marked - Install
shiki - Install
marked-highlightfor integration
- Install
-
Theme Support
- Light mode:
github-lighttheme - Dark mode:
github-darktheme - Respect system theme preference
- Light mode:
-
Security
- Sanitize HTML output
- No script execution
- Safe link handling
-
Performance
- Lazy load Shiki highlighter
- Cache highlighter instance
- Don't re-parse unchanged messages
Implementation Steps
Step 1: Install Dependencies
cd packages/opencode-client
npm install marked shiki
npm install -D @types/marked
Step 2: Create Markdown Utility
Create src/lib/markdown.ts:
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 `<pre><code>${escapeHtml(code)}</code></pre>`
}
try {
const html = hl.codeToHtml(code, {
lang: language,
theme: isDark ? "github-dark" : "github-light",
})
return html
} catch (e) {
return `<pre><code class="language-${language}">${escapeHtml(code)}</code></pre>`
}
}
renderer.link = (href: string, title: string | null, text: string) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""
return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
}
marked.use({ renderer })
}
export function renderMarkdown(content: string): string {
return marked.parse(content) as string
}
function escapeHtml(text: string): string {
const map: Record<string, string> = {
"&": "&",
"<": "<",
">": ">",
'"': """,
"'": "'",
}
return text.replace(/[&<>"']/g, (m) => map[m])
}
Step 3: Create Markdown Component
Create src/components/markdown.tsx:
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 (
<div
class="prose prose-sm dark:prose-invert max-w-none"
innerHTML={html()}
/>
)
}
Step 4: Add Copy Button to Code Blocks
Create src/components/code-block.tsx:
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 (
<div class="relative group">
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={copyCode}
class="px-2 py-1 text-xs bg-gray-700 text-white rounded hover:bg-gray-600"
>
<Show when={copied()} fallback="Copy">
Copied!
</Show>
</button>
</div>
<Show when={props.language}>
<div class="text-xs text-gray-500 dark:text-gray-400 px-4 pt-2">
{props.language}
</div>
</Show>
<div innerHTML={props.code} />
</div>
)
}
Step 5: Update Message Component
Update src/components/message-item.tsx to use Markdown component:
import { Markdown } from './markdown'
// In the assistant message rendering:
<For each={textParts()}>
{(part) => (
<Markdown
content={part.content}
isDark={/* get from theme context */}
/>
)}
</For>
Step 6: Add Markdown Styles
Add to src/index.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:
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 (
<ThemeContext.Provider value={{ isDark, toggleTheme }}>
{props.children}
</ThemeContext.Provider>
)
}
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:
- Headings:
# Heading 1\n## Heading 2 - Code blocks:
```typescript\nconst x = 1\n``` - Inline code:
`npm install` - Lists:
- Item 1\n- Item 2 - Links:
[OpenCode](https://opencode.ai) - Bold/Italic:
**bold** and *italic* - 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