diff --git a/ui/src/components/Artifact/CodeArtifact.tsx b/ui/src/components/Artifact/CodeArtifact.tsx
index 376e0d2..97e6e8c 100644
--- a/ui/src/components/Artifact/CodeArtifact.tsx
+++ b/ui/src/components/Artifact/CodeArtifact.tsx
@@ -1,16 +1,14 @@
/**
* CodeArtifact - Syntax-Highlighted Code Output
*
- * Renders code output from tools like code_interpreter with syntax highlighting.
- * Uses a simple pre/code block approach that works with the prose styling.
+ * Renders code output from tools like code_interpreter with syntax highlighting
+ * via Shiki. Uses the shared HighlightedCode component.
*/
-import { memo, useState } from "react";
-import { Copy, Check } from "lucide-react";
+import { memo } from "react";
import type { Artifact, CodeArtifactData } from "@/components/chat-types";
-import { Button } from "@/components/Button/Button";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/Tooltip/Tooltip";
+import { HighlightedCode } from "@/components/HighlightedCode/HighlightedCode";
import { cn } from "@/utils/cn";
export interface CodeArtifactProps {
@@ -28,64 +26,21 @@ function isCodeArtifactData(data: unknown): data is CodeArtifactData {
}
function CodeArtifactComponent({ artifact, className }: CodeArtifactProps) {
- const [copied, setCopied] = useState(false);
-
- // Validate and extract data
if (!isCodeArtifactData(artifact.data)) {
return
Invalid code artifact data
;
}
const { code, language } = artifact.data;
- const handleCopy = async () => {
- await navigator.clipboard.writeText(code);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- };
-
return (
-
- {/* Copy button */}
-
-
-
-
-
- {copied ? "Copied!" : "Copy code"}
-
-
-
- {/* Language badge */}
- {language && (
-
-
- {language}
-
-
- )}
-
- {/* Code block */}
- {/* eslint-disable jsx-a11y/no-noninteractive-tabindex -- scrollable region needs keyboard access (axe: scrollable-region-focusable) */}
-
- {code}
-
- {/* eslint-enable jsx-a11y/no-noninteractive-tabindex */}
-
+
);
}
diff --git a/ui/src/components/Artifact/HtmlArtifact.tsx b/ui/src/components/Artifact/HtmlArtifact.tsx
index ee3e4d1..b32820b 100644
--- a/ui/src/components/Artifact/HtmlArtifact.tsx
+++ b/ui/src/components/Artifact/HtmlArtifact.tsx
@@ -6,11 +6,12 @@
*/
import { memo, useState, useRef, useEffect } from "react";
-import { Code2, Eye, Copy, Check, ExternalLink, Maximize2, X } from "lucide-react";
+import { Code2, Eye, ExternalLink, Maximize2, X } from "lucide-react";
import type { Artifact } from "@/components/chat-types";
import { Button } from "@/components/Button/Button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/Tooltip/Tooltip";
+import { HighlightedCode } from "@/components/HighlightedCode/HighlightedCode";
import { cn } from "@/utils/cn";
export interface HtmlArtifactProps {
@@ -69,7 +70,6 @@ function wrapHtml(content: string): string {
function HtmlArtifactComponent({ artifact, className }: HtmlArtifactProps) {
const [viewMode, setViewMode] = useState<"preview" | "source">("preview");
- const [copied, setCopied] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const iframeRef = useRef(null);
@@ -87,12 +87,6 @@ function HtmlArtifactComponent({ artifact, className }: HtmlArtifactProps) {
return Invalid HTML artifact data
;
}
- const handleCopy = async () => {
- await navigator.clipboard.writeText(html);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- };
-
const handleOpenInNewTab = () => {
const blob = new Blob([wrapHtml(html)], { type: "text/html" });
const url = URL.createObjectURL(blob);
@@ -129,21 +123,6 @@ function HtmlArtifactComponent({ artifact, className }: HtmlArtifactProps) {
-
-
-
-
- {copied ? "Copied!" : "Copy HTML"}
-
-
) : (
-
- {html}
-
+
)}
diff --git a/ui/src/components/HighlightedCode/HighlightedCode.stories.tsx b/ui/src/components/HighlightedCode/HighlightedCode.stories.tsx
new file mode 100644
index 0000000..96ea95c
--- /dev/null
+++ b/ui/src/components/HighlightedCode/HighlightedCode.stories.tsx
@@ -0,0 +1,100 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { HighlightedCode } from "./HighlightedCode";
+
+const meta = {
+ title: "Chat/HighlightedCode",
+ component: HighlightedCode,
+ parameters: {
+ layout: "padded",
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Python: Story = {
+ args: {
+ code: `import pandas as pd
+import numpy as np
+
+def analyze(data: list[float]) -> dict:
+ """Compute summary statistics."""
+ arr = np.array(data)
+ return {
+ "mean": float(arr.mean()),
+ "std": float(arr.std()),
+ "median": float(np.median(arr)),
+ }
+
+result = analyze([1, 2, 3, 4, 5])
+print(result)`,
+ language: "python",
+ showLanguage: true,
+ },
+};
+
+export const JavaScript: Story = {
+ args: {
+ code: `const fetchUsers = async () => {
+ const response = await fetch('/api/users');
+ const data = await response.json();
+ return data.users.filter(u => u.active);
+};
+
+fetchUsers().then(console.log);`,
+ language: "javascript",
+ showLanguage: true,
+ },
+};
+
+export const SQL: Story = {
+ args: {
+ code: `SELECT u.name, COUNT(o.id) AS order_count
+FROM users u
+LEFT JOIN orders o ON o.user_id = u.id
+WHERE u.created_at > '2024-01-01'
+GROUP BY u.name
+ORDER BY order_count DESC
+LIMIT 10;`,
+ language: "sql",
+ showLanguage: true,
+ },
+};
+
+export const Compact: Story = {
+ args: {
+ code: `data = [1, 2, 3, 4, 5]
+result = sum(data) / len(data)
+print(f"Average: {result}")`,
+ language: "python",
+ compact: true,
+ showCopy: false,
+ },
+};
+
+export const WithCopyButton: Story = {
+ args: {
+ code: `console.log("Hello, world!");`,
+ language: "javascript",
+ showCopy: true,
+ },
+};
+
+export const WithMaxHeight: Story = {
+ args: {
+ code: Array(50)
+ .fill(null)
+ .map((_, i) => `console.log("Line ${i + 1}");`)
+ .join("\n"),
+ language: "javascript",
+ showLanguage: true,
+ maxHeight: "200px",
+ },
+};
+
+export const UnknownLanguage: Story = {
+ args: {
+ code: "Some plain text content\nwith multiple lines\nand no highlighting",
+ language: "custom-lang",
+ },
+};
diff --git a/ui/src/components/HighlightedCode/HighlightedCode.tsx b/ui/src/components/HighlightedCode/HighlightedCode.tsx
new file mode 100644
index 0000000..c882565
--- /dev/null
+++ b/ui/src/components/HighlightedCode/HighlightedCode.tsx
@@ -0,0 +1,169 @@
+import { memo, useState, useEffect, useCallback } from "react";
+import { createHighlighter, type Highlighter } from "shiki";
+import { Copy, Check } from "lucide-react";
+
+import { Button } from "@/components/Button/Button";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/Tooltip/Tooltip";
+import { usePreferences } from "@/preferences/PreferencesProvider";
+import { cn } from "@/utils/cn";
+
+const THEMES = ["github-light-high-contrast", "github-dark"] as const;
+const PRELOADED_LANGS = [
+ "python",
+ "javascript",
+ "typescript",
+ "sql",
+ "json",
+ "html",
+ "css",
+ "bash",
+] as const;
+
+let highlighterPromise: Promise | null = null;
+
+function getHighlighter(): Promise {
+ if (!highlighterPromise) {
+ highlighterPromise = createHighlighter({
+ themes: [...THEMES],
+ langs: [...PRELOADED_LANGS],
+ });
+ }
+ return highlighterPromise;
+}
+
+export interface HighlightedCodeProps {
+ code: string;
+ language?: string;
+ className?: string;
+ /** Show copy button (default: true) */
+ showCopy?: boolean;
+ /** Show language badge (default: false) */
+ showLanguage?: boolean;
+ /** Max height with overflow scroll */
+ maxHeight?: string;
+ /** Compact mode — smaller font and padding for inline previews */
+ compact?: boolean;
+}
+
+function HighlightedCodeComponent({
+ code,
+ language,
+ className,
+ showCopy = true,
+ showLanguage = false,
+ maxHeight,
+ compact = false,
+}: HighlightedCodeProps) {
+ const { resolvedTheme } = usePreferences();
+ const [html, setHtml] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ const theme = resolvedTheme === "dark" ? "github-dark" : "github-light-high-contrast";
+
+ useEffect(() => {
+ let cancelled = false;
+
+ getHighlighter().then((highlighter) => {
+ if (cancelled) return;
+
+ const lang = language?.toLowerCase() ?? "text";
+ const loadedLangs = highlighter.getLoadedLanguages();
+
+ // Use plain text for unknown languages
+ const effectiveLang = loadedLangs.includes(lang) ? lang : "text";
+
+ const result = highlighter.codeToHtml(code, {
+ lang: effectiveLang,
+ theme,
+ });
+ setHtml(result);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [code, language, theme]);
+
+ const handleCopy = useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(code);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (error) {
+ console.error("Failed to copy to clipboard:", error);
+ }
+ }, [code]);
+
+ return (
+
+ {showCopy && (
+
+
+
+
+
+ {copied ? "Copied!" : "Copy code"}
+
+
+ )}
+
+ {showLanguage && language && (
+
+
+ {language}
+
+
+ )}
+
+ {/* eslint-disable jsx-a11y/no-noninteractive-tabindex -- scrollable region needs keyboard access (axe: scrollable-region-focusable) */}
+ {html ? (
+
+ ) : (
+
+ {code}
+
+ )}
+ {/* eslint-enable jsx-a11y/no-noninteractive-tabindex */}
+
+ );
+}
+
+export const HighlightedCode = memo(HighlightedCodeComponent);
diff --git a/ui/src/components/ToolExecution/ToolExecution.stories.tsx b/ui/src/components/ToolExecution/ToolExecution.stories.tsx
index eebd49f..1933adc 100644
--- a/ui/src/components/ToolExecution/ToolExecution.stories.tsx
+++ b/ui/src/components/ToolExecution/ToolExecution.stories.tsx
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
-import { expect, within, userEvent } from "storybook/test";
+import { expect, within, userEvent, waitFor } from "storybook/test";
import type { ToolExecution, ToolExecutionRound, Artifact } from "@/components/chat-types";
import { ToolExecutionBlock } from "./ToolExecutionBlock";
import { ExecutionTimeline } from "./ExecutionTimeline";
@@ -462,9 +462,10 @@ export const StepWithInputExpanded: Story = {
),
play: async ({ canvasElement }) => {
- const canvas = within(canvasElement);
- // Code should be visible inline
- await expect(canvas.getByText(/import pandas/)).toBeInTheDocument();
+ // Code should be visible inline (text may be split across syntax highlighting spans)
+ await waitFor(() => {
+ expect(canvasElement.textContent).toMatch(/import pandas/);
+ });
},
};
@@ -476,8 +477,10 @@ export const StepExpandInput: Story = {
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- // Code preview should be visible inline
- await expect(canvas.getByText(/import pandas/)).toBeInTheDocument();
+ // Code preview should be visible inline (text may be split across syntax highlighting spans)
+ await waitFor(() => {
+ expect(canvasElement.textContent).toMatch(/import pandas/);
+ });
// Click to expand full code
const expandButton = canvas.getByText("expand");
await userEvent.click(expandButton);
diff --git a/ui/src/components/ToolExecution/ToolExecutionStep.tsx b/ui/src/components/ToolExecution/ToolExecutionStep.tsx
index 4a776dd..d8bd0a9 100644
--- a/ui/src/components/ToolExecution/ToolExecutionStep.tsx
+++ b/ui/src/components/ToolExecution/ToolExecutionStep.tsx
@@ -16,6 +16,7 @@ import {
import type { ToolExecution, Artifact, CodeArtifactData } from "@/components/chat-types";
import { cn } from "@/utils/cn";
import { Artifact as ArtifactComponent } from "@/components/Artifact";
+import { HighlightedCode } from "@/components/HighlightedCode/HighlightedCode";
import { ArtifactThumbnail } from "./ArtifactThumbnail";
interface ToolExecutionStepProps {
@@ -46,6 +47,14 @@ const TOOL_NAMES: Record = {
chart_render: "Chart",
};
+/** Tool name to language mapping for syntax highlighting */
+const TOOL_LANGUAGES: Record = {
+ code_interpreter: "python",
+ js_code_interpreter: "javascript",
+ sql_query: "sql",
+ chart_render: "json",
+};
+
/** Format duration in human-readable form */
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
@@ -102,8 +111,10 @@ function ToolExecutionStepComponent({
const code = getCodeFromArtifact(codeArtifact);
if (!code) return null;
const { preview, isTruncated } = getCodePreview(code, 4);
- return { code, preview, isTruncated, artifact: codeArtifact };
- }, [execution.inputArtifacts]);
+ const language =
+ (codeArtifact.data as CodeArtifactData)?.language || TOOL_LANGUAGES[execution.toolName];
+ return { code, preview, isTruncated, artifact: codeArtifact, language };
+ }, [execution.inputArtifacts, execution.toolName]);
// Other input artifacts (non-code)
const otherInputArtifacts = useMemo(
@@ -156,27 +167,48 @@ function ToolExecutionStepComponent({
{/* Inline code preview - always visible */}
{inlineCode && (
-
)}