mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-16 17:11:04 -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
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user