Skip to content
1 change: 0 additions & 1 deletion packages/cli/src/nonInteractiveCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1598,7 +1598,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',
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/services/BuiltinCommandLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,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';
Expand Down Expand Up @@ -86,6 +87,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
: []),
...(isDevelopment ? [profileCommand] : []),
quitCommand,
renameCommand,
restoreCommand(this.config),
resumeCommand,
statsCommand,
Expand Down
114 changes: 114 additions & 0 deletions packages/cli/src/ui/commands/renameCommand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* @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 { ChatRecordingService, type Config } from '@google/gemini-cli-core';
import { SessionSelector } from '../../utils/sessionUtils.js';
import type { CommandContext } from './types.js';
import path from 'node:path';

vi.mock('@google/gemini-cli-core');
vi.mock('../../utils/sessionUtils.js');

describe('renameCommand', () => {
let mockConfig: Config;
let mockContext: CommandContext;
let mockChatRecordingService: {
initialize: ReturnType<typeof vi.fn>;
setDisplayName: ReturnType<typeof vi.fn>;
};
let mockSessionSelector: {
listSessions: ReturnType<typeof vi.fn>;
};

beforeEach(() => {
mockConfig = {
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini'),
},
} as unknown as Config;

mockContext = {
services: {
config: mockConfig,
},
} as unknown as CommandContext;

mockChatRecordingService = {
initialize: vi.fn(),
setDisplayName: vi.fn(),
};
vi.mocked(ChatRecordingService).mockImplementation(
() => mockChatRecordingService as unknown as ChatRecordingService,
);

mockSessionSelector = {
listSessions: vi.fn(),
};
vi.mocked(SessionSelector).mockImplementation(
() => mockSessionSelector as unknown as SessionSelector,
);
});

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 silently ignore if no active session is found', async () => {
mockSessionSelector.listSessions.mockResolvedValue([
{ id: 'other-session', isCurrentSession: false },
]);

const result = await renameCommand.action!(mockContext, 'New Name');

expect(result).toBeUndefined();
});

it('should rename the current session', async () => {
const currentSession = {
id: 'current-session-id',
fileName: 'session-file.json',
isCurrentSession: true,
};
mockSessionSelector.listSessions.mockResolvedValue([currentSession]);

const result = await renameCommand.action!(mockContext, 'New Name');

const expectedFilePath = path.join(
'/tmp/gemini',
'chats',
'session-file.json',
);

expect(mockChatRecordingService.initialize).toHaveBeenCalledWith({
sessionId: 'current-session-id',
filePath: expectedFilePath,
});
expect(mockChatRecordingService.setDisplayName).toHaveBeenCalledWith(
'New Name',
);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Session renamed to "New Name"',
});
});
});
62 changes: 62 additions & 0 deletions packages/cli/src/ui/commands/renameCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { ChatRecordingService } from '@google/gemini-cli-core';
import path from 'node:path';
import { SessionSelector } from '../../utils/sessionUtils.js';
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<SlashCommandActionReturn | void> => {
const newName = input.trim();
if (!newName) {
return {
type: 'message',
messageType: 'error',
content: 'Please provide a new name for the session.',
};
}

if (!context.services.config) {
return;
}

const sessionSelector = new SessionSelector(context.services.config);
const sessions = await sessionSelector.listSessions();
const currentSession = sessions.find((s) => s.isCurrentSession);

if (!currentSession) {
return;
}

const chatsDir = path.join(
context.services.config.storage.getProjectTempDir(),
'chats',
);
const filePath = path.join(chatsDir, currentSession.fileName);

const recordingService = new ChatRecordingService(context.services.config);
recordingService.initialize({
sessionId: currentSession.id,
filePath,
});

recordingService.setDisplayName(newName);

return {
type: 'message',
messageType: 'info',
content: `Session renamed to "${newName}"`,
};
},
};
31 changes: 28 additions & 3 deletions packages/cli/src/ui/components/SessionBrowser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,6 @@ const createSession = (overrides: Partial<SessionInfo>): SessionInfo => ({
lastUpdated: new Date().toISOString(),
messageCount: 1,
displayName: 'Test Session',
firstUserMessage: 'Test Session',
isCurrentSession: false,
index: 0,
...overrides,
Expand Down Expand Up @@ -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(
<TestSessionBrowser
config={config}
onResumeSession={onResumeSession}
onDeleteSession={onDeleteSession}
onExit={onExit}
testSessions={[session]}
/>,
);

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: [
{
Expand All @@ -226,7 +252,6 @@ describe('SessionBrowser component', () => {
id: 'other',
file: 'other',
displayName: 'Nothing interesting here.',
firstUserMessage: 'Nothing interesting here.',
fullContent: 'Nothing interesting here.',
messages: [
{
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/ui/components/SessionBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading