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}>
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseAndFormatApiError } from './errorParsing.js';
|
||||
import {
|
||||
AuthType,
|
||||
UserTierId,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
isProQuotaExceededError,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
describe('parseAndFormatApiError', () => {
|
||||
const _enterpriseMessage =
|
||||
'upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits';
|
||||
const vertexMessage = 'request a quota increase through Vertex';
|
||||
const geminiMessage = 'request a quota increase through AI Studio';
|
||||
|
||||
it('should format a valid API error JSON', () => {
|
||||
const errorMessage =
|
||||
'got status: 400 Bad Request. {"error":{"code":400,"message":"API key not valid. Please pass a valid API key.","status":"INVALID_ARGUMENT"}}';
|
||||
const expected =
|
||||
'[API Error: API key not valid. Please pass a valid API key. (Status: INVALID_ARGUMENT)]';
|
||||
expect(parseAndFormatApiError(errorMessage)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should format a 429 API error with the default message', () => {
|
||||
const errorMessage =
|
||||
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Rate limit exceeded","status":"RESOURCE_EXHAUSTED"}}';
|
||||
const result = parseAndFormatApiError(
|
||||
errorMessage,
|
||||
undefined,
|
||||
undefined,
|
||||
'gemini-2.5-pro',
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
);
|
||||
expect(result).toContain('[API Error: Rate limit exceeded');
|
||||
expect(result).toContain(
|
||||
'Possible quota limitations in place or slow response times detected. Switching to the gemini-2.5-flash model',
|
||||
);
|
||||
});
|
||||
|
||||
it('should format a 429 API error with the personal message', () => {
|
||||
const errorMessage =
|
||||
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Rate limit exceeded","status":"RESOURCE_EXHAUSTED"}}';
|
||||
const result = parseAndFormatApiError(
|
||||
errorMessage,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
undefined,
|
||||
'gemini-2.5-pro',
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
);
|
||||
expect(result).toContain('[API Error: Rate limit exceeded');
|
||||
expect(result).toContain(
|
||||
'Possible quota limitations in place or slow response times detected. Switching to the gemini-2.5-flash model',
|
||||
);
|
||||
});
|
||||
|
||||
it('should format a 429 API error with the vertex message', () => {
|
||||
const errorMessage =
|
||||
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Rate limit exceeded","status":"RESOURCE_EXHAUSTED"}}';
|
||||
const result = parseAndFormatApiError(errorMessage, AuthType.USE_VERTEX_AI);
|
||||
expect(result).toContain('[API Error: Rate limit exceeded');
|
||||
expect(result).toContain(vertexMessage);
|
||||
});
|
||||
|
||||
it('should return the original message if it is not a JSON error', () => {
|
||||
const errorMessage = 'This is a plain old error message';
|
||||
expect(parseAndFormatApiError(errorMessage)).toBe(
|
||||
`[API Error: ${errorMessage}]`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the original message for malformed JSON', () => {
|
||||
const errorMessage = '[Stream Error: {"error": "malformed}';
|
||||
expect(parseAndFormatApiError(errorMessage)).toBe(
|
||||
`[API Error: ${errorMessage}]`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle JSON that does not match the ApiError structure', () => {
|
||||
const errorMessage = '[Stream Error: {"not_an_error": "some other json"}]';
|
||||
expect(parseAndFormatApiError(errorMessage)).toBe(
|
||||
`[API Error: ${errorMessage}]`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should format a nested API error', () => {
|
||||
const nestedErrorMessage = JSON.stringify({
|
||||
error: {
|
||||
code: 429,
|
||||
message:
|
||||
"Gemini 2.5 Pro Preview doesn't have a free quota tier. For more information on this error, head to: https://ai.google.dev/gemini-api/docs/rate-limits.",
|
||||
status: 'RESOURCE_EXHAUSTED',
|
||||
},
|
||||
});
|
||||
|
||||
const errorMessage = JSON.stringify({
|
||||
error: {
|
||||
code: 429,
|
||||
message: nestedErrorMessage,
|
||||
status: 'Too Many Requests',
|
||||
},
|
||||
});
|
||||
|
||||
const result = parseAndFormatApiError(errorMessage, AuthType.USE_GEMINI);
|
||||
expect(result).toContain('Gemini 2.5 Pro Preview');
|
||||
expect(result).toContain(geminiMessage);
|
||||
});
|
||||
|
||||
it('should format a StructuredError', () => {
|
||||
const error: StructuredError = {
|
||||
message: 'A structured error occurred',
|
||||
status: 500,
|
||||
};
|
||||
const expected = '[API Error: A structured error occurred]';
|
||||
expect(parseAndFormatApiError(error)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should format a 429 StructuredError with the vertex message', () => {
|
||||
const error: StructuredError = {
|
||||
message: 'Rate limit exceeded',
|
||||
status: 429,
|
||||
};
|
||||
const result = parseAndFormatApiError(error, AuthType.USE_VERTEX_AI);
|
||||
expect(result).toContain('[API Error: Rate limit exceeded]');
|
||||
expect(result).toContain(vertexMessage);
|
||||
});
|
||||
|
||||
it('should handle an unknown error type', () => {
|
||||
const error = 12345;
|
||||
const expected = '[API Error: An unknown error occurred.]';
|
||||
expect(parseAndFormatApiError(error)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should format a 429 API error with Pro quota exceeded message for Google auth (Free tier)', () => {
|
||||
const errorMessage =
|
||||
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'Gemini 2.5 Pro Requests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}';
|
||||
const result = parseAndFormatApiError(
|
||||
errorMessage,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
undefined,
|
||||
'gemini-2.5-pro',
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
);
|
||||
expect(result).toContain(
|
||||
"[API Error: Quota exceeded for quota metric 'Gemini 2.5 Pro Requests'",
|
||||
);
|
||||
expect(result).toContain(
|
||||
'You have reached your daily gemini-2.5-pro quota limit',
|
||||
);
|
||||
expect(result).toContain(
|
||||
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
|
||||
);
|
||||
});
|
||||
|
||||
it('should format a regular 429 API error with standard message for Google auth', () => {
|
||||
const errorMessage =
|
||||
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Rate limit exceeded","status":"RESOURCE_EXHAUSTED"}}';
|
||||
const result = parseAndFormatApiError(
|
||||
errorMessage,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
undefined,
|
||||
'gemini-2.5-pro',
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
);
|
||||
expect(result).toContain('[API Error: Rate limit exceeded');
|
||||
expect(result).toContain(
|
||||
'Possible quota limitations in place or slow response times detected. Switching to the gemini-2.5-flash model',
|
||||
);
|
||||
expect(result).not.toContain(
|
||||
'You have reached your daily gemini-2.5-pro quota limit',
|
||||
);
|
||||
});
|
||||
|
||||
it('should format a 429 API error with generic quota exceeded message for Google auth', () => {
|
||||
const errorMessage =
|
||||
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'GenerationRequests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}';
|
||||
const result = parseAndFormatApiError(
|
||||
errorMessage,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
undefined,
|
||||
'gemini-2.5-pro',
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
);
|
||||
expect(result).toContain(
|
||||
"[API Error: Quota exceeded for quota metric 'GenerationRequests'",
|
||||
);
|
||||
expect(result).toContain('You have reached your daily quota limit');
|
||||
expect(result).not.toContain(
|
||||
'You have reached your daily Gemini 2.5 Pro quota limit',
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize Pro quota message over generic quota message for Google auth', () => {
|
||||
const errorMessage =
|
||||
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'Gemini 2.5 Pro Requests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}';
|
||||
const result = parseAndFormatApiError(
|
||||
errorMessage,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
undefined,
|
||||
'gemini-2.5-pro',
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
);
|
||||
expect(result).toContain(
|
||||
"[API Error: Quota exceeded for quota metric 'Gemini 2.5 Pro Requests'",
|
||||
);
|
||||
expect(result).toContain(
|
||||
'You have reached your daily gemini-2.5-pro quota limit',
|
||||
);
|
||||
expect(result).not.toContain('You have reached your daily quota limit');
|
||||
});
|
||||
|
||||
it('should format a 429 API error with Pro quota exceeded message for Google auth (Standard tier)', () => {
|
||||
const errorMessage =
|
||||
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'Gemini 2.5 Pro Requests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}';
|
||||
const result = parseAndFormatApiError(
|
||||
errorMessage,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
UserTierId.STANDARD,
|
||||
'gemini-2.5-pro',
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
);
|
||||
expect(result).toContain(
|
||||
"[API Error: Quota exceeded for quota metric 'Gemini 2.5 Pro Requests'",
|
||||
);
|
||||
expect(result).toContain(
|
||||
'You have reached your daily gemini-2.5-pro quota limit',
|
||||
);
|
||||
expect(result).toContain(
|
||||
'We appreciate you for choosing Gemini Code Assist and the Gemini CLI',
|
||||
);
|
||||
expect(result).not.toContain(
|
||||
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
|
||||
);
|
||||
});
|
||||
|
||||
it('should format a 429 API error with Pro quota exceeded message for Google auth (Legacy tier)', () => {
|
||||
const errorMessage =
|
||||
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'Gemini 2.5 Pro Requests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}';
|
||||
const result = parseAndFormatApiError(
|
||||
errorMessage,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
UserTierId.LEGACY,
|
||||
'gemini-2.5-pro',
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
);
|
||||
expect(result).toContain(
|
||||
"[API Error: Quota exceeded for quota metric 'Gemini 2.5 Pro Requests'",
|
||||
);
|
||||
expect(result).toContain(
|
||||
'You have reached your daily gemini-2.5-pro quota limit',
|
||||
);
|
||||
expect(result).toContain(
|
||||
'We appreciate you for choosing Gemini Code Assist and the Gemini CLI',
|
||||
);
|
||||
expect(result).not.toContain(
|
||||
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle different Gemini 2.5 version strings in Pro quota exceeded errors', () => {
|
||||
const errorMessage25 =
|
||||
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'Gemini 2.5 Pro Requests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}';
|
||||
const errorMessagePreview =
|
||||
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'Gemini 2.5-preview Pro Requests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}';
|
||||
|
||||
const result25 = parseAndFormatApiError(
|
||||
errorMessage25,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
undefined,
|
||||
'gemini-2.5-pro',
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
);
|
||||
const resultPreview = parseAndFormatApiError(
|
||||
errorMessagePreview,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
undefined,
|
||||
'gemini-2.5-preview-pro',
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
);
|
||||
|
||||
expect(result25).toContain(
|
||||
'You have reached your daily gemini-2.5-pro quota limit',
|
||||
);
|
||||
expect(resultPreview).toContain(
|
||||
'You have reached your daily gemini-2.5-preview-pro quota limit',
|
||||
);
|
||||
expect(result25).toContain(
|
||||
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
|
||||
);
|
||||
expect(resultPreview).toContain(
|
||||
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not match non-Pro models with similar version strings', () => {
|
||||
// Test that Flash models with similar version strings don't match
|
||||
expect(
|
||||
isProQuotaExceededError(
|
||||
"Quota exceeded for quota metric 'Gemini 2.5 Flash Requests' and limit",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isProQuotaExceededError(
|
||||
"Quota exceeded for quota metric 'Gemini 2.5-preview Flash Requests' and limit",
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
// Test other model types
|
||||
expect(
|
||||
isProQuotaExceededError(
|
||||
"Quota exceeded for quota metric 'Gemini 2.5 Ultra Requests' and limit",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isProQuotaExceededError(
|
||||
"Quota exceeded for quota metric 'Gemini 2.5 Standard Requests' and limit",
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
// Test generic quota messages
|
||||
expect(
|
||||
isProQuotaExceededError(
|
||||
"Quota exceeded for quota metric 'GenerationRequests' and limit",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isProQuotaExceededError(
|
||||
"Quota exceeded for quota metric 'EmbeddingRequests' and limit",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should format a generic quota exceeded message for Google auth (Standard tier)', () => {
|
||||
const errorMessage =
|
||||
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Quota exceeded for quota metric \'GenerationRequests\' and limit \'RequestsPerDay\' of service \'generativelanguage.googleapis.com\' for consumer \'project_number:123456789\'.","status":"RESOURCE_EXHAUSTED"}}';
|
||||
const result = parseAndFormatApiError(
|
||||
errorMessage,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
UserTierId.STANDARD,
|
||||
'gemini-2.5-pro',
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
);
|
||||
expect(result).toContain(
|
||||
"[API Error: Quota exceeded for quota metric 'GenerationRequests'",
|
||||
);
|
||||
expect(result).toContain('You have reached your daily quota limit');
|
||||
expect(result).toContain(
|
||||
'We appreciate you for choosing Gemini Code Assist and the Gemini CLI',
|
||||
);
|
||||
expect(result).not.toContain(
|
||||
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
|
||||
);
|
||||
});
|
||||
|
||||
it('should format a regular 429 API error with standard message for Google auth (Standard tier)', () => {
|
||||
const errorMessage =
|
||||
'got status: 429 Too Many Requests. {"error":{"code":429,"message":"Rate limit exceeded","status":"RESOURCE_EXHAUSTED"}}';
|
||||
const result = parseAndFormatApiError(
|
||||
errorMessage,
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
UserTierId.STANDARD,
|
||||
'gemini-2.5-pro',
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
);
|
||||
expect(result).toContain('[API Error: Rate limit exceeded');
|
||||
expect(result).toContain(
|
||||
'We appreciate you for choosing Gemini Code Assist and the Gemini CLI',
|
||||
);
|
||||
expect(result).not.toContain(
|
||||
'upgrade to a Gemini Code Assist Standard or Enterprise plan',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthType,
|
||||
UserTierId,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
isProQuotaExceededError,
|
||||
isGenericQuotaExceededError,
|
||||
isApiError,
|
||||
isStructuredError,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
// Free Tier message functions
|
||||
const getRateLimitErrorMessageGoogleFree = (
|
||||
fallbackModel: string = DEFAULT_GEMINI_FLASH_MODEL,
|
||||
) =>
|
||||
`\nPossible quota limitations in place or slow response times detected. Switching to the ${fallbackModel} model for the rest of this session.`;
|
||||
|
||||
const getRateLimitErrorMessageGoogleProQuotaFree = (
|
||||
currentModel: string = DEFAULT_GEMINI_MODEL,
|
||||
fallbackModel: string = DEFAULT_GEMINI_FLASH_MODEL,
|
||||
) =>
|
||||
`\nYou have reached your daily ${currentModel} quota limit. You will be switched to the ${fallbackModel} model for the rest of this session. To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist, or use /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
|
||||
|
||||
const getRateLimitErrorMessageGoogleGenericQuotaFree = () =>
|
||||
`\nYou have reached your daily quota limit. To increase your limits, upgrade to a Gemini Code Assist Standard or Enterprise plan with higher limits at https://goo.gle/set-up-gemini-code-assist, or use /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
|
||||
|
||||
// Legacy/Standard Tier message functions
|
||||
const getRateLimitErrorMessageGooglePaid = (
|
||||
fallbackModel: string = DEFAULT_GEMINI_FLASH_MODEL,
|
||||
) =>
|
||||
`\nPossible quota limitations in place or slow response times detected. Switching to the ${fallbackModel} model for the rest of this session. We appreciate you for choosing Gemini Code Assist and the Gemini CLI.`;
|
||||
|
||||
const getRateLimitErrorMessageGoogleProQuotaPaid = (
|
||||
currentModel: string = DEFAULT_GEMINI_MODEL,
|
||||
fallbackModel: string = DEFAULT_GEMINI_FLASH_MODEL,
|
||||
) =>
|
||||
`\nYou have reached your daily ${currentModel} quota limit. You will be switched to the ${fallbackModel} model for the rest of this session. We appreciate you for choosing Gemini Code Assist and the Gemini CLI. To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
|
||||
|
||||
const getRateLimitErrorMessageGoogleGenericQuotaPaid = (
|
||||
currentModel: string = DEFAULT_GEMINI_MODEL,
|
||||
) =>
|
||||
`\nYou have reached your daily quota limit. We appreciate you for choosing Gemini Code Assist and the Gemini CLI. To continue accessing the ${currentModel} model today, consider using /auth to switch to using a paid API key from AI Studio at https://aistudio.google.com/apikey`;
|
||||
const RATE_LIMIT_ERROR_MESSAGE_USE_GEMINI =
|
||||
'\nPlease wait and try again later. To increase your limits, request a quota increase through AI Studio, or switch to another /auth method';
|
||||
const RATE_LIMIT_ERROR_MESSAGE_VERTEX =
|
||||
'\nPlease wait and try again later. To increase your limits, request a quota increase through Vertex, or switch to another /auth method';
|
||||
const getRateLimitErrorMessageDefault = (
|
||||
fallbackModel: string = DEFAULT_GEMINI_FLASH_MODEL,
|
||||
) =>
|
||||
`\nPossible quota limitations in place or slow response times detected. Switching to the ${fallbackModel} model for the rest of this session.`;
|
||||
|
||||
function getRateLimitMessage(
|
||||
authType?: AuthType,
|
||||
error?: unknown,
|
||||
userTier?: UserTierId,
|
||||
currentModel?: string,
|
||||
fallbackModel?: string,
|
||||
): string {
|
||||
switch (authType) {
|
||||
case AuthType.LOGIN_WITH_GOOGLE: {
|
||||
// Determine if user is on a paid tier (Legacy or Standard) - default to FREE if not specified
|
||||
const isPaidTier =
|
||||
userTier === UserTierId.LEGACY || userTier === UserTierId.STANDARD;
|
||||
|
||||
if (isProQuotaExceededError(error)) {
|
||||
return isPaidTier
|
||||
? getRateLimitErrorMessageGoogleProQuotaPaid(
|
||||
currentModel || DEFAULT_GEMINI_MODEL,
|
||||
fallbackModel,
|
||||
)
|
||||
: getRateLimitErrorMessageGoogleProQuotaFree(
|
||||
currentModel || DEFAULT_GEMINI_MODEL,
|
||||
fallbackModel,
|
||||
);
|
||||
} else if (isGenericQuotaExceededError(error)) {
|
||||
return isPaidTier
|
||||
? getRateLimitErrorMessageGoogleGenericQuotaPaid(
|
||||
currentModel || DEFAULT_GEMINI_MODEL,
|
||||
)
|
||||
: getRateLimitErrorMessageGoogleGenericQuotaFree();
|
||||
} else {
|
||||
return isPaidTier
|
||||
? getRateLimitErrorMessageGooglePaid(fallbackModel)
|
||||
: getRateLimitErrorMessageGoogleFree(fallbackModel);
|
||||
}
|
||||
}
|
||||
case AuthType.USE_GEMINI:
|
||||
return RATE_LIMIT_ERROR_MESSAGE_USE_GEMINI;
|
||||
case AuthType.USE_VERTEX_AI:
|
||||
return RATE_LIMIT_ERROR_MESSAGE_VERTEX;
|
||||
default:
|
||||
return getRateLimitErrorMessageDefault(fallbackModel);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAndFormatApiError(
|
||||
error: unknown,
|
||||
authType?: AuthType,
|
||||
userTier?: UserTierId,
|
||||
currentModel?: string,
|
||||
fallbackModel?: string,
|
||||
): string {
|
||||
if (isStructuredError(error)) {
|
||||
let text = `[API Error: ${error.message}]`;
|
||||
if (error.status === 429) {
|
||||
text += getRateLimitMessage(
|
||||
authType,
|
||||
error,
|
||||
userTier,
|
||||
currentModel,
|
||||
fallbackModel,
|
||||
);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// The error message might be a string containing a JSON object.
|
||||
if (typeof error === 'string') {
|
||||
const jsonStart = error.indexOf('{');
|
||||
if (jsonStart === -1) {
|
||||
return `[API Error: ${error}]`; // Not a JSON error, return as is.
|
||||
}
|
||||
|
||||
const jsonString = error.substring(jsonStart);
|
||||
|
||||
try {
|
||||
const parsedError = JSON.parse(jsonString) as unknown;
|
||||
if (isApiError(parsedError)) {
|
||||
let finalMessage = parsedError.error.message;
|
||||
try {
|
||||
// See if the message is a stringified JSON with another error
|
||||
const nestedError = JSON.parse(finalMessage) as unknown;
|
||||
if (isApiError(nestedError)) {
|
||||
finalMessage = nestedError.error.message;
|
||||
}
|
||||
} catch (_e) {
|
||||
// It's not a nested JSON error, so we just use the message as is.
|
||||
}
|
||||
let text = `[API Error: ${finalMessage} (Status: ${parsedError.error.status})]`;
|
||||
if (parsedError.error.code === 429) {
|
||||
text += getRateLimitMessage(
|
||||
authType,
|
||||
parsedError,
|
||||
userTier,
|
||||
currentModel,
|
||||
fallbackModel,
|
||||
);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Not a valid JSON, fall through and return the original message.
|
||||
}
|
||||
return `[API Error: ${error}]`;
|
||||
}
|
||||
|
||||
return '[API Error: An unknown error occurred.]';
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user