Skip to content

Commit 818059c

Browse files
feat(ui): add ShowCode component with expand/collapse, copy, and startLine support
Adds a new ShowCode component that unifies all code display surfaces — tool call file views and markdown code fences — into a single block with: - Filename/language header with optional clickable file path and line range - Expand/collapse toggle (appears when content exceeds ~15 lines) - Copy and open-in-pane action buttons in the header - startLine offset for partial-file display (line numbers count from the correct offset rather than always starting at 1) - Language fallback in highlightCode: unknown Shiki languages silently retry as "text" instead of throwing
1 parent 618b9ac commit 818059c

2 files changed

Lines changed: 304 additions & 40 deletions

File tree

packages/ui/src/components/ai-elements/code-block.tsx

Lines changed: 58 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
1717
code: string;
1818
language: BundledLanguage;
1919
showLineNumbers?: boolean;
20+
/** Starting line number offset (for partial file display). Default: 1 */
21+
startLine?: number;
2022
/** When false, suppresses syntax-highlight colors — all tokens render in the foreground color. */
2123
colorize?: boolean;
2224
};
@@ -29,54 +31,68 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
2931
code: "",
3032
});
3133

32-
const lineNumberTransformer: ShikiTransformer = {
33-
name: "line-numbers",
34-
line(node, line) {
35-
node.children.unshift({
36-
type: "element",
37-
tagName: "span",
38-
properties: {
39-
className: [
40-
"inline-block",
41-
"min-w-10",
42-
"mr-4",
43-
"text-right",
44-
"select-none",
45-
"text-muted-foreground",
46-
],
47-
},
48-
children: [{ type: "text", value: String(line) }],
49-
});
50-
},
51-
};
34+
function createLineNumberTransformer(startLine = 1): ShikiTransformer {
35+
return {
36+
name: "line-numbers",
37+
line(node, line) {
38+
node.children.unshift({
39+
type: "element",
40+
tagName: "span",
41+
properties: {
42+
className: [
43+
"inline-block",
44+
"min-w-10",
45+
"mr-4",
46+
"text-right",
47+
"select-none",
48+
"text-muted-foreground",
49+
],
50+
},
51+
children: [{ type: "text", value: String(line + startLine - 1) }],
52+
});
53+
},
54+
};
55+
}
5256

5357
export async function highlightCode(
5458
code: string,
5559
language: BundledLanguage,
5660
showLineNumbers = false,
61+
startLine = 1,
5762
) {
5863
const transformers: ShikiTransformer[] = showLineNumbers
59-
? [lineNumberTransformer]
64+
? [createLineNumberTransformer(startLine)]
6065
: [];
6166

62-
return await Promise.all([
63-
codeToHtml(code, {
64-
lang: language,
65-
theme: "one-light",
66-
transformers,
67-
}),
68-
codeToHtml(code, {
69-
lang: language,
70-
theme: "one-dark-pro",
71-
transformers,
72-
}),
73-
]);
67+
try {
68+
return await Promise.all([
69+
codeToHtml(code, {
70+
lang: language,
71+
theme: "one-light",
72+
transformers,
73+
}),
74+
codeToHtml(code, {
75+
lang: language,
76+
theme: "one-dark-pro",
77+
transformers,
78+
}),
79+
]);
80+
} catch {
81+
// Unknown/unsupported language — fall back to plain text
82+
return highlightCode(
83+
code,
84+
"text" as BundledLanguage,
85+
showLineNumbers,
86+
startLine,
87+
);
88+
}
7489
}
7590

