Sync upstream Gemini-CLI v0.8.2 (#838)

This commit is contained in:
tanzhenxin
2025-10-23 09:27:04 +08:00
committed by GitHub
parent 096fabb5d6
commit eb95c131be
644 changed files with 70389 additions and 23709 deletions

View File

@@ -5,3 +5,6 @@
*/
export const QWEN_CODE_COMPANION_EXTENSION_NAME = 'Qwen Code Companion';
export const IDE_MAX_OPEN_FILES = 10;
export const IDE_MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit
export const IDE_REQUEST_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes

View File

@@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { detectIde, DetectedIde, getIdeInfo } from './detect-ide.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { detectIde, IDE_DEFINITIONS } from './detect-ide.js';
describe('detectIde', () => {
const ideProcessInfo = { pid: 123, command: 'some/path/to/code' };
@@ -35,110 +35,103 @@ describe('detectIde', () => {
it('should detect Devin', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('__COG_BASHRC_SOURCED', '1');
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Devin);
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.devin);
});
it('should detect Replit', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('REPLIT_USER', 'testuser');
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Replit);
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.replit);
});
it('should detect Cursor', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('CURSOR_TRACE_ID', 'some-id');
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Cursor);
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cursor);
});
it('should detect Codespaces', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('CODESPACES', 'true');
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Codespaces);
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.codespaces);
});
it('should detect Cloud Shell via EDITOR_IN_CLOUD_SHELL', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('EDITOR_IN_CLOUD_SHELL', 'true');
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.CloudShell);
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cloudshell);
});
it('should detect Cloud Shell via CLOUD_SHELL', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('CLOUD_SHELL', 'true');
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.CloudShell);
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.cloudshell);
});
it('should detect Trae', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('TERM_PRODUCT', 'Trae');
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Trae);
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.trae);
});
it('should detect Firebase Studio via MONOSPACE_ENV', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('MONOSPACE_ENV', 'true');
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.FirebaseStudio);
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.firebasestudio);
});
it('should detect VSCode when no other IDE is detected and command includes "code"', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('MONOSPACE_ENV', '');
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.VSCode);
expect(detectIde(ideProcessInfo)).toBe(IDE_DEFINITIONS.vscode);
});
it('should detect VSCodeFork when no other IDE is detected and command does not include "code"', () => {
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('MONOSPACE_ENV', '');
expect(detectIde(ideProcessInfoNoCode)).toBe(DetectedIde.VSCodeFork);
expect(detectIde(ideProcessInfoNoCode)).toBe(IDE_DEFINITIONS.vscodefork);
});
});
describe('detectIde with ideInfoFromFile', () => {
const ideProcessInfo = { pid: 123, command: 'some/path/to/code' };
beforeEach(() => {
vi.stubEnv('__COG_BASHRC_SOURCED', '');
vi.stubEnv('REPLIT_USER', '');
vi.stubEnv('CURSOR_TRACE_ID', '');
vi.stubEnv('CODESPACES', '');
vi.stubEnv('EDITOR_IN_CLOUD_SHELL', '');
vi.stubEnv('CLOUD_SHELL', '');
vi.stubEnv('TERM_PRODUCT', '');
vi.stubEnv('MONOSPACE_ENV', '');
});
it('should prioritize other IDEs over VSCode detection', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('should use the name and displayName from the file', () => {
const ideInfoFromFile = {
name: 'custom-ide',
displayName: 'Custom IDE',
};
expect(detectIde(ideProcessInfo, ideInfoFromFile)).toEqual(ideInfoFromFile);
});
it('should fall back to env detection if name is missing', () => {
const ideInfoFromFile = { displayName: 'Custom IDE' };
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('REPLIT_USER', 'testuser');
expect(detectIde(ideProcessInfo)).toBe(DetectedIde.Replit);
});
});
describe('getIdeInfo', () => {
it('should return correct info for Devin', () => {
expect(getIdeInfo(DetectedIde.Devin)).toEqual({ displayName: 'Devin' });
});
it('should return correct info for Replit', () => {
expect(getIdeInfo(DetectedIde.Replit)).toEqual({ displayName: 'Replit' });
});
it('should return correct info for Cursor', () => {
expect(getIdeInfo(DetectedIde.Cursor)).toEqual({ displayName: 'Cursor' });
});
it('should return correct info for CloudShell', () => {
expect(getIdeInfo(DetectedIde.CloudShell)).toEqual({
displayName: 'Cloud Shell',
});
});
it('should return correct info for Codespaces', () => {
expect(getIdeInfo(DetectedIde.Codespaces)).toEqual({
displayName: 'GitHub Codespaces',
});
});
it('should return correct info for FirebaseStudio', () => {
expect(getIdeInfo(DetectedIde.FirebaseStudio)).toEqual({
displayName: 'Firebase Studio',
});
});
it('should return correct info for Trae', () => {
expect(getIdeInfo(DetectedIde.Trae)).toEqual({ displayName: 'Trae' });
});
it('should return correct info for VSCode', () => {
expect(getIdeInfo(DetectedIde.VSCode)).toEqual({ displayName: 'VS Code' });
});
it('should return correct info for VSCodeFork', () => {
expect(getIdeInfo(DetectedIde.VSCodeFork)).toEqual({ displayName: 'IDE' });
expect(detectIde(ideProcessInfo, ideInfoFromFile)).toBe(
IDE_DEFINITIONS.vscode,
);
});
it('should fall back to env detection if displayName is missing', () => {
const ideInfoFromFile = { name: 'custom-ide' };
vi.stubEnv('TERM_PROGRAM', 'vscode');
expect(detectIde(ideProcessInfo, ideInfoFromFile)).toBe(
IDE_DEFINITIONS.vscode,
);
});
});

View File

@@ -4,113 +4,82 @@
* SPDX-License-Identifier: Apache-2.0
*/
export enum DetectedIde {
Devin = 'devin',
Replit = 'replit',
Cursor = 'cursor',
CloudShell = 'cloudshell',
Codespaces = 'codespaces',
FirebaseStudio = 'firebasestudio',
Trae = 'trae',
VSCode = 'vscode',
VSCodeFork = 'vscodefork',
}
export const IDE_DEFINITIONS = {
devin: { name: 'devin', displayName: 'Devin' },
replit: { name: 'replit', displayName: 'Replit' },
cursor: { name: 'cursor', displayName: 'Cursor' },
cloudshell: { name: 'cloudshell', displayName: 'Cloud Shell' },
codespaces: { name: 'codespaces', displayName: 'GitHub Codespaces' },
firebasestudio: { name: 'firebasestudio', displayName: 'Firebase Studio' },
trae: { name: 'trae', displayName: 'Trae' },
vscode: { name: 'vscode', displayName: 'VS Code' },
vscodefork: { name: 'vscodefork', displayName: 'IDE' },
} as const;
export interface IdeInfo {
name: string;
displayName: string;
}
export function getIdeInfo(ide: DetectedIde): IdeInfo {
switch (ide) {
case DetectedIde.Devin:
return {
displayName: 'Devin',
};
case DetectedIde.Replit:
return {
displayName: 'Replit',
};
case DetectedIde.Cursor:
return {
displayName: 'Cursor',
};
case DetectedIde.CloudShell:
return {
displayName: 'Cloud Shell',
};
case DetectedIde.Codespaces:
return {
displayName: 'GitHub Codespaces',
};
case DetectedIde.FirebaseStudio:
return {
displayName: 'Firebase Studio',
};
case DetectedIde.Trae:
return {
displayName: 'Trae',
};
case DetectedIde.VSCode:
return {
displayName: 'VS Code',
};
case DetectedIde.VSCodeFork:
return {
displayName: 'IDE',
};
default: {
// This ensures that if a new IDE is added to the enum, we get a compile-time error.
const exhaustiveCheck: never = ide;
return exhaustiveCheck;
}
}
export function isCloudShell(): boolean {
return !!(process.env['EDITOR_IN_CLOUD_SHELL'] || process.env['CLOUD_SHELL']);
}
export function detectIdeFromEnv(): DetectedIde {
export function detectIdeFromEnv(): IdeInfo {
if (process.env['__COG_BASHRC_SOURCED']) {
return DetectedIde.Devin;
return IDE_DEFINITIONS.devin;
}
if (process.env['REPLIT_USER']) {
return DetectedIde.Replit;
return IDE_DEFINITIONS.replit;
}
if (process.env['CURSOR_TRACE_ID']) {
return DetectedIde.Cursor;
return IDE_DEFINITIONS.cursor;
}
if (process.env['CODESPACES']) {
return DetectedIde.Codespaces;
return IDE_DEFINITIONS.codespaces;
}
if (process.env['EDITOR_IN_CLOUD_SHELL'] || process.env['CLOUD_SHELL']) {
return DetectedIde.CloudShell;
if (isCloudShell()) {
return IDE_DEFINITIONS.cloudshell;
}
if (process.env['TERM_PRODUCT'] === 'Trae') {
return DetectedIde.Trae;
return IDE_DEFINITIONS.trae;
}
if (process.env['MONOSPACE_ENV']) {
return DetectedIde.FirebaseStudio;
return IDE_DEFINITIONS.firebasestudio;
}
return DetectedIde.VSCode;
return IDE_DEFINITIONS.vscode;
}
function verifyVSCode(
ide: DetectedIde,
ide: IdeInfo,
ideProcessInfo: {
pid: number;
command: string;
},
): DetectedIde {
if (ide !== DetectedIde.VSCode) {
): IdeInfo {
if (ide.name !== IDE_DEFINITIONS.vscode.name) {
return ide;
}
if (ideProcessInfo.command.toLowerCase().includes('code')) {
return DetectedIde.VSCode;
return IDE_DEFINITIONS.vscode;
}
return DetectedIde.VSCodeFork;
return IDE_DEFINITIONS.vscodefork;
}
export function detectIde(ideProcessInfo: {
pid: number;
command: string;
}): DetectedIde | undefined {
export function detectIde(
ideProcessInfo: {
pid: number;
command: string;
},
ideInfoFromFile?: { name?: string; displayName?: string },
): IdeInfo | undefined {
if (ideInfoFromFile?.name && ideInfoFromFile.displayName) {
return {
name: ideInfoFromFile.name,
displayName: ideInfoFromFile.displayName,
};
}
// Only VSCode-based integrations are currently supported.
if (process.env['TERM_PROGRAM'] !== 'vscode') {
return undefined;

View File

@@ -12,6 +12,7 @@ import {
beforeEach,
afterEach,
type Mocked,
type Mock,
} from 'vitest';
import { IdeClient, IDEConnectionStatus } from './ide-client.js';
import * as fs from 'node:fs';
@@ -19,21 +20,18 @@ import { getIdeProcessInfo } from './process-utils.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import {
detectIde,
DetectedIde,
getIdeInfo,
type IdeInfo,
} from './detect-ide.js';
import { detectIde, IDE_DEFINITIONS } from './detect-ide.js';
import * as os from 'node:os';
import * as path from 'node:path';
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal();
const actual = await importOriginal<typeof fs>();
return {
...(actual as object),
promises: {
...actual.promises,
readFile: vi.fn(),
readdir: vi.fn(),
},
realpathSync: (p: string) => p,
existsSync: () => false,
@@ -64,10 +62,7 @@ describe('IdeClient', () => {
// Mock dependencies
vi.spyOn(process, 'cwd').mockReturnValue('/test/workspace/sub-dir');
vi.mocked(detectIde).mockReturnValue(DetectedIde.VSCode);
vi.mocked(getIdeInfo).mockReturnValue({
displayName: 'VS Code',
} as IdeInfo);
vi.mocked(detectIde).mockReturnValue(IDE_DEFINITIONS.vscode);
vi.mocked(getIdeProcessInfo).mockResolvedValue({
pid: 12345,
command: 'test-ide',
@@ -80,6 +75,7 @@ describe('IdeClient', () => {
close: vi.fn(),
setNotificationHandler: vi.fn(),
callTool: vi.fn(),
request: vi.fn(),
} as unknown as Mocked<Client>;
mockHttpTransport = {
close: vi.fn(),
@@ -100,29 +96,14 @@ describe('IdeClient', () => {
});
describe('connect', () => {
it('should return invalid if QWEN_CODE_IDE_WORKSPACE_PATH is undefined', () => {
const result = IdeClient.validateWorkspacePath(
undefined,
'VS Code',
'/Users/person/gemini-cli/sub-dir',
);
expect(result.isValid).toBe(false);
expect(result.error).toContain('Failed to connect');
});
it('should return invalid if QWEN_CODE_IDE_WORKSPACE_PATH is empty', () => {
const result = IdeClient.validateWorkspacePath(
'',
'VS Code',
'/Users/person/gemini-cli/sub-dir',
);
expect(result.isValid).toBe(false);
expect(result.error).toContain('please open a workspace folder');
});
it('should connect using HTTP when port is provided in config file', async () => {
const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
@@ -144,6 +125,11 @@ describe('IdeClient', () => {
it('should connect using stdio when stdio config is provided in file', async () => {
const config = { stdio: { command: 'test-cmd', args: ['--foo'] } };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
@@ -164,6 +150,11 @@ describe('IdeClient', () => {
stdio: { command: 'test-cmd', args: ['--foo'] },
};
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
@@ -179,6 +170,11 @@ describe('IdeClient', () => {
vi.mocked(fs.promises.readFile).mockRejectedValue(
new Error('File not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '9090';
const ideClient = await IdeClient.getInstance();
@@ -198,6 +194,12 @@ describe('IdeClient', () => {
vi.mocked(fs.promises.readFile).mockRejectedValue(
new Error('File not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
process.env['QWEN_CODE_IDE_SERVER_STDIO_COMMAND'] = 'env-cmd';
process.env['QWEN_CODE_IDE_SERVER_STDIO_ARGS'] = '["--bar"]';
@@ -217,6 +219,11 @@ describe('IdeClient', () => {
it('should prioritize file config over environment variables', async () => {
const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '9090';
const ideClient = await IdeClient.getInstance();
@@ -235,6 +242,11 @@ describe('IdeClient', () => {
vi.mocked(fs.promises.readFile).mockRejectedValue(
new Error('File not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
@@ -249,4 +261,422 @@ describe('IdeClient', () => {
);
});
});
describe('getConnectionConfigFromFile', () => {
it('should return config from the specific pid file if it exists', async () => {
const config = { port: '1234', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
const ideClient = await IdeClient.getInstance();
// In tests, the private method can be accessed like this.
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp', 'qwen-code-ide-server-12345.json'),
'utf8',
);
});
it('should return undefined if no config files are found', async () => {
vi.mocked(fs.promises.readFile).mockRejectedValue(new Error('not found'));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toBeUndefined();
});
it('should find and parse a single config file with the new naming scheme', async () => {
const config = { port: '5678', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
); // For old path
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue(['qwen-code-ide-server-12345-123.json']);
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(config);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-123.json'),
'utf8',
);
});
it('should filter out configs with invalid workspace paths', async () => {
const validConfig = {
port: '5678',
workspacePath: '/test/workspace',
};
const invalidConfig = {
port: '1111',
workspacePath: '/invalid/workspace',
};
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(invalidConfig))
.mockResolvedValueOnce(JSON.stringify(validConfig));
const validateSpy = vi
.spyOn(IdeClient, 'validateWorkspacePath')
.mockReturnValueOnce({ isValid: false })
.mockReturnValueOnce({ isValid: true });
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(validConfig);
expect(validateSpy).toHaveBeenCalledWith(
'/invalid/workspace',
'/test/workspace/sub-dir',
);
expect(validateSpy).toHaveBeenCalledWith(
'/test/workspace',
'/test/workspace/sub-dir',
);
});
it('should return the first valid config when multiple workspaces are valid', async () => {
const config1 = { port: '1111', workspacePath: '/test/workspace' };
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(config1);
});
it('should prioritize the config matching the port from the environment variable', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '2222';
const config1 = { port: '1111', workspacePath: '/test/workspace' };
const config2 = { port: '2222', workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(config2);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
it('should handle invalid JSON in one of the config files', async () => {
const validConfig = { port: '2222', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce('invalid json')
.mockResolvedValueOnce(JSON.stringify(validConfig));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(validConfig);
});
it('should return undefined if readdir throws an error', async () => {
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
vi.mocked(fs.promises.readdir).mockRejectedValue(
new Error('readdir failed'),
);
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toBeUndefined();
});
it('should ignore files with invalid names', async () => {
const validConfig = { port: '3333', workspacePath: '/test/workspace' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json', // valid
'not-a-config-file.txt', // invalid
'qwen-code-ide-server-asdf.json', // invalid
]);
vi.mocked(fs.promises.readFile).mockResolvedValueOnce(
JSON.stringify(validConfig),
);
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(validConfig);
expect(fs.promises.readFile).toHaveBeenCalledWith(
path.join('/tmp/gemini/ide', 'qwen-code-ide-server-12345-111.json'),
'utf8',
);
expect(fs.promises.readFile).not.toHaveBeenCalledWith(
path.join('/tmp/gemini/ide', 'not-a-config-file.txt'),
'utf8',
);
});
it('should match env port string to a number port in the config', async () => {
process.env['QWEN_CODE_IDE_SERVER_PORT'] = '3333';
const config1 = { port: 1111, workspacePath: '/test/workspace' };
const config2 = { port: 3333, workspacePath: '/test/workspace2' };
vi.mocked(fs.promises.readFile).mockRejectedValueOnce(
new Error('not found'),
);
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([
'qwen-code-ide-server-12345-111.json',
'qwen-code-ide-server-12345-222.json',
]);
vi.mocked(fs.promises.readFile)
.mockResolvedValueOnce(JSON.stringify(config1))
.mockResolvedValueOnce(JSON.stringify(config2));
vi.spyOn(IdeClient, 'validateWorkspacePath').mockReturnValue({
isValid: true,
});
const ideClient = await IdeClient.getInstance();
const result = await (
ideClient as unknown as {
getConnectionConfigFromFile: () => Promise<unknown>;
}
).getConnectionConfigFromFile();
expect(result).toEqual(config2);
delete process.env['QWEN_CODE_IDE_SERVER_PORT'];
});
});
describe('isDiffingEnabled', () => {
it('should return false if not connected', async () => {
const ideClient = await IdeClient.getInstance();
expect(ideClient.isDiffingEnabled()).toBe(false);
});
it('should return false if tool discovery fails', async () => {
const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
mockClient.request.mockRejectedValue(new Error('Method not found'));
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
expect(ideClient.isDiffingEnabled()).toBe(false);
});
it('should return false if diffing tools are not available', async () => {
const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
mockClient.request.mockResolvedValue({
tools: [{ name: 'someOtherTool' }],
});
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
expect(ideClient.isDiffingEnabled()).toBe(false);
});
it('should return false if only openDiff tool is available', async () => {
const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
mockClient.request.mockResolvedValue({
tools: [{ name: 'openDiff' }],
});
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
expect(ideClient.isDiffingEnabled()).toBe(false);
});
it('should return true if connected and diffing tools are available', async () => {
const config = { port: '8080' };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
mockClient.request.mockResolvedValue({
tools: [{ name: 'openDiff' }, { name: 'closeDiff' }],
});
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
expect(ideClient.isDiffingEnabled()).toBe(true);
});
});
describe('authentication', () => {
it('should connect with an auth token if provided in the discovery file', async () => {
const authToken = 'test-auth-token';
const config = { port: '8080', authToken };
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(config));
(
vi.mocked(fs.promises.readdir) as Mock<
(path: fs.PathLike) => Promise<string[]>
>
).mockResolvedValue([]);
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
expect(StreamableHTTPClientTransport).toHaveBeenCalledWith(
new URL('http://localhost:8080/mcp'),
expect.objectContaining({
requestInit: {
headers: {
Authorization: `Bearer ${authToken}`,
},
},
}),
);
expect(ideClient.getConnectionStatus().status).toBe(
IDEConnectionStatus.Connected,
);
});
});
});

View File

@@ -6,22 +6,24 @@
import * as fs from 'node:fs';
import { isSubpath } from '../utils/paths.js';
import { detectIde, type DetectedIde, getIdeInfo } from '../ide/detect-ide.js';
import type { DiffUpdateResult } from '../ide/ideContext.js';
import { detectIde, type IdeInfo } from '../ide/detect-ide.js';
import { ideContextStore } from './ideContext.js';
import {
ideContext,
IdeContextNotificationSchema,
IdeDiffAcceptedNotificationSchema,
IdeDiffClosedNotificationSchema,
CloseDiffResponseSchema,
} from '../ide/ideContext.js';
IdeDiffRejectedNotificationSchema,
} from './types.js';
import { getIdeProcessInfo } from './process-utils.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
import * as os from 'node:os';
import * as path from 'node:path';
import { EnvHttpProxyAgent } from 'undici';
import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js';
import { IDE_REQUEST_TIMEOUT_MS } from './constants.js';
const logger = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -30,6 +32,16 @@ const logger = {
error: (...args: any[]) => console.error('[ERROR] [IDEClient]', ...args),
};
export type DiffUpdateResult =
| {
status: 'accepted';
content?: string;
}
| {
status: 'rejected';
content: undefined;
};
export type IDEConnectionState = {
status: IDEConnectionStatus;
details?: string; // User-facing
@@ -48,6 +60,7 @@ type StdioConfig = {
type ConnectionConfig = {
port?: string;
authToken?: string;
stdio?: StdioConfig;
};
@@ -65,34 +78,46 @@ function getRealPath(path: string): string {
* Manages the connection to and interaction with the IDE server.
*/
export class IdeClient {
private static instance: IdeClient;
private static instancePromise: Promise<IdeClient> | null = null;
private client: Client | undefined = undefined;
private state: IDEConnectionState = {
status: IDEConnectionStatus.Disconnected,
details:
'IDE integration is currently disabled. To enable it, run /ide enable.',
};
private currentIde: DetectedIde | undefined;
private currentIdeDisplayName: string | undefined;
private currentIde: IdeInfo | undefined;
private ideProcessInfo: { pid: number; command: string } | undefined;
private connectionConfig:
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
| undefined;
private authToken: string | undefined;
private diffResponses = new Map<string, (result: DiffUpdateResult) => void>();
private statusListeners = new Set<(state: IDEConnectionState) => void>();
private trustChangeListeners = new Set<(isTrusted: boolean) => void>();
private availableTools: string[] = [];
/**
* A mutex to ensure that only one diff view is open in the IDE at a time.
* This prevents race conditions and UI issues in IDEs like VSCode that
* can't handle multiple diff views being opened simultaneously.
*/
private diffMutex = Promise.resolve();
private constructor() {}
static async getInstance(): Promise<IdeClient> {
if (!IdeClient.instance) {
const client = new IdeClient();
client.ideProcessInfo = await getIdeProcessInfo();
client.currentIde = detectIde(client.ideProcessInfo);
if (client.currentIde) {
client.currentIdeDisplayName = getIdeInfo(
client.currentIde,
).displayName;
}
IdeClient.instance = client;
static getInstance(): Promise<IdeClient> {
if (!IdeClient.instancePromise) {
IdeClient.instancePromise = (async () => {
const client = new IdeClient();
client.ideProcessInfo = await getIdeProcessInfo();
client.connectionConfig = await client.getConnectionConfigFromFile();
client.currentIde = detectIde(
client.ideProcessInfo,
client.connectionConfig?.ideInfo,
);
return client;
})();
}
return IdeClient.instance;
return IdeClient.instancePromise;
}
addStatusChangeListener(listener: (state: IDEConnectionState) => void) {
@@ -103,8 +128,16 @@ export class IdeClient {
this.statusListeners.delete(listener);
}
addTrustChangeListener(listener: (isTrusted: boolean) => void) {
this.trustChangeListeners.add(listener);
}
removeTrustChangeListener(listener: (isTrusted: boolean) => void) {
this.trustChangeListeners.delete(listener);
}
async connect(): Promise<void> {
if (!this.currentIde || !this.currentIdeDisplayName) {
if (!this.currentIde) {
this.setState(
IDEConnectionStatus.Disconnected,
`IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks`,
@@ -115,14 +148,16 @@ export class IdeClient {
this.setState(IDEConnectionStatus.Connecting);
const configFromFile = await this.getConnectionConfigFromFile();
this.connectionConfig = await this.getConnectionConfigFromFile();
if (this.connectionConfig?.authToken) {
this.authToken = this.connectionConfig.authToken;
}
const workspacePath =
configFromFile?.workspacePath ??
this.connectionConfig?.workspacePath ??
process.env['QWEN_CODE_IDE_WORKSPACE_PATH'];
const { isValid, error } = IdeClient.validateWorkspacePath(
workspacePath,
this.currentIdeDisplayName,
process.cwd(),
);
@@ -131,18 +166,18 @@ export class IdeClient {
return;
}
if (configFromFile) {
if (configFromFile.port) {
if (this.connectionConfig) {
if (this.connectionConfig.port) {
const connected = await this.establishHttpConnection(
configFromFile.port,
this.connectionConfig.port,
);
if (connected) {
return;
}
}
if (configFromFile.stdio) {
if (this.connectionConfig.stdio) {
const connected = await this.establishStdioConnection(
configFromFile.stdio,
this.connectionConfig.stdio,
);
if (connected) {
return;
@@ -168,70 +203,185 @@ export class IdeClient {
this.setState(
IDEConnectionStatus.Disconnected,
`Failed to connect to IDE companion extension in ${this.currentIdeDisplayName}. Please ensure the extension is running. To install the extension, run /ide install.`,
`Failed to connect to IDE companion extension in ${this.currentIde.displayName}. Please ensure the extension is running. To install the extension, run /ide install.`,
true,
);
}
/**
* 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`
* Opens a diff view in the IDE, allowing the user to review and accept or
* reject changes.
*
* 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
* This method sends a request to the IDE to display a diff between the
* current content of a file and the new content provided. It then waits for
* a notification from the IDE indicating that the user has either accepted
* (potentially with manual edits) or rejected the diff.
*
* A mutex ensures that only one diff view can be open at a time to prevent
* race conditions.
*
* @param filePath The absolute path to the file to be diffed.
* @param newContent The proposed new content for the file.
* @returns A promise that resolves with a `DiffUpdateResult`, indicating
* whether the diff was 'accepted' or 'rejected' and including the final
* content if accepted.
*/
async openDiff(
filePath: string,
newContent?: string,
newContent: string,
): Promise<DiffUpdateResult> {
return new Promise<DiffUpdateResult>((resolve, reject) => {
const release = await this.acquireMutex();
const promise = new Promise<DiffUpdateResult>((resolve, reject) => {
if (!this.client) {
// The promise will be rejected, and the finally block below will release the mutex.
return reject(new Error('IDE client is not connected.'));
}
this.diffResponses.set(filePath, resolve);
this.client
?.callTool({
name: `openDiff`,
arguments: {
filePath,
newContent,
.request(
{
method: 'tools/call',
params: {
name: `openDiff`,
arguments: {
filePath,
newContent,
},
},
},
CallToolResultSchema,
{ timeout: IDE_REQUEST_TIMEOUT_MS },
)
.then((parsedResultData) => {
if (parsedResultData.isError) {
const textPart = parsedResultData.content.find(
(part) => part.type === 'text',
);
const errorMessage =
textPart?.text ?? `Tool 'openDiff' reported an error.`;
logger.debug(
`Request for openDiff ${filePath} failed with isError:`,
errorMessage,
);
this.diffResponses.delete(filePath);
reject(new Error(errorMessage));
}
})
.catch((err) => {
logger.debug(`callTool for ${filePath} failed:`, err);
logger.debug(`Request for openDiff ${filePath} failed:`, err);
this.diffResponses.delete(filePath);
reject(err);
});
});
// Ensure the mutex is released only after the diff interaction is complete.
promise.finally(release);
return promise;
}
async closeDiff(filePath: string): Promise<string | undefined> {
try {
const result = await this.client?.callTool({
name: `closeDiff`,
arguments: {
filePath,
},
});
/**
* Acquires a lock to ensure sequential execution of critical sections.
*
* This method implements a promise-based mutex. It works by chaining promises.
* Each call to `acquireMutex` gets the current `diffMutex` promise. It then
* creates a *new* promise (`newMutex`) that will be resolved when the caller
* invokes the returned `release` function. The `diffMutex` is immediately
* updated to this `newMutex`.
*
* The method returns a promise that resolves with the `release` function only
* *after* the *previous* `diffMutex` promise has resolved. This creates a
* queue where each subsequent operation must wait for the previous one to release
* the lock.
*
* @returns A promise that resolves to a function that must be called to
* release the lock.
*/
private acquireMutex(): Promise<() => void> {
let release: () => void;
const newMutex = new Promise<void>((resolve) => {
release = resolve;
});
const oldMutex = this.diffMutex;
this.diffMutex = newMutex;
return oldMutex.then(() => release);
}
if (result) {
const parsed = CloseDiffResponseSchema.parse(result);
return parsed.content;
async closeDiff(
filePath: string,
options?: { suppressNotification?: boolean },
): Promise<string | undefined> {
try {
if (!this.client) {
return undefined;
}
const resultData = await this.client.request(
{
method: 'tools/call',
params: {
name: `closeDiff`,
arguments: {
filePath,
suppressNotification: options?.suppressNotification,
},
},
},
CallToolResultSchema,
{ timeout: IDE_REQUEST_TIMEOUT_MS },
);
if (!resultData) {
return undefined;
}
if (resultData.isError) {
const textPart = resultData.content.find(
(part) => part.type === 'text',
);
const errorMessage =
textPart?.text ?? `Tool 'closeDiff' reported an error.`;
logger.debug(
`Request for closeDiff ${filePath} failed with isError:`,
errorMessage,
);
return undefined;
}
const textPart = resultData.content.find((part) => part.type === 'text');
if (textPart?.text) {
try {
const parsedJson = JSON.parse(textPart.text);
if (parsedJson && typeof parsedJson.content === 'string') {
return parsedJson.content;
}
if (parsedJson && parsedJson.content === null) {
return undefined;
}
} catch (_e) {
logger.debug(
`Invalid JSON in closeDiff response for ${filePath}:`,
textPart.text,
);
}
}
} catch (err) {
logger.debug(`callTool for ${filePath} failed:`, err);
logger.debug(`Request for closeDiff ${filePath} failed:`, err);
}
return;
return undefined;
}
// 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);
const content = await this.closeDiff(filePath, {
// Suppress notification to avoid race where closing the diff rejects the
// request.
suppressNotification: true,
});
if (resolver) {
if (outcome === 'accepted') {
resolver({ status: 'accepted', content });
@@ -257,7 +407,7 @@ export class IdeClient {
this.client?.close();
}
getCurrentIde(): DetectedIde | undefined {
getCurrentIde(): IdeInfo | undefined {
return this.currentIde;
}
@@ -266,7 +416,54 @@ export class IdeClient {
}
getDetectedIdeDisplayName(): string | undefined {
return this.currentIdeDisplayName;
return this.currentIde?.displayName;
}
isDiffingEnabled(): boolean {
return (
!!this.client &&
this.state.status === IDEConnectionStatus.Connected &&
this.availableTools.includes('openDiff') &&
this.availableTools.includes('closeDiff')
);
}
private async discoverTools(): Promise<void> {
if (!this.client) {
return;
}
try {
logger.debug('Discovering tools from IDE...');
const response = await this.client.request(
{ method: 'tools/list', params: {} },
ListToolsResultSchema,
);
// Map the array of tool objects to an array of tool names (strings)
this.availableTools = response.tools.map((tool) => tool.name);
if (this.availableTools.length > 0) {
logger.debug(
`Discovered ${this.availableTools.length} tools from IDE: ${this.availableTools.join(', ')}`,
);
} else {
logger.debug(
'IDE supports tool discovery, but no tools are available.',
);
}
} catch (error) {
// It's okay if this fails, the IDE might not support it.
// Don't log an error if the method is not found, which is a common case.
if (
error instanceof Error &&
!error.message?.includes('Method not found')
) {
logger.error(`Error discovering tools from IDE: ${error.message}`);
} else {
logger.debug('IDE does not support tool discovery.');
}
this.availableTools = [];
}
}
private setState(
@@ -297,26 +494,25 @@ export class IdeClient {
}
if (status === IDEConnectionStatus.Disconnected) {
ideContext.clearIdeContext();
ideContextStore.clear();
}
}
static validateWorkspacePath(
ideWorkspacePath: string | undefined,
currentIdeDisplayName: string | undefined,
cwd: string,
): { isValid: boolean; error?: string } {
if (ideWorkspacePath === undefined) {
return {
isValid: false,
error: `Failed to connect to IDE companion extension in ${currentIdeDisplayName}. Please ensure the extension is running. To install the extension, run /ide install.`,
error: `Failed to connect to IDE companion extension. Please ensure the extension is running. To install the extension, run /ide install.`,
};
}
if (ideWorkspacePath === '') {
return {
isValid: false,
error: `To use this feature, please open a workspace folder in ${currentIdeDisplayName} and try again.`,
error: `To use this feature, please open a workspace folder in your IDE and try again.`,
};
}
@@ -330,7 +526,7 @@ export class IdeClient {
if (!isWithinWorkspace) {
return {
isValid: false,
error: `Directory mismatch. Qwen Code is running in a different location than the open workspace in ${currentIdeDisplayName}. Please run the CLI from one of the following directories: ${ideWorkspacePaths.join(
error: `Directory mismatch. Qwen Code is running in a different location than the open workspace in the IDE. Please run the CLI from one of the following directories: ${ideWorkspacePaths.join(
', ',
)}`,
};
@@ -373,11 +569,14 @@ export class IdeClient {
}
private async getConnectionConfigFromFile(): Promise<
(ConnectionConfig & { workspacePath?: string }) | undefined
| (ConnectionConfig & { workspacePath?: string; ideInfo?: IdeInfo })
| undefined
> {
if (!this.ideProcessInfo) {
return {};
return undefined;
}
// For backwards compatability
try {
const portFile = path.join(
os.tmpdir(),
@@ -386,8 +585,85 @@ export class IdeClient {
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
return JSON.parse(portFileContents);
} catch (_) {
// For newer extension versions, the file name matches the pattern
// /^qwen-code-ide-server-${pid}-\d+\.json$/. If multiple IDE
// windows are open, multiple files matching the pattern are expected to
// exist.
}
const portFileDir = path.join(os.tmpdir(), 'gemini', 'ide');
let portFiles;
try {
portFiles = await fs.promises.readdir(portFileDir);
} catch (e) {
logger.debug('Failed to read IDE connection directory:', e);
return undefined;
}
if (!portFiles) {
return undefined;
}
const fileRegex = new RegExp(
`^qwen-code-ide-server-${this.ideProcessInfo.pid}-\\d+\\.json$`,
);
const matchingFiles = portFiles
.filter((file) => fileRegex.test(file))
.sort();
if (matchingFiles.length === 0) {
return undefined;
}
let fileContents: string[];
try {
fileContents = await Promise.all(
matchingFiles.map((file) =>
fs.promises.readFile(path.join(portFileDir, file), 'utf8'),
),
);
} catch (e) {
logger.debug('Failed to read IDE connection config file(s):', e);
return undefined;
}
const parsedContents = fileContents.map((content) => {
try {
return JSON.parse(content);
} catch (e) {
logger.debug('Failed to parse JSON from config file: ', e);
return undefined;
}
});
const validWorkspaces = parsedContents.filter((content) => {
if (!content) {
return false;
}
const { isValid } = IdeClient.validateWorkspacePath(
content.workspacePath,
process.cwd(),
);
return isValid;
});
if (validWorkspaces.length === 0) {
return undefined;
}
if (validWorkspaces.length === 1) {
return validWorkspaces[0];
}
const portFromEnv = this.getPortFromEnv();
if (portFromEnv) {
const matchingPort = validWorkspaces.find(
(content) => String(content.port) === portFromEnv,
);
if (matchingPort) {
return matchingPort;
}
}
return validWorkspaces[0];
}
private createProxyAwareFetch() {
@@ -414,7 +690,7 @@ export class IdeClient {
return new Response(response.body as ReadableStream<unknown> | null, {
status: response.status,
statusText: response.statusText,
headers: standardHeaders,
headers: [...response.headers.entries()],
});
};
}
@@ -427,20 +703,27 @@ export class IdeClient {
this.client.setNotificationHandler(
IdeContextNotificationSchema,
(notification) => {
ideContext.setIdeContext(notification.params);
ideContextStore.set(notification.params);
const isTrusted = notification.params.workspaceState?.isTrusted;
if (isTrusted !== undefined) {
for (const listener of this.trustChangeListeners) {
listener(isTrusted);
}
}
},
);
this.client.onerror = (_error) => {
const errorMessage = _error instanceof Error ? _error.message : `_error`;
this.setState(
IDEConnectionStatus.Disconnected,
`IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable`,
`IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable\n${errorMessage}`,
true,
);
};
this.client.onclose = () => {
this.setState(
IDEConnectionStatus.Disconnected,
`IDE connection error. The connection was lost unexpectedly. Please try reconnecting by running /ide enable`,
`IDE connection closed. To reconnect, run /ide enable.`,
true,
);
};
@@ -458,6 +741,22 @@ export class IdeClient {
},
);
this.client.setNotificationHandler(
IdeDiffRejectedNotificationSchema,
(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}`);
}
},
);
// For backwards compatability. Newer extension versions will only send
// IdeDiffRejectedNotificationSchema.
this.client.setNotificationHandler(
IdeDiffClosedNotificationSchema,
(notification) => {
@@ -487,6 +786,11 @@ export class IdeClient {
new URL(`http://${getIdeServerHost()}:${port}/mcp`),
{
fetch: this.createProxyAwareFetch(),
requestInit: {
headers: this.authToken
? { Authorization: `Bearer ${this.authToken}` }
: {},
},
},
);
@@ -494,6 +798,7 @@ export class IdeClient {
await this.client.connect(transport);
this.registerClientHandlers();
await this.discoverTools();
this.setState(IDEConnectionStatus.Connected);
return true;
} catch (_error) {
@@ -527,6 +832,7 @@ export class IdeClient {
});
await this.client.connect(transport);
this.registerClientHandlers();
await this.discoverTools();
this.setState(IDEConnectionStatus.Connected);
return true;
} catch (_error) {

View File

@@ -4,17 +4,27 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { vi } from 'vitest';
vi.mock('node:child_process', async (importOriginal) => {
const actual =
(await importOriginal()) as typeof import('node:child_process');
return {
...actual,
execSync: vi.fn(),
spawnSync: vi.fn(() => ({ status: 0 })),
};
});
vi.mock('fs');
vi.mock('os');
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { getIdeInstaller } from './ide-installer.js';
import * as child_process from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { DetectedIde } from './detect-ide.js';
vi.mock('child_process');
vi.mock('fs');
vi.mock('os');
import { IDE_DEFINITIONS, type IdeInfo } from './detect-ide.js';
describe('ide-installer', () => {
const HOME_DIR = '/home/user';
@@ -28,23 +38,28 @@ describe('ide-installer', () => {
});
describe('getIdeInstaller', () => {
it.each([{ ide: DetectedIde.VSCode }, { ide: DetectedIde.FirebaseStudio }])(
'returns a VsCodeInstaller for "$ide"',
({ ide }) => {
const installer = getIdeInstaller(ide);
it.each([
{ ide: IDE_DEFINITIONS.vscode },
{ ide: IDE_DEFINITIONS.firebasestudio },
])('returns a VsCodeInstaller for "$ide.name"', ({ ide }) => {
const installer = getIdeInstaller(ide);
expect(installer).not.toBeNull();
expect(installer?.install).toEqual(expect.any(Function));
},
);
expect(installer).not.toBeNull();
expect(installer?.install).toEqual(expect.any(Function));
});
});
describe('VsCodeInstaller', () => {
function setup({
ide = DetectedIde.VSCode,
ide = IDE_DEFINITIONS.vscode,
existsResult = false,
execSync = () => '',
platform = 'linux' as NodeJS.Platform,
}: {
ide?: IdeInfo;
existsResult?: boolean;
execSync?: () => string;
platform?: NodeJS.Platform;
} = {}) {
vi.spyOn(child_process, 'execSync').mockImplementation(execSync);
vi.spyOn(fs, 'existsSync').mockReturnValue(existsResult);
@@ -97,20 +112,25 @@ describe('ide-installer', () => {
platform: 'linux',
});
await installer.install();
expect(child_process.execSync).toHaveBeenCalledWith(
'"code" --install-extension qwenlm.qwen-code-vscode-ide-companion --force',
expect(child_process.spawnSync).toHaveBeenCalledWith(
'code',
[
'--install-extension',
'qwenlm.qwen-code-vscode-ide-companion',
'--force',
],
{ stdio: 'pipe' },
);
});
it.each([
{
ide: DetectedIde.VSCode,
ide: IDE_DEFINITIONS.vscode,
expectedMessage:
'VS Code companion extension was installed successfully',
},
{
ide: DetectedIde.FirebaseStudio,
ide: IDE_DEFINITIONS.firebasestudio,
expectedMessage:
'Firebase Studio companion extension was installed successfully',
},
@@ -125,9 +145,12 @@ describe('ide-installer', () => {
);
it.each([
{ ide: DetectedIde.VSCode, expectedErr: 'VS Code CLI not found' },
{
ide: DetectedIde.FirebaseStudio,
ide: IDE_DEFINITIONS.vscode,
expectedErr: 'VS Code CLI not found',
},
{
ide: IDE_DEFINITIONS.firebasestudio,
expectedErr: 'Firebase Studio CLI not found',
},
])(

View File

@@ -9,7 +9,7 @@ import * as process from 'node:process';
import * as path from 'node:path';
import * as fs from 'node:fs';
import * as os from 'node:os';
import { DetectedIde, getIdeInfo, type IdeInfo } from './detect-ide.js';
import { IDE_DEFINITIONS, type IdeInfo } from './detect-ide.js';
import { QWEN_CODE_COMPANION_EXTENSION_NAME } from './constants.js';
function getVsCodeCommand(platform: NodeJS.Platform = process.platform) {
@@ -100,14 +100,12 @@ async function findVsCodeCommand(
class VsCodeInstaller implements IdeInstaller {
private vsCodeCommand: Promise<string | null>;
private readonly ideInfo: IdeInfo;
constructor(
readonly ide: DetectedIde,
readonly ideInfo: IdeInfo,
readonly platform = process.platform,
) {
this.vsCodeCommand = findVsCodeCommand(platform);
this.ideInfo = getIdeInfo(ide);
}
async install(): Promise<InstallResult> {
@@ -119,9 +117,23 @@ class VsCodeInstaller implements IdeInstaller {
};
}
const command = `"${commandPath}" --install-extension qwenlm.qwen-code-vscode-ide-companion --force`;
try {
child_process.execSync(command, { stdio: 'pipe' });
const result = child_process.spawnSync(
commandPath,
[
'--install-extension',
'qwenlm.qwen-code-vscode-ide-companion',
'--force',
],
{ stdio: 'pipe' },
);
if (result.status !== 0) {
throw new Error(
`Failed to install extension: ${result.stderr?.toString()}`,
);
}
return {
success: true,
message: `${this.ideInfo.displayName} companion extension was installed successfully.`,
@@ -136,12 +148,12 @@ class VsCodeInstaller implements IdeInstaller {
}
export function getIdeInstaller(
ide: DetectedIde,
ide: IdeInfo,
platform = process.platform,
): IdeInstaller | null {
switch (ide) {
case DetectedIde.VSCode:
case DetectedIde.FirebaseStudio:
switch (ide.name) {
case IDE_DEFINITIONS.vscode.name:
case IDE_DEFINITIONS.firebasestudio.name:
return new VsCodeInstaller(ide, platform);
default:
return null;

View File

@@ -4,24 +4,34 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
createIdeContextStore,
IDE_MAX_OPEN_FILES,
IDE_MAX_SELECTED_TEXT_LENGTH,
} from './constants.js';
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { IdeContextStore } from './ideContext.js';
import {
type IdeContext,
FileSchema,
IdeContextSchema,
} from './ideContext.js';
type File,
} from './types.js';
describe('ideContext', () => {
describe('createIdeContextStore', () => {
let ideContext: ReturnType<typeof createIdeContextStore>;
let ideContextStore: IdeContextStore;
beforeEach(() => {
// Create a fresh, isolated instance for each test
ideContext = createIdeContextStore();
ideContextStore = new IdeContextStore();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return undefined initially for ide context', () => {
expect(ideContext.getIdeContext()).toBeUndefined();
expect(ideContextStore.get()).toBeUndefined();
});
it('should set and retrieve the ide context', () => {
@@ -38,9 +48,9 @@ describe('ideContext', () => {
},
};
ideContext.setIdeContext(testFile);
ideContextStore.set(testFile);
const activeFile = ideContext.getIdeContext();
const activeFile = ideContextStore.get();
expect(activeFile).toEqual(testFile);
});
@@ -57,7 +67,7 @@ describe('ideContext', () => {
],
},
};
ideContext.setIdeContext(firstFile);
ideContextStore.set(firstFile);
const secondFile = {
workspaceState: {
@@ -71,9 +81,9 @@ describe('ideContext', () => {
],
},
};
ideContext.setIdeContext(secondFile);
ideContextStore.set(secondFile);
const activeFile = ideContext.getIdeContext();
const activeFile = ideContextStore.get();
expect(activeFile).toEqual(secondFile);
});
@@ -90,16 +100,16 @@ describe('ideContext', () => {
],
},
};
ideContext.setIdeContext(testFile);
expect(ideContext.getIdeContext()).toEqual(testFile);
ideContextStore.set(testFile);
expect(ideContextStore.get()).toEqual(testFile);
});
it('should notify subscribers when ide context changes', () => {
const subscriber1 = vi.fn();
const subscriber2 = vi.fn();
ideContext.subscribeToIdeContext(subscriber1);
ideContext.subscribeToIdeContext(subscriber2);
ideContextStore.subscribe(subscriber1);
ideContextStore.subscribe(subscriber2);
const testFile = {
workspaceState: {
@@ -113,7 +123,7 @@ describe('ideContext', () => {
],
},
};
ideContext.setIdeContext(testFile);
ideContextStore.set(testFile);
expect(subscriber1).toHaveBeenCalledTimes(1);
expect(subscriber1).toHaveBeenCalledWith(testFile);
@@ -133,7 +143,7 @@ describe('ideContext', () => {
],
},
};
ideContext.setIdeContext(newFile);
ideContextStore.set(newFile);
expect(subscriber1).toHaveBeenCalledTimes(2);
expect(subscriber1).toHaveBeenCalledWith(newFile);
@@ -145,10 +155,10 @@ describe('ideContext', () => {
const subscriber1 = vi.fn();
const subscriber2 = vi.fn();
const unsubscribe1 = ideContext.subscribeToIdeContext(subscriber1);
ideContext.subscribeToIdeContext(subscriber2);
const unsubscribe1 = ideContextStore.subscribe(subscriber1);
ideContextStore.subscribe(subscriber2);
ideContext.setIdeContext({
ideContextStore.set({
workspaceState: {
openFiles: [
{
@@ -165,7 +175,7 @@ describe('ideContext', () => {
unsubscribe1();
ideContext.setIdeContext({
ideContextStore.set({
workspaceState: {
openFiles: [
{
@@ -195,13 +205,152 @@ describe('ideContext', () => {
},
};
ideContext.setIdeContext(testFile);
ideContextStore.set(testFile);
expect(ideContext.getIdeContext()).toEqual(testFile);
expect(ideContextStore.get()).toEqual(testFile);
ideContext.clearIdeContext();
ideContextStore.clear();
expect(ideContext.getIdeContext()).toBeUndefined();
expect(ideContextStore.get()).toBeUndefined();
});
it('should set the context and notify subscribers when no workspaceState is present', () => {
const subscriber = vi.fn();
ideContextStore.subscribe(subscriber);
const context: IdeContext = {};
ideContextStore.set(context);
expect(ideContextStore.get()).toBe(context);
expect(subscriber).toHaveBeenCalledWith(context);
});
it('should handle an empty openFiles array', () => {
const context: IdeContext = {
workspaceState: {
openFiles: [],
},
};
ideContextStore.set(context);
expect(ideContextStore.get()?.workspaceState?.openFiles).toEqual([]);
});
it('should sort openFiles by timestamp in descending order', () => {
const context: IdeContext = {
workspaceState: {
openFiles: [
{ path: 'file1.ts', timestamp: 100, isActive: false },
{ path: 'file2.ts', timestamp: 300, isActive: true },
{ path: 'file3.ts', timestamp: 200, isActive: false },
],
},
};
ideContextStore.set(context);
const openFiles = ideContextStore.get()?.workspaceState?.openFiles;
expect(openFiles?.[0]?.path).toBe('file2.ts');
expect(openFiles?.[1]?.path).toBe('file3.ts');
expect(openFiles?.[2]?.path).toBe('file1.ts');
});
it('should mark only the most recent file as active and clear other active files', () => {
const context: IdeContext = {
workspaceState: {
openFiles: [
{
path: 'file1.ts',
timestamp: 100,
isActive: true,
selectedText: 'hello',
},
{
path: 'file2.ts',
timestamp: 300,
isActive: true,
cursor: { line: 1, character: 1 },
selectedText: 'hello',
},
{
path: 'file3.ts',
timestamp: 200,
isActive: false,
selectedText: 'hello',
},
],
},
};
ideContextStore.set(context);
const openFiles = ideContextStore.get()?.workspaceState?.openFiles;
expect(openFiles?.[0]?.isActive).toBe(true);
expect(openFiles?.[0]?.cursor).toBeDefined();
expect(openFiles?.[0]?.selectedText).toBeDefined();
expect(openFiles?.[1]?.isActive).toBe(false);
expect(openFiles?.[1]?.cursor).toBeUndefined();
expect(openFiles?.[1]?.selectedText).toBeUndefined();
expect(openFiles?.[2]?.isActive).toBe(false);
expect(openFiles?.[2]?.cursor).toBeUndefined();
expect(openFiles?.[2]?.selectedText).toBeUndefined();
});
it('should truncate selectedText if it exceeds the max length', () => {
const longText = 'a'.repeat(IDE_MAX_SELECTED_TEXT_LENGTH + 10);
const context: IdeContext = {
workspaceState: {
openFiles: [
{
path: 'file1.ts',
timestamp: 100,
isActive: true,
selectedText: longText,
},
],
},
};
ideContextStore.set(context);
const selectedText =
ideContextStore.get()?.workspaceState?.openFiles?.[0]?.selectedText;
expect(selectedText).toHaveLength(
IDE_MAX_SELECTED_TEXT_LENGTH + '... [TRUNCATED]'.length,
);
expect(selectedText?.endsWith('... [TRUNCATED]')).toBe(true);
});
it('should not truncate selectedText if it is within the max length', () => {
const shortText = 'a'.repeat(IDE_MAX_SELECTED_TEXT_LENGTH);
const context: IdeContext = {
workspaceState: {
openFiles: [
{
path: 'file1.ts',
timestamp: 100,
isActive: true,
selectedText: shortText,
},
],
},
};
ideContextStore.set(context);
const selectedText =
ideContextStore.get()?.workspaceState?.openFiles?.[0]?.selectedText;
expect(selectedText).toBe(shortText);
});
it('should truncate the openFiles list if it exceeds the max length', () => {
const files: File[] = Array.from(
{ length: IDE_MAX_OPEN_FILES + 5 },
(_, i) => ({
path: `file${i}.ts`,
timestamp: i,
isActive: false,
}),
);
const context: IdeContext = {
workspaceState: {
openFiles: files,
},
};
ideContextStore.set(context);
const openFiles = ideContextStore.get()?.workspaceState?.openFiles;
expect(openFiles).toHaveLength(IDE_MAX_OPEN_FILES);
});
});

View File

@@ -4,120 +4,24 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { z } from 'zod';
import {
IDE_MAX_OPEN_FILES,
IDE_MAX_SELECTED_TEXT_LENGTH,
} from './constants.js';
import type { IdeContext } from './types.js';
/**
* Zod schema for validating a file context from the IDE.
*/
export const FileSchema = z.object({
path: z.string(),
timestamp: z.number(),
isActive: z.boolean().optional(),
selectedText: z.string().optional(),
cursor: z
.object({
line: z.number(),
character: z.number(),
})
.optional(),
});
export type File = z.infer<typeof FileSchema>;
type IdeContextSubscriber = (ideContext?: IdeContext) => void;
export const IdeContextSchema = z.object({
workspaceState: z
.object({
openFiles: z.array(FileSchema).optional(),
})
.optional(),
});
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;
/**
* Creates a new store for managing the IDE's 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 IDE context.
*/
export function createIdeContextStore() {
let ideContextState: IdeContext | undefined = undefined;
const subscribers = new Set<IdeContextSubscriber>();
export class IdeContextStore {
private ideContextState?: IdeContext;
private readonly subscribers = new Set<IdeContextSubscriber>();
/**
* Notifies all registered subscribers about the current IDE context.
*/
function notifySubscribers(): void {
for (const subscriber of subscribers) {
subscriber(ideContextState);
private notifySubscribers(): void {
for (const subscriber of this.subscribers) {
subscriber(this.ideContextState);
}
}
@@ -125,25 +29,76 @@ export function createIdeContextStore() {
* Sets the IDE context and notifies all registered subscribers of the change.
* @param newIdeContext The new IDE context from the IDE.
*/
function setIdeContext(newIdeContext: IdeContext): void {
ideContextState = newIdeContext;
notifySubscribers();
set(newIdeContext: IdeContext): void {
const { workspaceState } = newIdeContext;
if (!workspaceState) {
this.ideContextState = newIdeContext;
this.notifySubscribers();
return;
}
const { openFiles } = workspaceState;
if (openFiles && openFiles.length > 0) {
// Sort by timestamp descending (newest first)
openFiles.sort((a, b) => b.timestamp - a.timestamp);
// The most recent file is now at index 0.
const mostRecentFile = openFiles[0];
// If the most recent file is not active, then no file is active.
if (!mostRecentFile.isActive) {
openFiles.forEach((file) => {
file.isActive = false;
file.cursor = undefined;
file.selectedText = undefined;
});
} else {
// The most recent file is active. Ensure it's the only one.
openFiles.forEach((file, index: number) => {
if (index !== 0) {
file.isActive = false;
file.cursor = undefined;
file.selectedText = undefined;
}
});
// Truncate selected text in the active file
if (
mostRecentFile.selectedText &&
mostRecentFile.selectedText.length > IDE_MAX_SELECTED_TEXT_LENGTH
) {
mostRecentFile.selectedText =
mostRecentFile.selectedText.substring(
0,
IDE_MAX_SELECTED_TEXT_LENGTH,
) + '... [TRUNCATED]';
}
}
// Truncate files list
if (openFiles.length > IDE_MAX_OPEN_FILES) {
workspaceState.openFiles = openFiles.slice(0, IDE_MAX_OPEN_FILES);
}
}
this.ideContextState = newIdeContext;
this.notifySubscribers();
}
/**
* Clears the IDE context and notifies all registered subscribers of the change.
*/
function clearIdeContext(): void {
ideContextState = undefined;
notifySubscribers();
clear(): void {
this.ideContextState = undefined;
this.notifySubscribers();
}
/**
* Retrieves the current IDE context.
* @returns The `IdeContext` object if a file is active; otherwise, `undefined`.
*/
function getIdeContext(): IdeContext | undefined {
return ideContextState;
get(): IdeContext | undefined {
return this.ideContextState;
}
/**
@@ -155,22 +110,15 @@ export function createIdeContextStore() {
* @param subscriber The function to be called when the IDE context changes.
* @returns A function that, when called, will unsubscribe the provided subscriber.
*/
function subscribeToIdeContext(subscriber: IdeContextSubscriber): () => void {
subscribers.add(subscriber);
subscribe(subscriber: IdeContextSubscriber): () => void {
this.subscribers.add(subscriber);
return () => {
subscribers.delete(subscriber);
this.subscribers.delete(subscriber);
};
}
return {
setIdeContext,
getIdeContext,
subscribeToIdeContext,
clearIdeContext,
};
}
/**
* The default, shared instance of the IDE context store for the application.
*/
export const ideContext = createIdeContextStore();
export const ideContextStore = new IdeContextStore();

View File

@@ -44,11 +44,18 @@ async function getProcessInfo(pid: number): Promise<{
ParentProcessId = 0,
CommandLine = '',
} = JSON.parse(output);
return { parentPid: ParentProcessId, name: Name, command: CommandLine };
return {
parentPid: ParentProcessId,
name: Name,
command: CommandLine ?? '',
};
} else {
const command = `ps -o ppid=,command= -p ${pid}`;
const { stdout } = await execAsync(command);
const trimmedStdout = stdout.trim();
if (!trimmedStdout) {
return { parentPid: 0, name: '', command: '' };
}
const ppidString = trimmedStdout.split(/\s+/)[0];
const parentPid = parseInt(ppidString, 10);
const fullCommand = trimmedStdout.substring(ppidString.length).trim();

View File

@@ -0,0 +1,148 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { z } from 'zod';
/**
* A file that is open in the IDE.
*/
export const FileSchema = z.object({
/**
* The absolute path to the file.
*/
path: z.string(),
/**
* The unix timestamp of when the file was last focused.
*/
timestamp: z.number(),
/**
* Whether the file is the currently active file. Only one file can be active at a time.
*/
isActive: z.boolean().optional(),
/**
* The text that is currently selected in the active file.
*/
selectedText: z.string().optional(),
/**
* The cursor position in the active file.
*/
cursor: z
.object({
/**
* The 1-based line number.
*/
line: z.number(),
/**
* The 1-based character offset.
*/
character: z.number(),
})
.optional(),
});
export type File = z.infer<typeof FileSchema>;
/**
* The context of the IDE.
*/
export const IdeContextSchema = z.object({
workspaceState: z
.object({
/**
* The list of files that are currently open.
*/
openFiles: z.array(FileSchema).optional(),
/**
* Whether the workspace is trusted.
*/
isTrusted: z.boolean().optional(),
})
.optional(),
});
export type IdeContext = z.infer<typeof IdeContextSchema>;
/**
* A notification that the IDE context has been updated.
*/
export const IdeContextNotificationSchema = z.object({
jsonrpc: z.literal('2.0'),
method: z.literal('ide/contextUpdate'),
params: IdeContextSchema,
});
/**
* A notification that a diff has been accepted in the IDE.
*/
export const IdeDiffAcceptedNotificationSchema = z.object({
jsonrpc: z.literal('2.0'),
method: z.literal('ide/diffAccepted'),
params: z.object({
/**
* The absolute path to the file that was diffed.
*/
filePath: z.string(),
/**
* The full content of the file after the diff was accepted, which includes any manual edits the user may have made.
*/
content: z.string(),
}),
});
/**
* A notification that a diff has been rejected in the IDE.
*/
export const IdeDiffRejectedNotificationSchema = z.object({
jsonrpc: z.literal('2.0'),
method: z.literal('ide/diffRejected'),
params: z.object({
/**
* The absolute path to the file that was diffed.
*/
filePath: z.string(),
}),
});
/**
* This is defineded for backwards compatability only. Newer extension versions
* will only send IdeDiffRejectedNotificationSchema.
*
* A notification that a diff has been closed in the IDE.
*/
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(),
}),
});
/**
* The request to open a diff view in the IDE.
*/
export const OpenDiffRequestSchema = z.object({
/**
* The absolute path to the file to be diffed.
*/
filePath: z.string(),
/**
* The proposed new content for the file.
*/
newContent: z.string(),
});
/**
* The request to close a diff view in the IDE.
*/
export const CloseDiffRequestSchema = z.object({
/**
* The absolute path to the file to be diffed.
*/
filePath: z.string(),
/**
* @deprecated
*/
suppressNotification: z.boolean().optional(),
});