mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-11 22:51:00 -07:00
fix(ui): prevent flicker by constraining content height
Resolves flicker and UI instability by dynamically calculating the available vertical space for the main content area. A ResizeObserver is used to measure the height of the composer/input area. This height is subtracted from the total terminal height, and the result is used to set a on the component via . This ensures the total UI height never exceeds the terminal viewport, preventing overflow and eliminating the root cause of the flicker during re-renders.
This commit is contained in:
@@ -16,6 +16,21 @@ import { AppContext, type AppState } from './contexts/AppContext.js';
|
||||
import { SettingsContext } from './contexts/SettingsContext.js';
|
||||
import { LoadedSettings, type SettingsFile } from '../config/settings.js';
|
||||
|
||||
// Mock the ResizeObserver API for tests.
|
||||
class ResizeObserver {
|
||||
observe() {
|
||||
// do nothing
|
||||
}
|
||||
unobserve() {
|
||||
// do nothing
|
||||
}
|
||||
disconnect() {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
global.ResizeObserver = ResizeObserver;
|
||||
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('ink')>();
|
||||
return {
|
||||
|
||||
@@ -95,7 +95,7 @@ describe('MainContent', () => {
|
||||
});
|
||||
|
||||
it('renders in normal buffer mode', async () => {
|
||||
const { lastFrame } = render(<MainContent />);
|
||||
const { lastFrame } = render(<MainContent composerHeight={0} />);
|
||||
await waitFor(() => expect(lastFrame()).toContain('AppHeader'));
|
||||
const output = lastFrame();
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('MainContent', () => {
|
||||
|
||||
it('renders in alternate buffer mode', async () => {
|
||||
vi.mocked(useAlternateBuffer).mockReturnValue(true);
|
||||
const { lastFrame } = render(<MainContent />);
|
||||
const { lastFrame } = render(<MainContent composerHeight={0} />);
|
||||
await waitFor(() => expect(lastFrame()).toContain('ScrollableList'));
|
||||
const output = lastFrame();
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('MainContent', () => {
|
||||
|
||||
it('does not constrain height in alternate buffer mode', async () => {
|
||||
vi.mocked(useAlternateBuffer).mockReturnValue(true);
|
||||
const { lastFrame } = render(<MainContent />);
|
||||
const { lastFrame } = render(<MainContent composerHeight={0} />);
|
||||
await waitFor(() => expect(lastFrame()).toContain('HistoryItem: Hello'));
|
||||
const output = lastFrame();
|
||||
|
||||
|
||||
@@ -16,15 +16,20 @@ import { SCROLL_TO_ITEM_END } from './shared/VirtualizedList.js';
|
||||
import { ScrollableList } from './shared/ScrollableList.js';
|
||||
import { useMemo, memo, useCallback } from 'react';
|
||||
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
|
||||
import { MaxSizedBox } from './shared/MaxSizedBox.js';
|
||||
|
||||
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
|
||||
const MemoizedAppHeader = memo(AppHeader);
|
||||
|
||||
interface MainContentProps {
|
||||
composerHeight: number;
|
||||
}
|
||||
|
||||
// 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.
|
||||
export const MainContent = () => {
|
||||
export const MainContent = ({ composerHeight }: MainContentProps) => {
|
||||
const { version } = useAppContext();
|
||||
const uiState = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
@@ -144,17 +149,26 @@ export const MainContent = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Static
|
||||
key={uiState.historyRemountKey}
|
||||
items={[
|
||||
<AppHeader key="app-header" version={version} />,
|
||||
...historyItems,
|
||||
]}
|
||||
<Box flexGrow={1} flexShrink={1} flexDirection="column" overflow="hidden">
|
||||
<MaxSizedBox
|
||||
maxHeight={
|
||||
availableTerminalHeight
|
||||
? availableTerminalHeight - composerHeight
|
||||
: // HACK: Sometimes availableTerminalHeight is 0 on the first render.
|
||||
1
|
||||
}
|
||||
>
|
||||
{(item) => item}
|
||||
</Static>
|
||||
{pendingItems}
|
||||
</>
|
||||
<Static
|
||||
key={uiState.historyRemountKey}
|
||||
items={[
|
||||
<AppHeader key="app-header" version={version} />,
|
||||
...historyItems,
|
||||
]}
|
||||
>
|
||||
{(item) => item}
|
||||
</Static>
|
||||
{pendingItems}
|
||||
</MaxSizedBox>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { type DOMElement, Box } from 'ink';
|
||||
import { Notifications } from '../components/Notifications.js';
|
||||
import { MainContent } from '../components/MainContent.js';
|
||||
import { DialogManager } from '../components/DialogManager.js';
|
||||
@@ -15,10 +14,36 @@ import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { CopyModeWarning } from '../components/CopyModeWarning.js';
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
|
||||
export const DefaultAppLayout: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const [composerHeight, setComposerHeight] = useState(0);
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const onRefChange = useCallback(
|
||||
(node: DOMElement | null) => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
|
||||
uiState.mainControlsRef.current = node;
|
||||
|
||||
if (node && typeof ResizeObserver !== 'undefined') {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
setComposerHeight(entry.contentRect.height);
|
||||
}
|
||||
});
|
||||
observer.observe(node as unknown as Element);
|
||||
observerRef.current = observer;
|
||||
}
|
||||
},
|
||||
[uiState.mainControlsRef],
|
||||
);
|
||||
|
||||
const { rootUiRef, terminalHeight } = uiState;
|
||||
useFlickerDetector(rootUiRef, terminalHeight);
|
||||
@@ -37,14 +62,9 @@ export const DefaultAppLayout: React.FC = () => {
|
||||
overflow="hidden"
|
||||
ref={uiState.rootUiRef}
|
||||
>
|
||||
<MainContent />
|
||||
<MainContent composerHeight={composerHeight} />
|
||||
|
||||
<Box
|
||||
flexDirection="column"
|
||||
ref={uiState.mainControlsRef}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
>
|
||||
<Box flexDirection="column" ref={onRefChange} flexShrink={0} flexGrow={0}>
|
||||
<Notifications />
|
||||
<CopyModeWarning />
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export const ScreenReaderAppLayout: React.FC = () => {
|
||||
<Notifications />
|
||||
<Footer />
|
||||
<Box flexGrow={1} overflow="hidden">
|
||||
<MainContent />
|
||||
<MainContent composerHeight={0} />
|
||||
</Box>
|
||||
{uiState.dialogsVisible ? (
|
||||
<DialogManager
|
||||
|
||||
@@ -4,6 +4,24 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Mock the ResizeObserver API for tests.
|
||||
// This needs to be at the top of the file to ensure it's available globally
|
||||
// before any other modules (especially React components) are imported.
|
||||
// Otherwise, we can get a "ResizeObserver is not defined" error during test runs.
|
||||
class ResizeObserver {
|
||||
observe() {
|
||||
// do nothing
|
||||
}
|
||||
unobserve() {
|
||||
// do nothing
|
||||
}
|
||||
disconnect() {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
global.ResizeObserver = ResizeObserver;
|
||||
|
||||
import { vi, beforeEach, afterEach } from 'vitest';
|
||||
import { format } from 'node:util';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user