import React, { useCallback, useEffect, useRef, useState } from 'react'; import type { HTMLAttributes } from 'react'; import { cn } from '../../lib/utils'; import { Check, ChevronDown, ChevronRight, CheckCircle2, Copy, 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'; import { cancelApprovalTimeout } from '../../infrastructure/ai/shared/approvalGate'; export const MAX_TOOL_COMMAND_TOOLTIP_CHARS = 240; /** Collapsed approval command block max height (px). Full text remains scrollable. */ export const APPROVAL_COMMAND_COLLAPSED_MAX_HEIGHT_PX = 144; /** Expanded approval command block max height (px). */ export const APPROVAL_COMMAND_EXPANDED_MAX_HEIGHT_PX = 384; /** Prefer expand control when the raw command exceeds this many characters. */ export const APPROVAL_COMMAND_EXPAND_CHAR_THRESHOLD = 180; const NESTED_INTERACTIVE_SELECTOR = 'button, a, input, textarea, select, [role="button"]'; /** * Enter on the pending-card root means Approve Once. Enter on nested review * controls (Copy / Expand / action buttons) must not approve — those controls * also stopPropagation on Enter so the card handler is a second line of defense. */ export function isNestedInteractiveApprovalTarget( target: { closest?: (selector: string) => unknown } | null, currentTarget: unknown, ): boolean { if (!target || target === currentTarget) return false; if (typeof target.closest !== 'function') return false; return Boolean(target.closest(NESTED_INTERACTIVE_SELECTOR)); } export function truncateToolCommandTooltip( command: string, maxChars = MAX_TOOL_COMMAND_TOOLTIP_CHARS, ): string { const normalized = command.replace(/\s+/g, ' ').trim(); if (normalized.length <= maxChars) return normalized; if (maxChars <= 1) return '…'.slice(0, maxChars); return `${normalized.slice(0, maxChars - 1).trimEnd()}…`; } /** * 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: ""}` * - Codex `local_shell` → `{command: ["zsh","-lc",""]}` * - Codex command_execution (SDK) → `{command: "/bin/zsh -lc ''"}` * - Claude `Bash` → `{command: ""}` * * The SDK form is a STRING that wraps the real command in ` -lc ''`, * 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 --chat-session -- * * 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 | 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 ''`. // The array branch above already extracts the inner command; the string form // (codex command_execution) does not, so strip ` -l?c ` // 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. // Packaged / Windows paths may be `netcatty-tool-cli.cjs` or `.cmd`; strip the // optional extension so the subcommand after the binary is still found. const cliIdx = cmdString.search(/netcatty-tool-cli(?:\.(?:cjs|cmd|exe|js))?/i); if (cliIdx >= 0) { const cliMatch = cmdString.slice(cliIdx).match(/^netcatty-tool-cli(?:\.(?:cjs|cmd|exe|js))?/i); const cliTokenLen = cliMatch?.[0]?.length ?? 'netcatty-tool-cli'.length; const afterCli = cmdString .slice(cliIdx + cliTokenLen) .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; } export interface ApprovalExecutionContext { sessionId?: string; cwd?: string; shell?: string; reason?: string; } function rawCommandString(args: Record | undefined): string | null { if (!args) return null; const raw = (args as { command?: unknown }).command; if (typeof raw === 'string') return raw || null; if (Array.isArray(raw) && raw.length > 0) return raw.map((p) => String(p)).join(' '); return null; } const APPROVAL_CONTEXT_ARG_KEYS = new Set([ 'command', 'cwd', 'working_directory', 'workdir', 'workingDirectory', 'sessionId', 'shell', 'reason', ]); /** * True when pending args still carry review-relevant fields beyond the * command block / execution-context strip (e.g. commandActions). */ export function approvalArgsHaveExtraContext( args: Record | undefined, ): boolean { if (!args) return false; return Object.keys(args).some((key) => !APPROVAL_CONTEXT_ARG_KEYS.has(key)); } /** * True when the reviewable display command was unwrapped from a Skills+CLI / * shell wrapper — the pending card should still surface target flags. */ export function approvalCommandWasUnwrapped( args: Record | undefined, displayCommand: string | null, ): boolean { if (!displayCommand) return false; const raw = rawCommandString(args); if (!raw || raw === displayCommand) return false; return raw.includes('netcatty-tool-cli') || /(?:^|\/)(sh|bash|zsh|fish|ash|dash)\s+-l?c\s+/.test(raw) || (Array.isArray(args?.command) && args.command.length >= 3); } /** * Best-effort execution context for approval review (session / cwd / shell). * Never invents host names; only surfaces fields already present on tool args * or explicit netcatty-tool-cli flags in the command string. */ export function extractApprovalExecutionContext( args: Record | undefined, ): ApprovalExecutionContext | null { if (!args) return null; let sessionId = typeof args.sessionId === 'string' && args.sessionId.trim() ? args.sessionId.trim() : undefined; const cwdCandidate = [args.cwd, args.working_directory, args.workdir, args.workingDirectory] .find((value) => typeof value === 'string' && value.trim()); const cwd = typeof cwdCandidate === 'string' ? cwdCandidate.trim() : undefined; let shell = typeof args.shell === 'string' && args.shell.trim() ? args.shell.trim() : undefined; const reason = typeof args.reason === 'string' && args.reason.trim() ? args.reason.trim() : undefined; const raw = (args as { command?: unknown }).command; if (!shell) { if (Array.isArray(raw) && raw.length >= 2) { const first = String(raw[0] ?? ''); const shellMatch = first.match(/(?:^|\/)(sh|bash|zsh|fish|ash|dash)$/); if (shellMatch) shell = shellMatch[1]; } else if (typeof raw === 'string') { const shellMatch = raw.match(/^(?:\S*\/)?(sh|bash|zsh|fish|ash|dash)\s+-l?c\s+/); if (shellMatch) shell = shellMatch[1]; } } // Skills+CLI wrappers keep the Netcatty target only on CLI flags after unwrap. if (!sessionId) { const cmd = rawCommandString(args); if (cmd && cmd.includes('netcatty-tool-cli')) { const sessionMatch = cmd.match(/--session(?:\s+|=)(?:"([^"]+)"|'([^']+)'|(\S+))/); const fromFlag = sessionMatch?.[1] ?? sessionMatch?.[2] ?? sessionMatch?.[3]; if (fromFlag) sessionId = fromFlag; } } if (!sessionId && !cwd && !shell && !reason) return null; return { sessionId, cwd, shell, reason }; } /** * 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; 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 { name: string; className?: string; args?: Record; result?: unknown; isError?: boolean; isLoading?: boolean; isInterrupted?: boolean; /** Approval state for this tool call (from the approval gate). */ approvalStatus?: 'pending' | 'approved' | 'denied'; /** Pending approval id used to cancel the auto-deny timer on review. */ approvalId?: string; /** 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; /** Optional source-specific label for the persistent/session approval action. */ alwaysAllowLabel?: string; } async function copyTextToClipboard(text: string): Promise { try { if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; } } catch { // fall through } try { if (typeof document === 'undefined') return false; const el = document.createElement('textarea'); el.value = text; el.setAttribute('readonly', ''); el.style.position = 'fixed'; el.style.left = '-9999px'; document.body.appendChild(el); el.select(); const ok = document.execCommand('copy'); document.body.removeChild(el); return ok; } catch { return false; } } export const ToolCall = ({ name, args, result, isError, isLoading, isInterrupted, approvalStatus, approvalId, onApprove, onReject, onApproveOnce, onAlwaysAllow, alwaysAllowLabel, className, ...props }: ToolCallProps) => { const { t } = useI18n(); const [expanded, setExpanded] = useState(false); const [commandExpanded, setCommandExpanded] = useState(false); const [copied, setCopied] = useState(false); const [frozenCommand, setFrozenCommand] = useState(null); const cardRef = useRef(null); const approveBtnRef = useRef(null); const [responded, setResponded] = useState(false); const isPendingApproval = approvalStatus === 'pending' && !responded; const liveDisplayCommand = extractDisplayCommand(args); const reviewCommand = isPendingApproval ? (frozenCommand ?? liveDisplayCommand) : liveDisplayCommand; const executionContext = extractApprovalExecutionContext(args); const showApprovalCommand = Boolean(isPendingApproval && reviewCommand); const showArgsAlongsideCommand = Boolean( showApprovalCommand && args && Object.keys(args).length > 0 && (approvalCommandWasUnwrapped(args, reviewCommand) || approvalArgsHaveExtraContext(args)), ); const commandNeedsExpand = Boolean( reviewCommand && (reviewCommand.length > APPROVAL_COMMAND_EXPAND_CHAR_THRESHOLD || reviewCommand.includes('\n')), ); // Each review interaction re-arms the Catty idle window (capped by hard // deadline). Do not one-shot cancel — subsequent focus/scroll/key events // must keep extending idle while the user is still deciding. const markReviewing = useCallback(() => { if (!isPendingApproval || !approvalId) return; cancelApprovalTimeout(approvalId); }, [approvalId, isPendingApproval]); 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]); const handleCopyCommand = useCallback(async () => { if (!reviewCommand) return; markReviewing(); const ok = await copyTextToClipboard(reviewCommand); if (!ok) return; setCopied(true); window.setTimeout(() => setCopied(false), 1500); }, [markReviewing, reviewCommand]); // Keyboard: Enter = approve, Escape = reject (when pending). // Ignore Enter from nested controls (Copy / Expand / action buttons) so it // activates that control instead of approving the pending command. const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (!isPendingApproval) return; if (e.key === 'Enter') { if (isNestedInteractiveApprovalTarget(e.target as HTMLElement | null, e.currentTarget)) { return; } e.preventDefault(); handleApproveOnce(); } else if (e.key === 'Escape') { e.preventDefault(); handleReject(); } else { // Typing / navigation while reviewing cancels the idle auto-deny timer. markReviewing(); } }, [isPendingApproval, handleApproveOnce, handleReject, markReviewing]); // Auto-focus and auto-scroll when approval is pending. // Do not treat this programmatic expand/focus as user review (timeout stays armed). useEffect(() => { if (!isPendingApproval || !cardRef.current) return; cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' }); setExpanded(true); const focusTimer = setTimeout(() => approveBtnRef.current?.focus(), 100); return () => clearTimeout(focusTimer); }, [isPendingApproval]); // Freeze the reviewable command for the life of this pending approval. // Do not reset review/timeout state when args identity churns while still pending. useEffect(() => { if (approvalStatus === 'pending') { setResponded(false); setCommandExpanded(false); setFrozenCommand(extractDisplayCommand(args)); return; } setFrozenCommand(null); setCommandExpanded(false); // Intentionally depend only on approvalStatus so late arg patches cannot // replace the command the user is already reviewing. // eslint-disable-next-line react-hooks/exhaustive-deps -- freeze on pending enter }, [approvalStatus]); // If the first pending paint had no command yet, accept the first non-empty one. useEffect(() => { if (!isPendingApproval || frozenCommand) return; const next = extractDisplayCommand(args); if (next) setFrozenCommand(next); }, [args, frozenCommand, isPendingApproval]); // 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' ? ( ) : isLoading ? ( ) : isInterrupted ? ( ) : isError ? ( ) : result !== undefined ? ( ) : null; const headerCommand = reviewCommand ?? liveDisplayCommand; return (
{expanded && (
{showApprovalCommand && reviewCommand && (
{executionContext && (
{t('ai.chat.targetLabel')} {executionContext.sessionId && ( {t('ai.chat.approvalSession')}: {executionContext.sessionId} )} {executionContext.shell && ( {t('ai.chat.approvalShell')}: {executionContext.shell} )} {executionContext.cwd && ( {t('ai.chat.approvalCwd')}: {executionContext.cwd} )} {executionContext.reason && ( {t('ai.chat.approvalReason')}: {executionContext.reason} )}
)}
{t('ai.chat.rawCommand')}
{commandNeedsExpand && ( )}
                {reviewCommand}
              
)} {args && Object.keys(args).length > 0 && (!showApprovalCommand || showArgsAlongsideCommand) && (
{showArgsAlongsideCommand ? t('ai.chat.approvalInvocation') : 'Arguments'}
{/* Args-only approvals (Codex file-change/permissions, write tools with JSON args) have no command pre — wheel/trackpad scroll must re-arm idle the same way the command overflow block does. */}
                {JSON.stringify(args, null, 2)}
              
)} {/* Inline approval buttons */} {isPendingApproval && (

{t('ai.chat.toolApprovalHint')}

{onAlwaysAllow && ( )}
)} {result !== undefined && (
Result
                {formatToolResult(result)}
              
)} {isInterrupted && result === undefined && (
Status
Interrupted
)}
)}
); };