Files
gemini-cli/packages/cli/src/ui/hooks/useSuspend.ts

160 lines
4.4 KiB
TypeScript

/**
* @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';
import { formatCommand } from '../key/keybindingUtils.js';
import { Command } from '../key/keyBindings.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;
}
const suspendKey = formatCommand(Command.SUSPEND_APP);
if (ctrlZPressCount > 1) {
setCtrlZPressCount(0);
if (process.platform === 'win32') {
handleWarning(`${suspendKey} 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) {
const undoKey = formatCommand(Command.UNDO);
handleWarning(
`Press ${suspendKey} again to suspend. Undo has moved to ${undoKey}.`,
);
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 };
}