2025-07-25 17:46:55 +00:00
/ * *
* @license
* Copyright 2025 Google LLC
* SPDX - License - Identifier : Apache - 2.0
* /
2025-07-30 21:26:31 +00:00
import {
detectIde ,
DetectedIde ,
getIdeDisplayName ,
} from '../ide/detect-ide.js' ;
2025-08-06 17:36:05 +00:00
import {
ideContext ,
IdeContextNotificationSchema ,
IdeDiffAcceptedNotificationSchema ,
IdeDiffClosedNotificationSchema ,
CloseDiffResponseSchema ,
DiffUpdateResult ,
} from '../ide/ideContext.js' ;
2025-07-25 17:46:55 +00:00
import { Client } from '@modelcontextprotocol/sdk/client/index.js' ;
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' ;
const logger = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2025-07-25 21:57:34 -04:00
debug : ( . . . args : any [ ] ) = > console . debug ( '[DEBUG] [IDEClient]' , . . . args ) ,
2025-07-25 17:46:55 +00:00
} ;
export type IDEConnectionState = {
status : IDEConnectionStatus ;
2025-07-28 16:55:00 -04:00
details? : string ; // User-facing
2025-07-25 17:46:55 +00:00
} ;
export enum IDEConnectionStatus {
Connected = 'connected' ,
Disconnected = 'disconnected' ,
Connecting = 'connecting' ,
}
/ * *
* Manages the connection to and interaction with the IDE server .
* /
export class IdeClient {
2025-08-05 18:52:58 -04:00
private static instance : IdeClient ;
private client : Client | undefined = undefined ;
2025-07-28 16:55:00 -04:00
private state : IDEConnectionState = {
status : IDEConnectionStatus.Disconnected ,
2025-08-05 18:52:58 -04:00
details :
'IDE integration is currently disabled. To enable it, run /ide enable.' ,
2025-07-28 16:55:00 -04:00
} ;
2025-07-30 21:26:31 +00:00
private readonly currentIde : DetectedIde | undefined ;
private readonly currentIdeDisplayName : string | undefined ;
2025-08-06 17:36:05 +00:00
private diffResponses = new Map < string , ( result : DiffUpdateResult ) = > void > ( ) ;
2025-07-25 17:46:55 +00:00
2025-08-05 18:52:58 -04:00
private constructor ( ) {
2025-07-30 21:26:31 +00:00
this . currentIde = detectIde ( ) ;
if ( this . currentIde ) {
this . currentIdeDisplayName = getIdeDisplayName ( this . currentIde ) ;
}
2025-07-25 17:46:55 +00:00
}
2025-07-25 21:57:34 -04:00
2025-08-05 18:52:58 -04:00
static getInstance ( ) : IdeClient {
2025-07-30 21:26:31 +00:00
if ( ! IdeClient . instance ) {
2025-08-05 18:52:58 -04:00
IdeClient . instance = new IdeClient ( ) ;
2025-07-30 21:26:31 +00:00
}
return IdeClient . instance ;
}
2025-08-05 18:52:58 -04:00
async connect ( ) : Promise < void > {
this . setState ( IDEConnectionStatus . Connecting ) ;
if ( ! this . currentIde || ! this . currentIdeDisplayName ) {
this . setState ( IDEConnectionStatus . Disconnected ) ;
return ;
}
if ( ! this . validateWorkspacePath ( ) ) {
return ;
}
const port = this . getPortFromEnv ( ) ;
if ( ! port ) {
return ;
}
await this . establishConnection ( port ) ;
}
2025-08-06 17:36:05 +00:00
/ * *
* A diff is accepted with any modifications if the user performs one of the
* following actions :
* - Clicks the checkbox icon in the IDE to accept
* - Runs ` command+shift+p ` > "Gemini CLI: Accept Diff in IDE" to accept
* - Selects "accept" in the CLI UI
* - Saves the file via ` ctrl/command+s `
*
* A diff is rejected if the user performs one of the following actions :
* - Clicks the "x" icon in the IDE
* - Runs "Gemini CLI: Close Diff in IDE"
* - Selects "no" in the CLI UI
* - Closes the file
* /
async openDiff (
filePath : string ,
newContent? : string ,
) : Promise < DiffUpdateResult > {
return new Promise < DiffUpdateResult > ( ( resolve , reject ) = > {
this . diffResponses . set ( filePath , resolve ) ;
this . client
? . callTool ( {
name : ` openDiff ` ,
arguments : {
filePath ,
newContent ,
} ,
} )
. catch ( ( err ) = > {
logger . debug ( ` callTool for ${ filePath } failed: ` , err ) ;
reject ( err ) ;
} ) ;
} ) ;
}
async closeDiff ( filePath : string ) : Promise < string | undefined > {
try {
const result = await this . client ? . callTool ( {
name : ` closeDiff ` ,
arguments : {
filePath ,
} ,
} ) ;
if ( result ) {
const parsed = CloseDiffResponseSchema . parse ( result ) ;
return parsed . content ;
}
} catch ( err ) {
logger . debug ( ` callTool for ${ filePath } failed: ` , err ) ;
}
return ;
}
// Closes the diff. Instead of waiting for a notification,
// manually resolves the diff resolver as the desired outcome.
async resolveDiffFromCli ( filePath : string , outcome : 'accepted' | 'rejected' ) {
const content = await this . closeDiff ( filePath ) ;
const resolver = this . diffResponses . get ( filePath ) ;
if ( resolver ) {
if ( outcome === 'accepted' ) {
resolver ( { status : 'accepted' , content } ) ;
} else {
resolver ( { status : 'rejected' , content : undefined } ) ;
}
this . diffResponses . delete ( filePath ) ;
}
}
2025-08-05 18:52:58 -04:00
disconnect() {
this . setState (
IDEConnectionStatus . Disconnected ,
'IDE integration disabled. To enable it again, run /ide enable.' ,
) ;
this . client ? . close ( ) ;
}
2025-07-30 21:26:31 +00:00
getCurrentIde ( ) : DetectedIde | undefined {
return this . currentIde ;
}
2025-07-28 16:55:00 -04:00
getConnectionStatus ( ) : IDEConnectionState {
return this . state ;
}
2025-08-05 18:52:58 -04:00
getDetectedIdeDisplayName ( ) : string | undefined {
return this . currentIdeDisplayName ;
}
2025-07-28 16:55:00 -04:00
private setState ( status : IDEConnectionStatus , details? : string ) {
2025-08-05 18:52:58 -04:00
const isAlreadyDisconnected =
this . state . status === IDEConnectionStatus . Disconnected &&
status === IDEConnectionStatus . Disconnected ;
// Only update details if the state wasn't already disconnected, so that
// the first detail message is preserved.
if ( ! isAlreadyDisconnected ) {
this . state = { status , details } ;
}
2025-07-28 16:55:00 -04:00
if ( status === IDEConnectionStatus . Disconnected ) {
2025-08-05 18:52:58 -04:00
logger . debug ( 'IDE integration disconnected:' , details ) ;
2025-07-28 16:55:00 -04:00
ideContext . clearIdeContext ( ) ;
2025-07-25 17:46:55 +00:00
}
}
2025-08-05 18:52:58 -04:00
private validateWorkspacePath ( ) : boolean {
const ideWorkspacePath = process . env [ 'GEMINI_CLI_IDE_WORKSPACE_PATH' ] ;
if ( ideWorkspacePath === undefined ) {
2025-07-28 16:55:00 -04:00
this . setState (
IDEConnectionStatus . Disconnected ,
2025-08-05 18:52:58 -04:00
` Failed to connect to IDE companion extension for ${ this . currentIdeDisplayName } . Please ensure the extension is running and try refreshing your terminal. To install the extension, run /ide install. ` ,
2025-07-28 16:55:00 -04:00
) ;
2025-08-05 18:52:58 -04:00
return false ;
2025-07-28 16:55:00 -04:00
}
2025-08-05 18:52:58 -04:00
if ( ideWorkspacePath === '' ) {
2025-07-28 16:55:00 -04:00
this . setState (
IDEConnectionStatus . Disconnected ,
2025-08-05 18:52:58 -04:00
` To use this feature, please open a single workspace folder in ${ this . currentIdeDisplayName } and try again. ` ,
2025-07-25 17:46:55 +00:00
) ;
2025-07-28 16:55:00 -04:00
return false ;
}
if ( ideWorkspacePath !== process . cwd ( ) ) {
this . setState (
IDEConnectionStatus . Disconnected ,
2025-08-05 18:52:58 -04:00
` Directory mismatch. Gemini CLI is running in a different location than the open workspace in ${ this . currentIdeDisplayName } . Please run the CLI from the same directory as your project's root folder. ` ,
2025-07-28 16:55:00 -04:00
) ;
return false ;
}
return true ;
}
2025-08-05 18:52:58 -04:00
private getPortFromEnv ( ) : string | undefined {
const port = process . env [ 'GEMINI_CLI_IDE_SERVER_PORT' ] ;
if ( ! port ) {
this . setState (
IDEConnectionStatus . Disconnected ,
` Failed to connect to IDE companion extension for ${ this . currentIdeDisplayName } . Please ensure the extension is running and try refreshing your terminal. To install the extension, run /ide install. ` ,
) ;
return undefined ;
}
return port ;
}
2025-07-28 16:55:00 -04:00
private registerClientHandlers() {
if ( ! this . client ) {
2025-07-25 17:46:55 +00:00
return ;
}
2025-07-28 16:55:00 -04:00
this . client . setNotificationHandler (
IdeContextNotificationSchema ,
( notification ) = > {
ideContext . setIdeContext ( notification . params ) ;
} ,
) ;
this . client . onerror = ( _error ) = > {
2025-08-05 18:52:58 -04:00
this . setState (
IDEConnectionStatus . Disconnected ,
` IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable ` ,
) ;
2025-07-28 16:55:00 -04:00
} ;
this . client . onclose = ( ) = > {
2025-08-05 18:52:58 -04:00
this . setState (
IDEConnectionStatus . Disconnected ,
` IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable ` ,
) ;
2025-07-28 16:55:00 -04:00
} ;
2025-08-06 17:36:05 +00:00
this . client . setNotificationHandler (
IdeDiffAcceptedNotificationSchema ,
( notification ) = > {
const { filePath , content } = notification . params ;
const resolver = this . diffResponses . get ( filePath ) ;
if ( resolver ) {
resolver ( { status : 'accepted' , content } ) ;
this . diffResponses . delete ( filePath ) ;
} else {
logger . debug ( ` No resolver found for ${ filePath } ` ) ;
}
} ,
) ;
this . client . setNotificationHandler (
IdeDiffClosedNotificationSchema ,
( notification ) = > {
const { filePath } = notification . params ;
const resolver = this . diffResponses . get ( filePath ) ;
if ( resolver ) {
resolver ( { status : 'rejected' , content : undefined } ) ;
this . diffResponses . delete ( filePath ) ;
} else {
logger . debug ( ` No resolver found for ${ filePath } ` ) ;
}
} ,
) ;
2025-07-28 16:55:00 -04:00
}
private async establishConnection ( port : string ) {
2025-07-25 21:57:34 -04:00
let transport : StreamableHTTPClientTransport | undefined ;
2025-07-25 17:46:55 +00:00
try {
this . client = new Client ( {
name : 'streamable-http-client' ,
// TODO(#3487): use the CLI version here.
version : '1.0.0' ,
} ) ;
2025-07-25 21:57:34 -04:00
transport = new StreamableHTTPClientTransport (
2025-07-28 16:55:00 -04:00
new URL ( ` http://localhost: ${ port } /mcp ` ) ,
2025-07-25 17:46:55 +00:00
) ;
await this . client . connect ( transport ) ;
2025-08-05 18:52:58 -04:00
this . registerClientHandlers ( ) ;
2025-07-28 16:55:00 -04:00
this . setState ( IDEConnectionStatus . Connected ) ;
2025-08-05 18:52:58 -04:00
} catch ( _error ) {
2025-07-28 16:55:00 -04:00
this . setState (
IDEConnectionStatus . Disconnected ,
2025-08-05 18:52:58 -04:00
` Failed to connect to IDE companion extension for ${ this . currentIdeDisplayName } . Please ensure the extension is running and try refreshing your terminal. To install the extension, run /ide install. ` ,
2025-07-28 16:55:00 -04:00
) ;
2025-07-25 21:57:34 -04:00
if ( transport ) {
try {
await transport . close ( ) ;
} catch ( closeError ) {
logger . debug ( 'Failed to close transport:' , closeError ) ;
}
}
2025-07-25 17:46:55 +00:00
}
}
}