diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 9cb48dfc7b..e2c223b55c 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -234,7 +234,7 @@ describe('parseArguments', () => { afterEach(() => { vi.restoreAllMocks(); }); - it('should fail if both --resume and --session-id are provided', async () => { + it('should fail if multiple session flags are provided', async () => { process.argv = [ 'node', 'script.js', @@ -255,7 +255,7 @@ describe('parseArguments', () => { expect(mockConsoleError).toHaveBeenCalledWith( expect.stringContaining( - 'Cannot use both --resume (-r) and --session-id together', + 'The flags --resume, --session-id, and --session-file are mutually exclusive. Please provide only one.', ), ); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 389fc4d2a7..d683084413 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -97,6 +97,7 @@ export interface CliArgs { extensions: string[] | undefined; listExtensions: boolean | undefined; resume: string | typeof RESUME_LATEST | undefined; + sessionFile?: string | undefined; sessionId: string | undefined; listSessions: boolean | undefined; deleteSession: string | undefined; @@ -239,8 +240,14 @@ export async function parseArguments( ? query.length > 0 : !!query; - if (argv['resume'] !== undefined && argv['session-id'] !== undefined) { - return 'Cannot use both --resume (-r) and --session-id together'; + const sessionFlags = [ + argv['resume'] !== undefined, + argv['session-id'] !== undefined, + argv['session-file'] !== undefined, + ].filter(Boolean).length; + + if (sessionFlags > 1) { + return 'The flags --resume, --session-id, and --session-file are mutually exclusive. Please provide only one.'; } if (argv['prompt'] && hasPositionalQuery) { @@ -412,6 +419,11 @@ export async function parseArguments( return trimmed; }, }) + .option('session-file', { + type: 'string', + nargs: 1, + description: 'Load a session from a JSON file', + }) .option('session-id', { type: 'string', nargs: 1, diff --git a/packages/cli/src/config/mutual-exclusivity.test.ts b/packages/cli/src/config/mutual-exclusivity.test.ts new file mode 100644 index 0000000000..9ed9378231 --- /dev/null +++ b/packages/cli/src/config/mutual-exclusivity.test.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { parseArguments } from './config.js'; +import { createTestMergedSettings } from './settings.js'; + +describe('parseArguments mutual exclusivity', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const combinations = [ + ['--resume', '--session-id', 'test-id'], + ['--resume', '--session-file', 'test.json'], + ['--session-id', 'test-id', '--session-file', 'test.json'], + ['--resume', '--session-id', 'test-id', '--session-file', 'test.json'], + ]; + + combinations.forEach((args) => { + it(`should fail if ${args.filter((a) => a.startsWith('--')).join(' and ')} are provided`, async () => { + process.argv = ['node', 'script.js', ...args]; + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + await expect(parseArguments(createTestMergedSettings())).rejects.toThrow( + 'process.exit called', + ); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + 'The flags --resume, --session-id, and --session-file are mutually exclusive. Please provide only one.', + ), + ); + }); + }); +}); diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index ca990420d1..f678d9ad71 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -44,6 +44,7 @@ import { type Config, type ResumedSessionData, type StartupWarning, + type ConversationRecord, WarningPriority, debugLogger, coreEvents, @@ -828,14 +829,14 @@ describe('gemini.tsx main function kitty protocol', () => { }); it('should handle session selector error', async () => { - vi.mocked(SessionSelector).mockImplementation( - () => - ({ - resolveSession: vi - .fn() - .mockRejectedValue(new Error('Session not found')), - }) as any, // eslint-disable-line @typescript-eslint/no-explicit-any - ); + // eslint-disable-next-line prefer-arrow-callback + vi.mocked(SessionSelector).mockImplementation(function () { + return { + resolveSession: vi + .fn() + .mockRejectedValue(new Error('Session not found')), + } as unknown as InstanceType; + }); const processExitSpy = vi .spyOn(process, 'exit') @@ -884,14 +885,14 @@ describe('gemini.tsx main function kitty protocol', () => { }); it('should start normally with a warning when no sessions found for resume', async () => { - vi.mocked(SessionSelector).mockImplementation( - () => - ({ - resolveSession: vi - .fn() - .mockRejectedValue(SessionError.noSessionsFound()), - }) as unknown as InstanceType, - ); + // eslint-disable-next-line prefer-arrow-callback + vi.mocked(SessionSelector).mockImplementation(function () { + return { + resolveSession: vi + .fn() + .mockRejectedValue(SessionError.noSessionsFound()), + } as unknown as InstanceType; + }); const processExitSpy = vi .spyOn(process, 'exit') @@ -1068,13 +1069,88 @@ describe('resolveSessionId', () => { expect(resumedSessionData).toBeUndefined(); }); + it('should import from session file when sessionFile is provided', async () => { + // eslint-disable-next-line prefer-arrow-callback + vi.mocked(SessionSelector).mockImplementation(function () { + return { + sessionExists: vi.fn().mockResolvedValue(false), + } as unknown as InstanceType; + }); + + const coreModule = await import('@google/gemini-cli-core'); + vi.spyOn(coreModule, 'loadConversationRecord').mockResolvedValueOnce({ + sessionId: 'old-session-id', + projectHash: 'hash', + startTime: 'time', + lastUpdated: 'time', + messages: [ + { type: 'info', content: 'Old info', id: '1' }, + { type: 'user', content: 'Hello', id: '2' }, + { type: 'gemini', content: 'Hi', id: '3' }, + { type: 'error', content: 'Old error', id: '4' }, + { type: 'user', id: '5' }, // Missing content + null, // Null object + { type: 'unknown', content: 'Something', id: '6' }, // Unknown type + ], + } as unknown as ConversationRecord); + + const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + + try { + const { sessionId, resumedSessionData } = await resolveSessionId( + undefined, + undefined, + 'dummy-session.json', + ); + + expect(sessionId).toBeDefined(); + expect(sessionId).not.toBe('old-session-id'); // A new session ID should be created + expect(resumedSessionData).toBeDefined(); + expect(resumedSessionData?.conversation.sessionId).toBe(sessionId); // Overwritten + + // Verify messages: should have 1 info (the new import confirmation) + 2 valid conversation messages + // Invalid messages (missing content, null, unknown type) and transient messages should be filtered out. + expect(resumedSessionData?.conversation.messages).toHaveLength(3); + expect(resumedSessionData?.conversation.messages![0]).toMatchObject({ + type: 'info', + content: expect.stringContaining('Imported session from'), + }); + expect(resumedSessionData?.conversation.messages![1]).toMatchObject({ + type: 'user', + content: 'Hello', + }); + expect(resumedSessionData?.conversation.messages![2]).toMatchObject({ + type: 'gemini', + content: 'Hi', + }); + + expect(resumedSessionData?.filePath).toContain(sessionId.slice(0, 8)); // New path + } catch (e) { + if (e instanceof MockProcessExitError) { + throw new Error( + 'process.exit called with: ' + + JSON.stringify(emitFeedbackSpy.mock.calls), + ); + } + throw e; + } finally { + emitFeedbackSpy.mockRestore(); + processExitSpy.mockRestore(); + } + }); + it('should exit with FATAL_INPUT_ERROR when sessionId already exists', async () => { - vi.mocked(SessionSelector).mockImplementation( - () => - ({ - sessionExists: vi.fn().mockResolvedValue(true), - }) as unknown as InstanceType, - ); + // eslint-disable-next-line prefer-arrow-callback + vi.mocked(SessionSelector).mockImplementation(function () { + return { + sessionExists: vi.fn().mockResolvedValue(true), + } as unknown as InstanceType; + }); const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); const processExitSpy = vi @@ -1100,12 +1176,12 @@ describe('resolveSessionId', () => { }); it('should return provided sessionId when it does not exist', async () => { - vi.mocked(SessionSelector).mockImplementation( - () => - ({ - sessionExists: vi.fn().mockResolvedValue(false), - }) as unknown as InstanceType, - ); + // eslint-disable-next-line prefer-arrow-callback + vi.mocked(SessionSelector).mockImplementation(function () { + return { + sessionExists: vi.fn().mockResolvedValue(false), + } as unknown as InstanceType; + }); const { sessionId, resumedSessionData } = await resolveSessionId( undefined, 'new-id', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 892ee9862a..ab97f7f574 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -35,6 +35,9 @@ import { debugLogger, isHeadlessMode, Storage, + getProjectHash, + loadConversationRecord, + type MessageRecord, } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments } from './config/config.js'; @@ -44,6 +47,8 @@ import { createHash } from 'node:crypto'; import v8 from 'node:v8'; import os from 'node:os'; import dns from 'node:dns'; +import * as path from 'node:path'; +import * as fsPromises from 'node:fs/promises'; import { start_sandbox } from './utils/sandbox.js'; import { loadSettings, @@ -194,11 +199,12 @@ ${reason.stack}` export async function resolveSessionId( resumeArg: string | undefined, sessionIdArg?: string | undefined, + sessionFileArg?: string | undefined, ): Promise<{ sessionId: string; resumedSessionData?: ResumedSessionData; }> { - if (!resumeArg && !sessionIdArg) { + if (!resumeArg && !sessionIdArg && !sessionFileArg) { return { sessionId: createSessionId() }; } @@ -207,6 +213,80 @@ export async function resolveSessionId( const sessionSelector = new SessionSelector(storage); + if (sessionFileArg) { + try { + const sessionData = await loadConversationRecord(sessionFileArg); + if (!sessionData) { + throw new Error(`File not found or invalid format: ${sessionFileArg}`); + } + + const now = Date.now(); + const isoNow = new Date(now).toISOString(); + + // Filter out old system/info messages that are specific to the previous run + // and only keep actual conversation messages (user/gemini). + // Best effort parse: ensure message is an object and has required fields. + sessionData.messages = (sessionData.messages || []).filter( + (m) => + typeof m === 'object' && + m !== null && + (m.type === 'user' || m.type === 'gemini') && + m.content !== undefined, + ); + + // Add a single info message to the history to confirm the import + sessionData.messages.unshift({ + id: `import-${now}`, + type: 'info', + content: `Imported session from ${sessionFileArg}`, + timestamp: isoNow, + } as MessageRecord); + + const newSessionId = createSessionId(); + sessionData.sessionId = newSessionId; + sessionData.projectHash = getProjectHash(storage.getProjectRoot()); + sessionData.startTime = isoNow; + sessionData.lastUpdated = isoNow; + + const chatsDir = path.join(storage.getProjectTempDir(), 'chats'); + const newSessionPath = path.join( + chatsDir, + `session-${now}-${newSessionId.slice(0, 8)}.jsonl`, + ); + + const { messages: _messages, ...initialMetadata } = sessionData; + + const lines = [JSON.stringify(initialMetadata)]; + if (sessionData.messages) { + for (const msg of sessionData.messages) { + lines.push(JSON.stringify(msg)); + } + } + + await fsPromises.mkdir(chatsDir, { recursive: true }); + await fsPromises.writeFile( + newSessionPath, + lines.join('\n') + '\n', + 'utf-8', + ); + + return { + sessionId: newSessionId, + resumedSessionData: { + conversation: sessionData, + filePath: newSessionPath, + }, + }; + } catch (error) { + coreEvents.emitFeedback( + 'error', + `Error importing session from file: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + await runExitCleanup(); + process.exit(ExitCodes.FATAL_INPUT_ERROR); + } + } + if (sessionIdArg) { if (await sessionSelector.sessionExists(sessionIdArg)) { coreEvents.emitFeedback( @@ -340,6 +420,7 @@ export async function main() { const { sessionId, resumedSessionData } = await resolveSessionId( argv.resume, argv.sessionId, + argv.sessionFile, ); if ( diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 19d7181931..67b0861eb5 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -30,6 +30,7 @@ import { compressCommand } from '../ui/commands/compressCommand.js'; import { copyCommand } from '../ui/commands/copyCommand.js'; import { corgiCommand } from '../ui/commands/corgiCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js'; +import { exportSessionCommand } from '../ui/commands/exportSessionCommand.js'; import { directoryCommand } from '../ui/commands/directoryCommand.js'; import { editorCommand } from '../ui/commands/editorCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; @@ -135,6 +136,7 @@ export class BuiltinCommandLoader implements ICommandLoader { copyCommand, corgiCommand, docsCommand, + exportSessionCommand, directoryCommand, editorCommand, ...(this.config?.getExtensionsEnabled() === false diff --git a/packages/cli/src/ui/commands/exportSessionCommand.test.ts b/packages/cli/src/ui/commands/exportSessionCommand.test.ts new file mode 100644 index 0000000000..08d6a924f4 --- /dev/null +++ b/packages/cli/src/ui/commands/exportSessionCommand.test.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { exportSessionCommand } from './exportSessionCommand.js'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { SessionSelector } from '../../utils/sessionUtils.js'; +import type { CommandContext } from './types.js'; +import { Storage, type ConversationRecord } from '@google/gemini-cli-core'; + +vi.mock('node:fs/promises'); +vi.mock('../../utils/sessionUtils.js'); + +describe('exportSessionCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(Storage.prototype, 'initialize').mockResolvedValue(undefined); + vi.spyOn(Storage.prototype, 'getProjectTempDir').mockReturnValue( + path.join(path.sep, 'tmp', 'mock-dir'), + ); + mockContext = { + services: { + agentContext: { + config: { + sessionId: 'test-session-id', + getSessionId: () => 'test-session-id', + storage: new Storage(process.cwd()), + }, + }, + }, + invocation: { + args: ' export.json ', + name: 'export-session', + raw: '/export-session export.json', + }, + ui: { + addItem: vi.fn(), + setPendingItem: vi.fn(), + pendingItem: null, + }, + } as unknown as CommandContext; + }); + + it('should return error if no path is provided', async () => { + mockContext.invocation!.args = ' '; + const result = await exportSessionCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('Please provide a file path'), + }); + }); + + it('should return error if sessionId is missing', async () => { + mockContext.services.agentContext!.config.getSessionId = () => + undefined as unknown as string; + const result = await exportSessionCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }); + }); + + it('should export the session successfully', async () => { + const mockSessionData: ConversationRecord = { + sessionId: 'test-session-id', + messages: [], + projectHash: 'hash', + startTime: 'time', + lastUpdated: 'time', + }; + vi.mocked(SessionSelector.prototype.resolveSession).mockResolvedValue({ + sessionData: mockSessionData, + sessionPath: path.join( + path.sep, + 'tmp', + 'mock-dir', + 'chats', + 'session.jsonl', + ), + displayInfo: 'test', + }); + + vi.mocked(fs.access).mockRejectedValue(new Error('Not found')); + + const result = await exportSessionCommand.action!(mockContext, ''); + + expect(result).toBeUndefined(); + expect(fs.writeFile).toHaveBeenCalledWith( + path.resolve(process.cwd(), 'export.json'), + JSON.stringify(mockSessionData, null, 2), + 'utf-8', + ); + expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'export_session', + exportSession: { isPending: true }, + }), + ); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'export_session', + exportSession: { + isPending: false, + targetPath: expect.stringContaining('export.json'), + }, + }), + expect.any(Number), + ); + expect(mockContext.ui.setPendingItem).toHaveBeenLastCalledWith(null); + }); + + it('should return error if resolveSession fails', async () => { + vi.mocked(SessionSelector.prototype.resolveSession).mockRejectedValue( + new Error('Session not found'), + ); + + const result = await exportSessionCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to export session: Session not found', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/exportSessionCommand.ts b/packages/cli/src/ui/commands/exportSessionCommand.ts new file mode 100644 index 0000000000..af56574dff --- /dev/null +++ b/packages/cli/src/ui/commands/exportSessionCommand.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { + type CommandContext, + type SlashCommand, + type SlashCommandActionReturn, + CommandKind, +} from './types.js'; +import { MessageType, type HistoryItemExportSession } from '../types.js'; +import { SessionSelector } from '../../utils/sessionUtils.js'; + +export const exportSessionCommand: SlashCommand = { + name: 'export-session', + description: 'Export the current session to a JSON file', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async ( + context: CommandContext, + ): Promise => { + const { ui } = context; + const args = context.invocation?.args.trim(); + if (!args) { + return { + type: 'message', + messageType: 'error', + content: + 'Please provide a file path to export the session to. Example: /export-session ./my-session.json', + }; + } + + const sessionId = context.services.agentContext?.config.getSessionId(); + if (!sessionId) { + return { + type: 'message', + messageType: 'error', + content: 'No active session found to export.', + }; + } + + if (ui.pendingItem) { + ui.addItem( + { + type: MessageType.ERROR, + text: 'Operation already in progress, please wait.', + }, + Date.now(), + ); + return; + } + + const pendingMessage: HistoryItemExportSession = { + type: MessageType.EXPORT_SESSION, + exportSession: { + isPending: true, + }, + }; + + try { + ui.setPendingItem(pendingMessage); + const storage = context.services.agentContext!.config.storage; + const sessionSelector = new SessionSelector(storage); + const { sessionData } = await sessionSelector.resolveSession(sessionId); + + const targetPath = path.resolve(process.cwd(), args); + + await fs.writeFile( + targetPath, + JSON.stringify(sessionData, null, 2), + 'utf-8', + ); + + ui.addItem( + { + type: MessageType.EXPORT_SESSION, + exportSession: { + isPending: false, + targetPath, + }, + } as HistoryItemExportSession, + Date.now(), + ); + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to export session: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } finally { + ui.setPendingItem(null); + } + }, +}; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx index 2f6e9e1b8a..aeebf478f3 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.test.tsx @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as path from 'node:path'; import { describe, it, expect, vi } from 'vitest'; import { HistoryItemDisplay } from './HistoryItemDisplay.js'; import { MessageType, type HistoryItem } from '../types.js'; @@ -198,6 +199,25 @@ describe('', () => { unmount(); }); + it('renders ExportSessionMessage for "export_session" type', async () => { + const testPath = path.join(path.sep, 'test', 'path.json'); + const item: HistoryItem = { + ...baseItem, + type: 'export_session', + exportSession: { + isPending: false, + targetPath: testPath, + }, + }; + const { lastFrame, unmount } = await renderWithProviders( + , + ); + expect(lastFrame()).toContain( + `Successfully exported session to ${testPath}`, + ); + unmount(); + }); + it('should escape ANSI codes in text content', async () => { const historyItem: HistoryItem = { id: 1, diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 1ae783fe75..0594f2ed3a 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -17,6 +17,7 @@ import { ToolGroupMessage } from './messages/ToolGroupMessage.js'; import { ToolGroupDisplay } from './messages/ToolGroupDisplay.js'; import { GeminiMessageContent } from './messages/GeminiMessageContent.js'; import { CompressionMessage } from './messages/CompressionMessage.js'; +import { ExportSessionMessage } from './messages/ExportSessionMessage.js'; import { WarningMessage } from './messages/WarningMessage.js'; import { SubagentHistoryMessage } from './messages/SubagentHistoryMessage.js'; import { Box } from 'ink'; @@ -211,6 +212,9 @@ export const HistoryItemDisplay: React.FC = ({ {itemForDisplay.type === 'compression' && ( )} + {itemForDisplay.type === 'export_session' && ( + + )} {itemForDisplay.type === 'extensions_list' && ( )} diff --git a/packages/cli/src/ui/components/messages/ExportSessionMessage.test.tsx b/packages/cli/src/ui/components/messages/ExportSessionMessage.test.tsx new file mode 100644 index 0000000000..24a53a39c2 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ExportSessionMessage.test.tsx @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import { render } from '../../../test-utils/render.js'; +import { Text } from 'ink'; +import { describe, it, expect, vi } from 'vitest'; +import { ExportSessionMessage } from './ExportSessionMessage.js'; + +vi.mock('../CliSpinner.js', () => ({ + CliSpinner: () => [spinner], +})); + +describe('ExportSessionMessage', () => { + it('renders pending state correctly', async () => { + const { lastFrame } = await render( + , + ); + expect(lastFrame()).toContain('[spinner]'); + expect(lastFrame()).toContain('Exporting session...'); + }); + + it('renders success state correctly', async () => { + const testPath = path.join(path.sep, 'path', 'to', 'session.json'); + const { lastFrame } = await render( + , + ); + expect(lastFrame()).toContain('✓'); + expect(lastFrame()).toContain( + `Successfully exported session to ${testPath}`, + ); + }); +}); diff --git a/packages/cli/src/ui/components/messages/ExportSessionMessage.tsx b/packages/cli/src/ui/components/messages/ExportSessionMessage.tsx new file mode 100644 index 0000000000..fdc49a86b6 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ExportSessionMessage.tsx @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { JSX } from 'react'; +import { Box, Text } from 'ink'; +import type { ExportSessionProps } from '../../types.js'; +import { CliSpinner } from '../CliSpinner.js'; +import { theme } from '../../semantic-colors.js'; + +export interface ExportSessionDisplayProps { + exportSession: ExportSessionProps; +} + +/* + * Export session messages appear when the /export-session command is run, and show a loading spinner + * while export is in progress, followed by a success message. + */ +export function ExportSessionMessage({ + exportSession, +}: ExportSessionDisplayProps): JSX.Element { + const { isPending, targetPath } = exportSession; + + return ( + + + {isPending ? ( + + ) : ( + + )} + + + + {isPending + ? 'Exporting session...' + : `Successfully exported session to ${targetPath}`} + + + + ); +} diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 7cb204a339..5ed6e05723 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -148,6 +148,11 @@ export interface CompressionProps { compressionStatus: CompressionStatus | null; } +export interface ExportSessionProps { + isPending: boolean; + targetPath?: string; +} + /** * For use when you want no icon. */ @@ -284,6 +289,11 @@ export type HistoryItemCompression = HistoryItemBase & { compression: CompressionProps; }; +export type HistoryItemExportSession = HistoryItemBase & { + type: 'export_session'; + exportSession: ExportSessionProps; +}; + export type HistoryItemExtensionsList = HistoryItemBase & { type: 'extensions_list'; extensions: GeminiCLIExtension[]; @@ -427,6 +437,7 @@ export type HistoryItemWithoutId = | HistoryItemModel | HistoryItemQuit | HistoryItemCompression + | HistoryItemExportSession | HistoryItemExtensionsList | HistoryItemToolsList | HistoryItemSkillsList @@ -454,6 +465,7 @@ export enum MessageType { QUIT = 'quit', GEMINI = 'gemini', COMPRESSION = 'compression', + EXPORT_SESSION = 'export_session', EXTENSIONS_LIST = 'extensions_list', TOOLS_LIST = 'tools_list', SKILLS_LIST = 'skills_list',