merge main and fix conflict

This commit is contained in:
koalazf.99
2025-08-26 13:50:29 +08:00
176 changed files with 9529 additions and 4561 deletions

View File

@@ -38,7 +38,7 @@
"dotenv": "^17.1.0",
"glob": "^10.4.1",
"highlight.js": "^11.11.1",
"ink": "^6.0.1",
"ink": "^6.1.1",
"ink-big-text": "^2.0.0",
"ink-gradient": "^3.0.0",
"ink-link": "^4.1.0",

View File

@@ -1,464 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/* ACP defines a schema for a simple (experimental) JSON-RPC protocol that allows GUI applications to interact with agents. */
import { Icon } from '@qwen-code/qwen-code-core';
import { WritableStream, ReadableStream } from 'node:stream/web';
export class ClientConnection implements Client {
#connection: Connection<Agent>;
constructor(
agent: (client: Client) => Agent,
input: WritableStream<Uint8Array>,
output: ReadableStream<Uint8Array>,
) {
this.#connection = new Connection(agent(this), input, output);
}
/**
* Streams part of an assistant response to the client
*/
async streamAssistantMessageChunk(
params: StreamAssistantMessageChunkParams,
): Promise<void> {
await this.#connection.sendRequest('streamAssistantMessageChunk', params);
}
/**
* Request confirmation before running a tool
*
* When allowed, the client returns a [`ToolCallId`] which can be used
* to update the tool call's `status` and `content` as it runs.
*/
requestToolCallConfirmation(
params: RequestToolCallConfirmationParams,
): Promise<RequestToolCallConfirmationResponse> {
return this.#connection.sendRequest('requestToolCallConfirmation', params);
}
/**
* pushToolCall allows the agent to start a tool call
* when it does not need to request permission to do so.
*
* The returned id can be used to update the UI for the tool
* call as needed.
*/
pushToolCall(params: PushToolCallParams): Promise<PushToolCallResponse> {
return this.#connection.sendRequest('pushToolCall', params);
}
/**
* updateToolCall allows the agent to update the content and status of the tool call.
*
* The new content replaces what is currently displayed in the UI.
*
* The [`ToolCallId`] is included in the response of
* `pushToolCall` or `requestToolCallConfirmation` respectively.
*/
async updateToolCall(params: UpdateToolCallParams): Promise<void> {
await this.#connection.sendRequest('updateToolCall', params);
}
}
type AnyMessage = AnyRequest | AnyResponse;
type AnyRequest = {
id: number;
method: string;
params?: unknown;
};
type AnyResponse = { jsonrpc: '2.0'; id: number } & Result<unknown>;
type Result<T> =
| {
result: T;
}
| {
error: ErrorResponse;
};
type ErrorResponse = {
code: number;
message: string;
data?: { details?: string };
};
type PendingResponse = {
resolve: (response: unknown) => void;
reject: (error: ErrorResponse) => void;
};
class Connection<D> {
#pendingResponses: Map<number, PendingResponse> = new Map();
#nextRequestId: number = 0;
#delegate: D;
#peerInput: WritableStream<Uint8Array>;
#writeQueue: Promise<void> = Promise.resolve();
#textEncoder: TextEncoder;
constructor(
delegate: D,
peerInput: WritableStream<Uint8Array>,
peerOutput: ReadableStream<Uint8Array>,
) {
this.#peerInput = peerInput;
this.#textEncoder = new TextEncoder();
this.#delegate = delegate;
this.#receive(peerOutput);
}
async #receive(output: ReadableStream<Uint8Array>) {
let content = '';
const decoder = new TextDecoder();
for await (const chunk of output) {
content += decoder.decode(chunk, { stream: true });
const lines = content.split('\n');
content = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine) {
const message = JSON.parse(trimmedLine);
this.#processMessage(message);
}
}
}
}
async #processMessage(message: AnyMessage) {
if ('method' in message) {
const response = await this.#tryCallDelegateMethod(
message.method,
message.params,
);
await this.#sendMessage({
jsonrpc: '2.0',
id: message.id,
...response,
});
} else {
this.#handleResponse(message);
}
}
async #tryCallDelegateMethod(
method: string,
params?: unknown,
): Promise<Result<unknown>> {
const methodName = method as keyof D;
if (typeof this.#delegate[methodName] !== 'function') {
return RequestError.methodNotFound(method).toResult();
}
try {
const result = await this.#delegate[methodName](params);
return { result: result ?? null };
} catch (error: unknown) {
if (error instanceof RequestError) {
return error.toResult();
}
let details;
if (error instanceof Error) {
details = error.message;
} else if (
typeof error === 'object' &&
error != null &&
'message' in error &&
typeof error.message === 'string'
) {
details = error.message;
}
return RequestError.internalError(details).toResult();
}
}
#handleResponse(response: AnyResponse) {
const pendingResponse = this.#pendingResponses.get(response.id);
if (pendingResponse) {
if ('result' in response) {
pendingResponse.resolve(response.result);
} else if ('error' in response) {
pendingResponse.reject(response.error);
}
this.#pendingResponses.delete(response.id);
}
}
async sendRequest<Req, Resp>(method: string, params?: Req): Promise<Resp> {
const id = this.#nextRequestId++;
const responsePromise = new Promise((resolve, reject) => {
this.#pendingResponses.set(id, { resolve, reject });
});
await this.#sendMessage({ jsonrpc: '2.0', id, method, params });
return responsePromise as Promise<Resp>;
}
async #sendMessage(json: AnyMessage) {
const content = JSON.stringify(json) + '\n';
this.#writeQueue = this.#writeQueue
.then(async () => {
const writer = this.#peerInput.getWriter();
try {
await writer.write(this.#textEncoder.encode(content));
} finally {
writer.releaseLock();
}
})
.catch((error) => {
// Continue processing writes on error
console.error('ACP write error:', error);
});
return this.#writeQueue;
}
}
export class RequestError extends Error {
data?: { details?: string };
constructor(
public code: number,
message: string,
details?: string,
) {
super(message);
this.name = 'RequestError';
if (details) {
this.data = { details };
}
}
static parseError(details?: string): RequestError {
return new RequestError(-32700, 'Parse error', details);
}
static invalidRequest(details?: string): RequestError {
return new RequestError(-32600, 'Invalid request', details);
}
static methodNotFound(details?: string): RequestError {
return new RequestError(-32601, 'Method not found', details);
}
static invalidParams(details?: string): RequestError {
return new RequestError(-32602, 'Invalid params', details);
}
static internalError(details?: string): RequestError {
return new RequestError(-32603, 'Internal error', details);
}
toResult<T>(): Result<T> {
return {
error: {
code: this.code,
message: this.message,
data: this.data,
},
};
}
}
// Protocol types
export const LATEST_PROTOCOL_VERSION = '0.0.9';
export type AssistantMessageChunk =
| {
text: string;
}
| {
thought: string;
};
export type ToolCallConfirmation =
| {
description?: string | null;
type: 'edit';
}
| {
description?: string | null;
type: 'execute';
command: string;
rootCommand: string;
}
| {
description?: string | null;
type: 'mcp';
serverName: string;
toolDisplayName: string;
toolName: string;
}
| {
description?: string | null;
type: 'fetch';
urls: string[];
}
| {
description: string;
type: 'other';
};
export type ToolCallContent =
| {
type: 'markdown';
markdown: string;
}
| {
type: 'diff';
newText: string;
oldText: string | null;
path: string;
};
export type ToolCallStatus = 'running' | 'finished' | 'error';
export type ToolCallId = number;
export type ToolCallConfirmationOutcome =
| 'allow'
| 'alwaysAllow'
| 'alwaysAllowMcpServer'
| 'alwaysAllowTool'
| 'reject'
| 'cancel';
/**
* A part in a user message
*/
export type UserMessageChunk =
| {
text: string;
}
| {
path: string;
};
export interface StreamAssistantMessageChunkParams {
chunk: AssistantMessageChunk;
}
export interface RequestToolCallConfirmationParams {
confirmation: ToolCallConfirmation;
content?: ToolCallContent | null;
icon: Icon;
label: string;
locations?: ToolCallLocation[];
}
export interface ToolCallLocation {
line?: number | null;
path: string;
}
export interface PushToolCallParams {
content?: ToolCallContent | null;
icon: Icon;
label: string;
locations?: ToolCallLocation[];
}
export interface UpdateToolCallParams {
content: ToolCallContent | null;
status: ToolCallStatus;
toolCallId: ToolCallId;
}
export interface RequestToolCallConfirmationResponse {
id: ToolCallId;
outcome: ToolCallConfirmationOutcome;
}
export interface PushToolCallResponse {
id: ToolCallId;
}
export interface InitializeParams {
/**
* The version of the protocol that the client supports.
* This should be the latest version supported by the client.
*/
protocolVersion: string;
}
export interface SendUserMessageParams {
chunks: UserMessageChunk[];
}
export interface InitializeResponse {
/**
* Indicates whether the agent is authenticated and
* ready to handle requests.
*/
isAuthenticated: boolean;
/**
* The version of the protocol that the agent supports.
* If the agent supports the requested version, it should respond with the same version.
* Otherwise, the agent should respond with the latest version it supports.
*/
protocolVersion: string;
}
export interface Error {
code: number;
data?: unknown;
message: string;
}
export interface Client {
streamAssistantMessageChunk(
params: StreamAssistantMessageChunkParams,
): Promise<void>;
requestToolCallConfirmation(
params: RequestToolCallConfirmationParams,
): Promise<RequestToolCallConfirmationResponse>;
pushToolCall(params: PushToolCallParams): Promise<PushToolCallResponse>;
updateToolCall(params: UpdateToolCallParams): Promise<void>;
}
export interface Agent {
/**
* Initializes the agent's state. It should be called before any other method,
* and no other methods should be called until it has completed.
*
* If the agent is not authenticated, then the client should prompt the user to authenticate,
* and then call the `authenticate` method.
* Otherwise the client can send other messages to the agent.
*/
initialize(params: InitializeParams): Promise<InitializeResponse>;
/**
* Begins the authentication process.
*
* This method should only be called if `initialize` indicates the user isn't already authenticated.
* The Promise MUST not resolve until authentication is complete.
*/
authenticate(): Promise<void>;
/**
* Allows the user to send a message to the agent.
* This method should complete after the agent is finished, during
* which time the agent may update the client by calling
* streamAssistantMessageChunk and other methods.
*/
sendUserMessage(params: SendUserMessageParams): Promise<void>;
/**
* Cancels the current generation.
*/
cancelSendMessage(): Promise<void>;
}

View File

@@ -13,6 +13,25 @@ import {
ConfigParameters,
ContentGeneratorConfig,
} from '@qwen-code/qwen-code-core';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
export const server = setupServer();
// TODO(richieforeman): Consider moving this to test setup globally.
beforeAll(() => {
server.listen({});
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
const CLEARCUT_URL = 'https://play.googleapis.com/log';
const TEST_CONTENT_GENERATOR_CONFIG: ContentGeneratorConfig = {
apiKey: 'test-key',
@@ -38,6 +57,8 @@ describe('Configuration Integration Tests', () => {
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(tmpdir(), 'qwen-code-test-'));
server.resetHandlers(http.post(CLEARCUT_URL, () => HttpResponse.text()));
originalEnv = { ...process.env };
process.env.GEMINI_API_KEY = 'test-api-key';
vi.clearAllMocks();
@@ -240,4 +261,149 @@ describe('Configuration Integration Tests', () => {
expect(config.getExtensionContextFilePaths()).toEqual(contextFiles);
});
});
describe('Approval Mode Integration Tests', () => {
let parseArguments: typeof import('./config').parseArguments;
beforeEach(async () => {
// Import the argument parsing function for integration testing
const { parseArguments: parseArgs } = await import('./config');
parseArguments = parseArgs;
});
it('should parse --approval-mode=auto_edit correctly through the full argument parsing flow', async () => {
const originalArgv = process.argv;
try {
process.argv = [
'node',
'script.js',
'--approval-mode',
'auto_edit',
'-p',
'test',
];
const argv = await parseArguments();
// Verify that the argument was parsed correctly
expect(argv.approvalMode).toBe('auto_edit');
expect(argv.prompt).toBe('test');
expect(argv.yolo).toBe(false);
} finally {
process.argv = originalArgv;
}
});
it('should parse --approval-mode=yolo correctly through the full argument parsing flow', async () => {
const originalArgv = process.argv;
try {
process.argv = [
'node',
'script.js',
'--approval-mode',
'yolo',
'-p',
'test',
];
const argv = await parseArguments();
expect(argv.approvalMode).toBe('yolo');
expect(argv.prompt).toBe('test');
expect(argv.yolo).toBe(false); // Should NOT be set when using --approval-mode
} finally {
process.argv = originalArgv;
}
});
it('should parse --approval-mode=default correctly through the full argument parsing flow', async () => {
const originalArgv = process.argv;
try {
process.argv = [
'node',
'script.js',
'--approval-mode',
'default',
'-p',
'test',
];
const argv = await parseArguments();
expect(argv.approvalMode).toBe('default');
expect(argv.prompt).toBe('test');
expect(argv.yolo).toBe(false);
} finally {
process.argv = originalArgv;
}
});
it('should parse legacy --yolo flag correctly', async () => {
const originalArgv = process.argv;
try {
process.argv = ['node', 'script.js', '--yolo', '-p', 'test'];
const argv = await parseArguments();
expect(argv.yolo).toBe(true);
expect(argv.approvalMode).toBeUndefined(); // Should NOT be set when using --yolo
expect(argv.prompt).toBe('test');
} finally {
process.argv = originalArgv;
}
});
it('should reject invalid approval mode values during argument parsing', async () => {
const originalArgv = process.argv;
try {
process.argv = ['node', 'script.js', '--approval-mode', 'invalid_mode'];
// Should throw during argument parsing due to yargs validation
await expect(parseArguments()).rejects.toThrow();
} finally {
process.argv = originalArgv;
}
});
it('should reject conflicting --yolo and --approval-mode flags', async () => {
const originalArgv = process.argv;
try {
process.argv = [
'node',
'script.js',
'--yolo',
'--approval-mode',
'default',
];
// Should throw during argument parsing due to conflict validation
await expect(parseArguments()).rejects.toThrow();
} finally {
process.argv = originalArgv;
}
});
it('should handle backward compatibility with mixed scenarios', async () => {
const originalArgv = process.argv;
try {
// Test that no approval mode arguments defaults to no flags set
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments();
expect(argv.approvalMode).toBeUndefined();
expect(argv.yolo).toBe(false);
expect(argv.prompt).toBe('test');
} finally {
process.argv = originalArgv;
}
});
});
});

View File

@@ -9,10 +9,15 @@ import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import { ShellTool, EditTool, WriteFileTool } from '@qwen-code/qwen-code-core';
import { loadCliConfig, parseArguments } from './config.js';
import { loadCliConfig, parseArguments, CliArgs } from './config.js';
import { Settings } from './settings.js';
import { Extension } from './extension.js';
import * as ServerConfig from '@qwen-code/qwen-code-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
vi.mock('./trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn(),
}));
vi.mock('os', async (importOriginal) => {
const actualOs = await importOriginal<typeof os>();
@@ -156,6 +161,93 @@ describe('parseArguments', () => {
expect(argv.promptInteractive).toBe('interactive prompt');
expect(argv.prompt).toBeUndefined();
});
it('should throw an error when both --yolo and --approval-mode are used together', async () => {
process.argv = [
'node',
'script.js',
'--yolo',
'--approval-mode',
'default',
];
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(parseArguments()).rejects.toThrow('process.exit called');
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
),
);
mockExit.mockRestore();
mockConsoleError.mockRestore();
});
it('should throw an error when using short flags -y and --approval-mode together', async () => {
process.argv = ['node', 'script.js', '-y', '--approval-mode', 'yolo'];
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(parseArguments()).rejects.toThrow('process.exit called');
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining(
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
),
);
mockExit.mockRestore();
mockConsoleError.mockRestore();
});
it('should allow --approval-mode without --yolo', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit'];
const argv = await parseArguments();
expect(argv.approvalMode).toBe('auto_edit');
expect(argv.yolo).toBe(false);
});
it('should allow --yolo without --approval-mode', async () => {
process.argv = ['node', 'script.js', '--yolo'];
const argv = await parseArguments();
expect(argv.yolo).toBe(true);
expect(argv.approvalMode).toBeUndefined();
});
it('should reject invalid --approval-mode values', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'invalid'];
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
await expect(parseArguments()).rejects.toThrow('process.exit called');
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining('Invalid values:'),
);
mockExit.mockRestore();
mockConsoleError.mockRestore();
});
});
describe('loadCliConfig', () => {
@@ -474,6 +566,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
const settings: Settings = {};
const extensions: Extension[] = [
{
path: '/path/to/ext1',
config: {
name: 'ext1',
version: '1.0.0',
@@ -481,6 +574,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
contextFiles: ['/path/to/ext1/QWEN.md'],
},
{
path: '/path/to/ext2',
config: {
name: 'ext2',
version: '1.0.0',
@@ -488,6 +582,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
contextFiles: [],
},
{
path: '/path/to/ext3',
config: {
name: 'ext3',
version: '1.0.0',
@@ -553,6 +648,7 @@ describe('mergeMcpServers', () => {
};
const extensions: Extension[] = [
{
path: '/path/to/ext1',
config: {
name: 'ext1',
version: '1.0.0',
@@ -651,6 +747,7 @@ describe('mergeExcludeTools', () => {
const settings: Settings = { excludeTools: ['tool1', 'tool2'] };
const extensions: Extension[] = [
{
path: '/path/to/ext1',
config: {
name: 'ext1',
version: '1.0.0',
@@ -659,6 +756,7 @@ describe('mergeExcludeTools', () => {
contextFiles: [],
},
{
path: '/path/to/ext2',
config: {
name: 'ext2',
version: '1.0.0',
@@ -685,6 +783,7 @@ describe('mergeExcludeTools', () => {
const settings: Settings = { excludeTools: ['tool1', 'tool2'] };
const extensions: Extension[] = [
{
path: '/path/to/ext1',
config: {
name: 'ext1',
version: '1.0.0',
@@ -834,6 +933,211 @@ describe('mergeExcludeTools', () => {
});
});
describe('Approval mode tool exclusion logic', () => {
const originalIsTTY = process.stdin.isTTY;
beforeEach(() => {
process.stdin.isTTY = false; // Ensure non-interactive mode
});
afterEach(() => {
process.stdin.isTTY = originalIsTTY;
});
it('should exclude all interactive tools in non-interactive mode with default approval mode', async () => {
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments();
const settings: Settings = {};
const extensions: Extension[] = [];
const config = await loadCliConfig(
settings,
extensions,
'test-session',
argv,
);
const excludedTools = config.getExcludeTools();
expect(excludedTools).toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
});
it('should exclude all interactive tools in non-interactive mode with explicit default approval mode', async () => {
process.argv = [
'node',
'script.js',
'--approval-mode',
'default',
'-p',
'test',
];
const argv = await parseArguments();
const settings: Settings = {};
const extensions: Extension[] = [];
const config = await loadCliConfig(
settings,
extensions,
'test-session',
argv,
);
const excludedTools = config.getExcludeTools();
expect(excludedTools).toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
});
it('should exclude only shell tools in non-interactive mode with auto_edit approval mode', async () => {
process.argv = [
'node',
'script.js',
'--approval-mode',
'auto_edit',
'-p',
'test',
];
const argv = await parseArguments();
const settings: Settings = {};
const extensions: Extension[] = [];
const config = await loadCliConfig(
settings,
extensions,
'test-session',
argv,
);
const excludedTools = config.getExcludeTools();
expect(excludedTools).toContain(ShellTool.Name);
expect(excludedTools).not.toContain(EditTool.Name);
expect(excludedTools).not.toContain(WriteFileTool.Name);
});
it('should exclude no interactive tools in non-interactive mode with yolo approval mode', async () => {
process.argv = [
'node',
'script.js',
'--approval-mode',
'yolo',
'-p',
'test',
];
const argv = await parseArguments();
const settings: Settings = {};
const extensions: Extension[] = [];
const config = await loadCliConfig(
settings,
extensions,
'test-session',
argv,
);
const excludedTools = config.getExcludeTools();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).not.toContain(EditTool.Name);
expect(excludedTools).not.toContain(WriteFileTool.Name);
});
it('should exclude no interactive tools in non-interactive mode with legacy yolo flag', async () => {
process.argv = ['node', 'script.js', '--yolo', '-p', 'test'];
const argv = await parseArguments();
const settings: Settings = {};
const extensions: Extension[] = [];
const config = await loadCliConfig(
settings,
extensions,
'test-session',
argv,
);
const excludedTools = config.getExcludeTools();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).not.toContain(EditTool.Name);
expect(excludedTools).not.toContain(WriteFileTool.Name);
});
it('should not exclude interactive tools in interactive mode regardless of approval mode', async () => {
process.stdin.isTTY = true; // Interactive mode
const testCases = [
{ args: ['node', 'script.js'] }, // default
{ args: ['node', 'script.js', '--approval-mode', 'default'] },
{ args: ['node', 'script.js', '--approval-mode', 'auto_edit'] },
{ args: ['node', 'script.js', '--approval-mode', 'yolo'] },
{ args: ['node', 'script.js', '--yolo'] },
];
for (const testCase of testCases) {
process.argv = testCase.args;
const argv = await parseArguments();
const settings: Settings = {};
const extensions: Extension[] = [];
const config = await loadCliConfig(
settings,
extensions,
'test-session',
argv,
);
const excludedTools = config.getExcludeTools();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).not.toContain(EditTool.Name);
expect(excludedTools).not.toContain(WriteFileTool.Name);
}
});
it('should merge approval mode exclusions with settings exclusions in auto_edit mode', async () => {
process.argv = [
'node',
'script.js',
'--approval-mode',
'auto_edit',
'-p',
'test',
];
const argv = await parseArguments();
const settings: Settings = { excludeTools: ['custom_tool'] };
const extensions: Extension[] = [];
const config = await loadCliConfig(
settings,
extensions,
'test-session',
argv,
);
const excludedTools = config.getExcludeTools();
expect(excludedTools).toContain('custom_tool'); // From settings
expect(excludedTools).toContain(ShellTool.Name); // From approval mode
expect(excludedTools).not.toContain(EditTool.Name); // Should be allowed in auto_edit
expect(excludedTools).not.toContain(WriteFileTool.Name); // Should be allowed in auto_edit
});
it('should throw an error for invalid approval mode values in loadCliConfig', async () => {
// Create a mock argv with an invalid approval mode that bypasses argument parsing validation
const invalidArgv: Partial<CliArgs> & { approvalMode: string } = {
approvalMode: 'invalid_mode',
promptInteractive: '',
prompt: '',
yolo: false,
};
const settings: Settings = {};
const extensions: Extension[] = [];
await expect(
loadCliConfig(settings, extensions, 'test-session', invalidArgv),
).rejects.toThrow(
'Invalid approval mode: invalid_mode. Valid values are: yolo, auto_edit, default',
);
});
});
describe('loadCliConfig with allowed-mcp-server-names', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
@@ -1084,33 +1388,6 @@ describe('loadCliConfig model selection', () => {
});
});
describe('loadCliConfig ideModeFeature', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
delete process.env.SANDBOX;
delete process.env.QWEN_CODE_IDE_SERVER_PORT;
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
vi.restoreAllMocks();
});
it('should be false by default', async () => {
process.argv = ['node', 'script.js'];
const settings: Settings = {};
const argv = await parseArguments();
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getIdeModeFeature()).toBe(false);
});
});
describe('loadCliConfig folderTrustFeature', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
@@ -1428,3 +1705,198 @@ describe('loadCliConfig interactive', () => {
expect(config.isInteractive()).toBe(false);
});
});
describe('loadCliConfig approval mode', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
process.argv = ['node', 'script.js']; // Reset argv for each test
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
vi.restoreAllMocks();
});
it('should default to DEFAULT approval mode when no flags are set', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
});
it('should set YOLO approval mode when --yolo flag is used', async () => {
process.argv = ['node', 'script.js', '--yolo'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO);
});
it('should set YOLO approval mode when -y flag is used', async () => {
process.argv = ['node', 'script.js', '-y'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO);
});
it('should set DEFAULT approval mode when --approval-mode=default', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'default'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
});
it('should set AUTO_EDIT approval mode when --approval-mode=auto_edit', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT);
});
it('should set YOLO approval mode when --approval-mode=yolo', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'yolo'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO);
});
it('should prioritize --approval-mode over --yolo when both would be valid (but validation prevents this)', async () => {
// Note: This test documents the intended behavior, but in practice the validation
// prevents both flags from being used together
process.argv = ['node', 'script.js', '--approval-mode', 'default'];
const argv = await parseArguments();
// Manually set yolo to true to simulate what would happen if validation didn't prevent it
argv.yolo = true;
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
});
it('should fall back to --yolo behavior when --approval-mode is not set', async () => {
process.argv = ['node', 'script.js', '--yolo'];
const argv = await parseArguments();
const config = await loadCliConfig({}, [], 'test-session', argv);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO);
});
});
describe('loadCliConfig trustedFolder', () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
process.env.GEMINI_API_KEY = 'test-api-key';
process.argv = ['node', 'script.js']; // Reset argv for each test
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
vi.restoreAllMocks();
});
const testCases = [
// Cases where folderTrustFeature is false (feature disabled)
{
folderTrustFeature: false,
folderTrust: true,
isWorkspaceTrusted: true,
expectedFolderTrust: false,
expectedIsTrustedFolder: true,
description:
'feature disabled, folderTrust true, workspace trusted -> behave as trusted',
},
{
folderTrustFeature: false,
folderTrust: true,
isWorkspaceTrusted: false,
expectedFolderTrust: false,
expectedIsTrustedFolder: true,
description:
'feature disabled, folderTrust true, workspace not trusted -> behave as trusted',
},
{
folderTrustFeature: false,
folderTrust: false,
isWorkspaceTrusted: true,
expectedFolderTrust: false,
expectedIsTrustedFolder: true,
description:
'feature disabled, folderTrust false, workspace trusted -> behave as trusted',
},
// Cases where folderTrustFeature is true but folderTrust setting is false
{
folderTrustFeature: true,
folderTrust: false,
isWorkspaceTrusted: true,
expectedFolderTrust: false,
expectedIsTrustedFolder: true,
description:
'feature on, folderTrust false, workspace trusted -> behave as trusted',
},
{
folderTrustFeature: true,
folderTrust: false,
isWorkspaceTrusted: false,
expectedFolderTrust: false,
expectedIsTrustedFolder: true,
description:
'feature on, folderTrust false, workspace not trusted -> behave as trusted',
},
// Cases where feature is fully enabled (folderTrustFeature and folderTrust are true)
{
folderTrustFeature: true,
folderTrust: true,
isWorkspaceTrusted: true,
expectedFolderTrust: true,
expectedIsTrustedFolder: true,
description:
'feature on, folderTrust on, workspace trusted -> is trusted',
},
{
folderTrustFeature: true,
folderTrust: true,
isWorkspaceTrusted: false,
expectedFolderTrust: true,
expectedIsTrustedFolder: false,
description:
'feature on, folderTrust on, workspace NOT trusted -> is NOT trusted',
},
{
folderTrustFeature: true,
folderTrust: true,
isWorkspaceTrusted: undefined,
expectedFolderTrust: true,
expectedIsTrustedFolder: undefined,
description:
'feature on, folderTrust on, workspace trust unknown -> is unknown',
},
];
for (const {
folderTrustFeature,
folderTrust,
isWorkspaceTrusted: mockTrustValue,
expectedFolderTrust,
expectedIsTrustedFolder,
description,
} of testCases) {
it(`should be correct for: ${description}`, async () => {
(isWorkspaceTrusted as vi.Mock).mockReturnValue(mockTrustValue);
const argv = await parseArguments();
const settings: Settings = { folderTrustFeature, folderTrust };
const config = await loadCliConfig(settings, [], 'test-session', argv);
expect(config.getFolderTrust()).toBe(expectedFolderTrust);
expect(config.isTrustedFolder()).toBe(expectedIsTrustedFolder);
});
}
});

View File

