Compare commits

...

2 Commits

Author SHA1 Message Date
jacob314 8956d77da4 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
2026-03-10 15:02:50 -07:00
jacob314 05fe1bce97 fix(ui): use centralized queue for all transient hints and warnings
This commit migrates several distinct, uncoordinated timers for temporary messages (like raw markdown toggle and overflow hints) into a unified `transientMessageQueue` in `AppContainer.tsx`.

This fixes flickering issues caused when the UI rendered is taller than the terminal window in standard mode by adding a `useLegacyNonAlternateBufferMode` hook to pause the message queue when the content overflows. This ensures that the "Show more lines" or "raw markdown mode" hints do not cause continuous re-renders and terminal scrolling.

It also removes the old permanent `RawMarkdownIndicator` component entirely, replacing it with a clean, temporary hint when the mode is toggled via keyboard shortcut.

Fixes #21824
2026-03-09 22:06:17 -07:00
18 changed files with 343 additions and 492 deletions
-1
View File
@@ -606,7 +606,6 @@ const mockUIActions: UIActions = {
handleFolderTrustSelect: vi.fn(), handleFolderTrustSelect: vi.fn(),
setIsPolicyUpdateDialogOpen: vi.fn(), setIsPolicyUpdateDialogOpen: vi.fn(),
setConstrainHeight: vi.fn(), setConstrainHeight: vi.fn(),
onEscapePromptChange: vi.fn(),
refreshStatic: vi.fn(), refreshStatic: vi.fn(),
handleFinalSubmit: vi.fn(), handleFinalSubmit: vi.fn(),
handleClearScreen: 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 { renderWithProviders } from '../test-utils/render.js';
import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink'; import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink';
import { App } from './App.js'; import { App } from './App.js';
import { TransientMessageType } from '../utils/events.js';
import { type UIState } from './contexts/UIStateContext.js'; import { type UIState } from './contexts/UIStateContext.js';
import { StreamingState } from './types.js'; import { StreamingState } from './types.js';
import { makeFakeConfig, CoreToolCallStatus } from '@google/gemini-cli-core'; import { makeFakeConfig, CoreToolCallStatus } from '@google/gemini-cli-core';
@@ -170,16 +171,16 @@ describe('App', () => {
}); });
it.each([ it.each([
{ key: 'C', stateKey: 'ctrlCPressedOnce' }, { key: 'C' },
{ key: 'D', stateKey: 'ctrlDPressedOnce' }, { key: 'D' },
])( ])(
'should show Ctrl+$key exit prompt when dialogs are visible and $stateKey is true', 'should show Ctrl+$key exit prompt when dialogs are visible and warning is present',
async ({ key, stateKey }) => { async ({ key }) => {
const uiState = { const uiState = {
...mockUIState, ...mockUIState,
dialogsVisible: true, dialogsVisible: true,
[stateKey]: true, transientMessage: { message: `Press Ctrl+${key} again to exit.`, type: TransientMessageType.Warning },
} as UIState; } as unknown as UIState;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders( const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<App />, <App />,
+34 -27
View File
@@ -192,11 +192,18 @@ vi.mock('../utils/terminalNotifications.js', () => ({
vi.mock('./hooks/useTerminalTheme.js', () => ({ vi.mock('./hooks/useTerminalTheme.js', () => ({
useTerminalTheme: vi.fn(), 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 { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
import { useFocus } from './hooks/useFocus.js'; import { useFocus } from './hooks/useFocus.js';
import { TransientMessageType } from '../utils/events.js';
// Mock external utilities // Mock external utilities
vi.mock('../utils/events.js'); vi.mock('../utils/events.js');
@@ -2140,19 +2147,19 @@ describe('AppContainer State Management', () => {
vi.advanceTimersByTime(0); vi.advanceTimersByTime(0);
}); });
expect(capturedUIState.queueErrorMessage).toBeNull(); expect(capturedUIState.transientMessage).toBeNull();
act(() => { act(() => {
capturedUIActions.setQueueErrorMessage('Test error'); capturedUIActions.setQueueErrorMessage('Test error');
}); });
rerender(getAppContainer()); rerender(getAppContainer());
expect(capturedUIState.queueErrorMessage).toBe('Test error'); await waitFor(() => expect(capturedUIState.transientMessage?.message).toBe('Test error'));
act(() => { act(() => {
vi.advanceTimersByTime(3000); vi.advanceTimersByTime(3000);
}); });
rerender(getAppContainer()); rerender(getAppContainer());
expect(capturedUIState.queueErrorMessage).toBeNull(); expect(capturedUIState.transientMessage).toBeNull();
unmount(); unmount();
}); });
@@ -2166,7 +2173,7 @@ describe('AppContainer State Management', () => {
capturedUIActions.setQueueErrorMessage('First error'); capturedUIActions.setQueueErrorMessage('First error');
}); });
rerender(getAppContainer()); rerender(getAppContainer());
expect(capturedUIState.queueErrorMessage).toBe('First error'); await waitFor(() => expect(capturedUIState.transientMessage?.message).toBe('First error'));
act(() => { act(() => {
vi.advanceTimersByTime(1500); vi.advanceTimersByTime(1500);
@@ -2176,20 +2183,20 @@ describe('AppContainer State Management', () => {
capturedUIActions.setQueueErrorMessage('Second error'); capturedUIActions.setQueueErrorMessage('Second error');
}); });
rerender(getAppContainer()); rerender(getAppContainer());
expect(capturedUIState.queueErrorMessage).toBe('Second error'); await waitFor(() => expect(capturedUIState.transientMessage?.message).toBe('Second error'));
act(() => { act(() => {
vi.advanceTimersByTime(2000); vi.advanceTimersByTime(2000);
}); });
rerender(getAppContainer()); 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 // 5. Advance time past the 3 second timeout from the second message
act(() => { act(() => {
vi.advanceTimersByTime(1000); vi.advanceTimersByTime(1000);
}); });
rerender(getAppContainer()); rerender(getAppContainer());
expect(capturedUIState.queueErrorMessage).toBeNull(); expect(capturedUIState.transientMessage).toBeNull();
unmount(); unmount();
}); });
}); });
@@ -3389,21 +3396,21 @@ describe('AppContainer State Management', () => {
await waitFor(() => { await waitFor(() => {
// Should show hint because we are in Standard Mode (default settings) and have overflow // 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 // Advance just before the timeout
act(() => { act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100); 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 // Advance to hit the timeout mark
act(() => { act(() => {
vi.advanceTimersByTime(100); vi.advanceTimersByTime(100);
}); });
await waitFor(() => { await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(false); expect(capturedUIState.transientMessage).toBeNull();
}); });
unmount!(); unmount!();
@@ -3423,14 +3430,14 @@ describe('AppContainer State Management', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(true); expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
}); });
// 2. Advance half the duration // 2. Advance half the duration
act(() => { act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); 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) // 3. Trigger second overflow (this should reset the timer)
act(() => { act(() => {
@@ -3444,7 +3451,7 @@ describe('AppContainer State Management', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(true); expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
}); });
// 4. Advance enough that the ORIGINAL timer would have expired // 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); vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 100 - 1);
}); });
// The hint should STILL be visible because the timer reset at step 3 // 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 // 5. Advance to the end of the NEW timer
act(() => { act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 100); vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 100);
}); });
await waitFor(() => { await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(false); expect(capturedUIState.transientMessage).toBeNull();
}); });
unmount!(); unmount!();
@@ -3485,14 +3492,14 @@ describe('AppContainer State Management', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(true); expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
}); });
// Advance half the duration // Advance half the duration
act(() => { act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
}); });
expect(capturedUIState.showIsExpandableHint).toBe(true); expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
// Simulate Ctrl+O // Simulate Ctrl+O
act(() => { act(() => {
@@ -3510,7 +3517,7 @@ describe('AppContainer State Management', () => {
}); });
// We expect it to still be true because Ctrl+O should have reset the timer // 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 // Advance remaining time to reach the new timeout
act(() => { act(() => {
@@ -3518,7 +3525,7 @@ describe('AppContainer State Management', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(false); expect(capturedUIState.transientMessage).toBeNull();
}); });
unmount!(); unmount!();
@@ -3543,14 +3550,14 @@ describe('AppContainer State Management', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(true); expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
}); });
// Advance half the duration // Advance half the duration
act(() => { act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2);
}); });
expect(capturedUIState.showIsExpandableHint).toBe(true); expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
// First toggle 'on' (expanded) // First toggle 'on' (expanded)
act(() => { act(() => {
@@ -3564,7 +3571,7 @@ describe('AppContainer State Management', () => {
act(() => { act(() => {
vi.advanceTimersByTime(1000); vi.advanceTimersByTime(1000);
}); });
expect(capturedUIState.showIsExpandableHint).toBe(true); expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
// Second toggle 'off' (collapsed) // Second toggle 'off' (collapsed)
act(() => { act(() => {
@@ -3578,7 +3585,7 @@ describe('AppContainer State Management', () => {
act(() => { act(() => {
vi.advanceTimersByTime(1000); vi.advanceTimersByTime(1000);
}); });
expect(capturedUIState.showIsExpandableHint).toBe(true); expect(capturedUIState.transientMessage?.type).toBe(TransientMessageType.Accent);
// Third toggle 'on' (expanded) // Third toggle 'on' (expanded)
act(() => { act(() => {
@@ -3593,7 +3600,7 @@ describe('AppContainer State Management', () => {
act(() => { act(() => {
vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100); 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. // Wait 0.1s more to hit exactly the timeout since the last toggle.
// It should hide now. // It should hide now.
@@ -3601,7 +3608,7 @@ describe('AppContainer State Management', () => {
vi.advanceTimersByTime(100); vi.advanceTimersByTime(100);
}); });
await waitFor(() => { await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(false); expect(capturedUIState.transientMessage).toBeNull();
}); });
unmount!(); unmount!();
@@ -3635,9 +3642,9 @@ describe('AppContainer State Management', () => {
capturedOverflowActions.addOverflowingId('test-id'); 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(() => { await waitFor(() => {
expect(capturedUIState.showIsExpandableHint).toBe(true); expect(capturedUIState.transientMessage).toBeNull();
}); });
unmount!(); unmount!();
+165 -84
View File
@@ -4,6 +4,13 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { useLegacyNonAlternateBufferMode } from './hooks/useAlternateBuffer.js';
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { import {
useMemo, useMemo,
useState, useState,
@@ -13,6 +20,7 @@ import {
useLayoutEffect, useLayoutEffect,
} from 'react'; } from 'react';
import { import {
Box,
type DOMElement, type DOMElement,
measureElement, measureElement,
useApp, useApp,
@@ -120,12 +128,18 @@ import { useFocus } from './hooks/useFocus.js';
import { useKeypress, type Key } from './hooks/useKeypress.js'; import { useKeypress, type Key } from './hooks/useKeypress.js';
import { KeypressPriority } from './contexts/KeypressContext.js'; import { KeypressPriority } from './contexts/KeypressContext.js';
import { keyMatchers, Command } from './keyMatchers.js'; import { keyMatchers, Command } from './keyMatchers.js';
import { formatCommand } from './utils/keybindingUtils.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
import { useFolderTrust } from './hooks/useFolderTrust.js'; import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js'; import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.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 { type UpdateObject } from './utils/updateCheck.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
@@ -230,6 +244,89 @@ export const AppContainer = (props: AppContainerProps) => {
useMemoryMonitor(historyManager); useMemoryMonitor(historyManager);
const isAlternateBuffer = config.getUseAlternateBuffer(); 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 [corgiMode, setCorgiMode] = useState(false);
const [forceRerenderKey, setForceRerenderKey] = useState(0); const [forceRerenderKey, setForceRerenderKey] = useState(0);
const [debugMessage, setDebugMessage] = useState<string>(''); const [debugMessage, setDebugMessage] = useState<string>('');
@@ -265,16 +362,9 @@ export const AppContainer = (props: AppContainerProps) => {
() => isWorkspaceTrusted(settings.merged).isTrusted, () => isWorkspaceTrusted(settings.merged).isTrusted,
); );
const [queueErrorMessage, setQueueErrorMessage] = useTimedMessage<string>(
QUEUE_ERROR_DISPLAY_DURATION_MS,
);
const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null); const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null);
const [constrainHeight, setConstrainHeight] = useState<boolean>(true); const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
const [expandHintTrigger, triggerExpandHint] = useTimedMessage<boolean>(
EXPAND_HINT_DURATION_MS,
);
const showIsExpandableHint = Boolean(expandHintTrigger);
const overflowState = useOverflowState(); const overflowState = useOverflowState();
const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0; const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0;
const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight; 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. * to avoid noise, but the user can still trigger it manually with Ctrl+O.
*/ */
useEffect(() => { useEffect(() => {
if (hasOverflowState) { if (hasOverflowState && !isAlternateBuffer) {
triggerExpandHint(true); 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 [defaultBannerText, setDefaultBannerText] = useState('');
const [warningBannerText, setWarningBannerText] = useState(''); const [warningBannerText, setWarningBannerText] = useState('');
@@ -415,7 +517,6 @@ export const AppContainer = (props: AppContainerProps) => {
// Layout measurements // Layout measurements
const mainControlsRef = useRef<DOMElement>(null); const mainControlsRef = useRef<DOMElement>(null);
// For performance profiling only // For performance profiling only
const rootUiRef = useRef<DOMElement>(null);
const lastTitleRef = useRef<string | null>(null); const lastTitleRef = useRef<string | null>(null);
const staticExtraHeight = 3; const staticExtraHeight = 3;
@@ -1260,7 +1361,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
async (submittedValue: string) => { async (submittedValue: string) => {
reset(); reset();
// Explicitly hide the expansion hint and clear its x-second timer when a new turn begins. // Explicitly hide the expansion hint and clear its x-second timer when a new turn begins.
triggerExpandHint(null); showTransientMessage(null);
if (!constrainHeight) { if (!constrainHeight) {
setConstrainHeight(true); setConstrainHeight(true);
if (!isAlternateBuffer) { if (!isAlternateBuffer) {
@@ -1322,24 +1423,24 @@ Logging in with Google... Restarting Gemini CLI to continue.
submitQuery, submitQuery,
isMcpReady, isMcpReady,
streamingState, streamingState,
messageQueue.length,
pendingSlashCommandHistoryItems, pendingSlashCommandHistoryItems,
pendingGeminiHistoryItems, pendingGeminiHistoryItems,
config, config,
constrainHeight, constrainHeight,
setConstrainHeight, handleSetConstrainHeight,
isAlternateBuffer, isAlternateBuffer,
isAgentConfigDialogOpen,
refreshStatic, refreshStatic,
reset, reset,
handleHintSubmit, handleHintSubmit,
triggerExpandHint, showTransientMessage,
], ],
); );
const handleClearScreen = useCallback(() => { const handleClearScreen = useCallback(() => {
reset(); reset();
// Explicitly hide the expansion hint and clear its x-second timer when clearing the screen. // Explicitly hide the expansion hint and clear its x-second timer when clearing the screen.
triggerExpandHint(null); showTransientMessage(null);
historyManager.clearItems(); historyManager.clearItems();
clearConsoleMessagesState(); clearConsoleMessagesState();
refreshStatic(); refreshStatic();
@@ -1348,7 +1449,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
clearConsoleMessagesState, clearConsoleMessagesState,
refreshStatic, refreshStatic,
reset, reset,
triggerExpandHint, showTransientMessage,
]); ]);
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); 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 [renderMarkdown, setRenderMarkdown] = useState<boolean>(true);
const handleExitRepeat = useCallback( const handleExitRepeat = useCallback(
(count: number) => { (count: number, message: string) => {
if (count > 2) { if (count > 2) {
recordExitFail(config); recordExitFail(config);
} }
if (count > 1) { if (count > 1) {
void handleSlashCommand('/quit', undefined, undefined, false); 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({ useRepeatedKeyPress({
windowMs: WARNING_PROMPT_DURATION_MS, 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({ useRepeatedKeyPress({
windowMs: WARNING_PROMPT_DURATION_MS, windowMs: WARNING_PROMPT_DURATION_MS,
onRepeat: handleExitRepeat, onRepeat: (count) => handleExitRepeat(count, 'Press Ctrl+D again to exit.'),
}); });
const [ideContextState, setIdeContextState] = useState< const [ideContextState, setIdeContextState] = useState<
IdeContext | undefined 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 { const {
isFolderTrustDialogOpen, isFolderTrustDialogOpen,
@@ -1532,20 +1633,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
message: string; message: string;
type: TransientMessageType; type: TransientMessageType;
}) => { }) => {
showTransientMessage({ text: payload.message, type: payload.type }); showTransientMessage({ message: payload.message, type: payload.type }, payload.durationMs);
}; };
const handleSelectionWarning = () => { const handleSelectionWarning = () => {
showTransientMessage({ showTransientMessage({ message: 'Press Ctrl-S to enter selection mode to copy text.', type: TransientMessageType.Warning });
text: 'Press Ctrl-S to enter selection mode to copy text.',
type: TransientMessageType.Warning,
});
}; };
const handlePasteTimeout = () => { const handlePasteTimeout = () => {
showTransientMessage({ showTransientMessage({ message: 'Paste Timed out. Possibly due to slow connection.', type: TransientMessageType.Warning });
text: 'Paste Timed out. Possibly due to slow connection.',
type: TransientMessageType.Warning,
});
}; };
appEvents.on(AppEvent.TransientMessage, handleTransientMessage); appEvents.on(AppEvent.TransientMessage, handleTransientMessage);
@@ -1564,10 +1659,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const handleWarning = useCallback( const handleWarning = useCallback(
(message: string) => { (message: string) => {
showTransientMessage({ showTransientMessage({ message, type: TransientMessageType.Warning });
text: message,
type: TransientMessageType.Warning,
});
}, },
[showTransientMessage], [showTransientMessage],
); );
@@ -1620,9 +1712,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
}; };
}, [config]); }, [config]);
const handleEscapePromptChange = useCallback((showPrompt: boolean) => {
setShowEscapePrompt(showPrompt);
}, []);
const handleIdePromptComplete = useCallback( const handleIdePromptComplete = useCallback(
(result: IdeIntegrationNudgeResult) => { (result: IdeIntegrationNudgeResult) => {
@@ -1680,20 +1769,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
keyMatchers[Command.TOGGLE_COPY_MODE](key) && keyMatchers[Command.TOGGLE_COPY_MODE](key) &&
!isAlternateBuffer !isAlternateBuffer
) { ) {
showTransientMessage({ showTransientMessage({ message: 'Use Ctrl+O to expand and collapse blocks of content.', type: TransientMessageType.Warning });
text: 'Use Ctrl+O to expand and collapse blocks of content.',
type: TransientMessageType.Warning,
});
return true; return true;
} }
let enteringConstrainHeightMode = false; let enteringConstrainHeightMode = false;
if (!constrainHeight) { if (!constrainHeight) {
enteringConstrainHeightMode = true; enteringConstrainHeightMode = true;
setConstrainHeight(true);
if (keyMatchers[Command.SHOW_MORE_LINES](key)) { if (keyMatchers[Command.SHOW_MORE_LINES](key)) {
// If the user manually collapses the view, show the hint and reset the x-second timer. handleSetConstrainHeight(true);
triggerExpandHint(true); } else {
setConstrainHeight(true);
} }
if (!isAlternateBuffer) { if (!isAlternateBuffer) {
refreshStatic(); refreshStatic();
@@ -1723,7 +1809,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
} else if (keyMatchers[Command.TOGGLE_MARKDOWN](key)) { } else if (keyMatchers[Command.TOGGLE_MARKDOWN](key)) {
setRenderMarkdown((prev) => { setRenderMarkdown((prev) => {
const newValue = !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(); refreshStatic();
return newValue; return newValue;
}); });
@@ -1740,9 +1834,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
keyMatchers[Command.SHOW_MORE_LINES](key) && keyMatchers[Command.SHOW_MORE_LINES](key) &&
!enteringConstrainHeightMode !enteringConstrainHeightMode
) { ) {
setConstrainHeight(false); handleSetConstrainHeight(false);
// If the user manually expands the view, show the hint and reset the x-second timer.
triggerExpandHint(true);
if (!isAlternateBuffer) { if (!isAlternateBuffer) {
refreshStatic(); refreshStatic();
} }
@@ -1760,10 +1852,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (lastOutputTimeRef.current === capturedTime) { if (lastOutputTimeRef.current === capturedTime) {
setEmbeddedShellFocused(false); setEmbeddedShellFocused(false);
} else { } else {
showTransientMessage({ showTransientMessage({ message: 'Use Shift+Tab to unfocus', type: TransientMessageType.Warning });
text: 'Use Shift+Tab to unfocus',
type: TransientMessageType.Warning,
});
} }
}, 150); }, 150);
return false; return false;
@@ -1821,7 +1910,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
}, },
[ [
constrainHeight, constrainHeight,
setConstrainHeight, handleSetConstrainHeight,
setShowErrorDetails, setShowErrorDetails,
config, config,
ideContextState, ideContextState,
@@ -1836,7 +1925,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
refreshStatic, refreshStatic,
setCopyModeEnabled, setCopyModeEnabled,
tabFocusTimeoutRef, tabFocusTimeoutRef,
isAlternateBuffer,
shortcutsHelpVisible, shortcutsHelpVisible,
backgroundCurrentShell, backgroundCurrentShell,
toggleBackgroundShell, toggleBackgroundShell,
@@ -1847,7 +1935,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
showTransientMessage, showTransientMessage,
settings.merged.general.devtools, settings.merged.general.devtools,
showErrorDetails, showErrorDetails,
triggerExpandHint, showTransientMessage,
], ],
); );
@@ -2201,9 +2289,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
filteredConsoleMessages, filteredConsoleMessages,
ideContextState, ideContextState,
renderMarkdown, renderMarkdown,
ctrlCPressedOnce: ctrlCPressCount >= 1,
ctrlDPressedOnce: ctrlDPressCount >= 1,
showEscapePrompt,
shortcutsHelpVisible, shortcutsHelpVisible,
cleanUiDetailsVisible, cleanUiDetailsVisible,
isFocused, isFocused,
@@ -2212,7 +2297,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
historyRemountKey, historyRemountKey,
activeHooks, activeHooks,
messageQueue, messageQueue,
queueErrorMessage,
showApprovalModeIndicator, showApprovalModeIndicator,
allowPlanMode, allowPlanMode,
currentModel, currentModel,
@@ -2253,7 +2337,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
showDebugProfiler, showDebugProfiler,
customDialog, customDialog,
copyModeEnabled, copyModeEnabled,
transientMessage,
bannerData, bannerData,
bannerVisible, bannerVisible,
terminalBackgroundColor: config.getTerminalBackground(), terminalBackgroundColor: config.getTerminalBackground(),
@@ -2264,7 +2347,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
isBackgroundShellListOpen, isBackgroundShellListOpen,
adminSettingsChanged, adminSettingsChanged,
newAgents, newAgents,
showIsExpandableHint,
hintMode: hintMode:
config.isModelSteeringEnabled() && config.isModelSteeringEnabled() &&
isToolExecuting([ isToolExecuting([
@@ -2272,6 +2354,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
...pendingGeminiHistoryItems, ...pendingGeminiHistoryItems,
]), ]),
hintBuffer: '', hintBuffer: '',
transientMessage: transientMessage
? { message: transientMessage.message, type: transientMessage.type }
: null,
}), }),
[ [
isThemeDialogOpen, isThemeDialogOpen,
@@ -2291,7 +2376,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
isSettingsDialogOpen, isSettingsDialogOpen,
isSessionBrowserOpen, isSessionBrowserOpen,
isModelDialogOpen, isModelDialogOpen,
isAgentConfigDialogOpen,
selectedAgentName, selectedAgentName,
selectedAgentDisplayName, selectedAgentDisplayName,
selectedAgentDefinition, selectedAgentDefinition,
@@ -2329,9 +2413,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
filteredConsoleMessages, filteredConsoleMessages,
ideContextState, ideContextState,
renderMarkdown, renderMarkdown,
ctrlCPressCount,
ctrlDPressCount,
showEscapePrompt,
shortcutsHelpVisible, shortcutsHelpVisible,
cleanUiDetailsVisible, cleanUiDetailsVisible,
isFocused, isFocused,
@@ -2340,7 +2421,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
historyRemountKey, historyRemountKey,
activeHooks, activeHooks,
messageQueue, messageQueue,
queueErrorMessage,
showApprovalModeIndicator, showApprovalModeIndicator,
allowPlanMode, allowPlanMode,
userTier, userTier,
@@ -2392,7 +2472,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
backgroundShells, backgroundShells,
adminSettingsChanged, adminSettingsChanged,
newAgents, newAgents,
showIsExpandableHint, transientMessage,
], ],
); );
@@ -2423,8 +2503,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleIdePromptComplete, handleIdePromptComplete,
handleFolderTrustSelect, handleFolderTrustSelect,
setIsPolicyUpdateDialogOpen, setIsPolicyUpdateDialogOpen,
setConstrainHeight, handleSetConstrainHeight,
onEscapePromptChange: handleEscapePromptChange, setConstrainHeight: handleSetConstrainHeight,
refreshStatic, refreshStatic,
handleFinalSubmit, handleFinalSubmit,
handleClearScreen, handleClearScreen,
@@ -2437,7 +2517,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
closeSessionBrowser, closeSessionBrowser,
handleResumeSession, handleResumeSession,
handleDeleteSession, handleDeleteSession,
setQueueErrorMessage, setQueueErrorMessage: handleSetQueueErrorMessage,
popAllMessages, popAllMessages,
handleApiKeySubmit, handleApiKeySubmit,
handleApiKeyCancel, handleApiKeyCancel,
@@ -2515,8 +2595,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleIdePromptComplete, handleIdePromptComplete,
handleFolderTrustSelect, handleFolderTrustSelect,
setIsPolicyUpdateDialogOpen, setIsPolicyUpdateDialogOpen,
setConstrainHeight, handleSetConstrainHeight,
handleEscapePromptChange,
refreshStatic, refreshStatic,
handleFinalSubmit, handleFinalSubmit,
handleClearScreen, handleClearScreen,
@@ -2528,7 +2607,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
closeSessionBrowser, closeSessionBrowser,
handleResumeSession, handleResumeSession,
handleDeleteSession, handleDeleteSession,
setQueueErrorMessage, handleSetQueueErrorMessage,
popAllMessages, popAllMessages,
handleApiKeySubmit, handleApiKeySubmit,
handleApiKeyCancel, handleApiKeyCancel,
@@ -2574,8 +2653,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
}} }}
> >
<ToolActionsProvider config={config} toolCalls={allToolCalls}> <ToolActionsProvider config={config} toolCalls={allToolCalls}>
<ShellFocusContext.Provider value={isFocused}> <ShellFocusContext.Provider value={embeddedShellFocused}>
<App key={`app-${forceRerenderKey}`} /> <Box ref={rootUiRef} flexDirection="column">
<App key={`app-${forceRerenderKey}`} />
</Box>
</ShellFocusContext.Provider> </ShellFocusContext.Provider>
</ToolActionsProvider> </ToolActionsProvider>
</AppContext.Provider> </AppContext.Provider>
@@ -63,13 +63,7 @@ vi.mock('./StatusDisplay.js', () => ({
vi.mock('./ToastDisplay.js', () => ({ vi.mock('./ToastDisplay.js', () => ({
ToastDisplay: () => <Text>ToastDisplay</Text>, ToastDisplay: () => <Text>ToastDisplay</Text>,
shouldShowToast: (uiState: UIState) => shouldShowToast: (uiState: UIState) => Boolean(uiState.transientMessage),
uiState.ctrlCPressedOnce ||
Boolean(uiState.transientMessage) ||
uiState.ctrlDPressedOnce ||
(uiState.showEscapePrompt &&
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
Boolean(uiState.queueErrorMessage),
})); }));
vi.mock('./ContextSummaryDisplay.js', () => ({ vi.mock('./ContextSummaryDisplay.js', () => ({
@@ -175,9 +169,6 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
thought: '', thought: '',
currentLoadingPhrase: '', currentLoadingPhrase: '',
elapsedTime: 0, elapsedTime: 0,
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
showEscapePrompt: false,
shortcutsHelpVisible: false, shortcutsHelpVisible: false,
cleanUiDetailsVisible: true, cleanUiDetailsVisible: true,
ideContextState: null, ideContextState: null,
@@ -539,10 +530,7 @@ describe('Composer', () => {
describe('Context and Status Display', () => { describe('Context and Status Display', () => {
it('shows StatusDisplay and ApprovalModeIndicator in normal state', async () => { it('shows StatusDisplay and ApprovalModeIndicator in normal state', async () => {
const uiState = createMockUIState({ const uiState = createMockUIState({
ctrlCPressedOnce: false, });
ctrlDPressedOnce: false,
showEscapePrompt: false,
});
const { lastFrame } = await renderComposer(uiState); const { lastFrame } = await renderComposer(uiState);
@@ -554,7 +542,7 @@ describe('Composer', () => {
it('shows ToastDisplay and hides ApprovalModeIndicator when a toast is present', async () => { it('shows ToastDisplay and hides ApprovalModeIndicator when a toast is present', async () => {
const uiState = createMockUIState({ const uiState = createMockUIState({
ctrlCPressedOnce: true, transientMessage: { message: 'test', type: TransientMessageType.Warning },
}); });
const { lastFrame } = await renderComposer(uiState); const { lastFrame } = await renderComposer(uiState);
@@ -567,10 +555,7 @@ describe('Composer', () => {
it('shows ToastDisplay for other toast types', async () => { it('shows ToastDisplay for other toast types', async () => {
const uiState = createMockUIState({ const uiState = createMockUIState({
transientMessage: { transientMessage: { message: 'Warning', type: TransientMessageType.Warning },
text: 'Warning',
type: TransientMessageType.Warning,
},
}); });
const { lastFrame } = await renderComposer(uiState); const { lastFrame } = await renderComposer(uiState);
@@ -646,26 +631,6 @@ describe('Composer', () => {
expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/); expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/);
}); });
it('shows RawMarkdownIndicator when renderMarkdown is false', async () => {
const uiState = createMockUIState({
renderMarkdown: false,
});
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('raw markdown mode');
});
it('does not show RawMarkdownIndicator when renderMarkdown is true', async () => {
const uiState = createMockUIState({
renderMarkdown: true,
});
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('raw markdown mode');
});
it.each([ it.each([
[ApprovalMode.YOLO, 'YOLO'], [ApprovalMode.YOLO, 'YOLO'],
[ApprovalMode.PLAN, 'plan'], [ApprovalMode.PLAN, 'plan'],
@@ -715,18 +680,7 @@ describe('Composer', () => {
expect(output).not.toContain('ShortcutsHint'); 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 () => { it('shows context usage bleed-through when over 60%', async () => {
const model = 'gemini-2.5-pro'; const model = 'gemini-2.5-pro';
@@ -17,7 +17,6 @@ import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js'; import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js'; import { ShellModeIndicator } from './ShellModeIndicator.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
import { ShortcutsHint } from './ShortcutsHint.js'; import { ShortcutsHint } from './ShortcutsHint.js';
import { ShortcutsHelp } from './ShortcutsHelp.js'; import { ShortcutsHelp } from './ShortcutsHelp.js';
import { InputPrompt } from './InputPrompt.js'; import { InputPrompt } from './InputPrompt.js';
@@ -114,7 +113,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
suggestionsVisible && suggestionsPosition === 'above'; suggestionsVisible && suggestionsPosition === 'above';
const showApprovalIndicator = const showApprovalIndicator =
!uiState.shellModeActive && !hideUiDetailsForSuggestions; !uiState.shellModeActive && !hideUiDetailsForSuggestions;
const showRawMarkdownIndicator = !uiState.renderMarkdown;
let modeBleedThrough: { text: string; color: string } | null = null; let modeBleedThrough: { text: string; color: string } | null = null;
switch (showApprovalModeIndicator) { switch (showApprovalModeIndicator) {
case ApprovalMode.YOLO: case ApprovalMode.YOLO:
@@ -378,26 +376,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
<ShellModeIndicator /> <ShellModeIndicator />
</Box> </Box>
)} )}
{showRawMarkdownIndicator && (
<Box
marginLeft={
(showApprovalIndicator ||
uiState.shellModeActive) &&
!isNarrow
? 1
: 0
}
marginTop={
(showApprovalIndicator ||
uiState.shellModeActive) &&
!isNarrow
? 1
: 0
}
>
<RawMarkdownIndicator />
</Box>
)}
</> </>
)} )}
</Box> </Box>
@@ -448,7 +426,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
shellModeActive={uiState.shellModeActive} shellModeActive={uiState.shellModeActive}
setShellModeActive={uiActions.setShellModeActive} setShellModeActive={uiActions.setShellModeActive}
approvalMode={showApprovalModeIndicator} approvalMode={showApprovalModeIndicator}
onEscapePromptChange={uiActions.onEscapePromptChange}
focus={isFocused} focus={isFocused}
vimHandleInput={uiActions.vimHandleInput} vimHandleInput={uiActions.vimHandleInput}
isEmbeddedShellFocused={uiState.embeddedShellFocused} isEmbeddedShellFocused={uiState.embeddedShellFocused}
@@ -8,6 +8,7 @@ import { render } from '../../test-utils/render.js';
import { ExitWarning } from './ExitWarning.js'; import { ExitWarning } from './ExitWarning.js';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useUIState, type UIState } from '../contexts/UIStateContext.js'; import { useUIState, type UIState } from '../contexts/UIStateContext.js';
import { TransientMessageType } from '../../utils/events.js';
vi.mock('../contexts/UIStateContext.js'); vi.mock('../contexts/UIStateContext.js');
@@ -21,8 +22,7 @@ describe('ExitWarning', () => {
it('renders nothing by default', async () => { it('renders nothing by default', async () => {
mockUseUIState.mockReturnValue({ mockUseUIState.mockReturnValue({
dialogsVisible: false, dialogsVisible: false,
ctrlCPressedOnce: false, transientMessage: null,
ctrlDPressedOnce: false,
} as unknown as UIState); } as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />); const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady(); await waitUntilReady();
@@ -30,35 +30,41 @@ describe('ExitWarning', () => {
unmount(); 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({ mockUseUIState.mockReturnValue({
dialogsVisible: true, dialogsVisible: true,
ctrlCPressedOnce: true, transientMessage: {
ctrlDPressedOnce: false, message: 'Test Warning',
type: TransientMessageType.Warning,
},
} as unknown as UIState); } as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />); const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady(); await waitUntilReady();
expect(lastFrame()).toContain('Press Ctrl+C again to exit'); expect(lastFrame()).toContain('Test Warning');
unmount(); 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({ mockUseUIState.mockReturnValue({
dialogsVisible: true, dialogsVisible: true,
ctrlCPressedOnce: false, transientMessage: {
ctrlDPressedOnce: true, message: 'Test Hint',
type: TransientMessageType.Hint,
},
} as unknown as UIState); } as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />); const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady(); await waitUntilReady();
expect(lastFrame()).toContain('Press Ctrl+D again to exit'); expect(lastFrame({ allowEmpty: true })).toBe('');
unmount(); unmount();
}); });
it('renders nothing if dialogs are not visible', async () => { it('renders nothing if dialogs are not visible', async () => {
mockUseUIState.mockReturnValue({ mockUseUIState.mockReturnValue({
dialogsVisible: false, dialogsVisible: false,
ctrlCPressedOnce: true, transientMessage: {
ctrlDPressedOnce: true, message: 'Test Warning',
type: TransientMessageType.Warning,
},
} as unknown as UIState); } as unknown as UIState);
const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />); const { lastFrame, waitUntilReady, unmount } = render(<ExitWarning />);
await waitUntilReady(); await waitUntilReady();
+16 -14
View File
@@ -8,22 +8,24 @@ import type React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js'; import { useUIState } from '../contexts/UIStateContext.js';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { TransientMessageType } from '../../utils/events.js';
export const ExitWarning: React.FC = () => { export const ExitWarning: React.FC = () => {
const uiState = useUIState(); const uiState = useUIState();
return ( if (!uiState.dialogsVisible) {
<> return null;
{uiState.dialogsVisible && uiState.ctrlCPressedOnce && ( }
<Box marginTop={1}>
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
</Box>
)}
{uiState.dialogsVisible && uiState.ctrlDPressedOnce && ( if (
<Box marginTop={1}> uiState.transientMessage?.type === TransientMessageType.Warning &&
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text> uiState.transientMessage.message
</Box> ) {
)} return (
</> <Box marginTop={1}>
); <Text color={theme.status.warning}>{uiState.transientMessage.message}</Text>
</Box>
);
}
return null;
}; };
@@ -1,48 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
describe('RawMarkdownIndicator', () => {
const originalPlatform = process.platform;
beforeEach(() => vi.stubEnv('FORCE_GENERIC_KEYBINDING_HINTS', ''));
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
vi.unstubAllEnvs();
});
it('renders correct key binding for darwin', async () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
});
const { lastFrame, waitUntilReady, unmount } = render(
<RawMarkdownIndicator />,
);
await waitUntilReady();
expect(lastFrame()).toContain('raw markdown mode');
expect(lastFrame()).toContain('Option+M to toggle');
unmount();
});
it('renders correct key binding for other platforms', async () => {
Object.defineProperty(process, 'platform', {
value: 'linux',
});
const { lastFrame, waitUntilReady, unmount } = render(
<RawMarkdownIndicator />,
);
await waitUntilReady();
expect(lastFrame()).toContain('raw markdown mode');
expect(lastFrame()).toContain('Alt+M to toggle');
unmount();
});
});
@@ -1,23 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { formatCommand } from '../utils/keybindingUtils.js';
import { Command } from '../../config/keyBindings.js';
export const RawMarkdownIndicator: React.FC = () => {
const modKey = formatCommand(Command.TOGGLE_MARKDOWN);
return (
<Box>
<Text>
raw markdown mode
<Text color={theme.text.secondary}> ({modKey} to toggle) </Text>
</Text>
</Box>
);
};
@@ -28,84 +28,20 @@ describe('ToastDisplay', () => {
describe('shouldShowToast', () => { describe('shouldShowToast', () => {
const baseState: Partial<UIState> = { const baseState: Partial<UIState> = {
ctrlCPressedOnce: false,
transientMessage: null, transientMessage: null,
ctrlDPressedOnce: false,
showEscapePrompt: false,
buffer: { text: '' } as TextBuffer, buffer: { text: '' } as TextBuffer,
history: [] as HistoryItem[], history: [] as HistoryItem[],
queueErrorMessage: null,
showIsExpandableHint: false,
}; };
it('returns false for default state', () => { it('returns false for default state', () => {
expect(shouldShowToast(baseState as UIState)).toBe(false); 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', () => { it('returns true when transientMessage is present', () => {
expect( expect(
shouldShowToast({ shouldShowToast({
...baseState, ...baseState,
transientMessage: { text: 'test', type: TransientMessageType.Hint }, transientMessage: { message: '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',
} as UIState), } as UIState),
).toBe(true); ).toBe(true);
}); });
@@ -117,18 +53,10 @@ describe('ToastDisplay', () => {
expect(lastFrame({ allowEmpty: true })).toBe(''); 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 () => { it('renders warning message', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({ const { lastFrame, waitUntilReady } = renderToastDisplay({
transientMessage: { transientMessage: {
text: 'This is a warning', message: 'This is a warning',
type: TransientMessageType.Warning, type: TransientMessageType.Warning,
}, },
}); });
@@ -139,7 +67,7 @@ describe('ToastDisplay', () => {
it('renders hint message', async () => { it('renders hint message', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({ const { lastFrame, waitUntilReady } = renderToastDisplay({
transientMessage: { transientMessage: {
text: 'This is a hint', message: 'This is a hint',
type: TransientMessageType.Hint, type: TransientMessageType.Hint,
}, },
}); });
@@ -147,59 +75,36 @@ describe('ToastDisplay', () => {
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
}); });
it('renders Ctrl+D prompt', async () => { it('renders Error transient message', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({ const { lastFrame, waitUntilReady } = renderToastDisplay({
ctrlDPressedOnce: true, transientMessage: {
message: 'Error Message',
type: TransientMessageType.Error,
},
}); });
await waitUntilReady(); await waitUntilReady();
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
}); });
it('renders Escape prompt when buffer is empty', async () => { it('renders Hint transient message', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({ const { lastFrame, waitUntilReady } = renderToastDisplay({
showEscapePrompt: true, transientMessage: {
history: [{ id: 1, type: 'user', text: 'test' }] as HistoryItem[], message: 'Hint Message',
type: TransientMessageType.Hint,
},
}); });
await waitUntilReady(); await waitUntilReady();
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
}); });
it('renders Escape prompt when buffer is NOT empty', async () => { it('renders Accent transient message', async () => {
const { lastFrame, waitUntilReady } = renderToastDisplay({ const { lastFrame, waitUntilReady } = renderToastDisplay({
showEscapePrompt: true, transientMessage: {
buffer: { text: 'some text' } as TextBuffer, message: 'Accent Message',
type: TransientMessageType.Accent,
},
}); });
await waitUntilReady(); await waitUntilReady();
expect(lastFrame()).toMatchSnapshot(); 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'; import { TransientMessageType } from '../../utils/events.js';
export function shouldShowToast(uiState: UIState): boolean { export function shouldShowToast(uiState: UIState): boolean {
return ( return Boolean(uiState.transientMessage);
uiState.ctrlCPressedOnce ||
Boolean(uiState.transientMessage) ||
uiState.ctrlDPressedOnce ||
(uiState.showEscapePrompt &&
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
Boolean(uiState.queueErrorMessage) ||
uiState.showIsExpandableHint
);
} }
export const ToastDisplay: React.FC = () => { export const ToastDisplay: React.FC = () => {
const uiState = useUIState(); const uiState = useUIState();
if (uiState.ctrlCPressedOnce) { if (
uiState.transientMessage?.type === TransientMessageType.Warning &&
uiState.transientMessage.message
) {
return ( return (
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text> <Text color={theme.status.warning}>{uiState.transientMessage.message}</Text>
); );
} }
if ( if (
uiState.transientMessage?.type === TransientMessageType.Warning && uiState.transientMessage?.type === TransientMessageType.Error &&
uiState.transientMessage.text uiState.transientMessage.message
) { ) {
return ( return (
<Text color={theme.status.warning}>{uiState.transientMessage.text}</Text> <Text color={theme.status.error}>{uiState.transientMessage.message}</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>
); );
} }
if ( if (
uiState.transientMessage?.type === TransientMessageType.Hint && uiState.transientMessage?.type === TransientMessageType.Hint &&
uiState.transientMessage.text uiState.transientMessage.message
) { ) {
return ( return (
<Text color={theme.text.secondary}>{uiState.transientMessage.text}</Text> <Text color={theme.text.secondary}>{uiState.transientMessage.message}</Text>
); );
} }
if (uiState.queueErrorMessage) { if (
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>; uiState.transientMessage?.type === TransientMessageType.Accent &&
} uiState.transientMessage.message
) {
if (uiState.showIsExpandableHint) {
const action = uiState.constrainHeight ? 'show more' : 'collapse';
return ( return (
<Text color={theme.text.accent}> <Text color={theme.text.accent}>{uiState.transientMessage.message}</Text>
Press Ctrl+O to {action} lines of the last response
</Text>
); );
} }
@@ -1,27 +1,17 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ToastDisplay > renders Ctrl+C prompt 1`] = ` exports[`ToastDisplay > renders Accent transient message 1`] = `
"Press Ctrl+C again to exit. "Accent Message
" "
`; `;
exports[`ToastDisplay > renders Ctrl+D prompt 1`] = ` exports[`ToastDisplay > renders Error transient message 1`] = `
"Press Ctrl+D again to exit. "Error Message
" "
`; `;
exports[`ToastDisplay > renders Escape prompt when buffer is NOT empty 1`] = ` exports[`ToastDisplay > renders Hint transient message 1`] = `
"Press Esc again to clear prompt. "Hint Message
"
`;
exports[`ToastDisplay > renders Escape prompt when buffer is empty 1`] = `
"Press Esc again to rewind.
"
`;
exports[`ToastDisplay > renders Queue Error Message 1`] = `
"Queue Error
" "
`; `;
@@ -55,7 +55,6 @@ export interface UIActions {
handleFolderTrustSelect: (choice: FolderTrustChoice) => void; handleFolderTrustSelect: (choice: FolderTrustChoice) => void;
setIsPolicyUpdateDialogOpen: (value: boolean) => void; setIsPolicyUpdateDialogOpen: (value: boolean) => void;
setConstrainHeight: (value: boolean) => void; setConstrainHeight: (value: boolean) => void;
onEscapePromptChange: (show: boolean) => void;
refreshStatic: () => void; refreshStatic: () => void;
handleFinalSubmit: (value: string) => Promise<void>; handleFinalSubmit: (value: string) => Promise<void>;
handleClearScreen: () => void; handleClearScreen: () => void;
@@ -5,6 +5,7 @@
*/ */
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
import { type TransientMessageType } from '../../utils/events.js';
import type { import type {
HistoryItem, HistoryItem,
ThoughtSummary, ThoughtSummary,
@@ -31,7 +32,6 @@ import type {
FolderDiscoveryResults, FolderDiscoveryResults,
PolicyUpdateConfirmationRequest, PolicyUpdateConfirmationRequest,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { type TransientMessageType } from '../../utils/events.js';
import type { DOMElement } from 'ink'; import type { DOMElement } from 'ink';
import type { SessionStatsState } from '../contexts/SessionContext.js'; import type { SessionStatsState } from '../contexts/SessionContext.js';
import type { ExtensionUpdateState } from '../state/extensions.js'; import type { ExtensionUpdateState } from '../state/extensions.js';
@@ -161,9 +161,6 @@ export interface UIState {
filteredConsoleMessages: ConsoleMessageItem[]; filteredConsoleMessages: ConsoleMessageItem[];
ideContextState: IdeContext | undefined; ideContextState: IdeContext | undefined;
renderMarkdown: boolean; renderMarkdown: boolean;
ctrlCPressedOnce: boolean;
ctrlDPressedOnce: boolean;
showEscapePrompt: boolean;
shortcutsHelpVisible: boolean; shortcutsHelpVisible: boolean;
cleanUiDetailsVisible: boolean; cleanUiDetailsVisible: boolean;
elapsedTime: number; elapsedTime: number;
@@ -171,7 +168,6 @@ export interface UIState {
historyRemountKey: number; historyRemountKey: number;
activeHooks: ActiveHook[]; activeHooks: ActiveHook[];
messageQueue: string[]; messageQueue: string[];
queueErrorMessage: string | null;
showApprovalModeIndicator: ApprovalMode; showApprovalModeIndicator: ApprovalMode;
allowPlanMode: boolean; allowPlanMode: boolean;
// Quota-related state // Quota-related state
@@ -220,11 +216,10 @@ export interface UIState {
isBackgroundShellListOpen: boolean; isBackgroundShellListOpen: boolean;
adminSettingsChanged: boolean; adminSettingsChanged: boolean;
newAgents: AgentDefinition[] | null; newAgents: AgentDefinition[] | null;
showIsExpandableHint: boolean;
hintMode: boolean; hintMode: boolean;
hintBuffer: string; hintBuffer: string;
transientMessage: { transientMessage: {
text: string; message: string;
type: TransientMessageType; type: TransientMessageType;
} | null; } | null;
} }
@@ -4,8 +4,11 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { useState, useLayoutEffect, type RefObject } from 'react';
import { useConfig } from '../contexts/ConfigContext.js'; import { useConfig } from '../contexts/ConfigContext.js';
import type { Config } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core';
import { type DOMElement, measureElement } from 'ink';
import { useTerminalSize } from './useTerminalSize.js';
export const isAlternateBufferEnabled = (config: Config): boolean => export const isAlternateBufferEnabled = (config: Config): boolean =>
config.getUseAlternateBuffer(); config.getUseAlternateBuffer();
@@ -15,3 +18,28 @@ export const useAlternateBuffer = (): boolean => {
const config = useConfig(); const config = useConfig();
return isAlternateBufferEnabled(config); return isAlternateBufferEnabled(config);
}; };
export const useLegacyNonAlternateBufferMode = (
rootUiRef: RefObject<DOMElement | null>,
isAlternateBuffer: boolean,
): boolean => {
const { rows: terminalHeight } = useTerminalSize();
const [isOverflowing, setIsOverflowing] = useState(false);
useLayoutEffect(() => {
if (isAlternateBuffer || !rootUiRef.current) {
if (isOverflowing) setIsOverflowing(false);
return;
}
const measurement = measureElement(rootUiRef.current);
// If the interactive UI is taller than the terminal height, we have a problem.
const currentlyOverflowing = measurement.height >= terminalHeight;
if (currentlyOverflowing !== isOverflowing) {
setIsOverflowing(currentlyOverflowing);
}
}, [isAlternateBuffer, rootUiRef, terminalHeight, isOverflowing]);
return !isAlternateBuffer && isOverflowing;
};
+26 -21
View File
@@ -7,36 +7,41 @@
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
/** /**
* A hook to manage a state value that automatically resets to null after a duration. * A hook to manage a state value that automatically resets to null after a specified duration.
* Useful for transient UI messages, hints, or warnings. * 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 [message, setMessage] = useState<T | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | 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( const showMessage = useCallback(
(msg: T | null) => { (msg: T | null, durationMs?: number) => {
setMessage(msg); setMessage(msg);
if (timeoutRef.current) { currentDurationRef.current = durationMs ?? defaultDurationMs;
clearTimeout(timeoutRef.current);
}
if (msg !== null) {
timeoutRef.current = setTimeout(() => {
setMessage(null);
}, durationMs);
}
}, },
[durationMs], [defaultDurationMs],
); );
useEffect( useEffect(() => {
() => () => { startTimer();
if (timeoutRef.current) { return () => {
clearTimeout(timeoutRef.current); if (timeoutRef.current) clearTimeout(timeoutRef.current);
} };
}, }, [startTimer]);
[],
);
return [message, showMessage] as const; return [message, showMessage] as const;
} }
+3
View File
@@ -9,11 +9,14 @@ import { EventEmitter } from 'node:events';
export enum TransientMessageType { export enum TransientMessageType {
Warning = 'warning', Warning = 'warning',
Hint = 'hint', Hint = 'hint',
Error = 'error',
Accent = 'accent',
} }
export interface TransientMessagePayload { export interface TransientMessagePayload {
message: string; message: string;
type: TransientMessageType; type: TransientMessageType;
durationMs?: number;
} }
export enum AppEvent { export enum AppEvent {