Skip to content

Commit 145685d

Browse files
authored
🤖 feat: add line numbers and copy button to markdown code blocks (#474)
Enhances markdown code block rendering with line numbers, syntax highlighting, and a reusable copy-to-clipboard button. ## Changes ### Code Block Display - **Line numbers** in left gutter using CSS grid layout - **Syntax highlighting** with Shiki (lazy-loads languages on-demand) - **Line wrapping** enabled (no horizontal scroll) - Increased line-height to 1.6 for better readability ### Copy Functionality - **Reusable CopyButton component** (`src/components/ui/CopyButton.tsx`) - Hover-to-reveal button positioned bottom-right - Shows "Copied!" feedback for 2 seconds - Fully documented with TypeScript interfaces - **CopyIcon component** (`src/components/icons/CopyIcon.tsx`) - SVG icon with clean clipboard metaphor - Accepts `className` for styling flexibility - **Clipboard utility** (`src/utils/clipboard.ts`) - Modern `navigator.clipboard` API with fallback - Supports Storybook, HTTP contexts, older browsers ### Storybook Examples - `CodeBlock.stories.tsx` - Various code block examples - `CopyButton.stories.tsx` - Reusable copy button demos ### Additional Fixes - Fixed `fmt.mk` to check `~/.local/bin/uvx` for uvx availability ## Visual Design Code blocks now have: - Subtle gutter background with vertical border - Rounded corners (4px) - Hover-reveals copy button with smooth opacity transition - Dark semi-transparent button background ## Testing All components visible in Storybook: - `Components/Messages/CodeBlock` - Code block examples - `UI/CopyButton` - Copy button standalone examples --- _Generated with `cmux`_
1 parent 6f81504 commit 145685d

File tree

12 files changed

+371
-46
lines changed

12 files changed

+371
-46
lines changed

docs/AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,10 @@ This project uses **Make** as the primary build orchestrator. See `Makefile` for
201201

202202
## Testing
203203

204+
### Storybook
205+
206+
**Prefer full application stories over component-level stories** - Use `App.stories.tsx` to demonstrate features in realistic contexts rather than creating isolated component stories.
207+
204208
### Test-Driven Development (TDD)
205209

206210
**TDD is the preferred development style for agents.**

fmt.mk

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ PRETTIER := bun x prettier
1616
# Tool availability checks
1717
SHFMT := $(shell command -v shfmt 2>/dev/null)
1818
NIX := $(shell command -v nix 2>/dev/null)
19-
UVX := $(shell command -v uvx 2>/dev/null)
19+
UVX := $(shell command -v uvx 2>/dev/null || (test -x $(HOME)/.local/bin/uvx && echo $(HOME)/.local/bin/uvx))
2020

2121
fmt: fmt-prettier fmt-shell fmt-python fmt-nix
2222
@echo "==> All formatting complete!"
@@ -59,11 +59,11 @@ endif
5959

6060
fmt-python: .check-uvx
6161
@echo "Formatting Python files..."
62-
@uvx ruff format $(PYTHON_DIRS)
62+
@$(UVX) ruff format $(PYTHON_DIRS)
6363

6464
fmt-python-check: .check-uvx
6565
@echo "Checking Python formatting..."
66-
@uvx ruff format --check $(PYTHON_DIRS)
66+
@$(UVX) ruff format --check $(PYTHON_DIRS)
6767

6868
fmt-nix:
6969
ifeq ($(NIX),)

src/App.stories.tsx

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -484,20 +484,43 @@ export const ActiveWorkspaceWithChat: Story = {
484484
},
485485
});
486486

487-
// User asking to run tests
487+
// Assistant with code block example
488488
callback({
489489
id: "msg-5",
490+
role: "assistant",
491+
parts: [
492+
{
493+
type: "text",
494+
text: "Perfect! I've added JWT authentication. Here's what the updated endpoint looks like:\n\n```typescript\nimport { verifyToken } from '../auth/jwt';\n\nexport function getUser(req, res) {\n const token = req.headers.authorization?.split(' ')[1];\n if (!token || !verifyToken(token)) {\n return res.status(401).json({ error: 'Unauthorized' });\n }\n const user = db.users.find(req.params.id);\n res.json(user);\n}\n```\n\nThe endpoint now requires a valid JWT token in the Authorization header. Let me run the tests to verify everything works.",
495+
},
496+
],
497+
metadata: {
498+
historySequence: 5,
499+
timestamp: STABLE_TIMESTAMP - 260000,
500+
model: "claude-sonnet-4-20250514",
501+
usage: {
502+
inputTokens: 1800,
503+
outputTokens: 520,
504+
totalTokens: 2320,
505+
},
506+
duration: 3200,
507+
},
508+
});
509+
510+
// User asking to run tests
511+
callback({
512+
id: "msg-6",
490513
role: "user",
491514
parts: [{ type: "text", text: "Can you run the tests to make sure it works?" }],
492515
metadata: {
493-
historySequence: 5,
516+
historySequence: 6,
494517
timestamp: STABLE_TIMESTAMP - 240000,
495518
},
496519
});
497520

