mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8956d77da4 | |||
| 05fe1bce97 |
@@ -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(),
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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!();
|
||||
|
||||
@@ -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);
|
||||
@@ -646,26 +631,6 @@ describe('Composer', () => {
|
||||
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([
|
||||
[ApprovalMode.YOLO, 'YOLO'],
|
||||
[ApprovalMode.PLAN, 'plan'],
|
||||
@@ -715,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';
|
||||
|
||||
@@ -17,7 +17,6 @@ import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
|
||||
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
|
||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
|
||||
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
|
||||
import { ShortcutsHint } from './ShortcutsHint.js';
|
||||
import { ShortcutsHelp } from './ShortcutsHelp.js';
|
||||
import { InputPrompt } from './InputPrompt.js';
|
||||
@@ -114,7 +113,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
suggestionsVisible && suggestionsPosition === 'above';
|
||||
const showApprovalIndicator =
|
||||
!uiState.shellModeActive && !hideUiDetailsForSuggestions;
|
||||
const showRawMarkdownIndicator = !uiState.renderMarkdown;
|
||||
let modeBleedThrough: { text: string; color: string } | null = null;
|
||||
switch (showApprovalModeIndicator) {
|
||||
case ApprovalMode.YOLO:
|
||||
@@ -378,26 +376,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
<ShellModeIndicator />
|
||||
</Box>
|
||||
)}
|
||||
{showRawMarkdownIndicator && (
|
||||
<Box
|
||||
marginLeft={
|
||||
(showApprovalIndicator ||
|
||||
uiState.shellModeActive) &&
|
||||
!isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
marginTop={
|
||||
(showApprovalIndicator ||
|
||||
uiState.shellModeActive) &&
|
||||
!isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<RawMarkdownIndicator />
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
@@ -448,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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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', () => {
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useLayoutEffect, type RefObject } from 'react';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
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 =>
|
||||
config.getUseAlternateBuffer();
|
||||
@@ -15,3 +18,28 @@ export const useAlternateBuffer = (): boolean => {
|
||||
const config = useConfig();
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user