mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -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 { describe, it, expect } from 'vitest';
|
||||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||||
import { DenseToolMessage } from './DenseToolMessage.js';
|
import { DenseToolMessage } from './DenseToolMessage.js';
|
||||||
import type { ToolResultDisplay } from '../../types.js';
|
|
||||||
import { ToolCallStatus } from '../../types.js';
|
import { ToolCallStatus } from '../../types.js';
|
||||||
|
import type {
|
||||||
|
FileDiff,
|
||||||
|
SerializableConfirmationDetails,
|
||||||
|
ToolResultDisplay,
|
||||||
|
} from '../../types.js';
|
||||||
|
|
||||||
describe('DenseToolMessage', () => {
|
describe('DenseToolMessage', () => {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
@@ -49,19 +53,208 @@ describe('DenseToolMessage', () => {
|
|||||||
expect(output).toContain('→ Line 1 Line 2');
|
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 = {
|
const diffResult = {
|
||||||
fileDiff: 'diff content',
|
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+diff content',
|
||||||
fileName: 'test.ts',
|
fileName: 'test.ts',
|
||||||
filePath: '/path/to/test.ts',
|
filePath: '/path/to/test.ts',
|
||||||
originalContent: 'old content',
|
originalContent: 'old content',
|
||||||
newContent: 'new 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(
|
const { lastFrame } = renderWithProviders(
|
||||||
<DenseToolMessage {...defaultProps} resultDisplay={diffResult} />,
|
<DenseToolMessage {...defaultProps} resultDisplay={diffResult} />,
|
||||||
);
|
);
|
||||||
const output = lastFrame();
|
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', () => {
|
it('renders correctly for todo updates', () => {
|
||||||
|
|||||||
@@ -5,60 +5,326 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { ToolCallStatus } from '../../types.js';
|
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 { ToolStatusIndicator } from './ToolShared.js';
|
||||||
import { theme } from '../../semantic-colors.js';
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
import { DiffRenderer } from './DiffRenderer.js';
|
||||||
|
|
||||||
type DenseToolMessageProps = IndividualToolCallDisplay;
|
interface DenseToolMessageProps extends IndividualToolCallDisplay {
|
||||||
|
terminalWidth?: number;
|
||||||
interface FileDiffResult {
|
availableTerminalHeight?: number;
|
||||||
fileDiff: string;
|
|
||||||
fileName: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DenseToolMessage: React.FC<DenseToolMessageProps> = ({
|
interface ViewParts {
|
||||||
name,
|
description?: string;
|
||||||
description,
|
summary?: React.ReactNode;
|
||||||
status,
|
payload?: React.ReactNode;
|
||||||
resultDisplay,
|
}
|
||||||
outputFile,
|
|
||||||
}) => {
|
|
||||||
let denseResult: string | undefined;
|
|
||||||
|
|
||||||
if (status === ToolCallStatus.Success && resultDisplay) {
|
/**
|
||||||
if (typeof resultDisplay === 'string') {
|
* --- TYPE GUARDS ---
|
||||||
const flattened = resultDisplay.replace(/\n/g, ' ').trim();
|
*/
|
||||||
denseResult =
|
const isFileDiff = (res: unknown): res is FileDiff =>
|
||||||
flattened.length > 120 ? flattened.slice(0, 117) + '...' : flattened;
|
typeof res === 'object' && res !== null && 'fileDiff' in res;
|
||||||
} else if (typeof resultDisplay === 'object') {
|
|
||||||
if ('fileDiff' in resultDisplay) {
|
const isGrepResult = (res: unknown): res is GrepResult =>
|
||||||
denseResult = `Diff applied to ${(resultDisplay as FileDiffResult).fileName}`;
|
typeof res === 'object' &&
|
||||||
} else if ('todos' in resultDisplay) {
|
res !== null &&
|
||||||
denseResult = 'Todos updated';
|
'matches' in res &&
|
||||||
} else {
|
'summary' in res;
|
||||||
// Fallback for AnsiOutput or other objects
|
|
||||||
denseResult = 'Output received';
|
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) {
|
} else if (status === ToolCallStatus.Error) {
|
||||||
if (typeof resultDisplay === 'string') {
|
decision = typeof resultDisplay === 'string' ? resultDisplay : 'Failed';
|
||||||
const flattened = resultDisplay.replace(/\n/g, ' ').trim();
|
decisionColor = theme.text.accent;
|
||||||
denseResult =
|
|
||||||
flattened.length > 120 ? flattened.slice(0, 117) + '...' : flattened;
|
|
||||||
} else {
|
|
||||||
denseResult = 'Failed';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column" marginBottom={payload ? 1 : 0}>
|
||||||
<Box marginLeft={3} flexDirection="row" flexWrap="wrap">
|
<Box marginLeft={3} flexDirection="row" flexWrap="wrap">
|
||||||
<ToolStatusIndicator status={status} name={name} />
|
<ToolStatusIndicator status={status} name={name} />
|
||||||
<Box maxWidth={25} flexShrink={1} flexGrow={0}>
|
<Box maxWidth={25} flexShrink={1} flexGrow={0}>
|
||||||
<Text color={theme.text.primary} bold wrap="truncate-end">
|
<Text color={theme.text.primary} bold wrap="truncate-end">
|
||||||
{name}
|
{name}{' '}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box marginLeft={1} flexShrink={1} flexGrow={0}>
|
<Box marginLeft={1} flexShrink={1} flexGrow={0}>
|
||||||
@@ -66,14 +332,13 @@ export const DenseToolMessage: React.FC<DenseToolMessageProps> = ({
|
|||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{denseResult && (
|
{summary && (
|
||||||
<Box marginLeft={1} flexGrow={1}>
|
<Box marginLeft={1} flexGrow={1}>
|
||||||
<Text color={theme.text.accent} wrap="wrap">
|
{summary}
|
||||||
→ {denseResult}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
{payload && <Box marginLeft={6}>{payload}</Box>}
|
||||||
{outputFile && (
|
{outputFile && (
|
||||||
<Box marginLeft={6}>
|
<Box marginLeft={6}>
|
||||||
<Text color={theme.text.secondary}>
|
<Text color={theme.text.secondary}>
|
||||||
|
|||||||
@@ -191,7 +191,14 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
|||||||
tool.status !== ToolCallStatus.Confirming;
|
tool.status !== ToolCallStatus.Confirming;
|
||||||
|
|
||||||
if (useDenseView) {
|
if (useDenseView) {
|
||||||
return <DenseToolMessage key={tool.callId} {...tool} />;
|
return (
|
||||||
|
<DenseToolMessage
|
||||||
|
key={tool.callId}
|
||||||
|
{...tool}
|
||||||
|
terminalWidth={terminalWidth}
|
||||||
|
availableTerminalHeight={availableTerminalHeightPerToolMessage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const commonProps = {
|
const commonProps = {
|
||||||
|
|||||||
@@ -301,4 +301,56 @@ describe('ToolResultDisplay', () => {
|
|||||||
expect(output).not.toContain('Line 1');
|
expect(output).not.toContain('Line 1');
|
||||||
expect(output).toContain('Line 50');
|
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 'error':
|
||||||
case 'cancelled':
|
case 'cancelled':
|
||||||
resultDisplay = call.response.resultDisplay;
|
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;
|
break;
|
||||||
case 'awaiting_approval':
|
case 'awaiting_approval':
|
||||||
correlationId = call.correlationId;
|
correlationId = call.correlationId;
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
|||||||
import { useStateAndRef } from './useStateAndRef.js';
|
import { useStateAndRef } from './useStateAndRef.js';
|
||||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
import { useLogger } from './useLogger.js';
|
import { useLogger } from './useLogger.js';
|
||||||
import { SHELL_COMMAND_NAME } from '../constants.js';
|
|
||||||
import { mapToDisplay as mapTrackedToolCallsToDisplay } from './toolMapping.js';
|
import { mapToDisplay as mapTrackedToolCallsToDisplay } from './toolMapping.js';
|
||||||
import {
|
import {
|
||||||
useToolScheduler,
|
useToolScheduler,
|
||||||
@@ -555,23 +554,19 @@ export const useGeminiStream = (
|
|||||||
cancelAllToolCalls(abortControllerRef.current.signal);
|
cancelAllToolCalls(abortControllerRef.current.signal);
|
||||||
|
|
||||||
if (pendingHistoryItemRef.current) {
|
if (pendingHistoryItemRef.current) {
|
||||||
const isShellCommand =
|
if (pendingHistoryItemRef.current.type === 'tool_group') {
|
||||||
pendingHistoryItemRef.current.type === 'tool_group' &&
|
// Mark all in-progress tools as Canceled when the turn is cancelled.
|
||||||
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
|
const toolGroup = pendingHistoryItemRef.current;
|
||||||
// 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;
|
|
||||||
const updatedTools = toolGroup.tools.map((tool) => {
|
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 {
|
return {
|
||||||
...tool,
|
...tool,
|
||||||
status: ToolCallStatus.Canceled,
|
status: ToolCallStatus.Canceled,
|
||||||
resultDisplay: tool.resultDisplay,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return tool;
|
return tool;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type {
|
|||||||
ListDirectoryResult,
|
ListDirectoryResult,
|
||||||
ReadManyFilesResult,
|
ReadManyFilesResult,
|
||||||
FileDiff,
|
FileDiff,
|
||||||
|
SerializableConfirmationDetails,
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum AuthState {
|
export enum AuthState {
|
||||||
|
|||||||
Reference in New Issue
Block a user