Add global session history and naming workflow

This commit is contained in:
Dmitry Lyalin
2026-02-12 23:29:34 -05:00
parent d82f66973f
commit 0806784b90
27 changed files with 1434 additions and 895 deletions

View File

@@ -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',

View File

@@ -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);

View File

@@ -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(),

View File

@@ -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<React.ReactNode | null>(
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<void> => {
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<void> => {
await handleResumeSessionBase(session);
updateSessionRecoveryState(true);
},
[handleResumeSessionBase, updateSessionRecoveryState],
);
const handleRenameSession = useCallback(
async (session: SessionInfo, newNameBase: string): Promise<SessionInfo> => {
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(
<CrashResumeDialog
sessionName={recovery.sessionName}
projectRoot={recovery.projectRoot}
updatedAt={recovery.updatedAt}
onSelect={(choice) => {
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,

View File

@@ -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,
},
],

View File

@@ -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<CrashResumeDialogProps> = ({
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<RadioSelectItem<CrashResumeChoice>> = [
{ 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 (
<Box flexDirection="column" width="100%">
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.status.warning}
padding={1}
marginLeft={1}
marginRight={1}
>
<Text bold color={theme.text.primary}>
Gemini CLI did not exit cleanly last time.
</Text>
<Text color={theme.text.secondary}>Resume your previous chat session?</Text>
<Text color={theme.text.primary}>
Session: <Text>{sessionName}</Text>
</Text>
<Text color={theme.text.primary}>
Project: <Text>{projectRoot}</Text>
</Text>
<Text color={theme.text.secondary}>Last update: {updatedAt}</Text>
<Box marginTop={1}>
<RadioButtonSelect
items={options}
onSelect={(choice) => {
setSubmitting(true);
onSelect(choice);
}}
isFocused={!submitting}
/>
</Box>
</Box>
</Box>
);
};

View File

@@ -299,6 +299,7 @@ export const DialogManager = ({
config={config}
onResumeSession={uiActions.handleResumeSession}
onDeleteSession={uiActions.handleDeleteSession}
onRenameSession={uiActions.handleRenameSession}
onExit={uiActions.closeSessionBrowser}
/>
);

View File

@@ -169,7 +169,11 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
<ModelMessage model={itemForDisplay.model} />
)}
{itemForDisplay.type === 'quit' && (
<SessionSummaryDisplay duration={itemForDisplay.duration} />
<SessionSummaryDisplay
duration={itemForDisplay.duration}
sessionName={itemForDisplay.sessionName}
resumeCommandHint={itemForDisplay.resumeCommandHint}
/>
)}
{itemForDisplay.type === 'tool_group' && (
<ToolGroupMessage

View File

@@ -61,6 +61,11 @@ vi.mock('./SessionBrowser.js', async (importOriginal) => {
(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>): 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"
/>,

View File

@@ -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<void>;
/** Callback when user renames a session */
onRenameSession: (
session: SessionInfo,
newNameBase: string,
) => Promise<SessionInfo>;
/** 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<React.SetStateAction<string>>;
/** Update search mode state */
setIsSearchMode: React.Dispatch<React.SetStateAction<boolean>>;
/** Update rename mode state */
setIsRenameMode: React.Dispatch<React.SetStateAction<boolean>>;
/** Update rename query */
setRenameQuery: React.Dispatch<React.SetStateAction<string>>;
/** 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 = ({
</Box>
);
/**
* Rename input display component.
*/
const RenameModeDisplay = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box marginTop={1}>
<Text color={Colors.Gray}>Rename: </Text>
<Text color={Colors.AccentPurple}>{state.renameQuery}</Text>
<Text color={Colors.Gray}> (Enter to save, Esc to cancel)</Text>
</Box>
);
/**
* Header component showing session count and sort information.
*/
@@ -314,6 +343,8 @@ const NavigationHelp = (): React.JSX.Element => (
{' '}
<Kbd name="Search" shortcut="/" />
{' '}
<Kbd name="Rename" shortcut="n" />
{' '}
<Kbd name="Delete" shortcut="x" />
{' '}
<Kbd name="Quit" shortcut="q" />
@@ -380,6 +411,48 @@ const NoResultsDisplay = ({
</Box>
);
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 (
<Box flexDirection="column" marginTop={1}>
<Text color={Colors.Gray}>
Selected: <Text color={Colors.Foreground}>{session.sessionName}</Text>
</Text>
<Text color={Colors.Gray}>
Project:{' '}
<Text color={Colors.Foreground}>
{session.projectRoot || '(unknown)'}
</Text>
</Text>
<Text color={Colors.Gray}>
Started:{' '}
<Text color={Colors.Foreground}>{formatTimestamp(session.startTime)}</Text>
</Text>
<Text color={Colors.Gray}>
Updated:{' '}
<Text color={Colors.Foreground}>
{formatTimestamp(session.lastUpdated)}
</Text>
</Text>
</Box>
);
};
/**
* Match snippet display component for search results.
*/
@@ -472,14 +545,14 @@ const SessionItem = ({
const truncatedMessage =
matchDisplay ||
(session.displayName.length === 0 ? (
(session.sessionName.length === 0 ? (
<Text color={textColor(Colors.Gray)} dimColor>
(No messages)
</Text>
) : 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<void>,
onRenameSession: (session: SessionInfo, newNameBase: string) => Promise<SessionInfo>,
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 <SessionBrowserEmpty />;
}
const selectedSession = state.filteredAndSortedSessions[state.activeIndex];
return (
<Box flexDirection="column" paddingX={1}>
<SessionListHeader state={state} />
{state.isSearchMode && <SearchModeDisplay state={state} />}
{state.isRenameMode && <RenameModeDisplay state={state} />}
{state.totalSessions === 0 ? (
<NoResultsDisplay state={state} />
) : (
<SessionList state={state} formatRelativeTime={formatRelativeTime} />
)}
{!state.isSearchMode && <SessionDetailsPanel session={selectedSession} />}
</Box>
);
}
@@ -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,
);

View File

@@ -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<SessionSummaryDisplayProps> = ({
duration,
sessionName,
resumeCommandHint,
}) => (
<StatsDisplay title="Agent powering down. Goodbye!" duration={duration} />
<Box flexDirection="column">
<StatsDisplay title="Agent powering down. Goodbye!" duration={duration} />
{sessionName && resumeCommandHint && (
<Box flexDirection="column" marginTop={1}>
<Text color={Colors.Gray}>
Session: <Text color={Colors.Foreground}>{sessionName}</Text>
</Text>
<Text color={Colors.Gray}>
You can resume your session by typing:{' '}
<Text color={Colors.AccentPurple}>{resumeCommandHint}</Text>
</Text>
</Box>
)}
</Box>
);

View File

@@ -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`] = `

View File

@@ -65,6 +65,10 @@ export interface UIActions {
closeSessionBrowser: () => void;
handleResumeSession: (session: SessionInfo) => Promise<void>;
handleDeleteSession: (session: SessionInfo) => Promise<void>;
handleRenameSession: (
session: SessionInfo,
newNameBase: string,
) => Promise<SessionInfo>;
setQueueErrorMessage: (message: string | null) => void;
popAllMessages: () => string | undefined;
handleApiKeySubmit: (apiKey: string) => Promise<void>;

View File

@@ -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<typeof import('../../utils/sessionUtils.js')>();
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(() =>

View File

@@ -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-<timestamp>-<sessionIdPrefix>.json".
// The ChatRecordingService.deleteSession API expects this file basename
// (without the ".json" extension), not the full session UUID.
async (session: SessionInfo): Promise<void> => {
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<SessionInfo> => {
try {
const result = await renameSession(session, newNameBase);
return result.sessionInfo;
} catch (error) {
coreEvents.emitFeedback('error', 'Error renaming session:', error);
throw error;
}
},
[],
),
};
};

View File

@@ -184,6 +184,8 @@ export type HistoryItemModel = HistoryItemBase & {
export type HistoryItemQuit = HistoryItemBase & {
type: 'quit';
duration: string;
sessionName?: string;
resumeCommandHint?: string;
};
export type HistoryItemToolGroup = HistoryItemBase & {

View File

@@ -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
}

View File

@@ -52,6 +52,44 @@ function createMockConfig(overrides: Partial<Config> = {}): Config {
}
// Create test session data
function createSessionInfo(overrides: Partial<SessionInfo>): 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(

View File

@@ -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<Config> 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 = {

View File

@@ -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: <base>-<immutableSuffix> */
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<SessionFileEntry[]> => {
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<ProjectChatDirectory[]> {
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<ProjectChatDirectory | null> => {
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<SessionInfo[]> {
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<string, SessionInfo>();
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<RenameSessionResult> {
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<void> {
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<SessionInfo[]> {
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<SessionSelectionResult> {
@@ -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<SessionSelectionResult> {
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<typeof msg.toolCalls>[number]) => ({
callId: tool.id,
name: tool.displayName || tool.name,
description: tool.description || '',

View File

@@ -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<typeof import('./sessionUtils.js')>();
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<typeof vi.fn>;
const createSession = (overrides: Partial<SessionInfo> = {}): 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<typeof SessionSelector>,
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<typeof vi.fn>;
let mockDeleteSession: ReturnType<typeof vi.fn>;
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<typeof SessionSelector>,
);
// Mock ChatRecordingService
vi.mocked(ChatRecordingService).mockImplementation(
() =>
({
deleteSession: mockDeleteSession,
}) as unknown as InstanceType<typeof ChatRecordingService>,
);
});
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',
);
});
});
});

View File

@@ -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<void> {
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<void> {
.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(

View File

@@ -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: {

View File

@@ -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';

View File

@@ -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<ThoughtSummary & { timestamp: string }> = [];
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<void> {
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.
*/

View File

@@ -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();
});
});

View File

@@ -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<string | null> {
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);
}
}