mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
feat(cli): support Ctrl-Z suspension (#18931)
Co-authored-by: Bharat Kunwar <brtkwr@gmail.com>
This commit is contained in:
committed by
GitHub
parent
868f43927e
commit
375ebca2da
@@ -523,5 +523,5 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
[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.',
|
||||
};
|
||||
|
||||
@@ -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<Config['getGeminiClient']>,
|
||||
);
|
||||
|
||||
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<Config['getGeminiClient']>,
|
||||
);
|
||||
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<Config['getGeminiClient']>,
|
||||
);
|
||||
|
||||
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<Config['getGeminiClient']>,
|
||||
);
|
||||
|
||||
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<Config['getGeminiClient']>,
|
||||
);
|
||||
|
||||
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<Config['getGeminiClient']>,
|
||||
);
|
||||
|
||||
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
|
||||
|
||||
@@ -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<string>('');
|
||||
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<string | null>(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.
|
||||
>
|
||||
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
<App />
|
||||
<App key={`app-${forceRerenderKey}`} />
|
||||
</ShellFocusContext.Provider>
|
||||
</ToolActionsProvider>
|
||||
</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`] = `
|
||||
"[40m[30m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀[39m[49m
|
||||
[40m [97m> [7m[[27mPasted Text: 10 lines][39m [49m
|
||||
[40m[30m▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄[39m[49m"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = `
|
||||
"[40m[30m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀[39m[49m
|
||||
[40m [97m> [39m[7ml[27mine1 [49m
|
||||
[40m line2 [49m
|
||||
[40m line3 [49m
|
||||
[40m line4 [49m
|
||||
[40m line5 [49m
|
||||
[40m line6 [49m
|
||||
[40m line7 [49m
|
||||
[40m line8 [49m
|
||||
[40m line9 [49m
|
||||
[40m line10 [49m
|
||||
[40m[30m▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄[39m[49m"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 7`] = `
|
||||
"[40m[30m▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀[39m[49m
|
||||
[40m [97m> [7m[[27mPasted Text: 10 lines][39m [49m
|
||||
[40m[30m▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄[39m[49m"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Type your message or @path/to/file
|
||||
|
||||
201
packages/cli/src/ui/hooks/useSuspend.test.ts
Normal file
201
packages/cli/src/ui/hooks/useSuspend.test.ts
Normal 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
packages/cli/src/ui/hooks/useSuspend.ts
Normal file
155
packages/cli/src/ui/hooks/useSuspend.ts
Normal 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 };
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -18,6 +18,23 @@ import { parseColor } from '../themes/color-utils.js';
|
||||
|
||||
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 {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user