chore: sync gemini-cli v0.1.19

This commit is contained in:
tanzhenxin
2025-08-18 19:55:46 +08:00
244 changed files with 19407 additions and 5030 deletions

View File

@@ -6,12 +6,33 @@
export enum DetectedIde {
VSCode = 'vscode',
VSCodium = 'vscodium',
Cursor = 'cursor',
CloudShell = 'cloudshell',
Codespaces = 'codespaces',
Windsurf = 'windsurf',
FirebaseStudio = 'firebasestudio',
Trae = 'trae',
}
export function getIdeDisplayName(ide: DetectedIde): string {
switch (ide) {
case DetectedIde.VSCode:
return 'VS Code';
case DetectedIde.VSCodium:
return 'VSCodium';
case DetectedIde.Cursor:
return 'Cursor';
case DetectedIde.CloudShell:
return 'Cloud Shell';
case DetectedIde.Codespaces:
return 'GitHub Codespaces';
case DetectedIde.Windsurf:
return 'Windsurf';
case DetectedIde.FirebaseStudio:
return 'Firebase Studio';
case DetectedIde.Trae:
return 'Trae';
default: {
// This ensures that if a new IDE is added to the enum, we get a compile-time error.
const exhaustiveCheck: never = ide;
@@ -21,8 +42,24 @@ export function getIdeDisplayName(ide: DetectedIde): string {
}
export function detectIde(): DetectedIde | undefined {
if (process.env.TERM_PROGRAM === 'vscode') {
return DetectedIde.VSCode;
// Only VSCode-based integrations are currently supported.
if (process.env.TERM_PROGRAM !== 'vscode') {
return undefined;
}
return undefined;
if (process.env.CURSOR_TRACE_ID) {
return DetectedIde.Cursor;
}
if (process.env.CODESPACES) {
return DetectedIde.Codespaces;
}
if (process.env.EDITOR_IN_CLOUD_SHELL) {
return DetectedIde.CloudShell;
}
if (process.env.TERM_PRODUCT === 'Trae') {
return DetectedIde.Trae;
}
if (process.env.FIREBASE_DEPLOY_AGENT) {
return DetectedIde.FirebaseStudio;
}
return DetectedIde.VSCode;
}

View File

@@ -4,18 +4,29 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import {
detectIde,
DetectedIde,
getIdeDisplayName,
} from '../ide/detect-ide.js';
import { ideContext, IdeContextNotificationSchema } from '../ide/ideContext.js';
import {
ideContext,
IdeContextNotificationSchema,
IdeDiffAcceptedNotificationSchema,
IdeDiffClosedNotificationSchema,
CloseDiffResponseSchema,
DiffUpdateResult,
} from '../ide/ideContext.js';
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
debug: (...args: any[]) => console.debug('[DEBUG] [IDEClient]', ...args),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: (...args: any[]) => console.error('[ERROR] [IDEClient]', ...args),
};
export type IDEConnectionState = {
@@ -29,6 +40,16 @@ export enum IDEConnectionStatus {
Connecting = 'connecting',
}
function getRealPath(path: string): string {
try {
return fs.realpathSync(path);
} catch (_e) {
// If realpathSync fails, it might be because the path doesn't exist.
// In that case, we can fall back to the original path.
return path;
}
}
/**
* Manages the connection to and interaction with the IDE server.
*/
@@ -42,6 +63,7 @@ export class IdeClient {
};
private readonly currentIde: DetectedIde | undefined;
private readonly currentIdeDisplayName: string | undefined;
private diffResponses = new Map<string, (result: DiffUpdateResult) => void>();
private constructor() {
this.currentIde = detectIde();
@@ -58,13 +80,21 @@ export class IdeClient {
}
async connect(): Promise<void> {
this.setState(IDEConnectionStatus.Connecting);
if (!this.currentIde || !this.currentIdeDisplayName) {
this.setState(IDEConnectionStatus.Disconnected);
this.setState(
IDEConnectionStatus.Disconnected,
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: ${Object.values(
DetectedIde,
)
.map((ide) => getIdeDisplayName(ide))
.join(', ')}`,
false,
);
return;
}
this.setState(IDEConnectionStatus.Connecting);
if (!this.validateWorkspacePath()) {
return;
}
@@ -77,7 +107,83 @@ export class IdeClient {
await this.establishConnection(port);
}
disconnect() {
/**
* 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);
}
}
async disconnect() {
if (this.state.status === IDEConnectionStatus.Disconnected) {
return;
}
for (const filePath of this.diffResponses.keys()) {
await this.closeDiff(filePath);
}
this.diffResponses.clear();
this.setState(
IDEConnectionStatus.Disconnected,
'IDE integration disabled. To enable it again, run /ide enable.',
@@ -93,19 +199,35 @@ export class IdeClient {
return this.state;
}
private setState(status: IDEConnectionStatus, details?: string) {
getDetectedIdeDisplayName(): string | undefined {
return this.currentIdeDisplayName;
}
private setState(
status: IDEConnectionStatus,
details?: string,
logToConsole = false,
) {
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.
// Only update details & log to console if the state wasn't already
// disconnected, so that the first detail message is preserved.
if (!isAlreadyDisconnected) {
this.state = { status, details };
if (details) {
if (logToConsole) {
logger.error(details);
} else {
// We only want to log disconnect messages to debug
// if they are not already being logged to the console.
logger.debug(details);
}
}
}
if (status === IDEConnectionStatus.Disconnected) {
logger.debug('IDE integration disconnected:', details);
ideContext.clearIdeContext();
}
}
@@ -116,6 +238,7 @@ export class IdeClient {
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.`,
true,
);
return false;
}
@@ -123,13 +246,19 @@ export class IdeClient {
this.setState(
IDEConnectionStatus.Disconnected,
`To use this feature, please open a single workspace folder in ${this.currentIdeDisplayName} and try again.`,
true,
);
return false;
}
if (ideWorkspacePath !== process.cwd()) {
const idePath = getRealPath(ideWorkspacePath).toLocaleLowerCase();
const cwd = getRealPath(process.cwd()).toLocaleLowerCase();
const rel = path.relative(idePath, cwd);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
this.setState(
IDEConnectionStatus.Disconnected,
`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.`,
true,
);
return false;
}
@@ -141,7 +270,8 @@ export class IdeClient {
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.`,
`Failed to connect to IDE companion extension for ${this.currentIdeDisplayName}. Please ensure the extension is running and try restarting your terminal. To install the extension, run /ide install.`,
true,
);
return undefined;
}
@@ -163,14 +293,43 @@ export class IdeClient {
this.setState(
IDEConnectionStatus.Disconnected,
`IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable`,
true,
);
};
this.client.onclose = () => {
this.setState(
IDEConnectionStatus.Disconnected,
`IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable`,
true,
);
};
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}`);
}
},
);
}
private async establishConnection(port: string) {
@@ -183,7 +342,7 @@ export class IdeClient {
});
transport = new StreamableHTTPClientTransport(
new URL(`http://localhost:${port}/mcp`),
new URL(`http://${getIdeServerHost()}:${port}/mcp`),
);
this.registerClientHandlers();
@@ -194,7 +353,8 @@ export class IdeClient {
} catch (_error) {
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.`,
`Failed to connect to IDE companion extension for ${this.currentIdeDisplayName}. Please ensure the extension is running and try restarting your terminal. To install the extension, run /ide install.`,
true,
);
if (transport) {
try {
@@ -236,11 +396,13 @@ export class IdeClient {
this.client?.close();
}
getDetectedIdeDisplayName(): string | undefined {
return this.currentIdeDisplayName;
}
setDisconnected() {
this.setState(IDEConnectionStatus.Disconnected);
}
}
function getIdeServerHost() {
const isInContainer =
fs.existsSync('/.dockerenv') || fs.existsSync('/run/.containerenv');
return isInContainer ? 'host.docker.internal' : 'localhost';
}

View File

@@ -24,9 +24,17 @@ describe('ide-installer', () => {
expect(installer).toBeInstanceOf(Object);
});
it('should return null for an unknown IDE', () => {
it('should return an OpenVSXInstaller for "vscodium"', () => {
const installer = getIdeInstaller(DetectedIde.VSCodium);
expect(installer).not.toBeNull();
expect(installer).toBeInstanceOf(Object);
});
it('should return a DefaultIDEInstaller for an unknown IDE', () => {
const installer = getIdeInstaller('unknown' as DetectedIde);
expect(installer).toBeNull();
// Assuming DefaultIDEInstaller is the fallback
expect(installer).not.toBeNull();
expect(installer).toBeInstanceOf(Object);
});
});
@@ -59,4 +67,44 @@ describe('ide-installer', () => {
});
});
});
describe('OpenVSXInstaller', () => {
let installer: IdeInstaller;
beforeEach(() => {
installer = getIdeInstaller(DetectedIde.VSCodium)!;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('install', () => {
it('should call execSync with the correct command and return success', async () => {
const execSyncSpy = vi
.spyOn(child_process, 'execSync')
.mockImplementation(() => '');
const result = await installer.install();
expect(execSyncSpy).toHaveBeenCalledWith(
'npx ovsx get google.gemini-cli-vscode-ide-companion',
{ stdio: 'pipe' },
);
expect(result.success).toBe(true);
expect(result.message).toContain(
'VS Code companion extension was installed successfully from OpenVSX',
);
});
it('should return a failure message on failed installation', async () => {
vi.spyOn(child_process, 'execSync').mockImplementation(() => {
throw new Error('Command failed');
});
const result = await installer.install();
expect(result.success).toBe(false);
expect(result.message).toContain(
'Failed to install VS Code companion extension from OpenVSX',
);
});
});
});
});

View File

@@ -147,11 +147,31 @@ class VsCodeInstaller implements IdeInstaller {
}
}
class OpenVSXInstaller implements IdeInstaller {
async install(): Promise<InstallResult> {
// TODO: Use the correct extension path.
const command = `npx ovsx get google.gemini-cli-vscode-ide-companion`;
try {
child_process.execSync(command, { stdio: 'pipe' });
return {
success: true,
message:
'VS Code companion extension was installed successfully from OpenVSX. Please restart your terminal to complete the setup.',
};
} catch (_error) {
return {
success: false,
message: `Failed to install VS Code companion extension from OpenVSX. Please try installing it manually.`,
};
}
}
}
export function getIdeInstaller(ide: DetectedIde): IdeInstaller | null {
switch (ide) {
case DetectedIde.VSCode:
return new VsCodeInstaller();
default:
return null;
return new OpenVSXInstaller();
}
}

View File

@@ -36,10 +36,69 @@ export type IdeContext = z.infer<typeof IdeContextSchema>;
* Zod schema for validating the 'ide/contextUpdate' notification from the IDE.
*/
export const IdeContextNotificationSchema = z.object({
jsonrpc: z.literal('2.0'),
method: z.literal('ide/contextUpdate'),
params: IdeContextSchema,
});
export const IdeDiffAcceptedNotificationSchema = z.object({
jsonrpc: z.literal('2.0'),
method: z.literal('ide/diffAccepted'),
params: z.object({
filePath: z.string(),
content: z.string(),
}),
});
export const IdeDiffClosedNotificationSchema = z.object({
jsonrpc: z.literal('2.0'),
method: z.literal('ide/diffClosed'),
params: z.object({
filePath: z.string(),
content: z.string().optional(),
}),
});
export const CloseDiffResponseSchema = z
.object({
content: z
.array(
z.object({
text: z.string(),
type: z.literal('text'),
}),
)
.min(1),
})
.transform((val, ctx) => {
try {
const parsed = JSON.parse(val.content[0].text);
const innerSchema = z.object({ content: z.string().optional() });
const validationResult = innerSchema.safeParse(parsed);
if (!validationResult.success) {
validationResult.error.issues.forEach((issue) => ctx.addIssue(issue));
return z.NEVER;
}
return validationResult.data;
} catch (_) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid JSON in text content',
});
return z.NEVER;
}
});
export type DiffUpdateResult =
| {
status: 'accepted';
content?: string;
}
| {
status: 'rejected';
content: undefined;
};
type IdeContextSubscriber = (ideContext: IdeContext | undefined) => void;
/**