import type { ToolCallBlock } from '@/chat/types' import type { ApiClient } from '@/api/client' import type { SessionMetadataSummary } from '@/types/api' import { memo, useEffect, useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import { isObject, safeStringify } from '@hapi/protocol' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { CodeBlock } from '@/components/CodeBlock' import { MarkdownRenderer } from '@/components/MarkdownRenderer' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { PermissionFooter } from '@/components/ToolCard/PermissionFooter' import { AskUserQuestionFooter } from '@/components/ToolCard/AskUserQuestionFooter' import { RequestUserInputFooter } from '@/components/ToolCard/RequestUserInputFooter' import { isAskUserQuestionToolName } from '@/components/ToolCard/askUserQuestion' import { isRequestUserInputToolName } from '@/components/ToolCard/requestUserInput' import { getToolPresentation } from '@/components/ToolCard/knownTools' import { getToolFullViewComponent, getToolViewComponent } from '@/components/ToolCard/views/_all' import { getToolResultViewComponent } from '@/components/ToolCard/views/_results' import { formatTaskChildLabel, TaskStateIcon } from '@/components/ToolCard/helpers' import type { TerminalToolDisplayMode } from '@/hooks/useTerminalToolDisplayMode' import { usePointerFocusRing } from '@/hooks/usePointerFocusRing' import { getInputStringAny, truncate } from '@/lib/toolInputUtils' import { cn } from '@/lib/utils' import { useTranslation } from '@/lib/use-translation' import { TraceSection } from '@/components/ToolCard/trace' import { isSubagentToolName } from '@/chat/subagentTool' const ELAPSED_INTERVAL_MS = 1000 const TERMINAL_RELATED_TOOL_NAMES = new Set(['Bash', 'CodexBash', 'shell_command', 'run_shell_command']) export function shouldUseCompactTerminalToolCard(toolName: string, terminalToolDisplayMode: TerminalToolDisplayMode): boolean { return TERMINAL_RELATED_TOOL_NAMES.has(toolName) && terminalToolDisplayMode === 'compact' } export function shouldShowInlineToolCardBody( toolName: string, presentationMinimal: boolean, terminalToolDisplayMode: TerminalToolDisplayMode ): boolean { if (isSubagentToolName(toolName)) return false if (TERMINAL_RELATED_TOOL_NAMES.has(toolName)) { return terminalToolDisplayMode === 'detailed' } return !presentationMinimal } function ElapsedView(props: { from: number; active: boolean }) { const [now, setNow] = useState(() => Date.now()) useEffect(() => { if (!props.active) return setNow(Date.now()) const id = setInterval(() => setNow(Date.now()), ELAPSED_INTERVAL_MS) return () => clearInterval(id) }, [props.active, props.from]) if (!props.active) return null const elapsed = Math.max(0, now - props.from) / 1000 if (!Number.isFinite(elapsed)) return null return ( {elapsed.toFixed(1)}s ) } function getTaskSummaryChildren(block: ToolCallBlock): { visible: ToolCallBlock[]; remaining: number } | null { if (!isSubagentToolName(block.tool.name)) return null const children = block.children .filter((child): child is ToolCallBlock => child.kind === 'tool-call') .filter((child) => child.tool.state === 'pending' || child.tool.state === 'running' || child.tool.state === 'completed' || child.tool.state === 'error') if (children.length === 0) return null const visible = children.slice(-3) return { visible, remaining: children.length - visible.length } } function renderTaskSummary( block: ToolCallBlock, metadata: SessionMetadataSummary | null, t: (key: string, params?: Record) => string, ): ReactNode | null { const summary = getTaskSummaryChildren(block) if (!summary) return null const visible = summary.visible const remaining = summary.remaining return (
{visible.map((child) => (
{formatTaskChildLabel(child, metadata, t)}
))} {remaining > 0 ? (
(+{remaining} more)
) : null}
) } function renderToolInput(block: ToolCallBlock, surface: 'inline' | 'dialog' = 'inline'): ReactNode { const collapseLongContent = surface === 'inline' const codeBlockSurfaceProps = surface === 'dialog' ? { size: 'comfortable' as const, scrollY: true } : {} const toolName = block.tool.name const input = block.tool.input if (isSubagentToolName(toolName) && isObject(input) && typeof input.prompt === 'string') { return } const commandArray = isObject(input) && Array.isArray(input.command) ? input.command : null if ((toolName === 'CodexBash' || toolName === 'Bash') && (typeof commandArray?.[0] === 'string' || typeof input === 'object')) { const cmd = Array.isArray(commandArray) ? commandArray.filter((part) => typeof part === 'string').join(' ') : getInputStringAny(input, ['command', 'cmd']) if (cmd) { return } } return } export function ToolStatusIcon(props: { state: ToolCallBlock['tool']['state'] }) { if (props.state === 'completed') { return ( ) } if (props.state === 'error') { return ( ) } if (props.state === 'pending') { return ( ) } return ( ) } export function toolStatusColorClass(state: ToolCallBlock['tool']['state']): string { if (state === 'completed') return 'text-emerald-600' if (state === 'error') return 'text-red-600' if (state === 'pending') return 'text-amber-600' return 'text-[var(--app-hint)]' } function DetailsIcon() { return ( ) } const INLINE_PREVIEW_INTERACTIVE_SELECTOR = 'a, button, input, textarea, select, summary, [role="button"], [contenteditable="true"]' function isNestedInteractiveElement(event: MouseEvent | KeyboardEvent): boolean { if (event.target === event.currentTarget) return false if (!(event.target instanceof Element)) return false const interactive = event.target.closest(INLINE_PREVIEW_INTERACTIVE_SELECTOR) return interactive !== null && interactive !== event.currentTarget } type ToolCardProps = { api: ApiClient sessionId: string metadata: SessionMetadataSummary | null terminalToolDisplayMode: TerminalToolDisplayMode disabled: boolean onDone: () => void block: ToolCallBlock } export function ToolDetailDialogContent(props: { block: ToolCallBlock metadata: SessionMetadataSummary | null }) { const { t } = useTranslation() const toolName = props.block.tool.name const FullToolView = getToolFullViewComponent(toolName) const ResultToolView = getToolResultViewComponent(toolName) const permission = props.block.tool.permission const isAskUserQuestion = isAskUserQuestionToolName(toolName) const isRequestUserInput = isRequestUserInputToolName(toolName) const isQuestionTool = isAskUserQuestion || isRequestUserInput const isQuestionToolWithAnswers = isQuestionTool && permission?.answers && Object.keys(permission.answers).length > 0 return (
{isQuestionToolWithAnswers ? t('tool.questionsAnswers') : t('tool.input')}
{FullToolView ? ( ) : ( renderToolInput(props.block, 'dialog') )}
{!isQuestionToolWithAnswers ? (
{t('tool.result')}
) : null}
) } function ToolCardInner(props: ToolCardProps) { const { t } = useTranslation() const [detailsOpen, setDetailsOpen] = useState(false) const presentation = useMemo(() => getToolPresentation({ toolName: props.block.tool.name, input: props.block.tool.input, result: props.block.tool.result, childrenCount: props.block.children.length, description: props.block.tool.description, metadata: props.metadata }, t), [ props.block.tool.name, props.block.tool.input, props.block.tool.result, props.block.children.length, props.block.tool.description, props.metadata, t ]) const toolName = props.block.tool.name const toolTitle = presentation.title const subtitle = presentation.subtitle ?? props.block.tool.description const taskSummary = renderTaskSummary(props.block, props.metadata, t) const runningFrom = props.block.tool.startedAt ?? props.block.tool.createdAt const isCodexAgentCard = toolName === 'CodexAgent' const useCompactTerminalCard = shouldUseCompactTerminalToolCard(toolName, props.terminalToolDisplayMode) const showInline = shouldShowInlineToolCardBody(toolName, presentation.minimal, props.terminalToolDisplayMode) const CompactToolView = showInline ? getToolViewComponent(toolName) : null const ResultToolView = getToolResultViewComponent(toolName) const permission = props.block.tool.permission const isAskUserQuestion = isAskUserQuestionToolName(toolName) const isRequestUserInput = isRequestUserInputToolName(toolName) const isQuestionTool = isAskUserQuestion || isRequestUserInput const showsPermissionFooter = Boolean(permission && ( permission.status === 'pending' || ((permission.status === 'denied' || permission.status === 'canceled') && Boolean(permission.reason)) )) const hasBody = showInline || taskSummary !== null || showsPermissionFooter const stateColor = toolStatusColorClass(props.block.tool.state) const { suppressFocusRing, onTriggerPointerDown, onTriggerKeyDown, onTriggerBlur } = usePointerFocusRing() const openDetails = () => setDetailsOpen(true) const openDetailsFromInlinePreview = (event: MouseEvent) => { if (isNestedInteractiveElement(event)) return openDetails() } const openDetailsFromInlinePreviewKeyDown = (event: KeyboardEvent) => { if (isNestedInteractiveElement(event)) return if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() openDetails() } } const header = (
{presentation.icon}
{toolTitle}
{subtitle ? ( {truncate(subtitle, 160)} ) : null}
) return ( {toolTitle} {hasBody ? ( {taskSummary ? (
{taskSummary}
) : null} {showInline ? ( CompactToolView ? (
) : (
{t('tool.input')}
{renderToolInput(props.block, 'inline')}
{t('tool.result')}
) ) : null} {isAskUserQuestion && permission?.status === 'pending' ? ( ) : isRequestUserInput && permission?.status === 'pending' ? ( ) : ( )}
) : null}
) } export const ToolCard = memo(ToolCardInner)