Adds the user's active file in the IDE to the footer (#4154)

This commit is contained in:
Shreya Keshive 2025-07-15 10:19:59 -04:00 committed by GitHub
parent 97cc1e6418
commit b09bc66560
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 287 additions and 4 deletions

View File

@ -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';

View File

@ -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

View File

@ -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<FooterProps> = ({
const limit = tokenLimit(model);
const percentage = promptTokenCount / limit;
const [activeFile, setActiveFile] = useState<ActiveFile | undefined>(
undefined,
);
useEffect(() => {
const updateActiveFile = () => {
const currentActiveFile = ideContext.getActiveFileContext();
setActiveFile(currentActiveFile);
};
updateActiveFile();
const unsubscribe = ideContext.subscribeToActiveFile(setActiveFile);
return () => {
unsubscribe();
};
}, []);
return (
<Box marginTop={1} justifyContent="space-between" width="100%">
<Box>
@ -59,6 +83,19 @@ export const Footer: React.FC<FooterProps> = ({
{branchName && <Text color={Colors.Gray}> ({branchName}*)</Text>}
</Text>
)}
{activeFile && activeFile.filePath && (
<Text>
<Text color={Colors.Gray}> | </Text>
<Text color={Colors.LightBlue}>
{shortenPath(tildeifyPath(activeFile.filePath), 70)}
</Text>
{activeFile.cursor && (
<Text color={Colors.Gray}>
:{activeFile.cursor.line}:{activeFile.cursor.character}
</Text>
)}
</Text>
)}
{debugMode && (
<Text color={Colors.AccentRed}>
{' ' + (debugMessage || '--debug')}

View File

@ -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';

View File

@ -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<typeof createIdeContextStore>;
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);
});
});

View File

@ -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<typeof CursorSchema>;
/**
* 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<typeof ActiveFileSchema>;
/**
* 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<ActiveFileSubscriber>();
/**
* 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();

View File

@ -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,