7691
export const CodeBlock = ({
7792
code,
7893
language,
7994
showLineNumbers = false,
95+
startLine = 1,
8096
colorize = true,
8197
className,
8298
children,
@@ -87,16 +103,18 @@ export const CodeBlock = ({
87103

88104
useEffect(() => {
89105
let cancelled = false;
90-
highlightCode(code, language, showLineNumbers).then(([light, dark]) => {
91-
if (!cancelled) {
92-
setHtml(light);
93-
setDarkHtml(dark);
94-
}
95-
});
106+
highlightCode(code, language, showLineNumbers, startLine).then(
107+
([light, dark]) => {
108+
if (!cancelled) {
109+
setHtml(light);
110+
setDarkHtml(dark);
111+
}
112+
},
113+
);
96114
return () => {
97115
cancelled = true;
98116
};
99-
}, [code, language, showLineNumbers]);
117+
}, [code, language, showLineNumbers, startLine]);
100118

101119
return (
102120
<CodeBlockContext.Provider value={{ code }}>
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
"use client";
2+
3+
import {
4+
CheckIcon,
5+
ChevronDownIcon,
6+
ChevronUpIcon,
7+
CopyIcon,
8+
ExternalLinkIcon,
9+
} from "lucide-react";
10+
import { useState } from "react";
11+
import type { BundledLanguage } from "shiki";
12+
import { cn } from "../../lib/utils";
13+
import { Button } from "../ui/button";
14+
import {
15+
Tooltip,
16+
TooltipContent,
17+
TooltipProvider,
18+
TooltipTrigger,
19+
} from "../ui/tooltip";
20+
import { ClickableFilePath } from "./clickable-file-path";
21+
import { CodeBlock } from "./code-block";
22+
23+
/** Approximate line count threshold before the expand/collapse controls appear (~300px at ~20px/line). */
24+
const DEFAULT_MAX_LINES = 15;
25+
26+
export type ShowCodeProps = {
27+
code: string;
28+
/** Shiki language for syntax highlighting. Defaults to "text". */
29+
language?: BundledLanguage;
30+
/**
31+
* When provided, displays a filename header instead of a plain language label.
32+
* The filename is made clickable if `onOpen` is also provided.
33+
*/
34+
filename?: string;
35+
/** Line range label shown after the filename, e.g. "1–142". */
36+
lineRange?: string;
37+
/** Whether to render line numbers. Default: true. */
38+
showLineNumbers?: boolean;
39+
/**
40+
* Starting line number for offset display (e.g. when showing a partial file).
41+
* Default: 1.
42+
*/
43+
startLine?: number;
44+
/** When false, renders all tokens in the foreground color (no syntax colors). Default: true. */
45+
colorize?: boolean;
46+
/**
47+
* Number of lines before expand/collapse controls appear.
48+
* Default: 15 (roughly 300px).
49+
*/
50+
maxLines?: number;
51+
/**
52+
* When provided, shows an "Open" icon button in the header.
53+
* Only rendered when `filename` is also provided.
54+
*/
55+
onOpen?: () => void;
56+
className?: string;
57+
};
58+
59+
/**
60+
* Shared code display block used for both tool-call file views and
61+
* markdown code fences. Shows a header (language label or filename + line
62+
* range), action buttons (expand/collapse, open, copy), and syntax-
63+
* highlighted code with optional line numbers.
64+
*
65+
* When the code exceeds `maxLines`, a gradient overlay with a "Show more"
66+
* link floats at the bottom, and a chevron button appears in the header.
67+
*/
68+
export function ShowCode({
69+
code,
70+
language = "text" as BundledLanguage,
71+
filename,
72+
lineRange,
73+
showLineNumbers = true,
74+
startLine,
75+
colorize = true,
76+
maxLines = DEFAULT_MAX_LINES,
77+
onOpen,
78+
className,
79+
}: ShowCodeProps) {
80+
const [isCopied, setIsCopied] = useState(false);
81+
const [isExpanded, setIsExpanded] = useState(false);
82+
83+
const lineCount = code.split("\n").length;
84+
const isOverflowing = lineCount > maxLines;
85+
86+
const handleCopy = async () => {
87+
try {
88+
await navigator.clipboard.writeText(code);
89+
setIsCopied(true);
90+
setTimeout(() => setIsCopied(false), 2000);
91+
} catch {
92+
// ignore — clipboard unavailable
93+
}
94+
};
95+
96+
return (
97+
<div
98+
className={cn(
99+
"overflow-hidden rounded-md border border-border",
100+
className,
101+
)}
102+
>
103+
{/* Header */}
104+
<div className="flex items-center justify-between border-b border-border bg-muted/50 px-3 py-1.5">
105+
{/* Left: language label or clickable filename + line range */}
106+
<div className="flex min-w-0 items-center gap-2 font-mono text-xs">
107+
{filename ? (
108+
<>
109+
<ClickableFilePath
110+
path={filename}
111+
onOpen={onOpen}
112+
className="text-foreground"
113+
/>
114+
{lineRange && (
115+
<span className="shrink-0 text-muted-foreground">
116+
{lineRange}
117+
</span>
118+
)}
119+
</>
120+
) : (
121+
<span className="text-muted-foreground">{language}</span>
122+
)}
123+
</div>
124+
{/* Right: action buttons */}
125+
<div className="ml-2 flex shrink-0 items-center gap-0.5">
126+
{isOverflowing && (
127+
<TooltipProvider>
128+
<Tooltip>
129+
<TooltipTrigger asChild>
130+
<Button
131+
className="h-6 w-6"
132+
onClick={() => setIsExpanded((prev) => !prev)}
133+
size="icon"
134+
variant="ghost"
135+
>
136+
{isExpanded ? (
137+
<ChevronUpIcon className="h-3.5 w-3.5" />
138+
) : (
139+
<ChevronDownIcon className="h-3.5 w-3.5" />
140+
)}
141+
</Button>
142+
</TooltipTrigger>
143+
<TooltipContent>
144+
{isExpanded ? "Collapse" : "Expand"}
145+
</TooltipContent>
146+
</Tooltip>
147+
</TooltipProvider>
148+
)}
149+
{onOpen && filename && (
150+
<TooltipProvider>
151+
<Tooltip>
152+
<TooltipTrigger asChild>
153+
<Button
154+
className="h-6 w-6"
155+
onClick={(e) => {
156+
e.stopPropagation();
157+
onOpen();
158+
}}
159+
size="icon"
160+
variant="ghost"
161+
>
162+
<ExternalLinkIcon className="h-3.5 w-3.5" />
163+
</Button>
164+
</TooltipTrigger>
165+
<TooltipContent>Open</TooltipContent>
166+
</Tooltip>
167+
</TooltipProvider>
168+
)}
169+
<TooltipProvider>
170+
<Tooltip>
171+
<TooltipTrigger asChild>
172+
<Button
173+
className="h-6 w-6"
174+
onClick={handleCopy}
175+
size="icon"
176+
variant="ghost"
177+
>
178+
<div className="relative h-3.5 w-3.5">
179+
<CopyIcon
180+
className={cn(
181+
"absolute inset-0 h-3.5 w-3.5 transition-[opacity,transform] duration-200 ease-out",
182+
isCopied
183+
? "scale-50 opacity-0"
184+
: "scale-100 opacity-100",
185+
)}
186+
/>
187+
<CheckIcon
188+
className={cn(
189+
"absolute inset-0 h-3.5 w-3.5 transition-[opacity,transform] duration-200 ease-out",
190+
isCopied
191+
? "scale-100 opacity-100"
192+
: "scale-50 opacity-0",
193+
)}
194+
/>
195+
</div>
196+
</Button>
197+
</TooltipTrigger>
198+
<TooltipContent>Copy</TooltipContent>
199+
</Tooltip>
200+
</TooltipProvider>
201+
</div>
202+
</div>
203+
204+
{/* Code content */}
205+
<div className="relative">
206+
<CodeBlock
207+
className={cn(
208+
"rounded-none border-0 [&_pre]:!p-2",
209+
!isExpanded && "[&>div>div]:max-h-[300px]",
210+
)}
211+
code={code}
212+
colorize={colorize}
213+
language={language}
214+
showLineNumbers={showLineNumbers}
215+
startLine={startLine}
216+
/>
217+
218+
{/* Floating "Show more" overlay when truncated */}
219+
{isOverflowing && !isExpanded && (
220+
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center bg-gradient-to-t from-background pb-1.5 pt-8">
221+
<button
222+
className="pointer-events-auto text-xs text-muted-foreground underline transition-colors hover:text-foreground"
223+
onClick={() => setIsExpanded(true)}
224+
type="button"
225+
>
226+
Show more
227+
</button>
228+
</div>
229+
)}
230+
231+
{/* "Show less" link when fully expanded */}
232+
{isOverflowing && isExpanded && (
233+
<div className="flex justify-center pb-1.5 pt-1">
234+
<button
235+
className="text-xs text-muted-foreground underline transition-colors hover:text-foreground"
236+
onClick={() => setIsExpanded(false)}
237+
type="button"
238+
>
239+
Show less
240+
</button>
241+
</div>
242+
)}
243+
</div>
244+
</div>
245+
);
246+
}

0 commit comments

Comments
 (0)