diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 1a8851685a..695de98684 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -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(); return { diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index 63cbdce790..b0160db814 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -95,7 +95,7 @@ describe('MainContent', () => { }); it('renders in normal buffer mode', async () => { - const { lastFrame } = render(); + const { lastFrame } = render(); 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(); + const { lastFrame } = render(); 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(); + const { lastFrame } = render(); await waitFor(() => expect(lastFrame()).toContain('HistoryItem: Hello')); const output = lastFrame(); diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 7f3982eec0..2a60109613 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -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 ( - <> - , - ...historyItems, - ]} + + - {(item) => item} - - {pendingItems} - + , + ...historyItems, + ]} + > + {(item) => item} + + {pendingItems} + + ); }; diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index bf68aee85d..5bfc0628b0 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -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(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} > - + - + diff --git a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx index d88f3f1fb2..95df0ab633 100644 --- a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx +++ b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx @@ -30,7 +30,7 @@ export const ScreenReaderAppLayout: React.FC = () => {