mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -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
@@ -120,7 +120,7 @@ available combinations.
|
|||||||
| Move focus from the shell back to Gemini. | `Shift + Tab` |
|
| Move focus from the shell back to Gemini. | `Shift + Tab` |
|
||||||
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
|
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
|
||||||
| Restart the application. | `R` |
|
| Restart the application. | `R` |
|
||||||
| Suspend the application (not yet implemented). | `Ctrl + Z` |
|
| Suspend the CLI and move it to the background. | `Ctrl + Z` |
|
||||||
|
|
||||||
<!-- KEYBINDINGS-AUTOGEN:END -->
|
<!-- KEYBINDINGS-AUTOGEN:END -->
|
||||||
|
|
||||||
|
|||||||
@@ -523,5 +523,5 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
|||||||
[Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.',
|
[Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.',
|
||||||
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
|
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
|
||||||
[Command.RESTART_APP]: 'Restart the application.',
|
[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/useFocus.js');
|
||||||
vi.mock('./hooks/useBracketedPaste.js');
|
vi.mock('./hooks/useBracketedPaste.js');
|
||||||
vi.mock('./hooks/useLoadingIndicator.js');
|
vi.mock('./hooks/useLoadingIndicator.js');
|
||||||
|
vi.mock('./hooks/useSuspend.js');
|
||||||
vi.mock('./hooks/useFolderTrust.js');
|
vi.mock('./hooks/useFolderTrust.js');
|
||||||
vi.mock('./hooks/useIdeTrustListener.js');
|
vi.mock('./hooks/useIdeTrustListener.js');
|
||||||
vi.mock('./hooks/useMessageQueue.js');
|
vi.mock('./hooks/useMessageQueue.js');
|
||||||
@@ -199,6 +200,7 @@ import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
|||||||
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||||
import * as useKeypressModule from './hooks/useKeypress.js';
|
import * as useKeypressModule from './hooks/useKeypress.js';
|
||||||
|
import { useSuspend } from './hooks/useSuspend.js';
|
||||||
import { measureElement } from 'ink';
|
import { measureElement } from 'ink';
|
||||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||||
import {
|
import {
|
||||||
@@ -271,6 +273,7 @@ describe('AppContainer State Management', () => {
|
|||||||
const mockedUseTextBuffer = useTextBuffer as Mock;
|
const mockedUseTextBuffer = useTextBuffer as Mock;
|
||||||
const mockedUseLogger = useLogger as Mock;
|
const mockedUseLogger = useLogger as Mock;
|
||||||
const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
|
const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
|
||||||
|
const mockedUseSuspend = useSuspend as Mock;
|
||||||
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
|
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
|
||||||
const mockedUseHookDisplayState = useHookDisplayState as Mock;
|
const mockedUseHookDisplayState = useHookDisplayState as Mock;
|
||||||
const mockedUseTerminalTheme = useTerminalTheme as Mock;
|
const mockedUseTerminalTheme = useTerminalTheme as Mock;
|
||||||
@@ -402,6 +405,9 @@ describe('AppContainer State Management', () => {
|
|||||||
elapsedTime: '0.0s',
|
elapsedTime: '0.0s',
|
||||||
currentLoadingPhrase: '',
|
currentLoadingPhrase: '',
|
||||||
});
|
});
|
||||||
|
mockedUseSuspend.mockReturnValue({
|
||||||
|
handleSuspend: vi.fn(),
|
||||||
|
});
|
||||||
mockedUseHookDisplayState.mockReturnValue([]);
|
mockedUseHookDisplayState.mockReturnValue([]);
|
||||||
mockedUseTerminalTheme.mockReturnValue(undefined);
|
mockedUseTerminalTheme.mockReturnValue(undefined);
|
||||||
mockedUseShellInactivityStatus.mockReturnValue({
|
mockedUseShellInactivityStatus.mockReturnValue({
|
||||||
@@ -441,9 +447,9 @@ describe('AppContainer State Management', () => {
|
|||||||
...defaultMergedSettings.ui,
|
...defaultMergedSettings.ui,
|
||||||
showStatusInTitle: false,
|
showStatusInTitle: false,
|
||||||
hideWindowTitle: false,
|
hideWindowTitle: false,
|
||||||
},
|
|
||||||
useAlternateBuffer: false,
|
useAlternateBuffer: false,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
} as unknown as LoadedSettings;
|
} as unknown as LoadedSettings;
|
||||||
|
|
||||||
// Mock InitializationResult
|
// Mock InitializationResult
|
||||||
@@ -728,10 +734,10 @@ describe('AppContainer State Management', () => {
|
|||||||
getChatRecordingService: vi.fn(() => mockChatRecordingService),
|
getChatRecordingService: vi.fn(() => mockChatRecordingService),
|
||||||
};
|
};
|
||||||
|
|
||||||
const configWithRecording = {
|
const configWithRecording = makeFakeConfig();
|
||||||
...mockConfig,
|
vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue(
|
||||||
getGeminiClient: vi.fn(() => mockGeminiClient),
|
mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
|
||||||
} as unknown as Config;
|
);
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
renderAppContainer({
|
renderAppContainer({
|
||||||
@@ -762,11 +768,13 @@ describe('AppContainer State Management', () => {
|
|||||||
setHistory: vi.fn(),
|
setHistory: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const configWithRecording = {
|
const configWithRecording = makeFakeConfig();
|
||||||
...mockConfig,
|
vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue(
|
||||||
getGeminiClient: vi.fn(() => mockGeminiClient),
|
mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
|
||||||
getSessionId: vi.fn(() => 'test-session-123'),
|
);
|
||||||
} as unknown as Config;
|
vi.spyOn(configWithRecording, 'getSessionId').mockReturnValue(
|
||||||
|
'test-session-123',
|
||||||
|
);
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
renderAppContainer({
|
renderAppContainer({
|
||||||
@@ -802,10 +810,10 @@ describe('AppContainer State Management', () => {
|
|||||||
getUserTier: vi.fn(),
|
getUserTier: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const configWithRecording = {
|
const configWithRecording = makeFakeConfig();
|
||||||
...mockConfig,
|
vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue(
|
||||||
getGeminiClient: vi.fn(() => mockGeminiClient),
|
mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
|
||||||
} as unknown as Config;
|
);
|
||||||
|
|
||||||
renderAppContainer({
|
renderAppContainer({
|
||||||
config: configWithRecording,
|
config: configWithRecording,
|
||||||
@@ -836,10 +844,10 @@ describe('AppContainer State Management', () => {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
const configWithClient = {
|
const configWithClient = makeFakeConfig();
|
||||||
...mockConfig,
|
vi.spyOn(configWithClient, 'getGeminiClient').mockReturnValue(
|
||||||
getGeminiClient: vi.fn(() => mockGeminiClient),
|
mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
|
||||||
} as unknown as Config;
|
);
|
||||||
|
|
||||||
const resumedData = {
|
const resumedData = {
|
||||||
conversation: {
|
conversation: {
|
||||||
@@ -892,10 +900,10 @@ describe('AppContainer State Management', () => {
|
|||||||
getChatRecordingService: vi.fn(),
|
getChatRecordingService: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const configWithClient = {
|
const configWithClient = makeFakeConfig();
|
||||||
...mockConfig,
|
vi.spyOn(configWithClient, 'getGeminiClient').mockReturnValue(
|
||||||
getGeminiClient: vi.fn(() => mockGeminiClient),
|
mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
|
||||||
} as unknown as Config;
|
);
|
||||||
|
|
||||||
const resumedData = {
|
const resumedData = {
|
||||||
conversation: {
|
conversation: {
|
||||||
@@ -945,10 +953,10 @@ describe('AppContainer State Management', () => {
|
|||||||
getUserTier: vi.fn(),
|
getUserTier: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const configWithRecording = {
|
const configWithRecording = makeFakeConfig();
|
||||||
...mockConfig,
|
vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue(
|
||||||
getGeminiClient: vi.fn(() => mockGeminiClient),
|
mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
|
||||||
} as unknown as Config;
|
);
|
||||||
|
|
||||||
renderAppContainer({
|
renderAppContainer({
|
||||||
config: configWithRecording,
|
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)', () => {
|
describe('Focus Handling (Tab / Shift+Tab)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Mock activePtyId to enable focus
|
// Mock activePtyId to enable focus
|
||||||
|
|||||||
@@ -12,7 +12,14 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
} from 'react';
|
} 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 { App } from './App.js';
|
||||||
import { AppContext } from './contexts/AppContext.js';
|
import { AppContext } from './contexts/AppContext.js';
|
||||||
import { UIStateContext, type UIState } from './contexts/UIStateContext.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 { useConsoleMessages } from './hooks/useConsoleMessages.js';
|
||||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||||
import { calculatePromptWidths } from './components/InputPrompt.js';
|
import { calculatePromptWidths } from './components/InputPrompt.js';
|
||||||
import { useApp, useStdout, useStdin } from 'ink';
|
|
||||||
import { calculateMainAreaWidth } from './utils/ui-sizing.js';
|
import { calculateMainAreaWidth } from './utils/ui-sizing.js';
|
||||||
import ansiEscapes from 'ansi-escapes';
|
import ansiEscapes from 'ansi-escapes';
|
||||||
import { basename } from 'node:path';
|
import { basename } from 'node:path';
|
||||||
@@ -146,8 +152,8 @@ import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
|||||||
import { isSlashCommand } from './utils/commandUtils.js';
|
import { isSlashCommand } from './utils/commandUtils.js';
|
||||||
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
||||||
import { useTimedMessage } from './hooks/useTimedMessage.js';
|
import { useTimedMessage } from './hooks/useTimedMessage.js';
|
||||||
import { isITerm2 } from './utils/terminalUtils.js';
|
|
||||||
import { shouldDismissShortcutsHelpOnHotkey } from './utils/shortcutsHelp.js';
|
import { shouldDismissShortcutsHelpOnHotkey } from './utils/shortcutsHelp.js';
|
||||||
|
import { useSuspend } from './hooks/useSuspend.js';
|
||||||
|
|
||||||
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
|
||||||
return pendingHistoryItems.some((item) => {
|
return pendingHistoryItems.some((item) => {
|
||||||
@@ -201,6 +207,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
useMemoryMonitor(historyManager);
|
useMemoryMonitor(historyManager);
|
||||||
const isAlternateBuffer = useAlternateBuffer();
|
const isAlternateBuffer = useAlternateBuffer();
|
||||||
const [corgiMode, setCorgiMode] = useState(false);
|
const [corgiMode, setCorgiMode] = useState(false);
|
||||||
|
const [forceRerenderKey, setForceRerenderKey] = useState(0);
|
||||||
const [debugMessage, setDebugMessage] = useState<string>('');
|
const [debugMessage, setDebugMessage] = useState<string>('');
|
||||||
const [quittingMessages, setQuittingMessages] = useState<
|
const [quittingMessages, setQuittingMessages] = useState<
|
||||||
HistoryItem[] | null
|
HistoryItem[] | null
|
||||||
@@ -347,7 +354,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize();
|
const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize();
|
||||||
const { stdin, setRawMode } = useStdin();
|
const { stdin, setRawMode } = useStdin();
|
||||||
const { stdout } = useStdout();
|
const { stdout } = useStdout();
|
||||||
const app = useApp();
|
const app: AppProps = useApp();
|
||||||
|
|
||||||
// Additional hooks moved from App.tsx
|
// Additional hooks moved from App.tsx
|
||||||
const { stats: sessionStats } = useSessionStats();
|
const { stats: sessionStats } = useSessionStats();
|
||||||
@@ -536,10 +543,13 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
setHistoryRemountKey((prev) => prev + 1);
|
setHistoryRemountKey((prev) => prev + 1);
|
||||||
}, [setHistoryRemountKey, isAlternateBuffer, stdout]);
|
}, [setHistoryRemountKey, isAlternateBuffer, stdout]);
|
||||||
|
|
||||||
|
const shouldUseAlternateScreen = shouldEnterAlternateScreen(
|
||||||
|
isAlternateBuffer,
|
||||||
|
config.getScreenReader(),
|
||||||
|
);
|
||||||
|
|
||||||
const handleEditorClose = useCallback(() => {
|
const handleEditorClose = useCallback(() => {
|
||||||
if (
|
if (shouldUseAlternateScreen) {
|
||||||
shouldEnterAlternateScreen(isAlternateBuffer, config.getScreenReader())
|
|
||||||
) {
|
|
||||||
// The editor may have exited alternate buffer mode so we need to
|
// The editor may have exited alternate buffer mode so we need to
|
||||||
// enter it again to be safe.
|
// enter it again to be safe.
|
||||||
enterAlternateScreen();
|
enterAlternateScreen();
|
||||||
@@ -549,7 +559,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
}
|
}
|
||||||
terminalCapabilityManager.enableSupportedModes();
|
terminalCapabilityManager.enableSupportedModes();
|
||||||
refreshStatic();
|
refreshStatic();
|
||||||
}, [refreshStatic, isAlternateBuffer, app, config]);
|
}, [refreshStatic, shouldUseAlternateScreen, app]);
|
||||||
|
|
||||||
const [editorError, setEditorError] = useState<string | null>(null);
|
const [editorError, setEditorError] = useState<string | null>(null);
|
||||||
const {
|
const {
|
||||||
@@ -1370,6 +1380,24 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
};
|
};
|
||||||
}, [showTransientMessage]);
|
}, [showTransientMessage]);
|
||||||
|
|
||||||
|
const handleWarning = useCallback(
|
||||||
|
(message: string) => {
|
||||||
|
showTransientMessage({
|
||||||
|
text: message,
|
||||||
|
type: TransientMessageType.Warning,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[showTransientMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { handleSuspend } = useSuspend({
|
||||||
|
handleWarning,
|
||||||
|
setRawMode,
|
||||||
|
refreshStatic,
|
||||||
|
setForceRerenderKey,
|
||||||
|
shouldUseAlternateScreen,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ideNeedsRestart) {
|
if (ideNeedsRestart) {
|
||||||
// IDE trust changed, force a restart.
|
// 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)) {
|
} else if (keyMatchers[Command.EXIT](key)) {
|
||||||
setCtrlDPressCount((prev) => prev + 1);
|
setCtrlDPressCount((prev) => prev + 1);
|
||||||
return true;
|
return true;
|
||||||
|
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
|
||||||
|
handleSuspend();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let enteringConstrainHeightMode = false;
|
let enteringConstrainHeightMode = false;
|
||||||
@@ -1535,15 +1566,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
setShowErrorDetails((prev) => !prev);
|
setShowErrorDetails((prev) => !prev);
|
||||||
}
|
}
|
||||||
return true;
|
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)) {
|
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
|
||||||
setShowFullTodos((prev) => !prev);
|
setShowFullTodos((prev) => !prev);
|
||||||
return true;
|
return true;
|
||||||
@@ -1652,10 +1674,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
handleSlashCommand,
|
handleSlashCommand,
|
||||||
cancelOngoingRequest,
|
cancelOngoingRequest,
|
||||||
activePtyId,
|
activePtyId,
|
||||||
|
handleSuspend,
|
||||||
embeddedShellFocused,
|
embeddedShellFocused,
|
||||||
settings.merged.general.debugKeystrokeLogging,
|
settings.merged.general.debugKeystrokeLogging,
|
||||||
refreshStatic,
|
refreshStatic,
|
||||||
setCopyModeEnabled,
|
setCopyModeEnabled,
|
||||||
|
tabFocusTimeoutRef,
|
||||||
isAlternateBuffer,
|
isAlternateBuffer,
|
||||||
shortcutsHelpVisible,
|
shortcutsHelpVisible,
|
||||||
backgroundCurrentShell,
|
backgroundCurrentShell,
|
||||||
@@ -1664,7 +1688,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
isBackgroundShellVisible,
|
isBackgroundShellVisible,
|
||||||
setIsBackgroundShellListOpen,
|
setIsBackgroundShellListOpen,
|
||||||
lastOutputTimeRef,
|
lastOutputTimeRef,
|
||||||
tabFocusTimeoutRef,
|
|
||||||
showTransientMessage,
|
showTransientMessage,
|
||||||
settings.merged.general.devtools,
|
settings.merged.general.devtools,
|
||||||
showErrorDetails,
|
showErrorDetails,
|
||||||
@@ -2276,7 +2299,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
>
|
>
|
||||||
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
|
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
|
||||||
<ShellFocusContext.Provider value={isFocused}>
|
<ShellFocusContext.Provider value={isFocused}>
|
||||||
<App />
|
<App key={`app-${forceRerenderKey}`} />
|
||||||
</ShellFocusContext.Provider>
|
</ShellFocusContext.Provider>
|
||||||
</ToolActionsProvider>
|
</ToolActionsProvider>
|
||||||
</AppContext.Provider>
|
</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`] = `
|
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
|
||||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||||
> Type your message or @path/to/file
|
> Type your message or @path/to/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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 })],
|
positive: [createKey('d', { ctrl: true })],
|
||||||
negative: [createKey('d'), createKey('c', { 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,
|
command: Command.SHOW_MORE_LINES,
|
||||||
positive: [
|
positive: [
|
||||||
|
|||||||
@@ -18,6 +18,23 @@ import { parseColor } from '../themes/color-utils.js';
|
|||||||
|
|
||||||
export type TerminalBackgroundColor = string | undefined;
|
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 {
|
export class TerminalCapabilityManager {
|
||||||
private static instance: TerminalCapabilityManager | undefined;
|
private static instance: TerminalCapabilityManager | undefined;
|
||||||
|
|
||||||
@@ -64,14 +81,6 @@ export class TerminalCapabilityManager {
|
|||||||
this.instance = undefined;
|
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,
|
* Detects terminal capabilities (Kitty protocol support, terminal name,
|
||||||
* background color).
|
* background color).
|
||||||
@@ -85,12 +94,12 @@ export class TerminalCapabilityManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
process.off('exit', TerminalCapabilityManager.cleanupOnExit);
|
process.off('exit', cleanupTerminalOnExit);
|
||||||
process.off('SIGTERM', TerminalCapabilityManager.cleanupOnExit);
|
process.off('SIGTERM', cleanupTerminalOnExit);
|
||||||
process.off('SIGINT', TerminalCapabilityManager.cleanupOnExit);
|
process.off('SIGINT', cleanupTerminalOnExit);
|
||||||
process.on('exit', TerminalCapabilityManager.cleanupOnExit);
|
process.on('exit', cleanupTerminalOnExit);
|
||||||
process.on('SIGTERM', TerminalCapabilityManager.cleanupOnExit);
|
process.on('SIGTERM', cleanupTerminalOnExit);
|
||||||
process.on('SIGINT', TerminalCapabilityManager.cleanupOnExit);
|
process.on('SIGINT', cleanupTerminalOnExit);
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const originalRawMode = process.stdin.isRaw;
|
const originalRawMode = process.stdin.isRaw;
|
||||||
|
|||||||
Reference in New Issue
Block a user