diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index ad8b1d44..5043fd59 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -868,7 +868,7 @@ describe('loadCliConfig ideMode', () => { expect(config.getIdeMode()).toBe(false); }); - it('should add __ide_server when ideMode is true', async () => { + it('should add _ide_server when ideMode is true', async () => { process.argv = ['node', 'script.js', '--ide-mode']; const argv = await parseArguments(); process.env.TERM_PROGRAM = 'vscode'; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 626f23e1..a5eb327d 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -18,6 +18,7 @@ import { FileDiscoveryService, TelemetryTarget, MCPServerConfig, + IDE_SERVER_NAME, } from '@google/gemini-cli-core'; import { Settings } from './settings.js'; @@ -285,7 +286,7 @@ export async function loadCliConfig( } if (ideMode) { - mcpServers['_ide_server'] = new MCPServerConfig( + mcpServers[IDE_SERVER_NAME] = new MCPServerConfig( undefined, // command undefined, // args undefined, // env diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 95904cd9..5524114b 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -4,10 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Box, Text } from 'ink'; import { Colors } from '../colors.js'; -import { shortenPath, tildeifyPath, tokenLimit } from '@google/gemini-cli-core'; +import { + shortenPath, + tildeifyPath, + tokenLimit, + ideContext, + ActiveFile, +} from '@google/gemini-cli-core'; import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js'; import process from 'node:process'; import Gradient from 'ink-gradient'; @@ -43,6 +49,24 @@ export const Footer: React.FC = ({ const limit = tokenLimit(model); const percentage = promptTokenCount / limit; + const [activeFile, setActiveFile] = useState( + undefined, + ); + + useEffect(() => { + const updateActiveFile = () => { + const currentActiveFile = ideContext.getActiveFileContext(); + setActiveFile(currentActiveFile); + }; + + updateActiveFile(); + + const unsubscribe = ideContext.subscribeToActiveFile(setActiveFile); + return () => { + unsubscribe(); + }; + }, []); + return ( @@ -59,6 +83,19 @@ export const Footer: React.FC = ({ {branchName && ({branchName}*)} )} + {activeFile && activeFile.filePath && ( + + | + + {shortenPath(tildeifyPath(activeFile.filePath), 70)} + + {activeFile.cursor && ( + + :{activeFile.cursor.line}:{activeFile.cursor.character} + + )} + + )} {debugMode && ( {' ' + (debugMessage || '--debug')} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index df7db12c..5f1dc3e7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -37,6 +37,7 @@ export * from './utils/quotaErrorDetection.js'; // Export services export * from './services/fileDiscoveryService.js'; export * from './services/gitService.js'; +export * from './services/ideContext.js'; // Export base tool definitions export * from './tools/tools.js'; diff --git a/packages/core/src/services/ideContext.test.ts b/packages/core/src/services/ideContext.test.ts new file mode 100644 index 00000000..0e6ff045 --- /dev/null +++ b/packages/core/src/services/ideContext.test.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createIdeContextStore } from './ideContext.js'; + +describe('ideContext - Active File', () => { + let ideContext: ReturnType; + + beforeEach(() => { + // Create a fresh, isolated instance for each test + ideContext = createIdeContextStore(); + }); + + it('should return undefined initially for active file context', () => { + expect(ideContext.getActiveFileContext()).toBeUndefined(); + }); + + it('should set and retrieve the active file context', () => { + const testFile = { + filePath: '/path/to/test/file.ts', + cursor: { line: 5, character: 10 }, + }; + + ideContext.setActiveFileContext(testFile); + + const activeFile = ideContext.getActiveFileContext(); + expect(activeFile).toEqual(testFile); + }); + + it('should update the active file context when called multiple times', () => { + const firstFile = { + filePath: '/path/to/first.js', + cursor: { line: 1, character: 1 }, + }; + ideContext.setActiveFileContext(firstFile); + + const secondFile = { + filePath: '/path/to/second.py', + cursor: { line: 20, character: 30 }, + }; + ideContext.setActiveFileContext(secondFile); + + const activeFile = ideContext.getActiveFileContext(); + expect(activeFile).toEqual(secondFile); + }); + + it('should handle empty string for file path', () => { + const testFile = { + filePath: '', + cursor: { line: 0, character: 0 }, + }; + ideContext.setActiveFileContext(testFile); + expect(ideContext.getActiveFileContext()).toEqual(testFile); + }); + + it('should notify subscribers when active file context changes', () => { + const subscriber1 = vi.fn(); + const subscriber2 = vi.fn(); + + ideContext.subscribeToActiveFile(subscriber1); + ideContext.subscribeToActiveFile(subscriber2); + + const testFile = { + filePath: '/path/to/subscribed.ts', + cursor: { line: 15, character: 25 }, + }; + ideContext.setActiveFileContext(testFile); + + expect(subscriber1).toHaveBeenCalledTimes(1); + expect(subscriber1).toHaveBeenCalledWith(testFile); + expect(subscriber2).toHaveBeenCalledTimes(1); + expect(subscriber2).toHaveBeenCalledWith(testFile); + + // Test with another update + const newFile = { + filePath: '/path/to/new.js', + cursor: { line: 1, character: 1 }, + }; + ideContext.setActiveFileContext(newFile); + + expect(subscriber1).toHaveBeenCalledTimes(2); + expect(subscriber1).toHaveBeenCalledWith(newFile); + expect(subscriber2).toHaveBeenCalledTimes(2); + expect(subscriber2).toHaveBeenCalledWith(newFile); + }); + + it('should stop notifying a subscriber after unsubscribe', () => { + const subscriber1 = vi.fn(); + const subscriber2 = vi.fn(); + + const unsubscribe1 = ideContext.subscribeToActiveFile(subscriber1); + ideContext.subscribeToActiveFile(subscriber2); + + ideContext.setActiveFileContext({ + filePath: '/path/to/file1.txt', + cursor: { line: 1, character: 1 }, + }); + expect(subscriber1).toHaveBeenCalledTimes(1); + expect(subscriber2).toHaveBeenCalledTimes(1); + + unsubscribe1(); + + ideContext.setActiveFileContext({ + filePath: '/path/to/file2.txt', + cursor: { line: 2, character: 2 }, + }); + expect(subscriber1).toHaveBeenCalledTimes(1); // Should not be called again + expect(subscriber2).toHaveBeenCalledTimes(2); + }); + + it('should allow the cursor to be optional', () => { + const testFile = { + filePath: '/path/to/test/file.ts', + }; + + ideContext.setActiveFileContext(testFile); + + const activeFile = ideContext.getActiveFileContext(); + expect(activeFile).toEqual(testFile); + }); +}); diff --git a/packages/core/src/services/ideContext.ts b/packages/core/src/services/ideContext.ts new file mode 100644 index 00000000..6bbe8cb9 --- /dev/null +++ b/packages/core/src/services/ideContext.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; + +/** + * The reserved server name for the IDE's MCP server. + */ +export const IDE_SERVER_NAME = '_ide_server'; + +/** + * Zod schema for validating a cursor position. + */ +export const CursorSchema = z.object({ + line: z.number(), + character: z.number(), +}); +export type Cursor = z.infer; + +/** + * Zod schema for validating an active file context from the IDE. + */ +export const ActiveFileSchema = z.object({ + filePath: z.string(), + cursor: CursorSchema.optional(), +}); +export type ActiveFile = z.infer; + +/** + * Zod schema for validating the 'ide/activeFileChanged' notification from the IDE. + */ +export const ActiveFileNotificationSchema = z.object({ + method: z.literal('ide/activeFileChanged'), + params: ActiveFileSchema, +}); + +type ActiveFileSubscriber = (activeFile: ActiveFile | undefined) => void; + +/** + * Creates a new store for managing the IDE's active file context. + * This factory function encapsulates the state and logic, allowing for the creation + * of isolated instances, which is particularly useful for testing. + * + * @returns An object with methods to interact with the active file context. + */ +export function createIdeContextStore() { + let activeFileContext: ActiveFile | undefined = undefined; + const subscribers = new Set(); + + /** + * Notifies all registered subscribers about the current active file context. + */ + function notifySubscribers(): void { + for (const subscriber of subscribers) { + subscriber(activeFileContext); + } + } + + /** + * Sets the active file context and notifies all registered subscribers of the change. + * @param newActiveFile The new active file context from the IDE. + */ + function setActiveFileContext(newActiveFile: ActiveFile): void { + activeFileContext = newActiveFile; + notifySubscribers(); + } + + /** + * Retrieves the current active file context. + * @returns The `ActiveFile` object if a file is active, otherwise `undefined`. + */ + function getActiveFileContext(): ActiveFile | undefined { + return activeFileContext; + } + + /** + * Subscribes to changes in the active file context. + * + * When the active file context changes, the provided `subscriber` function will be called. + * Note: The subscriber is not called with the current value upon subscription. + * + * @param subscriber The function to be called when the active file context changes. + * @returns A function that, when called, will unsubscribe the provided subscriber. + */ + function subscribeToActiveFile(subscriber: ActiveFileSubscriber): () => void { + subscribers.add(subscriber); + return () => { + subscribers.delete(subscriber); + }; + } + + return { + setActiveFileContext, + getActiveFileContext, + subscribeToActiveFile, + }; +} + +/** + * The default, shared instance of the IDE context store for the application. + */ +export const ideContext = createIdeContextStore(); diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index eb82190b..beb70549 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -20,6 +20,11 @@ import { MCPServerConfig } from '../config/config.js'; import { DiscoveredMCPTool } from './mcp-tool.js'; import { FunctionDeclaration, Type, mcpToTool } from '@google/genai'; import { sanitizeParameters, ToolRegistry } from './tool-registry.js'; +import { + ActiveFileNotificationSchema, + IDE_SERVER_NAME, + ideContext, +} from '../services/ideContext.js'; export const MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000; // default to 10 minutes @@ -211,6 +216,15 @@ export async function connectAndDiscover( updateMCPServerStatus(mcpServerName, MCPServerStatus.DISCONNECTED); }; + if (mcpServerName === IDE_SERVER_NAME) { + mcpClient.setNotificationHandler( + ActiveFileNotificationSchema, + (notification) => { + ideContext.setActiveFileContext(notification.params); + }, + ); + } + const tools = await discoverTools( mcpServerName, mcpServerConfig,