Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
157 lines
5.5 KiB
TypeScript
157 lines
5.5 KiB
TypeScript
// Adapted from agentui (https://github.com/ashish200729/agentui), components/agents/code-block.tsx + agent-code.tsx
|
|
// Copyright (c) 2026 Saurabh Chauhan. MIT License, see ./LICENSE-agentui.md
|
|
// 改造:不引 shiki(包体大,且高亮主题带十六进制颜色),纯文本按行渲染;JSON 输入先尝试缩进美化;
|
|
// 状态色:streaming 用中性色 + 转圈,complete 用 success;高亮行用 warning 洗层;行号/字号/圆角换 token;
|
|
// 去掉 motion 按压;复制失败静默。
|
|
import { Check, Copy, FileCode2, LoaderCircle } from "lucide-react";
|
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
import { cn } from "~/lib/utils.ts";
|
|
|
|
export type CodeLanguage = "text" | "json" | "bash" | "diff";
|
|
|
|
/** JSON 文本尝试缩进美化;不是合法 JSON 原样返回(摘要可能被截断) */
|
|
export function prettyMaybeJson(s: string): { text: string; language: CodeLanguage } {
|
|
const t = s.trim();
|
|
if ((t.startsWith("{") && t.endsWith("}")) || (t.startsWith("[") && t.endsWith("]"))) {
|
|
try {
|
|
return { text: JSON.stringify(JSON.parse(t), null, 2), language: "json" };
|
|
} catch {
|
|
return { text: s, language: "json" };
|
|
}
|
|
}
|
|
return { text: s, language: "text" };
|
|
}
|
|
|
|
export interface CodeBlockProps {
|
|
code: string;
|
|
language?: CodeLanguage;
|
|
filename?: ReactNode;
|
|
status?: "streaming" | "complete";
|
|
showLineNumbers?: boolean;
|
|
highlightLines?: number[];
|
|
/** 最大高度(Tailwind 类,如 max-h-56) */
|
|
maxHeightClassName?: string;
|
|
wrap?: boolean;
|
|
copyable?: boolean;
|
|
/** 不显示顶栏(嵌在别的卡片里时) */
|
|
bare?: boolean;
|
|
tone?: "default" | "destructive";
|
|
className?: string;
|
|
}
|
|
|
|
export function CodeBlock({
|
|
code,
|
|
language = "text",
|
|
filename,
|
|
status = "complete",
|
|
showLineNumbers = false,
|
|
highlightLines = [],
|
|
maxHeightClassName = "max-h-56",
|
|
wrap = true,
|
|
copyable = true,
|
|
bare = false,
|
|
tone = "default",
|
|
className,
|
|
}: CodeBlockProps) {
|
|
const [copied, setCopied] = useState(false);
|
|
const t = useRef<number | undefined>(undefined);
|
|
useEffect(
|
|
() => () => {
|
|
if (t.current) window.clearTimeout(t.current);
|
|
},
|
|
[],
|
|
);
|
|
const streaming = status === "streaming";
|
|
const hl = new Set(highlightLines);
|
|
const lines = code.split("\n");
|
|
const copy = async () => {
|
|
try {
|
|
await navigator.clipboard?.writeText(code);
|
|
setCopied(true);
|
|
if (t.current) window.clearTimeout(t.current);
|
|
t.current = window.setTimeout(() => setCopied(false), 1600);
|
|
} catch {
|
|
// 非安全上下文没有剪贴板
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
data-state={status}
|
|
aria-busy={streaming}
|
|
className={cn(
|
|
"w-full min-w-0 overflow-hidden rounded-lg border border-border/60 bg-foreground/[0.03] text-sm",
|
|
tone === "destructive" && "border-destructive/30",
|
|
className,
|
|
)}
|
|
>
|
|
{bare ? null : (
|
|
<div className="flex h-8 items-center gap-2 border-b border-border/50 px-2.5">
|
|
<FileCode2 aria-hidden className="size-3.5 shrink-0 text-muted-foreground/70" />
|
|
{filename ? <span className="min-w-0 truncate text-meta text-foreground/80">{filename}</span> : null}
|
|
<span className="text-micro font-medium tracking-wide text-muted-foreground uppercase">{language}</span>
|
|
<span
|
|
className={cn(
|
|
"ml-auto inline-flex shrink-0 items-center gap-1 text-micro font-medium",
|
|
streaming ? "text-muted-foreground" : "text-success",
|
|
)}
|
|
>
|
|
{streaming ? (
|
|
<LoaderCircle aria-hidden className="size-3 animate-spin motion-reduce:animate-none" />
|
|
) : (
|
|
<Check aria-hidden className="size-3" />
|
|
)}
|
|
{streaming ? "输出中" : "已完成"}
|
|
</span>
|
|
{copyable ? (
|
|
<button
|
|
type="button"
|
|
aria-label={copied ? "已复制" : "复制"}
|
|
title={copied ? "已复制" : "复制"}
|
|
onClick={copy}
|
|
className="grid size-6 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground outline-none transition-colors duration-micro hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
<div className={cn("overflow-auto py-1.5", maxHeightClassName)}>
|
|
<pre
|
|
className={cn(
|
|
"m-0 font-mono text-meta leading-5 text-foreground/85",
|
|
tone === "destructive" && "text-destructive",
|
|
!wrap && "min-w-max",
|
|
)}
|
|
>
|
|
<code>
|
|
{lines.map((line, i) => (
|
|
<span
|
|
key={i}
|
|
className={cn(
|
|
"grid min-h-5",
|
|
showLineNumbers ? "grid-cols-[2.25rem_minmax(0,1fr)]" : "grid-cols-1",
|
|
hl.has(i + 1) && "bg-warning/10",
|
|
)}
|
|
>
|
|
{showLineNumbers ? (
|
|
<span className="pr-2 text-right text-muted-foreground/50 tabular-nums select-none">{i + 1}</span>
|
|
) : null}
|
|
<span
|
|
className={cn(
|
|
"pr-3",
|
|
showLineNumbers ? "pl-1" : "pl-3",
|
|
wrap ? "break-all whitespace-pre-wrap" : "whitespace-pre",
|
|
)}
|
|
>
|
|
{line || " "}
|
|
</span>
|
|
</span>
|
|
))}
|
|
</code>
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|