mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
refactor(cli): code review cleanup fix for tab+tab (#18967)
This commit is contained in:
@@ -134,7 +134,6 @@ import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
|||||||
import { type ExtensionManager } from '../config/extension-manager.js';
|
import { type ExtensionManager } from '../config/extension-manager.js';
|
||||||
import { requestConsentInteractive } from '../config/extensions/consent.js';
|
import { requestConsentInteractive } from '../config/extensions/consent.js';
|
||||||
import { useSessionBrowser } from './hooks/useSessionBrowser.js';
|
import { useSessionBrowser } from './hooks/useSessionBrowser.js';
|
||||||
import { persistentState } from '../utils/persistentState.js';
|
|
||||||
import { useSessionResume } from './hooks/useSessionResume.js';
|
import { useSessionResume } from './hooks/useSessionResume.js';
|
||||||
import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
|
import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
|
||||||
import { useSessionRetentionCheck } from './hooks/useSessionRetentionCheck.js';
|
import { useSessionRetentionCheck } from './hooks/useSessionRetentionCheck.js';
|
||||||
@@ -189,8 +188,11 @@ interface AppContainerProps {
|
|||||||
resumedSessionData?: ResumedSessionData;
|
resumedSessionData?: ResumedSessionData;
|
||||||
}
|
}
|
||||||
|
|
||||||
const APPROVAL_MODE_REVEAL_DURATION_MS = 1200;
|
import { useRepeatedKeyPress } from './hooks/useRepeatedKeyPress.js';
|
||||||
const FOCUS_UI_ENABLED_STATE_KEY = 'focusUiEnabled';
|
import {
|
||||||
|
useVisibilityToggle,
|
||||||
|
APPROVAL_MODE_REVEAL_DURATION_MS,
|
||||||
|
} from './hooks/useVisibilityToggle.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The fraction of the terminal width to allocate to the shell.
|
* The fraction of the terminal width to allocate to the shell.
|
||||||
@@ -803,65 +805,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
|
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
|
||||||
() => {},
|
() => {},
|
||||||
);
|
);
|
||||||
const [focusUiEnabledByDefault] = useState(
|
|
||||||
() => persistentState.get(FOCUS_UI_ENABLED_STATE_KEY) === true,
|
|
||||||
);
|
|
||||||
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
|
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
|
||||||
const [cleanUiDetailsVisible, setCleanUiDetailsVisibleState] = useState(
|
|
||||||
!focusUiEnabledByDefault,
|
|
||||||
);
|
|
||||||
const modeRevealTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
||||||
const cleanUiDetailsPinnedRef = useRef(!focusUiEnabledByDefault);
|
|
||||||
|
|
||||||
const clearModeRevealTimeout = useCallback(() => {
|
const {
|
||||||
if (modeRevealTimeoutRef.current) {
|
cleanUiDetailsVisible,
|
||||||
clearTimeout(modeRevealTimeoutRef.current);
|
setCleanUiDetailsVisible,
|
||||||
modeRevealTimeoutRef.current = null;
|
toggleCleanUiDetailsVisible,
|
||||||
}
|
revealCleanUiDetailsTemporarily,
|
||||||
}, []);
|
} = useVisibilityToggle();
|
||||||
|
|
||||||
const persistFocusUiPreference = useCallback((isFullUiVisible: boolean) => {
|
|
||||||
persistentState.set(FOCUS_UI_ENABLED_STATE_KEY, !isFullUiVisible);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setCleanUiDetailsVisible = useCallback(
|
|
||||||
(visible: boolean) => {
|
|
||||||
clearModeRevealTimeout();
|
|
||||||
cleanUiDetailsPinnedRef.current = visible;
|
|
||||||
setCleanUiDetailsVisibleState(visible);
|
|
||||||
persistFocusUiPreference(visible);
|
|
||||||
},
|
|
||||||
[clearModeRevealTimeout, persistFocusUiPreference],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleCleanUiDetailsVisible = useCallback(() => {
|
|
||||||
clearModeRevealTimeout();
|
|
||||||
setCleanUiDetailsVisibleState((visible) => {
|
|
||||||
const nextVisible = !visible;
|
|
||||||
cleanUiDetailsPinnedRef.current = nextVisible;
|
|
||||||
persistFocusUiPreference(nextVisible);
|
|
||||||
return nextVisible;
|
|
||||||
});
|
|
||||||
}, [clearModeRevealTimeout, persistFocusUiPreference]);
|
|
||||||
|
|
||||||
const revealCleanUiDetailsTemporarily = useCallback(
|
|
||||||
(durationMs: number = APPROVAL_MODE_REVEAL_DURATION_MS) => {
|
|
||||||
if (cleanUiDetailsPinnedRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearModeRevealTimeout();
|
|
||||||
setCleanUiDetailsVisibleState(true);
|
|
||||||
modeRevealTimeoutRef.current = setTimeout(() => {
|
|
||||||
if (!cleanUiDetailsPinnedRef.current) {
|
|
||||||
setCleanUiDetailsVisibleState(false);
|
|
||||||
}
|
|
||||||
modeRevealTimeoutRef.current = null;
|
|
||||||
}, durationMs);
|
|
||||||
},
|
|
||||||
[clearModeRevealTimeout],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => () => clearModeRevealTimeout(), [clearModeRevealTimeout]);
|
|
||||||
|
|
||||||
const slashCommandActions = useMemo(
|
const slashCommandActions = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -1396,10 +1347,29 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
const [showFullTodos, setShowFullTodos] = useState<boolean>(false);
|
const [showFullTodos, setShowFullTodos] = useState<boolean>(false);
|
||||||
const [renderMarkdown, setRenderMarkdown] = useState<boolean>(true);
|
const [renderMarkdown, setRenderMarkdown] = useState<boolean>(true);
|
||||||
|
|
||||||
const [ctrlCPressCount, setCtrlCPressCount] = useState(0);
|
const handleExitRepeat = useCallback(
|
||||||
const ctrlCTimerRef = useRef<NodeJS.Timeout | null>(null);
|
(count: number) => {
|
||||||
const [ctrlDPressCount, setCtrlDPressCount] = useState(0);
|
if (count > 2) {
|
||||||
const ctrlDTimerRef = useRef<NodeJS.Timeout | null>(null);
|
recordExitFail(config);
|
||||||
|
}
|
||||||
|
if (count > 1) {
|
||||||
|
void handleSlashCommand('/quit', undefined, undefined, false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[config, handleSlashCommand],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { pressCount: ctrlCPressCount, handlePress: handleCtrlCPress } =
|
||||||
|
useRepeatedKeyPress({
|
||||||
|
windowMs: WARNING_PROMPT_DURATION_MS,
|
||||||
|
onRepeat: handleExitRepeat,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { pressCount: ctrlDPressCount, handlePress: handleCtrlDPress } =
|
||||||
|
useRepeatedKeyPress({
|
||||||
|
windowMs: WARNING_PROMPT_DURATION_MS,
|
||||||
|
onRepeat: handleExitRepeat,
|
||||||
|
});
|
||||||
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
|
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
|
||||||
const [ideContextState, setIdeContextState] = useState<
|
const [ideContextState, setIdeContextState] = useState<
|
||||||
IdeContext | undefined
|
IdeContext | undefined
|
||||||
@@ -1478,9 +1448,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
if (tabFocusTimeoutRef.current) {
|
if (tabFocusTimeoutRef.current) {
|
||||||
clearTimeout(tabFocusTimeoutRef.current);
|
clearTimeout(tabFocusTimeoutRef.current);
|
||||||
}
|
}
|
||||||
if (modeRevealTimeoutRef.current) {
|
|
||||||
clearTimeout(modeRevealTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [showTransientMessage]);
|
}, [showTransientMessage]);
|
||||||
|
|
||||||
@@ -1553,44 +1520,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
};
|
};
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (ctrlCTimerRef.current) {
|
|
||||||
clearTimeout(ctrlCTimerRef.current);
|
|
||||||
ctrlCTimerRef.current = null;
|
|
||||||
}
|
|
||||||
if (ctrlCPressCount > 2) {
|
|
||||||
recordExitFail(config);
|
|
||||||
}
|
|
||||||
if (ctrlCPressCount > 1) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
handleSlashCommand('/quit', undefined, undefined, false);
|
|
||||||
} else if (ctrlCPressCount > 0) {
|
|
||||||
ctrlCTimerRef.current = setTimeout(() => {
|
|
||||||
setCtrlCPressCount(0);
|
|
||||||
ctrlCTimerRef.current = null;
|
|
||||||
}, WARNING_PROMPT_DURATION_MS);
|
|
||||||
}
|
|
||||||
}, [ctrlCPressCount, config, setCtrlCPressCount, handleSlashCommand]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (ctrlDTimerRef.current) {
|
|
||||||
clearTimeout(ctrlDTimerRef.current);
|
|
||||||
ctrlCTimerRef.current = null;
|
|
||||||
}
|
|
||||||
if (ctrlDPressCount > 2) {
|
|
||||||
recordExitFail(config);
|
|
||||||
}
|
|
||||||
if (ctrlDPressCount > 1) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
handleSlashCommand('/quit', undefined, undefined, false);
|
|
||||||
} else if (ctrlDPressCount > 0) {
|
|
||||||
ctrlDTimerRef.current = setTimeout(() => {
|
|
||||||
setCtrlDPressCount(0);
|
|
||||||
ctrlDTimerRef.current = null;
|
|
||||||
}, WARNING_PROMPT_DURATION_MS);
|
|
||||||
}
|
|
||||||
}, [ctrlDPressCount, config, setCtrlDPressCount, handleSlashCommand]);
|
|
||||||
|
|
||||||
const handleEscapePromptChange = useCallback((showPrompt: boolean) => {
|
const handleEscapePromptChange = useCallback((showPrompt: boolean) => {
|
||||||
setShowEscapePrompt(showPrompt);
|
setShowEscapePrompt(showPrompt);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1637,10 +1566,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
// This should happen regardless of the count.
|
// This should happen regardless of the count.
|
||||||
cancelOngoingRequest?.();
|
cancelOngoingRequest?.();
|
||||||
|
|
||||||
setCtrlCPressCount((prev) => prev + 1);
|
handleCtrlCPress();
|
||||||
return true;
|
return true;
|
||||||
} else if (keyMatchers[Command.EXIT](key)) {
|
} else if (keyMatchers[Command.EXIT](key)) {
|
||||||
setCtrlDPressCount((prev) => prev + 1);
|
handleCtrlDPress();
|
||||||
return true;
|
return true;
|
||||||
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
|
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
|
||||||
handleSuspend();
|
handleSuspend();
|
||||||
@@ -1781,8 +1710,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
setShowErrorDetails,
|
setShowErrorDetails,
|
||||||
config,
|
config,
|
||||||
ideContextState,
|
ideContextState,
|
||||||
setCtrlCPressCount,
|
handleCtrlCPress,
|
||||||
setCtrlDPressCount,
|
handleCtrlDPress,
|
||||||
handleSlashCommand,
|
handleSlashCommand,
|
||||||
cancelOngoingRequest,
|
cancelOngoingRequest,
|
||||||
activePtyId,
|
activePtyId,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { act } from 'react';
|
||||||
import { renderWithProviders } from '../../test-utils/render.js';
|
import { renderWithProviders } from '../../test-utils/render.js';
|
||||||
import {
|
import {
|
||||||
describe,
|
describe,
|
||||||
@@ -318,9 +319,10 @@ describe('AuthDialog', () => {
|
|||||||
renderWithProviders(<AuthDialog {...props} />);
|
renderWithProviders(<AuthDialog {...props} />);
|
||||||
const { onSelect: handleAuthSelect } =
|
const { onSelect: handleAuthSelect } =
|
||||||
mockedRadioButtonSelect.mock.calls[0][0];
|
mockedRadioButtonSelect.mock.calls[0][0];
|
||||||
await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE);
|
await act(async () => {
|
||||||
|
await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE);
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
|
});
|
||||||
|
|
||||||
expect(mockedRunExitCleanup).toHaveBeenCalled();
|
expect(mockedRunExitCleanup).toHaveBeenCalled();
|
||||||
expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
|
expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
|
||||||
|
|||||||
@@ -138,11 +138,15 @@ describe('useAuth', () => {
|
|||||||
},
|
},
|
||||||
}) as LoadedSettings;
|
}) as LoadedSettings;
|
||||||
|
|
||||||
it('should initialize with Unauthenticated state', () => {
|
it('should initialize with Unauthenticated state', async () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
||||||
);
|
);
|
||||||
expect(result.current.authState).toBe(AuthState.Unauthenticated);
|
expect(result.current.authState).toBe(AuthState.Unauthenticated);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.authState).toBe(AuthState.Authenticated);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set error if no auth type is selected and no env key', async () => {
|
it('should set error if no auth type is selected and no env key', async () => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useState, useEffect, useMemo } from 'react';
|
|||||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||||
import {
|
import {
|
||||||
ApprovalMode,
|
ApprovalMode,
|
||||||
tokenLimit,
|
checkExhaustive,
|
||||||
CoreToolCallStatus,
|
CoreToolCallStatus,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||||
@@ -38,6 +38,7 @@ import { StreamingState, type HistoryItemToolGroup } from '../types.js';
|
|||||||
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
||||||
import { TodoTray } from './messages/Todo.js';
|
import { TodoTray } from './messages/Todo.js';
|
||||||
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
|
||||||
|
import { isContextUsageHigh } from '../utils/contextUsage.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
|
|
||||||
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||||
@@ -114,30 +115,41 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
|||||||
const showApprovalIndicator =
|
const showApprovalIndicator =
|
||||||
!uiState.shellModeActive && !hideUiDetailsForSuggestions;
|
!uiState.shellModeActive && !hideUiDetailsForSuggestions;
|
||||||
const showRawMarkdownIndicator = !uiState.renderMarkdown;
|
const showRawMarkdownIndicator = !uiState.renderMarkdown;
|
||||||
const modeBleedThrough =
|
let modeBleedThrough: { text: string; color: string } | null = null;
|
||||||
showApprovalModeIndicator === ApprovalMode.YOLO
|
switch (showApprovalModeIndicator) {
|
||||||
? { text: 'YOLO', color: theme.status.error }
|
case ApprovalMode.YOLO:
|
||||||
: showApprovalModeIndicator === ApprovalMode.PLAN
|
modeBleedThrough = { text: 'YOLO', color: theme.status.error };
|
||||||
? { text: 'plan', color: theme.status.success }
|
break;
|
||||||
: showApprovalModeIndicator === ApprovalMode.AUTO_EDIT
|
case ApprovalMode.PLAN:
|
||||||
? { text: 'auto edit', color: theme.status.warning }
|
modeBleedThrough = { text: 'plan', color: theme.status.success };
|
||||||
: null;
|
break;
|
||||||
|
case ApprovalMode.AUTO_EDIT:
|
||||||
|
modeBleedThrough = { text: 'auto edit', color: theme.status.warning };
|
||||||
|
break;
|
||||||
|
case ApprovalMode.DEFAULT:
|
||||||
|
modeBleedThrough = null;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
checkExhaustive(showApprovalModeIndicator);
|
||||||
|
modeBleedThrough = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const hideMinimalModeHintWhileBusy =
|
const hideMinimalModeHintWhileBusy =
|
||||||
!showUiDetails && (showLoadingIndicator || hasPendingActionRequired);
|
!showUiDetails && (showLoadingIndicator || hasPendingActionRequired);
|
||||||
const minimalModeBleedThrough = hideMinimalModeHintWhileBusy
|
const minimalModeBleedThrough = hideMinimalModeHintWhileBusy
|
||||||
? null
|
? null
|
||||||
: modeBleedThrough;
|
: modeBleedThrough;
|
||||||
const hasMinimalStatusBleedThrough = shouldShowToast(uiState);
|
const hasMinimalStatusBleedThrough = shouldShowToast(uiState);
|
||||||
const contextTokenLimit =
|
|
||||||
typeof uiState.currentModel === 'string' && uiState.currentModel.length > 0
|
|
||||||
? tokenLimit(uiState.currentModel)
|
|
||||||
: 0;
|
|
||||||
const showMinimalContextBleedThrough =
|
const showMinimalContextBleedThrough =
|
||||||
!settings.merged.ui.footer.hideContextPercentage &&
|
!settings.merged.ui.footer.hideContextPercentage &&
|
||||||
typeof uiState.currentModel === 'string' &&
|
isContextUsageHigh(
|
||||||
uiState.currentModel.length > 0 &&
|
uiState.sessionStats.lastPromptTokenCount,
|
||||||
contextTokenLimit > 0 &&
|
typeof uiState.currentModel === 'string'
|
||||||
uiState.sessionStats.lastPromptTokenCount / contextTokenLimit > 0.6;
|
? uiState.currentModel
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
const hideShortcutsHintForSuggestions = hideUiDetailsForSuggestions;
|
const hideShortcutsHintForSuggestions = hideUiDetailsForSuggestions;
|
||||||
const showShortcutsHint =
|
const showShortcutsHint =
|
||||||
settings.merged.ui.showShortcutsHint &&
|
settings.merged.ui.showShortcutsHint &&
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import { Text } from 'ink';
|
import { Text } from 'ink';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { tokenLimit } from '@google/gemini-cli-core';
|
import { getContextUsagePercentage } from '../utils/contextUsage.js';
|
||||||
|
|
||||||
export const ContextUsageDisplay = ({
|
export const ContextUsageDisplay = ({
|
||||||
promptTokenCount,
|
promptTokenCount,
|
||||||
@@ -17,7 +17,7 @@ export const ContextUsageDisplay = ({
|
|||||||
model: string;
|
model: string;
|
||||||
terminalWidth: number;
|
terminalWidth: number;
|
||||||
}) => {
|
}) => {
|
||||||
const percentage = promptTokenCount / tokenLimit(model);
|
const percentage = getContextUsagePercentage(promptTokenCount, model);
|
||||||
const percentageLeft = ((1 - percentage) * 100).toFixed(0);
|
const percentageLeft = ((1 - percentage) * 100).toFixed(0);
|
||||||
|
|
||||||
const label = terminalWidth < 100 ? '%' : '% context left';
|
const label = terminalWidth < 100 ? '%' : '% context left';
|
||||||
|
|||||||
@@ -33,10 +33,15 @@ describe('GeminiRespondingSpinner', () => {
|
|||||||
const mockUseIsScreenReaderEnabled = vi.mocked(useIsScreenReaderEnabled);
|
const mockUseIsScreenReaderEnabled = vi.mocked(useIsScreenReaderEnabled);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockUseIsScreenReaderEnabled.mockReturnValue(false);
|
mockUseIsScreenReaderEnabled.mockReturnValue(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders spinner when responding', () => {
|
it('renders spinner when responding', () => {
|
||||||
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
|
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
|
||||||
const { lastFrame } = render(<GeminiRespondingSpinner />);
|
const { lastFrame } = render(<GeminiRespondingSpinner />);
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
|
|||||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||||
import { shouldDismissShortcutsHelpOnHotkey } from '../utils/shortcutsHelp.js';
|
import { shouldDismissShortcutsHelpOnHotkey } from '../utils/shortcutsHelp.js';
|
||||||
|
import { useRepeatedKeyPress } from '../hooks/useRepeatedKeyPress.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns if the terminal can be trusted to handle paste events atomically
|
* Returns if the terminal can be trusted to handle paste events atomically
|
||||||
@@ -227,10 +228,31 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
shortcutsHelpVisible,
|
shortcutsHelpVisible,
|
||||||
} = useUIState();
|
} = useUIState();
|
||||||
const [suppressCompletion, setSuppressCompletion] = useState(false);
|
const [suppressCompletion, setSuppressCompletion] = useState(false);
|
||||||
const escPressCount = useRef(0);
|
const { handlePress: registerPlainTabPress, resetCount: resetPlainTabPress } =
|
||||||
const lastPlainTabPressTimeRef = useRef<number | null>(null);
|
useRepeatedKeyPress({
|
||||||
|
windowMs: DOUBLE_TAB_CLEAN_UI_TOGGLE_WINDOW_MS,
|
||||||
|
});
|
||||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||||
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const { handlePress: handleEscPress, resetCount: resetEscapeState } =
|
||||||
|
useRepeatedKeyPress({
|
||||||
|
windowMs: 500,
|
||||||
|
onRepeat: (count) => {
|
||||||
|
if (count === 1) {
|
||||||
|
setShowEscapePrompt(true);
|
||||||
|
} else if (count === 2) {
|
||||||
|
resetEscapeState();
|
||||||
|
if (buffer.text.length > 0) {
|
||||||
|
buffer.setText('');
|
||||||
|
resetCompletionState();
|
||||||
|
} else if (history.length > 0) {
|
||||||
|
onSubmit('/rewind');
|
||||||
|
} else {
|
||||||
|
coreEvents.emitFeedback('info', 'Nothing to rewind to');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onReset: () => setShowEscapePrompt(false),
|
||||||
|
});
|
||||||
const [recentUnsafePasteTime, setRecentUnsafePasteTime] = useState<
|
const [recentUnsafePasteTime, setRecentUnsafePasteTime] = useState<
|
||||||
number | null
|
number | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -284,15 +306,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
|
|
||||||
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
|
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
|
||||||
|
|
||||||
const resetEscapeState = useCallback(() => {
|
|
||||||
if (escapeTimerRef.current) {
|
|
||||||
clearTimeout(escapeTimerRef.current);
|
|
||||||
escapeTimerRef.current = null;
|
|
||||||
}
|
|
||||||
escPressCount.current = 0;
|
|
||||||
setShowEscapePrompt(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Notify parent component about escape prompt state changes
|
// Notify parent component about escape prompt state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onEscapePromptChange) {
|
if (onEscapePromptChange) {
|
||||||
@@ -300,12 +313,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
}
|
}
|
||||||
}, [showEscapePrompt, onEscapePromptChange]);
|
}, [showEscapePrompt, onEscapePromptChange]);
|
||||||
|
|
||||||
// Clear escape prompt timer on unmount
|
// Clear paste timeout on unmount
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
if (escapeTimerRef.current) {
|
|
||||||
clearTimeout(escapeTimerRef.current);
|
|
||||||
}
|
|
||||||
if (pasteTimeoutRef.current) {
|
if (pasteTimeoutRef.current) {
|
||||||
clearTimeout(pasteTimeoutRef.current);
|
clearTimeout(pasteTimeoutRef.current);
|
||||||
}
|
}
|
||||||
@@ -335,8 +345,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
resetReverseSearchCompletionState();
|
resetReverseSearchCompletionState();
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
onSubmit,
|
|
||||||
buffer,
|
buffer,
|
||||||
|
onSubmit,
|
||||||
resetCompletionState,
|
resetCompletionState,
|
||||||
shellModeActive,
|
shellModeActive,
|
||||||
shellHistory,
|
shellHistory,
|
||||||
@@ -639,22 +649,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
commandSearchActive;
|
commandSearchActive;
|
||||||
if (isPlainTab) {
|
if (isPlainTab) {
|
||||||
if (!hasTabCompletionInteraction) {
|
if (!hasTabCompletionInteraction) {
|
||||||
const now = Date.now();
|
if (registerPlainTabPress() === 2) {
|
||||||
const isDoubleTabPress =
|
|
||||||
lastPlainTabPressTimeRef.current !== null &&
|
|
||||||
now - lastPlainTabPressTimeRef.current <=
|
|
||||||
DOUBLE_TAB_CLEAN_UI_TOGGLE_WINDOW_MS;
|
|
||||||
if (isDoubleTabPress) {
|
|
||||||
lastPlainTabPressTimeRef.current = null;
|
|
||||||
toggleCleanUiDetailsVisible();
|
toggleCleanUiDetailsVisible();
|
||||||
|
resetPlainTabPress();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
lastPlainTabPressTimeRef.current = now;
|
|
||||||
} else {
|
} else {
|
||||||
lastPlainTabPressTimeRef.current = null;
|
resetPlainTabPress();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
lastPlainTabPressTimeRef.current = null;
|
resetPlainTabPress();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key.name === 'paste') {
|
if (key.name === 'paste') {
|
||||||
@@ -732,9 +736,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
|
|
||||||
// Reset ESC count and hide prompt on any non-ESC key
|
// Reset ESC count and hide prompt on any non-ESC key
|
||||||
if (key.name !== 'escape') {
|
if (key.name !== 'escape') {
|
||||||
if (escPressCount.current > 0 || showEscapePrompt) {
|
resetEscapeState();
|
||||||
resetEscapeState();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+O to expand/collapse paste placeholders
|
// Ctrl+O to expand/collapse paste placeholders
|
||||||
@@ -798,30 +800,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle double ESC
|
handleEscPress();
|
||||||
if (escPressCount.current === 0) {
|
|
||||||
escPressCount.current = 1;
|
|
||||||
setShowEscapePrompt(true);
|
|
||||||
if (escapeTimerRef.current) {
|
|
||||||
clearTimeout(escapeTimerRef.current);
|
|
||||||
}
|
|
||||||
escapeTimerRef.current = setTimeout(() => {
|
|
||||||
resetEscapeState();
|
|
||||||
}, 500);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second ESC
|
|
||||||
resetEscapeState();
|
|
||||||
if (buffer.text.length > 0) {
|
|
||||||
buffer.setText('');
|
|
||||||
resetCompletionState();
|
|
||||||
return true;
|
|
||||||
} else if (history.length > 0) {
|
|
||||||
onSubmit('/rewind');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
coreEvents.emitFeedback('info', 'Nothing to rewind to');
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1193,7 +1172,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
reverseSearchCompletion,
|
reverseSearchCompletion,
|
||||||
handleClipboardPaste,
|
handleClipboardPaste,
|
||||||
resetCompletionState,
|
resetCompletionState,
|
||||||
showEscapePrompt,
|
|
||||||
resetEscapeState,
|
resetEscapeState,
|
||||||
vimHandleInput,
|
vimHandleInput,
|
||||||
reverseSearchActive,
|
reverseSearchActive,
|
||||||
@@ -1205,16 +1183,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
kittyProtocol.enabled,
|
kittyProtocol.enabled,
|
||||||
shortcutsHelpVisible,
|
shortcutsHelpVisible,
|
||||||
setShortcutsHelpVisible,
|
setShortcutsHelpVisible,
|
||||||
toggleCleanUiDetailsVisible,
|
|
||||||
tryLoadQueuedMessages,
|
tryLoadQueuedMessages,
|
||||||
setBannerVisible,
|
setBannerVisible,
|
||||||
onSubmit,
|
|
||||||
activePtyId,
|
activePtyId,
|
||||||
setEmbeddedShellFocused,
|
setEmbeddedShellFocused,
|
||||||
backgroundShells.size,
|
backgroundShells.size,
|
||||||
backgroundShellHeight,
|
backgroundShellHeight,
|
||||||
history,
|
|
||||||
streamingState,
|
streamingState,
|
||||||
|
handleEscPress,
|
||||||
|
registerPlainTabPress,
|
||||||
|
resetPlainTabPress,
|
||||||
|
toggleCleanUiDetailsVisible,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import type {
|
|||||||
MessageRecord,
|
MessageRecord,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
vi.mock('./CliSpinner.js', () => ({
|
||||||
|
CliSpinner: () => 'MockSpinner',
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../utils/formatters.js', async (importOriginal) => {
|
vi.mock('../utils/formatters.js', async (importOriginal) => {
|
||||||
const original =
|
const original =
|
||||||
await importOriginal<typeof import('../utils/formatters.js')>();
|
await importOriginal<typeof import('../utils/formatters.js')>();
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import { type AnsiOutput, CoreToolCallStatus } from '@google/gemini-cli-core';
|
|||||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||||
import { tryParseJSON } from '../../../utils/jsonoutput.js';
|
import { tryParseJSON } from '../../../utils/jsonoutput.js';
|
||||||
|
|
||||||
|
vi.mock('../GeminiRespondingSpinner.js', () => ({
|
||||||
|
GeminiRespondingSpinner: () => <Text>MockRespondingSpinner</Text>,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../TerminalOutput.js', () => ({
|
vi.mock('../TerminalOutput.js', () => ({
|
||||||
TerminalOutput: function MockTerminalOutput({
|
TerminalOutput: function MockTerminalOutput({
|
||||||
cursor,
|
cursor,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows - for Canceled
|
|||||||
|
|
||||||
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows MockRespondingSpinner for Executing status when streamingState is Responding 1`] = `
|
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows MockRespondingSpinner for Executing status when streamingState is Responding 1`] = `
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||||
│ ⊶ test-tool A tool for testing │
|
│ MockRespondingSpinnertest-tool A tool for testing │
|
||||||
│ │
|
│ │
|
||||||
│ Test result │"
|
│ Test result │"
|
||||||
`;
|
`;
|
||||||
@@ -40,14 +40,14 @@ exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows o for Pending s
|
|||||||
|
|
||||||
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is Idle 1`] = `
|
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is Idle 1`] = `
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||||
│ ⊷ test-tool A tool for testing │
|
│ MockRespondingSpinnertest-tool A tool for testing │
|
||||||
│ │
|
│ │
|
||||||
│ Test result │"
|
│ Test result │"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is WaitingForConfirmation 1`] = `
|
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is WaitingForConfirmation 1`] = `
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||||
│ ⊷ test-tool A tool for testing │
|
│ MockRespondingSpinnertest-tool A tool for testing │
|
||||||
│ │
|
│ │
|
||||||
│ Test result │"
|
│ Test result │"
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -138,8 +138,10 @@ describe('ScrollableList Demo Behavior', () => {
|
|||||||
let listRef: ScrollableListRef<Item> | null = null;
|
let listRef: ScrollableListRef<Item> | null = null;
|
||||||
let lastFrame: () => string | undefined;
|
let lastFrame: () => string | undefined;
|
||||||
|
|
||||||
|
let result: ReturnType<typeof render>;
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
const result = render(
|
result = render(
|
||||||
<TestComponent
|
<TestComponent
|
||||||
onAddItem={(add) => {
|
onAddItem={(add) => {
|
||||||
addItem = add;
|
addItem = add;
|
||||||
@@ -192,6 +194,10 @@ describe('ScrollableList Demo Behavior', () => {
|
|||||||
expect(lastFrame!()).toContain('Count: 1003');
|
expect(lastFrame!()).toContain('Count: 1003');
|
||||||
});
|
});
|
||||||
expect(lastFrame!()).not.toContain('Item 1003');
|
expect(lastFrame!()).not.toContain('Item 1003');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display sticky header when scrolled past the item', async () => {
|
it('should display sticky header when scrolled past the item', async () => {
|
||||||
@@ -243,8 +249,9 @@ describe('ScrollableList Demo Behavior', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let lastFrame: () => string | undefined;
|
let lastFrame: () => string | undefined;
|
||||||
|
let result: ReturnType<typeof render>;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
const result = render(<StickyTestComponent />);
|
result = render(<StickyTestComponent />);
|
||||||
lastFrame = result.lastFrame;
|
lastFrame = result.lastFrame;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -286,6 +293,10 @@ describe('ScrollableList Demo Behavior', () => {
|
|||||||
expect(lastFrame!()).toContain('[Normal] Item 1');
|
expect(lastFrame!()).toContain('[Normal] Item 1');
|
||||||
});
|
});
|
||||||
expect(lastFrame!()).not.toContain('[STICKY] Item 1');
|
expect(lastFrame!()).not.toContain('[STICKY] Item 1');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Keyboard Navigation', () => {
|
describe('Keyboard Navigation', () => {
|
||||||
@@ -299,8 +310,9 @@ describe('ScrollableList Demo Behavior', () => {
|
|||||||
title: `Item ${i}`,
|
title: `Item ${i}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let result: ReturnType<typeof render>;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
const result = render(
|
result = render(
|
||||||
<MouseProvider mouseEventsEnabled={false}>
|
<MouseProvider mouseEventsEnabled={false}>
|
||||||
<KeypressProvider>
|
<KeypressProvider>
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
@@ -378,6 +390,10 @@ describe('ScrollableList Demo Behavior', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(listRef?.getScrollState()?.scrollTop).toBe(0);
|
expect(listRef?.getScrollState()?.scrollTop).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -386,8 +402,9 @@ describe('ScrollableList Demo Behavior', () => {
|
|||||||
const items = [{ id: '1', title: 'Item 1' }];
|
const items = [{ id: '1', title: 'Item 1' }];
|
||||||
let lastFrame: () => string | undefined;
|
let lastFrame: () => string | undefined;
|
||||||
|
|
||||||
|
let result: ReturnType<typeof render>;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
const result = render(
|
result = render(
|
||||||
<MouseProvider mouseEventsEnabled={false}>
|
<MouseProvider mouseEventsEnabled={false}>
|
||||||
<KeypressProvider>
|
<KeypressProvider>
|
||||||
<ScrollProvider>
|
<ScrollProvider>
|
||||||
@@ -411,6 +428,10 @@ describe('ScrollableList Demo Behavior', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(lastFrame()).toContain('Item 1');
|
expect(lastFrame()).toContain('Item 1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
result.unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useRef, useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export interface UseRepeatedKeyPressOptions {
|
||||||
|
onRepeat?: (count: number) => void;
|
||||||
|
onReset?: () => void;
|
||||||
|
windowMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRepeatedKeyPress(options: UseRepeatedKeyPressOptions) {
|
||||||
|
const [pressCount, setPressCount] = useState(0);
|
||||||
|
const pressCountRef = useRef(0);
|
||||||
|
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// To avoid stale closures
|
||||||
|
const optionsRef = useRef(options);
|
||||||
|
useEffect(() => {
|
||||||
|
optionsRef.current = options;
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
|
const resetCount = useCallback(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
if (pressCountRef.current > 0) {
|
||||||
|
pressCountRef.current = 0;
|
||||||
|
setPressCount(0);
|
||||||
|
optionsRef.current.onReset?.();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePress = useCallback((): number => {
|
||||||
|
const newCount = pressCountRef.current + 1;
|
||||||
|
pressCountRef.current = newCount;
|
||||||
|
setPressCount(newCount);
|
||||||
|
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
pressCountRef.current = 0;
|
||||||
|
setPressCount(0);
|
||||||
|
timerRef.current = null;
|
||||||
|
optionsRef.current.onReset?.();
|
||||||
|
}, optionsRef.current.windowMs);
|
||||||
|
|
||||||
|
optionsRef.current.onRepeat?.(newCount);
|
||||||
|
|
||||||
|
return newCount;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { pressCount, handlePress, resetCount };
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import { persistentState } from '../../utils/persistentState.js';
|
||||||
|
|
||||||
|
export const APPROVAL_MODE_REVEAL_DURATION_MS = 1200;
|
||||||
|
const FOCUS_UI_ENABLED_STATE_KEY = 'focusUiEnabled';
|
||||||
|
|
||||||
|
export function useVisibilityToggle() {
|
||||||
|
const [focusUiEnabledByDefault] = useState(
|
||||||
|
() => persistentState.get(FOCUS_UI_ENABLED_STATE_KEY) === true,
|
||||||
|
);
|
||||||
|
const [cleanUiDetailsVisible, setCleanUiDetailsVisibleState] = useState(
|
||||||
|
!focusUiEnabledByDefault,
|
||||||
|
);
|
||||||
|
const modeRevealTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const cleanUiDetailsPinnedRef = useRef(!focusUiEnabledByDefault);
|
||||||
|
|
||||||
|
const clearModeRevealTimeout = useCallback(() => {
|
||||||
|
if (modeRevealTimeoutRef.current) {
|
||||||
|
clearTimeout(modeRevealTimeoutRef.current);
|
||||||
|
modeRevealTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const persistFocusUiPreference = useCallback((isFullUiVisible: boolean) => {
|
||||||
|
persistentState.set(FOCUS_UI_ENABLED_STATE_KEY, !isFullUiVisible);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setCleanUiDetailsVisible = useCallback(
|
||||||
|
(visible: boolean) => {
|
||||||
|
clearModeRevealTimeout();
|
||||||
|
cleanUiDetailsPinnedRef.current = visible;
|
||||||
|
setCleanUiDetailsVisibleState(visible);
|
||||||
|
persistFocusUiPreference(visible);
|
||||||
|
},
|
||||||
|
[clearModeRevealTimeout, persistFocusUiPreference],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleCleanUiDetailsVisible = useCallback(() => {
|
||||||
|
clearModeRevealTimeout();
|
||||||
|
setCleanUiDetailsVisibleState((visible) => {
|
||||||
|
const nextVisible = !visible;
|
||||||
|
cleanUiDetailsPinnedRef.current = nextVisible;
|
||||||
|
persistFocusUiPreference(nextVisible);
|
||||||
|
return nextVisible;
|
||||||
|
});
|
||||||
|
}, [clearModeRevealTimeout, persistFocusUiPreference]);
|
||||||
|
|
||||||
|
const revealCleanUiDetailsTemporarily = useCallback(
|
||||||
|
(durationMs: number = APPROVAL_MODE_REVEAL_DURATION_MS) => {
|
||||||
|
if (cleanUiDetailsPinnedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearModeRevealTimeout();
|
||||||
|
setCleanUiDetailsVisibleState(true);
|
||||||
|
modeRevealTimeoutRef.current = setTimeout(() => {
|
||||||
|
if (!cleanUiDetailsPinnedRef.current) {
|
||||||
|
setCleanUiDetailsVisibleState(false);
|
||||||
|
}
|
||||||
|
modeRevealTimeoutRef.current = null;
|
||||||
|
}, durationMs);
|
||||||
|
},
|
||||||
|
[clearModeRevealTimeout],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => () => clearModeRevealTimeout(), [clearModeRevealTimeout]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cleanUiDetailsVisible,
|
||||||
|
setCleanUiDetailsVisible,
|
||||||
|
toggleCleanUiDetailsVisible,
|
||||||
|
revealCleanUiDetailsTemporarily,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { tokenLimit } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
export function getContextUsagePercentage(
|
||||||
|
promptTokenCount: number,
|
||||||
|
model: string | undefined,
|
||||||
|
): number {
|
||||||
|
if (!model || typeof model !== 'string' || model.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const limit = tokenLimit(model);
|
||||||
|
if (limit <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return promptTokenCount / limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isContextUsageHigh(
|
||||||
|
promptTokenCount: number,
|
||||||
|
model: string | undefined,
|
||||||
|
threshold = 0.6,
|
||||||
|
): boolean {
|
||||||
|
return getContextUsagePercentage(promptTokenCount, model) > threshold;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user