feat: Add /chat share command (#8139)

This commit is contained in:
Rohit Ramkumar
2025-09-13 02:21:40 -04:00
committed by GitHub
parent 73466b626d
commit a96cc2f148
3 changed files with 272 additions and 4 deletions

View File

@@ -30,6 +30,9 @@ Slash commands provide meta-level control over the CLI itself.
- **`delete`**
- **Description:** Deletes a saved conversation checkpoint.
- **Usage:** `/chat delete <tag>`
- **`share`**
- **Description** Writes the current conversation to a provided Markdown or JSON file.
- **Usage** `/chat share file.md` or `/chat share file.json`. If no filename is provided, then the CLI will generate one.
- **`/clear`**
- **Description:** Clear the terminal screen, including the visible session history and scrollback within the CLI. The underlying session data (for history recall) might be preserved depending on the exact implementation, but the visual display is cleared.

View File

@@ -17,13 +17,15 @@ import type { Content } from '@google/genai';
import type { GeminiClient } from '@google/gemini-cli-core';
import * as fsPromises from 'node:fs/promises';
import { chatCommand } from './chatCommand.js';
import { chatCommand, serializeHistoryToMarkdown } from './chatCommand.js';
import type { Stats } from 'node:fs';
import type { HistoryItemWithoutId } from '../types.js';
import path from 'node:path';
vi.mock('fs/promises', () => ({
stat: vi.fn(),
readdir: vi.fn().mockResolvedValue(['file1.txt', 'file2.txt'] as string[]),
writeFile: vi.fn(),
}));
describe('chatCommand', () => {
@@ -37,7 +39,7 @@ describe('chatCommand', () => {
let mockGetHistory: ReturnType<typeof vi.fn>;
const getSubCommand = (
name: 'list' | 'save' | 'resume' | 'delete',
name: 'list' | 'save' | 'resume' | 'delete' | 'share',
): SlashCommand => {
const subCommand = chatCommand.subCommands?.find(
(cmd) => cmd.name === name,
@@ -86,7 +88,7 @@ describe('chatCommand', () => {
it('should have the correct main command definition', () => {
expect(chatCommand.name).toBe('chat');
expect(chatCommand.description).toBe('Manage conversation history.');
expect(chatCommand.subCommands).toHaveLength(4);
expect(chatCommand.subCommands).toHaveLength(5);
});
describe('list subcommand', () => {
@@ -407,4 +409,179 @@ describe('chatCommand', () => {
});
});
});
describe('share subcommand', () => {
let shareCommand: SlashCommand;
const mockHistory = [
{ role: 'user', parts: [{ text: 'context' }] },
{ role: 'model', parts: [{ text: 'context response' }] },
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there!' }] },
];
beforeEach(() => {
shareCommand = getSubCommand('share');
vi.spyOn(process, 'cwd').mockReturnValue(
path.resolve('/usr/local/google/home/myuser/gemini-cli'),
);
vi.spyOn(Date, 'now').mockReturnValue(1234567890);
mockGetHistory.mockReturnValue(mockHistory);
mockFs.writeFile.mockClear();
});
it('should default to a json file if no path is provided', async () => {
const result = await shareCommand?.action?.(mockContext, '');
const expectedPath = path.join(
process.cwd(),
'gemini-conversation-1234567890.json',
);
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation shared to ${expectedPath}`,
});
});
it('should share the conversation to a JSON file', async () => {
const filePath = 'my-chat.json';
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation shared to ${expectedPath}`,
});
});
it('should share the conversation to a Markdown file', async () => {
const filePath = 'my-chat.md';
const result = await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const expectedContent =
'**user**:\n\ncontext\n\n---\n\n' +
'**model**:\n\ncontext response\n\n---\n\n' +
'**user**:\n\nHello\n\n---\n\n' +
'**model**:\n\nHi there!';
expect(actualContent).toEqual(expectedContent);
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: `Conversation shared to ${expectedPath}`,
});
});
it('should return an error for unsupported file extensions', async () => {
const filePath = 'my-chat.txt';
const result = await shareCommand?.action?.(mockContext, filePath);
expect(mockFs.writeFile).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Invalid file format. Only .md and .json are supported.',
});
});
it('should inform if there is no conversation to share', async () => {
mockGetHistory.mockReturnValue([
{ role: 'user', parts: [{ text: 'context' }] },
{ role: 'model', parts: [{ text: 'context response' }] },
]);
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
expect(mockFs.writeFile).not.toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'No conversation found to share.',
});
});
it('should handle errors during file writing', async () => {
const error = new Error('Permission denied');
mockFs.writeFile.mockRejectedValue(error);
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: `Error sharing conversation: ${error.message}`,
});
});
it('should output valid JSON schema', async () => {
const filePath = 'my-chat.json';
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.json');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const parsedContent = JSON.parse(actualContent);
expect(Array.isArray(parsedContent)).toBe(true);
parsedContent.forEach((item: Content) => {
expect(item).toHaveProperty('role');
expect(item).toHaveProperty('parts');
expect(Array.isArray(item.parts)).toBe(true);
});
});
it('should output correct markdown format', async () => {
const filePath = 'my-chat.md';
await shareCommand?.action?.(mockContext, filePath);
const expectedPath = path.join(process.cwd(), 'my-chat.md');
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
expect(actualPath).toEqual(expectedPath);
const entries = actualContent.split('\n\n---\n\n');
expect(entries.length).toBe(mockHistory.length);
entries.forEach((entry, index) => {
const { role, parts } = mockHistory[index];
const text = parts.map((p) => p.text).join('');
expect(entry).toBe(`**${role}**:\n\n${text}`);
});
});
});
describe('serializeHistoryToMarkdown', () => {
it('should correctly serialize chat history to Markdown', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [{ text: 'Hi there!' }] },
{ role: 'user', parts: [{ text: 'How are you?' }] },
];
const expectedMarkdown =
'**user**:\n\nHello\n\n---\n\n' +
'**model**:\n\nHi there!\n\n---\n\n' +
'**user**:\n\nHow are you?';
const result = serializeHistoryToMarkdown(history);
expect(result).toBe(expectedMarkdown);
});
it('should handle empty history', () => {
const history: Content[] = [];
const result = serializeHistoryToMarkdown(history);
expect(result).toBe('');
});
it('should handle items with no text parts', () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{ role: 'model', parts: [] },
{ role: 'user', parts: [{ text: 'How are you?' }] },
];
const expectedMarkdown =
'**user**:\n\nHello\n\n---\n\n' +
'**model**:\n\n\n\n---\n\n' +
'**user**:\n\nHow are you?';
const result = serializeHistoryToMarkdown(history);
expect(result).toBe(expectedMarkdown);
});
});
});

