mirror of
https://github.com/codeaashu/claude-code.git
synced 2026-04-10 07:08:47 +03:00
claude-code
This commit is contained in:
223
web/components/tools/AnsiRenderer.tsx
Normal file
223
web/components/tools/AnsiRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
397
web/components/tools/DiffView.tsx
Normal file
397
web/components/tools/DiffView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
web/components/tools/FileIcon.tsx
Normal file
106
web/components/tools/FileIcon.tsx
Normal 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} />;
|
||||
}
|
||||
161
web/components/tools/SyntaxHighlight.tsx
Normal file
161
web/components/tools/SyntaxHighlight.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
web/components/tools/ToolBash.tsx
Normal file
157
web/components/tools/ToolBash.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
web/components/tools/ToolFileEdit.tsx
Normal file
95
web/components/tools/ToolFileEdit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
web/components/tools/ToolFileRead.tsx
Normal file
119
web/components/tools/ToolFileRead.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
web/components/tools/ToolFileWrite.tsx
Normal file
103
web/components/tools/ToolFileWrite.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
web/components/tools/ToolGlob.tsx
Normal file
88
web/components/tools/ToolGlob.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
web/components/tools/ToolGrep.tsx
Normal file
182
web/components/tools/ToolGrep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
247
web/components/tools/ToolUseBlock.tsx
Normal file
247
web/components/tools/ToolUseBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
web/components/tools/ToolWebFetch.tsx
Normal file
125
web/components/tools/ToolWebFetch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
web/components/tools/ToolWebSearch.tsx
Normal file
116
web/components/tools/ToolWebSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user