247 lines
8.3 KiB
TypeScript
247 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
import { memo } from "react";
|
|
import {
|
|
IconCheck,
|
|
IconCode,
|
|
IconEdit,
|
|
IconEye,
|
|
IconFile,
|
|
IconSearch,
|
|
IconTerminal2,
|
|
IconX,
|
|
} from "@tabler/icons-react";
|
|
import { GridSpinner } from "@/components/grid-spinner";
|
|
import { cn, transformPathsInText } from "@/lib/utils";
|
|
import { prettifyToolTitle } from "@/lib/pretty-tool-title";
|
|
import type { Message } from "@/lib/types/http";
|
|
import type { ToolCallMetadata } from "@/components/task/chat/types";
|
|
import { PermissionActionRow } from "./permission-action-row";
|
|
import { ExpandableRow } from "./expandable-row";
|
|
import { useExpandState } from "./use-expand-state";
|
|
import { parsePermission, usePermissionResponseHandlers } from "./use-permission-handlers";
|
|
|
|
const TOOL_ICON_RULES: Array<{ keywords: string[]; Icon: typeof IconCode }> = [
|
|
{ keywords: ["edit", "replace", "write", "save"], Icon: IconEdit },
|
|
{ keywords: ["view", "read"], Icon: IconEye },
|
|
{ keywords: ["search", "find", "retrieval"], Icon: IconSearch },
|
|
{ keywords: ["terminal", "exec", "execute", "launch", "process"], Icon: IconTerminal2 },
|
|
{ keywords: ["delete", "move", "file", "create"], Icon: IconFile },
|
|
];
|
|
|
|
function getToolIcon(toolName: string | undefined, className: string) {
|
|
const name = toolName?.toLowerCase() ?? "";
|
|
const match = TOOL_ICON_RULES.find(({ keywords }) =>
|
|
keywords.some((kw) => name === kw || name.includes(kw)),
|
|
);
|
|
const Icon = match?.Icon ?? IconCode;
|
|
return <Icon className={className} />;
|
|
}
|
|
|
|
type ToolCallMessageProps = {
|
|
comment: Message;
|
|
permissionMessage?: Message;
|
|
worktreePath?: string;
|
|
};
|
|
|
|
function getToolCallStatusIcon(status: string | undefined, permissionStatus: string | undefined) {
|
|
if (permissionStatus === "approved") return <IconCheck className="h-3.5 w-3.5 text-green-500" />;
|
|
if (permissionStatus === "rejected") return <IconX className="h-3.5 w-3.5 text-red-500" />;
|
|
if (status === "complete") return <IconCheck className="h-3.5 w-3.5 text-green-500" />;
|
|
if (status === "error") return <IconX className="h-3.5 w-3.5 text-red-500" />;
|
|
if (status === "running") return <GridSpinner className="text-muted-foreground" />;
|
|
return null;
|
|
}
|
|
|
|
function formatToolOutput(value: unknown): { content: string; isJson: boolean } {
|
|
if (typeof value === "string") {
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return { content: JSON.stringify(parsed, null, 2), isJson: true };
|
|
} catch {
|
|
return { content: value, isJson: false };
|
|
}
|
|
}
|
|
return { content: JSON.stringify(value, null, 2), isJson: true };
|
|
}
|
|
|
|
type ToolCallExpandedContentProps = {
|
|
formattedOutput: { content: string; isJson: boolean } | null;
|
|
isHttpError: boolean | undefined;
|
|
isPermissionPending: boolean;
|
|
onApprove: () => void;
|
|
onReject: () => void;
|
|
onAllowAlways?: () => void;
|
|
isResponding: boolean;
|
|
};
|
|
|
|
function ToolCallExpandedContent({
|
|
formattedOutput,
|
|
isHttpError,
|
|
isPermissionPending,
|
|
onApprove,
|
|
onReject,
|
|
onAllowAlways,
|
|
isResponding,
|
|
}: ToolCallExpandedContentProps) {
|
|
return (
|
|
<div className="pl-4 border-l-2 border-border/30 space-y-2">
|
|
{formattedOutput && (
|
|
<pre
|
|
className={cn(
|
|
"text-xs rounded p-2 overflow-x-auto whitespace-pre-wrap max-h-[200px] overflow-y-auto",
|
|
formattedOutput.isJson ? "bg-muted/30 font-mono text-[11px]" : "bg-muted/30",
|
|
isHttpError && "text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30",
|
|
)}
|
|
>
|
|
{formattedOutput.content}
|
|
</pre>
|
|
)}
|
|
{isPermissionPending && (
|
|
<PermissionActionRow
|
|
onApprove={onApprove}
|
|
onReject={onReject}
|
|
onAllowAlways={onAllowAlways}
|
|
isResponding={isResponding}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function hasToolOutput(output: unknown): boolean {
|
|
if (!output) return false;
|
|
if (typeof output === "string") return output.length > 0;
|
|
return Object.keys(output as object).length > 0;
|
|
}
|
|
|
|
function parseToolCallOutput(metadata: ToolCallMetadata | undefined) {
|
|
const normalizedGeneric = metadata?.normalized?.generic;
|
|
const normalizedHttpRequest = metadata?.normalized?.http_request;
|
|
const output = normalizedHttpRequest?.response ?? normalizedGeneric?.output ?? metadata?.result;
|
|
const isHttpError = normalizedHttpRequest?.is_error;
|
|
return { output, isHttpError };
|
|
}
|
|
|
|
// Short, single-line tool output renders inline in the header (e.g. "Launching skill: e2e")
|
|
// rather than behind an expand chevron.
|
|
const INLINE_OUTPUT_MAX_LENGTH = 120;
|
|
|
|
function getInlineOutput(output: unknown): string | null {
|
|
if (typeof output !== "string") return null;
|
|
const trimmed = output.trim();
|
|
// Reject both \n and \r — shell tool output can carry \r progress-bar overwrites
|
|
// that would render as multiple visual lines even without a \n.
|
|
if (!trimmed || /[\n\r]/.test(trimmed) || trimmed.length > INLINE_OUTPUT_MAX_LENGTH) return null;
|
|
return trimmed;
|
|
}
|
|
|
|
function parseToolCallMetadata(comment: Message, permissionMessage: Message | undefined) {
|
|
const metadata = comment.metadata as ToolCallMetadata | undefined;
|
|
const toolName = metadata?.tool_name ?? "";
|
|
const status = metadata?.status;
|
|
const { output, isHttpError } = parseToolCallOutput(metadata);
|
|
const { permissionMetadata, permissionStatus, isPermissionPending } =
|
|
parsePermission(permissionMessage);
|
|
const hasOutput = hasToolOutput(output);
|
|
const inlineOutput = getInlineOutput(output);
|
|
// When output is shown inline, there's nothing more to expand — skip the expand affordance
|
|
// unless a permission prompt still needs to be rendered.
|
|
const hasExpandableContent = (hasOutput && !inlineOutput) || isPermissionPending;
|
|
const isSuccess = status === "complete" && !permissionStatus;
|
|
return {
|
|
toolName,
|
|
status,
|
|
output,
|
|
isHttpError,
|
|
permissionMetadata,
|
|
permissionStatus,
|
|
isPermissionPending,
|
|
hasOutput,
|
|
hasExpandableContent,
|
|
inlineOutput,
|
|
isSuccess,
|
|
};
|
|
}
|
|
|
|
export const ToolCallMessage = memo(function ToolCallMessage({
|
|
comment,
|
|
permissionMessage,
|
|
worktreePath,
|
|
}: ToolCallMessageProps) {
|
|
const {
|
|
toolName,
|
|
status,
|
|
output,
|
|
isHttpError,
|
|
permissionMetadata,
|
|
permissionStatus,
|
|
isPermissionPending,
|
|
hasOutput,
|
|
hasExpandableContent,
|
|
inlineOutput,
|
|
isSuccess,
|
|
} = parseToolCallMetadata(comment, permissionMessage);
|
|
const { isResponding, handleApprove, handleAllowAlways, hasAllowAlways, handleReject } =
|
|
usePermissionResponseHandlers({
|
|
permissionMetadata,
|
|
permissionMessage,
|
|
});
|
|
const autoExpanded = status === "running" || isPermissionPending;
|
|
const { isExpanded, handleToggle } = useExpandState(status, autoExpanded);
|
|
|
|
const metadata = comment.metadata as ToolCallMetadata | undefined;
|
|
const rawTitle = metadata?.title ?? comment.content ?? "Tool call";
|
|
const title = transformPathsInText(prettifyToolTitle(rawTitle), worktreePath);
|
|
|
|
const formattedOutput = hasOutput && !inlineOutput ? formatToolOutput(output) : null;
|
|
|
|
return (
|
|
<ExpandableRow
|
|
icon={getToolIcon(
|
|
toolName,
|
|
cn(
|
|
"h-4 w-4",
|
|
isPermissionPending ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground",
|
|
),
|
|
)}
|
|
header={
|
|
<div className="flex items-center gap-2 text-xs">
|
|
<span
|
|
className={cn(
|
|
"inline-flex items-center gap-1.5",
|
|
isPermissionPending && "text-amber-600 dark:text-amber-400",
|
|
)}
|
|
>
|
|
<span className="font-mono text-xs text-muted-foreground">{title}</span>
|
|
{inlineOutput && (
|
|
<span
|
|
className={cn(
|
|
"text-xs",
|
|
isHttpError ? "text-red-600 dark:text-red-400" : "text-muted-foreground/80",
|
|
)}
|
|
>
|
|
{inlineOutput}
|
|
</span>
|
|
)}
|
|
{!isSuccess && getToolCallStatusIcon(status, permissionStatus)}
|
|
</span>
|
|
</div>
|
|
}
|
|
hasExpandableContent={!!hasExpandableContent}
|
|
isExpanded={isExpanded}
|
|
onToggle={handleToggle}
|
|
>
|
|
<ToolCallExpandedContent
|
|
formattedOutput={formattedOutput}
|
|
isHttpError={isHttpError}
|
|
isPermissionPending={isPermissionPending}
|
|
onApprove={handleApprove}
|
|
onReject={handleReject}
|
|
onAllowAlways={hasAllowAlways ? handleAllowAlways : undefined}
|
|
isResponding={isResponding}
|
|
/>
|
|
</ExpandableRow>
|
|
);
|
|
});
|