Skip to content

Commit b8ea2a1

Browse files
committed
feat(cli): support Ctrl-Z suspension
- Restores Ctrl+Z functionality to suspend the CLI process. - Resolves keybinding conflicts and refines suspend behavior. Fixes google-gemini#5018
1 parent eccc200 commit b8ea2a1

6 files changed

Lines changed: 328 additions & 2 deletions

File tree

docs/cli/keyboard-shortcuts.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ available combinations.
108108
| Focus the Gemini input from the shell input. | `Tab` |
109109
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
110110
| Restart the application. | `R` |
111+
| Suspend the CLI and move it to the background. | `Ctrl + Z` |
111112

112113
<!-- KEYBINDINGS-AUTOGEN:END -->
113114

packages/cli/src/config/keyBindings.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export enum Command {
8383
UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput',
8484
CLEAR_SCREEN = 'app.clearScreen',
8585
RESTART_APP = 'app.restart',
86+
SUSPEND = 'app.suspend',
8687
}
8788

8889
/**
@@ -253,6 +254,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
253254
[Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }],
254255
[Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }],
255256
[Command.CYCLE_APPROVAL_MODE]: [{ key: 'tab', shift: true }],
257+
[Command.SUSPEND]: [{ key: 'z', ctrl: true }],
256258
[Command.SHOW_MORE_LINES]: [
257259
{ key: 'o', ctrl: true },
258260
{ key: 's', ctrl: true },
@@ -368,6 +370,7 @@ export const commandCategories: readonly CommandCategory[] = [
368370
Command.UNFOCUS_SHELL_INPUT,
369371
Command.CLEAR_SCREEN,
370372
Command.RESTART_APP,
373+
Command.SUSPEND,
371374
],
372375
},
373376
];
@@ -450,6 +453,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
450453
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
451454
[Command.CYCLE_APPROVAL_MODE]:
452455
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
456+
[Command.SUSPEND]: 'Suspend the CLI and move it to the background.',
453457
[Command.SHOW_MORE_LINES]:
454458
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
455459
[Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.',
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {
8+
describe,
9+
it,
10+
expect,
11+
vi,
12+
beforeEach,
13+
afterEach,
14+
type Mock,
15+
} from 'vitest';
16+
import { render as inkRender } from '../test-utils/render.js';
17+
import { act } from 'react';
18+
import { AppContainer } from './AppContainer.js';
19+
import { SettingsContext } from './contexts/SettingsContext.js';
20+
import {
21+
type Config,
22+
makeFakeConfig,
23+
enableMouseEvents,
24+
disableMouseEvents,
25+
writeToStdout,
26+
} from '@google/gemini-cli-core';
27+
import { useKeypress, type Key } from './hooks/useKeypress.js';
28+
import type { ExtensionManager } from '../config/extension-manager.js';
29+
import type { LoadedSettings } from '../config/settings.js';
30+
import type { InitializationResult } from '../core/initializer.js';
31+
32+
// Mocks
33+
const mocks = vi.hoisted(() => ({
34+
mockStdout: { write: vi.fn(), emit: vi.fn() },
35+
mockStdin: {
36+
isTTY: true,
37+
setRawMode: vi.fn(),
38+
resume: vi.fn(),
39+
ref: vi.fn(),
40+
on: vi.fn(),
41+
off: vi.fn(),
42+
removeListener: vi.fn(),
43+
},
44+
mockApp: { rerender: vi.fn() },
45+
}));
46+
47+
// Mock process
48+
const mockProcessStdoutWrite = vi
49+
.spyOn(process.stdout, 'write')
50+
.mockImplementation(() => true);
51+
vi.spyOn(process.stdout, 'emit').mockImplementation(() => true);
52+
const mockProcessOn = vi.spyOn(process, 'on').mockImplementation(() => process);
53+
vi.spyOn(process, 'kill').mockImplementation(() => true);
54+
vi.spyOn(process, 'off').mockImplementation(() => process);
55+
56+
vi.mock('ink', async (importOriginal) => {
57+
const actual = await importOriginal<typeof import('ink')>();
58+
return {
59+
...actual,
60+
useStdout: () => ({ stdout: mocks.mockStdout }),
61+
useStdin: () => ({
62+
stdin: mocks.mockStdin,
63+
setRawMode: mocks.mockStdin.setRawMode,
64+
}),
65+
useApp: () => mocks.mockApp,
66+
measureElement: vi.fn(),
67+
};
68+
});
69+
70+
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
71+
const actual =
72+
await importOriginal<typeof import('@google/gemini-cli-core')>();
73+
return {
74+
...actual,
75+
enableMouseEvents: vi.fn(),
76+
disableMouseEvents: vi.fn(),
77+
writeToStdout: vi.fn(),
78+
startupProfiler: { start: vi.fn(), end: vi.fn(), flush: vi.fn() },
79+
};
80+
});
81+
82+
// Mock all the hooks used in AppContainer to avoid complex setup
83+
vi.mock('./hooks/useQuotaAndFallback.js', () => ({
84+
useQuotaAndFallback: () => ({ proQuotaRequest: null }),
85+
}));
86+
vi.mock('./hooks/useHistoryManager.js', () => ({
87+
useHistory: () => ({ history: [], addItem: vi.fn() }),
88+
}));
89+
vi.mock('./hooks/useThemeCommand.js', () => ({ useThemeCommand: () => ({}) }));
90+
vi.mock('./auth/useAuth.js', () => ({
91+
useAuthCommand: () => ({ authState: 'authenticated' }),
92+
}));
93+
vi.mock('./hooks/useEditorSettings.js', () => ({
94+
useEditorSettings: () => ({}),
95+
}));
96+
vi.mock('./hooks/useSettingsCommand.js', () => ({
97+
useSettingsCommand: () => ({}),
98+
}));
99+
vi.mock('./hooks/useModelCommand.js', () => ({ useModelCommand: () => ({}) }));
100+
vi.mock('./hooks/slashCommandProcessor.js', () => ({
101+
useSlashCommandProcessor: () => ({ handleSlashCommand: vi.fn() }),
102+
}));
103+
vi.mock('./hooks/useConsoleMessages.js', () => ({
104+
useConsoleMessages: () => ({ consoleMessages: [] }),
105+
}));
106+
vi.mock('./hooks/useTerminalSize.js', () => ({
107+
useTerminalSize: () => ({ columns: 80, rows: 24 }),
108+
}));
109+
vi.mock('./hooks/useGeminiStream.js', () => ({
110+
useGeminiStream: () => ({ streamingState: 'idle' }),
111+
}));
112+
vi.mock('./hooks/vim.js', () => ({ useVim: () => ({}) }));
113+
vi.mock('./hooks/useFocus.js', () => ({
114+
useFocus: () => ({ isFocused: true }),
115+
}));
116+
vi.mock('./hooks/useBracketedPaste.js', () => ({
117+
useBracketedPaste: () => ({}),
118+
}));
119+
vi.mock('./hooks/useLoadingIndicator.js', () => ({
120+
useLoadingIndicator: () => ({}),
121+
}));
122+
vi.mock('./hooks/useFolderTrust.js', () => ({ useFolderTrust: () => ({}) }));
123+
vi.mock('./hooks/useIdeTrustListener.js', () => ({
124+
useIdeTrustListener: () => ({}),
125+
}));
126+
vi.mock('./hooks/useMessageQueue.js', () => ({
127+
useMessageQueue: () => ({ messageQueue: [] }),
128+
}));
129+
vi.mock('./hooks/useAutoAcceptIndicator.js', () => ({
130+
useAutoAcceptIndicator: () => false,
131+
}));
132+
vi.mock('./hooks/useGitBranchName.js', () => ({ useGitBranchName: () => '' }));
133+
vi.mock('./contexts/VimModeContext.js', () => ({ useVimMode: () => ({}) }));
134+
vi.mock('./contexts/SessionContext.js', () => ({
135+
useSessionStats: () => ({ stats: {} }),
136+
}));
137+
vi.mock('./components/shared/text-buffer.js', () => ({
138+
useTextBuffer: () => ({ text: '' }),
139+
}));
140+
vi.mock('./hooks/useLogger.js', () => ({ useLogger: () => ({}) }));
141+
vi.mock('./hooks/useInputHistoryStore.js', () => ({
142+
useInputHistoryStore: () => ({}),
143+
}));
144+
vi.mock('./utils/ConsolePatcher.js');
145+
vi.mock('../utils/cleanup.js');
146+
vi.mock('../utils/windowTitle.js', () => ({ computeWindowTitle: () => '' }));
147+
148+
// Mock useKeypress to capture the handler
149+
vi.mock('./hooks/useKeypress.js', () => ({
150+
useKeypress: vi.fn(),
151+
}));
152+
153+
describe('AppContainer Suspend (Ctrl+Z)', () => {
154+
let mockConfig: Config;
155+
let handleGlobalKeypress: (key: Key) => void;
156+
157+
beforeEach(() => {
158+
vi.clearAllMocks();
159+
mockConfig = makeFakeConfig();
160+
vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test');
161+
vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined);
162+
vi.spyOn(mockConfig, 'getExtensionLoader').mockReturnValue({
163+
setRequestConsent: vi.fn(),
164+
setRequestSetting: vi.fn(),
165+
getExtensions: vi.fn().mockReturnValue([]),
166+
} as unknown as ExtensionManager);
167+
168+
(useKeypress as Mock).mockImplementation((handler) => {
169+
handleGlobalKeypress = handler;
170+
});
171+
172+
// Mock process properties
173+
Object.defineProperty(process, 'platform', { value: 'linux' });
174+
Object.defineProperty(process, 'stdin', { value: mocks.mockStdin });
175+
});
176+
177+
afterEach(() => {
178+
vi.restoreAllMocks();
179+
});
180+
181+
it('should suspend process on Ctrl+Z and restore on resume', async () => {
182+
// Render AppContainer
183+
const { unmount } = inkRender(
184+
<SettingsContext.Provider
185+
value={{ merged: {} } as unknown as LoadedSettings}
186+
>
187+
<AppContainer
188+
config={mockConfig}
189+
version="1.0.0"
190+
initializationResult={{} as unknown as InitializationResult}
191+
/>
192+
</SettingsContext.Provider>,
193+
);
194+
195+
expect(handleGlobalKeypress).toBeDefined();
196+
197+
// Trigger Ctrl+Z
198+
await act(async () => {
199+
handleGlobalKeypress({
200+
name: 'z',
201+
ctrl: true,
202+
alt: false,
203+
cmd: false,
204+
shift: false,
205+
insertable: false,
206+
sequence: '\x1a',
207+
});
208+
});
209+
210+
// Verify suspend actions
211+
// 1. Show cursor
212+
expect(writeToStdout).toHaveBeenCalledWith('\x1b[?25h');
213+
// 2. Disable mouse
214+
expect(disableMouseEvents).toHaveBeenCalled();
215+
// 3. Disable raw mode
216+
expect(mocks.mockStdin.setRawMode).toHaveBeenCalledWith(false);
217+
// 4. Register SIGCONT handler
218+
expect(process.on).toHaveBeenCalledWith('SIGCONT', expect.any(Function));
219+
// 5. Send SIGTSTP
220+
expect(process.kill).toHaveBeenCalledWith(0, 'SIGTSTP');
221+
222+
// Get the onResume handler
223+
const onResume = mockProcessOn.mock.calls.find(
224+
(call) => call[0] === 'SIGCONT',
225+
)?.[1] as () => void;
226+
expect(onResume).toBeDefined();
227+
228+
// Reset mocks to verify resume actions
229+
(disableMouseEvents as Mock).mockClear();
230+
(enableMouseEvents as Mock).mockClear();
231+
mocks.mockStdin.setRawMode.mockClear();
232+
mockProcessStdoutWrite.mockClear();
233+
234+
// Simulate SIGCONT (Resume)
235+
await act(async () => {
236+
onResume();
237+
});
238+
239+
// Verify resume actions
240+
// 1. Restore raw mode
241+
expect(mocks.mockStdin.setRawMode).toHaveBeenCalledWith(true);
242+
expect(mocks.mockStdin.resume).toHaveBeenCalled();
243+
// 2. Hide cursor
244+
expect(writeToStdout).toHaveBeenCalledWith('\x1b[?25l');
245+
// 3. Enable mouse
246+
expect(enableMouseEvents).toHaveBeenCalled();
247+
// 4. Emit resize to repaint
248+
expect(process.stdout.emit).toHaveBeenCalledWith('resize');
249+
// 5. Cleanup listener
250+
expect(process.off).toHaveBeenCalledWith('SIGCONT', onResume);
251+
252+
// 6. Verify rerender key update (indirectly via App rerender? or just assume side effects)
253+
// We can't easily check state internal to component without hacking, but checking side effects is good enough.
254+
255+
unmount();
256+
});
257+
});

packages/cli/src/ui/AppContainer.tsx

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ export const AppContainer = (props: AppContainerProps) => {
187187
const settings = useSettings();
188188
const isAlternateBuffer = useAlternateBuffer();
189189
const [corgiMode, setCorgiMode] = useState(false);
190+
const [forceRerenderKey, setForceRerenderKey] = useState(0);
190191
const [debugMessage, setDebugMessage] = useState<string>('');
191192
const [quittingMessages, setQuittingMessages] = useState<
192193
HistoryItem[] | null
@@ -493,7 +494,7 @@ export const AppContainer = (props: AppContainerProps) => {
493494
enterAlternateScreen();
494495
enableMouseEvents();
495496
disableLineWrapping();
496-
app.rerender();
497+
(app as unknown as { rerender: () => void }).rerender();
497498
}
498499
terminalCapabilityManager.enableSupportedModes();
499500
refreshStatic();
@@ -1367,6 +1368,49 @@ Logging in with Google... Restarting Gemini CLI to continue.
13671368
}
13681369
setCtrlDPressCount((prev) => prev + 1);
13691370
return;
1371+
} else if (keyMatchers[Command.SUSPEND](key)) {
1372+
if (process.platform !== 'win32') {
1373+
// Cleanup before suspend
1374+
writeToStdout('\x1b[?25h'); // Show cursor
1375+
disableMouseEvents();
1376+
terminalCapabilityManager.disableKittyProtocol();
1377+
1378+
if (process.stdin.isTTY) {
1379+
process.stdin.setRawMode(false);
1380+
}
1381+
setRawMode(false);
1382+
1383+
const onResume = () => {
1384+
// Restore terminal state
1385+
if (process.stdin.isTTY) {
1386+
process.stdin.setRawMode(true);
1387+
process.stdin.resume();
1388+
process.stdin.ref();
1389+
}
1390+
setRawMode(true);
1391+
1392+
terminalCapabilityManager.enableKittyProtocol();
1393+
writeToStdout('\x1b[?25l'); // Hide cursor
1394+
enableMouseEvents();
1395+
1396+
// Force Ink to do a complete repaint by:
1397+
// 1. Emitting a resize event (tricks Ink into full redraw)
1398+
// 2. Remounting components via state changes
1399+
process.stdout.emit('resize');
1400+
1401+
// Give a tick for resize to process, then trigger remount
1402+
setImmediate(() => {
1403+
refreshStatic();
1404+
setForceRerenderKey((prev) => prev + 1);
1405+
});
1406+
1407+
process.off('SIGCONT', onResume);
1408+
};
1409+
process.on('SIGCONT', onResume);
1410+
1411+
process.kill(0, 'SIGTSTP');
1412+
}
1413+
return;
13701414
}
13711415

13721416
let enteringConstrainHeightMode = false;
@@ -1446,6 +1490,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
14461490
embeddedShellFocused,
14471491
settings.merged.general.debugKeystrokeLogging,
14481492
refreshStatic,
1493+
setRawMode,
14491494
setCopyModeEnabled,
14501495
copyModeEnabled,
14511496
isAlternateBuffer,
@@ -1979,7 +2024,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
19792024
>
19802025
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
19812026
<ShellFocusContext.Provider value={isFocused}>
1982-
<App />
2027+
<App key={`app-${forceRerenderKey}`} />
19832028
</ShellFocusContext.Provider>
19842029
</ToolActionsProvider>
19852030
</AppContext.Provider>

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,11 @@ describe('keyMatchers', () => {
306306
positive: [createKey('d', { ctrl: true })],
307307
negative: [createKey('d'), createKey('c', { ctrl: true })],
308308
},
309+
{
310+
command: Command.SUSPEND,
311+
positive: [createKey('z', { ctrl: true })],
312+
negative: [createKey('z'), createKey('y', { ctrl: true })],
313+
},
309314
{
310315
command: Command.SHOW_MORE_LINES,
311316
positive: [

0 commit comments

Comments
 (0)