mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-09 16:36:40 -07:00
44259a00e2
Checkpoint optimizing virtualized list Fixes for fallback rendering where terminalBuffer=false Change terminalBuffer false back to the default while we fix performance with very large chats. Checkpoint changes to virtualized list. Fix virtualized list NO commit Update ink version. Fix UI snapshot mismatch in MainContent tests and VirtualizedList computation Checkpoint. Optimize scrolling checkpoint Fix tests and resolve remaining issues after rebase - Fixed ToolStickyHeaderRegression scrolling logic. - Added MouseProvider to VirtualizedList test. - Updated snapshots to reflect layout changes in optimized virtual list. Fix flaky plan-mode test by removing model request assertions Fix unit tests by updating expected decisions for tool permissions after rebase use onStaticRender in VirtualizedList Checkpoint VirtualizedListClick Update version Bug fixes. Code review comment fixes. settings.json hack Update version and local tweaks.
312 lines
9.5 KiB
TypeScript
312 lines
9.5 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 { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
|
import { theme } from '../../semantic-colors.js';
|
|
import {
|
|
type AnsiOutput,
|
|
type AnsiLine,
|
|
isSubagentProgress,
|
|
isStructuredToolResult,
|
|
} 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 { FixedScrollableList } from '../shared/FixedScrollableList.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 {
|
|
itemKey?: string;
|
|
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> = ({
|
|
itemKey,
|
|
resultDisplay,
|
|
availableTerminalHeight,
|
|
terminalWidth,
|
|
renderOutputAsMarkdown = true,
|
|
maxLines,
|
|
hasFocus = false,
|
|
overflowDirection = 'top',
|
|
}) => {
|
|
const { renderMarkdown, constrainHeight } = 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}
|
|
terminalWidth={childWidth}
|
|
/>
|
|
);
|
|
} 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 (isStructuredToolResult(contentData)) {
|
|
if (renderOutputAsMarkdown) {
|
|
content = (
|
|
<MarkdownDisplay
|
|
text={contentData.summary}
|
|
terminalWidth={childWidth}
|
|
renderMarkdown={renderMarkdown}
|
|
isPending={false}
|
|
/>
|
|
);
|
|
} else {
|
|
content = (
|
|
<Text wrap="wrap" color={theme.text.primary}>
|
|
{contentData.summary}
|
|
</Text>
|
|
);
|
|
}
|
|
} else if (
|
|
typeof contentData === 'object' &&
|
|
contentData !== null &&
|
|
'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 if (Array.isArray(contentData)) {
|
|
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}
|
|
/>
|
|
);
|
|
} else if (typeof contentData === 'object' && contentData !== null) {
|
|
// Render as JSON for other non-null objects
|
|
content = (
|
|
<Text wrap="wrap" color={theme.text.primary}>
|
|
{JSON.stringify(contentData, null, 2)}
|
|
</Text>
|
|
);
|
|
} else {
|
|
content = null;
|
|
}
|
|
|
|
// Final render based on session mode
|
|
if (isAlternateBuffer) {
|
|
// Use maxLines if provided, otherwise fall back to the calculated available height
|
|
const effectiveMaxHeight = maxLines ?? availableHeight;
|
|
|
|
return (
|
|
<Scrollable
|
|
itemKey={itemKey}
|
|
width={childWidth}
|
|
maxHeight={effectiveMaxHeight}
|
|
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
|
|
scrollToBottom={true}
|
|
reportOverflow={true}
|
|
>
|
|
{content}
|
|
</Scrollable>
|
|
);
|
|
}
|
|
|
|
return content;
|
|
};
|
|
|
|
if (Array.isArray(resultDisplay)) {
|
|
const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
const data = resultDisplay as AnsiOutput;
|
|
|
|
// In alternate buffer, always constrain to limit to ensure virtualization works and fits viewport.
|
|
const listHeight = isAlternateBuffer
|
|
? Math.min(data.length, limit)
|
|
: !constrainHeight
|
|
? data.length
|
|
: Math.min(data.length, limit);
|
|
|
|
if (isAlternateBuffer) {
|
|
const initialScrollIndex =
|
|
overflowDirection === 'bottom' ? 0 : SCROLL_TO_ITEM_END;
|
|
|
|
return (
|
|
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
|
|
<FixedScrollableList
|
|
itemKey={itemKey}
|
|
width={childWidth}
|
|
maxHeight={listHeight}
|
|
data={data}
|
|
renderItem={renderVirtualizedAnsiLine}
|
|
itemHeight={1}
|
|
keyExtractor={keyExtractor}
|
|
initialScrollIndex={initialScrollIndex}
|
|
hasFocus={hasFocus}
|
|
/>
|
|
</Box>
|
|
);
|
|
} else {
|
|
let displayData = data;
|
|
let hiddenLines = 0;
|
|
|
|
if (constrainHeight && data.length > listHeight) {
|
|
hiddenLines = data.length - listHeight;
|
|
if (overflowDirection === 'top') {
|
|
displayData = data.slice(hiddenLines);
|
|
} else {
|
|
displayData = data.slice(0, listHeight);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Box width={childWidth} flexDirection="column">
|
|
<MaxSizedBox
|
|
maxHeight={constrainHeight ? listHeight : undefined}
|
|
maxWidth={childWidth}
|
|
overflowDirection={overflowDirection}
|
|
additionalHiddenLinesCount={hiddenLines}
|
|
>
|
|
{displayData.map((item, index) => {
|
|
const actualIndex =
|
|
(overflowDirection === 'top' ? hiddenLines : 0) + index;
|
|
return (
|
|
<Box
|
|
key={keyExtractor(item, actualIndex)}
|
|
height={1}
|
|
overflow="hidden"
|
|
>
|
|
<AnsiLineText line={item} />
|
|
</Box>
|
|
);
|
|
})}
|
|
</MaxSizedBox>
|
|
</Box>
|
|
);
|
|
}
|
|
}
|
|
|
|
// ASB Mode Handling (Interactive/Fullscreen)
|
|
if (isAlternateBuffer) {
|
|
// 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>
|
|
);
|
|
};
|