diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index f2870a5f57..bbc8b1681e 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -843,6 +843,7 @@ export async function loadCliConfig( interactive, trustedFolder, useBackgroundColor: settings.ui?.useBackgroundColor, + useAlternateBuffer: settings.ui?.useAlternateBuffer, useRipgrep: settings.tools?.useRipgrep, enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell, shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout, diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index dae249a8ac..c2a1d079c5 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -1182,6 +1182,7 @@ describe('startInteractiveUI', () => { getProjectRoot: () => '/root', getScreenReader: () => false, getDebugMode: () => false, + getUseAlternateBuffer: () => true, }); const mockSettings = { merged: { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 8cd7048a7e..2e238765e8 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -102,8 +102,8 @@ import { loadSandboxConfig } from './config/sandboxConfig.js'; import { deleteSession, listSessions } from './utils/sessions.js'; import { createPolicyUpdater } from './config/policy.js'; import { ScrollProvider } from './ui/contexts/ScrollProvider.js'; -import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js'; import { TerminalProvider } from './ui/contexts/TerminalContext.js'; +import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js'; import { OverflowProvider } from './ui/contexts/OverflowContext.js'; import { setupTerminalAndTheme } from './utils/terminalTheme.js'; @@ -196,7 +196,7 @@ export async function startInteractiveUI( // and the Ink alternate buffer mode requires line wrapping harmful to // screen readers. const useAlternateBuffer = shouldEnterAlternateScreen( - isAlternateBufferEnabled(settings), + isAlternateBufferEnabled(config), config.getScreenReader(), ); const mouseEventsEnabled = useAlternateBuffer; @@ -678,7 +678,7 @@ export async function main() { let input = config.getQuestion(); const useAlternateBuffer = shouldEnterAlternateScreen( - isAlternateBufferEnabled(settings), + isAlternateBufferEnabled(config), config.getScreenReader(), ); const rawStartupWarnings = await getStartupWarnings(); diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index af36444c39..bae89d36c9 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -156,6 +156,7 @@ export const createMockConfig = (overrides: Partial = {}): Config => getExperiments: vi.fn().mockReturnValue(undefined), getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), validatePathAccess: vi.fn().mockReturnValue(null), + getUseAlternateBuffer: 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 0420252149..2cfb89d0f2 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -703,6 +703,21 @@ export const renderWithProviders = ( }); } + // Wrap config in a Proxy so useAlternateBuffer hook (which reads from Config) gets the correct value, + // without replacing the entire config object and its other values. + let finalConfig = config; + if (useAlternateBuffer !== undefined) { + finalConfig = new Proxy(config, { + get(target, prop, receiver) { + if (prop === 'getUseAlternateBuffer') { + return () => useAlternateBuffer; + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Reflect.get(target, prop, receiver); + }, + }); + } + const mainAreaWidth = terminalWidth; const finalUiState = { @@ -731,7 +746,7 @@ export const renderWithProviders = ( const renderResult = render( - + @@ -743,7 +758,7 @@ export const renderWithProviders = ( { isAlternateMode = false, childHandler?: Mock, ) => { + vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue( + isAlternateMode, + ); + // Update settings for this test run const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); const testSettings = { @@ -3364,6 +3368,8 @@ describe('AppContainer State Management', () => { ); vi.mocked(checkPermissions).mockResolvedValue([]); + vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); + let unmount: () => void; await act(async () => { unmount = renderAppContainer({ @@ -3596,6 +3602,8 @@ describe('AppContainer State Management', () => { }, } as unknown as LoadedSettings; + vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); + let unmount: () => void; await act(async () => { const result = renderAppContainer({ diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 99c4b0c424..986bcafaa1 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -145,7 +145,6 @@ import { useSessionResume } from './hooks/useSessionResume.js'; import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js'; import { useSessionRetentionCheck } from './hooks/useSessionRetentionCheck.js'; import { isWorkspaceTrusted } from '../config/trustedFolders.js'; -import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; import { useSettings } from './contexts/SettingsContext.js'; import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js'; import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; @@ -228,7 +227,7 @@ export const AppContainer = (props: AppContainerProps) => { }); useMemoryMonitor(historyManager); - const isAlternateBuffer = useAlternateBuffer(); + const isAlternateBuffer = config.getUseAlternateBuffer(); const [corgiMode, setCorgiMode] = useState(false); const [forceRerenderKey, setForceRerenderKey] = useState(0); const [debugMessage, setDebugMessage] = useState(''); @@ -545,7 +544,7 @@ export const AppContainer = (props: AppContainerProps) => { const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } = useConsoleMessages(); - const mainAreaWidth = calculateMainAreaWidth(terminalWidth, settings); + const mainAreaWidth = calculateMainAreaWidth(terminalWidth, config); // Derive widths for InputPrompt using shared helper const { inputWidth, suggestionsWidth } = useMemo(() => { const { inputWidth, suggestionsWidth } = diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index c9def1a8c2..d691caba1a 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -167,6 +167,7 @@ Implement a comprehensive authentication system with multiple providers. readTextFile: vi.fn(), writeTextFile: vi.fn(), }), + getUseAlternateBuffer: () => options?.useAlternateBuffer ?? true, } as unknown as import('@google/gemini-cli-core').Config, }, ); @@ -443,6 +444,7 @@ Implement a comprehensive authentication system with multiple providers. readTextFile: vi.fn(), writeTextFile: vi.fn(), }), + getUseAlternateBuffer: () => useAlternateBuffer ?? true, } as unknown as import('@google/gemini-cli-core').Config, }, ); diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx index cabce1af2f..7b45bd0458 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx @@ -51,6 +51,7 @@ describe('ToolConfirmationQueue', () => { storage: { getPlansDir: () => '/mock/temp/plans', }, + getUseAlternateBuffer: () => false, } as unknown as Config; beforeEach(() => { diff --git a/packages/cli/src/ui/hooks/useAlternateBuffer.test.ts b/packages/cli/src/ui/hooks/useAlternateBuffer.test.ts new file mode 100644 index 0000000000..bf0e27aa37 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAlternateBuffer.test.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '../../test-utils/render.js'; +import { + useAlternateBuffer, + isAlternateBufferEnabled, +} from './useAlternateBuffer.js'; +import type { Config } from '@google/gemini-cli-core'; + +vi.mock('../contexts/ConfigContext.js', () => ({ + useConfig: vi.fn(), +})); + +const mockUseConfig = vi.mocked( + await import('../contexts/ConfigContext.js').then((m) => m.useConfig), +); + +describe('useAlternateBuffer', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return false when config.getUseAlternateBuffer returns false', () => { + mockUseConfig.mockReturnValue({ + getUseAlternateBuffer: () => false, + } as unknown as ReturnType); + + const { result } = renderHook(() => useAlternateBuffer()); + expect(result.current).toBe(false); + }); + + it('should return true when config.getUseAlternateBuffer returns true', () => { + mockUseConfig.mockReturnValue({ + getUseAlternateBuffer: () => true, + } as unknown as ReturnType); + + const { result } = renderHook(() => useAlternateBuffer()); + expect(result.current).toBe(true); + }); + + it('should return the immutable config value, not react to settings changes', () => { + const mockConfig = { + getUseAlternateBuffer: () => true, + } as unknown as ReturnType; + + mockUseConfig.mockReturnValue(mockConfig); + + const { result, rerender } = renderHook(() => useAlternateBuffer()); + + // Value should remain true even after rerender + expect(result.current).toBe(true); + + rerender(); + + expect(result.current).toBe(true); + }); +}); + +describe('isAlternateBufferEnabled', () => { + it('should return true when config.getUseAlternateBuffer returns true', () => { + const config = { + getUseAlternateBuffer: () => true, + } as unknown as Config; + + expect(isAlternateBufferEnabled(config)).toBe(true); + }); + + it('should return false when config.getUseAlternateBuffer returns false', () => { + const config = { + getUseAlternateBuffer: () => 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 45b387173a..8300df70de 100644 --- a/packages/cli/src/ui/hooks/useAlternateBuffer.ts +++ b/packages/cli/src/ui/hooks/useAlternateBuffer.ts @@ -4,13 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useSettings } from '../contexts/SettingsContext.js'; -import type { LoadedSettings } from '../../config/settings.js'; +import { useConfig } from '../contexts/ConfigContext.js'; +import type { Config } from '@google/gemini-cli-core'; -export const isAlternateBufferEnabled = (settings: LoadedSettings): boolean => - settings.merged.ui.useAlternateBuffer === true; +export const isAlternateBufferEnabled = (config: Config): boolean => + config.getUseAlternateBuffer(); +// This is read from Config so that the UI reads the same value per application session export const useAlternateBuffer = (): boolean => { - const settings = useSettings(); - return isAlternateBufferEnabled(settings); + const config = useConfig(); + return isAlternateBufferEnabled(config); }; diff --git a/packages/cli/src/ui/utils/ui-sizing.test.ts b/packages/cli/src/ui/utils/ui-sizing.test.ts index dc3b21e862..1b849bd9df 100644 --- a/packages/cli/src/ui/utils/ui-sizing.test.ts +++ b/packages/cli/src/ui/utils/ui-sizing.test.ts @@ -4,29 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect } from 'vitest'; import { calculateMainAreaWidth } from './ui-sizing.js'; -import { type LoadedSettings } from '../../config/settings.js'; - -// Mock dependencies -const mocks = vi.hoisted(() => ({ - isAlternateBufferEnabled: vi.fn(), -})); - -vi.mock('../hooks/useAlternateBuffer.js', () => ({ - isAlternateBufferEnabled: mocks.isAlternateBufferEnabled, -})); +import type { Config } from '@google/gemini-cli-core'; describe('ui-sizing', () => { - const createSettings = (useFullWidth?: boolean): LoadedSettings => - ({ - merged: { - ui: { - useFullWidth, - }, - }, - }) as unknown as LoadedSettings; - describe('calculateMainAreaWidth', () => { it.each([ // expected, width, altBuffer @@ -37,10 +19,10 @@ describe('ui-sizing', () => { ])( 'should return %i when width=%i and altBuffer=%s', (expected, width, altBuffer) => { - mocks.isAlternateBufferEnabled.mockReturnValue(altBuffer); - const settings = createSettings(); - - expect(calculateMainAreaWidth(width, settings)).toBe(expected); + const mockConfig = { + getUseAlternateBuffer: () => altBuffer, + } as unknown as Config; + expect(calculateMainAreaWidth(width, mockConfig)).toBe(expected); }, ); }); diff --git a/packages/cli/src/ui/utils/ui-sizing.ts b/packages/cli/src/ui/utils/ui-sizing.ts index d8b7f8e73f..8541c6c552 100644 --- a/packages/cli/src/ui/utils/ui-sizing.ts +++ b/packages/cli/src/ui/utils/ui-sizing.ts @@ -4,14 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { type LoadedSettings } from '../../config/settings.js'; +import type { Config } from '@google/gemini-cli-core'; import { isAlternateBufferEnabled } from '../hooks/useAlternateBuffer.js'; export const calculateMainAreaWidth = ( terminalWidth: number, - settings: LoadedSettings, + config: Config, ): number => { - if (isAlternateBufferEnabled(settings)) { + if (isAlternateBufferEnabled(config)) { return terminalWidth - 1; } return terminalWidth; diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 1034246e9c..ad8af8656c 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -941,6 +941,31 @@ describe('Server Config (config.ts)', () => { }); }); + describe('UseAlternateBuffer Configuration', () => { + it('should default useAlternateBuffer to false when not provided', () => { + const config = new Config(baseParams); + expect(config.getUseAlternateBuffer()).toBe(false); + }); + + it('should set useAlternateBuffer to true when provided as true', () => { + const paramsWithAlternateBuffer: ConfigParameters = { + ...baseParams, + useAlternateBuffer: true, + }; + const config = new Config(paramsWithAlternateBuffer); + expect(config.getUseAlternateBuffer()).toBe(true); + }); + + it('should set useAlternateBuffer to false when explicitly provided as false', () => { + const paramsWithAlternateBuffer: ConfigParameters = { + ...baseParams, + useAlternateBuffer: false, + }; + const config = new Config(paramsWithAlternateBuffer); + expect(config.getUseAlternateBuffer()).toBe(false); + }); + }); + describe('UseWriteTodos Configuration', () => { it('should default useWriteTodos to true when not provided', () => { const config = new Config(baseParams); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 32d74479e7..256e079fde 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -519,6 +519,7 @@ export interface ConfigParameters { interactive?: boolean; trustedFolder?: boolean; useBackgroundColor?: boolean; + useAlternateBuffer?: boolean; useRipgrep?: boolean; enableInteractiveShell?: boolean; skipNextSpeakerCheck?: boolean; @@ -702,6 +703,7 @@ export class Config { private readonly enableInteractiveShell: boolean; private readonly skipNextSpeakerCheck: boolean; private readonly useBackgroundColor: boolean; + private readonly useAlternateBuffer: boolean; private shellExecutionConfig: ShellExecutionConfig; private readonly extensionManagement: boolean = true; private readonly truncateToolOutputThreshold: number; @@ -900,6 +902,7 @@ export class Config { this.directWebFetch = params.directWebFetch ?? false; this.useRipgrep = params.useRipgrep ?? true; this.useBackgroundColor = params.useBackgroundColor ?? true; + this.useAlternateBuffer = params.useAlternateBuffer ?? false; this.enableInteractiveShell = params.enableInteractiveShell ?? false; this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true; this.shellExecutionConfig = { @@ -2521,6 +2524,10 @@ export class Config { return this.useBackgroundColor; } + getUseAlternateBuffer(): boolean { + return this.useAlternateBuffer; + } + getEnableInteractiveShell(): boolean { return this.enableInteractiveShell; }