mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
merge main and fix conflict
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,7 +44,6 @@ describe('SettingsSchema', () => {
|
||||
'telemetry',
|
||||
'bugCommand',
|
||||
'summarizeToolOutput',
|
||||
'ideModeFeature',
|
||||
'dnsResolutionOrder',
|
||||
'excludedProjectEnvVars',
|
||||
'disableUpdateNag',
|
||||
|
||||
@@ -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',
|
||||
|
||||
203
packages/cli/src/config/trustedFolders.test.ts
Normal file
203
packages/cli/src/config/trustedFolders.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
158
packages/cli/src/config/trustedFolders.ts
Normal file
158
packages/cli/src/config/trustedFolders.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
85
packages/cli/src/ui/commands/terminalSetupCommand.test.ts
Normal file
85
packages/cli/src/ui/commands/terminalSetupCommand.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
45
packages/cli/src/ui/commands/terminalSetupCommand.ts
Normal file
45
packages/cli/src/ui/commands/terminalSetupCommand.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -61,6 +61,7 @@ export interface CommandContext {
|
||||
toggleCorgiMode: () => void;
|
||||
toggleVimEnabled: () => Promise<boolean>;
|
||||
setGeminiMdFileCount: (count: number) => void;
|
||||
reloadCommands: () => void;
|
||||
};
|
||||
// Session-specific data
|
||||
session: {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>> = [
|
||||
{
|
||||
|
||||
@@ -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] =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
31
packages/cli/src/ui/hooks/useKittyKeyboardProtocol.ts
Normal file
31
packages/cli/src/ui/hooks/useKittyKeyboardProtocol.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
105
packages/cli/src/ui/utils/kittyProtocolDetector.ts
Normal file
105
packages/cli/src/ui/utils/kittyProtocolDetector.ts
Normal 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;
|
||||
}
|
||||
44
packages/cli/src/ui/utils/platformConstants.ts
Normal file
44
packages/cli/src/ui/utils/platformConstants.ts
Normal 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;
|
||||
340
packages/cli/src/ui/utils/terminalSetup.ts
Normal file
340
packages/cli/src/ui/utils/terminalSetup.ts
Normal 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.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
28
packages/cli/src/utils/checks.ts
Normal file
28
packages/cli/src/utils/checks.ts
Normal 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);
|
||||
}
|
||||
366
packages/cli/src/zed-integration/acp.ts
Normal file
366
packages/cli/src/zed-integration/acp.ts
Normal 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>;
|
||||
}
|
||||
457
packages/cli/src/zed-integration/schema.ts
Normal file
457
packages/cli/src/zed-integration/schema.ts
Normal 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;
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -15,3 +15,4 @@ export {
|
||||
IdeConnectionEvent,
|
||||
IdeConnectionType,
|
||||
} from './src/telemetry/types.js';
|
||||
export { makeFakeConfig } from './src/test-utils/config.js';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
92
packages/core/src/ide/detect-ide.test.ts
Normal file
92
packages/core/src/ide/detect-ide.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||
9
packages/core/src/mocks/msw.ts
Normal file
9
packages/core/src/mocks/msw.ts
Normal 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();
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
36
packages/core/src/test-utils/config.ts
Normal file
36
packages/core/src/test-utils/config.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user