mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-01 04:31:03 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fda45d9f6 | |||
| ef02cec2cd |
@@ -181,6 +181,9 @@ export interface SessionRetentionSettings {
|
||||
|
||||
/** Minimum retention period (safety limit, defaults to "1d") */
|
||||
minRetention?: string;
|
||||
|
||||
/** Timestamp (ISO string or ms) to start age checks from for existing files. */
|
||||
gracePeriodStart?: string;
|
||||
}
|
||||
|
||||
export interface SettingsError {
|
||||
|
||||
@@ -319,6 +319,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: `Minimum retention period (safety limit, defaults to "${DEFAULT_MIN_RETENTION}")`,
|
||||
showInDialog: false,
|
||||
},
|
||||
gracePeriodStart: {
|
||||
type: 'string',
|
||||
label: 'Grace Period Start',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'Timestamp (ISO string) to start age checks from for existing files.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
description: 'Settings for automatic session cleanup.',
|
||||
},
|
||||
|
||||
@@ -141,6 +141,10 @@ import {
|
||||
QUEUE_ERROR_DISPLAY_DURATION_MS,
|
||||
} from './constants.js';
|
||||
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
||||
import {
|
||||
type SessionRetentionChoice,
|
||||
SessionRetentionWarningDialog,
|
||||
} from './components/SessionRetentionWarningDialog.js';
|
||||
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
||||
import { isSlashCommand } from './utils/commandUtils.js';
|
||||
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
||||
@@ -242,6 +246,49 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const [warningBannerText, setWarningBannerText] = useState('');
|
||||
const [bannerVisible, setBannerVisible] = useState(true);
|
||||
|
||||
const [showRetentionWarning, setShowRetentionWarning] = useState(false);
|
||||
const retentionCheckDone = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (retentionCheckDone.current) return;
|
||||
retentionCheckDone.current = true;
|
||||
|
||||
// We only want to show this if the user hasn't explicitly set it in User or Workspace scope.
|
||||
const userSettings = settings.user.settings;
|
||||
const workspaceSettings = settings.workspace.settings;
|
||||
|
||||
const userRetention = userSettings.general?.sessionRetention;
|
||||
const workspaceRetention = workspaceSettings.general?.sessionRetention;
|
||||
|
||||
// Check if it's completely undefined in both explicit scopes
|
||||
if (userRetention === undefined && workspaceRetention === undefined) {
|
||||
setShowRetentionWarning(true);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
const handleRetentionConfirm = useCallback(
|
||||
(choice: SessionRetentionChoice) => {
|
||||
const now = new Date().toISOString();
|
||||
const retentionSettings: any = {
|
||||
enabled: true,
|
||||
maxAge: '60d',
|
||||
};
|
||||
|
||||
if (choice === 'graceful') {
|
||||
retentionSettings.gracePeriodStart = now;
|
||||
}
|
||||
|
||||
// Store in Workspace scope as requested
|
||||
settings.setValue(
|
||||
SettingScope.Workspace,
|
||||
'general.sessionRetention',
|
||||
retentionSettings,
|
||||
);
|
||||
setShowRetentionWarning(false);
|
||||
},
|
||||
[settings],
|
||||
);
|
||||
|
||||
const bannerData = useMemo(
|
||||
() => ({
|
||||
defaultText: defaultBannerText,
|
||||
@@ -2231,6 +2278,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
);
|
||||
}
|
||||
|
||||
if (showRetentionWarning) {
|
||||
return <SessionRetentionWarningDialog onConfirm={handleRetentionConfirm} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<UIActionsContext.Provider value={uiActions}>
|
||||
|
||||
@@ -89,6 +89,8 @@ describe('MainContent', () => {
|
||||
historyRemountKey: 0,
|
||||
bannerData: { defaultText: '', warningText: '' },
|
||||
bannerVisible: false,
|
||||
copyModeEnabled: false,
|
||||
terminalWidth: 100,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -173,6 +175,7 @@ describe('MainContent', () => {
|
||||
vi.mocked(useAlternateBuffer).mockReturnValue(isAlternateBuffer);
|
||||
const ptyId = 123;
|
||||
const uiState = {
|
||||
...defaultMockUiState,
|
||||
history: [],
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js';
|
||||
import { act } from 'react';
|
||||
|
||||
describe('SessionRetentionWarningDialog', () => {
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should render correctly', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<SessionRetentionWarningDialog onConfirm={onConfirm} />,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
expect(lastFrame()).toContain('Session Retention Policy');
|
||||
expect(lastFrame()).toContain('Graceful Cleanup');
|
||||
expect(lastFrame()).toContain('Strict Cleanup');
|
||||
});
|
||||
|
||||
it('should confirm with "graceful" when selecting "Graceful Cleanup"', async () => {
|
||||
const { stdin } = renderWithProviders(
|
||||
<SessionRetentionWarningDialog onConfirm={onConfirm} />,
|
||||
);
|
||||
|
||||
// Default selection is "Graceful Cleanup", so just press Enter
|
||||
act(() => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConfirm).toHaveBeenCalledWith('graceful');
|
||||
});
|
||||
});
|
||||
|
||||
it('should confirm with "strict" when selecting "Strict Cleanup"', async () => {
|
||||
const { stdin } = renderWithProviders(
|
||||
<SessionRetentionWarningDialog onConfirm={onConfirm} />,
|
||||
);
|
||||
|
||||
// Move down to "Strict Cleanup"
|
||||
act(() => {
|
||||
stdin.write('\x1b[B'); // Arrow Down
|
||||
});
|
||||
|
||||
// Press Enter
|
||||
act(() => {
|
||||
stdin.write('\r');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onConfirm).toHaveBeenCalledWith('strict');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import {
|
||||
DescriptiveRadioButtonSelect,
|
||||
type DescriptiveRadioSelectItem,
|
||||
} from './shared/DescriptiveRadioButtonSelect.js';
|
||||
|
||||
export type SessionRetentionChoice = 'graceful' | 'strict';
|
||||
|
||||
interface SessionRetentionWarningDialogProps {
|
||||
onConfirm: (choice: SessionRetentionChoice) => void;
|
||||
}
|
||||
|
||||
export const SessionRetentionWarningDialog = ({
|
||||
onConfirm,
|
||||
}: SessionRetentionWarningDialogProps): React.JSX.Element => {
|
||||
const items: Array<DescriptiveRadioSelectItem<SessionRetentionChoice>> = [
|
||||
{
|
||||
key: 'graceful',
|
||||
title: 'Graceful Cleanup (Recommended)',
|
||||
description:
|
||||
'Start 60-day limit from today. Existing sessions are safe for now.',
|
||||
value: 'graceful',
|
||||
},
|
||||
{
|
||||
key: 'strict',
|
||||
title: 'Strict Cleanup',
|
||||
description:
|
||||
'Apply 60-day limit immediately. Older sessions will be deleted.',
|
||||
value: 'strict',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width={60}
|
||||
>
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.status.warning} bold>
|
||||
Session Retention Policy
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginBottom={1}>
|
||||
<Text>
|
||||
To save disk space, we recommend enabling automatic cleanup of old
|
||||
sessions. Please choose how to apply the 60-day retention policy:
|
||||
</Text>
|
||||
</Box>
|
||||
<DescriptiveRadioButtonSelect
|
||||
items={items}
|
||||
onSelect={onConfirm}
|
||||
initialIndex={0}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -107,9 +107,9 @@ ShowMoreLines"
|
||||
exports[`MainContent > does not constrain height in alternate buffer mode 1`] = `
|
||||
"ScrollableList
|
||||
AppHeader
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Hello
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
✦ Hi there
|
||||
ShowMoreLines
|
||||
"
|
||||
|
||||
@@ -14,6 +14,12 @@ import { MouseProvider } from '../../contexts/MouseContext.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
|
||||
vi.mock('../../contexts/UIStateContext.js', () => ({
|
||||
useUIState: vi.fn(() => ({
|
||||
copyModeEnabled: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock useStdout to provide a fixed size for testing
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ink')>();
|
||||
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { UIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
vi.mock('../../contexts/UIStateContext.js', () => ({
|
||||
useUIState: vi.fn(() => ({
|
||||
copyModeEnabled: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@@ -324,4 +331,39 @@ describe('<VirtualizedList />', () => {
|
||||
|
||||
expect(ref.current?.getScrollState().scrollTop).toBe(4);
|
||||
});
|
||||
|
||||
it('renders correctly in copyModeEnabled when scrolled', async () => {
|
||||
const { useUIState } = await import('../../contexts/UIStateContext.js');
|
||||
vi.mocked(useUIState).mockReturnValue({
|
||||
copyModeEnabled: true,
|
||||
} as Partial<UIState> as UIState);
|
||||
|
||||
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
|
||||
// Use copy mode
|
||||
const { lastFrame } = render(
|
||||
<Box height={10} width={100}>
|
||||
<VirtualizedList
|
||||
data={longData}
|
||||
renderItem={({ item }) => (
|
||||
<Box height={1}>
|
||||
<Text>{item}</Text>
|
||||
</Box>
|
||||
)}
|
||||
keyExtractor={(item) => item}
|
||||
estimatedItemHeight={() => 1}
|
||||
initialScrollIndex={50}
|
||||
/>
|
||||
</Box>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
// Item 50 should be visible
|
||||
expect(lastFrame()).toContain('Item 50');
|
||||
// And surrounding items
|
||||
expect(lastFrame()).toContain('Item 59');
|
||||
// But far away items should not be (ensures we are actually scrolled)
|
||||
expect(lastFrame()).not.toContain('Item 0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import type React from 'react';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
|
||||
import { type DOMElement, measureElement, Box } from 'ink';
|
||||
|
||||
@@ -78,6 +79,7 @@ function VirtualizedList<T>(
|
||||
initialScrollIndex,
|
||||
initialScrollOffsetInIndex,
|
||||
} = props;
|
||||
const { copyModeEnabled } = useUIState();
|
||||
const dataRef = useRef(data);
|
||||
useEffect(() => {
|
||||
dataRef.current = data;
|
||||
@@ -474,16 +476,21 @@ function VirtualizedList<T>(
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
overflowY="scroll"
|
||||
overflowY={copyModeEnabled ? 'hidden' : 'scroll'}
|
||||
overflowX="hidden"
|
||||
scrollTop={scrollTop}
|
||||
scrollTop={copyModeEnabled ? 0 : scrollTop}
|
||||
scrollbarThumbColor={props.scrollbarThumbColor ?? theme.text.secondary}
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
paddingRight={1}
|
||||
paddingRight={copyModeEnabled ? 0 : 1}
|
||||
>
|
||||
<Box flexShrink={0} width="100%" flexDirection="column">
|
||||
<Box
|
||||
flexShrink={0}
|
||||
width="100%"
|
||||
flexDirection="column"
|
||||
marginTop={copyModeEnabled ? -scrollTop : 0}
|
||||
>
|
||||
<Box height={topSpacerHeight} flexShrink={0} />
|
||||
{renderedItems}
|
||||
<Box height={bottomSpacerHeight} flexShrink={0} />
|
||||
|
||||
@@ -31,7 +31,9 @@ export const DefaultAppLayout: React.FC = () => {
|
||||
flexDirection="column"
|
||||
width={uiState.terminalWidth}
|
||||
height={isAlternateBuffer ? terminalHeight : undefined}
|
||||
paddingBottom={isAlternateBuffer ? 1 : undefined}
|
||||
paddingBottom={
|
||||
isAlternateBuffer && !uiState.copyModeEnabled ? 1 : undefined
|
||||
}
|
||||
flexShrink={0}
|
||||
flexGrow={0}
|
||||
overflow="hidden"
|
||||
|
||||
@@ -198,7 +198,66 @@ async function identifySessionsToDelete(
|
||||
if (retentionConfig.maxAge) {
|
||||
try {
|
||||
const maxAgeMs = parseRetentionPeriod(retentionConfig.maxAge);
|
||||
cutoffDate = new Date(now.getTime() - maxAgeMs);
|
||||
const nowMs = now.getTime();
|
||||
|
||||
// If gracePeriodStart is set, we treat files as if they were created at
|
||||
// max(file.mtime, gracePeriodStart) for the purpose of age check?
|
||||
// actually, simpler:
|
||||
// The deletion condition is:
|
||||
// age > maxAge AND (now > gracePeriodStart + maxAge)
|
||||
// Wait, if I have a file from 2020.
|
||||
// gracePeriodStart = Today.
|
||||
// I want it to survive for 60 days.
|
||||
// So effectively, the file's "effective mtime" is max(real_mtime, gracePeriodStart).
|
||||
// We check if (now - effective_mtime) > maxAge.
|
||||
|
||||
if (retentionConfig.gracePeriodStart) {
|
||||
const graceStart = new Date(retentionConfig.gracePeriodStart).getTime();
|
||||
if (!isNaN(graceStart)) {
|
||||
// We can't set a single cutoff date because it depends on the file's mtime vs graceStart?
|
||||
// Actually, we can.
|
||||
// effectiveAge = now - max(mtime, graceStart)
|
||||
// We want effectiveAge > maxAge
|
||||
// now - max(mtime, graceStart) > maxAge
|
||||
// max(mtime, graceStart) < now - maxAge
|
||||
// mtime < now - maxAge AND graceStart < now - maxAge
|
||||
// So, BOTH must be true.
|
||||
// If graceStart is recent (today), then `graceStart < now - maxAge` is FALSE.
|
||||
// So nothing gets deleted.
|
||||
// This works perfectly!
|
||||
// So checking `graceStart < now - maxAge` is a global gate.
|
||||
// If we are within the grace period (globally), we delete NOTHING based on age?
|
||||
// Wait, what if a file was created *after* graceStart?
|
||||
// mtime (Post-Grace) < now - maxAge.
|
||||
// If graceStart is today.
|
||||
// File created tomorrow.
|
||||
// now is tomorrow + 61 days.
|
||||
// mtime < now - maxAge (True).
|
||||
// graceStart < now - maxAge (True, because graceStart was 61 days ago).
|
||||
// So it deletes.
|
||||
// This logic holds up.
|
||||
// We just need to ensure we use the SAME cutoff for all files?
|
||||
// Yes, `cutoffDate` is `now - maxAge`.
|
||||
// And we also verify `graceStart < cutoffDate`.
|
||||
if (graceStart > nowMs - maxAgeMs) {
|
||||
// We are still within the global grace period.
|
||||
// UNLESS the file is somehow older than maxAge AND older than graceStart?
|
||||
// No, if `graceStart > cutoffDate`, then `max(mtime, graceStart)` is at least `graceStart`.
|
||||
// `graceStart > cutoffDate` means `graceStart > now - maxAge`.
|
||||
// So `now - graceStart < maxAge`.
|
||||
// So effective age is ALWAYS < maxAge.
|
||||
// So NOTHING deletes.
|
||||
cutoffDate = null; // Disable age cleanup
|
||||
} else {
|
||||
cutoffDate = new Date(nowMs - maxAgeMs);
|
||||
}
|
||||
} else {
|
||||
cutoffDate = new Date(nowMs - maxAgeMs);
|
||||
}
|
||||
} else {
|
||||
cutoffDate = new Date(nowMs - maxAgeMs);
|
||||
}
|
||||
|
||||
} catch {
|
||||
// This should not happen as validation should have caught it,
|
||||
// but handle gracefully just in case
|
||||
|
||||
Reference in New Issue
Block a user