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
@@ -6,8 +6,8 @@
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { BackgroundTaskDisplay } from './BackgroundTaskDisplay.js';
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { act } from 'react';
import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js';
@@ -15,15 +15,15 @@ import { ScrollProvider } from '../contexts/ScrollProvider.js';
import { Box } from 'ink';
// Mock dependencies
const mockDismissBackgroundShell = vi.fn();
const mockSetActiveBackgroundShellPid = vi.fn();
const mockSetIsBackgroundShellListOpen = vi.fn();
const mockDismissBackgroundTask = vi.fn();
const mockSetActiveBackgroundTaskPid = vi.fn();
const mockSetIsBackgroundTaskListOpen = vi.fn();
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: () => ({
dismissBackgroundShell: mockDismissBackgroundShell,
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
dismissBackgroundTask: mockDismissBackgroundTask,
setActiveBackgroundTaskPid: mockSetActiveBackgroundTaskPid,
setIsBackgroundTaskListOpen: mockSetIsBackgroundTaskListOpen,
}),
}));
@@ -86,14 +86,14 @@ vi.mock('./shared/ScrollableList.js', () => ({
data,
renderItem,
}: {
data: BackgroundShell[];
data: BackgroundTask[];
renderItem: (props: {
item: BackgroundShell;
item: BackgroundTask;
index: number;
}) => React.ReactNode;
}) => (
<Box flexDirection="column">
{data.map((item: BackgroundShell, index: number) => (
{data.map((item: BackgroundTask, index: number) => (
<Box key={index}>{renderItem({ item, index })}</Box>
))}
</Box>
@@ -116,9 +116,9 @@ const createMockKey = (overrides: Partial<Key>): Key => ({
...overrides,
});
describe('<BackgroundShellDisplay />', () => {
const mockShells = new Map<number, BackgroundShell>();
const shell1: BackgroundShell = {
describe('<BackgroundTaskDisplay />', () => {
const mockShells = new Map<number, BackgroundTask>();
const shell1: BackgroundTask = {
pid: 1001,
command: 'npm start',
output: 'Starting server...',
@@ -126,7 +126,7 @@ describe('<BackgroundShellDisplay />', () => {
binaryBytesReceived: 0,
status: 'running',
};
const shell2: BackgroundShell = {
const shell2: BackgroundTask = {
pid: 1002,
command: 'tail -f log.txt',
output: 'Log entry 1',
@@ -147,7 +147,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -167,7 +167,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 100;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -187,7 +187,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -207,7 +207,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { rerender, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -227,7 +227,7 @@ describe('<BackgroundShellDisplay />', () => {
rerender(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={100}
@@ -250,7 +250,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -270,7 +270,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -287,13 +287,13 @@ describe('<BackgroundShellDisplay />', () => {
simulateKey({ name: 'down' });
});
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
// Simulate Ctrl+L (handled by BackgroundTaskDisplay)
await act(async () => {
simulateKey({ name: 'l', ctrl: true });
});
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
expect(mockSetActiveBackgroundTaskPid).toHaveBeenCalledWith(shell2.pid);
expect(mockSetIsBackgroundTaskListOpen).toHaveBeenCalledWith(false);
unmount();
});
@@ -301,7 +301,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -325,7 +325,7 @@ describe('<BackgroundShellDisplay />', () => {
simulateKey({ name: 'k', ctrl: true });
});
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell2.pid);
unmount();
});
@@ -333,7 +333,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell1.pid}
width={width}
@@ -349,7 +349,7 @@ describe('<BackgroundShellDisplay />', () => {
simulateKey({ name: 'k', ctrl: true });
});
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell1.pid);
unmount();
});
@@ -358,7 +358,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={shell2.pid}
width={width}
@@ -375,7 +375,7 @@ describe('<BackgroundShellDisplay />', () => {
});
it('keeps exit code status color even when selected', async () => {
const exitedShell: BackgroundShell = {
const exitedShell: BackgroundTask = {
pid: 1003,
command: 'exit 0',
output: '',
@@ -389,7 +389,7 @@ describe('<BackgroundShellDisplay />', () => {
const width = 80;
const { lastFrame, unmount } = await render(
<ScrollProvider>
<BackgroundShellDisplay
<BackgroundTaskDisplay
shells={mockShells}
activePid={exitedShell.pid}
width={width}
@@ -17,7 +17,7 @@ import {
type AnsiToken,
} from '@google/gemini-cli-core';
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js';
import { Command } from '../key/keyMatchers.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { formatCommand } from '../key/keybindingUtils.js';
@@ -34,8 +34,8 @@ import {
} from './shared/RadioButtonSelect.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface BackgroundShellDisplayProps {
shells: Map<number, BackgroundShell>;
interface BackgroundTaskDisplayProps {
shells: Map<number, BackgroundTask>;
activePid: number;
width: number;
height: number;
@@ -61,19 +61,19 @@ const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
: commandFirstLine;
};
export const BackgroundShellDisplay = ({
export const BackgroundTaskDisplay = ({
shells,
activePid,
width,
height,
isFocused,
isListOpenProp,
}: BackgroundShellDisplayProps) => {
}: BackgroundTaskDisplayProps) => {
const keyMatchers = useKeyMatchers();
const {
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
dismissBackgroundTask,
setActiveBackgroundTaskPid,
setIsBackgroundTaskListOpen,
} = useUIActions();
const activeShell = shells.get(activePid);
const [output, setOutput] = useState<string | AnsiOutput>(
@@ -152,13 +152,13 @@ export const BackgroundShellDisplay = ({
// RadioButtonSelect handles Enter -> onSelect
if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) {
setIsBackgroundShellListOpen(false);
setIsBackgroundTaskListOpen(false);
return true;
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
if (highlightedPid) {
void dismissBackgroundShell(highlightedPid);
void dismissBackgroundTask(highlightedPid);
// If we killed the active one, the list might update via props
}
return true;
@@ -166,9 +166,9 @@ export const BackgroundShellDisplay = ({
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
if (highlightedPid) {
setActiveBackgroundShellPid(highlightedPid);
setActiveBackgroundTaskPid(highlightedPid);
}
setIsBackgroundShellListOpen(false);
setIsBackgroundTaskListOpen(false);
return true;
}
return false;
@@ -179,12 +179,12 @@ export const BackgroundShellDisplay = ({
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
void dismissBackgroundShell(activeShell.pid);
void dismissBackgroundTask(activeShell.pid);
return true;
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
setIsBackgroundShellListOpen(true);
setIsBackgroundTaskListOpen(true);
return true;
}
@@ -339,8 +339,8 @@ export const BackgroundShellDisplay = ({
items={items}
initialIndex={initialIndex >= 0 ? initialIndex : 0}
onSelect={(pid) => {
setActiveBackgroundShellPid(pid);
setIsBackgroundShellListOpen(false);
setActiveBackgroundTaskPid(pid);
setIsBackgroundTaskListOpen(false);
}}
onHighlight={(pid) => setHighlightedPid(pid)}
isFocused={isFocused}
@@ -198,7 +198,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
nightly: false,
isTrustedFolder: true,
activeHooks: [],
isBackgroundShellVisible: false,
isBackgroundTaskVisible: false,
embeddedShellFocused: false,
showIsExpandableHint: false,
quota: {
@@ -464,7 +464,7 @@ describe('Composer', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundShellVisible: true,
isBackgroundTaskVisible: true,
});
const { lastFrame } = await renderComposer(uiState);
@@ -494,7 +494,7 @@ describe('Composer', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundShellVisible: false,
isBackgroundTaskVisible: false,
});
const { lastFrame } = await renderComposer(uiState);
@@ -232,8 +232,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
terminalWidth,
activePtyId,
history,
backgroundShells,
backgroundShellHeight,
backgroundTasks,
backgroundTaskHeight,
shortcutsHelpVisible,
} = useUIState();
const [suppressCompletion, setSuppressCompletion] = useState(false);
@@ -1262,7 +1262,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
if (
activePtyId ||
(backgroundShells.size > 0 && backgroundShellHeight > 0)
(backgroundTasks.size > 0 && backgroundTaskHeight > 0)
) {
setEmbeddedShellFocused(true);
return true;
@@ -1325,8 +1325,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setBannerVisible,
activePtyId,
setEmbeddedShellFocused,
backgroundShells.size,
backgroundShellHeight,
backgroundTasks.size,
backgroundTaskHeight,
streamingState,
handleEscPress,
registerPlainTabPress,
@@ -86,10 +86,10 @@ vi.mock('./shared/ScrollableList.js', () => ({
}));
import { theme } from '../semantic-colors.js';
import { type BackgroundShell } from '../hooks/shellReducer.js';
import { type BackgroundTask } from '../hooks/shellReducer.js';
describe('getToolGroupBorderAppearance', () => {
const mockBackgroundShells = new Map<number, BackgroundShell>();
const mockBackgroundTasks = new Map<number, BackgroundTask>();
const activeShellPtyId = 123;
it('returns default empty values for non-tool_group items', () => {
@@ -99,7 +99,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({ borderColor: '', borderDimColor: false });
});
@@ -144,7 +144,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
pendingItems,
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({
borderColor: theme.border.default,
@@ -173,7 +173,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({
borderColor: theme.border.default,
@@ -202,7 +202,7 @@ describe('getToolGroupBorderAppearance', () => {
null,
false,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({
borderColor: theme.status.warning,
@@ -232,7 +232,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
false,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({
borderColor: theme.ui.active,
@@ -262,7 +262,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
true,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({
borderColor: theme.ui.focus,
@@ -291,7 +291,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
false,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
expect(result).toEqual({
borderColor: theme.ui.active,
@@ -308,7 +308,7 @@ describe('getToolGroupBorderAppearance', () => {
activeShellPtyId,
true,
[],
mockBackgroundShells,
mockBackgroundTasks,
);
// Since there are no tools to inspect, it falls back to empty pending, but isCurrentlyInShellTurn=true
// so it counts as pending shell.
@@ -51,7 +51,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
ideContextState: null,
geminiMdFileCount: 0,
contextFileNames: [],
backgroundShellCount: 0,
backgroundTaskCount: 0,
buffer: { text: '' },
history: [{ id: 1, type: 'user', text: 'test' }],
...overrides,
@@ -159,9 +159,9 @@ describe('StatusDisplay', () => {
unmount();
});
it('passes backgroundShellCount to ContextSummaryDisplay', async () => {
it('passes backgroundTaskCount to ContextSummaryDisplay', async () => {
const uiState = createMockUIState({
backgroundShellCount: 3,
backgroundTaskCount: 3,
});
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
@@ -38,7 +38,7 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
}
skillCount={config.getSkillManager().getDisplayableSkills().length}
backgroundProcessCount={uiState.backgroundShellCount}
backgroundProcessCount={uiState.backgroundTaskCount}
/>
);
}
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
exports[`<BackgroundTaskDisplay /> > highlights the focused state 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -10,7 +10,7 @@ exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
"
`;
exports[`<BackgroundShellDisplay /> > keeps exit code status color even when selected 1`] = `
exports[`<BackgroundTaskDisplay /> > keeps exit code status color even when selected 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1003) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -25,7 +25,7 @@ exports[`<BackgroundShellDisplay /> > keeps exit code status color even when sel
"
`;
exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
exports[`<BackgroundTaskDisplay /> > renders tabs for multiple shells 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server... │
@@ -34,7 +34,7 @@ exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
"
`;
exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`] = `
exports[`<BackgroundTaskDisplay /> > renders the output of the active shell 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ Starting server... │
@@ -43,7 +43,7 @@ exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`]
"
`;
exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenProp is true 1`] = `
exports[`<BackgroundTaskDisplay /> > renders the process list when isListOpenProp is true 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -57,7 +57,7 @@ exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenPr
"
`;
exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`] = `
exports[`<BackgroundTaskDisplay /> > scrolls to active shell when list opens 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta.. (PID: 1002) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
@@ -81,7 +81,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const {
activePtyId,
embeddedShellFocused,
backgroundShells,
backgroundTasks,
pendingHistoryItems,
} = useUIState();
@@ -92,14 +92,14 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
activePtyId,
embeddedShellFocused,
pendingHistoryItems,
backgroundShells,
backgroundTasks,
),
[
item,
activePtyId,
embeddedShellFocused,
pendingHistoryItems,
backgroundShells,
backgroundTasks,
],
);