Files
gemini-cli/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx
T

223 lines
6.9 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
import { SlicingMaxSizedBox } from '../shared/SlicingMaxSizedBox.js';
import { theme } from '../../semantic-colors.js';
import {
type AnsiOutput,
type AnsiLine,
isSubagentProgress,
} from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { Scrollable } from '../shared/Scrollable.js';
import { ScrollableList } from '../shared/ScrollableList.js';
import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js';
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
export interface ToolResultDisplayProps {
resultDisplay: string | object | undefined;
availableTerminalHeight?: number;
terminalWidth: number;
renderOutputAsMarkdown?: boolean;
maxLines?: number;
hasFocus?: boolean;
overflowDirection?: 'top' | 'bottom';
}
interface FileDiffResult {
fileDiff: string;
fileName: string;
}
export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
resultDisplay,
availableTerminalHeight,
terminalWidth,
renderOutputAsMarkdown = true,
maxLines,
hasFocus = false,
overflowDirection = 'top',
}) => {
const { renderMarkdown } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const availableHeight = calculateToolContentMaxLines({
availableTerminalHeight,
isAlternateBuffer,
maxLinesLimit: maxLines,
});
const combinedPaddingAndBorderWidth = 4;
const childWidth = terminalWidth - combinedPaddingAndBorderWidth;
const keyExtractor = React.useCallback(
(_: AnsiLine, index: number) => index.toString(),
[],
);
const renderVirtualizedAnsiLine = React.useCallback(
({ item }: { item: AnsiLine }) => (
<Box height={1} overflow="hidden">
<AnsiLineText line={item} />
</Box>
),
[],
);
if (!resultDisplay) return null;
// 1. Early return for background tools (Todos)
if (typeof resultDisplay === 'object' && 'todos' in resultDisplay) {
// display nothing, as the TodoTray will handle rendering todos
return null;
}
const renderContent = (contentData: string | object | undefined) => {
// Check if string content is valid JSON and pretty-print it
const prettyJSON =
typeof contentData === 'string' ? tryParseJSON(contentData) : null;
const formattedJSON = prettyJSON
? JSON.stringify(prettyJSON, null, 2)
: null;
let content: React.ReactNode;
if (formattedJSON) {
// Render pretty-printed JSON
content = (
<Text wrap="wrap" color={theme.text.primary}>
{formattedJSON}
</Text>
);
} else if (isSubagentProgress(contentData)) {
content = <SubagentProgressDisplay progress={contentData} />;
} else if (typeof contentData === 'string' && renderOutputAsMarkdown) {
content = (
<MarkdownDisplay
text={contentData}
terminalWidth={childWidth}
renderMarkdown={renderMarkdown}
isPending={false}
/>
);
} else if (typeof contentData === 'string' && !renderOutputAsMarkdown) {
content = (
<Text wrap="wrap" color={theme.text.primary}>
{contentData}
</Text>
);
} else if (typeof contentData === 'object' && 'fileDiff' in contentData) {
content = (
<DiffRenderer
diffContent={
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(contentData as FileDiffResult).fileDiff
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
filename={(contentData as FileDiffResult).fileName}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
/>
);
} else {
const shouldDisableTruncation =
isAlternateBuffer ||
(availableTerminalHeight === undefined && maxLines === undefined);
content = (
<AnsiOutputText
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
data={contentData as AnsiOutput}
availableTerminalHeight={
isAlternateBuffer ? undefined : availableHeight
}
width={childWidth}
maxLines={isAlternateBuffer ? undefined : maxLines}
disableTruncation={shouldDisableTruncation}
/>
);
}
// Final render based on session mode
if (isAlternateBuffer) {
return (
<Scrollable
width={childWidth}
maxHeight={maxLines ?? availableHeight}
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
scrollToBottom={true}
reportOverflow={true}
>
{content}
</Scrollable>
);
}
return content;
};
// ASB Mode Handling (Interactive/Fullscreen)
if (isAlternateBuffer) {
// Virtualized path for large ANSI arrays
if (Array.isArray(resultDisplay)) {
const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
const listHeight = Math.min(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(resultDisplay as AnsiOutput).length,
limit,
);
return (
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
<ScrollableList
width={childWidth}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
data={resultDisplay as AnsiOutput}
renderItem={renderVirtualizedAnsiLine}
estimatedItemHeight={() => 1}
keyExtractor={keyExtractor}
initialScrollIndex={SCROLL_TO_ITEM_END}
hasFocus={hasFocus}
/>
</Box>
);
}
// Standard path for strings/diffs in ASB
return (
<Box width={childWidth} flexDirection="column">
{renderContent(resultDisplay)}
</Box>
);
}
// Standard Mode Handling (History/Scrollback)
// We use SlicingMaxSizedBox which includes MaxSizedBox for precision truncation + hidden labels
return (
<Box width={childWidth} flexDirection="column">
<SlicingMaxSizedBox
data={resultDisplay}
maxLines={maxLines}
isAlternateBuffer={isAlternateBuffer}
maxHeight={availableHeight}
maxWidth={childWidth}
overflowDirection={overflowDirection}
>
{(truncatedResultDisplay) => renderContent(truncatedResultDisplay)}
</SlicingMaxSizedBox>
</Box>
);
};