From 8956d77da40914e51a2789c05c7e902cca821c4d Mon Sep 17 00:00:00 2001 From: jacob314 Date: Tue, 10 Mar 2026 15:02:50 -0700 Subject: [PATCH] fix(ui): use centralized queue for all transient hints and warnings This commit properly implements the transient message queue in AppContainer, fixing flickering issues caused when the UI exceeds terminal height in legacy non-alternate buffer mode. By introducing useLegacyNonAlternateBufferMode, the timer for transient messages (like the markdown toggle and Ctrl+O overflow hint) pauses when an overflow happens in standard mode. This ensures hints remain on screen without causing infinite loops or terminal jumping. Additionally: - Consolidates older scattered state (queueErrorMessage, showEscapePrompt, etc) into the single transientMessageQueue. - Standardizes transient message payloads with a uniform format. - Extensively updates tests to handle these context changes correctly. Fixes #21824 --- packages/cli/src/test-utils/render.tsx | 1 - packages/cli/src/ui/App.test.tsx | 13 +- packages/cli/src/ui/AppContainer.test.tsx | 61 +++-- packages/cli/src/ui/AppContainer.tsx | 249 ++++++++++++------ .../cli/src/ui/components/Composer.test.tsx | 34 +-- packages/cli/src/ui/components/Composer.tsx | 1 - .../src/ui/components/ExitWarning.test.tsx | 30 ++- .../cli/src/ui/components/ExitWarning.tsx | 30 ++- .../src/ui/components/ToastDisplay.test.tsx | 131 ++------- .../cli/src/ui/components/ToastDisplay.tsx | 62 ++--- .../__snapshots__/ToastDisplay.test.tsx.snap | 22 +- .../cli/src/ui/contexts/UIActionsContext.tsx | 1 - .../cli/src/ui/contexts/UIStateContext.tsx | 9 +- .../cli/src/ui/hooks/useAlternateBuffer.ts | 2 +- packages/cli/src/ui/hooks/useTimedMessage.ts | 47 ++-- packages/cli/src/utils/events.ts | 3 + 16 files changed, 316 insertions(+), 380 deletions(-) diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 39425af171..e6452d6aba 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -606,7 +606,6 @@ const mockUIActions: UIActions = { handleFolderTrustSelect: vi.fn(), setIsPolicyUpdateDialogOpen: vi.fn(), setConstrainHeight: vi.fn(), - onEscapePromptChange: vi.fn(), refreshStatic: vi.fn(), handleFinalSubmit: vi.fn(), handleClearScreen: vi.fn(), diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index d96bfe3071..3df6fb1c01 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -9,6 +9,7 @@ import type React from 'react'; import { renderWithProviders } from '../test-utils/render.js'; import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink'; import { App } from './App.js'; +import { TransientMessageType } from '../utils/events.js'; import { type UIState } from './contexts/UIStateContext.js'; import { StreamingState } from './types.js'; import { makeFakeConfig, CoreToolCallStatus } from '@google/gemini-cli-core'; @@ -170,16 +171,16 @@ describe('App', () => { }); it.each([ - { key: 'C', stateKey: 'ctrlCPressedOnce' }, - { key: 'D', stateKey: 'ctrlDPressedOnce' }, + { key: 'C' }, + { key: 'D' }, ])( - 'should show Ctrl+$key exit prompt when dialogs are visible and $stateKey is true', - async ({ key, stateKey }) => { + 'should show Ctrl+$key exit prompt when dialogs are visible and warning is present', + async ({ key }) => { const uiState = { ...mockUIState, dialogsVisible: true, - [stateKey]: true, - } as UIState; + transientMessage: { message: `Press Ctrl+${key} again to exit.`, type: TransientMessageType.Warning }, + } as unknown as UIState; const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 0b6eaa037b..52183296ae 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -192,11 +192,18 @@ vi.mock('../utils/terminalNotifications.js', () => ({ vi.mock('./hooks/useTerminalTheme.js', () => ({ useTerminalTheme: vi.fn(), })); +vi.mock('./hooks/useAlternateBuffer.js', () => ({ + useAlternateBuffer: vi.fn().mockReturnValue(false), + useLegacyNonAlternateBufferMode: vi.fn().mockReturnValue(false), + isAlternateBufferEnabled: vi.fn().mockReturnValue(false), +})); + import { useHookDisplayState } from './hooks/useHookDisplayState.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js'; import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; import { useFocus } from './hooks/useFocus.js'; +import { TransientMessageType } from '../utils/events.js'; // Mock external utilities vi.mock('../utils/events.js'); @@ -2140,19 +2147,19 @@ describe('AppContainer State Management', () => { vi.advanceTimersByTime(0); }); - expect(capturedUIState.queueErrorMessage).toBeNull(); + expect(capturedUIState.transientMessage).toBeNull(); act(() => { capturedUIActions.setQueueErrorMessage('Test error'); }); rerender(getAppContainer()); - expect(capturedUIState.queueErrorMessage).toBe('Test error'); + await waitFor(() => expect(capturedUIState.transientMessage?.message).toBe('Test error')); act(() => { vi.advanceTimersByTime(3000); }); rerender(getAppContainer()); - expect(capturedUIState.queueErrorMessage).toBeNull(); + expect(capturedUIState.transientMessage).toBeNull(); unmount(); }); @@ -2166,7 +2173,7 @@ describe('AppContainer State Management', () => { capturedUIActions.setQueueErrorMessage('First error'); }); rerender(getAppContainer()); - expect(capturedUIState.queueErrorMessage).toBe('First error'); + await waitFor(() => expect(capturedUIState.transientMessage?.message).toBe('First error')); act(() => { vi.advanceTimersByTime(1500); @@ -2176,20 +2183,20 @@ describe('AppContainer State Management', () => { capturedUIActions.setQueueErrorMessage('Second error'); }); rerender(getAppContainer()); - expect(capturedUIState.queueErrorMessage).toBe('Second error'); + await waitFor(() => expect(capturedUIState.transientMessage?.message).toBe('Second error')); act(() => { vi.advanceTimersByTime(2000); }); rerender(getAppContainer()); - expect(capturedUIState.queueErrorMessage).toBe('Second error'); + await waitFor(() => expect(capturedUIState.transientMessage?.message).toBe('Second error')); // 5. Advance time past the 3 second timeout from the second message act(() => { vi.advanceTimersByTime(1000); }); rerender(getAppContainer()); - expect(capturedUIState.queueErrorMessage).toBeNull(); + expect(capturedUIState.transientMessage).toBeNull(); unmount(); }); }); @@ -3389,21 +3396,21 @@ describe('AppContainer State Management', () => { await waitFor(() => { // Should show hint because we are in Standard Mode (default settings) and have overflow - expect(capturedUIState.showIsExpandableHint).toBe(true); + expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent); }); // Advance just before the timeout act(() => { vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100); }); - expect(capturedUIState.showIsExpandableHint).toBe(true); + expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent); // Advance to hit the timeout mark act(() => { vi.advanceTimersByTime(100); }); await waitFor(() => { - expect(capturedUIState.showIsExpandableHint).toBe(false); + expect(capturedUIState.transientMessage).toBeNull(); }); unmount!(); @@ -3423,14 +3430,14 @@ describe('AppContainer State Management', () => { }); await waitFor(() => { - expect(capturedUIState.showIsExpandableHint).toBe(true); + expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent); }); // 2. Advance half the duration act(() => { vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); }); - expect(capturedUIState.showIsExpandableHint).toBe(true); + expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent); // 3. Trigger second overflow (this should reset the timer) act(() => { @@ -3444,7 +3451,7 @@ describe('AppContainer State Management', () => { }); await waitFor(() => { - expect(capturedUIState.showIsExpandableHint).toBe(true); + expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent); }); // 4. Advance enough that the ORIGINAL timer would have expired @@ -3453,14 +3460,14 @@ describe('AppContainer State Management', () => { vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 100 - 1); }); // The hint should STILL be visible because the timer reset at step 3 - expect(capturedUIState.showIsExpandableHint).toBe(true); + expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent); // 5. Advance to the end of the NEW timer act(() => { vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 100); }); await waitFor(() => { - expect(capturedUIState.showIsExpandableHint).toBe(false); + expect(capturedUIState.transientMessage).toBeNull(); }); unmount!(); @@ -3485,14 +3492,14 @@ describe('AppContainer State Management', () => { }); await waitFor(() => { - expect(capturedUIState.showIsExpandableHint).toBe(true); + expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent); }); // Advance half the duration act(() => { vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); }); - expect(capturedUIState.showIsExpandableHint).toBe(true); + expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent); // Simulate Ctrl+O act(() => { @@ -3510,7 +3517,7 @@ describe('AppContainer State Management', () => { }); // We expect it to still be true because Ctrl+O should have reset the timer - expect(capturedUIState.showIsExpandableHint).toBe(true); + expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent); // Advance remaining time to reach the new timeout act(() => { @@ -3518,7 +3525,7 @@ describe('AppContainer State Management', () => { }); await waitFor(() => { - expect(capturedUIState.showIsExpandableHint).toBe(false); + expect(capturedUIState.transientMessage).toBeNull(); }); unmount!(); @@ -3543,14 +3550,14 @@ describe('AppContainer State Management', () => { }); await waitFor(() => { - expect(capturedUIState.showIsExpandableHint).toBe(true); + expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent); }); // Advance half the duration act(() => { vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); }); - expect(capturedUIState.showIsExpandableHint).toBe(true); + expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent); // First toggle 'on' (expanded) act(() => { @@ -3564,7 +3571,7 @@ describe('AppContainer State Management', () => { act(() => { vi.advanceTimersByTime(1000); }); - expect(capturedUIState.showIsExpandableHint).toBe(true); + expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent); // Second toggle 'off' (collapsed) act(() => { @@ -3578,7 +3585,7 @@ describe('AppContainer State Management', () => { act(() => { vi.advanceTimersByTime(1000); }); - expect(capturedUIState.showIsExpandableHint).toBe(true); + expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent); // Third toggle 'on' (expanded) act(() => { @@ -3593,7 +3600,7 @@ describe('AppContainer State Management', () => { act(() => { vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100); }); - expect(capturedUIState.showIsExpandableHint).toBe(true); + expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent); // Wait 0.1s more to hit exactly the timeout since the last toggle. // It should hide now. @@ -3601,7 +3608,7 @@ describe('AppContainer State Management', () => { vi.advanceTimersByTime(100); }); await waitFor(() => { - expect(capturedUIState.showIsExpandableHint).toBe(false); + expect(capturedUIState.transientMessage).toBeNull(); }); unmount!(); @@ -3635,9 +3642,9 @@ describe('AppContainer State Management', () => { capturedOverflowActions.addOverflowingId('test-id'); }); - // Should NOW show hint because we are in Alternate Buffer Mode + // Should NOT show hint because we are in Alternate Buffer Mode await waitFor(() => { - expect(capturedUIState.showIsExpandableHint).toBe(true); + expect(capturedUIState.transientMessage).toBeNull(); }); unmount!(); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 67f2d5dd84..1755ac134a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -4,6 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { useLegacyNonAlternateBufferMode } from './hooks/useAlternateBuffer.js'; +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import { useMemo, useState, @@ -13,6 +20,7 @@ import { useLayoutEffect, } from 'react'; import { + Box, type DOMElement, measureElement, useApp, @@ -120,12 +128,18 @@ import { useFocus } from './hooks/useFocus.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; import { KeypressPriority } from './contexts/KeypressContext.js'; import { keyMatchers, Command } from './keyMatchers.js'; +import { formatCommand } from './utils/keybindingUtils.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; import { useIdeTrustListener } from './hooks/useIdeTrustListener.js'; import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js'; -import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js'; +import { + appEvents, + AppEvent, + TransientMessageType, + type TransientMessagePayload, +} from '../utils/events.js'; import { type UpdateObject } from './utils/updateCheck.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; @@ -230,6 +244,89 @@ export const AppContainer = (props: AppContainerProps) => { useMemoryMonitor(historyManager); const isAlternateBuffer = config.getUseAlternateBuffer(); + + const rootUiRef = useRef(null); + const isLegacyNonAlternateBufferMode = + useLegacyNonAlternateBufferMode(rootUiRef, isAlternateBuffer); + + const [transientMessage, setTransientMessageInternal] = useTimedMessage<{ + message: string; + type: TransientMessageType; + }>(WARNING_PROMPT_DURATION_MS, isLegacyNonAlternateBufferMode); + + const currentlyShowingTypeRef = useRef(null); + + const [transientMessageQueue, setTransientMessageQueue] = useState< + TransientMessagePayload[] + >([]); + + const showTransientMessage = useCallback( + (payload: TransientMessagePayload | null, durationMs?: number) => { + if (payload === null) { + setTransientMessageInternal(null); + setTransientMessageQueue([]); + currentlyShowingTypeRef.current = null; + return; + } + + if (currentlyShowingTypeRef.current === payload.type) { + setTransientMessageInternal( + { message: payload.message, type: payload.type }, + durationMs, + ); + return; + } + + setTransientMessageQueue((prev) => [...prev, { ...payload, durationMs }]); + }, + [setTransientMessageInternal], + ); + + useEffect(() => { + if (isLegacyNonAlternateBufferMode) { + return; + } + if (transientMessageQueue.length > 0 && !transientMessage) { + const [next, ...rest] = transientMessageQueue; + currentlyShowingTypeRef.current = next.type; + setTransientMessageInternal( + { message: next.message, type: next.type }, + next.durationMs, + ); + setTransientMessageQueue(rest); + } else if (!transientMessage) { + currentlyShowingTypeRef.current = null; + } + }, [ + transientMessageQueue, + transientMessage, + setTransientMessageInternal, + isLegacyNonAlternateBufferMode, + ]); + + const handleSetConstrainHeight = useCallback( + (value: boolean) => { + setConstrainHeight(value); + showTransientMessage( + { + message: `Ctrl+O to ${!value ? 'show more' : 'collapse'} lines of the last response`, + type: TransientMessageType.Accent, + }, + EXPAND_HINT_DURATION_MS, + ); + }, + [showTransientMessage], + ); + + const handleSetQueueErrorMessage = useCallback( + (message: string | null) => { + showTransientMessage( + message ? { message, type: TransientMessageType.Error } : null, + QUEUE_ERROR_DISPLAY_DURATION_MS, + ); + }, + [showTransientMessage], + ); const [corgiMode, setCorgiMode] = useState(false); const [forceRerenderKey, setForceRerenderKey] = useState(0); const [debugMessage, setDebugMessage] = useState(''); @@ -265,16 +362,9 @@ export const AppContainer = (props: AppContainerProps) => { () => isWorkspaceTrusted(settings.merged).isTrusted, ); - const [queueErrorMessage, setQueueErrorMessage] = useTimedMessage( - QUEUE_ERROR_DISPLAY_DURATION_MS, - ); const [newAgents, setNewAgents] = useState(null); const [constrainHeight, setConstrainHeight] = useState(true); - const [expandHintTrigger, triggerExpandHint] = useTimedMessage( - EXPAND_HINT_DURATION_MS, - ); - const showIsExpandableHint = Boolean(expandHintTrigger); const overflowState = useOverflowState(); const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0; const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight; @@ -291,10 +381,22 @@ export const AppContainer = (props: AppContainerProps) => { * to avoid noise, but the user can still trigger it manually with Ctrl+O. */ useEffect(() => { - if (hasOverflowState) { - triggerExpandHint(true); + if (hasOverflowState && !isAlternateBuffer) { + showTransientMessage( + { + message: `Ctrl+O to ${constrainHeight ? 'show more' : 'collapse'} lines of the last response`, + type: TransientMessageType.Accent, + }, + EXPAND_HINT_DURATION_MS, + ); } - }, [hasOverflowState, overflowingIdsSize, triggerExpandHint]); + }, [ + hasOverflowState, + isAlternateBuffer, + constrainHeight, + showTransientMessage, + overflowingIdsSize, + ]); const [defaultBannerText, setDefaultBannerText] = useState(''); const [warningBannerText, setWarningBannerText] = useState(''); @@ -415,7 +517,6 @@ export const AppContainer = (props: AppContainerProps) => { // Layout measurements const mainControlsRef = useRef(null); // For performance profiling only - const rootUiRef = useRef(null); const lastTitleRef = useRef(null); const staticExtraHeight = 3; @@ -1260,7 +1361,7 @@ Logging in with Google... Restarting Gemini CLI to continue. async (submittedValue: string) => { reset(); // Explicitly hide the expansion hint and clear its x-second timer when a new turn begins. - triggerExpandHint(null); + showTransientMessage(null); if (!constrainHeight) { setConstrainHeight(true); if (!isAlternateBuffer) { @@ -1322,24 +1423,24 @@ Logging in with Google... Restarting Gemini CLI to continue. submitQuery, isMcpReady, streamingState, - messageQueue.length, pendingSlashCommandHistoryItems, pendingGeminiHistoryItems, config, constrainHeight, - setConstrainHeight, + handleSetConstrainHeight, isAlternateBuffer, + isAgentConfigDialogOpen, refreshStatic, reset, handleHintSubmit, - triggerExpandHint, + showTransientMessage, ], ); const handleClearScreen = useCallback(() => { reset(); // Explicitly hide the expansion hint and clear its x-second timer when clearing the screen. - triggerExpandHint(null); + showTransientMessage(null); historyManager.clearItems(); clearConsoleMessagesState(); refreshStatic(); @@ -1348,7 +1449,7 @@ Logging in with Google... Restarting Gemini CLI to continue. clearConsoleMessagesState, refreshStatic, reset, - triggerExpandHint, + showTransientMessage, ]); const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); @@ -1471,39 +1572,39 @@ Logging in with Google... Restarting Gemini CLI to continue. const [renderMarkdown, setRenderMarkdown] = useState(true); const handleExitRepeat = useCallback( - (count: number) => { + (count: number, message: string) => { if (count > 2) { recordExitFail(config); } if (count > 1) { void handleSlashCommand('/quit', undefined, undefined, false); + } else if (count === 1) { + showTransientMessage({ + message, + type: TransientMessageType.Warning, + }); } }, - [config, handleSlashCommand], + [config, handleSlashCommand, showTransientMessage], ); - const { pressCount: ctrlCPressCount, handlePress: handleCtrlCPress } = + const { handlePress: handleCtrlCPress } = useRepeatedKeyPress({ windowMs: WARNING_PROMPT_DURATION_MS, - onRepeat: handleExitRepeat, + onRepeat: (count) => handleExitRepeat(count, 'Press Ctrl+C again to exit.'), }); - const { pressCount: ctrlDPressCount, handlePress: handleCtrlDPress } = + const { handlePress: handleCtrlDPress } = useRepeatedKeyPress({ windowMs: WARNING_PROMPT_DURATION_MS, - onRepeat: handleExitRepeat, + onRepeat: (count) => handleExitRepeat(count, 'Press Ctrl+D again to exit.'), }); const [ideContextState, setIdeContextState] = useState< IdeContext | undefined >(); - const [showEscapePrompt, setShowEscapePrompt] = useState(false); - const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false); + const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false); - const [transientMessage, showTransientMessage] = useTimedMessage<{ - text: string; - type: TransientMessageType; - }>(WARNING_PROMPT_DURATION_MS); const { isFolderTrustDialogOpen, @@ -1532,20 +1633,14 @@ Logging in with Google... Restarting Gemini CLI to continue. message: string; type: TransientMessageType; }) => { - showTransientMessage({ text: payload.message, type: payload.type }); + showTransientMessage({ message: payload.message, type: payload.type }, payload.durationMs); }; const handleSelectionWarning = () => { - showTransientMessage({ - text: 'Press Ctrl-S to enter selection mode to copy text.', - type: TransientMessageType.Warning, - }); + showTransientMessage({ message: 'Press Ctrl-S to enter selection mode to copy text.', type: TransientMessageType.Warning }); }; const handlePasteTimeout = () => { - showTransientMessage({ - text: 'Paste Timed out. Possibly due to slow connection.', - type: TransientMessageType.Warning, - }); + showTransientMessage({ message: 'Paste Timed out. Possibly due to slow connection.', type: TransientMessageType.Warning }); }; appEvents.on(AppEvent.TransientMessage, handleTransientMessage); @@ -1564,10 +1659,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const handleWarning = useCallback( (message: string) => { - showTransientMessage({ - text: message, - type: TransientMessageType.Warning, - }); + showTransientMessage({ message, type: TransientMessageType.Warning }); }, [showTransientMessage], ); @@ -1620,9 +1712,6 @@ Logging in with Google... Restarting Gemini CLI to continue. }; }, [config]); - const handleEscapePromptChange = useCallback((showPrompt: boolean) => { - setShowEscapePrompt(showPrompt); - }, []); const handleIdePromptComplete = useCallback( (result: IdeIntegrationNudgeResult) => { @@ -1680,20 +1769,17 @@ Logging in with Google... Restarting Gemini CLI to continue. keyMatchers[Command.TOGGLE_COPY_MODE](key) && !isAlternateBuffer ) { - showTransientMessage({ - text: 'Use Ctrl+O to expand and collapse blocks of content.', - type: TransientMessageType.Warning, - }); + showTransientMessage({ message: 'Use Ctrl+O to expand and collapse blocks of content.', type: TransientMessageType.Warning }); return true; } let enteringConstrainHeightMode = false; 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. - triggerExpandHint(true); + handleSetConstrainHeight(true); + } else { + setConstrainHeight(true); } if (!isAlternateBuffer) { refreshStatic(); @@ -1723,7 +1809,15 @@ Logging in with Google... Restarting Gemini CLI to continue. } else if (keyMatchers[Command.TOGGLE_MARKDOWN](key)) { setRenderMarkdown((prev) => { const newValue = !prev; - // Force re-render of static content + showTransientMessage( + { + message: newValue + ? 'rendered markdown mode' + : `raw markdown mode (${formatCommand(Command.TOGGLE_MARKDOWN)} to toggle)`, + type: TransientMessageType.Accent, + }, + EXPAND_HINT_DURATION_MS, + ); refreshStatic(); return newValue; }); @@ -1740,9 +1834,7 @@ Logging in with Google... Restarting Gemini CLI to continue. keyMatchers[Command.SHOW_MORE_LINES](key) && !enteringConstrainHeightMode ) { - setConstrainHeight(false); - // If the user manually expands the view, show the hint and reset the x-second timer. - triggerExpandHint(true); + handleSetConstrainHeight(false); if (!isAlternateBuffer) { refreshStatic(); } @@ -1760,10 +1852,7 @@ Logging in with Google... Restarting Gemini CLI to continue. if (lastOutputTimeRef.current === capturedTime) { setEmbeddedShellFocused(false); } else { - showTransientMessage({ - text: 'Use Shift+Tab to unfocus', - type: TransientMessageType.Warning, - }); + showTransientMessage({ message: 'Use Shift+Tab to unfocus', type: TransientMessageType.Warning }); } }, 150); return false; @@ -1821,7 +1910,7 @@ Logging in with Google... Restarting Gemini CLI to continue. }, [ constrainHeight, - setConstrainHeight, + handleSetConstrainHeight, setShowErrorDetails, config, ideContextState, @@ -1836,7 +1925,6 @@ Logging in with Google... Restarting Gemini CLI to continue. refreshStatic, setCopyModeEnabled, tabFocusTimeoutRef, - isAlternateBuffer, shortcutsHelpVisible, backgroundCurrentShell, toggleBackgroundShell, @@ -1847,7 +1935,7 @@ Logging in with Google... Restarting Gemini CLI to continue. showTransientMessage, settings.merged.general.devtools, showErrorDetails, - triggerExpandHint, + showTransientMessage, ], ); @@ -2201,9 +2289,6 @@ Logging in with Google... Restarting Gemini CLI to continue. filteredConsoleMessages, ideContextState, renderMarkdown, - ctrlCPressedOnce: ctrlCPressCount >= 1, - ctrlDPressedOnce: ctrlDPressCount >= 1, - showEscapePrompt, shortcutsHelpVisible, cleanUiDetailsVisible, isFocused, @@ -2212,7 +2297,6 @@ Logging in with Google... Restarting Gemini CLI to continue. historyRemountKey, activeHooks, messageQueue, - queueErrorMessage, showApprovalModeIndicator, allowPlanMode, currentModel, @@ -2253,7 +2337,6 @@ Logging in with Google... Restarting Gemini CLI to continue. showDebugProfiler, customDialog, copyModeEnabled, - transientMessage, bannerData, bannerVisible, terminalBackgroundColor: config.getTerminalBackground(), @@ -2264,7 +2347,6 @@ Logging in with Google... Restarting Gemini CLI to continue. isBackgroundShellListOpen, adminSettingsChanged, newAgents, - showIsExpandableHint, hintMode: config.isModelSteeringEnabled() && isToolExecuting([ @@ -2272,6 +2354,9 @@ Logging in with Google... Restarting Gemini CLI to continue. ...pendingGeminiHistoryItems, ]), hintBuffer: '', + transientMessage: transientMessage + ? { message: transientMessage.message, type: transientMessage.type } + : null, }), [ isThemeDialogOpen, @@ -2291,7 +2376,6 @@ Logging in with Google... Restarting Gemini CLI to continue. isSettingsDialogOpen, isSessionBrowserOpen, isModelDialogOpen, - isAgentConfigDialogOpen, selectedAgentName, selectedAgentDisplayName, selectedAgentDefinition, @@ -2329,9 +2413,6 @@ Logging in with Google... Restarting Gemini CLI to continue. filteredConsoleMessages, ideContextState, renderMarkdown, - ctrlCPressCount, - ctrlDPressCount, - showEscapePrompt, shortcutsHelpVisible, cleanUiDetailsVisible, isFocused, @@ -2340,7 +2421,6 @@ Logging in with Google... Restarting Gemini CLI to continue. historyRemountKey, activeHooks, messageQueue, - queueErrorMessage, showApprovalModeIndicator, allowPlanMode, userTier, @@ -2392,7 +2472,7 @@ Logging in with Google... Restarting Gemini CLI to continue. backgroundShells, adminSettingsChanged, newAgents, - showIsExpandableHint, + transientMessage, ], ); @@ -2423,8 +2503,8 @@ Logging in with Google... Restarting Gemini CLI to continue. handleIdePromptComplete, handleFolderTrustSelect, setIsPolicyUpdateDialogOpen, - setConstrainHeight, - onEscapePromptChange: handleEscapePromptChange, + handleSetConstrainHeight, + setConstrainHeight: handleSetConstrainHeight, refreshStatic, handleFinalSubmit, handleClearScreen, @@ -2437,7 +2517,7 @@ Logging in with Google... Restarting Gemini CLI to continue. closeSessionBrowser, handleResumeSession, handleDeleteSession, - setQueueErrorMessage, + setQueueErrorMessage: handleSetQueueErrorMessage, popAllMessages, handleApiKeySubmit, handleApiKeyCancel, @@ -2515,8 +2595,7 @@ Logging in with Google... Restarting Gemini CLI to continue. handleIdePromptComplete, handleFolderTrustSelect, setIsPolicyUpdateDialogOpen, - setConstrainHeight, - handleEscapePromptChange, + handleSetConstrainHeight, refreshStatic, handleFinalSubmit, handleClearScreen, @@ -2528,7 +2607,7 @@ Logging in with Google... Restarting Gemini CLI to continue. closeSessionBrowser, handleResumeSession, handleDeleteSession, - setQueueErrorMessage, + handleSetQueueErrorMessage, popAllMessages, handleApiKeySubmit, handleApiKeyCancel, @@ -2574,8 +2653,10 @@ Logging in with Google... Restarting Gemini CLI to continue. }} > - - + + + + diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index cbc9b792c7..0a62173df9 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -63,13 +63,7 @@ vi.mock('./StatusDisplay.js', () => ({ vi.mock('./ToastDisplay.js', () => ({ ToastDisplay: () => ToastDisplay, - shouldShowToast: (uiState: UIState) => - uiState.ctrlCPressedOnce || - Boolean(uiState.transientMessage) || - uiState.ctrlDPressedOnce || - (uiState.showEscapePrompt && - (uiState.buffer.text.length > 0 || uiState.history.length > 0)) || - Boolean(uiState.queueErrorMessage), + shouldShowToast: (uiState: UIState) => Boolean(uiState.transientMessage), })); vi.mock('./ContextSummaryDisplay.js', () => ({ @@ -175,9 +169,6 @@ const createMockUIState = (overrides: Partial = {}): UIState => thought: '', currentLoadingPhrase: '', elapsedTime: 0, - ctrlCPressedOnce: false, - ctrlDPressedOnce: false, - showEscapePrompt: false, shortcutsHelpVisible: false, cleanUiDetailsVisible: true, ideContextState: null, @@ -539,10 +530,7 @@ describe('Composer', () => { describe('Context and Status Display', () => { it('shows StatusDisplay and ApprovalModeIndicator in normal state', async () => { const uiState = createMockUIState({ - ctrlCPressedOnce: false, - ctrlDPressedOnce: false, - showEscapePrompt: false, - }); + }); const { lastFrame } = await renderComposer(uiState); @@ -554,7 +542,7 @@ describe('Composer', () => { it('shows ToastDisplay and hides ApprovalModeIndicator when a toast is present', async () => { const uiState = createMockUIState({ - ctrlCPressedOnce: true, + transientMessage: { message: 'test', type: TransientMessageType.Warning }, }); const { lastFrame } = await renderComposer(uiState); @@ -567,10 +555,7 @@ describe('Composer', () => { it('shows ToastDisplay for other toast types', async () => { const uiState = createMockUIState({ - transientMessage: { - text: 'Warning', - type: TransientMessageType.Warning, - }, + transientMessage: { message: 'Warning', type: TransientMessageType.Warning }, }); const { lastFrame } = await renderComposer(uiState); @@ -695,18 +680,7 @@ describe('Composer', () => { expect(output).not.toContain('ShortcutsHint'); }); - it('shows Esc rewind prompt in minimal mode without showing full UI', async () => { - const uiState = createMockUIState({ - cleanUiDetailsVisible: false, - showEscapePrompt: true, - history: [{ id: 1, type: 'user', text: 'msg' }], - }); - const { lastFrame } = await renderComposer(uiState); - const output = lastFrame(); - expect(output).toContain('ToastDisplay'); - expect(output).not.toContain('ContextSummaryDisplay'); - }); it('shows context usage bleed-through when over 60%', async () => { const model = 'gemini-2.5-pro'; diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 32ac92318c..57167d0c99 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -426,7 +426,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { shellModeActive={uiState.shellModeActive} setShellModeActive={uiActions.setShellModeActive} approvalMode={showApprovalModeIndicator} - onEscapePromptChange={uiActions.onEscapePromptChange} focus={isFocused} vimHandleInput={uiActions.vimHandleInput} isEmbeddedShellFocused={uiState.embeddedShellFocused} diff --git a/packages/cli/src/ui/components/ExitWarning.test.tsx b/packages/cli/src/ui/components/ExitWarning.test.tsx index 6d495a5e21..67f373e949 100644 --- a/packages/cli/src/ui/components/ExitWarning.test.tsx +++ b/packages/cli/src/ui/components/ExitWarning.test.tsx @@ -8,6 +8,7 @@ import { render } from '../../test-utils/render.js'; import { ExitWarning } from './ExitWarning.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useUIState, type UIState } from '../contexts/UIStateContext.js'; +import { TransientMessageType } from '../../utils/events.js'; vi.mock('../contexts/UIStateContext.js'); @@ -21,8 +22,7 @@ describe('ExitWarning', () => { it('renders nothing by default', async () => { mockUseUIState.mockReturnValue({ dialogsVisible: false, - ctrlCPressedOnce: false, - ctrlDPressedOnce: false, + transientMessage: null, } as unknown as UIState); const { lastFrame, waitUntilReady, unmount } = render(); await waitUntilReady(); @@ -30,35 +30,41 @@ describe('ExitWarning', () => { unmount(); }); - it('renders Ctrl+C warning when pressed once and dialogs visible', async () => { + it('renders warning when transient message is a warning and dialogs visible', async () => { mockUseUIState.mockReturnValue({ dialogsVisible: true, - ctrlCPressedOnce: true, - ctrlDPressedOnce: false, + transientMessage: { + message: 'Test Warning', + type: TransientMessageType.Warning, + }, } as unknown as UIState); const { lastFrame, waitUntilReady, unmount } = render(); await waitUntilReady(); - expect(lastFrame()).toContain('Press Ctrl+C again to exit'); + expect(lastFrame()).toContain('Test Warning'); unmount(); }); - it('renders Ctrl+D warning when pressed once and dialogs visible', async () => { + it('renders nothing if transient message is not a warning', async () => { mockUseUIState.mockReturnValue({ dialogsVisible: true, - ctrlCPressedOnce: false, - ctrlDPressedOnce: true, + transientMessage: { + message: 'Test Hint', + type: TransientMessageType.Hint, + }, } as unknown as UIState); const { lastFrame, waitUntilReady, unmount } = render(); await waitUntilReady(); - expect(lastFrame()).toContain('Press Ctrl+D again to exit'); + expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); it('renders nothing if dialogs are not visible', async () => { mockUseUIState.mockReturnValue({ dialogsVisible: false, - ctrlCPressedOnce: true, - ctrlDPressedOnce: true, + transientMessage: { + message: 'Test Warning', + type: TransientMessageType.Warning, + }, } as unknown as UIState); const { lastFrame, waitUntilReady, unmount } = render(); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/ExitWarning.tsx b/packages/cli/src/ui/components/ExitWarning.tsx index 598ff2fab4..e118e1a9f4 100644 --- a/packages/cli/src/ui/components/ExitWarning.tsx +++ b/packages/cli/src/ui/components/ExitWarning.tsx @@ -8,22 +8,24 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { useUIState } from '../contexts/UIStateContext.js'; import { theme } from '../semantic-colors.js'; +import { TransientMessageType } from '../../utils/events.js'; export const ExitWarning: React.FC = () => { const uiState = useUIState(); - return ( - <> - {uiState.dialogsVisible && uiState.ctrlCPressedOnce && ( - - Press Ctrl+C again to exit. - - )} + if (!uiState.dialogsVisible) { + return null; + } - {uiState.dialogsVisible && uiState.ctrlDPressedOnce && ( - - Press Ctrl+D again to exit. - - )} - - ); + if ( + uiState.transientMessage?.type === TransientMessageType.Warning && + uiState.transientMessage.message + ) { + return ( + + {uiState.transientMessage.message} + + ); + } + + return null; }; diff --git a/packages/cli/src/ui/components/ToastDisplay.test.tsx b/packages/cli/src/ui/components/ToastDisplay.test.tsx index b1432caa9d..137ccc044b 100644 --- a/packages/cli/src/ui/components/ToastDisplay.test.tsx +++ b/packages/cli/src/ui/components/ToastDisplay.test.tsx @@ -28,84 +28,20 @@ describe('ToastDisplay', () => { describe('shouldShowToast', () => { const baseState: Partial = { - ctrlCPressedOnce: false, transientMessage: null, - ctrlDPressedOnce: false, - showEscapePrompt: false, 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), - ).toBe(true); - }); - it('returns true when transientMessage is present', () => { expect( shouldShowToast({ ...baseState, - transientMessage: { text: 'test', type: TransientMessageType.Hint }, - } as UIState), - ).toBe(true); - }); - - it('returns true when ctrlDPressedOnce is true', () => { - expect( - shouldShowToast({ ...baseState, ctrlDPressedOnce: true } as UIState), - ).toBe(true); - }); - - it('returns true when showEscapePrompt is true and buffer is NOT empty', () => { - expect( - shouldShowToast({ - ...baseState, - showEscapePrompt: true, - buffer: { text: 'some text' } as TextBuffer, - } as UIState), - ).toBe(true); - }); - - it('returns true when showEscapePrompt is true and history is NOT empty', () => { - expect( - shouldShowToast({ - ...baseState, - showEscapePrompt: true, - history: [{ id: '1' } as unknown as HistoryItem], - } as UIState), - ).toBe(true); - }); - - it('returns false when showEscapePrompt is true but buffer and history are empty', () => { - expect( - shouldShowToast({ - ...baseState, - showEscapePrompt: true, - } as UIState), - ).toBe(false); - }); - - it('returns true when queueErrorMessage is present', () => { - expect( - shouldShowToast({ - ...baseState, - queueErrorMessage: 'error', + transientMessage: { message: 'test', type: TransientMessageType.Hint }, } as UIState), ).toBe(true); }); @@ -117,18 +53,10 @@ describe('ToastDisplay', () => { expect(lastFrame({ allowEmpty: true })).toBe(''); }); - it('renders Ctrl+C prompt', async () => { - const { lastFrame, waitUntilReady } = renderToastDisplay({ - ctrlCPressedOnce: true, - }); - await waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - }); - it('renders warning message', async () => { const { lastFrame, waitUntilReady } = renderToastDisplay({ transientMessage: { - text: 'This is a warning', + message: 'This is a warning', type: TransientMessageType.Warning, }, }); @@ -139,7 +67,7 @@ describe('ToastDisplay', () => { it('renders hint message', async () => { const { lastFrame, waitUntilReady } = renderToastDisplay({ transientMessage: { - text: 'This is a hint', + message: 'This is a hint', type: TransientMessageType.Hint, }, }); @@ -147,59 +75,36 @@ describe('ToastDisplay', () => { expect(lastFrame()).toMatchSnapshot(); }); - it('renders Ctrl+D prompt', async () => { + it('renders Error transient message', async () => { const { lastFrame, waitUntilReady } = renderToastDisplay({ - ctrlDPressedOnce: true, + transientMessage: { + message: 'Error Message', + type: TransientMessageType.Error, + }, }); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); - it('renders Escape prompt when buffer is empty', async () => { + it('renders Hint transient message', async () => { const { lastFrame, waitUntilReady } = renderToastDisplay({ - showEscapePrompt: true, - history: [{ id: 1, type: 'user', text: 'test' }] as HistoryItem[], + transientMessage: { + message: 'Hint Message', + type: TransientMessageType.Hint, + }, }); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); - it('renders Escape prompt when buffer is NOT empty', async () => { + it('renders Accent transient message', async () => { const { lastFrame, waitUntilReady } = renderToastDisplay({ - showEscapePrompt: true, - buffer: { text: 'some text' } as TextBuffer, + transientMessage: { + message: 'Accent Message', + type: TransientMessageType.Accent, + }, }); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); - - it('renders Queue Error Message', async () => { - const { lastFrame, waitUntilReady } = renderToastDisplay({ - queueErrorMessage: 'Queue Error', - }); - 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 of the last response', - ); - }); - - 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( - 'Ctrl+O to collapse lines of the last response', - ); - }); }); diff --git a/packages/cli/src/ui/components/ToastDisplay.tsx b/packages/cli/src/ui/components/ToastDisplay.tsx index 869139cb39..d57f38320a 100644 --- a/packages/cli/src/ui/components/ToastDisplay.tsx +++ b/packages/cli/src/ui/components/ToastDisplay.tsx @@ -11,75 +11,45 @@ import { useUIState, type UIState } from '../contexts/UIStateContext.js'; import { TransientMessageType } from '../../utils/events.js'; export function shouldShowToast(uiState: UIState): boolean { - return ( - uiState.ctrlCPressedOnce || - Boolean(uiState.transientMessage) || - uiState.ctrlDPressedOnce || - (uiState.showEscapePrompt && - (uiState.buffer.text.length > 0 || uiState.history.length > 0)) || - Boolean(uiState.queueErrorMessage) || - uiState.showIsExpandableHint - ); + return Boolean(uiState.transientMessage); } export const ToastDisplay: React.FC = () => { const uiState = useUIState(); - if (uiState.ctrlCPressedOnce) { + if ( + uiState.transientMessage?.type === TransientMessageType.Warning && + uiState.transientMessage.message + ) { return ( - Press Ctrl+C again to exit. + {uiState.transientMessage.message} ); } if ( - uiState.transientMessage?.type === TransientMessageType.Warning && - uiState.transientMessage.text + uiState.transientMessage?.type === TransientMessageType.Error && + uiState.transientMessage.message ) { return ( - {uiState.transientMessage.text} - ); - } - - if (uiState.ctrlDPressedOnce) { - return ( - Press Ctrl+D again to exit. - ); - } - - if (uiState.showEscapePrompt) { - const isPromptEmpty = uiState.buffer.text.length === 0; - const hasHistory = uiState.history.length > 0; - - if (isPromptEmpty && !hasHistory) { - return null; - } - - return ( - - Press Esc again to {isPromptEmpty ? 'rewind' : 'clear prompt'}. - + {uiState.transientMessage.message} ); } if ( uiState.transientMessage?.type === TransientMessageType.Hint && - uiState.transientMessage.text + uiState.transientMessage.message ) { return ( - {uiState.transientMessage.text} + {uiState.transientMessage.message} ); } - if (uiState.queueErrorMessage) { - return {uiState.queueErrorMessage}; - } - - if (uiState.showIsExpandableHint) { - const action = uiState.constrainHeight ? 'show more' : 'collapse'; + if ( + uiState.transientMessage?.type === TransientMessageType.Accent && + uiState.transientMessage.message + ) { return ( - - Press Ctrl+O to {action} lines of the last response - + {uiState.transientMessage.message} ); } diff --git a/packages/cli/src/ui/components/__snapshots__/ToastDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ToastDisplay.test.tsx.snap index 94b4717762..55a25debd3 100644 --- a/packages/cli/src/ui/components/__snapshots__/ToastDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ToastDisplay.test.tsx.snap @@ -1,27 +1,17 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`ToastDisplay > renders Ctrl+C prompt 1`] = ` -"Press Ctrl+C again to exit. +exports[`ToastDisplay > renders Accent transient message 1`] = ` +"Accent Message " `; -exports[`ToastDisplay > renders Ctrl+D prompt 1`] = ` -"Press Ctrl+D again to exit. +exports[`ToastDisplay > renders Error transient message 1`] = ` +"Error Message " `; -exports[`ToastDisplay > renders Escape prompt when buffer is NOT empty 1`] = ` -"Press Esc again to clear prompt. -" -`; - -exports[`ToastDisplay > renders Escape prompt when buffer is empty 1`] = ` -"Press Esc again to rewind. -" -`; - -exports[`ToastDisplay > renders Queue Error Message 1`] = ` -"Queue Error +exports[`ToastDisplay > renders Hint transient message 1`] = ` +"Hint Message " `; diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 988837df4d..ec51b0cc13 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -55,7 +55,6 @@ export interface UIActions { handleFolderTrustSelect: (choice: FolderTrustChoice) => void; setIsPolicyUpdateDialogOpen: (value: boolean) => void; setConstrainHeight: (value: boolean) => void; - onEscapePromptChange: (show: boolean) => void; refreshStatic: () => void; handleFinalSubmit: (value: string) => Promise; handleClearScreen: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index ea9025aa6b..f2cbe739e1 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -5,6 +5,7 @@ */ import { createContext, useContext } from 'react'; +import { type TransientMessageType } from '../../utils/events.js'; import type { HistoryItem, ThoughtSummary, @@ -31,7 +32,6 @@ import type { FolderDiscoveryResults, PolicyUpdateConfirmationRequest, } from '@google/gemini-cli-core'; -import { type TransientMessageType } from '../../utils/events.js'; import type { DOMElement } from 'ink'; import type { SessionStatsState } from '../contexts/SessionContext.js'; import type { ExtensionUpdateState } from '../state/extensions.js'; @@ -161,9 +161,6 @@ export interface UIState { filteredConsoleMessages: ConsoleMessageItem[]; ideContextState: IdeContext | undefined; renderMarkdown: boolean; - ctrlCPressedOnce: boolean; - ctrlDPressedOnce: boolean; - showEscapePrompt: boolean; shortcutsHelpVisible: boolean; cleanUiDetailsVisible: boolean; elapsedTime: number; @@ -171,7 +168,6 @@ export interface UIState { historyRemountKey: number; activeHooks: ActiveHook[]; messageQueue: string[]; - queueErrorMessage: string | null; showApprovalModeIndicator: ApprovalMode; allowPlanMode: boolean; // Quota-related state @@ -220,11 +216,10 @@ export interface UIState { isBackgroundShellListOpen: boolean; adminSettingsChanged: boolean; newAgents: AgentDefinition[] | null; - showIsExpandableHint: boolean; hintMode: boolean; hintBuffer: string; transientMessage: { - text: string; + message: string; type: TransientMessageType; } | null; } diff --git a/packages/cli/src/ui/hooks/useAlternateBuffer.ts b/packages/cli/src/ui/hooks/useAlternateBuffer.ts index 3db30202b9..a98ce5e8cf 100644 --- a/packages/cli/src/ui/hooks/useAlternateBuffer.ts +++ b/packages/cli/src/ui/hooks/useAlternateBuffer.ts @@ -21,8 +21,8 @@ export const useAlternateBuffer = (): boolean => { export const useLegacyNonAlternateBufferMode = ( rootUiRef: RefObject, + isAlternateBuffer: boolean, ): boolean => { - const isAlternateBuffer = useAlternateBuffer(); const { rows: terminalHeight } = useTerminalSize(); const [isOverflowing, setIsOverflowing] = useState(false); diff --git a/packages/cli/src/ui/hooks/useTimedMessage.ts b/packages/cli/src/ui/hooks/useTimedMessage.ts index 547968cb90..b3f361c3f5 100644 --- a/packages/cli/src/ui/hooks/useTimedMessage.ts +++ b/packages/cli/src/ui/hooks/useTimedMessage.ts @@ -7,36 +7,41 @@ import { useState, useCallback, useRef, useEffect } from 'react'; /** - * A hook to manage a state value that automatically resets to null after a duration. - * Useful for transient UI messages, hints, or warnings. + * A hook to manage a state value that automatically resets to null after a specified duration. + * Provides a function to manually set the value and reset the timer. + * Can be paused to prevent the timer from expiring. */ -export function useTimedMessage(durationMs: number) { +export function useTimedMessage( + defaultDurationMs: number, + isPaused: boolean = false, +) { const [message, setMessage] = useState(null); const timeoutRef = useRef(null); + const currentDurationRef = useRef(defaultDurationMs); + + const startTimer = useCallback(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + if (!isPaused && message !== null) { + timeoutRef.current = setTimeout(() => { + setMessage(null); + }, currentDurationRef.current); + } + }, [isPaused, message]); const showMessage = useCallback( - (msg: T | null) => { + (msg: T | null, durationMs?: number) => { setMessage(msg); - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - if (msg !== null) { - timeoutRef.current = setTimeout(() => { - setMessage(null); - }, durationMs); - } + currentDurationRef.current = durationMs ?? defaultDurationMs; }, - [durationMs], + [defaultDurationMs], ); - useEffect( - () => () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - }, - [], - ); + useEffect(() => { + startTimer(); + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, [startTimer]); return [message, showMessage] as const; } diff --git a/packages/cli/src/utils/events.ts b/packages/cli/src/utils/events.ts index 8291528ac1..627f11afcb 100644 --- a/packages/cli/src/utils/events.ts +++ b/packages/cli/src/utils/events.ts @@ -9,11 +9,14 @@ import { EventEmitter } from 'node:events'; export enum TransientMessageType { Warning = 'warning', Hint = 'hint', + Error = 'error', + Accent = 'accent', } export interface TransientMessagePayload { message: string; type: TransientMessageType; + durationMs?: number; } export enum AppEvent {