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 {