mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-12 15:10:59 -07:00
feat(cli): refactor dense tool output to modular summary+payload pattern
Introduces a modular architecture for compact tool output in the CLI. - Separates single-line summaries from multiline payloads across all tools. - Implements specialized handlers for file operations and ReadManyFiles. - Passes terminal dimensions to ensure accurate diff rendering in dense mode. - Ensures state persistence for rejected operations in history. - Includes unit tests for all new layouts, result types, and terminal states.
This commit is contained in:
@@ -7,8 +7,12 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { DenseToolMessage } from './DenseToolMessage.js';
|
||||
import type { ToolResultDisplay } from '../../types.js';
|
||||
import { ToolCallStatus } from '../../types.js';
|
||||
import type {
|
||||
FileDiff,
|
||||
SerializableConfirmationDetails,
|
||||
ToolResultDisplay,
|
||||
} from '../../types.js';
|
||||
|
||||
describe('DenseToolMessage', () => {
|
||||
const defaultProps = {
|
||||
@@ -49,19 +53,208 @@ describe('DenseToolMessage', () => {
|
||||
expect(output).toContain('→ Line 1 Line 2');
|
||||
});
|
||||
|
||||
it('renders correctly for file diff results', () => {
|
||||
it('renders correctly for file diff results with stats', () => {
|
||||
const diffResult = {
|
||||
fileDiff: 'diff content',
|
||||
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+diff content',
|
||||
fileName: 'test.ts',
|
||||
filePath: '/path/to/test.ts',
|
||||
originalContent: 'old content',
|
||||
newContent: 'new content',
|
||||
diffStat: {
|
||||
user_added_lines: 5,
|
||||
user_removed_lines: 2,
|
||||
user_added_chars: 50,
|
||||
user_removed_chars: 20,
|
||||
model_added_lines: 10,
|
||||
model_removed_lines: 4,
|
||||
model_added_chars: 100,
|
||||
model_removed_chars: 40,
|
||||
},
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<DenseToolMessage {...defaultProps} resultDisplay={diffResult} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('→ Diff applied to test.ts');
|
||||
expect(output).toContain('test.ts (+15, -6) → Accepted');
|
||||
expect(output).toContain('diff content');
|
||||
});
|
||||
|
||||
it('renders correctly for Edit tool using confirmationDetails', () => {
|
||||
const confirmationDetails: SerializableConfirmationDetails = {
|
||||
type: 'edit',
|
||||
title: 'Confirm Edit',
|
||||
fileName: 'styles.scss',
|
||||
filePath: '/path/to/styles.scss',
|
||||
fileDiff:
|
||||
'@@ -1,1 +1,1 @@\n-body { color: blue; }\n+body { color: red; }',
|
||||
originalContent: 'body { color: blue; }',
|
||||
newContent: 'body { color: red; }',
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="Edit"
|
||||
status={ToolCallStatus.Confirming}
|
||||
resultDisplay={undefined}
|
||||
confirmationDetails={confirmationDetails}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Edit');
|
||||
expect(output).toContain('styles.scss');
|
||||
expect(output).toContain('→ Confirming');
|
||||
expect(output).toContain('body { color: red; }');
|
||||
});
|
||||
|
||||
it('renders correctly for Rejected Edit tool', () => {
|
||||
const diffResult: FileDiff = {
|
||||
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+new line',
|
||||
fileName: 'styles.scss',
|
||||
filePath: '/path/to/styles.scss',
|
||||
originalContent: 'old line',
|
||||
newContent: 'new line',
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="Edit"
|
||||
status={ToolCallStatus.Canceled}
|
||||
resultDisplay={diffResult}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Edit');
|
||||
expect(output).toContain('styles.scss');
|
||||
expect(output).toContain('→ Rejected');
|
||||
expect(output).toContain('- old line');
|
||||
expect(output).toContain('+ new line');
|
||||
});
|
||||
|
||||
it('renders correctly for WriteFile tool', () => {
|
||||
const diffResult: FileDiff = {
|
||||
fileDiff: '@@ -1,1 +1,1 @@\n-old content\n+new content',
|
||||
fileName: 'config.json',
|
||||
filePath: '/path/to/config.json',
|
||||
originalContent: 'old content',
|
||||
newContent: 'new content',
|
||||
diffStat: {
|
||||
user_added_lines: 1,
|
||||
user_removed_lines: 1,
|
||||
user_added_chars: 0,
|
||||
user_removed_chars: 0,
|
||||
model_added_lines: 0,
|
||||
model_removed_lines: 0,
|
||||
model_added_chars: 0,
|
||||
model_removed_chars: 0,
|
||||
},
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="WriteFile"
|
||||
status={ToolCallStatus.Success}
|
||||
resultDisplay={diffResult}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('WriteFile');
|
||||
expect(output).toContain('config.json (+1, -1)');
|
||||
expect(output).toContain('→ Accepted');
|
||||
expect(output).toContain('+ new content');
|
||||
});
|
||||
|
||||
it('renders correctly for Rejected WriteFile tool', () => {
|
||||
const diffResult: FileDiff = {
|
||||
fileDiff: '@@ -1,1 +1,1 @@\n-old content\n+new content',
|
||||
fileName: 'config.json',
|
||||
filePath: '/path/to/config.json',
|
||||
originalContent: 'old content',
|
||||
newContent: 'new content',
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="WriteFile"
|
||||
status={ToolCallStatus.Canceled}
|
||||
resultDisplay={diffResult}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('WriteFile');
|
||||
expect(output).toContain('config.json');
|
||||
expect(output).toContain('→ Rejected');
|
||||
expect(output).toContain('- old content');
|
||||
expect(output).toContain('+ new content');
|
||||
});
|
||||
|
||||
it('renders correctly for Errored Edit tool', () => {
|
||||
const diffResult: FileDiff = {
|
||||
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+new line',
|
||||
fileName: 'styles.scss',
|
||||
filePath: '/path/to/styles.scss',
|
||||
originalContent: 'old line',
|
||||
newContent: 'new line',
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<DenseToolMessage
|
||||
{...defaultProps}
|
||||
name="Edit"
|
||||
status={ToolCallStatus.Error}
|
||||
resultDisplay={diffResult}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Edit');
|
||||
expect(output).toContain('styles.scss');
|
||||
expect(output).toContain('→ Failed');
|
||||
});
|
||||
|
||||
it('renders correctly for grep results', () => {
|
||||
const grepResult = {
|
||||
summary: 'Found 2 matches',
|
||||
matches: [
|
||||
{ filePath: 'file1.ts', lineNumber: 10, line: 'match 1' },
|
||||
{ filePath: 'file2.ts', lineNumber: 20, line: 'match 2' },
|
||||
],
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<DenseToolMessage {...defaultProps} resultDisplay={grepResult} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('→ Found 2 matches');
|
||||
expect(output).toContain('file1.ts:10: match 1');
|
||||
expect(output).toContain('file2.ts:20: match 2');
|
||||
});
|
||||
|
||||
it('renders correctly for ls results', () => {
|
||||
const lsResult = {
|
||||
summary: 'Listed 2 files',
|
||||
files: ['file1.ts', 'dir1'],
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<DenseToolMessage {...defaultProps} resultDisplay={lsResult} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('→ Listed 2 files');
|
||||
expect(output).toContain('file1.ts');
|
||||
expect(output).toContain('dir1');
|
||||
});
|
||||
|
||||
it('renders correctly for ReadManyFiles results', () => {
|
||||
const rmfResult = {
|
||||
summary: 'Read 3 file(s)',
|
||||
files: ['file1.ts', 'file2.ts', 'file3.ts'],
|
||||
skipped: [{ path: 'skipped.bin', reason: 'binary' }],
|
||||
};
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<DenseToolMessage {...defaultProps} resultDisplay={rmfResult} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('→ Read 3 file(s)');
|
||||
expect(output).toContain('file1.ts');
|
||||
expect(output).toContain('file2.ts');
|
||||
expect(output).toContain('file3.ts');
|
||||
expect(output).toContain('(1 skipped)');
|
||||
});
|
||||
|
||||
it('renders correctly for todo updates', () => {
|
||||
|
||||
@@ -5,60 +5,326 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { ToolCallStatus } from '../../types.js';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import type {
|
||||
IndividualToolCallDisplay,
|
||||
FileDiff,
|
||||
GrepResult,
|
||||
ListDirectoryResult,
|
||||
ReadManyFilesResult,
|
||||
} from '../../types.js';
|
||||
import { ToolStatusIndicator } from './ToolShared.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
|
||||
type DenseToolMessageProps = IndividualToolCallDisplay;
|
||||
|
||||
interface FileDiffResult {
|
||||
fileDiff: string;
|
||||
fileName: string;
|
||||
interface DenseToolMessageProps extends IndividualToolCallDisplay {
|
||||
terminalWidth?: number;
|
||||
availableTerminalHeight?: number;
|
||||
}
|
||||
|
||||
export const DenseToolMessage: React.FC<DenseToolMessageProps> = ({
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
resultDisplay,
|
||||
outputFile,
|
||||
}) => {
|
||||
let denseResult: string | undefined;
|
||||
interface ViewParts {
|
||||
description?: string;
|
||||
summary?: React.ReactNode;
|
||||
payload?: React.ReactNode;
|
||||
}
|
||||
|
||||
if (status === ToolCallStatus.Success && resultDisplay) {
|
||||
if (typeof resultDisplay === 'string') {
|
||||
const flattened = resultDisplay.replace(/\n/g, ' ').trim();
|
||||
denseResult =
|
||||
flattened.length > 120 ? flattened.slice(0, 117) + '...' : flattened;
|
||||
} else if (typeof resultDisplay === 'object') {
|
||||
if ('fileDiff' in resultDisplay) {
|
||||
denseResult = `Diff applied to ${(resultDisplay as FileDiffResult).fileName}`;
|
||||
} else if ('todos' in resultDisplay) {
|
||||
denseResult = 'Todos updated';
|
||||
} else {
|
||||
// Fallback for AnsiOutput or other objects
|
||||
denseResult = 'Output received';
|
||||
}
|
||||
}
|
||||
/**
|
||||
* --- TYPE GUARDS ---
|
||||
*/
|
||||
const isFileDiff = (res: unknown): res is FileDiff =>
|
||||
typeof res === 'object' && res !== null && 'fileDiff' in res;
|
||||
|
||||
const isGrepResult = (res: unknown): res is GrepResult =>
|
||||
typeof res === 'object' &&
|
||||
res !== null &&
|
||||
'matches' in res &&
|
||||
'summary' in res;
|
||||
|
||||
const isListResult = (
|
||||
res: unknown,
|
||||
): res is ListDirectoryResult | ReadManyFilesResult =>
|
||||
typeof res === 'object' && res !== null && 'files' in res && 'summary' in res;
|
||||
|
||||
const hasPayload = (
|
||||
res: unknown,
|
||||
): res is { summary: string; payload: string } =>
|
||||
typeof res === 'object' &&
|
||||
res !== null &&
|
||||
'payload' in res &&
|
||||
'summary' in res;
|
||||
|
||||
const isTodoList = (res: unknown): res is { todos: unknown[] } =>
|
||||
typeof res === 'object' && res !== null && 'todos' in res;
|
||||
|
||||
/**
|
||||
* --- RENDER HELPERS ---
|
||||
*/
|
||||
|
||||
const RenderItemsList: React.FC<{
|
||||
items: string[];
|
||||
maxVisible?: number;
|
||||
}> = ({ items, maxVisible = 20 }) => (
|
||||
<Box flexDirection="column">
|
||||
{items.slice(0, maxVisible).map((item, i) => (
|
||||
<Text key={i} color={theme.text.secondary}>
|
||||
{item}
|
||||
</Text>
|
||||
))}
|
||||
{items.length > maxVisible && (
|
||||
<Text color={theme.text.secondary}>
|
||||
... and {items.length - maxVisible} more
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* --- 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 stats = diff.diffStat ? ` (+${added}, -${removed})` : '';
|
||||
|
||||
const description = `${diff.fileName}${stats}`;
|
||||
let decision = '';
|
||||
let decisionColor = theme.text.secondary;
|
||||
|
||||
if (
|
||||
status === ToolCallStatus.Success ||
|
||||
status === ToolCallStatus.Executing
|
||||
) {
|
||||
decision = 'Accepted';
|
||||
decisionColor = theme.text.accent;
|
||||
} else if (status === ToolCallStatus.Canceled) {
|
||||
decision = 'Rejected';
|
||||
decisionColor = theme.text.primary;
|
||||
} else if (status === ToolCallStatus.Confirming) {
|
||||
decision = 'Confirming';
|
||||
} else if (status === ToolCallStatus.Error) {
|
||||
if (typeof resultDisplay === 'string') {
|
||||
const flattened = resultDisplay.replace(/\n/g, ' ').trim();
|
||||
denseResult =
|
||||
flattened.length > 120 ? flattened.slice(0, 117) + '...' : flattened;
|
||||
} else {
|
||||
denseResult = 'Failed';
|
||||
}
|
||||
decision = typeof resultDisplay === 'string' ? resultDisplay : 'Failed';
|
||||
decisionColor = theme.text.accent;
|
||||
}
|
||||
|
||||
const summary = decision ? (
|
||||
<Text color={decisionColor} wrap="truncate-end">
|
||||
→ {decision.replace(/\n/g, ' ')}
|
||||
</Text>
|
||||
) : undefined;
|
||||
|
||||
const payload = (
|
||||
<DiffRenderer
|
||||
diffContent={diff.fileDiff}
|
||||
filename={diff.fileName}
|
||||
terminalWidth={terminalWidth ? terminalWidth - 6 : 80}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
disableColor={status === ToolCallStatus.Canceled}
|
||||
/>
|
||||
);
|
||||
|
||||
return { description, summary, payload };
|
||||
}
|
||||
|
||||
function getListResultData(
|
||||
result: ListDirectoryResult | ReadManyFilesResult,
|
||||
toolName: string,
|
||||
originalDescription?: string,
|
||||
): ViewParts {
|
||||
let description = originalDescription;
|
||||
const items: string[] = result.files;
|
||||
const maxVisible = 20;
|
||||
|
||||
// Enhance with ReadManyFiles specific data if present
|
||||
const rmf = result as ReadManyFilesResult;
|
||||
if (toolName === 'ReadManyFiles' && rmf.include) {
|
||||
const includePatterns = rmf.include.join(', ');
|
||||
description = `Attempting to read files from ${includePatterns}`;
|
||||
}
|
||||
|
||||
const summary = <Text color={theme.text.accent}>→ {result.summary}</Text>;
|
||||
|
||||
const skippedCount = rmf.skipped?.length ?? 0;
|
||||
const skippedText =
|
||||
skippedCount > 0 ? `(${skippedCount} skipped)` : undefined;
|
||||
|
||||
const excludedText =
|
||||
rmf.excludes && rmf.excludes.length > 0
|
||||
? `Excluded patterns: ${rmf.excludes.slice(0, 3).join(', ')}${rmf.excludes.length > 3 ? '...' : ''}`
|
||||
: undefined;
|
||||
|
||||
const payload = (
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
<RenderItemsList items={items} maxVisible={maxVisible} />
|
||||
{skippedText && (
|
||||
<Text color={theme.text.secondary} dimColor>
|
||||
{skippedText}
|
||||
</Text>
|
||||
)}
|
||||
{excludedText && (
|
||||
<Text color={theme.text.secondary} dimColor>
|
||||
{excludedText}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return { description, summary, payload };
|
||||
}
|
||||
|
||||
function getGenericSuccessData(
|
||||
resultDisplay: unknown,
|
||||
originalDescription?: string,
|
||||
): ViewParts {
|
||||
let summary: React.ReactNode;
|
||||
let payload: React.ReactNode;
|
||||
|
||||
if (typeof resultDisplay === 'string') {
|
||||
const flattened = resultDisplay.replace(/\n/g, ' ').trim();
|
||||
summary = (
|
||||
<Text color={theme.text.accent} wrap="wrap">
|
||||
→ {flattened.length > 120 ? flattened.slice(0, 117) + '...' : flattened}
|
||||
</Text>
|
||||
);
|
||||
} else if (isGrepResult(resultDisplay)) {
|
||||
summary = <Text color={theme.text.accent}>→ {resultDisplay.summary}</Text>;
|
||||
payload = (
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
<RenderItemsList
|
||||
items={resultDisplay.matches.map(
|
||||
(m) => `${m.filePath}:${m.lineNumber}: ${m.line.trim()}`,
|
||||
)}
|
||||
maxVisible={10}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
} else if (isTodoList(resultDisplay)) {
|
||||
summary = (
|
||||
<Text color={theme.text.accent} wrap="wrap">
|
||||
→ Todos updated
|
||||
</Text>
|
||||
);
|
||||
} else if (hasPayload(resultDisplay)) {
|
||||
summary = <Text color={theme.text.accent}>→ {resultDisplay.summary}</Text>;
|
||||
payload = (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>{resultDisplay.payload}</Text>
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
summary = (
|
||||
<Text color={theme.text.accent} wrap="wrap">
|
||||
→ Output received
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return { description: originalDescription, summary, payload };
|
||||
}
|
||||
|
||||
/**
|
||||
* --- MAIN COMPONENT ---
|
||||
*/
|
||||
|
||||
export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
|
||||
const {
|
||||
name,
|
||||
status,
|
||||
resultDisplay,
|
||||
confirmationDetails,
|
||||
outputFile,
|
||||
terminalWidth,
|
||||
availableTerminalHeight,
|
||||
description: originalDescription,
|
||||
} = props;
|
||||
|
||||
// 1. Unified File Data Extraction (Safely bridge resultDisplay and confirmationDetails)
|
||||
const diff = useMemo((): FileDiff | undefined => {
|
||||
if (isFileDiff(resultDisplay)) return resultDisplay;
|
||||
if (confirmationDetails?.type === 'edit') {
|
||||
return {
|
||||
fileName: confirmationDetails.fileName,
|
||||
fileDiff: confirmationDetails.fileDiff,
|
||||
filePath: confirmationDetails.filePath,
|
||||
originalContent: confirmationDetails.originalContent,
|
||||
newContent: confirmationDetails.newContent,
|
||||
diffStat: confirmationDetails.diffStat,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [resultDisplay, confirmationDetails]);
|
||||
|
||||
// 2. State-to-View Coordination
|
||||
const viewParts = useMemo((): ViewParts => {
|
||||
if (diff) {
|
||||
return getFileOpData(
|
||||
diff,
|
||||
status,
|
||||
resultDisplay,
|
||||
terminalWidth,
|
||||
availableTerminalHeight,
|
||||
);
|
||||
}
|
||||
if (isListResult(resultDisplay)) {
|
||||
return getListResultData(resultDisplay, name, originalDescription);
|
||||
}
|
||||
if (status === ToolCallStatus.Success && resultDisplay) {
|
||||
return getGenericSuccessData(resultDisplay, originalDescription);
|
||||
}
|
||||
if (status === ToolCallStatus.Error) {
|
||||
const text =
|
||||
typeof resultDisplay === 'string'
|
||||
? resultDisplay.replace(/\n/g, ' ')
|
||||
: 'Failed';
|
||||
const errorSummary = (
|
||||
<Text color={theme.text.accent} wrap="wrap">
|
||||
→ {text.length > 120 ? text.slice(0, 117) + '...' : text}
|
||||
</Text>
|
||||
);
|
||||
return {
|
||||
description: originalDescription,
|
||||
summary: errorSummary,
|
||||
payload: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
description: originalDescription,
|
||||
summary: undefined,
|
||||
payload: undefined,
|
||||
};
|
||||
}, [
|
||||
diff,
|
||||
status,
|
||||
resultDisplay,
|
||||
name,
|
||||
terminalWidth,
|
||||
availableTerminalHeight,
|
||||
originalDescription,
|
||||
]);
|
||||
|
||||
const { description, summary, payload } = viewParts;
|
||||
|
||||
// 3. Final Layout
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column" marginBottom={payload ? 1 : 0}>
|
||||
<Box marginLeft={3} flexDirection="row" flexWrap="wrap">
|
||||
<ToolStatusIndicator status={status} name={name} />
|
||||
<Box maxWidth={25} flexShrink={1} flexGrow={0}>
|
||||
<Text color={theme.text.primary} bold wrap="truncate-end">
|
||||
{name}
|
||||
{name}{' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginLeft={1} flexShrink={1} flexGrow={0}>
|
||||
@@ -66,14 +332,13 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = ({
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
{denseResult && (
|
||||
{summary && (
|
||||
<Box marginLeft={1} flexGrow={1}>
|
||||
<Text color={theme.text.accent} wrap="wrap">
|
||||
→ {denseResult}
|
||||
</Text>
|
||||
{summary}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{payload && <Box marginLeft={6}>{payload}</Box>}
|
||||
{outputFile && (
|
||||
<Box marginLeft={6}>
|
||||
<Text color={theme.text.secondary}>
|
||||
|
||||
@@ -191,7 +191,14 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
tool.status !== ToolCallStatus.Confirming;
|
||||
|
||||
if (useDenseView) {
|
||||
return <DenseToolMessage key={tool.callId} {...tool} />;
|
||||
return (
|
||||
<DenseToolMessage
|
||||
key={tool.callId}
|
||||
{...tool}
|
||||
terminalWidth={terminalWidth}
|
||||
availableTerminalHeight={availableTerminalHeightPerToolMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const commonProps = {
|
||||
|
||||
@@ -301,4 +301,56 @@ describe('ToolResultDisplay', () => {
|
||||
expect(output).not.toContain('Line 1');
|
||||
expect(output).toContain('Line 50');
|
||||
});
|
||||
|
||||
it('renders GrepResult as summary string', () => {
|
||||
const grepResult = {
|
||||
summary: 'Found 5 matches',
|
||||
matches: [{ filePath: 'test.ts', lineNumber: 1, line: 'code' }],
|
||||
};
|
||||
const { lastFrame } = render(
|
||||
<ToolResultDisplay
|
||||
resultDisplay={grepResult}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={20}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Found 5 matches');
|
||||
expect(output).not.toContain('filePath'); // Should not render the array content
|
||||
expect(output).not.toContain('code');
|
||||
});
|
||||
|
||||
it('renders ListDirectoryResult as summary string', () => {
|
||||
const lsResult = {
|
||||
summary: 'Listed 10 files',
|
||||
files: ['some-file.txt'],
|
||||
};
|
||||
const { lastFrame } = render(
|
||||
<ToolResultDisplay
|
||||
resultDisplay={lsResult}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={20}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Listed 10 files');
|
||||
expect(output).not.toContain('some-file.txt'); // Should not render the array content
|
||||
});
|
||||
|
||||
it('renders ReadManyFilesResult as summary string', () => {
|
||||
const rmfResult = {
|
||||
summary: 'Read 20 files',
|
||||
files: ['f1.txt', 'f2.txt'],
|
||||
};
|
||||
const { lastFrame } = render(
|
||||
<ToolResultDisplay
|
||||
resultDisplay={rmfResult}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={20}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Read 20 files');
|
||||
expect(output).not.toContain('f1.txt');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,6 +92,9 @@ export function mapToDisplay(
|
||||
case 'error':
|
||||
case 'cancelled':
|
||||
resultDisplay = call.response.resultDisplay;
|
||||
// Preserve confirmation details so we can still show what was being proposed
|
||||
// if it was an edit/write-file that was rejected.
|
||||
confirmationDetails = call.confirmationDetails;
|
||||
break;
|
||||
case 'awaiting_approval':
|
||||
correlationId = call.correlationId;
|
||||
|
||||
@@ -64,7 +64,6 @@ import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
||||
import { useStateAndRef } from './useStateAndRef.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import { useLogger } from './useLogger.js';
|
||||
import { SHELL_COMMAND_NAME } from '../constants.js';
|
||||
import { mapToDisplay as mapTrackedToolCallsToDisplay } from './toolMapping.js';
|
||||
import {
|
||||
useToolScheduler,
|
||||
@@ -555,23 +554,19 @@ export const useGeminiStream = (
|
||||
cancelAllToolCalls(abortControllerRef.current.signal);
|
||||
|
||||
if (pendingHistoryItemRef.current) {
|
||||
const isShellCommand =
|
||||
pendingHistoryItemRef.current.type === 'tool_group' &&
|
||||
pendingHistoryItemRef.current.tools.some(
|
||||
(t) => t.name === SHELL_COMMAND_NAME,
|
||||
);
|
||||
|
||||
// If it is a shell command, we update the status to Canceled and clear the output
|
||||
// to avoid artifacts, then add it to history immediately.
|
||||
if (isShellCommand) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const toolGroup = pendingHistoryItemRef.current as HistoryItemToolGroup;
|
||||
if (pendingHistoryItemRef.current.type === 'tool_group') {
|
||||
// Mark all in-progress tools as Canceled when the turn is cancelled.
|
||||
|
||||
const toolGroup = pendingHistoryItemRef.current;
|
||||
const updatedTools = toolGroup.tools.map((tool) => {
|
||||
if (tool.name === SHELL_COMMAND_NAME) {
|
||||
if (
|
||||
tool.status === ToolCallStatus.Pending ||
|
||||
tool.status === ToolCallStatus.Confirming ||
|
||||
tool.status === ToolCallStatus.Executing
|
||||
) {
|
||||
return {
|
||||
...tool,
|
||||
status: ToolCallStatus.Canceled,
|
||||
resultDisplay: tool.resultDisplay,
|
||||
};
|
||||
}
|
||||
return tool;
|
||||
|
||||
@@ -31,6 +31,7 @@ export type {
|
||||
ListDirectoryResult,
|
||||
ReadManyFilesResult,
|
||||
FileDiff,
|
||||
SerializableConfirmationDetails,
|
||||
};
|
||||
|
||||
export enum AuthState {
|
||||
|
||||
Reference in New Issue
Block a user