diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 412ba01b310..aa830c02502 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -102,6 +102,7 @@ import { createPolicyUpdater } from './config/policy.js'; import { ScrollProvider } from './ui/contexts/ScrollProvider.js'; import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js'; import { TerminalProvider } from './ui/contexts/TerminalContext.js'; +import { OverflowProvider } from './ui/contexts/OverflowContext.js'; import { setupTerminalAndTheme } from './utils/terminalTheme.js'; import { profiler } from './ui/components/DebugProfiler.js'; @@ -238,17 +239,19 @@ export async function startInteractiveUI( > - - - - - + + + + + + + diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 257ea844661..a13d6e25583 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -35,6 +35,13 @@ import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js'; import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js'; import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.js'; import { TerminalProvider } from '../ui/contexts/TerminalContext.js'; +import { + OverflowProvider, + useOverflowActions, + useOverflowState, + type OverflowActions, + type OverflowState, +} from '../ui/contexts/OverflowContext.js'; import { makeFakeConfig, type Config } from '@google/gemini-cli-core'; import { FakePersistentState } from './persistentStateFake.js'; @@ -335,6 +342,8 @@ export type RenderInstance = { lastFrame: (options?: { allowEmpty?: boolean }) => string; terminal: Terminal; waitUntilReady: () => Promise; + capturedOverflowState: OverflowState | undefined; + capturedOverflowActions: OverflowActions | undefined; }; const instances: InkInstance[] = []; @@ -343,7 +352,10 @@ const instances: InkInstance[] = []; export const render = ( tree: React.ReactElement, terminalWidth?: number, -): RenderInstance => { +): Omit< + RenderInstance, + 'capturedOverflowState' | 'capturedOverflowActions' +> => { const cols = terminalWidth ?? 100; // We use 1000 rows to avoid windows with incorrect snapshots if a correct // value was used (e.g. 40 rows). The alternatives to make things worse are @@ -562,6 +574,16 @@ const mockUIActions: UIActions = { handleNewAgentsSelect: vi.fn(), }; +let capturedOverflowState: OverflowState | undefined; +let capturedOverflowActions: OverflowActions | undefined; +const ContextCapture: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + capturedOverflowState = useOverflowState(); + capturedOverflowActions = useOverflowActions(); + return <>{children}; +}; + export const renderWithProviders = ( component: React.ReactElement, { @@ -663,6 +685,9 @@ export const renderWithProviders = ( .filter((item): item is HistoryItemToolGroup => item.type === 'tool_group') .flatMap((item) => item.tools); + capturedOverflowState = undefined; + capturedOverflowActions = undefined; + const renderResult = render( @@ -675,35 +700,39 @@ export const renderWithProviders = ( value={finalUiState.streamingState} > - - + - - - - - - {component} - - - - - - - + + + + + + + + {component} + + + + + + + + + @@ -718,6 +747,8 @@ export const renderWithProviders = ( return { ...renderResult, + capturedOverflowState, + capturedOverflowActions, simulateClick: (col: number, row: number, button?: 0 | 1 | 2) => simulateClick(renderResult.stdin, col, row, button), }; diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 3aeef34292f..b3610a6d728 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -105,6 +105,11 @@ import { type UIActions, } from './contexts/UIActionsContext.js'; import { KeypressProvider } from './contexts/KeypressContext.js'; +import { OverflowProvider } from './contexts/OverflowContext.js'; +import { + useOverflowActions, + type OverflowActions, +} from './contexts/OverflowContext.js'; // Mock useStdout to capture terminal title writes vi.mock('ink', async (importOriginal) => { @@ -120,9 +125,11 @@ vi.mock('ink', async (importOriginal) => { // so we can assert against them in our tests. let capturedUIState: UIState; let capturedUIActions: UIActions; +let capturedOverflowActions: OverflowActions; function TestContextConsumer() { capturedUIState = useContext(UIStateContext)!; capturedUIActions = useContext(UIActionsContext)!; + capturedOverflowActions = useOverflowActions()!; return null; } @@ -229,7 +236,10 @@ import { disableMouseEvents, } from '@google/gemini-cli-core'; import { type ExtensionManager } from '../config/extension-manager.js'; -import { WARNING_PROMPT_DURATION_MS } from './constants.js'; +import { + WARNING_PROMPT_DURATION_MS, + EXPAND_HINT_DURATION_MS, +} from './constants.js'; describe('AppContainer State Management', () => { let mockConfig: Config; @@ -255,13 +265,15 @@ describe('AppContainer State Management', () => { } = {}) => ( - + + + ); @@ -2687,12 +2699,14 @@ describe('AppContainer State Management', () => { const getTree = (settings: LoadedSettings) => ( - - + + + + ); @@ -3303,6 +3317,306 @@ describe('AppContainer State Management', () => { }); }); + describe('Submission Handling', () => { + it('resets expansion state on submission when not in alternate buffer', async () => { + const { checkPermissions } = await import( + './hooks/atCommandProcessor.js' + ); + vi.mocked(checkPermissions).mockResolvedValue([]); + + let unmount: () => void; + await act(async () => { + unmount = renderAppContainer({ + settings: { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { ...mockSettings.merged.ui, useAlternateBuffer: false }, + }, + } as LoadedSettings, + }).unmount; + }); + + await waitFor(() => expect(capturedUIActions).toBeTruthy()); + + // Expand first + act(() => capturedUIActions.setConstrainHeight(false)); + expect(capturedUIState.constrainHeight).toBe(false); + + // Reset mock stdout to clear any initial writes + mocks.mockStdout.write.mockClear(); + + // Submit + await act(async () => capturedUIActions.handleFinalSubmit('test prompt')); + + // Should be reset + expect(capturedUIState.constrainHeight).toBe(true); + // Should refresh static (which clears terminal in non-alternate buffer) + expect(mocks.mockStdout.write).toHaveBeenCalledWith( + ansiEscapes.clearTerminal, + ); + unmount!(); + }); + + it('resets expansion state on submission when in alternate buffer without clearing terminal', async () => { + const { checkPermissions } = await import( + './hooks/atCommandProcessor.js' + ); + vi.mocked(checkPermissions).mockResolvedValue([]); + + let unmount: () => void; + await act(async () => { + unmount = renderAppContainer({ + settings: { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { ...mockSettings.merged.ui, useAlternateBuffer: true }, + }, + } as LoadedSettings, + }).unmount; + }); + + await waitFor(() => expect(capturedUIActions).toBeTruthy()); + + // Expand first + act(() => capturedUIActions.setConstrainHeight(false)); + expect(capturedUIState.constrainHeight).toBe(false); + + // Reset mock stdout + mocks.mockStdout.write.mockClear(); + + // Submit + await act(async () => capturedUIActions.handleFinalSubmit('test prompt')); + + // Should be reset + expect(capturedUIState.constrainHeight).toBe(true); + // Should NOT refresh static's clearTerminal in alternate buffer + expect(mocks.mockStdout.write).not.toHaveBeenCalledWith( + ansiEscapes.clearTerminal, + ); + unmount!(); + }); + }); + + describe('Overflow Hint Handling', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('sets showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => { + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + // Trigger overflow + act(() => { + capturedOverflowActions.addOverflowingId('test-id'); + }); + + await waitFor(() => { + // Should show hint because we are in Standard Mode (default settings) and have overflow + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // Advance just before the timeout + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Advance to hit the timeout mark + act(() => { + vi.advanceTimersByTime(100); + }); + 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 () => { + let unmount: () => void; + let stdin: ReturnType['stdin']; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + stdin = result.stdin; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + // Initial state is constrainHeight = true + expect(capturedUIState.constrainHeight).toBe(true); + + // Trigger overflow so the hint starts showing + act(() => { + capturedOverflowActions.addOverflowingId('test-id'); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // Advance half the duration + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Simulate Ctrl+O + act(() => { + stdin.write('\x0f'); // \x0f is Ctrl+O + }); + + await waitFor(() => { + // constrainHeight should toggle + expect(capturedUIState.constrainHeight).toBe(false); + }); + + // Advance enough that the original timer would have expired if it hadn't reset + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 1000); + }); + + // We expect it to still be true because Ctrl+O should have reset the timer + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Advance remaining time to reach the new timeout + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 1000); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(false); + }); + + unmount!(); + }); + + it('toggles Ctrl+O multiple times and verifies the hint disappears exactly after the last toggle', async () => { + let unmount: () => void; + let stdin: ReturnType['stdin']; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + stdin = result.stdin; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + // Initial state is constrainHeight = true + expect(capturedUIState.constrainHeight).toBe(true); + + // Trigger overflow so the hint starts showing + act(() => { + capturedOverflowActions.addOverflowingId('test-id'); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // Advance half the duration + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // First toggle 'on' (expanded) + act(() => { + stdin.write('\x0f'); // Ctrl+O + }); + await waitFor(() => { + expect(capturedUIState.constrainHeight).toBe(false); + }); + + // Wait 1 second + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Second toggle 'off' (collapsed) + act(() => { + stdin.write('\x0f'); // Ctrl+O + }); + await waitFor(() => { + expect(capturedUIState.constrainHeight).toBe(true); + }); + + // Wait 1 second + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Third toggle 'on' (expanded) + act(() => { + stdin.write('\x0f'); // Ctrl+O + }); + await waitFor(() => { + expect(capturedUIState.constrainHeight).toBe(false); + }); + + // Now we wait just before the timeout from the LAST toggle. + // It should still be true. + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Wait 0.1s more to hit exactly the timeout since the last toggle. + // It should hide now. + act(() => { + vi.advanceTimersByTime(100); + }); + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(false); + }); + + unmount!(); + }); + + it('does NOT set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { + const alternateSettings = mergeSettings({}, {}, {}, {}, true); + const settingsWithAlternateBuffer = { + merged: { + ...alternateSettings, + ui: { + ...alternateSettings.ui, + useAlternateBuffer: true, + }, + }, + } as unknown as LoadedSettings; + + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ + settings: settingsWithAlternateBuffer, + }); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + // Trigger overflow + act(() => { + capturedOverflowActions.addOverflowingId('test-id'); + }); + + // Should NOT show hint because we are in Alternate Buffer Mode + expect(capturedUIState.showIsExpandableHint).toBe(false); + + unmount!(); + }); + }); + describe('Permission Handling', () => { it('shows permission dialog when checkPermissions returns paths', async () => { const { checkPermissions } = await import( diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index bdf9501c4ea..d0ba22d4559 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -95,6 +95,10 @@ import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useVimMode } from './contexts/VimModeContext.js'; +import { + useOverflowActions, + useOverflowState, +} from './contexts/OverflowContext.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { calculatePromptWidths } from './components/InputPrompt.js'; @@ -151,6 +155,7 @@ import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js' import { WARNING_PROMPT_DURATION_MS, QUEUE_ERROR_DISPLAY_DURATION_MS, + EXPAND_HINT_DURATION_MS, } from './constants.js'; import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js'; import { NewAgentsChoice } from './components/NewAgentsNotification.js'; @@ -214,6 +219,7 @@ const SHELL_HEIGHT_PADDING = 10; export const AppContainer = (props: AppContainerProps) => { const { config, initializationResult, resumedSessionData } = props; const settings = useSettings(); + const { reset } = useOverflowActions()!; const notificationsEnabled = isNotificationsEnabled(settings); const historyManager = useHistory({ @@ -262,6 +268,54 @@ export const AppContainer = (props: AppContainerProps) => { ); const [newAgents, setNewAgents] = useState(null); + const [constrainHeight, setConstrainHeight] = useState(true); + const [showIsExpandableHint, setShowIsExpandableHint] = useState(false); + const expandHintTimerRef = useRef(null); + const overflowState = useOverflowState(); + const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0; + const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight; + + /** + * 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. We use a stable + * boolean dependency (hasOverflowState) to ensure the timer only resets on + * genuine state transitions, preventing it from infinitely resetting during + * active text streaming. + */ + useEffect(() => { + if (isAlternateBuffer) { + setShowIsExpandableHint(false); + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } + return; + } + + if (hasOverflowState) { + setShowIsExpandableHint(true); + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } + expandHintTimerRef.current = setTimeout(() => { + setShowIsExpandableHint(false); + }, EXPAND_HINT_DURATION_MS); + } + }, [hasOverflowState, isAlternateBuffer, constrainHeight]); + + /** + * Safe cleanup to ensure the expansion hint timer is cancelled when the + * component unmounts, preventing memory leaks. + */ + useEffect( + () => () => { + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } + }, + [], + ); const [defaultBannerText, setDefaultBannerText] = useState(''); const [warningBannerText, setWarningBannerText] = useState(''); @@ -1189,6 +1243,19 @@ Logging in with Google... Restarting Gemini CLI to continue. const handleFinalSubmit = useCallback( async (submittedValue: string) => { + reset(); + // Explicitly hide the expansion hint and clear its x-second timer when a new turn begins. + setShowIsExpandableHint(false); + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } + if (!constrainHeight) { + setConstrainHeight(true); + if (!isAlternateBuffer) { + refreshStatic(); + } + } + const isSlash = isSlashCommand(submittedValue.trim()); const isIdle = streamingState === StreamingState.Idle; const isAgentRunning = @@ -1247,15 +1314,32 @@ Logging in with Google... Restarting Gemini CLI to continue. pendingSlashCommandHistoryItems, pendingGeminiHistoryItems, config, + constrainHeight, + setConstrainHeight, + isAlternateBuffer, + refreshStatic, + reset, handleHintSubmit, ], ); const handleClearScreen = useCallback(() => { + reset(); + // Explicitly hide the expansion hint and clear its x-second timer when clearing the screen. + setShowIsExpandableHint(false); + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } historyManager.clearItems(); clearConsoleMessagesState(); refreshStatic(); - }, [historyManager, clearConsoleMessagesState, refreshStatic]); + }, [ + historyManager, + clearConsoleMessagesState, + refreshStatic, + reset, + setShowIsExpandableHint, + ]); const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); @@ -1425,7 +1509,7 @@ Logging in with Google... Restarting Gemini CLI to continue. windowMs: WARNING_PROMPT_DURATION_MS, onRepeat: handleExitRepeat, }); - const [constrainHeight, setConstrainHeight] = useState(true); + const [ideContextState, setIdeContextState] = useState< IdeContext | undefined >(); @@ -1655,6 +1739,19 @@ Logging in with Google... Restarting Gemini CLI to continue. if (!constrainHeight) { enteringConstrainHeightMode = true; setConstrainHeight(true); + if (keyMatchers[Command.SHOW_MORE_LINES](key)) { + // If the user manually collapses the view, show the hint and reset the x-second timer. + setShowIsExpandableHint(true); + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } + expandHintTimerRef.current = setTimeout(() => { + setShowIsExpandableHint(false); + }, EXPAND_HINT_DURATION_MS); + } + if (!isAlternateBuffer) { + refreshStatic(); + } } if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) { @@ -1698,6 +1795,17 @@ Logging in with Google... Restarting Gemini CLI to continue. !enteringConstrainHeightMode ) { setConstrainHeight(false); + // If the user manually expands the view, show the hint and reset the x-second timer. + setShowIsExpandableHint(true); + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } + expandHintTimerRef.current = setTimeout(() => { + setShowIsExpandableHint(false); + }, EXPAND_HINT_DURATION_MS); + if (!isAlternateBuffer) { + refreshStatic(); + } return true; } else if ( (keyMatchers[Command.FOCUS_SHELL_INPUT](key) || @@ -2218,6 +2326,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isBackgroundShellListOpen, adminSettingsChanged, newAgents, + showIsExpandableHint, hintMode: config.isModelSteeringEnabled() && isToolExecuting([ @@ -2344,6 +2453,7 @@ Logging in with Google... Restarting Gemini CLI to continue. backgroundShells, adminSettingsChanged, newAgents, + showIsExpandableHint, ], ); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index a227047fba6..07693db1513 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -173,7 +173,9 @@ describe('FolderTrustDialog', () => { // Initial state: truncated await waitFor(() => { expect(lastFrame()).toContain('Do you trust the files in this folder?'); - expect(lastFrame()).toContain('Press ctrl-o to show more lines'); + // In standard terminal mode, the expansion hint is handled globally by ToastDisplay + // via AppContainer, so it should not be present in the dialog's local frame. + expect(lastFrame()).not.toContain('Press Ctrl+O'); expect(lastFrame()).toContain('hidden'); }); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx index 70cfd9fd4c8..2067a5dc3a4 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx @@ -285,33 +285,37 @@ export const FolderTrustDialog: React.FC = ({ ); }; - return ( - - - - {renderContent()} - + const content = ( + + + {renderContent()} + + + + + - - + {isRestarting && ( + + + Gemini CLI is restarting to apply the trust changes... + + + )} + {exiting && ( + + + A folder trust level must be selected to continue. Exiting since + escape was pressed. + + )} + + ); - {isRestarting && ( - - - Gemini CLI is restarting to apply the trust changes... - - - )} - {exiting && ( - - - A folder trust level must be selected to continue. Exiting since - escape was pressed. - - - )} - - + return isAlternateBuffer ? ( + {content} + ) : ( + content ); }; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index f863e2272af..2fceacdf2f4 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -46,6 +46,7 @@ interface HistoryItemDisplayProps { isPending: boolean; commands?: readonly SlashCommand[]; availableTerminalHeightGemini?: number; + isExpandable?: boolean; } export const HistoryItemDisplay: React.FC = ({ @@ -55,6 +56,7 @@ export const HistoryItemDisplay: React.FC = ({ isPending, commands, availableTerminalHeightGemini, + isExpandable, }) => { const settings = useSettings(); const inlineThinkingMode = getInlineThinkingMode(settings); @@ -180,6 +182,7 @@ export const HistoryItemDisplay: React.FC = ({ terminalWidth={terminalWidth} borderTop={itemForDisplay.borderTop} borderBottom={itemForDisplay.borderBottom} + isExpandable={isExpandable} /> )} {itemForDisplay.type === 'compression' && ( diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index fce375c3065..598d19240fa 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -493,7 +493,8 @@ describe('MainContent', () => { isAlternateBuffer: true, embeddedShellFocused: true, constrainHeight: true, - shouldShowLine1: true, + shouldShowLine1: false, + staticAreaMaxItemHeight: 15, }, { name: 'ASB mode - Unfocused shell', @@ -501,6 +502,7 @@ describe('MainContent', () => { embeddedShellFocused: false, constrainHeight: true, shouldShowLine1: false, + staticAreaMaxItemHeight: 15, }, { name: 'Normal mode - Constrained height', @@ -508,13 +510,15 @@ describe('MainContent', () => { embeddedShellFocused: false, constrainHeight: true, shouldShowLine1: false, + staticAreaMaxItemHeight: 15, }, { name: 'Normal mode - Unconstrained height', isAlternateBuffer: false, embeddedShellFocused: false, constrainHeight: false, - shouldShowLine1: false, + shouldShowLine1: true, + staticAreaMaxItemHeight: 15, }, ]; @@ -525,6 +529,7 @@ describe('MainContent', () => { embeddedShellFocused, constrainHeight, shouldShowLine1, + staticAreaMaxItemHeight, }) => { vi.mocked(useAlternateBuffer).mockReturnValue(isAlternateBuffer); const ptyId = 123; @@ -554,6 +559,7 @@ describe('MainContent', () => { }, ], availableTerminalHeight: 30, // In ASB mode, focused shell should get ~28 lines + staticAreaMaxItemHeight, terminalHeight: 50, terminalWidth: 100, mainAreaWidth: 100, diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index cba57756e32..fbcc9626637 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -47,32 +47,61 @@ export const MainContent = () => { pendingHistoryItems, mainAreaWidth, staticAreaMaxItemHeight, - availableTerminalHeight, cleanUiDetailsVisible, } = uiState; const showHeaderDetails = cleanUiDetailsVisible; + const lastUserPromptIndex = useMemo(() => { + for (let i = uiState.history.length - 1; i >= 0; i--) { + const type = uiState.history[i].type; + if (type === 'user' || type === 'user_shell') { + return i; + } + } + return -1; + }, [uiState.history]); + const historyItems = useMemo( () => - uiState.history.map((h) => ( - - )), + uiState.history.map((h, index) => { + const isExpandable = index > lastUserPromptIndex; + return ( + + ); + }), [ uiState.history, mainAreaWidth, staticAreaMaxItemHeight, uiState.slashCommands, + uiState.constrainHeight, + lastUserPromptIndex, ], ); + const staticHistoryItems = useMemo( + () => historyItems.slice(0, lastUserPromptIndex + 1), + [historyItems, lastUserPromptIndex], + ); + + const lastResponseHistoryItems = useMemo( + () => historyItems.slice(lastUserPromptIndex + 1), + [historyItems, lastUserPromptIndex], + ); + const pendingItems = useMemo( () => ( @@ -80,14 +109,12 @@ export const MainContent = () => { ))} {showConfirmationQueue && confirmingTool && ( @@ -98,8 +125,7 @@ export const MainContent = () => { [ pendingHistoryItems, uiState.constrainHeight, - isAlternateBuffer, - availableTerminalHeight, + staticAreaMaxItemHeight, mainAreaWidth, showConfirmationQueue, confirmingTool, @@ -109,10 +135,14 @@ export const MainContent = () => { const virtualizedData = useMemo( () => [ { type: 'header' as const }, - ...uiState.history.map((item) => ({ type: 'history' as const, item })), + ...uiState.history.map((item, index) => ({ + type: 'history' as const, + item, + isExpandable: index > lastUserPromptIndex, + })), { type: 'pending' as const }, ], - [uiState.history], + [uiState.history, lastUserPromptIndex], ); const renderItem = useCallback( @@ -129,12 +159,17 @@ export const MainContent = () => { return ( ); } else { @@ -147,6 +182,8 @@ export const MainContent = () => { mainAreaWidth, uiState.slashCommands, pendingItems, + uiState.constrainHeight, + staticAreaMaxItemHeight, ], ); @@ -176,7 +213,8 @@ export const MainContent = () => { key={uiState.historyRemountKey} items={[ , - ...historyItems, + ...staticHistoryItems, + ...lastResponseHistoryItems, ]} > {(item) => item} diff --git a/packages/cli/src/ui/components/ShowMoreLines.test.tsx b/packages/cli/src/ui/components/ShowMoreLines.test.tsx index 699e2b7f019..4a6829809ae 100644 --- a/packages/cli/src/ui/components/ShowMoreLines.test.tsx +++ b/packages/cli/src/ui/components/ShowMoreLines.test.tsx @@ -29,7 +29,6 @@ describe('ShowMoreLines', () => { it.each([ [new Set(), StreamingState.Idle, true], // No overflow [new Set(['1']), StreamingState.Idle, false], // Not constraining height - [new Set(['1']), StreamingState.Responding, true], // Streaming ])( 'renders nothing when: overflow=%s, streaming=%s, constrain=%s', async (overflowingIds, streamingState, constrainHeight) => { @@ -46,9 +45,28 @@ describe('ShowMoreLines', () => { }, ); - it.each([[StreamingState.Idle], [StreamingState.WaitingForConfirmation]])( - 'renders message when overflowing and state is %s', + it('renders nothing in STANDARD mode even if overflowing', async () => { + mockUseAlternateBuffer.mockReturnValue(false); + mockUseOverflowState.mockReturnValue({ + overflowingIds: new Set(['1']), + } as NonNullable>); + mockUseStreamingContext.mockReturnValue(StreamingState.Idle); + const { lastFrame, waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); + }); + + it.each([ + [StreamingState.Idle], + [StreamingState.WaitingForConfirmation], + [StreamingState.Responding], + ])( + 'renders message in ASB mode when overflowing and state is %s', async (streamingState) => { + mockUseAlternateBuffer.mockReturnValue(true); mockUseOverflowState.mockReturnValue({ overflowingIds: new Set(['1']), } as NonNullable>); @@ -57,8 +75,39 @@ describe('ShowMoreLines', () => { , ); await waitUntilReady(); - expect(lastFrame()).toContain('Press ctrl-o to show more lines'); + expect(lastFrame().toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ); unmount(); }, ); + + it('renders message in ASB mode when isOverflowing prop is true even if internal overflow state is empty', async () => { + mockUseAlternateBuffer.mockReturnValue(true); + mockUseOverflowState.mockReturnValue({ + overflowingIds: new Set(), + } as NonNullable>); + mockUseStreamingContext.mockReturnValue(StreamingState.Idle); + const { lastFrame, waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + expect(lastFrame().toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ); + unmount(); + }); + + it('renders nothing when isOverflowing prop is false even if internal overflow state has IDs', async () => { + mockUseOverflowState.mockReturnValue({ + overflowingIds: new Set(['1']), + } as NonNullable>); + mockUseStreamingContext.mockReturnValue(StreamingState.Idle); + const { lastFrame, waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); + }); }); diff --git a/packages/cli/src/ui/components/ShowMoreLines.tsx b/packages/cli/src/ui/components/ShowMoreLines.tsx index a3317d4dc60..92acd2b29a7 100644 --- a/packages/cli/src/ui/components/ShowMoreLines.tsx +++ b/packages/cli/src/ui/components/ShowMoreLines.tsx @@ -9,31 +9,42 @@ import { useOverflowState } from '../contexts/OverflowContext.js'; import { useStreamingContext } from '../contexts/StreamingContext.js'; import { StreamingState } from '../types.js'; import { theme } from '../semantic-colors.js'; +import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; interface ShowMoreLinesProps { constrainHeight: boolean; + isOverflowing?: boolean; } -export const ShowMoreLines = ({ constrainHeight }: ShowMoreLinesProps) => { +export const ShowMoreLines = ({ + constrainHeight, + isOverflowing: isOverflowingProp, +}: ShowMoreLinesProps) => { + const isAlternateBuffer = useAlternateBuffer(); const overflowState = useOverflowState(); const streamingState = useStreamingContext(); + const isOverflowing = + isOverflowingProp ?? + (overflowState !== undefined && overflowState.overflowingIds.size > 0); + if ( - overflowState === undefined || - overflowState.overflowingIds.size === 0 || + !isAlternateBuffer || + !isOverflowing || !constrainHeight || !( streamingState === StreamingState.Idle || - streamingState === StreamingState.WaitingForConfirmation + streamingState === StreamingState.WaitingForConfirmation || + streamingState === StreamingState.Responding ) ) { return null; } return ( - - - Press ctrl-o to show more lines + + + Press Ctrl+O to show more lines ); diff --git a/packages/cli/src/ui/components/ToastDisplay.test.tsx b/packages/cli/src/ui/components/ToastDisplay.test.tsx index da509992047..f2ef9a287b0 100644 --- a/packages/cli/src/ui/components/ToastDisplay.test.tsx +++ b/packages/cli/src/ui/components/ToastDisplay.test.tsx @@ -35,12 +35,22 @@ describe('ToastDisplay', () => { buffer: { text: '' } as TextBuffer, history: [] as HistoryItem[], queueErrorMessage: null, + showIsExpandableHint: false, }; it('returns false for default state', () => { expect(shouldShowToast(baseState as UIState)).toBe(false); }); + it('returns true when showIsExpandableHint is true', () => { + expect( + shouldShowToast({ + ...baseState, + showIsExpandableHint: true, + } as UIState), + ).toBe(true); + }); + it('returns true when ctrlCPressedOnce is true', () => { expect( shouldShowToast({ ...baseState, ctrlCPressedOnce: true } as UIState), @@ -170,4 +180,22 @@ describe('ToastDisplay', () => { await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); + + it('renders expansion hint when showIsExpandableHint is true', async () => { + const { lastFrame, waitUntilReady } = renderToastDisplay({ + showIsExpandableHint: true, + constrainHeight: true, + }); + await waitUntilReady(); + expect(lastFrame()).toContain('Press Ctrl+O to show more lines'); + }); + + it('renders collapse hint when showIsExpandableHint is true and constrainHeight is false', async () => { + const { lastFrame, waitUntilReady } = renderToastDisplay({ + showIsExpandableHint: true, + constrainHeight: false, + }); + await waitUntilReady(); + expect(lastFrame()).toContain('Press Ctrl+O to collapse lines'); + }); }); diff --git a/packages/cli/src/ui/components/ToastDisplay.tsx b/packages/cli/src/ui/components/ToastDisplay.tsx index 37d2997e336..e3832012195 100644 --- a/packages/cli/src/ui/components/ToastDisplay.tsx +++ b/packages/cli/src/ui/components/ToastDisplay.tsx @@ -17,7 +17,8 @@ export function shouldShowToast(uiState: UIState): boolean { uiState.ctrlDPressedOnce || (uiState.showEscapePrompt && (uiState.buffer.text.length > 0 || uiState.history.length > 0)) || - Boolean(uiState.queueErrorMessage) + Boolean(uiState.queueErrorMessage) || + uiState.showIsExpandableHint ); } @@ -73,5 +74,14 @@ export const ToastDisplay: React.FC = () => { return {uiState.queueErrorMessage}; } + if (uiState.showIsExpandableHint) { + const action = uiState.constrainHeight ? 'show more' : 'collapse'; + return ( + + Press Ctrl+O to {action} lines for the most recent response + + ); + } + return null; }; diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx index 345d00a263f..ab7d080b370 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx @@ -161,7 +161,7 @@ describe('ToolConfirmationQueue', () => { , { config: mockConfig, - useAlternateBuffer: false, + useAlternateBuffer: true, uiState: { terminalWidth: 80, terminalHeight: 20, @@ -173,10 +173,11 @@ describe('ToolConfirmationQueue', () => { await waitUntilReady(); await waitFor(() => - expect(lastFrame()).toContain('Press ctrl-o to show more lines'), + expect(lastFrame()?.toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ), ); expect(lastFrame()).toMatchSnapshot(); - expect(lastFrame()).toContain('Press ctrl-o to show more lines'); unmount(); }); @@ -324,7 +325,7 @@ describe('ToolConfirmationQueue', () => { await waitUntilReady(); const output = lastFrame(); - expect(output).not.toContain('Press ctrl-o to show more lines'); + expect(output).not.toContain('Press CTRL-O to show more lines'); expect(output).toMatchSnapshot(); unmount(); }); diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx index e3c18e0231b..c89c98f8d49 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx @@ -71,13 +71,12 @@ export const ToolConfirmationQueue: React.FC = ({ // - 2 lines for the rounded border // - 2 lines for the Header (text + margin) // - 2 lines for Tool Identity (text + margin) - const availableContentHeight = - constrainHeight && !isAlternateBuffer - ? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4) - : undefined; + const availableContentHeight = constrainHeight + ? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4) + : undefined; - return ( - + const content = ( + <> = ({ /> - + + ); + + return isAlternateBuffer ? ( + /* Shadow the global provider to maintain isolation in ASB mode. */ + {content} + ) : ( + content ); }; diff --git a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap index 3da5a05c0c5..8fb49b8b71f 100644 --- a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap @@ -43,7 +43,6 @@ Tips for getting started: │ ✓ tool1 Description for tool 1 │ │ │ ╰──────────────────────────────────────────────────────────────────────────╯ - ╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ tool2 Description for tool 2 │ │ │ @@ -90,7 +89,6 @@ Tips for getting started: │ ✓ tool1 Description for tool 1 │ │ │ ╰──────────────────────────────────────────────────────────────────────────╯ - ╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ tool2 Description for tool 2 │ │ │ diff --git a/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap index 28929deee56..8d03baaa498 100644 --- a/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap @@ -18,20 +18,8 @@ Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more " `; -exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = ` -" -Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more -" -`; - exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = ` " Spinner Connecting to MCP servers... (1/2) - Waiting for: server2 " `; - -exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = ` -" -Spinner Connecting to MCP servers... (1/2) - Waiting for: server2 -" -`; diff --git a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap index faa759a0502..587ded8f299 100644 --- a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap @@ -27,33 +27,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel " `; -exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 2`] = ` -"Overview - -Add user authentication to the CLI application. - -Implementation Steps - - 1. Create src/auth/AuthService.ts with login/logout methods - 2. Add session storage in src/storage/SessionStore.ts - 3. Update src/commands/index.ts to check auth status - 4. Add tests in src/auth/__tests__/ - -Files to Modify - - - src/index.ts - Add auth middleware - - src/config.ts - Add auth configuration options - - 1. Yes, automatically accept edits - Approves plan and allows tools to run automatically - 2. Yes, manually accept edits - Approves plan but requires confirmation for each tool -● 3. Type your feedback... - -Enter to submit · Esc to cancel -" -`; - exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 1`] = ` "Overview @@ -81,33 +54,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel " `; -exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 2`] = ` -"Overview - -Add user authentication to the CLI application. - -Implementation Steps - - 1. Create src/auth/AuthService.ts with login/logout methods - 2. Add session storage in src/storage/SessionStore.ts - 3. Update src/commands/index.ts to check auth status - 4. Add tests in src/auth/__tests__/ - -Files to Modify - - - src/index.ts - Add auth middleware - - src/config.ts - Add auth configuration options - - 1. Yes, automatically accept edits - Approves plan and allows tools to run automatically - 2. Yes, manually accept edits - Approves plan but requires confirmation for each tool -● 3. Add tests - -Enter to submit · Esc to cancel -" -`; - exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = ` " Error reading plan: File not found " @@ -194,33 +140,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel " `; -exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 2`] = ` -"Overview - -Add user authentication to the CLI application. - -Implementation Steps - - 1. Create src/auth/AuthService.ts with login/logout methods - 2. Add session storage in src/storage/SessionStore.ts - 3. Update src/commands/index.ts to check auth status - 4. Add tests in src/auth/__tests__/ - -Files to Modify - - - src/index.ts - Add auth middleware - - src/config.ts - Add auth configuration options - - 1. Yes, automatically accept edits - Approves plan and allows tools to run automatically - 2. Yes, manually accept edits - Approves plan but requires confirmation for each tool -● 3. Type your feedback... - -Enter to submit · Esc to cancel -" -`; - exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = ` "Overview @@ -248,33 +167,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel " `; -exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 2`] = ` -"Overview - -Add user authentication to the CLI application. - -Implementation Steps - - 1. Create src/auth/AuthService.ts with login/logout methods - 2. Add session storage in src/storage/SessionStore.ts - 3. Update src/commands/index.ts to check auth status - 4. Add tests in src/auth/__tests__/ - -Files to Modify - - - src/index.ts - Add auth middleware - - src/config.ts - Add auth configuration options - - 1. Yes, automatically accept edits - Approves plan and allows tools to run automatically - 2. Yes, manually accept edits - Approves plan but requires confirmation for each tool -● 3. Add tests - -Enter to submit · Esc to cancel -" -`; - exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = ` " Error reading plan: File not found " diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap index 29eda04babb..c7a1d0f48b1 100644 --- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap @@ -6,27 +6,17 @@ AppHeader(full) ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command Running a long command... │ │ │ -│ Line 1 │ -│ Line 2 │ -│ Line 3 │ -│ Line 4 │ -│ Line 5 │ -│ Line 6 │ -│ Line 7 │ -│ Line 8 │ -│ Line 9 │ │ Line 10 │ │ Line 11 │ │ Line 12 │ │ Line 13 │ │ Line 14 │ -│ Line 15 │ -│ Line 16 │ -│ Line 17 │ -│ Line 18 │ -│ Line 19 │ -│ Line 20 │ -│ │ +│ Line 15 █ │ +│ Line 16 █ │ +│ Line 17 █ │ +│ Line 18 █ │ +│ Line 19 █ │ +│ Line 20 █ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ ShowMoreLines " @@ -38,15 +28,11 @@ AppHeader(full) ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command Running a long command... │ │ │ -│ Line 6 │ -│ Line 7 │ -│ Line 8 │ -│ Line 9 ▄ │ -│ Line 10 █ │ -│ Line 11 █ │ -│ Line 12 █ │ -│ Line 13 █ │ -│ Line 14 █ │ +│ Line 10 │ +│ Line 11 │ +│ Line 12 │ +│ Line 13 │ +│ Line 14 │ │ Line 15 █ │ │ Line 16 █ │ │ Line 17 █ │ @@ -63,12 +49,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command Running a long command... │ │ │ -│ Line 6 │ -│ Line 7 │ -│ Line 8 │ -│ Line 9 │ -│ Line 10 │ -│ Line 11 │ +│ ... first 11 lines hidden ... │ │ Line 12 │ │ Line 13 │ │ Line 14 │ @@ -88,6 +69,11 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command Running a long command... │ │ │ +│ Line 1 │ +│ Line 2 │ +│ Line 3 │ +│ Line 4 │ +│ Line 5 │ │ Line 6 │ │ Line 7 │ │ Line 8 │ diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap index 56dcb64d707..b5e013ef487 100644 --- a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap @@ -16,7 +16,6 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai │ 4. No, suggest changes (esc) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────╯ - Press ctrl-o to show more lines " `; @@ -107,7 +106,7 @@ exports[`ToolConfirmationQueue > renders expansion hint when content is long and │ 4. No, suggest changes (esc) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────╯ - Press ctrl-o to show more lines + Press Ctrl+O to show more lines " `; diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx index 3c17a3850f6..0bdf9b65e94 100644 --- a/packages/cli/src/ui/components/messages/GeminiMessage.tsx +++ b/packages/cli/src/ui/components/messages/GeminiMessage.tsx @@ -12,6 +12,7 @@ import { theme } from '../../semantic-colors.js'; import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js'; import { useUIState } from '../../contexts/UIStateContext.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; +import { OverflowProvider } from '../../contexts/OverflowContext.js'; interface GeminiMessageProps { text: string; @@ -31,7 +32,7 @@ export const GeminiMessage: React.FC = ({ const prefixWidth = prefix.length; const isAlternateBuffer = useAlternateBuffer(); - return ( + const content = ( @@ -61,4 +62,11 @@ export const GeminiMessage: React.FC = ({ ); + + return isAlternateBuffer ? ( + /* Shadow the global provider to maintain isolation in ASB mode. */ + {content} + ) : ( + content + ); }; diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index 6359b5b250b..72ce8cec5f1 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -191,7 +191,7 @@ describe('', () => { true, ], [ - 'defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined', + 'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined', undefined, ACTIVE_SHELL_MAX_LINES, false, @@ -219,5 +219,75 @@ describe('', () => { expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines); expect(frame).toMatchSnapshot(); }); + + it('fully expands in standard mode when availableTerminalHeight is undefined', async () => { + const { lastFrame } = renderShell( + { + resultDisplay: LONG_OUTPUT, + renderOutputAsMarkdown: false, + availableTerminalHeight: undefined, + status: CoreToolCallStatus.Executing, + }, + { useAlternateBuffer: false }, + ); + + await waitFor(() => { + const frame = lastFrame(); + // Should show all 100 lines + expect(frame.match(/Line \d+/g)?.length).toBe(100); + }); + }); + + it('fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true', async () => { + const { lastFrame, waitUntilReady } = renderShell( + { + resultDisplay: LONG_OUTPUT, + renderOutputAsMarkdown: false, + availableTerminalHeight: undefined, + status: CoreToolCallStatus.Success, + isExpandable: true, + }, + { + useAlternateBuffer: true, + uiState: { + constrainHeight: false, + }, + }, + ); + + await waitUntilReady(); + await waitFor(() => { + const frame = lastFrame(); + // Should show all 100 lines because constrainHeight is false and isExpandable is true + expect(frame.match(/Line \d+/g)?.length).toBe(100); + }); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false', async () => { + const { lastFrame, waitUntilReady } = renderShell( + { + resultDisplay: LONG_OUTPUT, + renderOutputAsMarkdown: false, + availableTerminalHeight: undefined, + status: CoreToolCallStatus.Success, + isExpandable: false, + }, + { + useAlternateBuffer: true, + uiState: { + constrainHeight: false, + }, + }, + ); + + await waitUntilReady(); + await waitFor(() => { + const frame = lastFrame(); + // Should still be constrained to ACTIVE_SHELL_MAX_LINES (15) because isExpandable is false + expect(frame.match(/Line \d+/g)?.length).toBe(15); + }); + expect(lastFrame()).toMatchSnapshot(); + }); }); }); diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index 50af3bc1e66..54abbc09d3a 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -15,24 +15,21 @@ import { ToolStatusIndicator, ToolInfo, TrailingIndicator, - STATUS_INDICATOR_WIDTH, isThisShellFocusable as checkIsShellFocusable, isThisShellFocused as checkIsShellFocused, useFocusHint, FocusHint, } from './ToolShared.js'; import type { ToolMessageProps } from './ToolMessage.js'; -import { - ACTIVE_SHELL_MAX_LINES, - COMPLETED_SHELL_MAX_LINES, -} from '../../constants.js'; +import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; -import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core'; - import { useUIState } from '../../contexts/UIStateContext.js'; +import { type Config } from '@google/gemini-cli-core'; +import { calculateShellMaxLines } from '../../utils/toolLayoutUtils.js'; export interface ShellToolMessageProps extends ToolMessageProps { config?: Config; + isExpandable?: boolean; } export const ShellToolMessage: React.FC = ({ @@ -61,9 +58,15 @@ export const ShellToolMessage: React.FC = ({ borderColor, borderDimColor, + isExpandable, }) => { - const { activePtyId: activeShellPtyId, embeddedShellFocused } = useUIState(); + const { + activePtyId: activeShellPtyId, + embeddedShellFocused, + constrainHeight, + } = useUIState(); const isAlternateBuffer = useAlternateBuffer(); + const isThisShellFocused = checkIsShellFocused( name, status, @@ -155,59 +158,23 @@ export const ShellToolMessage: React.FC = ({ terminalWidth={terminalWidth} renderOutputAsMarkdown={renderOutputAsMarkdown} hasFocus={isThisShellFocused} - maxLines={getShellMaxLines( + maxLines={calculateShellMaxLines({ status, isAlternateBuffer, isThisShellFocused, availableTerminalHeight, - )} + constrainHeight, + isExpandable, + })} /> {isThisShellFocused && config && ( - - - + )} ); }; - -/** - * Calculates the maximum number of lines to display for shell output. - * - * For completed processes (Success, Error, Canceled), it returns COMPLETED_SHELL_MAX_LINES. - * For active processes, it returns the available terminal height if in alternate buffer mode - * and focused. Otherwise, it returns ACTIVE_SHELL_MAX_LINES. - * - * This function ensures a finite number of lines is always returned to prevent performance issues. - */ -function getShellMaxLines( - status: CoreToolCallStatus, - isAlternateBuffer: boolean, - isThisShellFocused: boolean, - availableTerminalHeight: number | undefined, -): number { - if ( - status === CoreToolCallStatus.Success || - status === CoreToolCallStatus.Error || - status === CoreToolCallStatus.Cancelled - ) { - return COMPLETED_SHELL_MAX_LINES; - } - - if (availableTerminalHeight === undefined) { - return ACTIVE_SHELL_MAX_LINES; - } - - const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2); - - if (isAlternateBuffer && isThisShellFocused) { - return maxLinesBasedOnHeight; - } - - return Math.min(maxLinesBasedOnHeight, ACTIVE_SHELL_MAX_LINES); -} diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index b3e0275a1bd..1ead1503e59 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -6,6 +6,7 @@ import { renderWithProviders } from '../../../test-utils/render.js'; import { describe, it, expect, vi, afterEach } from 'vitest'; +import { act } from 'react'; import { ToolGroupMessage } from './ToolGroupMessage.js'; import type { HistoryItem, @@ -678,4 +679,194 @@ describe('', () => { }, ); }); + + describe('Manual Overflow Detection', () => { + it('detects overflow for string results exceeding available height', async () => { + const toolCalls = [ + createToolCall({ + resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5', + }), + ]; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + config: baseMockConfig, + useAlternateBuffer: true, + uiState: { + constrainHeight: true, + }, + }, + ); + await waitUntilReady(); + expect(lastFrame()?.toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ); + unmount(); + }); + + it('detects overflow for array results exceeding available height', async () => { + // resultDisplay when array is expected to be AnsiLine[] + // AnsiLine is AnsiToken[] + const toolCalls = [ + createToolCall({ + resultDisplay: Array(5).fill([{ text: 'line', fg: 'default' }]), + }), + ]; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + config: baseMockConfig, + useAlternateBuffer: true, + uiState: { + constrainHeight: true, + }, + }, + ); + await waitUntilReady(); + expect(lastFrame()?.toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ); + unmount(); + }); + + it('respects ACTIVE_SHELL_MAX_LINES for focused shell tools', async () => { + const toolCalls = [ + createToolCall({ + name: 'run_shell_command', + status: CoreToolCallStatus.Executing, + ptyId: 1, + resultDisplay: Array(20).fill('line').join('\n'), // 20 lines > 15 (limit) + }), + ]; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + config: baseMockConfig, + useAlternateBuffer: true, + uiState: { + constrainHeight: true, + activePtyId: 1, + embeddedShellFocused: true, + }, + }, + ); + await waitUntilReady(); + expect(lastFrame()?.toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ); + unmount(); + }); + + it('does not show expansion hint when content is within limits', async () => { + const toolCalls = [ + createToolCall({ + resultDisplay: 'small result', + }), + ]; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + config: baseMockConfig, + useAlternateBuffer: true, + uiState: { + constrainHeight: true, + }, + }, + ); + await waitUntilReady(); + expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines'); + unmount(); + }); + + it('hides expansion hint when constrainHeight is false', async () => { + const toolCalls = [ + createToolCall({ + resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5', + }), + ]; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + config: baseMockConfig, + useAlternateBuffer: true, + uiState: { + constrainHeight: false, + }, + }, + ); + await waitUntilReady(); + expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines'); + unmount(); + }); + + it('isolates overflow hint in ASB mode (ignores global overflow state)', async () => { + // In this test, the tool output is SHORT (no local overflow). + // We will inject a dummy ID into the global overflow state. + // ToolGroupMessage should still NOT show the hint because it calculates + // overflow locally and passes it as a prop. + const toolCalls = [ + createToolCall({ + resultDisplay: 'short result', + }), + ]; + const { lastFrame, unmount, waitUntilReady, capturedOverflowActions } = + renderWithProviders( + , + { + config: baseMockConfig, + useAlternateBuffer: true, + uiState: { + constrainHeight: true, + }, + }, + ); + await waitUntilReady(); + + // Manually trigger a global overflow + act(() => { + expect(capturedOverflowActions).toBeDefined(); + capturedOverflowActions!.addOverflowingId('unrelated-global-id'); + }); + + // The hint should NOT appear because ToolGroupMessage is isolated by its prop logic + expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines'); + unmount(); + }); + }); }); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index f4e1c200db5..3c3dcf56d32 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -17,10 +17,15 @@ import { ToolMessage } from './ToolMessage.js'; import { ShellToolMessage } from './ShellToolMessage.js'; import { theme } from '../../semantic-colors.js'; import { useConfig } from '../../contexts/ConfigContext.js'; -import { isShellTool } from './ToolShared.js'; +import { isShellTool, isThisShellFocused } from './ToolShared.js'; import { shouldHideToolCall } from '@google/gemini-cli-core'; import { ShowMoreLines } from '../ShowMoreLines.js'; import { useUIState } from '../../contexts/UIStateContext.js'; +import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; +import { + calculateShellMaxLines, + calculateToolContentMaxLines, +} from '../../utils/toolLayoutUtils.js'; import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js'; interface ToolGroupMessageProps { @@ -31,6 +36,7 @@ interface ToolGroupMessageProps { onShellInputSubmit?: (input: string) => void; borderTop?: boolean; borderBottom?: boolean; + isExpandable?: boolean; } // Main component renders the border and maps the tools using ToolMessage @@ -43,6 +49,7 @@ export const ToolGroupMessage: React.FC = ({ terminalWidth, borderTop: borderTopOverride, borderBottom: borderBottomOverride, + isExpandable, }) => { // Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations). const toolCalls = useMemo( @@ -67,6 +74,7 @@ export const ToolGroupMessage: React.FC = ({ backgroundShells, pendingHistoryItems, } = useUIState(); + const isAlternateBuffer = useAlternateBuffer(); const { borderColor, borderDimColor } = useMemo( () => @@ -106,14 +114,6 @@ export const ToolGroupMessage: React.FC = ({ const staticHeight = /* border */ 2; - // If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools), - // only render if we need to close a border from previous - // tool groups. borderBottomOverride=true means we must render the closing border; - // undefined or false means there's nothing to display. - if (visibleToolCalls.length === 0 && borderBottomOverride !== true) { - return null; - } - let countToolCallsWithResults = 0; for (const tool of visibleToolCalls) { if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') { @@ -134,21 +134,91 @@ export const ToolGroupMessage: React.FC = ({ const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN; - return ( - // This box doesn't have a border even though it conceptually does because - // we need to allow the sticky headers to render the borders themselves so - // that the top border can be sticky. + /* + * ToolGroupMessage calculates its own overflow state locally and passes + * it as a prop to ShowMoreLines. This isolates it from global overflow + * reports in ASB mode, while allowing it to contribute to the global + * 'Toast' hint in Standard mode. + * + * Because of this prop-based isolation and the explicit mode-checks in + * AppContainer, we do not need to shadow the OverflowProvider here. + */ + const hasOverflow = useMemo(() => { + if (!availableTerminalHeightPerToolMessage) return false; + return visibleToolCalls.some((tool) => { + const isShellToolCall = isShellTool(tool.name); + const isFocused = isThisShellFocused( + tool.name, + tool.status, + tool.ptyId, + activePtyId, + embeddedShellFocused, + ); + + let maxLines: number | undefined; + + if (isShellToolCall) { + maxLines = calculateShellMaxLines({ + status: tool.status, + isAlternateBuffer, + isThisShellFocused: isFocused, + availableTerminalHeight: availableTerminalHeightPerToolMessage, + constrainHeight, + isExpandable, + }); + } + + // Standard tools and Shell tools both eventually use ToolResultDisplay's logic. + // ToolResultDisplay uses calculateToolContentMaxLines to find the final line budget. + const contentMaxLines = calculateToolContentMaxLines({ + availableTerminalHeight: availableTerminalHeightPerToolMessage, + isAlternateBuffer, + maxLinesLimit: maxLines, + }); + + if (!contentMaxLines) return false; + + if (typeof tool.resultDisplay === 'string') { + const text = tool.resultDisplay; + const hasTrailingNewline = text.endsWith('\n'); + const contentText = hasTrailingNewline ? text.slice(0, -1) : text; + const lineCount = contentText.split('\n').length; + return lineCount > contentMaxLines; + } + if (Array.isArray(tool.resultDisplay)) { + return tool.resultDisplay.length > contentMaxLines; + } + return false; + }); + }, [ + visibleToolCalls, + availableTerminalHeightPerToolMessage, + activePtyId, + embeddedShellFocused, + isAlternateBuffer, + constrainHeight, + isExpandable, + ]); + + // If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools), + // only render if we need to close a border from previous + // tool groups. borderBottomOverride=true means we must render the closing border; + // undefined or false means there's nothing to display. + if (visibleToolCalls.length === 0 && borderBottomOverride !== true) { + return null; + } + + const content = ( {visibleToolCalls.map((tool, index) => { const isFirst = index === 0; @@ -165,6 +235,7 @@ export const ToolGroupMessage: React.FC = ({ : isFirst, borderColor, borderDimColor, + isExpandable, }; return ( @@ -179,34 +250,34 @@ export const ToolGroupMessage: React.FC = ({ ) : ( )} - - {tool.outputFile && ( + {tool.outputFile && ( + Output too long and was saved to: {tool.outputFile} - )} - + + )} ); })} { /* - We have to keep the bottom border separate so it doesn't get - drawn over by the sticky header directly inside it. - */ + We have to keep the bottom border separate so it doesn't get + drawn over by the sticky header directly inside it. + */ (visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && ( = ({ ) } {(borderBottomOverride ?? true) && visibleToolCalls.length > 0 && ( - + )} ); + + return content; }; diff --git a/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx b/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx new file mode 100644 index 00000000000..f7629945d9c --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { ToolGroupMessage } from './ToolGroupMessage.js'; +import { renderWithProviders } from '../../../test-utils/render.js'; +import { StreamingState, type IndividualToolCallDisplay } from '../../types.js'; +import { OverflowProvider } from '../../contexts/OverflowContext.js'; +import { waitFor } from '../../../test-utils/async.js'; +import { CoreToolCallStatus } from '@google/gemini-cli-core'; + +describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay synchronization', () => { + it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Alternate Buffer (ASB) mode', async () => { + /** + * Logic: + * 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool. + * 2. ASB mode reserves 1 + 6 = 7 lines. + * 3. Line budget = 10 - 7 = 3 lines. + * 4. 5 lines of output > 3 lines budget => hasOverflow should be TRUE. + */ + + const lines = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`); + const resultDisplay = lines.join('\n'); + + const toolCalls: IndividualToolCallDisplay[] = [ + { + callId: 'call-1', + name: 'test-tool', + description: 'a test tool', + status: CoreToolCallStatus.Success, + resultDisplay, + confirmationDetails: undefined, + }, + ]; + + const { lastFrame } = renderWithProviders( + + + , + { + uiState: { + streamingState: StreamingState.Idle, + constrainHeight: true, + }, + useAlternateBuffer: true, + }, + ); + + // In ASB mode, the hint should appear because hasOverflow is now correctly calculated. + await waitFor(() => + expect(lastFrame()?.toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ), + ); + }); + + it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Standard mode', async () => { + /** + * Logic: + * 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool. + * 2. Standard mode reserves 1 + 2 = 3 lines. + * 3. Line budget = 10 - 3 = 7 lines. + * 4. 9 lines of output > 7 lines budget => hasOverflow should be TRUE. + */ + + const lines = Array.from({ length: 9 }, (_, i) => `line ${i + 1}`); + const resultDisplay = lines.join('\n'); + + const toolCalls: IndividualToolCallDisplay[] = [ + { + callId: 'call-1', + name: 'test-tool', + description: 'a test tool', + status: CoreToolCallStatus.Success, + resultDisplay, + confirmationDetails: undefined, + }, + ]; + + const { lastFrame } = renderWithProviders( + + + , + { + uiState: { + streamingState: StreamingState.Idle, + constrainHeight: true, + }, + useAlternateBuffer: false, + }, + ); + + // Verify truncation is occurring (standard mode uses MaxSizedBox) + await waitFor(() => expect(lastFrame()).toContain('hidden ...')); + + // In Standard mode, ToolGroupMessage calculates hasOverflow correctly now. + // While Standard mode doesn't render the inline hint (ShowMoreLines returns null), + // the logic inside ToolGroupMessage is now synchronized. + }); +}); diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx index c24fb8e58b3..f7d158d68cf 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx @@ -277,21 +277,47 @@ describe('ToolResultDisplay', () => { inverse: false, }, ], + [ + { + text: 'Line 4', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + [ + { + text: 'Line 5', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], ]; const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 1'); - expect(output).toContain('Line 2'); - expect(output).toContain('Line 3'); + expect(output).not.toContain('Line 2'); + expect(output).not.toContain('Line 3'); + expect(output).toContain('Line 4'); + expect(output).toContain('Line 5'); unmount(); }); diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 61f1540017e..8e0fc4442a5 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -19,10 +19,7 @@ import { Scrollable } from '../shared/Scrollable.js'; import { ScrollableList } from '../shared/ScrollableList.js'; import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js'; import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js'; - -const STATIC_HEIGHT = 1; -const RESERVED_LINE_COUNT = 6; // for tool name, status, padding, and 'ShowMoreLines' hint -const MIN_LINES_SHOWN = 2; // show at least this many lines +import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js'; // Large threshold to ensure we don't cause performance issues for very large // outputs that will get truncated further MaxSizedBox anyway. @@ -53,16 +50,11 @@ export const ToolResultDisplay: React.FC = ({ const { renderMarkdown } = useUIState(); const isAlternateBuffer = useAlternateBuffer(); - let availableHeight = availableTerminalHeight - ? Math.max( - availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT, - MIN_LINES_SHOWN + 1, // enforce minimum lines shown - ) - : undefined; - - if (maxLines && availableHeight) { - availableHeight = Math.min(availableHeight, maxLines); - } + const availableHeight = calculateToolContentMaxLines({ + availableTerminalHeight, + isAlternateBuffer, + maxLinesLimit: maxLines, + }); const combinedPaddingAndBorderWidth = 4; const childWidth = terminalWidth - combinedPaddingAndBorderWidth; @@ -81,7 +73,8 @@ export const ToolResultDisplay: React.FC = ({ [], ); - const truncatedResultDisplay = React.useMemo(() => { + const { truncatedResultDisplay, hiddenLinesCount } = React.useMemo(() => { + let hiddenLines = 0; // Only truncate string output if not in alternate buffer mode to ensure // we can scroll through the full output. if (typeof resultDisplay === 'string' && !isAlternateBuffer) { @@ -94,14 +87,29 @@ export const ToolResultDisplay: React.FC = ({ const contentText = hasTrailingNewline ? text.slice(0, -1) : text; const lines = contentText.split('\n'); if (lines.length > maxLines) { + // We will have a label from MaxSizedBox. Reserve space for it. + const targetLines = Math.max(1, maxLines - 1); + hiddenLines = lines.length - targetLines; text = - lines.slice(-maxLines).join('\n') + + lines.slice(-targetLines).join('\n') + (hasTrailingNewline ? '\n' : ''); } } - return text; + return { truncatedResultDisplay: text, hiddenLinesCount: hiddenLines }; + } + + if (Array.isArray(resultDisplay) && !isAlternateBuffer && maxLines) { + if (resultDisplay.length > maxLines) { + // We will have a label from MaxSizedBox. Reserve space for it. + const targetLines = Math.max(1, maxLines - 1); + return { + truncatedResultDisplay: resultDisplay.slice(-targetLines), + hiddenLinesCount: resultDisplay.length - targetLines, + }; + } } - return resultDisplay; + + return { truncatedResultDisplay: resultDisplay, hiddenLinesCount: 0 }; }, [resultDisplay, isAlternateBuffer, maxLines]); if (!truncatedResultDisplay) return null; @@ -229,7 +237,11 @@ export const ToolResultDisplay: React.FC = ({ return ( - + {content} diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx index dd3184a19ca..a196b8d9897 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx @@ -39,6 +39,7 @@ describe('ToolResultDisplay Overflow', () => { toolCalls={toolCalls} availableTerminalHeight={15} // Small height to force overflow terminalWidth={80} + isExpandable={true} /> , { @@ -46,26 +47,28 @@ describe('ToolResultDisplay Overflow', () => { streamingState: StreamingState.Idle, constrainHeight: true, }, - useAlternateBuffer: false, + useAlternateBuffer: true, }, ); // ResizeObserver might take a tick await waitFor(() => - expect(lastFrame()).toContain('Press ctrl-o to show more lines'), + expect(lastFrame()?.toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ), ); const frame = lastFrame(); expect(frame).toBeDefined(); if (frame) { - expect(frame).toContain('Press ctrl-o to show more lines'); + expect(frame.toLowerCase()).toContain('press ctrl+o to show more lines'); // Ensure it's AFTER the bottom border const linesOfOutput = frame.split('\n'); const bottomBorderIndex = linesOfOutput.findLastIndex((l) => l.includes('╰─'), ); const hintIndex = linesOfOutput.findIndex((l) => - l.includes('Press ctrl-o to show more lines'), + l.toLowerCase().includes('press ctrl+o to show more lines'), ); expect(hintIndex).toBeGreaterThan(bottomBorderIndex); expect(frame).toMatchSnapshot(); diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap index 7e3d34a5773..0d34c7e49d6 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[` > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined 1`] = ` +exports[` > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command A shell command │ │ │ @@ -22,6 +22,113 @@ exports[` > Height Constraints > defaults to ACTIVE_SHELL_MA " `; +exports[` > Height Constraints > fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ✓ Shell Command A shell command │ +│ │ +│ Line 1 │ +│ Line 2 │ +│ Line 3 │ +│ Line 4 │ +│ Line 5 │ +│ Line 6 │ +│ Line 7 │ +│ Line 8 │ +│ Line 9 │ +│ Line 10 │ +│ Line 11 │ +│ Line 12 │ +│ Line 13 │ +│ Line 14 │ +│ Line 15 │ +│ Line 16 │ +│ Line 17 │ +│ Line 18 │ +│ Line 19 │ +│ Line 20 │ +│ Line 21 │ +│ Line 22 │ +│ Line 23 │ +│ Line 24 │ +│ Line 25 │ +│ Line 26 │ +│ Line 27 │ +│ Line 28 │ +│ Line 29 │ +│ Line 30 │ +│ Line 31 │ +│ Line 32 │ +│ Line 33 │ +│ Line 34 │ +│ Line 35 │ +│ Line 36 │ +│ Line 37 │ +│ Line 38 │ +│ Line 39 │ +│ Line 40 │ +│ Line 41 │ +│ Line 42 │ +│ Line 43 │ +│ Line 44 │ +│ Line 45 │ +│ Line 46 │ +│ Line 47 │ +│ Line 48 │ +│ Line 49 │ +│ Line 50 │ +│ Line 51 │ +│ Line 52 │ +│ Line 53 │ +│ Line 54 │ +│ Line 55 │ +│ Line 56 │ +│ Line 57 │ +│ Line 58 │ +│ Line 59 │ +│ Line 60 │ +│ Line 61 │ +│ Line 62 │ +│ Line 63 │ +│ Line 64 │ +│ Line 65 │ +│ Line 66 │ +│ Line 67 │ +│ Line 68 │ +│ Line 69 │ +│ Line 70 │ +│ Line 71 │ +│ Line 72 │ +│ Line 73 │ +│ Line 74 │ +│ Line 75 │ +│ Line 76 │ +│ Line 77 │ +│ Line 78 │ +│ Line 79 │ +│ Line 80 │ +│ Line 81 │ +│ Line 82 │ +│ Line 83 │ +│ Line 84 │ +│ Line 85 │ +│ Line 86 │ +│ Line 87 │ +│ Line 88 │ +│ Line 89 │ +│ Line 90 │ +│ Line 91 │ +│ Line 92 │ +│ Line 93 │ +│ Line 94 │ +│ Line 95 │ +│ Line 96 │ +│ Line 97 │ +│ Line 98 │ +│ Line 99 │ +│ Line 100 │ +" +`; + exports[` > Height Constraints > respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command A shell command │ @@ -37,6 +144,28 @@ exports[` > Height Constraints > respects availableTerminalH " `; +exports[` > Height Constraints > stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ✓ Shell Command A shell command │ +│ │ +│ Line 86 │ +│ Line 87 │ +│ Line 88 │ +│ Line 89 │ +│ Line 90 │ +│ Line 91 │ +│ Line 92 │ +│ Line 93 │ +│ Line 94 │ +│ Line 95 │ +│ Line 96 │ +│ Line 97 │ +│ Line 98 ▄ │ +│ Line 99 █ │ +│ Line 100 █ │ +" +`; + exports[` > Height Constraints > uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command A shell command │ @@ -161,7 +290,6 @@ exports[` > Height Constraints > uses full availableTerminal │ Line 98 █ │ │ Line 99 █ │ │ Line 100 █ │ -│ │ " `; @@ -170,7 +298,6 @@ exports[` > Snapshots > renders in Alternate Buffer mode whi │ ⊷ Shell Command A shell command (Shift+Tab to unfocus) │ │ │ │ Test result │ -│ │ " `; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap index c8a97070046..6adcb80a5cc 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap @@ -55,13 +55,13 @@ exports[` > Golden Snapshots > renders header when scrolled "╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ tool-1 Description 1. This is a long description that will need to b… │ │──────────────────────────────────────────────────────────────────────────│ -│ │ ▄ +│ line5 │ █ +│ │ █ │ ✓ tool-2 Description 2 │ █ │ │ █ │ line1 │ █ │ line2 │ █ ╰──────────────────────────────────────────────────────────────────────────╯ █ - █ " `; @@ -111,12 +111,12 @@ exports[` > Golden Snapshots > renders tool call with output `; exports[` > Golden Snapshots > renders two tool groups where only the last line of the previous group is visible 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────╮ +"╰──────────────────────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ tool-2 Description 2 │ -│ │ -│ line1 │ ▄ +│ │ ▄ +│ line1 │ █ ╰──────────────────────────────────────────────────────────────────────────╯ █ - █ " `; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap index 1d9b58f0ce4..d1e4b16d2f0 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap @@ -37,7 +37,11 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp `; exports[`ToolResultDisplay > truncates very long string results 1`] = ` -"... first 252 lines hidden ... +"... first 248 lines hidden ... +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap index 3854b291db4..aab4b690a16 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap @@ -4,13 +4,13 @@ exports[`ToolResultDisplay Overflow > should display "press ctrl-o" hint when co "╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ test-tool a test tool │ │ │ -│ ... first 45 lines hidden ... │ +│ line 45 │ │ line 46 │ │ line 47 │ │ line 48 │ │ line 49 │ -│ line 50 │ +│ line 50 █ │ ╰──────────────────────────────────────────────────────────────────────────╯ - Press ctrl-o to show more lines + Press Ctrl+O to show more lines " `; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap index 66ca527b4b5..dda93c1c21f 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap @@ -2,7 +2,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 1`] = ` "╭────────────────────────────────────────────────────────────────────────╮ █ -│ ✓ Shell Command Description for Shell Command │ ▀ +│ ✓ Shell Command Description for Shell Command │ █ │ │ │ shell-01 │ │ shell-02 │ @@ -11,7 +11,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage i exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 2`] = ` "╭────────────────────────────────────────────────────────────────────────╮ -│ ✓ Shell Command Description for Shell Command │ +│ ✓ Shell Command Description for Shell Command │ ▄ │────────────────────────────────────────────────────────────────────────│ █ │ shell-06 │ ▀ │ shell-07 │ diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index 197711f82f2..93a3198ca8d 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -33,6 +33,7 @@ export const WARNING_PROMPT_DURATION_MS = 3000; export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000; export const SHELL_SILENT_WORKING_TITLE_DELAY_MS = 120000; +export const EXPAND_HINT_DURATION_MS = 5000; export const DEFAULT_BACKGROUND_OPACITY = 0.16; export const DEFAULT_INPUT_BACKGROUND_OPACITY = 0.24; diff --git a/packages/cli/src/ui/contexts/OverflowContext.tsx b/packages/cli/src/ui/contexts/OverflowContext.tsx index 9d8b78d4c7f..cee02090b6a 100644 --- a/packages/cli/src/ui/contexts/OverflowContext.tsx +++ b/packages/cli/src/ui/contexts/OverflowContext.tsx @@ -13,13 +13,14 @@ import { useMemo, } from 'react'; -interface OverflowState { +export interface OverflowState { overflowingIds: ReadonlySet; } -interface OverflowActions { +export interface OverflowActions { addOverflowingId: (id: string) => void; removeOverflowingId: (id: string) => void; + reset: () => void; } const OverflowStateContext = createContext( @@ -63,6 +64,10 @@ export const OverflowProvider: React.FC<{ children: React.ReactNode }> = ({ }); }, []); + const reset = useCallback(() => { + setOverflowingIds(new Set()); + }, []); + const stateValue = useMemo( () => ({ overflowingIds, @@ -74,8 +79,9 @@ export const OverflowProvider: React.FC<{ children: React.ReactNode }> = ({ () => ({ addOverflowingId, removeOverflowingId, + reset, }), - [addOverflowingId, removeOverflowingId], + [addOverflowingId, removeOverflowingId, reset], ); return ( diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index a1c63759e93..9fb2852361e 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -182,6 +182,7 @@ export interface UIState { isBackgroundShellListOpen: boolean; adminSettingsChanged: boolean; newAgents: AgentDefinition[] | null; + showIsExpandableHint: boolean; hintMode: boolean; hintBuffer: string; transientMessage: { diff --git a/packages/cli/src/ui/utils/CodeColorizer.tsx b/packages/cli/src/ui/utils/CodeColorizer.tsx index 1034e7372e4..56e34eefa4f 100644 --- a/packages/cli/src/ui/utils/CodeColorizer.tsx +++ b/packages/cli/src/ui/utils/CodeColorizer.tsx @@ -22,7 +22,6 @@ import { } from '../components/shared/MaxSizedBox.js'; import type { LoadedSettings } from '../../config/settings.js'; import { debugLogger } from '@google/gemini-cli-core'; -import { isAlternateBufferEnabled } from '../hooks/useAlternateBuffer.js'; // Configure theming and parsing utilities. const lowlight = createLowlight(common); @@ -152,7 +151,6 @@ export function colorizeCode({ ? false : settings.merged.ui.showLineNumbers; - const useMaxSizedBox = !isAlternateBufferEnabled(settings); try { // Render the HAST tree using the adapted theme // Apply the theme's default foreground color to the top-level Text element @@ -162,7 +160,7 @@ export function colorizeCode({ let hiddenLinesCount = 0; // Optimization to avoid highlighting lines that cannot possibly be displayed. - if (availableHeight !== undefined && useMaxSizedBox) { + if (availableHeight !== undefined) { availableHeight = Math.max(availableHeight, MINIMUM_MAX_HEIGHT); if (lines.length > availableHeight) { const sliceIndex = lines.length - availableHeight; @@ -200,7 +198,7 @@ export function colorizeCode({ ); }); - if (useMaxSizedBox) { + if (availableHeight !== undefined) { return ( )); - if (useMaxSizedBox) { + if (availableHeight !== undefined) { return (