From bcc4b395b61dab6d4eb13f4b168714ac3f090804 Mon Sep 17 00:00:00 2001 From: Bharat Kunwar Date: Thu, 12 Feb 2026 09:02:28 +0000 Subject: [PATCH] feat(cli): support Ctrl-Z suspension --- docs/cli/keyboard-shortcuts.md | 2 +- packages/cli/src/config/keyBindings.ts | 2 +- packages/cli/src/ui/AppContainer.test.tsx | 73 ++++--- packages/cli/src/ui/AppContainer.tsx | 61 ++++-- .../__snapshots__/InputPrompt.test.tsx.snap | 33 +++ packages/cli/src/ui/hooks/useSuspend.test.ts | 201 ++++++++++++++++++ packages/cli/src/ui/hooks/useSuspend.ts | 155 ++++++++++++++ packages/cli/src/ui/keyMatchers.test.ts | 12 ++ .../src/ui/utils/terminalCapabilityManager.ts | 37 ++-- 9 files changed, 515 insertions(+), 61 deletions(-) create mode 100644 packages/cli/src/ui/hooks/useSuspend.test.ts create mode 100644 packages/cli/src/ui/hooks/useSuspend.ts diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index 91baedc8c96..0dc32b77794 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -120,7 +120,7 @@ available combinations. | Move focus from the shell back to Gemini. | `Shift + Tab` | | Clear the terminal screen and redraw the UI. | `Ctrl + L` | | Restart the application. | `R` | -| Suspend the application (not yet implemented). | `Ctrl + Z` | +| Suspend the CLI and move it to the background. | `Ctrl + Z` | diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 96e50f36d67..adf88d4d25c 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -523,5 +523,5 @@ export const commandDescriptions: Readonly> = { [Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.', [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', [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.', }; diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 063315f8acc..ff84834c69f 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -135,6 +135,7 @@ vi.mock('./hooks/vim.js'); vi.mock('./hooks/useFocus.js'); vi.mock('./hooks/useBracketedPaste.js'); vi.mock('./hooks/useLoadingIndicator.js'); +vi.mock('./hooks/useSuspend.js'); vi.mock('./hooks/useFolderTrust.js'); vi.mock('./hooks/useIdeTrustListener.js'); vi.mock('./hooks/useMessageQueue.js'); @@ -199,6 +200,7 @@ import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; import * as useKeypressModule from './hooks/useKeypress.js'; +import { useSuspend } from './hooks/useSuspend.js'; import { measureElement } from 'ink'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { @@ -271,6 +273,7 @@ describe('AppContainer State Management', () => { const mockedUseTextBuffer = useTextBuffer as Mock; const mockedUseLogger = useLogger as Mock; const mockedUseLoadingIndicator = useLoadingIndicator as Mock; + const mockedUseSuspend = useSuspend as Mock; const mockedUseInputHistoryStore = useInputHistoryStore as Mock; const mockedUseHookDisplayState = useHookDisplayState as Mock; const mockedUseTerminalTheme = useTerminalTheme as Mock; @@ -402,6 +405,9 @@ describe('AppContainer State Management', () => { elapsedTime: '0.0s', currentLoadingPhrase: '', }); + mockedUseSuspend.mockReturnValue({ + handleSuspend: vi.fn(), + }); mockedUseHookDisplayState.mockReturnValue([]); mockedUseTerminalTheme.mockReturnValue(undefined); mockedUseShellInactivityStatus.mockReturnValue({ @@ -441,8 +447,8 @@ describe('AppContainer State Management', () => { ...defaultMergedSettings.ui, showStatusInTitle: false, hideWindowTitle: false, + useAlternateBuffer: false, }, - useAlternateBuffer: false, }, } as unknown as LoadedSettings; @@ -728,10 +734,10 @@ describe('AppContainer State Management', () => { getChatRecordingService: vi.fn(() => mockChatRecordingService), }; - const configWithRecording = { - ...mockConfig, - getGeminiClient: vi.fn(() => mockGeminiClient), - } as unknown as Config; + const configWithRecording = makeFakeConfig(); + vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( + mockGeminiClient as unknown as ReturnType, + ); expect(() => { renderAppContainer({ @@ -762,11 +768,13 @@ describe('AppContainer State Management', () => { setHistory: vi.fn(), }; - const configWithRecording = { - ...mockConfig, - getGeminiClient: vi.fn(() => mockGeminiClient), - getSessionId: vi.fn(() => 'test-session-123'), - } as unknown as Config; + const configWithRecording = makeFakeConfig(); + vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( + mockGeminiClient as unknown as ReturnType, + ); + vi.spyOn(configWithRecording, 'getSessionId').mockReturnValue( + 'test-session-123', + ); expect(() => { renderAppContainer({ @@ -802,10 +810,10 @@ describe('AppContainer State Management', () => { getUserTier: vi.fn(), }; - const configWithRecording = { - ...mockConfig, - getGeminiClient: vi.fn(() => mockGeminiClient), - } as unknown as Config; + const configWithRecording = makeFakeConfig(); + vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( + mockGeminiClient as unknown as ReturnType, + ); renderAppContainer({ config: configWithRecording, @@ -836,10 +844,10 @@ describe('AppContainer State Management', () => { })), }; - const configWithClient = { - ...mockConfig, - getGeminiClient: vi.fn(() => mockGeminiClient), - } as unknown as Config; + const configWithClient = makeFakeConfig(); + vi.spyOn(configWithClient, 'getGeminiClient').mockReturnValue( + mockGeminiClient as unknown as ReturnType, + ); const resumedData = { conversation: { @@ -892,10 +900,10 @@ describe('AppContainer State Management', () => { getChatRecordingService: vi.fn(), }; - const configWithClient = { - ...mockConfig, - getGeminiClient: vi.fn(() => mockGeminiClient), - } as unknown as Config; + const configWithClient = makeFakeConfig(); + vi.spyOn(configWithClient, 'getGeminiClient').mockReturnValue( + mockGeminiClient as unknown as ReturnType, + ); const resumedData = { conversation: { @@ -945,10 +953,10 @@ describe('AppContainer State Management', () => { getUserTier: vi.fn(), }; - const configWithRecording = { - ...mockConfig, - getGeminiClient: vi.fn(() => mockGeminiClient), - } as unknown as Config; + const configWithRecording = makeFakeConfig(); + vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue( + mockGeminiClient as unknown as ReturnType, + ); renderAppContainer({ 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)', () => { beforeEach(() => { // Mock activePtyId to enable focus diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 7489d07e2af..a2f25a71de5 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -12,7 +12,14 @@ import { useRef, useLayoutEffect, } 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 { AppContext } from './contexts/AppContext.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 { useTerminalSize } from './hooks/useTerminalSize.js'; import { calculatePromptWidths } from './components/InputPrompt.js'; -import { useApp, useStdout, useStdin } from 'ink'; import { calculateMainAreaWidth } from './utils/ui-sizing.js'; import ansiEscapes from 'ansi-escapes'; import { basename } from 'node:path'; @@ -146,8 +152,8 @@ import { NewAgentsChoice } from './components/NewAgentsNotification.js'; import { isSlashCommand } from './utils/commandUtils.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js'; import { useTimedMessage } from './hooks/useTimedMessage.js'; -import { isITerm2 } from './utils/terminalUtils.js'; import { shouldDismissShortcutsHelpOnHotkey } from './utils/shortcutsHelp.js'; +import { useSuspend } from './hooks/useSuspend.js'; function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { return pendingHistoryItems.some((item) => { @@ -201,6 +207,7 @@ export const AppContainer = (props: AppContainerProps) => { useMemoryMonitor(historyManager); const isAlternateBuffer = useAlternateBuffer(); const [corgiMode, setCorgiMode] = useState(false); + const [forceRerenderKey, setForceRerenderKey] = useState(0); const [debugMessage, setDebugMessage] = useState(''); const [quittingMessages, setQuittingMessages] = useState< HistoryItem[] | null @@ -347,7 +354,7 @@ export const AppContainer = (props: AppContainerProps) => { const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize(); const { stdin, setRawMode } = useStdin(); const { stdout } = useStdout(); - const app = useApp(); + const app: AppProps = useApp(); // Additional hooks moved from App.tsx const { stats: sessionStats } = useSessionStats(); @@ -536,10 +543,13 @@ export const AppContainer = (props: AppContainerProps) => { setHistoryRemountKey((prev) => prev + 1); }, [setHistoryRemountKey, isAlternateBuffer, stdout]); + const shouldUseAlternateScreen = shouldEnterAlternateScreen( + isAlternateBuffer, + config.getScreenReader(), + ); + const handleEditorClose = useCallback(() => { - if ( - shouldEnterAlternateScreen(isAlternateBuffer, config.getScreenReader()) - ) { + if (shouldUseAlternateScreen) { // The editor may have exited alternate buffer mode so we need to // enter it again to be safe. enterAlternateScreen(); @@ -549,7 +559,7 @@ export const AppContainer = (props: AppContainerProps) => { } terminalCapabilityManager.enableSupportedModes(); refreshStatic(); - }, [refreshStatic, isAlternateBuffer, app, config]); + }, [refreshStatic, shouldUseAlternateScreen, app]); const [editorError, setEditorError] = useState(null); const { @@ -1370,6 +1380,24 @@ Logging in with Google... Restarting Gemini CLI to continue. }; }, [showTransientMessage]); + const handleWarning = useCallback( + (message: string) => { + showTransientMessage({ + text: message, + type: TransientMessageType.Warning, + }); + }, + [showTransientMessage], + ); + + const { handleSuspend } = useSuspend({ + handleWarning, + setRawMode, + refreshStatic, + setForceRerenderKey, + shouldUseAlternateScreen, + }); + useEffect(() => { if (ideNeedsRestart) { // 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)) { setCtrlDPressCount((prev) => prev + 1); return true; + } else if (keyMatchers[Command.SUSPEND_APP](key)) { + handleSuspend(); + return true; } let enteringConstrainHeightMode = false; @@ -1535,15 +1566,6 @@ Logging in with Google... Restarting Gemini CLI to continue. setShowErrorDetails((prev) => !prev); } 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)) { setShowFullTodos((prev) => !prev); return true; @@ -1652,10 +1674,12 @@ Logging in with Google... Restarting Gemini CLI to continue. handleSlashCommand, cancelOngoingRequest, activePtyId, + handleSuspend, embeddedShellFocused, settings.merged.general.debugKeystrokeLogging, refreshStatic, setCopyModeEnabled, + tabFocusTimeoutRef, isAlternateBuffer, shortcutsHelpVisible, backgroundCurrentShell, @@ -1664,7 +1688,6 @@ Logging in with Google... Restarting Gemini CLI to continue. isBackgroundShellVisible, setIsBackgroundShellListOpen, lastOutputTimeRef, - tabFocusTimeoutRef, showTransientMessage, settings.merged.general.devtools, showErrorDetails, @@ -2276,7 +2299,7 @@ Logging in with Google... Restarting Gemini CLI to continue. > - + diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index ff3818d6f8b..05d128f738c 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -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`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ > Type your message or @path/to/file diff --git a/packages/cli/src/ui/hooks/useSuspend.test.ts b/packages/cli/src/ui/hooks/useSuspend.test.ts new file mode 100644 index 00000000000..9aa90d16b36 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSuspend.test.ts @@ -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(); + }); +}); diff --git a/packages/cli/src/ui/hooks/useSuspend.ts b/packages/cli/src/ui/hooks/useSuspend.ts new file mode 100644 index 00000000000..9c986d30d67 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSuspend.ts @@ -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(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 }; +} diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 3b7c14d8966..a014d2bdc13 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -330,6 +330,18 @@ describe('keyMatchers', () => { positive: [createKey('d', { 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, positive: [ diff --git a/packages/cli/src/ui/utils/terminalCapabilityManager.ts b/packages/cli/src/ui/utils/terminalCapabilityManager.ts index 94e3ecb8fff..8fa21460726 100644 --- a/packages/cli/src/ui/utils/terminalCapabilityManager.ts +++ b/packages/cli/src/ui/utils/terminalCapabilityManager.ts @@ -18,6 +18,23 @@ import { parseColor } from '../themes/color-utils.js'; export type TerminalBackgroundColor = string | undefined; +const TERMINAL_CLEANUP_SEQUENCE = '\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 { private static instance: TerminalCapabilityManager | undefined; @@ -64,14 +81,6 @@ export class TerminalCapabilityManager { 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, * background color). @@ -85,12 +94,12 @@ export class TerminalCapabilityManager { return; } - process.off('exit', TerminalCapabilityManager.cleanupOnExit); - process.off('SIGTERM', TerminalCapabilityManager.cleanupOnExit); - process.off('SIGINT', TerminalCapabilityManager.cleanupOnExit); - process.on('exit', TerminalCapabilityManager.cleanupOnExit); - process.on('SIGTERM', TerminalCapabilityManager.cleanupOnExit); - process.on('SIGINT', TerminalCapabilityManager.cleanupOnExit); + process.off('exit', cleanupTerminalOnExit); + process.off('SIGTERM', cleanupTerminalOnExit); + process.off('SIGINT', cleanupTerminalOnExit); + process.on('exit', cleanupTerminalOnExit); + process.on('SIGTERM', cleanupTerminalOnExit); + process.on('SIGINT', cleanupTerminalOnExit); return new Promise((resolve) => { const originalRawMode = process.stdin.isRaw;