mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-15 22:33:05 -07:00
feat: implement alternate buffer toggling and unit tests
This commit is contained in:
@@ -534,6 +534,7 @@ export const mockAppState: AppState = {
|
||||
};
|
||||
|
||||
const mockUIActions: UIActions = {
|
||||
toggleAlternateBuffer: vi.fn(),
|
||||
handleThemeSelect: vi.fn(),
|
||||
closeThemeDialog: vi.fn(),
|
||||
handleThemeHighlight: vi.fn(),
|
||||
|
||||
@@ -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<string>('');
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -142,4 +142,38 @@ describe('<StatusRow />', () => {
|
||||
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<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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -206,7 +206,12 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
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 &&
|
||||
|
||||
@@ -205,6 +205,8 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
|
||||
'\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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -118,6 +118,8 @@ export interface UIState {
|
||||
isEditorDialogOpen: boolean;
|
||||
showPrivacyNotice: boolean;
|
||||
corgiMode: boolean;
|
||||
isAlternateBuffer: boolean;
|
||||
showIsAlternateBufferHint: boolean;
|
||||
debugMessage: string;
|
||||
quittingMessages: HistoryItem[] | null;
|
||||
isSettingsDialogOpen: boolean;
|
||||
|
||||
@@ -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<typeof mockUseConfig>);
|
||||
it('should return false when uiState.isAlternateBuffer is false', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
isAlternateBuffer: false,
|
||||
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||
|
||||
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<typeof mockUseConfig>);
|
||||
it('should return true when uiState.isAlternateBuffer is true', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
isAlternateBuffer: true,
|
||||
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||
|
||||
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<typeof mockUseConfig>;
|
||||
|
||||
mockUseConfig.mockReturnValue(mockConfig);
|
||||
it('should react to state changes', async () => {
|
||||
mockUseUIState.mockReturnValue({
|
||||
isAlternateBuffer: false,
|
||||
} as unknown as ReturnType<typeof mockUseUIState>);
|
||||
|
||||
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<typeof mockUseUIState>);
|
||||
|
||||
rerender();
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<Record<Command, string>> = {
|
||||
[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.',
|
||||
|
||||
Reference in New Issue
Block a user