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 {