fix(ui): use centralized queue for all transient hints and warnings

This commit properly implements the transient message queue in AppContainer, fixing flickering issues caused when the UI exceeds terminal height in legacy non-alternate buffer mode. By introducing useLegacyNonAlternateBufferMode, the timer for transient messages (like the markdown toggle and Ctrl+O overflow hint) pauses when an overflow happens in standard mode. This ensures hints remain on screen without causing infinite loops or terminal jumping.

Additionally:
- Consolidates older scattered state (queueErrorMessage, showEscapePrompt, etc) into the single transientMessageQueue.
- Standardizes transient message payloads with a uniform format.
- Extensively updates tests to handle these context changes correctly.

Fixes #21824
This commit is contained in:
jacob314
2026-03-10 15:02:50 -07:00
parent 05fe1bce97
commit 8956d77da4
16 changed files with 316 additions and 380 deletions
-1
View File
@@ -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(),
+7 -6
View File
@@ -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(
<App />,
+34 -27
View File
@@ -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!();
+165 -84
View File
@@ -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<DOMElement>(null);
const isLegacyNonAlternateBufferMode =
useLegacyNonAlternateBufferMode(rootUiRef, isAlternateBuffer);
const [transientMessage, setTransientMessageInternal] = useTimedMessage<{
message: string;
type: TransientMessageType;
}>(WARNING_PROMPT_DURATION_MS, isLegacyNonAlternateBufferMode);
const currentlyShowingTypeRef = useRef<TransientMessageType | null>(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<string>('');
@@ -265,16 +362,9 @@ export const AppContainer = (props: AppContainerProps) => {
() => isWorkspaceTrusted(settings.merged).isTrusted,
);
const [queueErrorMessage, setQueueErrorMessage] = useTimedMessage<string>(
QUEUE_ERROR_DISPLAY_DURATION_MS,
);
const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null);
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
const [expandHintTrigger, triggerExpandHint] = useTimedMessage<boolean>(
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<DOMElement>(null);
// For performance profiling only
const rootUiRef = useRef<DOMElement>(null);
const lastTitleRef = useRef<string | null>(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<boolean>(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.
}}
>
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
<ShellFocusContext.Provider value={isFocused}>
<App key={`app-${forceRerenderKey}`} />
<ShellFocusContext.Provider value={embeddedShellFocused}>
<Box ref={rootUiRef} flexDirection="column">
<App key={`app-${forceRerenderKey}`} />
</Box>
</ShellFocusContext.Provider>
</ToolActionsProvider>
</AppContext.Provider>
@@ -63,13 +63,7 @@ vi.mock('./StatusDisplay.js', () => ({
vi.mock('./ToastDisplay.js', () => ({
ToastDisplay: () => <Text>ToastDisplay</Text>,
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> = {}): 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';
@@ -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}
@@ -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(<ExitWarning />);
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(<ExitWarning />);
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(<ExitWarning />);
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(<ExitWarning />);
await waitUntilReady();
+16 -14
View File
@@ -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 && (
<Box marginTop={1}>
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
</Box>
)}
if (!uiState.dialogsVisible) {
return null;
}
{uiState.dialogsVisible && uiState.ctrlDPressedOnce && (
<Box marginTop={1}>
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text>
</Box>
)}
</>
);
if (
uiState.transientMessage?.type === TransientMessageType.Warning &&
uiState.transientMessage.message
) {
return (
<Box marginTop={1}>
<Text color={theme.status.warning}>{uiState.transientMessage.message}</Text>
</Box>
);
}
return null;
};
@@ -28,84 +28,20 @@ describe('ToastDisplay', () => {
describe('shouldShowToast', () => {
const baseState: Partial<UIState> = {
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',
);
});
});
+16 -46
View File
@@ -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 (
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
<Text color={theme.status.warning}>{uiState.transientMessage.message}</Text>
);
}
if (
uiState.transientMessage?.type === TransientMessageType.Warning &&
uiState.transientMessage.text
uiState.transientMessage?.type === TransientMessageType.Error &&
uiState.transientMessage.message
) {
return (
<Text color={theme.status.warning}>{uiState.transientMessage.text}</Text>
);
}
if (uiState.ctrlDPressedOnce) {
return (
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text>
);
}
if (uiState.showEscapePrompt) {
const isPromptEmpty = uiState.buffer.text.length === 0;
const hasHistory = uiState.history.length > 0;
if (isPromptEmpty && !hasHistory) {
return null;
}
return (
<Text color={theme.text.secondary}>
Press Esc again to {isPromptEmpty ? 'rewind' : 'clear prompt'}.
</Text>
<Text color={theme.status.error}>{uiState.transientMessage.message}</Text>
);
}
if (
uiState.transientMessage?.type === TransientMessageType.Hint &&
uiState.transientMessage.text
uiState.transientMessage.message
) {
return (
<Text color={theme.text.secondary}>{uiState.transientMessage.text}</Text>
<Text color={theme.text.secondary}>{uiState.transientMessage.message}</Text>
);
}
if (uiState.queueErrorMessage) {
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
}
if (uiState.showIsExpandableHint) {
const action = uiState.constrainHeight ? 'show more' : 'collapse';
if (
uiState.transientMessage?.type === TransientMessageType.Accent &&
uiState.transientMessage.message
) {
return (
<Text color={theme.text.accent}>
Press Ctrl+O to {action} lines of the last response
</Text>
<Text color={theme.text.accent}>{uiState.transientMessage.message}</Text>
);
}
@@ -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
"
`;
@@ -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<void>;
handleClearScreen: () => void;
@@ -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;
}
@@ -21,8 +21,8 @@ export const useAlternateBuffer = (): boolean => {
export const useLegacyNonAlternateBufferMode = (
rootUiRef: RefObject<DOMElement | null>,
isAlternateBuffer: boolean,
): boolean => {
const isAlternateBuffer = useAlternateBuffer();
const { rows: terminalHeight } = useTerminalSize();
const [isOverflowing, setIsOverflowing] = useState(false);
+26 -21
View File
@@ -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<T>(durationMs: number) {
export function useTimedMessage<T>(
defaultDurationMs: number,
isPaused: boolean = false,
) {
const [message, setMessage] = useState<T | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const currentDurationRef = useRef<number>(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;
}
+3
View File
@@ -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 {