mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
97b1a2a336
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>
347 lines
14 KiB
TypeScript
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>
|
|
);
|
|
};
|