mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-13 11:30:39 -07:00
feat(core): agnostic background task UI with CompletionBehavior (#22740)
Co-authored-by: mkorwel <matt.korwel@gmail.com>
This commit is contained in:
@@ -1000,6 +1000,8 @@ export async function loadCliConfig(
|
||||
useAlternateBuffer: settings.ui?.useAlternateBuffer,
|
||||
useRipgrep: settings.tools?.useRipgrep,
|
||||
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
|
||||
shellBackgroundCompletionBehavior: settings.tools?.shell
|
||||
?.backgroundCompletionBehavior as string | undefined,
|
||||
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
|
||||
enableShellOutputEfficiency:
|
||||
settings.tools?.shell?.enableShellOutputEfficiency ?? true,
|
||||
|
||||
@@ -1458,6 +1458,21 @@ const SETTINGS_SCHEMA = {
|
||||
`,
|
||||
showInDialog: true,
|
||||
},
|
||||
backgroundCompletionBehavior: {
|
||||
type: 'enum',
|
||||
label: 'Background Completion Behavior',
|
||||
category: 'Tools',
|
||||
requiresRestart: false,
|
||||
default: 'silent',
|
||||
description:
|
||||
"Controls what happens when a background shell command finishes. 'silent' (default): quietly exits in background. 'inject': automatically returns output to agent. 'notify': shows brief message in chat.",
|
||||
showInDialog: false,
|
||||
options: [
|
||||
{ label: 'Silent', value: 'silent' },
|
||||
{ label: 'Inject', value: 'inject' },
|
||||
{ label: 'Notify', value: 'notify' },
|
||||
],
|
||||
},
|
||||
pager: {
|
||||
type: 'string',
|
||||
label: 'Pager',
|
||||
|
||||
@@ -56,7 +56,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 { tasksCommand } from '../ui/commands/tasksCommand.js';
|
||||
import { vimCommand } from '../ui/commands/vimCommand.js';
|
||||
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||
@@ -221,7 +221,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
: [skillsCommand]
|
||||
: []),
|
||||
settingsCommand,
|
||||
shellsCommand,
|
||||
tasksCommand,
|
||||
vimCommand,
|
||||
setupGithubCommand,
|
||||
terminalSetupCommand,
|
||||
|
||||
@@ -506,8 +506,8 @@ const baseMockUiState = {
|
||||
cleanUiDetailsVisible: false,
|
||||
allowPlanMode: true,
|
||||
activePtyId: undefined,
|
||||
backgroundShells: new Map(),
|
||||
backgroundShellHeight: 0,
|
||||
backgroundTasks: new Map(),
|
||||
backgroundTaskHeight: 0,
|
||||
quota: {
|
||||
userTier: undefined,
|
||||
stats: undefined,
|
||||
@@ -579,9 +579,9 @@ const mockUIActions: UIActions = {
|
||||
revealCleanUiDetailsTemporarily: vi.fn(),
|
||||
handleWarning: vi.fn(),
|
||||
setEmbeddedShellFocused: vi.fn(),
|
||||
dismissBackgroundShell: vi.fn(),
|
||||
setActiveBackgroundShellPid: vi.fn(),
|
||||
setIsBackgroundShellListOpen: vi.fn(),
|
||||
dismissBackgroundTask: vi.fn(),
|
||||
setActiveBackgroundTaskPid: vi.fn(),
|
||||
setIsBackgroundTaskListOpen: vi.fn(),
|
||||
setAuthContext: vi.fn(),
|
||||
onHintInput: vi.fn(),
|
||||
onHintBackspace: vi.fn(),
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('App', () => {
|
||||
defaultText: 'Mock Banner Text',
|
||||
warningText: '',
|
||||
},
|
||||
backgroundShells: new Map(),
|
||||
backgroundTasks: new Map(),
|
||||
};
|
||||
|
||||
it('should render main content and composer when not quitting', async () => {
|
||||
|
||||
@@ -328,13 +328,13 @@ describe('AppContainer State Management', () => {
|
||||
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(),
|
||||
backgroundTaskCount: 0,
|
||||
isBackgroundTaskVisible: false,
|
||||
toggleBackgroundTasks: vi.fn(),
|
||||
backgroundCurrentExecution: vi.fn(),
|
||||
backgroundTasks: new Map(),
|
||||
registerBackgroundTask: vi.fn(),
|
||||
dismissBackgroundTask: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -2257,13 +2257,13 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
it('should focus background shell on Tab when already visible (not toggle it off)', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
const mockToggleBackgroundTask = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
isBackgroundTaskVisible: true,
|
||||
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundTasks: mockToggleBackgroundTask,
|
||||
});
|
||||
|
||||
await setupKeypressTest();
|
||||
@@ -2277,7 +2277,7 @@ describe('AppContainer State Management', () => {
|
||||
// Should be focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
// Should NOT have toggled (closed) the shell
|
||||
expect(mockToggleBackgroundShell).not.toHaveBeenCalled();
|
||||
expect(mockToggleBackgroundTask).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
@@ -2285,13 +2285,13 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
describe('Background Shell Toggling (CTRL+B)', () => {
|
||||
it('should toggle background shell on Ctrl+B even if visible but not focused', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
const mockToggleBackgroundTask = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
isBackgroundTaskVisible: true,
|
||||
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundTasks: mockToggleBackgroundTask,
|
||||
});
|
||||
|
||||
await setupKeypressTest();
|
||||
@@ -2303,7 +2303,7 @@ describe('AppContainer State Management', () => {
|
||||
pressKey('\x02');
|
||||
|
||||
// Should have toggled (closed) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
expect(mockToggleBackgroundTask).toHaveBeenCalled();
|
||||
// Should be unfocused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
@@ -2311,28 +2311,28 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
it('should show and focus background shell on Ctrl+B if hidden', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
const mockToggleBackgroundTask = vi.fn();
|
||||
const geminiStreamMock = {
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: false,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
isBackgroundTaskVisible: false,
|
||||
backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundTasks: mockToggleBackgroundTask,
|
||||
};
|
||||
mockedUseGeminiStream.mockReturnValue(geminiStreamMock);
|
||||
|
||||
await setupKeypressTest();
|
||||
|
||||
// Update the mock state when toggled to simulate real behavior
|
||||
mockToggleBackgroundShell.mockImplementation(() => {
|
||||
geminiStreamMock.isBackgroundShellVisible = true;
|
||||
mockToggleBackgroundTask.mockImplementation(() => {
|
||||
geminiStreamMock.isBackgroundTaskVisible = true;
|
||||
});
|
||||
|
||||
// Press Ctrl+B
|
||||
pressKey('\x02');
|
||||
|
||||
// Should have toggled (shown) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
expect(mockToggleBackgroundTask).toHaveBeenCalled();
|
||||
// Should be focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
|
||||
@@ -110,7 +110,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 { type BackgroundTask } from './hooks/useExecutionLifecycle.js';
|
||||
import { useVim } from './hooks/vim.js';
|
||||
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
|
||||
import { type InitializationResult } from '../core/initializer.js';
|
||||
@@ -151,7 +151,7 @@ import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||
import { useBanner } from './hooks/useBanner.js';
|
||||
import { useTerminalSetupPrompt } from './utils/terminalSetup.js';
|
||||
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
|
||||
import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
|
||||
import { useBackgroundTaskManager } from './hooks/useBackgroundTaskManager.js';
|
||||
import {
|
||||
WARNING_PROMPT_DURATION_MS,
|
||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||
@@ -232,9 +232,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
);
|
||||
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
|
||||
const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false);
|
||||
const toggleBackgroundShellRef = useRef<() => void>(() => {});
|
||||
const isBackgroundShellVisibleRef = useRef<boolean>(false);
|
||||
const backgroundShellsRef = useRef<Map<number, BackgroundShell>>(new Map());
|
||||
const toggleBackgroundTasksRef = useRef<() => void>(() => {});
|
||||
const isBackgroundTaskVisibleRef = useRef<boolean>(false);
|
||||
const backgroundTasksRef = useRef<Map<number, BackgroundTask>>(new Map());
|
||||
|
||||
const [adminSettingsChanged, setAdminSettingsChanged] = useState(false);
|
||||
|
||||
@@ -454,7 +454,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
// Kill all background shells
|
||||
await Promise.all(
|
||||
Array.from(backgroundShellsRef.current.keys()).map((pid) =>
|
||||
Array.from(backgroundTasksRef.current.keys()).map((pid) =>
|
||||
ShellExecutionService.kill(pid),
|
||||
),
|
||||
);
|
||||
@@ -865,7 +865,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const { toggleVimEnabled } = useVimMode();
|
||||
|
||||
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
|
||||
const setIsBackgroundTaskListOpenRef = useRef<(open: boolean) => void>(
|
||||
() => {},
|
||||
);
|
||||
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
|
||||
@@ -900,14 +900,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
toggleDebugProfiler,
|
||||
dispatchExtensionStateUpdate,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
toggleBackgroundShell: () => {
|
||||
toggleBackgroundShellRef.current();
|
||||
if (!isBackgroundShellVisibleRef.current) {
|
||||
toggleBackgroundTasks: () => {
|
||||
toggleBackgroundTasksRef.current();
|
||||
if (!isBackgroundTaskVisibleRef.current) {
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShellsRef.current.size > 1) {
|
||||
setIsBackgroundShellListOpenRef.current(true);
|
||||
if (backgroundTasksRef.current.size > 1) {
|
||||
setIsBackgroundTaskListOpenRef.current(true);
|
||||
} else {
|
||||
setIsBackgroundShellListOpenRef.current(false);
|
||||
setIsBackgroundTaskListOpenRef.current(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1079,7 +1079,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
useEffect(() => {
|
||||
const hintListener = (text: string, source: InjectionSource) => {
|
||||
if (source !== 'user_steering') {
|
||||
if (source !== 'user_steering' && source !== 'background_completion') {
|
||||
return;
|
||||
}
|
||||
pendingHintsRef.current.push(text);
|
||||
@@ -1103,12 +1103,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
activePtyId,
|
||||
loopDetectionConfirmationRequest,
|
||||
lastOutputTime,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
toggleBackgroundShell,
|
||||
backgroundCurrentShell,
|
||||
backgroundShells,
|
||||
dismissBackgroundShell,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
toggleBackgroundTasks,
|
||||
backgroundCurrentExecution,
|
||||
backgroundTasks,
|
||||
dismissBackgroundTask,
|
||||
retryStatus,
|
||||
} = useGeminiStream(
|
||||
config.getGeminiClient(),
|
||||
@@ -1142,27 +1142,27 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
[pendingHistoryItems],
|
||||
);
|
||||
|
||||
toggleBackgroundShellRef.current = toggleBackgroundShell;
|
||||
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
|
||||
backgroundShellsRef.current = backgroundShells;
|
||||
toggleBackgroundTasksRef.current = toggleBackgroundTasks;
|
||||
isBackgroundTaskVisibleRef.current = isBackgroundTaskVisible;
|
||||
backgroundTasksRef.current = backgroundTasks;
|
||||
|
||||
const {
|
||||
activeBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
isBackgroundShellListOpen,
|
||||
setActiveBackgroundShellPid,
|
||||
backgroundShellHeight,
|
||||
} = useBackgroundShellManager({
|
||||
backgroundShells,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
activeBackgroundTaskPid,
|
||||
setIsBackgroundTaskListOpen,
|
||||
isBackgroundTaskListOpen,
|
||||
setActiveBackgroundTaskPid,
|
||||
backgroundTaskHeight,
|
||||
} = useBackgroundTaskManager({
|
||||
backgroundTasks,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
activePtyId,
|
||||
embeddedShellFocused,
|
||||
setEmbeddedShellFocused,
|
||||
terminalHeight,
|
||||
});
|
||||
|
||||
setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen;
|
||||
setIsBackgroundTaskListOpenRef.current = setIsBackgroundTaskListOpen;
|
||||
|
||||
const lastOutputTimeRef = useRef(0);
|
||||
|
||||
@@ -1434,7 +1434,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
// Compute available terminal height based on stable controls measurement
|
||||
const availableTerminalHeight = Math.max(
|
||||
0,
|
||||
terminalHeight - stableControlsHeight - backgroundShellHeight - 1,
|
||||
terminalHeight - stableControlsHeight - backgroundTaskHeight - 1,
|
||||
);
|
||||
|
||||
config.setShellExecutionConfig({
|
||||
@@ -1790,7 +1790,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
} else if (
|
||||
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
|
||||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) &&
|
||||
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
|
||||
(activePtyId || (isBackgroundTaskVisible && backgroundTasks.size > 0))
|
||||
) {
|
||||
if (embeddedShellFocused) {
|
||||
const capturedTime = lastOutputTimeRef.current;
|
||||
@@ -1811,12 +1811,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const isIdle = Date.now() - lastOutputTimeRef.current >= 100;
|
||||
|
||||
if (isIdle && !activePtyId && !isBackgroundShellVisible) {
|
||||
if (isIdle && !activePtyId && !isBackgroundTaskVisible) {
|
||||
if (tabFocusTimeoutRef.current)
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
toggleBackgroundShell();
|
||||
toggleBackgroundTasks();
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShells.size > 1) setIsBackgroundShellListOpen(true);
|
||||
if (backgroundTasks.size > 1) setIsBackgroundTaskListOpen(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1833,15 +1833,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
return false;
|
||||
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||
if (activePtyId) {
|
||||
backgroundCurrentShell();
|
||||
backgroundCurrentExecution();
|
||||
// After backgrounding, we explicitly do NOT show or focus the background UI.
|
||||
} else {
|
||||
toggleBackgroundShell();
|
||||
toggleBackgroundTasks();
|
||||
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
|
||||
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
|
||||
if (!isBackgroundTaskVisible && backgroundTasks.size > 0) {
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShells.size > 1) {
|
||||
setIsBackgroundShellListOpen(true);
|
||||
if (backgroundTasks.size > 1) {
|
||||
setIsBackgroundTaskListOpen(true);
|
||||
}
|
||||
} else {
|
||||
setEmbeddedShellFocused(false);
|
||||
@@ -1849,11 +1849,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
return true;
|
||||
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
|
||||
if (backgroundShells.size > 0 && isBackgroundShellVisible) {
|
||||
if (backgroundTasks.size > 0 && isBackgroundTaskVisible) {
|
||||
if (!embeddedShellFocused) {
|
||||
setEmbeddedShellFocused(true);
|
||||
}
|
||||
setIsBackgroundShellListOpen(true);
|
||||
setIsBackgroundTaskListOpen(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1878,11 +1878,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
tabFocusTimeoutRef,
|
||||
isAlternateBuffer,
|
||||
shortcutsHelpVisible,
|
||||
backgroundCurrentShell,
|
||||
toggleBackgroundShell,
|
||||
backgroundShells,
|
||||
isBackgroundShellVisible,
|
||||
setIsBackgroundShellListOpen,
|
||||
backgroundCurrentExecution,
|
||||
toggleBackgroundTasks,
|
||||
backgroundTasks,
|
||||
isBackgroundTaskVisible,
|
||||
setIsBackgroundTaskListOpen,
|
||||
lastOutputTimeRef,
|
||||
showTransientMessage,
|
||||
settings.merged.general.devtools,
|
||||
@@ -2055,7 +2055,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const showStatusWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
|
||||
|
||||
const showLoadingIndicator =
|
||||
(!embeddedShellFocused || isBackgroundShellVisible) &&
|
||||
(!embeddedShellFocused || isBackgroundTaskVisible) &&
|
||||
streamingState === StreamingState.Responding &&
|
||||
!hasPendingActionRequired;
|
||||
|
||||
@@ -2313,8 +2313,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isRestarting,
|
||||
extensionsUpdateState,
|
||||
activePtyId,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
embeddedShellFocused,
|
||||
showDebugProfiler,
|
||||
customDialog,
|
||||
@@ -2324,10 +2324,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
bannerVisible,
|
||||
terminalBackgroundColor: config.getTerminalBackground(),
|
||||
settingsNonce,
|
||||
backgroundShells,
|
||||
activeBackgroundShellPid,
|
||||
backgroundShellHeight,
|
||||
isBackgroundShellListOpen,
|
||||
backgroundTasks,
|
||||
activeBackgroundTaskPid,
|
||||
backgroundTaskHeight,
|
||||
isBackgroundTaskListOpen,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
@@ -2436,8 +2436,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
currentModel,
|
||||
extensionsUpdateState,
|
||||
activePtyId,
|
||||
backgroundShellCount,
|
||||
isBackgroundShellVisible,
|
||||
backgroundTaskCount,
|
||||
isBackgroundTaskVisible,
|
||||
historyManager,
|
||||
embeddedShellFocused,
|
||||
showDebugProfiler,
|
||||
@@ -2450,10 +2450,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
bannerVisible,
|
||||
config,
|
||||
settingsNonce,
|
||||
backgroundShellHeight,
|
||||
isBackgroundShellListOpen,
|
||||
activeBackgroundShellPid,
|
||||
backgroundShells,
|
||||
backgroundTaskHeight,
|
||||
isBackgroundTaskListOpen,
|
||||
activeBackgroundTaskPid,
|
||||
backgroundTasks,
|
||||
adminSettingsChanged,
|
||||
newAgents,
|
||||
showIsExpandableHint,
|
||||
@@ -2513,9 +2513,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
revealCleanUiDetailsTemporarily,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
dismissBackgroundTask,
|
||||
setActiveBackgroundTaskPid,
|
||||
setIsBackgroundTaskListOpen,
|
||||
setAuthContext,
|
||||
onHintInput: () => {},
|
||||
onHintBackspace: () => {},
|
||||
@@ -2605,9 +2605,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
revealCleanUiDetailsTemporarily,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
dismissBackgroundTask,
|
||||
setActiveBackgroundTaskPid,
|
||||
setIsBackgroundTaskListOpen,
|
||||
setAuthContext,
|
||||
setAccountSuspensionInfo,
|
||||
newAgents,
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { shellsCommand } from './shellsCommand.js';
|
||||
import type { CommandContext } from './types.js';
|
||||
|
||||
describe('shellsCommand', () => {
|
||||
it('should call toggleBackgroundShell', async () => {
|
||||
const toggleBackgroundShell = vi.fn();
|
||||
const context = {
|
||||
ui: {
|
||||
toggleBackgroundShell,
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
|
||||
if (shellsCommand.action) {
|
||||
await shellsCommand.action(context, '');
|
||||
}
|
||||
|
||||
expect(toggleBackgroundShell).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have correct name and altNames', () => {
|
||||
expect(shellsCommand.name).toBe('shells');
|
||||
expect(shellsCommand.altNames).toContain('bashes');
|
||||
});
|
||||
|
||||
it('should auto-execute', () => {
|
||||
expect(shellsCommand.autoExecute).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { tasksCommand } from './tasksCommand.js';
|
||||
import type { CommandContext } from './types.js';
|
||||
|
||||
describe('tasksCommand', () => {
|
||||
it('should call toggleBackgroundTasks', async () => {
|
||||
const toggleBackgroundTasks = vi.fn();
|
||||
const context = {
|
||||
ui: {
|
||||
toggleBackgroundTasks,
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
|
||||
if (tasksCommand.action) {
|
||||
await tasksCommand.action(context, '');
|
||||
}
|
||||
|
||||
expect(toggleBackgroundTasks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have correct name and altNames', () => {
|
||||
expect(tasksCommand.name).toBe('tasks');
|
||||
expect(tasksCommand.altNames).toContain('bg');
|
||||
expect(tasksCommand.altNames).toContain('background');
|
||||
});
|
||||
|
||||
it('should auto-execute', () => {
|
||||
expect(tasksCommand.autoExecute).toBe(true);
|
||||
});
|
||||
});
|
||||
+5
-5
@@ -6,13 +6,13 @@
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
|
||||
export const shellsCommand: SlashCommand = {
|
||||
name: 'shells',
|
||||
altNames: ['bashes'],
|
||||
export const tasksCommand: SlashCommand = {
|
||||
name: 'tasks',
|
||||
altNames: ['bg', 'background'],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Toggle background shells view',
|
||||
description: 'Toggle background tasks view',
|
||||
autoExecute: true,
|
||||
action: async (context) => {
|
||||
context.ui.toggleBackgroundShell();
|
||||
context.ui.toggleBackgroundTasks();
|
||||
},
|
||||
};
|
||||
@@ -90,7 +90,7 @@ export interface CommandContext {
|
||||
*/
|
||||
setConfirmationRequest: (value: ConfirmationRequest) => void;
|
||||
removeComponent: () => void;
|
||||
toggleBackgroundShell: () => void;
|
||||
toggleBackgroundTasks: () => void;
|
||||
toggleShortcutsHelp: () => void;
|
||||
};
|
||||
// Session-specific data
|
||||
|
||||
+32
-32
@@ -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}
|
||||
+16
-16
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+6
-6
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -81,9 +81,9 @@ export interface UIActions {
|
||||
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
|
||||
handleWarning: (message: string) => void;
|
||||
setEmbeddedShellFocused: (value: boolean) => void;
|
||||
dismissBackgroundShell: (pid: number) => Promise<void>;
|
||||
setActiveBackgroundShellPid: (pid: number) => void;
|
||||
setIsBackgroundShellListOpen: (isOpen: boolean) => void;
|
||||
dismissBackgroundTask: (pid: number) => Promise<void>;
|
||||
setActiveBackgroundTaskPid: (pid: number) => void;
|
||||
setIsBackgroundTaskListOpen: (isOpen: boolean) => void;
|
||||
setAuthContext: (context: { requiresRestart?: boolean }) => void;
|
||||
onHintInput: (char: string) => void;
|
||||
onHintBackspace: () => void;
|
||||
|
||||
@@ -84,7 +84,7 @@ export interface EmptyWalletDialogRequest {
|
||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
||||
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
|
||||
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||
|
||||
export interface QuotaState {
|
||||
userTier: UserTierId | undefined;
|
||||
@@ -201,8 +201,8 @@ export interface UIState {
|
||||
isRestarting: boolean;
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateState>;
|
||||
activePtyId: number | undefined;
|
||||
backgroundShellCount: number;
|
||||
isBackgroundShellVisible: boolean;
|
||||
backgroundTaskCount: number;
|
||||
isBackgroundTaskVisible: boolean;
|
||||
embeddedShellFocused: boolean;
|
||||
showDebugProfiler: boolean;
|
||||
showFullTodos: boolean;
|
||||
@@ -215,10 +215,10 @@ export interface UIState {
|
||||
customDialog: React.ReactNode | null;
|
||||
terminalBackgroundColor: TerminalBackgroundColor;
|
||||
settingsNonce: number;
|
||||
backgroundShells: Map<number, BackgroundShell>;
|
||||
activeBackgroundShellPid: number | null;
|
||||
backgroundShellHeight: number;
|
||||
isBackgroundShellListOpen: boolean;
|
||||
backgroundTasks: Map<number, BackgroundTask>;
|
||||
activeBackgroundTaskPid: number | null;
|
||||
backgroundTaskHeight: number;
|
||||
isBackgroundTaskListOpen: boolean;
|
||||
adminSettingsChanged: boolean;
|
||||
newAgents: AgentDefinition[] | null;
|
||||
showIsExpandableHint: boolean;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+99
-82
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
+141
-57
@@ -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(),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import { DefaultAppLayout } from './DefaultAppLayout.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { Text } from 'ink';
|
||||
import type { UIState } from '../contexts/UIStateContext.js';
|
||||
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js';
|
||||
|
||||
// Mock dependencies
|
||||
const mockUIState = {
|
||||
@@ -18,13 +18,13 @@ const mockUIState = {
|
||||
terminalHeight: 24,
|
||||
terminalWidth: 80,
|
||||
mainAreaWidth: 80,
|
||||
backgroundShells: new Map<number, BackgroundShell>(),
|
||||
activeBackgroundShellPid: null as number | null,
|
||||
backgroundShellHeight: 10,
|
||||
backgroundTasks: new Map<number, BackgroundTask>(),
|
||||
activeBackgroundTaskPid: null as number | null,
|
||||
backgroundTaskHeight: 10,
|
||||
embeddedShellFocused: false,
|
||||
dialogsVisible: false,
|
||||
streamingState: StreamingState.Idle,
|
||||
isBackgroundShellListOpen: false,
|
||||
isBackgroundTaskListOpen: false,
|
||||
mainControlsRef: vi.fn(),
|
||||
customDialog: null,
|
||||
historyManager: { addItem: vi.fn() },
|
||||
@@ -34,7 +34,7 @@ const mockUIState = {
|
||||
constrainHeight: false,
|
||||
availableTerminalHeight: 20,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: true,
|
||||
isBackgroundTaskVisible: true,
|
||||
} as unknown as UIState;
|
||||
|
||||
vi.mock('../contexts/UIStateContext.js', () => ({
|
||||
@@ -79,11 +79,11 @@ vi.mock('../components/ExitWarning.js', () => ({
|
||||
vi.mock('../components/CopyModeWarning.js', () => ({
|
||||
CopyModeWarning: () => <Text>CopyModeWarning</Text>,
|
||||
}));
|
||||
vi.mock('../components/BackgroundShellDisplay.js', () => ({
|
||||
BackgroundShellDisplay: () => <Text>BackgroundShellDisplay</Text>,
|
||||
vi.mock('../components/BackgroundTaskDisplay.js', () => ({
|
||||
BackgroundTaskDisplay: () => <Text>BackgroundTaskDisplay</Text>,
|
||||
}));
|
||||
|
||||
const createMockShell = (pid: number): BackgroundShell => ({
|
||||
const createMockShell = (pid: number): BackgroundTask => ({
|
||||
pid,
|
||||
command: 'test command',
|
||||
output: 'test output',
|
||||
@@ -96,25 +96,25 @@ describe('<DefaultAppLayout />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset mock state defaults
|
||||
mockUIState.backgroundShells = new Map();
|
||||
mockUIState.activeBackgroundShellPid = null;
|
||||
mockUIState.backgroundTasks = new Map();
|
||||
mockUIState.activeBackgroundTaskPid = null;
|
||||
mockUIState.streamingState = StreamingState.Idle;
|
||||
});
|
||||
|
||||
it('renders BackgroundShellDisplay when shells exist and active', async () => {
|
||||
mockUIState.backgroundShells.set(123, createMockShell(123));
|
||||
mockUIState.activeBackgroundShellPid = 123;
|
||||
mockUIState.backgroundShellHeight = 5;
|
||||
it('renders BackgroundTaskDisplay when shells exist and active', async () => {
|
||||
mockUIState.backgroundTasks.set(123, createMockShell(123));
|
||||
mockUIState.activeBackgroundTaskPid = 123;
|
||||
mockUIState.backgroundTaskHeight = 5;
|
||||
|
||||
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation', async () => {
|
||||
mockUIState.backgroundShells.set(123, createMockShell(123));
|
||||
mockUIState.activeBackgroundShellPid = 123;
|
||||
mockUIState.backgroundShellHeight = 5;
|
||||
it('hides BackgroundTaskDisplay when StreamingState is WaitingForConfirmation', async () => {
|
||||
mockUIState.backgroundTasks.set(123, createMockShell(123));
|
||||
mockUIState.activeBackgroundTaskPid = 123;
|
||||
mockUIState.backgroundTaskHeight = 5;
|
||||
mockUIState.streamingState = StreamingState.WaitingForConfirmation;
|
||||
|
||||
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
|
||||
@@ -122,10 +122,10 @@ describe('<DefaultAppLayout />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation', async () => {
|
||||
mockUIState.backgroundShells.set(123, createMockShell(123));
|
||||
mockUIState.activeBackgroundShellPid = 123;
|
||||
mockUIState.backgroundShellHeight = 5;
|
||||
it('shows BackgroundTaskDisplay when StreamingState is NOT WaitingForConfirmation', async () => {
|
||||
mockUIState.backgroundTasks.set(123, createMockShell(123));
|
||||
mockUIState.activeBackgroundTaskPid = 123;
|
||||
mockUIState.backgroundTaskHeight = 5;
|
||||
mockUIState.streamingState = StreamingState.Responding;
|
||||
|
||||
const { lastFrame, unmount } = await render(<DefaultAppLayout />);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { CopyModeWarning } from '../components/CopyModeWarning.js';
|
||||
import { BackgroundShellDisplay } from '../components/BackgroundShellDisplay.js';
|
||||
import { BackgroundTaskDisplay } from '../components/BackgroundTaskDisplay.js';
|
||||
import { StreamingState } from '../types.js';
|
||||
|
||||
export const DefaultAppLayout: React.FC = () => {
|
||||
@@ -39,21 +39,21 @@ export const DefaultAppLayout: React.FC = () => {
|
||||
>
|
||||
<MainContent />
|
||||
|
||||
{uiState.isBackgroundShellVisible &&
|
||||
uiState.backgroundShells.size > 0 &&
|
||||
uiState.activeBackgroundShellPid &&
|
||||
uiState.backgroundShellHeight > 0 &&
|
||||
{uiState.isBackgroundTaskVisible &&
|
||||
uiState.backgroundTasks.size > 0 &&
|
||||
uiState.activeBackgroundTaskPid &&
|
||||
uiState.backgroundTaskHeight > 0 &&
|
||||
uiState.streamingState !== StreamingState.WaitingForConfirmation && (
|
||||
<Box height={uiState.backgroundShellHeight} flexShrink={0}>
|
||||
<BackgroundShellDisplay
|
||||
shells={uiState.backgroundShells}
|
||||
activePid={uiState.activeBackgroundShellPid}
|
||||
<Box height={uiState.backgroundTaskHeight} flexShrink={0}>
|
||||
<BackgroundTaskDisplay
|
||||
shells={uiState.backgroundTasks}
|
||||
activePid={uiState.activeBackgroundTaskPid}
|
||||
width={uiState.terminalWidth}
|
||||
height={uiState.backgroundShellHeight}
|
||||
height={uiState.backgroundTaskHeight}
|
||||
isFocused={
|
||||
uiState.embeddedShellFocused && !uiState.dialogsVisible
|
||||
}
|
||||
isListOpenProp={uiState.isBackgroundShellListOpen}
|
||||
isListOpenProp={uiState.isBackgroundTaskListOpen}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<DefaultAppLayout /> > hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation 1`] = `
|
||||
exports[`<DefaultAppLayout /> > hides BackgroundTaskDisplay when StreamingState is WaitingForConfirmation 1`] = `
|
||||
"MainContent
|
||||
Notifications
|
||||
CopyModeWarning
|
||||
@@ -9,9 +9,9 @@ ExitWarning
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<DefaultAppLayout /> > renders BackgroundShellDisplay when shells exist and active 1`] = `
|
||||
exports[`<DefaultAppLayout /> > renders BackgroundTaskDisplay when shells exist and active 1`] = `
|
||||
"MainContent
|
||||
BackgroundShellDisplay
|
||||
BackgroundTaskDisplay
|
||||
|
||||
|
||||
|
||||
@@ -23,9 +23,9 @@ ExitWarning
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`<DefaultAppLayout /> > shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation 1`] = `
|
||||
exports[`<DefaultAppLayout /> > shows BackgroundTaskDisplay when StreamingState is NOT WaitingForConfirmation 1`] = `
|
||||
"MainContent
|
||||
BackgroundShellDisplay
|
||||
BackgroundTaskDisplay
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
||||
addConfirmUpdateExtensionRequest: (_request) => {},
|
||||
setConfirmationRequest: (_request) => {},
|
||||
removeComponent: () => {},
|
||||
toggleBackgroundShell: () => {},
|
||||
toggleBackgroundTasks: () => {},
|
||||
toggleShortcutsHelp: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
HistoryItemToolGroup,
|
||||
IndividualToolCallDisplay,
|
||||
} from '../types.js';
|
||||
import type { BackgroundShell } from '../hooks/shellReducer.js';
|
||||
import type { BackgroundTask } from '../hooks/shellReducer.js';
|
||||
import type { TrackedToolCall } from '../hooks/useToolScheduler.js';
|
||||
|
||||
function isTrackedToolCall(
|
||||
@@ -33,7 +33,7 @@ export function getToolGroupBorderAppearance(
|
||||
activeShellPtyId: number | null | undefined,
|
||||
embeddedShellFocused: boolean | undefined,
|
||||
allPendingItems: HistoryItemWithoutId[] = [],
|
||||
backgroundShells: Map<number, BackgroundShell> = new Map(),
|
||||
backgroundTasks: Map<number, BackgroundTask> = new Map(),
|
||||
): { borderColor: string; borderDimColor: boolean } {
|
||||
if (item.type !== 'tool_group') {
|
||||
return { borderColor: '', borderDimColor: false };
|
||||
@@ -100,7 +100,7 @@ export function getToolGroupBorderAppearance(
|
||||
// If we have an active PTY that isn't a background shell, then the current
|
||||
// pending batch is definitely a shell batch.
|
||||
const isCurrentlyInShellTurn =
|
||||
!!activeShellPtyId && !backgroundShells.has(activeShellPtyId);
|
||||
!!activeShellPtyId && !backgroundTasks.has(activeShellPtyId);
|
||||
|
||||
const isShell =
|
||||
isShellCommand || (item.tools.length === 0 && isCurrentlyInShellTurn);
|
||||
|
||||
Reference in New Issue
Block a user