diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 6ca30dd8b9..4ffcc54adf 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -534,6 +534,7 @@ export const mockAppState: AppState = { }; const mockUIActions: UIActions = { + toggleAlternateBuffer: vi.fn(), handleThemeSelect: vi.fn(), closeThemeDialog: vi.fn(), handleThemeHighlight: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 9942e24e48..cae68610aa 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -68,8 +68,10 @@ import { writeToStdout, disableMouseEvents, enterAlternateScreen, + exitAlternateScreen, enableMouseEvents, disableLineWrapping, + enableLineWrapping, shouldEnterAlternateScreen, startupProfiler, SessionStartSource, @@ -213,7 +215,7 @@ export const AppContainer = (props: AppContainerProps) => { }); useMemoryMonitor(historyManager); - const isAlternateBuffer = config.getUseAlternateBuffer(); + const [isAlternateBuffer, setIsAlternateBuffer] = useState(config.getUseAlternateBuffer()); const [corgiMode, setCorgiMode] = useState(false); const [forceRerenderKey, setForceRerenderKey] = useState(0); const [debugMessage, setDebugMessage] = useState(''); @@ -1550,6 +1552,23 @@ Logging in with Google... Restarting Gemini CLI to continue. type: TransientMessageType; }>(WARNING_PROMPT_DURATION_MS); + const [shownBufferToggleHint, setShownBufferToggleHint] = useState(false); + + useEffect(() => { + if (isAlternateBuffer) return; + + const isLongHistory = historyManager.history.length > 15; + const isComplexPrompt = buffer.text.length > 200 || buffer.text.includes('\n'); + + if ((isLongHistory || isComplexPrompt) && !shownBufferToggleHint) { + showTransientMessage({ + text: 'Tip: Press Alt+T to toggle full-screen mode for better scrolling/editing', + type: TransientMessageType.Hint + }); + setShownBufferToggleHint(true); + } + }, [historyManager.history.length, buffer.text, isAlternateBuffer, shownBufferToggleHint, showTransientMessage]); + const { isFolderTrustDialogOpen, discoveryResults: folderDiscoveryResults, @@ -1700,6 +1719,11 @@ Logging in with Google... Restarting Gemini CLI to continue. return true; } + if (keyMatchers[Command.TOGGLE_BUFFER_MODE](key)) { + toggleAlternateBuffer(); + return true; + } + if (keyMatchers[Command.QUIT](key)) { // If the user presses Ctrl+C, we want to cancel any ongoing requests. // This should happen regardless of the count. @@ -2204,8 +2228,11 @@ Logging in with Google... Restarting Gemini CLI to continue. }; }, [config, refreshStatic]); + const showIsAlternateBufferHint = (historyManager.history.length > 15 || buffer.text.length > 200 || buffer.text.includes('\n')) && !isAlternateBuffer; + const uiState: UIState = useMemo( () => ({ + isAlternateBuffer, history: historyManager.history, historyManager, isThemeDialogOpen, @@ -2331,6 +2358,7 @@ Logging in with Google... Restarting Gemini CLI to continue. adminSettingsChanged, newAgents, showIsExpandableHint, + showIsAlternateBufferHint, hintMode: config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems), hintBuffer: '', @@ -2457,6 +2485,8 @@ Logging in with Google... Restarting Gemini CLI to continue. adminSettingsChanged, newAgents, showIsExpandableHint, + showIsAlternateBufferHint, + isAlternateBuffer, ], ); @@ -2465,6 +2495,31 @@ Logging in with Google... Restarting Gemini CLI to continue. [setShowPrivacyNotice], ); + const toggleAlternateBuffer = useCallback(() => { + setIsAlternateBuffer(prev => { + const next = !prev; + if (next) { + enterAlternateScreen(); + enableMouseEvents(); + disableLineWrapping(); + } else { + exitAlternateScreen(); + disableMouseEvents(); + enableLineWrapping(); + writeToStdout('\x1b[2J\x1b[H'); + } + process.stdout.emit('resize'); + + // Give a tick for resize to process, then trigger remount to force full redraw + setImmediate(() => { + refreshStatic(); + setForceRerenderKey((prev) => prev + 1); + }); + + return next; + }); + }, [setIsAlternateBuffer, refreshStatic, setForceRerenderKey]); + const uiActions: UIActions = useMemo( () => ({ handleThemeSelect, @@ -2476,6 +2531,7 @@ Logging in with Google... Restarting Gemini CLI to continue. handleEditorSelect, exitEditorDialog, exitPrivacyNotice, + toggleAlternateBuffer, closeSettingsDialog, closeModelDialog, openAgentConfigDialog, @@ -2614,6 +2670,7 @@ Logging in with Google... Restarting Gemini CLI to continue. config, historyManager, getPreferredEditor, + toggleAlternateBuffer, ], ); diff --git a/packages/cli/src/ui/components/StatusRow.test.tsx b/packages/cli/src/ui/components/StatusRow.test.tsx index b80dbacabe..145f0cb7d0 100644 --- a/packages/cli/src/ui/components/StatusRow.test.tsx +++ b/packages/cli/src/ui/components/StatusRow.test.tsx @@ -142,4 +142,38 @@ describe('', () => { await waitUntilReady(); expect(lastFrame()).toContain('Tip: Test Tip'); }); + + it('renders buffer toggle hint when showIsAlternateBufferHint is true', async () => { + (useComposerStatus as Mock).mockReturnValue({ + isInteractiveShellWaiting: false, + showLoadingIndicator: false, + showTips: false, + showWit: false, + modeContentObj: null, + showMinimalContext: false, + }); + + const uiState: Partial = { + ...defaultUiState, + showIsAlternateBufferHint: true, + }; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { + width: 100, + uiState, + }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('[Alt+T] Switch to Full Screen'); + }); }); diff --git a/packages/cli/src/ui/components/StatusRow.tsx b/packages/cli/src/ui/components/StatusRow.tsx index adaa339a64..886eb0916c 100644 --- a/packages/cli/src/ui/components/StatusRow.tsx +++ b/packages/cli/src/ui/components/StatusRow.tsx @@ -206,7 +206,12 @@ export const StatusRow: React.FC = ({ return uiState.currentTip; } - // 2. Shortcut Hint (Fallback) + // 2. Buffer Toggle Hint + if (uiState.showIsAlternateBufferHint) { + return '[Alt+T] Switch to Full Screen'; + } + + // 3. Shortcut Hint (Fallback) if ( settings.merged.ui.showShortcutsHint && !hideUiDetailsForSuggestions && diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 3189172792..c2e1f63a20 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -205,6 +205,8 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record = { '\u03A9': 'z', // "Ω" Option+z '\u00B8': 'Z', // "¸" Option+Shift+z '\u2202': 'd', // "∂" delete word forward + '\u2020': 't', // "†" toggle full screen buffer + '\u00E5': 'a', // "å" Option+a for alternate buffer }; function nonKeyboardEventFilter( diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index f1959c0173..3f1c4f1abd 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -78,6 +78,7 @@ export interface UIActions { setShortcutsHelpVisible: (visible: boolean) => void; setCleanUiDetailsVisible: (visible: boolean) => void; toggleCleanUiDetailsVisible: () => void; + toggleAlternateBuffer: () => void; revealCleanUiDetailsTemporarily: (durationMs?: number) => void; handleWarning: (message: string) => void; setEmbeddedShellFocused: (value: boolean) => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index a5d10820b2..facf7f60a2 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -118,6 +118,8 @@ export interface UIState { isEditorDialogOpen: boolean; showPrivacyNotice: boolean; corgiMode: boolean; + isAlternateBuffer: boolean; + showIsAlternateBufferHint: boolean; debugMessage: string; quittingMessages: HistoryItem[] | null; isSettingsDialogOpen: boolean; diff --git a/packages/cli/src/ui/hooks/useAlternateBuffer.test.ts b/packages/cli/src/ui/hooks/useAlternateBuffer.test.ts index 23e5a8b444..db7434a631 100644 --- a/packages/cli/src/ui/hooks/useAlternateBuffer.test.ts +++ b/packages/cli/src/ui/hooks/useAlternateBuffer.test.ts @@ -11,49 +11,47 @@ import { isAlternateBufferEnabled, } from './useAlternateBuffer.js'; import type { Config } from '@google/gemini-cli-core'; +import { useUIState } from '../contexts/UIStateContext.js'; -vi.mock('../contexts/ConfigContext.js', () => ({ - useConfig: vi.fn(), -})); +vi.mock('../contexts/UIStateContext.js'); -const mockUseConfig = vi.mocked( - await import('../contexts/ConfigContext.js').then((m) => m.useConfig), -); +const mockUseUIState = vi.mocked(useUIState); describe('useAlternateBuffer', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('should return false when config.getUseAlternateBuffer returns false', async () => { - mockUseConfig.mockReturnValue({ - getUseAlternateBuffer: () => false, - } as unknown as ReturnType); + it('should return false when uiState.isAlternateBuffer is false', async () => { + mockUseUIState.mockReturnValue({ + isAlternateBuffer: false, + } as unknown as ReturnType); const { result } = await renderHook(() => useAlternateBuffer()); expect(result.current).toBe(false); }); - it('should return true when config.getUseAlternateBuffer returns true', async () => { - mockUseConfig.mockReturnValue({ - getUseAlternateBuffer: () => true, - } as unknown as ReturnType); + it('should return true when uiState.isAlternateBuffer is true', async () => { + mockUseUIState.mockReturnValue({ + isAlternateBuffer: true, + } as unknown as ReturnType); const { result } = await renderHook(() => useAlternateBuffer()); expect(result.current).toBe(true); }); - it('should return the immutable config value, not react to settings changes', async () => { - const mockConfig = { - getUseAlternateBuffer: () => true, - } as unknown as ReturnType; - - mockUseConfig.mockReturnValue(mockConfig); + it('should react to state changes', async () => { + mockUseUIState.mockReturnValue({ + isAlternateBuffer: false, + } as unknown as ReturnType); const { result, rerender } = await renderHook(() => useAlternateBuffer()); - // Value should remain true even after rerender - expect(result.current).toBe(true); + expect(result.current).toBe(false); + + mockUseUIState.mockReturnValue({ + isAlternateBuffer: true, + } as unknown as ReturnType); rerender(); diff --git a/packages/cli/src/ui/hooks/useAlternateBuffer.ts b/packages/cli/src/ui/hooks/useAlternateBuffer.ts index 8300df70de..ad9874813c 100644 --- a/packages/cli/src/ui/hooks/useAlternateBuffer.ts +++ b/packages/cli/src/ui/hooks/useAlternateBuffer.ts @@ -4,14 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useConfig } from '../contexts/ConfigContext.js'; +import { useUIState } from '../contexts/UIStateContext.js'; import type { Config } from '@google/gemini-cli-core'; export const isAlternateBufferEnabled = (config: Config): boolean => config.getUseAlternateBuffer(); -// This is read from Config so that the UI reads the same value per application session +// This is read from UIState so that the UI can toggle dynamically export const useAlternateBuffer = (): boolean => { - const config = useConfig(); - return isAlternateBufferEnabled(config); + const uiState = useUIState(); + return uiState.isAlternateBuffer; }; diff --git a/packages/cli/src/ui/key/keyBindings.ts b/packages/cli/src/ui/key/keyBindings.ts index ae5350e394..9d3acaeec2 100644 --- a/packages/cli/src/ui/key/keyBindings.ts +++ b/packages/cli/src/ui/key/keyBindings.ts @@ -95,6 +95,7 @@ export enum Command { RESTART_APP = 'app.restart', SUSPEND_APP = 'app.suspend', SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'app.showShellUnfocusWarning', + TOGGLE_BUFFER_MODE = 'app.toggleBufferMode', // Background Shell Controls BACKGROUND_SHELL_ESCAPE = 'background.escape', @@ -392,6 +393,7 @@ 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.TOGGLE_BUFFER_MODE, [new KeyBinding('alt+a')]], // Background Shell Controls [Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]], @@ -609,6 +611,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_BUFFER_MODE]: 'Toggle between regular and full screen (alternate buffer) mode.', [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.',