@@ -36,6 +36,8 @@ import { getCliVersion } from '../utils/version.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
// Simple console logger for now - replace with actual logger if available
const logger = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -58,6 +60,7 @@ export interface CliArgs {
showMemoryUsage: boolean | undefined;
show_memory_usage: boolean | undefined;
yolo: boolean | undefined;
approvalMode: string | undefined;
telemetry: boolean | undefined;
checkpointing: boolean | undefined;
telemetryTarget: string | undefined;
@@ -68,7 +71,6 @@ export interface CliArgs {
experimentalAcp: boolean | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
ideModeFeature: boolean | undefined;
openaiLogging: boolean | undefined;
openaiApiKey: string | undefined;
openaiBaseUrl: string | undefined;
@@ -79,6 +81,8 @@ export interface CliArgs {
export async function parseArguments(): Promise<CliArgs> {
const yargsInstance = yargs(hideBin(process.argv))
// Set locale to English for consistent output, especially in tests
.locale('en')
.scriptName('qwen')
.usage(
'Usage: qwen [options] [command]\n\nQwen Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode',
@@ -153,6 +157,12 @@ export async function parseArguments(): Promise<CliArgs> {
'Automatically accept all actions (aka YOLO mode, see https://www.youtube.com/watch?v=xvFZjo5PgG0 for more details)?',
default: false,
})
.option('approval-mode', {
type: 'string',
choices: ['default', 'auto_edit', 'yolo'],
description:
'Set the approval mode: default (prompt for approval), auto_edit (auto-approve edit tools), yolo (auto-approve all tools)',
})
.option('telemetry', {
type: 'boolean',
description:
@@ -205,10 +215,6 @@ export async function parseArguments(): Promise<CliArgs> {
type: 'boolean',
description: 'List all available extensions and exit.',
})
.option('ide-mode-feature', {
type: 'boolean',
description: 'Run in IDE mode?',
})
.option('proxy', {
type: 'string',
description:
@@ -246,6 +252,11 @@ export async function parseArguments(): Promise<CliArgs> {
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
);
}
if (argv.yolo && argv.approvalMode) {
throw new Error(
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
);
}
return true;
}),
)
@@ -319,6 +330,7 @@ export async function loadCliConfig(
extensions: Extension[],
sessionId: string,
argv: CliArgs,
cwd: string = process.cwd(),
): Promise<Config> {
const debugMode =
argv.debug ||
@@ -329,12 +341,11 @@ export async function loadCliConfig(
const memoryImportFormat = settings.memoryImportFormat || 'tree';
const ideMode = settings.ideMode ?? false;
const ideModeFeature =
argv.ideModeFeature ?? settings.ideModeFeature ?? false;
const folderTrustFeature = settings.folderTrustFeature ?? false;
const folderTrustSetting = settings.folderTrust ?? false;
const folderTrustSetting = settings.folderTrust ?? true;
const folderTrust = folderTrustFeature && folderTrustSetting;
const trustedFolder = folderTrust ? isWorkspaceTrusted() : true;
const allExtensions = annotateActiveExtensions(
extensions,
@@ -374,7 +385,7 @@ export async function loadCliConfig(
(e) => e.contextFiles,
);
const fileService = new FileDiscoveryService(process.cwd());
const fileService = new FileDiscoveryService(cwd);
const fileFiltering = {
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
@@ -387,7 +398,7 @@ export async function loadCliConfig(
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
process.cwd(),
cwd,
settings.loadMemoryFromIncludeDirectories ? includeDirectories : [],
debugMode,
fileService,
@@ -399,20 +410,59 @@ export async function loadCliConfig(
let mcpServers = mergeMcpServers(settings, activeExtensions);
const question = argv.promptInteractive || argv.prompt || '';
const approvalMode =
argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT;
// Determine approval mode with backward compatibility
let approvalMode: ApprovalMode;
if (argv.approvalMode) {
// New --approval-mode flag takes precedence
switch (argv.approvalMode) {
case 'yolo':
approvalMode = ApprovalMode.YOLO;
break;
case 'auto_edit':
approvalMode = ApprovalMode.AUTO_EDIT;
break;
case 'default':
approvalMode = ApprovalMode.DEFAULT;
break;
default:
throw new Error(
`Invalid approval mode: ${argv.approvalMode}. Valid values are: yolo, auto_edit, default`,
);
}
} else {
// Fallback to legacy --yolo flag behavior
approvalMode =
argv.yolo || false ? ApprovalMode.YOLO : ApprovalMode.DEFAULT;
}
const interactive =
!!argv.promptInteractive || (process.stdin.isTTY && question.length === 0);
// In non-interactive and non-yolo mode, exclude interactive built in tools.
const extraExcludes =
!interactive && approvalMode !== ApprovalMode.YOLO
? [ShellTool.Name, EditTool.Name, WriteFileTool.Name]
: undefined;
// In non-interactive mode, exclude tools that require a prompt.
const extraExcludes: string[] = [];
if (!interactive && !argv.experimentalAcp) {
switch (approvalMode) {
case ApprovalMode.DEFAULT:
// In default non-interactive mode, all tools that require approval are excluded.
extraExcludes.push(ShellTool.Name, EditTool.Name, WriteFileTool.Name);
break;
case ApprovalMode.AUTO_EDIT:
// In auto-edit non-interactive mode, only tools that still require a prompt are excluded.
extraExcludes.push(ShellTool.Name);
break;
case ApprovalMode.YOLO:
// No extra excludes for YOLO mode.
break;
default:
// This should never happen due to validation earlier, but satisfies the linter
break;
}
}
const excludeTools = mergeExcludeTools(
settings,
activeExtensions,
extraExcludes,
extraExcludes.length > 0 ? extraExcludes : undefined,
);
const blockedMcpServers: Array<{ name: string; extensionName: string }> = [];
@@ -450,7 +500,7 @@ export async function loadCliConfig(
sessionId,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
sandbox: sandboxConfig,
targetDir: process.cwd(),
targetDir: cwd,
includeDirectories,
loadMemoryFromIncludeDirectories:
settings.loadMemoryFromIncludeDirectories || false,
@@ -498,21 +548,20 @@ export async function loadCliConfig(
process.env.https_proxy ||
process.env.HTTP_PROXY ||
process.env.http_proxy,
cwd: process.cwd(),
cwd,
fileDiscoveryService: fileService,
bugCommand: settings.bugCommand,
model: argv.model || settings.model || DEFAULT_GEMINI_MODEL,
extensionContextFilePaths,
maxSessionTurns: settings.maxSessionTurns ?? -1,
sessionTokenLimit: settings.sessionTokenLimit ?? -1,
experimentalAcp: argv.experimentalAcp || false,
experimentalZedIntegration: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
extensions: allExtensions,
blockedMcpServers,
noBrowser: !!process.env.NO_BROWSER,
summarizeToolOutput: settings.summarizeToolOutput,
ideMode,
ideModeFeature,
enableOpenAILogging:
(typeof argv.openaiLogging === 'undefined'
? settings.enableOpenAILogging
@@ -536,6 +585,7 @@ export async function loadCliConfig(
folderTrustFeature,
folderTrust,
interactive,
trustedFolder,
});
}

View File

@@ -129,20 +129,24 @@ export const defaultKeyBindings: KeyBindingConfig = {
// Text input
// Original: key.name === 'return' && !key.ctrl && !key.meta && !key.paste
// Must also exclude shift to allow shift+enter for newline
[Command.SUBMIT]: [
{
key: 'return',
ctrl: false,
command: false,
paste: false,
shift: false,
},
],
// Original: key.name === 'return' && (key.ctrl || key.meta || key.paste)
// Split into multiple data-driven bindings
// Now also includes shift+enter for multi-line input
[Command.NEWLINE]: [
{ key: 'return', ctrl: true },
{ key: 'return', command: true },
{ key: 'return', paste: true },
{ key: 'return', shift: true },
],
// External tools

View File

@@ -44,7 +44,6 @@ describe('SettingsSchema', () => {
'telemetry',
'bugCommand',
'summarizeToolOutput',
'ideModeFeature',
'dnsResolutionOrder',
'excludedProjectEnvVars',
'disableUpdateNag',

View File

@@ -395,15 +395,7 @@ export const SETTINGS_SCHEMA = {
description: 'Settings for summarizing tool output.',
showInDialog: false,
},
ideModeFeature: {
type: 'boolean',
label: 'IDE Mode Feature Flag',
category: 'Advanced',
requiresRestart: true,
default: undefined as boolean | undefined,
description: 'Internal feature flag for IDE mode.',
showInDialog: false,
},
dnsResolutionOrder: {
type: 'string',
label: 'DNS Resolution Order',

View File

@@ -0,0 +1,203 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Mock 'os' first.
import * as osActual from 'os';
vi.mock('os', async (importOriginal) => {
const actualOs = await importOriginal<typeof osActual>();
return {
...actualOs,
homedir: vi.fn(() => '/mock/home/user'),
platform: vi.fn(() => 'linux'),
};
});
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mocked,
type Mock,
} from 'vitest';
import * as fs from 'fs';
import stripJsonComments from 'strip-json-comments';
import * as path from 'path';
import {
loadTrustedFolders,
USER_TRUSTED_FOLDERS_PATH,
TrustLevel,
isWorkspaceTrusted,
} from './trustedFolders.js';
vi.mock('fs', async (importOriginal) => {
const actualFs = await importOriginal<typeof fs>();
return {
...actualFs,
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
};
});
vi.mock('strip-json-comments', () => ({
default: vi.fn((content) => content),
}));
describe('Trusted Folders Loading', () => {
let mockFsExistsSync: Mocked<typeof fs.existsSync>;
let mockStripJsonComments: Mocked<typeof stripJsonComments>;
let mockFsWriteFileSync: Mocked<typeof fs.writeFileSync>;
beforeEach(() => {
vi.resetAllMocks();
mockFsExistsSync = vi.mocked(fs.existsSync);
mockStripJsonComments = vi.mocked(stripJsonComments);
mockFsWriteFileSync = vi.mocked(fs.writeFileSync);
vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user');
(mockStripJsonComments as unknown as Mock).mockImplementation(
(jsonString: string) => jsonString,
);
(mockFsExistsSync as Mock).mockReturnValue(false);
(fs.readFileSync as Mock).mockReturnValue('{}');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should load empty rules if no files exist', () => {
const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([]);
expect(errors).toEqual([]);
});
it('should load user rules if only user file exists', () => {
const userPath = USER_TRUSTED_FOLDERS_PATH;
(mockFsExistsSync as Mock).mockImplementation((p) => p === userPath);
const userContent = {
'/user/folder': TrustLevel.TRUST_FOLDER,
};
(fs.readFileSync as Mock).mockImplementation((p) => {
if (p === userPath) return JSON.stringify(userContent);
return '{}';
});
const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([
{ path: '/user/folder', trustLevel: TrustLevel.TRUST_FOLDER },
]);
expect(errors).toEqual([]);
});
it('should handle JSON parsing errors gracefully', () => {
const userPath = USER_TRUSTED_FOLDERS_PATH;
(mockFsExistsSync as Mock).mockImplementation((p) => p === userPath);
(fs.readFileSync as Mock).mockImplementation((p) => {
if (p === userPath) return 'invalid json';
return '{}';
});
const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([]);
expect(errors.length).toBe(1);
expect(errors[0].path).toBe(userPath);
expect(errors[0].message).toContain('Unexpected token');
});
it('setValue should update the user config and save it', () => {
const loadedFolders = loadTrustedFolders();
loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER);
expect(loadedFolders.user.config['/new/path']).toBe(
TrustLevel.TRUST_FOLDER,
);
expect(mockFsWriteFileSync).toHaveBeenCalledWith(
USER_TRUSTED_FOLDERS_PATH,
JSON.stringify({ '/new/path': TrustLevel.TRUST_FOLDER }, null, 2),
'utf-8',
);
});
});
describe('isWorkspaceTrusted', () => {
let mockCwd: string;
const mockRules: Record<string, TrustLevel> = {};
beforeEach(() => {
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
if (p === USER_TRUSTED_FOLDERS_PATH) {
return JSON.stringify(mockRules);
}
return '{}';
});
vi.spyOn(fs, 'existsSync').mockImplementation(
(p) => p === USER_TRUSTED_FOLDERS_PATH,
);
});
afterEach(() => {
vi.restoreAllMocks();
// Clear the object
Object.keys(mockRules).forEach((key) => delete mockRules[key]);
});
it('should return true for a directly trusted folder', () => {
mockCwd = '/home/user/projectA';
mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER;
expect(isWorkspaceTrusted()).toBe(true);
});
it('should return true for a child of a trusted folder', () => {
mockCwd = '/home/user/projectA/src';
mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER;
expect(isWorkspaceTrusted()).toBe(true);
});
it('should return true for a child of a trusted parent folder', () => {
mockCwd = '/home/user/projectB';
mockRules['/home/user/projectB/somefile.txt'] = TrustLevel.TRUST_PARENT;
expect(isWorkspaceTrusted()).toBe(true);
});
it('should return false for a directly untrusted folder', () => {
mockCwd = '/home/user/untrusted';
mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST;
expect(isWorkspaceTrusted()).toBe(false);
});
it('should return undefined for a child of an untrusted folder', () => {
mockCwd = '/home/user/untrusted/src';
mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST;
expect(isWorkspaceTrusted()).toBeUndefined();
});
it('should return undefined when no rules match', () => {
mockCwd = '/home/user/other';
mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER;
mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST;
expect(isWorkspaceTrusted()).toBeUndefined();
});
it('should prioritize trust over distrust', () => {
mockCwd = '/home/user/projectA/untrusted';
mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER;
mockRules['/home/user/projectA/untrusted'] = TrustLevel.DO_NOT_TRUST;
expect(isWorkspaceTrusted()).toBe(true);
});
it('should handle path normalization', () => {
mockCwd = '/home/user/projectA';
mockRules[`/home/user/../user/${path.basename('/home/user/projectA')}`] =
TrustLevel.TRUST_FOLDER;
expect(isWorkspaceTrusted()).toBe(true);
});
});

View File

@@ -0,0 +1,158 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'fs';
import * as path from 'path';
import { homedir } from 'os';
import { getErrorMessage, isWithinRoot } from '@qwen-code/qwen-code-core';
import stripJsonComments from 'strip-json-comments';
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
export const SETTINGS_DIRECTORY_NAME = '.qwen';
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
export const USER_TRUSTED_FOLDERS_PATH = path.join(
USER_SETTINGS_DIR,
TRUSTED_FOLDERS_FILENAME,
);
export enum TrustLevel {
TRUST_FOLDER = 'TRUST_FOLDER',
TRUST_PARENT = 'TRUST_PARENT',
DO_NOT_TRUST = 'DO_NOT_TRUST',
}
export interface TrustRule {
path: string;
trustLevel: TrustLevel;
}
export interface TrustedFoldersError {
message: string;
path: string;
}
export interface TrustedFoldersFile {
config: Record<string, TrustLevel>;
path: string;
}
export class LoadedTrustedFolders {
constructor(
public user: TrustedFoldersFile,
public errors: TrustedFoldersError[],
) {}
get rules(): TrustRule[] {
return Object.entries(this.user.config).map(([path, trustLevel]) => ({
path,
trustLevel,
}));
}
setValue(path: string, trustLevel: TrustLevel): void {
this.user.config[path] = trustLevel;
saveTrustedFolders(this.user);
}
}
export function loadTrustedFolders(): LoadedTrustedFolders {
const errors: TrustedFoldersError[] = [];
const userConfig: Record<string, TrustLevel> = {};
const userPath = USER_TRUSTED_FOLDERS_PATH;
// Load user trusted folders
try {
if (fs.existsSync(userPath)) {
const content = fs.readFileSync(userPath, 'utf-8');
const parsed = JSON.parse(stripJsonComments(content)) as Record<
string,
TrustLevel
>;
if (parsed) {
Object.assign(userConfig, parsed);
}
}
} catch (error: unknown) {
errors.push({
message: getErrorMessage(error),
path: userPath,
});
}
return new LoadedTrustedFolders(
{ path: userPath, config: userConfig },
errors,
);
}
export function saveTrustedFolders(
trustedFoldersFile: TrustedFoldersFile,
): void {
try {
// Ensure the directory exists
const dirPath = path.dirname(trustedFoldersFile.path);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
fs.writeFileSync(
trustedFoldersFile.path,
JSON.stringify(trustedFoldersFile.config, null, 2),
'utf-8',
);
} catch (error) {
console.error('Error saving trusted folders file:', error);
}
}
export function isWorkspaceTrusted(): boolean | undefined {
const { rules, errors } = loadTrustedFolders();
if (errors.length > 0) {
for (const error of errors) {
console.error(
`Error loading trusted folders config from ${error.path}: ${error.message}`,
);
}
}
const trustedPaths: string[] = [];
const untrustedPaths: string[] = [];
for (const rule of rules) {
switch (rule.trustLevel) {
case TrustLevel.TRUST_FOLDER:
trustedPaths.push(rule.path);
break;
case TrustLevel.TRUST_PARENT:
trustedPaths.push(path.dirname(rule.path));
break;
case TrustLevel.DO_NOT_TRUST:
untrustedPaths.push(rule.path);
break;
default:
// Do nothing for unknown trust levels.
break;
}
}
const cwd = process.cwd();
for (const trustedPath of trustedPaths) {
if (isWithinRoot(cwd, trustedPath)) {
return true;
}
}
for (const untrustedPath of untrustedPaths) {
if (path.normalize(cwd) === path.normalize(untrustedPath)) {
return false;
}
}
return undefined;
}

View File

@@ -41,6 +41,7 @@ import {
import { validateAuthMethod } from './config/auth.js';
import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
import { appEvents, AppEvent } from './utils/events.js';
@@ -106,7 +107,7 @@ async function relaunchWithAdditionalArgs(additionalArgs: string[]) {
await new Promise((resolve) => child.on('close', resolve));
process.exit(0);
}
import { runAcpPeer } from './acp/acpPeer.js';
import { runZedIntegration } from './zed-integration/zedIntegration.js';
export function setupUnhandledRejectionHandler() {
let unhandledRejectionOccurred = false;
@@ -191,7 +192,7 @@ export async function main() {
await config.initialize();
if (config.getIdeMode() && config.getIdeModeFeature()) {
if (config.getIdeMode()) {
await config.getIdeClient().connect();
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
}
@@ -250,8 +251,8 @@ export async function main() {
await getOauthClient(settings.merged.selectedAuthType, config);
}
if (config.getExperimentalAcp()) {
return runAcpPeer(config, settings);
if (config.getExperimentalZedIntegration()) {
return runZedIntegration(config, settings, extensions, argv);
}
let input = config.getQuestion();
@@ -263,6 +264,8 @@ export async function main() {
// Render UI, passing necessary config values. Check that there is no command line question.
if (config.isInteractive()) {
const version = await getCliVersion();
// Detect and enable Kitty keyboard protocol once at startup
await detectAndEnableKittyProtocol();
setWindowTitle(basename(workspaceRoot), settings);
const instance = render(
<React.StrictMode>

View File

@@ -13,10 +13,10 @@ import {
isTelemetrySdkInitialized,
GeminiEventType,
ToolErrorType,
parseAndFormatApiError,
} from '@qwen-code/qwen-code-core';
import { Content, Part, FunctionCall } from '@google/genai';
import { parseAndFormatApiError } from './ui/utils/errorParsing.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
export async function runNonInteractive(
@@ -143,7 +143,7 @@ export async function runNonInteractive(
} finally {
consolePatcher.cleanup();
if (isTelemetrySdkInitialized()) {
await shutdownTelemetry();
await shutdownTelemetry(config);
}
}
}

View File

@@ -33,6 +33,7 @@ import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
/**
* Loads the core, hard-coded slash commands that are an integral part
@@ -76,6 +77,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
settingsCommand,
vimCommand,
setupGithubCommand,
terminalSetupCommand,
];
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);

View File

@@ -155,13 +155,13 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
setFlashFallbackHandler: vi.fn(),
getSessionId: vi.fn(() => 'test-session-id'),
getUserTier: vi.fn().mockResolvedValue(undefined),
getIdeModeFeature: vi.fn(() => false),
getIdeMode: vi.fn(() => false),
getIdeMode: vi.fn(() => true),
getWorkspaceContext: vi.fn(() => ({
getDirectories: vi.fn(() => []),
})),
getIdeClient: vi.fn(() => ({
getCurrentIde: vi.fn(() => 'vscode'),
getDetectedIdeDisplayName: vi.fn(() => 'VSCode'),
})),
};
});

View File

@@ -82,6 +82,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js';
import { useVim } from './hooks/vim.js';
import { useKeypress, Key } from './hooks/useKeypress.js';
import { useKittyKeyboardProtocol } from './hooks/useKittyKeyboardProtocol.js';
import { keyMatchers, Command } from './keyMatchers.js';
import * as fs from 'fs';
import { UpdateNotification } from './components/UpdateNotification.js';
@@ -132,7 +133,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
registerCleanup(() => config.getIdeClient().disconnect());
}, [config]);
const shouldShowIdePrompt =
config.getIdeModeFeature() &&
currentIDE &&
!config.getIdeMode() &&
!settings.merged.hasSeenIdeIntegrationNudge &&
@@ -254,8 +254,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
useSettingsCommand();
const { isFolderTrustDialogOpen, handleFolderTrustSelect } =
useFolderTrust(settings);
const { isFolderTrustDialogOpen, handleFolderTrustSelect } = useFolderTrust(
settings,
config,
);
const {
isAuthDialogOpen,
@@ -608,14 +610,18 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const handleIdePromptComplete = useCallback(
(result: IdeIntegrationNudgeResult) => {
if (result === 'yes') {
handleSlashCommand('/ide install');
if (result.userSelection === 'yes') {
if (result.isExtensionPreInstalled) {
handleSlashCommand('/ide enable');
} else {
handleSlashCommand('/ide install');
}
settings.setValue(
SettingScope.User,
'hasSeenIdeIntegrationNudge',
true,
);
} else if (result === 'dismiss') {
} else if (result.userSelection === 'dismiss') {
settings.setValue(
SettingScope.User,
'hasSeenIdeIntegrationNudge',
@@ -634,6 +640,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const { elapsedTime, currentLoadingPhrase } =
useLoadingIndicator(streamingState);
const showAutoAcceptIndicator = useAutoAcceptIndicator({ config });
const kittyProtocolStatus = useKittyKeyboardProtocol();
const handleExit = useCallback(
(
@@ -726,7 +733,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
],
);
useKeypress(handleGlobalKeypress, { isActive: true });
useKeypress(handleGlobalKeypress, {
isActive: true,
kittyProtocolEnabled: kittyProtocolStatus.enabled,
config,
});
useEffect(() => {
if (config) {
@@ -974,9 +985,9 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
</Box>
)}
{shouldShowIdePrompt ? (
{shouldShowIdePrompt && currentIDE ? (
<IdeIntegrationNudge
ideName={config.getIdeClient().getDetectedIdeDisplayName()}
ide={currentIDE}
onComplete={handleIdePromptComplete}
/>
) : isFolderTrustDialogOpen ? (

View File

@@ -4,44 +4,78 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text, useInput } from 'ink';
import { DetectedIde, getIdeInfo } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import {
RadioButtonSelect,
RadioSelectItem,
} from './components/shared/RadioButtonSelect.js';
import { useKeypress } from './hooks/useKeypress.js';
export type IdeIntegrationNudgeResult = 'yes' | 'no' | 'dismiss';
export type IdeIntegrationNudgeResult = {
userSelection: 'yes' | 'no' | 'dismiss';
isExtensionPreInstalled: boolean;
};
interface IdeIntegrationNudgeProps {
ideName?: string;
ide: DetectedIde;
onComplete: (result: IdeIntegrationNudgeResult) => void;
}
export function IdeIntegrationNudge({
ideName,
ide,
onComplete,
}: IdeIntegrationNudgeProps) {
useInput((_input, key) => {
if (key.escape) {
onComplete('no');
}
});
useKeypress(
(key) => {
if (key.name === 'escape') {
onComplete({
userSelection: 'no',
isExtensionPreInstalled: false,
});
}
},
{ isActive: true },
);
const { displayName: ideName } = getIdeInfo(ide);
// Assume extension is already installed if the env variables are set.
const isExtensionPreInstalled =
!!process.env.GEMINI_CLI_IDE_SERVER_PORT &&
!!process.env.GEMINI_CLI_IDE_WORKSPACE_PATH;
const OPTIONS: Array<RadioSelectItem<IdeIntegrationNudgeResult>> = [
{
label: 'Yes',
value: 'yes',
value: {
userSelection: 'yes',
isExtensionPreInstalled,
},
},
{
label: 'No (esc)',
value: 'no',
value: {
userSelection: 'no',
isExtensionPreInstalled,
},
},
{
label: "No, don't ask again",
value: 'dismiss',
value: {
userSelection: 'dismiss',
isExtensionPreInstalled,
},
},
];
const installText = isExtensionPreInstalled
? `If you select Yes, the CLI will have access to your open files and display diffs directly in ${
ideName ?? 'your editor'
}.`
: `If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${
ideName ?? 'your editor'
}.`;
return (
<Box
flexDirection="column"
@@ -54,11 +88,9 @@ export function IdeIntegrationNudge({
<Box marginBottom={1} flexDirection="column">
<Text>
<Text color="yellow">{'> '}</Text>
{`Do you want to connect your ${ideName ?? 'your'} editor to Gemini CLI?`}
{`Do you want to connect ${ideName ?? 'your'} editor to Gemini CLI?`}
</Text>
<Text
dimColor
>{`If you select Yes, we'll install an extension that allows the CLI to access your open files and display diffs directly in ${ideName ?? 'your editor'}.`}</Text>
<Text dimColor>{installText}</Text>
</Box>
<RadioButtonSelect
items={OPTIONS}

View File

@@ -138,13 +138,11 @@ export const directoryCommand: SlashCommand = {
if (errors.length > 0) {
addItem(
{
type: MessageType.ERROR,
text: errors.join('\n'),
},
{ type: MessageType.ERROR, text: errors.join('\n') },
Date.now(),
);
}
return;
},
},
{

View File

@@ -40,7 +40,6 @@ describe('ideCommand', () => {
} as unknown as CommandContext;
mockConfig = {
getIdeModeFeature: vi.fn(),
getIdeMode: vi.fn(),
getIdeClient: vi.fn(() => ({
reconnect: vi.fn(),
@@ -60,14 +59,12 @@ describe('ideCommand', () => {
vi.restoreAllMocks();
});
it('should return null if ideModeFeature is not enabled', () => {
vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(false);
const command = ideCommand(mockConfig);
it('should return null if config is not provided', () => {
const command = ideCommand(null);
expect(command).toBeNull();
});
it('should return the ide command if ideModeFeature is enabled', () => {
vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(true);
it('should return the ide command', () => {
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getCurrentIde: () => DetectedIde.VSCode,
@@ -85,7 +82,6 @@ describe('ideCommand', () => {
describe('status subcommand', () => {
const mockGetConnectionStatus = vi.fn();
beforeEach(() => {
vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(true);
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getConnectionStatus: mockGetConnectionStatus,
getCurrentIde: () => DetectedIde.VSCode,
@@ -162,7 +158,6 @@ describe('ideCommand', () => {
describe('install subcommand', () => {
const mockInstall = vi.fn();
beforeEach(() => {
vi.mocked(mockConfig.getIdeModeFeature).mockReturnValue(true);
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
getCurrentIde: () => DetectedIde.VSCode,

View File

@@ -9,7 +9,7 @@ import {
DetectedIde,
QWEN_CODE_COMPANION_EXTENSION_NAME,
IDEConnectionStatus,
getIdeDisplayName,
getIdeInfo,
getIdeInstaller,
IdeClient,
type File,
@@ -116,7 +116,7 @@ async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{
}
export const ideCommand = (config: Config | null): SlashCommand | null => {
if (!config || !config.getIdeModeFeature()) {
if (!config) {
return null;
}
const ideClient = config.getIdeClient();
@@ -133,7 +133,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
content: `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))
.map((ide) => getIdeInfo(ide).displayName)
.join(', ')}`,
}) as const,
};

View File

@@ -881,9 +881,14 @@ describe('mcpCommand', () => {
}),
getToolRegistry: vi.fn().mockResolvedValue(mockToolRegistry),
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
getPromptRegistry: vi.fn().mockResolvedValue({
removePromptsByServer: vi.fn(),
}),
},
},
});
// Mock the reloadCommands function
context.ui.reloadCommands = vi.fn();
const { MCPOAuthProvider } = await import('@qwen-code/qwen-code-core');
@@ -901,6 +906,7 @@ describe('mcpCommand', () => {
'test-server',
);
expect(mockGeminiClient.setTools).toHaveBeenCalled();
expect(context.ui.reloadCommands).toHaveBeenCalledTimes(1);
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
@@ -985,6 +991,8 @@ describe('mcpCommand', () => {
},
},
});
// Mock the reloadCommands function, which is new logic.
context.ui.reloadCommands = vi.fn();
const refreshCommand = mcpCommand.subCommands?.find(
(cmd) => cmd.name === 'refresh',
@@ -1002,6 +1010,7 @@ describe('mcpCommand', () => {
);
expect(mockToolRegistry.discoverMcpTools).toHaveBeenCalled();
expect(mockGeminiClient.setTools).toHaveBeenCalled();
expect(context.ui.reloadCommands).toHaveBeenCalledTimes(1);
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {

View File

@@ -417,6 +417,9 @@ const authCommand: SlashCommand = {
await geminiClient.setTools();
}
// Reload the slash commands to reflect the changes.
context.ui.reloadCommands();
return {
type: 'message',
messageType: 'info',
@@ -507,6 +510,9 @@ const refreshCommand: SlashCommand = {
await geminiClient.setTools();
}
// Reload the slash commands to reflect the changes.
context.ui.reloadCommands();
return getMcpStatus(context, false, false, false);
},
};

View File

@@ -30,7 +30,7 @@ describe('settingsCommand', () => {
it('should have the correct name and description', () => {
expect(settingsCommand.name).toBe('settings');
expect(settingsCommand.description).toBe(
'View and edit Gemini CLI settings',
'View and edit Qwen Code settings',
);
});
});

View File

@@ -8,7 +8,7 @@ import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
export const settingsCommand: SlashCommand = {
name: 'settings',
description: 'View and edit Gemini CLI settings',
description: 'View and edit Qwen Code settings',
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog',

View File

@@ -0,0 +1,85 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { terminalSetupCommand } from './terminalSetupCommand.js';
import * as terminalSetupModule from '../utils/terminalSetup.js';
import { CommandContext } from './types.js';
vi.mock('../utils/terminalSetup.js');
describe('terminalSetupCommand', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should have correct metadata', () => {
expect(terminalSetupCommand.name).toBe('terminal-setup');
expect(terminalSetupCommand.description).toContain('multiline input');
expect(terminalSetupCommand.kind).toBe('built-in');
});
it('should return success message when terminal setup succeeds', async () => {
vi.spyOn(terminalSetupModule, 'terminalSetup').mockResolvedValue({
success: true,
message: 'Terminal configured successfully',
});
const result = await terminalSetupCommand.action({} as CommandContext, '');
expect(result).toEqual({
type: 'message',
content: 'Terminal configured successfully',
messageType: 'info',
});
});
it('should append restart message when terminal setup requires restart', async () => {
vi.spyOn(terminalSetupModule, 'terminalSetup').mockResolvedValue({
success: true,
message: 'Terminal configured successfully',
requiresRestart: true,
});
const result = await terminalSetupCommand.action({} as CommandContext, '');
expect(result).toEqual({
type: 'message',
content:
'Terminal configured successfully\n\nPlease restart your terminal for the changes to take effect.',
messageType: 'info',
});
});
it('should return error message when terminal setup fails', async () => {
vi.spyOn(terminalSetupModule, 'terminalSetup').mockResolvedValue({
success: false,
message: 'Failed to detect terminal',
});
const result = await terminalSetupCommand.action({} as CommandContext, '');
expect(result).toEqual({
type: 'message',
content: 'Failed to detect terminal',
messageType: 'error',
});
});
it('should handle exceptions from terminal setup', async () => {
vi.spyOn(terminalSetupModule, 'terminalSetup').mockRejectedValue(
new Error('Unexpected error'),
);
const result = await terminalSetupCommand.action({} as CommandContext, '');
expect(result).toEqual({
type: 'message',
content: 'Failed to configure terminal: Error: Unexpected error',
messageType: 'error',
});
});
});

View File

@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { MessageActionReturn, SlashCommand, CommandKind } from './types.js';
import { terminalSetup } from '../utils/terminalSetup.js';
/**
* Command to configure terminal keybindings for multiline input support.
*
* This command automatically detects and configures VS Code, Cursor, and Windsurf
* to support Shift+Enter and Ctrl+Enter for multiline input.
*/
export const terminalSetupCommand: SlashCommand = {
name: 'terminal-setup',
description:
'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf)',
kind: CommandKind.BUILT_IN,
action: async (): Promise<MessageActionReturn> => {
try {
const result = await terminalSetup();
let content = result.message;
if (result.requiresRestart) {
content +=
'\n\nPlease restart your terminal for the changes to take effect.';
}
return {
type: 'message',
content,
messageType: result.success ? 'info' : 'error',
};
} catch (error) {
return {
type: 'message',
content: `Failed to configure terminal: ${error}`,
messageType: 'error',
};
}
},
};

View File

@@ -61,6 +61,7 @@ export interface CommandContext {
toggleCorgiMode: () => void;
toggleVimEnabled: () => Promise<boolean>;
setGeminiMdFileCount: (count: number) => void;
reloadCommands: () => void;
};
// Session-specific data
session: {

View File

@@ -4,19 +4,20 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Colors } from '../colors.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import React, { useState } from 'react';
import {
validateAuthMethod,
setOpenAIApiKey,
setOpenAIBaseUrl,
setOpenAIModel,
validateAuthMethod,
} from '../../config/auth.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { OpenAIKeyPrompt } from './OpenAIKeyPrompt.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
interface AuthDialogProps {
onSelect: (authMethod: AuthType | undefined, scope: SettingScope) => void;
@@ -108,27 +109,30 @@ export function AuthDialog({
setErrorMessage('OpenAI API key is required to use OpenAI authentication.');
};
useInput((_input, key) => {
if (showOpenAIKeyPrompt) {
return;
}
useKeypress(
(key) => {
if (showOpenAIKeyPrompt) {
return;
}
if (key.escape) {
// Prevent exit if there is an error message.
// This means they user is not authenticated yet.
if (errorMessage) {
return;
if (key.name === 'escape') {
// Prevent exit if there is an error message.
// This means they user is not authenticated yet.
if (errorMessage) {
return;
}
if (settings.merged.selectedAuthType === undefined) {
// Prevent exiting if no auth method is set
setErrorMessage(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
);
return;
}
onSelect(undefined, SettingScope.User);
}
if (settings.merged.selectedAuthType === undefined) {
// Prevent exiting if no auth method is set
setErrorMessage(
'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
);
return;
}
onSelect(undefined, SettingScope.User);
}
});
},
{ isActive: true },
);
if (showOpenAIKeyPrompt) {
return (

View File

@@ -5,9 +5,10 @@
*/
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface AuthInProgressProps {
onTimeout: () => void;
@@ -18,11 +19,14 @@ export function AuthInProgress({
}: AuthInProgressProps): React.JSX.Element {
const [timedOut, setTimedOut] = useState(false);
useInput((input, key) => {
if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) {
onTimeout();
}
});
useKeypress(
(key) => {
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
onTimeout();
}
},
{ isActive: true },
);
useEffect(() => {
const timer = setTimeout(() => {

View File

@@ -4,9 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Text, useInput } from 'ink';
import { Text } from 'ink';
import { useEffect, useRef, useState } from 'react';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
export const DebugProfiler = () => {
const numRenders = useRef(0);
@@ -16,11 +17,14 @@ export const DebugProfiler = () => {
numRenders.current++;
});
useInput((input, key) => {
if (key.ctrl && input === 'b') {
setShowNumRenders((prev) => !prev);
}
});
useKeypress(
(key) => {
if (key.ctrl && key.name === 'b') {
setShowNumRenders((prev) => !prev);
}
},
{ isActive: true },
);
if (!showNumRenders) {
return null;

View File

@@ -5,7 +5,7 @@
*/
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import {
EDITOR_DISPLAY_NAMES,
@@ -15,6 +15,7 @@ import {
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { EditorType, isEditorAvailable } from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js';
interface EditorDialogProps {
onSelect: (editorType: EditorType | undefined, scope: SettingScope) => void;
@@ -33,14 +34,17 @@ export function EditorSettingsDialog({
const [focusedSection, setFocusedSection] = useState<'editor' | 'scope'>(
'editor',
);
useInput((_, key) => {
if (key.tab) {
setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor'));
}
if (key.escape) {
onExit();
}
});
useKeypress(
(key) => {
if (key.name === 'tab') {
setFocusedSection((prev) => (prev === 'editor' ? 'scope' : 'editor'));
}
if (key.name === 'escape') {
onExit();
}
},
{ isActive: true },
);
const editorItems: EditorDisplay[] =
editorSettingsManager.getAvailableEditorDisplays();

View File

@@ -5,6 +5,7 @@
*/
import { render } from 'ink-testing-library';
import { waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import { FolderTrustDialog, FolderTrustChoice } from './FolderTrustDialog.js';
@@ -18,12 +19,14 @@ describe('FolderTrustDialog', () => {
);
});
it('should call onSelect with DO_NOT_TRUST when escape is pressed', () => {
it('should call onSelect with DO_NOT_TRUST when escape is pressed', async () => {
const onSelect = vi.fn();
const { stdin } = render(<FolderTrustDialog onSelect={onSelect} />);
stdin.write('\u001B'); // Simulate escape key
stdin.write('\x1b');
expect(onSelect).toHaveBeenCalledWith(FolderTrustChoice.DO_NOT_TRUST);
await waitFor(() => {
expect(onSelect).toHaveBeenCalledWith(FolderTrustChoice.DO_NOT_TRUST);
});
});
});

View File

@@ -4,13 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import React from 'react';
import { Colors } from '../colors.js';
import {
RadioButtonSelect,
RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
export enum FolderTrustChoice {
TRUST_FOLDER = 'trust_folder',
@@ -25,11 +26,14 @@ interface FolderTrustDialogProps {
export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
onSelect,
}) => {
useInput((_, key) => {
if (key.escape) {
onSelect(FolderTrustChoice.DO_NOT_TRUST);
}
});
useKeypress(
(key) => {
if (key.name === 'escape') {
onSelect(FolderTrustChoice.DO_NOT_TRUST);
}
},
{ isActive: true },
);
const options: Array<RadioSelectItem<FolderTrustChoice>> = [
{

View File

@@ -17,6 +17,7 @@ import { useShellHistory } from '../hooks/useShellHistory.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
import { useKeypress, Key } from '../hooks/useKeypress.js';
import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config } from '@qwen-code/qwen-code-core';
@@ -66,6 +67,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
const kittyProtocolStatus = useKittyKeyboardProtocol();
const [dirs, setDirs] = useState<readonly string[]>(
config.getWorkspaceContext().getDirectories(),
@@ -525,7 +527,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
],
);
useKeypress(handleInput, { isActive: true });
useKeypress(handleInput, {
isActive: true,
kittyProtocolEnabled: kittyProtocolStatus.enabled,
config,
});
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =

View File

@@ -5,7 +5,7 @@
*/
import React, { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import {
LoadedSettings,
@@ -31,6 +31,7 @@ import {
getDefaultValue,
} from '../../utils/settingsUtils.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface SettingsDialogProps {
settings: LoadedSettings;
@@ -256,107 +257,111 @@ export function SettingsDialog({
const showScrollUp = true;
const showScrollDown = true;
useInput((input, key) => {
if (key.tab) {
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
}
if (focusSection === 'settings') {
if (key.upArrow || input === 'k') {
const newIndex =
activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
setActiveSettingIndex(newIndex);
// Adjust scroll offset for wrap-around
if (newIndex === items.length - 1) {
setScrollOffset(Math.max(0, items.length - maxItemsToShow));
} else if (newIndex < scrollOffset) {
setScrollOffset(newIndex);
}
} else if (key.downArrow || input === 'j') {
const newIndex =
activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
setActiveSettingIndex(newIndex);
// Adjust scroll offset for wrap-around
if (newIndex === 0) {
setScrollOffset(0);
} else if (newIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newIndex - maxItemsToShow + 1);
}
} else if (key.return || input === ' ') {
items[activeSettingIndex]?.toggle();
} else if ((key.ctrl && input === 'c') || (key.ctrl && input === 'l')) {
// Ctrl+C or Ctrl+L: Clear current setting and reset to default
const currentSetting = items[activeSettingIndex];
if (currentSetting) {
const defaultValue = getDefaultValue(currentSetting.value);
// Ensure defaultValue is a boolean for setPendingSettingValue
const booleanDefaultValue =
typeof defaultValue === 'boolean' ? defaultValue : false;
useKeypress(
(key) => {
const { name, ctrl } = key;
if (name === 'tab') {
setFocusSection((prev) => (prev === 'settings' ? 'scope' : 'settings'));
}
if (focusSection === 'settings') {
if (name === 'up' || name === 'k') {
const newIndex =
activeSettingIndex > 0 ? activeSettingIndex - 1 : items.length - 1;
setActiveSettingIndex(newIndex);
// Adjust scroll offset for wrap-around
if (newIndex === items.length - 1) {
setScrollOffset(Math.max(0, items.length - maxItemsToShow));
} else if (newIndex < scrollOffset) {
setScrollOffset(newIndex);
}
} else if (name === 'down' || name === 'j') {
const newIndex =
activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
setActiveSettingIndex(newIndex);
// Adjust scroll offset for wrap-around
if (newIndex === 0) {
setScrollOffset(0);
} else if (newIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newIndex - maxItemsToShow + 1);
}
} else if (name === 'return' || name === 'space') {
items[activeSettingIndex]?.toggle();
} else if (ctrl && (name === 'c' || name === 'l')) {
// Ctrl+C or Ctrl+L: Clear current setting and reset to default
const currentSetting = items[activeSettingIndex];
if (currentSetting) {
const defaultValue = getDefaultValue(currentSetting.value);
// Ensure defaultValue is a boolean for setPendingSettingValue
const booleanDefaultValue =
typeof defaultValue === 'boolean' ? defaultValue : false;
// Update pending settings to default value
setPendingSettings((prev) =>
setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
prev,
),
);
// Remove from modified settings since it's now at default
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(currentSetting.value);
return updated;
});
// Remove from restart-required settings if it was there
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
updated.delete(currentSetting.value);
return updated;
});
// If this setting doesn't require restart, save it immediately
if (!requiresRestart(currentSetting.value)) {
const immediateSettings = new Set([currentSetting.value]);
const immediateSettingsObject = setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
{},
// Update pending settings to default value
setPendingSettings((prev) =>
setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
prev,
),
);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
settings,
selectedScope,
);
// Remove from modified settings since it's now at default
setModifiedSettings((prev) => {
const updated = new Set(prev);
updated.delete(currentSetting.value);
return updated;
});
// Remove from restart-required settings if it was there
setRestartRequiredSettings((prev) => {
const updated = new Set(prev);
updated.delete(currentSetting.value);
return updated;
});
// If this setting doesn't require restart, save it immediately
if (!requiresRestart(currentSetting.value)) {
const immediateSettings = new Set([currentSetting.value]);
const immediateSettingsObject = setPendingSettingValue(
currentSetting.value,
booleanDefaultValue,
{},
);
saveModifiedSettings(
immediateSettings,
immediateSettingsObject,
settings,
selectedScope,
);
}
}
}
}
}
if (showRestartPrompt && input === 'r') {
// Only save settings that require restart (non-restart settings were already saved immediately)
const restartRequiredSettings =
getRestartRequiredFromModified(modifiedSettings);
const restartRequiredSet = new Set(restartRequiredSettings);
if (showRestartPrompt && name === 'r') {
// Only save settings that require restart (non-restart settings were already saved immediately)
const restartRequiredSettings =
getRestartRequiredFromModified(modifiedSettings);
const restartRequiredSet = new Set(restartRequiredSettings);
if (restartRequiredSet.size > 0) {
saveModifiedSettings(
restartRequiredSet,
pendingSettings,
settings,
selectedScope,
);
if (restartRequiredSet.size > 0) {
saveModifiedSettings(
restartRequiredSet,
pendingSettings,
settings,
selectedScope,
);
}
setShowRestartPrompt(false);
setRestartRequiredSettings(new Set()); // Clear restart-required settings
if (onRestartRequest) onRestartRequest();
}
setShowRestartPrompt(false);
setRestartRequiredSettings(new Set()); // Clear restart-required settings
if (onRestartRequest) onRestartRequest();
}
if (key.escape) {
onSelect(undefined, selectedScope);
}
});
if (name === 'escape') {
onSelect(undefined, selectedScope);
}
},
{ isActive: true },
);
return (
<Box

View File

@@ -5,9 +5,10 @@
*/
import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import React from 'react';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import {
RadioButtonSelect,
RadioSelectItem,
@@ -30,11 +31,14 @@ export const ShellConfirmationDialog: React.FC<
> = ({ request }) => {
const { commands, onConfirm } = request;
useInput((_, key) => {
if (key.escape) {
onConfirm(ToolConfirmationOutcome.Cancel);
}
});
useKeypress(
(key) => {
if (key.name === 'escape') {
onConfirm(ToolConfirmationOutcome.Cancel);
}
},
{ isActive: true },
);
const handleSelect = (item: ToolConfirmationOutcome) => {
if (item === ToolConfirmationOutcome.Cancel) {

View File

@@ -5,7 +5,7 @@
*/
import React, { useCallback, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { Colors } from '../colors.js';
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
@@ -16,6 +16,7 @@ import {
getScopeItems,
getScopeMessageForSetting,
} from '../../utils/dialogScopeUtils.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface ThemeDialogProps {
/** Callback function when a theme is selected */
@@ -111,14 +112,17 @@ export function ThemeDialog({
'theme',
);
useInput((input, key) => {
if (key.tab) {
setFocusedSection((prev) => (prev === 'theme' ? 'scope' : 'theme'));
}
if (key.escape) {
onSelect(undefined, selectedScope);
}
});
useKeypress(
(key) => {
if (key.name === 'tab') {
setFocusedSection((prev) => (prev === 'theme' ? 'scope' : 'theme'));
}
if (key.name === 'escape') {
onSelect(undefined, selectedScope);
}
},
{ isActive: true },
);
// Generate scope message for theme setting
const otherScopeModifiedMessage = getScopeMessageForSetting(

View File

@@ -5,7 +5,7 @@
*/
import React from 'react';
import { Box, Text, useInput } from 'ink';
import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { Colors } from '../../colors.js';
import {
@@ -20,6 +20,7 @@ import {
RadioSelectItem,
} from '../shared/RadioButtonSelect.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { useKeypress } from '../../hooks/useKeypress.js';
export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails;
@@ -44,7 +45,7 @@ export const ToolConfirmationMessage: React.FC<
const handleConfirm = async (outcome: ToolConfirmationOutcome) => {
if (confirmationDetails.type === 'edit') {
const ideClient = config?.getIdeClient();
if (config?.getIdeMode() && config?.getIdeModeFeature()) {
if (config?.getIdeMode()) {
const cliOutcome =
outcome === ToolConfirmationOutcome.Cancel ? 'rejected' : 'accepted';
await ideClient?.resolveDiffFromCli(
@@ -56,12 +57,15 @@ export const ToolConfirmationMessage: React.FC<
onConfirm(outcome);
};
useInput((input, key) => {
if (!isFocused) return;
if (key.escape || (key.ctrl && (input === 'c' || input === 'C'))) {
handleConfirm(ToolConfirmationOutcome.Cancel);
}
});
useKeypress(
(key) => {
if (!isFocused) return;
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
handleConfirm(ToolConfirmationOutcome.Cancel);
}
},
{ isActive: isFocused },
);
const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item);
@@ -132,7 +136,7 @@ export const ToolConfirmationMessage: React.FC<
value: ToolConfirmationOutcome.ProceedAlways,
},
);
if (config?.getIdeMode() && config?.getIdeModeFeature()) {
if (config?.getIdeMode()) {
options.push({
label: 'No (esc)',
value: ToolConfirmationOutcome.Cancel,

View File

@@ -5,8 +5,9 @@
*/
import React, { useEffect, useState, useRef } from 'react';
import { Text, Box, useInput } from 'ink';
import { Text, Box } from 'ink';
import { Colors } from '../../colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
/**
* Represents a single option for the RadioButtonSelect.
@@ -85,9 +86,10 @@ export function RadioButtonSelect<T>({
[],
);
useInput(
(input, key) => {
const isNumeric = showNumbers && /^[0-9]$/.test(input);
useKeypress(
(key) => {
const { sequence, name } = key;
const isNumeric = showNumbers && /^[0-9]$/.test(sequence);
// Any key press that is not a digit should clear the number input buffer.
if (!isNumeric && numberInputTimer.current) {
@@ -95,21 +97,21 @@ export function RadioButtonSelect<T>({
setNumberInput('');
}
if (input === 'k' || key.upArrow) {
if (name === 'k' || name === 'up') {
const newIndex = activeIndex > 0 ? activeIndex - 1 : items.length - 1;
setActiveIndex(newIndex);
onHighlight?.(items[newIndex]!.value);
return;
}
if (input === 'j' || key.downArrow) {
if (name === 'j' || name === 'down') {
const newIndex = activeIndex < items.length - 1 ? activeIndex + 1 : 0;
setActiveIndex(newIndex);
onHighlight?.(items[newIndex]!.value);
return;
}
if (key.return) {
if (name === 'return') {
onSelect(items[activeIndex]!.value);
return;
}
@@ -120,7 +122,7 @@ export function RadioButtonSelect<T>({
clearTimeout(numberInputTimer.current);
}
const newNumberInput = numberInput + input;
const newNumberInput = numberInput + sequence;
setNumberInput(newNumberInput);
const targetIndex = Number.parseInt(newNumberInput, 10) - 1;
@@ -154,7 +156,7 @@ export function RadioButtonSelect<T>({
}
}
},
{ isActive: isFocused && items.length > 0 },
{ isActive: !!(isFocused && items.length > 0) },
);
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);

View File

@@ -5,6 +5,7 @@
*/
import { describe, it, expect, beforeEach } from 'vitest';
import stripAnsi from 'strip-ansi';
import { renderHook, act } from '@testing-library/react';
import {
useTextBuffer,
@@ -1278,6 +1279,45 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
);
expect(getBufferState(result).text).toBe('Pasted Text');
});
it('should not strip popular emojis', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const emojis = '🐍🐳🦀🦄';
act(() =>
result.current.handleInput({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: emojis,
}),
);
expect(getBufferState(result).text).toBe(emojis);
});
});
describe('stripAnsi', () => {
it('should correctly strip ANSI escape codes', () => {
const textWithAnsi = '\x1B[31mHello\x1B[0m World';
expect(stripAnsi(textWithAnsi)).toBe('Hello World');
});
it('should handle multiple ANSI codes', () => {
const textWithMultipleAnsi = '\x1B[1m\x1B[34mBold Blue\x1B[0m Text';
expect(stripAnsi(textWithMultipleAnsi)).toBe('Bold Blue Text');
});
it('should not modify text without ANSI codes', () => {
const plainText = 'Plain text';
expect(stripAnsi(plainText)).toBe('Plain text');
});
it('should handle empty string', () => {
expect(stripAnsi('')).toBe('');
});
});
});

View File

@@ -5,6 +5,7 @@
*/
import stripAnsi from 'strip-ansi';
import { stripVTControlCharacters } from 'util';
import { spawnSync } from 'child_process';
import fs from 'fs';
import os from 'os';
@@ -496,21 +497,44 @@ export const replaceRangeInternal = (
/**
* Strip characters that can break terminal rendering.
*
* Strip ANSI escape codes and control characters except for line breaks.
* Control characters such as delete break terminal UI rendering.
* Uses Node.js built-in stripVTControlCharacters to handle VT sequences,
* then filters remaining control characters that can disrupt display.
*
* Characters stripped:
* - ANSI escape sequences (via strip-ansi)
* - VT control sequences (via Node.js util.stripVTControlCharacters)
* - C0 control chars (0x00-0x1F) except CR/LF which are handled elsewhere
* - C1 control chars (0x80-0x9F) that can cause display issues
*
* Characters preserved:
* - All printable Unicode including emojis
* - DEL (0x7F) - handled functionally by applyOperations, not a display issue
* - CR/LF (0x0D/0x0A) - needed for line breaks
*/
function stripUnsafeCharacters(str: string): string {
const stripped = stripAnsi(str);
return toCodePoints(stripped)
const strippedAnsi = stripAnsi(str);
const strippedVT = stripVTControlCharacters(strippedAnsi);
return toCodePoints(strippedVT)
.filter((char) => {
if (char.length > 1) return false;
const code = char.codePointAt(0);
if (code === undefined) {
return false;
}
const isUnsafe =
code === 127 || (code <= 31 && code !== 13 && code !== 10);
return !isUnsafe;
if (code === undefined) return false;
// Preserve CR/LF for line handling
if (code === 0x0a || code === 0x0d) return true;
// Remove C0 control chars (except CR/LF) that can break display
// Examples: BELL(0x07) makes noise, BS(0x08) moves cursor, VT(0x0B), FF(0x0C)
if (code >= 0x00 && code <= 0x1f) return false;
// Remove C1 control chars (0x80-0x9F) - legacy 8-bit control codes
if (code >= 0x80 && code <= 0x9f) return false;
// Preserve DEL (0x7F) - it's handled functionally by applyOperations as backspace
// and doesn't cause rendering issues when displayed
// Preserve all other characters including Unicode/emojis
return true;
})
.join('');
}

View File

@@ -19,6 +19,7 @@ import {
findWordEndInLine,
} from './text-buffer.js';
import { cpLen, toCodePoints } from '../../utils/textUtils.js';
import { assumeExhaustive } from '../../../utils/checks.js';
// Check if we're at the end of a base word (on the last base character)
// Returns true if current position has a base character followed only by combining marks until non-word
@@ -806,7 +807,7 @@ export function handleVimAction(
default: {
// This should never happen if TypeScript is working correctly
const _exhaustiveCheck: never = action;
assumeExhaustive(action);
return state;
}
}

View File

@@ -4,9 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
const { logSlashCommand, SlashCommandEvent } = vi.hoisted(() => ({
const { logSlashCommand } = vi.hoisted(() => ({
logSlashCommand: vi.fn(),
SlashCommandEvent: vi.fn((command, subCommand) => ({ command, subCommand })),
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
@@ -15,7 +14,6 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
return {
...original,
logSlashCommand,
SlashCommandEvent,
getIdeInstaller: vi.fn().mockReturnValue(null),
};
});
@@ -25,10 +23,10 @@ const { mockProcessExit } = vi.hoisted(() => ({
}));
vi.mock('node:process', () => {
const mockProcess = {
const mockProcess: Partial<NodeJS.Process> = {
exit: mockProcessExit,
platform: 'test-platform',
};
platform: 'sunos',
} as unknown as NodeJS.Process;
return {
...mockProcess,
default: mockProcess,
@@ -68,31 +66,37 @@ vi.mock('../../utils/cleanup.js', () => ({
runExitCleanup: mockRunExitCleanup,
}));
import {
SlashCommandStatus,
ToolConfirmationOutcome,
makeFakeConfig,
} from '@qwen-code/qwen-code-core';
import { act, renderHook, waitFor } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
import { useSlashCommandProcessor } from './slashCommandProcessor.js';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { LoadedSettings } from '../../config/settings.js';
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
import {
CommandContext,
CommandKind,
ConfirmShellCommandsActionReturn,
SlashCommand,
} from '../commands/types.js';
import { Config, ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import { LoadedSettings } from '../../config/settings.js';
import { MessageType } from '../types.js';
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
import { useSlashCommandProcessor } from './slashCommandProcessor.js';
const createTestCommand = (
function createTestCommand(
overrides: Partial<SlashCommand>,
kind: CommandKind = CommandKind.BUILT_IN,
): SlashCommand => ({
name: 'test',
description: 'a test command',
kind,
...overrides,
});
): SlashCommand {
return {
name: 'test',
description: 'a test command',
kind,
...overrides,
};
}
describe('useSlashCommandProcessor', () => {
const mockAddItem = vi.fn();
@@ -102,15 +106,7 @@ describe('useSlashCommandProcessor', () => {
const mockOpenAuthDialog = vi.fn();
const mockSetQuittingMessages = vi.fn();
const mockConfig = {
getProjectRoot: vi.fn(() => '/mock/cwd'),
getSessionId: vi.fn(() => 'test-session'),
getGeminiClient: vi.fn(() => ({
setHistory: vi.fn().mockResolvedValue(undefined),
})),
getExtensions: vi.fn(() => []),
getIdeMode: vi.fn(() => false),
} as unknown as Config;
const mockConfig = makeFakeConfig({});
const mockSettings = {} as LoadedSettings;
@@ -314,6 +310,39 @@ describe('useSlashCommandProcessor', () => {
);
});
it('sets isProcessing to false if the the input is not a command', async () => {
const setMockIsProcessing = vi.fn();
const result = setupProcessorHook([], [], [], setMockIsProcessing);
await act(async () => {
await result.current.handleSlashCommand('imnotacommand');
});
expect(setMockIsProcessing).not.toHaveBeenCalled();
});
it('sets isProcessing to false if the command has an error', async () => {
const setMockIsProcessing = vi.fn();
const failCommand = createTestCommand({
name: 'fail',
action: vi.fn().mockRejectedValue(new Error('oh no!')),
});
const result = setupProcessorHook(
[failCommand],
[],
[],
setMockIsProcessing,
);
await act(async () => {
await result.current.handleSlashCommand('/fail');
});
expect(setMockIsProcessing).toHaveBeenNthCalledWith(1, true);
expect(setMockIsProcessing).toHaveBeenNthCalledWith(2, false);
});
it('should set isProcessing to true during execution and false afterwards', async () => {
const mockSetIsProcessing = vi.fn();
const command = createTestCommand({
@@ -329,14 +358,14 @@ describe('useSlashCommandProcessor', () => {
});
// It should be true immediately after starting
expect(mockSetIsProcessing).toHaveBeenCalledWith(true);
expect(mockSetIsProcessing).toHaveBeenNthCalledWith(1, true);
// It should not have been called with false yet
expect(mockSetIsProcessing).not.toHaveBeenCalledWith(false);
await executionPromise;
// After the promise resolves, it should be called with false
expect(mockSetIsProcessing).toHaveBeenCalledWith(false);
expect(mockSetIsProcessing).toHaveBeenNthCalledWith(2, false);
expect(mockSetIsProcessing).toHaveBeenCalledTimes(2);
});
});
@@ -884,7 +913,9 @@ describe('useSlashCommandProcessor', () => {
const loggingTestCommands: SlashCommand[] = [
createTestCommand({
name: 'logtest',
action: mockCommandAction,
action: vi
.fn()
.mockResolvedValue({ type: 'message', content: 'hello world' }),
}),
createTestCommand({
name: 'logwithsub',
@@ -895,6 +926,10 @@ describe('useSlashCommandProcessor', () => {
}),
],
}),
createTestCommand({
name: 'fail',
action: vi.fn().mockRejectedValue(new Error('oh no!')),
}),
createTestCommand({
name: 'logalias',
altNames: ['la'],
@@ -905,7 +940,6 @@ describe('useSlashCommandProcessor', () => {
beforeEach(() => {
mockCommandAction.mockClear();
vi.mocked(logSlashCommand).mockClear();
vi.mocked(SlashCommandEvent).mockClear();
});
it('should log a simple slash command', async () => {
@@ -917,8 +951,45 @@ describe('useSlashCommandProcessor', () => {
await result.current.handleSlashCommand('/logtest');
});
expect(logSlashCommand).toHaveBeenCalledTimes(1);
expect(SlashCommandEvent).toHaveBeenCalledWith('logtest', undefined);
expect(logSlashCommand).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
command: 'logtest',
subcommand: undefined,
status: SlashCommandStatus.SUCCESS,
}),
);
});
it('logs nothing for a bogus command', async () => {
const result = setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands.length).toBeGreaterThan(0),
);
await act(async () => {
await result.current.handleSlashCommand('/bogusbogusbogus');
});
expect(logSlashCommand).not.toHaveBeenCalled();
});
it('logs a failure event for a failed command', async () => {
const result = setupProcessorHook(loggingTestCommands);
await waitFor(() =>
expect(result.current.slashCommands.length).toBeGreaterThan(0),
);
await act(async () => {
await result.current.handleSlashCommand('/fail');
});
expect(logSlashCommand).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
command: 'fail',
status: 'error',
subcommand: undefined,
}),
);
});
it('should log a slash command with a subcommand', async () => {
@@ -930,8 +1001,13 @@ describe('useSlashCommandProcessor', () => {
await result.current.handleSlashCommand('/logwithsub sub');
});
expect(logSlashCommand).toHaveBeenCalledTimes(1);
expect(SlashCommandEvent).toHaveBeenCalledWith('logwithsub', 'sub');
expect(logSlashCommand).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
command: 'logwithsub',
subcommand: 'sub',
}),
);
});
it('should log the command path when an alias is used', async () => {
@@ -942,8 +1018,12 @@ describe('useSlashCommandProcessor', () => {
await act(async () => {
await result.current.handleSlashCommand('/la');
});
expect(logSlashCommand).toHaveBeenCalledTimes(1);
expect(SlashCommandEvent).toHaveBeenCalledWith('logalias', undefined);
expect(logSlashCommand).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
command: 'logalias',
}),
);
});
it('should not log for unknown commands', async () => {

View File

@@ -14,7 +14,8 @@ import {
GitService,
Logger,
logSlashCommand,
SlashCommandEvent,
makeSlashCommandEvent,
SlashCommandStatus,
ToolConfirmationOutcome,
} from '@qwen-code/qwen-code-core';
import { useSessionStats } from '../contexts/SessionContext.js';
@@ -57,6 +58,11 @@ export const useSlashCommandProcessor = (
) => {
const session = useSessionStats();
const [commands, setCommands] = useState<readonly SlashCommand[]>([]);
const [reloadTrigger, setReloadTrigger] = useState(0);
const reloadCommands = useCallback(() => {
setReloadTrigger((v) => v + 1);
}, []);
const [shellConfirmationRequest, setShellConfirmationRequest] =
useState<null | {
commands: string[];
@@ -172,6 +178,7 @@ export const useSlashCommandProcessor = (
toggleCorgiMode,
toggleVimEnabled,
setGeminiMdFileCount,
reloadCommands,
},
session: {
stats: session.stats,
@@ -197,6 +204,7 @@ export const useSlashCommandProcessor = (
toggleVimEnabled,
sessionShellAllowlist,
setGeminiMdFileCount,
reloadCommands,
],
);
@@ -222,7 +230,7 @@ export const useSlashCommandProcessor = (
return () => {
controller.abort();
};
}, [config, ideMode]);
}, [config, ideMode, reloadTrigger]);
const handleSlashCommand = useCallback(
async (
@@ -230,77 +238,71 @@ export const useSlashCommandProcessor = (
oneTimeShellAllowlist?: Set<string>,
overwriteConfirmed?: boolean,
): Promise<SlashCommandProcessorResult | false> => {
if (typeof rawQuery !== 'string') {
return false;
}
const trimmed = rawQuery.trim();
if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) {
return false;
}
setIsProcessing(true);
try {
if (typeof rawQuery !== 'string') {
return false;
const userMessageTimestamp = Date.now();
addItem({ type: MessageType.USER, text: trimmed }, userMessageTimestamp);
const parts = trimmed.substring(1).trim().split(/\s+/);
const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add']
let currentCommands = commands;
let commandToExecute: SlashCommand | undefined;
let pathIndex = 0;
let hasError = false;
const canonicalPath: string[] = [];
for (const part of commandPath) {
// TODO: For better performance and architectural clarity, this two-pass
// search could be replaced. A more optimal approach would be to
// pre-compute a single lookup map in `CommandService.ts` that resolves
// all name and alias conflicts during the initial loading phase. The
// processor would then perform a single, fast lookup on that map.
// First pass: check for an exact match on the primary command name.
let foundCommand = currentCommands.find((cmd) => cmd.name === part);
// Second pass: if no primary name matches, check for an alias.
if (!foundCommand) {
foundCommand = currentCommands.find((cmd) =>
cmd.altNames?.includes(part),
);
}
const trimmed = rawQuery.trim();
if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) {
return false;
}
const userMessageTimestamp = Date.now();
addItem(
{ type: MessageType.USER, text: trimmed },
userMessageTimestamp,
);
const parts = trimmed.substring(1).trim().split(/\s+/);
const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add']
let currentCommands = commands;
let commandToExecute: SlashCommand | undefined;
let pathIndex = 0;
const canonicalPath: string[] = [];
for (const part of commandPath) {
// TODO: For better performance and architectural clarity, this two-pass
// search could be replaced. A more optimal approach would be to
// pre-compute a single lookup map in `CommandService.ts` that resolves
// all name and alias conflicts during the initial loading phase. The
// processor would then perform a single, fast lookup on that map.
// First pass: check for an exact match on the primary command name.
let foundCommand = currentCommands.find((cmd) => cmd.name === part);
// Second pass: if no primary name matches, check for an alias.
if (!foundCommand) {
foundCommand = currentCommands.find((cmd) =>
cmd.altNames?.includes(part),
);
}
if (foundCommand) {
commandToExecute = foundCommand;
canonicalPath.push(foundCommand.name);
pathIndex++;
if (foundCommand.subCommands) {
currentCommands = foundCommand.subCommands;
} else {
break;
}
if (foundCommand) {
commandToExecute = foundCommand;
canonicalPath.push(foundCommand.name);
pathIndex++;
if (foundCommand.subCommands) {
currentCommands = foundCommand.subCommands;
} else {
break;
}
} else {
break;
}
}
const resolvedCommandPath = canonicalPath;
const subcommand =
resolvedCommandPath.length > 1
? resolvedCommandPath.slice(1).join(' ')
: undefined;
try {
if (commandToExecute) {
const args = parts.slice(pathIndex).join(' ');
if (commandToExecute.action) {
if (config) {
const resolvedCommandPath = canonicalPath;
const event = new SlashCommandEvent(
resolvedCommandPath[0],
resolvedCommandPath.length > 1
? resolvedCommandPath.slice(1).join(' ')
: undefined,
);
logSlashCommand(config, event);
}
const fullCommandContext: CommandContext = {
...commandContext,
invocation: {
@@ -322,7 +324,6 @@ export const useSlashCommandProcessor = (
]),
};
}
const result = await commandToExecute.action(
fullCommandContext,
args,
@@ -495,8 +496,18 @@ export const useSlashCommandProcessor = (
content: `Unknown command: ${trimmed}`,
timestamp: new Date(),
});
return { type: 'handled' };
} catch (e) {
} catch (e: unknown) {
hasError = true;
if (config) {
const event = makeSlashCommandEvent({
command: resolvedCommandPath[0],
subcommand,
status: SlashCommandStatus.ERROR,
});
logSlashCommand(config, event);
}
addItem(
{
type: MessageType.ERROR,
@@ -506,6 +517,14 @@ export const useSlashCommandProcessor = (
);
return { type: 'handled' };
} finally {
if (config && resolvedCommandPath[0] && !hasError) {
const event = makeSlashCommandEvent({
command: resolvedCommandPath[0],
subcommand,
status: SlashCommandStatus.SUCCESS,
});
logSlashCommand(config, event);
}
setIsProcessing(false);
}
},

View File

@@ -21,9 +21,9 @@ import {
Config as ActualConfigType,
ApprovalMode,
} from '@qwen-code/qwen-code-core';
import { useInput, type Key as InkKey } from 'ink';
import { useKeypress, Key } from './useKeypress.js';
vi.mock('ink');
vi.mock('./useKeypress.js');
vi.mock('@qwen-code/qwen-code-core', async () => {
const actualServerModule = (await vi.importActual(
@@ -53,13 +53,12 @@ interface MockConfigInstanceShape {
getToolRegistry: Mock<() => { discoverTools: Mock<() => void> }>;
}
type UseInputKey = InkKey;
type UseInputHandler = (input: string, key: UseInputKey) => void;
type UseKeypressHandler = (key: Key) => void;
describe('useAutoAcceptIndicator', () => {
let mockConfigInstance: MockConfigInstanceShape;
let capturedUseInputHandler: UseInputHandler;
let mockedInkUseInput: MockedFunction<typeof useInput>;
let capturedUseKeypressHandler: UseKeypressHandler;
let mockedUseKeypress: MockedFunction<typeof useKeypress>;
beforeEach(() => {
vi.resetAllMocks();
@@ -111,10 +110,12 @@ describe('useAutoAcceptIndicator', () => {
return instance;
});
mockedInkUseInput = useInput as MockedFunction<typeof useInput>;
mockedInkUseInput.mockImplementation((handler: UseInputHandler) => {
capturedUseInputHandler = handler;
});
mockedUseKeypress = useKeypress as MockedFunction<typeof useKeypress>;
mockedUseKeypress.mockImplementation(
(handler: UseKeypressHandler, _options) => {
capturedUseKeypressHandler = handler;
},
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockConfigInstance = new (Config as any)() as MockConfigInstanceShape;
@@ -163,7 +164,10 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.DEFAULT);
act(() => {
capturedUseInputHandler('', { tab: true, shift: true } as InkKey);
capturedUseKeypressHandler({
name: 'tab',
shift: true,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
@@ -171,7 +175,7 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
act(() => {
capturedUseInputHandler('y', { ctrl: true } as InkKey);
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
@@ -179,7 +183,7 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.YOLO);
act(() => {
capturedUseInputHandler('y', { ctrl: true } as InkKey);
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
@@ -187,7 +191,7 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.DEFAULT);
act(() => {
capturedUseInputHandler('y', { ctrl: true } as InkKey);
capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
@@ -195,7 +199,10 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.YOLO);
act(() => {
capturedUseInputHandler('', { tab: true, shift: true } as InkKey);
capturedUseKeypressHandler({
name: 'tab',
shift: true,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
@@ -203,7 +210,10 @@ describe('useAutoAcceptIndicator', () => {
expect(result.current).toBe(ApprovalMode.AUTO_EDIT);
act(() => {
capturedUseInputHandler('', { tab: true, shift: true } as InkKey);
capturedUseKeypressHandler({
name: 'tab',
shift: true,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
@@ -220,37 +230,51 @@ describe('useAutoAcceptIndicator', () => {
);
act(() => {
capturedUseInputHandler('', { tab: true, shift: false } as InkKey);
capturedUseKeypressHandler({
name: 'tab',
shift: false,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => {
capturedUseInputHandler('', { tab: false, shift: true } as InkKey);
capturedUseKeypressHandler({
name: 'unknown',
shift: true,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => {
capturedUseInputHandler('a', { tab: false, shift: false } as InkKey);
capturedUseKeypressHandler({
name: 'a',
shift: false,
ctrl: false,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => {
capturedUseInputHandler('y', { tab: true } as InkKey);
capturedUseKeypressHandler({ name: 'y', ctrl: false } as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => {
capturedUseInputHandler('a', { ctrl: true } as InkKey);
capturedUseKeypressHandler({ name: 'a', ctrl: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => {
capturedUseInputHandler('y', { shift: true } as InkKey);
capturedUseKeypressHandler({ name: 'y', shift: true } as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
act(() => {
capturedUseInputHandler('a', { ctrl: true, shift: true } as InkKey);
capturedUseKeypressHandler({
name: 'a',
ctrl: true,
shift: true,
} as Key);
});
expect(mockConfigInstance.setApprovalMode).not.toHaveBeenCalled();
});

View File

@@ -4,9 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect } from 'react';
import { useInput } from 'ink';
import { ApprovalMode, type Config } from '@qwen-code/qwen-code-core';
import { useEffect, useState } from 'react';
import { useKeypress } from './useKeypress.js';
export interface UseAutoAcceptIndicatorArgs {
config: Config;
@@ -23,27 +23,30 @@ export function useAutoAcceptIndicator({
setShowAutoAcceptIndicator(currentConfigValue);
}, [currentConfigValue]);
useInput((input, key) => {
let nextApprovalMode: ApprovalMode | undefined;
useKeypress(
(key) => {
let nextApprovalMode: ApprovalMode | undefined;
if (key.ctrl && input === 'y') {
nextApprovalMode =
config.getApprovalMode() === ApprovalMode.YOLO
? ApprovalMode.DEFAULT
: ApprovalMode.YOLO;
} else if (key.tab && key.shift) {
nextApprovalMode =
config.getApprovalMode() === ApprovalMode.AUTO_EDIT
? ApprovalMode.DEFAULT
: ApprovalMode.AUTO_EDIT;
}
if (key.ctrl && key.name === 'y') {
nextApprovalMode =
config.getApprovalMode() === ApprovalMode.YOLO
? ApprovalMode.DEFAULT
: ApprovalMode.YOLO;
} else if (key.shift && key.name === 'tab') {
nextApprovalMode =
config.getApprovalMode() === ApprovalMode.AUTO_EDIT
? ApprovalMode.DEFAULT
: ApprovalMode.AUTO_EDIT;
}
if (nextApprovalMode) {
config.setApprovalMode(nextApprovalMode);
// Update local state immediately for responsiveness
setShowAutoAcceptIndicator(nextApprovalMode);
}
});
if (nextApprovalMode) {
config.setApprovalMode(nextApprovalMode);
// Update local state immediately for responsiveness
setShowAutoAcceptIndicator(nextApprovalMode);
}
},
{ isActive: true },
);
return showAutoAcceptIndicator;
}

View File

@@ -8,12 +8,12 @@ import { useStdin, useStdout } from 'ink';
import { useEffect, useState } from 'react';
// ANSI escape codes to enable/disable terminal focus reporting
const ENABLE_FOCUS_REPORTING = '\x1b[?1004h';
const DISABLE_FOCUS_REPORTING = '\x1b[?1004l';
export const ENABLE_FOCUS_REPORTING = '\x1b[?1004h';
export const DISABLE_FOCUS_REPORTING = '\x1b[?1004l';
// ANSI escape codes for focus events
const FOCUS_IN = '\x1b[I';
const FOCUS_OUT = '\x1b[O';
export const FOCUS_IN = '\x1b[I';
export const FOCUS_OUT = '\x1b[O';
export const useFocus = () => {
const { stdin } = useStdin();

View File

@@ -4,15 +4,33 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { renderHook, act } from '@testing-library/react';
import { vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useFolderTrust } from './useFolderTrust.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { type Config } from '@qwen-code/qwen-code-core';
import { LoadedSettings } from '../../config/settings.js';
import { FolderTrustChoice } from '../components/FolderTrustDialog.js';
import {
LoadedTrustedFolders,
TrustLevel,
} from '../../config/trustedFolders.js';
import * as process from 'process';
import * as trustedFolders from '../../config/trustedFolders.js';
vi.mock('process', () => ({
cwd: vi.fn(),
platform: 'linux',
}));
describe('useFolderTrust', () => {
it('should set isFolderTrustDialogOpen to true when folderTrustFeature is true and folderTrust is undefined', () => {
const settings = {
let mockSettings: LoadedSettings;
let mockConfig: Config;
let mockTrustedFolders: LoadedTrustedFolders;
let loadTrustedFoldersSpy: vi.SpyInstance;
beforeEach(() => {
mockSettings = {
merged: {
folderTrustFeature: true,
folderTrust: undefined,
@@ -20,59 +38,110 @@ describe('useFolderTrust', () => {
setValue: vi.fn(),
} as unknown as LoadedSettings;
const { result } = renderHook(() => useFolderTrust(settings));
mockConfig = {
isTrustedFolder: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
mockTrustedFolders = {
setValue: vi.fn(),
} as unknown as LoadedTrustedFolders;
loadTrustedFoldersSpy = vi
.spyOn(trustedFolders, 'loadTrustedFolders')
.mockReturnValue(mockTrustedFolders);
(process.cwd as vi.Mock).mockReturnValue('/test/path');
});
afterEach(() => {
vi.clearAllMocks();
});
it('should not open dialog when folder is already trusted', () => {
(mockConfig.isTrustedFolder as vi.Mock).mockReturnValue(true);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
it('should not open dialog when folder is already untrusted', () => {
(mockConfig.isTrustedFolder as vi.Mock).mockReturnValue(false);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
it('should open dialog when folder trust is undefined', () => {
(mockConfig.isTrustedFolder as vi.Mock).mockReturnValue(undefined);
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
);
expect(result.current.isFolderTrustDialogOpen).toBe(true);
});
it('should set isFolderTrustDialogOpen to false when folderTrustFeature is false', () => {
const settings = {
merged: {
folderTrustFeature: false,
folderTrust: undefined,
},
setValue: vi.fn(),
} as unknown as LoadedSettings;
const { result } = renderHook(() => useFolderTrust(settings));
expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
it('should set isFolderTrustDialogOpen to false when folderTrust is defined', () => {
const settings = {
merged: {
folderTrustFeature: true,
folderTrust: true,
},
setValue: vi.fn(),
} as unknown as LoadedSettings;
const { result } = renderHook(() => useFolderTrust(settings));
expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
it('should call setValue and set isFolderTrustDialogOpen to false on handleFolderTrustSelect', () => {
const settings = {
merged: {
folderTrustFeature: true,
folderTrust: undefined,
},
setValue: vi.fn(),
} as unknown as LoadedSettings;
const { result } = renderHook(() => useFolderTrust(settings));
it('should handle TRUST_FOLDER choice', () => {
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
);
act(() => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER);
});
expect(settings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'folderTrust',
true,
expect(loadTrustedFoldersSpy).toHaveBeenCalled();
expect(mockTrustedFolders.setValue).toHaveBeenCalledWith(
'/test/path',
TrustLevel.TRUST_FOLDER,
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
it('should handle TRUST_PARENT choice', () => {
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
);
act(() => {
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_PARENT);
});
expect(mockTrustedFolders.setValue).toHaveBeenCalledWith(
'/test/path',
TrustLevel.TRUST_PARENT,
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
it('should handle DO_NOT_TRUST choice', () => {
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
);
act(() => {
result.current.handleFolderTrustSelect(FolderTrustChoice.DO_NOT_TRUST);
});
expect(mockTrustedFolders.setValue).toHaveBeenCalledWith(
'/test/path',
TrustLevel.DO_NOT_TRUST,
);
expect(result.current.isFolderTrustDialogOpen).toBe(false);
});
it('should do nothing for default choice', () => {
const { result } = renderHook(() =>
useFolderTrust(mockSettings, mockConfig),
);
act(() => {
result.current.handleFolderTrustSelect(
'invalid_choice' as FolderTrustChoice,
);
});
expect(mockTrustedFolders.setValue).not.toHaveBeenCalled();
expect(mockSettings.setValue).not.toHaveBeenCalled();
expect(result.current.isFolderTrustDialogOpen).toBe(true);
});
});

View File

@@ -5,24 +5,39 @@
*/
import { useState, useCallback } from 'react';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { type Config } from '@qwen-code/qwen-code-core';
import { LoadedSettings } from '../../config/settings.js';
import { FolderTrustChoice } from '../components/FolderTrustDialog.js';
import { loadTrustedFolders, TrustLevel } from '../../config/trustedFolders.js';
import * as process from 'process';
export const useFolderTrust = (settings: LoadedSettings) => {
export const useFolderTrust = (settings: LoadedSettings, config: Config) => {
const [isFolderTrustDialogOpen, setIsFolderTrustDialogOpen] = useState(
!!settings.merged.folderTrustFeature &&
// TODO: Update to avoid showing dialog for folders that are trusted.
settings.merged.folderTrust === undefined,
config.isTrustedFolder() === undefined,
);
const handleFolderTrustSelect = useCallback(
(_choice: FolderTrustChoice) => {
// TODO: Store folderPath in the trusted folders config file based on the choice.
settings.setValue(SettingScope.User, 'folderTrust', true);
setIsFolderTrustDialogOpen(false);
},
[settings],
);
const handleFolderTrustSelect = useCallback((choice: FolderTrustChoice) => {
const trustedFolders = loadTrustedFolders();
const cwd = process.cwd();
let trustLevel: TrustLevel;
switch (choice) {
case FolderTrustChoice.TRUST_FOLDER:
trustLevel = TrustLevel.TRUST_FOLDER;
break;
case FolderTrustChoice.TRUST_PARENT:
trustLevel = TrustLevel.TRUST_PARENT;
break;
case FolderTrustChoice.DO_NOT_TRUST:
trustLevel = TrustLevel.DO_NOT_TRUST;
break;
default:
return;
}
trustedFolders.setValue(cwd, trustLevel);
setIsFolderTrustDialogOpen(false);
}, []);
return {
isFolderTrustDialogOpen,

View File

@@ -8,7 +8,7 @@
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useGeminiStream, mergePartListUnions } from './useGeminiStream.js';
import { useInput } from 'ink';
import { useKeypress } from './useKeypress.js';
import {
useReactToolScheduler,
TrackedToolCall,
@@ -51,6 +51,7 @@ const MockedGeminiClientClass = vi.hoisted(() =>
const MockedUserPromptEvent = vi.hoisted(() =>
vi.fn().mockImplementation(() => {}),
);
const mockParseAndFormatApiError = vi.hoisted(() => vi.fn());
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actualCoreModule = (await importOriginal()) as any;
@@ -59,6 +60,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
GitService: vi.fn(),
GeminiClient: MockedGeminiClientClass,
UserPromptEvent: MockedUserPromptEvent,
parseAndFormatApiError: mockParseAndFormatApiError,
};
});
@@ -71,10 +73,9 @@ vi.mock('./useReactToolScheduler.js', async (importOriginal) => {
};
});
vi.mock('ink', async (importOriginal) => {
const actualInkModule = (await importOriginal()) as any;
return { ...(actualInkModule || {}), useInput: vi.fn() };
});
vi.mock('./useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('./shellCommandProcessor.js', () => ({
useShellCommandProcessor: vi.fn().mockReturnValue({
@@ -128,11 +129,6 @@ vi.mock('./slashCommandProcessor.js', () => ({
handleSlashCommand: vi.fn().mockReturnValue(false),
}));
const mockParseAndFormatApiError = vi.hoisted(() => vi.fn());
vi.mock('../utils/errorParsing.js', () => ({
parseAndFormatApiError: mockParseAndFormatApiError,
}));
// --- END MOCKS ---
describe('mergePartListUnions', () => {
@@ -903,19 +899,23 @@ describe('useGeminiStream', () => {
});
describe('User Cancellation', () => {
let useInputCallback: (input: string, key: any) => void;
const mockUseInput = useInput as Mock;
let keypressCallback: (key: any) => void;
const mockUseKeypress = useKeypress as Mock;
beforeEach(() => {
// Capture the callback passed to useInput
mockUseInput.mockImplementation((callback) => {
useInputCallback = callback;
// Capture the callback passed to useKeypress
mockUseKeypress.mockImplementation((callback, options) => {
if (options.isActive) {
keypressCallback = callback;
} else {
keypressCallback = () => {};
}
});
});
const simulateEscapeKeyPress = () => {
act(() => {
useInputCallback('', { escape: true });
keypressCallback({ name: 'escape' });
});
};

View File

@@ -4,57 +4,57 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { useInput } from 'ink';
import {
Config,
GeminiClient,
GeminiEventType as ServerGeminiEventType,
ServerGeminiStreamEvent as GeminiEvent,
ServerGeminiContentEvent as ContentEvent,
ServerGeminiErrorEvent as ErrorEvent,
ServerGeminiChatCompressedEvent,
ServerGeminiFinishedEvent,
getErrorMessage,
isNodeError,
MessageSenderType,
ToolCallRequestInfo,
logUserPrompt,
GitService,
EditorType,
ThoughtSummary,
UnauthorizedError,
UserPromptEvent,
DEFAULT_GEMINI_FLASH_MODEL,
} from '@qwen-code/qwen-code-core';
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
import {
StreamingState,
Config,
ServerGeminiContentEvent as ContentEvent,
DEFAULT_GEMINI_FLASH_MODEL,
EditorType,
ServerGeminiErrorEvent as ErrorEvent,
GeminiClient,
ServerGeminiStreamEvent as GeminiEvent,
getErrorMessage,
GitService,
isNodeError,
logUserPrompt,
MessageSenderType,
parseAndFormatApiError,
ServerGeminiChatCompressedEvent,
GeminiEventType as ServerGeminiEventType,
ServerGeminiFinishedEvent,
ThoughtSummary,
ToolCallRequestInfo,
UnauthorizedError,
UserPromptEvent,
} from '@qwen-code/qwen-code-core';
import { promises as fs } from 'fs';
import path from 'path';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSessionStats } from '../contexts/SessionContext.js';
import {
HistoryItem,
HistoryItemWithoutId,
HistoryItemToolGroup,
HistoryItemWithoutId,
MessageType,
SlashCommandProcessorResult,
StreamingState,
ToolCallStatus,
} from '../types.js';
import { isAtCommand } from '../utils/commandUtils.js';
import { parseAndFormatApiError } from '../utils/errorParsing.js';
import { useShellCommandProcessor } from './shellCommandProcessor.js';
import { handleAtCommand } from './atCommandProcessor.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { useStateAndRef } from './useStateAndRef.js';
import { handleAtCommand } from './atCommandProcessor.js';
import { useShellCommandProcessor } from './shellCommandProcessor.js';
import { UseHistoryManagerReturn } from './useHistoryManager.js';
import { useKeypress } from './useKeypress.js';
import { useLogger } from './useLogger.js';
import { promises as fs } from 'fs';
import path from 'path';
import {
useReactToolScheduler,
mapToDisplay as mapTrackedToolCallsToDisplay,
TrackedToolCall,
TrackedCompletedToolCall,
TrackedCancelledToolCall,
TrackedCompletedToolCall,
TrackedToolCall,
useReactToolScheduler,
} from './useReactToolScheduler.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import { useStateAndRef } from './useStateAndRef.js';
export function mergePartListUnions(list: PartListUnion[]): PartListUnion {
const resultParts: PartListUnion = [];
@@ -215,11 +215,14 @@ export const useGeminiStream = (
pendingHistoryItemRef,
]);
useInput((_input, key) => {
if (key.escape) {
cancelOngoingRequest();
}
});
useKeypress(
(key) => {
if (key.name === 'escape') {
cancelOngoingRequest();
}
},
{ isActive: streamingState === StreamingState.Responding },
);
const prepareQueryForGemini = useCallback(
async (

View File

@@ -134,9 +134,14 @@ describe('useKeypress', () => {
expect(onKeypress).not.toHaveBeenCalled();
});
it('should listen for keypress when active', () => {
it.each([
{ key: { name: 'a', sequence: 'a' } },
{ key: { name: 'left', sequence: '\x1b[D' } },
{ key: { name: 'right', sequence: '\x1b[C' } },
{ key: { name: 'up', sequence: '\x1b[A' } },
{ key: { name: 'down', sequence: '\x1b[B' } },
])('should listen for keypress when active for key $key.name', ({ key }) => {
renderHook(() => useKeypress(onKeypress, { isActive: true }));
const key = { name: 'a', sequence: 'a' };
act(() => stdin.pressKey(key));
expect(onKeypress).toHaveBeenCalledWith(expect.objectContaining(key));
});
@@ -187,7 +192,7 @@ describe('useKeypress', () => {
},
isLegacy: true,
},
])('Paste Handling in $description', ({ setup, isLegacy }) => {
])('in $description', ({ setup, isLegacy }) => {
beforeEach(() => {
setup();
stdin.setLegacy(isLegacy);

View File

@@ -8,6 +8,21 @@ import { useEffect, useRef } from 'react';
import { useStdin } from 'ink';
import readline from 'readline';
import { PassThrough } from 'stream';
import {
KITTY_CTRL_C,
BACKSLASH_ENTER_DETECTION_WINDOW_MS,
MAX_KITTY_SEQUENCE_LENGTH,
} from '../utils/platformConstants.js';
import {
KittySequenceOverflowEvent,
logKittySequenceOverflow,
Config,
} from '@qwen-code/qwen-code-core';
import { FOCUS_IN, FOCUS_OUT } from './useFocus.js';
const ESC = '\u001B';
export const PASTE_MODE_PREFIX = `${ESC}[200~`;
export const PASTE_MODE_SUFFIX = `${ESC}[201~`;
export interface Key {
name: string;
@@ -16,6 +31,7 @@ export interface Key {
shift: boolean;
paste: boolean;
sequence: string;
kittyProtocol?: boolean;
}
/**
@@ -30,10 +46,16 @@ export interface Key {
* @param onKeypress - The callback function to execute on each keypress.
* @param options - Options to control the hook's behavior.
* @param options.isActive - Whether the hook should be actively listening for input.
* @param options.kittyProtocolEnabled - Whether Kitty keyboard protocol is enabled.
* @param options.config - Optional config for telemetry logging.
*/
export function useKeypress(
onKeypress: (key: Key) => void,
{ isActive }: { isActive: boolean },
{
isActive,
kittyProtocolEnabled = false,
config,
}: { isActive: boolean; kittyProtocolEnabled?: boolean; config?: Config },
) {
const { stdin, setRawMode } = useStdin();
const onKeypressRef = useRef(onKeypress);
@@ -64,8 +86,210 @@ export function useKeypress(
let isPaste = false;
let pasteBuffer = Buffer.alloc(0);
let kittySequenceBuffer = '';
let backslashTimeout: NodeJS.Timeout | null = null;
let waitingForEnterAfterBackslash = false;
// Parse Kitty protocol sequences
const parseKittySequence = (sequence: string): Key | null => {
// Match CSI <number> ; <modifiers> u or ~
// Format: ESC [ <keycode> ; <modifiers> u/~
const kittyPattern = new RegExp(`^${ESC}\\[(\\d+)(;(\\d+))?([u~])$`);
const match = sequence.match(kittyPattern);
if (!match) return null;
const keyCode = parseInt(match[1], 10);
const modifiers = match[3] ? parseInt(match[3], 10) : 1;
// Decode modifiers (subtract 1 as per Kitty protocol spec)
const modifierBits = modifiers - 1;
const shift = (modifierBits & 1) === 1;
const alt = (modifierBits & 2) === 2;
const ctrl = (modifierBits & 4) === 4;
// Handle Escape key (code 27)
if (keyCode === 27) {
return {
name: 'escape',
ctrl,
meta: alt,
shift,
paste: false,
sequence,
kittyProtocol: true,
};
}
// Handle Enter key (code 13)
if (keyCode === 13) {
return {
name: 'return',
ctrl,
meta: alt,
shift,
paste: false,
sequence,
kittyProtocol: true,
};
}
// Handle Ctrl+letter combinations (a-z)
// ASCII codes: a=97, b=98, c=99, ..., z=122
if (keyCode >= 97 && keyCode <= 122 && ctrl) {
const letter = String.fromCharCode(keyCode);
return {
name: letter,
ctrl: true,
meta: alt,
shift,
paste: false,
sequence,
kittyProtocol: true,
};
}
// Handle other keys as needed
return null;
};
const handleKeypress = (_: unknown, key: Key) => {
// Handle VS Code's backslash+return pattern (Shift+Enter)
if (key.name === 'return' && waitingForEnterAfterBackslash) {
// Cancel the timeout since we got the Enter
if (backslashTimeout) {
clearTimeout(backslashTimeout);
backslashTimeout = null;
}
waitingForEnterAfterBackslash = false;
// Convert to Shift+Enter
onKeypressRef.current({
...key,
shift: true,
sequence: '\\\r', // VS Code's Shift+Enter representation
});
return;
}
// Handle backslash - hold it to see if Enter follows
if (key.sequence === '\\' && !key.name) {
// Don't pass through the backslash yet - wait to see if Enter follows
waitingForEnterAfterBackslash = true;
// Set up a timeout to pass through the backslash if no Enter follows
backslashTimeout = setTimeout(() => {
waitingForEnterAfterBackslash = false;
backslashTimeout = null;
// Pass through the backslash since no Enter followed
onKeypressRef.current(key);
}, BACKSLASH_ENTER_DETECTION_WINDOW_MS);
return;
}
// If we're waiting for Enter after backslash but got something else,
// pass through the backslash first, then the new key
if (waitingForEnterAfterBackslash && key.name !== 'return') {
if (backslashTimeout) {
clearTimeout(backslashTimeout);
backslashTimeout = null;
}
waitingForEnterAfterBackslash = false;
// Pass through the backslash that was held
onKeypressRef.current({
name: '',
sequence: '\\',
ctrl: false,
meta: false,
shift: false,
paste: false,
});
// Then continue processing the current key normally
}
// If readline has already identified an arrow key, pass it through
// immediately, bypassing the Kitty protocol sequence buffering.
if (['up', 'down', 'left', 'right'].includes(key.name)) {
onKeypressRef.current(key);
return;
}
// Always pass through Ctrl+C immediately, regardless of protocol state
// Check both standard format and Kitty protocol sequence
if (
(key.ctrl && key.name === 'c') ||
key.sequence === `${ESC}${KITTY_CTRL_C}`
) {
kittySequenceBuffer = '';
// If it's the Kitty sequence, create a proper key object
if (key.sequence === `${ESC}${KITTY_CTRL_C}`) {
onKeypressRef.current({
name: 'c',
ctrl: true,
meta: false,
shift: false,
paste: false,
sequence: key.sequence,
kittyProtocol: true,
});
} else {
onKeypressRef.current(key);
}
return;
}
// If Kitty protocol is enabled, handle CSI sequences
if (kittyProtocolEnabled) {
// If we have a buffer or this starts a CSI sequence
if (
kittySequenceBuffer ||
(key.sequence.startsWith(`${ESC}[`) &&
!key.sequence.startsWith(PASTE_MODE_PREFIX) &&
!key.sequence.startsWith(PASTE_MODE_SUFFIX) &&
!key.sequence.startsWith(FOCUS_IN) &&
!key.sequence.startsWith(FOCUS_OUT))
) {
kittySequenceBuffer += key.sequence;
// Try to parse the buffer as a Kitty sequence
const kittyKey = parseKittySequence(kittySequenceBuffer);
if (kittyKey) {
kittySequenceBuffer = '';
onKeypressRef.current(kittyKey);
return;
}
if (config?.getDebugMode()) {
const codes = Array.from(kittySequenceBuffer).map((ch) =>
ch.charCodeAt(0),
);
// Unless the user is sshing over a slow connection, this likely
// indicates this is not a kitty sequence but we have incorrectly
// interpreted it as such. See the examples above for sequences
// such as FOCUS_IN that are not Kitty sequences.
console.warn('Kitty sequence buffer has char codes:', codes);
}
// If buffer doesn't match expected pattern and is getting long, flush it
if (kittySequenceBuffer.length > MAX_KITTY_SEQUENCE_LENGTH) {
// Log telemetry for buffer overflow
if (config) {
const event = new KittySequenceOverflowEvent(
kittySequenceBuffer.length,
kittySequenceBuffer,
);
logKittySequenceOverflow(config, event);
}
// Not a Kitty sequence, treat as regular key
kittySequenceBuffer = '';
} else {
// Wait for more characters
return;
}
}
}
if (key.name === 'paste-start') {
isPaste = true;
} else if (key.name === 'paste-end') {
@@ -84,7 +308,7 @@ export function useKeypress(
pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]);
} else {
// Handle special keys
if (key.name === 'return' && key.sequence === '\x1B\r') {
if (key.name === 'return' && key.sequence === `${ESC}\r`) {
key.meta = true;
}
onKeypressRef.current({ ...key, paste: isPaste });
@@ -93,13 +317,13 @@ export function useKeypress(
};
const handleRawKeypress = (data: Buffer) => {
const PASTE_MODE_PREFIX = Buffer.from('\x1B[200~');
const PASTE_MODE_SUFFIX = Buffer.from('\x1B[201~');
const pasteModePrefixBuffer = Buffer.from(PASTE_MODE_PREFIX);
const pasteModeSuffixBuffer = Buffer.from(PASTE_MODE_SUFFIX);
let pos = 0;
while (pos < data.length) {
const prefixPos = data.indexOf(PASTE_MODE_PREFIX, pos);
const suffixPos = data.indexOf(PASTE_MODE_SUFFIX, pos);
const prefixPos = data.indexOf(pasteModePrefixBuffer, pos);
const suffixPos = data.indexOf(pasteModeSuffixBuffer, pos);
// Determine which marker comes first, if any.
const isPrefixNext =
@@ -115,7 +339,7 @@ export function useKeypress(
} else if (isSuffixNext) {
nextMarkerPos = suffixPos;
}
markerLength = PASTE_MODE_SUFFIX.length;
markerLength = pasteModeSuffixBuffer.length;
if (nextMarkerPos === -1) {
keypressStream.write(data.slice(pos));
@@ -170,6 +394,12 @@ export function useKeypress(
rl.close();
setRawMode(false);
// Clean up any pending backslash timeout
if (backslashTimeout) {
clearTimeout(backslashTimeout);
backslashTimeout = null;
}
// If we are in the middle of a paste, send what we have.
if (isPaste) {
onKeypressRef.current({
@@ -183,5 +413,5 @@ export function useKeypress(
pasteBuffer = Buffer.alloc(0);
}
};
}, [isActive, stdin, setRawMode]);
}, [isActive, stdin, setRawMode, kittyProtocolEnabled, config]);
}

View File

@@ -0,0 +1,31 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState } from 'react';
import {
isKittyProtocolEnabled,
isKittyProtocolSupported,
} from '../utils/kittyProtocolDetector.js';
export interface KittyProtocolStatus {
supported: boolean;
enabled: boolean;
checking: boolean;
}
/**
* Hook that returns the cached Kitty keyboard protocol status.
* Detection is done once at app startup to avoid repeated queries.
*/
export function useKittyKeyboardProtocol(): KittyProtocolStatus {
const [status] = useState<KittyProtocolStatus>({
supported: isKittyProtocolSupported(),
enabled: isKittyProtocolEnabled(),
checking: false,
});
return status;
}

View File

@@ -23,7 +23,7 @@ import {
ToolCall, // Import from core
Status as ToolCallStatusType,
ApprovalMode,
Icon,
Kind,
BaseTool,
AnyDeclarativeTool,
AnyToolInvocation,
@@ -67,7 +67,7 @@ class MockTool extends BaseTool<object, ToolResult> {
name,
displayName,
'A mock tool for testing',
Icon.Hammer,
Kind.Other,
{},
isOutputMarkdown,
canUpdateOutput,

View File

@@ -4,12 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Newline, Text, useInput } from 'ink';
import { Box, Newline, Text } from 'ink';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import { usePrivacySettings } from '../hooks/usePrivacySettings.js';
import { CloudPaidPrivacyNotice } from './CloudPaidPrivacyNotice.js';
import { Config } from '@qwen-code/qwen-code-core';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface CloudFreePrivacyNoticeProps {
config: Config;
@@ -23,11 +24,14 @@ export const CloudFreePrivacyNotice = ({
const { privacyState, updateDataCollectionOptIn } =
usePrivacySettings(config);
useInput((input, key) => {
if (privacyState.error && key.escape) {
onExit();
}
});
useKeypress(
(key) => {
if (privacyState.error && key.name === 'escape') {
onExit();
}
},
{ isActive: true },
);
if (privacyState.isLoading) {
return <Text color={Colors.Gray}>Loading...</Text>;

View File

@@ -4,8 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Newline, Text, useInput } from 'ink';
import { Box, Newline, Text } from 'ink';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface CloudPaidPrivacyNoticeProps {
onExit: () => void;
@@ -14,11 +15,14 @@ interface CloudPaidPrivacyNoticeProps {
export const CloudPaidPrivacyNotice = ({
onExit,
}: CloudPaidPrivacyNoticeProps) => {
useInput((input, key) => {
if (key.escape) {
onExit();
}
});
useKeypress(
(key) => {
if (key.name === 'escape') {
onExit();
}
},
{ isActive: true },
);
return (
<Box flexDirection="column" marginBottom={1}>

View File

@@ -4,19 +4,23 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Newline, Text, useInput } from 'ink';
import { Box, Newline, Text } from 'ink';
import { Colors } from '../colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
interface GeminiPrivacyNoticeProps {
onExit: () => void;
}
export const GeminiPrivacyNotice = ({ onExit }: GeminiPrivacyNoticeProps) => {
useInput((input, key) => {
if (key.escape) {
onExit();
}
});
useKeypress(
(key) => {
if (key.name === 'escape') {
onExit();
}
},
{ isActive: true },
);
return (
<Box flexDirection="column" marginBottom={1}>

View File

@@ -0,0 +1,105 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
let detectionComplete = false;
let protocolSupported = false;
let protocolEnabled = false;
/**
* Detects Kitty keyboard protocol support.
* Definitive document about this protocol lives at https://sw.kovidgoyal.net/kitty/keyboard-protocol/
* This function should be called once at app startup.
*/
export async function detectAndEnableKittyProtocol(): Promise<boolean> {
if (detectionComplete) {
return protocolSupported;
}
return new Promise((resolve) => {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
detectionComplete = true;
resolve(false);
return;
}
const originalRawMode = process.stdin.isRaw;
if (!originalRawMode) {
process.stdin.setRawMode(true);
}
let responseBuffer = '';
let progressiveEnhancementReceived = false;
let checkFinished = false;
const handleData = (data: Buffer) => {
responseBuffer += data.toString();
// Check for progressive enhancement response (CSI ? <flags> u)
if (responseBuffer.includes('\x1b[?') && responseBuffer.includes('u')) {
progressiveEnhancementReceived = true;
}
// Check for device attributes response (CSI ? <attrs> c)
if (responseBuffer.includes('\x1b[?') && responseBuffer.includes('c')) {
if (!checkFinished) {
checkFinished = true;
process.stdin.removeListener('data', handleData);
if (!originalRawMode) {
process.stdin.setRawMode(false);
}
if (progressiveEnhancementReceived) {
// Enable the protocol
process.stdout.write('\x1b[>1u');
protocolSupported = true;
protocolEnabled = true;
// Set up cleanup on exit
process.on('exit', disableProtocol);
process.on('SIGTERM', disableProtocol);
}
detectionComplete = true;
resolve(protocolSupported);
}
}
};
process.stdin.on('data', handleData);
// Send queries
process.stdout.write('\x1b[?u'); // Query progressive enhancement
process.stdout.write('\x1b[c'); // Query device attributes
// Timeout after 50ms
setTimeout(() => {
if (!checkFinished) {
process.stdin.removeListener('data', handleData);
if (!originalRawMode) {
process.stdin.setRawMode(false);
}
detectionComplete = true;
resolve(false);
}
}, 50);
});
}
function disableProtocol() {
if (protocolEnabled) {
process.stdout.write('\x1b[<u');
protocolEnabled = false;
}
}
export function isKittyProtocolEnabled(): boolean {
return protocolEnabled;
}
export function isKittyProtocolSupported(): boolean {
return protocolSupported;
}

View File

@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Terminal Platform Constants
*
* This file contains terminal-related constants used throughout the application,
* specifically for handling keyboard inputs and terminal protocols.
*/
/**
* Kitty keyboard protocol sequences for enhanced keyboard input.
* @see https://sw.kovidgoyal.net/kitty/keyboard-protocol/
*/
export const KITTY_CTRL_C = '[99;5u';
/**
* Timing constants for terminal interactions
*/
export const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
/**
* VS Code terminal integration constants
*/
export const VSCODE_SHIFT_ENTER_SEQUENCE = '\\\r\n';
/**
* Backslash + Enter detection window in milliseconds.
* Used to detect Shift+Enter pattern where backslash
* is followed by Enter within this timeframe.
*/
export const BACKSLASH_ENTER_DETECTION_WINDOW_MS = 5;
/**
* Maximum expected length of a Kitty keyboard protocol sequence.
* Format: ESC [ <keycode> ; <modifiers> u/~
* Example: \x1b[13;2u (Shift+Enter) = 8 chars
* Longest reasonable: \x1b[127;15~ = 11 chars (Del with all modifiers)
* We use 12 to provide a small buffer.
*/
export const MAX_KITTY_SEQUENCE_LENGTH = 12;

View File

@@ -0,0 +1,340 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Terminal setup utility for configuring Shift+Enter and Ctrl+Enter support.
*
* This module provides automatic detection and configuration of various terminal
* emulators to support multiline input through modified Enter keys.
*
* Supported terminals:
* - VS Code: Configures keybindings.json to send \\\r\n
* - Cursor: Configures keybindings.json to send \\\r\n (VS Code fork)
* - Windsurf: Configures keybindings.json to send \\\r\n (VS Code fork)
*
* For VS Code and its forks:
* - Shift+Enter: Sends \\\r\n (backslash followed by CRLF)
* - Ctrl+Enter: Sends \\\r\n (backslash followed by CRLF)
*
* The module will not modify existing shift+enter or ctrl+enter keybindings
* to avoid conflicts with user customizations.
*/
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import { isKittyProtocolEnabled } from './kittyProtocolDetector.js';
import { VSCODE_SHIFT_ENTER_SEQUENCE } from './platformConstants.js';
const execAsync = promisify(exec);
/**
* Removes single-line JSON comments (// ...) from a string to allow parsing
* VS Code style JSON files that may contain comments.
*/
function stripJsonComments(content: string): string {
// Remove single-line comments (// ...)
return content.replace(/^\s*\/\/.*$/gm, '');
}
export interface TerminalSetupResult {
success: boolean;
message: string;
requiresRestart?: boolean;
}
type SupportedTerminal = 'vscode' | 'cursor' | 'windsurf';
// Terminal detection
async function detectTerminal(): Promise<SupportedTerminal | null> {
const termProgram = process.env.TERM_PROGRAM;
// Check VS Code and its forks - check forks first to avoid false positives
// Check for Cursor-specific indicators
if (
process.env.CURSOR_TRACE_ID ||
process.env.VSCODE_GIT_ASKPASS_MAIN?.toLowerCase().includes('cursor')
) {
return 'cursor';
}
// Check for Windsurf-specific indicators
if (process.env.VSCODE_GIT_ASKPASS_MAIN?.toLowerCase().includes('windsurf')) {
return 'windsurf';
}
// Check VS Code last since forks may also set VSCODE env vars
if (termProgram === 'vscode' || process.env.VSCODE_GIT_IPC_HANDLE) {
return 'vscode';
}
// Check parent process name
if (os.platform() !== 'win32') {
try {
const { stdout } = await execAsync('ps -o comm= -p $PPID');
const parentName = stdout.trim();
// Check forks before VS Code to avoid false positives
if (parentName.includes('windsurf') || parentName.includes('Windsurf'))
return 'windsurf';
if (parentName.includes('cursor') || parentName.includes('Cursor'))
return 'cursor';
if (parentName.includes('code') || parentName.includes('Code'))
return 'vscode';
} catch (error) {
// Continue detection even if process check fails
console.debug('Parent process detection failed:', error);
}
}
return null;
}
// Backup file helper
async function backupFile(filePath: string): Promise<void> {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${filePath}.backup.${timestamp}`;
await fs.copyFile(filePath, backupPath);
} catch (error) {
// Log backup errors but continue with operation
console.warn(`Failed to create backup of ${filePath}:`, error);
}
}
// Helper function to get VS Code-style config directory
function getVSCodeStyleConfigDir(appName: string): string | null {
const platform = os.platform();
if (platform === 'darwin') {
return path.join(
os.homedir(),
'Library',
'Application Support',
appName,
'User',
);
} else if (platform === 'win32') {
if (!process.env.APPDATA) {
return null;
}
return path.join(process.env.APPDATA, appName, 'User');
} else {
return path.join(os.homedir(), '.config', appName, 'User');
}
}
// Generic VS Code-style terminal configuration
async function configureVSCodeStyle(
terminalName: string,
appName: string,
): Promise<TerminalSetupResult> {
const configDir = getVSCodeStyleConfigDir(appName);
if (!configDir) {
return {
success: false,
message: `Could not determine ${terminalName} config path on Windows: APPDATA environment variable is not set.`,
};
}
const keybindingsFile = path.join(configDir, 'keybindings.json');
try {
await fs.mkdir(configDir, { recursive: true });
let keybindings: unknown[] = [];
try {
const content = await fs.readFile(keybindingsFile, 'utf8');
await backupFile(keybindingsFile);
try {
const cleanContent = stripJsonComments(content);
const parsedContent = JSON.parse(cleanContent);
if (!Array.isArray(parsedContent)) {
return {
success: false,
message:
`${terminalName} keybindings.json exists but is not a valid JSON array. ` +
`Please fix the file manually or delete it to allow automatic configuration.\n` +
`File: ${keybindingsFile}`,
};
}
keybindings = parsedContent;
} catch (parseError) {
return {
success: false,
message:
`Failed to parse ${terminalName} keybindings.json. The file contains invalid JSON.\n` +
`Please fix the file manually or delete it to allow automatic configuration.\n` +
`File: ${keybindingsFile}\n` +
`Error: ${parseError}`,
};
}
} catch {
// File doesn't exist, will create new one
}
const shiftEnterBinding = {
key: 'shift+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
};
const ctrlEnterBinding = {
key: 'ctrl+enter',
command: 'workbench.action.terminal.sendSequence',
when: 'terminalFocus',
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
};
// Check if ANY shift+enter or ctrl+enter bindings already exist
const existingShiftEnter = keybindings.find((kb) => {
const binding = kb as { key?: string };
return binding.key === 'shift+enter';
});
const existingCtrlEnter = keybindings.find((kb) => {
const binding = kb as { key?: string };
return binding.key === 'ctrl+enter';
});
if (existingShiftEnter || existingCtrlEnter) {
const messages: string[] = [];
if (existingShiftEnter) {
messages.push(`- Shift+Enter binding already exists`);
}
if (existingCtrlEnter) {
messages.push(`- Ctrl+Enter binding already exists`);
}
return {
success: false,
message:
`Existing keybindings detected. Will not modify to avoid conflicts.\n` +
messages.join('\n') +
'\n' +
`Please check and modify manually if needed: ${keybindingsFile}`,
};
}
// Check if our specific bindings already exist
const hasOurShiftEnter = keybindings.some((kb) => {
const binding = kb as {
command?: string;
args?: { text?: string };
key?: string;
};
return (
binding.key === 'shift+enter' &&
binding.command === 'workbench.action.terminal.sendSequence' &&
binding.args?.text === '\\\r\n'
);
});
const hasOurCtrlEnter = keybindings.some((kb) => {
const binding = kb as {
command?: string;
args?: { text?: string };
key?: string;
};
return (
binding.key === 'ctrl+enter' &&
binding.command === 'workbench.action.terminal.sendSequence' &&
binding.args?.text === '\\\r\n'
);
});
if (!hasOurShiftEnter || !hasOurCtrlEnter) {
if (!hasOurShiftEnter) keybindings.unshift(shiftEnterBinding);
if (!hasOurCtrlEnter) keybindings.unshift(ctrlEnterBinding);
await fs.writeFile(keybindingsFile, JSON.stringify(keybindings, null, 4));
return {
success: true,
message: `Added Shift+Enter and Ctrl+Enter keybindings to ${terminalName}.\nModified: ${keybindingsFile}`,
requiresRestart: true,
};
} else {
return {
success: true,
message: `${terminalName} keybindings already configured.`,
};
}
} catch (error) {
return {
success: false,
message: `Failed to configure ${terminalName}.\nFile: ${keybindingsFile}\nError: ${error}`,
};
}
}
// Terminal-specific configuration functions
async function configureVSCode(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('VS Code', 'Code');
}
async function configureCursor(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('Cursor', 'Cursor');
}
async function configureWindsurf(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('Windsurf', 'Windsurf');
}
/**
* Main terminal setup function that detects and configures the current terminal.
*
* This function:
* 1. Detects the current terminal emulator
* 2. Applies appropriate configuration for Shift+Enter and Ctrl+Enter support
* 3. Creates backups of configuration files before modifying them
*
* @returns Promise<TerminalSetupResult> Result object with success status and message
*
* @example
* const result = await terminalSetup();
* if (result.success) {
* console.log(result.message);
* if (result.requiresRestart) {
* console.log('Please restart your terminal');
* }
* }
*/
export async function terminalSetup(): Promise<TerminalSetupResult> {
// Check if terminal already has optimal keyboard support
if (isKittyProtocolEnabled()) {
return {
success: true,
message:
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).',
};
}
const terminal = await detectTerminal();
if (!terminal) {
return {
success: false,
message:
'Could not detect terminal type. Supported terminals: VS Code, Cursor, and Windsurf.',
};
}
switch (terminal) {
case 'vscode':
return configureVSCode();
case 'cursor':
return configureCursor();
case 'windsurf':
return configureWindsurf();
default:
return {
success: false,
message: `Terminal "${terminal}" is not supported yet.`,
};
}
}

View File

@@ -0,0 +1,28 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/* Fail to compile on unexpected values. */
export function assumeExhaustive(_value: never): void {}
/**
* Throws an exception on unexpected values.
*
* A common use case is switch statements:
* switch(enumValue) {
* case Enum.A:
* case Enum.B:
* break;
* default:
* checkExhaustive(enumValue);
* }
*/
export function checkExhaustive(
value: never,
msg = `unexpected value ${value}!`,
): never {
assumeExhaustive(value);
throw new Error(msg);
}

View File

@@ -0,0 +1,366 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/* ACP defines a schema for a simple (experimental) JSON-RPC protocol that allows GUI applications to interact with agents. */
import { z } from 'zod';
import * as schema from './schema.js';
export * from './schema.js';
import { WritableStream, ReadableStream } from 'node:stream/web';
export class AgentSideConnection implements Client {
#connection: Connection;
constructor(
toAgent: (conn: Client) => Agent,
input: WritableStream<Uint8Array>,
output: ReadableStream<Uint8Array>,
) {
const agent = toAgent(this);
const handler = async (
method: string,
params: unknown,
): Promise<unknown> => {
switch (method) {
case schema.AGENT_METHODS.initialize: {
const validatedParams = schema.initializeRequestSchema.parse(params);
return agent.initialize(validatedParams);
}
case schema.AGENT_METHODS.session_new: {
const validatedParams = schema.newSessionRequestSchema.parse(params);
return agent.newSession(validatedParams);
}
case schema.AGENT_METHODS.session_load: {
if (!agent.loadSession) {
throw RequestError.methodNotFound();
}
const validatedParams = schema.loadSessionRequestSchema.parse(params);
return agent.loadSession(validatedParams);
}
case schema.AGENT_METHODS.authenticate: {
const validatedParams =
schema.authenticateRequestSchema.parse(params);
return agent.authenticate(validatedParams);
}
case schema.AGENT_METHODS.session_prompt: {
const validatedParams = schema.promptRequestSchema.parse(params);
return agent.prompt(validatedParams);
}
case schema.AGENT_METHODS.session_cancel: {
const validatedParams = schema.cancelNotificationSchema.parse(params);
return agent.cancel(validatedParams);
}
default:
throw RequestError.methodNotFound(method);
}
};
this.#connection = new Connection(handler, input, output);
}
/**
* Streams new content to the client including text, tool calls, etc.
*/
async sessionUpdate(params: schema.SessionNotification): Promise<void> {
return await this.#connection.sendNotification(
schema.CLIENT_METHODS.session_update,
params,
);
}
/**
* Request permission before running a tool
*
* The agent specifies a series of permission options with different granularity,
* and the client returns the chosen one.
*/
async requestPermission(
params: schema.RequestPermissionRequest,
): Promise<schema.RequestPermissionResponse> {
return await this.#connection.sendRequest(
schema.CLIENT_METHODS.session_request_permission,
params,
);
}
async readTextFile(
params: schema.ReadTextFileRequest,
): Promise<schema.ReadTextFileResponse> {
return await this.#connection.sendRequest(
schema.CLIENT_METHODS.fs_read_text_file,
params,
);
}
async writeTextFile(
params: schema.WriteTextFileRequest,
): Promise<schema.WriteTextFileResponse> {
return await this.#connection.sendRequest(
schema.CLIENT_METHODS.fs_write_text_file,
params,
);
}
}
type AnyMessage = AnyRequest | AnyResponse | AnyNotification;
type AnyRequest = {
jsonrpc: '2.0';
id: string | number;
method: string;
params?: unknown;
};
type AnyResponse = {
jsonrpc: '2.0';
id: string | number;
} & Result<unknown>;
type AnyNotification = {
jsonrpc: '2.0';
method: string;
params?: unknown;
};
type Result<T> =
| {
result: T;
}
| {
error: ErrorResponse;
};
type ErrorResponse = {
code: number;
message: string;
data?: unknown;
};
type PendingResponse = {
resolve: (response: unknown) => void;
reject: (error: ErrorResponse) => void;
};
type MethodHandler = (method: string, params: unknown) => Promise<unknown>;
class Connection {
#pendingResponses: Map<string | number, PendingResponse> = new Map();
#nextRequestId: number = 0;
#handler: MethodHandler;
#peerInput: WritableStream<Uint8Array>;
#writeQueue: Promise<void> = Promise.resolve();
#textEncoder: TextEncoder;
constructor(
handler: MethodHandler,
peerInput: WritableStream<Uint8Array>,
peerOutput: ReadableStream<Uint8Array>,
) {
this.#handler = handler;
this.#peerInput = peerInput;
this.#textEncoder = new TextEncoder();
this.#receive(peerOutput);
}
async #receive(output: ReadableStream<Uint8Array>) {
let content = '';
const decoder = new TextDecoder();
for await (const chunk of output) {
content += decoder.decode(chunk, { stream: true });
const lines = content.split('\n');
content = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine) {
const message = JSON.parse(trimmedLine);
this.#processMessage(message);
}
}
}
}
async #processMessage(message: AnyMessage) {
if ('method' in message && 'id' in message) {
// It's a request
const response = await this.#tryCallHandler(
message.method,
message.params,
);
await this.#sendMessage({
jsonrpc: '2.0',
id: message.id,
...response,
});
} else if ('method' in message) {
// It's a notification
await this.#tryCallHandler(message.method, message.params);
} else if ('id' in message) {
// It's a response
this.#handleResponse(message as AnyResponse);
}
}
async #tryCallHandler(
method: string,
params?: unknown,
): Promise<Result<unknown>> {
try {
const result = await this.#handler(method, params);
return { result: result ?? null };
} catch (error: unknown) {
if (error instanceof RequestError) {
return error.toResult();
}
if (error instanceof z.ZodError) {
return RequestError.invalidParams(
JSON.stringify(error.format(), undefined, 2),
).toResult();
}
let details;
if (error instanceof Error) {
details = error.message;
} else if (
typeof error === 'object' &&
error != null &&
'message' in error &&
typeof error.message === 'string'
) {
details = error.message;
}
return RequestError.internalError(details).toResult();
}
}
#handleResponse(response: AnyResponse) {
const pendingResponse = this.#pendingResponses.get(response.id);
if (pendingResponse) {
if ('result' in response) {
pendingResponse.resolve(response.result);
} else if ('error' in response) {
pendingResponse.reject(response.error);
}
this.#pendingResponses.delete(response.id);
}
}
async sendRequest<Req, Resp>(method: string, params?: Req): Promise<Resp> {
const id = this.#nextRequestId++;
const responsePromise = new Promise((resolve, reject) => {
this.#pendingResponses.set(id, { resolve, reject });
});
await this.#sendMessage({ jsonrpc: '2.0', id, method, params });
return responsePromise as Promise<Resp>;
}
async sendNotification<N>(method: string, params?: N): Promise<void> {
await this.#sendMessage({ jsonrpc: '2.0', method, params });
}
async #sendMessage(json: AnyMessage) {
const content = JSON.stringify(json) + '\n';
this.#writeQueue = this.#writeQueue
.then(async () => {
const writer = this.#peerInput.getWriter();
try {
await writer.write(this.#textEncoder.encode(content));
} finally {
writer.releaseLock();
}
})
.catch((error) => {
// Continue processing writes on error
console.error('ACP write error:', error);
});
return this.#writeQueue;
}
}
export class RequestError extends Error {
data?: { details?: string };
constructor(
public code: number,
message: string,
details?: string,
) {
super(message);
this.name = 'RequestError';
if (details) {
this.data = { details };
}
}
static parseError(details?: string): RequestError {
return new RequestError(-32700, 'Parse error', details);
}
static invalidRequest(details?: string): RequestError {
return new RequestError(-32600, 'Invalid request', details);
}
static methodNotFound(details?: string): RequestError {
return new RequestError(-32601, 'Method not found', details);
}
static invalidParams(details?: string): RequestError {
return new RequestError(-32602, 'Invalid params', details);
}
static internalError(details?: string): RequestError {
return new RequestError(-32603, 'Internal error', details);
}
static authRequired(details?: string): RequestError {
return new RequestError(-32000, 'Authentication required', details);
}
toResult<T>(): Result<T> {
return {
error: {
code: this.code,
message: this.message,
data: this.data,
},
};
}
}
export interface Client {
requestPermission(
params: schema.RequestPermissionRequest,
): Promise<schema.RequestPermissionResponse>;
sessionUpdate(params: schema.SessionNotification): Promise<void>;
writeTextFile(
params: schema.WriteTextFileRequest,
): Promise<schema.WriteTextFileResponse>;
readTextFile(
params: schema.ReadTextFileRequest,
): Promise<schema.ReadTextFileResponse>;
}
export interface Agent {
initialize(
params: schema.InitializeRequest,
): Promise<schema.InitializeResponse>;
newSession(
params: schema.NewSessionRequest,
): Promise<schema.NewSessionResponse>;
loadSession?(
params: schema.LoadSessionRequest,
): Promise<schema.LoadSessionResponse>;
authenticate(params: schema.AuthenticateRequest): Promise<void>;
prompt(params: schema.PromptRequest): Promise<schema.PromptResponse>;
cancel(params: schema.CancelNotification): Promise<void>;
}

View File

@@ -0,0 +1,457 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { z } from 'zod';
export const AGENT_METHODS = {
authenticate: 'authenticate',
initialize: 'initialize',
session_cancel: 'session/cancel',
session_load: 'session/load',
session_new: 'session/new',
session_prompt: 'session/prompt',
};
export const CLIENT_METHODS = {
fs_read_text_file: 'fs/read_text_file',
fs_write_text_file: 'fs/write_text_file',
session_request_permission: 'session/request_permission',
session_update: 'session/update',
};
export const PROTOCOL_VERSION = 1;
export type WriteTextFileRequest = z.infer<typeof writeTextFileRequestSchema>;
export type ReadTextFileRequest = z.infer<typeof readTextFileRequestSchema>;
export type PermissionOptionKind = z.infer<typeof permissionOptionKindSchema>;
export type Role = z.infer<typeof roleSchema>;
export type TextResourceContents = z.infer<typeof textResourceContentsSchema>;
export type BlobResourceContents = z.infer<typeof blobResourceContentsSchema>;
export type ToolKind = z.infer<typeof toolKindSchema>;
export type ToolCallStatus = z.infer<typeof toolCallStatusSchema>;
export type WriteTextFileResponse = z.infer<typeof writeTextFileResponseSchema>;
export type ReadTextFileResponse = z.infer<typeof readTextFileResponseSchema>;
export type RequestPermissionOutcome = z.infer<
typeof requestPermissionOutcomeSchema
>;
export type CancelNotification = z.infer<typeof cancelNotificationSchema>;
export type AuthenticateRequest = z.infer<typeof authenticateRequestSchema>;
export type AuthenticateResponse = z.infer<typeof authenticateResponseSchema>;
export type NewSessionResponse = z.infer<typeof newSessionResponseSchema>;
export type LoadSessionResponse = z.infer<typeof loadSessionResponseSchema>;
export type StopReason = z.infer<typeof stopReasonSchema>;
export type PromptResponse = z.infer<typeof promptResponseSchema>;
export type ToolCallLocation = z.infer<typeof toolCallLocationSchema>;
export type PlanEntry = z.infer<typeof planEntrySchema>;
export type PermissionOption = z.infer<typeof permissionOptionSchema>;
export type Annotations = z.infer<typeof annotationsSchema>;
export type RequestPermissionResponse = z.infer<
typeof requestPermissionResponseSchema
>;
export type FileSystemCapability = z.infer<typeof fileSystemCapabilitySchema>;
export type EnvVariable = z.infer<typeof envVariableSchema>;
export type McpServer = z.infer<typeof mcpServerSchema>;
export type AgentCapabilities = z.infer<typeof agentCapabilitiesSchema>;
export type AuthMethod = z.infer<typeof authMethodSchema>;
export type ClientResponse = z.infer<typeof clientResponseSchema>;
export type ClientNotification = z.infer<typeof clientNotificationSchema>;
export type EmbeddedResourceResource = z.infer<
typeof embeddedResourceResourceSchema
>;
export type NewSessionRequest = z.infer<typeof newSessionRequestSchema>;
export type LoadSessionRequest = z.infer<typeof loadSessionRequestSchema>;
export type InitializeResponse = z.infer<typeof initializeResponseSchema>;
export type ContentBlock = z.infer<typeof contentBlockSchema>;
export type ToolCallContent = z.infer<typeof toolCallContentSchema>;
export type ToolCall = z.infer<typeof toolCallSchema>;
export type ClientCapabilities = z.infer<typeof clientCapabilitiesSchema>;
export type PromptRequest = z.infer<typeof promptRequestSchema>;
export type SessionUpdate = z.infer<typeof sessionUpdateSchema>;
export type AgentResponse = z.infer<typeof agentResponseSchema>;
export type RequestPermissionRequest = z.infer<
typeof requestPermissionRequestSchema
>;
export type InitializeRequest = z.infer<typeof initializeRequestSchema>;
export type SessionNotification = z.infer<typeof sessionNotificationSchema>;
export type ClientRequest = z.infer<typeof clientRequestSchema>;
export type AgentRequest = z.infer<typeof agentRequestSchema>;
export type AgentNotification = z.infer<typeof agentNotificationSchema>;
export const writeTextFileRequestSchema = z.object({
content: z.string(),
path: z.string(),
sessionId: z.string(),
});
export const readTextFileRequestSchema = z.object({
limit: z.number().optional().nullable(),
line: z.number().optional().nullable(),
path: z.string(),
sessionId: z.string(),
});
export const permissionOptionKindSchema = z.union([
z.literal('allow_once'),
z.literal('allow_always'),
z.literal('reject_once'),
z.literal('reject_always'),
]);
export const roleSchema = z.union([z.literal('assistant'), z.literal('user')]);
export const textResourceContentsSchema = z.object({
mimeType: z.string().optional().nullable(),
text: z.string(),
uri: z.string(),
});
export const blobResourceContentsSchema = z.object({
blob: z.string(),
mimeType: z.string().optional().nullable(),
uri: z.string(),
});
export const toolKindSchema = z.union([
z.literal('read'),
z.literal('edit'),
z.literal('delete'),
z.literal('move'),
z.literal('search'),
z.literal('execute'),
z.literal('think'),
z.literal('fetch'),
z.literal('other'),
]);
export const toolCallStatusSchema = z.union([
z.literal('pending'),
z.literal('in_progress'),
z.literal('completed'),
z.literal('failed'),
]);
export const writeTextFileResponseSchema = z.null();
export const readTextFileResponseSchema = z.object({
content: z.string(),
});
export const requestPermissionOutcomeSchema = z.union([
z.object({
outcome: z.literal('cancelled'),
}),
z.object({
optionId: z.string(),
outcome: z.literal('selected'),
}),
]);
export const cancelNotificationSchema = z.object({
sessionId: z.string(),
});
export const authenticateRequestSchema = z.object({
methodId: z.string(),
});
export const authenticateResponseSchema = z.null();
export const newSessionResponseSchema = z.object({
sessionId: z.string(),
});
export const loadSessionResponseSchema = z.null();
export const stopReasonSchema = z.union([
z.literal('end_turn'),
z.literal('max_tokens'),
z.literal('refusal'),
z.literal('cancelled'),
]);
export const promptResponseSchema = z.object({
stopReason: stopReasonSchema,
});
export const toolCallLocationSchema = z.object({
line: z.number().optional().nullable(),
path: z.string(),
});
export const planEntrySchema = z.object({
content: z.string(),
priority: z.union([z.literal('high'), z.literal('medium'), z.literal('low')]),
status: z.union([
z.literal('pending'),
z.literal('in_progress'),
z.literal('completed'),
]),
});
export const permissionOptionSchema = z.object({
kind: permissionOptionKindSchema,
name: z.string(),
optionId: z.string(),
});
export const annotationsSchema = z.object({
audience: z.array(roleSchema).optional().nullable(),
lastModified: z.string().optional().nullable(),
priority: z.number().optional().nullable(),
});
export const requestPermissionResponseSchema = z.object({
outcome: requestPermissionOutcomeSchema,
});
export const fileSystemCapabilitySchema = z.object({
readTextFile: z.boolean(),
writeTextFile: z.boolean(),
});
export const envVariableSchema = z.object({
name: z.string(),
value: z.string(),
});
export const mcpServerSchema = z.object({
args: z.array(z.string()),
command: z.string(),
env: z.array(envVariableSchema),
name: z.string(),
});
export const agentCapabilitiesSchema = z.object({
loadSession: z.boolean(),
});
export const authMethodSchema = z.object({
description: z.string().nullable(),
id: z.string(),
name: z.string(),
});
export const clientResponseSchema = z.union([
writeTextFileResponseSchema,
readTextFileResponseSchema,
requestPermissionResponseSchema,
]);
export const clientNotificationSchema = cancelNotificationSchema;
export const embeddedResourceResourceSchema = z.union([
textResourceContentsSchema,
blobResourceContentsSchema,
]);
export const newSessionRequestSchema = z.object({
cwd: z.string(),
mcpServers: z.array(mcpServerSchema),
});
export const loadSessionRequestSchema = z.object({
cwd: z.string(),
mcpServers: z.array(mcpServerSchema),
sessionId: z.string(),
});
export const initializeResponseSchema = z.object({
agentCapabilities: agentCapabilitiesSchema,
authMethods: z.array(authMethodSchema),
protocolVersion: z.number(),
});
export const contentBlockSchema = z.union([
z.object({
annotations: annotationsSchema.optional().nullable(),
text: z.string(),
type: z.literal('text'),
}),
z.object({
annotations: annotationsSchema.optional().nullable(),
data: z.string(),
mimeType: z.string(),
type: z.literal('image'),
}),
z.object({
annotations: annotationsSchema.optional().nullable(),
data: z.string(),
mimeType: z.string(),
type: z.literal('audio'),
}),
z.object({
annotations: annotationsSchema.optional().nullable(),
description: z.string().optional().nullable(),
mimeType: z.string().optional().nullable(),
name: z.string(),
size: z.number().optional().nullable(),
title: z.string().optional().nullable(),
type: z.literal('resource_link'),
uri: z.string(),
}),
z.object({
annotations: annotationsSchema.optional().nullable(),
resource: embeddedResourceResourceSchema,
type: z.literal('resource'),
}),
]);
export const toolCallContentSchema = z.union([
z.object({
content: contentBlockSchema,
type: z.literal('content'),
}),
z.object({
newText: z.string(),
oldText: z.string().nullable(),
path: z.string(),
type: z.literal('diff'),
}),
]);
export const toolCallSchema = z.object({
content: z.array(toolCallContentSchema).optional(),
kind: toolKindSchema,
locations: z.array(toolCallLocationSchema).optional(),
rawInput: z.unknown().optional(),
status: toolCallStatusSchema,
title: z.string(),
toolCallId: z.string(),
});
export const clientCapabilitiesSchema = z.object({
fs: fileSystemCapabilitySchema,
});
export const promptRequestSchema = z.object({
prompt: z.array(contentBlockSchema),
sessionId: z.string(),
});
export const sessionUpdateSchema = z.union([
z.object({
content: contentBlockSchema,
sessionUpdate: z.literal('user_message_chunk'),
}),
z.object({
content: contentBlockSchema,
sessionUpdate: z.literal('agent_message_chunk'),
}),
z.object({
content: contentBlockSchema,
sessionUpdate: z.literal('agent_thought_chunk'),
}),
z.object({
content: z.array(toolCallContentSchema).optional(),
kind: toolKindSchema,
locations: z.array(toolCallLocationSchema).optional(),
rawInput: z.unknown().optional(),
sessionUpdate: z.literal('tool_call'),
status: toolCallStatusSchema,
title: z.string(),
toolCallId: z.string(),
}),
z.object({
content: z.array(toolCallContentSchema).optional().nullable(),
kind: toolKindSchema.optional().nullable(),
locations: z.array(toolCallLocationSchema).optional().nullable(),
rawInput: z.unknown().optional(),
sessionUpdate: z.literal('tool_call_update'),
status: toolCallStatusSchema.optional().nullable(),
title: z.string().optional().nullable(),
toolCallId: z.string(),
}),
z.object({
entries: z.array(planEntrySchema),
sessionUpdate: z.literal('plan'),
}),
]);
export const agentResponseSchema = z.union([
initializeResponseSchema,
authenticateResponseSchema,
newSessionResponseSchema,
loadSessionResponseSchema,
promptResponseSchema,
]);
export const requestPermissionRequestSchema = z.object({
options: z.array(permissionOptionSchema),
sessionId: z.string(),
toolCall: toolCallSchema,
});
export const initializeRequestSchema = z.object({
clientCapabilities: clientCapabilitiesSchema,
protocolVersion: z.number(),
});
export const sessionNotificationSchema = z.object({
sessionId: z.string(),
update: sessionUpdateSchema,
});
export const clientRequestSchema = z.union([
writeTextFileRequestSchema,
readTextFileRequestSchema,
requestPermissionRequestSchema,
]);
export const agentRequestSchema = z.union([
initializeRequestSchema,
authenticateRequestSchema,
newSessionRequestSchema,
loadSessionRequestSchema,
promptRequestSchema,
]);
export const agentNotificationSchema = sessionNotificationSchema;

View File

@@ -4,33 +4,43 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { WritableStream, ReadableStream } from 'node:stream/web';
import { ReadableStream, WritableStream } from 'node:stream/web';
import { Content, FunctionCall, Part, PartListUnion } from '@google/genai';
import {
AuthType,
clearCachedCredentialFile,
Config,
GeminiChat,
ToolRegistry,
logToolCall,
ToolResult,
convertToFunctionResponse,
GeminiChat,
getErrorMessage,
getErrorStatus,
isNodeError,
isWithinRoot,
logToolCall,
MCPServerConfig,
ToolCallConfirmationDetails,
ToolConfirmationOutcome,
clearCachedCredentialFile,
isNodeError,
getErrorMessage,
isWithinRoot,
getErrorStatus,
ToolRegistry,
ToolResult,
} from '@qwen-code/qwen-code-core';
import * as acp from './acp.js';
import { Agent } from './acp.js';
import { Readable, Writable } from 'node:stream';
import { Content, Part, FunctionCall, PartListUnion } from '@google/genai';
import { LoadedSettings, SettingScope } from '../config/settings.js';
import * as fs from 'fs/promises';
import { Readable, Writable } from 'node:stream';
import * as path from 'path';
import { z } from 'zod';
import { LoadedSettings, SettingScope } from '../config/settings.js';
import * as acp from './acp.js';
export async function runAcpPeer(config: Config, settings: LoadedSettings) {
import { randomUUID } from 'crypto';
import { CliArgs, loadCliConfig } from '../config/config.js';
import { Extension } from '../config/extension.js';
export async function runZedIntegration(
config: Config,
settings: LoadedSettings,
extensions: Extension[],
argv: CliArgs,
) {
const stdout = Writable.toWeb(process.stdout) as WritableStream;
const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
@@ -40,76 +50,176 @@ export async function runAcpPeer(config: Config, settings: LoadedSettings) {
console.info = console.error;
console.debug = console.error;
new acp.ClientConnection(
(client: acp.Client) => new GeminiAgent(config, settings, client),
new acp.AgentSideConnection(
(client: acp.Client) =>
new GeminiAgent(config, settings, extensions, argv, client),
stdout,
stdin,
);
}
class GeminiAgent implements Agent {
chat?: GeminiChat;
pendingSend?: AbortController;
class GeminiAgent {
private sessions: Map<string, Session> = new Map();
constructor(
private config: Config,
private settings: LoadedSettings,
private extensions: Extension[],
private argv: CliArgs,
private client: acp.Client,
) {}
async initialize(_: acp.InitializeParams): Promise<acp.InitializeResponse> {
async initialize(
_args: acp.InitializeRequest,
): Promise<acp.InitializeResponse> {
const authMethods = [
{
id: AuthType.LOGIN_WITH_GOOGLE,
name: 'Log in with Google',
description: null,
},
{
id: AuthType.USE_GEMINI,
name: 'Use Gemini API key',
description:
'Requires setting the `GEMINI_API_KEY` environment variable',
},
{
id: AuthType.USE_VERTEX_AI,
name: 'Vertex AI',
description: null,
},
];
return {
protocolVersion: acp.PROTOCOL_VERSION,
authMethods,
agentCapabilities: {
loadSession: false,
},
};
}
async authenticate({ methodId }: acp.AuthenticateRequest): Promise<void> {
const method = z.nativeEnum(AuthType).parse(methodId);
await clearCachedCredentialFile();
await this.config.refreshAuth(method);
this.settings.setValue(SettingScope.User, 'selectedAuthType', method);
}
async newSession({
cwd,
mcpServers,
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
const sessionId = randomUUID();
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
let isAuthenticated = false;
if (this.settings.merged.selectedAuthType) {
try {
await this.config.refreshAuth(this.settings.merged.selectedAuthType);
await config.refreshAuth(this.settings.merged.selectedAuthType);
isAuthenticated = true;
} catch (error) {
console.error('Failed to refresh auth:', error);
} catch (e) {
console.error(`Authentication failed: ${e}`);
}
}
return { protocolVersion: acp.LATEST_PROTOCOL_VERSION, isAuthenticated };
if (!isAuthenticated) {
throw acp.RequestError.authRequired();
}
const geminiClient = config.getGeminiClient();
const chat = await geminiClient.startChat();
const session = new Session(sessionId, chat, config, this.client);
this.sessions.set(sessionId, session);
return {
sessionId,
};
}
async authenticate(): Promise<void> {
await clearCachedCredentialFile();
await this.config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
this.settings.setValue(
SettingScope.User,
'selectedAuthType',
AuthType.LOGIN_WITH_GOOGLE,
async newSessionConfig(
sessionId: string,
cwd: string,
mcpServers: acp.McpServer[],
): Promise<Config> {
const mergedMcpServers = { ...this.settings.merged.mcpServers };
for (const { command, args, env: rawEnv, name } of mcpServers) {
const env: Record<string, string> = {};
for (const { name: envName, value } of rawEnv) {
env[envName] = value;
}
mergedMcpServers[name] = new MCPServerConfig(command, args, env, cwd);
}
const settings = { ...this.settings.merged, mcpServers: mergedMcpServers };
const config = await loadCliConfig(
settings,
this.extensions,
sessionId,
this.argv,
cwd,
);
await config.initialize();
return config;
}
async cancelSendMessage(): Promise<void> {
if (!this.pendingSend) {
async cancel(params: acp.CancelNotification): Promise<void> {
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`);
}
await session.cancelPendingPrompt();
}
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`);
}
return session.prompt(params);
}
}
class Session {
private pendingPrompt: AbortController | null = null;
constructor(
private readonly id: string,
private readonly chat: GeminiChat,
private readonly config: Config,
private readonly client: acp.Client,
) {}
async cancelPendingPrompt(): Promise<void> {
if (!this.pendingPrompt) {
throw new Error('Not currently generating');
}
this.pendingSend.abort();
delete this.pendingSend;
this.pendingPrompt.abort();
this.pendingPrompt = null;
}
async sendUserMessage(params: acp.SendUserMessageParams): Promise<void> {
this.pendingSend?.abort();
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
this.pendingPrompt?.abort();
const pendingSend = new AbortController();
this.pendingSend = pendingSend;
if (!this.chat) {
const geminiClient = this.config.getGeminiClient();
this.chat = await geminiClient.startChat();
}
this.pendingPrompt = pendingSend;
const promptId = Math.random().toString(16).slice(2);
const chat = this.chat!;
const toolRegistry: ToolRegistry = await this.config.getToolRegistry();
const parts = await this.#resolveUserMessage(params, pendingSend.signal);
const chat = this.chat;
const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
let nextMessage: Content | null = { role: 'user', parts };
while (nextMessage !== null) {
if (pendingSend.signal.aborted) {
chat.addHistory(nextMessage);
return;
return { stopReason: 'cancelled' };
}
const functionCalls: FunctionCall[] = [];
@@ -120,11 +230,6 @@ class GeminiAgent implements Agent {
message: nextMessage?.parts ?? [],
config: {
abortSignal: pendingSend.signal,
tools: [
{
functionDeclarations: toolRegistry.getFunctionDeclarations(),
},
],
},
},
promptId,
@@ -133,7 +238,7 @@ class GeminiAgent implements Agent {
for await (const resp of responseStream) {
if (pendingSend.signal.aborted) {
return;
return { stopReason: 'cancelled' };
}
if (resp.candidates && resp.candidates.length > 0) {
@@ -143,10 +248,16 @@ class GeminiAgent implements Agent {
continue;
}
this.client.streamAssistantMessageChunk({
chunk: part.thought
? { thought: part.text }
: { text: part.text },
const content: acp.ContentBlock = {
type: 'text',
text: part.text,
};
this.sendUpdate({
sessionUpdate: part.thought
? 'agent_thought_chunk'
: 'agent_message_chunk',
content,
});
}
}
@@ -170,11 +281,7 @@ class GeminiAgent implements Agent {
const toolResponseParts: Part[] = [];
for (const fc of functionCalls) {
const response = await this.#runTool(
pendingSend.signal,
promptId,
fc,
);
const response = await this.runTool(pendingSend.signal, promptId, fc);
const parts = Array.isArray(response) ? response : [response];
@@ -190,9 +297,20 @@ class GeminiAgent implements Agent {
nextMessage = { role: 'user', parts: toolResponseParts };
}
}
return { stopReason: 'end_turn' };
}
async #runTool(
private async sendUpdate(update: acp.SessionUpdate): Promise<void> {
const params: acp.SessionNotification = {
sessionId: this.id,
update,
};
await this.client.sessionUpdate(params);
}
private async runTool(
abortSignal: AbortSignal,
promptId: string,
fc: FunctionCall,
@@ -239,68 +357,82 @@ class GeminiAgent implements Agent {
);
}
let toolCallId: number | undefined = undefined;
try {
const invocation = tool.build(args);
const confirmationDetails =
await invocation.shouldConfirmExecute(abortSignal);
if (confirmationDetails) {
let content: acp.ToolCallContent | null = null;
if (confirmationDetails.type === 'edit') {
content = {
type: 'diff',
path: confirmationDetails.fileName,
oldText: confirmationDetails.originalContent,
newText: confirmationDetails.newContent,
};
}
const invocation = tool.build(args);
const confirmationDetails =
await invocation.shouldConfirmExecute(abortSignal);
const result = await this.client.requestToolCallConfirmation({
label: invocation.getDescription(),
icon: tool.icon,
content,
confirmation: toAcpToolCallConfirmation(confirmationDetails),
locations: invocation.toolLocations(),
if (confirmationDetails) {
const content: acp.ToolCallContent[] = [];
if (confirmationDetails.type === 'edit') {
content.push({
type: 'diff',
path: confirmationDetails.fileName,
oldText: confirmationDetails.originalContent,
newText: confirmationDetails.newContent,
});
await confirmationDetails.onConfirm(toToolCallOutcome(result.outcome));
switch (result.outcome) {
case 'reject':
return errorResponse(
new Error(`Tool "${fc.name}" not allowed to run by the user.`),
);
case 'cancel':
return errorResponse(
new Error(`Tool "${fc.name}" was canceled by the user.`),
);
case 'allow':
case 'alwaysAllow':
case 'alwaysAllowMcpServer':
case 'alwaysAllowTool':
break;
default: {
const resultOutcome: never = result.outcome;
throw new Error(`Unexpected: ${resultOutcome}`);
}
}
toolCallId = result.id;
} else {
const result = await this.client.pushToolCall({
icon: tool.icon,
label: invocation.getDescription(),
locations: invocation.toolLocations(),
});
toolCallId = result.id;
}
const toolResult: ToolResult = await invocation.execute(abortSignal);
const toolCallContent = toToolCallContent(toolResult);
const params: acp.RequestPermissionRequest = {
sessionId: this.id,
options: toPermissionOptions(confirmationDetails),
toolCall: {
toolCallId: callId,
status: 'pending',
title: invocation.getDescription(),
content,
locations: invocation.toolLocations(),
kind: tool.kind,
},
};
await this.client.updateToolCall({
toolCallId,
status: 'finished',
content: toolCallContent,
const output = await this.client.requestPermission(params);
const outcome =
output.outcome.outcome === 'cancelled'
? ToolConfirmationOutcome.Cancel
: z
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
await confirmationDetails.onConfirm(outcome);
switch (outcome) {
case ToolConfirmationOutcome.Cancel:
return errorResponse(
new Error(`Tool "${fc.name}" was canceled by the user.`),
);
case ToolConfirmationOutcome.ProceedOnce:
case ToolConfirmationOutcome.ProceedAlways:
case ToolConfirmationOutcome.ProceedAlwaysServer:
case ToolConfirmationOutcome.ProceedAlwaysTool:
case ToolConfirmationOutcome.ModifyWithEditor:
break;
default: {
const resultOutcome: never = outcome;
throw new Error(`Unexpected: ${resultOutcome}`);
}
}
} else {
await this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: callId,
status: 'in_progress',
title: invocation.getDescription(),
content: [],
locations: invocation.toolLocations(),
kind: tool.kind,
});
}
try {
const toolResult: ToolResult = await invocation.execute(abortSignal);
const content = toToolCallContent(toolResult);
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'completed',
content: content ? [content] : [],
});
const durationMs = Date.now() - startTime;
@@ -317,31 +449,55 @@ class GeminiAgent implements Agent {
return convertToFunctionResponse(fc.name, callId, toolResult.llmContent);
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
if (toolCallId) {
await this.client.updateToolCall({
toolCallId,
status: 'error',
content: { type: 'markdown', markdown: error.message },
});
}
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'failed',
content: [
{ type: 'content', content: { type: 'text', text: error.message } },
],
});
return errorResponse(error);
}
}
async #resolveUserMessage(
message: acp.SendUserMessageParams,
async #resolvePrompt(
message: acp.ContentBlock[],
abortSignal: AbortSignal,
): Promise<Part[]> {
const atPathCommandParts = message.chunks.filter((part) => 'path' in part);
const parts = message.map((part) => {
switch (part.type) {
case 'text':
return { text: part.text };
case 'resource_link':
return {
fileData: {
mimeData: part.mimeType,
name: part.name,
fileUri: part.uri,
},
};
case 'resource': {
return {
fileData: {
mimeData: part.resource.mimeType,
name: part.resource.uri,
fileUri: part.resource.uri,
},
};
}
default: {
throw new Error(`Unexpected chunk type: '${part.type}'`);
}
}
});
const atPathCommandParts = parts.filter((part) => 'fileData' in part);
if (atPathCommandParts.length === 0) {
return message.chunks.map((chunk) => {
if ('text' in chunk) {
return { text: chunk.text };
} else {
throw new Error('Unexpected chunk type');
}
});
return parts;
}
// Get centralized file discovery service
@@ -362,8 +518,7 @@ class GeminiAgent implements Agent {
}
for (const atPathPart of atPathCommandParts) {
const pathName = atPathPart.path;
const pathName = atPathPart.fileData!.fileUri;
// Check if path should be ignored by git
if (fileDiscovery.shouldGitIgnoreFile(pathName)) {
ignoredPaths.push(pathName);
@@ -373,10 +528,8 @@ class GeminiAgent implements Agent {
console.warn(`Path ${pathName} is ${reason}.`);
continue;
}
let currentPathSpec = pathName;
let resolvedSuccessfully = false;
try {
const absolutePath = path.resolve(this.config.getTargetDir(), pathName);
if (isWithinRoot(absolutePath, this.config.getTargetDir())) {
@@ -385,24 +538,22 @@ class GeminiAgent implements Agent {
currentPathSpec = pathName.endsWith('/')
? `${pathName}**`
: `${pathName}/**`;
this.#debug(
this.debug(
`Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
);
} else {
this.#debug(
`Path ${pathName} resolved to file: ${currentPathSpec}`,
);
this.debug(`Path ${pathName} resolved to file: ${currentPathSpec}`);
}
resolvedSuccessfully = true;
} else {
this.#debug(
this.debug(
`Path ${pathName} is outside the project directory. Skipping.`,
);
}
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
if (this.config.getEnableRecursiveFileSearch() && globTool) {
this.#debug(
this.debug(
`Path ${pathName} not found directly, attempting glob search.`,
);
try {
@@ -426,17 +577,17 @@ class GeminiAgent implements Agent {
this.config.getTargetDir(),
firstMatchAbsolute,
);
this.#debug(
this.debug(
`Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`,
);
resolvedSuccessfully = true;
} else {
this.#debug(
this.debug(
`Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
);
}
} else {
this.#debug(
this.debug(
`Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`,
);
}
@@ -446,7 +597,7 @@ class GeminiAgent implements Agent {
);
}
} else {
this.#debug(
this.debug(
`Glob tool not found. Path ${pathName} will be skipped.`,
);
}
@@ -456,23 +607,22 @@ class GeminiAgent implements Agent {
);
}
}
if (resolvedSuccessfully) {
pathSpecsToRead.push(currentPathSpec);
atPathToResolvedSpecMap.set(pathName, currentPathSpec);
contentLabelsForDisplay.push(pathName);
}
}
// Construct the initial part of the query for the LLM
let initialQueryText = '';
for (let i = 0; i < message.chunks.length; i++) {
const chunk = message.chunks[i];
for (let i = 0; i < parts.length; i++) {
const chunk = parts[i];
if ('text' in chunk) {
initialQueryText += chunk.text;
} else {
// type === 'atPath'
const resolvedSpec = atPathToResolvedSpecMap.get(chunk.path);
const resolvedSpec =
chunk.fileData && atPathToResolvedSpecMap.get(chunk.fileData.fileUri);
if (
i > 0 &&
initialQueryText.length > 0 &&
@@ -480,10 +630,11 @@ class GeminiAgent implements Agent {
resolvedSpec
) {
// Add space if previous part was text and didn't end with space, or if previous was @path
const prevPart = message.chunks[i - 1];
const prevPart = parts[i - 1];
if (
'text' in prevPart ||
('path' in prevPart && atPathToResolvedSpecMap.has(prevPart.path))
('fileData' in prevPart &&
atPathToResolvedSpecMap.has(prevPart.fileData!.fileUri))
) {
initialQueryText += ' ';
}
@@ -497,56 +648,64 @@ class GeminiAgent implements Agent {
i > 0 &&
initialQueryText.length > 0 &&
!initialQueryText.endsWith(' ') &&
!chunk.path.startsWith(' ')
!chunk.fileData?.fileUri.startsWith(' ')
) {
initialQueryText += ' ';
}
initialQueryText += `@${chunk.path}`;
if (chunk.fileData?.fileUri) {
initialQueryText += `@${chunk.fileData.fileUri}`;
}
}
}
}
initialQueryText = initialQueryText.trim();
// Inform user about ignored paths
if (ignoredPaths.length > 0) {
const ignoreType = respectGitIgnore ? 'git-ignored' : 'custom-ignored';
this.#debug(
this.debug(
`Ignored ${ignoredPaths.length} ${ignoreType} files: ${ignoredPaths.join(', ')}`,
);
}
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
if (pathSpecsToRead.length === 0) {
console.warn('No valid file paths found in @ commands to read.');
return [{ text: initialQueryText }];
}
const processedQueryParts: Part[] = [{ text: initialQueryText }];
const toolArgs = {
paths: pathSpecsToRead,
respectGitIgnore, // Use configuration setting
};
let toolCallId: number | undefined = undefined;
const callId = `${readManyFilesTool.name}-${Date.now()}`;
try {
const invocation = readManyFilesTool.build(toolArgs);
const toolCall = await this.client.pushToolCall({
icon: readManyFilesTool.icon,
label: invocation.getDescription(),
});
toolCallId = toolCall.id;
const result = await invocation.execute(abortSignal);
const content = toToolCallContent(result) || {
type: 'markdown',
markdown: `Successfully read: ${contentLabelsForDisplay.join(', ')}`,
};
await this.client.updateToolCall({
toolCallId: toolCall.id,
status: 'finished',
content,
await this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: callId,
status: 'in_progress',
title: invocation.getDescription(),
content: [],
locations: invocation.toolLocations(),
kind: readManyFilesTool.kind,
});
const result = await invocation.execute(abortSignal);
const content = toToolCallContent(result) || {
type: 'content',
content: {
type: 'text',
text: `Successfully read: ${contentLabelsForDisplay.join(', ')}`,
},
};
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'completed',
content: content ? [content] : [],
});
if (Array.isArray(result.llmContent)) {
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
processedQueryParts.push({
@@ -576,24 +735,28 @@ class GeminiAgent implements Agent {
'read_many_files tool returned no content or empty content.',
);
}
return processedQueryParts;
} catch (error: unknown) {
if (toolCallId) {
await this.client.updateToolCall({
toolCallId,
status: 'error',
content: {
type: 'markdown',
markdown: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'failed',
content: [
{
type: 'content',
content: {
type: 'text',
text: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
},
},
});
}
],
});
throw error;
}
}
#debug(msg: string) {
debug(msg: string) {
if (this.config.getDebugMode()) {
console.warn(msg);
}
@@ -604,8 +767,8 @@ function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
if (toolResult.returnDisplay) {
if (typeof toolResult.returnDisplay === 'string') {
return {
type: 'markdown',
markdown: toolResult.returnDisplay,
type: 'content',
content: { type: 'text', text: toolResult.returnDisplay },
};
} else {
return {
@@ -620,57 +783,66 @@ function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
}
}
function toAcpToolCallConfirmation(
confirmationDetails: ToolCallConfirmationDetails,
): acp.ToolCallConfirmation {
switch (confirmationDetails.type) {
case 'edit':
return { type: 'edit' };
case 'exec':
return {
type: 'execute',
rootCommand: confirmationDetails.rootCommand,
command: confirmationDetails.command,
};
case 'mcp':
return {
type: 'mcp',
serverName: confirmationDetails.serverName,
toolName: confirmationDetails.toolName,
toolDisplayName: confirmationDetails.toolDisplayName,
};
case 'info':
return {
type: 'fetch',
urls: confirmationDetails.urls || [],
description: confirmationDetails.urls?.length
? null
: confirmationDetails.prompt,
};
default: {
const unreachable: never = confirmationDetails;
throw new Error(`Unexpected: ${unreachable}`);
}
}
}
const basicPermissionOptions = [
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: 'Allow',
kind: 'allow_once',
},
{
optionId: ToolConfirmationOutcome.Cancel,
name: 'Reject',
kind: 'reject_once',
},
] as const;
function toToolCallOutcome(
outcome: acp.ToolCallConfirmationOutcome,
): ToolConfirmationOutcome {
switch (outcome) {
case 'allow':
return ToolConfirmationOutcome.ProceedOnce;
case 'alwaysAllow':
return ToolConfirmationOutcome.ProceedAlways;
case 'alwaysAllowMcpServer':
return ToolConfirmationOutcome.ProceedAlwaysServer;
case 'alwaysAllowTool':
return ToolConfirmationOutcome.ProceedAlwaysTool;
case 'reject':
case 'cancel':
return ToolConfirmationOutcome.Cancel;
function toPermissionOptions(
confirmation: ToolCallConfirmationDetails,
): acp.PermissionOption[] {
switch (confirmation.type) {
case 'edit':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Allow All Edits',
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'exec':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow ${confirmation.rootCommand}`,
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'mcp':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
name: `Always Allow ${confirmation.serverName}`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
name: `Always Allow ${confirmation.toolName}`,
kind: 'allow_always',
},
...basicPermissionOptions,
];
case 'info':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow`,
kind: 'allow_always',
},
...basicPermissionOptions,
];
default: {
const unreachable: never = outcome;
const unreachable: never = confirmation;
throw new Error(`Unexpected: ${unreachable}`);
}
}

View File

@@ -15,3 +15,4 @@ export {
IdeConnectionEvent,
IdeConnectionType,
} from './src/telemetry/types.js';
export { makeFakeConfig } from './src/test-utils/config.js';

View File

@@ -20,7 +20,7 @@
"dist"
],
"dependencies": {
"@google/genai": "1.9.0",
"@google/genai": "1.13.0",
"@modelcontextprotocol/sdk": "^1.11.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.52.0",

View File

@@ -16,9 +16,17 @@ const mockPaidTier: GeminiUserTier = {
id: UserTierId.STANDARD,
name: 'paid',
description: 'Paid tier',
isDefault: true,
};
describe('setupUser', () => {
const mockFreeTier: GeminiUserTier = {
id: UserTierId.FREE,
name: 'free',
description: 'Free tier',
isDefault: true,
};
describe('setupUser for existing user', () => {
let mockLoad: ReturnType<typeof vi.fn>;
let mockOnboardUser: ReturnType<typeof vi.fn>;
@@ -42,7 +50,7 @@ describe('setupUser', () => {
);
});
it('should use GOOGLE_CLOUD_PROJECT when set', async () => {
it('should use GOOGLE_CLOUD_PROJECT when set and project from server is undefined', async () => {
process.env.GOOGLE_CLOUD_PROJECT = 'test-project';
mockLoad.mockResolvedValue({
currentTier: mockPaidTier,
@@ -57,8 +65,8 @@ describe('setupUser', () => {
);
});
it('should treat empty GOOGLE_CLOUD_PROJECT as undefined and use project from server', async () => {
process.env.GOOGLE_CLOUD_PROJECT = '';
it('should ignore GOOGLE_CLOUD_PROJECT when project from server is set', async () => {
process.env.GOOGLE_CLOUD_PROJECT = 'test-project';
mockLoad.mockResolvedValue({
cloudaicompanionProject: 'server-project',
currentTier: mockPaidTier,
@@ -66,7 +74,7 @@ describe('setupUser', () => {
const projectId = await setupUser({} as OAuth2Client);
expect(CodeAssistServer).toHaveBeenCalledWith(
{},
undefined,
'test-project',
{},
'',
undefined,
@@ -89,3 +97,119 @@ describe('setupUser', () => {
);
});
});
describe('setupUser for new user', () => {
let mockLoad: ReturnType<typeof vi.fn>;
let mockOnboardUser: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.resetAllMocks();
mockLoad = vi.fn();
mockOnboardUser = vi.fn().mockResolvedValue({
done: true,
response: {
cloudaicompanionProject: {
id: 'server-project',
},
},
});
vi.mocked(CodeAssistServer).mockImplementation(
() =>
({
loadCodeAssist: mockLoad,
onboardUser: mockOnboardUser,
}) as unknown as CodeAssistServer,
);
});
it('should use GOOGLE_CLOUD_PROJECT when set and onboard a new paid user', async () => {
process.env.GOOGLE_CLOUD_PROJECT = 'test-project';
mockLoad.mockResolvedValue({
allowedTiers: [mockPaidTier],
});
const userData = await setupUser({} as OAuth2Client);
expect(CodeAssistServer).toHaveBeenCalledWith(
{},
'test-project',
{},
'',
undefined,
);
expect(mockLoad).toHaveBeenCalled();
expect(mockOnboardUser).toHaveBeenCalledWith({
tierId: 'standard-tier',
cloudaicompanionProject: 'test-project',
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
duetProject: 'test-project',
},
});
expect(userData).toEqual({
projectId: 'server-project',
userTier: 'standard-tier',
});
});
it('should onboard a new free user when GOOGLE_CLOUD_PROJECT is not set', async () => {
delete process.env.GOOGLE_CLOUD_PROJECT;
mockLoad.mockResolvedValue({
allowedTiers: [mockFreeTier],
});
const userData = await setupUser({} as OAuth2Client);
expect(CodeAssistServer).toHaveBeenCalledWith(
{},
undefined,
{},
'',
undefined,
);
expect(mockLoad).toHaveBeenCalled();
expect(mockOnboardUser).toHaveBeenCalledWith({
tierId: 'free-tier',
cloudaicompanionProject: undefined,
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
},
});
expect(userData).toEqual({
projectId: 'server-project',
userTier: 'free-tier',
});
});
it('should use GOOGLE_CLOUD_PROJECT when onboard response has no project ID', async () => {
process.env.GOOGLE_CLOUD_PROJECT = 'test-project';
mockLoad.mockResolvedValue({
allowedTiers: [mockPaidTier],
});
mockOnboardUser.mockResolvedValue({
done: true,
response: {
cloudaicompanionProject: undefined,
},
});
const userData = await setupUser({} as OAuth2Client);
expect(userData).toEqual({
projectId: 'test-project',
userTier: 'standard-tier',
});
});
it('should throw ProjectIdRequiredError when no project ID is available', async () => {
delete process.env.GOOGLE_CLOUD_PROJECT;
mockLoad.mockResolvedValue({
allowedTiers: [mockPaidTier],
});
mockOnboardUser.mockResolvedValue({
done: true,
response: {},
});
await expect(setupUser({} as OAuth2Client)).rejects.toThrow(
ProjectIdRequiredError,
);
});
});

View File

@@ -33,32 +33,58 @@ export interface UserData {
* @returns the user's actual project id
*/
export async function setupUser(client: OAuth2Client): Promise<UserData> {
let projectId = process.env.GOOGLE_CLOUD_PROJECT || undefined;
const projectId = process.env.GOOGLE_CLOUD_PROJECT || undefined;
const caServer = new CodeAssistServer(client, projectId, {}, '', undefined);
const clientMetadata: ClientMetadata = {
const coreClientMetadata: ClientMetadata = {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
duetProject: projectId,
};
const loadRes = await caServer.loadCodeAssist({
cloudaicompanionProject: projectId,
metadata: clientMetadata,
metadata: {
...coreClientMetadata,
duetProject: projectId,
},
});
if (!projectId && loadRes.cloudaicompanionProject) {
projectId = loadRes.cloudaicompanionProject;
if (loadRes.currentTier) {
if (!loadRes.cloudaicompanionProject) {
if (projectId) {
return {
projectId,
userTier: loadRes.currentTier.id,
};
}
throw new ProjectIdRequiredError();
}
return {
projectId: loadRes.cloudaicompanionProject,
userTier: loadRes.currentTier.id,
};
}
const tier = getOnboardTier(loadRes);
const onboardReq: OnboardUserRequest = {
tierId: tier.id,
cloudaicompanionProject: projectId,
metadata: clientMetadata,
};
let onboardReq: OnboardUserRequest;
if (tier.id === UserTierId.FREE) {
// The free tier uses a managed google cloud project. Setting a project in the `onboardUser` request causes a `Precondition Failed` error.
onboardReq = {
tierId: tier.id,
cloudaicompanionProject: undefined,
metadata: coreClientMetadata,
};
} else {
onboardReq = {
tierId: tier.id,
cloudaicompanionProject: projectId,
metadata: {
...coreClientMetadata,
duetProject: projectId,
},
};
}
// Poll onboardUser until long running operation is complete.
let lroRes = await caServer.onboardUser(onboardReq);
@@ -67,20 +93,23 @@ export async function setupUser(client: OAuth2Client): Promise<UserData> {
lroRes = await caServer.onboardUser(onboardReq);
}
if (!lroRes.response?.cloudaicompanionProject?.id && !projectId) {
if (!lroRes.response?.cloudaicompanionProject?.id) {
if (projectId) {
return {
projectId,
userTier: tier.id,
};
}
throw new ProjectIdRequiredError();
}
return {
projectId: lroRes.response?.cloudaicompanionProject?.id || projectId!,
projectId: lroRes.response.cloudaicompanionProject.id,
userTier: tier.id,
};
}
function getOnboardTier(res: LoadCodeAssistResponse): GeminiUserTier {
if (res.currentTier) {
return res.currentTier;
}
for (const tier of res.allowedTiers || []) {
if (tier.isDefault) {
return tier;

View File

@@ -4,7 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Mock } from 'vitest';
import { Config, ConfigParameters, SandboxConfig } from './config.js';
import * as path from 'path';
import { setGeminiMdFilename as mockSetGeminiMdFilename } from '../tools/memoryTool.js';
@@ -14,6 +15,7 @@ import {
} from '../telemetry/index.js';
import {
AuthType,
ContentGeneratorConfig,
createContentGeneratorConfig,
} from '../core/contentGenerator.js';
import { GeminiClient } from '../core/client.js';
@@ -131,6 +133,7 @@ describe('Server Config (config.ts)', () => {
telemetry: TELEMETRY_SETTINGS,
sessionId: SESSION_ID,
model: MODEL,
usageStatisticsEnabled: false,
};
beforeEach(() => {
@@ -254,6 +257,7 @@ describe('Server Config (config.ts)', () => {
// Verify that history was restored to the new client
expect(mockNewClient.setHistory).toHaveBeenCalledWith(
mockExistingHistory,
{ stripThoughts: false },
);
});
@@ -287,6 +291,92 @@ describe('Server Config (config.ts)', () => {
// Verify that setHistory was not called since there was no existing history
expect(mockNewClient.setHistory).not.toHaveBeenCalled();
});
it('should strip thoughts when switching from GenAI to Vertex', async () => {
const config = new Config(baseParams);
const mockContentConfig = {
model: 'gemini-pro',
apiKey: 'test-key',
authType: AuthType.USE_GEMINI,
};
(
config as unknown as { contentGeneratorConfig: ContentGeneratorConfig }
).contentGeneratorConfig = mockContentConfig;
(createContentGeneratorConfig as Mock).mockReturnValue({
...mockContentConfig,
authType: AuthType.LOGIN_WITH_GOOGLE,
});
const mockExistingHistory = [
{ role: 'user', parts: [{ text: 'Hello' }] },
];
const mockExistingClient = {
isInitialized: vi.fn().mockReturnValue(true),
getHistory: vi.fn().mockReturnValue(mockExistingHistory),
};
const mockNewClient = {
isInitialized: vi.fn().mockReturnValue(true),
getHistory: vi.fn().mockReturnValue([]),
setHistory: vi.fn(),
initialize: vi.fn().mockResolvedValue(undefined),
};
(
config as unknown as { geminiClient: typeof mockExistingClient }
).geminiClient = mockExistingClient;
(GeminiClient as Mock).mockImplementation(() => mockNewClient);
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
expect(mockNewClient.setHistory).toHaveBeenCalledWith(
mockExistingHistory,
{ stripThoughts: true },
);
});
it('should not strip thoughts when switching from Vertex to GenAI', async () => {
const config = new Config(baseParams);
const mockContentConfig = {
model: 'gemini-pro',
apiKey: 'test-key',
authType: AuthType.LOGIN_WITH_GOOGLE,
};
(
config as unknown as { contentGeneratorConfig: ContentGeneratorConfig }
).contentGeneratorConfig = mockContentConfig;
(createContentGeneratorConfig as Mock).mockReturnValue({
...mockContentConfig,
authType: AuthType.USE_GEMINI,
});
const mockExistingHistory = [
{ role: 'user', parts: [{ text: 'Hello' }] },
];
const mockExistingClient = {
isInitialized: vi.fn().mockReturnValue(true),
getHistory: vi.fn().mockReturnValue(mockExistingHistory),
};
const mockNewClient = {
isInitialized: vi.fn().mockReturnValue(true),
getHistory: vi.fn().mockReturnValue([]),
setHistory: vi.fn(),
initialize: vi.fn().mockResolvedValue(undefined),
};
(
config as unknown as { geminiClient: typeof mockExistingClient }
).geminiClient = mockExistingClient;
(GeminiClient as Mock).mockImplementation(() => mockNewClient);
await config.refreshAuth(AuthType.USE_GEMINI);
expect(mockNewClient.setHistory).toHaveBeenCalledWith(
mockExistingHistory,
{ stripThoughts: false },
);
});
});
it('Config constructor should store userMemory correctly', () => {
@@ -384,6 +474,28 @@ describe('Server Config (config.ts)', () => {
expect(fileService).toBeDefined();
});
describe('Usage Statistics', () => {
it('defaults usage statistics to enabled if not specified', () => {
const config = new Config({
...baseParams,
usageStatisticsEnabled: undefined,
});
expect(config.getUsageStatisticsEnabled()).toBe(true);
});
it.each([{ enabled: true }, { enabled: false }])(
'sets usage statistics based on the provided value (enabled: $enabled)',
({ enabled }) => {
const config = new Config({
...baseParams,
usageStatisticsEnabled: enabled,
});
expect(config.getUsageStatisticsEnabled()).toBe(enabled);
},
);
});
describe('Telemetry Settings', () => {
it('should return default telemetry target if not provided', () => {
const params: ConfigParameters = {

View File

@@ -193,13 +193,12 @@ export interface ConfigParameters {
extensionContextFilePaths?: string[];
maxSessionTurns?: number;
sessionTokenLimit?: number;
experimentalAcp?: boolean;
experimentalZedIntegration?: boolean;
listExtensions?: boolean;
extensions?: GeminiCLIExtension[];
blockedMcpServers?: Array<{ name: string; extensionName: string }>;
noBrowser?: boolean;
summarizeToolOutput?: Record<string, SummarizeToolOutputSettings>;
ideModeFeature?: boolean;
folderTrustFeature?: boolean;
folderTrust?: boolean;
ideMode?: boolean;
@@ -222,6 +221,7 @@ export interface ConfigParameters {
tavilyApiKey?: string;
chatCompression?: ChatCompressionSettings;
interactive?: boolean;
trustedFolder?: boolean;
}
export class Config {
@@ -265,7 +265,6 @@ export class Config {
private readonly model: string;
private readonly extensionContextFilePaths: string[];
private readonly noBrowser: boolean;
private readonly ideModeFeature: boolean;
private readonly folderTrustFeature: boolean;
private readonly folderTrust: boolean;
private ideMode: boolean;
@@ -289,7 +288,6 @@ export class Config {
private readonly summarizeToolOutput:
| Record<string, SummarizeToolOutputSettings>
| undefined;
private readonly experimentalAcp: boolean = false;
private readonly enableOpenAILogging: boolean;
private readonly contentGenerator?: {
timeout?: number;
@@ -297,10 +295,12 @@ export class Config {
samplingParams?: Record<string, unknown>;
};
private readonly cliVersion?: string;
private readonly experimentalZedIntegration: boolean = false;
private readonly loadMemoryFromIncludeDirectories: boolean = false;
private readonly tavilyApiKey?: string;
private readonly chatCompression: ChatCompressionSettings | undefined;
private readonly interactive: boolean;
private readonly trustedFolder: boolean | undefined;
private initialized: boolean = false;
constructor(params: ConfigParameters) {
@@ -356,13 +356,13 @@ export class Config {
this.extensionContextFilePaths = params.extensionContextFilePaths ?? [];
this.maxSessionTurns = params.maxSessionTurns ?? -1;
this.sessionTokenLimit = params.sessionTokenLimit ?? -1;
this.experimentalAcp = params.experimentalAcp ?? false;
this.experimentalZedIntegration =
params.experimentalZedIntegration ?? false;
this.listExtensions = params.listExtensions ?? false;
this._extensions = params.extensions ?? [];
this._blockedMcpServers = params.blockedMcpServers ?? [];
this.noBrowser = params.noBrowser ?? false;
this.summarizeToolOutput = params.summarizeToolOutput;
this.ideModeFeature = params.ideModeFeature ?? false;
this.folderTrustFeature = params.folderTrustFeature ?? false;
this.folderTrust = params.folderTrust ?? false;
this.ideMode = params.ideMode ?? false;
@@ -376,6 +376,7 @@ export class Config {
params.loadMemoryFromIncludeDirectories ?? false;
this.chatCompression = params.chatCompression;
this.interactive = params.interactive ?? false;
this.trustedFolder = params.trustedFolder;
// Web search
this.tavilyApiKey = params.tavilyApiKey;
@@ -431,13 +432,21 @@ export class Config {
const newGeminiClient = new GeminiClient(this);
await newGeminiClient.initialize(newContentGeneratorConfig);
// Vertex and Genai have incompatible encryption and sending history with
// throughtSignature from Genai to Vertex will fail, we need to strip them
const fromGenaiToVertex =
this.contentGeneratorConfig?.authType === AuthType.USE_GEMINI &&
authMethod === AuthType.LOGIN_WITH_GOOGLE;
// Only assign to instance properties after successful initialization
this.contentGeneratorConfig = newContentGeneratorConfig;
this.geminiClient = newGeminiClient;
// Restore the conversation history to the new client
if (existingHistory.length > 0) {
this.geminiClient.setHistory(existingHistory);
this.geminiClient.setHistory(existingHistory, {
stripThoughts: fromGenaiToVertex,
});
}
// Reset the session flag since we're explicitly changing auth and using default model
@@ -685,8 +694,8 @@ export class Config {
return this.extensionContextFilePaths;
}
getExperimentalAcp(): boolean {
return this.experimentalAcp;
getExperimentalZedIntegration(): boolean {
return this.experimentalZedIntegration;
}
getListExtensions(): boolean {
@@ -720,10 +729,6 @@ export class Config {
return this.tavilyApiKey;
}
getIdeModeFeature(): boolean {
return this.ideModeFeature;
}
getIdeClient(): IdeClient {
return this.ideClient;
}
@@ -740,6 +745,10 @@ export class Config {
return this.folderTrust;
}
isTrustedFolder(): boolean | undefined {
return this.trustedFolder;
}
setIdeMode(value: boolean): void {
this.ideMode = value;
}

View File

@@ -708,7 +708,7 @@ describe('Gemini Client (client.ts)', () => {
});
describe('sendMessageStream', () => {
it('should include editor context when ideModeFeature is enabled', async () => {
it('should include editor context when ideMode is enabled', async () => {
// Arrange
vi.mocked(ideContext.getIdeContext).mockReturnValue({
workspaceState: {
@@ -732,7 +732,7 @@ describe('Gemini Client (client.ts)', () => {
},
});
vi.spyOn(client['config'], 'getIdeModeFeature').mockReturnValue(true);
vi.spyOn(client['config'], 'getIdeMode').mockReturnValue(true);
const mockStream = (async function* () {
yield { type: 'content', value: 'Hello' };
@@ -792,7 +792,7 @@ ${JSON.stringify(
});
});
it('should not add context if ideModeFeature is enabled but no open files', async () => {
it('should not add context if ideMode is enabled but no open files', async () => {
// Arrange
vi.mocked(ideContext.getIdeContext).mockReturnValue({
workspaceState: {
@@ -800,7 +800,7 @@ ${JSON.stringify(
},
});
vi.spyOn(client['config'], 'getIdeModeFeature').mockReturnValue(true);
vi.spyOn(client['config'], 'getIdeMode').mockReturnValue(true);
const mockStream = (async function* () {
yield { type: 'content', value: 'Hello' };
@@ -839,7 +839,7 @@ ${JSON.stringify(
);
});
it('should add context if ideModeFeature is enabled and there is one active file', async () => {
it('should add context if ideMode is enabled and there is one active file', async () => {
// Arrange
vi.mocked(ideContext.getIdeContext).mockReturnValue({
workspaceState: {
@@ -855,7 +855,7 @@ ${JSON.stringify(
},
});
vi.spyOn(client['config'], 'getIdeModeFeature').mockReturnValue(true);
vi.spyOn(client['config'], 'getIdeMode').mockReturnValue(true);
const mockStream = (async function* () {
yield { type: 'content', value: 'Hello' };
@@ -914,7 +914,7 @@ ${JSON.stringify(
});
});
it('should add context if ideModeFeature is enabled and there are open files but no active file', async () => {
it('should add context if ideMode is enabled and there are open files but no active file', async () => {
// Arrange
vi.mocked(ideContext.getIdeContext).mockReturnValue({
workspaceState: {
@@ -931,7 +931,7 @@ ${JSON.stringify(
},
});
vi.spyOn(client['config'], 'getIdeModeFeature').mockReturnValue(true);
vi.spyOn(client['config'], 'getIdeMode').mockReturnValue(true);
const mockStream = (async function* () {
yield { type: 'content', value: 'Hello' };
@@ -1267,7 +1267,7 @@ ${JSON.stringify(
beforeEach(() => {
client['forceFullIdeContext'] = false; // Reset before each delta test
vi.spyOn(client, 'tryCompressChat').mockResolvedValue(null);
vi.spyOn(client['config'], 'getIdeModeFeature').mockReturnValue(true);
vi.spyOn(client['config'], 'getIdeMode').mockReturnValue(true);
mockTurnRunFn.mockReturnValue(mockStream);
const mockChat: Partial<GeminiChat> = {
@@ -1637,4 +1637,73 @@ ${JSON.stringify(
);
});
});
describe('setHistory', () => {
it('should strip thought signatures when stripThoughts is true', () => {
const mockChat = {
setHistory: vi.fn(),
};
client['chat'] = mockChat as unknown as GeminiChat;
const historyWithThoughts: Content[] = [
{
role: 'user',
parts: [{ text: 'hello' }],
},
{
role: 'model',
parts: [
{ text: 'thinking...', thoughtSignature: 'thought-123' },
{
functionCall: { name: 'test', args: {} },
thoughtSignature: 'thought-456',
},
],
},
];
client.setHistory(historyWithThoughts, { stripThoughts: true });
const expectedHistory: Content[] = [
{
role: 'user',
parts: [{ text: 'hello' }],
},
{
role: 'model',
parts: [
{ text: 'thinking...' },
{ functionCall: { name: 'test', args: {} } },
],
},
];
expect(mockChat.setHistory).toHaveBeenCalledWith(expectedHistory);
});
it('should not strip thought signatures when stripThoughts is false', () => {
const mockChat = {
setHistory: vi.fn(),
};
client['chat'] = mockChat as unknown as GeminiChat;
const historyWithThoughts: Content[] = [
{
role: 'user',
parts: [{ text: 'hello' }],
},
{
role: 'model',
parts: [
{ text: 'thinking...', thoughtSignature: 'thought-123' },
{ text: 'ok', thoughtSignature: 'thought-456' },
],
},
];
client.setHistory(historyWithThoughts, { stripThoughts: false });
expect(mockChat.setHistory).toHaveBeenCalledWith(historyWithThoughts);
});
});
});

View File

@@ -162,8 +162,32 @@ export class GeminiClient {
return this.getChat().getHistory();
}
setHistory(history: Content[]) {
this.getChat().setHistory(history);
setHistory(
history: Content[],
{ stripThoughts = false }: { stripThoughts?: boolean } = {},
) {
const historyToSet = stripThoughts
? history.map((content) => {
const newContent = { ...content };
if (newContent.parts) {
newContent.parts = newContent.parts.map((part) => {
if (
part &&
typeof part === 'object' &&
'thoughtSignature' in part
) {
const newPart = { ...part };
delete (newPart as { thoughtSignature?: string })
.thoughtSignature;
return newPart;
}
return part;
});
}
return newContent;
})
: history;
this.getChat().setHistory(historyToSet);
this.forceFullIdeContext = true;
}
@@ -498,11 +522,7 @@ export class GeminiClient {
lastMessage.role === 'model' &&
(lastMessage.parts?.some((p) => 'functionCall' in p) || false);
if (
this.config.getIdeModeFeature() &&
this.config.getIdeMode() &&
!hasPendingToolCall
) {
if (this.config.getIdeMode() && !hasPendingToolCall) {
const { contextParts, newIdeContext } = this.getIdeContextParts(
this.forceFullIdeContext || this.getHistory().length === 0,
);

View File

@@ -16,7 +16,7 @@ import {
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
import { DEFAULT_GEMINI_MODEL, DEFAULT_QWEN_MODEL } from '../config/models.js';
import { Config } from '../config/config.js';
import { getEffectiveModel } from './modelCheck.js';
import { UserTierId } from '../code_assist/types.js';
import { LoggingContentGenerator } from './loggingContentGenerator.js';
@@ -71,6 +71,7 @@ export type ContentGeneratorConfig = {
max_tokens?: number;
};
proxy?: string | undefined;
userAgent?: string;
};
export function createContentGeneratorConfig(
@@ -112,11 +113,6 @@ export function createContentGeneratorConfig(
if (authType === AuthType.USE_GEMINI && geminiApiKey) {
contentGeneratorConfig.apiKey = geminiApiKey;
contentGeneratorConfig.vertexai = false;
getEffectiveModel(
contentGeneratorConfig.apiKey,
contentGeneratorConfig.model,
contentGeneratorConfig.proxy,
);
return contentGeneratorConfig;
}

View File

@@ -9,7 +9,6 @@ import { describe, it, expect, vi } from 'vitest';
import {
CoreToolScheduler,
ToolCall,
ValidatingToolCall,
convertToFunctionResponse,
} from './coreToolScheduler.js';
import {
@@ -19,7 +18,7 @@ import {
ToolConfirmationPayload,
ToolResult,
Config,
Icon,
Kind,
ApprovalMode,
} from '../index.js';
import { Part, PartListUnion } from '@google/genai';
@@ -54,7 +53,9 @@ class MockModifiableTool
};
}
async shouldConfirmExecute(): Promise<ToolCallConfirmationDetails | false> {
override async shouldConfirmExecute(): Promise<
ToolCallConfirmationDetails | false
> {
if (this.shouldConfirm) {
return {
type: 'edit',
@@ -121,8 +122,6 @@ describe('CoreToolScheduler', () => {
abortController.abort();
await scheduler.schedule([request], abortController.signal);
const _waitingCall = onToolCallsUpdate.mock
.calls[1][0][0] as ValidatingToolCall;
const confirmationDetails = await mockTool.shouldConfirmExecute(
{},
abortController.signal,
@@ -389,12 +388,12 @@ describe('CoreToolScheduler edit cancellation', () => {
'mockEditTool',
'mockEditTool',
'A mock edit tool',
Icon.Pencil,
Kind.Edit,
{},
);
}
async shouldConfirmExecute(
override async shouldConfirmExecute(
_params: Record<string, unknown>,
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {

View File

@@ -1,24 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// 移除未使用的导入
/**
* Checks if the default "pro" model is rate-limited and returns a fallback "flash"
* model if necessary. This function is designed to be silent.
* @param apiKey The API key to use for the check.
* @param currentConfiguredModel The model currently configured in settings.
* @returns An object indicating the model to use, whether a switch occurred,
* and the original model if a switch happened.
*/
export async function getEffectiveModel(
_apiKey: string,
currentConfiguredModel: string,
_proxy: string | undefined,
): Promise<string> {
// Disable Google API Model Check
return currentConfiguredModel;
}

View File

@@ -679,7 +679,7 @@ describe('OpenAIContentGenerator', () => {
model: 'text-embedding-ada-002',
};
const _result = await generator.embedContent(request);
await generator.embedContent(request);
expect(mockOpenAIClient.embeddings.create).toHaveBeenCalledWith({
model: 'text-embedding-ada-002',
@@ -1627,7 +1627,7 @@ describe('OpenAIContentGenerator', () => {
describe('error suppression functionality', () => {
it('should allow subclasses to suppress error logging', async () => {
class TestGenerator extends OpenAIContentGenerator {
protected shouldSuppressErrorLogging(): boolean {
protected override shouldSuppressErrorLogging(): boolean {
return true; // Always suppress for this test
}
}

View File

@@ -0,0 +1,92 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { detectIde, DetectedIde } from './detect-ide.js';
describe('detectIde', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it.each([
{
env: {},
expected: DetectedIde.VSCode,
},
{
env: { __COG_BASHRC_SOURCED: '1' },
expected: DetectedIde.Devin,
},
{
env: { REPLIT_USER: 'test' },
expected: DetectedIde.Replit,
},
{
env: { CURSOR_TRACE_ID: 'test' },
expected: DetectedIde.Cursor,
},
{
env: { CODESPACES: 'true' },
expected: DetectedIde.Codespaces,
},
{
env: { EDITOR_IN_CLOUD_SHELL: 'true' },
expected: DetectedIde.CloudShell,
},
{
env: { CLOUD_SHELL: 'true' },
expected: DetectedIde.CloudShell,
},
{
env: { TERM_PRODUCT: 'Trae' },
expected: DetectedIde.Trae,
},
{
env: { FIREBASE_DEPLOY_AGENT: 'true' },
expected: DetectedIde.FirebaseStudio,
},
{
env: { MONOSPACE_ENV: 'true' },
expected: DetectedIde.FirebaseStudio,
},
])('detects the IDE for $expected', ({ env, expected }) => {
// Clear all environment variables first
vi.unstubAllEnvs();
// Set TERM_PROGRAM to vscode (required for all IDE detection)
vi.stubEnv('TERM_PROGRAM', 'vscode');
// Explicitly stub all environment variables that detectIde() checks to undefined
// This ensures no real environment variables interfere with the tests
vi.stubEnv('__COG_BASHRC_SOURCED', undefined);
vi.stubEnv('REPLIT_USER', undefined);
vi.stubEnv('CURSOR_TRACE_ID', undefined);
vi.stubEnv('CODESPACES', undefined);
vi.stubEnv('EDITOR_IN_CLOUD_SHELL', undefined);
vi.stubEnv('CLOUD_SHELL', undefined);
vi.stubEnv('TERM_PRODUCT', undefined);
vi.stubEnv('FIREBASE_DEPLOY_AGENT', undefined);
vi.stubEnv('MONOSPACE_ENV', undefined);
// Set only the specific environment variables for this test case
for (const [key, value] of Object.entries(env)) {
vi.stubEnv(key, value);
}
expect(detectIde()).toBe(expected);
});
it('returns undefined for non-vscode', () => {
// Clear all environment variables first
vi.unstubAllEnvs();
// Set TERM_PROGRAM to something other than vscode
vi.stubEnv('TERM_PROGRAM', 'definitely-not-vscode');
expect(detectIde()).toBeUndefined();
});
});

View File

@@ -5,34 +5,54 @@
*/
export enum DetectedIde {
Devin = 'devin',
Replit = 'replit',
VSCode = 'vscode',
VSCodium = 'vscodium',
Cursor = 'cursor',
CloudShell = 'cloudshell',
Codespaces = 'codespaces',
Windsurf = 'windsurf',
FirebaseStudio = 'firebasestudio',
Trae = 'trae',
}
export function getIdeDisplayName(ide: DetectedIde): string {
export interface IdeInfo {
displayName: string;
}
export function getIdeInfo(ide: DetectedIde): IdeInfo {
switch (ide) {
case DetectedIde.Devin:
return {
displayName: 'Devin',
};
case DetectedIde.Replit:
return {
displayName: 'Replit',
};
case DetectedIde.VSCode:
return 'VS Code';
case DetectedIde.VSCodium:
return 'VSCodium';
return {
displayName: 'VS Code',
};
case DetectedIde.Cursor:
return 'Cursor';
return {
displayName: 'Cursor',
};
case DetectedIde.CloudShell:
return 'Cloud Shell';
return {
displayName: 'Cloud Shell',
};
case DetectedIde.Codespaces:
return 'GitHub Codespaces';
case DetectedIde.Windsurf:
return 'Windsurf';
return {
displayName: 'GitHub Codespaces',
};
case DetectedIde.FirebaseStudio:
return 'Firebase Studio';
return {
displayName: 'Firebase Studio',
};
case DetectedIde.Trae:
return 'Trae';
return {
displayName: 'Trae',
};
default: {
// This ensures that if a new IDE is added to the enum, we get a compile-time error.
const exhaustiveCheck: never = ide;
@@ -46,19 +66,25 @@ export function detectIde(): DetectedIde | undefined {
if (process.env.TERM_PROGRAM !== 'vscode') {
return undefined;
}
if (process.env.__COG_BASHRC_SOURCED) {
return DetectedIde.Devin;
}
if (process.env.REPLIT_USER) {
return DetectedIde.Replit;
}
if (process.env.CURSOR_TRACE_ID) {
return DetectedIde.Cursor;
}
if (process.env.CODESPACES) {
return DetectedIde.Codespaces;
}
if (process.env.EDITOR_IN_CLOUD_SHELL) {
if (process.env.EDITOR_IN_CLOUD_SHELL || process.env.CLOUD_SHELL) {
return DetectedIde.CloudShell;
}
if (process.env.TERM_PRODUCT === 'Trae') {
return DetectedIde.Trae;
}
if (process.env.FIREBASE_DEPLOY_AGENT) {
if (process.env.FIREBASE_DEPLOY_AGENT || process.env.MONOSPACE_ENV) {
return DetectedIde.FirebaseStudio;
}
return DetectedIde.VSCode;

View File

@@ -6,11 +6,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import {
detectIde,
DetectedIde,
getIdeDisplayName,
} from '../ide/detect-ide.js';
import { detectIde, DetectedIde, getIdeInfo } from '../ide/detect-ide.js';
import {
ideContext,
IdeContextNotificationSchema,
@@ -68,7 +64,7 @@ export class IdeClient {
private constructor() {
this.currentIde = detectIde();
if (this.currentIde) {
this.currentIdeDisplayName = getIdeDisplayName(this.currentIde);
this.currentIdeDisplayName = getIdeInfo(this.currentIde).displayName;
}
}
@@ -86,7 +82,7 @@ export class IdeClient {
`IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: ${Object.values(
DetectedIde,
)
.map((ide) => getIdeDisplayName(ide))
.map((ide) => getIdeInfo(ide).displayName)
.join(', ')}`,
false,
);

View File

@@ -24,11 +24,6 @@ describe('ide-installer', () => {
expect(installer).toBeInstanceOf(Object);
});
it('should return null for "vscodium" (not implemented)', () => {
const installer = getIdeInstaller(DetectedIde.VSCodium);
expect(installer).toBeNull();
});
it('should return null for an unknown IDE', () => {
const installer = getIdeInstaller('unknown' as DetectedIde);
expect(installer).toBeNull();

View File

@@ -42,6 +42,7 @@ export * from './utils/systemEncoding.js';
export * from './utils/textUtils.js';
export * from './utils/formatters.js';
export * from './utils/filesearch/fileSearch.js';
export * from './utils/errorParsing.js';
// Export services
export * from './services/fileDiscoveryService.js';
@@ -51,8 +52,8 @@ export * from './services/gitService.js';
export * from './ide/ide-client.js';
export * from './ide/ideContext.js';
export * from './ide/ide-installer.js';
export { getIdeDisplayName, DetectedIde } from './ide/detect-ide.js';
export * from './ide/constants.js';
export { getIdeInfo, DetectedIde, IdeInfo } from './ide/detect-ide.js';
// Export Shell Execution Service
export * from './services/shellExecutionService.js';

View File

@@ -91,7 +91,6 @@ export class MCPOAuthProvider {
private static readonly REDIRECT_PORT = 7777;
private static readonly REDIRECT_PATH = '/oauth/callback';
private static readonly HTTP_OK = 200;
private static readonly HTTP_REDIRECT = 302;
/**
* Register a client dynamically with the OAuth server.

View File

@@ -0,0 +1,9 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { setupServer } from 'msw/node';
export const server = setupServer();

View File

@@ -70,7 +70,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
/**
* Override error logging behavior to suppress auth errors during token refresh
*/
protected shouldSuppressErrorLogging(
protected override shouldSuppressErrorLogging(
error: unknown,
_request: GenerateContentParameters,
): boolean {
@@ -81,7 +81,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
/**
* Override to use dynamic token and endpoint
*/
async generateContent(
override async generateContent(
request: GenerateContentParameters,
userPromptId: string,
): Promise<GenerateContentResponse> {
@@ -105,7 +105,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
/**
* Override to use dynamic token and endpoint
*/
async generateContentStream(
override async generateContentStream(
request: GenerateContentParameters,
userPromptId: string,
): Promise<AsyncGenerator<GenerateContentResponse>> {
@@ -132,7 +132,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
/**
* Override to use dynamic token and endpoint
*/
async countTokens(
override async countTokens(
request: CountTokensParameters,
): Promise<CountTokensResponse> {
return this.withValidToken(async (token) => {
@@ -153,7 +153,7 @@ export class QwenContentGenerator extends OpenAIContentGenerator {
/**
* Override to use dynamic token and endpoint
*/
async embedContent(
override async embedContent(
request: EmbedContentParameters,
): Promise<EmbedContentResponse> {
return this.withValidToken(async (token) => {

View File

@@ -223,17 +223,9 @@ describe('Type Guards', () => {
describe('QwenOAuth2Client', () => {
let client: QwenOAuth2Client;
let _mockConfig: Config;
let originalFetch: typeof global.fetch;
beforeEach(() => {
// Setup mock config
_mockConfig = {
getQwenClientId: vi.fn().mockReturnValue('test-client-id'),
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
getProxy: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
// Create client instance
client = new QwenOAuth2Client({ proxy: undefined });
@@ -1010,7 +1002,6 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
let mockConfig: Config;
let originalFetch: typeof global.fetch;
let _client: QwenOAuth2Client;
beforeEach(() => {
mockConfig = {
@@ -1018,7 +1009,7 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
} as unknown as Config;
_client = new QwenOAuth2Client({ proxy: undefined });
new QwenOAuth2Client({ proxy: undefined });
originalFetch = global.fetch;
global.fetch = vi.fn();

View File

@@ -234,11 +234,8 @@ export interface IQwenOAuth2Client {
*/
export class QwenOAuth2Client implements IQwenOAuth2Client {
private credentials: QwenCredentials = {};
private proxy?: string;
constructor(options: { proxy?: string }) {
this.proxy = options.proxy;
}
constructor(_options?: { proxy?: string }) {}
setCredentials(credentials: QwenCredentials): void {
this.credentials = credentials;

View File

@@ -4,176 +4,306 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as https from 'https';
import { ClientRequest, IncomingMessage } from 'http';
import { Readable, Writable } from 'stream';
import {
ClearcutLogger,
LogResponse,
LogEventEntry,
} from './clearcut-logger.js';
import { Config } from '../../config/config.js';
vi,
describe,
it,
expect,
afterEach,
beforeAll,
afterAll,
} from 'vitest';
import { ClearcutLogger, LogEventEntry, TEST_ONLY } from './clearcut-logger.js';
import { ConfigParameters } from '../../config/config.js';
import * as userAccount from '../../utils/user_account.js';
import * as userId from '../../utils/user_id.js';
import { EventMetadataKey } from './event-metadata-key.js';
import { makeFakeConfig } from '../../test-utils/config.js';
import { http, HttpResponse } from 'msw';
import { server } from '../../mocks/msw.js';
// Mock dependencies
vi.mock('https-proxy-agent');
vi.mock('https');
vi.mock('../../utils/user_account');
vi.mock('../../utils/user_id');
const mockHttps = vi.mocked(https);
const mockUserAccount = vi.mocked(userAccount);
const mockUserId = vi.mocked(userId);
describe('ClearcutLogger', () => {
let mockConfig: Config;
let logger: ClearcutLogger | undefined;
// TODO(richieforeman): Consider moving this to test setup globally.
beforeAll(() => {
server.listen({});
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
describe('ClearcutLogger', () => {
const NEXT_WAIT_MS = 1234;
const CLEARCUT_URL = 'https://play.googleapis.com/log';
const MOCK_DATE = new Date('2025-01-02T00:00:00.000Z');
const EXAMPLE_RESPONSE = `["${NEXT_WAIT_MS}",null,[[["ANDROID_BACKUP",0],["BATTERY_STATS",0],["SMART_SETUP",0],["TRON",0]],-3334737594024971225],[]]`;
// A helper to get the internal events array for testing
const getEvents = (l: ClearcutLogger): LogEventEntry[][] =>
l['events'].toArray() as LogEventEntry[][];
const getEventsSize = (l: ClearcutLogger): number => l['events'].size;
const getMaxEvents = (l: ClearcutLogger): number => l['max_events'];
const getMaxRetryEvents = (l: ClearcutLogger): number =>
l['max_retry_events'];
const requeueFailedEvents = (l: ClearcutLogger, events: LogEventEntry[][]) =>
l['requeueFailedEvents'](events);
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date());
mockConfig = {
getUsageStatisticsEnabled: vi.fn().mockReturnValue(true),
getDebugMode: vi.fn().mockReturnValue(false),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getProxy: vi.fn().mockReturnValue(undefined),
} as unknown as Config;
mockUserAccount.getCachedGoogleAccount.mockReturnValue('test@google.com');
mockUserAccount.getLifetimeGoogleAccounts.mockReturnValue(1);
mockUserId.getInstallationId.mockReturnValue('test-installation-id');
logger = ClearcutLogger.getInstance(mockConfig);
expect(logger).toBeDefined();
afterEach(() => {
vi.unstubAllEnvs();
});
function setup({
config = {} as Partial<ConfigParameters>,
lifetimeGoogleAccounts = 1,
cachedGoogleAccount = 'test@google.com',
installationId = 'test-installation-id',
} = {}) {
server.resetHandlers(
http.post(CLEARCUT_URL, () => HttpResponse.text(EXAMPLE_RESPONSE)),
);
vi.useFakeTimers();
vi.setSystemTime(MOCK_DATE);
const loggerConfig = makeFakeConfig({
...config,
});
ClearcutLogger.clearInstance();
mockUserAccount.getCachedGoogleAccount.mockReturnValue(cachedGoogleAccount);
mockUserAccount.getLifetimeGoogleAccounts.mockReturnValue(
lifetimeGoogleAccounts,
);
mockUserId.getInstallationId.mockReturnValue(installationId);
const logger = ClearcutLogger.getInstance(loggerConfig);
return { logger, loggerConfig };
}
afterEach(() => {
ClearcutLogger.clearInstance();
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should not return an instance if usage statistics are disabled', () => {
ClearcutLogger.clearInstance();
vi.spyOn(mockConfig, 'getUsageStatisticsEnabled').mockReturnValue(false);
const disabledLogger = ClearcutLogger.getInstance(mockConfig);
expect(disabledLogger).toBeUndefined();
describe('getInstance', () => {
it.each([
{ usageStatisticsEnabled: false, expectedValue: undefined },
{
usageStatisticsEnabled: true,
expectedValue: expect.any(ClearcutLogger),
},
])(
'returns an instance if usage statistics are enabled',
({ usageStatisticsEnabled, expectedValue }) => {
ClearcutLogger.clearInstance();
const { logger } = setup({
config: {
usageStatisticsEnabled,
},
});
expect(logger).toEqual(expectedValue);
},
);
it('is a singleton', () => {
ClearcutLogger.clearInstance();
const { loggerConfig } = setup();
const logger1 = ClearcutLogger.getInstance(loggerConfig);
const logger2 = ClearcutLogger.getInstance(loggerConfig);
expect(logger1).toBe(logger2);
});
});
describe('createLogEvent', () => {
it('logs the total number of google accounts', () => {
const { logger } = setup({
lifetimeGoogleAccounts: 9001,
});
const event = logger?.createLogEvent('abc', []);
expect(event?.event_metadata[0][0]).toEqual({
gemini_cli_key: EventMetadataKey.GEMINI_CLI_GOOGLE_ACCOUNTS_COUNT,
value: '9001',
});
});
it('logs the current surface from a github action', () => {
const { logger } = setup({});
vi.stubEnv('GITHUB_SHA', '8675309');
const event = logger?.createLogEvent('abc', []);
expect(event?.event_metadata[0][1]).toEqual({
gemini_cli_key: EventMetadataKey.GEMINI_CLI_SURFACE,
value: 'GitHub',
});
});
it('honors the value from env.SURFACE over all others', () => {
const { logger } = setup({});
vi.stubEnv('TERM_PROGRAM', 'vscode');
vi.stubEnv('SURFACE', 'ide-1234');
const event = logger?.createLogEvent('abc', []);
expect(event?.event_metadata[0][1]).toEqual({
gemini_cli_key: EventMetadataKey.GEMINI_CLI_SURFACE,
value: 'ide-1234',
});
});
it.each([
{
env: {
CURSOR_TRACE_ID: 'abc123',
GITHUB_SHA: undefined,
},
expectedValue: 'cursor',
},
{
env: {
TERM_PROGRAM: 'vscode',
GITHUB_SHA: undefined,
},
expectedValue: 'vscode',
},
{
env: {
MONOSPACE_ENV: 'true',
GITHUB_SHA: undefined,
},
expectedValue: 'firebasestudio',
},
{
env: {
__COG_BASHRC_SOURCED: 'true',
GITHUB_SHA: undefined,
},
expectedValue: 'devin',
},
{
env: {
CLOUD_SHELL: 'true',
GITHUB_SHA: undefined,
},
expectedValue: 'cloudshell',
},
])(
'logs the current surface for as $expectedValue, preempting vscode detection',
({ env, expectedValue }) => {
const { logger } = setup({});
// Clear all environment variables that could interfere with surface detection
vi.stubEnv('SURFACE', undefined);
vi.stubEnv('GITHUB_SHA', undefined);
vi.stubEnv('CURSOR_TRACE_ID', undefined);
vi.stubEnv('__COG_BASHRC_SOURCED', undefined);
vi.stubEnv('REPLIT_USER', undefined);
vi.stubEnv('CODESPACES', undefined);
vi.stubEnv('EDITOR_IN_CLOUD_SHELL', undefined);
vi.stubEnv('CLOUD_SHELL', undefined);
vi.stubEnv('TERM_PRODUCT', undefined);
vi.stubEnv('FIREBASE_DEPLOY_AGENT', undefined);
vi.stubEnv('MONOSPACE_ENV', undefined);
// Set the specific environment variables for this test case
for (const [key, value] of Object.entries(env)) {
vi.stubEnv(key, value);
}
vi.stubEnv('TERM_PROGRAM', 'vscode');
const event = logger?.createLogEvent('abc', []);
expect(event?.event_metadata[0][1]).toEqual({
gemini_cli_key: EventMetadataKey.GEMINI_CLI_SURFACE,
value: expectedValue,
});
},
);
});
describe('enqueueLogEvent', () => {
it('should add events to the queue', () => {
const { logger } = setup();
logger!.enqueueLogEvent({ test: 'event1' });
expect(getEventsSize(logger!)).toBe(1);
});
it('should evict the oldest event when the queue is full', () => {
const maxEvents = getMaxEvents(logger!);
const { logger } = setup();
for (let i = 0; i < maxEvents; i++) {
for (let i = 0; i < TEST_ONLY.MAX_EVENTS; i++) {
logger!.enqueueLogEvent({ event_id: i });
}
expect(getEventsSize(logger!)).toBe(maxEvents);
expect(getEventsSize(logger!)).toBe(TEST_ONLY.MAX_EVENTS);
const firstEvent = JSON.parse(
getEvents(logger!)[0][0].source_extension_json,
);
expect(firstEvent.event_id).toBe(0);
// This should push out the first event
logger!.enqueueLogEvent({ event_id: maxEvents });
logger!.enqueueLogEvent({ event_id: TEST_ONLY.MAX_EVENTS });
expect(getEventsSize(logger!)).toBe(maxEvents);
expect(getEventsSize(logger!)).toBe(TEST_ONLY.MAX_EVENTS);
const newFirstEvent = JSON.parse(
getEvents(logger!)[0][0].source_extension_json,
);
expect(newFirstEvent.event_id).toBe(1);
const lastEvent = JSON.parse(
getEvents(logger!)[maxEvents - 1][0].source_extension_json,
getEvents(logger!)[TEST_ONLY.MAX_EVENTS - 1][0].source_extension_json,
);
expect(lastEvent.event_id).toBe(maxEvents);
expect(lastEvent.event_id).toBe(TEST_ONLY.MAX_EVENTS);
});
});
describe('flushToClearcut', () => {
let mockRequest: Writable;
let mockResponse: Readable & Partial<IncomingMessage>;
beforeEach(() => {
mockRequest = new Writable({
write(chunk, encoding, callback) {
callback();
it('allows for usage with a configured proxy agent', async () => {
const { logger } = setup({
config: {
proxy: 'http://mycoolproxy.whatever.com:3128',
},
});
vi.spyOn(mockRequest, 'on');
vi.spyOn(mockRequest, 'end').mockReturnThis();
vi.spyOn(mockRequest, 'destroy').mockReturnThis();
mockResponse = new Readable({ read() {} }) as Readable &
Partial<IncomingMessage>;
logger!.enqueueLogEvent({ event_id: 1 });
mockHttps.request.mockImplementation(
(
_options: string | https.RequestOptions | URL,
...args: unknown[]
): ClientRequest => {
const callback = args.find((arg) => typeof arg === 'function') as
| ((res: IncomingMessage) => void)
| undefined;
const response = await logger!.flushToClearcut();
if (callback) {
callback(mockResponse as IncomingMessage);
}
return mockRequest as ClientRequest;
},
);
expect(response.nextRequestWaitMs).toBe(NEXT_WAIT_MS);
});
it('should clear events on successful flush', async () => {
mockResponse.statusCode = 200;
const mockResponseBody = { nextRequestWaitMs: 1000 };
// Encoded protobuf for {nextRequestWaitMs: 1000} which is `08 E8 07`
const encodedResponse = Buffer.from([8, 232, 7]);
const { logger } = setup();
logger!.enqueueLogEvent({ event_id: 1 });
const flushPromise = logger!.flushToClearcut();
const response = await logger!.flushToClearcut();
mockResponse.push(encodedResponse);
mockResponse.push(null); // End the stream
const response: LogResponse = await flushPromise;
expect(getEventsSize(logger!)).toBe(0);
expect(response.nextRequestWaitMs).toBe(
mockResponseBody.nextRequestWaitMs,
);
expect(getEvents(logger!)).toEqual([]);
expect(response.nextRequestWaitMs).toBe(NEXT_WAIT_MS);
});
it('should handle a network error and requeue events', async () => {
const { logger } = setup();
server.resetHandlers(http.post(CLEARCUT_URL, () => HttpResponse.error()));
logger!.enqueueLogEvent({ event_id: 1 });
logger!.enqueueLogEvent({ event_id: 2 });
expect(getEventsSize(logger!)).toBe(2);
const flushPromise = logger!.flushToClearcut();
mockRequest.emit('error', new Error('Network error'));
await flushPromise;
const x = logger!.flushToClearcut();
await x;
expect(getEventsSize(logger!)).toBe(2);
const events = getEvents(logger!);
@@ -181,18 +311,28 @@ describe('ClearcutLogger', () => {
});
it('should handle an HTTP error and requeue events', async () => {
mockResponse.statusCode = 500;
mockResponse.statusMessage = 'Internal Server Error';
const { logger } = setup();
server.resetHandlers(
http.post(
CLEARCUT_URL,
() =>
new HttpResponse(
{ 'the system is down': true },
{
status: 500,
},
),
),
);
logger!.enqueueLogEvent({ event_id: 1 });
logger!.enqueueLogEvent({ event_id: 2 });
expect(getEventsSize(logger!)).toBe(2);
const flushPromise = logger!.flushToClearcut();
mockResponse.emit('end'); // End the response to trigger promise resolution
await flushPromise;
expect(getEvents(logger!).length).toBe(2);
await logger!.flushToClearcut();
expect(getEventsSize(logger!)).toBe(2);
expect(getEvents(logger!).length).toBe(2);
const events = getEvents(logger!);
expect(JSON.parse(events[0][0].source_extension_json).event_id).toBe(1);
});
@@ -200,7 +340,8 @@ describe('ClearcutLogger', () => {
describe('requeueFailedEvents logic', () => {
it('should limit the number of requeued events to max_retry_events', () => {
const maxRetryEvents = getMaxRetryEvents(logger!);
const { logger } = setup();
const maxRetryEvents = TEST_ONLY.MAX_RETRY_EVENTS;
const eventsToLogCount = maxRetryEvents + 5;
const eventsToSend: LogEventEntry[][] = [];
for (let i = 0; i < eventsToLogCount; i++) {
@@ -225,7 +366,8 @@ describe('ClearcutLogger', () => {
});
it('should not requeue more events than available space in the queue', () => {
const maxEvents = getMaxEvents(logger!);
const { logger } = setup();
const maxEvents = TEST_ONLY.MAX_EVENTS;
const spaceToLeave = 5;
const initialEventCount = maxEvents - spaceToLeave;
for (let i = 0; i < initialEventCount; i++) {

View File

@@ -4,10 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Buffer } from 'buffer';
import * as https from 'https';
import { HttpsProxyAgent } from 'https-proxy-agent';
import {
StartSessionEvent,
EndSessionEvent,
@@ -22,6 +19,7 @@ import {
SlashCommandEvent,
MalformedJsonResponseEvent,
IdeConnectionEvent,
KittySequenceOverflowEvent,
} from '../types.js';
import { EventMetadataKey } from './event-metadata-key.js';
import { Config } from '../../config/config.js';
@@ -32,6 +30,7 @@ import {
} from '../../utils/user_account.js';
import { getInstallationId } from '../../utils/user_id.js';
import { FixedDeque } from 'mnemonist';
import { DetectedIde, detectIde } from '../../ide/detect-ide.js';
const start_session_event_name = 'start_session';
const new_prompt_event_name = 'new_prompt';
@@ -46,6 +45,7 @@ const next_speaker_check_event_name = 'next_speaker_check';
const slash_command_event_name = 'slash_command';
const malformed_json_response_event_name = 'malformed_json_response';
const ide_connection_event_name = 'ide_connection';
const kitty_sequence_overflow_event_name = 'kitty_sequence_overflow';
export interface LogResponse {
nextRequestWaitMs?: number;
@@ -56,19 +56,25 @@ export interface LogEventEntry {
source_extension_json: string;
}
export type EventValue = {
export interface EventValue {
gemini_cli_key: EventMetadataKey | string;
value: string;
};
}
export type LogEvent = {
console_type: string;
export interface LogEvent {
console_type: 'GEMINI_CLI';
application: number;
event_name: string;
event_metadata: EventValue[][];
client_email?: string;
client_install_id?: string;
};
}
export interface LogRequest {
log_source_name: 'CONCORD';
request_time_ms: number;
log_event: LogEventEntry[][];
}
/**
* Determine the surface that the user is currently using. Surface is effectively the
@@ -80,31 +86,70 @@ export type LogEvent = {
* methods might have in their runtimes.
*/
function determineSurface(): string {
if (process.env.CLOUD_SHELL === 'true') {
return 'CLOUD_SHELL';
} else if (process.env.MONOSPACE_ENV === 'true') {
return 'FIREBASE_STUDIO';
if (process.env.SURFACE) {
return process.env.SURFACE;
} else if (process.env.GITHUB_SHA) {
return 'GitHub';
} else if (process.env.TERM_PROGRAM === 'vscode') {
return detectIde() || DetectedIde.VSCode;
} else {
return process.env.SURFACE || 'SURFACE_NOT_SET';
return 'SURFACE_NOT_SET';
}
}
/**
* Clearcut URL to send logging events to.
*/
const CLEARCUT_URL = 'https://play.googleapis.com/log?format=json&hasfast=true';
/**
* Interval in which buffered events are sent to clearcut.
*/
const FLUSH_INTERVAL_MS = 1000 * 60;
/**
* Maximum amount of events to keep in memory. Events added after this amount
* are dropped until the next flush to clearcut, which happens periodically as
* defined by {@link FLUSH_INTERVAL_MS}.
*/
const MAX_EVENTS = 1000;
/**
* Maximum events to retry after a failed clearcut flush
*/
const MAX_RETRY_EVENTS = 100;
// Singleton class for batch posting log events to Clearcut. When a new event comes in, the elapsed time
// is checked and events are flushed to Clearcut if at least a minute has passed since the last flush.
export class ClearcutLogger {
private static instance: ClearcutLogger;
private config?: Config;
/**
* Queue of pending events that need to be flushed to the server. New events
* are added to this queue and then flushed on demand (via `flushToClearcut`)
*/
private readonly events: FixedDeque<LogEventEntry[]>;
private last_flush_time: number = Date.now();
private flush_interval_ms: number = 1000 * 60; // Wait at least a minute before flushing events.
private readonly max_events: number = 1000; // Maximum events to keep in memory
private readonly max_retry_events: number = 100; // Maximum failed events to retry
private flushing: boolean = false; // Prevent concurrent flush operations
private pendingFlush: boolean = false; // Track if a flush was requested during an ongoing flush
/**
* The last time that the events were successfully flushed to the server.
*/
private lastFlushTime: number = Date.now();
/**
* the value is true when there is a pending flush happening. This prevents
* concurrent flush operations.
*/
private flushing: boolean = false;
/**
* This value is true when a flush was requested during an ongoing flush.
*/
private pendingFlush: boolean = false;
private constructor(config?: Config) {
this.config = config;
this.events = new FixedDeque<LogEventEntry[]>(Array, this.max_events);
this.events = new FixedDeque<LogEventEntry[]>(Array, MAX_EVENTS);
}
static getInstance(config?: Config): ClearcutLogger | undefined {
@@ -125,7 +170,7 @@ export class ClearcutLogger {
enqueueLogEvent(event: object): void {
try {
// Manually handle overflow for FixedDeque, which throws when full.
const wasAtCapacity = this.events.size >= this.max_events;
const wasAtCapacity = this.events.size >= MAX_EVENTS;
if (wasAtCapacity) {
this.events.shift(); // Evict oldest element to make space.
@@ -150,31 +195,14 @@ export class ClearcutLogger {
}
}
addDefaultFields(data: EventValue[]): void {
const totalAccounts = getLifetimeGoogleAccounts();
const surface = determineSurface();
const defaultLogMetadata = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_GOOGLE_ACCOUNTS_COUNT,
value: totalAccounts.toString(),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_SURFACE,
value: surface,
},
];
data.push(...defaultLogMetadata);
}
createLogEvent(name: string, data: EventValue[]): LogEvent {
const email = getCachedGoogleAccount();
// Add default fields that should exist for all logs
this.addDefaultFields(data);
data = addDefaultFields(data);
const logEvent: LogEvent = {
console_type: 'GEMINI_CLI',
application: 102,
application: 102, // GEMINI_CLI
event_name: name,
event_metadata: [data],
};
@@ -190,7 +218,7 @@ export class ClearcutLogger {
}
flushIfNeeded(): void {
if (Date.now() - this.last_flush_time < this.flush_interval_ms) {
if (Date.now() - this.lastFlushTime < FLUSH_INTERVAL_MS) {
return;
}
@@ -217,140 +245,67 @@ export class ClearcutLogger {
const eventsToSend = this.events.toArray() as LogEventEntry[][];
this.events.clear();
return new Promise<{ buffer: Buffer; statusCode?: number }>(
(resolve, reject) => {
const request = [
{
log_source_name: 'CONCORD',
request_time_ms: Date.now(),
log_event: eventsToSend,
},
];
const body = safeJsonStringify(request);
const options = {
hostname: 'play.googleapis.com',
path: '/log',
method: 'POST',
headers: { 'Content-Length': Buffer.byteLength(body) },
timeout: 30000, // 30-second timeout
};
const bufs: Buffer[] = [];
const req = https.request(
{
...options,
agent: this.getProxyAgent(),
},
(res) => {
res.on('error', reject); // Handle stream errors
res.on('data', (buf) => bufs.push(buf));
res.on('end', () => {
try {
const buffer = Buffer.concat(bufs);
// Check if we got a successful response
if (
res.statusCode &&
res.statusCode >= 200 &&
res.statusCode < 300
) {
resolve({ buffer, statusCode: res.statusCode });
} else {
// HTTP error - reject with status code for retry handling
reject(
new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`),
);
}
} catch (e) {
reject(e);
}
});
},
);
req.on('error', (e) => {
// Network-level error
reject(e);
});
req.on('timeout', () => {
if (!req.destroyed) {
req.destroy(new Error('Request timeout after 30 seconds'));
}
});
req.end(body);
const request: LogRequest[] = [
{
log_source_name: 'CONCORD',
request_time_ms: Date.now(),
log_event: eventsToSend,
},
)
.then(({ buffer }) => {
try {
this.last_flush_time = Date.now();
return this.decodeLogResponse(buffer) || {};
} catch (error: unknown) {
console.error('Error decoding log response:', error);
return {};
}
})
.catch((error: unknown) => {
// Handle both network-level and HTTP-level errors
];
let result: LogResponse = {};
try {
const response = await fetch(CLEARCUT_URL, {
method: 'POST',
body: safeJsonStringify(request),
headers: {
'Content-Type': 'application/json',
},
});
const responseBody = await response.text();
if (response.status >= 200 && response.status < 300) {
this.lastFlushTime = Date.now();
const nextRequestWaitMs = Number(JSON.parse(responseBody)[0]);
result = {
...result,
nextRequestWaitMs,
};
} else {
if (this.config?.getDebugMode()) {
console.error('Error flushing log events:', error);
console.error(
`Error flushing log events: HTTP ${response.status}: ${response.statusText}`,
);
}
// Re-queue failed events for retry
this.requeueFailedEvents(eventsToSend);
}
} catch (e: unknown) {
if (this.config?.getDebugMode()) {
console.error('Error flushing log events:', e as Error);
}
// Return empty response to maintain the Promise<LogResponse> contract
return {};
})
.finally(() => {
this.flushing = false;
// Re-queue failed events for retry
this.requeueFailedEvents(eventsToSend);
}
// If a flush was requested while we were flushing, flush again
if (this.pendingFlush) {
this.pendingFlush = false;
// Fire and forget the pending flush
this.flushToClearcut().catch((error) => {
if (this.config?.getDebugMode()) {
console.debug('Error in pending flush to Clearcut:', error);
}
});
this.flushing = false;
// If a flush was requested while we were flushing, flush again
if (this.pendingFlush) {
this.pendingFlush = false;
// Fire and forget the pending flush
this.flushToClearcut().catch((error) => {
if (this.config?.getDebugMode()) {
console.debug('Error in pending flush to Clearcut:', error);
}
});
}
// Visible for testing. Decodes protobuf-encoded response from Clearcut server.
decodeLogResponse(buf: Buffer): LogResponse | undefined {
// TODO(obrienowen): return specific errors to facilitate debugging.
if (buf.length < 1) {
return undefined;
}
// The first byte of the buffer is `field<<3 | type`. We're looking for field
// 1, with type varint, represented by type=0. If the first byte isn't 8, that
// means field 1 is missing or the message is corrupted. Either way, we return
// undefined.
if (buf.readUInt8(0) !== 8) {
return undefined;
}
let ms = BigInt(0);
let cont = true;
// In each byte, the most significant bit is the continuation bit. If it's
// set, we keep going. The lowest 7 bits, are data bits. They are concatenated
// in reverse order to form the final number.
for (let i = 1; cont && i < buf.length; i++) {
const byte = buf.readUInt8(i);
ms |= BigInt(byte & 0x7f) << BigInt(7 * (i - 1));
cont = (byte & 0x80) !== 0;
}
if (cont) {
// We have fallen off the buffer without seeing a terminating byte. The
// message is corrupted.
return undefined;
}
const returnVal = {
nextRequestWaitMs: Number(ms),
};
return returnVal;
return result;
}
logStartSessionEvent(event: StartSessionEvent): void {
@@ -687,6 +642,13 @@ export class ClearcutLogger {
});
}
if (event.status) {
data.push({
gemini_cli_key: EventMetadataKey.GEMINI_CLI_SLASH_COMMAND_STATUS,
value: JSON.stringify(event.status),
});
}
this.enqueueLogEvent(this.createLogEvent(slash_command_event_name, data));
this.flushIfNeeded();
}
@@ -718,6 +680,24 @@ export class ClearcutLogger {
this.flushIfNeeded();
}
logKittySequenceOverflowEvent(event: KittySequenceOverflowEvent): void {
const data: EventValue[] = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_KITTY_SEQUENCE_LENGTH,
value: event.sequence_length.toString(),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_KITTY_TRUNCATED_SEQUENCE,
value: event.truncated_sequence,
},
];
this.enqueueLogEvent(
this.createLogEvent(kitty_sequence_overflow_event_name, data),
);
this.flushIfNeeded();
}
logEndSessionEvent(event: EndSessionEvent): void {
const data: EventValue[] = [
{
@@ -752,24 +732,21 @@ export class ClearcutLogger {
private requeueFailedEvents(eventsToSend: LogEventEntry[][]): void {
// Add the events back to the front of the queue to be retried, but limit retry queue size
const eventsToRetry = eventsToSend.slice(-this.max_retry_events); // Keep only the most recent events
const eventsToRetry = eventsToSend.slice(-MAX_RETRY_EVENTS); // Keep only the most recent events
// Log a warning if we're dropping events
if (
eventsToSend.length > this.max_retry_events &&
this.config?.getDebugMode()
) {
if (eventsToSend.length > MAX_RETRY_EVENTS && this.config?.getDebugMode()) {
console.warn(
`ClearcutLogger: Dropping ${
eventsToSend.length - this.max_retry_events
eventsToSend.length - MAX_RETRY_EVENTS
} events due to retry queue limit. Total events: ${
eventsToSend.length
}, keeping: ${this.max_retry_events}`,
}, keeping: ${MAX_RETRY_EVENTS}`,
);
}
// Determine how many events can be re-queued
const availableSpace = this.max_events - this.events.size;
const availableSpace = MAX_EVENTS - this.events.size;
const numEventsToRequeue = Math.min(eventsToRetry.length, availableSpace);
if (numEventsToRequeue === 0) {
@@ -792,7 +769,7 @@ export class ClearcutLogger {
this.events.unshift(eventsToRequeue[i]);
}
// Clear any potential overflow
while (this.events.size > this.max_events) {
while (this.events.size > MAX_EVENTS) {
this.events.pop();
}
@@ -803,3 +780,28 @@ export class ClearcutLogger {
}
}
}
/**
* Adds default fields to data, and returns a new data array. This fields
* should exist on all log events.
*/
function addDefaultFields(data: EventValue[]): EventValue[] {
const totalAccounts = getLifetimeGoogleAccounts();
const surface = determineSurface();
const defaultLogMetadata: EventValue[] = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_GOOGLE_ACCOUNTS_COUNT,
value: `${totalAccounts}`,
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_SURFACE,
value: surface,
},
];
return [...data, ...defaultLogMetadata];
}
export const TEST_ONLY = {
MAX_RETRY_EVENTS,
MAX_EVENTS,
};

View File

@@ -174,6 +174,9 @@ export enum EventMetadataKey {
// Logs the subcommand of the slash command.
GEMINI_CLI_SLASH_COMMAND_SUBCOMMAND = 42,
// Logs the status of the slash command (e.g. 'success', 'error')
GEMINI_CLI_SLASH_COMMAND_STATUS = 51,
// ==========================================================================
// Next Speaker Check Event Keys
// ===========================================================================
@@ -209,6 +212,16 @@ export enum EventMetadataKey {
// Logs user removed lines in edit/write tool response.
GEMINI_CLI_USER_REMOVED_LINES = 50,
// ==========================================================================
// Kitty Sequence Overflow Event Keys
// ===========================================================================
// Logs the length of the kitty sequence that overflowed.
GEMINI_CLI_KITTY_SEQUENCE_LENGTH = 53,
// Logs the truncated kitty sequence.
GEMINI_CLI_KITTY_TRUNCATED_SEQUENCE = 52,
}
export function getEventMetadataKey(

View File

@@ -28,6 +28,7 @@ export {
logApiResponse,
logFlashFallback,
logSlashCommand,
logKittySequenceOverflow,
} from './loggers.js';
export {
StartSessionEvent,
@@ -39,7 +40,10 @@ export {
ApiResponseEvent,
TelemetryEvent,
FlashFallbackEvent,
KittySequenceOverflowEvent,
SlashCommandEvent,
makeSlashCommandEvent,
SlashCommandStatus,
} from './types.js';
export { SpanStatusCode, ValueType } from '@opentelemetry/api';
export { SemanticAttributes } from '@opentelemetry/semantic-conventions';

View File

@@ -4,45 +4,47 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { logs, LogRecord, LogAttributes } from '@opentelemetry/api-logs';
import { LogAttributes, LogRecord, logs } from '@opentelemetry/api-logs';
import { SemanticAttributes } from '@opentelemetry/semantic-conventions';
import { Config } from '../config/config.js';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
import {
EVENT_API_ERROR,
EVENT_API_REQUEST,
EVENT_API_RESPONSE,
EVENT_CLI_CONFIG,
EVENT_FLASH_FALLBACK,
EVENT_IDE_CONNECTION,
EVENT_NEXT_SPEAKER_CHECK,
EVENT_SLASH_COMMAND,
EVENT_TOOL_CALL,
EVENT_USER_PROMPT,
EVENT_FLASH_FALLBACK,
EVENT_NEXT_SPEAKER_CHECK,
SERVICE_NAME,
EVENT_SLASH_COMMAND,
} from './constants.js';
import {
recordApiErrorMetrics,
recordApiResponseMetrics,
recordTokenUsageMetrics,
recordToolCallMetrics,
} from './metrics.js';
import { QwenLogger } from './qwen-logger/qwen-logger.js';
import { isTelemetrySdkInitialized } from './sdk.js';
import {
ApiErrorEvent,
ApiRequestEvent,
ApiResponseEvent,
FlashFallbackEvent,
IdeConnectionEvent,
KittySequenceOverflowEvent,
LoopDetectedEvent,
NextSpeakerCheckEvent,
SlashCommandEvent,
StartSessionEvent,
ToolCallEvent,
UserPromptEvent,
FlashFallbackEvent,
NextSpeakerCheckEvent,
LoopDetectedEvent,
SlashCommandEvent,
} from './types.js';
import {
recordApiErrorMetrics,
recordTokenUsageMetrics,
recordApiResponseMetrics,
recordToolCallMetrics,
} from './metrics.js';
import { isTelemetrySdkInitialized } from './sdk.js';
import { uiTelemetryService, UiEvent } from './uiTelemetry.js';
import { QwenLogger } from './qwen-logger/qwen-logger.js';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
import { UiEvent, uiTelemetryService } from './uiTelemetry.js';
const shouldLogUserPrompts = (config: Config): boolean =>
config.getTelemetryLogPromptsEnabled();
@@ -377,3 +379,21 @@ export function logIdeConnection(
};
logger.emit(logRecord);
}
export function logKittySequenceOverflow(
config: Config,
event: KittySequenceOverflowEvent,
): void {
ClearcutLogger.getInstance(config)?.logKittySequenceOverflowEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
...getCommonAttributes(config),
...event,
};
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `Kitty sequence buffer overflow: ${event.sequence_length} bytes`,
attributes,
};
logger.emit(logRecord);
}

View File

@@ -124,24 +124,32 @@ export function initializeTelemetry(config: Config): void {
try {
sdk.start();
console.log('OpenTelemetry SDK started successfully.');
if (config.getDebugMode()) {
console.log('OpenTelemetry SDK started successfully.');
}
telemetryInitialized = true;
initializeMetrics(config);
} catch (error) {
console.error('Error starting OpenTelemetry SDK:', error);
}
process.on('SIGTERM', shutdownTelemetry);
process.on('SIGINT', shutdownTelemetry);
process.on('SIGTERM', () => {
shutdownTelemetry(config);
});
process.on('SIGINT', () => {
shutdownTelemetry(config);
});
}
export async function shutdownTelemetry(): Promise<void> {
export async function shutdownTelemetry(config: Config): Promise<void> {
if (!telemetryInitialized || !sdk) {
return;
}
try {
await sdk.shutdown();
console.log('OpenTelemetry SDK shut down successfully.');
if (config.getDebugMode()) {
console.log('OpenTelemetry SDK shut down successfully.');
}
} catch (error) {
console.error('Error shutting down SDK:', error);
} finally {

View File

@@ -45,7 +45,7 @@ describe('telemetry', () => {
afterEach(async () => {
// Ensure we shut down telemetry even if a test fails.
if (isTelemetrySdkInitialized()) {
await shutdownTelemetry();
await shutdownTelemetry(mockConfig);
}
});
@@ -57,7 +57,7 @@ describe('telemetry', () => {
it('should shutdown the telemetry service', async () => {
initializeTelemetry(mockConfig);
await shutdownTelemetry();
await shutdownTelemetry(mockConfig);
expect(mockNodeSdk.shutdown).toHaveBeenCalled();
});

View File

@@ -14,9 +14,17 @@ import {
ToolCallDecision,
} from './tool-call-decision.js';
export class StartSessionEvent {
interface BaseTelemetryEvent {
'event.name': string;
/** Current timestamp in ISO 8601 format */
'event.timestamp': string;
}
type CommonFields = keyof BaseTelemetryEvent;
export class StartSessionEvent implements BaseTelemetryEvent {
'event.name': 'cli_config';
'event.timestamp': string; // ISO 8601
'event.timestamp': string;
model: string;
embedding_model: string;
sandbox_enabled: boolean;
@@ -60,9 +68,9 @@ export class StartSessionEvent {
}
}
export class EndSessionEvent {
export class EndSessionEvent implements BaseTelemetryEvent {
'event.name': 'end_session';
'event.timestamp': string; // ISO 8601
'event.timestamp': string;
session_id?: string;
constructor(config?: Config) {
@@ -72,9 +80,9 @@ export class EndSessionEvent {
}
}
export class UserPromptEvent {
export class UserPromptEvent implements BaseTelemetryEvent {
'event.name': 'user_prompt';
'event.timestamp': string; // ISO 8601
'event.timestamp': string;
prompt_length: number;
prompt_id: string;
auth_type?: string;
@@ -95,9 +103,9 @@ export class UserPromptEvent {
}
}
export class ToolCallEvent {
export class ToolCallEvent implements BaseTelemetryEvent {
'event.name': 'tool_call';
'event.timestamp': string; // ISO 8601
'event.timestamp': string;
function_name: string;
function_args: Record<string, unknown>;
duration_ms: number;
@@ -142,9 +150,9 @@ export class ToolCallEvent {
}
}
export class ApiRequestEvent {
export class ApiRequestEvent implements BaseTelemetryEvent {
'event.name': 'api_request';
'event.timestamp': string; // ISO 8601
'event.timestamp': string;
model: string;
prompt_id: string;
request_text?: string;
@@ -158,7 +166,7 @@ export class ApiRequestEvent {
}
}
export class ApiErrorEvent {
export class ApiErrorEvent implements BaseTelemetryEvent {
'event.name': 'api_error';
'event.timestamp': string; // ISO 8601
response_id?: string;
@@ -193,7 +201,7 @@ export class ApiErrorEvent {
}
}
export class ApiResponseEvent {
export class ApiResponseEvent implements BaseTelemetryEvent {
'event.name': 'api_response';
'event.timestamp': string; // ISO 8601
response_id: string;
@@ -240,9 +248,9 @@ export class ApiResponseEvent {
}
}
export class FlashFallbackEvent {
export class FlashFallbackEvent implements BaseTelemetryEvent {
'event.name': 'flash_fallback';
'event.timestamp': string; // ISO 8601
'event.timestamp': string;
auth_type: string;
constructor(auth_type: string) {
@@ -258,9 +266,9 @@ export enum LoopType {
LLM_DETECTED_LOOP = 'llm_detected_loop',
}
export class LoopDetectedEvent {
export class LoopDetectedEvent implements BaseTelemetryEvent {
'event.name': 'loop_detected';
'event.timestamp': string; // ISO 8601
'event.timestamp': string;
loop_type: LoopType;
prompt_id: string;
@@ -272,9 +280,9 @@ export class LoopDetectedEvent {
}
}
export class NextSpeakerCheckEvent {
export class NextSpeakerCheckEvent implements BaseTelemetryEvent {
'event.name': 'next_speaker_check';
'event.timestamp': string; // ISO 8601
'event.timestamp': string;
prompt_id: string;
finish_reason: string;
result: string;
@@ -288,23 +296,36 @@ export class NextSpeakerCheckEvent {
}
}
export class SlashCommandEvent {
export interface SlashCommandEvent extends BaseTelemetryEvent {
'event.name': 'slash_command';
'event.timestamp': string; // ISO 8106
command: string;
subcommand?: string;
constructor(command: string, subcommand?: string) {
this['event.name'] = 'slash_command';
this['event.timestamp'] = new Date().toISOString();
this.command = command;
this.subcommand = subcommand;
}
status?: SlashCommandStatus;
}
export class MalformedJsonResponseEvent {
export function makeSlashCommandEvent({
command,
subcommand,
status,
}: Omit<SlashCommandEvent, CommonFields>): SlashCommandEvent {
return {
'event.name': 'slash_command',
'event.timestamp': new Date().toISOString(),
command,
subcommand,
status,
};
}
export enum SlashCommandStatus {
SUCCESS = 'success',
ERROR = 'error',
}
export class MalformedJsonResponseEvent implements BaseTelemetryEvent {
'event.name': 'malformed_json_response';
'event.timestamp': string; // ISO 8601
'event.timestamp': string;
model: string;
constructor(model: string) {
@@ -321,7 +342,7 @@ export enum IdeConnectionType {
export class IdeConnectionEvent {
'event.name': 'ide_connection';
'event.timestamp': string; // ISO 8601
'event.timestamp': string;
connection_type: IdeConnectionType;
constructor(connection_type: IdeConnectionType) {
@@ -331,6 +352,20 @@ export class IdeConnectionEvent {
}
}
export class KittySequenceOverflowEvent {
'event.name': 'kitty_sequence_overflow';
'event.timestamp': string; // ISO 8601
sequence_length: number;
truncated_sequence: string;
constructor(sequence_length: number, truncated_sequence: string) {
this['event.name'] = 'kitty_sequence_overflow';
this['event.timestamp'] = new Date().toISOString();
this.sequence_length = sequence_length;
// Truncate to first 20 chars for logging (avoid logging sensitive data)
this.truncated_sequence = truncated_sequence.substring(0, 20);
}
}
export type TelemetryEvent =
| StartSessionEvent
| EndSessionEvent
@@ -342,6 +377,7 @@ export type TelemetryEvent =
| FlashFallbackEvent
| LoopDetectedEvent
| NextSpeakerCheckEvent
| SlashCommandEvent
| KittySequenceOverflowEvent
| MalformedJsonResponseEvent
| IdeConnectionEvent;
| IdeConnectionEvent
| SlashCommandEvent;

View File

@@ -0,0 +1,36 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Config, ConfigParameters } from '../config/config.js';
/**
* Default parameters used for {@link FAKE_CONFIG}
*/
export const DEFAULT_CONFIG_PARAMETERS: ConfigParameters = {
usageStatisticsEnabled: true,
debugMode: false,
sessionId: 'test-session-id',
proxy: undefined,
model: 'gemini-9001-super-duper',
targetDir: '/',
cwd: '/',
};
/**
* Produces a config. Default paramters are set to
* {@link DEFAULT_CONFIG_PARAMETERS}, optionally, fields can be specified to
* override those defaults.
*/
export function makeFakeConfig(
config: Partial<ConfigParameters> = {
...DEFAULT_CONFIG_PARAMETERS,
},
): Config {
return new Config({
...DEFAULT_CONFIG_PARAMETERS,
...config,
});
}

View File

@@ -7,9 +7,9 @@
import { vi } from 'vitest';
import {
BaseTool,
Icon,
ToolCallConfirmationDetails,
ToolResult,
Kind,
} from '../tools/tools.js';
import { Schema, Type } from '@google/genai';
@@ -29,7 +29,7 @@ export class MockTool extends BaseTool<{ [key: string]: unknown }, ToolResult> {
properties: { param: { type: Type.STRING } },
},
) {
super(name, displayName ?? name, description, Icon.Hammer, params);
super(name, displayName ?? name, description, Kind.Other, params);
}
async execute(
@@ -45,7 +45,7 @@ export class MockTool extends BaseTool<{ [key: string]: unknown }, ToolResult> {
);
}
async shouldConfirmExecute(
override async shouldConfirmExecute(
_params: { [key: string]: unknown },
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {

View File

@@ -62,7 +62,6 @@ describe('EditTool', () => {
getWorkspaceContext: () => createMockWorkspaceContext(rootDir),
getIdeClient: () => undefined,
getIdeMode: () => false,
getIdeModeFeature: () => false,
// getGeminiConfig: () => ({ apiKey: 'test-api-key' }), // This was not a real Config method
// Add other properties/methods of Config if EditTool uses them
// Minimal other methods to satisfy Config type if needed by EditTool constructor or other direct uses:
@@ -810,7 +809,6 @@ describe('EditTool', () => {
}),
};
(mockConfig as any).getIdeMode = () => true;
(mockConfig as any).getIdeModeFeature = () => true;
(mockConfig as any).getIdeClient = () => ideClient;
});

View File

@@ -9,7 +9,7 @@ import * as path from 'path';
import * as Diff from 'diff';
import {
BaseDeclarativeTool,
Icon,
Kind,
ToolCallConfirmationDetails,
ToolConfirmationOutcome,
ToolEditConfirmationDetails,
@@ -250,7 +250,6 @@ class EditToolInvocation implements ToolInvocation<EditToolParams, ToolResult> {
);
const ideClient = this.config.getIdeClient();
const ideConfirmation =
this.config.getIdeModeFeature() &&
this.config.getIdeMode() &&
ideClient?.getConnectionStatus().status === IDEConnectionStatus.Connected
? ideClient.openDiff(this.params.file_path, editData.newContent)
@@ -436,7 +435,7 @@ Expectation for required parameters:
4. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement.
**Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations, or does not match exactly, the tool will fail.
**Multiple replacements:** Set \`expected_replacements\` to the number of occurrences you want to replace. The tool will replace ALL occurrences that match \`old_string\` exactly. Ensure the number of replacements matches your expectation.`,
Icon.Pencil,
Kind.Edit,
{
properties: {
file_path: {
@@ -472,7 +471,7 @@ Expectation for required parameters:
* @param params Parameters to validate
* @returns Error message string or null if valid
*/
validateToolParams(params: EditToolParams): string | null {
override validateToolParams(params: EditToolParams): string | null {
const errors = SchemaValidator.validate(
this.schema.parametersJsonSchema,
params,

View File

@@ -11,7 +11,7 @@ import { SchemaValidator } from '../utils/schemaValidator.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Icon,
Kind,
ToolInvocation,
ToolResult,
} from './tools.js';
@@ -248,7 +248,7 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
GlobTool.Name,
'FindFiles',
'Efficiently finds files matching specific glob patterns (e.g., `src/**/*.ts`, `**/*.md`), returning absolute paths sorted by modification time (newest first). Ideal for quickly locating files based on their name or path structure, especially in large codebases.',
Icon.FileSearch,
Kind.Search,
{
properties: {
pattern: {
@@ -281,7 +281,7 @@ export class GlobTool extends BaseDeclarativeTool<GlobToolParams, ToolResult> {
/**
* Validates the parameters for the tool.
*/
validateToolParams(params: GlobToolParams): string | null {
override validateToolParams(params: GlobToolParams): string | null {
const errors = SchemaValidator.validate(
this.schema.parametersJsonSchema,
params,

View File

@@ -13,7 +13,7 @@ import { globStream } from 'glob';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Icon,
Kind,
ToolInvocation,
ToolResult,
} from './tools.js';
@@ -594,7 +594,7 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
GrepTool.Name,
'SearchText',
'Searches for a regular expression pattern within the content of files in a specified directory (or current working directory). Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.',
Icon.Regex,
Kind.Search,
{
properties: {
pattern: {
@@ -672,7 +672,7 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
* @param params Parameters to validate
* @returns An error message string if invalid, null otherwise
*/
validateToolParams(params: GrepToolParams): string | null {
override validateToolParams(params: GrepToolParams): string | null {
const errors = SchemaValidator.validate(
this.schema.parametersJsonSchema,
params,

Some files were not shown because too many files have changed in this diff Show More