diff --git a/packages/cli/src/ui/components/StatusRow.test.tsx b/packages/cli/src/ui/components/StatusRow.test.tsx
new file mode 100644
index 0000000000..b80dbacabe
--- /dev/null
+++ b/packages/cli/src/ui/components/StatusRow.test.tsx
@@ -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('', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const defaultUiState: Partial = {
+ 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 = {
+ ...defaultUiState,
+ currentTip: 'Test Tip',
+ thought: { subject: 'Thinking...' } as unknown as ThoughtSummary,
+ elapsedTime: 5,
+ currentWittyPhrase: 'I am witty',
+ };
+
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ {
+ 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(
+ ,
+ {
+ 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 = {
+ ...defaultUiState,
+ currentTip: 'Test Tip',
+ };
+
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ {
+ width: 100,
+ uiState,
+ },
+ );
+
+ await waitUntilReady();
+ expect(lastFrame()).toContain('Tip: Test Tip');
+ });
+});
diff --git a/packages/cli/src/ui/components/StatusRow.tsx b/packages/cli/src/ui/components/StatusRow.tsx
index 4585438bee..adaa339a64 100644
--- a/packages/cli/src/ui/components/StatusRow.tsx
+++ b/packages/cli/src/ui/components/StatusRow.tsx
@@ -179,7 +179,13 @@ export const StatusRow: React.FC = ({
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
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);
@@ -230,6 +236,10 @@ export const StatusRow: React.FC = ({
const showRow1 = showUiDetails || showRow1Minimal;
const showRow2 = showUiDetails || showRow2Minimal;
+ const onStatusResize = useCallback((width: number) => {
+ if (width > 0) setStatusWidth(width);
+ }, []);
+
const statusNode = (
= ({
errorVerbosity={
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
}
- onResize={setStatusWidth}
+ onResize={onStatusResize}
/>
);
@@ -322,20 +332,23 @@ export const StatusRow: React.FC = ({
{/*
- We always render the tip node so it can be measured by ResizeObserver,
- but we control its visibility based on the collision detection.
+ We always render the tip node so it can be measured by ResizeObserver.
+ 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.
*/}
-
- {!isNarrow && tipContentStr && renderTipNode()}
-
+ {!isNarrow && tipContentStr && renderTipNode()}
)}