diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index ff2f1f9d25..bc9500aa65 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -993,6 +993,8 @@ export async function loadCliConfig( trustedFolder, useBackgroundColor: settings.ui?.useBackgroundColor, useAlternateBuffer: settings.ui?.useAlternateBuffer, + useTerminalBuffer: settings.ui?.terminalBuffer, + useRenderProcess: settings.ui?.renderProcess, useRipgrep: settings.tools?.useRipgrep, enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell, shellBackgroundCompletionBehavior: settings.tools?.shell diff --git a/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap b/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap index 59b00995eb..ff19d367de 100644 --- a/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap +++ b/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap @@ -11,7 +11,8 @@ This extension will exclude the following core tools: tool1,tool2 The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to -understand the permissions it requires and the actions it may perform." +understand the permissions it requires and the actions it may perform. +" `; exports[`consent > maybeRequestConsentOrFail > consent string generation > should include warning when hooks are present 1`] = ` @@ -21,7 +22,8 @@ exports[`consent > maybeRequestConsentOrFail > consent string generation > shoul The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to -understand the permissions it requires and the actions it may perform." +understand the permissions it requires and the actions it may perform. +" `; exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if extension is migrated 1`] = ` @@ -30,7 +32,8 @@ exports[`consent > maybeRequestConsentOrFail > consent string generation > shoul The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to -understand the permissions it requires and the actions it may perform." +understand the permissions it requires and the actions it may perform. +" `; exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if skills change 1`] = ` @@ -60,7 +63,8 @@ understand the permissions it requires and the actions it may perform. Agent skills inject specialized instructions and domain-specific knowledge into the agent's system prompt. This can change how the agent interprets your requests and interacts with your environment. Review the skill definitions at the location(s) provided below to ensure they meet your security -standards." +standards. +" `; exports[`consent > maybeRequestConsentOrFail > consent string generation > should show a warning if the skill directory cannot be read 1`] = ` @@ -82,7 +86,8 @@ understand the permissions it requires and the actions it may perform. Agent skills inject specialized instructions and domain-specific knowledge into the agent's system prompt. This can change how the agent interprets your requests and interacts with your environment. Review the skill definitions at the location(s) provided below to ensure they meet your security -standards." +standards. +" `; exports[`consent > skillsConsentString > should generate a consent string for skills 1`] = ` @@ -98,5 +103,6 @@ Install Destination: /mock/target/dir Agent skills inject specialized instructions and domain-specific knowledge into the agent's system prompt. This can change how the agent interprets your requests and interacts with your environment. Review the skill definitions at the location(s) provided below to ensure they meet your security -standards." +standards. +" `; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 371be2afd1..17531a75d4 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -743,6 +743,24 @@ const SETTINGS_SCHEMA = { 'Use an alternate screen buffer for the UI, preserving shell history.', showInDialog: true, }, + renderProcess: { + type: 'boolean', + label: 'Render Process', + category: 'UI', + requiresRestart: true, + default: true, + description: 'Enable Ink render process for the UI.', + showInDialog: true, + }, + terminalBuffer: { + type: 'boolean', + label: 'Terminal Buffer', + category: 'UI', + requiresRestart: true, + default: true, + description: 'Use the new terminal buffer architecture for rendering.', + showInDialog: true, + }, useBackgroundColor: { type: 'boolean', label: 'Use Background Color', diff --git a/packages/cli/src/interactiveCli.tsx b/packages/cli/src/interactiveCli.tsx index 2e0cd25619..418f58b193 100644 --- a/packages/cli/src/interactiveCli.tsx +++ b/packages/cli/src/interactiveCli.tsx @@ -43,7 +43,6 @@ import { KeypressProvider } from './ui/contexts/KeypressContext.js'; import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; import { ScrollProvider } from './ui/contexts/ScrollProvider.js'; import { TerminalProvider } from './ui/contexts/TerminalContext.js'; -import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js'; import { OverflowProvider } from './ui/contexts/OverflowContext.js'; import { profiler } from './ui/components/DebugProfiler.js'; import { initializeConsoleStore } from './ui/hooks/useConsoleMessages.js'; @@ -64,7 +63,7 @@ export async function startInteractiveUI( // and the Ink alternate buffer mode requires line wrapping harmful to // screen readers. const useAlternateBuffer = shouldEnterAlternateScreen( - isAlternateBufferEnabled(config), + config.getUseAlternateBuffer(), config.getScreenReader(), ); const mouseEventsEnabled = useAlternateBuffer; @@ -133,7 +132,6 @@ export async function startInteractiveUI( // Wait a moment for shpool to stabilize terminal size and state. await new Promise((resolve) => setTimeout(resolve, 100)); } - const instance = render( process.env['DEBUG'] ? ( @@ -154,8 +152,12 @@ export async function startInteractiveUI( } profiler.reportFrameRendered(); }, + standardReactLayoutTiming: + useAlternateBuffer || config.getUseTerminalBuffer(), patchConsole: false, alternateBuffer: useAlternateBuffer, + renderProcess: config.getUseRenderProcess(), + terminalBuffer: config.getUseTerminalBuffer(), incrementalRendering: settings.merged.ui.incrementalRendering !== false && useAlternateBuffer && diff --git a/packages/cli/src/test-utils/customMatchers.ts b/packages/cli/src/test-utils/customMatchers.ts index d34576cf3f..ba356081ac 100644 --- a/packages/cli/src/test-utils/customMatchers.ts +++ b/packages/cli/src/test-utils/customMatchers.ts @@ -36,15 +36,11 @@ export async function toMatchSvgSnapshot( } let textContent: string; - if (renderInstance.lastFrameRaw) { - textContent = renderInstance.lastFrameRaw({ - allowEmpty: options?.allowEmpty, - }); - } else if (renderInstance.lastFrame) { + if (renderInstance.lastFrame) { textContent = renderInstance.lastFrame({ allowEmpty: options?.allowEmpty }); } else { throw new Error( - 'toMatchSvgSnapshot requires a renderInstance with either lastFrameRaw or lastFrame', + 'toMatchSvgSnapshot requires a renderInstance with lastFrame', ); } const svgContent = renderInstance.generateSvg(); diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index daf109d928..29255cbd4d 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -175,6 +175,8 @@ export const createMockConfig = (overrides: Partial = {}): Config => getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), validatePathAccess: vi.fn().mockReturnValue(null), getUseAlternateBuffer: vi.fn().mockReturnValue(false), + getUseTerminalBuffer: vi.fn().mockReturnValue(false), + getUseRenderProcess: vi.fn().mockReturnValue(false), ...overrides, }) as unknown as Config; diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 817921e83a..b74d90b6b0 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -254,7 +254,12 @@ class XtermStdout extends EventEmitter { const isMatch = () => { if (expectedFrame === '...') { - return currentFrame !== ''; + // '...' is our fallback when output isn't in metrics, meaning Ink rendered *something* + // but we don't know what it is. If terminal has content, we consider it a match. + // However, if the component rendered null, both would be empty, but our fallback + // made expectedFrame '...'. In that case, we can't easily know if it's ready, + // but we can assume if there are no pending writes, it's ready. + return currentFrame !== '' || this.pendingWrites === 0; } // If Ink expects nothing (no new static content and no dynamic output), diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 0e436cc645..7388405df2 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -346,6 +346,7 @@ describe('AppContainer State Management', () => { // Initialize mock stdout for terminal title tests mocks.mockStdout.write.mockClear(); + (disableMouseEvents as import('vitest').Mock).mockClear(); capturedUIState = null!; @@ -470,6 +471,7 @@ describe('AppContainer State Management', () => { // Mock Config mockConfig = makeFakeConfig(); + vi.spyOn(mockConfig, 'getUseRenderProcess').mockReturnValue(false); // Mock config's getTargetDir to return consistent workspace directory vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace'); @@ -1356,6 +1358,7 @@ describe('AppContainer State Management', () => { beforeEach(() => { // Reset mock stdout for each test mocks.mockStdout.write.mockClear(); + (disableMouseEvents as import('vitest').Mock).mockClear(); }); it('verifies useStdout is mocked', async () => { @@ -2459,7 +2462,7 @@ describe('AppContainer State Management', () => { }); }); - describe('Copy Mode (CTRL+S)', () => { + describe('Copy Mode (F9)', () => { let rerender: () => void; let unmount: () => void; let stdin: Awaited>['stdin']; @@ -2468,6 +2471,8 @@ describe('AppContainer State Management', () => { isAlternateMode = false, childHandler?: Mock, ) => { + vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false); + vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue( isAlternateMode, ); @@ -2512,6 +2517,8 @@ describe('AppContainer State Management', () => { beforeEach(() => { mocks.mockStdout.write.mockClear(); + (disableMouseEvents as import('vitest').Mock).mockClear(); + vi.useFakeTimers(); }); @@ -2532,12 +2539,13 @@ describe('AppContainer State Management', () => { modeName: 'Alternate Buffer Mode', }, ])('$modeName', ({ isAlternateMode, shouldEnable }) => { - it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when Ctrl+S is pressed`, async () => { + it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when F9 is pressed`, async () => { await setupCopyModeTest(isAlternateMode); mocks.mockStdout.write.mockClear(); // Clear initial enable call + (disableMouseEvents as import('vitest').Mock).mockClear(); act(() => { - stdin.write('\x13'); // Ctrl+S + stdin.write('\x1b[20~'); // F9 }); rerender(); @@ -2550,13 +2558,13 @@ describe('AppContainer State Management', () => { }); if (shouldEnable) { - it('should toggle mouse back on when Ctrl+S is pressed again', async () => { + it('should toggle mouse back on when F9 is pressed again', async () => { await setupCopyModeTest(isAlternateMode); (writeToStdout as Mock).mockClear(); // Turn it on (disable mouse) act(() => { - stdin.write('\x13'); // Ctrl+S + stdin.write('\x1b[20~'); // F9 }); rerender(); expect(disableMouseEvents).toHaveBeenCalled(); @@ -2576,7 +2584,7 @@ describe('AppContainer State Management', () => { // Enter copy mode act(() => { - stdin.write('\x13'); // Ctrl+S + stdin.write('\x1b[20~'); // F9 }); rerender(); @@ -2656,7 +2664,7 @@ describe('AppContainer State Management', () => { // 2. Enter copy mode act(() => { - stdin.write('\x13'); // Ctrl+S + stdin.write('\x1b[20~'); // F9 }); rerender(); @@ -3093,6 +3101,7 @@ describe('AppContainer State Management', () => { // Clear previous calls mocks.mockStdout.write.mockClear(); + (disableMouseEvents as import('vitest').Mock).mockClear(); const { unmount } = await act(async () => renderAppContainer()); @@ -3135,6 +3144,7 @@ describe('AppContainer State Management', () => { // Reset mock stdout to clear any initial writes mocks.mockStdout.write.mockClear(); + (disableMouseEvents as import('vitest').Mock).mockClear(); // Submit await act(async () => capturedUIActions.handleFinalSubmit('test prompt')); @@ -3154,6 +3164,8 @@ describe('AppContainer State Management', () => { ); vi.mocked(checkPermissions).mockResolvedValue([]); + vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false); + vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); const { unmount } = await act(async () => @@ -3170,6 +3182,7 @@ describe('AppContainer State Management', () => { // Reset mock stdout mocks.mockStdout.write.mockClear(); + (disableMouseEvents as import('vitest').Mock).mockClear(); // Submit await act(async () => capturedUIActions.handleFinalSubmit('test prompt')); @@ -3403,6 +3416,8 @@ describe('AppContainer State Management', () => { ui: { useAlternateBuffer: true }, }); + vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false); + vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); const { unmount } = await act(async () => diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 4da8acfdb7..e38e1996db 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -11,6 +11,7 @@ import { useEffect, useRef, useLayoutEffect, + useContext, } from 'react'; import { type DOMElement, @@ -19,6 +20,7 @@ import { useStdout, useStdin, type AppProps, + AppContext as InkAppContext, } from 'ink'; import { App } from './App.js'; import { AppContext } from './contexts/AppContext.js'; @@ -38,6 +40,8 @@ import { import { checkPermissions } from './hooks/atCommandProcessor.js'; import { MessageType, StreamingState } from './types.js'; import { ToolActionsProvider } from './contexts/ToolActionsContext.js'; +import { MouseProvider } from './contexts/MouseContext.js'; +import { ScrollProvider } from './contexts/ScrollProvider.js'; import { type StartupWarning, type EditorType, @@ -209,12 +213,30 @@ export const AppContainer = (props: AppContainerProps) => { const { reset } = useOverflowActions()!; const notificationsEnabled = isNotificationsEnabled(settings); + const { setOptions, dumpCurrentFrame, startRecording, stopRecording } = + useContext(InkAppContext); + const recordingFilenameRef = useRef(null); const historyManager = useHistory({ chatRecordingService: config.getGeminiClient()?.getChatRecordingService(), }); useMemoryMonitor(historyManager); const isAlternateBuffer = config.getUseAlternateBuffer(); + const [mouseMode, setMouseMode] = useState(() => + config.getUseAlternateBuffer(), + ); + + useEffect(() => { + setOptions({ + stickyHeadersInBackbuffer: mouseMode, + }); + if (mouseMode) { + enableMouseEvents(); + } else { + disableMouseEvents(); + } + }, [mouseMode, setOptions]); + const [corgiMode, setCorgiMode] = useState(false); const [forceRerenderKey, setForceRerenderKey] = useState(0); const [debugMessage, setDebugMessage] = useState(''); @@ -613,11 +635,11 @@ export const AppContainer = (props: AppContainerProps) => { }); const refreshStatic = useCallback(() => { - if (!isAlternateBuffer) { + if (!isAlternateBuffer && !config.getUseTerminalBuffer()) { stdout.write(ansiEscapes.clearTerminal); + setHistoryRemountKey((prev) => prev + 1); } - setHistoryRemountKey((prev) => prev + 1); - }, [setHistoryRemountKey, isAlternateBuffer, stdout]); + }, [setHistoryRemountKey, isAlternateBuffer, stdout, config]); const shouldUseAlternateScreen = shouldEnterAlternateScreen( isAlternateBuffer, @@ -1426,6 +1448,14 @@ Logging in with Google... Restarting Gemini CLI to continue. !copyModeEnabled; const observerRef = useRef(null); + + useEffect( + () => () => { + observerRef.current?.disconnect(); + }, + [], + ); + const [controlsHeight, setControlsHeight] = useState(0); const [lastNonCopyControlsHeight, setLastNonCopyControlsHeight] = useState(0); @@ -1724,6 +1754,11 @@ Logging in with Google... Restarting Gemini CLI to continue. setShortcutsHelpVisible(false); } + if (keyMatchers[Command.TOGGLE_MOUSE_MODE](key)) { + setMouseMode((prev) => !prev); + return true; + } + if (isAlternateBuffer && keyMatchers[Command.TOGGLE_COPY_MODE](key)) { setCopyModeEnabled(true); disableMouseEvents(); @@ -1746,6 +1781,32 @@ Logging in with Google... Restarting Gemini CLI to continue. return true; } else if (keyMatchers[Command.SUSPEND_APP](key)) { handleSuspend(); + } else if (keyMatchers[Command.DUMP_FRAME](key)) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `snapshot-${timestamp}.json`; + if (dumpCurrentFrame) { + dumpCurrentFrame(filename); + debugLogger.log(`Dumped frame to: ${filename}`); + } + return true; + } else if (keyMatchers[Command.START_RECORDING](key)) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `recording-${timestamp}.json`; + if (startRecording) { + startRecording(filename); + recordingFilenameRef.current = filename; + debugLogger.log(`Started recording to: ${filename}`); + } + return true; + } else if (keyMatchers[Command.STOP_RECORDING](key)) { + if (stopRecording) { + stopRecording(); + debugLogger.log( + `Stopped recording, saved to: ${recordingFilenameRef.current ?? 'unknown'}`, + ); + recordingFilenameRef.current = null; + } + return true; } else if ( keyMatchers[Command.TOGGLE_COPY_MODE](key) && !isAlternateBuffer @@ -1932,6 +1993,9 @@ Logging in with Google... Restarting Gemini CLI to continue. historyManager.history, pendingHistoryItems, toggleAllExpansion, + dumpCurrentFrame, + startRecording, + stopRecording, ], ); @@ -1951,7 +2015,9 @@ Logging in with Google... Restarting Gemini CLI to continue. } setCopyModeEnabled(false); - enableMouseEvents(); + if (mouseMode) { + enableMouseEvents(); + } return true; }, { @@ -2268,6 +2334,7 @@ Logging in with Google... Restarting Gemini CLI to continue. editorError, isEditorDialogOpen, showPrivacyNotice, + mouseMode, corgiMode, debugMessage, quittingMessages, @@ -2394,6 +2461,7 @@ Logging in with Google... Restarting Gemini CLI to continue. editorError, isEditorDialogOpen, showPrivacyNotice, + mouseMode, corgiMode, debugMessage, quittingMessages, @@ -2694,7 +2762,11 @@ Logging in with Google... Restarting Gemini CLI to continue. toggleAllExpansion={toggleAllExpansion} > - + + + + + diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index d6fc23dd70..523f15516c 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -166,6 +166,7 @@ Implement a comprehensive authentication system with multiple providers. writeTextFile: vi.fn(), }), getUseAlternateBuffer: () => useAlternateBuffer, + getUseTerminalBuffer: () => false, } as unknown as import('@google/gemini-cli-core').Config, settings: createMockSettings({ ui: { useAlternateBuffer } }), }, @@ -466,6 +467,7 @@ Implement a comprehensive authentication system with multiple providers. writeTextFile: vi.fn(), }), getUseAlternateBuffer: () => useAlternateBuffer ?? true, + getUseTerminalBuffer: () => false, } as unknown as import('@google/gemini-cli-core').Config, settings: createMockSettings({ ui: { useAlternateBuffer: useAlternateBuffer ?? true }, diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index de6e8096ec..26cd93ba5d 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -18,7 +18,7 @@ vi.mock('../../utils/processUtils.js', () => ({ })); const mockedExit = vi.hoisted(() => vi.fn()); -const mockedCwd = vi.hoisted(() => vi.fn()); +const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd')); const mockedRows = vi.hoisted(() => ({ current: 24 })); vi.mock('node:process', async () => { diff --git a/packages/cli/src/ui/components/Help.test.tsx b/packages/cli/src/ui/components/Help.test.tsx index ed685f76c9..058fb0db55 100644 --- a/packages/cli/src/ui/components/Help.test.tsx +++ b/packages/cli/src/ui/components/Help.test.tsx @@ -72,7 +72,7 @@ describe('Help Component', () => { expect(output).toContain('Keyboard Shortcuts:'); expect(output).toContain('Ctrl+C'); - expect(output).toContain('Ctrl+S'); + expect(output).toContain('Shift+Tab'); expect(output).toContain('Page Up/Page Down'); unmount(); }); diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index c4e395c612..056f6041e6 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -12,6 +12,7 @@ import { useAppContext } from '../contexts/AppContext.js'; import { AppHeader } from './AppHeader.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; +import { useConfig } from '../contexts/ConfigContext.js'; import { SCROLL_TO_ITEM_END, type VirtualizedListRef, @@ -34,6 +35,11 @@ export const MainContent = () => { const { version } = useAppContext(); const uiState = useUIState(); const isAlternateBuffer = useAlternateBuffer(); + const config = useConfig(); + const useTerminalBuffer = + typeof config.getUseTerminalBuffer === 'function' + ? config.getUseTerminalBuffer() + : false; const confirmingTool = useConfirmingTool(); const showConfirmationQueue = confirmingTool !== null; @@ -53,6 +59,7 @@ export const MainContent = () => { staticAreaMaxItemHeight, availableTerminalHeight, cleanUiDetailsVisible, + mouseMode, } = uiState; const showHeaderDetails = cleanUiDetailsVisible; @@ -284,24 +291,63 @@ export const MainContent = () => { ], ); + const estimatedItemHeight = useCallback(() => 100, []); + + const keyExtractor = useCallback( + (item: (typeof virtualizedData)[number], _index: number) => { + if (item.type === 'header') return 'header'; + if (item.type === 'history') return item.item.id.toString(); + return 'pending'; + }, + [], + ); + + const isStaticItem = useCallback( + (item: (typeof virtualizedData)[number]) => item.type !== 'pending', + [], + ); + + const scrollableList = useMemo(() => { + if (isAlternateBuffer) { + return ( + + // TODO(jacobr): consider adding stableScrollback={!config.getUseAlternateBuffer()} + // but need to work out ensuring we only attempt it within a smaller range of scrollback vals. + ); + } + return null; + }, [ + isAlternateBuffer, + uiState.isEditorDialogOpen, + uiState.embeddedShellFocused, + uiState.terminalWidth, + virtualizedData, + renderItem, + estimatedItemHeight, + keyExtractor, + useTerminalBuffer, + isStaticItem, + mouseMode, + ]); + if (isAlternateBuffer) { - return ( - 100} - keyExtractor={(item, _index) => { - if (item.type === 'header') return 'header'; - if (item.type === 'history') return item.item.id.toString(); - return 'pending'; - }} - initialScrollIndex={SCROLL_TO_ITEM_END} - initialScrollOffsetInIndex={SCROLL_TO_ITEM_END} - /> - ); + return scrollableList; } return ( diff --git a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx index acb7897ba1..2e2ec16a94 100644 --- a/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/PermissionsModifyTrustDialog.test.tsx @@ -22,7 +22,7 @@ import * as processUtils from '../../utils/processUtils.js'; import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js'; // Hoist mocks for dependencies of the usePermissionsModifyTrust hook -const mockedCwd = vi.hoisted(() => vi.fn()); +const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd')); const mockedLoadTrustedFolders = vi.hoisted(() => vi.fn()); const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn()); diff --git a/packages/cli/src/ui/components/StatusRow.tsx b/packages/cli/src/ui/components/StatusRow.tsx index adaa339a64..2f059086b0 100644 --- a/packages/cli/src/ui/components/StatusRow.tsx +++ b/packages/cli/src/ui/components/StatusRow.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useRef, useState, useEffect } from 'react'; import { Box, Text, ResizeObserver, type DOMElement } from 'ink'; import { isUserVisibleHook, @@ -77,6 +77,13 @@ export const StatusNode: React.FC<{ }) => { const observerRef = useRef(null); + useEffect( + () => () => { + observerRef.current?.disconnect(); + }, + [], + ); + const onRefChange = useCallback( (node: DOMElement | null) => { if (observerRef.current) { @@ -169,6 +176,13 @@ export const StatusRow: React.FC = ({ const [tipWidth, setTipWidth] = useState(0); const tipObserverRef = useRef(null); + useEffect( + () => () => { + tipObserverRef.current?.disconnect(); + }, + [], + ); + const onTipRefChange = useCallback((node: DOMElement | null) => { if (tipObserverRef.current) { tipObserverRef.current.disconnect(); diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap index c8c4c53c89..0592c7bfe4 100644 --- a/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap @@ -15,7 +15,8 @@ Tips for getting started: 1. Create GEMINI.md files to customize your interactions 2. /help for more information 3. Ask coding questions, edit code or run commands -4. Be specific for the best results" +4. Be specific for the best results +" `; exports[`AppHeader Icon Rendering > renders the symmetric icon in Apple Terminal 1`] = ` @@ -33,5 +34,6 @@ Tips for getting started: 1. Create GEMINI.md files to customize your interactions 2. /help for more information 3. Ask coding questions, edit code or run commands -4. Be specific for the best results" +4. Be specific for the best results +" `; diff --git a/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap index 626ae93359..914f9bb0ed 100644 --- a/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/FooterConfigDialog.test.tsx.snap @@ -42,7 +42,8 @@ exports[` > highlights the active item in the preview 1`] │ │ ~/project/path main docker gemini-2.5-pro 97% +12 -4 │ │ │ └────────────────────────────────────────────────────────────────────────────────────────────┘ │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; exports[` > renders correctly with default settings 1`] = ` @@ -133,7 +134,8 @@ exports[` > renders correctly with default settings 2`] = │ │ ~/project/path main docker gemini-2.5-pro 97% │ │ │ └────────────────────────────────────────────────────────────────────────────────────────────┘ │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; exports[` > updates the preview when Show footer labels is toggled off 1`] = ` @@ -177,5 +179,6 @@ exports[` > updates the preview when Show footer labels is │ │ ~/project/path · main · docker · gemini-2.5-pro · 97% │ │ │ └────────────────────────────────────────────────────────────────────────────────────────────┘ │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap index f0260ddc91..362246423c 100644 --- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap @@ -225,5 +225,6 @@ AppHeader(full) │ │ Refining approach │ And finally a third multiple line paragraph for the third thinking message to - │ refine the solution." + │ refine the solution. +" `; diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index d585c9a918..7c57df0085 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -43,7 +43,8 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v │ │ │ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings enabled' correctly 1`] = ` @@ -89,7 +90,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings │ │ │ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings disabled' correctly 1`] = ` @@ -135,7 +137,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d │ │ │ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; exports[`SettingsDialog > Snapshot Tests > should render 'default state' correctly 1`] = ` @@ -181,7 +184,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct │ │ │ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; exports[`SettingsDialog > Snapshot Tests > should render 'file filtering settings configured' correctly 1`] = ` @@ -227,7 +231,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting │ │ │ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selector' correctly 1`] = ` @@ -273,7 +278,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ │ │ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and number settings' correctly 1`] = ` @@ -319,7 +325,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb │ │ │ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; exports[`SettingsDialog > Snapshot Tests > should render 'tools and security settings' correctly 1`] = ` @@ -365,7 +372,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set │ │ │ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settings enabled' correctly 1`] = ` @@ -411,5 +419,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin │ │ │ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +" `; diff --git a/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap index 8356ef4345..2c98e41b5e 100644 --- a/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap @@ -4,17 +4,20 @@ exports[`Table > should render headers and data correctly 1`] = ` "ID Name ──────────────────────────────────────────────────────────────────────────────────────────────────── 1 Alice -2 Bob" +2 Bob +" `; exports[`Table > should support custom cell rendering 1`] = ` "Value ──────────────────────────────────────────────────────────────────────────────────────────────────── -20" +20 +" `; exports[`Table > should support inverse text rendering 1`] = ` "Status ──────────────────────────────────────────────────────────────────────────────────────────────────── -Active" +Active +" `; diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 631bbf032d..fa565bc103 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -90,6 +90,13 @@ export const ToolConfirmationMessage: React.FC< useState(0); const observerRef = useRef(null); + useEffect( + () => () => { + observerRef.current?.disconnect(); + }, + [], + ); + const deceptiveUrlWarnings = useMemo(() => { const urls: string[] = []; if (confirmationDetails.type === 'info' && confirmationDetails.urls) { diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 4b51ae8ab8..7bb3934e94 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -51,7 +51,7 @@ export const ToolResultDisplay: React.FC = ({ hasFocus = false, overflowDirection = 'top', }) => { - const { renderMarkdown } = useUIState(); + const { renderMarkdown, terminalHeight } = useUIState(); const isAlternateBuffer = useAlternateBuffer(); const availableHeight = calculateToolContentMaxLines({ @@ -202,7 +202,8 @@ export const ToolResultDisplay: React.FC = ({ if (isAlternateBuffer) { // Virtualized path for large ANSI arrays if (Array.isArray(resultDisplay)) { - const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES; + const limit = + maxLines ?? availableHeight ?? terminalHeight ?? ACTIVE_SHELL_MAX_LINES; const listHeight = Math.min( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion (resultDisplay as AnsiOutput).length, diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap index f9eea8fb0a..1c4a9fe920 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ThinkingMessage.test.tsx.snap @@ -12,7 +12,8 @@ exports[`ThinkingMessage > filters out progress dots and empty lines 2`] = ` " Thinking... │ │ Thinking - │ Done" + │ Done +" `; exports[`ThinkingMessage > normalizes escaped newline tokens 1`] = ` @@ -27,7 +28,8 @@ exports[`ThinkingMessage > normalizes escaped newline tokens 2`] = ` " Thinking... │ │ Matching the Blocks - │ Some more text" + │ Some more text +" `; exports[`ThinkingMessage > renders "Thinking..." header when isFirstThinking is true 1`] = ` @@ -42,7 +44,8 @@ exports[`ThinkingMessage > renders "Thinking..." header when isFirstThinking is " Thinking... │ │ Summary line - │ First body line" + │ First body line +" `; exports[`ThinkingMessage > renders full mode with left border and full text 1`] = ` @@ -57,7 +60,8 @@ exports[`ThinkingMessage > renders full mode with left border and full text 2`] " Thinking... │ │ Planning - │ I am planning the solution." + │ I am planning the solution. +" `; exports[`ThinkingMessage > renders multiple thinking messages sequentially correctly 1`] = ` @@ -90,7 +94,8 @@ exports[`ThinkingMessage > renders multiple thinking messages sequentially corre │ │ Refining approach │ And finally a third multiple line paragraph for the third thinking message to - │ refine the solution." + │ refine the solution. +" `; exports[`ThinkingMessage > renders subject line with vertical rule and "Thinking..." header 1`] = ` @@ -105,7 +110,8 @@ exports[`ThinkingMessage > renders subject line with vertical rule and "Thinking " Thinking... │ │ Planning - │ test" + │ test +" `; exports[`ThinkingMessage > uses description when subject is empty 1`] = ` @@ -118,5 +124,6 @@ exports[`ThinkingMessage > uses description when subject is empty 1`] = ` exports[`ThinkingMessage > uses description when subject is empty 2`] = ` " Thinking... │ - │ Processing details" + │ Processing details +" `; diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx index 0e3869a3f0..7aa40cfc62 100644 --- a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx +++ b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx @@ -42,6 +42,14 @@ export const MaxSizedBox: React.FC = ({ const id = useId(); const { addOverflowingId, removeOverflowingId } = useOverflowActions() || {}; const observerRef = useRef(null); + + useEffect( + () => () => { + observerRef.current?.disconnect(); + }, + [], + ); + const [contentHeight, setContentHeight] = useState(0); const onRefChange = useCallback( diff --git a/packages/cli/src/ui/components/shared/Scrollable.tsx b/packages/cli/src/ui/components/shared/Scrollable.tsx index a95d2ff112..d9c3fb8c7a 100644 --- a/packages/cli/src/ui/components/shared/Scrollable.tsx +++ b/packages/cli/src/ui/components/shared/Scrollable.tsx @@ -33,6 +33,9 @@ interface ScrollableProps { scrollToBottom?: boolean; flexGrow?: number; reportOverflow?: boolean; + overflowToBackbuffer?: boolean; + scrollbar?: boolean; + stableScrollback?: boolean; } export const Scrollable: React.FC = ({ @@ -45,6 +48,9 @@ export const Scrollable: React.FC = ({ scrollToBottom, flexGrow, reportOverflow = false, + overflowToBackbuffer, + scrollbar = true, + stableScrollback, }) => { const keyMatchers = useKeyMatchers(); const [scrollTop, setScrollTop] = useState(0); @@ -91,6 +97,14 @@ export const Scrollable: React.FC = ({ const viewportObserverRef = useRef(null); const contentObserverRef = useRef(null); + useEffect( + () => () => { + viewportObserverRef.current?.disconnect(); + contentObserverRef.current?.disconnect(); + }, + [], + ); + const viewportRefCallback = useCallback((node: DOMElement | null) => { viewportObserverRef.current?.disconnect(); viewportRef.current = node; @@ -247,6 +261,9 @@ export const Scrollable: React.FC = ({ scrollTop={scrollTop} flexGrow={flexGrow} scrollbarThumbColor={scrollbarColor} + overflowToBackbuffer={overflowToBackbuffer} + scrollbar={scrollbar} + stableScrollback={stableScrollback} > {/* This inner box is necessary to prevent the parent from shrinking diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx index fd7eaeb8e3..0d45fc5aa1 100644 --- a/packages/cli/src/ui/components/shared/ScrollableList.tsx +++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx @@ -16,6 +16,7 @@ import type React from 'react'; import { VirtualizedList, type VirtualizedListRef, + type VirtualizedListProps, SCROLL_TO_ITEM_END, } from './VirtualizedList.js'; import { useScrollable } from '../../contexts/ScrollProvider.js'; @@ -27,18 +28,13 @@ import { useKeyMatchers } from '../../hooks/useKeyMatchers.js'; const ANIMATION_FRAME_DURATION_MS = 33; -type VirtualizedListProps = { - data: T[]; - renderItem: (info: { item: T; index: number }) => React.ReactElement; - estimatedItemHeight: (index: number) => number; - keyExtractor: (item: T, index: number) => string; - initialScrollIndex?: number; - initialScrollOffsetInIndex?: number; -}; - interface ScrollableListProps extends VirtualizedListProps { hasFocus: boolean; width?: string | number; + scrollbar?: boolean; + stableScrollback?: boolean; + copyModeEnabled?: boolean; + isStatic?: boolean; } export type ScrollableListRef = VirtualizedListRef; @@ -48,7 +44,7 @@ function ScrollableList( ref: React.Ref>, ) { const keyMatchers = useKeyMatchers(); - const { hasFocus, width } = props; + const { hasFocus, width, scrollbar = true, stableScrollback } = props; const virtualizedListRef = useRef>(null); const containerRef = useRef(null); @@ -258,17 +254,13 @@ function ScrollableList( useScrollable(scrollableEntry, true); return ( - + ); diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx index 75fcbd4633..98e7790538 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.test.tsx @@ -17,13 +17,6 @@ import { useState, } from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { UIState } from '../../contexts/UIStateContext.js'; - -vi.mock('../../contexts/UIStateContext.js', () => ({ - useUIState: vi.fn(() => ({ - copyModeEnabled: false, - })), -})); describe('', () => { const keyExtractor = (item: string) => item; @@ -324,11 +317,6 @@ describe('', () => { }); it('renders correctly in copyModeEnabled when scrolled', async () => { - const { useUIState } = await import('../../contexts/UIStateContext.js'); - vi.mocked(useUIState).mockReturnValue({ - copyModeEnabled: true, - } as Partial as UIState); - const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`); // Use copy mode const { lastFrame, unmount } = await render( @@ -343,6 +331,7 @@ describe('', () => { keyExtractor={(item) => item} estimatedItemHeight={() => 1} initialScrollIndex={50} + copyModeEnabled={true} /> , ); diff --git a/packages/cli/src/ui/components/shared/VirtualizedList.tsx b/packages/cli/src/ui/components/shared/VirtualizedList.tsx index 669b1bc035..de3cba1fc4 100644 --- a/packages/cli/src/ui/components/shared/VirtualizedList.tsx +++ b/packages/cli/src/ui/components/shared/VirtualizedList.tsx @@ -12,17 +12,18 @@ import { useImperativeHandle, useMemo, useCallback, + memo, } from 'react'; import type React from 'react'; import { theme } from '../../semantic-colors.js'; import { useBatchedScroll } from '../../hooks/useBatchedScroll.js'; -import { useUIState } from '../../contexts/UIStateContext.js'; +import { debugLogger } from '@google/gemini-cli-core'; -import { type DOMElement, Box, ResizeObserver } from 'ink'; +import { type DOMElement, Box, ResizeObserver, StaticRender } from 'ink'; export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER; -type VirtualizedListProps = { +export type VirtualizedListProps = { data: T[]; renderItem: (info: { item: T; index: number }) => React.ReactElement; estimatedItemHeight: (index: number) => number; @@ -30,6 +31,14 @@ type VirtualizedListProps = { initialScrollIndex?: number; initialScrollOffsetInIndex?: number; scrollbarThumbColor?: string; + renderStatic?: boolean; + isStatic?: boolean; + isStaticItem?: (item: T, index: number) => boolean; + width?: number | string; + overflowToBackbuffer?: boolean; + scrollbar?: boolean; + stableScrollback?: boolean; + copyModeEnabled?: boolean; }; export type VirtualizedListRef = { @@ -66,6 +75,43 @@ function findLastIndex( return -1; } +const VirtualizedListItem = memo( + ({ + content, + shouldBeStatic, + width, + containerWidth, + itemKey, + itemRef, + }: { + content: React.ReactElement; + shouldBeStatic: boolean; + width: number | string | undefined; + containerWidth: number; + itemKey: string; + itemRef: (el: DOMElement | null) => void; + }) => ( + + {shouldBeStatic ? ( + + {content} + + ) : ( + content + )} + + ), +); + +VirtualizedListItem.displayName = 'VirtualizedListItem'; + function VirtualizedList( props: VirtualizedListProps, ref: React.Ref>, @@ -77,8 +123,15 @@ function VirtualizedList( keyExtractor, initialScrollIndex, initialScrollOffsetInIndex, + renderStatic, + isStatic, + isStaticItem, + width, + overflowToBackbuffer, + scrollbar = true, + stableScrollback, + copyModeEnabled = false, } = props; - const { copyModeEnabled } = useUIState(); const dataRef = useRef(data); useLayoutEffect(() => { dataRef.current = data; @@ -119,6 +172,7 @@ function VirtualizedList( const containerRef = useRef(null); const [containerHeight, setContainerHeight] = useState(0); + const [containerWidth, setContainerWidth] = useState(0); const itemRefs = useRef>([]); const [heights, setHeights] = useState>({}); const isInitialScrollSet = useRef(false); @@ -133,7 +187,10 @@ function VirtualizedList( const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (entry) { - setContainerHeight(Math.round(entry.contentRect.height)); + const newHeight = Math.round(entry.contentRect.height); + const newWidth = Math.round(entry.contentRect.width); + setContainerHeight((prev) => (prev !== newHeight ? newHeight : prev)); + setContainerWidth((prev) => (prev !== newWidth ? newWidth : prev)); } }); observer.observe(node); @@ -242,7 +299,9 @@ function VirtualizedList( const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels; if (wasAtBottom && actualScrollTop >= prevScrollTop.current) { - setIsStickingToBottom(true); + if (!isStickingToBottom) { + setIsStickingToBottom(true); + } } const listGrew = data.length > prevDataLength.current; @@ -253,10 +312,16 @@ function VirtualizedList( (listGrew && (isStickingToBottom || wasAtBottom)) || (isStickingToBottom && containerChanged) ) { - setScrollAnchor({ - index: data.length > 0 ? data.length - 1 : 0, - offset: SCROLL_TO_ITEM_END, - }); + const newIndex = data.length > 0 ? data.length - 1 : 0; + if ( + scrollAnchor.index !== newIndex || + scrollAnchor.offset !== SCROLL_TO_ITEM_END + ) { + setScrollAnchor({ + index: newIndex, + offset: SCROLL_TO_ITEM_END, + }); + } if (!isStickingToBottom) { setIsStickingToBottom(true); } @@ -266,9 +331,17 @@ function VirtualizedList( data.length > 0 ) { const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight); - setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets)); + const newAnchor = getAnchorForScrollTop(newScrollTop, offsets); + if ( + scrollAnchor.index !== newAnchor.index || + scrollAnchor.offset !== newAnchor.offset + ) { + setScrollAnchor(newAnchor); + } } else if (data.length === 0) { - setScrollAnchor({ index: 0, offset: 0 }); + if (scrollAnchor.index !== 0 || scrollAnchor.offset !== 0) { + setScrollAnchor({ index: 0, offset: 0 }); + } } prevDataLength.current = data.length; @@ -281,6 +354,7 @@ function VirtualizedList( actualScrollTop, scrollableContainerHeight, scrollAnchor.index, + scrollAnchor.offset, getAnchorForScrollTop, offsets, isStickingToBottom, @@ -348,9 +422,11 @@ function VirtualizedList( ? data.length - 1 : Math.min(data.length - 1, endIndexOffset); - const topSpacerHeight = offsets[startIndex] ?? 0; - const bottomSpacerHeight = - totalHeight - (offsets[endIndex + 1] ?? totalHeight); + const topSpacerHeight = + renderStatic || overflowToBackbuffer ? 0 : (offsets[startIndex] ?? 0); + const bottomSpacerHeight = renderStatic + ? 0 + : totalHeight - (offsets[endIndex + 1] ?? totalHeight); // Maintain a stable set of observed nodes using useLayoutEffect const observedNodes = useRef>(new Set()); @@ -364,14 +440,16 @@ function VirtualizedList( const key = keyExtractor(item, i); // Always update the key mapping because React can reuse nodes at different indices/keys nodeToKeyRef.current.set(node, key); - if (!observedNodes.current.has(node)) { + if (!isStatic && !observedNodes.current.has(node)) { itemsObserver.observe(node); } } } for (const node of observedNodes.current) { if (!currentNodes.has(node)) { - itemsObserver.unobserve(node); + if (!isStatic) { + itemsObserver.unobserve(node); + } nodeToKeyRef.current.delete(node); } } @@ -379,25 +457,61 @@ function VirtualizedList( }); const renderedItems = []; - for (let i = startIndex; i <= endIndex; i++) { - const item = data[i]; - if (item) { - renderedItems.push( - { - itemRefs.current[i] = el; - }} - > - {renderItem({ item, index: i })} - , - ); + const renderRangeStart = + renderStatic || overflowToBackbuffer ? 0 : startIndex; + const renderRangeEnd = renderStatic ? data.length - 1 : endIndex; + + let staticCount = 0; + + // Always evaluate shouldBeStatic, width, etc. if we have a known width from the prop. + // If containerHeight or containerWidth is 0 we defer rendering unless a static render or defined width overrides. + // Wait, if it's not static and no width we need to wait for measure. + // BUT the initial render MUST render *something* with a width if width prop is provided to avoid layout shifts. + // We MUST wait for containerHeight > 0 before rendering, especially if renderStatic is true. + // If containerHeight is 0, we will misclassify items as isOutsideViewport and permanently print them to StaticRender! + const isReady = + containerHeight > 0 && + (containerWidth > 0 || (width !== undefined && typeof width === 'number')); + + if (isReady) { + for (let i = renderRangeStart; i <= renderRangeEnd; i++) { + const item = data[i]; + if (item) { + const isOutsideViewport = i < startIndex || i > endIndex; + const shouldBeStatic = !!( + (renderStatic && isOutsideViewport) || + isStaticItem?.(item, i) + ); + if (shouldBeStatic) { + staticCount++; + } + + const content = renderItem({ item, index: i }); + const key = keyExtractor(item, i); + + renderedItems.push( + { + if (i >= startIndex && i <= endIndex) { + itemRefs.current[i] = el; + } + }} + />, + ); + } } } + debugLogger.log( + `VirtualizedList rendered items: ${renderedItems.length}, isStatic property: ${!!isStatic}, static elements: ${staticCount}`, + ); + const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop); useImperativeHandle( @@ -539,6 +653,9 @@ function VirtualizedList( height="100%" flexDirection="column" paddingRight={copyModeEnabled ? 0 : 1} + overflowToBackbuffer={overflowToBackbuffer} + scrollbar={scrollbar} + stableScrollback={stableScrollback} > creates centered window around match when collapsed 1`] = ` "...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/ -components//and/then/some/more/components//and/..." +components//and/then/some/more/components//and/... +" `; -exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`; +exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = ` +"run: git commit -m "feat: add search" +" +`; -exports[`ExpandableText > renders plain label when no match (short label) 1`] = `"simple command"`; +exports[`ExpandableText > renders plain label when no match (short label) 1`] = ` +"simple command +" +`; -exports[`ExpandableText > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`; +exports[`ExpandableText > respects custom maxWidth 1`] = ` +"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz... +" +`; exports[`ExpandableText > shows full long label when expanded and no match 1`] = ` "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy -yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" +yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy +" `; exports[`ExpandableText > truncates long label when collapsed and no match 1`] = ` "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..." +xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx... +" `; exports[`ExpandableText > truncates match itself when match is very long 1`] = ` "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..." +xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx... +" `; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index a5d10820b2..99d5874aba 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -117,6 +117,7 @@ export interface UIState { editorError: string | null; isEditorDialogOpen: boolean; showPrivacyNotice: boolean; + mouseMode: boolean; corgiMode: boolean; debugMessage: string; quittingMessages: HistoryItem[] | null; @@ -191,7 +192,7 @@ export interface UIState { sessionStats: SessionStatsState; terminalWidth: number; terminalHeight: number; - mainControlsRef: React.RefCallback; + mainControlsRef: (node: DOMElement | null) => void; // NOTE: This is for performance profiling only. rootUiRef: React.MutableRefObject; currentIDE: IdeInfo | null; diff --git a/packages/cli/src/ui/hooks/useAlternateBuffer.test.ts b/packages/cli/src/ui/hooks/useAlternateBuffer.test.ts index 23e5a8b444..937a87195d 100644 --- a/packages/cli/src/ui/hooks/useAlternateBuffer.test.ts +++ b/packages/cli/src/ui/hooks/useAlternateBuffer.test.ts @@ -28,6 +28,7 @@ describe('useAlternateBuffer', () => { it('should return false when config.getUseAlternateBuffer returns false', async () => { mockUseConfig.mockReturnValue({ getUseAlternateBuffer: () => false, + getUseTerminalBuffer: () => false, } as unknown as ReturnType); const { result } = await renderHook(() => useAlternateBuffer()); @@ -37,6 +38,7 @@ describe('useAlternateBuffer', () => { it('should return true when config.getUseAlternateBuffer returns true', async () => { mockUseConfig.mockReturnValue({ getUseAlternateBuffer: () => true, + getUseTerminalBuffer: () => false, } as unknown as ReturnType); const { result } = await renderHook(() => useAlternateBuffer()); @@ -46,6 +48,7 @@ describe('useAlternateBuffer', () => { it('should return the immutable config value, not react to settings changes', async () => { const mockConfig = { getUseAlternateBuffer: () => true, + getUseTerminalBuffer: () => false, } as unknown as ReturnType; mockUseConfig.mockReturnValue(mockConfig); @@ -65,6 +68,7 @@ describe('isAlternateBufferEnabled', () => { it('should return true when config.getUseAlternateBuffer returns true', () => { const config = { getUseAlternateBuffer: () => true, + getUseTerminalBuffer: () => false, } as unknown as Config; expect(isAlternateBufferEnabled(config)).toBe(true); @@ -73,6 +77,7 @@ describe('isAlternateBufferEnabled', () => { it('should return false when config.getUseAlternateBuffer returns false', () => { const config = { getUseAlternateBuffer: () => false, + getUseTerminalBuffer: () => false, } as unknown as Config; expect(isAlternateBufferEnabled(config)).toBe(false); diff --git a/packages/cli/src/ui/hooks/useAlternateBuffer.ts b/packages/cli/src/ui/hooks/useAlternateBuffer.ts index 8300df70de..8b41d59f3f 100644 --- a/packages/cli/src/ui/hooks/useAlternateBuffer.ts +++ b/packages/cli/src/ui/hooks/useAlternateBuffer.ts @@ -8,7 +8,7 @@ import { useConfig } from '../contexts/ConfigContext.js'; import type { Config } from '@google/gemini-cli-core'; export const isAlternateBufferEnabled = (config: Config): boolean => - config.getUseAlternateBuffer(); + config.getUseAlternateBuffer() || config.getUseTerminalBuffer(); // This is read from Config so that the UI reads the same value per application session export const useAlternateBuffer = (): boolean => { diff --git a/packages/cli/src/ui/hooks/useAnimatedScrollbar.ts b/packages/cli/src/ui/hooks/useAnimatedScrollbar.ts index 46f6bb5c68..f45a7054d7 100644 --- a/packages/cli/src/ui/hooks/useAnimatedScrollbar.ts +++ b/packages/cli/src/ui/hooks/useAnimatedScrollbar.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useLayoutEffect, useRef, useCallback } from 'react'; import { theme } from '../semantic-colors.js'; import { interpolateColor } from '../themes/color-utils.js'; import { debugState } from '../debug.js'; @@ -107,7 +107,7 @@ export function useAnimatedScrollbar( }, [cleanup]); const wasFocused = useRef(isFocused); - useEffect(() => { + useLayoutEffect(() => { if (isFocused && !wasFocused.current) { flashScrollbar(); } else if (!isFocused && wasFocused.current) { diff --git a/packages/cli/src/ui/hooks/useBatchedScroll.ts b/packages/cli/src/ui/hooks/useBatchedScroll.ts index 05b73a9068..c294fb0cca 100644 --- a/packages/cli/src/ui/hooks/useBatchedScroll.ts +++ b/packages/cli/src/ui/hooks/useBatchedScroll.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useRef, useEffect, useCallback } from 'react'; +import { useRef, useLayoutEffect, useCallback } from 'react'; /** * A hook to manage batched scroll state updates. @@ -17,7 +17,7 @@ export function useBatchedScroll(currentScrollTop: number) { // and not depend on the currentScrollTop value directly in its dependency array. const currentScrollTopRef = useRef(currentScrollTop); - useEffect(() => { + useLayoutEffect(() => { currentScrollTopRef.current = currentScrollTop; pendingScrollTopRef.current = null; }); diff --git a/packages/cli/src/ui/hooks/useFolderTrust.test.ts b/packages/cli/src/ui/hooks/useFolderTrust.test.ts index 04c5b64dd2..c988fe711a 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.test.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.test.ts @@ -28,7 +28,7 @@ import * as trustedFolders from '../../config/trustedFolders.js'; import { coreEvents, ExitCodes, isHeadlessMode } from '@google/gemini-cli-core'; import { MessageType } from '../types.js'; -const mockedCwd = vi.hoisted(() => vi.fn()); +const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd')); const mockedExit = vi.hoisted(() => vi.fn()); vi.mock('@google/gemini-cli-core', async () => { diff --git a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts index 991a52a1c8..b2cd40df9a 100644 --- a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts +++ b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts @@ -24,7 +24,7 @@ import type { LoadedSettings } from '../../config/settings.js'; import { coreEvents } from '@google/gemini-cli-core'; // Hoist mocks -const mockedCwd = vi.hoisted(() => vi.fn()); +const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd')); const mockedLoadTrustedFolders = vi.hoisted(() => vi.fn()); const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn()); const mockedUseSettings = vi.hoisted(() => vi.fn()); diff --git a/packages/cli/src/ui/key/keyBindings.ts b/packages/cli/src/ui/key/keyBindings.ts index bef10f8522..c23596dc0f 100644 --- a/packages/cli/src/ui/key/keyBindings.ts +++ b/packages/cli/src/ui/key/keyBindings.ts @@ -85,6 +85,7 @@ export enum Command { SHOW_IDE_CONTEXT_DETAIL = 'app.showIdeContextDetail', TOGGLE_MARKDOWN = 'app.toggleMarkdown', TOGGLE_COPY_MODE = 'app.toggleCopyMode', + TOGGLE_MOUSE_MODE = 'app.toggleMouseMode', TOGGLE_YOLO = 'app.toggleYolo', CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode', SHOW_MORE_LINES = 'app.showMoreLines', @@ -109,6 +110,10 @@ export enum Command { // Extension Controls UPDATE_EXTENSION = 'extension.update', LINK_EXTENSION = 'extension.link', + + DUMP_FRAME = 'app.dumpFrame', + START_RECORDING = 'app.startRecording', + STOP_RECORDING = 'app.stopRecording', } /** @@ -385,7 +390,8 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([ [Command.SHOW_FULL_TODOS, [new KeyBinding('ctrl+t')]], [Command.SHOW_IDE_CONTEXT_DETAIL, [new KeyBinding('ctrl+g')]], [Command.TOGGLE_MARKDOWN, [new KeyBinding('alt+m')]], - [Command.TOGGLE_COPY_MODE, [new KeyBinding('ctrl+s')]], + [Command.TOGGLE_COPY_MODE, [new KeyBinding('f9')]], + [Command.TOGGLE_MOUSE_MODE, [new KeyBinding('ctrl+s')]], [Command.TOGGLE_YOLO, [new KeyBinding('ctrl+y')]], [Command.CYCLE_APPROVAL_MODE, [new KeyBinding('shift+tab')]], [Command.SHOW_MORE_LINES, [new KeyBinding('ctrl+o')]], @@ -396,6 +402,9 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([ [Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]], [Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]], [Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]], + [Command.DUMP_FRAME, [new KeyBinding('f8')]], + [Command.START_RECORDING, [new KeyBinding('f6')]], + [Command.STOP_RECORDING, [new KeyBinding('f7')]], // Background Shell Controls [Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]], @@ -512,6 +521,7 @@ export const commandCategories: readonly CommandCategory[] = [ Command.SHOW_IDE_CONTEXT_DETAIL, Command.TOGGLE_MARKDOWN, Command.TOGGLE_COPY_MODE, + Command.TOGGLE_MOUSE_MODE, Command.TOGGLE_YOLO, Command.CYCLE_APPROVAL_MODE, Command.SHOW_MORE_LINES, @@ -535,6 +545,9 @@ export const commandCategories: readonly CommandCategory[] = [ Command.UNFOCUS_BACKGROUND_SHELL, Command.UNFOCUS_BACKGROUND_SHELL_LIST, Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING, + Command.DUMP_FRAME, + Command.START_RECORDING, + Command.STOP_RECORDING, ], }, { @@ -621,6 +634,7 @@ export const commandDescriptions: Readonly> = { [Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.', [Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.', [Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.', + [Command.TOGGLE_MOUSE_MODE]: 'Toggle mouse mode (scrolling and clicking).', [Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.', [Command.CYCLE_APPROVAL_MODE]: 'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.', @@ -654,6 +668,10 @@ export const commandDescriptions: Readonly> = { // Extension Controls [Command.UPDATE_EXTENSION]: 'Update the current extension if available.', [Command.LINK_EXTENSION]: 'Link the current extension to a local path.', + + [Command.DUMP_FRAME]: 'Dump the current frame as a snapshot.', + [Command.START_RECORDING]: 'Start recording the session.', + [Command.STOP_RECORDING]: 'Stop recording the session.', }; const keybindingsSchema = z.array( diff --git a/packages/cli/src/ui/key/keyMatchers.test.ts b/packages/cli/src/ui/key/keyMatchers.test.ts index ab12ca1ddf..2a3709350f 100644 --- a/packages/cli/src/ui/key/keyMatchers.test.ts +++ b/packages/cli/src/ui/key/keyMatchers.test.ts @@ -346,6 +346,11 @@ describe('keyMatchers', () => { }, { command: Command.TOGGLE_COPY_MODE, + positive: [createKey('f9')], + negative: [createKey('f8'), createKey('f10')], + }, + { + command: Command.TOGGLE_MOUSE_MODE, positive: [createKey('s', { ctrl: true })], negative: [createKey('s'), createKey('s', { alt: true })], }, diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index aaa9e04632..964fb5ec55 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -34,7 +34,6 @@ export const DefaultAppLayout: React.FC = () => { paddingBottom={isAlternateBuffer ? 1 : undefined} flexShrink={0} flexGrow={0} - overflow="hidden" ref={uiState.rootUiRef} > diff --git a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx index d88f3f1fb2..f6eb87b102 100644 --- a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx +++ b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx @@ -29,7 +29,7 @@ export const ScreenReaderAppLayout: React.FC = () => { >