diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 8e9ff7380f..677fc565bb 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -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 { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 2d2fd01067..34e2f0dd55 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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.', }, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c18b9f24e8..c577546351 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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 ; + } + return ( diff --git a/packages/cli/src/ui/components/SessionRetentionWarningDialog.test.tsx b/packages/cli/src/ui/components/SessionRetentionWarningDialog.test.tsx new file mode 100644 index 0000000000..7945d2475a --- /dev/null +++ b/packages/cli/src/ui/components/SessionRetentionWarningDialog.test.tsx @@ -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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + // Move down to "Strict Cleanup" + act(() => { + stdin.write('\x1b[B'); // Arrow Down + }); + + // Press Enter + act(() => { + stdin.write('\r'); + }); + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith('strict'); + }); + }); +}); diff --git a/packages/cli/src/ui/components/SessionRetentionWarningDialog.tsx b/packages/cli/src/ui/components/SessionRetentionWarningDialog.tsx new file mode 100644 index 0000000000..9e96162d05 --- /dev/null +++ b/packages/cli/src/ui/components/SessionRetentionWarningDialog.tsx @@ -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> = [ + { + 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 ( + + + + Session Retention Policy + + + + + To save disk space, we recommend enabling automatic cleanup of old + sessions. Please choose how to apply the 60-day retention policy: + + + + + ); +}; diff --git a/packages/cli/src/utils/sessionCleanup.ts b/packages/cli/src/utils/sessionCleanup.ts index 6004cb8c5d..818d5000f1 100644 --- a/packages/cli/src/utils/sessionCleanup.ts +++ b/packages/cli/src/utils/sessionCleanup.ts @@ -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