diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index baccd7347ad..fbb21895289 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -40,6 +40,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; +import { viewCommand } from '../ui/commands/viewCommand.js'; import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js'; @@ -93,6 +94,7 @@ export class BuiltinCommandLoader implements ICommandLoader { toolsCommand, settingsCommand, vimCommand, + viewCommand, setupGithubCommand, terminalSetupCommand, ]; diff --git a/packages/cli/src/ui/commands/viewCommand.test.ts b/packages/cli/src/ui/commands/viewCommand.test.ts new file mode 100644 index 00000000000..8db778dd075 --- /dev/null +++ b/packages/cli/src/ui/commands/viewCommand.test.ts @@ -0,0 +1,341 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Mock } from 'vitest'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { viewCommand } from './viewCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { isEditorAvailable, openInEditor } from '@google/gemini-cli-core'; +import { writeFileSync, unlinkSync } from 'node:fs'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isEditorAvailable: vi.fn(), + openInEditor: vi.fn(), + }; +}); + +vi.mock('node:fs', () => ({ + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), +})); + +describe('viewCommand', () => { + let mockContext: CommandContext; + let mockIsEditorAvailable: Mock; + let mockOpenInEditor: Mock; + let mockWriteFileSync: Mock; + let mockUnlinkSync: Mock; + let mockGetChat: Mock; + let mockGetHistory: Mock; + + beforeEach(() => { + vi.clearAllMocks(); + + mockIsEditorAvailable = vi.mocked(isEditorAvailable); + mockOpenInEditor = vi.mocked(openInEditor); + mockWriteFileSync = vi.mocked(writeFileSync); + mockUnlinkSync = vi.mocked(unlinkSync); + mockGetChat = vi.fn(); + mockGetHistory = vi.fn(); + + mockContext = createMockCommandContext({ + services: { + config: { + getGeminiClient: () => ({ + getChat: mockGetChat, + }), + }, + settings: { + merged: { + general: { + preferredEditor: 'vscode', + }, + }, + }, + }, + }); + + mockGetChat.mockReturnValue({ + getHistory: mockGetHistory, + }); + }); + + it('should return error when no editor is configured', async () => { + if (!viewCommand.action) throw new Error('Command has no action'); + + const noEditorContext = createMockCommandContext({ + services: { + settings: { + merged: { + general: { + preferredEditor: undefined, + }, + }, + }, + }, + }); + + const result = await viewCommand.action(noEditorContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + 'No editor configured. Use /editor to set your preferred editor.', + }); + }); + + it('should return error when editor is not available', async () => { + if (!viewCommand.action) throw new Error('Command has no action'); + + mockIsEditorAvailable.mockReturnValue(false); + + const result = await viewCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + 'No editor configured. Use /editor to set your preferred editor.', + }); + }); + + it('should return info message when no history is available', async () => { + if (!viewCommand.action) throw new Error('Command has no action'); + + mockIsEditorAvailable.mockReturnValue(true); + mockGetChat.mockReturnValue(undefined); + + const result = await viewCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No output in history', + }); + }); + + it('should return info message when history is empty', async () => { + if (!viewCommand.action) throw new Error('Command has no action'); + + mockIsEditorAvailable.mockReturnValue(true); + mockGetHistory.mockReturnValue([]); + + const result = await viewCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No output in history', + }); + }); + + it('should return info message when no AI messages are found in history', async () => { + if (!viewCommand.action) throw new Error('Command has no action'); + + mockIsEditorAvailable.mockReturnValue(true); + const historyWithUserOnly = [ + { + role: 'user', + parts: [{ text: 'Hello' }], + }, + ]; + + mockGetHistory.mockReturnValue(historyWithUserOnly); + + const result = await viewCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No output in history', + }); + }); + + it('should open last AI message in editor successfully', async () => { + if (!viewCommand.action) throw new Error('Command has no action'); + + mockIsEditorAvailable.mockReturnValue(true); + const historyWithAiMessage = [ + { + role: 'user', + parts: [{ text: 'Hello' }], + }, + { + role: 'model', + parts: [{ text: 'Hi there! How can I help you?' }], + }, + ]; + + mockGetHistory.mockReturnValue(historyWithAiMessage); + mockOpenInEditor.mockResolvedValue(undefined); + + const result = await viewCommand.action(mockContext, ''); + + expect(mockWriteFileSync).toHaveBeenCalledWith( + expect.stringMatching(/gemini-output-\d+\.md$/), + 'Hi there! How can I help you?', + 'utf-8', + ); + expect(mockOpenInEditor).toHaveBeenCalledWith( + expect.stringMatching(/gemini-output-\d+\.md$/), + 'vscode', + ); + expect(mockUnlinkSync).toHaveBeenCalledWith( + expect.stringMatching(/gemini-output-\d+\.md$/), + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Opened last output in vscode', + }); + }); + + it('should handle multiple text parts in AI message', async () => { + if (!viewCommand.action) throw new Error('Command has no action'); + + mockIsEditorAvailable.mockReturnValue(true); + const historyWithMultipleParts = [ + { + role: 'model', + parts: [{ text: 'Part 1: ' }, { text: 'Part 2: ' }, { text: 'Part 3' }], + }, + ]; + + mockGetHistory.mockReturnValue(historyWithMultipleParts); + mockOpenInEditor.mockResolvedValue(undefined); + + const result = await viewCommand.action(mockContext, ''); + + expect(mockWriteFileSync).toHaveBeenCalledWith( + expect.stringMatching(/gemini-output-\d+\.md$/), + 'Part 1: Part 2: Part 3', + 'utf-8', + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Opened last output in vscode', + }); + }); + + it('should get the last AI message when multiple AI messages exist', async () => { + if (!viewCommand.action) throw new Error('Command has no action'); + + mockIsEditorAvailable.mockReturnValue(true); + const historyWithMultipleAiMessages = [ + { + role: 'model', + parts: [{ text: 'First AI response' }], + }, + { + role: 'user', + parts: [{ text: 'User message' }], + }, + { + role: 'model', + parts: [{ text: 'Second AI response' }], + }, + ]; + + mockGetHistory.mockReturnValue(historyWithMultipleAiMessages); + mockOpenInEditor.mockResolvedValue(undefined); + + const result = await viewCommand.action(mockContext, ''); + + expect(mockWriteFileSync).toHaveBeenCalledWith( + expect.stringMatching(/gemini-output-\d+\.md$/), + 'Second AI response', + 'utf-8', + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Opened last output in vscode', + }); + }); + + it('should handle editor open error', async () => { + if (!viewCommand.action) throw new Error('Command has no action'); + + mockIsEditorAvailable.mockReturnValue(true); + const historyWithAiMessage = [ + { + role: 'model', + parts: [{ text: 'AI response' }], + }, + ]; + + mockGetHistory.mockReturnValue(historyWithAiMessage); + const editorError = new Error('Editor launch failed'); + mockOpenInEditor.mockRejectedValue(editorError); + + const result = await viewCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: `Failed to open in editor. ${editorError.message}`, + }); + }); + + it('should return info message when no text parts found in AI message', async () => { + if (!viewCommand.action) throw new Error('Command has no action'); + + mockIsEditorAvailable.mockReturnValue(true); + const historyWithEmptyParts = [ + { + role: 'model', + parts: [{ image: 'base64data' }], // No text parts + }, + ]; + + mockGetHistory.mockReturnValue(historyWithEmptyParts); + + const result = await viewCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Last AI output contains no text to view.', + }); + + expect(mockOpenInEditor).not.toHaveBeenCalled(); + }); + + it('should handle unavailable config service', async () => { + if (!viewCommand.action) throw new Error('Command has no action'); + + mockIsEditorAvailable.mockReturnValue(true); + const nullConfigContext = createMockCommandContext({ + services: { + config: null, + settings: { + merged: { + general: { + preferredEditor: 'vscode', + }, + }, + }, + }, + }); + + const result = await viewCommand.action(nullConfigContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No output in history', + }); + + expect(mockOpenInEditor).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/commands/viewCommand.ts b/packages/cli/src/ui/commands/viewCommand.ts new file mode 100644 index 00000000000..f24a7ddcf88 --- /dev/null +++ b/packages/cli/src/ui/commands/viewCommand.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { writeFileSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + debugLogger, + openInEditor, + isEditorAvailable, + type EditorType, +} from '@google/gemini-cli-core'; +import type { SlashCommand, SlashCommandActionReturn } from './types.js'; +import { CommandKind } from './types.js'; + +export const viewCommand: SlashCommand = { + name: 'view', + description: 'Open the last result in external editor', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: async (context, _args): Promise => { + const preferredEditor = context.services.settings.merged.general + ?.preferredEditor as EditorType | undefined; + + if (!preferredEditor || !isEditorAvailable(preferredEditor)) { + return { + type: 'message', + messageType: 'error', + content: + 'No editor configured. Use /editor to set your preferred editor.', + }; + } + + const chat = await context.services.config?.getGeminiClient()?.getChat(); + const history = chat?.getHistory(); + + // Get the last message from the AI (model role) + const lastAiMessage = history + ? history.filter((item) => item.role === 'model').pop() + : undefined; + + if (!lastAiMessage) { + return { + type: 'message', + messageType: 'info', + content: 'No output in history', + }; + } + + // Extract text from the parts + const lastAiOutput = lastAiMessage.parts + ?.filter((part) => part.text) + .map((part) => part.text) + .join(''); + + if (!lastAiOutput) { + return { + type: 'message', + messageType: 'info', + content: 'Last AI output contains no text to view.', + }; + } + + let tempFile: string | undefined; + try { + // Create a temporary file with the content + tempFile = join(tmpdir(), `gemini-output-${Date.now()}.md`); + writeFileSync(tempFile, lastAiOutput, 'utf-8'); + + // Open the file in the preferred editor + await openInEditor(tempFile, preferredEditor); + + return { + type: 'message', + messageType: 'info', + content: `Opened last output in ${preferredEditor}`, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + debugLogger.debug(message); + + return { + type: 'message', + messageType: 'error', + content: `Failed to open in editor. ${message}`, + }; + } finally { + // Clean up temporary file + if (tempFile) { + try { + unlinkSync(tempFile); + } catch { + // Ignore cleanup errors + } + } + } + }, +}; diff --git a/packages/core/src/utils/editor.test.ts b/packages/core/src/utils/editor.test.ts index 52520ee71fb..94629a13fae 100644 --- a/packages/core/src/utils/editor.test.ts +++ b/packages/core/src/utils/editor.test.ts @@ -16,7 +16,9 @@ import { import { checkHasEditorType, getDiffCommand, + getOpenCommand, openDiff, + openInEditor, allowEditorTypeInSandbox, isEditorAvailable, type EditorType, @@ -460,6 +462,169 @@ describe('editor utils', () => { } }); + describe('getOpenCommand', () => { + const guiEditors: Array<{ + editor: EditorType; + commands: string[]; + win32Commands: string[]; + }> = [ + { editor: 'vscode', commands: ['code'], win32Commands: ['code.cmd'] }, + { + editor: 'vscodium', + commands: ['codium'], + win32Commands: ['codium.cmd'], + }, + { + editor: 'windsurf', + commands: ['windsurf'], + win32Commands: ['windsurf'], + }, + { editor: 'cursor', commands: ['cursor'], win32Commands: ['cursor'] }, + { editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] }, + { + editor: 'antigravity', + commands: ['agy'], + win32Commands: ['agy.cmd'], + }, + ]; + + for (const { editor, commands, win32Commands } of guiEditors) { + it(`should use first command "${commands[0]}" when it exists on non-windows for ${editor}`, () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + (execSync as Mock).mockReturnValue( + Buffer.from(`/usr/bin/${commands[0]}`), + ); + const openCommand = getOpenCommand('file.txt', editor); + expect(openCommand).toEqual({ + command: commands[0], + args: ['--wait', 'file.txt'], + }); + }); + + it(`should use first command "${win32Commands[0]}" when it exists on windows for ${editor}`, () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + (execSync as Mock).mockReturnValue( + Buffer.from(`C:\\Program Files\\...\\${win32Commands[0]}`), + ); + const openCommand = getOpenCommand('file.txt', editor); + expect(openCommand).toEqual({ + command: win32Commands[0], + args: ['--wait', 'file.txt'], + }); + }); + } + + const terminalEditors: Array<{ + editor: EditorType; + command: string; + }> = [ + { editor: 'vim', command: 'vim' }, + { editor: 'neovim', command: 'nvim' }, + { editor: 'emacs', command: 'emacs' }, + ]; + + for (const { editor, command } of terminalEditors) { + it(`should return the correct command for ${editor}`, () => { + const openCommand = getOpenCommand('file.txt', editor); + expect(openCommand).toEqual({ + command, + args: ['file.txt'], + }); + }); + } + + it('should return null for an unsupported editor', () => { + // @ts-expect-error Testing unsupported editor + const command = getOpenCommand('file.txt', 'foobar'); + expect(command).toBeNull(); + }); + }); + + describe('openInEditor', () => { + const guiEditors: EditorType[] = [ + 'vscode', + 'vscodium', + 'windsurf', + 'cursor', + 'zed', + ]; + + for (const editor of guiEditors) { + it(`should call spawn for ${editor}`, async () => { + const mockSpawnOn = vi.fn((event, cb) => { + if (event === 'close') { + cb(0); + } + }); + (spawn as Mock).mockReturnValue({ on: mockSpawnOn }); + + await openInEditor('file.txt', editor); + const openCommand = getOpenCommand('file.txt', editor)!; + expect(spawn).toHaveBeenCalledWith( + openCommand.command, + openCommand.args, + { + stdio: 'inherit', + shell: process.platform === 'win32', + }, + ); + expect(mockSpawnOn).toHaveBeenCalledWith('close', expect.any(Function)); + expect(mockSpawnOn).toHaveBeenCalledWith('error', expect.any(Function)); + }); + + it(`should reject if spawn for ${editor} fails`, async () => { + const mockError = new Error('spawn error'); + const mockSpawnOn = vi.fn((event, cb) => { + if (event === 'error') { + cb(mockError); + } + }); + (spawn as Mock).mockReturnValue({ on: mockSpawnOn }); + + await expect(openInEditor('file.txt', editor)).rejects.toThrow( + 'spawn error', + ); + }); + + it(`should reject if ${editor} exits with non-zero code`, async () => { + const mockSpawnOn = vi.fn((event, cb) => { + if (event === 'close') { + cb(1); + } + }); + (spawn as Mock).mockReturnValue({ on: mockSpawnOn }); + + await expect(openInEditor('file.txt', editor)).rejects.toThrow( + `${editor} exited with code 1`, + ); + }); + } + + const terminalEditors: EditorType[] = ['vim', 'neovim', 'emacs']; + + for (const editor of terminalEditors) { + it(`should call spawnSync for ${editor}`, async () => { + await openInEditor('file.txt', editor); + const openCommand = getOpenCommand('file.txt', editor)!; + expect(spawnSync).toHaveBeenCalledWith( + openCommand.command, + openCommand.args, + { + stdio: 'inherit', + }, + ); + }); + } + + it('should throw an error if editor is not available', async () => { + vi.spyOn(debugLogger, 'error').mockImplementation(() => {}); + // @ts-expect-error Testing unsupported editor + await expect(openInEditor('file.txt', 'foobar')).rejects.toThrow( + 'No editor available. Install a supported editor.', + ); + }); + }); + describe('isEditorAvailable', () => { it('should return false for undefined editor', () => { expect(isEditorAvailable(undefined)).toBe(false); diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts index a4ca04b2928..d1206ca645a 100644 --- a/packages/core/src/utils/editor.ts +++ b/packages/core/src/utils/editor.ts @@ -187,6 +187,98 @@ export function getDiffCommand( } } +interface OpenCommand { + command: string; + args: string[]; +} + +/** + * Get the command to open a file in a specific editor. + */ +export function getOpenCommand( + filePath: string, + editor: EditorType, +): OpenCommand | null { + if (!isValidEditorType(editor)) { + return null; + } + const commandConfig = editorCommands[editor]; + const commands = + process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; + const command = + commands.slice(0, -1).find((cmd) => commandExists(cmd)) || + commands[commands.length - 1]; + + switch (editor) { + case 'vscode': + case 'vscodium': + case 'windsurf': + case 'cursor': + case 'zed': + case 'antigravity': + return { command, args: ['--wait', filePath] }; + case 'vim': + case 'neovim': + return { command, args: [filePath] }; + case 'emacs': + return { command, args: [filePath] }; + default: + return null; + } +} + +/** + * Opens a file in the specified editor. + * Terminal-based editors by default blocks parent process until the editor exits. + * GUI-based editors require args such as "--wait" to block parent process. + */ +export async function openInEditor( + filePath: string, + editor: EditorType, +): Promise { + const openCommand = getOpenCommand(filePath, editor); + if (!openCommand) { + debugLogger.error('No editor available. Install a supported editor.'); + throw new Error('No editor available. Install a supported editor.'); + } + + if (isTerminalEditor(editor)) { + try { + const result = spawnSync(openCommand.command, openCommand.args, { + stdio: 'inherit', + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`${editor} exited with code ${result.status}`); + } + } finally { + coreEvents.emit(CoreEvent.ExternalEditorClosed); + } + return; + } + + return new Promise((resolve, reject) => { + const childProcess = spawn(openCommand.command, openCommand.args, { + stdio: 'inherit', + shell: process.platform === 'win32', + }); + + childProcess.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`${editor} exited with code ${code}`)); + } + }); + + childProcess.on('error', (error) => { + reject(error); + }); + }); +} + /** * Opens a diff tool to compare two files. * Terminal-based editors by default blocks parent process until the editor exits.