Skip to content

Commit be3e79e

Browse files
committed
feat(cli): support Ctrl-Z suspension
- Implement Ctrl+Z suspension with robust terminal state handling. - Rebind Undo/Redo to Alt+Z / Alt+Shift+Z to avoid conflicts. - Fix macOS Option key character mapping for Undo/Redo. - Improve type safety for app.rerender() in AppContainer. - Add comprehensive suspension test suite. - Fix environment leakage in core tests. - Update keyboard shortcut documentation. Fixes #5018 refactor(cli): address suspend PR review feedback
1 parent 67d9b76 commit be3e79e

File tree

8 files changed

+196
-28
lines changed

8 files changed

+196
-28
lines changed

docs/cli/keyboard-shortcuts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ available combinations.
120120
| Move focus from the shell back to Gemini. | `Shift + Tab` |
121121
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
122122
| Restart the application. | `R` |
123-
| Suspend the application (not yet implemented). | `Ctrl + Z` |
123+
| Suspend the CLI and move it to the background. | `Ctrl + Z` |
124124

125125
<!-- KEYBINDINGS-AUTOGEN:END -->
126126

packages/cli/src/config/keyBindings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -523,5 +523,5 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
523523
[Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.',
524524
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
525525
[Command.RESTART_APP]: 'Restart the application.',
526-
[Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).',
526+
[Command.SUSPEND_APP]: 'Suspend the CLI and move it to the background.',
527527
};

