Files
Netcatty/components/ai-elements/tool-call.tsx
T
陈大猫 97b1a2a336 Migrate Catty harness to AI SDK v7 and fix tool denial UI. (#1707)
Adopts streamText instructions/runtimeContext/toolApproval APIs, lifecycle events, and denied-tool result rendering so observer blocks and approval rejections stay visible in chat history.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 11:15:46 +08:00

347 lines
14 KiB
TypeScript

import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { HTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { useI18n } from '../../application/i18n/I18nProvider';
/**
* Pull the user-meaningful shell command out of the tool-call args.
*
* Different tool surfaces hand us different shapes:
* - Netcatty's own `terminal_execute` MCP tool → `{command: "<string>"}`
* - Codex `local_shell` → `{command: ["zsh","-lc","<full>"]}`
* - Codex command_execution (SDK) → `{command: "/bin/zsh -lc '<full>'"}`
* - Claude `Bash` → `{command: "<string>"}`
*
* The SDK form is a STRING that wraps the real command in `<shell> -lc '<full>'`,
* so we unwrap that wrapper too (the array branch already did the equivalent) —
* otherwise the outer shell quotes leak into the title.
*
* And under the "Skill + CLI" integration, the agent's shell tool wraps a
* call to our internal `netcatty-tool-cli` binary, so the real intent is one
* level deeper:
*
* netcatty-tool-cli exec --session <id> --chat-session <id> -- <real-cmd>
*
* We unwrap both layers so the chat panel shows what the user actually
* cares about (the remote command), not Codex's wrapper title which is
* just the local path to the CLI binary.
*/
export function extractDisplayCommand(args: Record<string, unknown> | undefined): string | null {
if (!args) return null;
const raw = (args as { command?: unknown }).command;
let cmdString: string;
if (typeof raw === 'string') {
if (!raw) return null;
cmdString = raw;
} else if (Array.isArray(raw) && raw.length > 0) {
const isShellWrap =
raw.length >= 3 &&
/(?:^|\/)(sh|bash|zsh|fish|ash|dash)$/.test(String(raw[0] ?? '')) &&
/^-l?c$/.test(String(raw[1] ?? ''));
cmdString = isShellWrap
? String(raw[raw.length - 1] ?? '')
: raw.map((p) => String(p)).join(' ');
} else {
return null;
}
// Unwrap a STRING shell wrapper, e.g. Codex SDK's `/bin/zsh -lc '<full>'`.
// The array branch above already extracts the inner command; the string form
// (codex command_execution) does not, so strip `<shell> -l?c <quote>…<quote>`
// here. Without this the outer quote leaks into the netcatty-cli title below.
const strWrap = cmdString.match(
/^(?:\S*\/)?(?:sh|bash|zsh|fish|ash|dash)\s+-l?c\s+(['"])([\s\S]*)\1\s*$/,
);
if (strWrap) cmdString = strWrap[2];
// Netcatty CLI wrapper extraction.
const cliIdx = cmdString.indexOf('netcatty-tool-cli');
if (cliIdx >= 0) {
const afterCli = cmdString
.slice(cliIdx + 'netcatty-tool-cli'.length)
.replace(/^["']?\s*/, '');
const subMatch = afterCli.match(/^(\S+)/);
const sub = subMatch ? subMatch[1] : '';
if (sub === 'exec' || sub === 'job-start') {
// Pull out the command after the ` -- ` separator.
const dashIdx = afterCli.indexOf(' -- ');
if (dashIdx >= 0) {
let inner = afterCli.slice(dashIdx + 4).trim();
if (
inner.length >= 2 &&
((inner[0] === '"' && inner.endsWith('"')) ||
(inner[0] === "'" && inner.endsWith("'")))
) {
inner = inner.slice(1, -1);
}
return inner;
}
}
if (sub === 'job-poll') return 'netcatty: poll job';
if (sub === 'job-stop') return 'netcatty: stop job';
if (sub === 'session') return 'netcatty: inspect session';
if (sub === 'env') return 'netcatty: list sessions';
if (sub === 'status') return 'netcatty: status';
if (sub) return `netcatty: ${sub}`;
}
return cmdString;
}
/**
* Format tool result for display. Extracts stdout/stderr from structured
* command results for terminal-like output.
*/
function formatToolResult(result: unknown): string {
let parsed = result;
if (typeof parsed === 'string') {
try {
const obj = JSON.parse(parsed);
if (obj && typeof obj === 'object') parsed = obj;
} catch {
return parsed;
}
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
if (typeof obj.stdout === 'string' || typeof obj.stderr === 'string') {
const parts: string[] = [];
if (typeof obj.stdout === 'string' && obj.stdout) parts.push(obj.stdout);
if (typeof obj.stderr === 'string' && obj.stderr) parts.push(obj.stderr);
if (typeof obj.exitCode === 'number' && obj.exitCode !== 0) {
parts.push(`exit code: ${obj.exitCode}`);
}
if (parts.length > 0) return parts.join('\n');
}
}
if (typeof parsed === 'string') return parsed;
return JSON.stringify(parsed, null, 2);
}
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
name: string;
className?: string;
args?: Record<string, unknown>;
result?: unknown;
isError?: boolean;
isLoading?: boolean;
isInterrupted?: boolean;
/** Approval state for this tool call (from the approval gate). */
approvalStatus?: 'pending' | 'approved' | 'denied';
/** Called when user approves this tool call. */
onApprove?: () => void;
/** Called when user rejects this tool call. */
onReject?: () => void;
/** Called when user approves once without persisting a grant rule. */
onApproveOnce?: () => void;
/** Called when user approves and persists an always-allow grant rule. */
onAlwaysAllow?: () => void;
}
export const ToolCall = ({
name, args, result, isError, isLoading, isInterrupted,
approvalStatus, onApprove, onReject, onApproveOnce, onAlwaysAllow,
className, ...props
}: ToolCallProps) => {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
const approveBtnRef = useRef<HTMLButtonElement>(null);
const [responded, setResponded] = useState(false);
const isPendingApproval = approvalStatus === 'pending' && !responded;
const handleApproveOnce = useCallback(() => {
if (!isPendingApproval) return;
setResponded(true);
(onApproveOnce ?? onApprove)?.();
}, [isPendingApproval, onApproveOnce, onApprove]);
const handleAlwaysAllow = useCallback(() => {
if (!isPendingApproval) return;
setResponded(true);
(onAlwaysAllow ?? onApprove)?.();
}, [isPendingApproval, onAlwaysAllow, onApprove]);
const handleReject = useCallback(() => {
if (!isPendingApproval) return;
setResponded(true);
onReject?.();
}, [isPendingApproval, onReject]);
// Keyboard: Enter = approve, Escape = reject (when pending)
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!isPendingApproval) return;
if (e.key === 'Enter') { e.preventDefault(); handleApproveOnce(); }
else if (e.key === 'Escape') { e.preventDefault(); handleReject(); }
}, [isPendingApproval, handleApproveOnce, handleReject]);
// Auto-focus and auto-scroll when approval is pending
useEffect(() => {
if (isPendingApproval && cardRef.current) {
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
// Small delay to let the UI render, then expand and focus
setExpanded(true);
setTimeout(() => approveBtnRef.current?.focus(), 100);
}
}, [isPendingApproval]);
// Reset responded state when approvalStatus changes (e.g. new approval)
useEffect(() => {
if (approvalStatus === 'pending') setResponded(false);
}, [approvalStatus]);
// Border/bg color based on approval status
const borderClass = approvalStatus === 'pending'
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
: approvalStatus === 'approved'
? 'border-green-500/20 bg-green-500/[0.03]'
: approvalStatus === 'denied'
? 'border-red-500/20 bg-red-500/[0.03]'
: 'border-border/25 bg-muted/10';
const statusIconClass = 'shrink-0';
const statusIcon = approvalStatus === 'pending' ? (
<ShieldAlert size={12} className={cn('text-yellow-500/70', statusIconClass)} />
) : isLoading ? (
<Loader2 size={12} className={cn('animate-spin text-blue-400/70', statusIconClass)} />
) : isInterrupted ? (
<Slash size={12} className={cn('text-muted-foreground/55', statusIconClass)} />
) : isError ? (
<XCircle size={12} className={cn('text-red-400/70', statusIconClass)} />
) : result !== undefined ? (
<CheckCircle2 size={12} className={cn('text-green-400/70', statusIconClass)} />
) : null;
return (
<div
ref={cardRef}
tabIndex={isPendingApproval ? 0 : undefined}
onKeyDown={isPendingApproval ? handleKeyDown : undefined}
className={cn('min-w-0 rounded-md border overflow-hidden text-[12px] outline-none', borderClass, className)}
{...props}
>
<button
type="button"
onClick={() => setExpanded(e => !e)}
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/20 transition-colors cursor-pointer"
>
{expanded
? <ChevronDown size={12} className="text-muted-foreground/40 shrink-0" />
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
}
{(() => {
const displayCmd = extractDisplayCommand(args);
if (displayCmd) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="font-mono text-muted-foreground/70 truncate cursor-default">
<span className="text-muted-foreground/40">$ </span>{displayCmd}
</span>
</TooltipTrigger>
<TooltipContent>{displayCmd}</TooltipContent>
</Tooltip>
);
}
return <span className="font-mono text-muted-foreground/70 truncate">{name}</span>;
})()}
<span className="flex-1" />
{/* Approval badge for resolved approvals */}
{approvalStatus === 'approved' && (
<Badge className="text-[10px] px-1.5 py-0 bg-green-600/20 text-green-400 border-green-600/30">
{t('ai.chat.toolApproved')}
</Badge>
)}
{approvalStatus === 'denied' && (
<Badge className="text-[10px] px-1.5 py-0 bg-red-600/20 text-red-400 border-red-600/30">
{t('ai.chat.toolDenied')}
</Badge>
)}
{statusIcon}
</button>
{expanded && (
<div className="border-t border-border/20">
{args && Object.keys(args).length > 0 && (
<div className="px-3 py-2">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Arguments</div>
<pre className="max-h-64 overflow-auto text-[11px] font-mono text-muted-foreground/50 whitespace-pre [overflow-wrap:normal]">
{JSON.stringify(args, null, 2)}
</pre>
</div>
)}
{/* Inline approval buttons */}
{isPendingApproval && (
<div className="min-w-0 px-3 py-2 border-t border-border/20">
<p className="mb-2 text-[10px] leading-snug text-muted-foreground/40">
{t('ai.chat.toolApprovalHint')}
</p>
<div className="flex w-full min-w-0 items-stretch gap-1.5">
<Button
variant="outline"
size="sm"
className="h-7 min-w-0 flex-1 gap-1 px-1.5 text-[11px] font-normal border-red-500/25 text-red-400/90 hover:bg-red-500/10 hover:text-red-400 hover:border-red-500/40"
onClick={handleReject}
>
<X size={12} className="shrink-0" />
<span className="truncate">{t('ai.chat.reject')}</span>
</Button>
<Button
ref={approveBtnRef}
variant="outline"
size="sm"
className="h-7 min-w-0 flex-1 gap-1 px-1.5 text-[11px] font-normal border-green-500/25 text-green-400/90 hover:bg-green-500/10 hover:text-green-400 hover:border-green-500/40"
onClick={handleApproveOnce}
>
<Check size={12} className="shrink-0" />
<span className="truncate">{t('ai.chat.approveOnce')}</span>
</Button>
<Button
variant="outline"
size="sm"
className="h-7 min-w-0 flex-1 gap-1 px-1.5 text-[11px] font-normal border-green-500/35 text-green-300/95 hover:bg-green-500/10 hover:text-green-300 hover:border-green-500/50"
onClick={handleAlwaysAllow}
>
<Check size={12} className="shrink-0" />
<span className="truncate">{t('ai.chat.alwaysAllow')}</span>
</Button>
</div>
</div>
)}
{result !== undefined && (
<div className="px-3 py-2 border-t border-border/20">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Result</div>
<pre className={cn(
'max-h-64 overflow-auto text-[11px] font-mono whitespace-pre [overflow-wrap:normal]',
isError ? 'text-red-400/60' : 'text-muted-foreground/50',
)}>
{formatToolResult(result)}
</pre>
</div>
)}
{isInterrupted && result === undefined && (
<div className="px-3 py-2 border-t border-border/20">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Status</div>
<div className="text-[11px] text-muted-foreground/50">
Interrupted
</div>
</div>
)}
</div>
)}
</div>
);
};