feat(cli): support Ctrl-Z suspension (#18931)

Co-authored-by: Bharat Kunwar <brtkwr@gmail.com>
This commit is contained in:
Tommaso Sciortino
2026-02-12 09:55:56 -08:00
committed by GitHub
parent 868f43927e
commit 375ebca2da
9 changed files with 515 additions and 61 deletions
+1 -1
View File
@@ -120,7 +120,7 @@ available combinations.
| Move focus from the shell back to Gemini. | `Shift + Tab` | | Move focus from the shell back to Gemini. | `Shift + Tab` |
| Clear the terminal screen and redraw the UI. | `Ctrl + L` | | Clear the terminal screen and redraw the UI. | `Ctrl + L` |
| Restart the application. | `R` | | Restart the application. | `R` |
| Suspend the application (not yet implemented). | `Ctrl + Z` | | Suspend the CLI and move it to the background. | `Ctrl + Z` |
<!-- KEYBINDINGS-AUTOGEN:END --> <!-- KEYBINDINGS-AUTOGEN:END -->
+1 -1
View File
@@ -523,5 +523,5 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.', [Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.',
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
[Command.RESTART_APP]: 'Restart the application.', [Command.RESTART_APP]: 'Restart the application.',
[Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).', [Command.SUSPEND_APP]: 'Suspend the CLI and move it to the background.',
}; };
+47 -26
View File
@@ -135,6 +135,7 @@ vi.mock('./hooks/vim.js');
vi.mock('./hooks/useFocus.js'); vi.mock('./hooks/useFocus.js');
vi.mock('./hooks/useBracketedPaste.js'); vi.mock('./hooks/useBracketedPaste.js');
vi.mock('./hooks/useLoadingIndicator.js'); vi.mock('./hooks/useLoadingIndicator.js');
vi.mock('./hooks/useSuspend.js');
vi.mock('./hooks/useFolderTrust.js'); vi.mock('./hooks/useFolderTrust.js');
vi.mock('./hooks/useIdeTrustListener.js'); vi.mock('./hooks/useIdeTrustListener.js');
vi.mock('./hooks/useMessageQueue.js'); vi.mock('./hooks/useMessageQueue.js');
@@ -199,6 +200,7 @@ import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useKeypress, type Key } from './hooks/useKeypress.js'; import { useKeypress, type Key } from './hooks/useKeypress.js';
import * as useKeypressModule from './hooks/useKeypress.js'; import * as useKeypressModule from './hooks/useKeypress.js';
import { useSuspend } from './hooks/useSuspend.js';
import { measureElement } from 'ink'; import { measureElement } from 'ink';
import { useTerminalSize } from './hooks/useTerminalSize.js'; import { useTerminalSize } from './hooks/useTerminalSize.js';
import { import {
@@ -271,6 +273,7 @@ describe('AppContainer State Management', () => {
const mockedUseTextBuffer = useTextBuffer as Mock; const mockedUseTextBuffer = useTextBuffer as Mock;
const mockedUseLogger = useLogger as Mock; const mockedUseLogger = useLogger as Mock;
const mockedUseLoadingIndicator = useLoadingIndicator as Mock; const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
const mockedUseSuspend = useSuspend as Mock;
const mockedUseInputHistoryStore = useInputHistoryStore as Mock; const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
const mockedUseHookDisplayState = useHookDisplayState as Mock; const mockedUseHookDisplayState = useHookDisplayState as Mock;
const mockedUseTerminalTheme = useTerminalTheme as Mock; const mockedUseTerminalTheme = useTerminalTheme as Mock;
@@ -402,6 +405,9 @@ describe('AppContainer State Management', () => {
elapsedTime: '0.0s', elapsedTime: '0.0s',
currentLoadingPhrase: '', currentLoadingPhrase: '',
}); });
mockedUseSuspend.mockReturnValue({
handleSuspend: vi.fn(),
});
mockedUseHookDisplayState.mockReturnValue([]); mockedUseHookDisplayState.mockReturnValue([]);
mockedUseTerminalTheme.mockReturnValue(undefined); mockedUseTerminalTheme.mockReturnValue(undefined);
mockedUseShellInactivityStatus.mockReturnValue({ mockedUseShellInactivityStatus.mockReturnValue({
@@ -441,9 +447,9 @@ describe('AppContainer State Management', () => {
...defaultMergedSettings.ui, ...defaultMergedSettings.ui,
showStatusInTitle: false, showStatusInTitle: false,
hideWindowTitle: false, hideWindowTitle: false,
},
useAlternateBuffer: false, useAlternateBuffer: false,
}, },
},
} as unknown as LoadedSettings; } as unknown as LoadedSettings;
// Mock InitializationResult // Mock InitializationResult
@@ -728,10 +734,10 @@ describe('AppContainer State Management', () => {
getChatRecordingService: vi.fn(() => mockChatRecordingService), getChatRecordingService: vi.fn(() => mockChatRecordingService),
}; };
const configWithRecording = { const configWithRecording = makeFakeConfig();
...mockConfig, vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue(
getGeminiClient: vi.fn(() => mockGeminiClient), mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
} as unknown as Config; );
expect(() => { expect(() => {
renderAppContainer({ renderAppContainer({
@@ -762,11 +768,13 @@ describe('AppContainer State Management', () => {
setHistory: vi.fn(), setHistory: vi.fn(),
}; };
const configWithRecording = { const configWithRecording = makeFakeConfig();
...mockConfig, vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue(
getGeminiClient: vi.fn(() => mockGeminiClient), mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
getSessionId: vi.fn(() => 'test-session-123'), );
} as unknown as Config; vi.spyOn(configWithRecording, 'getSessionId').mockReturnValue(
'test-session-123',
);
expect(() => { expect(() => {
renderAppContainer({ renderAppContainer({
@@ -802,10 +810,10 @@ describe('AppContainer State Management', () => {
getUserTier: vi.fn(), getUserTier: vi.fn(),
}; };
const configWithRecording = { const configWithRecording = makeFakeConfig();
...mockConfig, vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue(
getGeminiClient: vi.fn(() => mockGeminiClient), mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
} as unknown as Config; );
renderAppContainer({ renderAppContainer({
config: configWithRecording, config: configWithRecording,
@@ -836,10 +844,10 @@ describe('AppContainer State Management', () => {
})), })),
}; };
const configWithClient = { const configWithClient = makeFakeConfig();
...mockConfig, vi.spyOn(configWithClient, 'getGeminiClient').mockReturnValue(
getGeminiClient: vi.fn(() => mockGeminiClient), mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
} as unknown as Config; );
const resumedData = { const resumedData = {
conversation: { conversation: {
@@ -892,10 +900,10 @@ describe('AppContainer State Management', () => {
getChatRecordingService: vi.fn(), getChatRecordingService: vi.fn(),
}; };
const configWithClient = { const configWithClient = makeFakeConfig();
...mockConfig, vi.spyOn(configWithClient, 'getGeminiClient').mockReturnValue(
getGeminiClient: vi.fn(() => mockGeminiClient), mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
} as unknown as Config; );
const resumedData = { const resumedData = {
conversation: { conversation: {
@@ -945,10 +953,10 @@ describe('AppContainer State Management', () => {
getUserTier: vi.fn(), getUserTier: vi.fn(),
}; };
const configWithRecording = { const configWithRecording = makeFakeConfig();
...mockConfig, vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue(
getGeminiClient: vi.fn(() => mockGeminiClient), mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
} as unknown as Config; );
renderAppContainer({ renderAppContainer({
config: configWithRecording, config: configWithRecording,
@@ -1943,6 +1951,19 @@ describe('AppContainer State Management', () => {
}); });
}); });
describe('CTRL+Z', () => {
it('should call handleSuspend', async () => {
const handleSuspend = vi.fn();
mockedUseSuspend.mockReturnValue({ handleSuspend });
await setupKeypressTest();
pressKey('\x1A'); // Ctrl+Z
expect(handleSuspend).toHaveBeenCalledTimes(1);
unmount();
});
});
describe('Focus Handling (Tab / Shift+Tab)', () => { describe('Focus Handling (Tab / Shift+Tab)', () => {
beforeEach(() => { beforeEach(() => {
// Mock activePtyId to enable focus // Mock activePtyId to enable focus
+42 -19
View File
@@ -12,7 +12,14 @@ import {
useRef, useRef,
useLayoutEffect, useLayoutEffect,
} from 'react'; } from 'react';
import { type DOMElement, measureElement } from 'ink'; import {
type DOMElement,
measureElement,
useApp,
useStdout,
useStdin,
type AppProps,
} from 'ink';
import { App } from './App.js'; import { App } from './App.js';
import { AppContext } from './contexts/AppContext.js'; import { AppContext } from './contexts/AppContext.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
@@ -87,7 +94,6 @@ import { useVimMode } from './contexts/VimModeContext.js';
import { useConsoleMessages } from './hooks/useConsoleMessages.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js';
import { useTerminalSize } from './hooks/useTerminalSize.js'; import { useTerminalSize } from './hooks/useTerminalSize.js';
import { calculatePromptWidths } from './components/InputPrompt.js'; import { calculatePromptWidths } from './components/InputPrompt.js';
import { useApp, useStdout, useStdin } from 'ink';
import { calculateMainAreaWidth } from './utils/ui-sizing.js'; import { calculateMainAreaWidth } from './utils/ui-sizing.js';
import ansiEscapes from 'ansi-escapes'; import ansiEscapes from 'ansi-escapes';
import { basename } from 'node:path'; import { basename } from 'node:path';
@@ -146,8 +152,8 @@ import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js'; import { isSlashCommand } from './utils/commandUtils.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useTimedMessage } from './hooks/useTimedMessage.js'; import { useTimedMessage } from './hooks/useTimedMessage.js';
import { isITerm2 } from './utils/terminalUtils.js';
import { shouldDismissShortcutsHelpOnHotkey } from './utils/shortcutsHelp.js'; import { shouldDismissShortcutsHelpOnHotkey } from './utils/shortcutsHelp.js';
import { useSuspend } from './hooks/useSuspend.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => { return pendingHistoryItems.some((item) => {
@@ -201,6 +207,7 @@ export const AppContainer = (props: AppContainerProps) => {
useMemoryMonitor(historyManager); useMemoryMonitor(historyManager);
const isAlternateBuffer = useAlternateBuffer(); const isAlternateBuffer = useAlternateBuffer();
const [corgiMode, setCorgiMode] = useState(false); const [corgiMode, setCorgiMode] = useState(false);
const [forceRerenderKey, setForceRerenderKey] = useState(0);
const [debugMessage, setDebugMessage] = useState<string>(''); const [debugMessage, setDebugMessage] = useState<string>('');
const [quittingMessages, setQuittingMessages] = useState< const [quittingMessages, setQuittingMessages] = useState<
HistoryItem[] | null HistoryItem[] | null
@@ -347,7 +354,7 @@ export const AppContainer = (props: AppContainerProps) => {
const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize(); const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize();
const { stdin, setRawMode } = useStdin(); const { stdin, setRawMode } = useStdin();
const { stdout } = useStdout(); const { stdout } = useStdout();
const app = useApp(); const app: AppProps = useApp();
// Additional hooks moved from App.tsx // Additional hooks moved from App.tsx
const { stats: sessionStats } = useSessionStats(); const { stats: sessionStats } = useSessionStats();
@@ -536,10 +543,13 @@ export const AppContainer = (props: AppContainerProps) => {
setHistoryRemountKey((prev) => prev + 1); setHistoryRemountKey((prev) => prev + 1);
}, [setHistoryRemountKey, isAlternateBuffer, stdout]); }, [setHistoryRemountKey, isAlternateBuffer, stdout]);
const shouldUseAlternateScreen = shouldEnterAlternateScreen(
isAlternateBuffer,
config.getScreenReader(),
);
const handleEditorClose = useCallback(() => { const handleEditorClose = useCallback(() => {
if ( if (shouldUseAlternateScreen) {
shouldEnterAlternateScreen(isAlternateBuffer, config.getScreenReader())
) {
// The editor may have exited alternate buffer mode so we need to // The editor may have exited alternate buffer mode so we need to
// enter it again to be safe. // enter it again to be safe.
enterAlternateScreen(); enterAlternateScreen();
@@ -549,7 +559,7 @@ export const AppContainer = (props: AppContainerProps) => {
} }
terminalCapabilityManager.enableSupportedModes(); terminalCapabilityManager.enableSupportedModes();
refreshStatic(); refreshStatic();
}, [refreshStatic, isAlternateBuffer, app, config]); }, [refreshStatic, shouldUseAlternateScreen, app]);
const [editorError, setEditorError] = useState<string | null>(null); const [editorError, setEditorError] = useState<string | null>(null);
const { const {
@@ -1370,6 +1380,24 @@ Logging in with Google... Restarting Gemini CLI to continue.
}; };
}, [showTransientMessage]); }, [showTransientMessage]);
const handleWarning = useCallback(
(message: string) => {
showTransientMessage({
text: message,
type: TransientMessageType.Warning,
});
},
[showTransientMessage],
);
const { handleSuspend } = useSuspend({
handleWarning,
setRawMode,
refreshStatic,
setForceRerenderKey,
shouldUseAlternateScreen,
});
useEffect(() => { useEffect(() => {
if (ideNeedsRestart) { if (ideNeedsRestart) {
// IDE trust changed, force a restart. // IDE trust changed, force a restart.
@@ -1510,6 +1538,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
} else if (keyMatchers[Command.EXIT](key)) { } else if (keyMatchers[Command.EXIT](key)) {
setCtrlDPressCount((prev) => prev + 1); setCtrlDPressCount((prev) => prev + 1);
return true; return true;
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
handleSuspend();
return true;
} }
let enteringConstrainHeightMode = false; let enteringConstrainHeightMode = false;
@@ -1535,15 +1566,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
setShowErrorDetails((prev) => !prev); setShowErrorDetails((prev) => !prev);
} }
return true; return true;
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
const undoMessage = isITerm2()
? 'Undo has been moved to Option + Z'
: 'Undo has been moved to Alt/Option + Z or Cmd + Z';
showTransientMessage({
text: undoMessage,
type: TransientMessageType.Warning,
});
return true;
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) { } else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
setShowFullTodos((prev) => !prev); setShowFullTodos((prev) => !prev);
return true; return true;
@@ -1652,10 +1674,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleSlashCommand, handleSlashCommand,
cancelOngoingRequest, cancelOngoingRequest,
activePtyId, activePtyId,
handleSuspend,
embeddedShellFocused, embeddedShellFocused,
settings.merged.general.debugKeystrokeLogging, settings.merged.general.debugKeystrokeLogging,
refreshStatic, refreshStatic,
setCopyModeEnabled, setCopyModeEnabled,
tabFocusTimeoutRef,
isAlternateBuffer, isAlternateBuffer,
shortcutsHelpVisible, shortcutsHelpVisible,
backgroundCurrentShell, backgroundCurrentShell,
@@ -1664,7 +1688,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
isBackgroundShellVisible, isBackgroundShellVisible,
setIsBackgroundShellListOpen, setIsBackgroundShellListOpen,
lastOutputTimeRef, lastOutputTimeRef,
tabFocusTimeoutRef,
showTransientMessage, showTransientMessage,
settings.merged.general.devtools, settings.merged.general.devtools,
showErrorDetails, showErrorDetails,
@@ -2276,7 +2299,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
> >
<ToolActionsProvider config={config} toolCalls={allToolCalls}> <ToolActionsProvider config={config} toolCalls={allToolCalls}>
<ShellFocusContext.Provider value={isFocused}> <ShellFocusContext.Provider value={isFocused}>
<App /> <App key={`app-${forceRerenderKey}`} />
</ShellFocusContext.Provider> </ShellFocusContext.Provider>
</ToolActionsProvider> </ToolActionsProvider>
</AppContext.Provider> </AppContext.Provider>
@@ -77,6 +77,39 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄" ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`; `;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
 > [Pasted Text: 10 lines] 
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
 > line1 
 line2 
 line3 
 line4 
 line5 
 line6 
 line7 
 line8 
 line9 
 line10 
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 7`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
 > [Pasted Text: 10 lines] 
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file > Type your message or @path/to/file
@@ -0,0 +1,201 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { useSuspend } from './useSuspend.js';
import {
writeToStdout,
disableMouseEvents,
enableMouseEvents,
enterAlternateScreen,
exitAlternateScreen,
enableLineWrapping,
disableLineWrapping,
} from '@google/gemini-cli-core';
import {
cleanupTerminalOnExit,
terminalCapabilityManager,
} from '../utils/terminalCapabilityManager.js';
vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual('@google/gemini-cli-core');
return {
...actual,
writeToStdout: vi.fn(),
disableMouseEvents: vi.fn(),
enableMouseEvents: vi.fn(),
enterAlternateScreen: vi.fn(),
exitAlternateScreen: vi.fn(),
enableLineWrapping: vi.fn(),
disableLineWrapping: vi.fn(),
};
});
vi.mock('../utils/terminalCapabilityManager.js', () => ({
cleanupTerminalOnExit: vi.fn(),
terminalCapabilityManager: {
enableSupportedModes: vi.fn(),
},
}));
describe('useSuspend', () => {
const originalPlatform = process.platform;
let killSpy: Mock;
const setPlatform = (platform: NodeJS.Platform) => {
Object.defineProperty(process, 'platform', {
value: platform,
configurable: true,
});
};
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
killSpy = vi
.spyOn(process, 'kill')
.mockReturnValue(true) as unknown as Mock;
// Default tests to a POSIX platform so suspend path assertions are stable.
setPlatform('linux');
});
afterEach(() => {
vi.useRealTimers();
killSpy.mockRestore();
setPlatform(originalPlatform);
});
it('cleans terminal state on suspend and restores/repaints on resume in alternate screen mode', () => {
const handleWarning = vi.fn();
const setRawMode = vi.fn();
const refreshStatic = vi.fn();
const setForceRerenderKey = vi.fn();
const enableSupportedModes =
terminalCapabilityManager.enableSupportedModes as unknown as Mock;
const { result, unmount } = renderHook(() =>
useSuspend({
handleWarning,
setRawMode,
refreshStatic,
setForceRerenderKey,
shouldUseAlternateScreen: true,
}),
);
act(() => {
result.current.handleSuspend();
});
expect(handleWarning).toHaveBeenCalledWith(
'Press Ctrl+Z again to suspend. Undo has moved to Cmd + Z or Alt/Opt + Z.',
);
act(() => {
result.current.handleSuspend();
});
expect(exitAlternateScreen).toHaveBeenCalledTimes(1);
expect(enableLineWrapping).toHaveBeenCalledTimes(1);
expect(writeToStdout).toHaveBeenCalledWith('\x1b[2J\x1b[H');
expect(disableMouseEvents).toHaveBeenCalledTimes(1);
expect(cleanupTerminalOnExit).toHaveBeenCalledTimes(1);
expect(setRawMode).toHaveBeenCalledWith(false);
expect(killSpy).toHaveBeenCalledWith(0, 'SIGTSTP');
act(() => {
process.emit('SIGCONT');
vi.runAllTimers();
});
expect(enterAlternateScreen).toHaveBeenCalledTimes(1);
expect(disableLineWrapping).toHaveBeenCalledTimes(1);
expect(enableSupportedModes).toHaveBeenCalledTimes(1);
expect(enableMouseEvents).toHaveBeenCalledTimes(1);
expect(setRawMode).toHaveBeenCalledWith(true);
expect(refreshStatic).toHaveBeenCalledTimes(1);
expect(setForceRerenderKey).toHaveBeenCalledTimes(1);
unmount();
});
it('does not toggle alternate screen or mouse restore when alternate screen mode is disabled', () => {
const handleWarning = vi.fn();
const setRawMode = vi.fn();
const refreshStatic = vi.fn();
const setForceRerenderKey = vi.fn();
const { result, unmount } = renderHook(() =>
useSuspend({
handleWarning,
setRawMode,
refreshStatic,
setForceRerenderKey,
shouldUseAlternateScreen: false,
}),
);
act(() => {
result.current.handleSuspend();
result.current.handleSuspend();
process.emit('SIGCONT');
vi.runAllTimers();
});
expect(exitAlternateScreen).not.toHaveBeenCalled();
expect(enterAlternateScreen).not.toHaveBeenCalled();
expect(enableLineWrapping).not.toHaveBeenCalled();
expect(disableLineWrapping).not.toHaveBeenCalled();
expect(enableMouseEvents).not.toHaveBeenCalled();
unmount();
});
it('warns and skips suspension on windows', () => {
setPlatform('win32');
const handleWarning = vi.fn();
const setRawMode = vi.fn();
const refreshStatic = vi.fn();
const setForceRerenderKey = vi.fn();
const { result, unmount } = renderHook(() =>
useSuspend({
handleWarning,
setRawMode,
refreshStatic,
setForceRerenderKey,
shouldUseAlternateScreen: true,
}),
);
act(() => {
result.current.handleSuspend();
});
handleWarning.mockClear();
act(() => {
result.current.handleSuspend();
});
expect(handleWarning).toHaveBeenCalledWith(
'Ctrl+Z suspend is not supported on Windows.',
);
expect(killSpy).not.toHaveBeenCalled();
expect(cleanupTerminalOnExit).not.toHaveBeenCalled();
unmount();
});
});
+155
View File
@@ -0,0 +1,155 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import {
writeToStdout,
disableMouseEvents,
enableMouseEvents,
enterAlternateScreen,
exitAlternateScreen,
enableLineWrapping,
disableLineWrapping,
} from '@google/gemini-cli-core';
import process from 'node:process';
import {
cleanupTerminalOnExit,
terminalCapabilityManager,
} from '../utils/terminalCapabilityManager.js';
import { WARNING_PROMPT_DURATION_MS } from '../constants.js';
interface UseSuspendProps {
handleWarning: (message: string) => void;
setRawMode: (mode: boolean) => void;
refreshStatic: () => void;
setForceRerenderKey: (updater: (prev: number) => number) => void;
shouldUseAlternateScreen: boolean;
}
export function useSuspend({
handleWarning,
setRawMode,
refreshStatic,
setForceRerenderKey,
shouldUseAlternateScreen,
}: UseSuspendProps) {
const [ctrlZPressCount, setCtrlZPressCount] = useState(0);
const ctrlZTimerRef = useRef<NodeJS.Timeout | null>(null);
const onResumeHandlerRef = useRef<(() => void) | null>(null);
useEffect(
() => () => {
if (ctrlZTimerRef.current) {
clearTimeout(ctrlZTimerRef.current);
ctrlZTimerRef.current = null;
}
if (onResumeHandlerRef.current) {
process.off('SIGCONT', onResumeHandlerRef.current);
onResumeHandlerRef.current = null;
}
},
[],
);
useEffect(() => {
if (ctrlZTimerRef.current) {
clearTimeout(ctrlZTimerRef.current);
ctrlZTimerRef.current = null;
}
if (ctrlZPressCount > 1) {
setCtrlZPressCount(0);
if (process.platform === 'win32') {
handleWarning('Ctrl+Z suspend is not supported on Windows.');
return;
}
if (shouldUseAlternateScreen) {
// Leave alternate buffer before suspension so the shell stays usable.
exitAlternateScreen();
enableLineWrapping();
writeToStdout('\x1b[2J\x1b[H');
}
// Cleanup before suspend.
writeToStdout('\x1b[?25h'); // Show cursor
disableMouseEvents();
cleanupTerminalOnExit();
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
setRawMode(false);
const onResume = () => {
try {
// Restore terminal state.
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.ref();
}
setRawMode(true);
if (shouldUseAlternateScreen) {
enterAlternateScreen();
disableLineWrapping();
writeToStdout('\x1b[2J\x1b[H');
}
terminalCapabilityManager.enableSupportedModes();
writeToStdout('\x1b[?25l'); // Hide cursor
if (shouldUseAlternateScreen) {
enableMouseEvents();
}
// Force Ink to do a complete repaint by:
// 1. Emitting a resize event (tricks Ink into full redraw)
// 2. Remounting components via state changes
process.stdout.emit('resize');
// Give a tick for resize to process, then trigger remount
setImmediate(() => {
refreshStatic();
setForceRerenderKey((prev) => prev + 1);
});
} finally {
if (onResumeHandlerRef.current === onResume) {
onResumeHandlerRef.current = null;
}
}
};
if (onResumeHandlerRef.current) {
process.off('SIGCONT', onResumeHandlerRef.current);
}
onResumeHandlerRef.current = onResume;
process.once('SIGCONT', onResume);
process.kill(0, 'SIGTSTP');
} else if (ctrlZPressCount > 0) {
handleWarning(
'Press Ctrl+Z again to suspend. Undo has moved to Cmd + Z or Alt/Opt + Z.',
);
ctrlZTimerRef.current = setTimeout(() => {
setCtrlZPressCount(0);
ctrlZTimerRef.current = null;
}, WARNING_PROMPT_DURATION_MS);
}
}, [
ctrlZPressCount,
handleWarning,
setRawMode,
refreshStatic,
setForceRerenderKey,
shouldUseAlternateScreen,
]);
const handleSuspend = useCallback(() => {
setCtrlZPressCount((prev) => prev + 1);
}, []);
return { handleSuspend };
}
+12
View File
@@ -330,6 +330,18 @@ describe('keyMatchers', () => {
positive: [createKey('d', { ctrl: true })], positive: [createKey('d', { ctrl: true })],
negative: [createKey('d'), createKey('c', { ctrl: true })], negative: [createKey('d'), createKey('c', { ctrl: true })],
}, },
{
command: Command.SUSPEND_APP,
positive: [
createKey('z', { ctrl: true }),
createKey('z', { ctrl: true, shift: true }),
],
negative: [
createKey('z'),
createKey('y', { ctrl: true }),
createKey('z', { alt: true }),
],
},
{ {
command: Command.SHOW_MORE_LINES, command: Command.SHOW_MORE_LINES,
positive: [ positive: [
@@ -18,6 +18,23 @@ import { parseColor } from '../themes/color-utils.js';
export type TerminalBackgroundColor = string | undefined; export type TerminalBackgroundColor = string | undefined;
const TERMINAL_CLEANUP_SEQUENCE = '\x1b[<u\x1b[>4;0m\x1b[?2004l';
export function cleanupTerminalOnExit() {
try {
if (process.stdout?.fd !== undefined) {
fs.writeSync(process.stdout.fd, TERMINAL_CLEANUP_SEQUENCE);
return;
}
} catch (e) {
debugLogger.warn('Failed to synchronously cleanup terminal modes:', e);
}
disableKittyKeyboardProtocol();
disableModifyOtherKeys();
disableBracketedPasteMode();
}
export class TerminalCapabilityManager { export class TerminalCapabilityManager {
private static instance: TerminalCapabilityManager | undefined; private static instance: TerminalCapabilityManager | undefined;
@@ -64,14 +81,6 @@ export class TerminalCapabilityManager {
this.instance = undefined; this.instance = undefined;
} }
private static cleanupOnExit(): void {
// don't bother catching errors since if one write
// fails, the other probably will too
disableKittyKeyboardProtocol();
disableModifyOtherKeys();
disableBracketedPasteMode();
}
/** /**
* Detects terminal capabilities (Kitty protocol support, terminal name, * Detects terminal capabilities (Kitty protocol support, terminal name,
* background color). * background color).
@@ -85,12 +94,12 @@ export class TerminalCapabilityManager {
return; return;
} }
process.off('exit', TerminalCapabilityManager.cleanupOnExit); process.off('exit', cleanupTerminalOnExit);
process.off('SIGTERM', TerminalCapabilityManager.cleanupOnExit); process.off('SIGTERM', cleanupTerminalOnExit);
process.off('SIGINT', TerminalCapabilityManager.cleanupOnExit); process.off('SIGINT', cleanupTerminalOnExit);
process.on('exit', TerminalCapabilityManager.cleanupOnExit); process.on('exit', cleanupTerminalOnExit);
process.on('SIGTERM', TerminalCapabilityManager.cleanupOnExit); process.on('SIGTERM', cleanupTerminalOnExit);
process.on('SIGINT', TerminalCapabilityManager.cleanupOnExit); process.on('SIGINT', cleanupTerminalOnExit);
return new Promise((resolve) => { return new Promise((resolve) => {
const originalRawMode = process.stdin.isRaw; const originalRawMode = process.stdin.isRaw;