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:
129
src/components/code-block-inline.tsx
Normal file
129
src/components/code-block-inline.tsx
Normal 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> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
}
|
||||
return text.replace(/[&<>"']/g, (m) => map[m])
|
||||
}
|
||||
95
src/components/markdown.tsx
Normal file
95
src/components/markdown.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user