Support ink scrolling final pr (#12567)

This commit is contained in:
Jacob Richman
2025-11-11 07:50:11 -08:00
committed by GitHub
parent 7bb13d1c41
commit cbbf565121
43 changed files with 2498 additions and 1568 deletions
@@ -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"
`;
@@ -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) │
╰──────────────────────────────────────╯"
`;
@@ -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"
`;