mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-25 04:24:51 -07:00
feat: implement background process logging and cleanup
- Add persistent logging for backgrounded shell processes in ~/.gemini/tmp/background-processes - Implement automatic cleanup of background logs older than 7 days on CLI startup - Support real-time output syncing to log files for both PTY and child_process execution - Update UI to indicate log file availability for background tasks - Add comprehensive unit and integration tests for logging and cleanup logic
This commit is contained in:
@@ -428,9 +428,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
disableMouseEvents();
|
||||
|
||||
// Kill all background shells
|
||||
for (const pid of backgroundShellsRef.current.keys()) {
|
||||
ShellExecutionService.kill(pid);
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from(backgroundShellsRef.current.keys()).map((pid) =>
|
||||
ShellExecutionService.kill(pid),
|
||||
),
|
||||
);
|
||||
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.disconnect();
|
||||
|
||||
@@ -37,6 +37,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
ShellExecutionService: {
|
||||
resizePty: vi.fn(),
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
getLogFilePath: vi.fn(
|
||||
(pid) => `~/.gemini/tmp/background-processes/background-${pid}.log`,
|
||||
),
|
||||
getLogDir: vi.fn(() => '~/.gemini/tmp/background-processes'),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -221,7 +225,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
|
||||
shell1.pid,
|
||||
76,
|
||||
21,
|
||||
20,
|
||||
);
|
||||
|
||||
rerender(
|
||||
@@ -243,7 +247,7 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
|
||||
shell1.pid,
|
||||
96,
|
||||
27,
|
||||
26,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import {
|
||||
ShellExecutionService,
|
||||
shortenPath,
|
||||
tildeifyPath,
|
||||
type AnsiOutput,
|
||||
type AnsiLine,
|
||||
type AnsiToken,
|
||||
@@ -42,8 +44,14 @@ interface BackgroundShellDisplayProps {
|
||||
|
||||
const CONTENT_PADDING_X = 1;
|
||||
const BORDER_WIDTH = 2; // Left and Right border
|
||||
const HEADER_HEIGHT = 3; // 2 for border, 1 for header
|
||||
const MAIN_BORDER_HEIGHT = 2; // Top and Bottom border
|
||||
const HEADER_HEIGHT = 1;
|
||||
const FOOTER_HEIGHT = 1;
|
||||
const TOTAL_OVERHEAD_HEIGHT =
|
||||
MAIN_BORDER_HEIGHT + HEADER_HEIGHT + FOOTER_HEIGHT;
|
||||
const PROCESS_LIST_HEADER_HEIGHT = 3; // 1 padding top, 1 text, 1 margin bottom
|
||||
const TAB_DISPLAY_HORIZONTAL_PADDING = 4;
|
||||
const LOG_PATH_OVERHEAD = 7; // "Log: " (5) + paddingX (2)
|
||||
|
||||
const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
|
||||
const commandFirstLine = command.split('\n')[0];
|
||||
@@ -79,7 +87,7 @@ export const BackgroundShellDisplay = ({
|
||||
if (!activePid) return;
|
||||
|
||||
const ptyWidth = Math.max(1, width - BORDER_WIDTH - CONTENT_PADDING_X * 2);
|
||||
const ptyHeight = Math.max(1, height - HEADER_HEIGHT);
|
||||
const ptyHeight = Math.max(1, height - TOTAL_OVERHEAD_HEIGHT);
|
||||
ShellExecutionService.resizePty(activePid, ptyWidth, ptyHeight);
|
||||
}, [activePid, width, height]);
|
||||
|
||||
@@ -148,7 +156,7 @@ export const BackgroundShellDisplay = ({
|
||||
|
||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||
if (highlightedPid) {
|
||||
dismissBackgroundShell(highlightedPid);
|
||||
void dismissBackgroundShell(highlightedPid);
|
||||
// If we killed the active one, the list might update via props
|
||||
}
|
||||
return true;
|
||||
@@ -169,7 +177,7 @@ export const BackgroundShellDisplay = ({
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||
dismissBackgroundShell(activeShell.pid);
|
||||
void dismissBackgroundShell(activeShell.pid);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -334,7 +342,10 @@ export const BackgroundShellDisplay = ({
|
||||
}}
|
||||
onHighlight={(pid) => setHighlightedPid(pid)}
|
||||
isFocused={isFocused}
|
||||
maxItemsToShow={Math.max(1, height - HEADER_HEIGHT - 3)} // Adjust for header
|
||||
maxItemsToShow={Math.max(
|
||||
1,
|
||||
height - TOTAL_OVERHEAD_HEIGHT - PROCESS_LIST_HEADER_HEIGHT,
|
||||
)}
|
||||
renderItem={(
|
||||
item,
|
||||
{ isSelected: _isSelected, titleColor: _titleColor },
|
||||
@@ -381,6 +392,23 @@ export const BackgroundShellDisplay = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderFooter = () => {
|
||||
const pidToDisplay = isListOpenProp
|
||||
? (highlightedPid ?? activePid)
|
||||
: activePid;
|
||||
if (!pidToDisplay) return null;
|
||||
const logPath = ShellExecutionService.getLogFilePath(pidToDisplay);
|
||||
const displayPath = shortenPath(
|
||||
tildeifyPath(logPath),
|
||||
width - LOG_PATH_OVERHEAD,
|
||||
);
|
||||
return (
|
||||
<Box paddingX={1}>
|
||||
<Text color={theme.text.secondary}>Log: {displayPath}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderOutput = () => {
|
||||
const lines = typeof output === 'string' ? output.split('\n') : output;
|
||||
|
||||
@@ -452,6 +480,7 @@ export const BackgroundShellDisplay = ({
|
||||
<Box flexGrow={1} overflow="hidden" paddingX={CONTENT_PADDING_X}>
|
||||
{isListOpenProp ? renderProcessList() : renderOutput()}
|
||||
</Box>
|
||||
{renderFooter()}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -60,6 +60,15 @@ const mockSessionStats: SessionStatsState = {
|
||||
};
|
||||
|
||||
describe('<Footer />', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
vi.stubEnv('SEATBELT_PROFILE', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('renders the component', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
@@ -264,15 +273,6 @@ describe('<Footer />', () => {
|
||||
});
|
||||
|
||||
describe('footer configuration filtering (golden snapshots)', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
vi.stubEnv('SEATBELT_PROFILE', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('renders complete footer with all sections visible (baseline)', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
|
||||
@@ -399,6 +399,7 @@ describe('<ModelStatsDisplay />', () => {
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('gemini-3-pro-');
|
||||
expect(output).toContain('gemini-3-flash-');
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should display role breakdown correctly', () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||
│ Starting server... │
|
||||
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
@@ -16,6 +17,7 @@ exports[`<BackgroundShellDisplay /> > keeps exit code status color even when sel
|
||||
│ 1. npm start (PID: 1001) │
|
||||
│ 2. tail -f log.txt (PID: 1002) │
|
||||
│ ● 3. exit 0 (PID: 1003) (Exit Code: 0) │
|
||||
│ Log: ~/.gemini/tmp/background-processes/background-1003.log │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
@@ -23,6 +25,7 @@ exports[`<BackgroundShellDisplay /> > 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... │
|
||||
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
@@ -30,6 +33,7 @@ exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`]
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||
│ Starting server... │
|
||||
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
@@ -41,6 +45,7 @@ exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenPr
|
||||
│ │
|
||||
│ ● 1. npm start (PID: 1001) │
|
||||
│ 2. tail -f log.txt (PID: 1002) │
|
||||
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
@@ -52,5 +57,6 @@ exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`
|
||||
│ │
|
||||
│ 1. npm start (PID: 1001) │
|
||||
│ ● 2. tail -f log.txt (PID: 1002) │
|
||||
│ Log: ~/.gemini/tmp/background-processes/background-1002.log │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
@@ -76,7 +76,7 @@ export interface UIActions {
|
||||
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
|
||||
handleWarning: (message: string) => void;
|
||||
setEmbeddedShellFocused: (value: boolean) => void;
|
||||
dismissBackgroundShell: (pid: number) => void;
|
||||
dismissBackgroundShell: (pid: number) => Promise<void>;
|
||||
setActiveBackgroundShellPid: (pid: number) => void;
|
||||
setIsBackgroundShellListOpen: (isOpen: boolean) => void;
|
||||
setAuthContext: (context: { requiresRestart?: boolean }) => void;
|
||||
|
||||
@@ -830,8 +830,8 @@ describe('useShellCommandProcessor', () => {
|
||||
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.dismissBackgroundShell(1001);
|
||||
await act(async () => {
|
||||
await result.current.dismissBackgroundShell(1001);
|
||||
});
|
||||
|
||||
expect(mockShellKill).toHaveBeenCalledWith(1001);
|
||||
@@ -936,8 +936,8 @@ describe('useShellCommandProcessor', () => {
|
||||
expect(shell?.exitCode).toBe(1);
|
||||
|
||||
// Now dismiss it
|
||||
act(() => {
|
||||
result.current.dismissBackgroundShell(999);
|
||||
await act(async () => {
|
||||
await result.current.dismissBackgroundShell(999);
|
||||
});
|
||||
expect(result.current.backgroundShellCount).toBe(0);
|
||||
});
|
||||
|
||||
@@ -205,11 +205,11 @@ export const useShellCommandProcessor = (
|
||||
}, [state.activeShellPtyId, activeToolPtyId, m]);
|
||||
|
||||
const dismissBackgroundShell = useCallback(
|
||||
(pid: number) => {
|
||||
async (pid: number) => {
|
||||
const shell = state.backgroundShells.get(pid);
|
||||
if (shell) {
|
||||
if (shell.status === 'running') {
|
||||
ShellExecutionService.kill(pid);
|
||||
await ShellExecutionService.kill(pid);
|
||||
}
|
||||
dispatch({ type: 'DISMISS_SHELL', pid });
|
||||
m.backgroundedPids.delete(pid);
|
||||
|
||||
Reference in New Issue
Block a user