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

View 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();
});
});

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 };
}