// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered import React, { useMemo } from 'react' import { Ansi, Box, Text } from '@anthropic/ink' import { FilePathLink } from '../FilePathLink.js' import { toInkColor } from '../../utils/ink.js' import type { Attachment } from 'src/utils/attachments.js' import type { NullRenderingAttachmentType } from './nullRenderingAttachments.js' import { useAppState } from '../../state/AppState.js' import { getDisplayPath } from 'src/utils/file.js' import { formatFileSize } from 'src/utils/format.js' import { MessageResponse } from '../MessageResponse.js' import { basename, sep } from 'path' import { UserTextMessage } from './UserTextMessage.js' import { DiagnosticsDisplay } from '../DiagnosticsDisplay.js' import { getContentText } from 'src/utils/messages.js' import type { Theme } from 'src/utils/theme.js' import { UserImageMessage } from './UserImageMessage.js' import { jsonParse } from '../../utils/slowOperations.js' import { plural } from '../../utils/stringUtils.js' import { isEnvTruthy } from '../../utils/envUtils.js' import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' import { tryRenderPlanApprovalMessage, formatTeammateMessageContent, } from './PlanApprovalMessage.js' import { BLACK_CIRCLE } from '../../constants/figures.js' import { TeammateMessageContent } from './UserTeammateMessage.js' import { isShutdownApproved } from '../../utils/teammateMailbox.js' import { CtrlOToExpand } from '../CtrlOToExpand.js' import { feature } from 'bun:bundle' import { useSelectedMessageBg } from '../messageActions.js' type Props = { addMargin: boolean attachment: Attachment verbose: boolean isTranscriptMode?: boolean } export function AttachmentMessage({ attachment, addMargin, verbose, isTranscriptMode, }: Props): React.ReactNode { const bg = useSelectedMessageBg() // Hoisted to mount-time — per-message component, re-renders on every scroll. const isDemoEnv = feature('EXPERIMENTAL_SKILL_SEARCH') ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useMemo(() => isEnvTruthy(process.env.IS_DEMO), []) : false // Handle teammate_mailbox BEFORE switch if (isAgentSwarmsEnabled() && attachment.type === 'teammate_mailbox') { // Filter out idle notifications BEFORE counting - they are hidden in the UI // so showing them in the count would be confusing ("2 messages in mailbox:" with nothing shown) const visibleMessages = attachment.messages.filter(msg => { if (isShutdownApproved(msg.text)) { return false } try { const parsed = jsonParse(msg.text) return ( parsed?.type !== 'idle_notification' && parsed?.type !== 'teammate_terminated' ) } catch { return true // Non-JSON messages are visible } }) if (visibleMessages.length === 0) { return null } return ( {visibleMessages.map((msg, idx) => { // Try to parse as JSON for task_assignment messages let parsedMsg: { type?: string taskId?: string subject?: string assignedBy?: string } | null = null try { parsedMsg = jsonParse(msg.text) } catch { // Not JSON, treat as plain text } if (parsedMsg?.type === 'task_assignment') { return ( {BLACK_CIRCLE} Task assigned: #{parsedMsg.taskId} - {parsedMsg.subject} (from {parsedMsg.assignedBy || msg.from}) ) } // Note: idle_notification messages already filtered out above // Try to render as plan approval message (request or response) const planApprovalElement = tryRenderPlanApprovalMessage( msg.text, msg.from, ) if (planApprovalElement) { return ( {planApprovalElement} ) } // Plain text message - sender header with chevron, truncated content const inkColor = toInkColor(msg.color) const formattedContent = formatTeammateMessageContent(msg.text) ?? msg.text return ( ) })} ) } // skill_discovery rendered here (not in the switch) so the 'skill_discovery' // string literal stays inside a feature()-guarded block. A case label can't // be conditionally eliminated; an if-body can. if (feature('EXPERIMENTAL_SKILL_SEARCH')) { if (attachment.type === 'skill_discovery') { if (attachment.skills.length === 0) return null // Ant users get shortIds inline so they can /skill-feedback while the // turn is still fresh. External users (when this un-gates) just see // names — shortId is undefined outside ant builds anyway. const names = attachment.skills .map(s => (s.shortId ? `${s.name} [${s.shortId}]` : s.name)) .join(', ') const firstId = attachment.skills[0]?.shortId const hint = process.env.USER_TYPE === 'ant' && !isDemoEnv && firstId ? ` · /skill-feedback ${firstId} 1=wrong 2=noisy 3=good [comment]` : '' return ( {attachment.skills.length} relevant{' '} {plural(attachment.skills.length, 'skill')}: {names} {hint && {hint}} ) } } // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- teammate_mailbox/skill_discovery handled before switch switch (attachment.type) { case 'directory': return ( Listed directory {attachment.displayPath + sep} ) case 'file': case 'already_read_file': if (attachment.content.type === 'notebook') { return ( Read {attachment.displayPath} ( {attachment.content.file.cells.length} cells) ) } if (attachment.content.type === 'file_unchanged') { return ( Read {attachment.displayPath} (unchanged) ) } return ( Read {attachment.displayPath} ( {attachment.content.type === 'text' ? `${attachment.content.file.numLines}${attachment.truncated ? '+' : ''} lines` : formatFileSize(attachment.content.file.originalSize)} ) ) case 'compact_file_reference': return ( Referenced file {attachment.displayPath} ) case 'pdf_reference': return ( Referenced PDF {attachment.displayPath} ( {attachment.pageCount} pages) ) case 'selected_lines_in_ide': return ( ⧉ Selected{' '} {attachment.lineEnd - attachment.lineStart + 1}{' '} lines from {attachment.displayPath} in{' '} {attachment.ideName} ) case 'nested_memory': return ( Loaded {attachment.displayPath} ) case 'relevant_memories': // Usually absorbed into a CollapsedReadSearchGroup (collapseReadSearch.ts) // so this only renders when the preceding tool was non-collapsible (Edit, // Write) and no group was open. Match CollapsedReadSearchContent's style: // 2-space gutter, dim text, count only — filenames/content in ctrl+o. return ( Recalled {attachment.memories.length}{' '} {attachment.memories.length === 1 ? 'memory' : 'memories'} {!isTranscriptMode && ( <> {' '} )} {(verbose || isTranscriptMode) && attachment.memories.map(m => ( {basename(m.path)} {isTranscriptMode && ( {m.content} )} ))} ) case 'dynamic_skill': { const skillCount = attachment.skillNames.length return ( Loaded{' '} {skillCount} {plural(skillCount, 'skill')} {' '} from {attachment.displayPath} ) } case 'skill_listing': { if (attachment.isInitial) { return null } return ( {attachment.skillCount}{' '} {plural(attachment.skillCount, 'skill')} available ) } case 'agent_listing_delta': { if (attachment.isInitial || attachment.addedTypes.length === 0) { return null } const count = attachment.addedTypes.length return ( {count} agent {plural(count, 'type')} available ) } case 'queued_command': { const text = typeof attachment.prompt === 'string' ? attachment.prompt : getContentText(attachment.prompt) || '' const hasImages = attachment.imagePasteIds && attachment.imagePasteIds.length > 0 return ( {hasImages && attachment.imagePasteIds?.map(id => ( ))} ) } case 'plan_file_reference': return ( Plan file referenced ({getDisplayPath(attachment.planFilePath)}) ) case 'invoked_skills': { if (attachment.skills.length === 0) { return null } const skillNames = attachment.skills.map(s => s.name).join(', ') return Skills restored ({skillNames}) } case 'diagnostics': return case 'mcp_resource': return ( Read MCP resource {attachment.name} from{' '} {attachment.server} ) case 'command_permissions': // The skill success message is rendered by SkillTool's renderToolResultMessage, // so we don't render anything here to avoid duplicate messages. return null case 'async_hook_response': { // SessionStart hook completions are only shown in verbose mode if (attachment.hookEvent === 'SessionStart' && !verbose) { return null } // Generally hide async hook completion messages unless in verbose mode if (!verbose && !isTranscriptMode) { return null } return ( Async hook {attachment.hookEvent} completed ) } case 'hook_blocking_error': { // Stop hooks are rendered as a summary in SystemStopHookSummaryMessage if ( attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop' ) { return null } // Show stderr to the user so they can understand why the hook blocked const stderr = attachment.blockingError.blockingError.trim() return ( <> {attachment.hookName} hook returned blocking error {stderr ? {stderr} : null} ) } case 'hook_non_blocking_error': { // Stop hooks are rendered as a summary in SystemStopHookSummaryMessage if ( attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop' ) { return null } // Full hook output is logged to debug log via hookEvents.ts return {attachment.hookName} hook error } case 'hook_error_during_execution': // Stop hooks are rendered as a summary in SystemStopHookSummaryMessage if ( attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop' ) { return null } // Full hook output is logged to debug log via hookEvents.ts return {attachment.hookName} hook warning case 'hook_success': // Full hook output is logged to debug log via hookEvents.ts return null case 'hook_stopped_continuation': // Stop hooks are rendered as a summary in SystemStopHookSummaryMessage if ( attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop' ) { return null } return ( {attachment.hookName} hook stopped continuation: {attachment.message} ) case 'hook_system_message': return ( {attachment.hookName} says: {attachment.content} ) case 'hook_permission_decision': { const action = attachment.decision === 'allow' ? 'Allowed' : 'Denied' return ( {action} by {attachment.hookEvent} hook ) } case 'task_status': return case 'teammate_shutdown_batch': return ( {BLACK_CIRCLE} {attachment.count} {plural(attachment.count, 'teammate')} shut down gracefully ) default: // Exhaustiveness: every type reaching here must be in NULL_RENDERING_TYPES. // If TS errors, a new Attachment type was added without a case above AND // without an entry in NULL_RENDERING_TYPES — decide: render something (add // a case) or render nothing (add to the array). Messages.tsx pre-filters // these so this branch is defense-in-depth for other render paths. // // skill_discovery and teammate_mailbox are handled BEFORE the switch in // runtime-gated blocks (feature() / isAgentSwarmsEnabled()) that TS can't // narrow through — excluded here via type union (compile-time only, no emit). attachment.type satisfies | NullRenderingAttachmentType | 'skill_discovery' | 'teammate_mailbox' return null } } type TaskStatusAttachment = Extract function TaskStatusMessage({ attachment, }: { attachment: TaskStatusAttachment }): React.ReactNode { // For ants, killed task status is shown in the CoordinatorTaskPanel. // Don't render it again in the chat. if (process.env.USER_TYPE === 'ant' && attachment.status === 'killed') { return null } // Only access teammate-specific code when swarms are enabled. // TeammateTaskStatus subscribes to AppState; by gating the mount we // avoid adding a store listener for every non-teammate attachment. if (isAgentSwarmsEnabled() && attachment.taskType === 'in_process_teammate') { return } return } function GenericTaskStatus({ attachment, }: { attachment: TaskStatusAttachment }): React.ReactNode { const bg = useSelectedMessageBg() const statusText = attachment.status === 'completed' ? 'completed in background' : attachment.status === 'killed' ? 'stopped' : attachment.status === 'running' ? 'still running in background' : attachment.status return ( {BLACK_CIRCLE} Task "{attachment.description}" {statusText} ) } function TeammateTaskStatus({ attachment, }: { attachment: TaskStatusAttachment }): React.ReactNode { const bg = useSelectedMessageBg() // Narrow selector: only re-render when this specific task changes. const task = useAppState(s => s.tasks[attachment.taskId]) if (task?.type !== 'in_process_teammate') { // Fall through to generic rendering (task not yet in store, or wrong type) return } const agentColor = toInkColor(task.identity.color) const statusText = attachment.status === 'completed' ? 'shut down gracefully' : attachment.status return ( {BLACK_CIRCLE} Teammate{' '} @{task.identity.agentName} {' '} {statusText} ) } // We allow setting dimColor to false here to help work around the dim-bold bug. // https://github.com/chalk/chalk/issues/290 function Line({ dimColor = true, children, color, }: { dimColor?: boolean children: React.ReactNode color?: keyof Theme }): React.ReactNode { const bg = useSelectedMessageBg() return ( {children} ) }