feat(core): agnostic background task UI with CompletionBehavior (#22740)

Co-authored-by: mkorwel <matt.korwel@gmail.com>
This commit is contained in:
Adam Weidman
2026-03-28 17:27:51 -04:00
committed by GitHub
parent 07ab16dbbe
commit 3eebb75b7a
54 changed files with 1467 additions and 875 deletions
+36 -36
View File
@@ -36,27 +36,27 @@ describe('shellReducer', () => {
it('should handle SET_VISIBILITY', () => {
const action: ShellAction = { type: 'SET_VISIBILITY', visible: true };
const state = shellReducer(initialState, action);
expect(state.isBackgroundShellVisible).toBe(true);
expect(state.isBackgroundTaskVisible).toBe(true);
});
it('should handle TOGGLE_VISIBILITY', () => {
const action: ShellAction = { type: 'TOGGLE_VISIBILITY' };
let state = shellReducer(initialState, action);
expect(state.isBackgroundShellVisible).toBe(true);
expect(state.isBackgroundTaskVisible).toBe(true);
state = shellReducer(state, action);
expect(state.isBackgroundShellVisible).toBe(false);
expect(state.isBackgroundTaskVisible).toBe(false);
});
it('should handle REGISTER_SHELL', () => {
it('should handle REGISTER_TASK', () => {
const action: ShellAction = {
type: 'REGISTER_SHELL',
type: 'REGISTER_TASK',
pid: 1001,
command: 'ls',
initialOutput: 'init',
};
const state = shellReducer(initialState, action);
expect(state.backgroundShells.has(1001)).toBe(true);
expect(state.backgroundShells.get(1001)).toEqual({
expect(state.backgroundTasks.has(1001)).toBe(true);
expect(state.backgroundTasks.get(1001)).toEqual({
pid: 1001,
command: 'ls',
output: 'init',
@@ -66,9 +66,9 @@ describe('shellReducer', () => {
});
});
it('should not REGISTER_SHELL if PID already exists', () => {
it('should not REGISTER_TASK if PID already exists', () => {
const action: ShellAction = {
type: 'REGISTER_SHELL',
type: 'REGISTER_TASK',
pid: 1001,
command: 'ls',
initialOutput: 'init',
@@ -76,35 +76,35 @@ describe('shellReducer', () => {
const state = shellReducer(initialState, action);
const state2 = shellReducer(state, { ...action, command: 'other' });
expect(state2).toBe(state);
expect(state2.backgroundShells.get(1001)?.command).toBe('ls');
expect(state2.backgroundTasks.get(1001)?.command).toBe('ls');
});
it('should handle UPDATE_SHELL', () => {
it('should handle UPDATE_TASK', () => {
const registeredState = shellReducer(initialState, {
type: 'REGISTER_SHELL',
type: 'REGISTER_TASK',
pid: 1001,
command: 'ls',
initialOutput: 'init',
});
const action: ShellAction = {
type: 'UPDATE_SHELL',
type: 'UPDATE_TASK',
pid: 1001,
update: { status: 'exited', exitCode: 0 },
};
const state = shellReducer(registeredState, action);
const shell = state.backgroundShells.get(1001);
const shell = state.backgroundTasks.get(1001);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(0);
// Map should be new
expect(state.backgroundShells).not.toBe(registeredState.backgroundShells);
expect(state.backgroundTasks).not.toBe(registeredState.backgroundTasks);
});
it('should handle APPEND_SHELL_OUTPUT when visible (triggers re-render)', () => {
it('should handle APPEND_TASK_OUTPUT when visible (triggers re-render)', () => {
const visibleState: ShellState = {
...initialState,
isBackgroundShellVisible: true,
backgroundShells: new Map([
isBackgroundTaskVisible: true,
backgroundTasks: new Map([
[
1001,
{
@@ -120,21 +120,21 @@ describe('shellReducer', () => {
};
const action: ShellAction = {
type: 'APPEND_SHELL_OUTPUT',
type: 'APPEND_TASK_OUTPUT',
pid: 1001,
chunk: ' + more',
};
const state = shellReducer(visibleState, action);
expect(state.backgroundShells.get(1001)?.output).toBe('init + more');
expect(state.backgroundTasks.get(1001)?.output).toBe('init + more');
// Drawer is visible, so we expect a NEW map object to trigger React re-render
expect(state.backgroundShells).not.toBe(visibleState.backgroundShells);
expect(state.backgroundTasks).not.toBe(visibleState.backgroundTasks);
});
it('should handle APPEND_SHELL_OUTPUT when hidden (no re-render optimization)', () => {
it('should handle APPEND_TASK_OUTPUT when hidden (no re-render optimization)', () => {
const hiddenState: ShellState = {
...initialState,
isBackgroundShellVisible: false,
backgroundShells: new Map([
isBackgroundTaskVisible: false,
backgroundTasks: new Map([
[
1001,
{
@@ -150,27 +150,27 @@ describe('shellReducer', () => {
};
const action: ShellAction = {
type: 'APPEND_SHELL_OUTPUT',
type: 'APPEND_TASK_OUTPUT',
pid: 1001,
chunk: ' + more',
};
const state = shellReducer(hiddenState, action);
expect(state.backgroundShells.get(1001)?.output).toBe('init + more');
expect(state.backgroundTasks.get(1001)?.output).toBe('init + more');
// Drawer is hidden, so we expect the SAME map object (mutation optimization)
expect(state.backgroundShells).toBe(hiddenState.backgroundShells);
expect(state.backgroundTasks).toBe(hiddenState.backgroundTasks);
});
it('should handle SYNC_BACKGROUND_SHELLS', () => {
const action: ShellAction = { type: 'SYNC_BACKGROUND_SHELLS' };
it('should handle SYNC_BACKGROUND_TASKS', () => {
const action: ShellAction = { type: 'SYNC_BACKGROUND_TASKS' };
const state = shellReducer(initialState, action);
expect(state.backgroundShells).not.toBe(initialState.backgroundShells);
expect(state.backgroundTasks).not.toBe(initialState.backgroundTasks);
});
it('should handle DISMISS_SHELL', () => {
it('should handle DISMISS_TASK', () => {
const registeredState: ShellState = {
...initialState,
isBackgroundShellVisible: true,
backgroundShells: new Map([
isBackgroundTaskVisible: true,
backgroundTasks: new Map([
[
1001,
{
@@ -185,9 +185,9 @@ describe('shellReducer', () => {
]),
};
const action: ShellAction = { type: 'DISMISS_SHELL', pid: 1001 };
const action: ShellAction = { type: 'DISMISS_TASK', pid: 1001 };
const state = shellReducer(registeredState, action);
expect(state.backgroundShells.has(1001)).toBe(false);
expect(state.isBackgroundShellVisible).toBe(false); // Auto-hide if last shell
expect(state.backgroundTasks.has(1001)).toBe(false);
expect(state.isBackgroundTaskVisible).toBe(false); // Auto-hide if last shell
});
});
+47 -44
View File
@@ -4,9 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { AnsiOutput } from '@google/gemini-cli-core';
import type { AnsiOutput, CompletionBehavior } from '@google/gemini-cli-core';
export interface BackgroundShell {
export interface BackgroundTask {
pid: number;
command: string;
output: string | AnsiOutput;
@@ -14,13 +14,14 @@ export interface BackgroundShell {
binaryBytesReceived: number;
status: 'running' | 'exited';
exitCode?: number;
completionBehavior?: CompletionBehavior;
}
export interface ShellState {
activeShellPtyId: number | null;
lastShellOutputTime: number;
backgroundShells: Map<number, BackgroundShell>;
isBackgroundShellVisible: boolean;
backgroundTasks: Map<number, BackgroundTask>;
isBackgroundTaskVisible: boolean;
}
export type ShellAction =
@@ -29,21 +30,22 @@ export type ShellAction =
| { type: 'SET_VISIBILITY'; visible: boolean }
| { type: 'TOGGLE_VISIBILITY' }
| {
type: 'REGISTER_SHELL';
type: 'REGISTER_TASK';
pid: number;
command: string;
initialOutput: string | AnsiOutput;
completionBehavior?: CompletionBehavior;
}
| { type: 'UPDATE_SHELL'; pid: number; update: Partial<BackgroundShell> }
| { type: 'APPEND_SHELL_OUTPUT'; pid: number; chunk: string | AnsiOutput }
| { type: 'SYNC_BACKGROUND_SHELLS' }
| { type: 'DISMISS_SHELL'; pid: number };
| { type: 'UPDATE_TASK'; pid: number; update: Partial<BackgroundTask> }
| { type: 'APPEND_TASK_OUTPUT'; pid: number; chunk: string | AnsiOutput }
| { type: 'SYNC_BACKGROUND_TASKS' }
| { type: 'DISMISS_TASK'; pid: number };
export const initialState: ShellState = {
activeShellPtyId: null,
lastShellOutputTime: 0,
backgroundShells: new Map(),
isBackgroundShellVisible: false,
backgroundTasks: new Map(),
isBackgroundTaskVisible: false,
};
export function shellReducer(
@@ -56,75 +58,76 @@ export function shellReducer(
case 'SET_OUTPUT_TIME':
return { ...state, lastShellOutputTime: action.time };
case 'SET_VISIBILITY':
return { ...state, isBackgroundShellVisible: action.visible };
return { ...state, isBackgroundTaskVisible: action.visible };
case 'TOGGLE_VISIBILITY':
return {
...state,
isBackgroundShellVisible: !state.isBackgroundShellVisible,
isBackgroundTaskVisible: !state.isBackgroundTaskVisible,
};
case 'REGISTER_SHELL': {
if (state.backgroundShells.has(action.pid)) return state;
const nextShells = new Map(state.backgroundShells);
nextShells.set(action.pid, {
case 'REGISTER_TASK': {
if (state.backgroundTasks.has(action.pid)) return state;
const nextTasks = new Map(state.backgroundTasks);
nextTasks.set(action.pid, {
pid: action.pid,
command: action.command,
output: action.initialOutput,
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
completionBehavior: action.completionBehavior,
});
return { ...state, backgroundShells: nextShells };
return { ...state, backgroundTasks: nextTasks };
}
case 'UPDATE_SHELL': {
const shell = state.backgroundShells.get(action.pid);
if (!shell) return state;
const nextShells = new Map(state.backgroundShells);
const updatedShell = { ...shell, ...action.update };
case 'UPDATE_TASK': {
const task = state.backgroundTasks.get(action.pid);
if (!task) return state;
const nextTasks = new Map(state.backgroundTasks);
const updatedTask = { ...task, ...action.update };
// Maintain insertion order, move to end if status changed to exited
if (action.update.status === 'exited') {
nextShells.delete(action.pid);
nextTasks.delete(action.pid);
}
nextShells.set(action.pid, updatedShell);
return { ...state, backgroundShells: nextShells };
nextTasks.set(action.pid, updatedTask);
return { ...state, backgroundTasks: nextTasks };
}
case 'APPEND_SHELL_OUTPUT': {
const shell = state.backgroundShells.get(action.pid);
if (!shell) return state;
// Note: we mutate the shell object in the map for background updates
case 'APPEND_TASK_OUTPUT': {
const task = state.backgroundTasks.get(action.pid);
if (!task) return state;
// Note: we mutate the task object in the map for background updates
// to avoid re-rendering if the drawer is not visible.
// This is an intentional performance optimization for the CLI.
let newOutput = shell.output;
let newOutput = task.output;
if (typeof action.chunk === 'string') {
newOutput =
typeof shell.output === 'string'
? shell.output + action.chunk
typeof task.output === 'string'
? task.output + action.chunk
: action.chunk;
} else {
newOutput = action.chunk;
}
shell.output = newOutput;
task.output = newOutput;
const nextState = { ...state, lastShellOutputTime: Date.now() };
if (state.isBackgroundShellVisible) {
if (state.isBackgroundTaskVisible) {
return {
...nextState,
backgroundShells: new Map(state.backgroundShells),
backgroundTasks: new Map(state.backgroundTasks),
};
}
return nextState;
}
case 'SYNC_BACKGROUND_SHELLS': {
return { ...state, backgroundShells: new Map(state.backgroundShells) };
case 'SYNC_BACKGROUND_TASKS': {
return { ...state, backgroundTasks: new Map(state.backgroundTasks) };
}
case 'DISMISS_SHELL': {
const nextShells = new Map(state.backgroundShells);
nextShells.delete(action.pid);
case 'DISMISS_TASK': {
const nextTasks = new Map(state.backgroundTasks);
nextTasks.delete(action.pid);
return {
...state,
backgroundShells: nextShells,
isBackgroundShellVisible:
nextShells.size === 0 ? false : state.isBackgroundShellVisible,
backgroundTasks: nextTasks,
isBackgroundTaskVisible:
nextTasks.size === 0 ? false : state.isBackgroundTaskVisible,
};
}
default:
@@ -213,7 +213,7 @@ describe('useSlashCommandProcessor', () => {
toggleDebugProfiler: vi.fn(),
dispatchExtensionStateUpdate: vi.fn(),
addConfirmUpdateExtensionRequest: vi.fn(),
toggleBackgroundShell: vi.fn(),
toggleBackgroundTasks: vi.fn(),
toggleShortcutsHelp: vi.fn(),
setText: vi.fn(),
},
@@ -84,7 +84,7 @@ interface SlashCommandProcessorActions {
toggleDebugProfiler: () => void;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
toggleBackgroundShell: () => void;
toggleBackgroundTasks: () => void;
toggleShortcutsHelp: () => void;
setText: (text: string) => void;
}
@@ -242,7 +242,7 @@ export const useSlashCommandProcessor = (
actions.addConfirmUpdateExtensionRequest,
setConfirmationRequest,
removeComponent: () => setCustomDialog(null),
toggleBackgroundShell: actions.toggleBackgroundShell,
toggleBackgroundTasks: actions.toggleBackgroundTasks,
toggleShortcutsHelp: actions.toggleShortcutsHelp,
},
session: {
@@ -1,191 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import {
useBackgroundShellManager,
type BackgroundShellManagerProps,
} from './useBackgroundShellManager.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { type BackgroundShell } from './shellReducer.js';
describe('useBackgroundShellManager', () => {
const setEmbeddedShellFocused = vi.fn();
const terminalHeight = 30;
beforeEach(() => {
vi.clearAllMocks();
});
const renderHook = async (props: BackgroundShellManagerProps) => {
let hookResult: ReturnType<typeof useBackgroundShellManager>;
function TestComponent({ p }: { p: BackgroundShellManagerProps }) {
hookResult = useBackgroundShellManager(p);
return null;
}
const { rerender } = await render(<TestComponent p={props} />);
return {
result: {
get current() {
return hookResult;
},
},
rerender: (newProps: BackgroundShellManagerProps) =>
rerender(<TestComponent p={newProps} />),
};
};
it('should initialize with correct default values', async () => {
const backgroundShells = new Map<number, BackgroundShell>();
const { result } = await renderHook({
backgroundShells,
backgroundShellCount: 0,
isBackgroundShellVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.isBackgroundShellListOpen).toBe(false);
expect(result.current.activeBackgroundShellPid).toBe(null);
expect(result.current.backgroundShellHeight).toBe(0);
});
it('should auto-select the first background shell when added', async () => {
const backgroundShells = new Map<number, BackgroundShell>();
const { result, rerender } = await renderHook({
backgroundShells,
backgroundShellCount: 0,
isBackgroundShellVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
const newShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
rerender({
backgroundShells: newShells,
backgroundShellCount: 1,
isBackgroundShellVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundShellPid).toBe(123);
});
it('should reset state when all shells are removed', async () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
const { result, rerender } = await renderHook({
backgroundShells,
backgroundShellCount: 1,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
act(() => {
result.current.setIsBackgroundShellListOpen(true);
});
expect(result.current.isBackgroundShellListOpen).toBe(true);
rerender({
backgroundShells: new Map(),
backgroundShellCount: 0,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundShellPid).toBe(null);
expect(result.current.isBackgroundShellListOpen).toBe(false);
});
it('should unfocus embedded shell when no shells are active', async () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
await renderHook({
backgroundShells,
backgroundShellCount: 1,
isBackgroundShellVisible: false, // Background shell not visible
activePtyId: null, // No foreground shell
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(setEmbeddedShellFocused).toHaveBeenCalledWith(false);
});
it('should calculate backgroundShellHeight correctly when visible', async () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
const { result } = await renderHook({
backgroundShells,
backgroundShellCount: 1,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight: 100,
});
// 100 * 0.3 = 30
expect(result.current.backgroundShellHeight).toBe(30);
});
it('should maintain current active shell if it still exists', async () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
[456, {} as BackgroundShell],
]);
const { result, rerender } = await renderHook({
backgroundShells,
backgroundShellCount: 2,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
act(() => {
result.current.setActiveBackgroundShellPid(456);
});
expect(result.current.activeBackgroundShellPid).toBe(456);
// Remove the OTHER shell
const updatedShells = new Map<number, BackgroundShell>([
[456, {} as BackgroundShell],
]);
rerender({
backgroundShells: updatedShells,
backgroundShellCount: 1,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundShellPid).toBe(456);
});
});
@@ -1,91 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo } from 'react';
import { type BackgroundShell } from './shellCommandProcessor.js';
export interface BackgroundShellManagerProps {
backgroundShells: Map<number, BackgroundShell>;
backgroundShellCount: number;
isBackgroundShellVisible: boolean;
activePtyId: number | null | undefined;
embeddedShellFocused: boolean;
setEmbeddedShellFocused: (focused: boolean) => void;
terminalHeight: number;
}
export function useBackgroundShellManager({
backgroundShells,
backgroundShellCount,
isBackgroundShellVisible,
activePtyId,
embeddedShellFocused,
setEmbeddedShellFocused,
terminalHeight,
}: BackgroundShellManagerProps) {
const [isBackgroundShellListOpen, setIsBackgroundShellListOpen] =
useState(false);
const [activeBackgroundShellPid, setActiveBackgroundShellPid] = useState<
number | null
>(null);
useEffect(() => {
if (backgroundShells.size === 0) {
if (activeBackgroundShellPid !== null) {
setActiveBackgroundShellPid(null);
}
if (isBackgroundShellListOpen) {
setIsBackgroundShellListOpen(false);
}
} else if (
activeBackgroundShellPid === null ||
!backgroundShells.has(activeBackgroundShellPid)
) {
// If active shell is closed or none selected, select the first one (last added usually, or just first in iteration)
setActiveBackgroundShellPid(backgroundShells.keys().next().value ?? null);
}
}, [
backgroundShells,
activeBackgroundShellPid,
backgroundShellCount,
isBackgroundShellListOpen,
]);
useEffect(() => {
if (embeddedShellFocused) {
const hasActiveForegroundShell = !!activePtyId;
const hasVisibleBackgroundShell =
isBackgroundShellVisible && backgroundShells.size > 0;
if (!hasActiveForegroundShell && !hasVisibleBackgroundShell) {
setEmbeddedShellFocused(false);
}
}
}, [
isBackgroundShellVisible,
backgroundShells,
embeddedShellFocused,
backgroundShellCount,
activePtyId,
setEmbeddedShellFocused,
]);
const backgroundShellHeight = useMemo(
() =>
isBackgroundShellVisible && backgroundShells.size > 0
? Math.max(Math.floor(terminalHeight * 0.3), 5)
: 0,
[isBackgroundShellVisible, backgroundShells.size, terminalHeight],
);
return {
isBackgroundShellListOpen,
setIsBackgroundShellListOpen,
activeBackgroundShellPid,
setActiveBackgroundShellPid,
backgroundShellHeight,
};
}
@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import {
useBackgroundTaskManager,
type BackgroundTaskManagerProps,
} from './useBackgroundTaskManager.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { type BackgroundTask } from './shellReducer.js';
describe('useBackgroundTaskManager', () => {
const setEmbeddedShellFocused = vi.fn();
const terminalHeight = 30;
beforeEach(() => {
vi.clearAllMocks();
});
const renderHook = async (props: BackgroundTaskManagerProps) => {
let hookResult: ReturnType<typeof useBackgroundTaskManager>;
function TestComponent({ p }: { p: BackgroundTaskManagerProps }) {
hookResult = useBackgroundTaskManager(p);
return null;
}
const { rerender } = await render(<TestComponent p={props} />);
return {
result: {
get current() {
return hookResult;
},
},
rerender: (newProps: BackgroundTaskManagerProps) =>
rerender(<TestComponent p={newProps} />),
};
};
it('should initialize with correct default values', async () => {
const backgroundTasks = new Map<number, BackgroundTask>();
const { result } = await renderHook({
backgroundTasks,
backgroundTaskCount: 0,
isBackgroundTaskVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.isBackgroundTaskListOpen).toBe(false);
expect(result.current.activeBackgroundTaskPid).toBe(null);
expect(result.current.backgroundTaskHeight).toBe(0);
});
it('should auto-select the first background shell when added', async () => {
const backgroundTasks = new Map<number, BackgroundTask>();
const { result, rerender } = await renderHook({
backgroundTasks,
backgroundTaskCount: 0,
isBackgroundTaskVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
const newShells = new Map<number, BackgroundTask>([
[123, {} as BackgroundTask],
]);
rerender({
backgroundTasks: newShells,
backgroundTaskCount: 1,
isBackgroundTaskVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundTaskPid).toBe(123);
});
it('should reset state when all shells are removed', async () => {
const backgroundTasks = new Map<number, BackgroundTask>([
[123, {} as BackgroundTask],
]);
const { result, rerender } = await renderHook({
backgroundTasks,
backgroundTaskCount: 1,
isBackgroundTaskVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
act(() => {
result.current.setIsBackgroundTaskListOpen(true);
});
expect(result.current.isBackgroundTaskListOpen).toBe(true);
rerender({
backgroundTasks: new Map(),
backgroundTaskCount: 0,
isBackgroundTaskVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundTaskPid).toBe(null);
expect(result.current.isBackgroundTaskListOpen).toBe(false);
});
it('should unfocus embedded shell when no shells are active', async () => {
const backgroundTasks = new Map<number, BackgroundTask>([
[123, {} as BackgroundTask],
]);
await renderHook({
backgroundTasks,
backgroundTaskCount: 1,
isBackgroundTaskVisible: false, // Background shell not visible
activePtyId: null, // No foreground shell
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(setEmbeddedShellFocused).toHaveBeenCalledWith(false);
});
it('should calculate backgroundTaskHeight correctly when visible', async () => {
const backgroundTasks = new Map<number, BackgroundTask>([
[123, {} as BackgroundTask],
]);
const { result } = await renderHook({
backgroundTasks,
backgroundTaskCount: 1,
isBackgroundTaskVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight: 100,
});
// 100 * 0.3 = 30
expect(result.current.backgroundTaskHeight).toBe(30);
});
it('should maintain current active shell if it still exists', async () => {
const backgroundTasks = new Map<number, BackgroundTask>([
[123, {} as BackgroundTask],
[456, {} as BackgroundTask],
]);
const { result, rerender } = await renderHook({
backgroundTasks,
backgroundTaskCount: 2,
isBackgroundTaskVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
act(() => {
result.current.setActiveBackgroundTaskPid(456);
});
expect(result.current.activeBackgroundTaskPid).toBe(456);
// Remove the OTHER shell
const updatedShells = new Map<number, BackgroundTask>([
[456, {} as BackgroundTask],
]);
rerender({
backgroundTasks: updatedShells,
backgroundTaskCount: 1,
isBackgroundTaskVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundTaskPid).toBe(456);
});
});
@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo } from 'react';
import { type BackgroundTask } from './useExecutionLifecycle.js';
export interface BackgroundTaskManagerProps {
backgroundTasks: Map<number, BackgroundTask>;
backgroundTaskCount: number;
isBackgroundTaskVisible: boolean;
activePtyId: number | null | undefined;
embeddedShellFocused: boolean;
setEmbeddedShellFocused: (focused: boolean) => void;
terminalHeight: number;
}
export function useBackgroundTaskManager({
backgroundTasks,
backgroundTaskCount,
isBackgroundTaskVisible,
activePtyId,
embeddedShellFocused,
setEmbeddedShellFocused,
terminalHeight,
}: BackgroundTaskManagerProps) {
const [isBackgroundTaskListOpen, setIsBackgroundTaskListOpen] =
useState(false);
const [activeBackgroundTaskPid, setActiveBackgroundTaskPid] = useState<
number | null
>(null);
useEffect(() => {
if (backgroundTasks.size === 0) {
if (activeBackgroundTaskPid !== null) {
setActiveBackgroundTaskPid(null);
}
if (isBackgroundTaskListOpen) {
setIsBackgroundTaskListOpen(false);
}
} else if (
activeBackgroundTaskPid === null ||
!backgroundTasks.has(activeBackgroundTaskPid)
) {
// If active shell is closed or none selected, select the first one (last added usually, or just first in iteration)
setActiveBackgroundTaskPid(backgroundTasks.keys().next().value ?? null);
}
}, [
backgroundTasks,
activeBackgroundTaskPid,
backgroundTaskCount,
isBackgroundTaskListOpen,
]);
useEffect(() => {
if (embeddedShellFocused) {
const hasActiveForegroundShell = !!activePtyId;
const hasVisibleBackgroundTask =
isBackgroundTaskVisible && backgroundTasks.size > 0;
if (!hasActiveForegroundShell && !hasVisibleBackgroundTask) {
setEmbeddedShellFocused(false);
}
}
}, [
isBackgroundTaskVisible,
backgroundTasks,
embeddedShellFocused,
backgroundTaskCount,
activePtyId,
setEmbeddedShellFocused,
]);
const backgroundTaskHeight = useMemo(
() =>
isBackgroundTaskVisible && backgroundTasks.size > 0
? Math.max(Math.floor(terminalHeight * 0.3), 5)
: 0,
[isBackgroundTaskVisible, backgroundTasks.size, terminalHeight],
);
return {
isBackgroundTaskListOpen,
setIsBackgroundTaskListOpen,
activeBackgroundTaskPid,
setActiveBackgroundTaskPid,
backgroundTaskHeight,
};
}
@@ -49,7 +49,7 @@ export const useComposerStatus = () => {
);
const showLoadingIndicator =
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
(!uiState.embeddedShellFocused || uiState.isBackgroundTaskVisible) &&
uiState.streamingState === StreamingState.Responding &&
!hasPendingActionRequired;
@@ -35,6 +35,23 @@ const mockShellOnExit = vi.hoisted(() =>
) => () => void
>(() => vi.fn()),
);
const mockLifecycleSubscribe = vi.hoisted(() =>
vi.fn<
(pid: number, listener: (event: ShellOutputEvent) => void) => () => void
>(() => vi.fn()),
);
const mockLifecycleOnExit = vi.hoisted(() =>
vi.fn<
(
pid: number,
callback: (exitCode: number, signal?: number) => void,
) => () => void
>(() => vi.fn()),
);
const mockLifecycleKill = vi.hoisted(() => vi.fn());
const mockLifecycleBackground = vi.hoisted(() => vi.fn());
const mockLifecycleOnBackground = vi.hoisted(() => vi.fn());
const mockLifecycleOffBackground = vi.hoisted(() => vi.fn());
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -48,6 +65,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
subscribe: mockShellSubscribe,
onExit: mockShellOnExit,
},
ExecutionLifecycleService: {
subscribe: mockLifecycleSubscribe,
onExit: mockLifecycleOnExit,
kill: mockLifecycleKill,
background: mockLifecycleBackground,
onBackground: mockLifecycleOnBackground,
offBackground: mockLifecycleOffBackground,
},
isBinary: mockIsBinary,
};
});
@@ -68,9 +93,9 @@ vi.mock('node:os', async (importOriginal) => {
vi.mock('node:crypto');
import {
useShellCommandProcessor,
useExecutionLifecycle,
OUTPUT_UPDATE_INTERVAL_MS,
} from './shellCommandProcessor.js';
} from './useExecutionLifecycle.js';
import {
type Config,
type GeminiClient,
@@ -83,7 +108,7 @@ import * as os from 'node:os';
import * as path from 'node:path';
import * as crypto from 'node:crypto';
describe('useShellCommandProcessor', () => {
describe('useExecutionLifecycle', () => {
let addItemToHistoryMock: Mock;
let setPendingHistoryItemMock: Mock;
let onExecMock: Mock;
@@ -140,7 +165,7 @@ describe('useShellCommandProcessor', () => {
});
const renderProcessorHook = async () => {
let hookResult: ReturnType<typeof useShellCommandProcessor>;
let hookResult: ReturnType<typeof useExecutionLifecycle>;
let renderCount = 0;
function TestComponent({
isWaitingForConfirmation,
@@ -148,7 +173,7 @@ describe('useShellCommandProcessor', () => {
isWaitingForConfirmation?: boolean;
}) {
renderCount++;
hookResult = useShellCommandProcessor(
hookResult = useExecutionLifecycle(
addItemToHistoryMock,
setPendingHistoryItemMock,
onExecMock,
@@ -772,11 +797,11 @@ describe('useShellCommandProcessor', () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
expect(result.current.backgroundShellCount).toBe(1);
const shell = result.current.backgroundShells.get(1001);
expect(result.current.backgroundTaskCount).toBe(1);
const shell = result.current.backgroundTasks.get(1001);
expect(shell).toEqual(
expect.objectContaining({
pid: 1001,
@@ -784,8 +809,11 @@ describe('useShellCommandProcessor', () => {
output: 'initial',
}),
);
expect(mockShellOnExit).toHaveBeenCalledWith(1001, expect.any(Function));
expect(mockShellSubscribe).toHaveBeenCalledWith(
expect(mockLifecycleOnExit).toHaveBeenCalledWith(
1001,
expect.any(Function),
);
expect(mockLifecycleSubscribe).toHaveBeenCalledWith(
1001,
expect.any(Function),
);
@@ -795,55 +823,55 @@ describe('useShellCommandProcessor', () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
expect(result.current.isBackgroundShellVisible).toBe(false);
expect(result.current.isBackgroundTaskVisible).toBe(false);
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
expect(result.current.isBackgroundTaskVisible).toBe(true);
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
expect(result.current.isBackgroundShellVisible).toBe(false);
expect(result.current.isBackgroundTaskVisible).toBe(false);
});
it('should show info message when toggling background shells if none are active', async () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
expect(addItemToHistoryMock).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: 'No background shells are currently active.',
text: 'No background tasks are currently active.',
}),
expect.any(Number),
);
expect(result.current.isBackgroundShellVisible).toBe(false);
expect(result.current.isBackgroundTaskVisible).toBe(false);
});
it('should dismiss a background shell and remove it from state', async () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
await act(async () => {
await result.current.dismissBackgroundShell(1001);
await result.current.dismissBackgroundTask(1001);
});
expect(mockShellKill).toHaveBeenCalledWith(1001);
expect(result.current.backgroundShellCount).toBe(0);
expect(result.current.backgroundShells.has(1001)).toBe(false);
expect(mockLifecycleKill).toHaveBeenCalledWith(1001);
expect(result.current.backgroundTaskCount).toBe(0);
expect(result.current.backgroundTasks.has(1001)).toBe(false);
});
it('should handle backgrounding the current shell', async () => {
@@ -867,7 +895,7 @@ describe('useShellCommandProcessor', () => {
expect(result.current.activeShellPtyId).toBe(555);
act(() => {
result.current.backgroundCurrentShell();
result.current.backgroundCurrentExecution();
});
expect(mockShellBackground).toHaveBeenCalledWith(555);
@@ -887,19 +915,19 @@ describe('useShellCommandProcessor', () => {
// Wait for promise resolution
await act(async () => await onExecMock.mock.calls[0][0]);
expect(result.current.backgroundShellCount).toBe(1);
expect(result.current.backgroundTaskCount).toBe(1);
expect(result.current.activeShellPtyId).toBeNull();
});
it('should persist background shell on successful exit and mark as exited', async () => {
it('should auto-dismiss background task on successful exit', async () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(888, 'auto-exit', '');
result.current.registerBackgroundTask(888, 'auto-exit', '');
});
// Find the exit callback registered
const exitCallback = mockShellOnExit.mock.calls.find(
const exitCallback = mockLifecycleOnExit.mock.calls.find(
(call) => call[0] === 888,
)?.[1];
expect(exitCallback).toBeDefined();
@@ -910,22 +938,19 @@ describe('useShellCommandProcessor', () => {
});
}
// Should NOT be removed, but updated
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
expect(result.current.backgroundShells.has(888)).toBe(true); // Map has it
const shell = result.current.backgroundShells.get(888);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(0);
// Should be auto-dismissed from the panel
expect(result.current.backgroundTaskCount).toBe(0);
expect(result.current.backgroundTasks.has(888)).toBe(false);
});
it('should persist background shell on failed exit', async () => {
it('should auto-dismiss background task on failed exit', async () => {
const { result } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(999, 'fail-exit', '');
result.current.registerBackgroundTask(999, 'fail-exit', '');
});
const exitCallback = mockShellOnExit.mock.calls.find(
const exitCallback = mockLifecycleOnExit.mock.calls.find(
(call) => call[0] === 999,
)?.[1];
expect(exitCallback).toBeDefined();
@@ -936,34 +961,26 @@ describe('useShellCommandProcessor', () => {
});
}
// Should NOT be removed, but updated
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
const shell = result.current.backgroundShells.get(999);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(1);
// Now dismiss it
await act(async () => {
await result.current.dismissBackgroundShell(999);
});
expect(result.current.backgroundShellCount).toBe(0);
// Should be auto-dismissed from the panel
expect(result.current.backgroundTaskCount).toBe(0);
expect(result.current.backgroundTasks.has(999)).toBe(false);
});
it('should NOT trigger re-render on background shell output when visible', async () => {
const { result, getRenderCount } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
// Show the background shells
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
const initialRenderCount = getRenderCount();
const subscribeCallback = mockShellSubscribe.mock.calls.find(
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
@@ -975,7 +992,7 @@ describe('useShellCommandProcessor', () => {
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundShells.get(1001);
const shell = result.current.backgroundTasks.get(1001);
expect(shell?.output).toBe('initial + updated');
});
@@ -983,13 +1000,13 @@ describe('useShellCommandProcessor', () => {
const { result, getRenderCount } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
// Ensure background shells are hidden (default)
const initialRenderCount = getRenderCount();
const subscribeCallback = mockShellSubscribe.mock.calls.find(
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
@@ -1001,7 +1018,7 @@ describe('useShellCommandProcessor', () => {
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundShells.get(1001);
const shell = result.current.backgroundTasks.get(1001);
expect(shell?.output).toBe('initial + updated');
});
@@ -1009,17 +1026,17 @@ describe('useShellCommandProcessor', () => {
const { result, getRenderCount } = await renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
// Show the background shells
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
const initialRenderCount = getRenderCount();
const subscribeCallback = mockShellSubscribe.mock.calls.find(
const subscribeCallback = mockLifecycleSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
@@ -1031,7 +1048,7 @@ describe('useShellCommandProcessor', () => {
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundShells.get(1001);
const shell = result.current.backgroundTasks.get(1001);
expect(shell?.isBinary).toBe(true);
expect(shell?.binaryBytesReceived).toBe(1024);
});
@@ -1041,12 +1058,12 @@ describe('useShellCommandProcessor', () => {
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
expect(result.current.isBackgroundTaskVisible).toBe(true);
// 2. Simulate model responding (not waiting for confirmation)
act(() => {
@@ -1054,7 +1071,7 @@ describe('useShellCommandProcessor', () => {
});
// Should stay visible
expect(result.current.isBackgroundShellVisible).toBe(true);
expect(result.current.isBackgroundTaskVisible).toBe(true);
});
it('should hide background shell when waiting for confirmation and restore after delay', async () => {
@@ -1062,12 +1079,12 @@ describe('useShellCommandProcessor', () => {
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
expect(result.current.isBackgroundTaskVisible).toBe(true);
// 2. Simulate tool confirmation showing up
act(() => {
@@ -1075,7 +1092,7 @@ describe('useShellCommandProcessor', () => {
});
// Should be hidden
expect(result.current.isBackgroundShellVisible).toBe(false);
expect(result.current.isBackgroundTaskVisible).toBe(false);
// 3. Simulate confirmation accepted (waiting for PTY start)
act(() => {
@@ -1083,11 +1100,11 @@ describe('useShellCommandProcessor', () => {
});
// Should STAY hidden during the 300ms gap
expect(result.current.isBackgroundShellVisible).toBe(false);
expect(result.current.isBackgroundTaskVisible).toBe(false);
// 4. Wait for restore delay
await waitFor(() =>
expect(result.current.isBackgroundShellVisible).toBe(true),
expect(result.current.isBackgroundTaskVisible).toBe(true),
);
});
@@ -1096,12 +1113,12 @@ describe('useShellCommandProcessor', () => {
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
expect(result.current.isBackgroundTaskVisible).toBe(true);
// 2. Start foreground shell
act(() => {
@@ -1112,7 +1129,7 @@ describe('useShellCommandProcessor', () => {
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
// Should be hidden automatically
expect(result.current.isBackgroundShellVisible).toBe(false);
expect(result.current.isBackgroundTaskVisible).toBe(false);
// 3. Complete foreground shell
act(() => {
@@ -1123,7 +1140,7 @@ describe('useShellCommandProcessor', () => {
// Should be restored automatically (after delay)
await waitFor(() =>
expect(result.current.isBackgroundShellVisible).toBe(true),
expect(result.current.isBackgroundTaskVisible).toBe(true),
);
});
@@ -1132,25 +1149,25 @@ describe('useShellCommandProcessor', () => {
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
expect(result.current.isBackgroundTaskVisible).toBe(true);
// 2. Start foreground shell
act(() => {
result.current.handleShellCommand('ls', new AbortController().signal);
});
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
expect(result.current.isBackgroundShellVisible).toBe(false);
expect(result.current.isBackgroundTaskVisible).toBe(false);
// 3. Manually toggle visibility (e.g. user wants to peek)
act(() => {
result.current.toggleBackgroundShell();
result.current.toggleBackgroundTasks();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
expect(result.current.isBackgroundTaskVisible).toBe(true);
// 4. Complete foreground shell
act(() => {
@@ -1161,7 +1178,7 @@ describe('useShellCommandProcessor', () => {
// It should NOT change visibility because manual toggle cleared the auto-restore flag
// After delay it should stay true (as it was manually toggled to true)
await waitFor(() =>
expect(result.current.isBackgroundShellVisible).toBe(true),
expect(result.current.isBackgroundTaskVisible).toBe(true),
);
});
});
@@ -9,10 +9,16 @@ import type {
IndividualToolCallDisplay,
} from '../types.js';
import { useCallback, useReducer, useRef, useEffect } from 'react';
import type { AnsiOutput, Config, GeminiClient } from '@google/gemini-cli-core';
import type {
AnsiOutput,
Config,
GeminiClient,
CompletionBehavior,
} from '@google/gemini-cli-core';
import {
isBinary,
ShellExecutionService,
ExecutionLifecycleService,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { type PartListUnion } from '@google/genai';
@@ -27,9 +33,9 @@ import { themeManager } from '../../ui/themes/theme-manager.js';
import {
shellReducer,
initialState,
type BackgroundShell,
type BackgroundTask,
} from './shellReducer.js';
export { type BackgroundShell };
export { type BackgroundTask };
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
const RESTORE_VISIBILITY_DELAY_MS = 300;
@@ -66,7 +72,7 @@ function addShellCommandToGeminiHistory(
* Hook to process shell commands.
* Orchestrates command execution and updates history and agent context.
*/
export const useShellCommandProcessor = (
export const useExecutionLifecycle = (
addItemToHistory: UseHistoryManagerReturn['addItem'],
setPendingHistoryItem: React.Dispatch<
React.SetStateAction<HistoryItemWithoutId | null>
@@ -113,7 +119,7 @@ export const useShellCommandProcessor = (
m.restoreTimeout = null;
}
if (state.isBackgroundShellVisible && !m.wasVisibleBeforeForeground) {
if (state.isBackgroundTaskVisible && !m.wasVisibleBeforeForeground) {
m.wasVisibleBeforeForeground = true;
dispatch({ type: 'SET_VISIBILITY', visible: false });
}
@@ -135,14 +141,14 @@ export const useShellCommandProcessor = (
}, [
activePtyId,
isWaitingForConfirmation,
state.isBackgroundShellVisible,
state.isBackgroundTaskVisible,
m,
dispatch,
]);
useEffect(
() => () => {
// Unsubscribe from all background shell events on unmount
// Unsubscribe from all background task events on unmount
for (const unsubscribe of m.subscriptions.values()) {
unsubscribe();
}
@@ -151,9 +157,9 @@ export const useShellCommandProcessor = (
[m],
);
const toggleBackgroundShell = useCallback(() => {
if (state.backgroundShells.size > 0) {
const willBeVisible = !state.isBackgroundShellVisible;
const toggleBackgroundTasks = useCallback(() => {
if (state.backgroundTasks.size > 0) {
const willBeVisible = !state.isBackgroundTaskVisible;
dispatch({ type: 'TOGGLE_VISIBILITY' });
const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation;
@@ -167,34 +173,44 @@ export const useShellCommandProcessor = (
}
if (willBeVisible) {
dispatch({ type: 'SYNC_BACKGROUND_SHELLS' });
dispatch({ type: 'SYNC_BACKGROUND_TASKS' });
}
} else {
dispatch({ type: 'SET_VISIBILITY', visible: false });
addItemToHistory(
{
type: 'info',
text: 'No background shells are currently active.',
text: 'No background tasks are currently active.',
},
Date.now(),
);
}
}, [
addItemToHistory,
state.backgroundShells.size,
state.isBackgroundShellVisible,
state.backgroundTasks.size,
state.isBackgroundTaskVisible,
activePtyId,
isWaitingForConfirmation,
m,
dispatch,
]);
const backgroundCurrentShell = useCallback(() => {
const backgroundCurrentExecution = useCallback(() => {
const pidToBackground =
state.activeShellPtyId ?? activeBackgroundExecutionId;
if (pidToBackground) {
ShellExecutionService.background(pidToBackground);
// TRACK THE PID BEFORE TRIGGERING THE BACKGROUND ACTION
// This prevents the onBackground listener from double-registering.
m.backgroundedPids.add(pidToBackground);
// Use ShellExecutionService for shell PTYs (handles log files, etc.),
// fall back to ExecutionLifecycleService for non-shell executions
// (e.g. remote agents, MCP tools, local agents).
if (state.activeShellPtyId) {
ShellExecutionService.background(pidToBackground);
} else {
ExecutionLifecycleService.background(pidToBackground);
}
// Ensure backgrounding is silent and doesn't trigger restoration
m.wasVisibleBeforeForeground = false;
if (m.restoreTimeout) {
@@ -204,14 +220,16 @@ export const useShellCommandProcessor = (
}
}, [state.activeShellPtyId, activeBackgroundExecutionId, m]);
const dismissBackgroundShell = useCallback(
const dismissBackgroundTask = useCallback(
async (pid: number) => {
const shell = state.backgroundShells.get(pid);
const shell = state.backgroundTasks.get(pid);
if (shell) {
if (shell.status === 'running') {
await ShellExecutionService.kill(pid);
// ExecutionLifecycleService.kill handles both shell and non-shell
// executions. For shells, ShellExecutionService.kill delegates to it.
ExecutionLifecycleService.kill(pid);
}
dispatch({ type: 'DISMISS_SHELL', pid });
dispatch({ type: 'DISMISS_TASK', pid });
m.backgroundedPids.delete(pid);
// Unsubscribe from updates
@@ -222,40 +240,73 @@ export const useShellCommandProcessor = (
}
}
},
[state.backgroundShells, dispatch, m],
[state.backgroundTasks, dispatch, m],
);
const registerBackgroundShell = useCallback(
(pid: number, command: string, initialOutput: string | AnsiOutput) => {
dispatch({ type: 'REGISTER_SHELL', pid, command, initialOutput });
const registerBackgroundTask = useCallback(
(
pid: number,
command: string,
initialOutput: string | AnsiOutput,
completionBehavior?: CompletionBehavior,
) => {
m.backgroundedPids.add(pid);
dispatch({
type: 'REGISTER_TASK',
pid,
command,
initialOutput,
completionBehavior,
});
// Subscribe to process exit directly
const exitUnsubscribe = ShellExecutionService.onExit(pid, (code) => {
// Subscribe to exit via ExecutionLifecycleService (works for all execution types)
const exitUnsubscribe = ExecutionLifecycleService.onExit(pid, (code) => {
dispatch({
type: 'UPDATE_SHELL',
type: 'UPDATE_TASK',
pid,
update: { status: 'exited', exitCode: code },
});
// Auto-dismiss for inject/notify (output was delivered to conversation).
// Silent tasks stay in the UI until manually dismissed.
if (completionBehavior !== 'silent') {
dispatch({ type: 'DISMISS_TASK', pid });
}
const unsub = m.subscriptions.get(pid);
if (unsub) {
unsub();
m.subscriptions.delete(pid);
}
m.backgroundedPids.delete(pid);
});
// Subscribe to future updates (data only)
const dataUnsubscribe = ShellExecutionService.subscribe(pid, (event) => {
if (event.type === 'data') {
dispatch({ type: 'APPEND_SHELL_OUTPUT', pid, chunk: event.chunk });
} else if (event.type === 'binary_detected') {
dispatch({ type: 'UPDATE_SHELL', pid, update: { isBinary: true } });
} else if (event.type === 'binary_progress') {
dispatch({
type: 'UPDATE_SHELL',
pid,
update: {
isBinary: true,
binaryBytesReceived: event.bytesReceived,
},
});
}
});
// Subscribe to output via ExecutionLifecycleService (works for all execution types)
const dataUnsubscribe = ExecutionLifecycleService.subscribe(
pid,
(event) => {
if (event.type === 'data') {
dispatch({
type: 'APPEND_TASK_OUTPUT',
pid,
chunk: event.chunk,
});
} else if (event.type === 'binary_detected') {
dispatch({
type: 'UPDATE_TASK',
pid,
update: { isBinary: true },
});
} else if (event.type === 'binary_progress') {
dispatch({
type: 'UPDATE_TASK',
pid,
update: {
isBinary: true,
binaryBytesReceived: event.bytesReceived,
},
});
}
},
);
m.subscriptions.set(pid, () => {
exitUnsubscribe();
@@ -265,6 +316,34 @@ export const useShellCommandProcessor = (
[dispatch, m],
);
// Auto-register any execution that gets backgrounded, regardless of type.
// This is the agnostic hook: any tool that calls
// ExecutionLifecycleService.createExecution() or attachExecution()
// automatically gets Ctrl+B support — no UI changes needed per tool.
useEffect(() => {
const listener = (info: {
executionId: number;
label: string;
output: string;
completionBehavior: CompletionBehavior;
}) => {
// Skip if already registered (e.g. shells register via their own flow)
if (m.backgroundedPids.has(info.executionId)) {
return;
}
registerBackgroundTask(
info.executionId,
info.label,
info.output,
info.completionBehavior,
);
};
ExecutionLifecycleService.onBackground(listener);
return () => {
ExecutionLifecycleService.offBackground(listener);
};
}, [registerBackgroundTask, m]);
const handleShellCommand = useCallback(
(rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => {
if (typeof rawQuery !== 'string' || rawQuery.trim() === '') {
@@ -377,7 +456,7 @@ export const useShellCommandProcessor = (
if (executionPid && m.backgroundedPids.has(executionPid)) {
// If already backgrounded, let the background shell subscription handle it.
dispatch({
type: 'APPEND_SHELL_OUTPUT',
type: 'APPEND_TASK_OUTPUT',
pid: executionPid,
chunk:
event.type === 'data' ? event.chunk : cumulativeStdout,
@@ -437,7 +516,12 @@ export const useShellCommandProcessor = (
setPendingHistoryItem(null);
if (result.backgrounded && result.pid) {
registerBackgroundShell(result.pid, rawQuery, cumulativeStdout);
registerBackgroundTask(
result.pid,
rawQuery,
cumulativeStdout,
'notify',
);
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
}
@@ -529,26 +613,26 @@ export const useShellCommandProcessor = (
setShellInputFocused,
terminalHeight,
terminalWidth,
registerBackgroundShell,
registerBackgroundTask,
m,
dispatch,
],
);
const backgroundShellCount = Array.from(
state.backgroundShells.values(),
).filter((s: BackgroundShell) => s.status === 'running').length;
const backgroundTaskCount = Array.from(state.backgroundTasks.values()).filter(
(s: BackgroundTask) => s.status === 'running',
).length;
return {
handleShellCommand,
activeShellPtyId: state.activeShellPtyId,
lastShellOutputTime: state.lastShellOutputTime,
backgroundShellCount,
isBackgroundShellVisible: state.isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells: state.backgroundShells,
backgroundTaskCount,
isBackgroundTaskVisible: state.isBackgroundTaskVisible,
toggleBackgroundTasks,
backgroundCurrentExecution,
registerBackgroundTask,
dismissBackgroundTask,
backgroundTasks: state.backgroundTasks,
};
};
@@ -179,11 +179,18 @@ vi.mock('./useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('./shellCommandProcessor.js', () => ({
useShellCommandProcessor: vi.fn().mockReturnValue({
vi.mock('./useExecutionLifecycle.js', () => ({
useExecutionLifecycle: vi.fn().mockReturnValue({
handleShellCommand: vi.fn(),
activeShellPtyId: null,
lastShellOutputTime: 0,
backgroundTaskCount: 0,
isBackgroundTaskVisible: false,
toggleBackgroundTasks: vi.fn(),
backgroundCurrentExecution: vi.fn(),
backgroundTasks: new Map(),
dismissBackgroundTask: vi.fn(),
registerBackgroundTask: vi.fn(),
}),
}));
+21 -21
View File
@@ -73,7 +73,7 @@ import {
ToolCallStatus,
} from '../types.js';
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
import { useShellCommandProcessor } from './shellCommandProcessor.js';
import { useExecutionLifecycle } from './useExecutionLifecycle.js';
import { handleAtCommand } from './atCommandProcessor.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
@@ -364,14 +364,14 @@ export const useGeminiStream = (
handleShellCommand,
activeShellPtyId,
lastShellOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells,
} = useShellCommandProcessor(
backgroundTaskCount,
isBackgroundTaskVisible,
toggleBackgroundTasks,
backgroundCurrentExecution,
registerBackgroundTask,
dismissBackgroundTask,
backgroundTasks,
} = useExecutionLifecycle(
addItem,
setPendingHistoryItem,
onExec,
@@ -483,7 +483,7 @@ export const useGeminiStream = (
activeShellPtyId,
!!isShellFocused,
[],
backgroundShells,
backgroundTasks,
),
});
addItem(historyItem);
@@ -500,7 +500,7 @@ export const useGeminiStream = (
addItem,
activeShellPtyId,
isShellFocused,
backgroundShells,
backgroundTasks,
]);
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
@@ -515,7 +515,7 @@ export const useGeminiStream = (
activeShellPtyId,
!!isShellFocused,
[],
backgroundShells,
backgroundTasks,
);
if (remainingTools.length > 0) {
@@ -604,7 +604,7 @@ export const useGeminiStream = (
pushedToolCallIds,
activeShellPtyId,
isShellFocused,
backgroundShells,
backgroundTasks,
]);
const lastQueryRef = useRef<PartListUnion | null>(null);
@@ -1794,7 +1794,7 @@ export const useGeminiStream = (
for (const toolCall of completedAndReadyToSubmitTools) {
const backgroundedTool = getBackgroundedToolInfo(toolCall);
if (backgroundedTool) {
registerBackgroundShell(
registerBackgroundTask(
backgroundedTool.pid,
backgroundedTool.command,
backgroundedTool.initialOutput,
@@ -1928,7 +1928,7 @@ export const useGeminiStream = (
performMemoryRefresh,
modelSwitchedFromQuotaError,
addItem,
registerBackgroundShell,
registerBackgroundTask,
consumeUserHint,
isLowErrorVerbosity,
maybeAddSuppressedToolErrorNote,
@@ -2023,12 +2023,12 @@ export const useGeminiStream = (
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
backgroundShells,
dismissBackgroundShell,
backgroundTaskCount,
isBackgroundTaskVisible,
toggleBackgroundTasks,
backgroundCurrentExecution,
backgroundTasks,
dismissBackgroundTask,
retryStatus,
};
};