Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ import { OverflowProvider } from './ui/contexts/OverflowContext.js';
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
import { profiler } from './ui/components/DebugProfiler.js';
import { runDeferredCommand } from './deferred.js';
import { cleanupBackgroundLogs } from './utils/logCleanup.js';
import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js';

const SLOW_RENDER_MS = 200;
Expand Down Expand Up @@ -370,6 +371,7 @@ export async function main() {
await Promise.all([
cleanupCheckpoints(),
cleanupToolOutputFiles(settings.merged),
cleanupBackgroundLogs(),
]);

const parseArgsHandle = startupProfiler.start('parse_arguments');
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -473,9 +473,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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,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'),
},
};
});
Expand Down Expand Up @@ -222,7 +226,7 @@ describe('<BackgroundShellDisplay />', () => {
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
76,
21,
20,
);

rerender(
Expand All @@ -242,7 +246,7 @@ describe('<BackgroundShellDisplay />', () => {
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
96,
27,
26,
);
unmount();
});
Expand Down
39 changes: 34 additions & 5 deletions packages/cli/src/ui/components/BackgroundShellDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -43,8 +45,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];
Expand Down Expand Up @@ -81,7 +89,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]);

Expand Down Expand Up @@ -150,7 +158,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;
Expand All @@ -171,7 +179,7 @@ export const BackgroundShellDisplay = ({
}

if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
dismissBackgroundShell(activeShell.pid);
void dismissBackgroundShell(activeShell.pid);
return true;
}

Expand Down Expand Up @@ -336,7 +344,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 },
Expand Down Expand Up @@ -383,6 +394,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;

Expand Down Expand Up @@ -454,6 +482,7 @@ export const BackgroundShellDisplay = ({
<Box flexGrow={1} overflow="hidden" paddingX={CONTENT_PADDING_X}>
{isListOpenProp ? renderProcessList() : renderOutput()}
</Box>
{renderFooter()}
</Box>
);
};
71 changes: 32 additions & 39 deletions packages/cli/src/ui/components/Footer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ describe('<Footer />', () => {
beforeEach(() => {
const root = path.parse(process.cwd()).root;
vi.stubEnv('GEMINI_CLI_HOME', path.join(root, 'Users', 'test'));
vi.stubEnv('SANDBOX', '');
vi.stubEnv('SEATBELT_PROFILE', '');
});

afterEach(() => {
vi.unstubAllEnvs();
});

it('renders the component', async () => {
Expand Down Expand Up @@ -427,15 +433,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)', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
Expand All @@ -459,23 +456,21 @@ describe('<Footer />', () => {
});

it('renders footer with all optional sections hidden (minimal footer)', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: true,
hideModelInfo: true,
},
const { lastFrame, unmount } = renderWithProviders(<Footer />, {
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
hideCWD: true,
hideSandboxStatus: true,
hideModelInfo: true,
},
}),
},
);
await waitUntilReady();
},
}),
});
// Wait for Ink to render
await new Promise((resolve) => setTimeout(resolve, 50));
expect(normalizeFrame(lastFrame({ allowEmpty: true }))).toMatchSnapshot(
'footer-minimal',
);
Expand Down Expand Up @@ -797,21 +792,19 @@ describe('<Footer />', () => {
});

it('handles empty items array', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
items: [],
},
const { lastFrame, unmount } = renderWithProviders(<Footer />, {
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
ui: {
footer: {
items: [],
},
}),
},
);
await waitUntilReady();
},
}),
});
// Wait for Ink to render
await new Promise((resolve) => setTimeout(resolve, 50));

const output = lastFrame({ allowEmpty: true });
expect(output).toBeDefined();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ describe('<ModelStatsDisplay />', () => {
const output = lastFrame();
expect(output).toContain('gemini-3-pro-');
expect(output).toContain('gemini-3-flash-');
expect(output).toMatchSnapshot();
unmount();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
│ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │
│ (Focused) (Ctrl+L) │
│ Starting server... │
│ Log: ~/.gemini/tmp/background-processes/background-1001.log │
└──────────────────────────────────────────────────────────────────────────────┘
"
`;
Expand All @@ -19,6 +20,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 │
└──────────────────────────────────────────────────────────────────────────────┘
"
`;
Expand All @@ -27,6 +29,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 │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘
"
`;
Expand All @@ -35,6 +38,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 │
└──────────────────────────────────────────────────────────────────────────────┘
"
`;
Expand All @@ -48,6 +52,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 │
└──────────────────────────────────────────────────────────────────────────────┘
"
`;
Expand All @@ -61,6 +66,7 @@ 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 │
└──────────────────────────────────────────────────────────────────────────────┘
"
`;
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,29 @@ exports[`<ModelStatsDisplay /> > should handle long role name layout 1`] = `
"
`;

exports[`<ModelStatsDisplay /> > should handle models with long names (gemini-3-*-preview) without layout breaking 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ │
│ Auto (Gemini 3) Stats For Nerds │
│ │
│ │
│ Metric gemini-3-pro-preview gemini-3-flash-preview │
│ ────────────────────────────────────────────────────────────────────────── │
│ API │
│ Requests 10 20 │
│ Errors 0 (0.0%) 0 (0.0%) │
│ Avg Latency 200ms 50ms │
│ Tokens │
│ Total 6,000 12,000 │
│ ↳ Input 1,000 2,000 │
│ ↳ Cache Reads 500 (25.0%) 1,000 (25.0%) │
│ ↳ Thoughts 100 200 │
│ ↳ Tool 50 100 │
│ ↳ Output 4,000 8,000 │
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;

exports[`<ModelStatsDisplay /> > should not display conditional rows if no model has data for them 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/contexts/UIActionsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,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;
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
});
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/ui/hooks/shellCommandProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading