diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index b7b5dfc7d9..7bd34929b3 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -224,7 +224,7 @@ export async function parseArguments( // one, and not being passed at all. skipValidation: true, description: - 'Resume a previous session. Use "latest" for most recent or index number (e.g. --resume 5)', + 'Resume a previous session by name, index, UUID, or "latest" (e.g. --resume my-chat-abc12)', coerce: (value: string): string => { // When --resume passed with a value (`gemini --resume 123`): value = "123" (string) // When --resume passed without a value (`gemini --resume`): value = "" (string) @@ -238,13 +238,12 @@ export async function parseArguments( }) .option('list-sessions', { type: 'boolean', - description: - 'List available sessions for the current project and exit.', + description: 'List available sessions across all projects and exit.', }) .option('delete-session', { type: 'string', description: - 'Delete a session by index number (use --list-sessions to see available sessions).', + 'Delete a session by name, index, or UUID (use --list-sessions to see available sessions).', }) .option('include-directories', { type: 'array', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 7b385453bf..284153ffc0 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -79,7 +79,10 @@ import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; import { checkForUpdates } from './ui/utils/updateCheck.js'; import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; import { appEvents, AppEvent } from './utils/events.js'; -import { SessionSelector } from './utils/sessionUtils.js'; +import { + getConversationSessionName, + SessionSelector, +} from './utils/sessionUtils.js'; import { SettingsContext } from './ui/contexts/SettingsContext.js'; import { MouseProvider } from './ui/contexts/MouseContext.js'; import { StreamingState } from './ui/types.js'; @@ -790,6 +793,17 @@ export async function main() { prompt_id, resumedSessionData, }); + + const conversation = config + .getGeminiClient() + ?.getChatRecordingService() + ?.getConversation(); + if (conversation) { + const sessionName = getConversationSessionName(conversation); + writeToStdout( + `\nSession: ${sessionName}\nYou can resume your session by typing: gemini --resume ${sessionName}\n`, + ); + } // Call cleanup before process.exit, which causes cleanup to not run await runExitCleanup(); process.exit(ExitCodes.SUCCESS); diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index de0afc9c50..a691de3aed 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -190,6 +190,7 @@ const mockUIActions: UIActions = { closeSessionBrowser: vi.fn(), handleResumeSession: vi.fn(), handleDeleteSession: vi.fn(), + handleRenameSession: vi.fn(), closePermissionsDialog: vi.fn(), setShellModeActive: vi.fn(), vimHandleInput: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 1d91d44256..85484388e6 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -97,7 +97,7 @@ import { useTerminalSize } from './hooks/useTerminalSize.js'; import { calculatePromptWidths } from './components/InputPrompt.js'; import { calculateMainAreaWidth } from './utils/ui-sizing.js'; import ansiEscapes from 'ansi-escapes'; -import { basename } from 'node:path'; +import { basename, resolve } from 'node:path'; import { computeTerminalTitle } from '../utils/windowTitle.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; @@ -120,7 +120,11 @@ import { type UpdateObject } from './utils/updateCheck.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; import { RELAUNCH_EXIT_CODE } from '../utils/processUtils.js'; -import type { SessionInfo } from '../utils/sessionUtils.js'; +import { + getConversationSessionName, + getGlobalSessionFiles, + type SessionInfo, +} from '../utils/sessionUtils.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useMcpStatus } from './hooks/useMcpStatus.js'; import { useApprovalModeIndicator } from './hooks/useApprovalModeIndicator.js'; @@ -156,6 +160,7 @@ import { useTerminalTheme } from './hooks/useTerminalTheme.js'; import { useTimedMessage } from './hooks/useTimedMessage.js'; import { shouldDismissShortcutsHelpOnHotkey } from './utils/shortcutsHelp.js'; import { useSuspend } from './hooks/useSuspend.js'; +import { CrashResumeDialog } from './components/CrashResumeDialog.js'; function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { return pendingHistoryItems.some((item) => { @@ -227,6 +232,8 @@ export const AppContainer = (props: AppContainerProps) => { const [customDialog, setCustomDialog] = useState( null, ); + const crashRecoveryPromptHandledRef = useRef(false); + const [isCrashRecoveryChecked, setIsCrashRecoveryChecked] = useState(false); const [copyModeEnabled, setCopyModeEnabled] = useState(false); const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false); const toggleBackgroundShellRef = useRef<() => void>(() => {}); @@ -666,17 +673,164 @@ export const AppContainer = (props: AppContainerProps) => { isSessionBrowserOpen, openSessionBrowser, closeSessionBrowser, - handleResumeSession, + handleResumeSession: handleResumeSessionBase, handleDeleteSession: handleDeleteSessionSync, + handleRenameSession: handleRenameSessionBase, } = useSessionBrowser(config, loadHistoryForResume); // Wrap handleDeleteSession to return a Promise for UIActions interface const handleDeleteSession = useCallback( async (session: SessionInfo): Promise => { - handleDeleteSessionSync(session); + await handleDeleteSessionSync(session); }, [handleDeleteSessionSync], ); + const updateSessionRecoveryState = useCallback( + (dirtyExit: boolean) => { + const chatRecordingService = config + .getGeminiClient() + ?.getChatRecordingService(); + const conversation = chatRecordingService?.getConversation(); + const sessionPath = chatRecordingService?.getConversationFilePath(); + if (!conversation || !sessionPath) { + return; + } + + persistentState.set('sessionRecovery', { + sessionId: conversation.sessionId, + sessionName: getConversationSessionName(conversation), + sessionPath, + projectRoot: config.getProjectRoot(), + dirtyExit, + updatedAt: new Date().toISOString(), + }); + }, + [config], + ); + + const handleResumeSession = useCallback( + async (session: SessionInfo): Promise => { + await handleResumeSessionBase(session); + updateSessionRecoveryState(true); + }, + [handleResumeSessionBase, updateSessionRecoveryState], + ); + + const handleRenameSession = useCallback( + async (session: SessionInfo, newNameBase: string): Promise => { + const updated = await handleRenameSessionBase(session, newNameBase); + if (updated.isCurrentSession) { + updateSessionRecoveryState(true); + } + return updated; + }, + [handleRenameSessionBase, updateSessionRecoveryState], + ); + + useEffect(() => { + if (!isConfigInitialized || !isCrashRecoveryChecked) { + return; + } + + updateSessionRecoveryState(true); + registerCleanup(() => { + updateSessionRecoveryState(false); + }); + }, [isConfigInitialized, isCrashRecoveryChecked, updateSessionRecoveryState]); + + useEffect(() => { + if (!isConfigInitialized || crashRecoveryPromptHandledRef.current) { + return; + } + crashRecoveryPromptHandledRef.current = true; + const finishCrashCheck = () => { + setIsCrashRecoveryChecked(true); + }; + + if (resumedSessionData) { + finishCrashCheck(); + return; + } + + const recovery = persistentState.get('sessionRecovery'); + if (!recovery || !recovery.dirtyExit) { + finishCrashCheck(); + return; + } + + if (resolve(recovery.projectRoot) !== resolve(config.getProjectRoot())) { + finishCrashCheck(); + return; + } + + const handleRecoveryChoice = async ( + choice: 'resume' | 'dismiss' | 'browse', + ) => { + const latest = persistentState.get('sessionRecovery'); + if (latest && latest.sessionId === recovery.sessionId) { + persistentState.set('sessionRecovery', { + ...latest, + dirtyExit: false, + updatedAt: new Date().toISOString(), + }); + } + finishCrashCheck(); + + setCustomDialog(null); + + if (choice === 'browse') { + openSessionBrowser(); + return; + } + + if (choice === 'resume') { + try { + const sessions = await getGlobalSessionFiles( + config.getSessionId(), + config.getProjectRoot(), + ); + const targetSession = sessions.find( + (session) => + session.id === recovery.sessionId || + session.sessionPath === recovery.sessionPath, + ); + + if (targetSession) { + await handleResumeSession(targetSession); + } else { + coreEvents.emitFeedback( + 'warning', + 'Last crashed session was not found. Opening session browser.', + ); + openSessionBrowser(); + } + } catch (error) { + coreEvents.emitFeedback( + 'warning', + `Failed to resume crashed session: ${getErrorMessage(error)}`, + ); + } + } + }; + + setCustomDialog( + { + void handleRecoveryChoice(choice); + }} + />, + ); + }, [ + config, + handleResumeSession, + isConfigInitialized, + openSessionBrowser, + resumedSessionData, + ]); + // Create handleAuthSelect wrapper for backward compatibility const handleAuthSelect = useCallback( async (authType: AuthType | undefined, scope: LoadableSettingScope) => { @@ -2272,6 +2426,7 @@ Logging in with Google... Restarting Gemini CLI to continue. closeSessionBrowser, handleResumeSession, handleDeleteSession, + handleRenameSession, setQueueErrorMessage, popAllMessages, handleApiKeySubmit, @@ -2352,6 +2507,7 @@ Logging in with Google... Restarting Gemini CLI to continue. closeSessionBrowser, handleResumeSession, handleDeleteSession, + handleRenameSession, setQueueErrorMessage, popAllMessages, handleApiKeySubmit, diff --git a/packages/cli/src/ui/commands/quitCommand.ts b/packages/cli/src/ui/commands/quitCommand.ts index ab879f22ca..2d31470938 100644 --- a/packages/cli/src/ui/commands/quitCommand.ts +++ b/packages/cli/src/ui/commands/quitCommand.ts @@ -6,6 +6,7 @@ import { formatDuration } from '../utils/formatters.js'; import { CommandKind, type SlashCommand } from './types.js'; +import { getConversationSessionName } from '../../utils/sessionUtils.js'; export const quitCommand: SlashCommand = { name: 'quit', @@ -17,6 +18,16 @@ export const quitCommand: SlashCommand = { const now = Date.now(); const { sessionStartTime } = context.session.stats; const wallDuration = now - sessionStartTime.getTime(); + const conversation = context.services.config + ?.getGeminiClient() + ?.getChatRecordingService() + ?.getConversation(); + const sessionName = conversation + ? getConversationSessionName(conversation) + : undefined; + const resumeCommandHint = sessionName + ? `gemini --resume ${sessionName}` + : undefined; return { type: 'quit', @@ -29,6 +40,8 @@ export const quitCommand: SlashCommand = { { type: 'quit', duration: formatDuration(wallDuration), + ...(sessionName ? { sessionName } : {}), + ...(resumeCommandHint ? { resumeCommandHint } : {}), id: now, }, ], diff --git a/packages/cli/src/ui/components/CrashResumeDialog.tsx b/packages/cli/src/ui/components/CrashResumeDialog.tsx new file mode 100644 index 0000000000..ead5876dbe --- /dev/null +++ b/packages/cli/src/ui/components/CrashResumeDialog.tsx @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { useState } from 'react'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import type { RadioSelectItem } from './shared/RadioButtonSelect.js'; +import { theme } from '../semantic-colors.js'; + +export type CrashResumeChoice = 'resume' | 'dismiss' | 'browse'; + +interface CrashResumeDialogProps { + sessionName: string; + projectRoot: string; + updatedAt: string; + onSelect: (choice: CrashResumeChoice) => void; +} + +export const CrashResumeDialog: React.FC = ({ + sessionName, + projectRoot, + updatedAt, + onSelect, +}) => { + const [submitting, setSubmitting] = useState(false); + + useKeypress( + (key) => { + if (key.name === 'escape' && !submitting) { + setSubmitting(true); + onSelect('dismiss'); + return true; + } + return false; + }, + { isActive: !submitting }, + ); + + const options: Array> = [ + { label: 'Yes, resume last session', value: 'resume', key: 'resume' }, + { label: 'No, start fresh', value: 'dismiss', key: 'dismiss' }, + { label: 'View session list', value: 'browse', key: 'browse' }, + ]; + + return ( + + + + Gemini CLI did not exit cleanly last time. + + Resume your previous chat session? + + Session: {sessionName} + + + Project: {projectRoot} + + Last update: {updatedAt} + + { + setSubmitting(true); + onSelect(choice); + }} + isFocused={!submitting} + /> + + + + ); +}; diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index e4e2f4a6e6..ad5871b5a1 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -299,6 +299,7 @@ export const DialogManager = ({ config={config} onResumeSession={uiActions.handleResumeSession} onDeleteSession={uiActions.handleDeleteSession} + onRenameSession={uiActions.handleRenameSession} onExit={uiActions.closeSessionBrowser} /> ); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index f41ee20895..a8000ca97c 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -169,7 +169,11 @@ export const HistoryItemDisplay: React.FC = ({ )} {itemForDisplay.type === 'quit' && ( - + )} {itemForDisplay.type === 'tool_group' && ( { (async () => { // no-op delete handler for tests that don't care about deletion }), + props.onRenameSession ?? + (async (session) => { + // no-op rename handler for tests that don't care about rename + return session; + }), props.onExit, ); @@ -126,10 +131,17 @@ const createSession = (overrides: Partial): SessionInfo => ({ id: 'session-id', file: 'session-id', fileName: 'session-id.json', + sessionPath: '/tmp/chats/session-id.json', + projectTempDir: '/tmp/project', + projectId: 'project-id', + projectRoot: '/tmp/project-root', startTime: new Date().toISOString(), lastUpdated: new Date().toISOString(), messageCount: 1, displayName: 'Test Session', + sessionName: 'test-session-abcde', + sessionNameBase: 'test-session', + sessionNameSuffix: 'abcde', firstUserMessage: 'Test Session', isCurrentSession: false, index: 0, @@ -153,6 +165,7 @@ describe('SessionBrowser component', () => { const config = createMockConfig(); const onResumeSession = vi.fn(); const onDeleteSession = vi.fn().mockResolvedValue(undefined); + const onRenameSession = vi.fn(); const onExit = vi.fn(); const { lastFrame } = render( @@ -160,6 +173,7 @@ describe('SessionBrowser component', () => { config={config} onResumeSession={onResumeSession} onDeleteSession={onDeleteSession} + onRenameSession={onRenameSession} onExit={onExit} testSessions={[]} />, @@ -190,6 +204,7 @@ describe('SessionBrowser component', () => { const config = createMockConfig(); const onResumeSession = vi.fn(); const onDeleteSession = vi.fn().mockResolvedValue(undefined); + const onRenameSession = vi.fn(); const onExit = vi.fn(); const { lastFrame } = render( @@ -197,6 +212,7 @@ describe('SessionBrowser component', () => { config={config} onResumeSession={onResumeSession} onDeleteSession={onDeleteSession} + onRenameSession={onRenameSession} onExit={onExit} testSessions={[session1, session2]} />, @@ -241,6 +257,7 @@ describe('SessionBrowser component', () => { const config = createMockConfig(); const onResumeSession = vi.fn(); const onDeleteSession = vi.fn().mockResolvedValue(undefined); + const onRenameSession = vi.fn(); const onExit = vi.fn(); const { lastFrame } = render( @@ -248,6 +265,7 @@ describe('SessionBrowser component', () => { config={config} onResumeSession={onResumeSession} onDeleteSession={onDeleteSession} + onRenameSession={onRenameSession} onExit={onExit} testSessions={[searchSession, otherSession]} />, @@ -298,6 +316,7 @@ describe('SessionBrowser component', () => { const config = createMockConfig(); const onResumeSession = vi.fn(); const onDeleteSession = vi.fn().mockResolvedValue(undefined); + const onRenameSession = vi.fn(); const onExit = vi.fn(); const { lastFrame } = render( @@ -305,6 +324,7 @@ describe('SessionBrowser component', () => { config={config} onResumeSession={onResumeSession} onDeleteSession={onDeleteSession} + onRenameSession={onRenameSession} onExit={onExit} testSessions={[session1, session2]} />, @@ -344,6 +364,7 @@ describe('SessionBrowser component', () => { const config = createMockConfig(); const onResumeSession = vi.fn(); const onDeleteSession = vi.fn().mockResolvedValue(undefined); + const onRenameSession = vi.fn(); const onExit = vi.fn(); render( @@ -351,6 +372,7 @@ describe('SessionBrowser component', () => { config={config} onResumeSession={onResumeSession} onDeleteSession={onDeleteSession} + onRenameSession={onRenameSession} onExit={onExit} testSessions={[currentSession, otherSession]} />, @@ -369,6 +391,7 @@ describe('SessionBrowser component', () => { const config = createMockConfig(); const onResumeSession = vi.fn(); const onDeleteSession = vi.fn().mockResolvedValue(undefined); + const onRenameSession = vi.fn(); const onExit = vi.fn(); const { lastFrame } = render( @@ -376,6 +399,7 @@ describe('SessionBrowser component', () => { config={config} onResumeSession={onResumeSession} onDeleteSession={onDeleteSession} + onRenameSession={onRenameSession} onExit={onExit} testError="storage failure" />, diff --git a/packages/cli/src/ui/components/SessionBrowser.tsx b/packages/cli/src/ui/components/SessionBrowser.tsx index 9d1ce57f52..42e3af05f0 100644 --- a/packages/cli/src/ui/components/SessionBrowser.tsx +++ b/packages/cli/src/ui/components/SessionBrowser.tsx @@ -5,18 +5,17 @@ */ import type React from 'react'; -import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import { useState, useCallback, useMemo, useEffect } from 'react'; import { Box, Text } from 'ink'; import { Colors } from '../colors.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import path from 'node:path'; import type { Config } from '@google/gemini-cli-core'; import type { SessionInfo, TextMatch } from '../../utils/sessionUtils.js'; import { cleanMessage, formatRelativeTime, - getSessionFiles, + getGlobalSessionFiles, } from '../../utils/sessionUtils.js'; /** @@ -29,6 +28,11 @@ export interface SessionBrowserProps { onResumeSession: (session: SessionInfo) => void; /** Callback when user deletes a session */ onDeleteSession: (session: SessionInfo) => Promise; + /** Callback when user renames a session */ + onRenameSession: ( + session: SessionInfo, + newNameBase: string, + ) => Promise; /** Callback when user exits the session browser */ onExit: () => void; } @@ -63,6 +67,10 @@ export interface SessionBrowserState { isSearchMode: boolean; /** Whether full content has been loaded for search */ hasLoadedFullContent: boolean; + /** Whether user is actively typing a rename */ + isRenameMode: boolean; + /** Draft rename text */ + renameQuery: string; // Sort state /** Current sort criteria */ @@ -95,6 +103,10 @@ export interface SessionBrowserState { setSearchQuery: React.Dispatch>; /** Update search mode state */ setIsSearchMode: React.Dispatch>; + /** Update rename mode state */ + setIsRenameMode: React.Dispatch>; + /** Update rename query */ + setRenameQuery: React.Dispatch>; /** Update sort order */ setSortOrder: React.Dispatch< React.SetStateAction<'date' | 'messages' | 'name'> @@ -170,7 +182,7 @@ const sortSessions = ( case 'messages': return b.messageCount - a.messageCount; case 'name': - return a.displayName.localeCompare(b.displayName); + return a.sessionName.localeCompare(b.sessionName); default: return 0; } @@ -249,7 +261,9 @@ const filterSessions = ( return sessions.filter((session) => { const titleMatch = session.displayName.toLowerCase().includes(lowerQuery) || + session.sessionName.toLowerCase().includes(lowerQuery) || session.id.toLowerCase().includes(lowerQuery) || + (session.projectRoot || '').toLowerCase().includes(lowerQuery) || session.firstUserMessage.toLowerCase().includes(lowerQuery); const contentMatch = session.fullContent @@ -283,6 +297,21 @@ const SearchModeDisplay = ({ ); +/** + * Rename input display component. + */ +const RenameModeDisplay = ({ + state, +}: { + state: SessionBrowserState; +}): React.JSX.Element => ( + + Rename: + {state.renameQuery} + (Enter to save, Esc to cancel) + +); + /** * Header component showing session count and sort information. */ @@ -314,6 +343,8 @@ const NavigationHelp = (): React.JSX.Element => ( {' '} {' '} + + {' '} {' '} @@ -380,6 +411,48 @@ const NoResultsDisplay = ({ ); +const formatTimestamp = (timestamp: string): string => { + const parsed = new Date(timestamp); + if (Number.isNaN(parsed.getTime())) { + return timestamp; + } + return parsed.toLocaleString(); +}; + +const SessionDetailsPanel = ({ + session, +}: { + session: SessionInfo | undefined; +}): React.JSX.Element | null => { + if (!session) { + return null; + } + + return ( + + + Selected: {session.sessionName} + + + Project:{' '} + + {session.projectRoot || '(unknown)'} + + + + Started:{' '} + {formatTimestamp(session.startTime)} + + + Updated:{' '} + + {formatTimestamp(session.lastUpdated)} + + + + ); +}; + /** * Match snippet display component for search results. */ @@ -472,14 +545,14 @@ const SessionItem = ({ const truncatedMessage = matchDisplay || - (session.displayName.length === 0 ? ( + (session.sessionName.length === 0 ? ( (No messages) - ) : session.displayName.length > availableMessageWidth ? ( - session.displayName.slice(0, availableMessageWidth - 1) + '…' + ) : session.sessionName.length > availableMessageWidth ? ( + session.sessionName.slice(0, availableMessageWidth - 1) + '…' ) : ( - session.displayName + session.sessionName )); return ( @@ -581,8 +654,9 @@ export const useSessionBrowserState = ( const [sortReverse, setSortReverse] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [isSearchMode, setIsSearchMode] = useState(false); + const [isRenameMode, setIsRenameMode] = useState(false); + const [renameQuery, setRenameQuery] = useState(''); const [hasLoadedFullContent, setHasLoadedFullContent] = useState(false); - const loadingFullContentRef = useRef(false); const filteredAndSortedSessions = useMemo(() => { const filtered = filterSessions(sessions, searchQuery); @@ -593,7 +667,6 @@ export const useSessionBrowserState = ( useEffect(() => { if (!searchQuery) { setHasLoadedFullContent(false); - loadingFullContentRef.current = false; } }, [searchQuery]); @@ -617,6 +690,10 @@ export const useSessionBrowserState = ( setSearchQuery, isSearchMode, setIsSearchMode, + isRenameMode, + setIsRenameMode, + renameQuery, + setRenameQuery, hasLoadedFullContent, setHasLoadedFullContent, sortOrder, @@ -650,10 +727,9 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => { useEffect(() => { const loadSessions = async () => { try { - const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats'); - const sessionData = await getSessionFiles( - chatsDir, + const sessionData = await getGlobalSessionFiles( config.getSessionId(), + config.getProjectRoot(), ); setSessions(sessionData); setLoading(false); @@ -673,13 +749,9 @@ const useLoadSessions = (config: Config, state: SessionBrowserState) => { const loadFullContent = async () => { if (isSearchMode && !hasLoadedFullContent) { try { - const chatsDir = path.join( - config.storage.getProjectTempDir(), - 'chats', - ); - const sessionData = await getSessionFiles( - chatsDir, + const sessionData = await getGlobalSessionFiles( config.getSessionId(), + config.getProjectRoot(), { includeFullContent: true }, ); setSessions(sessionData); @@ -764,10 +836,56 @@ export const useSessionBrowserInput = ( cycleSortOrder: () => void, onResumeSession: (session: SessionInfo) => void, onDeleteSession: (session: SessionInfo) => Promise, + onRenameSession: (session: SessionInfo, newNameBase: string) => Promise, onExit: () => void, ) => { useKeypress( (key) => { + const selectedSession = state.filteredAndSortedSessions[state.activeIndex]; + + if (state.isRenameMode) { + if (key.name === 'escape') { + state.setIsRenameMode(false); + state.setRenameQuery(''); + return true; + } + if (key.name === 'backspace') { + state.setRenameQuery((prev) => prev.slice(0, -1)); + return true; + } + if (key.name === 'return') { + if (selectedSession) { + onRenameSession(selectedSession, state.renameQuery) + .then((updatedSession) => { + state.setSessions((prev) => + prev.map((session) => + session.id === updatedSession.id ? updatedSession : session, + ), + ); + state.setIsRenameMode(false); + state.setRenameQuery(''); + }) + .catch((error) => { + state.setError( + `Failed to rename session: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + }); + } + return true; + } + if ( + key.sequence && + key.sequence.length === 1 && + !key.alt && + !key.ctrl && + !key.cmd + ) { + state.setRenameQuery((prev) => prev + key.sequence); + return true; + } + return false; + } + if (state.isSearchMode) { // Search-specific input handling. Only control/symbols here. if (key.name === 'escape') { @@ -819,6 +937,12 @@ export const useSessionBrowserInput = ( else if (key.sequence === '/') { state.setIsSearchMode(true); return true; + } else if (key.sequence === 'n' || key.sequence === 'N') { + if (selectedSession) { + state.setIsRenameMode(true); + state.setRenameQuery(selectedSession.sessionNameBase); + } + return true; } else if ( key.sequence === 'q' || key.sequence === 'Q' || @@ -829,8 +953,6 @@ export const useSessionBrowserInput = ( } // Delete session control. else if (key.sequence === 'x' || key.sequence === 'X') { - const selectedSession = - state.filteredAndSortedSessions[state.activeIndex]; if (selectedSession && !selectedSession.isCurrentSession) { onDeleteSession(selectedSession) .then(() => { @@ -870,10 +992,9 @@ export const useSessionBrowserInput = ( // Handling regardless of search mode. if ( key.name === 'return' && - state.filteredAndSortedSessions[state.activeIndex] + selectedSession && + !state.isSearchMode ) { - const selectedSession = - state.filteredAndSortedSessions[state.activeIndex]; // Don't allow resuming the current session if (!selectedSession.isCurrentSession) { onResumeSession(selectedSession); @@ -914,17 +1035,23 @@ export function SessionBrowserView({ if (state.sessions.length === 0) { return ; } + + const selectedSession = state.filteredAndSortedSessions[state.activeIndex]; + return ( {state.isSearchMode && } + {state.isRenameMode && } {state.totalSessions === 0 ? ( ) : ( )} + + {!state.isSearchMode && } ); } @@ -933,6 +1060,7 @@ export function SessionBrowser({ config, onResumeSession, onDeleteSession, + onRenameSession, onExit, }: SessionBrowserProps): React.JSX.Element { // Use all our custom hooks @@ -946,6 +1074,7 @@ export function SessionBrowser({ cycleSortOrder, onResumeSession, onDeleteSession, + onRenameSession, onExit, ); diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx index d4a0a11d6e..117dedee31 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx @@ -5,14 +5,33 @@ */ import type React from 'react'; +import { Box, Text } from 'ink'; import { StatsDisplay } from './StatsDisplay.js'; +import { Colors } from '../colors.js'; interface SessionSummaryDisplayProps { duration: string; + sessionName?: string; + resumeCommandHint?: string; } export const SessionSummaryDisplay: React.FC = ({ duration, + sessionName, + resumeCommandHint, }) => ( - + + + {sessionName && resumeCommandHint && ( + + + Session: {sessionName} + + + You can resume your session by typing:{' '} + {resumeCommandHint} + + + )} + ); diff --git a/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap index efffa48b4e..6cbea4bf49 100644 --- a/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SessionBrowser.test.tsx.snap @@ -12,13 +12,18 @@ exports[`SessionBrowser component > enters search mode, filters sessions, and re exports[`SessionBrowser component > renders a list of sessions and marks current session as disabled 1`] = ` " Chat Sessions (2 total) sorted by date desc - Navigate: ↑/↓ Resume: Enter Search: / Delete: x Quit: q + Navigate: ↑/↓ Resume: Enter Search: / Rename: n Delete: x Quit: q Sort: s Reverse: r First/Last: g/G Index │ Msgs │ Age │ Name - ❯ #1 │ 5 │ 10mo │ Second conversation about dogs (current) - #2 │ 2 │ 10mo │ First conversation about cats - ▼" + ❯ #1 │ 5 │ 10mo │ test-session-abcde (current) + #2 │ 2 │ 10mo │ test-session-abcde + ▼ + + Selected: test-session-abcde + Project: /tmp/project-root + Started: 11/1/2025, 8:00:00 AM + Updated: 1/1/2025, 6:30:00 AM" `; exports[`SessionBrowser component > shows an error state when loading sessions fails 1`] = ` diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index c80507f9d7..1078bbcb96 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -65,6 +65,10 @@ export interface UIActions { closeSessionBrowser: () => void; handleResumeSession: (session: SessionInfo) => Promise; handleDeleteSession: (session: SessionInfo) => Promise; + handleRenameSession: ( + session: SessionInfo, + newNameBase: string, + ) => Promise; setQueueErrorMessage: (message: string | null) => void; popAllMessages: () => string | undefined; handleApiKeySubmit: (apiKey: string) => Promise; diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts index 7e53d3c437..1a8d054a20 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts @@ -12,8 +12,7 @@ import { convertSessionToHistoryFormats, } from './useSessionBrowser.js'; import * as fs from 'node:fs/promises'; -import path from 'node:path'; -import { getSessionFiles, type SessionInfo } from '../../utils/sessionUtils.js'; +import type { SessionInfo } from '../../utils/sessionUtils.js'; import type { Config, ConversationRecord, @@ -23,25 +22,13 @@ import { coreEvents } from '@google/gemini-cli-core'; // Mock modules vi.mock('fs/promises'); -vi.mock('path'); -vi.mock('../../utils/sessionUtils.js', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - getSessionFiles: vi.fn(), - }; -}); const MOCKED_PROJECT_TEMP_DIR = '/test/project/temp'; -const MOCKED_CHATS_DIR = '/test/project/temp/chats'; const MOCKED_SESSION_ID = 'test-session-123'; const MOCKED_CURRENT_SESSION_ID = 'current-session-id'; describe('useSessionBrowser', () => { const mockedFs = vi.mocked(fs); - const mockedPath = vi.mocked(path); - const mockedGetSessionFiles = vi.mocked(getSessionFiles); const mockConfig = { storage: { @@ -61,7 +48,6 @@ describe('useSessionBrowser', () => { beforeEach(() => { vi.resetAllMocks(); vi.spyOn(coreEvents, 'emitFeedback').mockImplementation(() => {}); - mockedPath.join.mockImplementation((...args) => args.join('/')); vi.mocked(mockConfig.storage.getProjectTempDir).mockReturnValue( MOCKED_PROJECT_TEMP_DIR, ); @@ -77,11 +63,23 @@ describe('useSessionBrowser', () => { messages: [{ type: 'user', content: 'Hello' } as MessageRecord], } as ConversationRecord; - const mockSession = { + const mockSession: SessionInfo = { id: MOCKED_SESSION_ID, + file: MOCKED_FILENAME.replace('.json', ''), fileName: MOCKED_FILENAME, - } as SessionInfo; - mockedGetSessionFiles.mockResolvedValue([mockSession]); + sessionPath: `/test/sessions/${MOCKED_FILENAME}`, + projectTempDir: MOCKED_PROJECT_TEMP_DIR, + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T00:00:00Z', + messageCount: 1, + displayName: 'Test', + sessionName: 'test-session-abcde', + sessionNameBase: 'test-session', + sessionNameSuffix: 'abcde', + firstUserMessage: 'Test', + isCurrentSession: false, + index: 1, + }; mockedFs.readFile.mockResolvedValue(JSON.stringify(mockConversation)); const { result } = renderHook(() => @@ -92,7 +90,7 @@ describe('useSessionBrowser', () => { await result.current.handleResumeSession(mockSession); }); expect(mockedFs.readFile).toHaveBeenCalledWith( - `${MOCKED_CHATS_DIR}/${MOCKED_FILENAME}`, + mockSession.sessionPath, 'utf8', ); expect(mockConfig.setSessionId).toHaveBeenCalledWith( @@ -104,10 +102,23 @@ describe('useSessionBrowser', () => { it('should handle file read error', async () => { const MOCKED_FILENAME = 'session-2025-01-01-test-session-123.json'; - const mockSession = { + const mockSession: SessionInfo = { id: MOCKED_SESSION_ID, + file: MOCKED_FILENAME.replace('.json', ''), fileName: MOCKED_FILENAME, - } as SessionInfo; + sessionPath: `/test/sessions/${MOCKED_FILENAME}`, + projectTempDir: MOCKED_PROJECT_TEMP_DIR, + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T00:00:00Z', + messageCount: 1, + displayName: 'Test', + sessionName: 'test-session-abcde', + sessionNameBase: 'test-session', + sessionNameSuffix: 'abcde', + firstUserMessage: 'Test', + isCurrentSession: false, + index: 1, + }; mockedFs.readFile.mockRejectedValue(new Error('File not found')); const { result } = renderHook(() => @@ -128,10 +139,23 @@ describe('useSessionBrowser', () => { it('should handle JSON parse error', async () => { const MOCKED_FILENAME = 'invalid.json'; - const mockSession = { + const mockSession: SessionInfo = { id: MOCKED_SESSION_ID, + file: MOCKED_FILENAME.replace('.json', ''), fileName: MOCKED_FILENAME, - } as SessionInfo; + sessionPath: `/test/sessions/${MOCKED_FILENAME}`, + projectTempDir: MOCKED_PROJECT_TEMP_DIR, + startTime: '2025-01-01T00:00:00Z', + lastUpdated: '2025-01-01T00:00:00Z', + messageCount: 1, + displayName: 'Test', + sessionName: 'test-session-abcde', + sessionNameBase: 'test-session', + sessionNameSuffix: 'abcde', + firstUserMessage: 'Test', + isCurrentSession: false, + index: 1, + }; mockedFs.readFile.mockResolvedValue('invalid json'); const { result } = renderHook(() => diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.ts b/packages/cli/src/ui/hooks/useSessionBrowser.ts index de6495c3b9..a825d20ede 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.ts @@ -7,15 +7,17 @@ import { useState, useCallback } from 'react'; import type { HistoryItemWithoutId } from '../types.js'; import * as fs from 'node:fs/promises'; -import path from 'node:path'; import type { Config, - ConversationRecord, ResumedSessionData, } from '@google/gemini-cli-core'; import { coreEvents } from '@google/gemini-cli-core'; import type { SessionInfo } from '../../utils/sessionUtils.js'; -import { convertSessionToHistoryFormats } from '../../utils/sessionUtils.js'; +import { + convertSessionToHistoryFormats, + deleteSessionArtifacts, + renameSession, +} from '../../utils/sessionUtils.js'; import type { Part } from '@google/genai'; export { convertSessionToHistoryFormats }; @@ -47,18 +49,9 @@ export const useSessionBrowser = ( handleResumeSession: useCallback( async (session: SessionInfo) => { try { - const chatsDir = path.join( - config.storage.getProjectTempDir(), - 'chats', - ); - - const fileName = session.fileName; - - const originalFilePath = path.join(chatsDir, fileName); - // Load up the conversation. - const conversation: ConversationRecord = JSON.parse( - await fs.readFile(originalFilePath, 'utf8'), + const conversation = JSON.parse( + await fs.readFile(session.sessionPath, 'utf8'), ); // Use the old session's ID to continue it. @@ -67,7 +60,7 @@ export const useSessionBrowser = ( const resumedSessionData = { conversation, - filePath: originalFilePath, + filePath: session.sessionPath, }; // We've loaded it; tell the UI about it. @@ -89,27 +82,31 @@ export const useSessionBrowser = ( ), /** - * Deletes a session by ID using the ChatRecordingService. + * Deletes a session and related tool output artifacts. */ handleDeleteSession: useCallback( - (session: SessionInfo) => { - // Note: Chat sessions are stored on disk using a filename derived from - // the session, e.g. "session--.json". - // The ChatRecordingService.deleteSession API expects this file basename - // (without the ".json" extension), not the full session UUID. + async (session: SessionInfo): Promise => { try { - const chatRecordingService = config - .getGeminiClient() - ?.getChatRecordingService(); - if (chatRecordingService) { - chatRecordingService.deleteSession(session.file); - } + await deleteSessionArtifacts(session); } catch (error) { coreEvents.emitFeedback('error', 'Error deleting session:', error); throw error; } }, - [config], + [], + ), + + handleRenameSession: useCallback( + async (session: SessionInfo, newNameBase: string): Promise => { + try { + const result = await renameSession(session, newNameBase); + return result.sessionInfo; + } catch (error) { + coreEvents.emitFeedback('error', 'Error renaming session:', error); + throw error; + } + }, + [], ), }; }; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index ca9a992f80..b30ba6fe34 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -184,6 +184,8 @@ export type HistoryItemModel = HistoryItemBase & { export type HistoryItemQuit = HistoryItemBase & { type: 'quit'; duration: string; + sessionName?: string; + resumeCommandHint?: string; }; export type HistoryItemToolGroup = HistoryItemBase & { diff --git a/packages/cli/src/utils/persistentState.ts b/packages/cli/src/utils/persistentState.ts index cbdf1fc6cb..f7c5f0477a 100644 --- a/packages/cli/src/utils/persistentState.ts +++ b/packages/cli/src/utils/persistentState.ts @@ -15,6 +15,14 @@ interface PersistentStateData { tipsShown?: number; hasSeenScreenReaderNudge?: boolean; focusUiEnabled?: boolean; + sessionRecovery?: { + sessionId: string; + sessionName: string; + sessionPath: string; + projectRoot: string; + dirtyExit: boolean; + updatedAt: string; + }; // Add other persistent state keys here as needed } diff --git a/packages/cli/src/utils/sessionCleanup.test.ts b/packages/cli/src/utils/sessionCleanup.test.ts index cc775d01c9..02c9d24123 100644 --- a/packages/cli/src/utils/sessionCleanup.test.ts +++ b/packages/cli/src/utils/sessionCleanup.test.ts @@ -52,6 +52,44 @@ function createMockConfig(overrides: Partial = {}): Config { } // Create test session data +function createSessionInfo(overrides: Partial): SessionInfo { + const id = overrides.id ?? 'session'; + const displayName = overrides.displayName ?? 'Session'; + const normalizedDisplayName = displayName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + const sessionNameBase = + overrides.sessionNameBase ?? (normalizedDisplayName || 'session'); + const defaultSuffix = id + .toLowerCase() + .replace(/[^a-z0-9]/g, '') + .slice(0, 5) + .padEnd(5, '0'); + const sessionNameSuffix = overrides.sessionNameSuffix ?? defaultSuffix; + + return { + id, + file: `${SESSION_FILE_PREFIX}${id}`, + fileName: `${SESSION_FILE_PREFIX}${id}.json`, + sessionPath: `/tmp/test-project/chats/${SESSION_FILE_PREFIX}${id}.json`, + projectTempDir: '/tmp/test-project', + projectRoot: '/workspace/test-project', + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + messageCount: 1, + displayName, + sessionNameBase, + sessionNameSuffix, + sessionName: + overrides.sessionName || `${sessionNameBase}-${sessionNameSuffix}`, + firstUserMessage: displayName, + isCurrentSession: false, + index: 1, + ...overrides, + }; +} + function createTestSessions(): SessionInfo[] { const now = new Date(); const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); @@ -59,7 +97,7 @@ function createTestSessions(): SessionInfo[] { const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); return [ - { + createSessionInfo({ id: 'current123', file: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12`, fileName: `${SESSION_FILE_PREFIX}2025-01-20T10-30-00-current12.json`, @@ -70,8 +108,8 @@ function createTestSessions(): SessionInfo[] { firstUserMessage: 'Current session', isCurrentSession: true, index: 1, - }, - { + }), + createSessionInfo({ id: 'recent456', file: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45`, fileName: `${SESSION_FILE_PREFIX}2025-01-18T15-45-00-recent45.json`, @@ -82,8 +120,8 @@ function createTestSessions(): SessionInfo[] { firstUserMessage: 'Recent session', isCurrentSession: false, index: 2, - }, - { + }), + createSessionInfo({ id: 'old789abc', file: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab`, fileName: `${SESSION_FILE_PREFIX}2025-01-10T09-15-00-old789ab.json`, @@ -94,8 +132,8 @@ function createTestSessions(): SessionInfo[] { firstUserMessage: 'Old session', isCurrentSession: false, index: 3, - }, - { + }), + createSessionInfo({ id: 'ancient12', file: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1`, fileName: `${SESSION_FILE_PREFIX}2024-12-25T12-00-00-ancient1.json`, @@ -106,7 +144,7 @@ function createTestSessions(): SessionInfo[] { firstUserMessage: 'Ancient session', isCurrentSession: false, index: 4, - }, + }), ]; } @@ -444,7 +482,7 @@ describe('Session Cleanup', () => { const fifteenDaysAgo = new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000); const testSessions: SessionInfo[] = [ - { + createSessionInfo({ id: 'current', file: `${SESSION_FILE_PREFIX}current`, fileName: `${SESSION_FILE_PREFIX}current.json`, @@ -455,8 +493,8 @@ describe('Session Cleanup', () => { firstUserMessage: 'Current', isCurrentSession: true, index: 1, - }, - { + }), + createSessionInfo({ id: 'session5d', file: `${SESSION_FILE_PREFIX}5d`, fileName: `${SESSION_FILE_PREFIX}5d.json`, @@ -467,8 +505,8 @@ describe('Session Cleanup', () => { firstUserMessage: '5 days', isCurrentSession: false, index: 2, - }, - { + }), + createSessionInfo({ id: 'session8d', file: `${SESSION_FILE_PREFIX}8d`, fileName: `${SESSION_FILE_PREFIX}8d.json`, @@ -479,8 +517,8 @@ describe('Session Cleanup', () => { firstUserMessage: '8 days', isCurrentSession: false, index: 3, - }, - { + }), + createSessionInfo({ id: 'session15d', file: `${SESSION_FILE_PREFIX}15d`, fileName: `${SESSION_FILE_PREFIX}15d.json`, @@ -491,7 +529,7 @@ describe('Session Cleanup', () => { firstUserMessage: '15 days', isCurrentSession: false, index: 4, - }, + }), ]; mockGetAllSessionFiles.mockResolvedValue( @@ -566,7 +604,7 @@ describe('Session Cleanup', () => { ); const testSessions: SessionInfo[] = [ - { + createSessionInfo({ id: 'current', file: `${SESSION_FILE_PREFIX}current`, fileName: `${SESSION_FILE_PREFIX}current.json`, @@ -577,8 +615,8 @@ describe('Session Cleanup', () => { firstUserMessage: 'Current', isCurrentSession: true, index: 1, - }, - { + }), + createSessionInfo({ id: 'session1d', file: `${SESSION_FILE_PREFIX}1d`, fileName: `${SESSION_FILE_PREFIX}1d.json`, @@ -589,8 +627,8 @@ describe('Session Cleanup', () => { firstUserMessage: '1 day', isCurrentSession: false, index: 2, - }, - { + }), + createSessionInfo({ id: 'session7d', file: `${SESSION_FILE_PREFIX}7d`, fileName: `${SESSION_FILE_PREFIX}7d.json`, @@ -601,8 +639,8 @@ describe('Session Cleanup', () => { firstUserMessage: '7 days', isCurrentSession: false, index: 3, - }, - { + }), + createSessionInfo({ id: 'session13d', file: `${SESSION_FILE_PREFIX}13d`, fileName: `${SESSION_FILE_PREFIX}13d.json`, @@ -613,7 +651,7 @@ describe('Session Cleanup', () => { firstUserMessage: '13 days', isCurrentSession: false, index: 4, - }, + }), ]; mockGetAllSessionFiles.mockResolvedValue( @@ -662,7 +700,7 @@ describe('Session Cleanup', () => { // Create 6 sessions with different timestamps const now = new Date(); const sessions: SessionInfo[] = [ - { + createSessionInfo({ id: 'current', file: `${SESSION_FILE_PREFIX}current`, fileName: `${SESSION_FILE_PREFIX}current.json`, @@ -673,13 +711,13 @@ describe('Session Cleanup', () => { firstUserMessage: 'Current', isCurrentSession: true, index: 1, - }, + }), ]; // Add 5 more sessions with decreasing timestamps for (let i = 1; i <= 5; i++) { const daysAgo = new Date(now.getTime() - i * 24 * 60 * 60 * 1000); - sessions.push({ + sessions.push(createSessionInfo({ id: `session${i}`, file: `${SESSION_FILE_PREFIX}${i}d`, fileName: `${SESSION_FILE_PREFIX}${i}d.json`, @@ -690,7 +728,7 @@ describe('Session Cleanup', () => { firstUserMessage: `${i} days`, isCurrentSession: false, index: i + 1, - }); + })); } mockGetAllSessionFiles.mockResolvedValue( @@ -788,7 +826,7 @@ describe('Session Cleanup', () => { const twelveDaysAgo = new Date(now.getTime() - 12 * 24 * 60 * 60 * 1000); const testSessions: SessionInfo[] = [ - { + createSessionInfo({ id: 'current', file: `${SESSION_FILE_PREFIX}current`, fileName: `${SESSION_FILE_PREFIX}current.json`, @@ -799,8 +837,8 @@ describe('Session Cleanup', () => { firstUserMessage: 'Current', isCurrentSession: true, index: 1, - }, - { + }), + createSessionInfo({ id: 'session3d', file: `${SESSION_FILE_PREFIX}3d`, fileName: `${SESSION_FILE_PREFIX}3d.json`, @@ -811,8 +849,8 @@ describe('Session Cleanup', () => { firstUserMessage: '3 days', isCurrentSession: false, index: 2, - }, - { + }), + createSessionInfo({ id: 'session5d', file: `${SESSION_FILE_PREFIX}5d`, fileName: `${SESSION_FILE_PREFIX}5d.json`, @@ -823,8 +861,8 @@ describe('Session Cleanup', () => { firstUserMessage: '5 days', isCurrentSession: false, index: 3, - }, - { + }), + createSessionInfo({ id: 'session7d', file: `${SESSION_FILE_PREFIX}7d`, fileName: `${SESSION_FILE_PREFIX}7d.json`, @@ -835,8 +873,8 @@ describe('Session Cleanup', () => { firstUserMessage: '7 days', isCurrentSession: false, index: 4, - }, - { + }), + createSessionInfo({ id: 'session12d', file: `${SESSION_FILE_PREFIX}12d`, fileName: `${SESSION_FILE_PREFIX}12d.json`, @@ -847,7 +885,7 @@ describe('Session Cleanup', () => { firstUserMessage: '12 days', isCurrentSession: false, index: 5, - }, + }), ]; mockGetAllSessionFiles.mockResolvedValue( diff --git a/packages/cli/src/utils/sessionUtils.test.ts b/packages/cli/src/utils/sessionUtils.test.ts index 29fc5bdff9..01adfd5442 100644 --- a/packages/cli/src/utils/sessionUtils.test.ts +++ b/packages/cli/src/utils/sessionUtils.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { SessionSelector, extractFirstUserMessage, @@ -13,7 +13,7 @@ import { SessionError, } from './sessionUtils.js'; import type { Config, MessageRecord } from '@google/gemini-cli-core'; -import { SESSION_FILE_PREFIX } from '@google/gemini-cli-core'; +import { SESSION_FILE_PREFIX, Storage } from '@google/gemini-cli-core'; import * as fs from 'node:fs/promises'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; @@ -21,22 +21,26 @@ import { randomUUID } from 'node:crypto'; describe('SessionSelector', () => { let tmpDir: string; let config: Config; + const projectRoot = '/workspace/project-a'; beforeEach(async () => { // Create a temporary directory for testing tmpDir = path.join(process.cwd(), '.tmp-test-sessions'); await fs.mkdir(tmpDir, { recursive: true }); + vi.spyOn(Storage, 'getGlobalTempDir').mockReturnValue(tmpDir); // Mock config config = { storage: { - getProjectTempDir: () => tmpDir, + getProjectTempDir: () => path.join(tmpDir, 'project-a'), }, getSessionId: () => 'current-session-id', + getProjectRoot: () => projectRoot, } as Partial as Config; }); afterEach(async () => { + vi.restoreAllMocks(); // Clean up test files try { await fs.rm(tmpDir, { recursive: true, force: true }); @@ -45,13 +49,20 @@ describe('SessionSelector', () => { } }); + const createChatsDir = async () => { + const projectDir = path.join(tmpDir, 'project-a'); + const chatsDir = path.join(projectDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + await fs.writeFile(path.join(projectDir, '.project_root'), projectRoot); + return chatsDir; + }; + it('should resolve session by UUID', async () => { const sessionId1 = randomUUID(); const sessionId2 = randomUUID(); // Create test session files - const chatsDir = path.join(tmpDir, 'chats'); - await fs.mkdir(chatsDir, { recursive: true }); + const chatsDir = await createChatsDir(); const session1 = { sessionId: sessionId1, @@ -116,8 +127,7 @@ describe('SessionSelector', () => { const sessionId2 = randomUUID(); // Create test session files - const chatsDir = path.join(tmpDir, 'chats'); - await fs.mkdir(chatsDir, { recursive: true }); + const chatsDir = await createChatsDir(); const session1 = { sessionId: sessionId1, @@ -180,8 +190,7 @@ describe('SessionSelector', () => { const sessionId2 = randomUUID(); // Create test session files - const chatsDir = path.join(tmpDir, 'chats'); - await fs.mkdir(chatsDir, { recursive: true }); + const chatsDir = await createChatsDir(); const session1 = { sessionId: sessionId1, @@ -240,8 +249,7 @@ describe('SessionSelector', () => { const sessionId = randomUUID(); // Create test session files - const chatsDir = path.join(tmpDir, 'chats'); - await fs.mkdir(chatsDir, { recursive: true }); + const chatsDir = await createChatsDir(); const sessionOriginal = { sessionId, @@ -304,8 +312,7 @@ describe('SessionSelector', () => { const sessionId1 = randomUUID(); // Create test session files - const chatsDir = path.join(tmpDir, 'chats'); - await fs.mkdir(chatsDir, { recursive: true }); + const chatsDir = await createChatsDir(); const session1 = { sessionId: sessionId1, @@ -346,8 +353,7 @@ describe('SessionSelector', () => { const sessionIdSystemOnly = randomUUID(); // Create test session files - const chatsDir = path.join(tmpDir, 'chats'); - await fs.mkdir(chatsDir, { recursive: true }); + const chatsDir = await createChatsDir(); // Session with user message - should be listed const sessionWithUser = { @@ -415,8 +421,7 @@ describe('SessionSelector', () => { const sessionIdGeminiOnly = randomUUID(); // Create test session files - const chatsDir = path.join(tmpDir, 'chats'); - await fs.mkdir(chatsDir, { recursive: true }); + const chatsDir = await createChatsDir(); // Session with only gemini message - should be listed const sessionGeminiOnly = { diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index 6a132f42cc..883026e761 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -5,12 +5,19 @@ */ import { + buildSessionName, checkExhaustive, + ensureSessionNameBase, + getDefaultSessionNameBase, partListUnionToString, + sanitizeFilenamePart, SESSION_FILE_PREFIX, + Storage, type Config, type ConversationRecord, type MessageRecord, + getSessionNameSuffix, + normalizeSessionNameSuffix, } from '@google/gemini-cli-core'; import * as fs from 'node:fs/promises'; import path from 'node:path'; @@ -54,7 +61,7 @@ export class SessionError extends Error { static noSessionsFound(): SessionError { return new SessionError( 'NO_SESSIONS_FOUND', - 'No previous sessions found for this project.', + 'No previous sessions found.', ); } @@ -64,7 +71,7 @@ export class SessionError extends Error { static invalidSessionIdentifier(identifier: string): SessionError { return new SessionError( 'INVALID_SESSION_IDENTIFIER', - `Invalid session identifier "${identifier}".\n Use --list-sessions to see available sessions, then use --resume {number}, --resume {uuid}, or --resume latest.`, + `Invalid session identifier "${identifier}".\n Use --list-sessions to see available sessions, then use --resume {name}, --resume {number}, --resume {uuid}, or --resume latest.`, ); } } @@ -101,6 +108,12 @@ export interface SessionInfo { lastUpdated: string; /** Display name for the session (typically first user message) */ displayName: string; + /** Shell-friendly resumable session name: - */ + sessionName: string; + /** Mutable base segment of sessionName */ + sessionNameBase: string; + /** Immutable 5-char suffix segment of sessionName */ + sessionNameSuffix: string; /** Cleaned first user message content */ firstUserMessage: string; /** Whether this is the currently active session */ @@ -109,6 +122,14 @@ export interface SessionInfo { index: number; /** AI-generated summary of the session (if available) */ summary?: string; + /** Project root where this session was created (if known) */ + projectRoot?: string; + /** Project identifier in ~/.gemini/tmp */ + projectId?: string; + /** Absolute path to project temp directory */ + projectTempDir: string; + /** Absolute path to the session json file */ + sessionPath: string; /** Full concatenated content (only loaded when needed for search) */ fullContent?: string; /** Processed messages with normalized roles (only loaded when needed) */ @@ -138,6 +159,27 @@ export interface SessionSelectionResult { displayInfo: string; } +export interface RenameSessionResult { + sessionInfo: SessionInfo; + conversation: ConversationRecord; +} + +export function getConversationSessionName( + conversation: Pick< + ConversationRecord, + 'sessionId' | 'startTime' | 'sessionNameBase' | 'sessionNameSuffix' + >, +): string { + const base = ensureSessionNameBase( + conversation.sessionNameBase || + getDefaultSessionNameBase(new Date(conversation.startTime || Date.now())), + ); + const suffix = normalizeSessionNameSuffix( + conversation.sessionNameSuffix || getSessionNameSuffix(conversation.sessionId), + ); + return buildSessionName(base, suffix); +} + /** * Checks if a session has at least one user or assistant (gemini) message. * Sessions with only system messages (info, error, warning) are considered empty. @@ -238,6 +280,13 @@ export interface GetSessionOptions { includeFullContent?: boolean; } +export interface SessionFileContext { + projectRoot?: string; + projectId?: string; + projectTempDir: string; + isCurrentProject: boolean; +} + /** * Loads all session files (including corrupted ones) from the chats directory. * @returns Array of session file entries, with sessionInfo null for corrupted files @@ -246,6 +295,10 @@ export const getAllSessionFiles = async ( chatsDir: string, currentSessionId?: string, options: GetSessionOptions = {}, + context: SessionFileContext = { + projectTempDir: path.dirname(chatsDir), + isCurrentProject: false, + }, ): Promise => { try { const files = await fs.readdir(chatsDir); @@ -279,9 +332,25 @@ export const getAllSessionFiles = async ( } const firstUserMessage = extractFirstUserMessage(content.messages); - const isCurrentSession = currentSessionId - ? file.includes(currentSessionId.slice(0, 8)) - : false; + const isCurrentSession = Boolean( + context.isCurrentProject && + currentSessionId && + content.sessionId === currentSessionId, + ); + + const sessionNameSuffix = content.sessionNameSuffix + ? content.sessionNameSuffix + : getSessionNameSuffix(content.sessionId); + const sessionNameBase = ensureSessionNameBase( + content.sessionNameBase || + (content.summary + ? stripUnsafeCharacters(content.summary) + : firstUserMessage), + ); + const sessionName = buildSessionName( + sessionNameBase, + sessionNameSuffix, + ); let fullContent: string | undefined; let messages: @@ -290,9 +359,9 @@ export const getAllSessionFiles = async ( if (options.includeFullContent) { fullContent = content.messages - .map((msg) => partListUnionToString(msg.content)) + .map((msg: MessageRecord) => partListUnionToString(msg.content)) .join(' '); - messages = content.messages.map((msg) => ({ + messages = content.messages.map((msg: MessageRecord) => ({ role: msg.type === 'user' ? ('user' as const) @@ -305,9 +374,16 @@ export const getAllSessionFiles = async ( id: content.sessionId, file: file.replace('.json', ''), fileName: file, + sessionPath: filePath, + projectTempDir: context.projectTempDir, + projectRoot: context.projectRoot, + projectId: context.projectId, startTime: content.startTime, lastUpdated: content.lastUpdated, messageCount: content.messages.length, + sessionName, + sessionNameBase, + sessionNameSuffix, displayName: content.summary ? stripUnsafeCharacters(content.summary) : firstUserMessage, @@ -351,6 +427,10 @@ export const getSessionFiles = async ( chatsDir, currentSessionId, options, + { + projectTempDir: path.dirname(chatsDir), + isCurrentProject: true, + }, ); // Filter out corrupted files and extract SessionInfo @@ -388,6 +468,170 @@ export const getSessionFiles = async ( return uniqueSessions; }; +interface ProjectChatDirectory { + projectId: string; + projectTempDir: string; + chatsDir: string; + projectRoot?: string; +} + +async function getProjectChatDirectories(): Promise { + const globalTempDir = Storage.getGlobalTempDir(); + let entries: string[]; + try { + entries = await fs.readdir(globalTempDir); + } catch { + return []; + } + + const directories = await Promise.all( + entries.map(async (projectId): Promise => { + const projectTempDir = path.join(globalTempDir, projectId); + const chatsDir = path.join(projectTempDir, 'chats'); + + try { + const stat = await fs.stat(projectTempDir); + if (!stat.isDirectory()) { + return null; + } + } catch { + return null; + } + + let projectRoot: string | undefined; + try { + projectRoot = ( + await fs.readFile(path.join(projectTempDir, '.project_root'), 'utf8') + ).trim(); + } catch { + projectRoot = undefined; + } + + return { projectId, projectTempDir, chatsDir, projectRoot }; + }), + ); + + return directories.filter((entry): entry is ProjectChatDirectory => + Boolean(entry), + ); +} + +export async function getGlobalSessionFiles( + currentSessionId?: string, + currentProjectRoot?: string, + options: GetSessionOptions = {}, +): Promise { + const projectChatDirs = await getProjectChatDirectories(); + + const allEntries = await Promise.all( + projectChatDirs.map(async (projectDir) => + getAllSessionFiles(projectDir.chatsDir, currentSessionId, options, { + projectRoot: projectDir.projectRoot, + projectId: projectDir.projectId, + projectTempDir: projectDir.projectTempDir, + isCurrentProject: + !!currentProjectRoot && + !!projectDir.projectRoot && + path.resolve(currentProjectRoot) === path.resolve(projectDir.projectRoot), + }), + ), + ); + + const flattened = allEntries.flat(); + const validSessions = flattened + .filter( + (entry): entry is { fileName: string; sessionInfo: SessionInfo } => + entry.sessionInfo !== null, + ) + .map((entry) => entry.sessionInfo); + + const uniqueSessionsMap = new Map(); + for (const session of validSessions) { + if ( + !uniqueSessionsMap.has(session.id) || + new Date(session.lastUpdated).getTime() > + new Date(uniqueSessionsMap.get(session.id)!.lastUpdated).getTime() + ) { + uniqueSessionsMap.set(session.id, session); + } + } + + const uniqueSessions = Array.from(uniqueSessionsMap.values()); + uniqueSessions.sort( + (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), + ); + uniqueSessions.forEach((session, index) => { + session.index = index + 1; + }); + + return uniqueSessions; +} + +export async function renameSession( + session: SessionInfo, + newNameBase: string, +): Promise { + const conversation: ConversationRecord = JSON.parse( + await fs.readFile(session.sessionPath, 'utf8'), + ); + + const sessionNameBase = ensureSessionNameBase(newNameBase); + const existingSuffix = + conversation.sessionNameSuffix || + session.sessionNameSuffix || + getSessionNameSuffix(conversation.sessionId); + const sessionNameSuffix = normalizeSessionNameSuffix(existingSuffix); + + conversation.sessionNameBase = sessionNameBase; + conversation.sessionNameSuffix = sessionNameSuffix; + conversation.lastUpdated = new Date().toISOString(); + + await fs.writeFile(session.sessionPath, JSON.stringify(conversation, null, 2)); + + return { + conversation, + sessionInfo: { + ...session, + lastUpdated: conversation.lastUpdated, + sessionNameBase, + sessionNameSuffix, + sessionName: buildSessionName(sessionNameBase, sessionNameSuffix), + }, + }; +} + +export async function deleteSessionArtifacts(session: SessionInfo): Promise { + try { + await fs.unlink(session.sessionPath); + } catch (error) { + if ( + !(error instanceof Error) || + !('code' in error) || + error.code !== 'ENOENT' + ) { + throw error; + } + } + + const safeSessionId = sanitizeFilenamePart(session.id); + const toolOutputsBase = path.join(session.projectTempDir, 'tool-outputs'); + const toolOutputDir = path.join( + toolOutputsBase, + `session-${safeSessionId}`, + ); + const resolvedBase = path.resolve(toolOutputsBase); + const resolvedTarget = path.resolve(toolOutputDir); + + if ( + resolvedTarget === resolvedBase || + !resolvedTarget.startsWith(`${resolvedBase}${path.sep}`) + ) { + return; + } + + await fs.rm(toolOutputDir, { recursive: true, force: true }); +} + /** * Utility class for session discovery and selection. */ @@ -395,18 +639,17 @@ export class SessionSelector { constructor(private config: Config) {} /** - * Lists all available sessions for the current project. + * Lists all available sessions globally across projects. */ async listSessions(): Promise { - const chatsDir = path.join( - this.config.storage.getProjectTempDir(), - 'chats', + return getGlobalSessionFiles( + this.config.getSessionId(), + this.config.getProjectRoot(), ); - return getSessionFiles(chatsDir, this.config.getSessionId()); } /** - * Finds a session by identifier (UUID or numeric index). + * Finds a session by identifier (name, UUID or numeric index). * * @param identifier - Can be a full UUID or an index number (1-based) * @returns Promise resolving to the found SessionInfo @@ -425,7 +668,15 @@ export class SessionSelector { new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), ); - // Try to find by UUID first + // Try to find by session name first. + const sessionByName = sortedSessions.find( + (session) => session.sessionName === identifier, + ); + if (sessionByName) { + return sessionByName; + } + + // Try to find by UUID. const sessionByUuid = sortedSessions.find( (session) => session.id === identifier, ); @@ -450,7 +701,7 @@ export class SessionSelector { /** * Resolves a resume argument to a specific session. * - * @param resumeArg - Can be "latest", a full UUID, or an index number (1-based) + * @param resumeArg - Can be "latest", a session name, a full UUID, or an index number (1-based) * @returns Promise resolving to session selection result */ async resolveSession(resumeArg: string): Promise { @@ -460,7 +711,7 @@ export class SessionSelector { const sessions = await this.listSessions(); if (sessions.length === 0) { - throw new Error('No previous sessions found for this project.'); + throw new Error('No previous sessions found.'); } // Sort by startTime (oldest first, so newest sessions get highest numbers) @@ -494,11 +745,7 @@ export class SessionSelector { private async selectSession( sessionInfo: SessionInfo, ): Promise { - const chatsDir = path.join( - this.config.storage.getProjectTempDir(), - 'chats', - ); - const sessionPath = path.join(chatsDir, sessionInfo.fileName); + const sessionPath = sessionInfo.sessionPath; try { const sessionData: ConversationRecord = JSON.parse( @@ -508,7 +755,7 @@ export class SessionSelector { const displayInfo = `Session ${sessionInfo.index}: ${sessionInfo.firstUserMessage} (${sessionInfo.messageCount} messages, ${formatRelativeTime(sessionInfo.lastUpdated)})`; return { - sessionPath, + sessionPath: sessionInfo.sessionPath, sessionData, displayInfo, }; @@ -578,7 +825,7 @@ export function convertSessionToHistoryFormats( ) { uiHistory.push({ type: 'tool_group', - tools: msg.toolCalls.map((tool) => ({ + tools: msg.toolCalls.map((tool: NonNullable[number]) => ({ callId: tool.id, name: tool.displayName || tool.name, description: tool.description || '', diff --git a/packages/cli/src/utils/sessions.test.ts b/packages/cli/src/utils/sessions.test.ts index 8fe22cebba..499a015322 100644 --- a/packages/cli/src/utils/sessions.test.ts +++ b/packages/cli/src/utils/sessions.test.ts @@ -1,740 +1,192 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Config } from '@google/gemini-cli-core'; -import { ChatRecordingService } from '@google/gemini-cli-core'; import { listSessions, deleteSession } from './sessions.js'; -import { SessionSelector, type SessionInfo } from './sessionUtils.js'; +import { + SessionSelector, + SessionError, + type SessionInfo, +} from './sessionUtils.js'; const mocks = vi.hoisted(() => ({ writeToStdout: vi.fn(), writeToStderr: vi.fn(), + generateSummary: vi.fn().mockResolvedValue(undefined), + listSessions: vi.fn(), + findSession: vi.fn(), + deleteSessionArtifacts: vi.fn(), })); -// Mock the SessionSelector and ChatRecordingService -vi.mock('./sessionUtils.js', () => ({ - SessionSelector: vi.fn(), - formatRelativeTime: vi.fn(() => 'some time ago'), -})); +vi.mock('./sessionUtils.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + SessionSelector: vi.fn(), + formatRelativeTime: vi.fn(() => 'some time ago'), + deleteSessionArtifacts: mocks.deleteSessionArtifacts, + }; +}); vi.mock('@google/gemini-cli-core', async () => { const actual = await vi.importActual('@google/gemini-cli-core'); return { ...actual, - ChatRecordingService: vi.fn(), - generateSummary: vi.fn().mockResolvedValue(undefined), + generateSummary: mocks.generateSummary, writeToStdout: mocks.writeToStdout, writeToStderr: mocks.writeToStderr, }; }); -describe('listSessions', () => { - let mockConfig: Config; - let mockListSessions: ReturnType; +const createSession = (overrides: Partial = {}): SessionInfo => ({ + id: 'session-id', + file: 'session-file', + fileName: 'session-file.json', + sessionPath: '/tmp/project/chats/session-file.json', + projectTempDir: '/tmp/project', + projectId: 'project', + projectRoot: '/workspace/project', + startTime: '2025-01-20T12:00:00.000Z', + lastUpdated: '2025-01-20T12:00:00.000Z', + messageCount: 5, + displayName: 'Display title', + sessionName: 'display-title-abc12', + sessionNameBase: 'display-title', + sessionNameSuffix: 'abc12', + firstUserMessage: 'First user message', + isCurrentSession: false, + index: 1, + ...overrides, +}); + +describe('sessions utils', () => { + const mockConfig = {} as Config; beforeEach(() => { - // Create mock config - mockConfig = { - storage: { - getProjectTempDir: vi.fn().mockReturnValue('/tmp/test-project'), - }, - getSessionId: vi.fn().mockReturnValue('current-session-id'), - } as unknown as Config; - - // Create mock listSessions method - mockListSessions = vi.fn(); - - // Mock SessionSelector constructor to return object with listSessions method + vi.clearAllMocks(); vi.mocked(SessionSelector).mockImplementation( () => ({ - listSessions: mockListSessions, - }) as unknown as InstanceType, + listSessions: mocks.listSessions, + findSession: mocks.findSession, + }) as unknown as SessionSelector, ); }); - afterEach(() => { - vi.clearAllMocks(); - mocks.writeToStdout.mockClear(); - mocks.writeToStderr.mockClear(); - }); + describe('listSessions', () => { + it('prints empty message when no sessions exist', async () => { + mocks.listSessions.mockResolvedValue([]); - it('should display message when no previous sessions were found', async () => { - // Arrange: Return empty array from listSessions - mockListSessions.mockResolvedValue([]); + await listSessions(mockConfig); - // Act - await listSessions(mockConfig); - - // Assert - expect(mockListSessions).toHaveBeenCalledOnce(); - expect(mocks.writeToStdout).toHaveBeenCalledWith( - 'No previous sessions found for this project.', - ); - }); - - it('should list sessions when sessions are found', async () => { - // Arrange: Create test sessions - const now = new Date('2025-01-20T12:00:00.000Z'); - const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); - const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000); - - const mockSessions: SessionInfo[] = [ - { - id: 'session-1', - file: 'session-2025-01-18T12-00-00-session-1', - fileName: 'session-2025-01-18T12-00-00-session-1.json', - startTime: twoDaysAgo.toISOString(), - lastUpdated: twoDaysAgo.toISOString(), - messageCount: 5, - displayName: 'First user message', - firstUserMessage: 'First user message', - isCurrentSession: false, - index: 1, - }, - { - id: 'session-2', - file: 'session-2025-01-20T11-00-00-session-2', - fileName: 'session-2025-01-20T11-00-00-session-2.json', - startTime: oneHourAgo.toISOString(), - lastUpdated: oneHourAgo.toISOString(), - messageCount: 10, - displayName: 'Second user message', - firstUserMessage: 'Second user message', - isCurrentSession: false, - index: 2, - }, - { - id: 'current-session-id', - file: 'session-2025-01-20T12-00-00-current-s', - fileName: 'session-2025-01-20T12-00-00-current-s.json', - startTime: now.toISOString(), - lastUpdated: now.toISOString(), - messageCount: 3, - displayName: 'Current session', - firstUserMessage: 'Current session', - isCurrentSession: true, - index: 3, - }, - ]; - - mockListSessions.mockResolvedValue(mockSessions); - - // Act - await listSessions(mockConfig); - - // Assert - expect(mockListSessions).toHaveBeenCalledOnce(); - - // Check that the header was displayed - expect(mocks.writeToStdout).toHaveBeenCalledWith( - '\nAvailable sessions for this project (3):\n', - ); - - // Check that each session was logged - expect(mocks.writeToStdout).toHaveBeenCalledWith( - expect.stringContaining('1. First user message'), - ); - expect(mocks.writeToStdout).toHaveBeenCalledWith( - expect.stringContaining('[session-1]'), - ); - - expect(mocks.writeToStdout).toHaveBeenCalledWith( - expect.stringContaining('2. Second user message'), - ); - expect(mocks.writeToStdout).toHaveBeenCalledWith( - expect.stringContaining('[session-2]'), - ); - - expect(mocks.writeToStdout).toHaveBeenCalledWith( - expect.stringContaining('3. Current session'), - ); - expect(mocks.writeToStdout).toHaveBeenCalledWith( - expect.stringContaining(', current)'), - ); - expect(mocks.writeToStdout).toHaveBeenCalledWith( - expect.stringContaining('[current-session-id]'), - ); - }); - - it('should sort sessions by start time (oldest first)', async () => { - // Arrange: Create sessions in non-chronological order - const session1Time = new Date('2025-01-18T12:00:00.000Z'); - const session2Time = new Date('2025-01-19T12:00:00.000Z'); - const session3Time = new Date('2025-01-20T12:00:00.000Z'); - - const mockSessions: SessionInfo[] = [ - { - id: 'session-2', - file: 'session-2', - fileName: 'session-2.json', - startTime: session2Time.toISOString(), // Middle - lastUpdated: session2Time.toISOString(), - messageCount: 5, - displayName: 'Middle session', - firstUserMessage: 'Middle session', - isCurrentSession: false, - index: 2, - }, - { - id: 'session-1', - file: 'session-1', - fileName: 'session-1.json', - startTime: session1Time.toISOString(), // Oldest - lastUpdated: session1Time.toISOString(), - messageCount: 5, - displayName: 'Oldest session', - firstUserMessage: 'Oldest session', - isCurrentSession: false, - index: 1, - }, - { - id: 'session-3', - file: 'session-3', - fileName: 'session-3.json', - startTime: session3Time.toISOString(), // Newest - lastUpdated: session3Time.toISOString(), - messageCount: 5, - displayName: 'Newest session', - firstUserMessage: 'Newest session', - isCurrentSession: false, - index: 3, - }, - ]; - - mockListSessions.mockResolvedValue(mockSessions); - - // Act - await listSessions(mockConfig); - - // Assert - // Get all the session log calls (skip the header) - const sessionCalls = mocks.writeToStdout.mock.calls.filter( - (call): call is [string] => - typeof call[0] === 'string' && - call[0].includes('[session-') && - !call[0].includes('Available sessions'), - ); - - // Verify they are sorted by start time (oldest first) - expect(sessionCalls[0][0]).toContain('1. Oldest session'); - expect(sessionCalls[1][0]).toContain('2. Middle session'); - expect(sessionCalls[2][0]).toContain('3. Newest session'); - }); - - it('should format session output with relative time and session ID', async () => { - // Arrange - const now = new Date('2025-01-20T12:00:00.000Z'); - const mockSessions: SessionInfo[] = [ - { - id: 'abc123def456', - file: 'session-file', - fileName: 'session-file.json', - startTime: now.toISOString(), - lastUpdated: now.toISOString(), - messageCount: 5, - displayName: 'Test message', - firstUserMessage: 'Test message', - isCurrentSession: false, - index: 1, - }, - ]; - - mockListSessions.mockResolvedValue(mockSessions); - - // Act - await listSessions(mockConfig); - - // Assert - expect(mocks.writeToStdout).toHaveBeenCalledWith( - expect.stringContaining('1. Test message'), - ); - expect(mocks.writeToStdout).toHaveBeenCalledWith( - expect.stringContaining('some time ago'), - ); - expect(mocks.writeToStdout).toHaveBeenCalledWith( - expect.stringContaining('[abc123def456]'), - ); - }); - - it('should handle single session', async () => { - // Arrange - const now = new Date('2025-01-20T12:00:00.000Z'); - const mockSessions: SessionInfo[] = [ - { - id: 'single-session', - file: 'session-file', - fileName: 'session-file.json', - startTime: now.toISOString(), - lastUpdated: now.toISOString(), - messageCount: 5, - displayName: 'Only session', - firstUserMessage: 'Only session', - isCurrentSession: true, - index: 1, - }, - ]; - - mockListSessions.mockResolvedValue(mockSessions); - - // Act - await listSessions(mockConfig); - - // Assert - expect(mocks.writeToStdout).toHaveBeenCalledWith( - '\nAvailable sessions for this project (1):\n', - ); - expect(mocks.writeToStdout).toHaveBeenCalledWith( - expect.stringContaining('1. Only session'), - ); - expect(mocks.writeToStdout).toHaveBeenCalledWith( - expect.stringContaining(', current)'), - ); - }); - - it('should display summary as title when available instead of first user message', async () => { - // Arrange - const now = new Date('2025-01-20T12:00:00.000Z'); - const mockSessions: SessionInfo[] = [ - { - id: 'session-with-summary', - file: 'session-file', - fileName: 'session-file.json', - startTime: now.toISOString(), - lastUpdated: now.toISOString(), - messageCount: 10, - displayName: 'Add dark mode to the app', // Summary - firstUserMessage: - 'How do I add dark mode to my React application with CSS variables?', - isCurrentSession: false, - index: 1, - summary: 'Add dark mode to the app', - }, - ]; - - mockListSessions.mockResolvedValue(mockSessions); - - // Act - await listSessions(mockConfig); - - // Assert: Should show the summary (displayName), not the first user message - expect(mocks.writeToStdout).toHaveBeenCalledWith( - expect.stringContaining('1. Add dark mode to the app'), - ); - expect(mocks.writeToStdout).not.toHaveBeenCalledWith( - expect.stringContaining('How do I add dark mode to my React application'), - ); - }); -}); - -describe('deleteSession', () => { - let mockConfig: Config; - let mockListSessions: ReturnType; - let mockDeleteSession: ReturnType; - - beforeEach(() => { - // Create mock config - mockConfig = { - storage: { - getProjectTempDir: vi.fn().mockReturnValue('/tmp/test-project'), - }, - getSessionId: vi.fn().mockReturnValue('current-session-id'), - } as unknown as Config; - - // Create mock methods - mockListSessions = vi.fn(); - mockDeleteSession = vi.fn(); - - // Mock SessionSelector constructor - vi.mocked(SessionSelector).mockImplementation( - () => - ({ - listSessions: mockListSessions, - }) as unknown as InstanceType, - ); - - // Mock ChatRecordingService - vi.mocked(ChatRecordingService).mockImplementation( - () => - ({ - deleteSession: mockDeleteSession, - }) as unknown as InstanceType, - ); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('should display error when no sessions are found', async () => { - // Arrange - mockListSessions.mockResolvedValue([]); - - // Act - await deleteSession(mockConfig, '1'); - - // Assert - expect(mockListSessions).toHaveBeenCalledOnce(); - expect(mocks.writeToStderr).toHaveBeenCalledWith( - 'No sessions found for this project.', - ); - expect(mockDeleteSession).not.toHaveBeenCalled(); - }); - - it('should delete session by UUID', async () => { - // Arrange - const now = new Date('2025-01-20T12:00:00.000Z'); - const mockSessions: SessionInfo[] = [ - { - id: 'session-uuid-123', - file: 'session-file-123', - fileName: 'session-file-123.json', - startTime: now.toISOString(), - lastUpdated: now.toISOString(), - messageCount: 5, - displayName: 'Test session', - firstUserMessage: 'Test session', - isCurrentSession: false, - index: 1, - }, - ]; - - mockListSessions.mockResolvedValue(mockSessions); - mockDeleteSession.mockImplementation(() => {}); - - // Act - await deleteSession(mockConfig, 'session-uuid-123'); - - // Assert - expect(mockListSessions).toHaveBeenCalledOnce(); - expect(mockDeleteSession).toHaveBeenCalledWith('session-file-123'); - expect(mocks.writeToStdout).toHaveBeenCalledWith( - 'Deleted session 1: Test session (some time ago)', - ); - expect(mocks.writeToStderr).not.toHaveBeenCalled(); - }); - - it('should delete session by index', async () => { - // Arrange - const now = new Date('2025-01-20T12:00:00.000Z'); - const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); - - const mockSessions: SessionInfo[] = [ - { - id: 'session-1', - file: 'session-file-1', - fileName: 'session-file-1.json', - startTime: oneHourAgo.toISOString(), - lastUpdated: oneHourAgo.toISOString(), - messageCount: 5, - displayName: 'First session', - firstUserMessage: 'First session', - isCurrentSession: false, - index: 1, - }, - { - id: 'session-2', - file: 'session-file-2', - fileName: 'session-file-2.json', - startTime: now.toISOString(), - lastUpdated: now.toISOString(), - messageCount: 10, - displayName: 'Second session', - firstUserMessage: 'Second session', - isCurrentSession: false, - index: 2, - }, - ]; - - mockListSessions.mockResolvedValue(mockSessions); - mockDeleteSession.mockImplementation(() => {}); - - // Act - await deleteSession(mockConfig, '2'); - - // Assert - expect(mockListSessions).toHaveBeenCalledOnce(); - expect(mockDeleteSession).toHaveBeenCalledWith('session-file-2'); - expect(mocks.writeToStdout).toHaveBeenCalledWith( - 'Deleted session 2: Second session (some time ago)', - ); - }); - - it('should display error for invalid session identifier (non-numeric)', async () => { - // Arrange - const now = new Date('2025-01-20T12:00:00.000Z'); - const mockSessions: SessionInfo[] = [ - { - id: 'session-1', - file: 'session-file-1', - fileName: 'session-file-1.json', - startTime: now.toISOString(), - lastUpdated: now.toISOString(), - messageCount: 5, - displayName: 'Test session', - firstUserMessage: 'Test session', - isCurrentSession: false, - index: 1, - }, - ]; - - mockListSessions.mockResolvedValue(mockSessions); - - // Act - await deleteSession(mockConfig, 'invalid-id'); - - // Assert - expect(mocks.writeToStderr).toHaveBeenCalledWith( - 'Invalid session identifier "invalid-id". Use --list-sessions to see available sessions.', - ); - expect(mockDeleteSession).not.toHaveBeenCalled(); - }); - - it('should display error for invalid session identifier (out of range)', async () => { - // Arrange - const now = new Date('2025-01-20T12:00:00.000Z'); - const mockSessions: SessionInfo[] = [ - { - id: 'session-1', - file: 'session-file-1', - fileName: 'session-file-1.json', - startTime: now.toISOString(), - lastUpdated: now.toISOString(), - messageCount: 5, - displayName: 'Test session', - firstUserMessage: 'Test session', - isCurrentSession: false, - index: 1, - }, - ]; - - mockListSessions.mockResolvedValue(mockSessions); - - // Act - await deleteSession(mockConfig, '999'); - - // Assert - expect(mocks.writeToStderr).toHaveBeenCalledWith( - 'Invalid session identifier "999". Use --list-sessions to see available sessions.', - ); - expect(mockDeleteSession).not.toHaveBeenCalled(); - }); - - it('should display error for invalid session identifier (zero)', async () => { - // Arrange - const now = new Date('2025-01-20T12:00:00.000Z'); - const mockSessions: SessionInfo[] = [ - { - id: 'session-1', - file: 'session-file-1', - fileName: 'session-file-1.json', - startTime: now.toISOString(), - lastUpdated: now.toISOString(), - messageCount: 5, - displayName: 'Test session', - firstUserMessage: 'Test session', - isCurrentSession: false, - index: 1, - }, - ]; - - mockListSessions.mockResolvedValue(mockSessions); - - // Act - await deleteSession(mockConfig, '0'); - - // Assert - expect(mocks.writeToStderr).toHaveBeenCalledWith( - 'Invalid session identifier "0". Use --list-sessions to see available sessions.', - ); - expect(mockDeleteSession).not.toHaveBeenCalled(); - }); - - it('should prevent deletion of current session', async () => { - // Arrange - const now = new Date('2025-01-20T12:00:00.000Z'); - const mockSessions: SessionInfo[] = [ - { - id: 'current-session-id', - file: 'current-session-file', - fileName: 'current-session-file.json', - startTime: now.toISOString(), - lastUpdated: now.toISOString(), - messageCount: 5, - displayName: 'Current session', - firstUserMessage: 'Current session', - isCurrentSession: true, - index: 1, - }, - ]; - - mockListSessions.mockResolvedValue(mockSessions); - - // Act - try to delete by index - await deleteSession(mockConfig, '1'); - - // Assert - expect(mocks.writeToStderr).toHaveBeenCalledWith( - 'Cannot delete the current active session.', - ); - expect(mockDeleteSession).not.toHaveBeenCalled(); - }); - - it('should prevent deletion of current session by UUID', async () => { - // Arrange - const now = new Date('2025-01-20T12:00:00.000Z'); - const mockSessions: SessionInfo[] = [ - { - id: 'current-session-id', - file: 'current-session-file', - fileName: 'current-session-file.json', - startTime: now.toISOString(), - lastUpdated: now.toISOString(), - messageCount: 5, - displayName: 'Current session', - firstUserMessage: 'Current session', - isCurrentSession: true, - index: 1, - }, - ]; - - mockListSessions.mockResolvedValue(mockSessions); - - // Act - try to delete by UUID - await deleteSession(mockConfig, 'current-session-id'); - - // Assert - expect(mocks.writeToStderr).toHaveBeenCalledWith( - 'Cannot delete the current active session.', - ); - expect(mockDeleteSession).not.toHaveBeenCalled(); - }); - - it('should handle deletion errors gracefully', async () => { - // Arrange - const now = new Date('2025-01-20T12:00:00.000Z'); - const mockSessions: SessionInfo[] = [ - { - id: 'session-1', - file: 'session-file-1', - fileName: 'session-file-1.json', - startTime: now.toISOString(), - lastUpdated: now.toISOString(), - messageCount: 5, - displayName: 'Test session', - firstUserMessage: 'Test session', - isCurrentSession: false, - index: 1, - }, - ]; - - mockListSessions.mockResolvedValue(mockSessions); - mockDeleteSession.mockImplementation(() => { - throw new Error('File deletion failed'); + expect(mocks.writeToStdout).toHaveBeenCalledWith( + 'No previous sessions found.', + ); }); - // Act - await deleteSession(mockConfig, '1'); + it('prints global session list with name and project root', async () => { + mocks.listSessions.mockResolvedValue([ + createSession({ + id: 'older', + sessionName: 'older-chat-aaaaa', + startTime: '2025-01-19T12:00:00.000Z', + projectRoot: '/workspace/older', + }), + createSession({ + id: 'newer', + sessionName: 'newer-chat-bbbbb', + startTime: '2025-01-20T12:00:00.000Z', + projectRoot: '/workspace/newer', + isCurrentSession: true, + }), + ]); - // Assert - expect(mockDeleteSession).toHaveBeenCalledWith('session-file-1'); - expect(mocks.writeToStderr).toHaveBeenCalledWith( - 'Failed to delete session: File deletion failed', - ); + await listSessions(mockConfig); + + expect(mocks.writeToStdout).toHaveBeenCalledWith( + '\nAvailable sessions (2):\n', + ); + expect(mocks.writeToStdout).toHaveBeenCalledWith( + expect.stringContaining('1. older-chat-aaaaa'), + ); + expect(mocks.writeToStdout).toHaveBeenCalledWith( + expect.stringContaining('/workspace/older'), + ); + expect(mocks.writeToStdout).toHaveBeenCalledWith( + expect.stringContaining(', current)'), + ); + }); }); - it('should handle non-Error deletion failures', async () => { - // Arrange - const now = new Date('2025-01-20T12:00:00.000Z'); - const mockSessions: SessionInfo[] = [ - { - id: 'session-1', - file: 'session-file-1', - fileName: 'session-file-1.json', - startTime: now.toISOString(), - lastUpdated: now.toISOString(), - messageCount: 5, - displayName: 'Test session', - firstUserMessage: 'Test session', - isCurrentSession: false, - index: 1, - }, - ]; + describe('deleteSession', () => { + it('prints empty message when no sessions exist', async () => { + mocks.listSessions.mockResolvedValue([]); - mockListSessions.mockResolvedValue(mockSessions); - mockDeleteSession.mockImplementation(() => { - // eslint-disable-next-line no-restricted-syntax - throw 'Unknown error type'; + await deleteSession(mockConfig, '1'); + + expect(mocks.writeToStderr).toHaveBeenCalledWith('No sessions found.'); + expect(mocks.deleteSessionArtifacts).not.toHaveBeenCalled(); }); - // Act - await deleteSession(mockConfig, '1'); + it('deletes a selected session and prints confirmation', async () => { + const target = createSession({ sessionName: 'target-chat-ccccc' }); + mocks.listSessions.mockResolvedValue([target]); + mocks.findSession.mockResolvedValue(target); - // Assert - expect(mocks.writeToStderr).toHaveBeenCalledWith( - 'Failed to delete session: Unknown error', - ); - }); + await deleteSession(mockConfig, 'target-chat-ccccc'); - it('should sort sessions before finding by index', async () => { - // Arrange: Create sessions in non-chronological order - const session1Time = new Date('2025-01-18T12:00:00.000Z'); - const session2Time = new Date('2025-01-19T12:00:00.000Z'); - const session3Time = new Date('2025-01-20T12:00:00.000Z'); + expect(mocks.findSession).toHaveBeenCalledWith('target-chat-ccccc'); + expect(mocks.deleteSessionArtifacts).toHaveBeenCalledWith(target); + expect(mocks.writeToStdout).toHaveBeenCalledWith( + 'Deleted session: target-chat-ccccc (some time ago)', + ); + }); - const mockSessions: SessionInfo[] = [ - { - id: 'session-3', - file: 'session-file-3', - fileName: 'session-file-3.json', - startTime: session3Time.toISOString(), // Newest - lastUpdated: session3Time.toISOString(), - messageCount: 5, - displayName: 'Newest session', - firstUserMessage: 'Newest session', - isCurrentSession: false, - index: 3, - }, - { - id: 'session-1', - file: 'session-file-1', - fileName: 'session-file-1.json', - startTime: session1Time.toISOString(), // Oldest - lastUpdated: session1Time.toISOString(), - messageCount: 5, - displayName: 'Oldest session', - firstUserMessage: 'Oldest session', - isCurrentSession: false, - index: 1, - }, - { - id: 'session-2', - file: 'session-file-2', - fileName: 'session-file-2.json', - startTime: session2Time.toISOString(), // Middle - lastUpdated: session2Time.toISOString(), - messageCount: 5, - displayName: 'Middle session', - firstUserMessage: 'Middle session', - isCurrentSession: false, - index: 2, - }, - ]; + it('shows SessionError messages for invalid identifiers', async () => { + mocks.listSessions.mockResolvedValue([createSession()]); + mocks.findSession.mockRejectedValue( + SessionError.invalidSessionIdentifier('bad'), + ); - mockListSessions.mockResolvedValue(mockSessions); - mockDeleteSession.mockImplementation(() => {}); + await deleteSession(mockConfig, 'bad'); - // Act - delete index 1 (should be oldest session after sorting) - await deleteSession(mockConfig, '1'); + expect(mocks.writeToStderr).toHaveBeenCalledWith( + expect.stringContaining('Invalid session identifier "bad".'), + ); + expect(mocks.deleteSessionArtifacts).not.toHaveBeenCalled(); + }); - // Assert - expect(mockDeleteSession).toHaveBeenCalledWith('session-file-1'); - expect(mocks.writeToStdout).toHaveBeenCalledWith( - expect.stringContaining('Oldest session'), - ); + it('prevents deleting the current session', async () => { + const current = createSession({ + id: 'current-session-id', + isCurrentSession: true, + }); + mocks.listSessions.mockResolvedValue([current]); + mocks.findSession.mockResolvedValue(current); + + await deleteSession(mockConfig, 'current-session-id'); + + expect(mocks.writeToStderr).toHaveBeenCalledWith( + 'Cannot delete the current active session.', + ); + expect(mocks.deleteSessionArtifacts).not.toHaveBeenCalled(); + }); + + it('handles deletion failures', async () => { + const target = createSession({ sessionName: 'broken-chat-ddddd' }); + mocks.listSessions.mockResolvedValue([target]); + mocks.findSession.mockResolvedValue(target); + mocks.deleteSessionArtifacts.mockRejectedValue(new Error('disk failure')); + + await deleteSession(mockConfig, 'broken-chat-ddddd'); + + expect(mocks.writeToStderr).toHaveBeenCalledWith( + 'Failed to delete session: disk failure', + ); + }); }); }); diff --git a/packages/cli/src/utils/sessions.ts b/packages/cli/src/utils/sessions.ts index 56f9f06a6a..e1a13828e8 100644 --- a/packages/cli/src/utils/sessions.ts +++ b/packages/cli/src/utils/sessions.ts @@ -5,14 +5,15 @@ */ import { - ChatRecordingService, generateSummary, writeToStderr, writeToStdout, type Config, } from '@google/gemini-cli-core'; import { + deleteSessionArtifacts, formatRelativeTime, + SessionError, SessionSelector, type SessionInfo, } from './sessionUtils.js'; @@ -25,13 +26,11 @@ export async function listSessions(config: Config): Promise { const sessions = await sessionSelector.listSessions(); if (sessions.length === 0) { - writeToStdout('No previous sessions found for this project.'); + writeToStdout('No previous sessions found.'); return; } - writeToStdout( - `\nAvailable sessions for this project (${sessions.length}):\n`, - ); + writeToStdout(`\nAvailable sessions (${sessions.length}):\n`); sessions .sort( @@ -41,13 +40,11 @@ export async function listSessions(config: Config): Promise { .forEach((session, index) => { const current = session.isCurrentSession ? ', current' : ''; const time = formatRelativeTime(session.lastUpdated); - const title = - session.displayName.length > 100 - ? session.displayName.slice(0, 97) + '...' - : session.displayName; + const folder = session.projectRoot || '(unknown project)'; writeToStdout( - ` ${index + 1}. ${title} (${time}${current}) [${session.id}]\n`, + ` ${index + 1}. ${session.sessionName} (${time}${current})\n`, ); + writeToStdout(` ${folder}\n`); }); } @@ -59,33 +56,18 @@ export async function deleteSession( const sessions = await sessionSelector.listSessions(); if (sessions.length === 0) { - writeToStderr('No sessions found for this project.'); + writeToStderr('No sessions found.'); return; } - - // Sort sessions by start time to match list-sessions ordering - const sortedSessions = sessions.sort( - (a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), - ); - let sessionToDelete: SessionInfo; - - // Try to find by UUID first - const sessionByUuid = sortedSessions.find( - (session) => session.id === sessionIndex, - ); - if (sessionByUuid) { - sessionToDelete = sessionByUuid; - } else { - // Parse session index - const index = parseInt(sessionIndex, 10); - if (isNaN(index) || index < 1 || index > sessions.length) { - writeToStderr( - `Invalid session identifier "${sessionIndex}". Use --list-sessions to see available sessions.`, - ); + try { + sessionToDelete = await sessionSelector.findSession(sessionIndex); + } catch (error) { + if (error instanceof SessionError) { + writeToStderr(error.message); return; } - sessionToDelete = sortedSessions[index - 1]; + throw error; } // Prevent deleting the current session @@ -95,13 +77,11 @@ export async function deleteSession( } try { - // Use ChatRecordingService to delete the session - const chatRecordingService = new ChatRecordingService(config); - chatRecordingService.deleteSession(sessionToDelete.file); + await deleteSessionArtifacts(sessionToDelete); const time = formatRelativeTime(sessionToDelete.lastUpdated); writeToStdout( - `Deleted session ${sessionToDelete.index}: ${sessionToDelete.firstUserMessage} (${time})`, + `Deleted session: ${sessionToDelete.sessionName} (${time})`, ); } catch (error) { writeToStderr( diff --git a/packages/core/src/config/defaultModelConfigs.ts b/packages/core/src/config/defaultModelConfigs.ts index c0424de9e3..a668a748d8 100644 --- a/packages/core/src/config/defaultModelConfigs.ts +++ b/packages/core/src/config/defaultModelConfigs.ts @@ -156,6 +156,18 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = { }, }, }, + 'session-name-default': { + extends: 'base', + modelConfig: { + model: 'gemini-2.5-flash-lite', + generateContentConfig: { + maxOutputTokens: 128, + thinkingConfig: { + thinkingBudget: 0, + }, + }, + }, + }, 'web-search': { extends: 'gemini-3-flash-base', modelConfig: { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 448e555df4..b2a39a7e0d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -109,6 +109,7 @@ export * from './utils/constants.js'; export * from './services/fileDiscoveryService.js'; export * from './services/gitService.js'; export * from './services/chatRecordingService.js'; +export * from './services/sessionNamingService.js'; export * from './services/fileSystemService.js'; export * from './services/sessionSummaryUtils.js'; export * from './services/contextManager.js'; diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index bdce4f5f9e..ef18a3f8dc 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -6,6 +6,8 @@ import { type Config } from '../config/config.js'; import { type Status } from '../core/coreToolScheduler.js'; +import { partListUnionToString } from '../core/geminiRequest.js'; +import { BaseLlmClient } from '../core/baseLlmClient.js'; import { type ThoughtSummary } from '../utils/thoughtUtils.js'; import { getProjectHash } from '../utils/paths.js'; import { sanitizeFilenamePart } from '../utils/fileUtils.js'; @@ -20,6 +22,13 @@ import type { } from '@google/genai'; import { debugLogger } from '../utils/debugLogger.js'; import type { ToolResultDisplay } from '../tools/tools.js'; +import { + ensureSessionNameBase, + generateSessionNameBase, + getDefaultSessionNameBase, + getSessionNameSuffix, + normalizeSessionNameBase, +} from './sessionNamingService.js'; export const SESSION_FILE_PREFIX = 'session-'; @@ -99,6 +108,10 @@ export interface ConversationRecord { startTime: string; lastUpdated: string; messages: MessageRecord[]; + /** User-defined or AI-generated session name base (suffix stored separately). */ + sessionNameBase?: string; + /** Immutable session name suffix derived from sessionId. */ + sessionNameSuffix?: string; summary?: string; /** Workspace directories added during the session via /dir add */ directories?: string[]; @@ -131,11 +144,16 @@ export class ChatRecordingService { private queuedThoughts: Array = []; private queuedTokens: TokensSummary | null = null; private config: Config; + private sessionNameSuffix: string; + private fallbackSessionNameBase: string; + private sessionNameGenerationStarted = false; constructor(config: Config) { this.config = config; this.sessionId = config.getSessionId(); this.projectHash = getProjectHash(config.getProjectRoot()); + this.sessionNameSuffix = getSessionNameSuffix(this.sessionId); + this.fallbackSessionNameBase = getDefaultSessionNameBase(); } /** @@ -148,15 +166,22 @@ export class ChatRecordingService { // Resume from existing session this.conversationFile = resumedSessionData.filePath; this.sessionId = resumedSessionData.conversation.sessionId; + this.sessionNameSuffix = getSessionNameSuffix(this.sessionId); + this.fallbackSessionNameBase = getDefaultSessionNameBase( + new Date(resumedSessionData.conversation.startTime || Date.now()), + ); + this.sessionNameGenerationStarted = true; // Update the session ID in the existing file this.updateConversation((conversation) => { conversation.sessionId = this.sessionId; + this.ensureSessionNaming(conversation); }); // Clear any cached data to force fresh reads this.cachedLastConvData = null; } else { + this.sessionNameGenerationStarted = false; // Create new session const chatsDir = path.join( this.config.storage.getProjectTempDir(), @@ -179,6 +204,8 @@ export class ChatRecordingService { projectHash: this.projectHash, startTime: new Date().toISOString(), lastUpdated: new Date().toISOString(), + sessionNameBase: this.fallbackSessionNameBase, + sessionNameSuffix: this.sessionNameSuffix, messages: [], }); } @@ -234,8 +261,13 @@ export class ChatRecordingService { }): void { if (!this.conversationFile) return; + let firstUserMessage: string | null = null; + let expectedSessionNameBase: string | null = null; + try { this.updateConversation((conversation) => { + this.ensureSessionNaming(conversation); + const msg = this.newMessage( message.type, message.content, @@ -254,8 +286,24 @@ export class ChatRecordingService { } else { // Or else just add it. conversation.messages.push(msg); + + const meaningfulUserMessage = this.getMeaningfulUserMessage( + message.content, + ); + if (meaningfulUserMessage && !this.sessionNameGenerationStarted) { + this.sessionNameGenerationStarted = true; + firstUserMessage = meaningfulUserMessage; + expectedSessionNameBase = conversation.sessionNameBase ?? null; + } } }); + + if (firstUserMessage && expectedSessionNameBase) { + void this.generateAndSaveSessionName( + firstUserMessage, + expectedSessionNameBase, + ); + } } catch (error) { debugLogger.error('Error saving message to chat history.', error); throw error; @@ -433,6 +481,8 @@ export class ChatRecordingService { projectHash: this.projectHash, startTime: new Date().toISOString(), lastUpdated: new Date().toISOString(), + sessionNameBase: this.fallbackSessionNameBase, + sessionNameSuffix: this.sessionNameSuffix, messages: [], }; } @@ -486,6 +536,68 @@ export class ChatRecordingService { this.writeConversation(conversation); } + private ensureSessionNaming(conversation: ConversationRecord): void { + conversation.sessionNameSuffix = + conversation.sessionNameSuffix || this.sessionNameSuffix; + conversation.sessionNameBase = ensureSessionNameBase( + conversation.sessionNameBase || this.fallbackSessionNameBase, + ); + } + + private getMeaningfulUserMessage(content: PartListUnion): string | null { + const text = partListUnionToString(content).trim(); + if (!text) { + return null; + } + if (text.startsWith('/') || text.startsWith('?')) { + return null; + } + return text; + } + + private async generateAndSaveSessionName( + firstUserRequest: string, + expectedSessionNameBase: string, + ): Promise { + if (!this.conversationFile) { + return; + } + + try { + const contentGenerator = this.config.getContentGenerator(); + if (!contentGenerator) { + return; + } + + const baseLlmClient = new BaseLlmClient(contentGenerator, this.config); + const generatedBase = await generateSessionNameBase({ + baseLlmClient, + firstUserRequest, + }); + if (!generatedBase) { + return; + } + + this.updateConversation((conversation) => { + this.ensureSessionNaming(conversation); + + const currentBase = normalizeSessionNameBase( + conversation.sessionNameBase || '', + ); + const expectedBase = normalizeSessionNameBase(expectedSessionNameBase); + + // Avoid overwriting user-initiated renames while async generation was pending. + if (currentBase.length === 0 || currentBase === expectedBase) { + conversation.sessionNameBase = generatedBase; + } + }); + } catch (error) { + debugLogger.debug( + `[SessionName] Failed to generate session name: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + /** * Saves a summary for the current session. */ diff --git a/packages/core/src/services/sessionNamingService.test.ts b/packages/core/src/services/sessionNamingService.test.ts new file mode 100644 index 0000000000..53a567ef72 --- /dev/null +++ b/packages/core/src/services/sessionNamingService.test.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { BaseLlmClient } from '../core/baseLlmClient.js'; +import { + buildSessionName, + ensureSessionNameBase, + generateSessionNameBase, + getDefaultSessionNameBase, + getSessionNameSuffix, + normalizeSessionNameBase, + normalizeSessionNameSuffix, +} from './sessionNamingService.js'; + +describe('sessionNamingService', () => { + it('normalizes session name base as kebab-case', () => { + expect(normalizeSessionNameBase('Fix Login Bug!!')).toBe('fix-login-bug'); + }); + + it('returns default fallback base when empty', () => { + const base = ensureSessionNameBase(''); + expect(base.startsWith('session-')).toBe(true); + }); + + it('creates deterministic 5-char suffix', () => { + expect(getSessionNameSuffix('123e4567-e89b-12d3-a456-426614174000')).toBe( + '123e4', + ); + }); + + it('normalizes suffix with padding', () => { + expect(normalizeSessionNameSuffix('ab')).toBe('ab000'); + }); + + it('builds full name with immutable suffix', () => { + expect(buildSessionName('Fix auth flow', 'abc12')).toBe( + 'fix-auth-flow-abc12', + ); + }); + + it('formats default session base from timestamp', () => { + const base = getDefaultSessionNameBase(new Date('2026-02-13T12:34:56Z')); + expect(base).toBe('session-20260213-123456'); + }); + + it('generates normalized name from model output', async () => { + const baseLlmClient = { + generateContent: vi.fn().mockResolvedValue({ + candidates: [{ content: { parts: [{ text: 'Fix API auth bug' }] } }], + }), + } as unknown as BaseLlmClient; + + const generated = await generateSessionNameBase({ + baseLlmClient, + firstUserRequest: 'Please help fix our API authentication bug', + }); + + expect(generated).toBe('fix-api-auth-bug'); + }); + + it('returns null when model output is empty', async () => { + const baseLlmClient = { + generateContent: vi.fn().mockResolvedValue({ + candidates: [{ content: { parts: [{ text: ' ' }] } }], + }), + } as unknown as BaseLlmClient; + + const generated = await generateSessionNameBase({ + baseLlmClient, + firstUserRequest: 'name this', + }); + + expect(generated).toBeNull(); + }); +}); + diff --git a/packages/core/src/services/sessionNamingService.ts b/packages/core/src/services/sessionNamingService.ts new file mode 100644 index 0000000000..ee01d2f5c1 --- /dev/null +++ b/packages/core/src/services/sessionNamingService.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Content } from '@google/genai'; +import type { BaseLlmClient } from '../core/baseLlmClient.js'; +import { getResponseText } from '../utils/partUtils.js'; + +export const SESSION_NAME_SUFFIX_LENGTH = 5; +const SESSION_NAME_BASE_MAX_LENGTH = 60; +const SESSION_NAME_TIMEOUT_MS = 4000; + +const SESSION_NAME_PROMPT = `Generate a short session name based on the user's FIRST request. + +Rules: +- 3 to 8 words max +- Focus on user's goal +- No punctuation +- No quotes + +First user request: +{request} + +Session name:`; + +function leftPad(value: number): string { + return String(value).padStart(2, '0'); +} + +export function getDefaultSessionNameBase(date: Date = new Date()): string { + return `session-${date.getUTCFullYear()}${leftPad(date.getUTCMonth() + 1)}${leftPad(date.getUTCDate())}-${leftPad(date.getUTCHours())}${leftPad(date.getUTCMinutes())}${leftPad(date.getUTCSeconds())}`; +} + +export function normalizeSessionNameBase(input: string): string { + const normalized = input + .normalize('NFKD') + .replace(/[^\x00-\x7F]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, SESSION_NAME_BASE_MAX_LENGTH) + .replace(/-+$/g, ''); + + return normalized; +} + +export function normalizeSessionNameSuffix(input: string): string { + const alphanumeric = input.toLowerCase().replace(/[^a-z0-9]/g, ''); + + if (alphanumeric.length === 0) { + return '00000'; + } + + if (alphanumeric.length >= SESSION_NAME_SUFFIX_LENGTH) { + return alphanumeric.slice(0, SESSION_NAME_SUFFIX_LENGTH); + } + + return alphanumeric.padEnd(SESSION_NAME_SUFFIX_LENGTH, '0'); +} + +export function getSessionNameSuffix(sessionId: string): string { + return normalizeSessionNameSuffix(sessionId); +} + +export function ensureSessionNameBase(base: string | undefined): string { + const normalized = normalizeSessionNameBase(base ?? ''); + if (normalized.length > 0) { + return normalized; + } + return getDefaultSessionNameBase(); +} + +export function buildSessionName(base: string, suffix: string): string { + return `${ensureSessionNameBase(base)}-${normalizeSessionNameSuffix(suffix)}`; +} + +export interface GenerateSessionNameOptions { + baseLlmClient: BaseLlmClient; + firstUserRequest: string; + timeoutMs?: number; +} + +export async function generateSessionNameBase({ + baseLlmClient, + firstUserRequest, + timeoutMs = SESSION_NAME_TIMEOUT_MS, +}: GenerateSessionNameOptions): Promise { + const trimmedRequest = firstUserRequest.trim(); + if (!trimmedRequest) { + return null; + } + + const prompt = SESSION_NAME_PROMPT.replace('{request}', trimmedRequest); + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), timeoutMs); + + try { + const contents: Content[] = [ + { + role: 'user', + parts: [{ text: prompt }], + }, + ]; + + const response = await baseLlmClient.generateContent({ + modelConfigKey: { model: 'session-name-default' }, + contents, + abortSignal: abortController.signal, + promptId: 'session-name-generation', + }); + + const generated = getResponseText(response); + if (!generated) { + return null; + } + + const normalized = normalizeSessionNameBase(generated); + return normalized.length > 0 ? normalized : null; + } catch { + return null; + } finally { + clearTimeout(timeoutId); + } +} +