/** * @license * Copyright 2025 Vybestack LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { Box, Text } from 'ink'; import { DiffRenderer } from './DiffRenderer.js'; import { Colors } from '../../colors.js'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; import { AnsiOutputText } from '../AnsiOutput.js'; import { MaxSizedBox } from '../shared/MaxSizedBox.js'; import { type AnsiOutput } from '@vybestack/llxprt-code-core'; import { useUIState } from '../../contexts/UIStateContext.js'; import { STATUS_INDICATOR_WIDTH } from './ToolShared.js'; const STATIC_HEIGHT = 1; const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc. const MIN_LINES_SHOWN = 2; // show at least this many lines // Large threshold to ensure we don't cause performance issues for very large // outputs that will get truncated further MaxSizedBox anyway. const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 1000000; function isAnsiToken(value: unknown): boolean { return ( typeof value === 'object' && value !== null && typeof (value as { text?: unknown }).text === 'string' ); } function isAnsiOutput(value: unknown): value is AnsiOutput { return ( Array.isArray(value) && value.length > 0 && value.every( (line) => Array.isArray(line) && line.every((token) => isAnsiToken(token)), ) ); } /** * Render AST validation status. */ function renderAstValidation( metadata: Record | undefined, ): React.ReactNode { const astValidation = metadata?.astValidation as | { valid: boolean; errors: string[] } | undefined; if (!astValidation) return null; return ( {astValidation.valid ? ( AST Validation Passed ) : ( AST Validation Failed {astValidation.errors.map((err: string, i: number) => ( - {err} ))} )} ); } /** * Render file diff display content. */ function renderFileDiffContent( displayContent: { fileDiff: string; fileName?: string; metadata?: Record; }, availableHeight: number | undefined, childWidth: number, ): React.ReactNode { return ( <> {renderAstValidation(displayContent.metadata)} ); } /** * Render content display with metadata. */ function renderContentWithMetadata( displayContent: { content: string; metadata?: Record }, availableHeight: number | undefined, childWidth: number, renderMarkdown: boolean, ): React.ReactNode { const metadata = displayContent.metadata; const language = metadata?.language; const declarationsCount = metadata?.declarationsCount; return ( {typeof language === 'string' && ( Language: {language} )} {typeof declarationsCount === 'number' && ( Declarations Found: {declarationsCount} )} ); } /** * Render object content (fileDiff or content). */ function renderObjectContent( displayContent: object, availableHeight: number | undefined, childWidth: number, renderMarkdown: boolean, ): React.ReactNode { return ( {'fileDiff' in displayContent && renderFileDiffContent( displayContent as { fileDiff: string; fileName?: string; metadata?: Record; }, availableHeight, childWidth, )} {'content' in displayContent && renderContentWithMetadata( displayContent as { content: string; metadata?: Record; }, availableHeight, childWidth, renderMarkdown, )} ); } /** * Drops source lines that cannot reach the visible window. * * `MaxSizedBox` shows the tail of its content and lays out every line it is * given before clipping. A source line always occupies at least one display * line, so keeping the last `maxDisplayLines` source lines is enough to fill * the window, and everything earlier can be dropped before layout rather than * wrapped and discarded afterwards. * * The returned count is the estimated number of display lines removed, so the * caller can report them through `additionalHiddenLinesCount`. It is an * estimate because exact wrapping depends on word boundaries. */ export function trimToVisibleTail( text: string, maxDisplayLines: number, width: number, ): { text: string; hiddenDisplayLines: number } { const usableWidth = Math.max(1, width); let hiddenDisplayLines = 0; let kept = text; const lines = text.split('\n'); if (lines.length > maxDisplayLines) { for (const line of lines.slice(0, lines.length - maxDisplayLines)) { hiddenDisplayLines += Math.max(1, Math.ceil(line.length / usableWidth)); } kept = lines.slice(lines.length - maxDisplayLines).join('\n'); } // Line count alone does not bound the work: a single unbroken line, such as // minified output or a log without newlines, wraps into as many display rows // as it has width-sized chunks. Keeping the last `maxDisplayLines` rows worth // of characters always leaves at least that many rows, because a row never // holds more than `usableWidth` of them. const characterBudget = maxDisplayLines * usableWidth; if (kept.length > characterBudget) { const dropped = kept.length - characterBudget; // Count the dropped prefix line by line rather than dividing its length by // the width. Short lines each occupy a row regardless of length, so // ignoring newline boundaries here would undercount what was hidden. const droppedLines = kept.slice(0, dropped).split('\n'); if (droppedLines[droppedLines.length - 1] === '') { // The prefix ended exactly on a newline, so the trailing empty piece is // the start of a line that is being kept. droppedLines.pop(); } for (const line of droppedLines) { hiddenDisplayLines += Math.max(1, Math.ceil(line.length / usableWidth)); } kept = kept.slice(dropped); } return { text: kept, hiddenDisplayLines }; } /** * Render string content. */ function renderStringContent( displayContent: string, shouldRenderMarkdown: boolean, availableHeight: number | undefined, childWidth: number, renderMarkdown: boolean, ): React.ReactNode { if (shouldRenderMarkdown) { return ( ); } const { text, hiddenDisplayLines } = availableHeight === undefined ? { text: displayContent, hiddenDisplayLines: 0 } : trimToVisibleTail(displayContent, availableHeight, childWidth); return ( {text} ); } export interface ToolResultDisplayProps { resultDisplay: string | object | undefined; availableTerminalHeight?: number; terminalWidth: number; renderOutputAsMarkdown?: boolean; } export const ToolResultDisplay: React.FC = ({ resultDisplay, availableTerminalHeight, terminalWidth, renderOutputAsMarkdown = true, }) => { const { renderMarkdown } = useUIState(); const availableHeight = availableTerminalHeight !== undefined ? Math.max( availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT, MIN_LINES_SHOWN + 1, ) : undefined; const shouldRenderMarkdown = availableHeight !== undefined ? false : renderOutputAsMarkdown; const childWidth = terminalWidth; let displayContent = resultDisplay; if ( typeof displayContent === 'string' && displayContent.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS ) { displayContent = '...' + displayContent.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS); } if (displayContent === undefined) { return null; } const ansiOutput = isAnsiOutput(displayContent) ? displayContent : undefined; return ( {ansiOutput !== undefined && ( )} {typeof displayContent === 'string' && renderStringContent( displayContent, shouldRenderMarkdown, availableHeight, childWidth, renderMarkdown, )} {ansiOutput === undefined && typeof displayContent !== 'string' && renderObjectContent( displayContent, availableHeight, childWidth, renderMarkdown, )} ); };