Skip to content

Commit 4657ce7

Browse files
committed
feat(cli): support Ctrl-Z suspension
1 parent 099aa96 commit 4657ce7

9 files changed

Lines changed: 488 additions & 62 deletions

File tree

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: 47 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ vi.mock('./hooks/vim.js');
135135
vi.mock('./hooks/useFocus.js');
136136
vi.mock('./hooks/useBracketedPaste.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');
@@ -197,7 +198,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
197198
import { useLogger } from './hooks/useLogger.js';
198199
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
199200
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
200-
import { useKeypress } from './hooks/useKeypress.js';
201+
import { useSuspend } from './hooks/useSuspend.js';
201202
import { measureElement } from 'ink';
202203
import { useTerminalSize } from './hooks/useTerminalSize.js';
203204
import {
@@ -270,6 +271,7 @@ describe('AppContainer State Management', () => {
270271
const mockedUseTextBuffer = useTextBuffer as Mock;
271272
const mockedUseLogger = useLogger as Mock;
272273
const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
274+
const mockedUseSuspend = useSuspend as Mock;
273275
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
274276
const mockedUseHookDisplayState = useHookDisplayState as Mock;
275277
const mockedUseTerminalTheme = useTerminalTheme as Mock;
@@ -401,6 +403,9 @@ describe('AppContainer State Management', () => {
401403
elapsedTime: '0.0s',
402404
currentLoadingPhrase: '',
403405
});
406+
mockedUseSuspend.mockReturnValue({
407+
handleSuspend: vi.fn(),
408+
});
404409
mockedUseHookDisplayState.mockReturnValue([]);
405410
mockedUseTerminalTheme.mockReturnValue(undefined);
406411
mockedUseShellInactivityStatus.mockReturnValue({
@@ -440,8 +445,8 @@ describe('AppContainer State Management', () => {
440445
...defaultMergedSettings.ui,
441446
showStatusInTitle: false,
442447
hideWindowTitle: false,
448+
useAlternateBuffer: false,
443449
},
444-
useAlternateBuffer: false,
445450
},
446451
} as unknown as LoadedSettings;
447452

@@ -727,10 +732,10 @@ describe('AppContainer State Management', () => {
727732
getChatRecordingService: vi.fn(() => mockChatRecordingService),
728733
};
729734

730-
const configWithRecording = {
731-
...mockConfig,
732-
getGeminiClient: vi.fn(() => mockGeminiClient),
733-
} as unknown as Config;
735+
const configWithRecording = makeFakeConfig();
736+
vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue(
737+
mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
738+
);
734739

735740
expect(() => {
736741
renderAppContainer({
@@ -761,11 +766,13 @@ describe('AppContainer State Management', () => {
761766
setHistory: vi.fn(),
762767
};
763768

764-
const configWithRecording = {
765-
...mockConfig,
766-
getGeminiClient: vi.fn(() => mockGeminiClient),
767-
getSessionId: vi.fn(() => 'test-session-123'),
768-
} as unknown as Config;
769+
const configWithRecording = makeFakeConfig();
770+
vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue(
771+
mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
772+
);
773+
vi.spyOn(configWithRecording, 'getSessionId').mockReturnValue(
774+
'test-session-123',
775+
);
769776

770777
expect(() => {
771778
renderAppContainer({
@@ -801,10 +808,10 @@ describe('AppContainer State Management', () => {
801808
getUserTier: vi.fn(),
802809
};
803810

804-
const configWithRecording = {
805-
...mockConfig,
806-
getGeminiClient: vi.fn(() => mockGeminiClient),
807-
} as unknown as Config;
811+
const configWithRecording = makeFakeConfig();
812+
vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue(
813+
mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
814+
);
808815

809816
renderAppContainer({
810817
config: configWithRecording,
@@ -835,10 +842,10 @@ describe('AppContainer State Management', () => {
835842
})),
836843
};
837844

838-
const configWithClient = {
839-
...mockConfig,
840-
getGeminiClient: vi.fn(() => mockGeminiClient),
841-
} as unknown as Config;
845+
const configWithClient = makeFakeConfig();
846+
vi.spyOn(configWithClient, 'getGeminiClient').mockReturnValue(
847+
mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
848+
);
842849

843850
const resumedData = {
844851
conversation: {
@@ -891,10 +898,10 @@ describe('AppContainer State Management', () => {
891898
getChatRecordingService: vi.fn(),
892899
};
893900

894-
const configWithClient = {
895-
...mockConfig,
896-
getGeminiClient: vi.fn(() => mockGeminiClient),
897-
} as unknown as Config;
901+
const configWithClient = makeFakeConfig();
902+
vi.spyOn(configWithClient, 'getGeminiClient').mockReturnValue(
903+
mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
904+
);
898905

899906
const resumedData = {
900907
conversation: {
@@ -944,10 +951,10 @@ describe('AppContainer State Management', () => {
944951
getUserTier: vi.fn(),
945952
};
946953

947-
const configWithRecording = {
948-
...mockConfig,
949-
getGeminiClient: vi.fn(() => mockGeminiClient),
950-
} as unknown as Config;
954+
const configWithRecording = makeFakeConfig();
955+
vi.spyOn(configWithRecording, 'getGeminiClient').mockReturnValue(
956+
mockGeminiClient as unknown as ReturnType<Config['getGeminiClient']>,
957+
);
951958

952959
renderAppContainer({
953960
config: configWithRecording,
@@ -1942,6 +1949,19 @@ describe('AppContainer State Management', () => {
19421949
});
19431950
});
19441951

1952+
describe('CTRL+Z', () => {
1953+
it('should call handleSuspend', async () => {
1954+
const handleSuspend = vi.fn();
1955+
mockedUseSuspend.mockReturnValue({ handleSuspend });
1956+
await setupKeypressTest();
1957+
1958+
pressKey('\x1A'); // Ctrl+Z
1959+
1960+
expect(handleSuspend).toHaveBeenCalledTimes(1);
1961+
unmount();
1962+
});
1963+
});
1964+
19451965
describe('Focus Handling (Tab / Shift+Tab)', () => {
19461966
beforeEach(() => {
19471967
// Mock activePtyId to enable focus

packages/cli/src/ui/AppContainer.tsx

Lines changed: 42 additions & 19 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';
@@ -146,7 +152,7 @@ import { NewAgentsChoice } from './components/NewAgentsNotification.js';
146152
import { isSlashCommand } from './utils/commandUtils.js';
147153
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
148154
import { useTimedMessage } from './hooks/useTimedMessage.js';
149-
import { isITerm2 } from './utils/terminalUtils.js';
155+
import { useSuspend } from './hooks/useSuspend.js';
150156

151157
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
152158
return pendingHistoryItems.some((item) => {
@@ -200,6 +206,7 @@ export const AppContainer = (props: AppContainerProps) => {
200206
useMemoryMonitor(historyManager);
201207
const isAlternateBuffer = useAlternateBuffer();
202208
const [corgiMode, setCorgiMode] = useState(false);
209+
const [forceRerenderKey, setForceRerenderKey] = useState(0);
203210
const [debugMessage, setDebugMessage] = useState<string>('');
204211
const [quittingMessages, setQuittingMessages] = useState<
205212
HistoryItem[] | null
@@ -346,7 +353,7 @@ export const AppContainer = (props: AppContainerProps) => {
346353
const { columns: terminalWidth, rows: terminalHeight } = useTerminalSize();
347354
const { stdin, setRawMode } = useStdin();
348355
const { stdout } = useStdout();
349-
const app = useApp();
356+
const app: AppProps = useApp();
350357

351358
// Additional hooks moved from App.tsx
352359
const { stats: sessionStats } = useSessionStats();
@@ -535,10 +542,13 @@ export const AppContainer = (props: AppContainerProps) => {
535542
setHistoryRemountKey((prev) => prev + 1);
536543
}, [setHistoryRemountKey, isAlternateBuffer, stdout]);
537544

545+
const shouldUseAlternateScreen = shouldEnterAlternateScreen(
546+
isAlternateBuffer,
547+
config.getScreenReader(),
548+
);
549+
538550
const handleEditorClose = useCallback(() => {
539-
if (
540-
shouldEnterAlternateScreen(isAlternateBuffer, config.getScreenReader())
541-
) {
551+
if (shouldUseAlternateScreen) {
542552
// The editor may have exited alternate buffer mode so we need to
543553
// enter it again to be safe.
544554
enterAlternateScreen();
@@ -548,7 +558,7 @@ export const AppContainer = (props: AppContainerProps) => {
548558
}
549559
terminalCapabilityManager.enableSupportedModes();
550560
refreshStatic();
551-
}, [refreshStatic, isAlternateBuffer, app, config]);
561+
}, [refreshStatic, shouldUseAlternateScreen, app]);
552562

553563
const [editorError, setEditorError] = useState<string | null>(null);
554564
const {
@@ -1369,6 +1379,24 @@ Logging in with Google... Restarting Gemini CLI to continue.
13691379
};
13701380
}, [showTransientMessage]);
13711381

1382+
const handleWarning = useCallback(
1383+
(message: string) => {
1384+
showTransientMessage({
1385+
text: message,
1386+
type: TransientMessageType.Warning,
1387+
});
1388+
},
1389+
[showTransientMessage],
1390+
);
1391+
1392+
const { handleSuspend } = useSuspend({
1393+
handleWarning,
1394+
setRawMode,
1395+
refreshStatic,
1396+
setForceRerenderKey,
1397+
shouldUseAlternateScreen,
1398+
});
1399+
13721400
useEffect(() => {
13731401
if (ideNeedsRestart) {
13741402
// IDE trust changed, force a restart.
@@ -1505,6 +1533,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
15051533
} else if (keyMatchers[Command.EXIT](key)) {
15061534
setCtrlDPressCount((prev) => prev + 1);
15071535
return true;
1536+
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
1537+
handleSuspend();
1538+
return true;
15081539
}
15091540

15101541
let enteringConstrainHeightMode = false;
@@ -1530,15 +1561,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
15301561
setShowErrorDetails((prev) => !prev);
15311562
}
15321563
return true;
1533-
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
1534-
const undoMessage = isITerm2()
1535-
? 'Undo has been moved to Option + Z'
1536-
: 'Undo has been moved to Alt/Option + Z or Cmd + Z';
1537-
showTransientMessage({
1538-
text: undoMessage,
1539-
type: TransientMessageType.Warning,
1540-
});
1541-
return true;
15421564
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
15431565
setShowFullTodos((prev) => !prev);
15441566
return true;
@@ -1647,18 +1669,19 @@ Logging in with Google... Restarting Gemini CLI to continue.
16471669
handleSlashCommand,
16481670
cancelOngoingRequest,
16491671
activePtyId,
1672+
handleSuspend,
16501673
embeddedShellFocused,
16511674
settings.merged.general.debugKeystrokeLogging,
16521675
refreshStatic,
16531676
setCopyModeEnabled,
1677+
tabFocusTimeoutRef,
16541678
isAlternateBuffer,
16551679
backgroundCurrentShell,
16561680
toggleBackgroundShell,
16571681
backgroundShells,
16581682
isBackgroundShellVisible,
16591683
setIsBackgroundShellListOpen,
16601684
lastOutputTimeRef,
1661-
tabFocusTimeoutRef,
16621685
showTransientMessage,
16631686
settings.merged.general.devtools,
16641687
showErrorDetails,
@@ -2240,7 +2263,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
22402263
>
22412264
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
22422265
<ShellFocusContext.Provider value={isFocused}>
2243-
<App />
2266+
<App key={`app-${forceRerenderKey}`} />
22442267
</ShellFocusContext.Provider>
22452268
</ToolActionsProvider>
22462269
</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

0 commit comments

Comments
 (0)