claude-code

This commit is contained in:
ashutoshpythoncs@gmail.com
2026-03-31 18:58:05 +05:30
parent a2a44a5841
commit b564857c0b
2148 changed files with 564518 additions and 2 deletions

View File

@@ -0,0 +1,223 @@
"use client";
import React from "react";
// 16-color ANSI palette (matches common terminal defaults)
const FG_COLORS: Record<number, string> = {
30: "#3d3d3d",
31: "#cc0000",
32: "#4e9a06",
33: "#c4a000",
34: "#3465a4",
35: "#75507b",
36: "#06989a",
37: "#d3d7cf",
90: "#555753",
91: "#ef2929",
92: "#8ae234",
93: "#fce94f",
94: "#729fcf",
95: "#ad7fa8",
96: "#34e2e2",
97: "#eeeeec",
};
const BG_COLORS: Record<number, string> = {
40: "#3d3d3d",
41: "#cc0000",
42: "#4e9a06",
43: "#c4a000",
44: "#3465a4",
45: "#75507b",
46: "#06989a",
47: "#d3d7cf",
100: "#555753",
101: "#ef2929",
102: "#8ae234",
103: "#fce94f",
104: "#729fcf",
105: "#ad7fa8",
106: "#34e2e2",
107: "#eeeeec",
};
// 256-color palette
function get256Color(n: number): string {
if (n < 16) {
const fg = FG_COLORS[n + 30] ?? FG_COLORS[n + 82]; // handle 0-7 and 8-15
if (fg) return fg;
}
if (n < 232) {
// 6×6×6 color cube
const i = n - 16;
const b = i % 6;
const g = Math.floor(i / 6) % 6;
const r = Math.floor(i / 36);
const toHex = (v: number) =>
(v === 0 ? 0 : 55 + v * 40).toString(16).padStart(2, "0");
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
// Grayscale ramp
const gray = (n - 232) * 10 + 8;
const hex = gray.toString(16).padStart(2, "0");
return `#${hex}${hex}${hex}`;
}
interface AnsiStyle {
color?: string;
background?: string;
bold?: boolean;
dim?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
}
interface Segment {
text: string;
style: AnsiStyle;
}
function parseAnsi(input: string): Segment[] {
const segments: Segment[] = [];
let current: AnsiStyle = {};
let pos = 0;
let textStart = 0;
const pushSegment = (end: number) => {
const text = input.slice(textStart, end);
if (text) {
segments.push({ text, style: { ...current } });
}
};
while (pos < input.length) {
const esc = input.indexOf("\x1b[", pos);
if (esc === -1) break;
pushSegment(esc);
// Find the end of the escape sequence (letter terminator)
let seqEnd = esc + 2;
while (seqEnd < input.length && !/[A-Za-z]/.test(input[seqEnd])) {
seqEnd++;
}
const terminator = input[seqEnd];
const params = input.slice(esc + 2, seqEnd).split(";").map(Number);
if (terminator === "m") {
// SGR sequence
let i = 0;
while (i < params.length) {
const p = params[i];
if (p === 0 || isNaN(p)) {
current = {};
} else if (p === 1) {
current.bold = true;
} else if (p === 2) {
current.dim = true;
} else if (p === 3) {
current.italic = true;
} else if (p === 4) {
current.underline = true;
} else if (p === 9) {
current.strikethrough = true;
} else if (p === 22) {
current.bold = false;
current.dim = false;
} else if (p === 23) {
current.italic = false;
} else if (p === 24) {
current.underline = false;
} else if (p === 29) {
current.strikethrough = false;
} else if (p >= 30 && p <= 37) {
current.color = FG_COLORS[p];
} else if (p === 38) {
if (params[i + 1] === 5 && params[i + 2] !== undefined) {
current.color = get256Color(params[i + 2]);
i += 2;
} else if (
params[i + 1] === 2 &&
params[i + 2] !== undefined &&
params[i + 3] !== undefined &&
params[i + 4] !== undefined
) {
current.color = `rgb(${params[i + 2]},${params[i + 3]},${params[i + 4]})`;
i += 4;
}
} else if (p === 39) {
delete current.color;
} else if (p >= 40 && p <= 47) {
current.background = BG_COLORS[p];
} else if (p === 48) {
if (params[i + 1] === 5 && params[i + 2] !== undefined) {
current.background = get256Color(params[i + 2]);
i += 2;
} else if (
params[i + 1] === 2 &&
params[i + 2] !== undefined &&
params[i + 3] !== undefined &&
params[i + 4] !== undefined
) {
current.background = `rgb(${params[i + 2]},${params[i + 3]},${params[i + 4]})`;
i += 4;
}
} else if (p === 49) {
delete current.background;
} else if (p >= 90 && p <= 97) {
current.color = FG_COLORS[p];
} else if (p >= 100 && p <= 107) {
current.background = BG_COLORS[p];
}
i++;
}
}
pos = seqEnd + 1;
textStart = pos;
}
pushSegment(input.length);
return segments;
}
function segmentToStyle(style: AnsiStyle): React.CSSProperties {
return {
color: style.color,
backgroundColor: style.background,
fontWeight: style.bold ? "bold" : undefined,
opacity: style.dim ? 0.7 : undefined,
fontStyle: style.italic ? "italic" : undefined,
textDecoration: [
style.underline ? "underline" : "",
style.strikethrough ? "line-through" : "",
]
.filter(Boolean)
.join(" ") || undefined,
};
}
interface AnsiRendererProps {
text: string;
className?: string;
}
export function AnsiRenderer({ text, className }: AnsiRendererProps) {
const lines = text.split("\n");
return (
<span className={className}>
{lines.map((line, lineIdx) => (
<span key={lineIdx}>
{lineIdx > 0 && "\n"}
{parseAnsi(line).map((seg, segIdx) => (
<span key={segIdx} style={segmentToStyle(seg.style)}>
{seg.text}
</span>
))}
</span>
))}
</span>
);
}

View File

@@ -0,0 +1,397 @@
"use client";
import { useState, useMemo } from "react";
import { Columns2, AlignLeft, Copy, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { useHighlightedCode } from "./SyntaxHighlight";
// ─── Diff algorithm ──────────────────────────────────────────────────────────
type DiffLineType = "equal" | "add" | "remove";
interface DiffLine {
type: DiffLineType;
content: string;
oldLineNo?: number;
newLineNo?: number;
}
function computeDiff(oldStr: string, newStr: string): DiffLine[] {
const oldLines = oldStr.split("\n");
const newLines = newStr.split("\n");
const m = oldLines.length;
const n = newLines.length;
// Build LCS table
const dp: Uint32Array[] = Array.from(
{ length: m + 1 },
() => new Uint32Array(n + 1)
);
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (oldLines[i - 1] === newLines[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// Backtrack to build diff
const result: DiffLine[] = [];
let i = m;
let j = n;
let oldLineNo = m;
let newLineNo = n;
while (i > 0 || j > 0) {
if (
i > 0 &&
j > 0 &&
oldLines[i - 1] === newLines[j - 1]
) {
result.unshift({
type: "equal",
content: oldLines[i - 1],
oldLineNo: oldLineNo--,
newLineNo: newLineNo--,
});
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
result.unshift({
type: "add",
content: newLines[j - 1],
newLineNo: newLineNo--,
});
j--;
} else {
result.unshift({
type: "remove",
content: oldLines[i - 1],
oldLineNo: oldLineNo--,
});
i--;
}
}
return result;
}
// ─── Copy button ─────────────────────────────────────────────────────────────
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<button
onClick={handleCopy}
className="p-1 rounded text-surface-400 hover:text-surface-200 hover:bg-surface-700 transition-colors"
title="Copy"
>
{copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
</button>
);
}
// ─── Unified diff view ───────────────────────────────────────────────────────
const CONTEXT_LINES = 3;
interface UnifiedDiffProps {
lines: DiffLine[];
lang: string;
}
function UnifiedDiff({ lines, lang: _lang }: UnifiedDiffProps) {
const [expandedHunks, setExpandedHunks] = useState<Set<number>>(new Set());
// Identify collapsed regions (equal lines away from changes)
const visible = useMemo(() => {
const changed = new Set<number>();
lines.forEach((l, i) => {
if (l.type !== "equal") {
for (
let k = Math.max(0, i - CONTEXT_LINES);
k <= Math.min(lines.length - 1, i + CONTEXT_LINES);
k++
) {
changed.add(k);
}
}
});
return changed;
}, [lines]);
const items: Array<
| { kind: "line"; line: DiffLine; idx: number }
| { kind: "hunk"; start: number; end: number; count: number }
> = useMemo(() => {
const result: typeof items = [];
let i = 0;
while (i < lines.length) {
if (visible.has(i) || expandedHunks.has(i)) {
result.push({ kind: "line", line: lines[i], idx: i });
i++;
} else {
// Find the extent of the collapsed hunk
let end = i;
while (end < lines.length && !visible.has(end) && !expandedHunks.has(end)) {
end++;
}
result.push({ kind: "hunk", start: i, end, count: end - i });
i = end;
}
}
return result;
}, [lines, visible, expandedHunks]);
return (
<div className="font-mono text-xs leading-5 overflow-x-auto">
<table className="w-full border-collapse">
<tbody>
{items.map((item, idx) => {
if (item.kind === "hunk") {
return (
<tr key={`hunk-${idx}`}>
<td colSpan={3} className="bg-surface-800/50 text-center py-0.5">
<button
onClick={() => {
setExpandedHunks((prev) => {
const next = new Set(prev);
for (let k = item.start; k < item.end; k++) next.add(k);
return next;
});
}}
className="text-surface-400 hover:text-surface-200 text-xs px-2 py-0.5"
>
{item.count} unchanged line{item.count !== 1 ? "s" : ""}
</button>
</td>
</tr>
);
}
const { line } = item;
const bgClass =
line.type === "add"
? "bg-green-950/50 hover:bg-green-950/70"
: line.type === "remove"
? "bg-red-950/50 hover:bg-red-950/70"
: "hover:bg-surface-800/30";
const prefixClass =
line.type === "add"
? "text-green-400"
: line.type === "remove"
? "text-red-400"
: "text-surface-600";
const prefix =
line.type === "add" ? "+" : line.type === "remove" ? "" : " ";
return (
<tr key={`line-${idx}`} className={bgClass}>
{/* Old line number */}
<td className="select-none text-right text-surface-600 pr-2 pl-3 w-10 border-r border-surface-700/50">
{line.type !== "add" ? line.oldLineNo : ""}
</td>
{/* New line number */}
<td className="select-none text-right text-surface-600 pr-2 pl-2 w-10 border-r border-surface-700/50">
{line.type !== "remove" ? line.newLineNo : ""}
</td>
{/* Content */}
<td className="pl-3 pr-4 whitespace-pre">
<span className={cn("mr-2", prefixClass)}>{prefix}</span>
<span className="text-surface-100">{line.content}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ─── Side-by-side diff ───────────────────────────────────────────────────────
interface SideBySideDiffProps {
lines: DiffLine[];
}
function SideBySideDiff({ lines }: SideBySideDiffProps) {
// Build paired columns: match adds to removes
const pairs: Array<{
left: DiffLine | null;
right: DiffLine | null;
}> = useMemo(() => {
const result: Array<{ left: DiffLine | null; right: DiffLine | null }> = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.type === "equal") {
result.push({ left: line, right: line });
i++;
} else if (line.type === "remove") {
// Pair with next add if exists
const next = lines[i + 1];
if (next?.type === "add") {
result.push({ left: line, right: next });
i += 2;
} else {
result.push({ left: line, right: null });
i++;
}
} else {
result.push({ left: null, right: line });
i++;
}
}
return result;
}, [lines]);
return (
<div className="font-mono text-xs leading-5 overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="text-surface-500 border-b border-surface-700">
<th colSpan={2} className="text-left pl-3 py-1 font-normal">
Before
</th>
<th colSpan={2} className="text-left pl-3 py-1 font-normal border-l border-surface-700">
After
</th>
</tr>
</thead>
<tbody>
{pairs.map((pair, idx) => (
<tr key={idx}>
{/* Left column */}
<td
className={cn(
"select-none text-right text-surface-600 pr-2 pl-3 w-10 border-r border-surface-700/50",
pair.left?.type === "remove" && "bg-red-950/50"
)}
>
{pair.left?.oldLineNo ?? ""}
</td>
<td
className={cn(
"pl-2 pr-3 whitespace-pre border-r border-surface-700",
pair.left?.type === "remove"
? "bg-red-950/50 text-red-200"
: "text-surface-300"
)}
>
{pair.left?.content ?? ""}
</td>
{/* Right column */}
<td
className={cn(
"select-none text-right text-surface-600 pr-2 pl-3 w-10 border-r border-surface-700/50",
pair.right?.type === "add" && "bg-green-950/50"
)}
>
{pair.right?.newLineNo ?? ""}
</td>
<td
className={cn(
"pl-2 pr-3 whitespace-pre",
pair.right?.type === "add"
? "bg-green-950/50 text-green-200"
: "text-surface-300"
)}
>
{pair.right?.content ?? ""}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// ─── Public component ────────────────────────────────────────────────────────
interface DiffViewProps {
oldContent: string;
newContent: string;
lang?: string;
defaultMode?: "unified" | "side-by-side";
className?: string;
}
export function DiffView({
oldContent,
newContent,
lang = "text",
defaultMode = "unified",
className,
}: DiffViewProps) {
const [mode, setMode] = useState<"unified" | "side-by-side">(defaultMode);
const lines = useMemo(
() => computeDiff(oldContent, newContent),
[oldContent, newContent]
);
const addCount = lines.filter((l) => l.type === "add").length;
const removeCount = lines.filter((l) => l.type === "remove").length;
return (
<div className={cn("rounded-lg overflow-hidden border border-surface-700", className)}>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 bg-surface-800 border-b border-surface-700">
<div className="flex items-center gap-2 text-xs">
<span className="text-green-400 font-mono">+{addCount}</span>
<span className="text-red-400 font-mono">{removeCount}</span>
</div>
<div className="flex items-center gap-1">
<CopyButton text={newContent} />
<div className="flex items-center rounded overflow-hidden border border-surface-700">
<button
onClick={() => setMode("unified")}
className={cn(
"px-2 py-1 text-xs flex items-center gap-1 transition-colors",
mode === "unified"
? "bg-brand-600 text-white"
: "text-surface-400 hover:text-surface-200 hover:bg-surface-700"
)}
>
<AlignLeft className="w-3 h-3" />
Unified
</button>
<button
onClick={() => setMode("side-by-side")}
className={cn(
"px-2 py-1 text-xs flex items-center gap-1 transition-colors",
mode === "side-by-side"
? "bg-brand-600 text-white"
: "text-surface-400 hover:text-surface-200 hover:bg-surface-700"
)}
>
<Columns2 className="w-3 h-3" />
Side by side
</button>
</div>
</div>
</div>
{/* Diff content */}
<div className="bg-surface-900 overflow-auto max-h-[480px]">
{mode === "unified" ? (
<UnifiedDiff lines={lines} lang={lang} />
) : (
<SideBySideDiff lines={lines} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import {
FileText,
FileCode,
FileJson,
FileImage,
File,
Database,
Settings,
Package,
Globe,
BookOpen,
type LucideIcon,
} from "lucide-react";
const EXT_MAP: Record<string, LucideIcon> = {
// JavaScript / TypeScript
js: FileCode,
jsx: FileCode,
ts: FileCode,
tsx: FileCode,
mjs: FileCode,
cjs: FileCode,
// Web
html: Globe,
htm: Globe,
css: FileCode,
scss: FileCode,
sass: FileCode,
less: FileCode,
// Data
json: FileJson,
jsonc: FileJson,
yaml: FileJson,
yml: FileJson,
toml: FileJson,
xml: FileJson,
csv: Database,
// Config
env: Settings,
gitignore: Settings,
eslintrc: Settings,
prettierrc: Settings,
editorconfig: Settings,
// Docs
md: BookOpen,
mdx: BookOpen,
txt: FileText,
rst: FileText,
// Images
png: FileImage,
jpg: FileImage,
jpeg: FileImage,
gif: FileImage,
svg: FileImage,
ico: FileImage,
webp: FileImage,
// Package
lock: Package,
// Python
py: FileCode,
pyc: FileCode,
// Ruby
rb: FileCode,
// Go
go: FileCode,
// Rust
rs: FileCode,
// Java / Kotlin
java: FileCode,
kt: FileCode,
// C / C++
c: FileCode,
cpp: FileCode,
h: FileCode,
hpp: FileCode,
// Shell
sh: FileCode,
bash: FileCode,
zsh: FileCode,
fish: FileCode,
// SQL
sql: Database,
};
function getExtension(filePath: string): string {
const parts = filePath.split(".");
if (parts.length < 2) return "";
return parts[parts.length - 1].toLowerCase();
}
export function getFileIcon(filePath: string): LucideIcon {
const ext = getExtension(filePath);
return EXT_MAP[ext] ?? File;
}
interface FileIconProps {
filePath: string;
className?: string;
}
export function FileIcon({ filePath, className }: FileIconProps) {
const Icon = getFileIcon(filePath);
return <Icon className={className} />;
}

View File

@@ -0,0 +1,161 @@
"use client";
import { useEffect, useState, useRef } from "react";
import type { Highlighter } from "shiki";
// Singleton highlighter promise so we only init once
let highlighterPromise: Promise<Highlighter> | null = null;
async function getHighlighter(): Promise<Highlighter> {
if (!highlighterPromise) {
highlighterPromise = import("shiki").then((shiki) =>
shiki.createHighlighter({
themes: ["github-dark", "github-light"],
langs: [
"typescript",
"javascript",
"tsx",
"jsx",
"python",
"rust",
"go",
"java",
"c",
"cpp",
"ruby",
"shell",
"bash",
"json",
"yaml",
"toml",
"css",
"html",
"markdown",
"sql",
"dockerfile",
"kotlin",
"swift",
"php",
"xml",
],
})
);
}
return highlighterPromise;
}
// Map file extension to shiki language
const EXT_TO_LANG: Record<string, string> = {
ts: "typescript",
tsx: "tsx",
js: "javascript",
jsx: "jsx",
mjs: "javascript",
cjs: "javascript",
py: "python",
rs: "rust",
go: "go",
java: "java",
c: "c",
cpp: "cpp",
h: "c",
hpp: "cpp",
rb: "ruby",
sh: "bash",
bash: "bash",
zsh: "bash",
json: "json",
jsonc: "json",
yaml: "yaml",
yml: "yaml",
toml: "toml",
css: "css",
scss: "css",
html: "html",
htm: "html",
md: "markdown",
mdx: "markdown",
sql: "sql",
kt: "kotlin",
swift: "swift",
php: "php",
xml: "xml",
dockerfile: "dockerfile",
};
export function getLanguageFromPath(filePath: string): string {
const name = filePath.split("/").pop() ?? "";
if (name.toLowerCase() === "dockerfile") return "dockerfile";
const ext = name.split(".").pop()?.toLowerCase() ?? "";
return EXT_TO_LANG[ext] ?? "text";
}
interface UseHighlightedCodeOptions {
code: string;
lang: string;
theme?: "github-dark" | "github-light";
}
export function useHighlightedCode({
code,
lang,
theme = "github-dark",
}: UseHighlightedCodeOptions): string | null {
const [html, setHtml] = useState<string | null>(null);
const lastKey = useRef<string>("");
const key = `${lang}:${theme}:${code}`;
useEffect(() => {
if (lastKey.current === key) return;
lastKey.current = key;
let cancelled = false;
getHighlighter().then((hl) => {
if (cancelled) return;
try {
const highlighted = hl.codeToHtml(code, { lang, theme });
if (!cancelled) setHtml(highlighted);
} catch {
// Language not supported — fall back to plain
if (!cancelled) setHtml(null);
}
});
return () => {
cancelled = true;
};
}, [key, code, lang, theme]);
return html;
}
interface SyntaxHighlightProps {
code: string;
lang: string;
theme?: "github-dark" | "github-light";
className?: string;
}
export function SyntaxHighlight({
code,
lang,
theme = "github-dark",
className,
}: SyntaxHighlightProps) {
const html = useHighlightedCode({ code, lang, theme });
if (html) {
return (
<div
className={className}
// shiki wraps output in <pre><code> already
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
return (
<pre className={className}>
<code>{code}</code>
</pre>
);
}

View File

@@ -0,0 +1,157 @@
"use client";
import { useState } from "react";
import { Copy, Check, Clock } from "lucide-react";
import { cn } from "@/lib/utils";
import { AnsiRenderer } from "./AnsiRenderer";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolBashProps {
input: {
command: string;
timeout?: number;
description?: string;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}}
className="p-1 rounded text-surface-400 hover:text-surface-200 hover:bg-surface-700 transition-colors"
title="Copy command"
>
{copied ? (
<Check className="w-3.5 h-3.5 text-green-400" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
</button>
);
}
// Parse exit code from result — bash tool often appends it
function parseExitCode(result: string): number | null {
const match = result.match(/\nExit code: (\d+)\s*$/);
if (match) return parseInt(match[1], 10);
return null;
}
function stripExitCode(result: string): string {
return result.replace(/\nExit code: \d+\s*$/, "");
}
const MAX_OUTPUT_LINES = 200;
export function ToolBash({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolBashProps) {
const [showAll, setShowAll] = useState(false);
const exitCode = result ? parseExitCode(result) : null;
const outputText = result ? stripExitCode(result) : "";
const outputLines = outputText.split("\n");
const isTruncated = !showAll && outputLines.length > MAX_OUTPUT_LINES;
const displayOutput = isTruncated
? outputLines.slice(0, MAX_OUTPUT_LINES).join("\n")
: outputText;
// Determine if it's an error (non-zero exit code or isError prop)
const hasError = isError || (exitCode !== null && exitCode !== 0);
return (
<ToolUseBlock
toolName="bash"
toolInput={input}
toolResult={result}
isError={hasError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* Command display */}
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50">
<span className="text-brand-400 font-mono text-xs select-none">$</span>
<code className="font-mono text-xs text-surface-100 flex-1 break-all">
{input.command}
</code>
<div className="flex items-center gap-1 flex-shrink-0">
{input.timeout && (
<span
className="flex items-center gap-1 text-xs text-surface-500"
title={`Timeout: ${input.timeout}ms`}
>
<Clock className="w-3 h-3" />
{(input.timeout / 1000).toFixed(0)}s
</span>
)}
<CopyButton text={input.command} />
</div>
</div>
{/* Output */}
{isRunning ? (
<div className="px-3 py-3 font-mono text-xs text-brand-400 animate-pulse-soft">
Running
</div>
) : outputText ? (
<div>
<div
className={cn(
"overflow-auto max-h-[400px] bg-[#0d0d0d] px-3 py-3",
"font-mono text-xs leading-5 whitespace-pre"
)}
>
<AnsiRenderer text={displayOutput} />
{isTruncated && (
<button
onClick={() => setShowAll(true)}
className="mt-2 block text-brand-400 hover:text-brand-300 text-xs"
>
Show {outputLines.length - MAX_OUTPUT_LINES} more lines
</button>
)}
</div>
{/* Footer: exit code */}
{exitCode !== null && (
<div
className={cn(
"flex items-center gap-2 px-3 py-1.5 border-t border-surface-700/50",
exitCode === 0 ? "bg-surface-850" : "bg-red-950/20"
)}
>
<span className="text-xs text-surface-500">Exit code</span>
<span
className={cn(
"font-mono text-xs px-1.5 py-0.5 rounded",
exitCode === 0
? "bg-green-900/40 text-green-400"
: "bg-red-900/40 text-red-400"
)}
>
{exitCode}
</span>
</div>
)}
</div>
) : null}
</ToolUseBlock>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { ChevronRight } from "lucide-react";
import { FileIcon } from "./FileIcon";
import { DiffView } from "./DiffView";
import { getLanguageFromPath } from "./SyntaxHighlight";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolFileEditProps {
input: {
file_path: string;
old_string: string;
new_string: string;
replace_all?: boolean;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
function FileBreadcrumb({ filePath }: { filePath: string }) {
const parts = filePath.replace(/^\//, "").split("/");
return (
<div className="flex items-center gap-1 font-mono text-xs text-surface-400 flex-wrap">
{parts.map((part, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="w-3 h-3 text-surface-600" />}
<span
className={i === parts.length - 1 ? "text-surface-200 font-medium" : ""}
>
{part}
</span>
</span>
))}
</div>
);
}
export function ToolFileEdit({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolFileEditProps) {
const lang = getLanguageFromPath(input.file_path);
return (
<ToolUseBlock
toolName="edit"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* File path header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-surface-700/50 bg-surface-850">
<FileIcon
filePath={input.file_path}
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
/>
<FileBreadcrumb filePath={input.file_path} />
{input.replace_all && (
<span className="ml-auto text-xs px-1.5 py-0.5 rounded bg-surface-700 text-surface-300">
replace all
</span>
)}
</div>
{/* Content */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Editing
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono whitespace-pre-wrap">
{result}
</div>
) : (
<div className="p-3">
<DiffView
oldContent={input.old_string}
newContent={input.new_string}
lang={lang}
/>
</div>
)}
</ToolUseBlock>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import { ChevronRight, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { FileIcon } from "./FileIcon";
import { SyntaxHighlight, getLanguageFromPath } from "./SyntaxHighlight";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolFileReadProps {
input: {
file_path: string;
offset?: number;
limit?: number;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
function FileBreadcrumb({ filePath }: { filePath: string }) {
const parts = filePath.replace(/^\//, "").split("/");
return (
<div className="flex items-center gap-1 font-mono text-xs text-surface-400 flex-wrap">
{parts.map((part, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="w-3 h-3 text-surface-600" />}
<span
className={
i === parts.length - 1 ? "text-surface-200 font-medium" : ""
}
>
{part}
</span>
</span>
))}
</div>
);
}
const MAX_LINES_COLLAPSED = 40;
export function ToolFileRead({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolFileReadProps) {
const [showAll, setShowAll] = useState(false);
const lang = getLanguageFromPath(input.file_path);
const lines = result?.split("\n") ?? [];
const isTruncated = !showAll && lines.length > MAX_LINES_COLLAPSED;
const displayContent = isTruncated
? lines.slice(0, MAX_LINES_COLLAPSED).join("\n")
: (result ?? "");
return (
<ToolUseBlock
toolName="read"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* File path header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-surface-700/50 bg-surface-850">
<FileIcon
filePath={input.file_path}
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
/>
<FileBreadcrumb filePath={input.file_path} />
{(input.offset !== undefined || input.limit !== undefined) && (
<span className="ml-auto text-xs text-surface-500 flex-shrink-0">
{input.offset !== undefined && `offset: ${input.offset}`}
{input.offset !== undefined && input.limit !== undefined && " · "}
{input.limit !== undefined && `limit: ${input.limit}`}
</span>
)}
</div>
{/* Content */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Reading
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
) : result ? (
<div className="relative">
<div className="overflow-auto max-h-[480px] [&_pre]:!bg-transparent [&_pre]:!m-0 [&_.shiki]:!bg-transparent">
<SyntaxHighlight
code={displayContent}
lang={lang}
className="text-xs [&_pre]:p-3 [&_pre]:leading-5"
/>
</div>
{isTruncated && (
<div className="flex justify-center py-2 border-t border-surface-700/50 bg-surface-850">
<button
onClick={() => setShowAll(true)}
className="text-xs text-brand-400 hover:text-brand-300 flex items-center gap-1"
>
<ChevronDown className="w-3.5 h-3.5" />
Show {lines.length - MAX_LINES_COLLAPSED} more lines
</button>
</div>
)}
</div>
) : null}
</ToolUseBlock>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import { ChevronRight } from "lucide-react";
import { FileIcon } from "./FileIcon";
import { SyntaxHighlight, getLanguageFromPath } from "./SyntaxHighlight";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolFileWriteProps {
input: {
file_path: string;
content: string;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
function FileBreadcrumb({ filePath }: { filePath: string }) {
const parts = filePath.replace(/^\//, "").split("/");
return (
<div className="flex items-center gap-1 font-mono text-xs text-surface-400 flex-wrap">
{parts.map((part, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="w-3 h-3 text-surface-600" />}
<span
className={i === parts.length - 1 ? "text-surface-200 font-medium" : ""}
>
{part}
</span>
</span>
))}
</div>
);
}
function isNewFile(result?: string): boolean {
if (!result) return false;
return /creat|new/i.test(result);
}
export function ToolFileWrite({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolFileWriteProps) {
const lang = getLanguageFromPath(input.file_path);
const lineCount = input.content.split("\n").length;
return (
<ToolUseBlock
toolName="write"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* File path header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-surface-700/50 bg-surface-850">
<FileIcon
filePath={input.file_path}
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
/>
<FileBreadcrumb filePath={input.file_path} />
<div className="ml-auto flex items-center gap-2 flex-shrink-0">
<span className="text-xs text-surface-500">{lineCount} lines</span>
<span
className={
isNewFile(result)
? "text-xs px-1.5 py-0.5 rounded bg-green-900/40 text-green-400 border border-green-800/50"
: "text-xs px-1.5 py-0.5 rounded bg-yellow-900/30 text-yellow-400 border border-yellow-800/40"
}
>
{isNewFile(result) ? "New file" : "Overwrite"}
</span>
</div>
</div>
{/* Content */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Writing
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
) : (
<div className="overflow-auto max-h-[480px] [&_pre]:!bg-transparent [&_pre]:!m-0 [&_.shiki]:!bg-transparent">
<SyntaxHighlight
code={input.content}
lang={lang}
className="text-xs [&_pre]:p-3 [&_pre]:leading-5"
/>
</div>
)}
</ToolUseBlock>
);
}

View File

@@ -0,0 +1,88 @@
"use client";
import { FileIcon } from "./FileIcon";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolGlobProps {
input: {
pattern: string;
path?: string;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
function parseFilePaths(result: string): string[] {
return result
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
}
export function ToolGlob({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolGlobProps) {
const files = result ? parseFilePaths(result) : [];
const fileCount = files.length;
return (
<ToolUseBlock
toolName="glob"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* Pattern header */}
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50">
<code className="font-mono text-xs text-brand-300">{input.pattern}</code>
{input.path && (
<span className="text-xs text-surface-500">in {input.path}</span>
)}
{!isRunning && fileCount > 0 && (
<span className="ml-auto text-xs text-surface-500">
{fileCount} match{fileCount !== 1 ? "es" : ""}
</span>
)}
</div>
{/* File list */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Searching
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
) : files.length === 0 ? (
<div className="px-3 py-3 text-surface-500 text-xs">No matches found.</div>
) : (
<div className="overflow-auto max-h-[320px] py-1">
{files.map((filePath, i) => (
<div
key={i}
className="flex items-center gap-2 px-3 py-1 hover:bg-surface-800/50 transition-colors"
>
<FileIcon
filePath={filePath}
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
/>
<span className="font-mono text-xs text-surface-200 truncate">
{filePath}
</span>
</div>
))}
</div>
)}
</ToolUseBlock>
);
}

View File

@@ -0,0 +1,182 @@
"use client";
import { FileIcon } from "./FileIcon";
import { cn } from "@/lib/utils";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolGrepProps {
input: {
pattern: string;
path?: string;
glob?: string;
type?: string;
output_mode?: string;
"-i"?: boolean;
"-n"?: boolean;
context?: number;
"-A"?: number;
"-B"?: number;
"-C"?: number;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
interface GrepMatch {
file: string;
lineNo?: number;
content: string;
isContext?: boolean;
}
interface GrepGroup {
file: string;
matches: GrepMatch[];
}
function parseGrepOutput(result: string): GrepGroup[] {
const lines = result.split("\n").filter(Boolean);
const groups: Map<string, GrepMatch[]> = new Map();
for (const line of lines) {
// Format: "file:lineNo:content" or "file:content" or just "file"
const colonMatch = line.match(/^([^:]+):(\d+):(.*)$/);
if (colonMatch) {
const [, file, lineNo, content] = colonMatch;
if (!groups.has(file)) groups.set(file, []);
groups.get(file)!.push({ file, lineNo: parseInt(lineNo, 10), content });
} else if (line.match(/^[^:]+$/)) {
// Files-only mode
if (!groups.has(line)) groups.set(line, []);
} else {
// fallback: treat entire line as content with unknown file
if (!groups.has("")) groups.set("", []);
groups.get("")!.push({ file: "", content: line });
}
}
return Array.from(groups.entries()).map(([file, matches]) => ({
file,
matches,
}));
}
function highlightPattern(text: string, pattern: string): React.ReactNode {
try {
const re = new RegExp(`(${pattern})`, "gi");
const parts = text.split(re);
return parts.map((part, i) =>
re.test(part) ? (
<mark key={i} className="bg-yellow-500/30 text-yellow-200 rounded-sm">
{part}
</mark>
) : (
part
)
);
} catch {
return text;
}
}
export function ToolGrep({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolGrepProps) {
const groups = result ? parseGrepOutput(result) : [];
const totalMatches = groups.reduce((sum, g) => sum + g.matches.length, 0);
const flags = [
input["-i"] && "-i",
input["-n"] !== false && "-n",
input.glob && `--glob ${input.glob}`,
input.type && `--type ${input.type}`,
input.context && `-C ${input.context}`,
]
.filter(Boolean)
.join(" ");
return (
<ToolUseBlock
toolName="grep"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* Search header */}
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50 flex-wrap">
<code className="font-mono text-xs text-yellow-300">{input.pattern}</code>
{flags && <span className="text-xs text-surface-500 font-mono">{flags}</span>}
{input.path && (
<span className="text-xs text-surface-500">in {input.path}</span>
)}
{!isRunning && totalMatches > 0 && (
<span className="ml-auto text-xs text-surface-500">
{totalMatches} match{totalMatches !== 1 ? "es" : ""}
</span>
)}
</div>
{/* Results */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Searching
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
) : groups.length === 0 ? (
<div className="px-3 py-3 text-surface-500 text-xs">No matches found.</div>
) : (
<div className="overflow-auto max-h-[400px]">
{groups.map((group, gi) => (
<div key={gi} className="border-b border-surface-700/40 last:border-0">
{/* File header */}
<div className="flex items-center gap-2 px-3 py-1.5 bg-surface-800/40 sticky top-0">
<FileIcon
filePath={group.file}
className="w-3.5 h-3.5 text-surface-400 flex-shrink-0"
/>
<span className="font-mono text-xs text-surface-300 truncate">
{group.file || "(unknown)"}
</span>
<span className="ml-auto text-xs text-surface-500 flex-shrink-0">
{group.matches.length} match{group.matches.length !== 1 ? "es" : ""}
</span>
</div>
{/* Match lines */}
{group.matches.map((match, mi) => (
<div
key={mi}
className={cn(
"flex font-mono text-xs leading-5 hover:bg-surface-800/30",
match.isContext ? "text-surface-500" : "text-surface-200"
)}
>
{match.lineNo !== undefined && (
<span className="select-none text-right text-surface-600 pr-2 pl-3 w-12 border-r border-surface-700/50 flex-shrink-0">
{match.lineNo}
</span>
)}
<span className="pl-3 pr-4 py-0.5 whitespace-pre">
{highlightPattern(match.content, input.pattern)}
</span>
</div>
))}
</div>
))}
</div>
)}
</ToolUseBlock>
);
}

View File

@@ -0,0 +1,247 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
ChevronDown,
ChevronRight,
CheckCircle2,
XCircle,
Loader2,
Terminal,
FileText,
FileEdit,
FileSearch,
Search,
Globe,
BookOpen,
ClipboardList,
Bot,
Wrench,
} from "lucide-react";
import { cn } from "@/lib/utils";
// ─── Tool icon mapping ────────────────────────────────────────────────────────
const TOOL_ICONS: Record<string, React.ElementType> = {
bash: Terminal,
read: FileText,
write: FileText,
edit: FileEdit,
glob: FileSearch,
grep: Search,
webfetch: Globe,
websearch: Globe,
notebookedit: BookOpen,
todowrite: ClipboardList,
agent: Bot,
};
function getToolIcon(name: string): React.ElementType {
return TOOL_ICONS[name.toLowerCase()] ?? Wrench;
}
const TOOL_LABELS: Record<string, string> = {
bash: "Bash",
read: "Read File",
write: "Write File",
edit: "Edit File",
glob: "Glob",
grep: "Grep",
webfetch: "Web Fetch",
websearch: "Web Search",
notebookedit: "Notebook Edit",
todowrite: "Todo",
agent: "Agent",
};
function getToolLabel(name: string): string {
return TOOL_LABELS[name.toLowerCase()] ?? name;
}
// ─── Elapsed timer ────────────────────────────────────────────────────────────
function ElapsedTimer({ startMs }: { startMs: number }) {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setElapsed(Date.now() - startMs);
}, 100);
return () => clearInterval(interval);
}, [startMs]);
if (elapsed < 1000) return <span>{elapsed}ms</span>;
return <span>{(elapsed / 1000).toFixed(1)}s</span>;
}
// ─── Status badge ─────────────────────────────────────────────────────────────
interface StatusBadgeProps {
isRunning: boolean;
isError: boolean;
startedAt: number;
completedAt?: number;
}
function StatusBadge({
isRunning,
isError,
startedAt,
completedAt,
}: StatusBadgeProps) {
if (isRunning) {
return (
<span className="flex items-center gap-1.5 text-xs text-brand-400">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<ElapsedTimer startMs={startedAt} />
</span>
);
}
const duration = completedAt ? completedAt - startedAt : null;
const durationStr = duration
? duration < 1000
? `${duration}ms`
: `${(duration / 1000).toFixed(1)}s`
: null;
if (isError) {
return (
<span className="flex items-center gap-1.5 text-xs text-red-400">
<XCircle className="w-3.5 h-3.5" />
{durationStr && <span>{durationStr}</span>}
<span>Error</span>
</span>
);
}
return (
<span className="flex items-center gap-1.5 text-xs text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" />
{durationStr && <span>{durationStr}</span>}
</span>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export interface ToolUseBlockProps {
toolName: string;
toolInput: Record<string, unknown>;
toolResult?: string | null;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
children?: React.ReactNode;
defaultExpanded?: boolean;
}
export function ToolUseBlock({
toolName,
toolInput: _toolInput,
toolResult: _toolResult,
isError = false,
isRunning = false,
startedAt,
completedAt,
children,
defaultExpanded = false,
}: ToolUseBlockProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded || isRunning);
const startRef = useRef(startedAt ?? Date.now());
// Auto-expand while running, retain state after completion
useEffect(() => {
if (isRunning) setIsExpanded(true);
}, [isRunning]);
const Icon = getToolIcon(toolName);
const label = getToolLabel(toolName);
const borderColor = isRunning
? "border-brand-600/40"
: isError
? "border-red-800/50"
: "border-surface-700";
const headerBg = isRunning
? "bg-brand-950/30"
: isError
? "bg-red-950/20"
: "bg-surface-850";
return (
<div
className={cn(
"rounded-lg border overflow-hidden text-sm",
borderColor
)}
>
{/* Header row */}
<button
onClick={() => setIsExpanded((v) => !v)}
className={cn(
"w-full flex items-center gap-2 px-3 py-2.5 text-left transition-colors",
headerBg,
"hover:bg-surface-800"
)}
>
{/* Expand icon */}
<span className="text-surface-500 flex-shrink-0">
{isExpanded ? (
<ChevronDown className="w-3.5 h-3.5" />
) : (
<ChevronRight className="w-3.5 h-3.5" />
)}
</span>
{/* Tool icon */}
<span
className={cn(
"flex-shrink-0",
isRunning
? "text-brand-400"
: isError
? "text-red-400"
: "text-surface-400"
)}
>
<Icon className="w-4 h-4" />
</span>
{/* Tool name */}
<span className="text-surface-200 font-medium flex-1 truncate">
{label}
</span>
{/* Status */}
<StatusBadge
isRunning={isRunning}
isError={isError}
startedAt={startRef.current}
completedAt={completedAt}
/>
</button>
{/* Expandable body */}
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
key="body"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: "easeInOut" }}
style={{ overflow: "hidden" }}
>
<div className="border-t border-surface-700 bg-surface-900">
{children}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,125 @@
"use client";
import { useState } from "react";
import { ExternalLink, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
import { ToolUseBlock } from "./ToolUseBlock";
interface ToolWebFetchProps {
input: {
url: string;
prompt?: string;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
// Very rough HTTP status parsing from result text
function parseStatus(result: string): number | null {
const m = result.match(/^(?:HTTP[^\n]*\s)?(\d{3})\b/m);
if (m) return parseInt(m[1], 10);
return null;
}
function StatusBadge({ code }: { code: number | null }) {
if (!code) return null;
const isOk = code >= 200 && code < 300;
return (
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded font-mono",
isOk
? "bg-green-900/40 text-green-400 border border-green-800/40"
: "bg-red-900/40 text-red-400 border border-red-800/40"
)}
>
{code}
</span>
);
}
const MAX_VISIBLE = 80;
export function ToolWebFetch({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolWebFetchProps) {
const [showFull, setShowFull] = useState(false);
const status = result ? parseStatus(result) : null;
const isTruncated = !showFull && result && result.length > MAX_VISIBLE * 10;
return (
<ToolUseBlock
toolName="webfetch"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* URL header */}
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50">
<a
href={input.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="font-mono text-xs text-brand-400 hover:text-brand-300 hover:underline truncate flex-1 flex items-center gap-1"
>
{input.url}
<ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
{status && <StatusBadge code={status} />}
</div>
{/* Prompt if any */}
{input.prompt && (
<div className="px-3 py-2 border-b border-surface-700/50 text-xs text-surface-400 italic">
Prompt: {input.prompt}
</div>
)}
{/* Response body */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Fetching
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
) : result ? (
<div>
<div className="overflow-auto max-h-[400px] px-3 py-3 text-xs text-surface-300 leading-relaxed whitespace-pre-wrap font-mono">
{isTruncated ? result.slice(0, MAX_VISIBLE * 10) : result}
</div>
{isTruncated && (
<button
onClick={() => setShowFull(true)}
className="flex items-center gap-1 mx-3 mb-2 text-xs text-brand-400 hover:text-brand-300"
>
<ChevronDown className="w-3.5 h-3.5" />
Show full response ({Math.round(result.length / 1024)}KB)
</button>
)}
{showFull && (
<button
onClick={() => setShowFull(false)}
className="flex items-center gap-1 mx-3 mb-2 text-xs text-surface-400 hover:text-surface-200"
>
<ChevronUp className="w-3.5 h-3.5" />
Collapse
</button>
)}
</div>
) : null}
</ToolUseBlock>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import { ExternalLink, Search } from "lucide-react";
import { ToolUseBlock } from "./ToolUseBlock";
interface SearchResult {
title: string;
url: string;
snippet: string;
}
interface ToolWebSearchProps {
input: {
query: string;
};
result?: string;
isError?: boolean;
isRunning?: boolean;
startedAt?: number;
completedAt?: number;
}
function parseSearchResults(result: string): SearchResult[] {
// Try JSON first
try {
const data = JSON.parse(result);
if (Array.isArray(data)) {
return data.map((item) => ({
title: item.title ?? item.name ?? "(no title)",
url: item.url ?? item.link ?? "",
snippet: item.snippet ?? item.description ?? item.content ?? "",
}));
}
if (data.results) return parseSearchResults(JSON.stringify(data.results));
} catch {
// not JSON
}
// Fallback: treat raw text as a single result
return [{ title: "Search Result", url: "", snippet: result }];
}
export function ToolWebSearch({
input,
result,
isError = false,
isRunning = false,
startedAt,
completedAt,
}: ToolWebSearchProps) {
const results = result && !isError ? parseSearchResults(result) : [];
return (
<ToolUseBlock
toolName="websearch"
toolInput={input}
toolResult={result}
isError={isError}
isRunning={isRunning}
startedAt={startedAt}
completedAt={completedAt}
>
{/* Query header */}
<div className="flex items-center gap-2 px-3 py-2 bg-surface-850 border-b border-surface-700/50">
<Search className="w-3.5 h-3.5 text-surface-500 flex-shrink-0" />
<span className="text-sm text-surface-200 flex-1">{input.query}</span>
{!isRunning && results.length > 0 && (
<span className="text-xs text-surface-500">
{results.length} result{results.length !== 1 ? "s" : ""}
</span>
)}
</div>
{/* Results */}
{isRunning ? (
<div className="px-3 py-4 text-surface-500 text-xs animate-pulse">
Searching
</div>
) : isError ? (
<div className="px-3 py-3 text-red-400 text-xs font-mono">{result}</div>
) : results.length === 0 ? (
<div className="px-3 py-3 text-surface-500 text-xs">No results.</div>
) : (
<div className="overflow-auto max-h-[480px] divide-y divide-surface-700/40">
{results.map((r, i) => (
<div key={i} className="px-3 py-3 hover:bg-surface-800/30 transition-colors">
{r.url ? (
<a
href={r.url}
target="_blank"
rel="noopener noreferrer"
className="group flex flex-col gap-1"
>
<div className="flex items-center gap-1">
<span className="text-sm font-medium text-brand-400 group-hover:text-brand-300 group-hover:underline">
{r.title}
</span>
<ExternalLink className="w-3 h-3 text-surface-500 flex-shrink-0" />
</div>
<span className="text-xs text-surface-500 truncate">{r.url}</span>
</a>
) : (
<span className="text-sm font-medium text-surface-200">{r.title}</span>
)}
{r.snippet && (
<p className="mt-1 text-xs text-surface-400 leading-relaxed line-clamp-3">
{r.snippet}
</p>
)}
</div>
))}
</div>
)}
</ToolUseBlock>
);
}