mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
fix(cli): resolve layout contention and flashing loop in StatusRow (#24065)
This commit is contained in:
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderWithProviders } from '../../test-utils/render.js';
|
||||||
|
import { StatusRow } from './StatusRow.js';
|
||||||
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
|
import { useComposerStatus } from '../hooks/useComposerStatus.js';
|
||||||
|
import { type UIState } from '../contexts/UIStateContext.js';
|
||||||
|
import { type TextBuffer } from '../components/shared/text-buffer.js';
|
||||||
|
import { type SessionStatsState } from '../contexts/SessionContext.js';
|
||||||
|
import { type ThoughtSummary } from '../types.js';
|
||||||
|
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
vi.mock('../hooks/useComposerStatus.js', () => ({
|
||||||
|
useComposerStatus: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('<StatusRow />', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultUiState: Partial<UIState> = {
|
||||||
|
currentTip: undefined,
|
||||||
|
thought: null,
|
||||||
|
elapsedTime: 0,
|
||||||
|
currentWittyPhrase: undefined,
|
||||||
|
activeHooks: [],
|
||||||
|
buffer: { text: '' } as unknown as TextBuffer,
|
||||||
|
sessionStats: { lastPromptTokenCount: 0 } as unknown as SessionStatsState,
|
||||||
|
shortcutsHelpVisible: false,
|
||||||
|
contextFileNames: [],
|
||||||
|
showApprovalModeIndicator: ApprovalMode.DEFAULT,
|
||||||
|
allowPlanMode: false,
|
||||||
|
shellModeActive: false,
|
||||||
|
renderMarkdown: true,
|
||||||
|
currentModel: 'gemini-3',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('renders status and tip correctly when they both fit', async () => {
|
||||||
|
(useComposerStatus as Mock).mockReturnValue({
|
||||||
|
isInteractiveShellWaiting: false,
|
||||||
|
showLoadingIndicator: true,
|
||||||
|
showTips: true,
|
||||||
|
showWit: true,
|
||||||
|
modeContentObj: null,
|
||||||
|
showMinimalContext: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uiState: Partial<UIState> = {
|
||||||
|
...defaultUiState,
|
||||||
|
currentTip: 'Test Tip',
|
||||||
|
thought: { subject: 'Thinking...' } as unknown as ThoughtSummary,
|
||||||
|
elapsedTime: 5,
|
||||||
|
currentWittyPhrase: 'I am witty',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||||
|
<StatusRow
|
||||||
|
showUiDetails={false}
|
||||||
|
isNarrow={false}
|
||||||
|
terminalWidth={100}
|
||||||
|
hideContextSummary={false}
|
||||||
|
hideUiDetailsForSuggestions={false}
|
||||||
|
hasPendingActionRequired={false}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
width: 100,
|
||||||
|
uiState,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitUntilReady();
|
||||||
|
const output = lastFrame();
|
||||||
|
expect(output).toContain('Thinking...');
|
||||||
|
expect(output).toContain('I am witty');
|
||||||
|
expect(output).toContain('Tip: Test Tip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders correctly when interactive shell is waiting', async () => {
|
||||||
|
(useComposerStatus as Mock).mockReturnValue({
|
||||||
|
isInteractiveShellWaiting: true,
|
||||||
|
showLoadingIndicator: false,
|
||||||
|
showTips: false,
|
||||||
|
showWit: false,
|
||||||
|
modeContentObj: null,
|
||||||
|
showMinimalContext: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||||
|
<StatusRow
|
||||||
|
showUiDetails={true}
|
||||||
|
isNarrow={false}
|
||||||
|
terminalWidth={100}
|
||||||
|
hideContextSummary={false}
|
||||||
|
hideUiDetailsForSuggestions={false}
|
||||||
|
hasPendingActionRequired={false}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
width: 100,
|
||||||
|
uiState: defaultUiState,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitUntilReady();
|
||||||
|
expect(lastFrame()).toContain('! Shell awaiting input (Tab to focus)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders tip with absolute positioning when it fits but might collide (verification of container logic)', async () => {
|
||||||
|
(useComposerStatus as Mock).mockReturnValue({
|
||||||
|
isInteractiveShellWaiting: false,
|
||||||
|
showLoadingIndicator: true,
|
||||||
|
showTips: true,
|
||||||
|
showWit: true,
|
||||||
|
modeContentObj: null,
|
||||||
|
showMinimalContext: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uiState: Partial<UIState> = {
|
||||||
|
...defaultUiState,
|
||||||
|
currentTip: 'Test Tip',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { lastFrame, waitUntilReady } = await renderWithProviders(
|
||||||
|
<StatusRow
|
||||||
|
showUiDetails={false}
|
||||||
|
isNarrow={false}
|
||||||
|
terminalWidth={100}
|
||||||
|
hideContextSummary={false}
|
||||||
|
hideUiDetailsForSuggestions={false}
|
||||||
|
hasPendingActionRequired={false}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
width: 100,
|
||||||
|
uiState,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitUntilReady();
|
||||||
|
expect(lastFrame()).toContain('Tip: Test Tip');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -179,7 +179,13 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
const observer = new ResizeObserver((entries) => {
|
const observer = new ResizeObserver((entries) => {
|
||||||
const entry = entries[0];
|
const entry = entries[0];
|
||||||
if (entry) {
|
if (entry) {
|
||||||
setTipWidth(Math.round(entry.contentRect.width));
|
const width = Math.round(entry.contentRect.width);
|
||||||
|
// Only update if width > 0 to prevent layout feedback loops
|
||||||
|
// when the tip is hidden. This ensures we always use the
|
||||||
|
// intrinsic width for collision detection.
|
||||||
|
if (width > 0) {
|
||||||
|
setTipWidth(width);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
observer.observe(node);
|
observer.observe(node);
|
||||||
@@ -230,6 +236,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
const showRow1 = showUiDetails || showRow1Minimal;
|
const showRow1 = showUiDetails || showRow1Minimal;
|
||||||
const showRow2 = showUiDetails || showRow2Minimal;
|
const showRow2 = showUiDetails || showRow2Minimal;
|
||||||
|
|
||||||
|
const onStatusResize = useCallback((width: number) => {
|
||||||
|
if (width > 0) setStatusWidth(width);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const statusNode = (
|
const statusNode = (
|
||||||
<StatusNode
|
<StatusNode
|
||||||
showTips={showTips}
|
showTips={showTips}
|
||||||
@@ -242,7 +252,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
errorVerbosity={
|
errorVerbosity={
|
||||||
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
|
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
|
||||||
}
|
}
|
||||||
onResize={setStatusWidth}
|
onResize={onStatusResize}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -322,20 +332,23 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
|||||||
|
|
||||||
<Box
|
<Box
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
marginLeft={LAYOUT.TIP_LEFT_MARGIN}
|
marginLeft={showTipLine ? LAYOUT.TIP_LEFT_MARGIN : 0}
|
||||||
marginRight={
|
marginRight={
|
||||||
isNarrow
|
showTipLine
|
||||||
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
|
? isNarrow
|
||||||
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
|
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
|
||||||
|
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
|
||||||
|
: 0
|
||||||
}
|
}
|
||||||
|
position={showTipLine ? 'relative' : 'absolute'}
|
||||||
|
{...(showTipLine ? {} : { top: -100, left: -100 })}
|
||||||
>
|
>
|
||||||
{/*
|
{/*
|
||||||
We always render the tip node so it can be measured by ResizeObserver,
|
We always render the tip node so it can be measured by ResizeObserver.
|
||||||
but we control its visibility based on the collision detection.
|
When hidden, we use absolute positioning so it can still be measured
|
||||||
|
but doesn't affect the layout of Row 1. This prevents layout loops.
|
||||||
*/}
|
*/}
|
||||||
<Box display={showTipLine ? 'flex' : 'none'}>
|
{!isNarrow && tipContentStr && renderTipNode()}
|
||||||
{!isNarrow && tipContentStr && renderTipNode()}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user