diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 7b12f864b34..a023dd2a150 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -1540,7 +1540,6 @@ describe('runNonInteractive', () => { ] as any, // eslint-disable-line @typescript-eslint/no-explicit-any startTime: new Date().toISOString(), lastUpdated: new Date().toISOString(), - firstUserMessage: 'Previous message', projectHash: 'test-hash', }, filePath: '/path/to/session.json', diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 912121a2dd8..19eae076850 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -66,6 +66,7 @@ export const handleSlashCommand = async ( settings, git: undefined, logger, + chatRecordingService: undefined, }, ui: createNonInteractiveUI(), session: { diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index c7f94d02cbb..4c2b8cf84fb 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -40,6 +40,7 @@ import { policiesCommand } from '../ui/commands/policiesCommand.js'; import { profileCommand } from '../ui/commands/profileCommand.js'; import { quitCommand } from '../ui/commands/quitCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; +import { renameCommand } from '../ui/commands/renameCommand.js'; import { resumeCommand } from '../ui/commands/resumeCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; import { themeCommand } from '../ui/commands/themeCommand.js'; @@ -136,6 +137,7 @@ export class BuiltinCommandLoader implements ICommandLoader { policiesCommand, ...(isDevelopment ? [profileCommand] : []), quitCommand, + renameCommand, restoreCommand(this.config), resumeCommand, statsCommand, diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 928d04c7a1a..80fc7fdbf30 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -51,6 +51,7 @@ export const createMockCommandContext = ( loadCheckpoint: vi.fn().mockResolvedValue([]), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, // Cast because Logger is a class. + chatRecordingService: undefined, }, ui: { addItem: vi.fn(), diff --git a/packages/cli/src/ui/commands/renameCommand.test.ts b/packages/cli/src/ui/commands/renameCommand.test.ts new file mode 100644 index 00000000000..4ea44ebbfd1 --- /dev/null +++ b/packages/cli/src/ui/commands/renameCommand.test.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renameCommand } from './renameCommand.js'; +import type { ChatRecordingService } from '@google/gemini-cli-core'; +import type { CommandContext } from './types.js'; + +describe('renameCommand', () => { + let mockContext: CommandContext; + let mockChatRecordingService: { + setDisplayName: ReturnType; + }; + + beforeEach(() => { + mockChatRecordingService = { + setDisplayName: vi.fn(), + }; + + mockContext = { + services: { + chatRecordingService: + mockChatRecordingService as unknown as ChatRecordingService, + }, + } as unknown as CommandContext; + }); + + it('should require a name argument', async () => { + const result = await renameCommand.action!(mockContext, ''); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Please provide a new name for the session.', + }); + }); + + it('should handle empty name string', async () => { + const result = await renameCommand.action!(mockContext, ' '); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Please provide a new name for the session.', + }); + }); + + it('should return undefined if chatRecordingService is not available', async () => { + mockContext = { + services: { + chatRecordingService: undefined, + }, + } as unknown as CommandContext; + + const result = await renameCommand.action!(mockContext, 'New Name'); + expect(result).toEqual(undefined); + }); + + it('should rename the current session', async () => { + const result = await renameCommand.action!(mockContext, 'New Name'); + + expect(mockChatRecordingService.setDisplayName).toHaveBeenCalledWith( + 'New Name', + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Session renamed to "New Name"', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/renameCommand.ts b/packages/cli/src/ui/commands/renameCommand.ts new file mode 100644 index 00000000000..61cc29d0887 --- /dev/null +++ b/packages/cli/src/ui/commands/renameCommand.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CommandKind, + type SlashCommand, + type SlashCommandActionReturn, +} from './types.js'; + +export const renameCommand: SlashCommand = { + name: 'rename', + description: 'Set the display name for the current session', + kind: CommandKind.BUILT_IN, + action: async (context, input): Promise => { + const newName = input.trim(); + if (!newName) { + return { + type: 'message', + messageType: 'error', + content: 'Please provide a new name for the session.', + }; + } + + if (!context.services.chatRecordingService) { + return; + } + + context.services.chatRecordingService.setDisplayName(newName); + + return { + type: 'message', + messageType: 'info', + content: `Session renamed to "${newName}"`, + }; + }, +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 9f5ca8eb418..3fbe372d88f 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -11,6 +11,7 @@ import type { ConfirmationRequest, } from '../types.js'; import type { + ChatRecordingService, Config, GitService, Logger, @@ -43,6 +44,7 @@ export interface CommandContext { settings: LoadedSettings; git: GitService | undefined; logger: Logger; + chatRecordingService: ChatRecordingService | undefined; }; // UI state and history management ui: { diff --git a/packages/cli/src/ui/components/SessionBrowser.test.tsx b/packages/cli/src/ui/components/SessionBrowser.test.tsx index 5a461a551e4..8adf68994b9 100644 --- a/packages/cli/src/ui/components/SessionBrowser.test.tsx +++ b/packages/cli/src/ui/components/SessionBrowser.test.tsx @@ -130,7 +130,6 @@ const createSession = (overrides: Partial): SessionInfo => ({ lastUpdated: new Date().toISOString(), messageCount: 1, displayName: 'Test Session', - firstUserMessage: 'Test Session', isCurrentSession: false, index: 0, ...overrides, @@ -205,12 +204,39 @@ describe('SessionBrowser component', () => { expect(lastFrame()).toMatchSnapshot(); }); + it('displays fallback displayName', () => { + const session = createSession({ + id: 'no-display-name', + file: 'no-display-name', + displayName: 'Fallback Message', + lastUpdated: '2025-01-01T10:05:00Z', + messageCount: 2, + index: 0, + }); + + const config = createMockConfig(); + const onResumeSession = vi.fn(); + const onDeleteSession = vi.fn().mockResolvedValue(undefined); + const onExit = vi.fn(); + + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('Fallback Message'); + }); + it('enters search mode, filters sessions, and renders match snippets', async () => { const searchSession = createSession({ id: 'search1', file: 'search1', displayName: 'Query is here and another query.', - firstUserMessage: 'Query is here and another query.', fullContent: 'Query is here and another query.', messages: [ { @@ -226,7 +252,6 @@ describe('SessionBrowser component', () => { id: 'other', file: 'other', displayName: 'Nothing interesting here.', - firstUserMessage: 'Nothing interesting here.', fullContent: 'Nothing interesting here.', messages: [ { diff --git a/packages/cli/src/ui/components/SessionBrowser.tsx b/packages/cli/src/ui/components/SessionBrowser.tsx index 9e5836057c3..d43164e2d54 100644 --- a/packages/cli/src/ui/components/SessionBrowser.tsx +++ b/packages/cli/src/ui/components/SessionBrowser.tsx @@ -249,8 +249,7 @@ const filterSessions = ( return sessions.filter((session) => { const titleMatch = session.displayName.toLowerCase().includes(lowerQuery) || - session.id.toLowerCase().includes(lowerQuery) || - session.firstUserMessage.toLowerCase().includes(lowerQuery); + session.id.toLowerCase().includes(lowerQuery); const contentMatch = session.fullContent ?.toLowerCase() diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index 295696553f3..39e6a8b7da1 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -596,6 +596,7 @@ describe('useSlashCommandProcessor', () => { const mockClient = { setHistory: vi.fn(), stripThoughtsFromHistory: vi.fn(), + getChatRecordingService: vi.fn(), } as unknown as GeminiClient; vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index c4effdda3c2..f9137bafdf1 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -209,6 +209,9 @@ export const useSlashCommandProcessor = ( settings, git: gitService, logger, + chatRecordingService: config + ?.getGeminiClient() + ?.getChatRecordingService(), }, ui: { addItem, diff --git a/packages/cli/src/utils/sessionCleanup.test.ts b/packages/cli/src/utils/sessionCleanup.test.ts index e59ad4baf8b..80eec8b362f 100644 --- a/packages/cli/src/utils/sessionCleanup.test.ts +++ b/packages/cli/src/utils/sessionCleanup.test.ts @@ -54,7 +54,6 @@ function createTestSessions(): SessionInfo[] { lastUpdated: now.toISOString(), messageCount: 5, displayName: 'Current session', - firstUserMessage: 'Current session', isCurrentSession: true, index: 1, }, @@ -66,7 +65,6 @@ function createTestSessions(): SessionInfo[] { lastUpdated: oneWeekAgo.toISOString(), messageCount: 10, displayName: 'Recent session', - firstUserMessage: 'Recent session', isCurrentSession: false, index: 2, }, @@ -78,7 +76,6 @@ function createTestSessions(): SessionInfo[] { lastUpdated: twoWeeksAgo.toISOString(), messageCount: 3, displayName: 'Old session', - firstUserMessage: 'Old session', isCurrentSession: false, index: 3, }, @@ -90,7 +87,6 @@ function createTestSessions(): SessionInfo[] { lastUpdated: oneMonthAgo.toISOString(), messageCount: 15, displayName: 'Ancient session', - firstUserMessage: 'Ancient session', isCurrentSession: false, index: 4, }, @@ -439,7 +435,6 @@ describe('Session Cleanup', () => { lastUpdated: now.toISOString(), messageCount: 1, displayName: 'Current', - firstUserMessage: 'Current', isCurrentSession: true, index: 1, }, @@ -451,7 +446,6 @@ describe('Session Cleanup', () => { lastUpdated: fiveDaysAgo.toISOString(), messageCount: 1, displayName: '5 days old', - firstUserMessage: '5 days', isCurrentSession: false, index: 2, }, @@ -463,7 +457,6 @@ describe('Session Cleanup', () => { lastUpdated: eightDaysAgo.toISOString(), messageCount: 1, displayName: '8 days old', - firstUserMessage: '8 days', isCurrentSession: false, index: 3, }, @@ -475,7 +468,6 @@ describe('Session Cleanup', () => { lastUpdated: fifteenDaysAgo.toISOString(), messageCount: 1, displayName: '15 days old', - firstUserMessage: '15 days', isCurrentSession: false, index: 4, }, @@ -561,7 +553,6 @@ describe('Session Cleanup', () => { lastUpdated: now.toISOString(), messageCount: 1, displayName: 'Current', - firstUserMessage: 'Current', isCurrentSession: true, index: 1, }, @@ -573,7 +564,6 @@ describe('Session Cleanup', () => { lastUpdated: oneDayAgo.toISOString(), messageCount: 1, displayName: '1 day old', - firstUserMessage: '1 day', isCurrentSession: false, index: 2, }, @@ -585,7 +575,6 @@ describe('Session Cleanup', () => { lastUpdated: sevenDaysAgo.toISOString(), messageCount: 1, displayName: '7 days old', - firstUserMessage: '7 days', isCurrentSession: false, index: 3, }, @@ -597,7 +586,6 @@ describe('Session Cleanup', () => { lastUpdated: thirteenDaysAgo.toISOString(), messageCount: 1, displayName: '13 days old', - firstUserMessage: '13 days', isCurrentSession: false, index: 4, }, @@ -657,7 +645,6 @@ describe('Session Cleanup', () => { lastUpdated: now.toISOString(), messageCount: 1, displayName: 'Current (newest)', - firstUserMessage: 'Current', isCurrentSession: true, index: 1, }, @@ -674,7 +661,6 @@ describe('Session Cleanup', () => { lastUpdated: daysAgo.toISOString(), messageCount: 1, displayName: `${i} days old`, - firstUserMessage: `${i} days`, isCurrentSession: false, index: i + 1, }); @@ -783,7 +769,6 @@ describe('Session Cleanup', () => { lastUpdated: now.toISOString(), messageCount: 1, displayName: 'Current', - firstUserMessage: 'Current', isCurrentSession: true, index: 1, }, @@ -795,7 +780,6 @@ describe('Session Cleanup', () => { lastUpdated: threeDaysAgo.toISOString(), messageCount: 1, displayName: '3 days old', - firstUserMessage: '3 days', isCurrentSession: false, index: 2, }, @@ -807,7 +791,6 @@ describe('Session Cleanup', () => { lastUpdated: fiveDaysAgo.toISOString(), messageCount: 1, displayName: '5 days old', - firstUserMessage: '5 days', isCurrentSession: false, index: 3, }, @@ -819,7 +802,6 @@ describe('Session Cleanup', () => { lastUpdated: sevenDaysAgo.toISOString(), messageCount: 1, displayName: '7 days old', - firstUserMessage: '7 days', isCurrentSession: false, index: 4, }, @@ -831,7 +813,6 @@ describe('Session Cleanup', () => { lastUpdated: twelveDaysAgo.toISOString(), messageCount: 1, displayName: '12 days old', - firstUserMessage: '12 days', isCurrentSession: false, index: 5, }, diff --git a/packages/cli/src/utils/sessionUtils.test.ts b/packages/cli/src/utils/sessionUtils.test.ts index 29fc5bdff96..ed9ae5fbdfa 100644 --- a/packages/cli/src/utils/sessionUtils.test.ts +++ b/packages/cli/src/utils/sessionUtils.test.ts @@ -236,6 +236,166 @@ describe('SessionSelector', () => { expect(result.sessionData.messages[0].content).toBe('Latest session'); }); + it('should resolve session by display name', async () => { + const sessionId1 = randomUUID(); + + // Create test session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const session1 = { + sessionId: sessionId1, + displayName: 'My Cool Session', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Test message 1', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId1.slice(0, 8)}.json`, + ), + JSON.stringify(session1, null, 2), + ); + + const sessionSelector = new SessionSelector(config); + + // Test resolving by display name + const result = await sessionSelector.resolveSession('My Cool Session'); + expect(result.sessionData.sessionId).toBe(sessionId1); + expect(result.sessionData.displayName).toBe('My Cool Session'); + }); + + it('should resolve most recent session when multiple sessions have same display name', async () => { + const sessionId1 = randomUUID(); + const sessionId2 = randomUUID(); + const sessionId3 = randomUUID(); + + // Create test session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const sessionOld = { + sessionId: sessionId1, + displayName: 'Duplicate Name', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Old', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + const sessionNew = { + sessionId: sessionId2, + displayName: 'Duplicate Name', + startTime: '2024-01-02T10:00:00.000Z', + lastUpdated: '2024-01-02T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'New', + id: 'msg2', + timestamp: '2024-01-02T10:00:00.000Z', + }, + ], + }; + + // Another session with different name + const sessionOther = { + sessionId: sessionId3, + displayName: 'Other Name', + startTime: '2024-01-01T12:00:00.000Z', + lastUpdated: '2024-01-01T12:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Other', + id: 'msg3', + timestamp: '2024-01-01T12:00:00.000Z', + }, + ], + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId1.slice(0, 8)}.json`, + ), + JSON.stringify(sessionOld, null, 2), + ); + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-02T10-00-${sessionId2.slice(0, 8)}.json`, + ), + JSON.stringify(sessionNew, null, 2), + ); + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T12-00-${sessionId3.slice(0, 8)}.json`, + ), + JSON.stringify(sessionOther, null, 2), + ); + + const sessionSelector = new SessionSelector(config); + + // Test resolving by display name with duplicates + const result = await sessionSelector.resolveSession('Duplicate Name'); + expect(result.sessionData.sessionId).toBe(sessionId2); + expect(result.sessionData.messages[0].content).toBe('New'); + }); + + it('should resolve session by summary when display name does not match', async () => { + const sessionId = randomUUID(); + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + const session = { + sessionId, + displayName: 'Renamed Session', + summary: 'Original Summary', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Test', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`, + ), + JSON.stringify(session, null, 2), + ); + + const sessionSelector = new SessionSelector(config); + const result = await sessionSelector.resolveSession('Original Summary'); + + expect(result.sessionData.sessionId).toBe(sessionId); + }); + it('should deduplicate sessions by ID', async () => { const sessionId = randomUUID(); diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index 1d7be693b86..98c561be2dd 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -59,7 +59,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 {number}, --resume {uuid}, --resume "{name or summary}" or --resume latest.`, ); } } @@ -96,8 +96,6 @@ export interface SessionInfo { lastUpdated: string; /** Display name for the session (typically first user message) */ displayName: string; - /** Cleaned first user message content */ - firstUserMessage: string; /** Whether this is the currently active session */ isCurrentSession: boolean; /** Display index in the list */ @@ -303,10 +301,11 @@ export const getAllSessionFiles = async ( startTime: content.startTime, lastUpdated: content.lastUpdated, messageCount: content.messages.length, - displayName: content.summary - ? stripUnsafeCharacters(content.summary) - : firstUserMessage, - firstUserMessage, + displayName: content.displayName + ? stripUnsafeCharacters(content.displayName) + : content.summary + ? stripUnsafeCharacters(content.summary) + : firstUserMessage, isCurrentSession, index: 0, // Will be set after sorting valid sessions summary: content.summary, @@ -439,6 +438,22 @@ export class SessionSelector { return sortedSessions[index - 1]; } + // Try to find by display name or summary + // Filter sessions where displayName or summary matches identifier + const sessionsByName = sortedSessions.filter( + (session) => + session.displayName === identifier || session.summary === identifier, + ); + + if (sessionsByName.length > 0) { + // Sort by lastUpdated descending (newest first) + sessionsByName.sort( + (a, b) => + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), + ); + return sessionsByName[0]; + } + throw SessionError.invalidSessionIdentifier(identifier); } @@ -500,7 +515,7 @@ export class SessionSelector { await fs.readFile(sessionPath, 'utf8'), ); - const displayInfo = `Session ${sessionInfo.index}: ${sessionInfo.firstUserMessage} (${sessionInfo.messageCount} messages, ${formatRelativeTime(sessionInfo.lastUpdated)})`; + const displayInfo = `Session ${sessionInfo.index}: ${sessionInfo.displayName} (${sessionInfo.messageCount} messages, ${formatRelativeTime(sessionInfo.lastUpdated)})`; return { sessionPath, diff --git a/packages/cli/src/utils/sessions.test.ts b/packages/cli/src/utils/sessions.test.ts index 8fe22cebba7..adf6f484799 100644 --- a/packages/cli/src/utils/sessions.test.ts +++ b/packages/cli/src/utils/sessions.test.ts @@ -92,7 +92,6 @@ describe('listSessions', () => { lastUpdated: twoDaysAgo.toISOString(), messageCount: 5, displayName: 'First user message', - firstUserMessage: 'First user message', isCurrentSession: false, index: 1, }, @@ -104,7 +103,6 @@ describe('listSessions', () => { lastUpdated: oneHourAgo.toISOString(), messageCount: 10, displayName: 'Second user message', - firstUserMessage: 'Second user message', isCurrentSession: false, index: 2, }, @@ -116,7 +114,6 @@ describe('listSessions', () => { lastUpdated: now.toISOString(), messageCount: 3, displayName: 'Current session', - firstUserMessage: 'Current session', isCurrentSession: true, index: 3, }, @@ -176,7 +173,6 @@ describe('listSessions', () => { lastUpdated: session2Time.toISOString(), messageCount: 5, displayName: 'Middle session', - firstUserMessage: 'Middle session', isCurrentSession: false, index: 2, }, @@ -188,7 +184,6 @@ describe('listSessions', () => { lastUpdated: session1Time.toISOString(), messageCount: 5, displayName: 'Oldest session', - firstUserMessage: 'Oldest session', isCurrentSession: false, index: 1, }, @@ -200,7 +195,6 @@ describe('listSessions', () => { lastUpdated: session3Time.toISOString(), messageCount: 5, displayName: 'Newest session', - firstUserMessage: 'Newest session', isCurrentSession: false, index: 3, }, @@ -238,7 +232,6 @@ describe('listSessions', () => { lastUpdated: now.toISOString(), messageCount: 5, displayName: 'Test message', - firstUserMessage: 'Test message', isCurrentSession: false, index: 1, }, @@ -273,7 +266,6 @@ describe('listSessions', () => { lastUpdated: now.toISOString(), messageCount: 5, displayName: 'Only session', - firstUserMessage: 'Only session', isCurrentSession: true, index: 1, }, @@ -308,8 +300,6 @@ describe('listSessions', () => { 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', @@ -397,7 +387,6 @@ describe('deleteSession', () => { lastUpdated: now.toISOString(), messageCount: 5, displayName: 'Test session', - firstUserMessage: 'Test session', isCurrentSession: false, index: 1, }, @@ -432,7 +421,6 @@ describe('deleteSession', () => { lastUpdated: oneHourAgo.toISOString(), messageCount: 5, displayName: 'First session', - firstUserMessage: 'First session', isCurrentSession: false, index: 1, }, @@ -444,7 +432,6 @@ describe('deleteSession', () => { lastUpdated: now.toISOString(), messageCount: 10, displayName: 'Second session', - firstUserMessage: 'Second session', isCurrentSession: false, index: 2, }, @@ -476,7 +463,6 @@ describe('deleteSession', () => { lastUpdated: now.toISOString(), messageCount: 5, displayName: 'Test session', - firstUserMessage: 'Test session', isCurrentSession: false, index: 1, }, @@ -506,7 +492,6 @@ describe('deleteSession', () => { lastUpdated: now.toISOString(), messageCount: 5, displayName: 'Test session', - firstUserMessage: 'Test session', isCurrentSession: false, index: 1, }, @@ -536,7 +521,6 @@ describe('deleteSession', () => { lastUpdated: now.toISOString(), messageCount: 5, displayName: 'Test session', - firstUserMessage: 'Test session', isCurrentSession: false, index: 1, }, @@ -566,7 +550,6 @@ describe('deleteSession', () => { lastUpdated: now.toISOString(), messageCount: 5, displayName: 'Current session', - firstUserMessage: 'Current session', isCurrentSession: true, index: 1, }, @@ -596,7 +579,6 @@ describe('deleteSession', () => { lastUpdated: now.toISOString(), messageCount: 5, displayName: 'Current session', - firstUserMessage: 'Current session', isCurrentSession: true, index: 1, }, @@ -626,7 +608,6 @@ describe('deleteSession', () => { lastUpdated: now.toISOString(), messageCount: 5, displayName: 'Test session', - firstUserMessage: 'Test session', isCurrentSession: false, index: 1, }, @@ -659,7 +640,6 @@ describe('deleteSession', () => { lastUpdated: now.toISOString(), messageCount: 5, displayName: 'Test session', - firstUserMessage: 'Test session', isCurrentSession: false, index: 1, }, @@ -695,7 +675,6 @@ describe('deleteSession', () => { lastUpdated: session3Time.toISOString(), messageCount: 5, displayName: 'Newest session', - firstUserMessage: 'Newest session', isCurrentSession: false, index: 3, }, @@ -707,7 +686,6 @@ describe('deleteSession', () => { lastUpdated: session1Time.toISOString(), messageCount: 5, displayName: 'Oldest session', - firstUserMessage: 'Oldest session', isCurrentSession: false, index: 1, }, @@ -719,7 +697,6 @@ describe('deleteSession', () => { lastUpdated: session2Time.toISOString(), messageCount: 5, displayName: 'Middle session', - firstUserMessage: 'Middle session', isCurrentSession: false, index: 2, }, diff --git a/packages/cli/src/utils/sessions.ts b/packages/cli/src/utils/sessions.ts index 56f9f06a6ac..19873493bad 100644 --- a/packages/cli/src/utils/sessions.ts +++ b/packages/cli/src/utils/sessions.ts @@ -101,7 +101,7 @@ export async function deleteSession( const time = formatRelativeTime(sessionToDelete.lastUpdated); writeToStdout( - `Deleted session ${sessionToDelete.index}: ${sessionToDelete.firstUserMessage} (${time})`, + `Deleted session ${sessionToDelete.index}: ${sessionToDelete.displayName} (${time})`, ); } catch (error) { writeToStderr( diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index bd7182fd030..1f1dad33d02 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -246,7 +246,14 @@ export class GeminiChat { ) { validateHistory(history); this.chatRecordingService = new ChatRecordingService(config); - this.chatRecordingService.initialize(resumedSessionData); + if (resumedSessionData) { + this.chatRecordingService.initialize({ + sessionId: resumedSessionData.conversation.sessionId, + filePath: resumedSessionData.filePath, + }); + } else { + this.chatRecordingService.initialize(); + } this.lastPromptTokenCount = estimateTokenCountSync( this.history.flatMap((c) => c.parts || []), ); diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 6fb49fbd5fd..1ab5440522f 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -100,9 +100,7 @@ describe('ChatRecordingService', () => { chatRecordingService.initialize({ filePath: '/test/project/root/.gemini/tmp/chats/session.json', - conversation: { - sessionId: 'old-session-id', - } as ConversationRecord, + sessionId: 'old-session-id', }); expect(mkdirSyncSpy).not.toHaveBeenCalled(); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index b308cce789e..054e27806c2 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -88,6 +88,7 @@ export interface ConversationRecord { lastUpdated: string; messages: MessageRecord[]; summary?: string; + displayName?: string; } /** @@ -126,14 +127,14 @@ export class ChatRecordingService { /** * Initializes the chat recording service: creates a new conversation file and associates it with - * this service instance, or resumes from an existing session if resumedSessionData is provided. + * this service instance, or resumes from an existing session if existingSession is provided. */ - initialize(resumedSessionData?: ResumedSessionData): void { + initialize(existingSession?: { sessionId: string; filePath: string }): void { try { - if (resumedSessionData) { + if (existingSession) { // Resume from existing session - this.conversationFile = resumedSessionData.filePath; - this.sessionId = resumedSessionData.conversation.sessionId; + this.conversationFile = existingSession.filePath; + this.sessionId = existingSession.sessionId; // Update the session ID in the existing file this.updateConversation((conversation) => { @@ -458,6 +459,22 @@ export class ChatRecordingService { } } + /** + * Sets the display name for the current session. + */ + setDisplayName(displayName: string): void { + if (!this.conversationFile) return; + + try { + this.updateConversation((conversation) => { + conversation.displayName = displayName; + }); + } catch (error) { + debugLogger.error('Error saving display name to chat history.', error); + // Don't throw - we want graceful degradation + } + } + /** * Gets the current conversation data (for summary generation). */ diff --git a/packages/core/src/services/sessionSummaryUtils.test.ts b/packages/core/src/services/sessionSummaryUtils.test.ts index 2314b7ca066..73e38493a6b 100644 --- a/packages/core/src/services/sessionSummaryUtils.test.ts +++ b/packages/core/src/services/sessionSummaryUtils.test.ts @@ -30,11 +30,12 @@ vi.mock('../core/baseLlmClient.js', () => ({ // Helper to create a session with N user messages function createSessionWithUserMessages( count: number, - options: { summary?: string; sessionId?: string } = {}, + options: { summary?: string; sessionId?: string; displayName?: string } = {}, ) { return JSON.stringify({ sessionId: options.sessionId ?? 'session-id', summary: options.summary, + displayName: options.displayName, messages: Array.from({ length: count }, (_, i) => ({ id: String(i + 1), type: 'user', @@ -161,6 +162,20 @@ describe('sessionSummaryUtils', () => { ); }); + it('should return null if most recent session already has display name', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']); + vi.mocked(fs.readFile).mockResolvedValue( + createSessionWithUserMessages(5, { + displayName: 'User Renamed Session', + }), + ); + + const result = await getPreviousSession(mockConfig); + + expect(result).toBeNull(); + }); + it('should return null if most recent session file is corrupted', async () => { vi.mocked(fs.access).mockResolvedValue(undefined); mockReaddir.mockResolvedValue(['session-2024-01-01T10-00-abc12345.json']); diff --git a/packages/core/src/services/sessionSummaryUtils.ts b/packages/core/src/services/sessionSummaryUtils.ts index ed51cecd2ba..6bfb241a735 100644 --- a/packages/core/src/services/sessionSummaryUtils.ts +++ b/packages/core/src/services/sessionSummaryUtils.ts @@ -36,6 +36,14 @@ async function generateAndSaveSummary( return; } + // Skip if display name is already set + if (conversation.displayName) { + debugLogger.debug( + `[SessionSummary] Display name exists for ${sessionPath}, skipping summary generation`, + ); + return; + } + // Skip if no messages if (conversation.messages.length === 0) { debugLogger.debug( @@ -136,6 +144,13 @@ export async function getPreviousSession( return null; } + if (conversation.displayName) { + debugLogger.debug( + '[SessionSummary] Most recent session already has display name', + ); + return null; + } + // Only generate summaries for sessions with more than 1 user message const userMessageCount = conversation.messages.filter( (m) => m.type === 'user',