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:
Spencer
2026-01-16 20:09:25 +00:00
parent 53f54436c9
commit 5085e6ecc8
6 changed files with 92 additions and 25 deletions

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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>
);
};

View File

@@ -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 />

View File

@@ -30,7 +30,7 @@ export const ScreenReaderAppLayout: React.FC = () => {
<Notifications />
<Footer />
<Box flexGrow={1} overflow="hidden">
<MainContent />
<MainContent composerHeight={0} />
</Box>
{uiState.dialogsVisible ? (
<DialogManager

View File

@@ -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';