/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useMemo, useState, useRef } from 'react'; import { Box, Text, type DOMElement } from 'ink'; import { ToolCallStatus, type IndividualToolCallDisplay, type FileDiff, type ListDirectoryResult, type ReadManyFilesResult, mapCoreStatusToDisplayStatus, isFileDiff, isTodoList, hasSummary, isGrepResult, isListResult, } from '../../types.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; import { ToolStatusIndicator } from './ToolShared.js'; import { theme } from '../../semantic-colors.js'; import { DiffRenderer, renderDiffLines, isNewFile, parseDiffWithLineNumbers, } from './DiffRenderer.js'; import { useMouseClick } from '../../hooks/useMouseClick.js'; import { ScrollableList } from '../shared/ScrollableList.js'; import { COMPACT_TOOL_SUBVIEW_MAX_LINES } from '../../constants.js'; import { useSettings } from '../../contexts/SettingsContext.js'; import { colorizeCode } from '../../utils/CodeColorizer.js'; import { useToolActions } from '../../contexts/ToolActionsContext.js'; interface DenseToolMessageProps extends IndividualToolCallDisplay { terminalWidth?: number; availableTerminalHeight?: number; } interface ViewParts { description?: React.ReactNode; summary?: React.ReactNode; payload?: React.ReactNode; } /** * --- TYPE GUARDS --- */ const hasPayload = ( res: unknown, ): res is { summary: string; payload: string } => hasSummary(res) && 'payload' in res; /** * --- RENDER HELPERS --- */ const RenderItemsList: React.FC<{ items?: string[]; maxVisible?: number; }> = ({ items, maxVisible = 20 }) => { if (!items || items.length === 0) return null; return ( {items.slice(0, maxVisible).map((item, i) => ( {item} ))} {items.length > maxVisible && ( ... and {items.length - maxVisible} more )} ); }; /** * --- SCENARIO LOGIC (Pure Functions) --- */ function getFileOpData( diff: FileDiff, status: ToolCallStatus, resultDisplay: unknown, terminalWidth?: number, availableTerminalHeight?: number, ): ViewParts { const added = (diff.diffStat?.model_added_lines ?? 0) + (diff.diffStat?.user_added_lines ?? 0); const removed = (diff.diffStat?.model_removed_lines ?? 0) + (diff.diffStat?.user_removed_lines ?? 0); const isAcceptedOrConfirming = status === ToolCallStatus.Success || status === ToolCallStatus.Executing || status === ToolCallStatus.Confirming; const addColor = isAcceptedOrConfirming ? theme.status.success : theme.text.secondary; const removeColor = isAcceptedOrConfirming ? theme.status.error : theme.text.secondary; // Always show diff stats if available, using neutral colors for rejected const showDiffStat = !!diff.diffStat; const description = ( {diff.fileName} ); let decision = ''; let decisionColor = theme.text.secondary; if (status === ToolCallStatus.Confirming) { decision = 'Confirming'; } else if ( status === ToolCallStatus.Success || status === ToolCallStatus.Executing ) { decision = 'Accepted'; decisionColor = theme.text.accent; } else if (status === ToolCallStatus.Canceled) { decision = 'Rejected'; decisionColor = theme.status.error; } else if (status === ToolCallStatus.Error) { decision = typeof resultDisplay === 'string' ? resultDisplay : 'Failed'; decisionColor = theme.status.error; } const summary = ( {decision && ( → {decision.replace(/\n/g, ' ')} )} {showDiffStat && ( {'('} +{added} {', '} -{removed} {')'} )} ); const payload = ( ); return { description, summary, payload }; } function getReadManyFilesData(result: ReadManyFilesResult): ViewParts { const items = result.files ?? []; const maxVisible = 10; const includePatterns = result.include?.join(', ') ?? ''; const description = ( Attempting to read files from {includePatterns} ); const skippedCount = result.skipped?.length ?? 0; const summaryStr = `Read ${items.length} file(s)${ skippedCount > 0 ? ` (${skippedCount} ignored)` : '' }`; const summary = → {summaryStr}; const excludedText = result.excludes && result.excludes.length > 0 ? `Excluded patterns: ${result.excludes.slice(0, 3).join(', ')}${ result.excludes.length > 3 ? '...' : '' }` : undefined; const hasItems = items.length > 0; const payload = hasItems || excludedText ? ( {hasItems && } {excludedText && ( {excludedText} )} ) : undefined; return { description, summary, payload }; } function getListDirectoryData( result: ListDirectoryResult, originalDescription?: string, ): ViewParts { const summary = → {result.summary}; const description = originalDescription ? ( {originalDescription} ) : undefined; // For directory listings, we want NO payload in dense mode as per request return { description, summary, payload: undefined }; } function getListResultData( result: ListDirectoryResult | ReadManyFilesResult, _toolName: string, originalDescription?: string, ): ViewParts { // Use 'include' to determine if this is a ReadManyFilesResult if ('include' in result) { return getReadManyFilesData(result); } return getListDirectoryData( result as ListDirectoryResult, originalDescription, ); } function getGenericSuccessData( resultDisplay: unknown, originalDescription?: string, ): ViewParts { let summary: React.ReactNode; let payload: React.ReactNode; const description = originalDescription ? ( {originalDescription} ) : undefined; if (typeof resultDisplay === 'string') { const flattened = resultDisplay.replace(/\n/g, ' ').trim(); summary = ( → {flattened.length > 120 ? flattened.slice(0, 117) + '...' : flattened} ); } else if (isGrepResult(resultDisplay)) { summary = → {resultDisplay.summary}; const matches = resultDisplay.matches ?? []; if (matches.length > 0) { payload = ( `${m.filePath}:${m.lineNumber}: ${m.line.trim()}`, )} maxVisible={10} /> ); } } else if (isTodoList(resultDisplay)) { summary = ( → Todos updated ); } else if (hasPayload(resultDisplay)) { summary = → {resultDisplay.summary}; payload = ( {resultDisplay.payload} ); } else { summary = ( → Output received ); } return { description, summary, payload }; } /** * --- MAIN COMPONENT --- */ export const DenseToolMessage: React.FC = (props) => { const { callId, name, status, resultDisplay, confirmationDetails, outputFile, terminalWidth, availableTerminalHeight, description: originalDescription, } = props; const mappedStatus = useMemo( () => mapCoreStatusToDisplayStatus(props.status), [props.status], ); const settings = useSettings(); const isAlternateBuffer = useAlternateBuffer(); const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions(); // Handle optional context members const [localIsExpanded, setLocalIsExpanded] = useState(false); const isExpanded = isExpandedInContext ? isExpandedInContext(callId) : localIsExpanded; const [isFocused, setIsFocused] = useState(false); const toggleRef = useRef(null); // 1. Unified File Data Extraction (Safely bridge resultDisplay and confirmationDetails) const diff = useMemo((): FileDiff | undefined => { if (isFileDiff(resultDisplay)) return resultDisplay; if (confirmationDetails?.type === 'edit') { const details = confirmationDetails; return { fileName: details.fileName, fileDiff: details.fileDiff, filePath: details.filePath, originalContent: details.originalContent, newContent: details.newContent, diffStat: details.diffStat, }; } return undefined; }, [resultDisplay, confirmationDetails]); const handleToggle = () => { const next = !isExpanded; if (!next) { setIsFocused(false); } else { setIsFocused(true); } if (toggleExpansion) { toggleExpansion(callId); } else { setLocalIsExpanded(next); } }; useMouseClick(toggleRef, handleToggle, { isActive: isAlternateBuffer && !!diff, }); // 2. State-to-View Coordination const viewParts = useMemo((): ViewParts => { if (diff) { return getFileOpData( diff, mappedStatus, resultDisplay, terminalWidth, availableTerminalHeight, ); } if (isListResult(resultDisplay)) { return getListResultData(resultDisplay, name, originalDescription); } if (isGrepResult(resultDisplay)) { return getGenericSuccessData(resultDisplay, originalDescription); } if (mappedStatus === ToolCallStatus.Success && resultDisplay) { return getGenericSuccessData(resultDisplay, originalDescription); } if (mappedStatus === ToolCallStatus.Error) { const text = typeof resultDisplay === 'string' ? resultDisplay.replace(/\n/g, ' ') : 'Failed'; const errorSummary = ( → {text.length > 120 ? text.slice(0, 117) + '...' : text} ); const descriptionText = originalDescription ? ( {originalDescription} ) : undefined; return { description: descriptionText, summary: errorSummary, payload: undefined, }; } const descriptionText = originalDescription ? ( {originalDescription} ) : undefined; return { description: descriptionText, summary: undefined, payload: undefined, }; }, [ diff, mappedStatus, resultDisplay, name, terminalWidth, availableTerminalHeight, originalDescription, ]); const { description, summary } = viewParts; const diffLines = useMemo(() => { if (!diff || !isExpanded || !isAlternateBuffer) return []; const parsedLines = parseDiffWithLineNumbers(diff.fileDiff); const isNewFileResult = isNewFile(parsedLines); if (isNewFileResult) { const addedContent = parsedLines .filter((line) => line.type === 'add') .map((line) => line.content) .join('\n'); const fileExtension = diff.fileName?.split('.').pop() || null; // We use colorizeCode with returnLines: true // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return colorizeCode({ code: addedContent, language: fileExtension, maxWidth: terminalWidth ? terminalWidth - 6 : 80, settings, disableColor: mappedStatus === ToolCallStatus.Canceled, returnLines: true, }) as React.ReactNode[]; } else { return renderDiffLines({ parsedLines, filename: diff.fileName, terminalWidth: terminalWidth ? terminalWidth - 6 : 80, disableColor: mappedStatus === ToolCallStatus.Canceled, }); } }, [ diff, isExpanded, isAlternateBuffer, terminalWidth, settings, mappedStatus, ]); const showPayload = useMemo(() => { const policy = !isAlternateBuffer || !diff || isExpanded; if (!policy) return false; if (diff) { if (isAlternateBuffer) { return isExpanded && diffLines.length > 0; } // In non-alternate buffer mode, we always show the diff. return true; } return !!(viewParts.payload || outputFile); }, [ isAlternateBuffer, diff, isExpanded, diffLines.length, viewParts.payload, outputFile, ]); const keyExtractor = (_item: React.ReactNode, index: number) => `diff-line-${index}`; const renderItem = ({ item }: { item: React.ReactNode }) => ( {item} ); // 3. Final Layout return ( {name}{' '} {description} {summary && ( {summary} )} {isAlternateBuffer && diff && ( [{isExpanded ? 'Hide Diff' : 'Show Diff'}] )} {showPayload && isAlternateBuffer && diffLines.length > 0 && ( 1} hasFocus={isFocused} width={ // adjustment: 6 margin - 4 padding/border - 4 right-scroll-gutter terminalWidth ? Math.min(120, terminalWidth - 6 - 4 - 4) : 70 } /> )} {showPayload && (!isAlternateBuffer || !diff) && viewParts.payload && ( {viewParts.payload} )} {showPayload && outputFile && ( (Output saved to: {outputFile}) )} ); };