View File

@@ -19,6 +19,7 @@ import { decodeTagName } from '@google/gemini-cli-core';
import path from 'node:path';
import type { HistoryItemWithoutId } from '../types.js';
import { MessageType } from '../types.js';
import type { Content } from '@google/genai';
interface ChatDetail {
name: string;
@@ -274,9 +275,96 @@ const deleteCommand: SlashCommand = {
},
};
export function serializeHistoryToMarkdown(history: Content[]): string {
return history
.map((item) => {
const text =
item.parts
?.filter((m) => !!m.text)
.map((m) => m.text)
.join('') || '';
return `**${item.role}**:\n\n${text}`;
})
.join('\n\n---\n\n');
}
const shareCommand: SlashCommand = {
name: 'share',
description:
'Share the current conversation to a markdown or json file. Usage: /chat share <file>',
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<MessageActionReturn> => {
let filePathArg = args.trim();
if (!filePathArg) {
filePathArg = `gemini-conversation-${Date.now()}.json`;
}
const filePath = path.resolve(filePathArg);
const extension = path.extname(filePath);
if (extension !== '.md' && extension !== '.json') {
return {
type: 'message',
messageType: 'error',
content: 'Invalid file format. Only .md and .json are supported.',
};
}
const chat = await context.services.config?.getGeminiClient()?.getChat();
if (!chat) {
return {
type: 'message',
messageType: 'error',
content: 'No chat client available to share conversation.',
};
}
const history = chat.getHistory();
// An empty conversation has two hidden messages that setup the context for
// the chat. Thus, to check whether a conversation has been started, we
// can't check for length 0.
if (history.length <= 2) {
return {
type: 'message',
messageType: 'info',
content: 'No conversation found to share.',
};
}
let content = '';
if (extension === '.json') {
content = JSON.stringify(history, null, 2);
} else {
content = serializeHistoryToMarkdown(history);
}
try {
await fsPromises.writeFile(filePath, content);
return {
type: 'message',
messageType: 'info',
content: `Conversation shared to ${filePath}`,
};
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
return {
type: 'message',
messageType: 'error',
content: `Error sharing conversation: ${errorMessage}`,
};
}
},
};
export const chatCommand: SlashCommand = {
name: 'chat',
description: 'Manage conversation history.',
kind: CommandKind.BUILT_IN,
subCommands: [listCommand, saveCommand, resumeCommand, deleteCommand],
subCommands: [
listCommand,
saveCommand,
resumeCommand,
deleteCommand,
shareCommand,
],
};