Compare commits

...

1 Commits

Author SHA1 Message Date
Coco Sheng 80ff4bd39c feat: implement alternate buffer toggling and unit tests 2026-03-30 11:40:52 -04:00
10 changed files with 131 additions and 28 deletions
+1
View File
@@ -534,6 +534,7 @@ export const mockAppState: AppState = {
}; };
const mockUIActions: UIActions = { const mockUIActions: UIActions = {
toggleAlternateBuffer: vi.fn(),
handleThemeSelect: vi.fn(), handleThemeSelect: vi.fn(),
closeThemeDialog: vi.fn(), closeThemeDialog: vi.fn(),
handleThemeHighlight: vi.fn(), handleThemeHighlight: vi.fn(),
+58 -1
View File
@@ -68,8 +68,10 @@ import {
writeToStdout, writeToStdout,
disableMouseEvents, disableMouseEvents,
enterAlternateScreen, enterAlternateScreen,
exitAlternateScreen,
enableMouseEvents, enableMouseEvents,
disableLineWrapping, disableLineWrapping,
enableLineWrapping,
shouldEnterAlternateScreen, shouldEnterAlternateScreen,
startupProfiler, startupProfiler,
SessionStartSource, SessionStartSource,
@@ -213,7 +215,7 @@ export const AppContainer = (props: AppContainerProps) => {
}); });
useMemoryMonitor(historyManager); useMemoryMonitor(historyManager);
const isAlternateBuffer = config.getUseAlternateBuffer(); const [isAlternateBuffer, setIsAlternateBuffer] = useState(config.getUseAlternateBuffer());
const [corgiMode, setCorgiMode] = useState(false); const [corgiMode, setCorgiMode] = useState(false);
const [forceRerenderKey, setForceRerenderKey] = useState(0); const [forceRerenderKey, setForceRerenderKey] = useState(0);
const [debugMessage, setDebugMessage] = useState<string>(''); const [debugMessage, setDebugMessage] = useState<string>('');
@@ -1550,6 +1552,23 @@ Logging in with Google... Restarting Gemini CLI to continue.
type: TransientMessageType; type: TransientMessageType;
}>(WARNING_PROMPT_DURATION_MS); }>(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 { const {
isFolderTrustDialogOpen, isFolderTrustDialogOpen,
discoveryResults: folderDiscoveryResults, discoveryResults: folderDiscoveryResults,
@@ -1700,6 +1719,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
return true; return true;
} }
if (keyMatchers[Command.TOGGLE_BUFFER_MODE](key)) {
toggleAlternateBuffer();
return true;
}
if (keyMatchers[Command.QUIT](key)) { if (keyMatchers[Command.QUIT](key)) {
// If the user presses Ctrl+C, we want to cancel any ongoing requests. // If the user presses Ctrl+C, we want to cancel any ongoing requests.
// This should happen regardless of the count. // This should happen regardless of the count.
@@ -2204,8 +2228,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
}; };
}, [config, refreshStatic]); }, [config, refreshStatic]);
const showIsAlternateBufferHint = (historyManager.history.length > 15 || buffer.text.length > 200 || buffer.text.includes('\n')) && !isAlternateBuffer;
const uiState: UIState = useMemo( const uiState: UIState = useMemo(
() => ({ () => ({
isAlternateBuffer,
history: historyManager.history, history: historyManager.history,
historyManager, historyManager,
isThemeDialogOpen, isThemeDialogOpen,
@@ -2331,6 +2358,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
adminSettingsChanged, adminSettingsChanged,
newAgents, newAgents,
showIsExpandableHint, showIsExpandableHint,
showIsAlternateBufferHint,
hintMode: hintMode:
config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems), config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems),
hintBuffer: '', hintBuffer: '',
@@ -2457,6 +2485,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
adminSettingsChanged, adminSettingsChanged,
newAgents, newAgents,
showIsExpandableHint, showIsExpandableHint,
showIsAlternateBufferHint,
isAlternateBuffer,
], ],
); );
@@ -2465,6 +2495,31 @@ Logging in with Google... Restarting Gemini CLI to continue.
[setShowPrivacyNotice], [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( const uiActions: UIActions = useMemo(
() => ({ () => ({
handleThemeSelect, handleThemeSelect,
@@ -2476,6 +2531,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleEditorSelect, handleEditorSelect,
exitEditorDialog, exitEditorDialog,
exitPrivacyNotice, exitPrivacyNotice,
toggleAlternateBuffer,
closeSettingsDialog, closeSettingsDialog,
closeModelDialog, closeModelDialog,
openAgentConfigDialog, openAgentConfigDialog,
@@ -2614,6 +2670,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
config, config,
historyManager, historyManager,
getPreferredEditor, getPreferredEditor,
toggleAlternateBuffer,
], ],
); );
@@ -142,4 +142,38 @@ describe('<StatusRow />', () => {
await waitUntilReady(); await waitUntilReady();
expect(lastFrame()).toContain('Tip: Test Tip'); 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<UIState> = {
...defaultUiState,
showIsAlternateBufferHint: true,
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('[Alt+T] Switch to Full Screen');
});
}); });
+6 -1
View File
@@ -206,7 +206,12 @@ export const StatusRow: React.FC<StatusRowProps> = ({
return uiState.currentTip; 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 ( if (
settings.merged.ui.showShortcutsHint && settings.merged.ui.showShortcutsHint &&
!hideUiDetailsForSuggestions && !hideUiDetailsForSuggestions &&
@@ -205,6 +205,8 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
'\u03A9': 'z', // "Ω" Option+z '\u03A9': 'z', // "Ω" Option+z
'\u00B8': 'Z', // "¸" Option+Shift+z '\u00B8': 'Z', // "¸" Option+Shift+z
'\u2202': 'd', // "∂" delete word forward '\u2202': 'd', // "∂" delete word forward
'\u2020': 't', // "†" toggle full screen buffer
'\u00E5': 'a', // "å" Option+a for alternate buffer
}; };
function nonKeyboardEventFilter( function nonKeyboardEventFilter(
@@ -78,6 +78,7 @@ export interface UIActions {
setShortcutsHelpVisible: (visible: boolean) => void; setShortcutsHelpVisible: (visible: boolean) => void;
setCleanUiDetailsVisible: (visible: boolean) => void; setCleanUiDetailsVisible: (visible: boolean) => void;
toggleCleanUiDetailsVisible: () => void; toggleCleanUiDetailsVisible: () => void;
toggleAlternateBuffer: () => void;
revealCleanUiDetailsTemporarily: (durationMs?: number) => void; revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
handleWarning: (message: string) => void; handleWarning: (message: string) => void;
setEmbeddedShellFocused: (value: boolean) => void; setEmbeddedShellFocused: (value: boolean) => void;
@@ -118,6 +118,8 @@ export interface UIState {
isEditorDialogOpen: boolean; isEditorDialogOpen: boolean;
showPrivacyNotice: boolean; showPrivacyNotice: boolean;
corgiMode: boolean; corgiMode: boolean;
isAlternateBuffer: boolean;
showIsAlternateBufferHint: boolean;
debugMessage: string; debugMessage: string;
quittingMessages: HistoryItem[] | null; quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean; isSettingsDialogOpen: boolean;
@@ -11,49 +11,47 @@ import {
isAlternateBufferEnabled, isAlternateBufferEnabled,
} from './useAlternateBuffer.js'; } from './useAlternateBuffer.js';
import type { Config } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core';
import { useUIState } from '../contexts/UIStateContext.js';
vi.mock('../contexts/ConfigContext.js', () => ({ vi.mock('../contexts/UIStateContext.js');
useConfig: vi.fn(),
}));
const mockUseConfig = vi.mocked( const mockUseUIState = vi.mocked(useUIState);
await import('../contexts/ConfigContext.js').then((m) => m.useConfig),
);
describe('useAlternateBuffer', () => { describe('useAlternateBuffer', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('should return false when config.getUseAlternateBuffer returns false', async () => { it('should return false when uiState.isAlternateBuffer is false', async () => {
mockUseConfig.mockReturnValue({ mockUseUIState.mockReturnValue({
getUseAlternateBuffer: () => false, isAlternateBuffer: false,
} as unknown as ReturnType<typeof mockUseConfig>); } as unknown as ReturnType<typeof mockUseUIState>);
const { result } = await renderHook(() => useAlternateBuffer()); const { result } = await renderHook(() => useAlternateBuffer());
expect(result.current).toBe(false); expect(result.current).toBe(false);
}); });
it('should return true when config.getUseAlternateBuffer returns true', async () => { it('should return true when uiState.isAlternateBuffer is true', async () => {
mockUseConfig.mockReturnValue({ mockUseUIState.mockReturnValue({
getUseAlternateBuffer: () => true, isAlternateBuffer: true,
} as unknown as ReturnType<typeof mockUseConfig>); } as unknown as ReturnType<typeof mockUseUIState>);
const { result } = await renderHook(() => useAlternateBuffer()); const { result } = await renderHook(() => useAlternateBuffer());
expect(result.current).toBe(true); expect(result.current).toBe(true);
}); });
it('should return the immutable config value, not react to settings changes', async () => { it('should react to state changes', async () => {
const mockConfig = { mockUseUIState.mockReturnValue({
getUseAlternateBuffer: () => true, isAlternateBuffer: false,
} as unknown as ReturnType<typeof mockUseConfig>; } as unknown as ReturnType<typeof mockUseUIState>);
mockUseConfig.mockReturnValue(mockConfig);
const { result, rerender } = await renderHook(() => useAlternateBuffer()); const { result, rerender } = await renderHook(() => useAlternateBuffer());
// Value should remain true even after rerender expect(result.current).toBe(false);
expect(result.current).toBe(true);
mockUseUIState.mockReturnValue({
isAlternateBuffer: true,
} as unknown as ReturnType<typeof mockUseUIState>);
rerender(); rerender();
@@ -4,14 +4,14 @@
* SPDX-License-Identifier: Apache-2.0 * 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'; import type { Config } from '@google/gemini-cli-core';
export const isAlternateBufferEnabled = (config: Config): boolean => export const isAlternateBufferEnabled = (config: Config): boolean =>
config.getUseAlternateBuffer(); 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 => { export const useAlternateBuffer = (): boolean => {
const config = useConfig(); const uiState = useUIState();
return isAlternateBufferEnabled(config); return uiState.isAlternateBuffer;
}; };
+3
View File
@@ -95,6 +95,7 @@ export enum Command {
RESTART_APP = 'app.restart', RESTART_APP = 'app.restart',
SUSPEND_APP = 'app.suspend', SUSPEND_APP = 'app.suspend',
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'app.showShellUnfocusWarning', SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'app.showShellUnfocusWarning',
TOGGLE_BUFFER_MODE = 'app.toggleBufferMode',
// Background Shell Controls // Background Shell Controls
BACKGROUND_SHELL_ESCAPE = 'background.escape', 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.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]], [Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]], [Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
[Command.TOGGLE_BUFFER_MODE, [new KeyBinding('alt+a')]],
// Background Shell Controls // Background Shell Controls
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]], [Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
@@ -609,6 +611,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.', [Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.', [Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.', [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.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
[Command.CYCLE_APPROVAL_MODE]: [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.', 'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.',