mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 16:57:46 +00:00
chore: sync gemini-cli v0.1.19
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user