From 366f1df120e0b6fcb5ec29ab721084abf22bddf3 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Tue, 17 Feb 2026 07:16:37 -0800 Subject: [PATCH] refactor(cli): code review cleanup fix for tab+tab (#18967) --- packages/cli/src/ui/AppContainer.tsx | 147 +++++------------- packages/cli/src/ui/auth/AuthDialog.test.tsx | 8 +- packages/cli/src/ui/auth/useAuth.test.tsx | 6 +- packages/cli/src/ui/components/Composer.tsx | 46 ++++-- .../src/ui/components/ContextUsageDisplay.tsx | 4 +- .../GeminiRespondingSpinner.test.tsx | 5 + .../cli/src/ui/components/InputPrompt.tsx | 95 +++++------ .../src/ui/components/RewindViewer.test.tsx | 4 + .../components/messages/ToolMessage.test.tsx | 4 + .../__snapshots__/ToolMessage.test.tsx.snap | 6 +- .../components/shared/ScrollableList.test.tsx | 29 +++- .../cli/src/ui/hooks/useRepeatedKeyPress.ts | 69 ++++++++ .../cli/src/ui/hooks/useVisibilityToggle.ts | 79 ++++++++++ packages/cli/src/ui/utils/contextUsage.ts | 29 ++++ 14 files changed, 334 insertions(+), 197 deletions(-) create mode 100644 packages/cli/src/ui/hooks/useRepeatedKeyPress.ts create mode 100644 packages/cli/src/ui/hooks/useVisibilityToggle.ts create mode 100644 packages/cli/src/ui/utils/contextUsage.ts diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 5b33013846..9b3714ca87 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -134,7 +134,6 @@ import { ShellFocusContext } from './contexts/ShellFocusContext.js'; import { type ExtensionManager } from '../config/extension-manager.js'; import { requestConsentInteractive } from '../config/extensions/consent.js'; import { useSessionBrowser } from './hooks/useSessionBrowser.js'; -import { persistentState } from '../utils/persistentState.js'; import { useSessionResume } from './hooks/useSessionResume.js'; import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js'; import { useSessionRetentionCheck } from './hooks/useSessionRetentionCheck.js'; @@ -189,8 +188,11 @@ interface AppContainerProps { resumedSessionData?: ResumedSessionData; } -const APPROVAL_MODE_REVEAL_DURATION_MS = 1200; -const FOCUS_UI_ENABLED_STATE_KEY = 'focusUiEnabled'; +import { useRepeatedKeyPress } from './hooks/useRepeatedKeyPress.js'; +import { + useVisibilityToggle, + APPROVAL_MODE_REVEAL_DURATION_MS, +} from './hooks/useVisibilityToggle.js'; /** * 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 [focusUiEnabledByDefault] = useState( - () => persistentState.get(FOCUS_UI_ENABLED_STATE_KEY) === true, - ); const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false); - const [cleanUiDetailsVisible, setCleanUiDetailsVisibleState] = useState( - !focusUiEnabledByDefault, - ); - const modeRevealTimeoutRef = useRef(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]); + const { + cleanUiDetailsVisible, + setCleanUiDetailsVisible, + toggleCleanUiDetailsVisible, + revealCleanUiDetailsTemporarily, + } = useVisibilityToggle(); const slashCommandActions = useMemo( () => ({ @@ -1396,10 +1347,29 @@ Logging in with Google... Restarting Gemini CLI to continue. const [showFullTodos, setShowFullTodos] = useState(false); const [renderMarkdown, setRenderMarkdown] = useState(true); - const [ctrlCPressCount, setCtrlCPressCount] = useState(0); - const ctrlCTimerRef = useRef(null); - const [ctrlDPressCount, setCtrlDPressCount] = useState(0); - const ctrlDTimerRef = useRef(null); + const handleExitRepeat = useCallback( + (count: number) => { + if (count > 2) { + 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(true); const [ideContextState, setIdeContextState] = useState< IdeContext | undefined @@ -1478,9 +1448,6 @@ Logging in with Google... Restarting Gemini CLI to continue. if (tabFocusTimeoutRef.current) { clearTimeout(tabFocusTimeoutRef.current); } - if (modeRevealTimeoutRef.current) { - clearTimeout(modeRevealTimeoutRef.current); - } }; }, [showTransientMessage]); @@ -1553,44 +1520,6 @@ Logging in with Google... Restarting Gemini CLI to continue. }; }, [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) => { setShowEscapePrompt(showPrompt); }, []); @@ -1637,10 +1566,10 @@ Logging in with Google... Restarting Gemini CLI to continue. // This should happen regardless of the count. cancelOngoingRequest?.(); - setCtrlCPressCount((prev) => prev + 1); + handleCtrlCPress(); return true; } else if (keyMatchers[Command.EXIT](key)) { - setCtrlDPressCount((prev) => prev + 1); + handleCtrlDPress(); return true; } else if (keyMatchers[Command.SUSPEND_APP](key)) { handleSuspend(); @@ -1781,8 +1710,8 @@ Logging in with Google... Restarting Gemini CLI to continue. setShowErrorDetails, config, ideContextState, - setCtrlCPressCount, - setCtrlDPressCount, + handleCtrlCPress, + handleCtrlDPress, handleSlashCommand, cancelOngoingRequest, activePtyId, diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index b71d2cd2d2..8db4cc99ff 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { act } from 'react'; import { renderWithProviders } from '../../test-utils/render.js'; import { describe, @@ -318,9 +319,10 @@ describe('AuthDialog', () => { renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; - await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE); - - await vi.runAllTimersAsync(); + await act(async () => { + await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE); + await vi.runAllTimersAsync(); + }); expect(mockedRunExitCleanup).toHaveBeenCalled(); expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE); diff --git a/packages/cli/src/ui/auth/useAuth.test.tsx b/packages/cli/src/ui/auth/useAuth.test.tsx index c606bc76de..36d9aeec4f 100644 --- a/packages/cli/src/ui/auth/useAuth.test.tsx +++ b/packages/cli/src/ui/auth/useAuth.test.tsx @@ -138,11 +138,15 @@ describe('useAuth', () => { }, }) as LoadedSettings; - it('should initialize with Unauthenticated state', () => { + it('should initialize with Unauthenticated state', async () => { const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); 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 () => { diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 6470661e41..d3193d75dc 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -8,7 +8,7 @@ import { useState, useEffect, useMemo } from 'react'; import { Box, Text, useIsScreenReaderEnabled } from 'ink'; import { ApprovalMode, - tokenLimit, + checkExhaustive, CoreToolCallStatus, } from '@google/gemini-cli-core'; import { LoadingIndicator } from './LoadingIndicator.js'; @@ -38,6 +38,7 @@ import { StreamingState, type HistoryItemToolGroup } from '../types.js'; import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js'; import { TodoTray } from './messages/Todo.js'; import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js'; +import { isContextUsageHigh } from '../utils/contextUsage.js'; import { theme } from '../semantic-colors.js'; export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { @@ -114,30 +115,41 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const showApprovalIndicator = !uiState.shellModeActive && !hideUiDetailsForSuggestions; const showRawMarkdownIndicator = !uiState.renderMarkdown; - const modeBleedThrough = - showApprovalModeIndicator === ApprovalMode.YOLO - ? { text: 'YOLO', color: theme.status.error } - : showApprovalModeIndicator === ApprovalMode.PLAN - ? { text: 'plan', color: theme.status.success } - : showApprovalModeIndicator === ApprovalMode.AUTO_EDIT - ? { text: 'auto edit', color: theme.status.warning } - : null; + let modeBleedThrough: { text: string; color: string } | null = null; + switch (showApprovalModeIndicator) { + case ApprovalMode.YOLO: + modeBleedThrough = { text: 'YOLO', color: theme.status.error }; + break; + case ApprovalMode.PLAN: + modeBleedThrough = { text: 'plan', color: theme.status.success }; + 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 = !showUiDetails && (showLoadingIndicator || hasPendingActionRequired); const minimalModeBleedThrough = hideMinimalModeHintWhileBusy ? null : modeBleedThrough; const hasMinimalStatusBleedThrough = shouldShowToast(uiState); - const contextTokenLimit = - typeof uiState.currentModel === 'string' && uiState.currentModel.length > 0 - ? tokenLimit(uiState.currentModel) - : 0; + const showMinimalContextBleedThrough = !settings.merged.ui.footer.hideContextPercentage && - typeof uiState.currentModel === 'string' && - uiState.currentModel.length > 0 && - contextTokenLimit > 0 && - uiState.sessionStats.lastPromptTokenCount / contextTokenLimit > 0.6; + isContextUsageHigh( + uiState.sessionStats.lastPromptTokenCount, + typeof uiState.currentModel === 'string' + ? uiState.currentModel + : undefined, + ); const hideShortcutsHintForSuggestions = hideUiDetailsForSuggestions; const showShortcutsHint = settings.merged.ui.showShortcutsHint && diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.tsx index 09cd4c3922..1c1d24cc2d 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.tsx @@ -6,7 +6,7 @@ import { Text } from 'ink'; import { theme } from '../semantic-colors.js'; -import { tokenLimit } from '@google/gemini-cli-core'; +import { getContextUsagePercentage } from '../utils/contextUsage.js'; export const ContextUsageDisplay = ({ promptTokenCount, @@ -17,7 +17,7 @@ export const ContextUsageDisplay = ({ model: string; terminalWidth: number; }) => { - const percentage = promptTokenCount / tokenLimit(model); + const percentage = getContextUsagePercentage(promptTokenCount, model); const percentageLeft = ((1 - percentage) * 100).toFixed(0); const label = terminalWidth < 100 ? '%' : '% context left'; diff --git a/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx b/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx index 891ccaf39d..8ad8bfe65e 100644 --- a/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx +++ b/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx @@ -33,10 +33,15 @@ describe('GeminiRespondingSpinner', () => { const mockUseIsScreenReaderEnabled = vi.mocked(useIsScreenReaderEnabled); beforeEach(() => { + vi.useFakeTimers(); vi.clearAllMocks(); mockUseIsScreenReaderEnabled.mockReturnValue(false); }); + afterEach(() => { + vi.useRealTimers(); + }); + it('renders spinner when responding', () => { mockUseStreamingContext.mockReturnValue(StreamingState.Responding); const { lastFrame } = render(); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index d9f0f34288..ca9400e4d1 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -76,6 +76,7 @@ import { useMouse, type MouseEvent } from '../contexts/MouseContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.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 @@ -227,10 +228,31 @@ export const InputPrompt: React.FC = ({ shortcutsHelpVisible, } = useUIState(); const [suppressCompletion, setSuppressCompletion] = useState(false); - const escPressCount = useRef(0); - const lastPlainTabPressTimeRef = useRef(null); + const { handlePress: registerPlainTabPress, resetCount: resetPlainTabPress } = + useRepeatedKeyPress({ + windowMs: DOUBLE_TAB_CLEAN_UI_TOGGLE_WINDOW_MS, + }); const [showEscapePrompt, setShowEscapePrompt] = useState(false); - const escapeTimerRef = useRef(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< number | null >(null); @@ -284,15 +306,6 @@ export const InputPrompt: React.FC = ({ 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 useEffect(() => { if (onEscapePromptChange) { @@ -300,12 +313,9 @@ export const InputPrompt: React.FC = ({ } }, [showEscapePrompt, onEscapePromptChange]); - // Clear escape prompt timer on unmount + // Clear paste timeout on unmount useEffect( () => () => { - if (escapeTimerRef.current) { - clearTimeout(escapeTimerRef.current); - } if (pasteTimeoutRef.current) { clearTimeout(pasteTimeoutRef.current); } @@ -335,8 +345,8 @@ export const InputPrompt: React.FC = ({ resetReverseSearchCompletionState(); }, [ - onSubmit, buffer, + onSubmit, resetCompletionState, shellModeActive, shellHistory, @@ -639,22 +649,16 @@ export const InputPrompt: React.FC = ({ commandSearchActive; if (isPlainTab) { if (!hasTabCompletionInteraction) { - const now = Date.now(); - const isDoubleTabPress = - lastPlainTabPressTimeRef.current !== null && - now - lastPlainTabPressTimeRef.current <= - DOUBLE_TAB_CLEAN_UI_TOGGLE_WINDOW_MS; - if (isDoubleTabPress) { - lastPlainTabPressTimeRef.current = null; + if (registerPlainTabPress() === 2) { toggleCleanUiDetailsVisible(); + resetPlainTabPress(); return true; } - lastPlainTabPressTimeRef.current = now; } else { - lastPlainTabPressTimeRef.current = null; + resetPlainTabPress(); } } else { - lastPlainTabPressTimeRef.current = null; + resetPlainTabPress(); } if (key.name === 'paste') { @@ -732,9 +736,7 @@ export const InputPrompt: React.FC = ({ // Reset ESC count and hide prompt on any non-ESC key if (key.name !== 'escape') { - if (escPressCount.current > 0 || showEscapePrompt) { - resetEscapeState(); - } + resetEscapeState(); } // Ctrl+O to expand/collapse paste placeholders @@ -798,30 +800,7 @@ export const InputPrompt: React.FC = ({ return true; } - // Handle double ESC - 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'); + handleEscPress(); return true; } @@ -1193,7 +1172,6 @@ export const InputPrompt: React.FC = ({ reverseSearchCompletion, handleClipboardPaste, resetCompletionState, - showEscapePrompt, resetEscapeState, vimHandleInput, reverseSearchActive, @@ -1205,16 +1183,17 @@ export const InputPrompt: React.FC = ({ kittyProtocol.enabled, shortcutsHelpVisible, setShortcutsHelpVisible, - toggleCleanUiDetailsVisible, tryLoadQueuedMessages, setBannerVisible, - onSubmit, activePtyId, setEmbeddedShellFocused, backgroundShells.size, backgroundShellHeight, - history, streamingState, + handleEscPress, + registerPlainTabPress, + resetPlainTabPress, + toggleCleanUiDetailsVisible, ], ); diff --git a/packages/cli/src/ui/components/RewindViewer.test.tsx b/packages/cli/src/ui/components/RewindViewer.test.tsx index 5ad1f8b5e4..31f5f456da 100644 --- a/packages/cli/src/ui/components/RewindViewer.test.tsx +++ b/packages/cli/src/ui/components/RewindViewer.test.tsx @@ -14,6 +14,10 @@ import type { MessageRecord, } from '@google/gemini-cli-core'; +vi.mock('./CliSpinner.js', () => ({ + CliSpinner: () => 'MockSpinner', +})); + vi.mock('../utils/formatters.js', async (importOriginal) => { const original = await importOriginal(); diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index dd74a70970..29012bbd26 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -13,6 +13,10 @@ import { type AnsiOutput, CoreToolCallStatus } from '@google/gemini-cli-core'; import { renderWithProviders } from '../../../test-utils/render.js'; import { tryParseJSON } from '../../../utils/jsonoutput.js'; +vi.mock('../GeminiRespondingSpinner.js', () => ({ + GeminiRespondingSpinner: () => MockRespondingSpinner, +})); + vi.mock('../TerminalOutput.js', () => ({ TerminalOutput: function MockTerminalOutput({ cursor, diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap index 599c9e68da..a3fedd751b 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap @@ -26,7 +26,7 @@ exports[` > ToolStatusIndicator rendering > shows - for Canceled exports[` > 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 │" `; @@ -40,14 +40,14 @@ exports[` > ToolStatusIndicator rendering > shows o for Pending s exports[` > 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 │" `; exports[` > 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 │" `; diff --git a/packages/cli/src/ui/components/shared/ScrollableList.test.tsx b/packages/cli/src/ui/components/shared/ScrollableList.test.tsx index 3c0ecb31f5..b0b9fbe846 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.test.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.test.tsx @@ -138,8 +138,10 @@ describe('ScrollableList Demo Behavior', () => { let listRef: ScrollableListRef | null = null; let lastFrame: () => string | undefined; + let result: ReturnType; + await act(async () => { - const result = render( + result = render( { addItem = add; @@ -192,6 +194,10 @@ describe('ScrollableList Demo Behavior', () => { expect(lastFrame!()).toContain('Count: 1003'); }); expect(lastFrame!()).not.toContain('Item 1003'); + + await act(async () => { + result.unmount(); + }); }); it('should display sticky header when scrolled past the item', async () => { @@ -243,8 +249,9 @@ describe('ScrollableList Demo Behavior', () => { }; let lastFrame: () => string | undefined; + let result: ReturnType; await act(async () => { - const result = render(); + result = render(); lastFrame = result.lastFrame; }); @@ -286,6 +293,10 @@ describe('ScrollableList Demo Behavior', () => { expect(lastFrame!()).toContain('[Normal] Item 1'); }); expect(lastFrame!()).not.toContain('[STICKY] Item 1'); + + await act(async () => { + result.unmount(); + }); }); describe('Keyboard Navigation', () => { @@ -299,8 +310,9 @@ describe('ScrollableList Demo Behavior', () => { title: `Item ${i}`, })); + let result: ReturnType; await act(async () => { - const result = render( + result = render( @@ -378,6 +390,10 @@ describe('ScrollableList Demo Behavior', () => { await waitFor(() => { 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' }]; let lastFrame: () => string | undefined; + let result: ReturnType; await act(async () => { - const result = render( + result = render( @@ -411,6 +428,10 @@ describe('ScrollableList Demo Behavior', () => { await waitFor(() => { expect(lastFrame()).toContain('Item 1'); }); + + await act(async () => { + result.unmount(); + }); }); }); }); diff --git a/packages/cli/src/ui/hooks/useRepeatedKeyPress.ts b/packages/cli/src/ui/hooks/useRepeatedKeyPress.ts new file mode 100644 index 0000000000..9f6515cfad --- /dev/null +++ b/packages/cli/src/ui/hooks/useRepeatedKeyPress.ts @@ -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(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 }; +} diff --git a/packages/cli/src/ui/hooks/useVisibilityToggle.ts b/packages/cli/src/ui/hooks/useVisibilityToggle.ts new file mode 100644 index 0000000000..3cf924750e --- /dev/null +++ b/packages/cli/src/ui/hooks/useVisibilityToggle.ts @@ -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(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, + }; +} diff --git a/packages/cli/src/ui/utils/contextUsage.ts b/packages/cli/src/ui/utils/contextUsage.ts new file mode 100644 index 0000000000..b1a3e5e7fc --- /dev/null +++ b/packages/cli/src/ui/utils/contextUsage.ts @@ -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; +}