diff --git a/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js b/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js index 772f8d18a4..de99def0ce 100755 --- a/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js +++ b/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js @@ -20,7 +20,8 @@ async function run(cmd) { stdio: ['pipe', 'pipe', 'ignore'], }); return stdout.trim(); - } catch (_e) { // eslint-disable-line @typescript-eslint/no-unused-vars + } catch (_e) { + // eslint-disable-line @typescript-eslint/no-unused-vars return null; } } diff --git a/packages/cli/src/zed-integration/zedIntegration.test.ts b/packages/cli/src/zed-integration/zedIntegration.test.ts index cc71dd9309..147d6812e3 100644 --- a/packages/cli/src/zed-integration/zedIntegration.test.ts +++ b/packages/cli/src/zed-integration/zedIntegration.test.ts @@ -26,6 +26,7 @@ import { type Config, type MessageBus, LlmRole, + type MCPServerConfig, } from '@google/gemini-cli-core'; import { SettingScope, @@ -426,6 +427,7 @@ describe('Session', () => { getModel: vi.fn().mockReturnValue('gemini-pro'), getActiveModel: vi.fn().mockReturnValue('gemini-pro'), getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), + getMcpServers: vi.fn(), getFileService: vi.fn().mockReturnValue({ shouldIgnoreFile: vi.fn().mockReturnValue(false), }), @@ -450,6 +452,24 @@ describe('Session', () => { vi.clearAllMocks(); }); + it('should send available commands', async () => { + await session.sendAvailableCommands(); + + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'available_commands_update', + availableCommands: expect.arrayContaining([ + expect.objectContaining({ name: 'status' }), + expect.objectContaining({ name: 'mcp' }), + expect.objectContaining({ name: '$commit' }), + expect.objectContaining({ name: '$review-pr' }), + ]), + }), + }), + ); + }); + it('should handle prompt with text response', async () => { const stream = createMockStream([ { @@ -477,6 +497,163 @@ describe('Session', () => { expect(result).toEqual({ stopReason: 'end_turn' }); }); + it('should handle /status command directly with newlines', async () => { + mockConfig.getActiveModel.mockReturnValue('gemini-1.5-pro-test'); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: '/status\nTell me more' }], + }); + + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + }), + }), + ); + expect(result).toEqual({ stopReason: 'end_turn' }); + expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); + }); + it('should handle /status command directly', async () => { + mockConfig.getActiveModel.mockReturnValue('gemini-1.5-pro-test'); + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: '/status' }], + }); + + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + content: expect.objectContaining({ + type: 'text', + text: expect.stringContaining('gemini-1.5-pro-test'), + }), + }), + }), + ); + expect(result).toEqual({ stopReason: 'end_turn' }); + // Chat should not be called + expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); + }); + + it('should handle /mcp command directly', async () => { + mockConfig.getMcpServers.mockReturnValue({ + 'test-mcp': {}, + } as Record); + + const result = await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: '/mcp' }], + }); + + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + content: expect.objectContaining({ + type: 'text', + text: expect.stringContaining('`test-mcp`'), + }), + }), + }), + ); + expect(result).toEqual({ stopReason: 'end_turn' }); + expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); + }); + + it('should intercept $commit command and mutate prompt', async () => { + const stream = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: 'Committing...' }] } }], + }, + }, + ]); + mockChat.sendMessageStream.mockResolvedValue(stream); + + await session.prompt({ + sessionId: 'session-1', + // Should replace `$commit` with the instruction + prompt: [{ type: 'text', text: '$commit my cool changes' }], + }); + + expect(mockChat.sendMessageStream).toHaveBeenCalledWith( + expect.anything(), + // The prompt text should be modified to include the commit instruction + expect.arrayContaining([ + expect.objectContaining({ + text: 'Create a git commit based on the current changes using the tools available. my cool changes', + }), + ]), + expect.anything(), + expect.any(AbortSignal), + LlmRole.MAIN, + ); + }); + + it('should intercept $commit command with leading spaces and case insensitivity', async () => { + const stream = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: 'Committing...' }] } }], + }, + }, + ]); + mockChat.sendMessageStream.mockResolvedValue(stream); + + await session.prompt({ + sessionId: 'session-1', + // Should replace `$commit` with the instruction + prompt: [{ type: 'text', text: ' \n$cOmMiT my cool changes' }], + }); + + expect(mockChat.sendMessageStream).toHaveBeenCalledWith( + expect.anything(), + // The prompt text should be modified to include the commit instruction + expect.arrayContaining([ + expect.objectContaining({ + text: 'Create a git commit based on the current changes using the tools available. my cool changes', + }), + ]), + expect.anything(), + expect.any(AbortSignal), + LlmRole.MAIN, + ); + }); + + it('should intercept $review-pr command and mutate prompt', async () => { + const stream = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { + candidates: [{ content: { parts: [{ text: 'Reviewing...' }] } }], + }, + }, + ]); + mockChat.sendMessageStream.mockResolvedValue(stream); + + await session.prompt({ + sessionId: 'session-1', + prompt: [{ type: 'text', text: '$review-pr' }], + }); + + expect(mockChat.sendMessageStream).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([ + expect.objectContaining({ + text: 'Review the current pull request using the tools available.', + }), + ]), + expect.anything(), + expect.any(AbortSignal), + LlmRole.MAIN, + ); + }); + it('should handle tool calls', async () => { const stream1 = createMockStream([ { diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index 44b1890ce2..3211fc22b8 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -224,6 +224,9 @@ export class GeminiAgent { const session = new Session(sessionId, chat, config, this.connection); this.sessions.set(sessionId, session); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + session.sendAvailableCommands(); + return { sessionId, modes: { @@ -281,6 +284,9 @@ export class GeminiAgent { // eslint-disable-next-line @typescript-eslint/no-floating-promises session.streamHistory(sessionData.messages); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + session.sendAvailableCommands(); + return { modes: { availableModes: buildAvailableModes(config.isPlanEnabled()), @@ -429,6 +435,30 @@ export class Session { return {}; } + async sendAvailableCommands(): Promise { + await this.sendUpdate({ + sessionUpdate: 'available_commands_update', + availableCommands: [ + { + name: 'status', + description: 'Display session configuration and token usage', + }, + { + name: 'mcp', + description: 'List configured MCP tools', + }, + { + name: '$commit', + description: 'Create a git commit', + }, + { + name: '$review-pr', + description: 'Review a pull request', + }, + ], + }); + } + async streamHistory(messages: ConversationRecord['messages']): Promise { for (const msg of messages) { const contentString = partListUnionToString(msg.content); @@ -509,6 +539,23 @@ export class Session { const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal); + // Command interception + if ( + parts.length > 0 && + typeof parts[0] === 'object' && + parts[0] !== null && + 'text' in parts[0] && + parts[0].text + ) { + const firstText = parts[0].text.trim(); + if (firstText.startsWith('/') || firstText.startsWith('$')) { + const handled = await this.handleCommand(firstText, parts); + if (handled) { + return { stopReason: 'end_turn' }; + } + } + } + let nextMessage: Content | null = { role: 'user', parts }; while (nextMessage !== null) { @@ -605,6 +652,72 @@ export class Session { return { stopReason: 'end_turn' }; } + private async handleCommand( + commandText: string, + parts: Part[], + ): Promise { + const rawCommand = commandText.split(/\s+/)[0] || ''; + const commandToMatch = rawCommand.toLowerCase(); + + if (commandToMatch === '/status') { + const activeModel = this.config.getActiveModel(); + const resolvedModel = resolveModel(activeModel); + const content = `**Session Status**\n\n- Active Model: \`${resolvedModel}\``; + + await this.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: content }, + }); + return true; + } + + if (commandToMatch === '/mcp') { + const mcpServers = this.config.getMcpServers() || {}; + let content = '**Configured MCP Servers**\n'; + + const serverNames = Object.keys(mcpServers); + if (serverNames.length === 0) { + content += '\nNo MCP servers configured.'; + } else { + content += '\n' + serverNames.map((name) => `- \`${name}\``).join('\n'); + } + + await this.sendUpdate({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: content }, + }); + return true; + } + + if (commandToMatch === '$commit') { + const textPart = parts[0]; + if (textPart && 'text' in textPart && typeof textPart.text === 'string') { + textPart.text = textPart.text + .replace( + /^\s*\$commit/i, + 'Create a git commit based on the current changes using the tools available.', + ) + .trim(); + } + return false; // Proceed with LLM execution + } + + if (commandToMatch === '$review-pr') { + const textPart = parts[0]; + if (textPart && 'text' in textPart && typeof textPart.text === 'string') { + textPart.text = textPart.text + .replace( + /^\s*\$review-pr/i, + 'Review the current pull request using the tools available.', + ) + .trim(); + } + return false; // Proceed with LLM execution + } + + return false; + } + private async sendUpdate( update: acp.SessionNotification['update'], ): Promise {