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
This commit is contained in:
Shantur Rathore
2025-10-23 10:07:17 +01:00
parent 7cf0f9a179
commit b836086978
9 changed files with 1130 additions and 35 deletions

View File

@@ -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(`<pre><code>${escapeHtml(props.code)}</code></pre>`)
return
}
try {
const highlighted = hl.codeToHtml(props.code, {
lang: props.language,
theme: isDark() ? "github-dark" : "github-light",
})
setHtml(highlighted)
} catch {
setHtml(`<pre><code>${escapeHtml(props.code)}</code></pre>`)
}
}
const copyCode = async () => {
await navigator.clipboard.writeText(props.code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<Show
when={ready()}
fallback={
<pre class="tool-call-content">
<code>{props.code}</code>
</pre>
}
>
<div class="code-block-inline">
<div class="code-block-header">
<Show when={props.language}>
<span class="code-block-language">{props.language}</span>
</Show>
<button onClick={copyCode} class="code-block-copy">
<svg
class="copy-icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">
<Show when={copied()} fallback="Copy">
Copied!
</Show>
</span>
</button>
</div>
<div innerHTML={html()} />
</div>
</Show>
)
}
function escapeHtml(text: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
}
return text.replace(/[&<>"']/g, (m) => map[m])
}

View File

@@ -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
? `<span class="code-block-language">${lang}</span>`
: '<span class="code-block-language"></span>'
header.innerHTML = `
${languageSpan}
<button class="code-block-copy" data-code="${encodedCode || ""}">
<svg class="copy-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="copy-text">Copy</span>
</button>
`
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 (
<Show when={ready()} fallback={<div class="text-gray-500">Loading...</div>}>
<div ref={containerRef} class="prose prose-sm dark:prose-invert max-w-none" innerHTML={html()} />
</Show>
)
}

View File

@@ -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) {
<Switch>
<Match when={partType() === "text"}>
<Show when={!props.part.synthetic && props.part.text}>
<div class="message-text">{props.part.text}</div>
<div class="message-text">
<Markdown content={props.part.text} isDark={isDark()} />
</div>
</Show>
</Match>
@@ -40,7 +45,9 @@ export default function MessagePart(props: MessagePartProps) {
<span class="reasoning-label">Reasoning</span>
</div>
<Show when={isReasoningExpanded()}>
<div class="message-text mt-2">{props.part.text || ""}</div>
<div class="message-text mt-2">
<Markdown content={props.part.text || ""} isDark={isDark()} />
</div>
</Show>
</div>
</div>

View File

@@ -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<string, string> = {
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 (
<pre class="tool-call-content">
<code>{truncated}</code>
</pre>
)
const language = getLanguageFromPath(input.filePath)
return <CodeBlockInline code={truncated} language={language} />
}
return null
@@ -281,9 +315,7 @@ export default function ToolCall(props: ToolCallProps) {
if (diff) {
return (
<div class="tool-call-diff">
<pre class="tool-call-content">
<code>{diff}</code>
</pre>
<CodeBlockInline code={diff} language="diff" />
</div>
)
}
@@ -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 (
<pre class="tool-call-content">
<code>{truncated}</code>
</pre>
)
const language = getLanguageFromPath(input.filePath)
return <CodeBlockInline code={truncated} language={language} />
}
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 (
<div class="tool-call-bash">
<pre class="tool-call-content">
<code>
$ {input.command}
{output && "\n"}
{output}
</code>
</pre>
<CodeBlockInline code={fullOutput} language="bash" />
</div>
)
}
@@ -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 (
<pre class="tool-call-content">
<code>{truncated}</code>
</pre>
)
return <CodeBlockInline code={truncated} language="markdown" />
}
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 (
<pre class="tool-call-content">
<code>{truncated}</code>
</pre>
)
return <CodeBlockInline code={truncated} />
}
return null

View File

@@ -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;
}

98
src/lib/markdown.ts Normal file
View File

@@ -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 `<div class="markdown-code-block" data-language="" data-code="${encodedCode}"><pre><code>${escapeHtml(code)}</code></pre></div>`
}
try {
const html = hl.codeToHtml(code, {
lang,
theme: isDark ? "github-dark" : "github-light",
})
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}">${html}</div>`
} catch {
return `<div class="markdown-code-block" data-language="${escapedLang}" data-code="${encodedCode}"><pre><code class="language-${escapedLang}">${escapeHtml(code)}</code></pre></div>`
}
}
renderer.link = (href: string, title: string | null | undefined, text: string) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""
return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`
}
renderer.codespan = (code: string) => {
return `<code class="inline-code">${escapeHtml(code)}</code>`
}
marked.use({ renderer })
}
export async function renderMarkdown(content: string): Promise<string> {
if (!highlighter) {
await initMarkdown(currentTheme === "dark")
}
return marked.parse(content) as Promise<string>
}
function escapeHtml(text: string): string {
const map: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
}
return text.replace(/[&<>"']/g, (m) => map[m])
}

49
src/lib/theme.tsx Normal file
View File

@@ -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<ThemeContextValue>()
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 <ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>{props.children}</ThemeContext.Provider>
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error("useTheme must be used within ThemeProvider")
}
return context
}

View File

@@ -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(() => <App />, root)
render(
() => (
<ThemeProvider>
<App />
</ThemeProvider>
),
root,
)