feat(cli): truncate shell command output in history

Refines the display of shell command outputs in the history to prevent
excessive vertical scrolling and improve readability.

Key changes:
- Implemented pruneShellOutput utility in textUtils to safely truncate
  strings and AnsiOutput arrays.
- Updated useShellCommandProcessor to prune shell execution results to
  a maximum of 10 lines before adding them to the history.
- Updated toolMapping to apply truncation to run_shell_command results
  when mapping core status to display status.
- Added maxLines prop to AnsiOutputText and ToolResultDisplay components
  to enforce line limits during rendering.
- Configured ShellToolMessage to pass a 10-line limit to the display component.

This ensures that long shell outputs are truncated in the UI history while
retaining the full output for the model context.
This commit is contained in:
Jarrod Whelan
2026-01-23 11:58:41 -08:00
committed by Jarrod Whelan
parent 80e1fa198f
commit f9573d6352
7 changed files with 62 additions and 8 deletions

View File

@@ -14,18 +14,25 @@ interface AnsiOutputProps {
data: AnsiOutput;
availableTerminalHeight?: number;
width: number;
maxLines?: number;
}
export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
data,
availableTerminalHeight,
width,
maxLines,
}) => {
const lastLines = data.slice(
-(availableTerminalHeight && availableTerminalHeight > 0
const effectiveHeight =
availableTerminalHeight && availableTerminalHeight > 0
? availableTerminalHeight
: DEFAULT_HEIGHT),
);
: (maxLines ?? DEFAULT_HEIGHT);
const limit = maxLines
? Math.min(effectiveHeight, maxLines)
: effectiveHeight;
const lastLines = data.slice(-limit);
return (
<Box flexDirection="column" width={width} flexShrink={0}>
{lastLines.map((line: AnsiLine, lineIndex: number) => (

View File

@@ -153,6 +153,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
renderOutputAsMarkdown={renderOutputAsMarkdown}
maxLines={10}
/>
{isThisShellFocused && config && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>

View File

@@ -27,6 +27,7 @@ export interface ToolResultDisplayProps {
availableTerminalHeight?: number;
terminalWidth: number;
renderOutputAsMarkdown?: boolean;
maxLines?: number;
}
interface FileDiffResult {
@@ -39,16 +40,21 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
availableTerminalHeight,
terminalWidth,
renderOutputAsMarkdown = true,
maxLines,
}) => {
const { renderMarkdown } = useUIState();
const availableHeight = availableTerminalHeight
let availableHeight = availableTerminalHeight
? Math.max(
availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT,
MIN_LINES_SHOWN + 1, // enforce minimum lines shown
)
: undefined;
if (maxLines && availableHeight) {
availableHeight = Math.min(availableHeight, maxLines);
}
const combinedPaddingAndBorderWidth = 4;
const childWidth = terminalWidth - combinedPaddingAndBorderWidth;
@@ -107,6 +113,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
data={truncatedResultDisplay as AnsiOutput}
availableTerminalHeight={availableHeight}
width={childWidth}
maxLines={maxLines}
/>
);
}

View File

@@ -43,7 +43,9 @@ vi.mock('node:os', async (importOriginal) => {
};
});
vi.mock('node:crypto');
vi.mock('../utils/textUtils.js');
vi.mock('../utils/textUtils.js', () => ({
pruneShellOutput: vi.fn((output) => output),
}));
import {
useShellCommandProcessor,

View File

@@ -26,8 +26,10 @@ import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
import { themeManager } from '../../ui/themes/theme-manager.js';
import { pruneShellOutput } from '../utils/textUtils.js';
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
const MAX_OUTPUT_LINES_HISTORY = 10;
const MAX_OUTPUT_LENGTH = 10000;
function addShellCommandToGeminiHistory(
@@ -284,7 +286,10 @@ export const useShellCommandProcessor = (
const finalToolDisplay: IndividualToolCallDisplay = {
...initialToolDisplay,
status: finalStatus,
resultDisplay: finalOutput,
resultDisplay: pruneShellOutput(
finalOutput,
MAX_OUTPUT_LINES_HISTORY,
),
};
// Add the complete, contextual result to the local UI history.

View File

@@ -17,6 +17,9 @@ import {
type HistoryItemToolGroup,
type IndividualToolCallDisplay,
} from '../types.js';
import { pruneShellOutput } from '../utils/textUtils.js';
const MAX_OUTPUT_LINES_HISTORY = 10;
import { checkExhaustive } from '../../utils/checks.js';
@@ -117,7 +120,10 @@ export function mapToDisplay(
return {
...baseDisplayProperties,
status: mapCoreStatusToDisplayStatus(call.status),
resultDisplay,
resultDisplay:
call.request.name === 'run_shell_command'
? pruneShellOutput(resultDisplay, MAX_OUTPUT_LINES_HISTORY)
: resultDisplay,
confirmationDetails,
outputFile,
ptyId,

View File

@@ -10,6 +10,32 @@ import { stripVTControlCharacters } from 'node:util';
import stringWidth from 'string-width';
import { LRUCache } from 'mnemonist';
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../constants.js';
import type { AnsiOutput, ToolResultDisplay } from '@google/gemini-cli-core';
/**
* Prunes shell output to the last N lines.
* Handles both string output and AnsiOutput object.
*/
export function pruneShellOutput(
output: ToolResultDisplay | undefined,
maxLines: number,
): ToolResultDisplay | undefined {
if (output === undefined) return undefined;
if (typeof output === 'string') {
const lines = output.split('\n');
if (lines.length <= maxLines) return output;
return lines.slice(-maxLines).join('\n');
}
if (Array.isArray(output)) {
// Assume it's AnsiOutput (AnsiLine[])
if (output.length <= maxLines) return output;
return output.slice(-maxLines) as AnsiOutput;
}
return output;
}
/**
* Calculates the maximum width of a multi-line ASCII art string.