From bbdd8457df71a50a5bd7b217fd2cbabac743a02e Mon Sep 17 00:00:00 2001 From: matt korwel Date: Tue, 24 Mar 2026 16:16:48 -0700 Subject: [PATCH] fix(cli): stabilize copy mode to prevent flickering and cursor resets (#22584) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/cli/src/ui/AppContainer.tsx | 21 +- .../src/ui/__snapshots__/App.test.tsx.snap | 9 +- ...-the-frame-of-the-entire-terminal.snap.svg | 233 +++++++++--------- .../ToolConfirmationFullFrame.test.tsx.snap | 10 +- packages/cli/src/ui/components/Composer.tsx | 5 +- .../cli/src/ui/components/CopyModeWarning.tsx | 16 +- packages/cli/src/ui/components/Footer.tsx | 20 +- .../cli/src/ui/components/InputPrompt.tsx | 5 +- .../src/ui/components/MemoryUsageDisplay.tsx | 14 +- .../cli/src/ui/contexts/UIStateContext.tsx | 1 + .../cli/src/ui/layouts/DefaultAppLayout.tsx | 4 + 11 files changed, 187 insertions(+), 151 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 8c199c9387..ce5fc7c872 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1393,9 +1393,22 @@ Logging in with Google... Restarting Gemini CLI to continue. (streamingState === StreamingState.Idle || streamingState === StreamingState.Responding || streamingState === StreamingState.WaitingForConfirmation) && - !proQuotaRequest; + !proQuotaRequest && + !copyModeEnabled; const [controlsHeight, setControlsHeight] = useState(0); + const [lastNonCopyControlsHeight, setLastNonCopyControlsHeight] = useState(0); + + useLayoutEffect(() => { + if (!copyModeEnabled && controlsHeight > 0) { + setLastNonCopyControlsHeight(controlsHeight); + } + }, [copyModeEnabled, controlsHeight]); + + const stableControlsHeight = + copyModeEnabled && lastNonCopyControlsHeight > 0 + ? lastNonCopyControlsHeight + : controlsHeight; useLayoutEffect(() => { if (mainControlsRef.current) { @@ -1407,10 +1420,10 @@ Logging in with Google... Restarting Gemini CLI to continue. } }, [buffer, terminalWidth, terminalHeight, controlsHeight, isInputActive]); - // Compute available terminal height based on controls measurement + // Compute available terminal height based on stable controls measurement const availableTerminalHeight = Math.max( 0, - terminalHeight - controlsHeight - backgroundShellHeight - 1, + terminalHeight - stableControlsHeight - backgroundShellHeight - 1, ); config.setShellExecutionConfig({ @@ -2269,6 +2282,7 @@ Logging in with Google... Restarting Gemini CLI to continue. contextFileNames, errorCount, availableTerminalHeight, + stableControlsHeight, mainAreaWidth, staticAreaMaxItemHeight, staticExtraHeight, @@ -2390,6 +2404,7 @@ Logging in with Google... Restarting Gemini CLI to continue. contextFileNames, errorCount, availableTerminalHeight, + stableControlsHeight, mainAreaWidth, staticAreaMaxItemHeight, staticExtraHeight, diff --git a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap index 1d1ebbb3d1..f145eadfff 100644 --- a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap +++ b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap @@ -34,12 +34,11 @@ Tips for getting started: - - Notifications + Composer " `; @@ -100,12 +99,11 @@ exports[`App > Snapshots > renders with dialogs visible 1`] = ` - - Notifications + DialogManager " `; @@ -147,9 +145,8 @@ HistoryItemDisplay - - Notifications + Composer " `; diff --git a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg index be799c5d80..97b01f3025 100644 --- a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg +++ b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg @@ -1,271 +1,266 @@ - + - + - 3. Ask coding questions, edit code or run commands - 4. Be specific for the best results + + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + + + > + + Can you edit InputPrompt.tsx for me? + - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - - - > - - Can you edit InputPrompt.tsx for me? - - - ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ + + Action Required + + + - Action Required + ? + Edit + packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto + - ? - Edit - packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto - + ... first 44 lines hidden (Ctrl+O to show) ... + 45 + const + line45 + = + true + ; - ... first 44 lines hidden (Ctrl+O to show) ... + 46 + const + line46 + = + true + ; - - 45 + 47 const - line45 + line47 = true ; - 46 + 48 const - line46 + line48 = true ; - 47 + 49 const - line47 + line49 = true ; - 48 + 50 const - line48 + line50 = true ; - 49 + 51 const - line49 + line51 = true ; - 50 + 52 const - line50 + line52 = true ; - 51 + 53 const - line51 + line53 = true ; - 52 + 54 const - line52 + line54 = true ; - 53 + 55 const - line53 + line55 = true ; - 54 + 56 const - line54 + line56 = true ; - 55 + 57 const - line55 + line57 = true ; - 56 + 58 const - line56 + line58 = true ; - 57 + 59 const - line57 + line59 = true ; - 58 + 60 const - line58 + line60 = true ; - 59 - const - line59 - = - true - ; + + 61 + + + - + + + + return + + kittyProtocolSupporte...; - 60 - const - line60 - = - true - ; + + 61 + + + + + + + + return + + kittyProtocolSupporte...; - - 61 - - - - - - - - return - - kittyProtocolSupporte...; + 62 + buffer: TextBuffer; - - 61 - - - + - - - - return - - kittyProtocolSupporte...; + 63 + onSubmit + : ( + value + : + string + ) => + void + ; - 62 - buffer: TextBuffer; + Apply this change? - 63 - onSubmit - : ( - value - : - string - ) => - void - ; - Apply this change? + + + + + 1. + + + Allow once + + 2. + Allow for this session - - - - - 1. - - - Allow once - + 3. + Allow for this file in all future sessions - 2. - Allow for this session + 4. + Modify with external editor - 3. - Allow for this file in all future sessions + 5. + No, suggest changes (esc) - 4. - Modify with external editor - - 5. - No, suggest changes (esc) - + ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ - - - - ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ - \ No newline at end of file diff --git a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap index 202f814c05..98853434df 100644 --- a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap +++ b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap @@ -1,9 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation box in the frame of the entire terminal 1`] = ` -"3. Ask coding questions, edit code or run commands -4. Be specific for the best results -▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ > Can you edit InputPrompt.tsx for me? ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ @@ -11,9 +9,9 @@ exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation bo │ │ │ ? Edit packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto… │ │ │ -│ ... first 44 lines hidden (Ctrl+O to show) ... │█ -│ 45 const line45 = true; │█ -│ 46 const line46 = true; │█ +│ ... first 44 lines hidden (Ctrl+O to show) ... │ +│ 45 const line45 = true; │ +│ 46 const line46 = true; │ │ 47 const line47 = true; │█ │ 48 const line48 = true; │█ │ 49 const line49 = true; │█ diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 593b4e2a6a..af6d3b32da 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -588,12 +588,15 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { streamingState={uiState.streamingState} suggestionsPosition={suggestionsPosition} onSuggestionsVisibilityChange={setSuggestionsVisible} + copyModeEnabled={uiState.copyModeEnabled} /> )} {showUiDetails && !settings.merged.ui.hideFooter && - !isScreenReaderEnabled &&
} + !isScreenReaderEnabled && ( +
+ )} ); }; diff --git a/packages/cli/src/ui/components/CopyModeWarning.tsx b/packages/cli/src/ui/components/CopyModeWarning.tsx index 4b6328274b..eb5c1f6d78 100644 --- a/packages/cli/src/ui/components/CopyModeWarning.tsx +++ b/packages/cli/src/ui/components/CopyModeWarning.tsx @@ -12,16 +12,14 @@ import { theme } from '../semantic-colors.js'; export const CopyModeWarning: React.FC = () => { const { copyModeEnabled } = useUIState(); - if (!copyModeEnabled) { - return null; - } - return ( - - - In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other key - to exit. - + + {copyModeEnabled && ( + + In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other + key to exit. + + )} ); }; diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index c6816339f5..696cc5e417 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -175,12 +175,18 @@ interface FooterColumn { isHighPriority: boolean; } -export const Footer: React.FC = () => { +export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({ + copyModeEnabled = false, +}) => { const uiState = useUIState(); const config = useConfig(); const settings = useSettings(); const { vimEnabled, vimMode } = useVimMode(); + if (copyModeEnabled) { + return ; + } + const { model, targetDir, @@ -353,7 +359,17 @@ export const Footer: React.FC = () => { break; } case 'memory-usage': { - addCol(id, header, () => , 10); + addCol( + id, + header, + () => ( + + ), + 10, + ); break; } case 'session-id': { diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 0deb0c40d2..35cf7ef656 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -119,6 +119,7 @@ export interface InputPromptProps { popAllMessages?: () => string | undefined; suggestionsPosition?: 'above' | 'below'; setBannerVisible: (visible: boolean) => void; + copyModeEnabled?: boolean; } // The input content, input container, and input suggestions list may have different widths @@ -212,6 +213,7 @@ export const InputPrompt: React.FC = ({ popAllMessages, suggestionsPosition = 'below', setBannerVisible, + copyModeEnabled = false, }) => { const isHelpDismissKey = useIsHelpDismissKey(); const keyMatchers = useKeyMatchers(); @@ -331,7 +333,8 @@ export const InputPrompt: React.FC = ({ isShellSuggestionsVisible, } = completion; - const showCursor = focus && isShellFocused && !isEmbeddedShellFocused; + const showCursor = + focus && isShellFocused && !isEmbeddedShellFocused && !copyModeEnabled; // Notify parent component about escape prompt state changes useEffect(() => { diff --git a/packages/cli/src/ui/components/MemoryUsageDisplay.tsx b/packages/cli/src/ui/components/MemoryUsageDisplay.tsx index 7941a9cb1d..709f76baf3 100644 --- a/packages/cli/src/ui/components/MemoryUsageDisplay.tsx +++ b/packages/cli/src/ui/components/MemoryUsageDisplay.tsx @@ -11,13 +11,18 @@ import { theme } from '../semantic-colors.js'; import process from 'node:process'; import { formatBytes } from '../utils/formatters.js'; -export const MemoryUsageDisplay: React.FC<{ color?: string }> = ({ - color = theme.text.primary, -}) => { +export const MemoryUsageDisplay: React.FC<{ + color?: string; + isActive?: boolean; +}> = ({ color = theme.text.primary, isActive = true }) => { const [memoryUsage, setMemoryUsage] = useState(''); const [memoryUsageColor, setMemoryUsageColor] = useState(color); useEffect(() => { + if (!isActive) { + return; + } + const updateMemory = () => { const usage = process.memoryUsage().rss; setMemoryUsage(formatBytes(usage)); @@ -25,10 +30,11 @@ export const MemoryUsageDisplay: React.FC<{ color?: string }> = ({ usage >= 2 * 1024 * 1024 * 1024 ? theme.status.error : color, ); }; + const intervalId = setInterval(updateMemory, 2000); updateMemory(); // Initial update return () => clearInterval(intervalId); - }, [color]); + }, [color, isActive]); return ( diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index b77a56bbc3..e4d95a79af 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -180,6 +180,7 @@ export interface UIState { contextFileNames: string[]; errorCount: number; availableTerminalHeight: number | undefined; + stableControlsHeight: number; mainAreaWidth: number; staticAreaMaxItemHeight: number; staticExtraHeight: number; diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 74c02c1d9a..8370b78085 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -31,6 +31,7 @@ export const DefaultAppLayout: React.FC = () => { flexDirection="column" width={uiState.terminalWidth} height={isAlternateBuffer ? terminalHeight : undefined} + paddingBottom={isAlternateBuffer ? 1 : undefined} flexShrink={0} flexGrow={0} overflow="hidden" @@ -62,6 +63,9 @@ export const DefaultAppLayout: React.FC = () => { flexShrink={0} flexGrow={0} width={uiState.terminalWidth} + height={ + uiState.copyModeEnabled ? uiState.stableControlsHeight : undefined + } >