packages/cli/src/ui/AppContainer.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ vi.mock('./hooks/useFocus.js');
135135
vi.mock('./hooks/useBracketedPaste.js');
136136
vi.mock('./hooks/useKeypress.js');
137137
vi.mock('./hooks/useLoadingIndicator.js');
138+
vi.mock('./hooks/useSuspend.js');
138139
vi.mock('./hooks/useFolderTrust.js');
139140
vi.mock('./hooks/useIdeTrustListener.js');
140141
vi.mock('./hooks/useMessageQueue.js');
@@ -198,6 +199,7 @@ import { useLogger } from './hooks/useLogger.js';
198199
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
199200
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
200201
import { useKeypress, type Key } from './hooks/useKeypress.js';
202+
import { useSuspend } from './hooks/useSuspend.js';
201203
import { measureElement } from 'ink';
202204
import { useTerminalSize } from './hooks/useTerminalSize.js';
203205
import {
@@ -269,6 +271,7 @@ describe('AppContainer State Management', () => {
269271
const mockedUseLogger = useLogger as Mock;
270272
const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
271273
const mockedUseKeypress = useKeypress as Mock;
274+
const mockedUseSuspend = useSuspend as Mock;
272275
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
273276
const mockedUseHookDisplayState = useHookDisplayState as Mock;
274277
const mockedUseTerminalTheme = useTerminalTheme as Mock;
@@ -400,6 +403,9 @@ describe('AppContainer State Management', () => {
400403
elapsedTime: '0.0s',
401404
currentLoadingPhrase: '',
402405
});
406+
mockedUseSuspend.mockReturnValue({
407+
handleSuspend: vi.fn(),
408+
});
403409
mockedUseHookDisplayState.mockReturnValue([]);
404410
mockedUseTerminalTheme.mockReturnValue(undefined);
405411
mockedUseShellInactivityStatus.mockReturnValue({
@@ -1970,6 +1976,19 @@ describe('AppContainer State Management', () => {
19701976
});
19711977
});
19721978

1979+
describe('CTRL+Z', () => {
1980+
it('should call handleSuspend', async () => {
1981+
const handleSuspend = vi.fn();
1982+
mockedUseSuspend.mockReturnValue({ handleSuspend });
1983+
await setupKeypressTest();
1984+
1985+
pressKey({ name: 'z', ctrl: true });
1986+
1987+
expect(handleSuspend).toHaveBeenCalledTimes(1);
1988+
unmount();
1989+
});
1990+
});
1991+
19731992
describe('Focus Handling (Tab / Shift+Tab)', () => {
19741993
beforeEach(() => {
19751994
// Mock activePtyId to enable focus

packages/cli/src/ui/AppContainer.tsx

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,14 @@ import {
1212
useRef,
1313
useLayoutEffect,
1414
} from 'react';
15-
import { type DOMElement, measureElement } from 'ink';
15+
import {
16+
type DOMElement,
17+
measureElement,
18+
useApp,
19+
useStdout,
20+
useStdin,
21+
type AppProps,
22+
} from 'ink';
1623
import { App } from './App.js';
1724
import { AppContext } from './contexts/AppContext.js';
1825
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
@@ -87,7 +94,6 @@ import { useVimMode } from './contexts/VimModeContext.js';
8794
import { useConsoleMessages } from './hooks/useConsoleMessages.js';
8895
import { useTerminalSize } from './hooks/useTerminalSize.js';
8996
import { calculatePromptWidths } from './components/InputPrompt.js';
90-
import { useApp, useStdout, useStdin } from 'ink';
9197
import { calculateMainAreaWidth } from './utils/ui-sizing.js';
9298
import ansiEscapes from 'ansi-escapes';
9399
import { basename } from 'node:path';
@@ -145,7 +151,7 @@ import { NewAgentsChoice } from './components/NewAgentsNotification.js';
145151
import { isSlashCommand } from './utils/commandUtils.js';
146152
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
147153
import { useTimedMessage } from './hooks/useTimedMessage.js';
148-
import { isITerm2 } from './utils/terminalUtils.js';
154+
import { useSuspend } from './hooks/useSuspend.js';
149155

150156
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
151157
return pendingHistoryItems.some((item) => {
@@ -199,6 +205,7 @@ export const AppContainer = (props: AppContainerProps) => {
199205
useMemoryMonitor(historyManager);
200206
const isAlternateBuffer = useAlternateBuffer();
201207
const [corgiMode, setCorgiMode] = useState(false);
208+
const [forceRerenderKey, setForceRerenderKey] = useState(0);
202209
const [debugMessage, setDebugMessage] = useState<string>('');
203210
const [quittingMessages, setQuittingMessages] = useState<
204211
HistoryItem[] | null
@@ -345,7 +352,7 @@ export const AppContainer = (props: AppContainerProps) => {
345352
const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize();
346353
const { stdin, setRawMode } = useStdin();
347354
const { stdout } = useStdout();
348-
const app = useApp();
355+
const app: AppProps = useApp();
349356

350357
// Additional hooks moved from App.tsx
351358
const { stats: sessionStats } = useSessionStats();
@@ -1366,6 +1373,23 @@ Logging in with Google... Restarting Gemini CLI to continue.
13661373
};
13671374
}, [showTransientMessage]);
13681375

1376+
const handleWarning = useCallback(
1377+
(message: string) => {
1378+
showTransientMessage({
1379+
text: message,
1380+
type: TransientMessageType.Warning,
1381+
});
1382+
},
1383+
[showTransientMessage],
1384+
);
1385+
1386+
const { handleSuspend } = useSuspend({
1387+
handleWarning,
1388+
setRawMode,
1389+
refreshStatic,
1390+
setForceRerenderKey,
1391+
});
1392+
13691393
useEffect(() => {
13701394
if (ideNeedsRestart) {
13711395
// IDE trust changed, force a restart.
@@ -1517,6 +1541,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
15171541
} else if (keyMatchers[Command.EXIT](key)) {
15181542
setCtrlDPressCount((prev) => prev + 1);
15191543
return true;
1544+
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
1545+
handleSuspend();
1546+
return true;
15201547
}
15211548

15221549
let enteringConstrainHeightMode = false;
@@ -1528,15 +1555,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
15281555
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
15291556
setShowErrorDetails((prev) => !prev);
15301557
return true;
1531-
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
1532-
const undoMessage = isITerm2()
1533-
? 'Undo has been moved to Option + Z'
1534-
: 'Undo has been moved to Alt/Option + Z or Cmd + Z';
1535-
showTransientMessage({
1536-
text: undoMessage,
1537-
type: TransientMessageType.Warning,
1538-
});
1539-
return true;
15401558
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
15411559
setShowFullTodos((prev) => !prev);
15421560
return true;
@@ -1645,11 +1663,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
16451663
handleSlashCommand,
16461664
cancelOngoingRequest,
16471665
activePtyId,
1666+
handleSuspend,
16481667
embeddedShellFocused,
16491668
settings.merged.general.debugKeystrokeLogging,
16501669
refreshStatic,
1651-
setCopyModeEnabled,
1652-
copyModeEnabled,
1670+
tabFocusTimeoutRef,
1671+
handleWarning,
16531672
isAlternateBuffer,
16541673
backgroundCurrentShell,
16551674
toggleBackgroundShell,
@@ -1659,6 +1678,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
16591678
lastOutputTimeRef,
16601679
tabFocusTimeoutRef,
16611680
showTransientMessage,
1681+
handleWarning,
1682+
setCopyModeEnabled,
1683+
copyModeEnabled,
16621684
],
16631685
);
16641686

@@ -2223,7 +2245,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
22232245
>
22242246
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
22252247
<ShellFocusContext.Provider value={isFocused}>
2226-
<App />
2248+
<App key={`app-${forceRerenderKey}`} />
22272249
</ShellFocusContext.Provider>
22282250
</ToolActionsProvider>
22292251
</AppContext.Provider>

packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
7777
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
7878
`;
7979

80+
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
81+
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
82+
> [Pasted Text: 10 lines]
83+
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
84+
`;
85+
8086
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
8187
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
8288
> Type your message or @path/to/file
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { useState, useRef, useEffect, useCallback } from 'react';
8+
import {
9+
writeToStdout,
10+
disableMouseEvents,
11+
enableMouseEvents,
12+
} from '@google/gemini-cli-core';
13+
import process from 'node:process';
14+
import {
15+
cleanupTerminalOnExit,
16+
terminalCapabilityManager,
17+
} from '../utils/terminalCapabilityManager.js';
18+
import { WARNING_PROMPT_DURATION_MS } from '../constants.js';
19+
20+
interface UseSuspendProps {
21+
handleWarning: (message: string) => void;
22+
setRawMode: (mode: boolean) => void;
23+
refreshStatic: () => void;
24+
setForceRerenderKey: (updater: (prev: number) => number) => void;
25+
}
26+
27+
export function useSuspend({
28+
handleWarning,
29+
setRawMode,
30+
refreshStatic,
31+
setForceRerenderKey,
32+
}: UseSuspendProps) {
33+
const [ctrlZPressCount, setCtrlZPressCount] = useState(0);
34+
const ctrlZTimerRef = useRef<NodeJS.Timeout | null>(null);
35+
36+
useEffect(() => {
37+
if (ctrlZTimerRef.current) {
38+
clearTimeout(ctrlZTimerRef.current);
39+
ctrlZTimerRef.current = null;
40+
}
41+
if (ctrlZPressCount > 1) {
42+
setCtrlZPressCount(0);
43+
if (process.platform === 'win32') {
44+
handleWarning('Ctrl+Z suspend is not supported on Windows.');
45+
return;
46+
}
47+
48+
// Cleanup before suspend.
49+
writeToStdout('\x1b[?25h'); // Show cursor
50+
disableMouseEvents();
51+
cleanupTerminalOnExit();
52+
53+
if (process.stdin.isTTY) {
54+
process.stdin.setRawMode(false);
55+
}
56+
setRawMode(false);
57+
58+
const onResume = () => {
59+
// Restore terminal state.
60+
if (process.stdin.isTTY) {
61+
process.stdin.setRawMode(true);
62+
process.stdin.resume();
63+
process.stdin.ref();
64+
}
65+
setRawMode(true);
66+
67+
terminalCapabilityManager.enableSupportedModes();
68+
writeToStdout('\x1b[?25l'); // Hide cursor
69+
enableMouseEvents();
70+
71+
// Force Ink to do a complete repaint by:
72+
// 1. Emitting a resize event (tricks Ink into full redraw)
73+
// 2. Remounting components via state changes
74+
process.stdout.emit('resize');
75+
76+
// Give a tick for resize to process, then trigger remount
77+
setImmediate(() => {
78+
refreshStatic();
79+
setForceRerenderKey((prev) => prev + 1);
80+
});
81+
82+
process.off('SIGCONT', onResume);
83+
};
84+
process.on('SIGCONT', onResume);
85+
86+
process.kill(0, 'SIGTSTP');
87+
} else if (ctrlZPressCount > 0) {
88+
handleWarning(
89+
'Press Ctrl+Z again to suspend. Undo has moved to Cmd + Z or Alt/Opt + Z.',
90+
);
91+
ctrlZTimerRef.current = setTimeout(() => {
92+
setCtrlZPressCount(0);
93+
ctrlZTimerRef.current = null;
94+
}, WARNING_PROMPT_DURATION_MS);
95+
}
96+
}, [
97+
ctrlZPressCount,
98+
handleWarning,
99+
setRawMode,
100+
refreshStatic,
101+
setForceRerenderKey,
102+
]);
103+
104+
const handleSuspend = useCallback(() => {
105+
setCtrlZPressCount((prev) => prev + 1);
106+
}, []);
107+
108+
return { handleSuspend };
109+
}

packages/cli/src/ui/keyMatchers.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,18 @@ describe('keyMatchers', () => {
330330
positive: [createKey('d', { ctrl: true })],
331331
negative: [createKey('d'), createKey('c', { ctrl: true })],
332332
},
333+
{
334+
command: Command.SUSPEND_APP,
335+
positive: [
336+
createKey('z', { ctrl: true }),
337+
createKey('z', { ctrl: true, shift: true }),
338+
],
339+
negative: [
340+
createKey('z'),
341+
createKey('y', { ctrl: true }),
342+
createKey('z', { alt: true }),
343+
],
344+
},
333345
{
334346
command: Command.SHOW_MORE_LINES,
335347
positive: [

packages/cli/src/ui/utils/terminalCapabilityManager.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ import { parseColor } from '../themes/color-utils.js';
1818

1919
export type TerminalBackgroundColor = string | undefined;
2020

21+
export function cleanupTerminalOnExit() {
22+
// Don't bother catching errors since if one write fails, the others likely will.
23+
disableKittyKeyboardProtocol();
24+
disableModifyOtherKeys();
25+
disableBracketedPasteMode();
26+
}
27+
2128
export class TerminalCapabilityManager {
2229
private static instance: TerminalCapabilityManager | undefined;
2330

@@ -77,16 +84,9 @@ export class TerminalCapabilityManager {
7784
return;
7885
}
7986

80-
const cleanupOnExit = () => {
81-
// don't bother catching errors since if one write
82-
// fails, the other probably will too
83-
disableKittyKeyboardProtocol();
84-
disableModifyOtherKeys();
85-
disableBracketedPasteMode();
86-
};
87-
process.on('exit', cleanupOnExit);
88-
process.on('SIGTERM', cleanupOnExit);
89-
process.on('SIGINT', cleanupOnExit);
87+
process.on('exit', cleanupTerminalOnExit);
88+
process.on('SIGTERM', cleanupTerminalOnExit);
89+
process.on('SIGINT', cleanupTerminalOnExit);
9090

9191
return new Promise((resolve) => {
9292
const originalRawMode = process.stdin.isRaw;

0 commit comments

Comments
 (0)