mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-23 19:44:30 -07:00
Support ink scrolling final pr (#12567)
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { OverflowProvider } from '../../contexts/OverflowContext.js';
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import * as CodeColorizer from '../../utils/CodeColorizer.js';
|
||||
import { vi } from 'vitest';
|
||||
@@ -20,8 +20,11 @@ describe('<OverflowProvider><DiffRenderer /></OverflowProvider>', () => {
|
||||
const sanitizeOutput = (output: string | undefined, terminalWidth: number) =>
|
||||
output?.replace(/GAP_INDICATOR/g, '═'.repeat(terminalWidth));
|
||||
|
||||
it('should call colorizeCode with correct language for new file with known extension', () => {
|
||||
const newFileDiffContent = `
|
||||
describe.each([true, false])(
|
||||
'with useAlternateBuffer = %s',
|
||||
(useAlternateBuffer) => {
|
||||
it('should call colorizeCode with correct language for new file with known extension', () => {
|
||||
const newFileDiffContent = `
|
||||
diff --git a/test.py b/test.py
|
||||
new file mode 100644
|
||||
index 0000000..e69de29
|
||||
@@ -30,26 +33,28 @@ index 0000000..e69de29
|
||||
@@ -0,0 +1 @@
|
||||
+print("hello world")
|
||||
`;
|
||||
render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiffContent}
|
||||
filename="test.py"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(mockColorizeCode).toHaveBeenCalledWith(
|
||||
'print("hello world")',
|
||||
'python',
|
||||
undefined,
|
||||
80,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiffContent}
|
||||
filename="test.py"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
expect(mockColorizeCode).toHaveBeenCalledWith({
|
||||
code: 'print("hello world")',
|
||||
language: 'python',
|
||||
availableHeight: undefined,
|
||||
maxWidth: 80,
|
||||
theme: undefined,
|
||||
settings: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should call colorizeCode with null language for new file with unknown extension', () => {
|
||||
const newFileDiffContent = `
|
||||
it('should call colorizeCode with null language for new file with unknown extension', () => {
|
||||
const newFileDiffContent = `
|
||||
diff --git a/test.unknown b/test.unknown
|
||||
new file mode 100644
|
||||
index 0000000..e69de29
|
||||
@@ -58,26 +63,28 @@ index 0000000..e69de29
|
||||
@@ -0,0 +1 @@
|
||||
+some content
|
||||
`;
|
||||
render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiffContent}
|
||||
filename="test.unknown"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(mockColorizeCode).toHaveBeenCalledWith(
|
||||
'some content',
|
||||
null,
|
||||
undefined,
|
||||
80,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiffContent}
|
||||
filename="test.unknown"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
expect(mockColorizeCode).toHaveBeenCalledWith({
|
||||
code: 'some content',
|
||||
language: null,
|
||||
availableHeight: undefined,
|
||||
maxWidth: 80,
|
||||
theme: undefined,
|
||||
settings: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should call colorizeCode with null language for new file if no filename is provided', () => {
|
||||
const newFileDiffContent = `
|
||||
it('should call colorizeCode with null language for new file if no filename is provided', () => {
|
||||
const newFileDiffContent = `
|
||||
diff --git a/test.txt b/test.txt
|
||||
new file mode 100644
|
||||
index 0000000..e69de29
|
||||
@@ -86,22 +93,25 @@ index 0000000..e69de29
|
||||
@@ -0,0 +1 @@
|
||||
+some text content
|
||||
`;
|
||||
render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer diffContent={newFileDiffContent} terminalWidth={80} />
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(mockColorizeCode).toHaveBeenCalledWith(
|
||||
'some text content',
|
||||
null,
|
||||
undefined,
|
||||
80,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer diffContent={newFileDiffContent} terminalWidth={80} />
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
expect(mockColorizeCode).toHaveBeenCalledWith({
|
||||
code: 'some text content',
|
||||
language: null,
|
||||
availableHeight: undefined,
|
||||
maxWidth: 80,
|
||||
theme: undefined,
|
||||
settings: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should render diff content for existing file (not calling colorizeCode directly for the whole block)', () => {
|
||||
const existingFileDiffContent = `
|
||||
|
||||
it('should render diff content for existing file (not calling colorizeCode directly for the whole block)', () => {
|
||||
const existingFileDiffContent = `
|
||||
diff --git a/test.txt b/test.txt
|
||||
index 0000001..0000002 100644
|
||||
--- a/test.txt
|
||||
@@ -110,61 +120,64 @@ index 0000001..0000002 100644
|
||||
-old line
|
||||
+new line
|
||||
`;
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={existingFileDiffContent}
|
||||
filename="test.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
// colorizeCode is used internally by the line-by-line rendering, not for the whole block
|
||||
expect(mockColorizeCode).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('old line'),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockColorizeCode).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('new line'),
|
||||
expect.anything(),
|
||||
);
|
||||
const output = lastFrame();
|
||||
const lines = output!.split('\n');
|
||||
expect(lines[0]).toBe('1 - old line');
|
||||
expect(lines[1]).toBe('1 + new line');
|
||||
});
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={existingFileDiffContent}
|
||||
filename="test.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
// colorizeCode is used internally by the line-by-line rendering, not for the whole block
|
||||
expect(mockColorizeCode).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: expect.stringContaining('old line'),
|
||||
}),
|
||||
);
|
||||
expect(mockColorizeCode).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
code: expect.stringContaining('new line'),
|
||||
}),
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should handle diff with only header and no changes', () => {
|
||||
const noChangeDiff = `diff --git a/file.txt b/file.txt
|
||||
it('should handle diff with only header and no changes', () => {
|
||||
const noChangeDiff = `diff --git a/file.txt b/file.txt
|
||||
index 1234567..1234567 100644
|
||||
--- a/file.txt
|
||||
+++ b/file.txt
|
||||
`;
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={noChangeDiff}
|
||||
filename="file.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).toContain('No changes detected');
|
||||
expect(mockColorizeCode).not.toHaveBeenCalled();
|
||||
});
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={noChangeDiff}
|
||||
filename="file.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(mockColorizeCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty diff content', () => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer diffContent="" terminalWidth={80} />
|
||||
</OverflowProvider>,
|
||||
);
|
||||
expect(lastFrame()).toContain('No diff content');
|
||||
expect(mockColorizeCode).not.toHaveBeenCalled();
|
||||
});
|
||||
it('should handle empty diff content', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer diffContent="" terminalWidth={80} />
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(mockColorizeCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render a gap indicator for skipped lines', () => {
|
||||
const diffWithGap = `
|
||||
|
||||
it('should render a gap indicator for skipped lines', () => {
|
||||
const diffWithGap = `
|
||||
diff --git a/file.txt b/file.txt
|
||||
index 123..456 100644
|
||||
--- a/file.txt
|
||||
@@ -177,26 +190,22 @@ index 123..456 100644
|
||||
context line 10
|
||||
context line 11
|
||||
`;
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffWithGap}
|
||||
filename="file.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('═'); // Check for the border character used in the gap
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffWithGap}
|
||||
filename="file.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
// Verify that lines before and after the gap are rendered
|
||||
expect(output).toContain('context line 1');
|
||||
expect(output).toContain('added line');
|
||||
expect(output).toContain('context line 10');
|
||||
});
|
||||
it('should not render a gap indicator for small gaps (<= MAX_CONTEXT_LINES_WITHOUT_GAP)', () => {
|
||||
const diffWithSmallGap = `
|
||||
|
||||
it('should not render a gap indicator for small gaps (<= MAX_CONTEXT_LINES_WITHOUT_GAP)', () => {
|
||||
const diffWithSmallGap = `
|
||||
diff --git a/file.txt b/file.txt
|
||||
index abc..def 100644
|
||||
--- a/file.txt
|
||||
@@ -214,25 +223,22 @@ index abc..def 100644
|
||||
context line 14
|
||||
context line 15
|
||||
`;
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffWithSmallGap}
|
||||
filename="file.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('═'); // Ensure no separator is rendered
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffWithSmallGap}
|
||||
filename="file.txt"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
// Verify that lines before and after the gap are rendered
|
||||
expect(output).toContain('context line 5');
|
||||
expect(output).toContain('context line 11');
|
||||
});
|
||||
describe('should correctly render a diff with multiple hunks and a gap indicator', () => {
|
||||
const diffWithMultipleHunks = `
|
||||
|
||||
describe('should correctly render a diff with multiple hunks and a gap indicator', () => {
|
||||
const diffWithMultipleHunks = `
|
||||
diff --git a/multi.js b/multi.js
|
||||
index 123..789 100644
|
||||
--- a/multi.js
|
||||
@@ -249,61 +255,42 @@ index 123..789 100644
|
||||
console.log('end of second hunk');
|
||||
`;
|
||||
|
||||
it.each([
|
||||
{
|
||||
terminalWidth: 80,
|
||||
height: undefined,
|
||||
expected: ` 1 console.log('first hunk');
|
||||
2 - const oldVar = 1;
|
||||
2 + const newVar = 1;
|
||||
3 console.log('end of first hunk');
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');`,
|
||||
},
|
||||
{
|
||||
terminalWidth: 80,
|
||||
height: 6,
|
||||
expected: `... first 4 lines hidden ...
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');`,
|
||||
},
|
||||
{
|
||||
terminalWidth: 30,
|
||||
height: 6,
|
||||
expected: `... first 10 lines hidden ...
|
||||
;
|
||||
21 + const anotherNew = 'test'
|
||||
;
|
||||
22 console.log('end of
|
||||
second hunk');`,
|
||||
},
|
||||
])(
|
||||
'with terminalWidth $terminalWidth and height $height',
|
||||
({ terminalWidth, height, expected }) => {
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffWithMultipleHunks}
|
||||
filename="multi.js"
|
||||
terminalWidth={terminalWidth}
|
||||
availableTerminalHeight={height}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
it.each([
|
||||
{
|
||||
terminalWidth: 80,
|
||||
height: undefined,
|
||||
},
|
||||
{
|
||||
terminalWidth: 80,
|
||||
height: 6,
|
||||
},
|
||||
{
|
||||
terminalWidth: 30,
|
||||
height: 6,
|
||||
},
|
||||
])(
|
||||
'with terminalWidth $terminalWidth and height $height',
|
||||
({ terminalWidth, height }) => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={diffWithMultipleHunks}
|
||||
filename="multi.js"
|
||||
terminalWidth={terminalWidth}
|
||||
availableTerminalHeight={height}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(sanitizeOutput(output, terminalWidth)).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(sanitizeOutput(output, terminalWidth)).toEqual(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly render a diff with a SVN diff format', () => {
|
||||
const newFileDiff = `
|
||||
|
||||
it('should correctly render a diff with a SVN diff format', () => {
|
||||
const newFileDiff = `
|
||||
fileDiff Index: file.txt
|
||||
===================================================================
|
||||
--- a/file.txt Current
|
||||
@@ -318,26 +305,22 @@ fileDiff Index: file.txt
|
||||
+const anotherNew = 'test';
|
||||
\\ No newline at end of file
|
||||
`;
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiff}
|
||||
filename="TEST"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiff}
|
||||
filename="TEST"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
expect(output).toEqual(` 1 - const oldVar = 1;
|
||||
1 + const newVar = 1;
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 - const anotherOld = 'test';
|
||||
20 + const anotherNew = 'test';`);
|
||||
});
|
||||
it('should correctly render a new file with no file extension correctly', () => {
|
||||
const newFileDiff = `
|
||||
|
||||
it('should correctly render a new file with no file extension correctly', () => {
|
||||
const newFileDiff = `
|
||||
fileDiff Index: Dockerfile
|
||||
===================================================================
|
||||
--- Dockerfile Current
|
||||
@@ -348,18 +331,18 @@ fileDiff Index: Dockerfile
|
||||
+RUN npm run build
|
||||
\\ No newline at end of file
|
||||
`;
|
||||
const { lastFrame } = render(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiff}
|
||||
filename="Dockerfile"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toEqual(`1 FROM node:14
|
||||
2 RUN npm install
|
||||
3 RUN npm run build`);
|
||||
});
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<OverflowProvider>
|
||||
<DiffRenderer
|
||||
diffContent={newFileDiff}
|
||||
filename="Dockerfile"
|
||||
terminalWidth={80}
|
||||
/>
|
||||
</OverflowProvider>,
|
||||
{ useAlternateBuffer },
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import crypto from 'node:crypto';
|
||||
import { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
import { theme as semanticTheme } from '../../semantic-colors.js';
|
||||
import type { Theme } from '../../themes/theme.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
|
||||
interface DiffLine {
|
||||
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
|
||||
@@ -98,75 +101,100 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
||||
terminalWidth,
|
||||
theme,
|
||||
}) => {
|
||||
const settings = useSettings();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const screenReaderEnabled = useIsScreenReaderEnabled();
|
||||
if (!diffContent || typeof diffContent !== 'string') {
|
||||
return <Text color={semanticTheme.status.warning}>No diff content.</Text>;
|
||||
}
|
||||
|
||||
const parsedLines = parseDiffWithLineNumbers(diffContent);
|
||||
const parsedLines = useMemo(() => {
|
||||
if (!diffContent || typeof diffContent !== 'string') {
|
||||
return [];
|
||||
}
|
||||
return parseDiffWithLineNumbers(diffContent);
|
||||
}, [diffContent]);
|
||||
|
||||
if (parsedLines.length === 0) {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={semanticTheme.border.default}
|
||||
padding={1}
|
||||
>
|
||||
<Text dimColor>No changes detected.</Text>
|
||||
</Box>
|
||||
const isNewFile = useMemo(() => {
|
||||
if (parsedLines.length === 0) return false;
|
||||
return parsedLines.every(
|
||||
(line) =>
|
||||
line.type === 'add' ||
|
||||
line.type === 'hunk' ||
|
||||
line.type === 'other' ||
|
||||
line.content.startsWith('diff --git') ||
|
||||
line.content.startsWith('new file mode'),
|
||||
);
|
||||
}
|
||||
if (screenReaderEnabled) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{parsedLines.map((line, index) => (
|
||||
<Text key={index}>
|
||||
{line.type}: {line.content}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}, [parsedLines]);
|
||||
|
||||
// Check if the diff represents a new file (only additions and header lines)
|
||||
const isNewFile = parsedLines.every(
|
||||
(line) =>
|
||||
line.type === 'add' ||
|
||||
line.type === 'hunk' ||
|
||||
line.type === 'other' ||
|
||||
line.content.startsWith('diff --git') ||
|
||||
line.content.startsWith('new file mode'),
|
||||
);
|
||||
const renderedOutput = useMemo(() => {
|
||||
if (!diffContent || typeof diffContent !== 'string') {
|
||||
return <Text color={semanticTheme.status.warning}>No diff content.</Text>;
|
||||
}
|
||||
|
||||
let renderedOutput;
|
||||
if (parsedLines.length === 0) {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={semanticTheme.border.default}
|
||||
padding={1}
|
||||
>
|
||||
<Text dimColor>No changes detected.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (screenReaderEnabled) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{parsedLines.map((line, index) => (
|
||||
<Text key={index}>
|
||||
{line.type}: {line.content}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isNewFile) {
|
||||
// Extract only the added lines' content
|
||||
const addedContent = parsedLines
|
||||
.filter((line) => line.type === 'add')
|
||||
.map((line) => line.content)
|
||||
.join('\n');
|
||||
// Attempt to infer language from filename, default to plain text if no filename
|
||||
const fileExtension = filename?.split('.').pop() || null;
|
||||
const language = fileExtension
|
||||
? getLanguageFromExtension(fileExtension)
|
||||
: null;
|
||||
renderedOutput = colorizeCode(
|
||||
addedContent,
|
||||
language,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
theme,
|
||||
);
|
||||
} else {
|
||||
renderedOutput = renderDiffContent(
|
||||
parsedLines,
|
||||
filename,
|
||||
tabWidth,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
);
|
||||
}
|
||||
if (isNewFile) {
|
||||
// Extract only the added lines' content
|
||||
const addedContent = parsedLines
|
||||
.filter((line) => line.type === 'add')
|
||||
.map((line) => line.content)
|
||||
.join('\n');
|
||||
// Attempt to infer language from filename, default to plain text if no filename
|
||||
const fileExtension = filename?.split('.').pop() || null;
|
||||
const language = fileExtension
|
||||
? getLanguageFromExtension(fileExtension)
|
||||
: null;
|
||||
return colorizeCode({
|
||||
code: addedContent,
|
||||
language,
|
||||
availableHeight: availableTerminalHeight,
|
||||
maxWidth: terminalWidth,
|
||||
theme,
|
||||
settings,
|
||||
});
|
||||
} else {
|
||||
return renderDiffContent(
|
||||
parsedLines,
|
||||
filename,
|
||||
tabWidth,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
!isAlternateBuffer,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
diffContent,
|
||||
parsedLines,
|
||||
screenReaderEnabled,
|
||||
isNewFile,
|
||||
filename,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
theme,
|
||||
settings,
|
||||
isAlternateBuffer,
|
||||
tabWidth,
|
||||
]);
|
||||
|
||||
return renderedOutput;
|
||||
};
|
||||
@@ -177,6 +205,7 @@ const renderDiffContent = (
|
||||
tabWidth = DEFAULT_TAB_WIDTH,
|
||||
availableTerminalHeight: number | undefined,
|
||||
terminalWidth: number,
|
||||
useMaxSizedBox: boolean,
|
||||
) => {
|
||||
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
|
||||
const normalizedLines = parsedLines.map((line) => ({
|
||||
@@ -235,115 +264,151 @@ const renderDiffContent = (
|
||||
let lastLineNumber: number | null = null;
|
||||
const MAX_CONTEXT_LINES_WITHOUT_GAP = 5;
|
||||
|
||||
return (
|
||||
<MaxSizedBox
|
||||
maxHeight={availableTerminalHeight}
|
||||
maxWidth={terminalWidth}
|
||||
key={key}
|
||||
>
|
||||
{displayableLines.reduce<React.ReactNode[]>((acc, line, index) => {
|
||||
// Determine the relevant line number for gap calculation based on type
|
||||
let relevantLineNumberForGapCalc: number | null = null;
|
||||
if (line.type === 'add' || line.type === 'context') {
|
||||
relevantLineNumberForGapCalc = line.newLine ?? null;
|
||||
} else if (line.type === 'del') {
|
||||
// For deletions, the gap is typically in relation to the original file's line numbering
|
||||
relevantLineNumberForGapCalc = line.oldLine ?? null;
|
||||
}
|
||||
const content = displayableLines.reduce<React.ReactNode[]>(
|
||||
(acc, line, index) => {
|
||||
// Determine the relevant line number for gap calculation based on type
|
||||
let relevantLineNumberForGapCalc: number | null = null;
|
||||
if (line.type === 'add' || line.type === 'context') {
|
||||
relevantLineNumberForGapCalc = line.newLine ?? null;
|
||||
} else if (line.type === 'del') {
|
||||
// For deletions, the gap is typically in relation to the original file's line numbering
|
||||
relevantLineNumberForGapCalc = line.oldLine ?? null;
|
||||
}
|
||||
|
||||
if (
|
||||
lastLineNumber !== null &&
|
||||
relevantLineNumberForGapCalc !== null &&
|
||||
relevantLineNumberForGapCalc >
|
||||
lastLineNumber + MAX_CONTEXT_LINES_WITHOUT_GAP + 1
|
||||
) {
|
||||
acc.push(
|
||||
<Box key={`gap-${index}`}>
|
||||
if (
|
||||
lastLineNumber !== null &&
|
||||
relevantLineNumberForGapCalc !== null &&
|
||||
relevantLineNumberForGapCalc >
|
||||
lastLineNumber + MAX_CONTEXT_LINES_WITHOUT_GAP + 1
|
||||
) {
|
||||
acc.push(
|
||||
<Box key={`gap-${index}`}>
|
||||
{useMaxSizedBox ? (
|
||||
<Text wrap="truncate" color={semanticTheme.text.secondary}>
|
||||
{'═'.repeat(terminalWidth)}
|
||||
</Text>
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
|
||||
const lineKey = `diff-line-${index}`;
|
||||
let gutterNumStr = '';
|
||||
let prefixSymbol = ' ';
|
||||
|
||||
switch (line.type) {
|
||||
case 'add':
|
||||
gutterNumStr = (line.newLine ?? '').toString();
|
||||
prefixSymbol = '+';
|
||||
lastLineNumber = line.newLine ?? null;
|
||||
break;
|
||||
case 'del':
|
||||
gutterNumStr = (line.oldLine ?? '').toString();
|
||||
prefixSymbol = '-';
|
||||
// For deletions, update lastLineNumber based on oldLine if it's advancing.
|
||||
// This helps manage gaps correctly if there are multiple consecutive deletions
|
||||
// or if a deletion is followed by a context line far away in the original file.
|
||||
if (line.oldLine !== undefined) {
|
||||
lastLineNumber = line.oldLine;
|
||||
}
|
||||
break;
|
||||
case 'context':
|
||||
gutterNumStr = (line.newLine ?? '').toString();
|
||||
prefixSymbol = ' ';
|
||||
lastLineNumber = line.newLine ?? null;
|
||||
break;
|
||||
default:
|
||||
return acc;
|
||||
}
|
||||
|
||||
const displayContent = line.content.substring(baseIndentation);
|
||||
|
||||
acc.push(
|
||||
<Box key={lineKey} flexDirection="row">
|
||||
<Text
|
||||
color={semanticTheme.text.secondary}
|
||||
backgroundColor={
|
||||
line.type === 'add'
|
||||
? semanticTheme.background.diff.added
|
||||
: line.type === 'del'
|
||||
? semanticTheme.background.diff.removed
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{gutterNumStr.padStart(gutterWidth)}{' '}
|
||||
</Text>
|
||||
{line.type === 'context' ? (
|
||||
<>
|
||||
<Text>{prefixSymbol} </Text>
|
||||
<Text wrap="wrap">
|
||||
{colorizeLine(displayContent, language)}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text
|
||||
backgroundColor={
|
||||
line.type === 'add'
|
||||
? semanticTheme.background.diff.added
|
||||
: semanticTheme.background.diff.removed
|
||||
}
|
||||
wrap="wrap"
|
||||
>
|
||||
<Text
|
||||
color={
|
||||
line.type === 'add'
|
||||
? semanticTheme.status.success
|
||||
: semanticTheme.status.error
|
||||
}
|
||||
>
|
||||
{prefixSymbol}
|
||||
</Text>{' '}
|
||||
{colorizeLine(displayContent, language)}
|
||||
</Text>
|
||||
// We can use a proper separator when not using max sized box.
|
||||
<Box
|
||||
borderStyle="double"
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderBottom={false}
|
||||
width={terminalWidth}
|
||||
borderColor={semanticTheme.text.secondary}
|
||||
marginRight={1}
|
||||
></Box>
|
||||
)}
|
||||
</Box>,
|
||||
);
|
||||
return acc;
|
||||
}, [])}
|
||||
</MaxSizedBox>
|
||||
}
|
||||
|
||||
const lineKey = `diff-line-${index}`;
|
||||
let gutterNumStr = '';
|
||||
let prefixSymbol = ' ';
|
||||
|
||||
switch (line.type) {
|
||||
case 'add':
|
||||
gutterNumStr = (line.newLine ?? '').toString();
|
||||
prefixSymbol = '+';
|
||||
lastLineNumber = line.newLine ?? null;
|
||||
break;
|
||||
case 'del':
|
||||
gutterNumStr = (line.oldLine ?? '').toString();
|
||||
prefixSymbol = '-';
|
||||
// For deletions, update lastLineNumber based on oldLine if it's advancing.
|
||||
// This helps manage gaps correctly if there are multiple consecutive deletions
|
||||
// or if a deletion is followed by a context line far away in the original file.
|
||||
if (line.oldLine !== undefined) {
|
||||
lastLineNumber = line.oldLine;
|
||||
}
|
||||
break;
|
||||
case 'context':
|
||||
gutterNumStr = (line.newLine ?? '').toString();
|
||||
prefixSymbol = ' ';
|
||||
lastLineNumber = line.newLine ?? null;
|
||||
break;
|
||||
default:
|
||||
return acc;
|
||||
}
|
||||
|
||||
const displayContent = line.content.substring(baseIndentation);
|
||||
|
||||
const backgroundColor =
|
||||
line.type === 'add'
|
||||
? semanticTheme.background.diff.added
|
||||
: line.type === 'del'
|
||||
? semanticTheme.background.diff.removed
|
||||
: undefined;
|
||||
acc.push(
|
||||
<Box key={lineKey} flexDirection="row">
|
||||
{useMaxSizedBox ? (
|
||||
<Text
|
||||
color={semanticTheme.text.secondary}
|
||||
backgroundColor={backgroundColor}
|
||||
>
|
||||
{gutterNumStr.padStart(gutterWidth)}{' '}
|
||||
</Text>
|
||||
) : (
|
||||
<Box
|
||||
width={gutterWidth + 1}
|
||||
paddingRight={1}
|
||||
flexShrink={0}
|
||||
backgroundColor={backgroundColor}
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Text color={semanticTheme.text.secondary}>{gutterNumStr}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{line.type === 'context' ? (
|
||||
<>
|
||||
<Text>{prefixSymbol} </Text>
|
||||
<Text wrap="wrap">{colorizeLine(displayContent, language)}</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text
|
||||
backgroundColor={
|
||||
line.type === 'add'
|
||||
? semanticTheme.background.diff.added
|
||||
: semanticTheme.background.diff.removed
|
||||
}
|
||||
wrap="wrap"
|
||||
>
|
||||
<Text
|
||||
color={
|
||||
line.type === 'add'
|
||||
? semanticTheme.status.success
|
||||
: semanticTheme.status.error
|
||||
}
|
||||
>
|
||||
{prefixSymbol}
|
||||
</Text>{' '}
|
||||
{colorizeLine(displayContent, language)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>,
|
||||
);
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (useMaxSizedBox) {
|
||||
return (
|
||||
<MaxSizedBox
|
||||
maxHeight={availableTerminalHeight}
|
||||
maxWidth={terminalWidth}
|
||||
key={key}
|
||||
>
|
||||
{content}
|
||||
</MaxSizedBox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={key} flexDirection="column" width={terminalWidth} flexShrink={0}>
|
||||
{content}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
|
||||
interface GeminiMessageProps {
|
||||
text: string;
|
||||
@@ -28,6 +29,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
const prefix = '✦ ';
|
||||
const prefixWidth = prefix.length;
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth}>
|
||||
@@ -39,7 +41,9 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
|
||||
<MarkdownDisplay
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
availableTerminalHeight={
|
||||
isAlternateBuffer ? undefined : availableTerminalHeight
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
renderMarkdown={renderMarkdown}
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
|
||||
interface GeminiMessageContentProps {
|
||||
text: string;
|
||||
@@ -29,6 +30,7 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
terminalWidth,
|
||||
}) => {
|
||||
const { renderMarkdown } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const originalPrefix = '✦ ';
|
||||
const prefixWidth = originalPrefix.length;
|
||||
|
||||
@@ -37,7 +39,9 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
|
||||
<MarkdownDisplay
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
availableTerminalHeight={
|
||||
isAlternateBuffer ? undefined : availableTerminalHeight
|
||||
}
|
||||
terminalWidth={terminalWidth}
|
||||
renderMarkdown={renderMarkdown}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
|
||||
@@ -21,6 +21,7 @@ import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
|
||||
export interface ToolConfirmationMessageProps {
|
||||
confirmationDetails: ToolCallConfirmationDetails;
|
||||
@@ -42,6 +43,8 @@ export const ToolConfirmationMessage: React.FC<
|
||||
const { onConfirm } = confirmationDetails;
|
||||
const childWidth = terminalWidth - 2; // 2 for padding
|
||||
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
|
||||
const [isDiffingEnabled, setIsDiffingEnabled] = useState(false);
|
||||
|
||||
@@ -90,42 +93,230 @@ export const ToolConfirmationMessage: React.FC<
|
||||
|
||||
const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item);
|
||||
|
||||
let bodyContent: React.ReactNode | null = null; // Removed contextDisplay here
|
||||
let question: string;
|
||||
const { question, bodyContent, options } = useMemo(() => {
|
||||
let bodyContent: React.ReactNode | null = null;
|
||||
let question = '';
|
||||
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [];
|
||||
|
||||
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = new Array<
|
||||
RadioSelectItem<ToolConfirmationOutcome>
|
||||
>();
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
if (!confirmationDetails.isModifying) {
|
||||
question = `Apply this change?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: 'Yes, allow always',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always',
|
||||
});
|
||||
}
|
||||
if (!config.getIdeMode() || !isDiffingEnabled) {
|
||||
options.push({
|
||||
label: 'Modify with external editor',
|
||||
value: ToolConfirmationOutcome.ModifyWithEditor,
|
||||
key: 'Modify with external editor',
|
||||
});
|
||||
}
|
||||
|
||||
// Body content is now the DiffRenderer, passing filename to it
|
||||
// The bordered box is removed from here and handled within DiffRenderer
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
}
|
||||
} else if (confirmationDetails.type === 'exec') {
|
||||
const executionProps =
|
||||
confirmationDetails as ToolExecuteConfirmationDetails;
|
||||
|
||||
function availableBodyContentHeight() {
|
||||
if (options.length === 0) {
|
||||
// This should not happen in practice as options are always added before this is called.
|
||||
throw new Error('Options not provided for confirmation message');
|
||||
question = `Allow execution of: '${executionProps.rootCommand}'?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: `Yes, allow always ...`,
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: `Yes, allow always ...`,
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
} else if (confirmationDetails.type === 'info') {
|
||||
question = `Do you want to proceed?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: 'Yes, allow always',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always',
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
} else {
|
||||
// mcp tool confirmation
|
||||
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
|
||||
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated
|
||||
key: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
|
||||
});
|
||||
options.push({
|
||||
label: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
key: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
}
|
||||
|
||||
if (availableTerminalHeight === undefined) {
|
||||
return undefined;
|
||||
function availableBodyContentHeight() {
|
||||
if (options.length === 0) {
|
||||
// Should not happen if we populated options correctly above for all types
|
||||
// except when isModifying is true, but in that case we don't call this because we don't enter the if block for it.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (availableTerminalHeight === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Calculate the vertical space (in lines) consumed by UI elements
|
||||
// surrounding the main body content.
|
||||
const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom).
|
||||
const MARGIN_BODY_BOTTOM = 1; // margin on the body container.
|
||||
const HEIGHT_QUESTION = 1; // The question text is one line.
|
||||
const MARGIN_QUESTION_BOTTOM = 1; // Margin on the question container.
|
||||
const HEIGHT_OPTIONS = options.length; // Each option in the radio select takes one line.
|
||||
|
||||
const surroundingElementsHeight =
|
||||
PADDING_OUTER_Y +
|
||||
MARGIN_BODY_BOTTOM +
|
||||
HEIGHT_QUESTION +
|
||||
MARGIN_QUESTION_BOTTOM +
|
||||
HEIGHT_OPTIONS;
|
||||
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
|
||||
}
|
||||
|
||||
// Calculate the vertical space (in lines) consumed by UI elements
|
||||
// surrounding the main body content.
|
||||
const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom).
|
||||
const MARGIN_BODY_BOTTOM = 1; // margin on the body container.
|
||||
const HEIGHT_QUESTION = 1; // The question text is one line.
|
||||
const MARGIN_QUESTION_BOTTOM = 1; // Margin on the question container.
|
||||
const HEIGHT_OPTIONS = options.length; // Each option in the radio select takes one line.
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
if (!confirmationDetails.isModifying) {
|
||||
bodyContent = (
|
||||
<DiffRenderer
|
||||
diffContent={confirmationDetails.fileDiff}
|
||||
filename={confirmationDetails.fileName}
|
||||
availableTerminalHeight={availableBodyContentHeight()}
|
||||
terminalWidth={terminalWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (confirmationDetails.type === 'exec') {
|
||||
const executionProps =
|
||||
confirmationDetails as ToolExecuteConfirmationDetails;
|
||||
let bodyContentHeight = availableBodyContentHeight();
|
||||
if (bodyContentHeight !== undefined) {
|
||||
bodyContentHeight -= 2; // Account for padding;
|
||||
}
|
||||
|
||||
const surroundingElementsHeight =
|
||||
PADDING_OUTER_Y +
|
||||
MARGIN_BODY_BOTTOM +
|
||||
HEIGHT_QUESTION +
|
||||
MARGIN_QUESTION_BOTTOM +
|
||||
HEIGHT_OPTIONS;
|
||||
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
|
||||
}
|
||||
const commandBox = (
|
||||
<Box>
|
||||
<Text color={theme.text.link}>{executionProps.command}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column">
|
||||
<Box paddingX={1}>
|
||||
{isAlternateBuffer ? (
|
||||
commandBox
|
||||
) : (
|
||||
<MaxSizedBox
|
||||
maxHeight={bodyContentHeight}
|
||||
maxWidth={Math.max(childWidth, 1)}
|
||||
>
|
||||
{commandBox}
|
||||
</MaxSizedBox>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
} else if (confirmationDetails.type === 'info') {
|
||||
const infoProps = confirmationDetails;
|
||||
const displayUrls =
|
||||
infoProps.urls &&
|
||||
!(
|
||||
infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt
|
||||
);
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.text.link}>
|
||||
<RenderInline
|
||||
text={infoProps.prompt}
|
||||
defaultColor={theme.text.link}
|
||||
/>
|
||||
</Text>
|
||||
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.primary}>URLs to fetch:</Text>
|
||||
{infoProps.urls.map((url) => (
|
||||
<Text key={url}>
|
||||
{' '}
|
||||
- <RenderInline text={url} />
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
// mcp tool confirmation
|
||||
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.text.link}>MCP Server: {mcpProps.serverName}</Text>
|
||||
<Text color={theme.text.link}>Tool: {mcpProps.toolName}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return { question, bodyContent, options };
|
||||
}, [
|
||||
confirmationDetails,
|
||||
isTrustedFolder,
|
||||
config,
|
||||
isDiffingEnabled,
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
isAlternateBuffer,
|
||||
childWidth,
|
||||
]);
|
||||
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
if (confirmationDetails.isModifying) {
|
||||
@@ -145,177 +336,29 @@ export const ToolConfirmationMessage: React.FC<
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
question = `Apply this change?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: 'Yes, allow always',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always',
|
||||
});
|
||||
}
|
||||
if (!config.getIdeMode() || !isDiffingEnabled) {
|
||||
options.push({
|
||||
label: 'Modify with external editor',
|
||||
value: ToolConfirmationOutcome.ModifyWithEditor,
|
||||
key: 'Modify with external editor',
|
||||
});
|
||||
}
|
||||
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
|
||||
bodyContent = (
|
||||
<DiffRenderer
|
||||
diffContent={confirmationDetails.fileDiff}
|
||||
filename={confirmationDetails.fileName}
|
||||
availableTerminalHeight={availableBodyContentHeight()}
|
||||
terminalWidth={childWidth}
|
||||
/>
|
||||
);
|
||||
} else if (confirmationDetails.type === 'exec') {
|
||||
const executionProps =
|
||||
confirmationDetails as ToolExecuteConfirmationDetails;
|
||||
|
||||
question = `Allow execution of: '${executionProps.rootCommand}'?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: `Yes, allow always ...`,
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: `Yes, allow always ...`,
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
|
||||
let bodyContentHeight = availableBodyContentHeight();
|
||||
if (bodyContentHeight !== undefined) {
|
||||
bodyContentHeight -= 2; // Account for padding;
|
||||
}
|
||||
bodyContent = (
|
||||
<Box flexDirection="column">
|
||||
<Box paddingX={1} marginLeft={1}>
|
||||
<MaxSizedBox
|
||||
maxHeight={bodyContentHeight}
|
||||
maxWidth={Math.max(childWidth - 4, 1)}
|
||||
>
|
||||
<Box>
|
||||
<Text color={theme.text.link}>{executionProps.command}</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
} else if (confirmationDetails.type === 'info') {
|
||||
const infoProps = confirmationDetails;
|
||||
const displayUrls =
|
||||
infoProps.urls &&
|
||||
!(infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt);
|
||||
|
||||
question = `Do you want to proceed?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: 'Yes, allow always',
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always',
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column" paddingX={1} marginLeft={1}>
|
||||
<RenderInline text={infoProps.prompt} defaultColor={theme.text.link} />
|
||||
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.primary}>URLs to fetch:</Text>
|
||||
{infoProps.urls.map((url) => (
|
||||
<Text key={url}>
|
||||
{' '}
|
||||
- <RenderInline text={url} />
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
// mcp tool confirmation
|
||||
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
|
||||
|
||||
bodyContent = (
|
||||
<Box flexDirection="column" paddingX={1} marginLeft={1}>
|
||||
<Text color={theme.text.link}>MCP Server: {mcpProps.serverName}</Text>
|
||||
<Text color={theme.text.link}>Tool: {mcpProps.toolName}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
|
||||
options.push({
|
||||
label: 'Yes, allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
options.push({
|
||||
label: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated
|
||||
key: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
|
||||
});
|
||||
options.push({
|
||||
label: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
key: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
label: 'No, suggest changes (esc)',
|
||||
value: ToolConfirmationOutcome.Cancel,
|
||||
key: 'No, suggest changes (esc)',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1} width={childWidth}>
|
||||
<Box flexDirection="column" paddingTop={0} paddingBottom={1}>
|
||||
{/* Body Content (Diff Renderer or Command Info) */}
|
||||
{/* No separate context display here anymore for edits */}
|
||||
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
|
||||
<Box
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
overflow="hidden"
|
||||
marginBottom={1}
|
||||
paddingLeft={1}
|
||||
>
|
||||
{bodyContent}
|
||||
</Box>
|
||||
|
||||
{/* Confirmation Question */}
|
||||
<Box marginBottom={1} flexShrink={0}>
|
||||
<Text color={theme.text.primary} wrap="truncate">
|
||||
{question}
|
||||
</Text>
|
||||
<Box marginBottom={1} flexShrink={0} paddingX={1}>
|
||||
<Text color={theme.text.primary}>{question}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Select Input for Options */}
|
||||
<Box flexShrink={0}>
|
||||
<Box flexShrink={0} paddingX={1}>
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={handleSelect}
|
||||
|
||||
@@ -4,19 +4,14 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Text } from 'ink';
|
||||
import type React from 'react';
|
||||
import { ToolGroupMessage } from './ToolGroupMessage.js';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import { ToolCallStatus } from '../../types.js';
|
||||
import type {
|
||||
Config,
|
||||
ToolCallConfirmationDetails,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { ToolCallConfirmationDetails } from '@google/gemini-cli-core';
|
||||
import { TOOL_STATUS } from '../../constants.js';
|
||||
import { ConfigContext } from '../../contexts/ConfigContext.js';
|
||||
|
||||
// Mock child components to isolate ToolGroupMessage behavior
|
||||
vi.mock('./ToolMessage.js', () => ({
|
||||
@@ -66,8 +61,6 @@ vi.mock('./ToolConfirmationMessage.js', () => ({
|
||||
}));
|
||||
|
||||
describe('<ToolGroupMessage />', () => {
|
||||
const mockConfig: Config = {} as Config;
|
||||
|
||||
const createToolCall = (
|
||||
overrides: Partial<IndividualToolCallDisplay> = {},
|
||||
): IndividualToolCallDisplay => ({
|
||||
@@ -87,14 +80,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
isFocused: true,
|
||||
};
|
||||
|
||||
// Helper to wrap component with required providers
|
||||
const renderWithProviders = (component: React.ReactElement) =>
|
||||
render(
|
||||
<ConfigContext.Provider value={mockConfig}>
|
||||
{component}
|
||||
</ConfigContext.Provider>,
|
||||
);
|
||||
|
||||
describe('Golden Snapshots', () => {
|
||||
it('renders single successful tool call', () => {
|
||||
const toolCalls = [createToolCall()];
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
|
||||
interface ToolGroupMessageProps {
|
||||
groupId: number;
|
||||
@@ -47,6 +48,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
);
|
||||
|
||||
const config = useConfig();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const isShellCommand = toolCalls.some(
|
||||
(t) => t.name === SHELL_COMMAND_NAME || t.name === SHELL_NAME,
|
||||
);
|
||||
@@ -59,8 +61,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
|
||||
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
|
||||
// This is a bit of a magic number, but it accounts for the border and
|
||||
// marginLeft.
|
||||
const innerWidth = terminalWidth - 4;
|
||||
// marginLeft in regular mode and just the border in alternate buffer mode.
|
||||
const innerWidth = isAlternateBuffer ? terminalWidth - 3 : terminalWidth - 4;
|
||||
|
||||
// only prompt for tool approval on the first 'confirming' tool in the list
|
||||
// note, after the CTA, this automatically moves over to the next 'confirming' tool
|
||||
@@ -106,24 +108,23 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
{toolCalls.map((tool) => {
|
||||
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
|
||||
return (
|
||||
<Box key={tool.callId} flexDirection="column" minHeight={1}>
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
<ToolMessage
|
||||
{...tool}
|
||||
availableTerminalHeight={availableTerminalHeightPerToolMessage}
|
||||
terminalWidth={innerWidth}
|
||||
emphasis={
|
||||
isConfirming
|
||||
? 'high'
|
||||
: toolAwaitingApproval
|
||||
? 'low'
|
||||
: 'medium'
|
||||
}
|
||||
activeShellPtyId={activeShellPtyId}
|
||||
embeddedShellFocused={embeddedShellFocused}
|
||||
config={config}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
key={tool.callId}
|
||||
flexDirection="column"
|
||||
minHeight={1}
|
||||
width={innerWidth}
|
||||
>
|
||||
<ToolMessage
|
||||
{...tool}
|
||||
availableTerminalHeight={availableTerminalHeightPerToolMessage}
|
||||
terminalWidth={innerWidth}
|
||||
emphasis={
|
||||
isConfirming ? 'high' : toolAwaitingApproval ? 'low' : 'medium'
|
||||
}
|
||||
activeShellPtyId={activeShellPtyId}
|
||||
embeddedShellFocused={embeddedShellFocused}
|
||||
config={config}
|
||||
/>
|
||||
{tool.status === ToolCallStatus.Confirming &&
|
||||
isConfirming &&
|
||||
tool.confirmationDetails && (
|
||||
|
||||
@@ -14,6 +14,7 @@ import { AnsiOutputText } from '../AnsiOutput.js';
|
||||
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
import { ShellInputPrompt } from '../ShellInputPrompt.js';
|
||||
import { StickyHeader } from '../StickyHeader.js';
|
||||
import {
|
||||
SHELL_COMMAND_NAME,
|
||||
SHELL_NAME,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import type { AnsiOutput, Config } from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
|
||||
const STATIC_HEIGHT = 1;
|
||||
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
|
||||
@@ -58,6 +60,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
config,
|
||||
}) => {
|
||||
const { renderMarkdown } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const isThisShellFocused =
|
||||
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
|
||||
status === ToolCallStatus.Executing &&
|
||||
@@ -108,23 +111,93 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
: undefined;
|
||||
|
||||
// Long tool call response in MarkdownDisplay doesn't respect availableTerminalHeight properly,
|
||||
// we're forcing it to not render as markdown when the response is too long, it will fallback
|
||||
// so if we aren't using alternate buffer mode, we're forcing it to not render as markdown when the response is too long, it will fallback
|
||||
// to render as plain text, which is contained within the terminal using MaxSizedBox
|
||||
if (availableHeight) {
|
||||
if (availableHeight && !isAlternateBuffer) {
|
||||
renderOutputAsMarkdown = false;
|
||||
}
|
||||
const childWidth = terminalWidth;
|
||||
|
||||
const childWidth = terminalWidth - 3; // account for padding.
|
||||
if (typeof resultDisplay === 'string') {
|
||||
if (resultDisplay.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) {
|
||||
// Truncate the result display to fit within the available width.
|
||||
resultDisplay =
|
||||
'...' + resultDisplay.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
|
||||
const truncatedResultDisplay = React.useMemo(() => {
|
||||
if (typeof resultDisplay === 'string') {
|
||||
if (resultDisplay.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) {
|
||||
return '...' + resultDisplay.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
|
||||
}
|
||||
}
|
||||
}
|
||||
return resultDisplay;
|
||||
}, [resultDisplay]);
|
||||
|
||||
const renderedResult = React.useMemo(() => {
|
||||
if (!truncatedResultDisplay) return null;
|
||||
|
||||
return (
|
||||
<Box width={terminalWidth} flexDirection="column" paddingLeft={1}>
|
||||
<Box flexDirection="column">
|
||||
{typeof truncatedResultDisplay === 'string' &&
|
||||
renderOutputAsMarkdown ? (
|
||||
<Box flexDirection="column">
|
||||
<MarkdownDisplay
|
||||
text={truncatedResultDisplay}
|
||||
terminalWidth={childWidth}
|
||||
renderMarkdown={renderMarkdown}
|
||||
isPending={false}
|
||||
/>
|
||||
</Box>
|
||||
) : typeof truncatedResultDisplay === 'string' &&
|
||||
!renderOutputAsMarkdown ? (
|
||||
isAlternateBuffer ? (
|
||||
<Box flexDirection="column" width={childWidth}>
|
||||
<Text wrap="wrap" color={theme.text.primary}>
|
||||
{truncatedResultDisplay}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
|
||||
<Box>
|
||||
<Text wrap="wrap" color={theme.text.primary}>
|
||||
{truncatedResultDisplay}
|
||||
</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
)
|
||||
) : typeof truncatedResultDisplay === 'object' &&
|
||||
'fileDiff' in truncatedResultDisplay ? (
|
||||
<DiffRenderer
|
||||
diffContent={truncatedResultDisplay.fileDiff}
|
||||
filename={truncatedResultDisplay.fileName}
|
||||
availableTerminalHeight={availableHeight}
|
||||
terminalWidth={childWidth}
|
||||
/>
|
||||
) : typeof truncatedResultDisplay === 'object' &&
|
||||
'todos' in truncatedResultDisplay ? (
|
||||
// display nothing, as the TodoTray will handle rendering todos
|
||||
<></>
|
||||
) : (
|
||||
<AnsiOutputText
|
||||
data={truncatedResultDisplay as AnsiOutput}
|
||||
availableTerminalHeight={availableHeight}
|
||||
width={childWidth}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}, [
|
||||
truncatedResultDisplay,
|
||||
renderOutputAsMarkdown,
|
||||
childWidth,
|
||||
renderMarkdown,
|
||||
isAlternateBuffer,
|
||||
availableHeight,
|
||||
terminalWidth,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Box paddingX={1} paddingY={0} flexDirection="column">
|
||||
<Box minHeight={1}>
|
||||
// We have the StickyHeader intentionally exceedsthe allowed width for this
|
||||
// component by 1 so tne horizontal line it renders can extend into the 1
|
||||
// pixel of padding of the box drawn by the parent of the ToolMessage.
|
||||
<>
|
||||
<StickyHeader width={terminalWidth + 1}>
|
||||
<ToolStatusIndicator status={status} name={name} />
|
||||
<ToolInfo
|
||||
name={name}
|
||||
@@ -140,50 +213,8 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
</Box>
|
||||
)}
|
||||
{emphasis === 'high' && <TrailingIndicator />}
|
||||
</Box>
|
||||
{resultDisplay && (
|
||||
<Box paddingLeft={STATUS_INDICATOR_WIDTH} width="100%" marginTop={1}>
|
||||
<Box flexDirection="column">
|
||||
{typeof resultDisplay === 'string' && renderOutputAsMarkdown ? (
|
||||
<Box flexDirection="column">
|
||||
<MarkdownDisplay
|
||||
text={resultDisplay}
|
||||
isPending={false}
|
||||
availableTerminalHeight={availableHeight}
|
||||
terminalWidth={childWidth}
|
||||
renderMarkdown={renderMarkdown}
|
||||
/>
|
||||
</Box>
|
||||
) : typeof resultDisplay === 'string' && !renderOutputAsMarkdown ? (
|
||||
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
|
||||
<Box>
|
||||
<Text wrap="wrap" color={theme.text.primary}>
|
||||
{resultDisplay}
|
||||
</Text>
|
||||
</Box>
|
||||
</MaxSizedBox>
|
||||
) : typeof resultDisplay === 'object' &&
|
||||
'fileDiff' in resultDisplay ? (
|
||||
<DiffRenderer
|
||||
diffContent={resultDisplay.fileDiff}
|
||||
filename={resultDisplay.fileName}
|
||||
availableTerminalHeight={availableHeight}
|
||||
terminalWidth={childWidth}
|
||||
/>
|
||||
) : typeof resultDisplay === 'object' &&
|
||||
'todos' in resultDisplay ? (
|
||||
// display nothing, as the TodoTray will handle rendering todos
|
||||
<></>
|
||||
) : (
|
||||
<AnsiOutputText
|
||||
data={resultDisplay as AnsiOutput}
|
||||
availableTerminalHeight={availableHeight}
|
||||
width={childWidth}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</StickyHeader>
|
||||
{renderedResult}
|
||||
{isThisShellFocused && config && (
|
||||
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
|
||||
<ShellInputPrompt
|
||||
@@ -192,7 +223,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -271,10 +302,7 @@ const ToolInfo: React.FC<ToolInfo> = ({
|
||||
}, [emphasis]);
|
||||
return (
|
||||
<Box>
|
||||
<Text
|
||||
wrap="truncate-end"
|
||||
strikethrough={status === ToolCallStatus.Canceled}
|
||||
>
|
||||
<Text strikethrough={status === ToolCallStatus.Canceled}>
|
||||
<Text color={nameColor} bold>
|
||||
{name}
|
||||
</Text>{' '}
|
||||
|
||||
@@ -23,20 +23,52 @@ describe('<ToolMessage /> - Raw Markdown Display Snapshots', () => {
|
||||
};
|
||||
|
||||
it.each([
|
||||
{ renderMarkdown: true, description: '(default)' },
|
||||
{
|
||||
renderMarkdown: true,
|
||||
useAlternateBuffer: false,
|
||||
description: '(default, regular buffer)',
|
||||
},
|
||||
{
|
||||
renderMarkdown: true,
|
||||
useAlternateBuffer: true,
|
||||
description: '(default, alternate buffer)',
|
||||
},
|
||||
{
|
||||
renderMarkdown: false,
|
||||
description: '(raw markdown with syntax highlighting, no line numbers)',
|
||||
useAlternateBuffer: false,
|
||||
description: '(raw markdown, regular buffer)',
|
||||
},
|
||||
{
|
||||
renderMarkdown: false,
|
||||
useAlternateBuffer: true,
|
||||
description: '(raw markdown, alternate buffer)',
|
||||
},
|
||||
// Test cases where height constraint affects rendering in regular buffer but not alternate
|
||||
{
|
||||
renderMarkdown: true,
|
||||
useAlternateBuffer: false,
|
||||
availableTerminalHeight: 10,
|
||||
description: '(constrained height, regular buffer -> forces raw)',
|
||||
},
|
||||
{
|
||||
renderMarkdown: true,
|
||||
useAlternateBuffer: true,
|
||||
availableTerminalHeight: 10,
|
||||
description: '(constrained height, alternate buffer -> keeps markdown)',
|
||||
},
|
||||
])(
|
||||
'renders with renderMarkdown=$renderMarkdown $description',
|
||||
({ renderMarkdown }) => {
|
||||
'renders with renderMarkdown=$renderMarkdown, useAlternateBuffer=$useAlternateBuffer $description',
|
||||
({ renderMarkdown, useAlternateBuffer, availableTerminalHeight }) => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<StreamingContext.Provider value={StreamingState.Idle}>
|
||||
<ToolMessage {...baseProps} />
|
||||
<ToolMessage
|
||||
{...baseProps}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
/>
|
||||
</StreamingContext.Provider>,
|
||||
{
|
||||
uiState: { renderMarkdown, streamingState: StreamingState.Idle },
|
||||
useAlternateBuffer,
|
||||
},
|
||||
);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
|
||||
@@ -12,9 +12,10 @@ import { isSlashCommand as checkIsSlashCommand } from '../../utils/commandUtils.
|
||||
|
||||
interface UserMessageProps {
|
||||
text: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
|
||||
export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
|
||||
const prefix = '> ';
|
||||
const prefixWidth = prefix.length;
|
||||
const isSlashCommand = checkIsSlashCommand(text);
|
||||
@@ -22,8 +23,14 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
|
||||
const textColor = isSlashCommand ? theme.text.accent : theme.text.secondary;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" paddingY={0} marginY={1} alignSelf="flex-start">
|
||||
<Box width={prefixWidth}>
|
||||
<Box
|
||||
flexDirection="row"
|
||||
paddingY={0}
|
||||
marginY={1}
|
||||
alignSelf="flex-start"
|
||||
width={width}
|
||||
>
|
||||
<Box width={prefixWidth} flexShrink={0}>
|
||||
<Text color={theme.text.accent} aria-label={SCREEN_READER_USER_PREFIX}>
|
||||
{prefix}
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should correctly render a diff with a SVN diff format 1`] = `
|
||||
" 1 - const oldVar = 1;
|
||||
1 + const newVar = 1;
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 - const anotherOld = 'test';
|
||||
20 + const anotherNew = 'test';"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 30 and height 6 1`] = `
|
||||
"... first 10 lines hidden ...
|
||||
;
|
||||
21 + const anotherNew = 'test'
|
||||
;
|
||||
22 console.log('end of
|
||||
second hunk');"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height 6 1`] = `
|
||||
"... first 4 lines hidden ...
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height undefined 1`] = `
|
||||
" 1 console.log('first hunk');
|
||||
2 - const oldVar = 1;
|
||||
2 + const newVar = 1;
|
||||
3 console.log('end of first hunk');
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should correctly render a new file with no file extension correctly 1`] = `
|
||||
"1 FROM node:14
|
||||
2 RUN npm install
|
||||
3 RUN npm run build"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should handle diff with only header and no changes 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ No changes detected. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should handle empty diff content 1`] = `"No diff content."`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should not render a gap indicator for small gaps (<= MAX_CONTEXT_LINES_WITHOUT_GAP) 1`] = `
|
||||
" 1 context line 1
|
||||
2 context line 2
|
||||
3 context line 3
|
||||
4 context line 4
|
||||
5 context line 5
|
||||
11 context line 11
|
||||
12 context line 12
|
||||
13 context line 13
|
||||
14 context line 14
|
||||
15 context line 15"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should render a gap indicator for skipped lines 1`] = `
|
||||
" 1 context line 1
|
||||
2 - deleted line
|
||||
2 + added line
|
||||
════════════════════════════════════════════════════════════════════════════════
|
||||
10 context line 10
|
||||
11 context line 11"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should render diff content for existing file (not calling colorizeCode directly for the whole block) 1`] = `
|
||||
"1 - old line
|
||||
1 + new line"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should correctly render a diff with a SVN diff format 1`] = `
|
||||
" 1 - const oldVar = 1;
|
||||
1 + const newVar = 1;
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
20 - const anotherOld = 'test';
|
||||
20 + const anotherNew = 'test';"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 30 and height 6 1`] = `
|
||||
" 1 console.log('first hunk');
|
||||
|
||||
2 - const oldVar = 1;
|
||||
2 + const newVar = 1;
|
||||
3 console.log('end of first
|
||||
hunk');
|
||||
═════════════════════════════
|
||||
20 console.log('second
|
||||
hunk');
|
||||
21 - const anotherOld =
|
||||
'test';
|
||||
21 + const anotherNew =
|
||||
'test';
|
||||
22 console.log('end of second
|
||||
hunk');"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height 6 1`] = `
|
||||
" 1 console.log('first hunk');
|
||||
2 - const oldVar = 1;
|
||||
2 + const newVar = 1;
|
||||
3 console.log('end of first hunk');
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height undefined 1`] = `
|
||||
" 1 console.log('first hunk');
|
||||
2 - const oldVar = 1;
|
||||
2 + const newVar = 1;
|
||||
3 console.log('end of first hunk');
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
20 console.log('second hunk');
|
||||
21 - const anotherOld = 'test';
|
||||
21 + const anotherNew = 'test';
|
||||
22 console.log('end of second hunk');"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should correctly render a new file with no file extension correctly 1`] = `
|
||||
"1 FROM node:14
|
||||
2 RUN npm install
|
||||
3 RUN npm run build"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should handle diff with only header and no changes 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ │
|
||||
│ No changes detected. │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should handle empty diff content 1`] = `"No diff content."`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should not render a gap indicator for small gaps (<= MAX_CONTEXT_LINES_WITHOUT_GAP) 1`] = `
|
||||
" 1 context line 1
|
||||
2 context line 2
|
||||
3 context line 3
|
||||
4 context line 4
|
||||
5 context line 5
|
||||
11 context line 11
|
||||
12 context line 12
|
||||
13 context line 13
|
||||
14 context line 14
|
||||
15 context line 15"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should render a gap indicator for skipped lines 1`] = `
|
||||
" 1 context line 1
|
||||
2 - deleted line
|
||||
2 + added line
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
10 context line 10
|
||||
11 context line 11"
|
||||
`;
|
||||
|
||||
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should render diff content for existing file (not calling colorizeCode directly for the whole block) 1`] = `
|
||||
"1 - old line
|
||||
1 + new line"
|
||||
`;
|
||||
+4
-3
@@ -91,9 +91,10 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with narrow terminal width 1`] = `
|
||||
"╭──────────────────────────────────────╮
|
||||
│MockTool[tool-123]: ✓ │
|
||||
│very-long-tool-name-that-might-wrap - │
|
||||
│This is a very long description that │
|
||||
│might cause wrapping issues (medium) │
|
||||
│very-long-tool-name-that-might-wrap │
|
||||
│- This is a very long description │
|
||||
│that might cause wrapping issues │
|
||||
│(medium) │
|
||||
╰──────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
|
||||
+24
-6
@@ -1,13 +1,31 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=false '(raw markdown with syntax highlightin…' 1`] = `
|
||||
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=false, useAlternateBuffer=false '(raw markdown, regular buffer)' 1`] = `
|
||||
" ✓ test-tool A tool for testing
|
||||
|
||||
Test **bold** and \`code\` markdown"
|
||||
Test **bold** and \`code\` markdown"
|
||||
`;
|
||||
|
||||
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true '(default)' 1`] = `
|
||||
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=false, useAlternateBuffer=true '(raw markdown, alternate buffer)' 1`] = `
|
||||
" ✓ test-tool A tool for testing
|
||||
|
||||
Test bold and code markdown"
|
||||
Test **bold** and \`code\` markdown"
|
||||
`;
|
||||
|
||||
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true, useAlternateBuffer=false '(constrained height, regular buffer -…' 1`] = `
|
||||
" ✓ test-tool A tool for testing
|
||||
Test **bold** and \`code\` markdown"
|
||||
`;
|
||||
|
||||
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true, useAlternateBuffer=false '(default, regular buffer)' 1`] = `
|
||||
" ✓ test-tool A tool for testing
|
||||
Test bold and code markdown"
|
||||
`;
|
||||
|
||||
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true, useAlternateBuffer=true '(constrained height, alternate buffer…' 1`] = `
|
||||
" ✓ test-tool A tool for testing
|
||||
Test bold and code markdown"
|
||||
`;
|
||||
|
||||
exports[`<ToolMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true, useAlternateBuffer=true '(default, alternate buffer)' 1`] = `
|
||||
" ✓ test-tool A tool for testing
|
||||
Test bold and code markdown"
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user