diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index d78b56e11d..94dc423f0d 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -3290,6 +3290,85 @@ describe('AppContainer State Management', () => { unmount(); }); + it('does not reset the hint timer when overflowingIdsSize decreases', async () => { + const { unmount } = await act(async () => renderAppContainer()); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); + + act(() => { + capturedOverflowActions.addOverflowingId('test-id-1'); + capturedOverflowActions.addOverflowingId('test-id-2'); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + act(() => { + capturedOverflowActions.removeOverflowingId('test-id-2'); + }); + + act(() => { + vi.advanceTimersByTime(1); + }); + + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(false); + }); + + unmount(); + }); + + it('does not auto-reset the hint timer for new overflow while expanded', async () => { + const { stdin, unmount } = await act(async () => renderAppContainer()); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); + + act(() => { + capturedOverflowActions.addOverflowingId('test-id-1'); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + act(() => { + stdin.write('\x0f'); // Ctrl+O + }); + + expect(capturedUIState.constrainHeight).toBe(false); + + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 1000); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + act(() => { + capturedOverflowActions.addOverflowingId('test-id-2'); + }); + + act(() => { + vi.advanceTimersByTime(1); + }); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(false); + }); + + unmount(); + }); + it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => { const { stdin, unmount } = await act(async () => renderAppContainer()); await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index eaf6fc3e75..ded3151905 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -187,6 +187,7 @@ import { isToolAwaitingConfirmation, getAllToolCalls, } from './utils/historyUtils.js'; +import { shouldAutoTriggerExpandHint } from './utils/expandHint.js'; interface AppContainerProps { config: Config; @@ -329,24 +330,35 @@ export const AppContainer = (props: AppContainerProps) => { const showIsExpandableHint = Boolean(expandHintTrigger); const overflowState = useOverflowState(); const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0; - const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight; + const previousOverflowingIdsSizeRef = useRef(0); /** * Manages the visibility and x-second timer for the expansion hint. * * This effect triggers the timer countdown whenever an overflow is detected - * or the user manually toggles the expansion state with Ctrl+O. - * By depending on overflowingIdsSize, the timer resets when *new* views - * overflow, but avoids infinitely resetting during single-view streaming. + * while the response is actually constrained. The Ctrl+O handler still + * refreshes the hint manually when the user toggles expansion. * - * In alternate buffer mode, we don't trigger the hint automatically on overflow - * to avoid noise, but the user can still trigger it manually with Ctrl+O. + * We only auto-refresh when the number of overflowing regions grows. That + * keeps the "show more" hint responsive for newly truncated content without + * retriggering on layout churn, overflow shrinkage, or while the content is + * already expanded. */ useEffect(() => { - if (hasOverflowState) { + const previousOverflowingIdsSize = previousOverflowingIdsSizeRef.current; + + if ( + shouldAutoTriggerExpandHint({ + constrainHeight, + overflowingIdsSize, + previousOverflowingIdsSize, + }) + ) { triggerExpandHint(true); } - }, [hasOverflowState, overflowingIdsSize, triggerExpandHint]); + + previousOverflowingIdsSizeRef.current = overflowingIdsSize; + }, [constrainHeight, overflowingIdsSize, triggerExpandHint]); const [defaultBannerText, setDefaultBannerText] = useState(''); const [warningBannerText, setWarningBannerText] = useState(''); diff --git a/packages/cli/src/ui/hooks/useTimedMessage.test.ts b/packages/cli/src/ui/hooks/useTimedMessage.test.ts new file mode 100644 index 0000000000..d57d7aca55 --- /dev/null +++ b/packages/cli/src/ui/hooks/useTimedMessage.test.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { act } from 'react'; +import { renderHook } from '../../test-utils/render.js'; +import { useTimedMessage } from './useTimedMessage.js'; + +describe('useTimedMessage', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('resets the timeout when the same message is retriggered', async () => { + const { result, unmount } = await renderHook(() => useTimedMessage(1000)); + + act(() => { + result.current[1]('hint'); + }); + expect(result.current[0]).toBe('hint'); + + act(() => { + vi.advanceTimersByTime(500); + }); + + act(() => { + result.current[1]('hint'); + }); + expect(result.current[0]).toBe('hint'); + + act(() => { + vi.advanceTimersByTime(500); + }); + expect(result.current[0]).toBe('hint'); + + act(() => { + vi.advanceTimersByTime(500); + }); + expect(result.current[0]).toBeNull(); + + unmount(); + }); + + it('clears the message immediately when asked to hide it', async () => { + const { result, unmount } = await renderHook(() => useTimedMessage(1000)); + + act(() => { + result.current[1]('hint'); + }); + expect(result.current[0]).toBe('hint'); + + act(() => { + result.current[1](null); + }); + expect(result.current[0]).toBeNull(); + + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current[0]).toBeNull(); + + unmount(); + }); +}); diff --git a/packages/cli/src/ui/hooks/useTimedMessage.ts b/packages/cli/src/ui/hooks/useTimedMessage.ts index 547968cb90..d7a61d9c87 100644 --- a/packages/cli/src/ui/hooks/useTimedMessage.ts +++ b/packages/cli/src/ui/hooks/useTimedMessage.ts @@ -12,19 +12,29 @@ import { useState, useCallback, useRef, useEffect } from 'react'; */ export function useTimedMessage(durationMs: number) { const [message, setMessage] = useState(null); + const messageRef = useRef(null); const timeoutRef = useRef(null); const showMessage = useCallback( (msg: T | null) => { - setMessage(msg); if (timeoutRef.current) { clearTimeout(timeoutRef.current); + timeoutRef.current = null; } if (msg !== null) { timeoutRef.current = setTimeout(() => { - setMessage(null); + timeoutRef.current = null; + messageRef.current = null; + setMessage((prev) => (prev === null ? prev : null)); }, durationMs); } + + if (Object.is(messageRef.current, msg)) { + return; + } + + messageRef.current = msg; + setMessage(msg); }, [durationMs], ); @@ -33,6 +43,7 @@ export function useTimedMessage(durationMs: number) { () => () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); + timeoutRef.current = null; } }, [], diff --git a/packages/cli/src/ui/utils/expandHint.test.ts b/packages/cli/src/ui/utils/expandHint.test.ts new file mode 100644 index 0000000000..419a0ef696 --- /dev/null +++ b/packages/cli/src/ui/utils/expandHint.test.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { shouldAutoTriggerExpandHint } from './expandHint.js'; + +describe('shouldAutoTriggerExpandHint', () => { + it('returns true when constrained content gains a new overflowing region', () => { + expect( + shouldAutoTriggerExpandHint({ + constrainHeight: true, + overflowingIdsSize: 2, + previousOverflowingIdsSize: 1, + }), + ).toBe(true); + }); + + it('returns false when overflowingIdsSize decreases', () => { + expect( + shouldAutoTriggerExpandHint({ + constrainHeight: true, + overflowingIdsSize: 1, + previousOverflowingIdsSize: 2, + }), + ).toBe(false); + }); + + it('returns false when overflowingIdsSize is unchanged', () => { + expect( + shouldAutoTriggerExpandHint({ + constrainHeight: true, + overflowingIdsSize: 1, + previousOverflowingIdsSize: 1, + }), + ).toBe(false); + }); + + it('returns false while content is already expanded', () => { + expect( + shouldAutoTriggerExpandHint({ + constrainHeight: false, + overflowingIdsSize: 2, + previousOverflowingIdsSize: 1, + }), + ).toBe(false); + }); +}); diff --git a/packages/cli/src/ui/utils/expandHint.ts b/packages/cli/src/ui/utils/expandHint.ts new file mode 100644 index 0000000000..c098dedecc --- /dev/null +++ b/packages/cli/src/ui/utils/expandHint.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +interface ExpandHintAutoTriggerParams { + constrainHeight: boolean; + overflowingIdsSize: number; + previousOverflowingIdsSize: number; +} + +export function shouldAutoTriggerExpandHint({ + constrainHeight, + overflowingIdsSize, + previousOverflowingIdsSize, +}: ExpandHintAutoTriggerParams): boolean { + return constrainHeight && overflowingIdsSize > previousOverflowingIdsSize; +}