diff --git a/docs/cli/commands.md b/docs/cli/commands.md
index e79c374e71..fe0198d626 100644
--- a/docs/cli/commands.md
+++ b/docs/cli/commands.md
@@ -264,6 +264,9 @@ Slash commands provide meta-level control over the CLI itself.
modify them as desired. Changes to some settings are applied immediately,
while others require a restart.
+- **`/shells`** (or **`/bashes`**)
+ - **Description:** Toggle the background shells view. This allows you to view
+ and manage long-running processes that you've sent to the background.
- **`/setup-github`**
- **Description:** Set up GitHub Actions to triage issues and review PRs with
Gemini.
diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md
index 5cfa26cf92..a1a28665b9 100644
--- a/docs/cli/keyboard-shortcuts.md
+++ b/docs/cli/keyboard-shortcuts.md
@@ -23,7 +23,7 @@ available combinations.
| Move the cursor to the end of the line. | `Ctrl + E`
`End (no Shift, Ctrl)` |
| Move the cursor up one line. | `Up Arrow (no Shift, Alt, Ctrl, Cmd)` |
| Move the cursor down one line. | `Down Arrow (no Shift, Alt, Ctrl, Cmd)` |
-| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)`
`Ctrl + B` |
+| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)` |
| Move the cursor one character to the right. | `Right Arrow (no Shift, Alt, Ctrl, Cmd)`
`Ctrl + F` |
| Move the cursor one word to the left. | `Ctrl + Left Arrow`
`Alt + Left Arrow`
`Alt + B` |
| Move the cursor one word to the right. | `Ctrl + Right Arrow`
`Alt + Right Arrow`
`Alt + F` |
@@ -106,6 +106,14 @@ available combinations.
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`
`Ctrl + S` |
+| Ctrl+B | `Ctrl + B` |
+| Ctrl+L | `Ctrl + L` |
+| Ctrl+K | `Ctrl + K` |
+| Enter | `Enter` |
+| Esc | `Esc` |
+| Shift+Tab | `Shift + Tab` |
+| Tab | `Tab (no Shift)` |
+| Tab | `Tab (no Shift)` |
| Focus the shell input from the gemini input. | `Tab (no Shift)` |
| Focus the Gemini input from the shell input. | `Tab` |
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts
index 0d32ae2922..9b6a903a4b 100644
--- a/packages/cli/src/config/keyBindings.ts
+++ b/packages/cli/src/config/keyBindings.ts
@@ -72,6 +72,15 @@ export enum Command {
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
PASTE_CLIPBOARD = 'input.paste',
+ BACKGROUND_SHELL_ESCAPE = 'backgroundShellEscape',
+ BACKGROUND_SHELL_SELECT = 'backgroundShellSelect',
+ TOGGLE_BACKGROUND_SHELL = 'toggleBackgroundShell',
+ TOGGLE_BACKGROUND_SHELL_LIST = 'toggleBackgroundShellList',
+ KILL_BACKGROUND_SHELL = 'backgroundShell.kill',
+ UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus',
+ UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus',
+ SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning',
+
// App Controls
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
SHOW_FULL_TODOS = 'app.showFullTodos',
@@ -139,7 +148,6 @@ export const defaultKeyBindings: KeyBindingConfig = {
],
[Command.MOVE_LEFT]: [
{ key: 'left', shift: false, alt: false, ctrl: false, cmd: false },
- { key: 'b', ctrl: true },
],
[Command.MOVE_RIGHT]: [
{ key: 'right', shift: false, alt: false, ctrl: false, cmd: false },
@@ -265,6 +273,16 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }],
[Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }],
[Command.CYCLE_APPROVAL_MODE]: [{ key: 'tab', shift: true }],
+ [Command.TOGGLE_BACKGROUND_SHELL]: [{ key: 'b', ctrl: true }],
+ [Command.TOGGLE_BACKGROUND_SHELL_LIST]: [{ key: 'l', ctrl: true }],
+ [Command.KILL_BACKGROUND_SHELL]: [{ key: 'k', ctrl: true }],
+ [Command.UNFOCUS_BACKGROUND_SHELL]: [{ key: 'tab', shift: true }],
+ [Command.UNFOCUS_BACKGROUND_SHELL_LIST]: [{ key: 'tab', shift: false }],
+ [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [
+ { key: 'tab', shift: false },
+ ],
+ [Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }],
+ [Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }],
[Command.SHOW_MORE_LINES]: [
{ key: 'o', ctrl: true },
{ key: 's', ctrl: true },
@@ -379,6 +397,14 @@ export const commandCategories: readonly CommandCategory[] = [
Command.TOGGLE_YOLO,
Command.CYCLE_APPROVAL_MODE,
Command.SHOW_MORE_LINES,
+ Command.TOGGLE_BACKGROUND_SHELL,
+ Command.TOGGLE_BACKGROUND_SHELL_LIST,
+ Command.KILL_BACKGROUND_SHELL,
+ Command.BACKGROUND_SHELL_SELECT,
+ Command.BACKGROUND_SHELL_ESCAPE,
+ Command.UNFOCUS_BACKGROUND_SHELL,
+ Command.UNFOCUS_BACKGROUND_SHELL_LIST,
+ Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
Command.FOCUS_SHELL_INPUT,
Command.UNFOCUS_SHELL_INPUT,
Command.CLEAR_SCREEN,
@@ -470,6 +496,14 @@ export const commandDescriptions: Readonly> = {
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
[Command.SHOW_MORE_LINES]:
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
+ [Command.BACKGROUND_SHELL_SELECT]: 'Enter',
+ [Command.BACKGROUND_SHELL_ESCAPE]: 'Esc',
+ [Command.TOGGLE_BACKGROUND_SHELL]: 'Ctrl+B',
+ [Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Ctrl+L',
+ [Command.KILL_BACKGROUND_SHELL]: 'Ctrl+K',
+ [Command.UNFOCUS_BACKGROUND_SHELL]: 'Shift+Tab',
+ [Command.UNFOCUS_BACKGROUND_SHELL_LIST]: 'Tab',
+ [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: 'Tab',
[Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.',
[Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.',
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts
index 3c32549ef2..b72a239328 100644
--- a/packages/cli/src/services/BuiltinCommandLoader.ts
+++ b/packages/cli/src/services/BuiltinCommandLoader.ts
@@ -47,6 +47,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { skillsCommand } from '../ui/commands/skillsCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
+import { shellsCommand } from '../ui/commands/shellsCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
@@ -164,6 +165,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
: [skillsCommand]
: []),
settingsCommand,
+ shellsCommand,
vimCommand,
setupGithubCommand,
terminalSetupCommand,
diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx
index 18744ee2b4..a9e997a859 100644
--- a/packages/cli/src/test-utils/render.tsx
+++ b/packages/cli/src/test-utils/render.tsx
@@ -157,6 +157,9 @@ const baseMockUiState = {
terminalHeight: 40,
currentModel: 'gemini-pro',
terminalBackgroundColor: undefined,
+ activePtyId: undefined,
+ backgroundShells: new Map(),
+ backgroundShellHeight: 0,
};
export const mockAppState: AppState = {
@@ -201,7 +204,11 @@ const mockUIActions: UIActions = {
handleApiKeyCancel: vi.fn(),
setBannerVisible: vi.fn(),
setEmbeddedShellFocused: vi.fn(),
+ dismissBackgroundShell: vi.fn(),
+ setActiveBackgroundShellPid: vi.fn(),
+ setIsBackgroundShellListOpen: vi.fn(),
setAuthContext: vi.fn(),
+ handleWarning: vi.fn(),
handleRestart: vi.fn(),
handleNewAgentsSelect: vi.fn(),
};
diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx
index 81df8b9574..bff645b6f7 100644
--- a/packages/cli/src/ui/App.test.tsx
+++ b/packages/cli/src/ui/App.test.tsx
@@ -88,6 +88,7 @@ describe('App', () => {
defaultText: 'Mock Banner Text',
warningText: '',
},
+ backgroundShells: new Map(),
};
it('should render main content and composer when not quitting', () => {
diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
index 5170b60f62..d897bc91b4 100644
--- a/packages/cli/src/ui/AppContainer.test.tsx
+++ b/packages/cli/src/ui/AppContainer.test.tsx
@@ -269,6 +269,25 @@ describe('AppContainer State Management', () => {
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
const mockedUseHookDisplayState = useHookDisplayState as Mock;
+ const DEFAULT_GEMINI_STREAM_MOCK = {
+ streamingState: 'idle',
+ submitQuery: vi.fn(),
+ initError: null,
+ pendingHistoryItems: [],
+ thought: null,
+ cancelOngoingRequest: vi.fn(),
+ handleApprovalModeChange: vi.fn(),
+ activePtyId: null,
+ loopDetectionConfirmationRequest: null,
+ backgroundShellCount: 0,
+ isBackgroundShellVisible: false,
+ toggleBackgroundShell: vi.fn(),
+ backgroundCurrentShell: vi.fn(),
+ backgroundShells: new Map(),
+ registerBackgroundShell: vi.fn(),
+ dismissBackgroundShell: vi.fn(),
+ };
+
beforeEach(() => {
vi.clearAllMocks();
@@ -334,14 +353,7 @@ describe('AppContainer State Management', () => {
handleNewMessage: vi.fn(),
clearConsoleMessages: vi.fn(),
});
- mockedUseGeminiStream.mockReturnValue({
- streamingState: 'idle',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
- thought: null,
- cancelOngoingRequest: vi.fn(),
- });
+ mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
mockedUseVim.mockReturnValue({ handleInput: vi.fn() });
mockedUseFolderTrust.mockReturnValue({
isFolderTrustDialogOpen: false,
@@ -1193,12 +1205,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state as Active
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: 'Some thought' },
- cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1234,12 +1243,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: 'Some thought' },
- cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1306,12 +1312,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state and thought
const thoughtSubject = 'Processing request';
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: thoughtSubject },
- cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1347,14 +1350,7 @@ describe('AppContainer State Management', () => {
} as unknown as LoadedSettings;
// Mock the streaming state as Idle with no thought
- mockedUseGeminiStream.mockReturnValue({
- streamingState: 'idle',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
- thought: null,
- cancelOngoingRequest: vi.fn(),
- });
+ mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
// Act: Render the container
const { unmount } = renderAppContainer({
@@ -1391,12 +1387,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state and thought
const thoughtSubject = 'Confirm tool execution';
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'waiting_for_confirmation',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: thoughtSubject },
- cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1448,16 +1441,11 @@ describe('AppContainer State Management', () => {
// Mock an active shell pty but not focused
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
- cancelOngoingRequest: vi.fn(),
pendingToolCalls: [],
- handleApprovalModeChange: vi.fn(),
activePtyId: 'pty-1',
- loopDetectionConfirmationRequest: null,
lastOutputTime: startTime + 100, // Trigger aggressive delay
retryStatus: null,
});
@@ -1512,12 +1500,9 @@ describe('AppContainer State Management', () => {
// Mock an active shell pty with redirection active
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
- cancelOngoingRequest: vi.fn(),
pendingToolCalls: [
{
request: {
@@ -1527,9 +1512,7 @@ describe('AppContainer State Management', () => {
status: 'executing',
} as unknown as TrackedToolCall,
],
- handleApprovalModeChange: vi.fn(),
activePtyId: 'pty-1',
- loopDetectionConfirmationRequest: null,
lastOutputTime: startTime,
retryStatus: null,
});
@@ -1587,16 +1570,11 @@ describe('AppContainer State Management', () => {
// Mock an active shell pty with NO output since operation started (silent)
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
- cancelOngoingRequest: vi.fn(),
pendingToolCalls: [],
- handleApprovalModeChange: vi.fn(),
activePtyId: 'pty-1',
- loopDetectionConfirmationRequest: null,
lastOutputTime: startTime, // lastOutputTime <= operationStartTime
retryStatus: null,
});
@@ -1643,12 +1621,9 @@ describe('AppContainer State Management', () => {
// Mock an active shell pty but not focused
let lastOutputTime = startTime + 1000;
mockedUseGeminiStream.mockImplementation(() => ({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
- cancelOngoingRequest: vi.fn(),
activePtyId: 'pty-1',
lastOutputTime,
}));
@@ -1669,12 +1644,9 @@ describe('AppContainer State Management', () => {
// Update lastOutputTime to simulate new output
lastOutputTime = startTime + 21000;
mockedUseGeminiStream.mockImplementation(() => ({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
- cancelOngoingRequest: vi.fn(),
activePtyId: 'pty-1',
lastOutputTime,
}));
@@ -1734,12 +1706,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state and thought with a short subject
const shortTitle = 'Short';
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: shortTitle },
- cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1778,12 +1747,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state and thought
const title = 'Test Title';
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
thought: { subject: title },
- cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1821,12 +1787,8 @@ describe('AppContainer State Management', () => {
// Mock the streaming state
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
- thought: null,
- cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1928,12 +1890,7 @@ describe('AppContainer State Management', () => {
mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen
mockedUseGeminiStream.mockReturnValue({
- streamingState: 'idle',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
- thought: null,
- cancelOngoingRequest: vi.fn(),
+ ...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: 'some-id',
});
@@ -2005,11 +1962,7 @@ describe('AppContainer State Management', () => {
// Mock request cancellation
mockCancelOngoingRequest = vi.fn();
mockedUseGeminiStream.mockReturnValue({
- streamingState: 'idle',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
- thought: null,
+ ...DEFAULT_GEMINI_STREAM_MOCK,
cancelOngoingRequest: mockCancelOngoingRequest,
});
@@ -2030,11 +1983,8 @@ describe('AppContainer State Management', () => {
describe('CTRL+C', () => {
it('should cancel ongoing request on first press', async () => {
mockedUseGeminiStream.mockReturnValue({
+ ...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
- thought: null,
cancelOngoingRequest: mockCancelOngoingRequest,
});
await setupKeypressTest();
@@ -2574,12 +2524,7 @@ describe('AppContainer State Management', () => {
});
mockedUseGeminiStream.mockReturnValue({
- streamingState: 'idle',
- submitQuery: vi.fn(),
- initError: null,
- pendingHistoryItems: [],
- thought: null,
- cancelOngoingRequest: vi.fn(),
+ ...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: 'some-pty-id', // Make sure activePtyId is set
});
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index c792094969..aeeff89289 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -99,6 +99,7 @@ import { computeTerminalTitle } from '../utils/windowTitle.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
+import { type BackgroundShell } from './hooks/shellCommandProcessor.js';
import { useVim } from './hooks/vim.js';
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
import { type InitializationResult } from '../core/initializer.js';
@@ -138,6 +139,7 @@ import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js'
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useBanner } from './hooks/useBanner.js';
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
+import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
import {
WARNING_PROMPT_DURATION_MS,
QUEUE_ERROR_DISPLAY_DURATION_MS,
@@ -259,6 +261,10 @@ export const AppContainer = (props: AppContainerProps) => {
);
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false);
+ const toggleBackgroundShellRef = useRef<() => void>(() => {});
+ const isBackgroundShellVisibleRef = useRef(false);
+ const backgroundShellsRef = useRef