fix(ui): stop truncating output from the model rendered in <static> (#9972)

This commit is contained in:
Jacob Richman
2025-09-27 12:40:09 -07:00
committed by GitHub
parent ffcd996366
commit 0b2d79a2ea
7 changed files with 295 additions and 98 deletions

View File

@@ -6,17 +6,30 @@
import { render } from 'ink-testing-library';
import type React from 'react';
import { LoadedSettings } from '../config/settings.js';
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
const mockSettings = new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
true,
new Set(),
);
export const renderWithProviders = (
component: React.ReactElement,
{ shellFocus = true } = {},
{ shellFocus = true, settings = mockSettings } = {},
): ReturnType<typeof render> =>
render(
<ShellFocusContext.Provider value={shellFocus}>
<KeypressProvider kittyProtocolEnabled={true}>
{component}
</KeypressProvider>
</ShellFocusContext.Provider>,
<SettingsContext.Provider value={settings}>
<ShellFocusContext.Provider value={shellFocus}>
<KeypressProvider kittyProtocolEnabled={true}>
{component}
</KeypressProvider>
</ShellFocusContext.Provider>
</SettingsContext.Provider>,
);

View File

@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { type HistoryItem, ToolCallStatus } from '../types.js';
@@ -15,6 +14,7 @@ import type {
ToolExecuteConfirmationDetails,
} from '@google/gemini-cli-core';
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { renderWithProviders } from '../../test-utils/render.js';
// Mock child components
vi.mock('./messages/ToolGroupMessage.js', () => ({
@@ -37,7 +37,7 @@ describe('<HistoryItemDisplay />', () => {
type: MessageType.USER,
text: 'Hello',
};
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
expect(lastFrame()).toContain('Hello');
@@ -49,7 +49,7 @@ describe('<HistoryItemDisplay />', () => {
type: MessageType.USER,
text: '/theme',
};
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
expect(lastFrame()).toContain('/theme');
@@ -61,7 +61,7 @@ describe('<HistoryItemDisplay />', () => {
type: MessageType.STATS,
duration: '1s',
};
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
@@ -81,7 +81,7 @@ describe('<HistoryItemDisplay />', () => {
gcpProject: 'test-project',
ideClient: 'test-ide',
};
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
);
expect(lastFrame()).toContain('About Gemini CLI');
@@ -92,7 +92,7 @@ describe('<HistoryItemDisplay />', () => {
...baseItem,
type: 'model_stats',
};
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
@@ -107,7 +107,7 @@ describe('<HistoryItemDisplay />', () => {
...baseItem,
type: 'tool_stats',
};
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
@@ -123,7 +123,7 @@ describe('<HistoryItemDisplay />', () => {
type: 'quit',
duration: '1s',
};
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<SessionStatsProvider>
<HistoryItemDisplay {...baseItem} item={item} />
</SessionStatsProvider>,
@@ -138,7 +138,7 @@ describe('<HistoryItemDisplay />', () => {
text: 'Hello, \u001b[31mred\u001b[0m world!',
};
const { lastFrame } = render(
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay
item={historyItem}
terminalWidth={80}
@@ -174,7 +174,7 @@ describe('<HistoryItemDisplay />', () => {
],
};
render(
renderWithProviders(
<HistoryItemDisplay
item={historyItem}
terminalWidth={80}
@@ -190,4 +190,84 @@ describe('<HistoryItemDisplay />', () => {
'echo "\\u001b[31mhello\\u001b[0m"',
);
});
const longCode =
'# Example code block:\n' +
'```python\n' +
Array.from({ length: 50 }, (_, i) => `Line ${i + 1}`).join('\n') +
'\n```';
it('should render a truncated gemini item', () => {
const item: HistoryItem = {
id: 1,
type: 'gemini',
text: longCode,
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render a full gemini item when using availableTerminalHeightGemini', () => {
const item: HistoryItem = {
id: 1,
type: 'gemini',
text: longCode,
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render a truncated gemini_content item', () => {
const item: HistoryItem = {
id: 1,
type: 'gemini_content',
text: longCode,
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render a full gemini_content item when using availableTerminalHeightGemini', () => {
const item: HistoryItem = {
id: 1,
type: 'gemini_content',
text: longCode,
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={80}
availableTerminalHeight={10}
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
});

View File

@@ -36,6 +36,7 @@ interface HistoryItemDisplayProps {
commands?: readonly SlashCommand[];
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
availableTerminalHeightGemini?: number;
}
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@@ -47,6 +48,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
isFocused = true,
activeShellPtyId,
embeddedShellFocused,
availableTerminalHeightGemini,
}) => {
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
@@ -63,7 +65,9 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
<GeminiMessage
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
availableTerminalHeight={
availableTerminalHeightGemini ?? availableTerminalHeight
}
terminalWidth={terminalWidth}
/>
)}
@@ -71,7 +75,9 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
<GeminiMessageContent
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
availableTerminalHeight={
availableTerminalHeightGemini ?? availableTerminalHeight
}
terminalWidth={terminalWidth}
/>
)}

View File

@@ -12,6 +12,12 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
// Limit Gemini messages to a very high number of lines to mitigate performance
// issues in the worst case if we somehow get an enormous response from Gemini.
// This threshold is arbitrary but should be high enough to never impact normal
// usage.
const MAX_GEMINI_MESSAGE_LINES = 65536;
export const MainContent = () => {
const { version } = useAppContext();
const uiState = useUIState();
@@ -32,6 +38,7 @@ export const MainContent = () => {
<HistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={staticAreaMaxItemHeight}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={h.id}
item={h}
isPending={false}

View File

@@ -19,7 +19,6 @@ import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js
const mockedCwd = vi.hoisted(() => vi.fn());
const mockedLoadTrustedFolders = vi.hoisted(() => vi.fn());
const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn());
const mockedUseSettings = vi.hoisted(() => vi.fn());
// Mock the modules themselves
vi.mock('node:process', async (importOriginal) => {
@@ -40,10 +39,6 @@ vi.mock('../../config/trustedFolders.js', () => ({
},
}));
vi.mock('../contexts/SettingsContext.js', () => ({
useSettings: mockedUseSettings,
}));
vi.mock('../hooks/usePermissionsModifyTrust.js');
describe('PermissionsModifyTrustDialog', () => {

View File

@@ -0,0 +1,137 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<HistoryItemDisplay /> > should render a full gemini item when using availableTerminalHeightGemini 1`] = `
"✦ Example code block:
1 Line 1
2 Line 2
3 Line 3
4 Line 4
5 Line 5
6 Line 6
7 Line 7
8 Line 8
9 Line 9
10 Line 10
11 Line 11
12 Line 12
13 Line 13
14 Line 14
15 Line 15
16 Line 16
17 Line 17
18 Line 18
19 Line 19
20 Line 20
21 Line 21
22 Line 22
23 Line 23
24 Line 24
25 Line 25
26 Line 26
27 Line 27
28 Line 28
29 Line 29
30 Line 30
31 Line 31
32 Line 32
33 Line 33
34 Line 34
35 Line 35
36 Line 36
37 Line 37
38 Line 38
39 Line 39
40 Line 40
41 Line 41
42 Line 42
43 Line 43
44 Line 44
45 Line 45
46 Line 46
47 Line 47
48 Line 48
49 Line 49
50 Line 50"
`;
exports[`<HistoryItemDisplay /> > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = `
" Example code block:
1 Line 1
2 Line 2
3 Line 3
4 Line 4
5 Line 5
6 Line 6
7 Line 7
8 Line 8
9 Line 9
10 Line 10
11 Line 11
12 Line 12
13 Line 13
14 Line 14
15 Line 15
16 Line 16
17 Line 17
18 Line 18
19 Line 19
20 Line 20
21 Line 21
22 Line 22
23 Line 23
24 Line 24
25 Line 25
26 Line 26
27 Line 27
28 Line 28
29 Line 29
30 Line 30
31 Line 31
32 Line 32
33 Line 33
34 Line 34
35 Line 35
36 Line 36
37 Line 37
38 Line 38
39 Line 39
40 Line 40
41 Line 41
42 Line 42
43 Line 43
44 Line 44
45 Line 45
46 Line 46
47 Line 47
48 Line 48
49 Line 49
50 Line 50"
`;
exports[`<HistoryItemDisplay /> > should render a truncated gemini item 1`] = `
"✦ Example code block:
... first 41 lines hidden ...
42 Line 42
43 Line 43
44 Line 44
45 Line 45
46 Line 46
47 Line 47
48 Line 48
49 Line 49
50 Line 50"
`;
exports[`<HistoryItemDisplay /> > should render a truncated gemini_content item 1`] = `
" Example code block:
... first 41 lines hidden ...
42 Line 42
43 Line 43
44 Line 44
45 Line 45
46 Line 46
47 Line 47
48 Line 48
49 Line 49
50 Line 50"
`;

View File

@@ -4,12 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MarkdownDisplay } from './MarkdownDisplay.js';
import { LoadedSettings } from '../../config/settings.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { EOL } from 'node:os';
import { renderWithProviders } from '../../test-utils/render.js';
describe('<MarkdownDisplay />', () => {
const baseProps = {
@@ -18,34 +17,21 @@ describe('<MarkdownDisplay />', () => {
availableTerminalHeight: 40,
};
const mockSettings = new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
true,
new Set(),
);
beforeEach(() => {
vi.clearAllMocks();
});
it('renders nothing for empty text', () => {
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text="" />
</SettingsContext.Provider>,
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text="" />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders a simple paragraph', () => {
const text = 'Hello, world.';
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
@@ -57,10 +43,8 @@ describe('<MarkdownDisplay />', () => {
### Header 3
#### Header 4
`.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
@@ -70,30 +54,24 @@ describe('<MarkdownDisplay />', () => {
/\n/g,
EOL,
);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders a fenced code block without a language', () => {
const text = '```\nplain text\n```'.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('handles unclosed (pending) code blocks', () => {
const text = '```typescript\nlet y = 2;'.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} isPending={true} />
</SettingsContext.Provider>,
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} isPending={true} />,
);
expect(lastFrame()).toMatchSnapshot();
});
@@ -104,10 +82,8 @@ describe('<MarkdownDisplay />', () => {
* item B
+ item C
`.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
@@ -118,10 +94,8 @@ describe('<MarkdownDisplay />', () => {
* Level 2
* Level 3
`.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
@@ -131,10 +105,8 @@ describe('<MarkdownDisplay />', () => {
1. First item
2. Second item
`.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
@@ -147,10 +119,8 @@ World
***
Test
`.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
@@ -162,10 +132,8 @@ Test
| Cell 1 | Cell 2 |
| Cell 3 | Cell 4 |
`.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
@@ -176,10 +144,8 @@ Some text before.
| A | B |
|---|
| 1 | 2 |`.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
@@ -188,10 +154,8 @@ Some text before.
const text = `Paragraph 1.
Paragraph 2.`.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
@@ -211,10 +175,8 @@ some code
Another paragraph.
`.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
});
@@ -234,10 +196,9 @@ Another paragraph.
new Set(),
);
const { lastFrame } = render(
<SettingsContext.Provider value={settings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
{ settings },
);
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).not.toContain(' 1 ');
@@ -245,10 +206,8 @@ Another paragraph.
it('shows line numbers in code blocks by default', () => {
const text = '```javascript\nconst x = 1;\n```'.replace(/\n/g, EOL);
const { lastFrame } = render(
<SettingsContext.Provider value={mockSettings}>
<MarkdownDisplay {...baseProps} text={text} />
</SettingsContext.Provider>,
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).toContain(' 1 ');