diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 4d060ba5b6..174c8d2299 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -30,6 +30,7 @@ they appear in the UI. | Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` | | Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | | Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` | +| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` | ### Output diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index cb2e93ddb6..5f6b89b9a2 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -157,8 +157,8 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **`general.sessionRetention.maxAge`** (string): - - **Description:** Maximum age of sessions to keep (e.g., "30d", "7d", "24h", - "1w") + - **Description:** Automatically delete chats older than this time period + (e.g., "30d", "7d", "24h", "1w") - **Default:** `undefined` - **`general.sessionRetention.maxCount`** (number): @@ -170,6 +170,11 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Minimum retention period (safety limit, defaults to "1d") - **Default:** `"1d"` +- **`general.sessionRetention.warningAcknowledged`** (boolean): + - **Description:** INTERNAL: Whether the user has acknowledged the session + retention warning + - **Default:** `false` + #### `output` - **`output.format`** (enum): diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index b2b526a010..506cbef838 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -182,6 +182,9 @@ export interface SessionRetentionSettings { /** Minimum retention period (safety limit, defaults to "1d") */ minRetention?: string; + + /** INTERNAL: Whether the user has acknowledged the session retention warning */ + warningAcknowledged?: boolean; } export interface SettingsError { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index b486956211..b6b764808f 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -304,13 +304,13 @@ const SETTINGS_SCHEMA = { }, maxAge: { type: 'string', - label: 'Max Session Age', + label: 'Keep chat history', category: 'General', requiresRestart: false, default: undefined as string | undefined, description: - 'Maximum age of sessions to keep (e.g., "30d", "7d", "24h", "1w")', - showInDialog: false, + 'Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w")', + showInDialog: true, }, maxCount: { type: 'number', @@ -331,6 +331,16 @@ const SETTINGS_SCHEMA = { description: `Minimum retention period (safety limit, defaults to "${DEFAULT_MIN_RETENTION}")`, showInDialog: false, }, + warningAcknowledged: { + type: 'boolean', + label: 'Warning Acknowledged', + category: 'General', + requiresRestart: false, + default: false, + showInDialog: false, + description: + 'INTERNAL: Whether the user has acknowledged the session retention warning', + }, }, description: 'Settings for automatic session cleanup.', }, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index aaad9464f1..31e90f824e 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -137,6 +137,7 @@ import { useSessionBrowser } from './hooks/useSessionBrowser.js'; import { persistentState } from '../utils/persistentState.js'; import { useSessionResume } from './hooks/useSessionResume.js'; import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js'; +import { useSessionRetentionCheck } from './hooks/useSessionRetentionCheck.js'; import { isWorkspaceTrusted } from '../config/trustedFolders.js'; import { useAlternateBuffer } from './hooks/useAlternateBuffer.js'; import { useSettings } from './contexts/SettingsContext.js'; @@ -1422,6 +1423,28 @@ Logging in with Google... Restarting Gemini CLI to continue. useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog); + const handleAutoEnableRetention = useCallback(() => { + const userSettings = settings.forScope(SettingScope.User).settings; + const currentRetention = userSettings.general?.sessionRetention ?? {}; + + settings.setValue(SettingScope.User, 'general.sessionRetention', { + ...currentRetention, + enabled: true, + maxAge: '30d', + warningAcknowledged: true, + }); + }, [settings]); + + const { + shouldShowWarning: shouldShowRetentionWarning, + checkComplete: retentionCheckComplete, + sessionsToDeleteCount, + } = useSessionRetentionCheck( + config, + settings.merged, + handleAutoEnableRetention, + ); + const tabFocusTimeoutRef = useRef(null); useEffect(() => { @@ -1900,6 +1923,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const nightly = props.version.includes('nightly'); const dialogsVisible = + (shouldShowRetentionWarning && retentionCheckComplete) || shouldShowIdePrompt || isFolderTrustDialogOpen || adminSettingsChanged || @@ -2012,6 +2036,9 @@ Logging in with Google... Restarting Gemini CLI to continue. history: historyManager.history, historyManager, isThemeDialogOpen, + shouldShowRetentionWarning: + shouldShowRetentionWarning && retentionCheckComplete, + sessionsToDeleteCount: sessionsToDeleteCount ?? 0, themeError, isAuthenticating, isConfigInitialized, @@ -2125,6 +2152,9 @@ Logging in with Google... Restarting Gemini CLI to continue. }), [ isThemeDialogOpen, + shouldShowRetentionWarning, + retentionCheckComplete, + sessionsToDeleteCount, themeError, isAuthenticating, isConfigInitialized, diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index e4e2f4a6e6..b28f5de218 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -34,6 +34,9 @@ import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js'; import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; import { NewAgentsNotification } from './NewAgentsNotification.js'; import { AgentConfigDialog } from './AgentConfigDialog.js'; +import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js'; +import { useCallback } from 'react'; +import { SettingScope } from '../../config/settings.js'; interface DialogManagerProps { addItem: UseHistoryManagerReturn['addItem']; @@ -55,8 +58,56 @@ export const DialogManager = ({ terminalHeight, staticExtraHeight, terminalWidth: uiTerminalWidth, + shouldShowRetentionWarning, + sessionsToDeleteCount, } = uiState; + const handleKeep120Days = useCallback(() => { + settings.setValue( + SettingScope.User, + 'general.sessionRetention.warningAcknowledged', + true, + ); + settings.setValue( + SettingScope.User, + 'general.sessionRetention.enabled', + true, + ); + settings.setValue( + SettingScope.User, + 'general.sessionRetention.maxAge', + '120d', + ); + }, [settings]); + + const handleKeep30Days = useCallback(() => { + settings.setValue( + SettingScope.User, + 'general.sessionRetention.warningAcknowledged', + true, + ); + settings.setValue( + SettingScope.User, + 'general.sessionRetention.enabled', + true, + ); + settings.setValue( + SettingScope.User, + 'general.sessionRetention.maxAge', + '30d', + ); + }, [settings]); + + if (shouldShowRetentionWarning && sessionsToDeleteCount !== undefined) { + return ( + + ); + } + if (uiState.adminSettingsChanged) { 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..b4fde4af63 --- /dev/null +++ b/packages/cli/src/ui/components/SessionRetentionWarningDialog.test.tsx @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + * + * @license + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js'; +import { waitFor } from '../../test-utils/async.js'; +import { act } from 'react'; + +// Helper to write to stdin +const writeKey = (stdin: { write: (data: string) => void }, key: string) => { + act(() => { + stdin.write(key); + }); +}; + +describe('SessionRetentionWarningDialog', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders correctly with warning message and session count', () => { + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('Keep chat history'); + expect(lastFrame()).toContain( + 'introducing a limit on how long chat sessions are stored', + ); + expect(lastFrame()).toContain('Keep for 30 days (Recommended)'); + expect(lastFrame()).toContain('42 sessions will be deleted'); + expect(lastFrame()).toContain('Keep for 120 days'); + expect(lastFrame()).toContain('No sessions will be deleted at this time'); + }); + + it('handles pluralization correctly for 1 session', () => { + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('1 session will be deleted'); + }); + + it('defaults to "Keep for 120 days" when there are sessions to delete', async () => { + const onKeep120Days = vi.fn(); + const onKeep30Days = vi.fn(); + + const { stdin } = renderWithProviders( + , + ); + + // Initial selection should be "Keep for 120 days" (index 1) because count > 0 + // Pressing Enter immediately should select it. + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(onKeep120Days).toHaveBeenCalled(); + expect(onKeep30Days).not.toHaveBeenCalled(); + }); + }); + + it('calls onKeep30Days when "Keep for 30 days" is explicitly selected (from 120 days default)', async () => { + const onKeep120Days = vi.fn(); + const onKeep30Days = vi.fn(); + + const { stdin } = renderWithProviders( + , + ); + + // Default is index 1 (120 days). Move UP to index 0 (30 days). + writeKey(stdin, '\x1b[A'); // Up arrow + writeKey(stdin, '\r'); + + await waitFor(() => { + expect(onKeep30Days).toHaveBeenCalled(); + expect(onKeep120Days).not.toHaveBeenCalled(); + }); + }); + + it('should match snapshot', async () => { + const { lastFrame } = renderWithProviders( + , + ); + + // Initial render + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/SessionRetentionWarningDialog.tsx b/packages/cli/src/ui/components/SessionRetentionWarningDialog.tsx new file mode 100644 index 0000000000..cd0477105c --- /dev/null +++ b/packages/cli/src/ui/components/SessionRetentionWarningDialog.tsx @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { + RadioButtonSelect, + type RadioSelectItem, +} from './shared/RadioButtonSelect.js'; + +interface SessionRetentionWarningDialogProps { + onKeep120Days: () => void; + onKeep30Days: () => void; + sessionsToDeleteCount: number; +} + +export const SessionRetentionWarningDialog = ({ + onKeep120Days, + onKeep30Days, + sessionsToDeleteCount, +}: SessionRetentionWarningDialogProps) => { + const options: Array void>> = [ + { + label: 'Keep for 30 days (Recommended)', + value: onKeep30Days, + key: '30days', + sublabel: `${sessionsToDeleteCount} session${ + sessionsToDeleteCount === 1 ? '' : 's' + } will be deleted`, + }, + { + label: 'Keep for 120 days', + value: onKeep120Days, + key: '120days', + sublabel: 'No sessions will be deleted at this time', + }, + ]; + + return ( + + + Keep chat history + + + + + To keep your workspace clean, we are introducing a limit on how long + chat sessions are stored. Please choose a retention period for your + existing chats: + + + + + action()} + initialIndex={1} + /> + + + + + Set a custom limit /settings{' '} + and change "Keep chat history". + + + + ); +}; diff --git a/packages/cli/src/ui/components/__snapshots__/SessionRetentionWarningDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionRetentionWarningDialog.test.tsx.snap new file mode 100644 index 0000000000..2e0f5c8e97 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/SessionRetentionWarningDialog.test.tsx.snap @@ -0,0 +1,20 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SessionRetentionWarningDialog > should match snapshot 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ Keep chat history │ +│ │ +│ To keep your workspace clean, we are introducing a limit on how long chat sessions are stored. Please choose a │ +│ retention period for your existing chats: │ +│ │ +│ │ +│ 1. Keep for 30 days (Recommended) │ +│ 123 sessions will be deleted │ +│ ● 2. Keep for 120 days │ +│ No sessions will be deleted at this time │ +│ │ +│ Set a custom limit /settings and change "Keep chat history". │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index b6c7b64496..2252594d4b 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -28,12 +28,12 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ +│ Keep chat history undefined │ +│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ +│ │ │ Output Format Text │ │ The format of the CLI output. Can be \`text\` or \`json\`. │ │ │ -│ Auto Theme Switching true │ -│ Automatically switch between default light and dark themes based on terminal backgro… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -74,12 +74,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ +│ Keep chat history undefined │ +│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ +│ │ │ Output Format Text │ │ The format of the CLI output. Can be \`text\` or \`json\`. │ │ │ -│ Auto Theme Switching true │ -│ Automatically switch between default light and dark themes based on terminal backgro… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -120,12 +120,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ +│ Keep chat history undefined │ +│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ +│ │ │ Output Format Text │ │ The format of the CLI output. Can be \`text\` or \`json\`. │ │ │ -│ Auto Theme Switching true │ -│ Automatically switch between default light and dark themes based on terminal backgro… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -166,12 +166,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ +│ Keep chat history undefined │ +│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ +│ │ │ Output Format Text │ │ The format of the CLI output. Can be \`text\` or \`json\`. │ │ │ -│ Auto Theme Switching true │ -│ Automatically switch between default light and dark themes based on terminal backgro… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -212,12 +212,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ +│ Keep chat history undefined │ +│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ +│ │ │ Output Format Text │ │ The format of the CLI output. Can be \`text\` or \`json\`. │ │ │ -│ Auto Theme Switching true │ -│ Automatically switch between default light and dark themes based on terminal backgro… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -258,12 +258,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ +│ Keep chat history undefined │ +│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ +│ │ │ Output Format Text │ │ The format of the CLI output. Can be \`text\` or \`json\`. │ │ │ -│ Auto Theme Switching true │ -│ Automatically switch between default light and dark themes based on terminal backgro… │ -│ │ │ ▼ │ │ │ │ > Apply To │ @@ -304,12 +304,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ +│ Keep chat history undefined │ +│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ +│ │ │ Output Format Text │ │ The format of the CLI output. Can be \`text\` or \`json\`. │ │ │ -│ Auto Theme Switching true │ -│ Automatically switch between default light and dark themes based on terminal backgro… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -350,12 +350,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ +│ Keep chat history undefined │ +│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ +│ │ │ Output Format Text │ │ The format of the CLI output. Can be \`text\` or \`json\`. │ │ │ -│ Auto Theme Switching true │ -│ Automatically switch between default light and dark themes based on terminal backgro… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -396,12 +396,12 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ +│ Keep chat history undefined │ +│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ +│ │ │ Output Format Text │ │ The format of the CLI output. Can be \`text\` or \`json\`. │ │ │ -│ Auto Theme Switching true │ -│ Automatically switch between default light and dark themes based on terminal backgro… │ -│ │ │ ▼ │ │ │ │ Apply To │ diff --git a/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx b/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx index 1b15bc6f9e..33c77f1a25 100644 --- a/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx +++ b/packages/cli/src/ui/components/shared/RadioButtonSelect.test.tsx @@ -6,6 +6,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderWithProviders } from '../../../test-utils/render.js'; +import type { Text } from 'ink'; +import { Box } from 'ink'; import type React from 'react'; import { RadioButtonSelect, @@ -144,9 +146,16 @@ describe('RadioButtonSelect', () => { const result = renderItem(item, mockContext); - expect(result?.props?.color).toBe(mockContext.titleColor); - expect(result?.props?.children).toBe('Option 1'); - expect(result?.props?.wrap).toBe('truncate'); + expect(result.type).toBe(Box); + const props = result.props as { children: React.ReactNode }; + const textComponent = (props.children as React.ReactElement[])[0]; + const textProps = textComponent?.props as React.ComponentProps< + typeof Text + >; + + expect(textProps?.color).toBe(mockContext.titleColor); + expect(textProps?.children).toBe('Option 1'); + expect(textProps?.wrap).toBe('truncate'); }); it('should render the special theme display when theme props are present', () => { @@ -192,7 +201,13 @@ describe('RadioButtonSelect', () => { const result = renderItem(partialThemeItem, mockContext); - expect(result?.props?.children).toBe('Incomplete Theme'); + expect(result.type).toBe(Box); + const props = result.props as { children: React.ReactNode }; + const textComponent = (props.children as React.ReactElement[])[0]; + const textProps = textComponent?.props as React.ComponentProps< + typeof Text + >; + expect(textProps?.children).toBe('Incomplete Theme'); }); }); }); diff --git a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx index f21d6ce4c9..cb5b44d81b 100644 --- a/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx +++ b/packages/cli/src/ui/components/shared/RadioButtonSelect.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { Text } from 'ink'; +import { Text, Box } from 'ink'; import { theme } from '../../semantic-colors.js'; import { BaseSelectionList, @@ -19,6 +19,7 @@ import type { SelectionListItem } from '../../hooks/useSelectionList.js'; */ export interface RadioSelectItem extends SelectionListItem { label: string; + sublabel?: string; themeNameDisplay?: string; themeTypeDisplay?: string; } @@ -98,9 +99,16 @@ export function RadioButtonSelect({ } // Regular label display return ( - - {item.label} - + + + {item.label} + + {item.sublabel && ( + + {item.sublabel} + + )} + ); }) } diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 54bde1732a..159ffd21fc 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -66,6 +66,8 @@ export interface UIState { history: HistoryItem[]; historyManager: UseHistoryManagerReturn; isThemeDialogOpen: boolean; + shouldShowRetentionWarning: boolean; + sessionsToDeleteCount: number; themeError: string | null; isAuthenticating: boolean; isConfigInitialized: boolean; diff --git a/packages/cli/src/ui/hooks/useSessionRetentionCheck.test.ts b/packages/cli/src/ui/hooks/useSessionRetentionCheck.test.ts new file mode 100644 index 0000000000..67e5efbc6b --- /dev/null +++ b/packages/cli/src/ui/hooks/useSessionRetentionCheck.test.ts @@ -0,0 +1,217 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook } from '../../test-utils/render.js'; +import { useSessionRetentionCheck } from './useSessionRetentionCheck.js'; +import { type Config } from '@google/gemini-cli-core'; +import type { Settings } from '../../config/settingsSchema.js'; +import { waitFor } from '../../test-utils/async.js'; + +// Mock utils +const mockGetAllSessionFiles = vi.fn(); +const mockIdentifySessionsToDelete = vi.fn(); + +vi.mock('../../utils/sessionUtils.js', () => ({ + getAllSessionFiles: () => mockGetAllSessionFiles(), +})); + +vi.mock('../../utils/sessionCleanup.js', () => ({ + identifySessionsToDelete: () => mockIdentifySessionsToDelete(), + DEFAULT_MIN_RETENTION: '30d', +})); + +describe('useSessionRetentionCheck', () => { + const mockConfig = { + storage: { + getProjectTempDir: () => '/mock/project/temp/dir', + }, + getSessionId: () => 'mock-session-id', + } as unknown as Config; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should show warning if enabled is true but maxAge is undefined', async () => { + const settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: undefined, + warningAcknowledged: false, + }, + }, + } as unknown as Settings; + + mockGetAllSessionFiles.mockResolvedValue(['session1.json']); + mockIdentifySessionsToDelete.mockResolvedValue(['session1.json']); + + const { result } = renderHook(() => + useSessionRetentionCheck(mockConfig, settings), + ); + + await waitFor(() => { + expect(result.current.checkComplete).toBe(true); + expect(result.current.shouldShowWarning).toBe(true); + expect(mockGetAllSessionFiles).toHaveBeenCalled(); + expect(mockIdentifySessionsToDelete).toHaveBeenCalled(); + }); + }); + + it('should not show warning if warningAcknowledged is true', async () => { + const settings = { + general: { + sessionRetention: { + warningAcknowledged: true, + }, + }, + } as unknown as Settings; + + const { result } = renderHook(() => + useSessionRetentionCheck(mockConfig, settings), + ); + + await waitFor(() => { + expect(result.current.checkComplete).toBe(true); + expect(result.current.shouldShowWarning).toBe(false); + expect(mockGetAllSessionFiles).not.toHaveBeenCalled(); + expect(mockIdentifySessionsToDelete).not.toHaveBeenCalled(); + }); + }); + + it('should not show warning if retention is already enabled', async () => { + const settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '30d', // Explicitly enabled with non-default + }, + }, + } as unknown as Settings; + + const { result } = renderHook(() => + useSessionRetentionCheck(mockConfig, settings), + ); + + await waitFor(() => { + expect(result.current.checkComplete).toBe(true); + expect(result.current.shouldShowWarning).toBe(false); + expect(mockGetAllSessionFiles).not.toHaveBeenCalled(); + expect(mockIdentifySessionsToDelete).not.toHaveBeenCalled(); + }); + }); + + it('should show warning if sessions to delete exist', async () => { + const settings = { + general: { + sessionRetention: { + enabled: false, + warningAcknowledged: false, + }, + }, + } as unknown as Settings; + + mockGetAllSessionFiles.mockResolvedValue([ + 'session1.json', + 'session2.json', + ]); + mockIdentifySessionsToDelete.mockResolvedValue(['session1.json']); // 1 session to delete + + const { result } = renderHook(() => + useSessionRetentionCheck(mockConfig, settings), + ); + + await waitFor(() => { + expect(result.current.checkComplete).toBe(true); + expect(result.current.shouldShowWarning).toBe(true); + expect(result.current.sessionsToDeleteCount).toBe(1); + expect(mockGetAllSessionFiles).toHaveBeenCalled(); + expect(mockIdentifySessionsToDelete).toHaveBeenCalled(); + }); + }); + + it('should call onAutoEnable if no sessions to delete and currently disabled', async () => { + const settings = { + general: { + sessionRetention: { + enabled: false, + warningAcknowledged: false, + }, + }, + } as unknown as Settings; + + mockGetAllSessionFiles.mockResolvedValue(['session1.json']); + mockIdentifySessionsToDelete.mockResolvedValue([]); // 0 sessions to delete + + const onAutoEnable = vi.fn(); + + const { result } = renderHook(() => + useSessionRetentionCheck(mockConfig, settings, onAutoEnable), + ); + + await waitFor(() => { + expect(result.current.checkComplete).toBe(true); + expect(result.current.shouldShowWarning).toBe(false); + expect(onAutoEnable).toHaveBeenCalled(); + }); + }); + + it('should not show warning if no sessions to delete', async () => { + const settings = { + general: { + sessionRetention: { + enabled: false, + warningAcknowledged: false, + }, + }, + } as unknown as Settings; + + mockGetAllSessionFiles.mockResolvedValue([ + 'session1.json', + 'session2.json', + ]); + mockIdentifySessionsToDelete.mockResolvedValue([]); // 0 sessions to delete + + const { result } = renderHook(() => + useSessionRetentionCheck(mockConfig, settings), + ); + + await waitFor(() => { + expect(result.current.checkComplete).toBe(true); + expect(result.current.shouldShowWarning).toBe(false); + expect(result.current.sessionsToDeleteCount).toBe(0); + expect(mockGetAllSessionFiles).toHaveBeenCalled(); + expect(mockIdentifySessionsToDelete).toHaveBeenCalled(); + }); + }); + + it('should handle errors gracefully (assume no warning)', async () => { + const settings = { + general: { + sessionRetention: { + enabled: false, + warningAcknowledged: false, + }, + }, + } as unknown as Settings; + + mockGetAllSessionFiles.mockRejectedValue(new Error('FS Error')); + + const { result } = renderHook(() => + useSessionRetentionCheck(mockConfig, settings), + ); + + await waitFor(() => { + expect(result.current.checkComplete).toBe(true); + expect(result.current.shouldShowWarning).toBe(false); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useSessionRetentionCheck.ts b/packages/cli/src/ui/hooks/useSessionRetentionCheck.ts new file mode 100644 index 0000000000..99b443cffc --- /dev/null +++ b/packages/cli/src/ui/hooks/useSessionRetentionCheck.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect } from 'react'; +import { type Config } from '@google/gemini-cli-core'; +import { type Settings } from '../../config/settings.js'; +import { getAllSessionFiles } from '../../utils/sessionUtils.js'; +import { identifySessionsToDelete } from '../../utils/sessionCleanup.js'; +import path from 'node:path'; + +export function useSessionRetentionCheck( + config: Config, + settings: Settings, + onAutoEnable?: () => void, +) { + const [shouldShowWarning, setShouldShowWarning] = useState(false); + const [sessionsToDeleteCount, setSessionsToDeleteCount] = useState(0); + const [checkComplete, setCheckComplete] = useState(false); + + useEffect(() => { + // If warning already acknowledged or retention already enabled, skip check + if ( + settings.general?.sessionRetention?.warningAcknowledged || + (settings.general?.sessionRetention?.enabled && + settings.general?.sessionRetention?.maxAge !== undefined) + ) { + setShouldShowWarning(false); + setCheckComplete(true); + return; + } + + const checkSessions = async () => { + try { + const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats'); + const allFiles = await getAllSessionFiles( + chatsDir, + config.getSessionId(), + ); + + // Calculate how many sessions would be deleted if we applied a 30-day retention + const sessionsToDelete = await identifySessionsToDelete(allFiles, { + enabled: true, + maxAge: '30d', + }); + + if (sessionsToDelete.length > 0) { + setSessionsToDeleteCount(sessionsToDelete.length); + setShouldShowWarning(true); + } else { + setShouldShowWarning(false); + // If no sessions to delete, safe to auto-enable retention + onAutoEnable?.(); + } + } catch { + // If we can't check sessions, default to not showing the warning to be safe + setShouldShowWarning(false); + } finally { + setCheckComplete(true); + } + }; + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + checkSessions(); + }, [config, settings.general?.sessionRetention, onAutoEnable]); + + return { shouldShowWarning, checkComplete, sessionsToDeleteCount }; +} diff --git a/packages/cli/src/utils/sessionCleanup.ts b/packages/cli/src/utils/sessionCleanup.ts index 6004cb8c5d..64e3b4c565 100644 --- a/packages/cli/src/utils/sessionCleanup.ts +++ b/packages/cli/src/utils/sessionCleanup.ts @@ -174,7 +174,10 @@ export async function cleanupExpiredSessions( /** * Identifies sessions that should be deleted (corrupted or expired based on retention policy) */ -async function identifySessionsToDelete( +/** + * Identifies sessions that should be deleted (corrupted or expired based on retention policy) + */ +export async function identifySessionsToDelete( allFiles: SessionFileEntry[], retentionConfig: SessionRetentionSettings, ): Promise { diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index c965c0f339..12aec58973 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -133,9 +133,9 @@ "type": "boolean" }, "maxAge": { - "title": "Max Session Age", - "description": "Maximum age of sessions to keep (e.g., \"30d\", \"7d\", \"24h\", \"1w\")", - "markdownDescription": "Maximum age of sessions to keep (e.g., \"30d\", \"7d\", \"24h\", \"1w\")\n\n- Category: `General`\n- Requires restart: `no`", + "title": "Keep chat history", + "description": "Automatically delete chats older than this time period (e.g., \"30d\", \"7d\", \"24h\", \"1w\")", + "markdownDescription": "Automatically delete chats older than this time period (e.g., \"30d\", \"7d\", \"24h\", \"1w\")\n\n- Category: `General`\n- Requires restart: `no`", "type": "string" }, "maxCount": { @@ -150,6 +150,13 @@ "markdownDescription": "Minimum retention period (safety limit, defaults to \"1d\")\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `1d`", "default": "1d", "type": "string" + }, + "warningAcknowledged": { + "title": "Warning Acknowledged", + "description": "INTERNAL: Whether the user has acknowledged the session retention warning", + "markdownDescription": "INTERNAL: Whether the user has acknowledged the session retention warning\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" } }, "additionalProperties": false