498521
// Assistant running tests
499522
callback({
500-
id: "msg-6",
523+
id: "msg-7",
501524
role: "assistant",
502525
parts: [
503526
{
@@ -522,7 +545,7 @@ export const ActiveWorkspaceWithChat: Story = {
522545
},
523546
],
524547
metadata: {
525-
historySequence: 6,
548+
historySequence: 7,
526549
timestamp: STABLE_TIMESTAMP - 230000,
527550
model: "claude-sonnet-4-20250514",
528551
usage: {
@@ -536,7 +559,7 @@ export const ActiveWorkspaceWithChat: Story = {
536559

537560
// User follow-up about error handling
538561
callback({
539-
id: "msg-7",
562+
id: "msg-8",
540563
role: "user",
541564
parts: [
542565
{
@@ -545,14 +568,14 @@ export const ActiveWorkspaceWithChat: Story = {
545568
},
546569
],
547570
metadata: {
548-
historySequence: 7,
571+
historySequence: 8,
549572
timestamp: STABLE_TIMESTAMP - 180000,
550573
},
551574
});
552575

553576
// Assistant response with thinking (reasoning)
554577
callback({
555-
id: "msg-8",
578+
id: "msg-9",
556579
role: "assistant",
557580
parts: [
558581
{
@@ -582,7 +605,7 @@ export const ActiveWorkspaceWithChat: Story = {
582605
},
583606
],
584607
metadata: {
585-
historySequence: 8,
608+
historySequence: 9,
586609
timestamp: STABLE_TIMESTAMP - 170000,
587610
model: "claude-sonnet-4-20250514",
588611
usage: {

src/components/Messages/AssistantMessage.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ModelDisplay } from "./ModelDisplay";
1111
import { CompactingMessageContent } from "./CompactingMessageContent";
1212
import { CompactionBackground } from "./CompactionBackground";
1313
import type { KebabMenuItem } from "@/components/KebabMenu";
14+
import { copyToClipboard } from "@/utils/clipboard";
1415

1516
interface AssistantMessageProps {
1617
message: DisplayedMessage & { type: "assistant" };
@@ -25,7 +26,7 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
2526
className,
2627
workspaceId,
2728
isCompacting = false,
28-
clipboardWriteText = (data: string) => navigator.clipboard.writeText(data),
29+
clipboardWriteText = copyToClipboard,
2930
}) => {
3031
const [showRaw, setShowRaw] = useState(false);
3132

src/components/Messages/MarkdownComponents.tsx

Lines changed: 71 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
mapToShikiLang,
77
SHIKI_THEME,
88
} from "@/utils/highlighting/shikiHighlighter";
9+
import { CopyButton } from "@/components/ui/CopyButton";
910

1011
interface CodeProps {
1112
node?: unknown;
@@ -37,12 +38,36 @@ interface CodeBlockProps {
3738
language: string;
3839
}
3940

41+
/**
42+
* Extract line contents from Shiki HTML output
43+
* Shiki wraps code in <pre><code>...</code></pre> with <span class="line">...</span> per line
44+
*/
45+
function extractShikiLines(html: string): string[] {
46+
const codeMatch = /<code[^>]*>(.*?)<\/code>/s.exec(html);
47+
if (!codeMatch) return [];
48+
49+
return codeMatch[1].split("\n").map((chunk) => {
50+
const start = chunk.indexOf('<span class="line">');
51+
if (start === -1) return "";
52+
53+
const contentStart = start + '<span class="line">'.length;
54+
const end = chunk.lastIndexOf("</span>");
55+
56+
return end > contentStart ? chunk.substring(contentStart, end) : "";
57+
});
58+
}
59+
4060
/**
4161
* CodeBlock component with async Shiki highlighting
42-
* Reuses shared highlighter instance from diff rendering
62+
* Displays code with line numbers in a CSS grid
4363
*/
4464
const CodeBlock: React.FC<CodeBlockProps> = ({ code, language }) => {
45-
const [html, setHtml] = useState<string | null>(null);
65+
const [highlightedLines, setHighlightedLines] = useState<string[] | null>(null);
66+
67+
// Split code into lines, removing trailing empty line
68+
const plainLines = code
69+
.split("\n")
70+
.filter((line, idx, arr) => idx < arr.length - 1 || line !== "");
4671

4772
useEffect(() => {
4873
let cancelled = false;
@@ -52,41 +77,67 @@ const CodeBlock: React.FC<CodeBlockProps> = ({ code, language }) => {
5277
const highlighter = await getShikiHighlighter();
5378
const shikiLang = mapToShikiLang(language);
5479

55-
// codeToHtml lazy-loads languages automatically
56-
const result = highlighter.codeToHtml(code, {
80+
// Load language on-demand
81+
const loadedLangs = highlighter.getLoadedLanguages();
82+
if (!loadedLangs.includes(shikiLang)) {
83+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
84+
await highlighter.loadLanguage(shikiLang as any);
85+
}
86+
87+
const html = highlighter.codeToHtml(code, {
5788
lang: shikiLang,
5889
theme: SHIKI_THEME,
5990
});
6091

6192
if (!cancelled) {
62-
setHtml(result);
93+
const lines = extractShikiLines(html);
94+
// Remove trailing empty line if present
95+
const filteredLines = lines.filter(
96+
(line, idx, arr) => idx < arr.length - 1 || line.trim() !== ""
97+
);
98+
setHighlightedLines(filteredLines.length > 0 ? filteredLines : null);
6399
}
64100
} catch (error) {
65101
console.warn(`Failed to highlight code block (${language}):`, error);
66-
if (!cancelled) {
67-
setHtml(null);
68-
}
102+
if (!cancelled) setHighlightedLines(null);
69103
}
70104
}
71105

72106
void highlight();
73-
74107
return () => {
75108
cancelled = true;
76109
};
77110
}, [code, language]);
78111

79-
// Show loading state or fall back to plain code
80-
if (html === null) {
81-
return (
82-
<pre>
83-
<code>{code}</code>
84-
</pre>
85-
);
86-
}
87-
88-
// Render highlighted HTML
89-
return <div dangerouslySetInnerHTML={{ __html: html }} />;
112+
const lines = highlightedLines ?? plainLines;
113+
114+
return (
115+
<div className="code-block-wrapper">
116+
<div className="code-block-container">
117+
{lines.map((content, idx) => (
118+
<React.Fragment key={idx}>
119+
<div className="line-number">{idx + 1}</div>
120+
{/* SECURITY AUDIT: dangerouslySetInnerHTML usage
121+
* Source: Shiki syntax highlighter (highlighter.codeToHtml)
122+
* Safety: Shiki escapes all user content before wrapping in <span> tokens
123+
* Data flow: User markdown → react-markdown → code prop → Shiki → extractShikiLines → here
124+
* Verification: Shiki's codeToHtml tokenizes and escapes HTML entities in code content
125+
* Risk: Low - Shiki is a trusted library that properly escapes user input
126+
* Alternative considered: Render Shiki's full <code> block, but per-line rendering
127+
* required for line numbers in CSS grid layout
128+
*/}
129+
<div
130+
className="code-line"
131+
{...(highlightedLines
132+
? { dangerouslySetInnerHTML: { __html: content } }
133+
: { children: <code>{content}</code> })}
134+
/>
135+
</React.Fragment>
136+
))}
137+
</div>
138+
<CopyButton text={code} className="code-copy-button" />
139+
</div>
140+
);
90141
};
91142

92143
// Custom components for markdown rendering

src/components/Messages/UserMessage.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { TerminalOutput } from "./TerminalOutput";
66
import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds";
77
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
88
import type { KebabMenuItem } from "@/components/KebabMenu";
9+
import { copyToClipboard } from "@/utils/clipboard";
910

1011
interface UserMessageProps {
1112
message: DisplayedMessage & { type: "user" };
@@ -15,21 +16,12 @@ interface UserMessageProps {
1516
clipboardWriteText?: (data: string) => Promise<void>;
1617
}
1718

18-
async function defaultClipboardWriteText(data: string): Promise<void> {
19-
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
20-
await navigator.clipboard.writeText(data);
21-
return;
22-
}
23-
24-
console.warn("Clipboard API is not available; skipping copy action.");
25-
}
26-
2719
export const UserMessage: React.FC<UserMessageProps> = ({
2820
message,
2921
className,
3022
onEdit,
3123
isCompacting,
32-
clipboardWriteText = defaultClipboardWriteText,
24+
clipboardWriteText = copyToClipboard,
3325
}) => {
3426
const content = message.content;
3527

src/components/icons/CopyIcon.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import React from "react";
2+
3+
interface CopyIconProps {
4+
className?: string;
5+
}
6+
7+
export const CopyIcon: React.FC<CopyIconProps> = ({ className }) => (
8+
<svg
9+
xmlns="http://www.w3.org/2000/svg"
10+
viewBox="0 0 24 24"
11+
fill="none"
12+
stroke="currentColor"
13+
strokeWidth="2"
14+
strokeLinecap="round"
15+
strokeLinejoin="round"
16+
className={className}
17+
>
18+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
19+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
20+
</svg>
21+
);

0 commit comments

Comments
 (0)