mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 20:20:57 +00:00
Merge remote-tracking branch 'origin/main' into feat/ask-user-question-tool
This commit is contained in:
commit
2e91f0a4cd
122 changed files with 8126 additions and 6309 deletions
|
|
@ -1,503 +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 { z } from 'zod';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import * as schema from './schema.js';
|
||||
import { ACP_ERROR_CODES } from './errorCodes.js';
|
||||
import { pickAuthMethodsForDetails } from './authMethods.js';
|
||||
export * from './schema.js';
|
||||
|
||||
import type { WritableStream, ReadableStream } from 'node:stream/web';
|
||||
|
||||
const debugLogger = createDebugLogger('ACP_PROTOCOL');
|
||||
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.session_list: {
|
||||
if (!agent.listSessions) {
|
||||
throw RequestError.methodNotFound();
|
||||
}
|
||||
const validatedParams =
|
||||
schema.listSessionsRequestSchema.parse(params);
|
||||
return agent.listSessions(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);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_set_mode: {
|
||||
if (!agent.setMode) {
|
||||
throw RequestError.methodNotFound();
|
||||
}
|
||||
const validatedParams = schema.setModeRequestSchema.parse(params);
|
||||
return agent.setMode(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_set_model: {
|
||||
if (!agent.setModel) {
|
||||
throw RequestError.methodNotFound();
|
||||
}
|
||||
const validatedParams = schema.setModelRequestSchema.parse(params);
|
||||
return agent.setModel(validatedParams);
|
||||
}
|
||||
case schema.AGENT_METHODS.session_set_config_option: {
|
||||
if (!agent.setConfigOption) {
|
||||
throw RequestError.methodNotFound();
|
||||
}
|
||||
const validatedParams =
|
||||
schema.setConfigOptionRequestSchema.parse(params);
|
||||
return agent.setConfigOption(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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams authentication updates (e.g. Qwen OAuth authUri) to the client.
|
||||
*/
|
||||
async authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void> {
|
||||
return await this.#connection.sendNotification(
|
||||
schema.CLIENT_METHODS.authenticate_update,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a custom notification to the client.
|
||||
* Used for extension-specific notifications that are not part of the core ACP protocol.
|
||||
*/
|
||||
async sendCustomNotification<T>(method: string, params: T): Promise<void> {
|
||||
return await this.#connection.sendNotification(method, 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;
|
||||
authMethods?: schema.AuthMethod[];
|
||||
};
|
||||
|
||||
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) {
|
||||
try {
|
||||
const message = JSON.parse(trimmedLine);
|
||||
this.#processMessage(message);
|
||||
} catch (error) {
|
||||
debugLogger.error('ACP parse error for inbound message.', {
|
||||
code: ACP_ERROR_CODES.PARSE_ERROR,
|
||||
line: trimmedLine,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
debugLogger.debug('ACP handler returned request error.', {
|
||||
method,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
details: error.data?.details,
|
||||
});
|
||||
return error.toResult();
|
||||
}
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
const formattedDetails = JSON.stringify(error.format(), undefined, 2);
|
||||
debugLogger.debug('ACP handler validation error.', {
|
||||
method,
|
||||
code: ACP_ERROR_CODES.INVALID_PARAMS,
|
||||
details: formattedDetails,
|
||||
});
|
||||
return RequestError.invalidParams(formattedDetails).toResult();
|
||||
}
|
||||
|
||||
let errorName;
|
||||
let details;
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorName = error.name;
|
||||
details = error.message;
|
||||
} else if (
|
||||
typeof error === 'object' &&
|
||||
error != null &&
|
||||
'message' in error &&
|
||||
typeof error.message === 'string'
|
||||
) {
|
||||
details = error.message;
|
||||
}
|
||||
|
||||
if (errorName === 'TokenManagerError' || details?.includes('/auth')) {
|
||||
return RequestError.authRequired(
|
||||
details,
|
||||
pickAuthMethodsForDetails(details),
|
||||
).toResult();
|
||||
}
|
||||
|
||||
debugLogger.error(
|
||||
'ACP handler failed with internal error.',
|
||||
{ method, errorName, details },
|
||||
error,
|
||||
);
|
||||
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) {
|
||||
const { error } = response;
|
||||
debugLogger.warn('ACP response error received.', {
|
||||
id: response.id,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
data: error.data,
|
||||
});
|
||||
pendingResponse.reject(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
|
||||
debugLogger.error('ACP write error:', error);
|
||||
});
|
||||
return this.#writeQueue;
|
||||
}
|
||||
}
|
||||
|
||||
export class RequestError extends Error {
|
||||
data?: { details?: string; authMethods?: schema.AuthMethod[] };
|
||||
|
||||
constructor(
|
||||
public code: number,
|
||||
message: string,
|
||||
details?: string,
|
||||
authMethods?: schema.AuthMethod[],
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'RequestError';
|
||||
if (details || authMethods) {
|
||||
this.data = {};
|
||||
if (details) {
|
||||
this.data.details = details;
|
||||
}
|
||||
if (authMethods) {
|
||||
this.data.authMethods = authMethods;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static parseError(details?: string): RequestError {
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.PARSE_ERROR,
|
||||
'Parse error',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static invalidRequest(details?: string): RequestError {
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.INVALID_REQUEST,
|
||||
'Invalid request',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static methodNotFound(details?: string): RequestError {
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.METHOD_NOT_FOUND,
|
||||
'Method not found',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static invalidParams(details?: string): RequestError {
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.INVALID_PARAMS,
|
||||
'Invalid params',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static internalError(details?: string): RequestError {
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.INTERNAL_ERROR,
|
||||
'Internal error',
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
static authRequired(
|
||||
details?: string,
|
||||
authMethods?: schema.AuthMethod[],
|
||||
): RequestError {
|
||||
return new RequestError(
|
||||
ACP_ERROR_CODES.AUTH_REQUIRED,
|
||||
'Authentication required',
|
||||
details,
|
||||
authMethods,
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
authenticateUpdate(params: schema.AuthenticateUpdate): Promise<void>;
|
||||
sendCustomNotification<T>(method: string, params: T): 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>;
|
||||
listSessions?(
|
||||
params: schema.ListSessionsRequest,
|
||||
): Promise<schema.ListSessionsResponse>;
|
||||
authenticate(params: schema.AuthenticateRequest): Promise<void>;
|
||||
prompt(params: schema.PromptRequest): Promise<schema.PromptResponse>;
|
||||
cancel(params: schema.CancelNotification): Promise<void>;
|
||||
setMode?(params: schema.SetModeRequest): Promise<schema.SetModeResponse>;
|
||||
setModel?(params: schema.SetModelRequest): Promise<schema.SetModelResponse>;
|
||||
setConfigOption?(
|
||||
params: schema.SetConfigOptionRequest,
|
||||
): Promise<schema.SetConfigOptionResponse>;
|
||||
}
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ReadableStream, WritableStream } from 'node:stream/web';
|
||||
|
||||
import {
|
||||
APPROVAL_MODE_INFO,
|
||||
APPROVAL_MODES,
|
||||
|
|
@ -21,8 +19,40 @@ import {
|
|||
type ConversationRecord,
|
||||
type DeviceAuthorizationData,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { ApprovalModeValue, ConfigOption } from './schema.js';
|
||||
import * as acp from './acp.js';
|
||||
import {
|
||||
AgentSideConnection,
|
||||
RequestError,
|
||||
ndJsonStream,
|
||||
PROTOCOL_VERSION,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import type {
|
||||
Agent,
|
||||
AuthenticateRequest,
|
||||
AuthMethod,
|
||||
CancelNotification,
|
||||
ClientCapabilities,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
ListSessionsRequest,
|
||||
ListSessionsResponse,
|
||||
LoadSessionRequest,
|
||||
LoadSessionResponse,
|
||||
McpServer,
|
||||
McpServerStdio,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
SessionConfigOption,
|
||||
SessionInfo,
|
||||
SessionModeState,
|
||||
SetSessionConfigOptionRequest,
|
||||
SetSessionConfigOptionResponse,
|
||||
SetSessionModelRequest,
|
||||
SetSessionModelResponse,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import { buildAuthMethods } from './authMethods.js';
|
||||
import { AcpFileSystemService } from './service/filesystem.js';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
|
|
@ -31,9 +61,8 @@ import { SettingScope } from '../config/settings.js';
|
|||
import { z } from 'zod';
|
||||
import type { CliArgs } from '../config/config.js';
|
||||
import { loadCliConfig } from '../config/config.js';
|
||||
|
||||
// Import the modular Session class
|
||||
import { Session } from './session/Session.js';
|
||||
import type { ApprovalModeValue } from './session/types.js';
|
||||
import { formatAcpModelId } from '../utils/acpModelUtils.js';
|
||||
|
||||
const debugLogger = createDebugLogger('ACP_AGENT');
|
||||
|
|
@ -52,54 +81,46 @@ export async function runAcpAgent(
|
|||
console.info = console.error;
|
||||
console.debug = console.error;
|
||||
|
||||
new acp.AgentSideConnection(
|
||||
(client: acp.Client) => new GeminiAgent(config, settings, argv, client),
|
||||
stdout,
|
||||
stdin,
|
||||
const stream = ndJsonStream(stdout, stdin);
|
||||
const connection = new AgentSideConnection(
|
||||
(conn) => new QwenAgent(config, settings, argv, conn),
|
||||
stream,
|
||||
);
|
||||
|
||||
await connection.closed;
|
||||
}
|
||||
|
||||
class GeminiAgent {
|
||||
function toStdioServer(server: McpServer): McpServerStdio | undefined {
|
||||
if ('command' in server && 'args' in server && 'env' in server) {
|
||||
return server as McpServerStdio;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
class QwenAgent implements Agent {
|
||||
private sessions: Map<string, Session> = new Map();
|
||||
private clientCapabilities: acp.ClientCapabilities | undefined;
|
||||
private clientCapabilities: ClientCapabilities | undefined;
|
||||
|
||||
constructor(
|
||||
private config: Config,
|
||||
private settings: LoadedSettings,
|
||||
private argv: CliArgs,
|
||||
private client: acp.Client,
|
||||
private connection: AgentSideConnection,
|
||||
) {}
|
||||
|
||||
async initialize(
|
||||
args: acp.InitializeRequest,
|
||||
): Promise<acp.InitializeResponse> {
|
||||
async initialize(args: InitializeRequest): Promise<InitializeResponse> {
|
||||
this.clientCapabilities = args.clientCapabilities;
|
||||
const authMethods = buildAuthMethods();
|
||||
|
||||
// Get current approval mode from config
|
||||
const currentApprovalMode = this.config.getApprovalMode();
|
||||
|
||||
// Build available modes from shared APPROVAL_MODE_INFO
|
||||
const availableModes = APPROVAL_MODES.map((mode) => ({
|
||||
id: mode as ApprovalModeValue,
|
||||
name: APPROVAL_MODE_INFO[mode].name,
|
||||
description: APPROVAL_MODE_INFO[mode].description,
|
||||
}));
|
||||
|
||||
const version = process.env['CLI_VERSION'] || process.version;
|
||||
|
||||
return {
|
||||
protocolVersion: acp.PROTOCOL_VERSION,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
agentInfo: {
|
||||
name: 'qwen-code',
|
||||
title: 'Qwen Code',
|
||||
version,
|
||||
},
|
||||
authMethods,
|
||||
modes: {
|
||||
currentModeId: currentApprovalMode as ApprovalModeValue,
|
||||
availableModes,
|
||||
},
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: {
|
||||
|
|
@ -115,14 +136,15 @@ class GeminiAgent {
|
|||
};
|
||||
}
|
||||
|
||||
async authenticate({ methodId }: acp.AuthenticateRequest): Promise<void> {
|
||||
async authenticate({ methodId }: AuthenticateRequest): Promise<void> {
|
||||
const method = z.nativeEnum(AuthType).parse(methodId);
|
||||
|
||||
let authUri: string | undefined;
|
||||
const authUriHandler = (deviceAuth: DeviceAuthorizationData) => {
|
||||
authUri = deviceAuth.verification_uri_complete;
|
||||
// Send the auth URL to ACP client as soon as it's available (refreshAuth is blocking).
|
||||
void this.client.authenticateUpdate({ _meta: { authUri } });
|
||||
void this.connection.extNotification('authenticate/update', {
|
||||
_meta: { authUri },
|
||||
});
|
||||
};
|
||||
|
||||
if (method === AuthType.QWEN_OAUTH) {
|
||||
|
|
@ -138,19 +160,16 @@ class GeminiAgent {
|
|||
method,
|
||||
);
|
||||
} finally {
|
||||
// Ensure we don't leak listeners if auth fails early.
|
||||
if (method === AuthType.QWEN_OAUTH) {
|
||||
qwenOAuth2Events.off(QwenOAuth2Event.AuthUri, authUriHandler);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async newSession({
|
||||
cwd,
|
||||
mcpServers,
|
||||
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
|
||||
}: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
const config = await this.newSessionConfig(cwd, mcpServers);
|
||||
await this.ensureAuthenticated(config);
|
||||
this.setupFileSystem(config);
|
||||
|
|
@ -168,58 +187,12 @@ class GeminiAgent {
|
|||
};
|
||||
}
|
||||
|
||||
async newSessionConfig(
|
||||
cwd: string,
|
||||
mcpServers: acp.McpServer[],
|
||||
sessionId?: string,
|
||||
): 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 argvForSession = {
|
||||
...this.argv,
|
||||
resume: sessionId,
|
||||
continue: false,
|
||||
};
|
||||
|
||||
const config = await loadCliConfig(settings, argvForSession, cwd);
|
||||
|
||||
await config.initialize();
|
||||
return config;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async loadSession(
|
||||
params: acp.LoadSessionRequest,
|
||||
): Promise<acp.LoadSessionResponse> {
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
const sessionService = new SessionService(params.cwd);
|
||||
const exists = await sessionService.sessionExists(params.sessionId);
|
||||
if (!exists) {
|
||||
throw acp.RequestError.invalidParams(
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Session not found for id: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -234,182 +207,193 @@ class GeminiAgent {
|
|||
|
||||
const sessionData = config.getResumedSessionData();
|
||||
if (!sessionData) {
|
||||
throw acp.RequestError.internalError(
|
||||
throw RequestError.internalError(
|
||||
undefined,
|
||||
`Failed to load session data for id: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.createAndStoreSession(config, sessionData.conversation);
|
||||
|
||||
return null;
|
||||
const modesData = this.buildModesData(config);
|
||||
const availableModels = this.buildAvailableModels(config);
|
||||
const configOptions = this.buildConfigOptions(config);
|
||||
|
||||
return {
|
||||
modes: modesData,
|
||||
models: availableModels,
|
||||
configOptions,
|
||||
};
|
||||
}
|
||||
|
||||
async listSessions(
|
||||
params: acp.ListSessionsRequest,
|
||||
): Promise<acp.ListSessionsResponse> {
|
||||
async unstable_listSessions(
|
||||
params: ListSessionsRequest,
|
||||
): Promise<ListSessionsResponse> {
|
||||
const cwd = params.cwd || process.cwd();
|
||||
const sessionService = new SessionService(cwd);
|
||||
const numericCursor = params.cursor ? Number(params.cursor) : undefined;
|
||||
const result = await sessionService.listSessions({
|
||||
cursor: params.cursor,
|
||||
size: params.size,
|
||||
cursor: Number.isNaN(numericCursor) ? undefined : numericCursor,
|
||||
});
|
||||
|
||||
const sessions = result.items.map((item) => ({
|
||||
const sessions: SessionInfo[] = result.items.map((item) => ({
|
||||
cwd: item.cwd,
|
||||
filePath: item.filePath,
|
||||
gitBranch: item.gitBranch,
|
||||
messageCount: item.messageCount,
|
||||
mtime: item.mtime,
|
||||
prompt: item.prompt,
|
||||
sessionId: item.sessionId,
|
||||
startTime: item.startTime,
|
||||
title: item.prompt || '(session)',
|
||||
updatedAt: new Date(item.mtime).toISOString(),
|
||||
}));
|
||||
|
||||
return {
|
||||
hasMore: result.hasMore,
|
||||
items: sessions,
|
||||
nextCursor: result.nextCursor,
|
||||
sessions,
|
||||
nextCursor:
|
||||
result.nextCursor != null ? String(result.nextCursor) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async setMode(params: acp.SetModeRequest): Promise<acp.SetModeResponse> {
|
||||
async setSessionMode(
|
||||
params: SetSessionModeRequest,
|
||||
): Promise<SetSessionModeResponse | void> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw acp.RequestError.invalidParams(
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Session not found for id: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return session.setMode(params);
|
||||
}
|
||||
|
||||
async setModel(params: acp.SetModelRequest): Promise<acp.SetModelResponse> {
|
||||
async unstable_setSessionModel(
|
||||
params: SetSessionModelRequest,
|
||||
): Promise<SetSessionModelResponse | void> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw acp.RequestError.invalidParams(
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Session not found for id: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return await session.setModel(params);
|
||||
}
|
||||
|
||||
async setConfigOption(
|
||||
params: acp.SetConfigOptionRequest,
|
||||
): Promise<acp.SetConfigOptionResponse> {
|
||||
async setSessionConfigOption(
|
||||
params: SetSessionConfigOptionRequest,
|
||||
): Promise<SetSessionConfigOptionResponse> {
|
||||
const { sessionId, configId, value } = params;
|
||||
|
||||
// Get the session's config
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw acp.RequestError.invalidParams(
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Session not found for id: ${sessionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (configId) {
|
||||
case 'mode': {
|
||||
await this.setMode({
|
||||
await this.setSessionMode({
|
||||
sessionId,
|
||||
modeId: value as ApprovalModeValue,
|
||||
modeId: value as string,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'model': {
|
||||
await this.setModel({
|
||||
await this.unstable_setSessionModel({
|
||||
sessionId,
|
||||
modelId: value as string,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw acp.RequestError.invalidParams(
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Unsupported configId: ${configId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Return all config options with current values
|
||||
return {
|
||||
configOptions: this.buildConfigOptions(session.getConfig()),
|
||||
};
|
||||
}
|
||||
|
||||
private buildConfigOptions(config: Config): ConfigOption[] {
|
||||
const currentApprovalMode = config.getApprovalMode();
|
||||
const allConfiguredModels = config.getAllConfiguredModels();
|
||||
const rawCurrentModelId = (config.getModel() || '').trim();
|
||||
const currentAuthType = config.getAuthType?.();
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`);
|
||||
}
|
||||
return session.prompt(params);
|
||||
}
|
||||
|
||||
// Check if current model is a runtime model
|
||||
const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.();
|
||||
const currentModelId = activeRuntimeSnapshot
|
||||
? formatAcpModelId(
|
||||
activeRuntimeSnapshot.id,
|
||||
activeRuntimeSnapshot.authType,
|
||||
)
|
||||
: this.formatCurrentModelId(rawCurrentModelId, currentAuthType);
|
||||
async cancel(params: CancelNotification): Promise<void> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`);
|
||||
}
|
||||
await session.cancelPendingPrompt();
|
||||
}
|
||||
|
||||
// Build mode config option
|
||||
const modeOptions = APPROVAL_MODES.map((mode) => ({
|
||||
value: mode,
|
||||
name: APPROVAL_MODE_INFO[mode].name,
|
||||
description: APPROVAL_MODE_INFO[mode].description,
|
||||
}));
|
||||
async extMethod(
|
||||
method: string,
|
||||
_params: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
throw RequestError.methodNotFound(method);
|
||||
}
|
||||
|
||||
const modeConfigOption: ConfigOption = {
|
||||
id: 'mode',
|
||||
name: 'Mode',
|
||||
description: 'Session permission mode',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue: currentApprovalMode,
|
||||
options: modeOptions,
|
||||
// --- private helpers ---
|
||||
|
||||
private async newSessionConfig(
|
||||
cwd: string,
|
||||
mcpServers: McpServer[],
|
||||
sessionId?: string,
|
||||
): Promise<Config> {
|
||||
const mergedMcpServers = { ...this.settings.merged.mcpServers };
|
||||
|
||||
for (const server of mcpServers) {
|
||||
const stdioServer = toStdioServer(server);
|
||||
if (!stdioServer) continue;
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
for (const { name: envName, value } of stdioServer.env) {
|
||||
env[envName] = value;
|
||||
}
|
||||
mergedMcpServers[stdioServer.name] = new MCPServerConfig(
|
||||
stdioServer.command,
|
||||
stdioServer.args,
|
||||
env,
|
||||
cwd,
|
||||
);
|
||||
}
|
||||
|
||||
const settings = { ...this.settings.merged, mcpServers: mergedMcpServers };
|
||||
const argvForSession = {
|
||||
...this.argv,
|
||||
resume: sessionId,
|
||||
continue: false,
|
||||
};
|
||||
|
||||
// Build model config option
|
||||
const modelOptions = allConfiguredModels.map((model) => {
|
||||
const effectiveModelId =
|
||||
model.isRuntimeModel && model.runtimeSnapshotId
|
||||
? model.runtimeSnapshotId
|
||||
: model.id;
|
||||
return {
|
||||
value: formatAcpModelId(effectiveModelId, model.authType),
|
||||
name: model.label,
|
||||
description: model.description ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
const modelConfigOption: ConfigOption = {
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
description: 'AI model to use',
|
||||
category: 'model',
|
||||
type: 'select',
|
||||
currentValue: currentModelId,
|
||||
options: modelOptions,
|
||||
};
|
||||
|
||||
return [modeConfigOption, modelConfigOption];
|
||||
const config = await loadCliConfig(settings, argvForSession, cwd);
|
||||
await config.initialize();
|
||||
return config;
|
||||
}
|
||||
|
||||
private async ensureAuthenticated(config: Config): Promise<void> {
|
||||
const selectedType = config.getModelsConfig().getCurrentAuthType();
|
||||
if (!selectedType) {
|
||||
throw acp.RequestError.authRequired(
|
||||
throw RequestError.authRequired(
|
||||
{ authMethods: this.pickAuthMethodsForAuthRequired() },
|
||||
'Use Qwen Code CLI to authenticate first.',
|
||||
this.pickAuthMethodsForAuthRequired(),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use true for the second argument to ensure only cached credentials are used
|
||||
await config.refreshAuth(selectedType, true);
|
||||
} catch (e) {
|
||||
debugLogger.error(`Authentication failed: ${e}`);
|
||||
throw acp.RequestError.authRequired(
|
||||
throw RequestError.authRequired(
|
||||
{
|
||||
authMethods: this.pickAuthMethodsForAuthRequired(selectedType, e),
|
||||
},
|
||||
'Authentication failed: ' + (e as Error).message,
|
||||
this.pickAuthMethodsForAuthRequired(selectedType, e),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -417,7 +401,7 @@ class GeminiAgent {
|
|||
private pickAuthMethodsForAuthRequired(
|
||||
selectedType?: AuthType | string,
|
||||
error?: unknown,
|
||||
): acp.AuthMethod[] {
|
||||
): AuthMethod[] {
|
||||
const authMethods = buildAuthMethods();
|
||||
const errorMessage = this.extractErrorMessage(error);
|
||||
if (
|
||||
|
|
@ -425,25 +409,21 @@ class GeminiAgent {
|
|||
errorMessage?.includes('Qwen OAuth')
|
||||
) {
|
||||
const qwenOAuthMethods = authMethods.filter(
|
||||
(method) => method.id === AuthType.QWEN_OAUTH,
|
||||
(m) => m.id === AuthType.QWEN_OAUTH,
|
||||
);
|
||||
return qwenOAuthMethods.length ? qwenOAuthMethods : authMethods;
|
||||
}
|
||||
|
||||
if (selectedType) {
|
||||
const matchedMethods = authMethods.filter(
|
||||
(method) => method.id === selectedType,
|
||||
);
|
||||
return matchedMethods.length ? matchedMethods : authMethods;
|
||||
const matched = authMethods.filter((m) => m.id === selectedType);
|
||||
return matched.length ? matched : authMethods;
|
||||
}
|
||||
|
||||
return authMethods;
|
||||
}
|
||||
|
||||
private extractErrorMessage(error?: unknown): string | undefined {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (error instanceof Error) return error.message;
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error != null &&
|
||||
|
|
@ -452,19 +432,15 @@ class GeminiAgent {
|
|||
) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
if (typeof error === 'string') return error;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private setupFileSystem(config: Config): void {
|
||||
if (!this.clientCapabilities?.fs) {
|
||||
return;
|
||||
}
|
||||
if (!this.clientCapabilities?.fs) return;
|
||||
|
||||
const acpFileSystemService = new AcpFileSystemService(
|
||||
this.client,
|
||||
this.connection,
|
||||
config.getSessionId(),
|
||||
this.clientCapabilities.fs,
|
||||
config.getFileSystemService(),
|
||||
|
|
@ -479,26 +455,17 @@ class GeminiAgent {
|
|||
const sessionId = config.getSessionId();
|
||||
const geminiClient = config.getGeminiClient();
|
||||
|
||||
// Use GeminiClient to manage chat lifecycle properly
|
||||
// This ensures geminiClient.chat is in sync with the session's chat
|
||||
//
|
||||
// Note: When loading a session, config.initialize() has already been called
|
||||
// in newSessionConfig(), which in turn calls geminiClient.initialize().
|
||||
// The GeminiClient.initialize() method checks config.getResumedSessionData()
|
||||
// and automatically loads the conversation history into the chat instance.
|
||||
// So we only need to initialize if it hasn't been done yet.
|
||||
if (!geminiClient.isInitialized()) {
|
||||
await geminiClient.initialize();
|
||||
}
|
||||
|
||||
// Now get the chat instance that's managed by GeminiClient
|
||||
const chat = geminiClient.getChat();
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
chat,
|
||||
config,
|
||||
this.client,
|
||||
this.connection,
|
||||
this.settings,
|
||||
);
|
||||
this.sessions.set(sessionId, session);
|
||||
|
|
@ -514,9 +481,7 @@ class GeminiAgent {
|
|||
return session;
|
||||
}
|
||||
|
||||
private buildAvailableModels(
|
||||
config: Config,
|
||||
): acp.NewSessionResponse['models'] {
|
||||
private buildAvailableModels(config: Config): NewSessionResponse['models'] {
|
||||
const rawCurrentModelId = (
|
||||
config.getModel() ||
|
||||
this.config.getModel() ||
|
||||
|
|
@ -525,8 +490,6 @@ class GeminiAgent {
|
|||
const currentAuthType = config.getAuthType();
|
||||
const allConfiguredModels = config.getAllConfiguredModels();
|
||||
|
||||
// Check if current model is a runtime model
|
||||
// Runtime models use $runtime|${authType}|${modelId} format
|
||||
const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.();
|
||||
const currentModelId = activeRuntimeSnapshot
|
||||
? formatAcpModelId(
|
||||
|
|
@ -535,11 +498,7 @@ class GeminiAgent {
|
|||
)
|
||||
: this.formatCurrentModelId(rawCurrentModelId, currentAuthType);
|
||||
|
||||
const availableModels = allConfiguredModels;
|
||||
|
||||
const mappedAvailableModels = availableModels.map((model) => {
|
||||
// For runtime models, use runtimeSnapshotId as modelId for ACP protocol
|
||||
// This allows ACP clients to correctly identify and switch to runtime models
|
||||
const mappedAvailableModels = allConfiguredModels.map((model) => {
|
||||
const effectiveModelId =
|
||||
model.isRuntimeModel && model.runtimeSnapshotId
|
||||
? model.runtimeSnapshotId
|
||||
|
|
@ -561,7 +520,7 @@ class GeminiAgent {
|
|||
};
|
||||
}
|
||||
|
||||
private buildModesData(config: Config): acp.ModesData {
|
||||
private buildModesData(config: Config): SessionModeState {
|
||||
const currentApprovalMode = config.getApprovalMode();
|
||||
|
||||
const availableModes = APPROVAL_MODES.map((mode) => ({
|
||||
|
|
@ -576,14 +535,66 @@ class GeminiAgent {
|
|||
};
|
||||
}
|
||||
|
||||
private buildConfigOptions(config: Config): SessionConfigOption[] {
|
||||
const currentApprovalMode = config.getApprovalMode();
|
||||
const allConfiguredModels = config.getAllConfiguredModels();
|
||||
const rawCurrentModelId = (config.getModel() || '').trim();
|
||||
const currentAuthType = config.getAuthType?.();
|
||||
|
||||
const activeRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.();
|
||||
const currentModelId = activeRuntimeSnapshot
|
||||
? formatAcpModelId(
|
||||
activeRuntimeSnapshot.id,
|
||||
activeRuntimeSnapshot.authType,
|
||||
)
|
||||
: this.formatCurrentModelId(rawCurrentModelId, currentAuthType);
|
||||
|
||||
const modeOptions = APPROVAL_MODES.map((mode) => ({
|
||||
value: mode,
|
||||
name: APPROVAL_MODE_INFO[mode].name,
|
||||
description: APPROVAL_MODE_INFO[mode].description,
|
||||
}));
|
||||
|
||||
const modeConfigOption: SessionConfigOption = {
|
||||
id: 'mode',
|
||||
name: 'Mode',
|
||||
description: 'Session permission mode',
|
||||
category: 'mode',
|
||||
type: 'select' as const,
|
||||
currentValue: currentApprovalMode,
|
||||
options: modeOptions,
|
||||
};
|
||||
|
||||
const modelOptions = allConfiguredModels.map((model) => {
|
||||
const effectiveModelId =
|
||||
model.isRuntimeModel && model.runtimeSnapshotId
|
||||
? model.runtimeSnapshotId
|
||||
: model.id;
|
||||
return {
|
||||
value: formatAcpModelId(effectiveModelId, model.authType),
|
||||
name: model.label,
|
||||
description: model.description ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
const modelConfigOption: SessionConfigOption = {
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
description: 'AI model to use',
|
||||
category: 'model',
|
||||
type: 'select' as const,
|
||||
currentValue: currentModelId,
|
||||
options: modelOptions,
|
||||
};
|
||||
|
||||
return [modeConfigOption, modelConfigOption];
|
||||
}
|
||||
|
||||
private formatCurrentModelId(
|
||||
baseModelId: string,
|
||||
authType?: AuthType,
|
||||
): string {
|
||||
if (!baseModelId) {
|
||||
return baseModelId;
|
||||
}
|
||||
|
||||
if (!baseModelId) return baseModelId;
|
||||
return authType ? formatAcpModelId(baseModelId, authType) : baseModelId;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import type { AuthMethod } from './schema.js';
|
||||
import type { AuthMethod } from '@agentclientprotocol/sdk';
|
||||
|
||||
export function buildAuthMethods(): AuthMethod[] {
|
||||
return [
|
||||
|
|
@ -13,16 +13,20 @@ export function buildAuthMethods(): AuthMethod[] {
|
|||
id: AuthType.USE_OPENAI,
|
||||
name: 'Use OpenAI API key',
|
||||
description: 'Requires setting the `OPENAI_API_KEY` environment variable',
|
||||
type: 'terminal',
|
||||
args: ['--auth-type=openai'],
|
||||
_meta: {
|
||||
type: 'terminal',
|
||||
args: ['--auth-type=openai'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: AuthType.QWEN_OAUTH,
|
||||
name: 'Qwen OAuth',
|
||||
description:
|
||||
'OAuth authentication for Qwen models with free daily requests',
|
||||
type: 'terminal',
|
||||
args: ['--auth-type=qwen-oauth'],
|
||||
_meta: {
|
||||
type: 'terminal',
|
||||
args: ['--auth-type=qwen-oauth'],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,709 +0,0 @@
|
|||
/**
|
||||
* @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',
|
||||
session_list: 'session/list',
|
||||
session_set_mode: 'session/set_mode',
|
||||
session_set_model: 'session/set_model',
|
||||
session_set_config_option: 'session/set_config_option',
|
||||
};
|
||||
|
||||
export const CLIENT_METHODS = {
|
||||
fs_read_text_file: 'fs/read_text_file',
|
||||
fs_write_text_file: 'fs/write_text_file',
|
||||
authenticate_update: 'authenticate/update',
|
||||
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 SessionListItem = z.infer<typeof sessionListItemSchema>;
|
||||
export type ListSessionsRequest = z.infer<typeof listSessionsRequestSchema>;
|
||||
export type ListSessionsResponse = z.infer<typeof listSessionsResponseSchema>;
|
||||
|
||||
export type CancelNotification = z.infer<typeof cancelNotificationSchema>;
|
||||
|
||||
export type AuthenticateRequest = z.infer<typeof authenticateRequestSchema>;
|
||||
|
||||
// Note: NewSessionResponse type is defined later after 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 ModeInfo = z.infer<typeof modeInfoSchema>;
|
||||
|
||||
export type ModesData = z.infer<typeof modesDataSchema>;
|
||||
|
||||
export type AgentInfo = z.infer<typeof agentInfoSchema>;
|
||||
export type ModelInfo = z.infer<typeof modelInfoSchema>;
|
||||
|
||||
export type PromptCapabilities = z.infer<typeof promptCapabilitiesSchema>;
|
||||
|
||||
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 type ApprovalModeValue = z.infer<typeof approvalModeValueSchema>;
|
||||
|
||||
export type SetModeRequest = z.infer<typeof setModeRequestSchema>;
|
||||
|
||||
export type SetModeResponse = z.infer<typeof setModeResponseSchema>;
|
||||
|
||||
export type AvailableCommandInput = z.infer<typeof availableCommandInputSchema>;
|
||||
|
||||
export type AvailableCommand = z.infer<typeof availableCommandSchema>;
|
||||
|
||||
export type AvailableCommandsUpdate = z.infer<
|
||||
typeof availableCommandsUpdateSchema
|
||||
>;
|
||||
|
||||
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('switch_mode'),
|
||||
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 approvalModeValueSchema = z.union([
|
||||
z.literal('plan'),
|
||||
z.literal('default'),
|
||||
z.literal('auto-edit'),
|
||||
z.literal('yolo'),
|
||||
]);
|
||||
|
||||
export const setModeRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
modeId: approvalModeValueSchema,
|
||||
});
|
||||
|
||||
export const setModeResponseSchema = z.object({
|
||||
modeId: approvalModeValueSchema,
|
||||
});
|
||||
|
||||
export const authenticateRequestSchema = z.object({
|
||||
methodId: z.string(),
|
||||
});
|
||||
|
||||
export const authenticateUpdateSchema = z.object({
|
||||
_meta: z.object({
|
||||
authUri: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type AuthenticateUpdate = z.infer<typeof authenticateUpdateSchema>;
|
||||
|
||||
export const acpMetaSchema = z.record(z.unknown()).nullable().optional();
|
||||
|
||||
export const modelIdSchema = z.string();
|
||||
|
||||
export const modelInfoSchema = z.object({
|
||||
_meta: acpMetaSchema,
|
||||
description: z.string().nullable().optional(),
|
||||
modelId: modelIdSchema,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const setModelRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
modelId: z.string(),
|
||||
});
|
||||
|
||||
export const setModelResponseSchema = z.object({
|
||||
modelId: z.string(),
|
||||
});
|
||||
|
||||
export type SetModelRequest = z.infer<typeof setModelRequestSchema>;
|
||||
export type SetModelResponse = z.infer<typeof setModelResponseSchema>;
|
||||
|
||||
export const sessionModelStateSchema = z.object({
|
||||
_meta: acpMetaSchema,
|
||||
availableModels: z.array(modelInfoSchema),
|
||||
currentModelId: modelIdSchema,
|
||||
});
|
||||
|
||||
// Note: newSessionResponseSchema is defined later in the file after modesDataSchema
|
||||
|
||||
export const loadSessionResponseSchema = z.null();
|
||||
|
||||
export const sessionListItemSchema = z.object({
|
||||
cwd: z.string(),
|
||||
filePath: z.string().optional(),
|
||||
gitBranch: z.string().optional(),
|
||||
messageCount: z.number().optional(),
|
||||
mtime: z.number().optional(),
|
||||
prompt: z.string().optional(),
|
||||
sessionId: z.string(),
|
||||
startTime: z.string().optional(),
|
||||
title: z.string(),
|
||||
updatedAt: z.string(),
|
||||
});
|
||||
|
||||
export const listSessionsResponseSchema = z.object({
|
||||
hasMore: z.boolean().optional(),
|
||||
items: z.array(sessionListItemSchema).optional(),
|
||||
nextCursor: z.number().optional(),
|
||||
sessions: z.array(sessionListItemSchema),
|
||||
});
|
||||
|
||||
export const listSessionsRequestSchema = z.object({
|
||||
cursor: z.number().optional(),
|
||||
cwd: z.string().optional(),
|
||||
size: z.number().optional(),
|
||||
});
|
||||
|
||||
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 usageSchema = z.object({
|
||||
promptTokens: z.number().optional().nullable(),
|
||||
completionTokens: z.number().optional().nullable(),
|
||||
thoughtsTokens: z.number().optional().nullable(),
|
||||
totalTokens: z.number().optional().nullable(),
|
||||
cachedTokens: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export type Usage = z.infer<typeof usageSchema>;
|
||||
|
||||
export const sessionUpdateMetaSchema = z.object({
|
||||
usage: usageSchema.optional().nullable(),
|
||||
durationMs: z.number().optional().nullable(),
|
||||
toolName: z.string().optional().nullable(),
|
||||
parentToolCallId: z.string().optional().nullable(),
|
||||
subagentType: z.string().optional().nullable(),
|
||||
/** Server-side timestamp (ms since epoch) for correct message ordering */
|
||||
timestamp: z.number().optional().nullable(),
|
||||
});
|
||||
|
||||
export type SessionUpdateMeta = z.infer<typeof sessionUpdateMetaSchema>;
|
||||
|
||||
export const requestPermissionResponseSchema = z.object({
|
||||
outcome: requestPermissionOutcomeSchema,
|
||||
answers: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
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 promptCapabilitiesSchema = z.object({
|
||||
audio: z.boolean().optional(),
|
||||
embeddedContext: z.boolean().optional(),
|
||||
image: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const agentCapabilitiesSchema = z.object({
|
||||
loadSession: z.boolean().optional(),
|
||||
promptCapabilities: promptCapabilitiesSchema.optional(),
|
||||
sessionCapabilities: z
|
||||
.object({
|
||||
list: z.object({}).optional(),
|
||||
resume: z.object({}).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const authMethodSchema = z.object({
|
||||
args: z.array(z.string()).optional(),
|
||||
description: z.string().nullable(),
|
||||
env: z.record(z.string()).optional(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.string().optional(),
|
||||
});
|
||||
|
||||
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 modeInfoSchema = z.object({
|
||||
id: approvalModeValueSchema,
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
});
|
||||
|
||||
export const modesDataSchema = z.object({
|
||||
currentModeId: approvalModeValueSchema,
|
||||
availableModes: z.array(modeInfoSchema),
|
||||
});
|
||||
|
||||
export const configOptionSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
category: z.string(),
|
||||
type: z.string(),
|
||||
currentValue: z.string(),
|
||||
options: z.array(
|
||||
z.object({
|
||||
value: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type ConfigOption = z.infer<typeof configOptionSchema>;
|
||||
|
||||
export const setConfigOptionRequestSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
configId: z.string(),
|
||||
value: z.unknown(),
|
||||
});
|
||||
|
||||
export const setConfigOptionResponseSchema = z.object({
|
||||
configOptions: z.array(configOptionSchema),
|
||||
});
|
||||
|
||||
export type SetConfigOptionRequest = z.infer<
|
||||
typeof setConfigOptionRequestSchema
|
||||
>;
|
||||
export type SetConfigOptionResponse = z.infer<
|
||||
typeof setConfigOptionResponseSchema
|
||||
>;
|
||||
|
||||
// newSessionResponseSchema includes modes and configOptions for ACP/Zed integration
|
||||
export const newSessionResponseSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
models: sessionModelStateSchema,
|
||||
modes: modesDataSchema,
|
||||
configOptions: z.array(configOptionSchema),
|
||||
});
|
||||
|
||||
export type NewSessionResponse = z.infer<typeof newSessionResponseSchema>;
|
||||
|
||||
export const agentInfoSchema = z.object({
|
||||
name: z.string(),
|
||||
title: z.string(),
|
||||
version: z.string(),
|
||||
});
|
||||
|
||||
export const initializeResponseSchema = z.object({
|
||||
agentCapabilities: agentCapabilitiesSchema,
|
||||
agentInfo: agentInfoSchema,
|
||||
authMethods: z.array(authMethodSchema),
|
||||
modes: modesDataSchema,
|
||||
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 availableCommandInputSchema = z.object({
|
||||
hint: z.string(),
|
||||
});
|
||||
|
||||
export const availableCommandSchema = z.object({
|
||||
description: z.string(),
|
||||
input: availableCommandInputSchema.nullable().optional(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const availableCommandsUpdateSchema = z.object({
|
||||
availableCommands: z.array(availableCommandSchema),
|
||||
sessionUpdate: z.literal('available_commands_update'),
|
||||
});
|
||||
|
||||
export const currentModeUpdateSchema = z.object({
|
||||
sessionUpdate: z.literal('current_mode_update'),
|
||||
modeId: approvalModeValueSchema,
|
||||
});
|
||||
|
||||
export type CurrentModeUpdate = z.infer<typeof currentModeUpdateSchema>;
|
||||
|
||||
export const currentModelUpdateSchema = z.object({
|
||||
sessionUpdate: z.literal('current_model_update'),
|
||||
model: modelInfoSchema,
|
||||
});
|
||||
|
||||
export type CurrentModelUpdate = z.infer<typeof currentModelUpdateSchema>;
|
||||
|
||||
export const sessionUpdateSchema = z.union([
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('user_message_chunk'),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('agent_message_chunk'),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
content: contentBlockSchema,
|
||||
sessionUpdate: z.literal('agent_thought_chunk'),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
content: z.array(toolCallContentSchema).optional(),
|
||||
kind: toolKindSchema,
|
||||
locations: z.array(toolCallLocationSchema).optional(),
|
||||
rawInput: z.unknown().optional(),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
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(),
|
||||
rawOutput: z.unknown().optional(),
|
||||
_meta: sessionUpdateMetaSchema.optional().nullable(),
|
||||
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'),
|
||||
}),
|
||||
currentModeUpdateSchema,
|
||||
currentModelUpdateSchema,
|
||||
availableCommandsUpdateSchema,
|
||||
]);
|
||||
|
||||
export const agentResponseSchema = z.union([
|
||||
initializeResponseSchema,
|
||||
newSessionResponseSchema,
|
||||
loadSessionResponseSchema,
|
||||
promptResponseSchema,
|
||||
listSessionsResponseSchema,
|
||||
setModeResponseSchema,
|
||||
setModelResponseSchema,
|
||||
]);
|
||||
|
||||
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,
|
||||
listSessionsRequestSchema,
|
||||
setModeRequestSchema,
|
||||
setModelRequestSchema,
|
||||
setConfigOptionRequestSchema,
|
||||
]);
|
||||
|
||||
export const agentNotificationSchema = sessionNotificationSchema;
|
||||
|
|
@ -7,7 +7,10 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { FileSystemService } from '@qwen-code/qwen-code-core';
|
||||
import { AcpFileSystemService } from './filesystem.js';
|
||||
import { ACP_ERROR_CODES } from '../errorCodes.js';
|
||||
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
|
||||
|
||||
const RESOURCE_NOT_FOUND_CODE = -32002;
|
||||
const INTERNAL_ERROR_CODE = -32603;
|
||||
|
||||
const createFallback = (): FileSystemService => ({
|
||||
readTextFile: vi.fn(),
|
||||
|
|
@ -26,7 +29,7 @@ describe('AcpFileSystemService', () => {
|
|||
readTextFile: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ content: '\ufeff// BOM file' }),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
|
|
@ -40,7 +43,6 @@ describe('AcpFileSystemService', () => {
|
|||
expect(client.readTextFile).toHaveBeenCalledWith({
|
||||
path: '/test/file.txt',
|
||||
sessionId: 'session-1',
|
||||
line: null,
|
||||
limit: 1,
|
||||
});
|
||||
});
|
||||
|
|
@ -48,7 +50,7 @@ describe('AcpFileSystemService', () => {
|
|||
it('detects no BOM through ACP client when content does not start with U+FEFF', async () => {
|
||||
const client = {
|
||||
readTextFile: vi.fn().mockResolvedValue({ content: '// No BOM file' }),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
|
|
@ -64,7 +66,7 @@ describe('AcpFileSystemService', () => {
|
|||
it('falls back to local filesystem when ACP client fails', async () => {
|
||||
const client = {
|
||||
readTextFile: vi.fn().mockRejectedValue(new Error('Network error')),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const fallback = createFallback();
|
||||
(fallback.detectFileBOM as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
|
|
@ -86,7 +88,7 @@ describe('AcpFileSystemService', () => {
|
|||
it('falls back to local filesystem when readTextFile capability is disabled', async () => {
|
||||
const client = {
|
||||
readTextFile: vi.fn(),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const fallback = createFallback();
|
||||
(fallback.detectFileBOM as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
|
|
@ -110,12 +112,12 @@ describe('AcpFileSystemService', () => {
|
|||
describe('readTextFile ENOENT handling', () => {
|
||||
it('converts RESOURCE_NOT_FOUND error to ENOENT', async () => {
|
||||
const resourceNotFoundError = {
|
||||
code: ACP_ERROR_CODES.RESOURCE_NOT_FOUND,
|
||||
code: RESOURCE_NOT_FOUND_CODE,
|
||||
message: 'File not found',
|
||||
};
|
||||
const client = {
|
||||
readTextFile: vi.fn().mockRejectedValue(resourceNotFoundError),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
|
|
@ -133,12 +135,12 @@ describe('AcpFileSystemService', () => {
|
|||
|
||||
it('re-throws other errors unchanged', async () => {
|
||||
const otherError = {
|
||||
code: ACP_ERROR_CODES.INTERNAL_ERROR,
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: 'Internal error',
|
||||
};
|
||||
const client = {
|
||||
readTextFile: vi.fn().mockRejectedValue(otherError),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const svc = new AcpFileSystemService(
|
||||
client,
|
||||
|
|
@ -148,7 +150,7 @@ describe('AcpFileSystemService', () => {
|
|||
);
|
||||
|
||||
await expect(svc.readTextFile('/some/file.txt')).rejects.toMatchObject({
|
||||
code: ACP_ERROR_CODES.INTERNAL_ERROR,
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: 'Internal error',
|
||||
});
|
||||
});
|
||||
|
|
@ -156,7 +158,7 @@ describe('AcpFileSystemService', () => {
|
|||
it('uses fallback when readTextFile capability is disabled', async () => {
|
||||
const client = {
|
||||
readTextFile: vi.fn(),
|
||||
} as unknown as import('../acp.js').Client;
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const fallback = createFallback();
|
||||
(fallback.readTextFile as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
|
|
|
|||
|
|
@ -1,24 +1,26 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
FileSystemService,
|
||||
AgentSideConnection,
|
||||
FileSystemCapability,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import { RequestError } from '@agentclientprotocol/sdk';
|
||||
import type {
|
||||
FileReadResult,
|
||||
FileSystemService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type * as acp from '../acp.js';
|
||||
import { ACP_ERROR_CODES } from '../errorCodes.js';
|
||||
|
||||
/**
|
||||
* ACP client-based implementation of FileSystemService
|
||||
*/
|
||||
const RESOURCE_NOT_FOUND_CODE = -32002;
|
||||
|
||||
export class AcpFileSystemService implements FileSystemService {
|
||||
constructor(
|
||||
private readonly client: acp.Client,
|
||||
private readonly connection: AgentSideConnection,
|
||||
private readonly sessionId: string,
|
||||
private readonly capabilities: acp.FileSystemCapability,
|
||||
private readonly capabilities: FileSystemCapability,
|
||||
private readonly fallback: FileSystemService,
|
||||
) {}
|
||||
|
||||
|
|
@ -29,19 +31,19 @@ export class AcpFileSystemService implements FileSystemService {
|
|||
|
||||
let response: { content: string };
|
||||
try {
|
||||
response = await this.client.readTextFile({
|
||||
response = await this.connection.readTextFile({
|
||||
path: filePath,
|
||||
sessionId: this.sessionId,
|
||||
line: null,
|
||||
limit: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorCode =
|
||||
typeof error === 'object' && error !== null && 'code' in error
|
||||
? (error as { code?: unknown }).code
|
||||
: undefined;
|
||||
error instanceof RequestError
|
||||
? error.code
|
||||
: typeof error === 'object' && error !== null && 'code' in error
|
||||
? (error as { code?: unknown }).code
|
||||
: undefined;
|
||||
|
||||
if (errorCode === ACP_ERROR_CODES.RESOURCE_NOT_FOUND) {
|
||||
if (errorCode === RESOURCE_NOT_FOUND_CODE) {
|
||||
const err = new Error(
|
||||
`File not found: ${filePath}`,
|
||||
) as NodeJS.ErrnoException;
|
||||
|
|
@ -72,10 +74,9 @@ export class AcpFileSystemService implements FileSystemService {
|
|||
return this.fallback.writeTextFile(filePath, content, options);
|
||||
}
|
||||
|
||||
// Prepend BOM character if requested
|
||||
const finalContent = options?.bom ? '\uFEFF' + content : content;
|
||||
|
||||
await this.client.writeTextFile({
|
||||
await this.connection.writeTextFile({
|
||||
path: filePath,
|
||||
content: finalContent,
|
||||
sessionId: this.sessionId,
|
||||
|
|
@ -83,17 +84,13 @@ export class AcpFileSystemService implements FileSystemService {
|
|||
}
|
||||
|
||||
async detectFileBOM(filePath: string): Promise<boolean> {
|
||||
// Try to detect BOM through ACP client first by reading first line
|
||||
if (this.capabilities.readTextFile) {
|
||||
try {
|
||||
const response = await this.client.readTextFile({
|
||||
const response = await this.connection.readTextFile({
|
||||
path: filePath,
|
||||
sessionId: this.sessionId,
|
||||
line: null,
|
||||
limit: 1,
|
||||
});
|
||||
// Check if content starts with BOM character (U+FEFF)
|
||||
// Use codePointAt for better Unicode support and check content length first
|
||||
return (
|
||||
response.content.length > 0 &&
|
||||
response.content.codePointAt(0) === 0xfeff
|
||||
|
|
@ -102,7 +99,6 @@ export class AcpFileSystemService implements FileSystemService {
|
|||
// Fall through to fallback if ACP read fails
|
||||
}
|
||||
}
|
||||
// Fall back to local filesystem detection
|
||||
return this.fallback.detectFileBOM(filePath);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -464,11 +464,11 @@ describe('HistoryReplayer', () => {
|
|||
content: { type: 'text', text: '' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
thoughtsTokens: undefined,
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
totalTokens: 150,
|
||||
cachedTokens: undefined,
|
||||
thoughtTokens: undefined,
|
||||
cachedReadTokens: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import { Session } from './Session.js';
|
|||
import type { Config, GeminiChat } from '@qwen-code/qwen-code-core';
|
||||
import { ApprovalMode, AuthType } from '@qwen-code/qwen-code-core';
|
||||
import * as core from '@qwen-code/qwen-code-core';
|
||||
import type * as acp from '../acp.js';
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
PromptRequest,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js';
|
||||
|
||||
|
|
@ -24,7 +27,7 @@ vi.mock('../../nonInteractiveCliCommands.js', () => ({
|
|||
describe('Session', () => {
|
||||
let mockChat: GeminiChat;
|
||||
let mockConfig: Config;
|
||||
let mockClient: acp.Client;
|
||||
let mockClient: AgentSideConnection;
|
||||
let mockSettings: LoadedSettings;
|
||||
let session: Session;
|
||||
let currentModel: string;
|
||||
|
|
@ -76,8 +79,8 @@ describe('Session', () => {
|
|||
requestPermission: vi.fn().mockResolvedValue({
|
||||
outcome: { outcome: 'selected', optionId: 'proceed_once' },
|
||||
}),
|
||||
sendCustomNotification: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as acp.Client;
|
||||
extNotification: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
mockSettings = {
|
||||
merged: {},
|
||||
|
|
@ -103,20 +106,19 @@ describe('Session', () => {
|
|||
['auto-edit', ApprovalMode.AUTO_EDIT],
|
||||
['yolo', ApprovalMode.YOLO],
|
||||
] as const)('maps %s mode', async (modeId, expected) => {
|
||||
const result = await session.setMode({
|
||||
await session.setMode({
|
||||
sessionId: 'test-session-id',
|
||||
modeId,
|
||||
});
|
||||
|
||||
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(expected);
|
||||
expect(result).toEqual({ modeId });
|
||||
});
|
||||
});
|
||||
|
||||
describe('setModel', () => {
|
||||
it('sets model via config and returns current model', async () => {
|
||||
const requested = `qwen3-coder-plus(${AuthType.USE_OPENAI})`;
|
||||
const result = await session.setModel({
|
||||
await session.setModel({
|
||||
sessionId: 'test-session-id',
|
||||
modelId: ` ${requested} `,
|
||||
});
|
||||
|
|
@ -126,10 +128,6 @@ describe('Session', () => {
|
|||
'qwen3-coder-plus',
|
||||
undefined,
|
||||
);
|
||||
expect(mockConfig.getModel).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
modelId: `qwen3-coder-plus(${AuthType.USE_OPENAI})`,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects empty/whitespace model IDs', async () => {
|
||||
|
|
@ -221,7 +219,7 @@ describe('Session', () => {
|
|||
.fn()
|
||||
.mockResolvedValue((async function* () {})());
|
||||
|
||||
const promptRequest: acp.PromptRequest = {
|
||||
const promptRequest: PromptRequest = {
|
||||
sessionId: 'test-session-id',
|
||||
prompt: [
|
||||
{ type: 'text', text: 'Check this file' },
|
||||
|
|
|
|||
|
|
@ -36,7 +36,25 @@ import {
|
|||
readManyFiles,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
import * as acp from '../acp.js';
|
||||
import { RequestError } from '@agentclientprotocol/sdk';
|
||||
import type {
|
||||
AvailableCommand,
|
||||
ContentBlock,
|
||||
EmbeddedResourceResource,
|
||||
PermissionOption,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
RequestPermissionRequest,
|
||||
RequestPermissionResponse,
|
||||
SessionNotification,
|
||||
SessionUpdate,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
SetSessionModelRequest,
|
||||
SetSessionModelResponse,
|
||||
ToolCallContent,
|
||||
AgentSideConnection,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { z } from 'zod';
|
||||
import { normalizePartList } from '../../utils/nonInteractiveHelpers.js';
|
||||
|
|
@ -45,24 +63,15 @@ import {
|
|||
getAvailableCommands,
|
||||
type NonInteractiveSlashCommandResult,
|
||||
} from '../../nonInteractiveCliCommands.js';
|
||||
import type {
|
||||
AvailableCommand,
|
||||
AvailableCommandsUpdate,
|
||||
SetModeRequest,
|
||||
SetModeResponse,
|
||||
SetModelRequest,
|
||||
SetModelResponse,
|
||||
ApprovalModeValue,
|
||||
CurrentModeUpdate,
|
||||
} from '../schema.js';
|
||||
import { isSlashCommand } from '../../ui/utils/commandUtils.js';
|
||||
import {
|
||||
formatAcpModelId,
|
||||
parseAcpModelOption,
|
||||
} from '../../utils/acpModelUtils.js';
|
||||
import { parseAcpModelOption } from '../../utils/acpModelUtils.js';
|
||||
|
||||
// Import modular session components
|
||||
import type { SessionContext, ToolCallStartParams } from './types.js';
|
||||
import type {
|
||||
ApprovalModeValue,
|
||||
SessionContext,
|
||||
ToolCallStartParams,
|
||||
} from './types.js';
|
||||
import { HistoryReplayer } from './HistoryReplayer.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
import { PlanEmitter } from './emitters/PlanEmitter.js';
|
||||
|
|
@ -96,7 +105,7 @@ export class Session implements SessionContext {
|
|||
id: string,
|
||||
private readonly chat: GeminiChat,
|
||||
readonly config: Config,
|
||||
private readonly client: acp.Client,
|
||||
private readonly client: AgentSideConnection,
|
||||
private readonly settings: LoadedSettings,
|
||||
) {
|
||||
this.sessionId = id;
|
||||
|
|
@ -133,7 +142,7 @@ export class Session implements SessionContext {
|
|||
this.pendingPrompt = null;
|
||||
}
|
||||
|
||||
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
this.pendingPrompt?.abort();
|
||||
const pendingSend = new AbortController();
|
||||
this.pendingPrompt = pendingSend;
|
||||
|
|
@ -254,10 +263,7 @@ export class Session implements SessionContext {
|
|||
}
|
||||
} catch (error) {
|
||||
if (getErrorStatus(error) === 429) {
|
||||
throw new acp.RequestError(
|
||||
429,
|
||||
'Rate limit exceeded. Try again later.',
|
||||
);
|
||||
throw new RequestError(429, 'Rate limit exceeded. Try again later.');
|
||||
}
|
||||
|
||||
throw error;
|
||||
|
|
@ -287,8 +293,8 @@ export class Session implements SessionContext {
|
|||
return { stopReason: 'end_turn' };
|
||||
}
|
||||
|
||||
async sendUpdate(update: acp.SessionUpdate): Promise<void> {
|
||||
const params: acp.SessionNotification = {
|
||||
async sendUpdate(update: SessionUpdate): Promise<void> {
|
||||
const params: SessionNotification = {
|
||||
sessionId: this.sessionId,
|
||||
update,
|
||||
};
|
||||
|
|
@ -314,7 +320,7 @@ export class Session implements SessionContext {
|
|||
}),
|
||||
);
|
||||
|
||||
const update: AvailableCommandsUpdate = {
|
||||
const update: SessionUpdate = {
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands,
|
||||
};
|
||||
|
|
@ -331,8 +337,8 @@ export class Session implements SessionContext {
|
|||
* Used by SubAgentTracker for sub-agent approval requests.
|
||||
*/
|
||||
async requestPermission(
|
||||
params: acp.RequestPermissionRequest,
|
||||
): Promise<acp.RequestPermissionResponse> {
|
||||
params: RequestPermissionRequest,
|
||||
): Promise<RequestPermissionResponse> {
|
||||
return this.client.requestPermission(params);
|
||||
}
|
||||
|
||||
|
|
@ -340,7 +346,9 @@ export class Session implements SessionContext {
|
|||
* Sets the approval mode for the current session.
|
||||
* Maps ACP approval mode values to core ApprovalMode enum.
|
||||
*/
|
||||
async setMode(params: SetModeRequest): Promise<SetModeResponse> {
|
||||
async setMode(
|
||||
params: SetSessionModeRequest,
|
||||
): Promise<SetSessionModeResponse | void> {
|
||||
const modeMap: Record<ApprovalModeValue, ApprovalMode> = {
|
||||
plan: ApprovalMode.PLAN,
|
||||
default: ApprovalMode.DEFAULT,
|
||||
|
|
@ -348,21 +356,21 @@ export class Session implements SessionContext {
|
|||
yolo: ApprovalMode.YOLO,
|
||||
};
|
||||
|
||||
const approvalMode = modeMap[params.modeId];
|
||||
const approvalMode = modeMap[params.modeId as ApprovalModeValue];
|
||||
this.config.setApprovalMode(approvalMode);
|
||||
|
||||
return { modeId: params.modeId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the model for the current session.
|
||||
* Validates the model ID and switches the model via Config.
|
||||
*/
|
||||
async setModel(params: SetModelRequest): Promise<SetModelResponse> {
|
||||
async setModel(
|
||||
params: SetSessionModelRequest,
|
||||
): Promise<SetSessionModelResponse | void> {
|
||||
const rawModelId = params.modelId.trim();
|
||||
|
||||
if (!rawModelId) {
|
||||
throw acp.RequestError.invalidParams('modelId cannot be empty');
|
||||
throw RequestError.invalidParams(undefined, 'modelId cannot be empty');
|
||||
}
|
||||
|
||||
const parsed = parseAcpModelOption(rawModelId);
|
||||
|
|
@ -370,7 +378,8 @@ export class Session implements SessionContext {
|
|||
const selectedAuthType = parsed.authType ?? previousAuthType;
|
||||
|
||||
if (!selectedAuthType) {
|
||||
throw acp.RequestError.invalidParams(
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`authType cannot be determined for modelId "${parsed.modelId}"`,
|
||||
);
|
||||
}
|
||||
|
|
@ -383,14 +392,6 @@ export class Session implements SessionContext {
|
|||
? { requireCachedCredentials: true }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Get updated model info
|
||||
const currentModel = this.config.getModel();
|
||||
const currentAuthType = this.config.getAuthType?.() ?? selectedAuthType;
|
||||
|
||||
return {
|
||||
modelId: formatAcpModelId(currentModel, currentAuthType),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -413,9 +414,9 @@ export class Session implements SessionContext {
|
|||
break;
|
||||
}
|
||||
|
||||
const update: CurrentModeUpdate = {
|
||||
const update: SessionUpdate = {
|
||||
sessionUpdate: 'current_mode_update',
|
||||
modeId: newModeId,
|
||||
currentModeId: newModeId,
|
||||
};
|
||||
|
||||
await this.sendUpdate(update);
|
||||
|
|
@ -543,7 +544,7 @@ export class Session implements SessionContext {
|
|||
}
|
||||
|
||||
if (effectiveConfirmationDetails) {
|
||||
const content: acp.ToolCallContent[] = [];
|
||||
const content: ToolCallContent[] = [];
|
||||
|
||||
if (effectiveConfirmationDetails.type === 'edit') {
|
||||
content.push({
|
||||
|
|
@ -568,7 +569,7 @@ export class Session implements SessionContext {
|
|||
// Map tool kind, using switch_mode for exit_plan_mode per ACP spec
|
||||
const mappedKind = this.toolCallEmitter.mapToolKind(tool.kind, fc.name);
|
||||
|
||||
const params: acp.RequestPermissionRequest = {
|
||||
const params: RequestPermissionRequest = {
|
||||
sessionId: this.sessionId,
|
||||
options: toPermissionOptions(effectiveConfirmationDetails),
|
||||
toolCall: {
|
||||
|
|
@ -582,7 +583,11 @@ export class Session implements SessionContext {
|
|||
},
|
||||
};
|
||||
|
||||
const output = await this.client.requestPermission(params);
|
||||
const output = (await this.client.requestPermission(
|
||||
params,
|
||||
)) as RequestPermissionResponse & {
|
||||
answers?: Record<string, string>;
|
||||
};
|
||||
const outcome =
|
||||
output.outcome.outcome === 'cancelled'
|
||||
? ToolConfirmationOutcome.Cancel
|
||||
|
|
@ -749,7 +754,7 @@ export class Session implements SessionContext {
|
|||
*/
|
||||
async #processSlashCommandResult(
|
||||
result: NonInteractiveSlashCommandResult,
|
||||
originalPrompt: acp.ContentBlock[],
|
||||
originalPrompt: ContentBlock[],
|
||||
): Promise<Part[] | null> {
|
||||
switch (result.type) {
|
||||
case 'submit_prompt':
|
||||
|
|
@ -758,9 +763,7 @@ export class Session implements SessionContext {
|
|||
return normalizePartList(result.content);
|
||||
|
||||
case 'message': {
|
||||
// 'message' type is not ideal for ACP mode, but we handle it for compatibility
|
||||
// by converting it to a stream_messages-like notification
|
||||
await this.client.sendCustomNotification('_qwencode/slash_command', {
|
||||
await this.client.extNotification('_qwencode/slash_command', {
|
||||
sessionId: this.sessionId,
|
||||
command: originalPrompt
|
||||
.filter((block) => block.type === 'text')
|
||||
|
|
@ -787,7 +790,7 @@ export class Session implements SessionContext {
|
|||
|
||||
// Stream all messages to the client
|
||||
for await (const msg of result.messages) {
|
||||
await this.client.sendCustomNotification('_qwencode/slash_command', {
|
||||
await this.client.extNotification('_qwencode/slash_command', {
|
||||
sessionId: this.sessionId,
|
||||
command,
|
||||
messageType: msg.messageType,
|
||||
|
|
@ -829,12 +832,12 @@ export class Session implements SessionContext {
|
|||
}
|
||||
|
||||
async #resolvePrompt(
|
||||
message: acp.ContentBlock[],
|
||||
message: ContentBlock[],
|
||||
abortSignal: AbortSignal,
|
||||
): Promise<Part[]> {
|
||||
const FILE_URI_SCHEME = 'file://';
|
||||
|
||||
const embeddedContext: acp.EmbeddedResourceResource[] = [];
|
||||
const embeddedContext: EmbeddedResourceResource[] = [];
|
||||
|
||||
const parts = message.map((part) => {
|
||||
switch (part.type) {
|
||||
|
|
@ -983,7 +986,7 @@ const basicPermissionOptions = [
|
|||
|
||||
function toPermissionOptions(
|
||||
confirmation: ToolCallConfirmationDetails,
|
||||
): acp.PermissionOption[] {
|
||||
): PermissionOption[] {
|
||||
switch (confirmation.type) {
|
||||
case 'edit':
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
ToolConfirmationOutcome,
|
||||
TodoWriteTool,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type * as acp from '../acp.js';
|
||||
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
// Helper to create a mock SubAgentToolCallEvent with required fields
|
||||
|
|
@ -116,7 +116,7 @@ function createStreamTextEvent(
|
|||
|
||||
describe('SubAgentTracker', () => {
|
||||
let mockContext: SessionContext;
|
||||
let mockClient: acp.Client;
|
||||
let mockClient: AgentSideConnection;
|
||||
let sendUpdateSpy: ReturnType<typeof vi.fn>;
|
||||
let requestPermissionSpy: ReturnType<typeof vi.fn>;
|
||||
let tracker: SubAgentTracker;
|
||||
|
|
@ -143,7 +143,7 @@ describe('SubAgentTracker', () => {
|
|||
|
||||
mockClient = {
|
||||
requestPermission: requestPermissionSpy,
|
||||
} as unknown as acp.Client;
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
tracker = new SubAgentTracker(
|
||||
mockContext,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,12 @@ import { z } from 'zod';
|
|||
import type { SessionContext } from './types.js';
|
||||
import { ToolCallEmitter } from './emitters/ToolCallEmitter.js';
|
||||
import { MessageEmitter } from './emitters/MessageEmitter.js';
|
||||
import type * as acp from '../acp.js';
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
PermissionOption,
|
||||
RequestPermissionRequest,
|
||||
ToolCallContent,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
|
||||
const debugLogger = createDebugLogger('ACP_SUBAGENT_TRACKER');
|
||||
|
||||
|
|
@ -80,7 +85,7 @@ export class SubAgentTracker {
|
|||
|
||||
constructor(
|
||||
private readonly ctx: SessionContext,
|
||||
private readonly client: acp.Client,
|
||||
private readonly client: AgentSideConnection,
|
||||
private readonly parentToolCallId: string,
|
||||
private readonly subagentType: string,
|
||||
) {
|
||||
|
|
@ -214,7 +219,7 @@ export class SubAgentTracker {
|
|||
if (abortSignal.aborted) return;
|
||||
|
||||
const state = this.toolStates.get(event.callId);
|
||||
const content: acp.ToolCallContent[] = [];
|
||||
const content: ToolCallContent[] = [];
|
||||
|
||||
// Handle edit confirmation type - show diff
|
||||
if (event.confirmationDetails.type === 'edit') {
|
||||
|
|
@ -243,7 +248,7 @@ export class SubAgentTracker {
|
|||
const { title, locations, kind } =
|
||||
this.toolCallEmitter.resolveToolMetadata(event.name, state?.args);
|
||||
|
||||
const params: acp.RequestPermissionRequest = {
|
||||
const params: RequestPermissionRequest = {
|
||||
sessionId: this.ctx.sessionId,
|
||||
options: this.toPermissionOptions(fullConfirmationDetails),
|
||||
toolCall: {
|
||||
|
|
@ -324,7 +329,7 @@ export class SubAgentTracker {
|
|||
*/
|
||||
private toPermissionOptions(
|
||||
confirmation: ToolCallConfirmationDetails,
|
||||
): acp.PermissionOption[] {
|
||||
): PermissionOption[] {
|
||||
switch (confirmation.type) {
|
||||
case 'edit':
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import type { SessionContext } from '../types.js';
|
||||
import type * as acp from '../../acp.js';
|
||||
import type { SessionUpdate } from '@agentclientprotocol/sdk';
|
||||
|
||||
/**
|
||||
* Abstract base class for all session event emitters.
|
||||
|
|
@ -32,7 +32,7 @@ export abstract class BaseEmitter {
|
|||
/**
|
||||
* Sends a session update to the ACP client.
|
||||
*/
|
||||
protected async sendUpdate(update: acp.SessionUpdate): Promise<void> {
|
||||
protected async sendUpdate(update: SessionUpdate): Promise<void> {
|
||||
return this.ctx.sendUpdate(update);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -166,11 +166,11 @@ describe('MessageEmitter', () => {
|
|||
content: { type: 'text', text: '' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 100,
|
||||
completionTokens: 50,
|
||||
thoughtsTokens: 25,
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
totalTokens: 175,
|
||||
cachedTokens: 10,
|
||||
thoughtTokens: 25,
|
||||
cachedReadTokens: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -192,11 +192,11 @@ describe('MessageEmitter', () => {
|
|||
content: { type: 'text', text: 'done' },
|
||||
_meta: {
|
||||
usage: {
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
thoughtsTokens: 2,
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalTokens: 17,
|
||||
cachedTokens: 1,
|
||||
thoughtTokens: 2,
|
||||
cachedReadTokens: 1,
|
||||
},
|
||||
durationMs: 1234,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||
import type { Usage } from '../../schema.js';
|
||||
import type { Usage } from '@agentclientprotocol/sdk';
|
||||
import { BaseEmitter } from './BaseEmitter.js';
|
||||
|
||||
/**
|
||||
|
|
@ -80,11 +80,11 @@ export class MessageEmitter extends BaseEmitter {
|
|||
subagentMeta?: import('../types.js').SubagentMeta,
|
||||
): Promise<void> {
|
||||
const usage: Usage = {
|
||||
promptTokens: usageMetadata.promptTokenCount,
|
||||
completionTokens: usageMetadata.candidatesTokenCount,
|
||||
thoughtsTokens: usageMetadata.thoughtsTokenCount,
|
||||
totalTokens: usageMetadata.totalTokenCount,
|
||||
cachedTokens: usageMetadata.cachedContentTokenCount,
|
||||
inputTokens: usageMetadata.promptTokenCount ?? 0,
|
||||
outputTokens: usageMetadata.candidatesTokenCount ?? 0,
|
||||
totalTokens: usageMetadata.totalTokenCount ?? 0,
|
||||
thoughtTokens: usageMetadata.thoughtsTokenCount,
|
||||
cachedReadTokens: usageMetadata.cachedContentTokenCount,
|
||||
};
|
||||
|
||||
const meta =
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import { BaseEmitter } from './BaseEmitter.js';
|
||||
import type { TodoItem } from '../types.js';
|
||||
import type * as acp from '../../acp.js';
|
||||
import type { PlanEntry } from '@agentclientprotocol/sdk';
|
||||
|
||||
/**
|
||||
* Handles emission of plan/todo updates.
|
||||
|
|
@ -22,7 +22,7 @@ export class PlanEmitter extends BaseEmitter {
|
|||
* @param todos - Array of todo items to send as plan entries
|
||||
*/
|
||||
async emitPlan(todos: TodoItem[]): Promise<void> {
|
||||
const entries: acp.PlanEntry[] = todos.map((todo) => ({
|
||||
const entries: PlanEntry[] = todos.map((todo) => ({
|
||||
content: todo.content,
|
||||
priority: 'medium' as const, // Default priority since todos don't have priority
|
||||
status: todo.status,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,11 @@ import type {
|
|||
ResolvedToolMetadata,
|
||||
SubagentMeta,
|
||||
} from '../types.js';
|
||||
import type * as acp from '../../acp.js';
|
||||
import type {
|
||||
ToolCallContent,
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import type { Part } from '@google/genai';
|
||||
import {
|
||||
TodoWriteTool,
|
||||
|
|
@ -103,7 +107,7 @@ export class ToolCallEmitter extends BaseEmitter {
|
|||
}
|
||||
|
||||
// Determine content for the update
|
||||
let contentArray: acp.ToolCallContent[] = [];
|
||||
let contentArray: ToolCallContent[] = [];
|
||||
|
||||
// Special case: diff result from edit tools (format from resultDisplay)
|
||||
const diffContent = this.extractDiffContent(params.resultDisplay);
|
||||
|
|
@ -206,8 +210,8 @@ export class ToolCallEmitter extends BaseEmitter {
|
|||
const tool = toolRegistry.getTool(toolName);
|
||||
|
||||
let title = tool?.displayName ?? toolName;
|
||||
let locations: acp.ToolCallLocation[] = [];
|
||||
let kind: acp.ToolKind = 'other';
|
||||
let locations: ToolCallLocation[] = [];
|
||||
let kind: ToolKind = 'other';
|
||||
|
||||
if (tool && args) {
|
||||
try {
|
||||
|
|
@ -234,13 +238,13 @@ export class ToolCallEmitter extends BaseEmitter {
|
|||
* @param kind - The core Kind enum value
|
||||
* @param toolName - Optional tool name to handle special cases like exit_plan_mode
|
||||
*/
|
||||
mapToolKind(kind: Kind, toolName?: string): acp.ToolKind {
|
||||
mapToolKind(kind: Kind, toolName?: string): ToolKind {
|
||||
// Special case: exit_plan_mode uses 'switch_mode' kind per ACP spec
|
||||
if (toolName && this.isExitPlanModeTool(toolName)) {
|
||||
return 'switch_mode';
|
||||
}
|
||||
|
||||
const kindMap: Record<Kind, acp.ToolKind> = {
|
||||
const kindMap: Record<Kind, ToolKind> = {
|
||||
[Kind.Read]: 'read',
|
||||
[Kind.Edit]: 'edit',
|
||||
[Kind.Delete]: 'delete',
|
||||
|
|
@ -260,9 +264,7 @@ export class ToolCallEmitter extends BaseEmitter {
|
|||
* Extracts diff content from resultDisplay if it's a diff type (edit tool result).
|
||||
* Returns null if not a diff.
|
||||
*/
|
||||
private extractDiffContent(
|
||||
resultDisplay: unknown,
|
||||
): acp.ToolCallContent | null {
|
||||
private extractDiffContent(resultDisplay: unknown): ToolCallContent | null {
|
||||
if (!resultDisplay || typeof resultDisplay !== 'object') return null;
|
||||
|
||||
const obj = resultDisplay as Record<string, unknown>;
|
||||
|
|
@ -284,10 +286,8 @@ export class ToolCallEmitter extends BaseEmitter {
|
|||
* Transforms Part[] to ToolCallContent[].
|
||||
* Extracts text from functionResponse parts and text parts.
|
||||
*/
|
||||
private transformPartsToToolCallContent(
|
||||
parts: Part[],
|
||||
): acp.ToolCallContent[] {
|
||||
const result: acp.ToolCallContent[] = [];
|
||||
private transformPartsToToolCallContent(parts: Part[]): ToolCallContent[] {
|
||||
const result: ToolCallContent[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
// Handle text parts
|
||||
|
|
|
|||
|
|
@ -6,14 +6,20 @@
|
|||
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import type * as acp from '../acp.js';
|
||||
import type {
|
||||
SessionUpdate,
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
|
||||
export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo';
|
||||
|
||||
/**
|
||||
* Interface for sending session updates to the ACP client.
|
||||
* Implemented by Session class and used by all emitters.
|
||||
*/
|
||||
export interface SessionUpdateSender {
|
||||
sendUpdate(update: acp.SessionUpdate): Promise<void>;
|
||||
sendUpdate(update: SessionUpdate): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -91,6 +97,6 @@ export interface TodoItem {
|
|||
*/
|
||||
export interface ResolvedToolMetadata {
|
||||
title: string;
|
||||
locations: acp.ToolCallLocation[];
|
||||
kind: acp.ToolKind;
|
||||
locations: ToolCallLocation[];
|
||||
kind: ToolKind;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1322,7 +1322,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should read excludeMCPServers from settings', async () => {
|
||||
it('should read excludeMCPServers from settings but still return all servers', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
|
|
@ -1330,12 +1330,18 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
|
|||
mcp: { excluded: ['server1', 'server2'] },
|
||||
};
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
// getMcpServers() now returns all servers, use isMcpServerDisabled() to check status
|
||||
expect(config.getMcpServers()).toEqual({
|
||||
server1: { url: 'http://localhost:8080' },
|
||||
server2: { url: 'http://localhost:8081' },
|
||||
server3: { url: 'http://localhost:8082' },
|
||||
});
|
||||
expect(config.isMcpServerDisabled('server1')).toBe(true);
|
||||
expect(config.isMcpServerDisabled('server2')).toBe(true);
|
||||
expect(config.isMcpServerDisabled('server3')).toBe(false);
|
||||
});
|
||||
|
||||
it('should override allowMCPServers with excludeMCPServers if overlapping', async () => {
|
||||
it('should apply allowedMcpServers filter but excluded servers are still returned', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
|
|
@ -1346,9 +1352,14 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
|
|||
},
|
||||
};
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
// allowedMcpServers filters which servers are available
|
||||
// but excluded servers are still returned by getMcpServers()
|
||||
expect(config.getMcpServers()).toEqual({
|
||||
server1: { url: 'http://localhost:8080' },
|
||||
server2: { url: 'http://localhost:8081' },
|
||||
});
|
||||
expect(config.isMcpServerDisabled('server1')).toBe(true);
|
||||
expect(config.isMcpServerDisabled('server2')).toBe(false);
|
||||
});
|
||||
|
||||
it('should prioritize mcp server flag if set', async () => {
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ export default {
|
|||
// ============================================================================
|
||||
'Analyzes the project and creates a tailored QWEN.md file.':
|
||||
'Analysiert das Projekt und erstellt eine maßgeschneiderte QWEN.md-Datei.',
|
||||
'list available Qwen Code tools. Usage: /tools [desc]':
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'Verfügbare Qwen Code Werkzeuge auflisten. Verwendung: /tools [desc]',
|
||||
'Available Qwen Code CLI tools:': 'Verfügbare Qwen Code CLI-Werkzeuge:',
|
||||
'No tools available': 'Keine Werkzeuge verfügbar',
|
||||
|
|
@ -360,7 +360,9 @@ export default {
|
|||
'Show tool-specific usage statistics.':
|
||||
'Werkzeugspezifische Nutzungsstatistiken anzeigen.',
|
||||
'exit the cli': 'CLI beenden',
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'Open MCP management dialog, or authenticate with OAuth-enabled servers':
|
||||
'MCP-Verwaltungsdialog öffnen oder mit OAuth-fähigem Server authentifizieren',
|
||||
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'Konfigurierte MCP-Server und Werkzeuge auflisten oder mit OAuth-fähigen Servern authentifizieren',
|
||||
'Manage workspace directories': 'Arbeitsbereichsverzeichnisse verwalten',
|
||||
'Add directories to the workspace. Use comma to separate multiple paths':
|
||||
|
|
@ -882,9 +884,101 @@ export default {
|
|||
'Do you want to proceed?': 'Möchten Sie fortfahren?',
|
||||
'Yes, allow once': 'Ja, einmal erlauben',
|
||||
'Allow always': 'Immer erlauben',
|
||||
Yes: 'Ja',
|
||||
No: 'Nein',
|
||||
'No (esc)': 'Nein (Esc)',
|
||||
'Yes, allow always for this session': 'Ja, für diese Sitzung immer erlauben',
|
||||
|
||||
// MCP Management Dialog (translations for MCP UI components)
|
||||
'Manage MCP servers': 'MCP-Server verwalten',
|
||||
'Server Detail': 'Serverdetails',
|
||||
'Disable Server': 'Server deaktivieren',
|
||||
Tools: 'Werkzeuge',
|
||||
'Tool Detail': 'Werkzeugdetails',
|
||||
'MCP Management': 'MCP-Verwaltung',
|
||||
'Loading...': 'Lädt...',
|
||||
'Unknown step': 'Unbekannter Schritt',
|
||||
'Esc to back': 'Esc zurück',
|
||||
'↑↓ to navigate · Enter to select · Esc to close':
|
||||
'↑↓ navigieren · Enter auswählen · Esc schließen',
|
||||
'↑↓ to navigate · Enter to select · Esc to back':
|
||||
'↑↓ navigieren · Enter auswählen · Esc zurück',
|
||||
'↑↓ to navigate · Enter to confirm · Esc to back':
|
||||
'↑↓ navigieren · Enter bestätigen · Esc zurück',
|
||||
'User Settings (global)': 'Benutzereinstellungen (global)',
|
||||
'Workspace Settings (project-specific)':
|
||||
'Arbeitsbereichseinstellungen (projektspezifisch)',
|
||||
'Disable server:': 'Server deaktivieren:',
|
||||
'Select where to add the server to the exclude list:':
|
||||
'Wählen Sie, wo der Server zur Ausschlussliste hinzugefügt werden soll:',
|
||||
'Press Enter to confirm, Esc to cancel':
|
||||
'Enter zum Bestätigen, Esc zum Abbrechen',
|
||||
Disable: 'Deaktivieren',
|
||||
Enable: 'Aktivieren',
|
||||
Reconnect: 'Neu verbinden',
|
||||
'View tools': 'Werkzeuge anzeigen',
|
||||
'Status:': 'Status:',
|
||||
'Command:': 'Befehl:',
|
||||
'Working Directory:': 'Arbeitsverzeichnis:',
|
||||
'Capabilities:': 'Fähigkeiten:',
|
||||
'No server selected': 'Kein Server ausgewählt',
|
||||
'(disabled)': '(deaktiviert)',
|
||||
'Error:': 'Fehler:',
|
||||
Extension: 'Erweiterung',
|
||||
tool: 'Werkzeug',
|
||||
tools: 'Werkzeuge',
|
||||
connected: 'verbunden',
|
||||
connecting: 'verbindet',
|
||||
disconnected: 'getrennt',
|
||||
error: 'Fehler',
|
||||
|
||||
// MCP Server List
|
||||
'User MCPs': 'Benutzer-MCPs',
|
||||
'Project MCPs': 'Projekt-MCPs',
|
||||
'Extension MCPs': 'Erweiterungs-MCPs',
|
||||
server: 'Server',
|
||||
servers: 'Server',
|
||||
'Add MCP servers to your settings to get started.':
|
||||
'Fügen Sie MCP-Server zu Ihren Einstellungen hinzu, um zu beginnen.',
|
||||
'Run qwen --debug to see error logs':
|
||||
'Führen Sie qwen --debug aus, um Fehlerprotokolle anzuzeigen',
|
||||
|
||||
// MCP Tool List
|
||||
'No tools available for this server.':
|
||||
'Keine Werkzeuge für diesen Server verfügbar.',
|
||||
destructive: 'destruktiv',
|
||||
'read-only': 'schreibgeschützt',
|
||||
'open-world': 'offene Welt',
|
||||
idempotent: 'idempotent',
|
||||
'Tools for {{name}}': 'Werkzeuge für {{name}}',
|
||||
'{{current}}/{{total}}': '{{current}}/{{total}}',
|
||||
|
||||
// MCP Tool Detail
|
||||
required: 'erforderlich',
|
||||
Type: 'Typ',
|
||||
Enum: 'Aufzählung',
|
||||
Parameters: 'Parameter',
|
||||
'No tool selected': 'Kein Werkzeug ausgewählt',
|
||||
Annotations: 'Anmerkungen',
|
||||
Title: 'Titel',
|
||||
'Read Only': 'Schreibgeschützt',
|
||||
Destructive: 'Destruktiv',
|
||||
Idempotent: 'Idempotent',
|
||||
'Open World': 'Offene Welt',
|
||||
Server: 'Server',
|
||||
|
||||
// Invalid tool related translations
|
||||
'{{count}} invalid tools': '{{count}} ungültige Werkzeuge',
|
||||
invalid: 'ungültig',
|
||||
'invalid: {{reason}}': 'ungültig: {{reason}}',
|
||||
'missing name': 'Name fehlt',
|
||||
'missing description': 'Beschreibung fehlt',
|
||||
'(unnamed)': '(unbenannt)',
|
||||
'Warning: This tool cannot be called by the LLM':
|
||||
'Warnung: Dieses Werkzeug kann nicht vom LLM aufgerufen werden',
|
||||
Reason: 'Grund',
|
||||
'Tools must have both name and description to be used by the LLM.':
|
||||
'Werkzeuge müssen sowohl einen Namen als auch eine Beschreibung haben, um vom LLM verwendet zu werden.',
|
||||
'Modify in progress:': 'Änderung in Bearbeitung:',
|
||||
'Save and close external editor to continue':
|
||||
'Speichern und externen Editor schließen, um fortzufahren',
|
||||
|
|
|
|||
|
|
@ -116,8 +116,8 @@ export default {
|
|||
// ============================================================================
|
||||
'Analyzes the project and creates a tailored QWEN.md file.':
|
||||
'Analyzes the project and creates a tailored QWEN.md file.',
|
||||
'list available Qwen Code tools. Usage: /tools [desc]':
|
||||
'list available Qwen Code tools. Usage: /tools [desc]',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'List available Qwen Code tools. Usage: /tools [desc]',
|
||||
'Available Qwen Code CLI tools:': 'Available Qwen Code CLI tools:',
|
||||
'No tools available': 'No tools available',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -289,6 +289,73 @@ export default {
|
|||
'Failed to save and edit subagent: {{error}}':
|
||||
'Failed to save and edit subagent: {{error}}',
|
||||
|
||||
// ============================================================================
|
||||
// Extensions - Management Dialog
|
||||
// ============================================================================
|
||||
'Manage Extensions': 'Manage Extensions',
|
||||
'Extension Details': 'Extension Details',
|
||||
'View Extension': 'View Extension',
|
||||
'Update Extension': 'Update Extension',
|
||||
'Disable Extension': 'Disable Extension',
|
||||
'Enable Extension': 'Enable Extension',
|
||||
'Uninstall Extension': 'Uninstall Extension',
|
||||
'Select Scope': 'Select Scope',
|
||||
'User Scope': 'User Scope',
|
||||
'Workspace Scope': 'Workspace Scope',
|
||||
'No extensions found.': 'No extensions found.',
|
||||
Active: 'Active',
|
||||
Disabled: 'Disabled',
|
||||
'Update available': 'Update available',
|
||||
'Up to date': 'Up to date',
|
||||
'Checking...': 'Checking...',
|
||||
'Updating...': 'Updating...',
|
||||
Unknown: 'Unknown',
|
||||
Error: 'Error',
|
||||
'Version:': 'Version:',
|
||||
'Status:': 'Status:',
|
||||
'Are you sure you want to uninstall extension "{{name}}"?':
|
||||
'Are you sure you want to uninstall extension "{{name}}"?',
|
||||
'This action cannot be undone.': 'This action cannot be undone.',
|
||||
'Extension "{{name}}" disabled successfully.':
|
||||
'Extension "{{name}}" disabled successfully.',
|
||||
'Extension "{{name}}" enabled successfully.':
|
||||
'Extension "{{name}}" enabled successfully.',
|
||||
'Extension "{{name}}" updated successfully.':
|
||||
'Extension "{{name}}" updated successfully.',
|
||||
'Failed to update extension "{{name}}": {{error}}':
|
||||
'Failed to update extension "{{name}}": {{error}}',
|
||||
'Select the scope for this action:': 'Select the scope for this action:',
|
||||
'User - Applies to all projects': 'User - Applies to all projects',
|
||||
'Workspace - Applies to current project only':
|
||||
'Workspace - Applies to current project only',
|
||||
// Extension dialog - missing keys
|
||||
'Name:': 'Name:',
|
||||
'MCP Servers:': 'MCP Servers:',
|
||||
'Settings:': 'Settings:',
|
||||
active: 'active',
|
||||
disabled: 'disabled',
|
||||
'View Details': 'View Details',
|
||||
'Update failed:': 'Update failed:',
|
||||
'Updating {{name}}...': 'Updating {{name}}...',
|
||||
'Update complete!': 'Update complete!',
|
||||
'User (global)': 'User (global)',
|
||||
'Workspace (project-specific)': 'Workspace (project-specific)',
|
||||
'Disable "{{name}}" - Select Scope': 'Disable "{{name}}" - Select Scope',
|
||||
'Enable "{{name}}" - Select Scope': 'Enable "{{name}}" - Select Scope',
|
||||
'No extension selected': 'No extension selected',
|
||||
'Press Y/Enter to confirm, N/Esc to cancel':
|
||||
'Press Y/Enter to confirm, N/Esc to cancel',
|
||||
'Y/Enter to confirm, N/Esc to cancel': 'Y/Enter to confirm, N/Esc to cancel',
|
||||
'{{count}} extensions installed': '{{count}} extensions installed',
|
||||
"Use '/extensions install' to install your first extension.":
|
||||
"Use '/extensions install' to install your first extension.",
|
||||
// Update status values
|
||||
'up to date': 'up to date',
|
||||
'update available': 'update available',
|
||||
'checking...': 'checking...',
|
||||
'not updatable': 'not updatable',
|
||||
error: 'error',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - General (continued)
|
||||
// ============================================================================
|
||||
|
|
@ -376,8 +443,10 @@ export default {
|
|||
'Show tool-specific usage statistics.':
|
||||
'Show tool-specific usage statistics.',
|
||||
'exit the cli': 'exit the cli',
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers',
|
||||
'Open MCP management dialog, or authenticate with OAuth-enabled servers':
|
||||
'Open MCP management dialog, or authenticate with OAuth-enabled servers',
|
||||
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers',
|
||||
'Manage workspace directories': 'Manage workspace directories',
|
||||
'Add directories to the workspace. Use comma to separate multiple paths':
|
||||
'Add directories to the workspace. Use comma to separate multiple paths',
|
||||
|
|
@ -726,6 +795,7 @@ export default {
|
|||
'List configured MCP servers and tools':
|
||||
'List configured MCP servers and tools',
|
||||
'Restarts MCP servers.': 'Restarts MCP servers.',
|
||||
'Open MCP management dialog': 'Open MCP management dialog',
|
||||
'Config not loaded.': 'Config not loaded.',
|
||||
'Could not retrieve tool registry.': 'Could not retrieve tool registry.',
|
||||
'No MCP servers configured with OAuth authentication.':
|
||||
|
|
@ -742,6 +812,96 @@ export default {
|
|||
"Re-discovering tools from '{{name}}'...":
|
||||
"Re-discovering tools from '{{name}}'...",
|
||||
|
||||
// ============================================================================
|
||||
// MCP Management Dialog
|
||||
// ============================================================================
|
||||
'Manage MCP servers': 'Manage MCP servers',
|
||||
'Server Detail': 'Server Detail',
|
||||
'Disable Server': 'Disable Server',
|
||||
Tools: 'Tools',
|
||||
'Tool Detail': 'Tool Detail',
|
||||
'MCP Management': 'MCP Management',
|
||||
'Loading...': 'Loading...',
|
||||
'Unknown step': 'Unknown step',
|
||||
'Esc to back': 'Esc to back',
|
||||
'↑↓ to navigate · Enter to select · Esc to close':
|
||||
'↑↓ to navigate · Enter to select · Esc to close',
|
||||
'↑↓ to navigate · Enter to select · Esc to back':
|
||||
'↑↓ to navigate · Enter to select · Esc to back',
|
||||
'↑↓ to navigate · Enter to confirm · Esc to back':
|
||||
'↑↓ to navigate · Enter to confirm · Esc to back',
|
||||
'User Settings (global)': 'User Settings (global)',
|
||||
'Workspace Settings (project-specific)':
|
||||
'Workspace Settings (project-specific)',
|
||||
'Disable server:': 'Disable server:',
|
||||
'Select where to add the server to the exclude list:':
|
||||
'Select where to add the server to the exclude list:',
|
||||
'Press Enter to confirm, Esc to cancel':
|
||||
'Press Enter to confirm, Esc to cancel',
|
||||
'View tools': 'View tools',
|
||||
Reconnect: 'Reconnect',
|
||||
Enable: 'Enable',
|
||||
Disable: 'Disable',
|
||||
'Command:': 'Command:',
|
||||
'Working Directory:': 'Working Directory:',
|
||||
'Capabilities:': 'Capabilities:',
|
||||
'No server selected': 'No server selected',
|
||||
prompts: 'prompts',
|
||||
'(disabled)': '(disabled)',
|
||||
'Error:': 'Error:',
|
||||
Extension: 'Extension',
|
||||
tool: 'tool',
|
||||
tools: 'tools',
|
||||
connected: 'connected',
|
||||
connecting: 'connecting',
|
||||
disconnected: 'disconnected',
|
||||
|
||||
// MCP Server List
|
||||
'User MCPs': 'User MCPs',
|
||||
'Project MCPs': 'Project MCPs',
|
||||
'Extension MCPs': 'Extension MCPs',
|
||||
server: 'server',
|
||||
servers: 'servers',
|
||||
'Add MCP servers to your settings to get started.':
|
||||
'Add MCP servers to your settings to get started.',
|
||||
'Run qwen --debug to see error logs': 'Run qwen --debug to see error logs',
|
||||
|
||||
// MCP Tool List
|
||||
'No tools available for this server.': 'No tools available for this server.',
|
||||
destructive: 'destructive',
|
||||
'read-only': 'read-only',
|
||||
'open-world': 'open-world',
|
||||
idempotent: 'idempotent',
|
||||
'Tools for {{name}}': 'Tools for {{name}}',
|
||||
'{{current}}/{{total}}': '{{current}}/{{total}}',
|
||||
|
||||
// MCP Tool Detail
|
||||
required: 'required',
|
||||
Type: 'Type',
|
||||
Enum: 'Enum',
|
||||
Parameters: 'Parameters',
|
||||
'No tool selected': 'No tool selected',
|
||||
Annotations: 'Annotations',
|
||||
Title: 'Title',
|
||||
'Read Only': 'Read Only',
|
||||
Destructive: 'Destructive',
|
||||
Idempotent: 'Idempotent',
|
||||
'Open World': 'Open World',
|
||||
Server: 'Server',
|
||||
|
||||
// Invalid tool related translations
|
||||
'{{count}} invalid tools': '{{count}} invalid tools',
|
||||
invalid: 'invalid',
|
||||
'invalid: {{reason}}': 'invalid: {{reason}}',
|
||||
'missing name': 'missing name',
|
||||
'missing description': 'missing description',
|
||||
'(unnamed)': '(unnamed)',
|
||||
'Warning: This tool cannot be called by the LLM':
|
||||
'Warning: This tool cannot be called by the LLM',
|
||||
Reason: 'Reason',
|
||||
'Tools must have both name and description to be used by the LLM.':
|
||||
'Tools must have both name and description to be used by the LLM.',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Chat
|
||||
// ============================================================================
|
||||
|
|
@ -874,6 +1034,7 @@ export default {
|
|||
'Do you want to proceed?': 'Do you want to proceed?',
|
||||
'Yes, allow once': 'Yes, allow once',
|
||||
'Allow always': 'Allow always',
|
||||
Yes: 'Yes',
|
||||
No: 'No',
|
||||
'No (esc)': 'No (esc)',
|
||||
'Yes, allow always for this session': 'Yes, allow always for this session',
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export default {
|
|||
// ============================================================================
|
||||
'Analyzes the project and creates a tailored QWEN.md file.':
|
||||
'プロジェクトを分析し、カスタマイズされた QWEN.md ファイルを作成',
|
||||
'list available Qwen Code tools. Usage: /tools [desc]':
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'利用可能な Qwen Code ツールを一覧表示。使い方: /tools [desc]',
|
||||
'Available Qwen Code CLI tools:': '利用可能な Qwen Code CLI ツール:',
|
||||
'No tools available': '利用可能なツールはありません',
|
||||
|
|
@ -317,7 +317,9 @@ export default {
|
|||
'セッション統計を確認。使い方: /stats [model|tools]',
|
||||
'Show model-specific usage statistics.': 'モデル別の使用統計を表示',
|
||||
'Show tool-specific usage statistics.': 'ツール別の使用統計を表示',
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'Open MCP management dialog, or authenticate with OAuth-enabled servers':
|
||||
'MCP管理ダイアログを開く、またはOAuth対応サーバーで認証',
|
||||
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'設定済みのMCPサーバーとツールを一覧表示、またはOAuth対応サーバーで認証',
|
||||
'Manage workspace directories': 'ワークスペースディレクトリを管理',
|
||||
'Add directories to the workspace. Use comma to separate multiple paths':
|
||||
|
|
@ -622,9 +624,101 @@ export default {
|
|||
'Do you want to proceed?': '続行しますか?',
|
||||
'Yes, allow once': 'はい(今回のみ許可)',
|
||||
'Allow always': '常に許可する',
|
||||
Yes: 'はい',
|
||||
No: 'いいえ',
|
||||
'No (esc)': 'いいえ (Esc)',
|
||||
'Yes, allow always for this session': 'はい、このセッションで常に許可',
|
||||
|
||||
// MCP Management - Core translations
|
||||
'Manage MCP servers': 'MCPサーバーを管理',
|
||||
'Server Detail': 'サーバー詳細',
|
||||
'Disable Server': 'サーバーを無効化',
|
||||
Tools: 'ツール',
|
||||
'Tool Detail': 'ツール詳細',
|
||||
'MCP Management': 'MCP管理',
|
||||
'Loading...': '読み込み中...',
|
||||
'Unknown step': '不明なステップ',
|
||||
'Esc to back': 'Esc 戻る',
|
||||
'↑↓ to navigate · Enter to select · Esc to close':
|
||||
'↑↓ ナビゲート · Enter 選択 · Esc 閉じる',
|
||||
'↑↓ to navigate · Enter to select · Esc to back':
|
||||
'↑↓ ナビゲート · Enter 選択 · Esc 戻る',
|
||||
'↑↓ to navigate · Enter to confirm · Esc to back':
|
||||
'↑↓ ナビゲート · Enter 確認 · Esc 戻る',
|
||||
'User Settings (global)': 'ユーザー設定(グローバル)',
|
||||
'Workspace Settings (project-specific)':
|
||||
'ワークスペース設定(プロジェクト固有)',
|
||||
'Disable server:': 'サーバーを無効化:',
|
||||
'Select where to add the server to the exclude list:':
|
||||
'サーバーを除外リストに追加する場所を選択してください:',
|
||||
'Press Enter to confirm, Esc to cancel': 'Enter で確認、Esc でキャンセル',
|
||||
Disable: '無効化',
|
||||
Enable: '有効化',
|
||||
Reconnect: '再接続',
|
||||
'View tools': 'ツールを表示',
|
||||
'Status:': 'ステータス:',
|
||||
'Source:': 'ソース:',
|
||||
'Command:': 'コマンド:',
|
||||
'Working Directory:': '作業ディレクトリ:',
|
||||
'Capabilities:': '機能:',
|
||||
'No server selected': 'サーバーが選択されていません',
|
||||
'(disabled)': '(無効)',
|
||||
'Error:': 'エラー:',
|
||||
Extension: '拡張機能',
|
||||
tool: 'ツール',
|
||||
tools: 'ツール',
|
||||
connected: '接続済み',
|
||||
connecting: '接続中',
|
||||
disconnected: '切断済み',
|
||||
error: 'エラー',
|
||||
|
||||
// MCP Server List
|
||||
'User MCPs': 'ユーザーMCP',
|
||||
'Project MCPs': 'プロジェクトMCP',
|
||||
'Extension MCPs': '拡張機能MCP',
|
||||
server: 'サーバー',
|
||||
servers: 'サーバー',
|
||||
'Add MCP servers to your settings to get started.':
|
||||
'設定にMCPサーバーを追加して開始してください。',
|
||||
'Run qwen --debug to see error logs':
|
||||
'qwen --debug を実行してエラーログを確認してください',
|
||||
|
||||
// MCP Tool List
|
||||
'No tools available for this server.':
|
||||
'このサーバーには使用可能なツールがありません。',
|
||||
destructive: '破壊的',
|
||||
'read-only': '読み取り専用',
|
||||
'open-world': 'オープンワールド',
|
||||
idempotent: '冪等',
|
||||
'Tools for {{name}}': '{{name}} のツール',
|
||||
'{{current}}/{{total}}': '{{current}}/{{total}}',
|
||||
|
||||
// MCP Tool Detail
|
||||
required: '必須',
|
||||
Type: '型',
|
||||
Enum: '列挙',
|
||||
Parameters: 'パラメータ',
|
||||
'No tool selected': 'ツールが選択されていません',
|
||||
Annotations: '注釈',
|
||||
Title: 'タイトル',
|
||||
'Read Only': '読み取り専用',
|
||||
Destructive: '破壊的',
|
||||
Idempotent: '冪等',
|
||||
'Open World': 'オープンワールド',
|
||||
Server: 'サーバー',
|
||||
|
||||
// Invalid tool related translations
|
||||
'{{count}} invalid tools': '{{count}} 個の無効なツール',
|
||||
invalid: '無効',
|
||||
'invalid: {{reason}}': '無効: {{reason}}',
|
||||
'missing name': '名前なし',
|
||||
'missing description': '説明なし',
|
||||
'(unnamed)': '(名前なし)',
|
||||
'Warning: This tool cannot be called by the LLM':
|
||||
'警告: このツールはLLMによって呼び出すことができません',
|
||||
Reason: '理由',
|
||||
'Tools must have both name and description to be used by the LLM.':
|
||||
'ツールはLLMによって使用されるには名前と説明の両方が必要です。',
|
||||
'Modify in progress:': '変更中:',
|
||||
'Save and close external editor to continue':
|
||||
'続行するには外部エディタを保存して閉じてください',
|
||||
|
|
|
|||
|
|
@ -109,8 +109,8 @@ export default {
|
|||
// ============================================================================
|
||||
'Analyzes the project and creates a tailored QWEN.md file.':
|
||||
'Analisa o projeto e cria um arquivo QWEN.md personalizado.',
|
||||
'list available Qwen Code tools. Usage: /tools [desc]':
|
||||
'listar ferramentas Qwen Code disponíveis. Uso: /tools [desc]',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'Listar ferramentas Qwen Code disponíveis. Uso: /tools [desc]',
|
||||
'Available Qwen Code CLI tools:': 'Ferramentas CLI do Qwen Code disponíveis:',
|
||||
'No tools available': 'Nenhuma ferramenta disponível',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -385,8 +385,10 @@ export default {
|
|||
'Show tool-specific usage statistics.':
|
||||
'Mostrar estatísticas de uso específicas da ferramenta.',
|
||||
'exit the cli': 'sair da cli',
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'listar servidores e ferramentas MCP configurados, ou autenticar com servidores habilitados para OAuth',
|
||||
'Open MCP management dialog, or authenticate with OAuth-enabled servers':
|
||||
'Abrir diálogo de gerenciamento MCP ou autenticar com servidor habilitado para OAuth',
|
||||
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'Listar servidores e ferramentas MCP configurados, ou autenticar com servidores habilitados para OAuth',
|
||||
'Manage workspace directories': 'Gerenciar diretórios do workspace',
|
||||
'Add directories to the workspace. Use comma to separate multiple paths':
|
||||
'Adicionar diretórios ao workspace. Use vírgula para separar vários caminhos',
|
||||
|
|
@ -888,9 +890,102 @@ export default {
|
|||
'Do you want to proceed?': 'Você deseja prosseguir?',
|
||||
'Yes, allow once': 'Sim, permitir uma vez',
|
||||
'Allow always': 'Permitir sempre',
|
||||
Yes: 'Sim',
|
||||
No: 'Não',
|
||||
'No (esc)': 'Não (esc)',
|
||||
'Yes, allow always for this session': 'Sim, permitir sempre para esta sessão',
|
||||
|
||||
// MCP Management - Core translations
|
||||
'Manage MCP servers': 'Gerenciar servidores MCP',
|
||||
'Server Detail': 'Detalhes do servidor',
|
||||
'Disable Server': 'Desativar servidor',
|
||||
Tools: 'Ferramentas',
|
||||
'Tool Detail': 'Detalhes da ferramenta',
|
||||
'MCP Management': 'Gerenciamento MCP',
|
||||
'Loading...': 'Carregando...',
|
||||
'Unknown step': 'Etapa desconhecida',
|
||||
'Esc to back': 'Esc para voltar',
|
||||
'↑↓ to navigate · Enter to select · Esc to close':
|
||||
'↑↓ navegar · Enter selecionar · Esc fechar',
|
||||
'↑↓ to navigate · Enter to select · Esc to back':
|
||||
'↑↓ navegar · Enter selecionar · Esc voltar',
|
||||
'↑↓ to navigate · Enter to confirm · Esc to back':
|
||||
'↑↓ navegar · Enter confirmar · Esc voltar',
|
||||
'User Settings (global)': 'Configurações do usuário (global)',
|
||||
'Workspace Settings (project-specific)':
|
||||
'Configurações do workspace (específico do projeto)',
|
||||
'Disable server:': 'Desativar servidor:',
|
||||
'Select where to add the server to the exclude list:':
|
||||
'Selecione onde adicionar o servidor à lista de exclusão:',
|
||||
'Press Enter to confirm, Esc to cancel':
|
||||
'Enter para confirmar, Esc para cancelar',
|
||||
Disable: 'Desativar',
|
||||
Enable: 'Ativar',
|
||||
Reconnect: 'Reconectar',
|
||||
'View tools': 'Ver ferramentas',
|
||||
'Status:': 'Status:',
|
||||
'Source:': 'Fonte:',
|
||||
'Command:': 'Comando:',
|
||||
'Working Directory:': 'Diretório de trabalho:',
|
||||
'Capabilities:': 'Capacidades:',
|
||||
'No server selected': 'Nenhum servidor selecionado',
|
||||
'(disabled)': '(desativado)',
|
||||
'Error:': 'Erro:',
|
||||
Extension: 'Extensão',
|
||||
tool: 'ferramenta',
|
||||
tools: 'ferramentas',
|
||||
connected: 'conectado',
|
||||
connecting: 'conectando',
|
||||
disconnected: 'desconectado',
|
||||
error: 'erro',
|
||||
|
||||
// MCP Server List
|
||||
'User MCPs': 'MCPs do usuário',
|
||||
'Project MCPs': 'MCPs do projeto',
|
||||
'Extension MCPs': 'MCPs de extensão',
|
||||
server: 'servidor',
|
||||
servers: 'servidores',
|
||||
'Add MCP servers to your settings to get started.':
|
||||
'Adicione servidores MCP às suas configurações para começar.',
|
||||
'Run qwen --debug to see error logs':
|
||||
'Execute qwen --debug para ver os logs de erro',
|
||||
|
||||
// MCP Tool List
|
||||
'No tools available for this server.':
|
||||
'Nenhuma ferramenta disponível para este servidor.',
|
||||
destructive: 'destrutivo',
|
||||
'read-only': 'somente leitura',
|
||||
'open-world': 'mundo aberto',
|
||||
idempotent: 'idempotente',
|
||||
'Tools for {{name}}': 'Ferramentas para {{name}}',
|
||||
'{{current}}/{{total}}': '{{current}}/{{total}}',
|
||||
|
||||
// MCP Tool Detail
|
||||
required: 'obrigatório',
|
||||
Type: 'Tipo',
|
||||
Enum: 'Enumeração',
|
||||
Parameters: 'Parâmetros',
|
||||
'No tool selected': 'Nenhuma ferramenta selecionada',
|
||||
Annotations: 'Anotações',
|
||||
Title: 'Título',
|
||||
'Read Only': 'Somente leitura',
|
||||
Destructive: 'Destrutivo',
|
||||
Idempotent: 'Idempotente',
|
||||
'Open World': 'Mundo aberto',
|
||||
Server: 'Servidor',
|
||||
|
||||
// Invalid tool related translations
|
||||
'{{count}} invalid tools': '{{count}} ferramentas inválidas',
|
||||
invalid: 'inválido',
|
||||
'invalid: {{reason}}': 'inválido: {{reason}}',
|
||||
'missing name': 'nome ausente',
|
||||
'missing description': 'descrição ausente',
|
||||
'(unnamed)': '(sem nome)',
|
||||
'Warning: This tool cannot be called by the LLM':
|
||||
'Aviso: Esta ferramenta não pode ser chamada pelo LLM',
|
||||
Reason: 'Motivo',
|
||||
'Tools must have both name and description to be used by the LLM.':
|
||||
'As ferramentas devem ter tanto nome quanto descrição para serem usadas pelo LLM.',
|
||||
'Modify in progress:': 'Modificação em progresso:',
|
||||
'Save and close external editor to continue':
|
||||
'Salve e feche o editor externo para continuar',
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ export default {
|
|||
// ============================================================================
|
||||
'Analyzes the project and creates a tailored QWEN.md file.':
|
||||
'Анализ проекта и создание адаптированного файла QWEN.md',
|
||||
'list available Qwen Code tools. Usage: /tools [desc]':
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'Просмотр доступных инструментов Qwen Code. Использование: /tools [desc]',
|
||||
'Available Qwen Code CLI tools:': 'Доступные инструменты Qwen Code CLI:',
|
||||
'No tools available': 'Нет доступных инструментов',
|
||||
|
|
@ -380,7 +380,9 @@ export default {
|
|||
'Show tool-specific usage statistics.':
|
||||
'Показать статистику использования инструментов.',
|
||||
'exit the cli': 'Выход из CLI',
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'Open MCP management dialog, or authenticate with OAuth-enabled servers':
|
||||
'Открыть диалог управления MCP или авторизоваться на сервере с поддержкой OAuth',
|
||||
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'Показать настроенные MCP-серверы и инструменты, или авторизоваться на серверах с поддержкой OAuth',
|
||||
'Manage workspace directories':
|
||||
'Управление директориями рабочего пространства',
|
||||
|
|
@ -889,9 +891,36 @@ export default {
|
|||
'Do you want to proceed?': 'Вы хотите продолжить?',
|
||||
'Yes, allow once': 'Да, разрешить один раз',
|
||||
'Allow always': 'Всегда разрешать',
|
||||
Yes: 'Да',
|
||||
No: 'Нет',
|
||||
'No (esc)': 'Нет (esc)',
|
||||
'Yes, allow always for this session': 'Да, всегда разрешать для этой сессии',
|
||||
|
||||
// MCP Management - Core translations
|
||||
Disable: 'Отключить',
|
||||
Enable: 'Включить',
|
||||
Reconnect: 'Переподключить',
|
||||
'View tools': 'Просмотреть инструменты',
|
||||
'(disabled)': '(отключен)',
|
||||
'Error:': 'Ошибка:',
|
||||
Extension: 'Расширение',
|
||||
tool: 'инструмент',
|
||||
connected: 'подключен',
|
||||
connecting: 'подключение',
|
||||
disconnected: 'отключен',
|
||||
error: 'ошибка',
|
||||
// Invalid tool related translations
|
||||
'{{count}} invalid tools': '{{count}} недействительных инструментов',
|
||||
invalid: 'недействительный',
|
||||
'invalid: {{reason}}': 'недействительно: {{reason}}',
|
||||
'missing name': 'отсутствует имя',
|
||||
'missing description': 'отсутствует описание',
|
||||
'(unnamed)': '(без имени)',
|
||||
'Warning: This tool cannot be called by the LLM':
|
||||
'Предупреждение: Этот инструмент не может быть вызван LLM',
|
||||
Reason: 'Причина',
|
||||
'Tools must have both name and description to be used by the LLM.':
|
||||
'Инструменты должны иметь как имя, так и описание, чтобы использоваться LLM.',
|
||||
'Modify in progress:': 'Идет изменение:',
|
||||
'Save and close external editor to continue':
|
||||
'Сохраните и закройте внешний редактор для продолжения',
|
||||
|
|
@ -1472,6 +1501,75 @@ export default {
|
|||
'Доступны новые конфигурации моделей для {{region}}. Обновить сейчас?',
|
||||
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
|
||||
'Конфигурация {{region}} успешно обновлена. Модель переключена на "{{model}}".',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
|
||||
'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json (резервная копия создана).',
|
||||
|
||||
// ============================================================================
|
||||
// MCP Management Dialog
|
||||
// ============================================================================
|
||||
'MCP Management': 'Управление MCP',
|
||||
'Server List': 'Список серверов',
|
||||
'Server Detail': 'Детали сервера',
|
||||
'Disable Server': 'Отключить сервер',
|
||||
'Tool List': 'Список инструментов',
|
||||
'Tool Detail': 'Детали инструмента',
|
||||
'Loading...': 'Загрузка...',
|
||||
'Unknown step': 'Неизвестный шаг',
|
||||
'Esc to back': 'Esc для возврата',
|
||||
'↑↓ to navigate · Enter to select · Esc to close':
|
||||
'↑↓ навигация · Enter выбрать · Esc закрыть',
|
||||
'↑↓ to navigate · Enter to select · Esc to back':
|
||||
'↑↓ навигация · Enter выбрать · Esc назад',
|
||||
'↑↓ to navigate · Enter to confirm · Esc to back':
|
||||
'↑↓ навигация · Enter подтвердить · Esc назад',
|
||||
'User Settings (global)': 'Настройки пользователя (глобальные)',
|
||||
'Workspace Settings (project-specific)':
|
||||
'Настройки рабочего пространства (проектные)',
|
||||
'Disable server:': 'Отключить сервер:',
|
||||
'Select where to add the server to the exclude list:':
|
||||
'Выберите, где добавить сервер в список исключений:',
|
||||
'Press Enter to confirm, Esc to cancel':
|
||||
'Enter для подтверждения, Esc для отмены',
|
||||
'Status:': 'Статус:',
|
||||
'Command:': 'Команда:',
|
||||
'Working Directory:': 'Рабочий каталог:',
|
||||
'Capabilities:': 'Возможности:',
|
||||
'No server selected': 'Сервер не выбран',
|
||||
|
||||
// MCP Server List
|
||||
'User MCPs': 'MCP пользователя',
|
||||
'Project MCPs': 'MCP проекта',
|
||||
'Extension MCPs': 'MCP расширений',
|
||||
server: 'сервер',
|
||||
servers: 'серверов',
|
||||
'Add MCP servers to your settings to get started.':
|
||||
'Добавьте серверы MCP в настройки, чтобы начать.',
|
||||
'Run qwen --debug to see error logs':
|
||||
'Запустите qwen --debug для просмотра журналов ошибок',
|
||||
|
||||
// MCP Tool List
|
||||
'No tools available for this server.':
|
||||
'Для этого сервера нет доступных инструментов.',
|
||||
destructive: 'деструктивный',
|
||||
'read-only': 'только чтение',
|
||||
'open-world': 'открытый мир',
|
||||
idempotent: 'идемпотентный',
|
||||
'Tools for {{name}}': 'Инструменты для {{name}}',
|
||||
'{{current}}/{{total}}': '{{current}}/{{total}}',
|
||||
|
||||
// MCP Tool Detail
|
||||
required: 'обязательный',
|
||||
Type: 'Тип',
|
||||
Enum: 'Перечисление',
|
||||
Parameters: 'Параметры',
|
||||
'No tool selected': 'Инструмент не выбран',
|
||||
Annotations: 'Аннотации',
|
||||
Title: 'Заголовок',
|
||||
'Read Only': 'Только чтение',
|
||||
Destructive: 'Деструктивный',
|
||||
Idempotent: 'Идемпотентный',
|
||||
'Open World': 'Открытый мир',
|
||||
Server: 'Сервер',
|
||||
'{{region}} configuration updated successfully.':
|
||||
'Конфигурация {{region}} успешно обновлена.',
|
||||
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export default {
|
|||
'!': '!',
|
||||
'!npm run start': '!npm run start',
|
||||
'start server': 'start server',
|
||||
'Commands:': '命令:',
|
||||
'Commands:': '命令:',
|
||||
'shell command': 'shell 命令',
|
||||
'Model Context Protocol command (from external servers)':
|
||||
'模型上下文协议命令(来自外部服务器)',
|
||||
|
|
@ -114,7 +114,7 @@ export default {
|
|||
// ============================================================================
|
||||
'Analyzes the project and creates a tailored QWEN.md file.':
|
||||
'分析项目并创建定制的 QWEN.md 文件',
|
||||
'list available Qwen Code tools. Usage: /tools [desc]':
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'列出可用的 Qwen Code 工具。用法:/tools [desc]',
|
||||
'Available Qwen Code CLI tools:': '可用的 Qwen Code CLI 工具:',
|
||||
'No tools available': '没有可用工具',
|
||||
|
|
@ -278,6 +278,68 @@ export default {
|
|||
'Failed to save and edit subagent: {{error}}':
|
||||
'保存并编辑子智能体失败: {{error}}',
|
||||
|
||||
// ============================================================================
|
||||
// Extensions - Management Dialog
|
||||
// ============================================================================
|
||||
'Manage Extensions': '管理扩展',
|
||||
'Extension Details': '扩展详情',
|
||||
'View Extension': '查看扩展',
|
||||
'Update Extension': '更新扩展',
|
||||
'Disable Extension': '禁用扩展',
|
||||
'Enable Extension': '启用扩展',
|
||||
'Uninstall Extension': '卸载扩展',
|
||||
'Select Scope': '选择作用域',
|
||||
'User Scope': '用户作用域',
|
||||
'Workspace Scope': '工作区作用域',
|
||||
'No extensions found.': '未找到扩展。',
|
||||
Active: '已启用',
|
||||
Disabled: '已禁用',
|
||||
'Update available': '有可用更新',
|
||||
'Up to date': '已是最新',
|
||||
'Checking...': '检查中...',
|
||||
'Updating...': '更新中...',
|
||||
Unknown: '未知',
|
||||
Error: '错误',
|
||||
'Version:': '版本:',
|
||||
'Status:': '状态:',
|
||||
'Are you sure you want to uninstall extension "{{name}}"?':
|
||||
'确定要卸载扩展 "{{name}}" 吗?',
|
||||
'This action cannot be undone.': '此操作无法撤销。',
|
||||
'Extension "{{name}}" disabled successfully.': '扩展 "{{name}}" 禁用成功。',
|
||||
'Extension "{{name}}" enabled successfully.': '扩展 "{{name}}" 启用成功。',
|
||||
'Extension "{{name}}" updated successfully.': '扩展 "{{name}}" 更新成功。',
|
||||
'Failed to update extension "{{name}}": {{error}}':
|
||||
'更新扩展 "{{name}}" 失败:{{error}}',
|
||||
'Select the scope for this action:': '选择此操作的作用域:',
|
||||
'User - Applies to all projects': '用户 - 应用于所有项目',
|
||||
'Workspace - Applies to current project only': '工作区 - 仅应用于当前项目',
|
||||
// Extension dialog - missing keys
|
||||
'Name:': '名称:',
|
||||
'MCP Servers:': 'MCP 服务器:',
|
||||
'Settings:': '设置:',
|
||||
active: '已启用',
|
||||
disabled: '已禁用',
|
||||
'View Details': '查看详情',
|
||||
'Update failed:': '更新失败:',
|
||||
'Updating {{name}}...': '正在更新 {{name}}...',
|
||||
'Update complete!': '更新完成!',
|
||||
'User (global)': '用户(全局)',
|
||||
'Workspace (project-specific)': '工作区(项目特定)',
|
||||
'Disable "{{name}}" - Select Scope': '禁用 "{{name}}" - 选择作用域',
|
||||
'Enable "{{name}}" - Select Scope': '启用 "{{name}}" - 选择作用域',
|
||||
'No extension selected': '未选择扩展',
|
||||
'Press Y/Enter to confirm, N/Esc to cancel': '按 Y/Enter 确认,N/Esc 取消',
|
||||
'Y/Enter to confirm, N/Esc to cancel': 'Y/Enter 确认,N/Esc 取消',
|
||||
'{{count}} extensions installed': '已安装 {{count}} 个扩展',
|
||||
"Use '/extensions install' to install your first extension.":
|
||||
"使用 '/extensions install' 安装您的第一个扩展。",
|
||||
// Update status values
|
||||
'up to date': '已是最新',
|
||||
'update available': '有可用更新',
|
||||
'checking...': '检查中...',
|
||||
'not updatable': '不可更新',
|
||||
error: '错误',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - General (continued)
|
||||
// ============================================================================
|
||||
|
|
@ -361,7 +423,9 @@ export default {
|
|||
'Show model-specific usage statistics.': '显示模型相关的使用统计信息',
|
||||
'Show tool-specific usage statistics.': '显示工具相关的使用统计信息',
|
||||
'exit the cli': '退出命令行界面',
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'Open MCP management dialog, or authenticate with OAuth-enabled servers':
|
||||
'打开 MCP 管理对话框,或在支持 OAuth 的服务器上进行身份验证',
|
||||
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers':
|
||||
'列出已配置的 MCP 服务器和工具,或使用支持 OAuth 的服务器进行身份验证',
|
||||
'Manage workspace directories': '管理工作区目录',
|
||||
'Add directories to the workspace. Use comma to separate multiple paths':
|
||||
|
|
@ -685,6 +749,7 @@ export default {
|
|||
'使用支持 OAuth 的 MCP 服务器进行认证',
|
||||
'List configured MCP servers and tools': '列出已配置的 MCP 服务器和工具',
|
||||
'Restarts MCP servers.': '重启 MCP 服务器',
|
||||
'Open MCP management dialog': '打开 MCP 管理对话框',
|
||||
'Config not loaded.': '配置未加载',
|
||||
'Could not retrieve tool registry.': '无法检索工具注册表',
|
||||
'No MCP servers configured with OAuth authentication.':
|
||||
|
|
@ -700,6 +765,92 @@ export default {
|
|||
"Re-discovering tools from '{{name}}'...":
|
||||
"正在重新发现 '{{name}}' 的工具...",
|
||||
|
||||
// ============================================================================
|
||||
// MCP Management Dialog
|
||||
// ============================================================================
|
||||
'Manage MCP servers': '管理 MCP 服务器',
|
||||
'Server Detail': '服务器详情',
|
||||
'Disable Server': '禁用服务器',
|
||||
Tools: '工具',
|
||||
'Tool Detail': '工具详情',
|
||||
'MCP Management': 'MCP 管理',
|
||||
'Loading...': '加载中...',
|
||||
'Unknown step': '未知步骤',
|
||||
'Esc to back': 'Esc 返回',
|
||||
'↑↓ to navigate · Enter to select · Esc to close':
|
||||
'↑↓ 导航 · Enter 选择 · Esc 关闭',
|
||||
'↑↓ to navigate · Enter to select · Esc to back':
|
||||
'↑↓ 导航 · Enter 选择 · Esc 返回',
|
||||
'↑↓ to navigate · Enter to confirm · Esc to back':
|
||||
'↑↓ 导航 · Enter 确认 · Esc 返回',
|
||||
'User Settings (global)': '用户设置(全局)',
|
||||
'Workspace Settings (project-specific)': '工作区设置(项目级)',
|
||||
'Disable server:': '禁用服务器:',
|
||||
'Select where to add the server to the exclude list:':
|
||||
'选择将服务器添加到排除列表的位置:',
|
||||
'Press Enter to confirm, Esc to cancel': '按 Enter 确认,Esc 取消',
|
||||
'View tools': '查看工具',
|
||||
Reconnect: '重新连接',
|
||||
Enable: '启用',
|
||||
Disable: '禁用',
|
||||
'(disabled)': '(已禁用)',
|
||||
'Error:': '错误:',
|
||||
Extension: '扩展',
|
||||
tool: '工具',
|
||||
tools: '个工具',
|
||||
connected: '已连接',
|
||||
connecting: '连接中',
|
||||
disconnected: '已断开',
|
||||
|
||||
// MCP Server List
|
||||
'User MCPs': '用户 MCP',
|
||||
'Project MCPs': '项目 MCP',
|
||||
'Extension MCPs': '扩展 MCP',
|
||||
server: '个服务器',
|
||||
servers: '个服务器',
|
||||
'Add MCP servers to your settings to get started.':
|
||||
'请在设置中添加 MCP 服务器以开始使用。',
|
||||
'Run qwen --debug to see error logs': '运行 qwen --debug 查看错误日志',
|
||||
|
||||
// MCP Server Detail
|
||||
'Command:': '命令:',
|
||||
'Working Directory:': '工作目录:',
|
||||
'Capabilities:': '功能:',
|
||||
|
||||
// MCP Tool List
|
||||
'No tools available for this server.': '此服务器没有可用工具。',
|
||||
destructive: '破坏性',
|
||||
'read-only': '只读',
|
||||
'open-world': '开放世界',
|
||||
idempotent: '幂等',
|
||||
'Tools for {{name}}': '{{name}} 的工具',
|
||||
'{{current}}/{{total}}': '{{current}}/{{total}}',
|
||||
|
||||
// MCP Tool Detail
|
||||
Type: '类型',
|
||||
Parameters: '参数',
|
||||
'No tool selected': '未选择工具',
|
||||
Annotations: '注解',
|
||||
Title: '标题',
|
||||
'Read Only': '只读',
|
||||
Destructive: '破坏性',
|
||||
Idempotent: '幂等',
|
||||
'Open World': '开放世界',
|
||||
Server: '服务器',
|
||||
|
||||
// Invalid tool related translations
|
||||
'{{count}} invalid tools': '{{count}} 个无效工具',
|
||||
invalid: '无效',
|
||||
'invalid: {{reason}}': '无效:{{reason}}',
|
||||
'missing name': '缺少名称',
|
||||
'missing description': '缺少描述',
|
||||
'(unnamed)': '(未命名)',
|
||||
'Warning: This tool cannot be called by the LLM':
|
||||
'警告:此工具无法被 LLM 调用',
|
||||
Reason: '原因',
|
||||
'Tools must have both name and description to be used by the LLM.':
|
||||
'工具必须同时具有名称和描述才能被 LLM 使用。',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Chat
|
||||
// ============================================================================
|
||||
|
|
@ -825,6 +976,7 @@ export default {
|
|||
'Do you want to proceed?': '是否继续?',
|
||||
'Yes, allow once': '是,允许一次',
|
||||
'Allow always': '总是允许',
|
||||
Yes: '是',
|
||||
No: '否',
|
||||
'No (esc)': '否 (esc)',
|
||||
'Yes, allow always for this session': '是,本次会话总是允许',
|
||||
|
|
|
|||
|
|
@ -102,6 +102,8 @@ import { useDialogClose } from './hooks/useDialogClose.js';
|
|||
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
|
||||
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
|
||||
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
|
||||
import { useExtensionsManagerDialog } from './hooks/useExtensionsManagerDialog.js';
|
||||
import { useMcpDialog } from './hooks/useMcpDialog.js';
|
||||
import { useAttentionNotifications } from './hooks/useAttentionNotifications.js';
|
||||
import {
|
||||
requestConsentInteractive,
|
||||
|
|
@ -493,6 +495,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
openAgentsManagerDialog,
|
||||
closeAgentsManagerDialog,
|
||||
} = useAgentsManagerDialog();
|
||||
const {
|
||||
isExtensionsManagerDialogOpen,
|
||||
openExtensionsManagerDialog,
|
||||
closeExtensionsManagerDialog,
|
||||
} = useExtensionsManagerDialog();
|
||||
const { isMcpDialogOpen, openMcpDialog, closeMcpDialog } = useMcpDialog();
|
||||
|
||||
const slashCommandActions = useMemo(
|
||||
() => ({
|
||||
|
|
@ -515,6 +523,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
addConfirmUpdateExtensionRequest,
|
||||
openSubagentCreateDialog,
|
||||
openAgentsManagerDialog,
|
||||
openExtensionsManagerDialog,
|
||||
openMcpDialog,
|
||||
openResumeDialog,
|
||||
}),
|
||||
[
|
||||
|
|
@ -530,6 +540,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
addConfirmUpdateExtensionRequest,
|
||||
openSubagentCreateDialog,
|
||||
openAgentsManagerDialog,
|
||||
openExtensionsManagerDialog,
|
||||
openMcpDialog,
|
||||
openResumeDialog,
|
||||
],
|
||||
);
|
||||
|
|
@ -1299,8 +1311,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
showIdeRestartPrompt ||
|
||||
isSubagentCreateDialogOpen ||
|
||||
isAgentsManagerDialogOpen ||
|
||||
isMcpDialogOpen ||
|
||||
isApprovalModeDialogOpen ||
|
||||
isResumeDialogOpen;
|
||||
isResumeDialogOpen ||
|
||||
isExtensionsManagerDialogOpen;
|
||||
|
||||
const {
|
||||
isFeedbackDialogOpen,
|
||||
|
|
@ -1410,6 +1424,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen,
|
||||
isAgentsManagerDialogOpen,
|
||||
// Extensions manager dialog
|
||||
isExtensionsManagerDialogOpen,
|
||||
// MCP dialog
|
||||
isMcpDialogOpen,
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen,
|
||||
}),
|
||||
|
|
@ -1500,6 +1518,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen,
|
||||
isAgentsManagerDialogOpen,
|
||||
// Extensions manager dialog
|
||||
isExtensionsManagerDialogOpen,
|
||||
// MCP dialog
|
||||
isMcpDialogOpen,
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen,
|
||||
],
|
||||
|
|
@ -1541,6 +1563,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
// Subagent dialogs
|
||||
closeSubagentCreateDialog,
|
||||
closeAgentsManagerDialog,
|
||||
// Extensions manager dialog
|
||||
closeExtensionsManagerDialog,
|
||||
// MCP dialog
|
||||
closeMcpDialog,
|
||||
// Resume session dialog
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
|
|
@ -1584,6 +1610,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
// Subagent dialogs
|
||||
closeSubagentCreateDialog,
|
||||
closeAgentsManagerDialog,
|
||||
// Extensions manager dialog
|
||||
closeExtensionsManagerDialog,
|
||||
// MCP dialog
|
||||
closeMcpDialog,
|
||||
// Resume session dialog
|
||||
openResumeDialog,
|
||||
closeResumeDialog,
|
||||
|
|
|
|||
|
|
@ -16,9 +16,8 @@ import {
|
|||
beforeEach,
|
||||
type MockedFunction,
|
||||
} from 'vitest';
|
||||
import { ExtensionUpdateState } from '../state/extensions.js';
|
||||
|
||||
import {
|
||||
type Extension,
|
||||
ExtensionManager,
|
||||
parseInstallSource,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -33,24 +32,12 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|||
});
|
||||
|
||||
const mockGetExtensions = vi.fn();
|
||||
const mockUpdateExtension = vi.fn();
|
||||
const mockUpdateAllUpdatableExtensions = vi.fn();
|
||||
const mockCheckForAllExtensionUpdates = vi.fn();
|
||||
const mockInstallExtension = vi.fn();
|
||||
const mockUninstallExtension = vi.fn();
|
||||
const mockGetLoadedExtensions = vi.fn();
|
||||
const mockEnableExtension = vi.fn();
|
||||
const mockDisableExtension = vi.fn();
|
||||
const mockInstallExtension = vi.fn();
|
||||
|
||||
const createMockExtensionManager = () => ({
|
||||
updateExtension: mockUpdateExtension,
|
||||
updateAllUpdatableExtensions: mockUpdateAllUpdatableExtensions,
|
||||
checkForAllExtensionUpdates: mockCheckForAllExtensionUpdates,
|
||||
installExtension: mockInstallExtension,
|
||||
uninstallExtension: mockUninstallExtension,
|
||||
getLoadedExtensions: mockGetLoadedExtensions,
|
||||
enableExtension: mockEnableExtension,
|
||||
disableExtension: mockDisableExtension,
|
||||
});
|
||||
|
||||
describe('extensionsCommand', () => {
|
||||
|
|
@ -62,7 +49,6 @@ describe('extensionsCommand', () => {
|
|||
mockExtensionManager = createMockExtensionManager();
|
||||
mockGetExtensions.mockReturnValue([]);
|
||||
mockGetLoadedExtensions.mockReturnValue([]);
|
||||
mockCheckForAllExtensionUpdates.mockResolvedValue(undefined);
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
|
|
@ -78,334 +64,57 @@ describe('extensionsCommand', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('should add an EXTENSIONS_LIST item to the UI when extensions exist', async () => {
|
||||
describe('default action (manage)', () => {
|
||||
it('should open extensions manager dialog when extensions exist', async () => {
|
||||
if (!extensionsCommand.action) throw new Error('Action not defined');
|
||||
mockGetExtensions.mockReturnValue([{ name: 'test-ext', isActive: true }]);
|
||||
await extensionsCommand.action(mockContext, '');
|
||||
const result = await extensionsCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'extensions_manage',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show info message when no extensions installed', async () => {
|
||||
it('should open extensions manager dialog when no extensions installed', async () => {
|
||||
if (!extensionsCommand.action) throw new Error('Action not defined');
|
||||
mockGetExtensions.mockReturnValue([]);
|
||||
await extensionsCommand.action(mockContext, '');
|
||||
const result = await extensionsCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'No extensions installed.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'extensions_manage',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
const updateAction = extensionsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'update',
|
||||
describe('manage', () => {
|
||||
const manageAction = extensionsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'manage',
|
||||
)?.action;
|
||||
|
||||
if (!updateAction) {
|
||||
throw new Error('Update action not found');
|
||||
if (!manageAction) {
|
||||
throw new Error('Manage action not found');
|
||||
}
|
||||
|
||||
it('should show usage if no args are provided', async () => {
|
||||
await updateAction(mockContext, '');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /extensions update <extension-names>|--all',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
it('should return dialog action for extensions manager', async () => {
|
||||
mockGetExtensions.mockReturnValue([{ name: 'test-ext', isActive: true }]);
|
||||
const result = await manageAction(mockContext, '');
|
||||
|
||||
it('should inform user if there are no extensions to update with --all', async () => {
|
||||
mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]);
|
||||
mockUpdateAllUpdatableExtensions.mockResolvedValue([]);
|
||||
await updateAction(mockContext, '--all');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'No extensions to update.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call setPendingItem and addItem in a finally block on success', async () => {
|
||||
mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]);
|
||||
mockUpdateAllUpdatableExtensions.mockResolvedValue([
|
||||
{
|
||||
name: 'ext-one',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '1.0.1',
|
||||
},
|
||||
{
|
||||
name: 'ext-two',
|
||||
originalVersion: '2.0.0',
|
||||
updatedVersion: '2.0.1',
|
||||
},
|
||||
]);
|
||||
await updateAction(mockContext, '--all');
|
||||
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
});
|
||||
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call setPendingItem and addItem in a finally block on failure', async () => {
|
||||
mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]);
|
||||
mockUpdateAllUpdatableExtensions.mockRejectedValue(
|
||||
new Error('Something went wrong'),
|
||||
);
|
||||
await updateAction(mockContext, '--all');
|
||||
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
});
|
||||
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Something went wrong',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update a single extension by name', async () => {
|
||||
const extension: Extension = {
|
||||
id: 'ext-one',
|
||||
name: 'ext-one',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/ext-one',
|
||||
contextFiles: [],
|
||||
config: { name: 'ext-one', version: '1.0.0' },
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
autoUpdate: false,
|
||||
source: 'https://github.com/some/extension.git',
|
||||
},
|
||||
};
|
||||
mockUpdateExtension.mockResolvedValue({
|
||||
name: extension.name,
|
||||
originalVersion: extension.version,
|
||||
updatedVersion: '1.0.1',
|
||||
});
|
||||
mockGetExtensions.mockReturnValue([extension]);
|
||||
mockContext.ui.extensionsUpdateState.set(extension.name, {
|
||||
status: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
processed: false,
|
||||
});
|
||||
await updateAction(mockContext, 'ext-one');
|
||||
expect(mockUpdateExtension).toHaveBeenCalledWith(
|
||||
extension,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors when updating a single extension', async () => {
|
||||
// Provide at least one extension so we don't get "No extensions installed" message
|
||||
const otherExtension: Extension = {
|
||||
id: 'other-ext',
|
||||
name: 'other-ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/other-ext',
|
||||
contextFiles: [],
|
||||
config: { name: 'other-ext', version: '1.0.0' },
|
||||
};
|
||||
mockGetExtensions.mockReturnValue([otherExtension]);
|
||||
await updateAction(mockContext, 'ext-one');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Extension "ext-one" not found.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update multiple extensions by name', async () => {
|
||||
const extensionOne: Extension = {
|
||||
id: 'ext-one',
|
||||
name: 'ext-one',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/ext-one',
|
||||
contextFiles: [],
|
||||
config: { name: 'ext-one', version: '1.0.0' },
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
autoUpdate: false,
|
||||
source: 'https://github.com/some/extension.git',
|
||||
},
|
||||
};
|
||||
const extensionTwo: Extension = {
|
||||
id: 'ext-two',
|
||||
name: 'ext-two',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/ext-two',
|
||||
contextFiles: [],
|
||||
config: { name: 'ext-two', version: '1.0.0' },
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
autoUpdate: false,
|
||||
source: 'https://github.com/some/extension.git',
|
||||
},
|
||||
};
|
||||
mockGetExtensions.mockReturnValue([extensionOne, extensionTwo]);
|
||||
mockContext.ui.extensionsUpdateState.set(extensionOne.name, {
|
||||
status: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
processed: false,
|
||||
});
|
||||
mockContext.ui.extensionsUpdateState.set(extensionTwo.name, {
|
||||
status: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
processed: false,
|
||||
});
|
||||
mockUpdateExtension
|
||||
.mockResolvedValueOnce({
|
||||
name: 'ext-one',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '1.0.1',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
name: 'ext-two',
|
||||
originalVersion: '2.0.0',
|
||||
updatedVersion: '2.0.1',
|
||||
});
|
||||
await updateAction(mockContext, 'ext-one ext-two');
|
||||
expect(mockUpdateExtension).toHaveBeenCalledTimes(2);
|
||||
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
});
|
||||
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
describe('completion', () => {
|
||||
const updateCompletion = extensionsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'update',
|
||||
)?.completion;
|
||||
|
||||
if (!updateCompletion) {
|
||||
throw new Error('Update completion not found');
|
||||
}
|
||||
|
||||
const extensionOne: Extension = {
|
||||
id: 'ext-one',
|
||||
name: 'ext-one',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/ext-one',
|
||||
contextFiles: [],
|
||||
config: { name: 'ext-one', version: '1.0.0' },
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
autoUpdate: false,
|
||||
source: 'https://github.com/some/extension.git',
|
||||
},
|
||||
};
|
||||
const extensionTwo: Extension = {
|
||||
id: 'another-ext',
|
||||
contextFiles: [],
|
||||
config: { name: 'another-ext', version: '1.0.0' },
|
||||
name: 'another-ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/another-ext',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
autoUpdate: false,
|
||||
source: 'https://github.com/some/extension.git',
|
||||
},
|
||||
};
|
||||
const allExt: Extension = {
|
||||
id: 'all-ext',
|
||||
name: 'all-ext',
|
||||
contextFiles: [],
|
||||
config: { name: 'all-ext', version: '1.0.0' },
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/all-ext',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
autoUpdate: false,
|
||||
source: 'https://github.com/some/extension.git',
|
||||
},
|
||||
};
|
||||
|
||||
it.each([
|
||||
{
|
||||
description: 'should return matching extension names',
|
||||
extensions: [extensionOne, extensionTwo],
|
||||
partialArg: 'ext',
|
||||
expected: ['ext-one'],
|
||||
},
|
||||
{
|
||||
description: 'should return --all when partialArg matches',
|
||||
extensions: [],
|
||||
partialArg: '--al',
|
||||
expected: ['--all'],
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should return both extension names and --all when both match',
|
||||
extensions: [allExt],
|
||||
partialArg: 'all',
|
||||
expected: ['--all', 'all-ext'],
|
||||
},
|
||||
{
|
||||
description: 'should return an empty array if no matches',
|
||||
extensions: [extensionOne],
|
||||
partialArg: 'nomatch',
|
||||
expected: [],
|
||||
},
|
||||
])('$description', async ({ extensions, partialArg, expected }) => {
|
||||
mockGetExtensions.mockReturnValue(extensions);
|
||||
const suggestions = await updateCompletion(mockContext, partialArg);
|
||||
expect(suggestions).toEqual(expected);
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'extensions_manage',
|
||||
});
|
||||
});
|
||||
|
||||
it('should call reloadCommands in finally block', async () => {
|
||||
mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]);
|
||||
mockUpdateAllUpdatableExtensions.mockResolvedValue([
|
||||
{
|
||||
name: 'ext-one',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '1.0.1',
|
||||
},
|
||||
]);
|
||||
await updateAction(mockContext, '--all');
|
||||
expect(mockContext.ui.reloadCommands).toHaveBeenCalled();
|
||||
it('should return dialog action even when no extensions installed', async () => {
|
||||
mockGetExtensions.mockReturnValue([]);
|
||||
const result = await manageAction(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'extensions_manage',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -501,363 +210,4 @@ describe('extensionsCommand', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstall', () => {
|
||||
const uninstallAction = extensionsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'uninstall',
|
||||
)?.action;
|
||||
|
||||
if (!uninstallAction) {
|
||||
throw new Error('Uninstall action not found');
|
||||
}
|
||||
|
||||
let realMockExtensionManager: ExtensionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
realMockExtensionManager = Object.create(ExtensionManager.prototype);
|
||||
realMockExtensionManager.uninstallExtension = mockUninstallExtension;
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getExtensions: mockGetExtensions,
|
||||
getWorkingDir: () => '/test/dir',
|
||||
getExtensionManager: () => realMockExtensionManager,
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should show usage if no name is provided', async () => {
|
||||
await uninstallAction(mockContext, '');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /extensions uninstall <extension-name>',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should uninstall extension successfully', async () => {
|
||||
mockUninstallExtension.mockResolvedValue(undefined);
|
||||
|
||||
await uninstallAction(mockContext, 'test-extension');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Uninstalling extension "test-extension"...',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(mockUninstallExtension).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
false,
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Extension "test-extension" uninstalled successfully.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(mockContext.ui.reloadCommands).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle uninstall errors', async () => {
|
||||
mockUninstallExtension.mockRejectedValue(
|
||||
new Error('Extension not found.'),
|
||||
);
|
||||
|
||||
await uninstallAction(mockContext, 'nonexistent-extension');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed to uninstall extension "nonexistent-extension": Extension not found.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disable', () => {
|
||||
const disableAction = extensionsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'disable',
|
||||
)?.action;
|
||||
|
||||
if (!disableAction) {
|
||||
throw new Error('Disable action not found');
|
||||
}
|
||||
|
||||
let realMockExtensionManager: ExtensionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
realMockExtensionManager = Object.create(ExtensionManager.prototype);
|
||||
realMockExtensionManager.disableExtension = mockDisableExtension;
|
||||
realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions;
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/extensions disable',
|
||||
name: 'disable',
|
||||
args: '',
|
||||
},
|
||||
services: {
|
||||
config: {
|
||||
getExtensions: mockGetExtensions,
|
||||
getWorkingDir: () => '/test/dir',
|
||||
getExtensionManager: () => realMockExtensionManager,
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should show usage if invalid args are provided', async () => {
|
||||
await disableAction(mockContext, '');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /extensions disable <extension> [--scope=<user|workspace>]',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable extension at user scope', async () => {
|
||||
mockDisableExtension.mockResolvedValue(undefined);
|
||||
|
||||
await disableAction(mockContext, 'test-extension --scope=user');
|
||||
|
||||
expect(mockDisableExtension).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
'User',
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Extension "test-extension" disabled for scope "User"',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable extension at workspace scope', async () => {
|
||||
mockDisableExtension.mockResolvedValue(undefined);
|
||||
|
||||
await disableAction(mockContext, 'test-extension --scope workspace');
|
||||
|
||||
expect(mockDisableExtension).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
'Workspace',
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Extension "test-extension" disabled for scope "Workspace"',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error for invalid scope', async () => {
|
||||
await disableAction(mockContext, 'test-extension --scope=invalid');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Unsupported scope "invalid", should be one of "user" or "workspace"',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enable', () => {
|
||||
const enableAction = extensionsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'enable',
|
||||
)?.action;
|
||||
|
||||
if (!enableAction) {
|
||||
throw new Error('Enable action not found');
|
||||
}
|
||||
|
||||
let realMockExtensionManager: ExtensionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
realMockExtensionManager = Object.create(ExtensionManager.prototype);
|
||||
realMockExtensionManager.enableExtension = mockEnableExtension;
|
||||
realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions;
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/extensions enable',
|
||||
name: 'enable',
|
||||
args: '',
|
||||
},
|
||||
services: {
|
||||
config: {
|
||||
getExtensions: mockGetExtensions,
|
||||
getWorkingDir: () => '/test/dir',
|
||||
getExtensionManager: () => realMockExtensionManager,
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should show usage if invalid args are provided', async () => {
|
||||
await enableAction(mockContext, '');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /extensions enable <extension> [--scope=<user|workspace>]',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should enable extension at user scope', async () => {
|
||||
mockEnableExtension.mockResolvedValue(undefined);
|
||||
|
||||
await enableAction(mockContext, 'test-extension --scope=user');
|
||||
|
||||
expect(mockEnableExtension).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
'User',
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Extension "test-extension" enabled for scope "User"',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should enable extension at workspace scope', async () => {
|
||||
mockEnableExtension.mockResolvedValue(undefined);
|
||||
|
||||
await enableAction(mockContext, 'test-extension --scope workspace');
|
||||
|
||||
expect(mockEnableExtension).toHaveBeenCalledWith(
|
||||
'test-extension',
|
||||
'Workspace',
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'Extension "test-extension" enabled for scope "Workspace"',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error for invalid scope', async () => {
|
||||
await enableAction(mockContext, 'test-extension --scope=invalid');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Unsupported scope "invalid", should be one of "user" or "workspace"',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detail', () => {
|
||||
const detailAction = extensionsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'detail',
|
||||
)?.action;
|
||||
|
||||
if (!detailAction) {
|
||||
throw new Error('Detail action not found');
|
||||
}
|
||||
|
||||
let realMockExtensionManager: ExtensionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
realMockExtensionManager = Object.create(ExtensionManager.prototype);
|
||||
realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions;
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
invocation: {
|
||||
raw: '/extensions detail',
|
||||
name: 'detail',
|
||||
args: '',
|
||||
},
|
||||
services: {
|
||||
config: {
|
||||
getExtensions: mockGetExtensions,
|
||||
getWorkingDir: () => '/test/dir',
|
||||
getExtensionManager: () => realMockExtensionManager,
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should show usage if no name is provided', async () => {
|
||||
await detailAction(mockContext, '');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /extensions detail <extension-name>',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error if extension not found', async () => {
|
||||
mockGetExtensions.mockReturnValue([]);
|
||||
await detailAction(mockContext, 'nonexistent-extension');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Extension "nonexistent-extension" not found.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show extension details when found', async () => {
|
||||
const extension: Extension = {
|
||||
id: 'test-ext',
|
||||
name: 'test-ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/test-ext',
|
||||
contextFiles: [],
|
||||
config: { name: 'test-ext', version: '1.0.0' },
|
||||
};
|
||||
mockGetExtensions.mockReturnValue([extension]);
|
||||
realMockExtensionManager.isEnabled = vi.fn().mockReturnValue(true);
|
||||
|
||||
await detailAction(mockContext, 'test-ext');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: expect.stringContaining('test-ext'),
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
*/
|
||||
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { ExtensionUpdateState } from '../state/extensions.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import {
|
||||
type CommandContext,
|
||||
|
|
@ -16,12 +15,9 @@ import { t } from '../../i18n/index.js';
|
|||
import {
|
||||
ExtensionManager,
|
||||
parseInstallSource,
|
||||
type ExtensionUpdateInfo,
|
||||
createDebugLogger,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import open from 'open';
|
||||
import { extensionToOutputString } from '../../commands/extensions/utils.js';
|
||||
|
||||
const debugLogger = createDebugLogger('EXTENSIONS_COMMAND');
|
||||
const EXTENSION_EXPLORE_URL = {
|
||||
|
|
@ -31,23 +27,6 @@ const EXTENSION_EXPLORE_URL = {
|
|||
|
||||
type ExtensionExploreSource = keyof typeof EXTENSION_EXPLORE_URL;
|
||||
|
||||
function showMessageIfNoExtensions(
|
||||
context: CommandContext,
|
||||
extensions: unknown[],
|
||||
): boolean {
|
||||
if (extensions.length === 0) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('No extensions installed.'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function exploreAction(context: CommandContext, args: string) {
|
||||
const source = args.trim();
|
||||
const extensionsUrl = source
|
||||
|
|
@ -113,130 +92,11 @@ async function exploreAction(context: CommandContext, args: string) {
|
|||
}
|
||||
}
|
||||
|
||||
async function listAction(context: CommandContext) {
|
||||
const extensions = context.services.config
|
||||
? context.services.config.getExtensions()
|
||||
: [];
|
||||
|
||||
if (showMessageIfNoExtensions(context, extensions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
async function updateAction(context: CommandContext, args: string) {
|
||||
const updateArgs = args.split(' ').filter((value) => value.length > 0);
|
||||
const all = updateArgs.length === 1 && updateArgs[0] === '--all';
|
||||
const names = all ? undefined : updateArgs;
|
||||
|
||||
if (!all && names?.length === 0) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Usage: /extensions update <extension-names>|--all'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let updateInfos: ExtensionUpdateInfo[] = [];
|
||||
|
||||
const extensionManager = context.services.config!.getExtensionManager();
|
||||
const extensions = context.services.config
|
||||
? context.services.config.getExtensions()
|
||||
: [];
|
||||
|
||||
if (showMessageIfNoExtensions(context, extensions)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
try {
|
||||
context.ui.dispatchExtensionStateUpdate({ type: 'BATCH_CHECK_START' });
|
||||
await extensionManager.checkForAllExtensionUpdates((extensionName, state) =>
|
||||
context.ui.dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extensionName, state },
|
||||
}),
|
||||
);
|
||||
context.ui.dispatchExtensionStateUpdate({ type: 'BATCH_CHECK_END' });
|
||||
|
||||
context.ui.setPendingItem({
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
});
|
||||
if (all) {
|
||||
updateInfos = await extensionManager.updateAllUpdatableExtensions(
|
||||
context.ui.extensionsUpdateState,
|
||||
(extensionName, state) =>
|
||||
context.ui.dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extensionName, state },
|
||||
}),
|
||||
);
|
||||
} else if (names?.length) {
|
||||
const extensions = context.services.config!.getExtensions();
|
||||
for (const name of names) {
|
||||
const extension = extensions.find(
|
||||
(extension) => extension.name === name,
|
||||
);
|
||||
if (!extension) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Extension "{{name}}" not found.', { name }),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const updateInfo = await extensionManager.updateExtension(
|
||||
extension,
|
||||
context.ui.extensionsUpdateState.get(extension.name)?.status ??
|
||||
ExtensionUpdateState.UNKNOWN,
|
||||
(extensionName, state) =>
|
||||
context.ui.dispatchExtensionStateUpdate({
|
||||
type: 'SET_STATE',
|
||||
payload: { name: extensionName, state },
|
||||
}),
|
||||
);
|
||||
if (updateInfo) updateInfos.push(updateInfo);
|
||||
}
|
||||
}
|
||||
|
||||
if (updateInfos.length === 0) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('No extensions to update.'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: getErrorMessage(error),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} finally {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
context.ui.reloadCommands();
|
||||
context.ui.setPendingItem(null);
|
||||
}
|
||||
async function listAction(_context: CommandContext, _args: string) {
|
||||
return {
|
||||
type: 'dialog' as const,
|
||||
dialog: 'extensions_manage' as const,
|
||||
};
|
||||
}
|
||||
|
||||
async function installAction(context: CommandContext, args: string) {
|
||||
|
|
@ -296,235 +156,6 @@ async function installAction(context: CommandContext, args: string) {
|
|||
}
|
||||
}
|
||||
|
||||
async function uninstallAction(context: CommandContext, args: string) {
|
||||
const extensionManager = context.services.config?.getExtensionManager();
|
||||
if (!(extensionManager instanceof ExtensionManager)) {
|
||||
debugLogger.error(
|
||||
`Cannot ${context.invocation?.name} extensions in this environment`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const name = args.trim();
|
||||
if (!name) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Usage: /extensions uninstall <extension-name>'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('Uninstalling extension "{{name}}"...', { name }),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
try {
|
||||
await extensionManager.uninstallExtension(name, false);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('Extension "{{name}}" uninstalled successfully.', { name }),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
context.ui.reloadCommands();
|
||||
} catch (error) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Failed to uninstall extension "{{name}}": {{error}}', {
|
||||
name,
|
||||
error: getErrorMessage(error),
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getEnableDisableContext(
|
||||
context: CommandContext,
|
||||
argumentsString: string,
|
||||
): {
|
||||
extensionManager: ExtensionManager;
|
||||
names: string[];
|
||||
scope: SettingScope;
|
||||
} | null {
|
||||
const extensionManager = context.services.config?.getExtensionManager();
|
||||
if (!(extensionManager instanceof ExtensionManager)) {
|
||||
debugLogger.error(
|
||||
`Cannot ${context.invocation?.name} extensions in this environment`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const parts = argumentsString.split(' ');
|
||||
const name = parts[0];
|
||||
if (
|
||||
name === '' ||
|
||||
!(
|
||||
(parts.length === 2 && parts[1].startsWith('--scope=')) || // --scope=<scope>
|
||||
(parts.length === 3 && parts[1] === '--scope') // --scope <scope>
|
||||
)
|
||||
) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t(
|
||||
'Usage: /extensions {{command}} <extension> [--scope=<user|workspace>]',
|
||||
{
|
||||
command: context.invocation?.name ?? '',
|
||||
},
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
let scope: SettingScope;
|
||||
// Transform `--scope=<scope>` to `--scope <scope>`.
|
||||
if (parts.length === 2) {
|
||||
parts.push(...parts[1].split('='));
|
||||
parts.splice(1, 1);
|
||||
}
|
||||
switch (parts[2].toLowerCase()) {
|
||||
case 'workspace':
|
||||
scope = SettingScope.Workspace;
|
||||
break;
|
||||
case 'user':
|
||||
scope = SettingScope.User;
|
||||
break;
|
||||
default:
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t(
|
||||
'Unsupported scope "{{scope}}", should be one of "user" or "workspace"',
|
||||
{
|
||||
scope: parts[2],
|
||||
},
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
let names: string[] = [];
|
||||
if (name === '--all') {
|
||||
let extensions = extensionManager.getLoadedExtensions();
|
||||
if (context.invocation?.name === 'enable') {
|
||||
extensions = extensions.filter((ext) => !ext.isActive);
|
||||
}
|
||||
if (context.invocation?.name === 'disable') {
|
||||
extensions = extensions.filter((ext) => ext.isActive);
|
||||
}
|
||||
names = extensions.map((ext) => ext.name);
|
||||
} else {
|
||||
names = [name];
|
||||
}
|
||||
|
||||
return {
|
||||
extensionManager,
|
||||
names,
|
||||
scope,
|
||||
};
|
||||
}
|
||||
|
||||
async function disableAction(context: CommandContext, args: string) {
|
||||
const enableContext = getEnableDisableContext(context, args);
|
||||
if (!enableContext) return;
|
||||
|
||||
const { names, scope, extensionManager } = enableContext;
|
||||
for (const name of names) {
|
||||
await extensionManager.disableExtension(name, scope);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('Extension "{{name}}" disabled for scope "{{scope}}"', {
|
||||
name,
|
||||
scope,
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
context.ui.reloadCommands();
|
||||
}
|
||||
}
|
||||
|
||||
async function enableAction(context: CommandContext, args: string) {
|
||||
const enableContext = getEnableDisableContext(context, args);
|
||||
if (!enableContext) return;
|
||||
|
||||
const { names, scope, extensionManager } = enableContext;
|
||||
for (const name of names) {
|
||||
await extensionManager.enableExtension(name, scope);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('Extension "{{name}}" enabled for scope "{{scope}}"', {
|
||||
name,
|
||||
scope,
|
||||
}),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
context.ui.reloadCommands();
|
||||
}
|
||||
}
|
||||
|
||||
async function detailAction(context: CommandContext, args: string) {
|
||||
const extensionManager = context.services.config?.getExtensionManager();
|
||||
if (!(extensionManager instanceof ExtensionManager)) {
|
||||
debugLogger.error(
|
||||
`Cannot ${context.invocation?.name} extensions in this environment`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const name = args.trim();
|
||||
if (!name) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Usage: /extensions detail <extension-name>'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const extensions = context.services.config!.getExtensions();
|
||||
const extension = extensions.find((extension) => extension.name === name);
|
||||
if (!extension) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t('Extension "{{name}}" not found.', { name }),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: extensionToOutputString(
|
||||
extension,
|
||||
extensionManager,
|
||||
process.cwd(),
|
||||
true,
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function completeExtensions(
|
||||
context: CommandContext,
|
||||
partialArg: string,
|
||||
|
|
@ -589,45 +220,15 @@ const exploreExtensionsCommand: SlashCommand = {
|
|||
completion: completeExtensionsExplore,
|
||||
};
|
||||
|
||||
const listExtensionsCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
const manageExtensionsCommand: SlashCommand = {
|
||||
name: 'manage',
|
||||
get description() {
|
||||
return t('List active extensions');
|
||||
return t('Manage installed extensions');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: listAction,
|
||||
};
|
||||
|
||||
const updateExtensionsCommand: SlashCommand = {
|
||||
name: 'update',
|
||||
get description() {
|
||||
return t('Update extensions. Usage: update <extension-names>|--all');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: updateAction,
|
||||
completion: completeExtensions,
|
||||
};
|
||||
|
||||
const disableCommand: SlashCommand = {
|
||||
name: 'disable',
|
||||
get description() {
|
||||
return t('Disable an extension');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: disableAction,
|
||||
completion: completeExtensionsAndScopes,
|
||||
};
|
||||
|
||||
const enableCommand: SlashCommand = {
|
||||
name: 'enable',
|
||||
get description() {
|
||||
return t('Enable an extension');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: enableAction,
|
||||
completion: completeExtensionsAndScopes,
|
||||
};
|
||||
|
||||
const installCommand: SlashCommand = {
|
||||
name: 'install',
|
||||
get description() {
|
||||
|
|
@ -637,26 +238,6 @@ const installCommand: SlashCommand = {
|
|||
action: installAction,
|
||||
};
|
||||
|
||||
const uninstallCommand: SlashCommand = {
|
||||
name: 'uninstall',
|
||||
get description() {
|
||||
return t('Uninstall an extension');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: uninstallAction,
|
||||
completion: completeExtensions,
|
||||
};
|
||||
|
||||
const detailCommand: SlashCommand = {
|
||||
name: 'detail',
|
||||
get description() {
|
||||
return t('Get detail of an extension');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: detailAction,
|
||||
completion: completeExtensions,
|
||||
};
|
||||
|
||||
export const extensionsCommand: SlashCommand = {
|
||||
name: 'extensions',
|
||||
get description() {
|
||||
|
|
@ -664,16 +245,11 @@ export const extensionsCommand: SlashCommand = {
|
|||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
listExtensionsCommand,
|
||||
updateExtensionsCommand,
|
||||
disableCommand,
|
||||
enableCommand,
|
||||
manageExtensionsCommand,
|
||||
installCommand,
|
||||
uninstallCommand,
|
||||
exploreExtensionsCommand,
|
||||
detailCommand,
|
||||
],
|
||||
action: (context, args) =>
|
||||
action: async (context, args) =>
|
||||
// Default to list if no subcommand is provided
|
||||
listExtensionsCommand.action!(context, args),
|
||||
manageExtensionsCommand.action!(context, args),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,13 +12,8 @@ import {
|
|||
MCPDiscoveryState,
|
||||
getMCPServerStatus,
|
||||
getMCPDiscoveryState,
|
||||
DiscoveredMCPTool,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
import type { CallableTool } from '@google/genai';
|
||||
import { Type } from '@google/genai';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
|
|
@ -37,23 +32,6 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
// Helper function to create a mock DiscoveredMCPTool
|
||||
const createMockMCPTool = (
|
||||
name: string,
|
||||
serverName: string,
|
||||
description?: string,
|
||||
) =>
|
||||
new DiscoveredMCPTool(
|
||||
{
|
||||
callTool: vi.fn(),
|
||||
tool: vi.fn(),
|
||||
} as unknown as CallableTool,
|
||||
serverName,
|
||||
name,
|
||||
description || `Description for ${name}`,
|
||||
{ type: Type.OBJECT, properties: {} },
|
||||
);
|
||||
|
||||
describe('mcpCommand', () => {
|
||||
let mockContext: ReturnType<typeof createMockCommandContext>;
|
||||
let mockConfig: {
|
||||
|
|
@ -70,7 +48,7 @@ describe('mcpCommand', () => {
|
|||
// Set up default mock environment
|
||||
vi.unstubAllEnvs();
|
||||
|
||||
// Default mock implementations
|
||||
// Default mock implementations - these are kept for auth subcommand tests
|
||||
vi.mocked(getMCPServerStatus).mockReturnValue(MCPServerStatus.CONNECTED);
|
||||
vi.mocked(getMCPDiscoveryState).mockReturnValue(
|
||||
MCPDiscoveryState.COMPLETED,
|
||||
|
|
@ -98,7 +76,16 @@ describe('mcpCommand', () => {
|
|||
});
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('should show an error if config is not available', async () => {
|
||||
it('should open MCP management dialog by default', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
});
|
||||
});
|
||||
|
||||
it('should open MCP management dialog even if config is not available', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: {
|
||||
config: null,
|
||||
|
|
@ -108,21 +95,19 @@ describe('mcpCommand', () => {
|
|||
const result = await mcpCommand.action!(contextWithoutConfig, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show an error if tool registry is not available', async () => {
|
||||
it('should open MCP management dialog even if tool registry is not available', async () => {
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue(undefined);
|
||||
|
||||
const result = await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not retrieve tool registry.',
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -138,73 +123,31 @@ describe('mcpCommand', () => {
|
|||
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
|
||||
});
|
||||
|
||||
it('should display configured MCP servers with status indicators and their tools', async () => {
|
||||
// Setup getMCPServerStatus mock implementation
|
||||
vi.mocked(getMCPServerStatus).mockImplementation((serverName) => {
|
||||
if (serverName === 'server1') return MCPServerStatus.CONNECTED;
|
||||
if (serverName === 'server2') return MCPServerStatus.CONNECTED;
|
||||
return MCPServerStatus.DISCONNECTED; // server3
|
||||
it('should open MCP management dialog regardless of server configuration', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
});
|
||||
|
||||
// Mock tools from each server using actual DiscoveredMCPTool instances
|
||||
const mockServer1Tools = [
|
||||
createMockMCPTool('server1_tool1', 'server1'),
|
||||
createMockMCPTool('server1_tool2', 'server1'),
|
||||
];
|
||||
const mockServer2Tools = [createMockMCPTool('server2_tool1', 'server2')];
|
||||
const mockServer3Tools = [createMockMCPTool('server3_tool1', 'server3')];
|
||||
|
||||
const allTools = [
|
||||
...mockServer1Tools,
|
||||
...mockServer2Tools,
|
||||
...mockServer3Tools,
|
||||
];
|
||||
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
|
||||
getAllTools: vi.fn().mockReturnValue(allTools),
|
||||
});
|
||||
|
||||
await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.MCP_STATUS,
|
||||
tools: allTools.map((tool) => ({
|
||||
serverName: tool.serverName,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
schema: tool.schema,
|
||||
})),
|
||||
showTips: true,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display tool descriptions when desc argument is used', async () => {
|
||||
await mcpCommand.action!(mockContext, 'desc');
|
||||
it('should open MCP management dialog with desc argument', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, 'desc');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.MCP_STATUS,
|
||||
showDescriptions: true,
|
||||
showTips: false,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not display descriptions when nodesc argument is used', async () => {
|
||||
await mcpCommand.action!(mockContext, 'nodesc');
|
||||
it('should open MCP management dialog with nodesc argument', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, 'nodesc');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.MCP_STATUS,
|
||||
showDescriptions: false,
|
||||
showTips: false,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,24 +6,17 @@
|
|||
|
||||
import type {
|
||||
SlashCommand,
|
||||
SlashCommandActionReturn,
|
||||
CommandContext,
|
||||
MessageActionReturn,
|
||||
OpenDialogActionReturn,
|
||||
} from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import type { DiscoveredMCPPrompt } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
DiscoveredMCPTool,
|
||||
getMCPDiscoveryState,
|
||||
getMCPServerStatus,
|
||||
MCPDiscoveryState,
|
||||
MCPServerStatus,
|
||||
getErrorMessage,
|
||||
MCPOAuthTokenStorage,
|
||||
MCPOAuthProvider,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
const authCommand: SlashCommand = {
|
||||
|
|
@ -189,183 +182,30 @@ const authCommand: SlashCommand = {
|
|||
},
|
||||
};
|
||||
|
||||
const listCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
const manageCommand: SlashCommand = {
|
||||
name: 'manage',
|
||||
get description() {
|
||||
return t('List configured MCP servers and tools');
|
||||
return t('Open MCP management dialog');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void | MessageActionReturn> => {
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Config not loaded.'),
|
||||
};
|
||||
}
|
||||
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
if (!toolRegistry) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Could not retrieve tool registry.'),
|
||||
};
|
||||
}
|
||||
|
||||
const lowerCaseArgs = args.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
|
||||
const hasDesc =
|
||||
lowerCaseArgs.includes('desc') || lowerCaseArgs.includes('descriptions');
|
||||
const hasNodesc =
|
||||
lowerCaseArgs.includes('nodesc') ||
|
||||
lowerCaseArgs.includes('nodescriptions');
|
||||
const showSchema = lowerCaseArgs.includes('schema');
|
||||
|
||||
const showDescriptions = !hasNodesc && (hasDesc || showSchema);
|
||||
const showTips = lowerCaseArgs.length === 0;
|
||||
|
||||
const mcpServers = config.getMcpServers() || {};
|
||||
const serverNames = Object.keys(mcpServers);
|
||||
const blockedMcpServers = config.getBlockedMcpServers() || [];
|
||||
|
||||
const connectingServers = serverNames.filter(
|
||||
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
|
||||
);
|
||||
const discoveryState = getMCPDiscoveryState();
|
||||
const discoveryInProgress =
|
||||
discoveryState === MCPDiscoveryState.IN_PROGRESS ||
|
||||
connectingServers.length > 0;
|
||||
|
||||
const allTools = toolRegistry.getAllTools();
|
||||
const mcpTools = allTools.filter(
|
||||
(tool) => tool instanceof DiscoveredMCPTool,
|
||||
) as DiscoveredMCPTool[];
|
||||
|
||||
const promptRegistry = await config.getPromptRegistry();
|
||||
const mcpPrompts = promptRegistry
|
||||
.getAllPrompts()
|
||||
.filter(
|
||||
(prompt) =>
|
||||
'serverName' in prompt &&
|
||||
serverNames.includes(prompt.serverName as string),
|
||||
) as DiscoveredMCPPrompt[];
|
||||
|
||||
const authStatus: HistoryItemMcpStatus['authStatus'] = {};
|
||||
const tokenStorage = new MCPOAuthTokenStorage();
|
||||
for (const serverName of serverNames) {
|
||||
const server = mcpServers[serverName];
|
||||
if (server.oauth?.enabled) {
|
||||
const creds = await tokenStorage.getCredentials(serverName);
|
||||
if (creds) {
|
||||
if (creds.token.expiresAt && creds.token.expiresAt < Date.now()) {
|
||||
authStatus[serverName] = 'expired';
|
||||
} else {
|
||||
authStatus[serverName] = 'authenticated';
|
||||
}
|
||||
} else {
|
||||
authStatus[serverName] = 'unauthenticated';
|
||||
}
|
||||
} else {
|
||||
authStatus[serverName] = 'not-configured';
|
||||
}
|
||||
}
|
||||
|
||||
const mcpStatusItem: HistoryItemMcpStatus = {
|
||||
type: MessageType.MCP_STATUS,
|
||||
servers: mcpServers,
|
||||
tools: mcpTools.map((tool) => ({
|
||||
serverName: tool.serverName,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
schema: tool.schema,
|
||||
})),
|
||||
prompts: mcpPrompts.map((prompt) => ({
|
||||
serverName: prompt.serverName as string,
|
||||
name: prompt.name,
|
||||
description: prompt.description,
|
||||
})),
|
||||
authStatus,
|
||||
blockedServers: blockedMcpServers,
|
||||
discoveryInProgress,
|
||||
connectingServers,
|
||||
showDescriptions,
|
||||
showSchema,
|
||||
showTips,
|
||||
};
|
||||
|
||||
context.ui.addItem(mcpStatusItem, Date.now());
|
||||
},
|
||||
};
|
||||
|
||||
const refreshCommand: SlashCommand = {
|
||||
name: 'refresh',
|
||||
get description() {
|
||||
return t('Restarts MCP servers.');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
): Promise<void | SlashCommandActionReturn> => {
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Config not loaded.'),
|
||||
};
|
||||
}
|
||||
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
if (!toolRegistry) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: t('Could not retrieve tool registry.'),
|
||||
};
|
||||
}
|
||||
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: t('Restarting MCP servers...'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
await toolRegistry.restartMcpServers();
|
||||
|
||||
// Update the client with the new tools
|
||||
const geminiClient = config.getGeminiClient();
|
||||
if (geminiClient) {
|
||||
await geminiClient.setTools();
|
||||
}
|
||||
|
||||
// Reload the slash commands to reflect the changes.
|
||||
context.ui.reloadCommands();
|
||||
|
||||
return listCommand.action!(context, '');
|
||||
},
|
||||
action: async (): Promise<OpenDialogActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
}),
|
||||
};
|
||||
|
||||
export const mcpCommand: SlashCommand = {
|
||||
name: 'mcp',
|
||||
get description() {
|
||||
return t(
|
||||
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers',
|
||||
'Open MCP management dialog, or authenticate with OAuth-enabled servers',
|
||||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [listCommand, authCommand, refreshCommand],
|
||||
// Default action when no subcommand is provided
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void | SlashCommandActionReturn> =>
|
||||
// If no subcommand, run the list command
|
||||
listCommand.action!(context, args),
|
||||
subCommands: [manageCommand, authCommand],
|
||||
// Default action when no subcommand is provided - open dialog
|
||||
action: async (): Promise<OpenDialogActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -148,7 +148,9 @@ export interface OpenDialogActionReturn {
|
|||
| 'subagent_list'
|
||||
| 'permissions'
|
||||
| 'approval-mode'
|
||||
| 'resume';
|
||||
| 'resume'
|
||||
| 'extensions_manage'
|
||||
| 'mcp';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
|
|||
import { WelcomeBackDialog } from './WelcomeBackDialog.js';
|
||||
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
|
||||
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
|
||||
import { ExtensionsManagerDialog } from './extensions/ExtensionsManagerDialog.js';
|
||||
import { MCPManagementDialog } from './mcp/MCPManagementDialog.js';
|
||||
import { SessionPicker } from './SessionPicker.js';
|
||||
|
||||
interface DialogManagerProps {
|
||||
|
|
@ -292,6 +294,18 @@ export const DialogManager = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (uiState.isExtensionsManagerDialogOpen) {
|
||||
return (
|
||||
<ExtensionsManagerDialog
|
||||
onClose={uiActions.closeExtensionsManagerDialog}
|
||||
config={config}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.isMcpDialogOpen) {
|
||||
return <MCPManagementDialog onClose={uiActions.closeMcpDialog} />;
|
||||
}
|
||||
|
||||
if (uiState.isResumeDialogOpen) {
|
||||
return (
|
||||
<SessionPicker
|
||||
|
|
|
|||
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ExtensionsManagerDialog } from './ExtensionsManagerDialog.js';
|
||||
import { UIStateContext } from '../../contexts/UIStateContext.js';
|
||||
import { KeypressProvider } from '../../contexts/KeypressContext.js';
|
||||
import type { UIState } from '../../contexts/UIStateContext.js';
|
||||
import type { Config, Extension } from '@qwen-code/qwen-code-core';
|
||||
import { ExtensionUpdateState } from '../../state/extensions.js';
|
||||
|
||||
const createMockExtension = (
|
||||
name: string,
|
||||
isActive = true,
|
||||
version = '1.0.0',
|
||||
): Extension =>
|
||||
({
|
||||
id: name,
|
||||
name,
|
||||
version,
|
||||
path: `/home/user/.qwen/extensions/${name}`,
|
||||
isActive,
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: `github:user/${name}`,
|
||||
},
|
||||
mcpServers: {},
|
||||
commands: [],
|
||||
skills: [],
|
||||
agents: [],
|
||||
resolvedSettings: [],
|
||||
config: {},
|
||||
contextFiles: [],
|
||||
}) as unknown as Extension;
|
||||
|
||||
const createMockConfig = (extensions: Extension[] = []): Config =>
|
||||
({
|
||||
getExtensions: () => extensions,
|
||||
getExtensionManager: () => ({
|
||||
getLoadedExtensions: () => extensions,
|
||||
refreshCache: vi.fn().mockResolvedValue(undefined),
|
||||
checkForAllExtensionUpdates: vi.fn().mockResolvedValue(undefined),
|
||||
disableExtension: vi.fn().mockResolvedValue(undefined),
|
||||
enableExtension: vi.fn().mockResolvedValue(undefined),
|
||||
uninstallExtension: vi.fn().mockResolvedValue(undefined),
|
||||
updateExtension: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
getLoadedExtensions: () => extensions,
|
||||
}) as unknown as Config;
|
||||
|
||||
const createMockUIState = (
|
||||
extensionsUpdateState = new Map<string, ExtensionUpdateState>(),
|
||||
): UIState =>
|
||||
({
|
||||
extensionsUpdateState,
|
||||
}) as unknown as UIState;
|
||||
|
||||
describe('ExtensionsManagerDialog Snapshots', () => {
|
||||
const baseProps = {
|
||||
onClose: vi.fn(),
|
||||
config: createMockConfig(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should render empty state when no extensions installed', () => {
|
||||
const uiState = createMockUIState();
|
||||
const { lastFrame } = render(
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ExtensionsManagerDialog {...baseProps} />
|
||||
</KeypressProvider>
|
||||
</UIStateContext.Provider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render extension list with extensions', () => {
|
||||
const extensions = [
|
||||
createMockExtension('test-extension', true),
|
||||
createMockExtension('another-extension', false),
|
||||
];
|
||||
const uiState = createMockUIState(
|
||||
new Map([
|
||||
['test-extension', ExtensionUpdateState.UP_TO_DATE],
|
||||
['another-extension', ExtensionUpdateState.UPDATE_AVAILABLE],
|
||||
]),
|
||||
);
|
||||
const { lastFrame } = render(
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ExtensionsManagerDialog
|
||||
{...baseProps}
|
||||
config={createMockConfig(extensions)}
|
||||
/>
|
||||
</KeypressProvider>
|
||||
</UIStateContext.Provider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render with update available status', () => {
|
||||
const extensions = [createMockExtension('outdated-extension', true)];
|
||||
const uiState = createMockUIState(
|
||||
new Map([['outdated-extension', ExtensionUpdateState.UPDATE_AVAILABLE]]),
|
||||
);
|
||||
const { lastFrame } = render(
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ExtensionsManagerDialog
|
||||
{...baseProps}
|
||||
config={createMockConfig(extensions)}
|
||||
/>
|
||||
</KeypressProvider>
|
||||
</UIStateContext.Provider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render with checking status', () => {
|
||||
const extensions = [createMockExtension('checking-extension', true)];
|
||||
const uiState = createMockUIState(
|
||||
new Map([
|
||||
['checking-extension', ExtensionUpdateState.CHECKING_FOR_UPDATES],
|
||||
]),
|
||||
);
|
||||
const { lastFrame } = render(
|
||||
<UIStateContext.Provider value={uiState}>
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ExtensionsManagerDialog
|
||||
{...baseProps}
|
||||
config={createMockConfig(extensions)}
|
||||
/>
|
||||
</KeypressProvider>
|
||||
</UIStateContext.Provider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,526 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
ExtensionListStep,
|
||||
ExtensionDetailStep,
|
||||
ActionSelectionStep,
|
||||
UninstallConfirmStep,
|
||||
ScopeSelectStep,
|
||||
} from './steps/index.js';
|
||||
import { MANAGEMENT_STEPS, type ExtensionAction } from './types.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import type { Extension, Config } from '@qwen-code/qwen-code-core';
|
||||
import { SettingScope, createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import { ExtensionUpdateState } from '../../state/extensions.js';
|
||||
import { getErrorMessage } from '../../../utils/errors.js';
|
||||
|
||||
interface ExtensionsManagerDialogProps {
|
||||
onClose: () => void;
|
||||
config: Config | null;
|
||||
}
|
||||
|
||||
const debugLogger = createDebugLogger('EXTENSIONS_MANAGER_DIALOG');
|
||||
|
||||
export function ExtensionsManagerDialog({
|
||||
onClose,
|
||||
config,
|
||||
}: ExtensionsManagerDialogProps) {
|
||||
const { extensionsUpdateState } = useUIState();
|
||||
|
||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||
const [selectedExtensionIndex, setSelectedExtensionIndex] =
|
||||
useState<number>(-1);
|
||||
const [navigationStack, setNavigationStack] = useState<string[]>([
|
||||
MANAGEMENT_STEPS.EXTENSION_LIST,
|
||||
]);
|
||||
const [updateInProgress, setUpdateInProgress] = useState(false);
|
||||
const [updateError, setUpdateError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Load extensions
|
||||
const loadExtensions = useCallback(async () => {
|
||||
if (!config) return;
|
||||
|
||||
const extensionManager = config.getExtensionManager();
|
||||
if (!extensionManager) {
|
||||
debugLogger.error('ExtensionManager not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await extensionManager.refreshCache();
|
||||
const loadedExtensions = extensionManager.getLoadedExtensions();
|
||||
setExtensions(loadedExtensions);
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to load extensions:', error);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadExtensions();
|
||||
}, [loadExtensions]);
|
||||
|
||||
// Memoized selected extension
|
||||
const selectedExtension = useMemo(
|
||||
() =>
|
||||
selectedExtensionIndex >= 0 ? extensions[selectedExtensionIndex] : null,
|
||||
[extensions, selectedExtensionIndex],
|
||||
);
|
||||
|
||||
// Check if update is available for selected extension
|
||||
const hasUpdateAvailable = useMemo(() => {
|
||||
if (!selectedExtension) return false;
|
||||
const state = extensionsUpdateState.get(selectedExtension.name);
|
||||
return state === ExtensionUpdateState.UPDATE_AVAILABLE;
|
||||
}, [selectedExtension, extensionsUpdateState]);
|
||||
|
||||
// Helper to get current step
|
||||
const getCurrentStep = useCallback(
|
||||
() =>
|
||||
navigationStack[navigationStack.length - 1] ||
|
||||
MANAGEMENT_STEPS.EXTENSION_LIST,
|
||||
[navigationStack],
|
||||
);
|
||||
|
||||
const handleSelectExtension = useCallback((extensionIndex: number) => {
|
||||
setSelectedExtensionIndex(extensionIndex);
|
||||
setSuccessMessage(null); // Clear success message when navigating
|
||||
setErrorMessage(null); // Clear error message when navigating
|
||||
setNavigationStack((prev) => [...prev, MANAGEMENT_STEPS.ACTION_SELECTION]);
|
||||
}, []);
|
||||
|
||||
const handleNavigateToStep = useCallback((step: string) => {
|
||||
setNavigationStack((prev) => [...prev, step]);
|
||||
}, []);
|
||||
|
||||
const handleNavigateBack = useCallback(() => {
|
||||
setNavigationStack((prev) => {
|
||||
if (prev.length <= 1) {
|
||||
return prev;
|
||||
}
|
||||
return prev.slice(0, -1);
|
||||
});
|
||||
// Clear messages when navigating back
|
||||
setErrorMessage(null);
|
||||
}, []);
|
||||
|
||||
const handleUpdateExtension = useCallback(async () => {
|
||||
if (!config || !selectedExtension) return;
|
||||
|
||||
setUpdateInProgress(true);
|
||||
setUpdateError(null);
|
||||
|
||||
try {
|
||||
const extensionManager = config.getExtensionManager();
|
||||
if (!extensionManager) {
|
||||
throw new Error('ExtensionManager not available');
|
||||
}
|
||||
|
||||
const state = extensionsUpdateState.get(selectedExtension.name);
|
||||
if (state !== ExtensionUpdateState.UPDATE_AVAILABLE) {
|
||||
throw new Error('No update available');
|
||||
}
|
||||
|
||||
// Use the extension manager to update
|
||||
await extensionManager.updateExtension(
|
||||
selectedExtension,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
(name, newState) => {
|
||||
debugLogger.debug(`Update state for ${name}:`, newState);
|
||||
},
|
||||
);
|
||||
|
||||
// Reload extensions after update to get new version info
|
||||
await loadExtensions();
|
||||
|
||||
// Trigger a re-check of update status for all extensions
|
||||
await extensionManager.checkForAllExtensionUpdates((name, newState) => {
|
||||
debugLogger.debug(`Recheck update state for ${name}:`, newState);
|
||||
});
|
||||
|
||||
// Show success message
|
||||
setSuccessMessage(
|
||||
t('Extension "{{name}}" updated successfully.', {
|
||||
name: selectedExtension.name,
|
||||
}),
|
||||
);
|
||||
|
||||
// Go back to action selection
|
||||
handleNavigateBack();
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to update extension:', error);
|
||||
setUpdateError(
|
||||
error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
);
|
||||
} finally {
|
||||
setUpdateInProgress(false);
|
||||
}
|
||||
}, [
|
||||
config,
|
||||
selectedExtension,
|
||||
extensionsUpdateState,
|
||||
loadExtensions,
|
||||
handleNavigateBack,
|
||||
]);
|
||||
|
||||
const handleActionSelect = useCallback(
|
||||
(action: ExtensionAction) => {
|
||||
switch (action) {
|
||||
case 'view':
|
||||
handleNavigateToStep(MANAGEMENT_STEPS.EXTENSION_DETAIL);
|
||||
break;
|
||||
case 'update':
|
||||
handleNavigateToStep(MANAGEMENT_STEPS.UPDATE_PROGRESS);
|
||||
handleUpdateExtension();
|
||||
break;
|
||||
case 'disable':
|
||||
handleNavigateToStep(MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT);
|
||||
break;
|
||||
case 'enable':
|
||||
handleNavigateToStep(MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT);
|
||||
break;
|
||||
case 'uninstall':
|
||||
handleNavigateToStep(MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleNavigateToStep, handleUpdateExtension],
|
||||
);
|
||||
|
||||
// Unified handler for toggling extension state (enable/disable)
|
||||
const handleToggleExtensionState = useCallback(
|
||||
async (scope: 'user' | 'workspace', newState: boolean) => {
|
||||
if (!config || !selectedExtension) return;
|
||||
|
||||
try {
|
||||
const extensionManager = config.getExtensionManager();
|
||||
if (!extensionManager) {
|
||||
throw new Error('ExtensionManager not available');
|
||||
}
|
||||
|
||||
const settingScope =
|
||||
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
|
||||
|
||||
if (newState) {
|
||||
await extensionManager.enableExtension(
|
||||
selectedExtension.name,
|
||||
settingScope,
|
||||
);
|
||||
} else {
|
||||
await extensionManager.disableExtension(
|
||||
selectedExtension.name,
|
||||
settingScope,
|
||||
);
|
||||
}
|
||||
|
||||
// Update local state
|
||||
setExtensions((prev) =>
|
||||
prev.map((ext) =>
|
||||
ext.name === selectedExtension.name
|
||||
? { ...ext, isActive: newState }
|
||||
: ext,
|
||||
),
|
||||
);
|
||||
|
||||
// Show success message
|
||||
const actionKey = newState ? 'enabled' : 'disabled';
|
||||
setSuccessMessage(
|
||||
t(`Extension "{{name}}" ${actionKey} successfully.`, {
|
||||
name: selectedExtension.name,
|
||||
}),
|
||||
);
|
||||
setErrorMessage(null);
|
||||
|
||||
// Go back to extension list to show success message
|
||||
setNavigationStack([MANAGEMENT_STEPS.EXTENSION_LIST]);
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Failed to ${newState ? 'enable' : 'disable'} extension:`,
|
||||
error,
|
||||
);
|
||||
setErrorMessage(
|
||||
t('Failed to {{action}} extension "{{name}}": {{error}}', {
|
||||
action: newState ? 'enable' : 'disable',
|
||||
name: selectedExtension.name,
|
||||
error: getErrorMessage(error),
|
||||
}),
|
||||
);
|
||||
setSuccessMessage(null);
|
||||
}
|
||||
},
|
||||
[config, selectedExtension],
|
||||
);
|
||||
|
||||
const handleDisableExtension = useCallback(
|
||||
async (scope: 'user' | 'workspace') => {
|
||||
await handleToggleExtensionState(scope, false);
|
||||
},
|
||||
[handleToggleExtensionState],
|
||||
);
|
||||
|
||||
const handleEnableExtension = useCallback(
|
||||
async (scope: 'user' | 'workspace') => {
|
||||
await handleToggleExtensionState(scope, true);
|
||||
},
|
||||
[handleToggleExtensionState],
|
||||
);
|
||||
|
||||
const handleUninstallExtension = useCallback(
|
||||
async (extension: Extension) => {
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
const extensionManager = config.getExtensionManager();
|
||||
if (!extensionManager) {
|
||||
throw new Error('ExtensionManager not available');
|
||||
}
|
||||
|
||||
await extensionManager.uninstallExtension(extension.name, false);
|
||||
|
||||
// Reload extensions
|
||||
await loadExtensions();
|
||||
|
||||
// Navigate back to extension list
|
||||
setNavigationStack([MANAGEMENT_STEPS.EXTENSION_LIST]);
|
||||
setSelectedExtensionIndex(-1);
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to uninstall extension:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[config, loadExtensions],
|
||||
);
|
||||
|
||||
// Centralized ESC key handling
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name !== 'escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStep = getCurrentStep();
|
||||
// If there's a success message, clear it first instead of closing
|
||||
if (successMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
|
||||
setSuccessMessage(null);
|
||||
return;
|
||||
}
|
||||
if (currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
|
||||
onClose();
|
||||
} else {
|
||||
handleNavigateBack();
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const renderStepHeader = useCallback(() => {
|
||||
const currentStep = getCurrentStep();
|
||||
const getStepHeaderText = () => {
|
||||
switch (currentStep) {
|
||||
case MANAGEMENT_STEPS.EXTENSION_LIST:
|
||||
return t('Manage Extensions');
|
||||
case MANAGEMENT_STEPS.ACTION_SELECTION:
|
||||
return selectedExtension?.name || t('Choose Action');
|
||||
case MANAGEMENT_STEPS.EXTENSION_DETAIL:
|
||||
return t('Extension Details');
|
||||
case MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
|
||||
return t('Disable Extension');
|
||||
case MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT:
|
||||
return t('Enable Extension');
|
||||
case MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION:
|
||||
return t('Uninstall Extension');
|
||||
case MANAGEMENT_STEPS.UPDATE_PROGRESS:
|
||||
return t('Update Extension');
|
||||
default:
|
||||
return t('Unknown Step');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.text.accent} bold>
|
||||
{getStepHeaderText()}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}, [getCurrentStep, selectedExtension]);
|
||||
|
||||
const renderStepFooter = useCallback(() => {
|
||||
const currentStep = getCurrentStep();
|
||||
const getNavigationInstructions = () => {
|
||||
if (currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
|
||||
if (extensions.length === 0) {
|
||||
return t('Esc to close');
|
||||
}
|
||||
return t('Enter to select, ↑↓ to navigate, Esc to close');
|
||||
}
|
||||
|
||||
if (currentStep === MANAGEMENT_STEPS.EXTENSION_DETAIL) {
|
||||
return t('Esc to go back');
|
||||
}
|
||||
|
||||
if (currentStep === MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION) {
|
||||
return t('Y/Enter to confirm, N/Esc to cancel');
|
||||
}
|
||||
|
||||
if (currentStep === MANAGEMENT_STEPS.UPDATE_PROGRESS) {
|
||||
return updateInProgress ? t('Updating...') : '';
|
||||
}
|
||||
|
||||
return t('Enter to select, ↑↓ to navigate, Esc to go back');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>{getNavigationInstructions()}</Text>
|
||||
</Box>
|
||||
);
|
||||
}, [getCurrentStep, extensions.length, updateInProgress]);
|
||||
|
||||
const renderStepContent = useCallback(() => {
|
||||
const currentStep = getCurrentStep();
|
||||
|
||||
// Show error message if present (only on extension list step)
|
||||
if (errorMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color={theme.status.error}>{errorMessage}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Show success message if present (only on extension list step)
|
||||
if (successMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color={theme.status.success}>{successMessage}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (updateError && currentStep === MANAGEMENT_STEPS.UPDATE_PROGRESS) {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color={theme.status.error}>{t('Update failed:')}</Text>
|
||||
<Text>{updateError}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
switch (currentStep) {
|
||||
case MANAGEMENT_STEPS.EXTENSION_LIST:
|
||||
return (
|
||||
<ExtensionListStep
|
||||
extensions={extensions}
|
||||
extensionsUpdateState={extensionsUpdateState}
|
||||
onExtensionSelect={handleSelectExtension}
|
||||
/>
|
||||
);
|
||||
case MANAGEMENT_STEPS.ACTION_SELECTION:
|
||||
return (
|
||||
<ActionSelectionStep
|
||||
selectedExtension={selectedExtension}
|
||||
hasUpdateAvailable={hasUpdateAvailable}
|
||||
onNavigateToStep={handleNavigateToStep}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
onActionSelect={handleActionSelect}
|
||||
/>
|
||||
);
|
||||
case MANAGEMENT_STEPS.EXTENSION_DETAIL:
|
||||
return <ExtensionDetailStep selectedExtension={selectedExtension} />;
|
||||
case MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
|
||||
return (
|
||||
<ScopeSelectStep
|
||||
selectedExtension={selectedExtension}
|
||||
mode="disable"
|
||||
onScopeSelect={handleDisableExtension}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
case MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT:
|
||||
return (
|
||||
<ScopeSelectStep
|
||||
selectedExtension={selectedExtension}
|
||||
mode="enable"
|
||||
onScopeSelect={handleEnableExtension}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
case MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION:
|
||||
return (
|
||||
<UninstallConfirmStep
|
||||
selectedExtension={selectedExtension}
|
||||
onConfirm={handleUninstallExtension}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
case MANAGEMENT_STEPS.UPDATE_PROGRESS:
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
{updateInProgress
|
||||
? t('Updating {{name}}...', {
|
||||
name: selectedExtension?.name || '',
|
||||
})
|
||||
: t('Update complete!')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>
|
||||
{t('Invalid step: {{step}}', { step: currentStep })}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}, [
|
||||
getCurrentStep,
|
||||
extensions,
|
||||
extensionsUpdateState,
|
||||
selectedExtension,
|
||||
hasUpdateAvailable,
|
||||
updateInProgress,
|
||||
updateError,
|
||||
successMessage,
|
||||
errorMessage,
|
||||
handleSelectExtension,
|
||||
handleNavigateToStep,
|
||||
handleNavigateBack,
|
||||
handleActionSelect,
|
||||
handleDisableExtension,
|
||||
handleEnableExtension,
|
||||
handleUninstallExtension,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
gap={1}
|
||||
>
|
||||
{renderStepHeader()}
|
||||
{renderStepContent()}
|
||||
{renderStepFooter()}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ExtensionsManagerDialog Snapshots > should render empty state when no extensions installed 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Manage Extensions │
|
||||
│ │
|
||||
│ No extensions installed. │
|
||||
│ Use '/extensions install' to install your first extension. │
|
||||
│ │
|
||||
│ Esc to close │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
exports[`ExtensionsManagerDialog Snapshots > should render extension list with extensions 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Manage Extensions │
|
||||
│ │
|
||||
│ No extensions installed. │
|
||||
│ Use '/extensions install' to install your first extension. │
|
||||
│ │
|
||||
│ Esc to close │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
exports[`ExtensionsManagerDialog Snapshots > should render with checking status 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Manage Extensions │
|
||||
│ │
|
||||
│ No extensions installed. │
|
||||
│ Use '/extensions install' to install your first extension. │
|
||||
│ │
|
||||
│ Esc to close │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
exports[`ExtensionsManagerDialog Snapshots > should render with update available status 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Manage Extensions │
|
||||
│ │
|
||||
│ No extensions installed. │
|
||||
│ Use '/extensions install' to install your first extension. │
|
||||
│ │
|
||||
│ Esc to close │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
9
packages/cli/src/ui/components/extensions/index.ts
Normal file
9
packages/cli/src/ui/components/extensions/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export { ExtensionsManagerDialog } from './ExtensionsManagerDialog.js';
|
||||
export type { ExtensionsManagerDialogProps } from './types.js';
|
||||
export { MANAGEMENT_STEPS } from './types.js';
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ActionSelectionStep } from './ActionSelectionStep.js';
|
||||
import { KeypressProvider } from '../../../contexts/KeypressContext.js';
|
||||
import type { Extension } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const createMockExtension = (name: string, isActive = true): Extension =>
|
||||
({
|
||||
id: name,
|
||||
name,
|
||||
version: '1.0.0',
|
||||
path: `/home/user/.qwen/extensions/${name}`,
|
||||
isActive,
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: `github:user/${name}`,
|
||||
},
|
||||
mcpServers: {},
|
||||
commands: [],
|
||||
skills: [],
|
||||
agents: [],
|
||||
resolvedSettings: [],
|
||||
config: {},
|
||||
contextFiles: [],
|
||||
}) as unknown as Extension;
|
||||
|
||||
describe('ActionSelectionStep Snapshots', () => {
|
||||
const baseProps = {
|
||||
onNavigateToStep: vi.fn(),
|
||||
onNavigateBack: vi.fn(),
|
||||
onActionSelect: vi.fn(),
|
||||
};
|
||||
|
||||
it('should render for active extension without update', () => {
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ActionSelectionStep
|
||||
selectedExtension={createMockExtension('active-ext', true)}
|
||||
hasUpdateAvailable={false}
|
||||
{...baseProps}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render for disabled extension', () => {
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ActionSelectionStep
|
||||
selectedExtension={createMockExtension('disabled-ext', false)}
|
||||
hasUpdateAvailable={false}
|
||||
{...baseProps}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render for extension with update available', () => {
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ActionSelectionStep
|
||||
selectedExtension={createMockExtension('update-ext', true)}
|
||||
hasUpdateAvailable={true}
|
||||
{...baseProps}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render for disabled extension with update', () => {
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ActionSelectionStep
|
||||
selectedExtension={createMockExtension('disabled-update-ext', false)}
|
||||
hasUpdateAvailable={true}
|
||||
{...baseProps}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render with no extension selected', () => {
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ActionSelectionStep
|
||||
selectedExtension={null}
|
||||
hasUpdateAvailable={false}
|
||||
{...baseProps}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
|
||||
import { type Extension } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
import { type ExtensionAction } from '../types.js';
|
||||
|
||||
interface ActionSelectionStepProps {
|
||||
selectedExtension: Extension | null;
|
||||
hasUpdateAvailable: boolean;
|
||||
onNavigateToStep: (step: string) => void;
|
||||
onNavigateBack: () => void;
|
||||
onActionSelect: (action: ExtensionAction) => void;
|
||||
}
|
||||
|
||||
export const ActionSelectionStep = ({
|
||||
selectedExtension,
|
||||
hasUpdateAvailable,
|
||||
onNavigateBack,
|
||||
onActionSelect,
|
||||
}: ActionSelectionStepProps) => {
|
||||
const [selectedAction, setSelectedAction] = useState<ExtensionAction | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const isActive = selectedExtension?.isActive ?? false;
|
||||
|
||||
// Build action list based on extension state
|
||||
const actions = useMemo(() => {
|
||||
const allActions = [
|
||||
{
|
||||
key: 'view',
|
||||
get label() {
|
||||
return t('View Details');
|
||||
},
|
||||
value: 'view' as const,
|
||||
},
|
||||
...(hasUpdateAvailable
|
||||
? [
|
||||
{
|
||||
key: 'update',
|
||||
get label() {
|
||||
return t('Update Extension');
|
||||
},
|
||||
value: 'update' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isActive
|
||||
? [
|
||||
{
|
||||
key: 'disable',
|
||||
get label() {
|
||||
return t('Disable Extension');
|
||||
},
|
||||
value: 'disable' as const,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: 'enable',
|
||||
get label() {
|
||||
return t('Enable Extension');
|
||||
},
|
||||
value: 'enable' as const,
|
||||
},
|
||||
]),
|
||||
{
|
||||
key: 'uninstall',
|
||||
get label() {
|
||||
return t('Uninstall Extension');
|
||||
},
|
||||
value: 'uninstall' as const,
|
||||
},
|
||||
{
|
||||
key: 'back',
|
||||
get label() {
|
||||
return t('Back');
|
||||
},
|
||||
value: 'back' as const,
|
||||
},
|
||||
];
|
||||
return allActions;
|
||||
}, [hasUpdateAvailable, isActive]);
|
||||
|
||||
const handleActionSelect = (value: ExtensionAction) => {
|
||||
if (value === 'back') {
|
||||
onNavigateBack();
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedAction(value);
|
||||
onActionSelect(value);
|
||||
};
|
||||
|
||||
const selectedIndex = selectedAction
|
||||
? actions.findIndex((action) => action.value === selectedAction)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<RadioButtonSelect
|
||||
items={actions}
|
||||
initialIndex={selectedIndex}
|
||||
onSelect={handleActionSelect}
|
||||
showNumbers={false}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { type Extension } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
|
||||
interface ExtensionDetailStepProps {
|
||||
selectedExtension: Extension | null;
|
||||
}
|
||||
|
||||
export const ExtensionDetailStep = ({
|
||||
selectedExtension,
|
||||
}: ExtensionDetailStepProps) => {
|
||||
if (!selectedExtension) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>{t('No extension selected')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const ext = selectedExtension;
|
||||
const isActive = ext.isActive;
|
||||
const activeColor = isActive ? theme.status.success : theme.text.secondary;
|
||||
const activeString = isActive ? t('active') : t('disabled');
|
||||
|
||||
// Fixed width for labels to ensure alignment
|
||||
const LABEL_WIDTH = 12;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH} flexShrink={0}>
|
||||
<Text color={theme.text.primary}>{t('Name:')}</Text>
|
||||
</Box>
|
||||
<Text>{ext.name}</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH} flexShrink={0}>
|
||||
<Text color={theme.text.primary}>{t('Version:')}</Text>
|
||||
</Box>
|
||||
<Text>{ext.version}</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH} flexShrink={0}>
|
||||
<Text color={theme.text.primary}>{t('Status:')}</Text>
|
||||
</Box>
|
||||
<Text color={activeColor}>{activeString}</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH} flexShrink={0}>
|
||||
<Text color={theme.text.primary}>{t('Path:')}</Text>
|
||||
</Box>
|
||||
<Text>{ext.path}</Text>
|
||||
</Box>
|
||||
|
||||
{ext.installMetadata && (
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH} flexShrink={0}>
|
||||
<Text color={theme.text.primary}>{t('Source:')}</Text>
|
||||
</Box>
|
||||
<Text>{ext.installMetadata.source}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{ext.mcpServers && Object.keys(ext.mcpServers).length > 0 && (
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH} flexShrink={0}>
|
||||
<Text color={theme.text.primary}>{t('MCP Servers:')}</Text>
|
||||
</Box>
|
||||
<Text>{Object.keys(ext.mcpServers).join(', ')}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{ext.commands && ext.commands.length > 0 && (
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH} flexShrink={0}>
|
||||
<Text color={theme.text.primary}>{t('Commands:')}</Text>
|
||||
</Box>
|
||||
<Text>{ext.commands.join(', ')}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{ext.skills && ext.skills.length > 0 && (
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH} flexShrink={0}>
|
||||
<Text color={theme.text.primary}>{t('Skills:')}</Text>
|
||||
</Box>
|
||||
<Text>{ext.skills.map((s) => s.name).join(', ')}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{ext.agents && ext.agents.length > 0 && (
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH} flexShrink={0}>
|
||||
<Text color={theme.text.primary}>{t('Agents:')}</Text>
|
||||
</Box>
|
||||
<Text>{ext.agents.map((a) => a.name).join(', ')}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{ext.resolvedSettings && ext.resolvedSettings.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box width={LABEL_WIDTH} flexShrink={0}>
|
||||
<Text color={theme.text.primary}>{t('Settings:')}</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
{ext.resolvedSettings.map((setting) => (
|
||||
<Text key={setting.name}>
|
||||
- {setting.name}: {setting.value}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from 'ink-testing-library';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ExtensionListStep } from './ExtensionListStep.js';
|
||||
import { KeypressProvider } from '../../../contexts/KeypressContext.js';
|
||||
import type { Extension } from '@qwen-code/qwen-code-core';
|
||||
import { ExtensionUpdateState } from '../../../state/extensions.js';
|
||||
|
||||
const createMockExtension = (
|
||||
name: string,
|
||||
isActive = true,
|
||||
version = '1.0.0',
|
||||
): Extension =>
|
||||
({
|
||||
id: name,
|
||||
name,
|
||||
version,
|
||||
path: `/home/user/.qwen/extensions/${name}`,
|
||||
isActive,
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
source: `github:user/${name}`,
|
||||
},
|
||||
mcpServers: {},
|
||||
commands: [],
|
||||
skills: [],
|
||||
agents: [],
|
||||
resolvedSettings: [],
|
||||
config: {},
|
||||
contextFiles: [],
|
||||
}) as unknown as Extension;
|
||||
|
||||
describe('ExtensionListStep Snapshots', () => {
|
||||
const baseProps = {
|
||||
onExtensionSelect: vi.fn(),
|
||||
};
|
||||
|
||||
it('should render empty state', () => {
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ExtensionListStep
|
||||
extensions={[]}
|
||||
extensionsUpdateState={new Map()}
|
||||
{...baseProps}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render list with single extension', () => {
|
||||
const extensions = [createMockExtension('test-extension', true)];
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ExtensionListStep
|
||||
extensions={extensions}
|
||||
extensionsUpdateState={new Map()}
|
||||
{...baseProps}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render list with multiple extensions', () => {
|
||||
const extensions = [
|
||||
createMockExtension('active-extension', true),
|
||||
createMockExtension('disabled-extension', false),
|
||||
createMockExtension('update-available', true),
|
||||
];
|
||||
const updateState = new Map([
|
||||
['active-extension', ExtensionUpdateState.UP_TO_DATE],
|
||||
['disabled-extension', ExtensionUpdateState.NOT_UPDATABLE],
|
||||
['update-available', ExtensionUpdateState.UPDATE_AVAILABLE],
|
||||
]);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ExtensionListStep
|
||||
extensions={extensions}
|
||||
extensionsUpdateState={updateState}
|
||||
{...baseProps}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render with checking status', () => {
|
||||
const extensions = [createMockExtension('checking-extension', true)];
|
||||
const updateState = new Map([
|
||||
['checking-extension', ExtensionUpdateState.CHECKING_FOR_UPDATES],
|
||||
]);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ExtensionListStep
|
||||
extensions={extensions}
|
||||
extensionsUpdateState={updateState}
|
||||
{...baseProps}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should render with error status', () => {
|
||||
const extensions = [createMockExtension('error-extension', true)];
|
||||
const updateState = new Map([
|
||||
['error-extension', ExtensionUpdateState.ERROR],
|
||||
]);
|
||||
|
||||
const { lastFrame } = render(
|
||||
<KeypressProvider kittyProtocolEnabled={false}>
|
||||
<ExtensionListStep
|
||||
extensions={extensions}
|
||||
extensionsUpdateState={updateState}
|
||||
{...baseProps}
|
||||
/>
|
||||
</KeypressProvider>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { useKeypress } from '../../../hooks/useKeypress.js';
|
||||
import { type Extension } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
import { ExtensionUpdateState } from '../../../state/extensions.js';
|
||||
|
||||
interface ExtensionListStepProps {
|
||||
extensions: Extension[];
|
||||
extensionsUpdateState: Map<string, string>;
|
||||
onExtensionSelect: (extensionIndex: number) => void;
|
||||
}
|
||||
|
||||
export const ExtensionListStep = ({
|
||||
extensions,
|
||||
extensionsUpdateState,
|
||||
onExtensionSelect,
|
||||
}: ExtensionListStepProps) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// Calculate max widths for each column for alignment
|
||||
const { maxNameWidth, maxVersionWidth, maxStatusWidth } = useMemo(() => {
|
||||
let maxName = 0;
|
||||
let maxVersion = 0;
|
||||
let maxStatus = 0;
|
||||
for (const ext of extensions) {
|
||||
maxName = Math.max(maxName, ext.name.length);
|
||||
maxVersion = Math.max(maxVersion, ext.version.length);
|
||||
const statusLength = ext.isActive
|
||||
? t('active').length
|
||||
: t('disabled').length;
|
||||
maxStatus = Math.max(maxStatus, statusLength);
|
||||
}
|
||||
return {
|
||||
maxNameWidth: maxName,
|
||||
maxVersionWidth: maxVersion,
|
||||
maxStatusWidth: maxStatus,
|
||||
};
|
||||
}, [extensions]);
|
||||
|
||||
// Reset selection when extensions change
|
||||
useEffect(() => {
|
||||
if (extensions.length > 0 && selectedIndex >= extensions.length) {
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
}, [extensions, selectedIndex]);
|
||||
|
||||
// Keyboard navigation
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'up' || key.name === 'k') {
|
||||
setSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : extensions.length - 1,
|
||||
);
|
||||
} else if (key.name === 'down' || key.name === 'j') {
|
||||
setSelectedIndex((prev) =>
|
||||
prev < extensions.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
} else if (key.name === 'return' || key.name === 'space') {
|
||||
if (extensions.length > 0) {
|
||||
onExtensionSelect(selectedIndex);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
if (extensions.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('No extensions installed.')}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t("Use '/extensions install' to install your first extension.")}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const getUpdateStateColor = (state: string | undefined): string => {
|
||||
if (!state) return theme.text.secondary;
|
||||
|
||||
switch (state) {
|
||||
case ExtensionUpdateState.CHECKING_FOR_UPDATES:
|
||||
case ExtensionUpdateState.UPDATING:
|
||||
return theme.text.secondary;
|
||||
case ExtensionUpdateState.UPDATE_AVAILABLE:
|
||||
case ExtensionUpdateState.UPDATED_NEEDS_RESTART:
|
||||
return theme.status.warning;
|
||||
case ExtensionUpdateState.ERROR:
|
||||
return theme.status.error;
|
||||
case ExtensionUpdateState.UP_TO_DATE:
|
||||
case ExtensionUpdateState.NOT_UPDATABLE:
|
||||
case ExtensionUpdateState.UPDATED:
|
||||
return theme.status.success;
|
||||
default:
|
||||
return theme.text.secondary;
|
||||
}
|
||||
};
|
||||
|
||||
const getLocalizedUpdateState = (state: string | undefined): string => {
|
||||
if (!state) return '';
|
||||
// Map internal state values to translation keys
|
||||
const stateMap: Record<string, string> = {
|
||||
'up to date': t('up to date'),
|
||||
'update available': t('update available'),
|
||||
'checking...': t('checking...'),
|
||||
'not updatable': t('not updatable'),
|
||||
error: t('error'),
|
||||
};
|
||||
return stateMap[state] || state;
|
||||
};
|
||||
|
||||
const renderExtensionItem = (
|
||||
extension: Extension,
|
||||
index: number,
|
||||
isSelected: boolean,
|
||||
) => {
|
||||
const isActive = extension.isActive;
|
||||
const activeColor = isActive ? theme.status.success : theme.text.secondary;
|
||||
const activeString = isActive ? t('active') : t('disabled');
|
||||
|
||||
const updateState = extensionsUpdateState.get(extension.name);
|
||||
const stateColor = getUpdateStateColor(updateState);
|
||||
const stateText = getLocalizedUpdateState(updateState);
|
||||
|
||||
return (
|
||||
<Box key={extension.name} alignItems="center">
|
||||
<Box minWidth={2} flexShrink={0}>
|
||||
<Text color={isSelected ? theme.text.accent : theme.text.primary}>
|
||||
{isSelected ? '●' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={maxNameWidth} flexShrink={0}>
|
||||
<Text
|
||||
color={isSelected ? theme.text.accent : theme.text.primary}
|
||||
wrap="truncate"
|
||||
>
|
||||
{extension.name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box width={maxVersionWidth + 8} flexShrink={0}>
|
||||
<Text color={theme.text.secondary}> v{extension.version}</Text>
|
||||
</Box>
|
||||
<Box width={maxStatusWidth + 8} flexShrink={0}>
|
||||
<Text color={activeColor}>({activeString})</Text>
|
||||
</Box>
|
||||
{stateText && <Text color={stateColor}>[{stateText}]</Text>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{extensions.map((extension, index) =>
|
||||
renderExtensionItem(extension, index, index === selectedIndex),
|
||||
)}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('{{count}} extensions installed', {
|
||||
count: extensions.length.toString(),
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
|
||||
import { type Extension } from '@qwen-code/qwen-code-core';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
|
||||
interface ScopeSelectStepProps {
|
||||
selectedExtension: Extension | null;
|
||||
mode: 'disable' | 'enable';
|
||||
onScopeSelect: (scope: 'user' | 'workspace') => void;
|
||||
onNavigateBack: () => void;
|
||||
}
|
||||
|
||||
export function ScopeSelectStep({
|
||||
selectedExtension,
|
||||
mode,
|
||||
onScopeSelect,
|
||||
onNavigateBack,
|
||||
}: ScopeSelectStepProps) {
|
||||
const scopeItems = [
|
||||
{
|
||||
key: 'user',
|
||||
get label() {
|
||||
return t('User (global)');
|
||||
},
|
||||
value: 'user' as const,
|
||||
},
|
||||
{
|
||||
key: 'workspace',
|
||||
get label() {
|
||||
return t('Workspace (project-specific)');
|
||||
},
|
||||
value: 'workspace' as const,
|
||||
},
|
||||
{
|
||||
key: 'back',
|
||||
get label() {
|
||||
return t('Back');
|
||||
},
|
||||
value: 'back' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const handleSelect = (value: 'user' | 'workspace' | 'back') => {
|
||||
if (value === 'back') {
|
||||
onNavigateBack();
|
||||
return;
|
||||
}
|
||||
onScopeSelect(value);
|
||||
};
|
||||
|
||||
if (!selectedExtension) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>{t('No extension selected')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const title =
|
||||
mode === 'disable'
|
||||
? t('Disable "{{name}}" - Select Scope', { name: selectedExtension.name })
|
||||
: t('Enable "{{name}}" - Select Scope', { name: selectedExtension.name });
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color={theme.text.primary}>{title}</Text>
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect
|
||||
items={scopeItems}
|
||||
onSelect={handleSelect}
|
||||
showNumbers={false}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { type Extension } from '@qwen-code/qwen-code-core';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { useKeypress } from '../../../hooks/useKeypress.js';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
|
||||
interface UninstallConfirmStepProps {
|
||||
selectedExtension: Extension | null;
|
||||
onConfirm: (extension: Extension) => Promise<void>;
|
||||
onNavigateBack: () => void;
|
||||
}
|
||||
|
||||
const debugLogger = createDebugLogger('EXTENSION_UNINSTALL_STEP');
|
||||
|
||||
export function UninstallConfirmStep({
|
||||
selectedExtension,
|
||||
onConfirm,
|
||||
onNavigateBack,
|
||||
}: UninstallConfirmStepProps) {
|
||||
useKeypress(
|
||||
async (key) => {
|
||||
if (!selectedExtension) return;
|
||||
|
||||
if (key.name === 'y' || key.name === 'return') {
|
||||
try {
|
||||
await onConfirm(selectedExtension);
|
||||
// Navigation will be handled by the parent component after successful uninstall
|
||||
} catch (error) {
|
||||
debugLogger.error('Failed to uninstall extension:', error);
|
||||
}
|
||||
} else if (key.name === 'n' || key.name === 'escape') {
|
||||
onNavigateBack();
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
if (!selectedExtension) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>{t('No extension selected')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color={theme.status.error}>
|
||||
{t('Are you sure you want to uninstall extension "{{name}}"?', {
|
||||
name: selectedExtension.name,
|
||||
})}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('This action cannot be undone.')}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('Press Y/Enter to confirm, N/Esc to cancel')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ActionSelectionStep Snapshots > should render for active extension without update 1`] = `
|
||||
"● View Details
|
||||
Disable Extension
|
||||
Uninstall Extension
|
||||
Back"
|
||||
`;
|
||||
|
||||
exports[`ActionSelectionStep Snapshots > should render for disabled extension 1`] = `
|
||||
"● View Details
|
||||
Enable Extension
|
||||
Uninstall Extension
|
||||
Back"
|
||||
`;
|
||||
|
||||
exports[`ActionSelectionStep Snapshots > should render for disabled extension with update 1`] = `
|
||||
"● View Details
|
||||
Update Extension
|
||||
Enable Extension
|
||||
Uninstall Extension
|
||||
Back"
|
||||
`;
|
||||
|
||||
exports[`ActionSelectionStep Snapshots > should render for extension with update available 1`] = `
|
||||
"● View Details
|
||||
Update Extension
|
||||
Disable Extension
|
||||
Uninstall Extension
|
||||
Back"
|
||||
`;
|
||||
|
||||
exports[`ActionSelectionStep Snapshots > should render with no extension selected 1`] = `
|
||||
"● View Details
|
||||
Enable Extension
|
||||
Uninstall Extension
|
||||
Back"
|
||||
`;
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ExtensionListStep Snapshots > should render empty state 1`] = `
|
||||
"No extensions installed.
|
||||
Use '/extensions install' to install your first extension."
|
||||
`;
|
||||
|
||||
exports[`ExtensionListStep Snapshots > should render list with multiple extensions 1`] = `
|
||||
"● active-extension v1.0.0 (active) [up to date]
|
||||
disabled-extension v1.0.0 (disabled) [not updatable]
|
||||
update-available v1.0.0 (active) [update available]
|
||||
|
||||
|
||||
3 extensions installed"
|
||||
`;
|
||||
|
||||
exports[`ExtensionListStep Snapshots > should render list with single extension 1`] = `
|
||||
"● test-extension v1.0.0 (active)
|
||||
|
||||
|
||||
1 extensions installed"
|
||||
`;
|
||||
|
||||
exports[`ExtensionListStep Snapshots > should render with checking status 1`] = `
|
||||
"● checking-extension v1.0.0 (active) [checking for updates]
|
||||
|
||||
|
||||
1 extensions installed"
|
||||
`;
|
||||
|
||||
exports[`ExtensionListStep Snapshots > should render with error status 1`] = `
|
||||
"● error-extension v1.0.0 (active) [error]
|
||||
|
||||
|
||||
1 extensions installed"
|
||||
`;
|
||||
11
packages/cli/src/ui/components/extensions/steps/index.ts
Normal file
11
packages/cli/src/ui/components/extensions/steps/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export { ExtensionListStep } from './ExtensionListStep.js';
|
||||
export { ExtensionDetailStep } from './ExtensionDetailStep.js';
|
||||
export { ActionSelectionStep } from './ActionSelectionStep.js';
|
||||
export { UninstallConfirmStep } from './UninstallConfirmStep.js';
|
||||
export { ScopeSelectStep } from './ScopeSelectStep.js';
|
||||
89
packages/cli/src/ui/components/extensions/types.ts
Normal file
89
packages/cli/src/ui/components/extensions/types.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Extension, Config } from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* Management steps for the extensions manager dialog.
|
||||
*/
|
||||
export const MANAGEMENT_STEPS = {
|
||||
EXTENSION_LIST: 'extension-list',
|
||||
ACTION_SELECTION: 'action-selection',
|
||||
EXTENSION_DETAIL: 'extension-detail',
|
||||
UNINSTALL_CONFIRMATION: 'uninstall-confirmation',
|
||||
DISABLE_SCOPE_SELECT: 'disable-scope-select',
|
||||
ENABLE_SCOPE_SELECT: 'enable-scope-select',
|
||||
UPDATE_PROGRESS: 'update-progress',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Props for step navigation.
|
||||
*/
|
||||
export interface StepNavigationProps {
|
||||
onNavigateToStep: (step: string) => void;
|
||||
onNavigateBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the extension list step.
|
||||
*/
|
||||
export interface ExtensionListStepProps extends StepNavigationProps {
|
||||
extensions: Extension[];
|
||||
extensionsUpdateState: Map<string, string>;
|
||||
onExtensionSelect: (extensionIndex: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the extension detail step.
|
||||
*/
|
||||
export interface ExtensionDetailStepProps extends StepNavigationProps {
|
||||
selectedExtension: Extension | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the action selection step.
|
||||
*/
|
||||
export interface ActionSelectionStepProps extends StepNavigationProps {
|
||||
selectedExtension: Extension | null;
|
||||
hasUpdateAvailable: boolean;
|
||||
onActionSelect: (action: ExtensionAction) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the uninstall confirmation step.
|
||||
*/
|
||||
export interface UninstallConfirmStepProps extends StepNavigationProps {
|
||||
selectedExtension: Extension | null;
|
||||
onConfirm: (extension: Extension) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the scope selection step.
|
||||
*/
|
||||
export interface ScopeSelectStepProps extends StepNavigationProps {
|
||||
selectedExtension: Extension | null;
|
||||
mode: 'disable' | 'enable';
|
||||
onScopeSelect: (scope: 'user' | 'workspace') => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Available actions for an extension.
|
||||
*/
|
||||
export type ExtensionAction =
|
||||
| 'view'
|
||||
| 'update'
|
||||
| 'disable'
|
||||
| 'enable'
|
||||
| 'uninstall'
|
||||
| 'back';
|
||||
|
||||
/**
|
||||
* Props for the ExtensionsManagerDialog component.
|
||||
*/
|
||||
export interface ExtensionsManagerDialogProps {
|
||||
onClose: () => void;
|
||||
config: Config | null;
|
||||
}
|
||||
554
packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx
Normal file
554
packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx
Normal file
|
|
@ -0,0 +1,554 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import type {
|
||||
MCPManagementDialogProps,
|
||||
MCPServerDisplayInfo,
|
||||
MCPToolDisplayInfo,
|
||||
} from './types.js';
|
||||
import { MCP_MANAGEMENT_STEPS } from './types.js';
|
||||
import { ServerListStep } from './steps/ServerListStep.js';
|
||||
import { ServerDetailStep } from './steps/ServerDetailStep.js';
|
||||
import { ToolListStep } from './steps/ToolListStep.js';
|
||||
import { ToolDetailStep } from './steps/ToolDetailStep.js';
|
||||
import { DisableScopeSelectStep } from './steps/DisableScopeSelectStep.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import {
|
||||
getMCPServerStatus,
|
||||
DiscoveredMCPTool,
|
||||
type MCPServerConfig,
|
||||
type AnyDeclarativeTool,
|
||||
type DiscoveredMCPPrompt,
|
||||
createDebugLogger,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { loadSettings, SettingScope } from '../../../config/settings.js';
|
||||
import { isToolValid, getToolInvalidReasons } from './utils.js';
|
||||
|
||||
const debugLogger = createDebugLogger('MCP_DIALOG');
|
||||
|
||||
export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
|
||||
const [servers, setServers] = useState<MCPServerDisplayInfo[]>([]);
|
||||
const [selectedServerIndex, setSelectedServerIndex] = useState<number>(-1);
|
||||
const [selectedTool, setSelectedTool] = useState<MCPToolDisplayInfo | null>(
|
||||
null,
|
||||
);
|
||||
const [navigationStack, setNavigationStack] = useState<string[]>([
|
||||
MCP_MANAGEMENT_STEPS.SERVER_LIST,
|
||||
]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Load MCP server data - extracted to a separate function for reuse
|
||||
const fetchServerData = useCallback(async (): Promise<
|
||||
MCPServerDisplayInfo[]
|
||||
> => {
|
||||
if (!config) return [];
|
||||
|
||||
const mcpServers = config.getMcpServers() || {};
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
const promptRegistry = config.getPromptRegistry();
|
||||
|
||||
// Get settings to determine the scope of each server
|
||||
const settings = loadSettings();
|
||||
const userSettings = settings.forScope(SettingScope.User).settings;
|
||||
const workspaceSettings = settings.forScope(
|
||||
SettingScope.Workspace,
|
||||
).settings;
|
||||
|
||||
const serverInfos: MCPServerDisplayInfo[] = [];
|
||||
|
||||
for (const [name, serverConfig] of Object.entries(mcpServers) as Array<
|
||||
[string, MCPServerConfig]
|
||||
>) {
|
||||
const status = getMCPServerStatus(name);
|
||||
|
||||
// Get tools for this server
|
||||
const allTools: AnyDeclarativeTool[] = toolRegistry?.getAllTools() || [];
|
||||
const serverTools = allTools.filter(
|
||||
(t): t is DiscoveredMCPTool =>
|
||||
t instanceof DiscoveredMCPTool && t.serverName === name,
|
||||
);
|
||||
|
||||
// Get prompts for this server
|
||||
const allPrompts: DiscoveredMCPPrompt[] =
|
||||
promptRegistry?.getAllPrompts() || [];
|
||||
const serverPrompts = allPrompts.filter(
|
||||
(p) => 'serverName' in p && p.serverName === name,
|
||||
);
|
||||
|
||||
// Determine source type
|
||||
let source: 'user' | 'project' | 'extension' = 'user';
|
||||
if (serverConfig.extensionName) {
|
||||
source = 'extension';
|
||||
}
|
||||
|
||||
// Determine the scope of the configuration
|
||||
let scope: 'user' | 'workspace' | 'extension' = 'user';
|
||||
if (serverConfig.extensionName) {
|
||||
scope = 'extension';
|
||||
} else if (workspaceSettings.mcpServers?.[name]) {
|
||||
scope = 'workspace';
|
||||
} else if (userSettings.mcpServers?.[name]) {
|
||||
scope = 'user';
|
||||
}
|
||||
|
||||
// Use config.isMcpServerDisabled() to check if server is disabled
|
||||
const isDisabled = config.isMcpServerDisabled(name);
|
||||
|
||||
// Count invalid tools (missing name or description)
|
||||
const invalidToolCount = serverTools.filter(
|
||||
(t) => !t.name || !t.description,
|
||||
).length;
|
||||
|
||||
serverInfos.push({
|
||||
name,
|
||||
status,
|
||||
source,
|
||||
scope,
|
||||
config: serverConfig,
|
||||
toolCount: serverTools.length,
|
||||
invalidToolCount,
|
||||
promptCount: serverPrompts.length,
|
||||
isDisabled,
|
||||
});
|
||||
}
|
||||
|
||||
return serverInfos;
|
||||
}, [config]);
|
||||
|
||||
// Load MCP server data on initial render
|
||||
useEffect(() => {
|
||||
const loadServers = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const serverInfos = await fetchServerData();
|
||||
setServers(serverInfos);
|
||||
} catch (error) {
|
||||
debugLogger.error('Error loading MCP servers:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadServers();
|
||||
}, [fetchServerData]);
|
||||
|
||||
// Selected server
|
||||
const selectedServer = useMemo(() => {
|
||||
if (selectedServerIndex >= 0 && selectedServerIndex < servers.length) {
|
||||
return servers[selectedServerIndex];
|
||||
}
|
||||
return null;
|
||||
}, [servers, selectedServerIndex]);
|
||||
|
||||
// Current step
|
||||
const getCurrentStep = useCallback(
|
||||
() =>
|
||||
navigationStack[navigationStack.length - 1] ||
|
||||
MCP_MANAGEMENT_STEPS.SERVER_LIST,
|
||||
[navigationStack],
|
||||
);
|
||||
|
||||
// Navigation handlers
|
||||
const handleNavigateToStep = useCallback((step: string) => {
|
||||
setNavigationStack((prev) => [...prev, step]);
|
||||
}, []);
|
||||
|
||||
const handleNavigateBack = useCallback(() => {
|
||||
setNavigationStack((prev) => {
|
||||
if (prev.length <= 1) return prev;
|
||||
return prev.slice(0, -1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Select server
|
||||
const handleSelectServer = useCallback(
|
||||
(index: number) => {
|
||||
setSelectedServerIndex(index);
|
||||
handleNavigateToStep(MCP_MANAGEMENT_STEPS.SERVER_DETAIL);
|
||||
},
|
||||
[handleNavigateToStep],
|
||||
);
|
||||
|
||||
// Get server tool list
|
||||
const getServerTools = useCallback((): MCPToolDisplayInfo[] => {
|
||||
if (!config || !selectedServer) return [];
|
||||
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
if (!toolRegistry) return [];
|
||||
|
||||
const allTools: AnyDeclarativeTool[] = toolRegistry.getAllTools();
|
||||
const mcpTools: DiscoveredMCPTool[] = [];
|
||||
for (const tool of allTools) {
|
||||
if (
|
||||
tool instanceof DiscoveredMCPTool &&
|
||||
tool.serverName === selectedServer.name
|
||||
) {
|
||||
mcpTools.push(tool);
|
||||
}
|
||||
}
|
||||
return mcpTools.map((tool) => {
|
||||
// Check if tool is valid (has both name and description required by LLM)
|
||||
const isValid = isToolValid(tool.name, tool.description);
|
||||
|
||||
let invalidReason: string | undefined;
|
||||
if (!isValid) {
|
||||
const reasons = getToolInvalidReasons(tool.name, tool.description);
|
||||
invalidReason = reasons.map((r) => t(r)).join(', ');
|
||||
}
|
||||
|
||||
return {
|
||||
name: tool.name || t('(unnamed)'),
|
||||
description: tool.description,
|
||||
serverName: tool.serverName,
|
||||
schema: tool.parameterSchema as object | undefined,
|
||||
annotations: tool.annotations,
|
||||
isValid,
|
||||
invalidReason,
|
||||
};
|
||||
});
|
||||
}, [config, selectedServer]);
|
||||
|
||||
// View tool list
|
||||
const handleViewTools = useCallback(() => {
|
||||
handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST);
|
||||
}, [handleNavigateToStep]);
|
||||
|
||||
// Select tool
|
||||
const handleSelectTool = useCallback(
|
||||
(tool: MCPToolDisplayInfo) => {
|
||||
setSelectedTool(tool);
|
||||
handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_DETAIL);
|
||||
},
|
||||
[handleNavigateToStep],
|
||||
);
|
||||
|
||||
// Reload server data - uses the extracted fetchServerData function
|
||||
const reloadServers = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const serverInfos = await fetchServerData();
|
||||
setServers(serverInfos);
|
||||
} catch (error) {
|
||||
debugLogger.error('Error reloading MCP servers:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [fetchServerData]);
|
||||
|
||||
// Reconnect server
|
||||
const handleReconnect = useCallback(async () => {
|
||||
if (!config || !selectedServer) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
if (toolRegistry) {
|
||||
await toolRegistry.discoverToolsForServer(selectedServer.name);
|
||||
}
|
||||
// Reload server data to update status
|
||||
await reloadServers();
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Error reconnecting to server '${selectedServer.name}':`,
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [config, selectedServer, reloadServers]);
|
||||
|
||||
// Enable server
|
||||
const handleEnableServer = useCallback(async () => {
|
||||
if (!config || !selectedServer) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const server = selectedServer;
|
||||
const settings = loadSettings();
|
||||
|
||||
// Remove from user and workspace exclusion lists
|
||||
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
|
||||
const scopeSettings = settings.forScope(scope).settings;
|
||||
const currentExcluded = scopeSettings.mcp?.excluded || [];
|
||||
|
||||
if (currentExcluded.includes(server.name)) {
|
||||
const newExcluded = currentExcluded.filter(
|
||||
(name: string) => name !== server.name,
|
||||
);
|
||||
settings.setValue(scope, 'mcp.excluded', newExcluded);
|
||||
}
|
||||
}
|
||||
|
||||
// Update runtime config exclusion list
|
||||
const currentExcluded = config.getExcludedMcpServers() || [];
|
||||
const newExcluded = currentExcluded.filter(
|
||||
(name: string) => name !== server.name,
|
||||
);
|
||||
config.setExcludedMcpServers(newExcluded);
|
||||
|
||||
// Rediscover tools for this server
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
if (toolRegistry) {
|
||||
await toolRegistry.discoverToolsForServer(server.name);
|
||||
}
|
||||
|
||||
// Reload server data
|
||||
await reloadServers();
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Error enabling server '${selectedServer.name}':`,
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [config, selectedServer, reloadServers]);
|
||||
|
||||
// Handle disable/enable action
|
||||
const handleDisable = useCallback(() => {
|
||||
if (!selectedServer) return;
|
||||
|
||||
// If server is already disabled, enable it directly
|
||||
if (selectedServer.isDisabled) {
|
||||
void handleEnableServer();
|
||||
} else {
|
||||
// Otherwise navigate to disable scope selection
|
||||
handleNavigateToStep(MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT);
|
||||
}
|
||||
}, [selectedServer, handleEnableServer, handleNavigateToStep]);
|
||||
|
||||
// Execute disable after selecting scope
|
||||
const handleSelectDisableScope = useCallback(
|
||||
async (scope: 'user' | 'workspace') => {
|
||||
if (!config || !selectedServer) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const server = selectedServer;
|
||||
const settings = loadSettings();
|
||||
|
||||
// Get current exclusion list
|
||||
const scopeSettings = settings.forScope(
|
||||
scope === 'user' ? SettingScope.User : SettingScope.Workspace,
|
||||
).settings;
|
||||
const currentExcluded = scopeSettings.mcp?.excluded || [];
|
||||
|
||||
// If server is not in exclusion list, add it
|
||||
if (!currentExcluded.includes(server.name)) {
|
||||
const newExcluded = [...currentExcluded, server.name];
|
||||
settings.setValue(
|
||||
scope === 'user' ? SettingScope.User : SettingScope.Workspace,
|
||||
'mcp.excluded',
|
||||
newExcluded,
|
||||
);
|
||||
}
|
||||
|
||||
// Use new disableMcpServer method to disable server
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
if (toolRegistry) {
|
||||
await toolRegistry.disableMcpServer(server.name);
|
||||
}
|
||||
|
||||
// Reload server list
|
||||
await reloadServers();
|
||||
|
||||
// Return to server detail page
|
||||
handleNavigateBack();
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Error disabling server '${selectedServer.name}':`,
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[config, selectedServer, handleNavigateBack, reloadServers],
|
||||
);
|
||||
|
||||
// Render step header
|
||||
const renderStepHeader = useCallback(() => {
|
||||
const currentStep = getCurrentStep();
|
||||
let headerText = '';
|
||||
|
||||
switch (currentStep) {
|
||||
case MCP_MANAGEMENT_STEPS.SERVER_LIST:
|
||||
headerText = t('Manage MCP servers');
|
||||
break;
|
||||
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
|
||||
headerText = selectedServer?.name || t('Server Detail');
|
||||
break;
|
||||
case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
|
||||
headerText = t('Disable Server');
|
||||
break;
|
||||
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
|
||||
headerText = t('Tools');
|
||||
break;
|
||||
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
|
||||
headerText = selectedTool?.name || t('Tool Detail');
|
||||
break;
|
||||
default:
|
||||
headerText = t('MCP Management');
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.text.accent} bold>
|
||||
{headerText}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}, [getCurrentStep, selectedServer, selectedTool]);
|
||||
|
||||
// Render step content
|
||||
const renderStepContent = useCallback(() => {
|
||||
if (isLoading) {
|
||||
return <Text color={theme.text.secondary}>{t('Loading...')}</Text>;
|
||||
}
|
||||
|
||||
const currentStep = getCurrentStep();
|
||||
|
||||
switch (currentStep) {
|
||||
case MCP_MANAGEMENT_STEPS.SERVER_LIST:
|
||||
return (
|
||||
<ServerListStep servers={servers} onSelect={handleSelectServer} />
|
||||
);
|
||||
|
||||
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
|
||||
return (
|
||||
<ServerDetailStep
|
||||
server={selectedServer}
|
||||
onViewTools={handleViewTools}
|
||||
onReconnect={handleReconnect}
|
||||
onDisable={handleDisable}
|
||||
onBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
|
||||
case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
|
||||
return (
|
||||
<DisableScopeSelectStep
|
||||
server={selectedServer}
|
||||
onSelectScope={handleSelectDisableScope}
|
||||
onBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
|
||||
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
|
||||
return (
|
||||
<ToolListStep
|
||||
tools={getServerTools()}
|
||||
serverName={selectedServer?.name || ''}
|
||||
onSelect={handleSelectTool}
|
||||
onBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
|
||||
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
|
||||
return (
|
||||
<ToolDetailStep tool={selectedTool} onBack={handleNavigateBack} />
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>{t('Unknown step')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}, [
|
||||
isLoading,
|
||||
getCurrentStep,
|
||||
servers,
|
||||
selectedServer,
|
||||
selectedTool,
|
||||
handleSelectServer,
|
||||
handleViewTools,
|
||||
handleReconnect,
|
||||
handleDisable,
|
||||
handleNavigateBack,
|
||||
handleSelectTool,
|
||||
handleSelectDisableScope,
|
||||
getServerTools,
|
||||
]);
|
||||
|
||||
// Render step footer
|
||||
const renderStepFooter = useCallback(() => {
|
||||
const currentStep = getCurrentStep();
|
||||
let footerText = '';
|
||||
|
||||
switch (currentStep) {
|
||||
case MCP_MANAGEMENT_STEPS.SERVER_LIST:
|
||||
if (servers.length === 0) {
|
||||
footerText = t('Esc to close');
|
||||
} else {
|
||||
footerText = t('↑↓ to navigate · Enter to select · Esc to close');
|
||||
}
|
||||
break;
|
||||
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
|
||||
footerText = t('↑↓ to navigate · Enter to select · Esc to back');
|
||||
break;
|
||||
case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
|
||||
footerText = t('↑↓ to navigate · Enter to confirm · Esc to back');
|
||||
break;
|
||||
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
|
||||
footerText = t('↑↓ to navigate · Enter to select · Esc to back');
|
||||
break;
|
||||
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
|
||||
footerText = t('Esc to back');
|
||||
break;
|
||||
default:
|
||||
footerText = t('Esc to close');
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>{footerText}</Text>
|
||||
</Box>
|
||||
);
|
||||
}, [getCurrentStep, servers.length]);
|
||||
|
||||
// ESC key handler - only close dialog, child components handle back navigation to avoid duplicate triggers
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (
|
||||
key.name === 'escape' &&
|
||||
getCurrentStep() === MCP_MANAGEMENT_STEPS.SERVER_LIST
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
gap={1}
|
||||
>
|
||||
{renderStepHeader()}
|
||||
{renderStepContent()}
|
||||
{renderStepFooter()}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
47
packages/cli/src/ui/components/mcp/constants.ts
Normal file
47
packages/cli/src/ui/components/mcp/constants.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* MCP管理相关常量
|
||||
*/
|
||||
|
||||
/**
|
||||
* 最大显示工具数量
|
||||
*/
|
||||
export const MAX_DISPLAY_TOOLS = 10;
|
||||
|
||||
/**
|
||||
* 最大显示prompt数量
|
||||
*/
|
||||
export const MAX_DISPLAY_PROMPTS = 10;
|
||||
|
||||
/**
|
||||
* 日志列表可视区域最大显示数量
|
||||
*/
|
||||
export const VISIBLE_LOGS_COUNT = 15;
|
||||
|
||||
/**
|
||||
* 工具列表可视区域最大显示数量
|
||||
*/
|
||||
export const VISIBLE_TOOLS_COUNT = 10;
|
||||
|
||||
/**
|
||||
* 分组显示名称映射
|
||||
*/
|
||||
export const SOURCE_DISPLAY_NAMES: Record<string, string> = {
|
||||
user: 'User MCPs',
|
||||
project: 'Project MCPs',
|
||||
extension: 'Extension MCPs',
|
||||
};
|
||||
|
||||
/**
|
||||
* 状态显示文本
|
||||
*/
|
||||
export const STATUS_TEXT: Record<string, string> = {
|
||||
connected: 'connected',
|
||||
connecting: 'connecting',
|
||||
disconnected: 'failed',
|
||||
};
|
||||
30
packages/cli/src/ui/components/mcp/index.ts
Normal file
30
packages/cli/src/ui/components/mcp/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// Main Dialog
|
||||
export { MCPManagementDialog } from './MCPManagementDialog.js';
|
||||
|
||||
// Steps
|
||||
export { ServerListStep } from './steps/ServerListStep.js';
|
||||
export { ServerDetailStep } from './steps/ServerDetailStep.js';
|
||||
export { ToolListStep } from './steps/ToolListStep.js';
|
||||
export { ToolDetailStep } from './steps/ToolDetailStep.js';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
MCPManagementDialogProps,
|
||||
MCPServerDisplayInfo,
|
||||
MCPToolDisplayInfo,
|
||||
MCPPromptDisplayInfo,
|
||||
ServerListStepProps,
|
||||
ServerDetailStepProps,
|
||||
ToolListStepProps,
|
||||
ToolDetailStepProps,
|
||||
MCPManagementStep,
|
||||
} from './types.js';
|
||||
|
||||
// Constants
|
||||
export { MCP_MANAGEMENT_STEPS } from './types.js';
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { useKeypress } from '../../../hooks/useKeypress.js';
|
||||
import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
import type { DisableScopeSelectStepProps } from '../types.js';
|
||||
|
||||
export const DisableScopeSelectStep: React.FC<DisableScopeSelectStepProps> = ({
|
||||
server,
|
||||
onSelectScope,
|
||||
onBack,
|
||||
}) => {
|
||||
const [selectedScope, setSelectedScope] = useState<'user' | 'workspace'>(
|
||||
'user',
|
||||
);
|
||||
|
||||
const scopes = [
|
||||
{
|
||||
key: 'user',
|
||||
get label() {
|
||||
return t('User Settings (global)');
|
||||
},
|
||||
value: 'user' as const,
|
||||
},
|
||||
{
|
||||
key: 'workspace',
|
||||
get label() {
|
||||
return t('Workspace Settings (project-specific)');
|
||||
},
|
||||
value: 'workspace' as const,
|
||||
},
|
||||
];
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onBack();
|
||||
} else if (key.name === 'return') {
|
||||
onSelectScope(selectedScope);
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
if (!server) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>{t('No server selected')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.primary}>
|
||||
{t('Disable server:')} {server.name}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Select where to add the server to the exclude list:')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect<'user' | 'workspace'>
|
||||
items={scopes}
|
||||
onHighlight={(value: 'user' | 'workspace') => setSelectedScope(value)}
|
||||
onSelect={(value: 'user' | 'workspace') => onSelectScope(value)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Press Enter to confirm, Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
223
packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx
Normal file
223
packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { useKeypress } from '../../../hooks/useKeypress.js';
|
||||
import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
import type { ServerDetailStepProps } from '../types.js';
|
||||
import {
|
||||
getStatusColor,
|
||||
getStatusIcon,
|
||||
formatServerCommand,
|
||||
} from '../utils.js';
|
||||
|
||||
// 标签列宽度
|
||||
const LABEL_WIDTH = 15;
|
||||
|
||||
type ServerAction = 'view-tools' | 'reconnect' | 'toggle-disable';
|
||||
|
||||
export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
||||
server,
|
||||
onViewTools,
|
||||
onReconnect,
|
||||
onDisable,
|
||||
onBack,
|
||||
}) => {
|
||||
const [selectedAction, setSelectedAction] =
|
||||
useState<ServerAction>('view-tools');
|
||||
|
||||
const statusColor = server ? getStatusColor(server.status) : 'gray';
|
||||
|
||||
const actions = [
|
||||
{
|
||||
key: 'view-tools',
|
||||
get label() {
|
||||
return t('View tools');
|
||||
},
|
||||
value: 'view-tools' as const,
|
||||
},
|
||||
{
|
||||
key: 'reconnect',
|
||||
get label() {
|
||||
return t('Reconnect');
|
||||
},
|
||||
value: 'reconnect' as const,
|
||||
},
|
||||
{
|
||||
key: 'toggle-disable',
|
||||
get label() {
|
||||
return server?.isDisabled ? t('Enable') : t('Disable');
|
||||
},
|
||||
value: 'toggle-disable' as const,
|
||||
},
|
||||
];
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onBack();
|
||||
} else if (key.name === 'return') {
|
||||
switch (selectedAction) {
|
||||
case 'view-tools':
|
||||
onViewTools();
|
||||
break;
|
||||
case 'reconnect':
|
||||
onReconnect?.();
|
||||
break;
|
||||
case 'toggle-disable':
|
||||
onDisable?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
if (!server) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>{t('No server selected')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{/* 服务器详情 */}
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH}>
|
||||
<Text color={theme.text.primary}>{t('Status:')}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text
|
||||
color={
|
||||
statusColor === 'green'
|
||||
? theme.status.success
|
||||
: statusColor === 'yellow'
|
||||
? theme.status.warning
|
||||
: theme.status.error
|
||||
}
|
||||
>
|
||||
{getStatusIcon(server.status)} {t(server.status)}
|
||||
{server.isDisabled && (
|
||||
<Text color={theme.status.warning}> {t('(disabled)')}</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH}>
|
||||
<Text color={theme.text.primary}>{t('Source:')}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
{server.scope === 'user'
|
||||
? t('User Settings')
|
||||
: server.scope === 'workspace'
|
||||
? t('Workspace Settings')
|
||||
: t('Extension')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH}>
|
||||
<Text color={theme.text.primary}>{t('Command:')}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text wrap="truncate">{formatServerCommand(server)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{server.config.cwd && (
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH}>
|
||||
<Text color={theme.text.primary}>{t('Working Directory:')}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text wrap="truncate">{server.config.cwd}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH}>
|
||||
<Text color={theme.text.primary}>{t('Capabilities:')}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>
|
||||
{server.toolCount > 0 ? t('tools') : ''}
|
||||
{server.toolCount > 0 && server.promptCount > 0 ? ', ' : ''}
|
||||
{server.promptCount > 0 ? t('prompts') : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH}>
|
||||
<Text color={theme.text.primary}>{t('Tools:')}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>
|
||||
{server.toolCount}{' '}
|
||||
{server.toolCount === 1 ? t('tool') : t('tools')}
|
||||
{!!server.invalidToolCount && server.invalidToolCount > 0 && (
|
||||
<Text color={theme.status.warning}>
|
||||
{' '}
|
||||
({server.invalidToolCount}{' '}
|
||||
{server.invalidToolCount === 1 ? t('invalid') : t('invalid')})
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{server.errorMessage && (
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH}>
|
||||
<Text color={theme.status.error}>{t('Error:')}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.status.error} wrap="wrap">
|
||||
{server.errorMessage}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 操作列表 */}
|
||||
<Box>
|
||||
<RadioButtonSelect<ServerAction>
|
||||
items={actions}
|
||||
onHighlight={(value: ServerAction) => setSelectedAction(value)}
|
||||
onSelect={(value: ServerAction) => {
|
||||
switch (value) {
|
||||
case 'view-tools':
|
||||
onViewTools();
|
||||
break;
|
||||
case 'reconnect':
|
||||
onReconnect?.();
|
||||
break;
|
||||
case 'toggle-disable':
|
||||
onDisable?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
185
packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx
Normal file
185
packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { useKeypress } from '../../../hooks/useKeypress.js';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
import type { ServerListStepProps, MCPServerDisplayInfo } from '../types.js';
|
||||
import {
|
||||
groupServersBySource,
|
||||
getStatusIcon,
|
||||
getStatusColor,
|
||||
} from '../utils.js';
|
||||
|
||||
export const ServerListStep: React.FC<ServerListStepProps> = ({
|
||||
servers,
|
||||
onSelect,
|
||||
}) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const groupedServers = useMemo(
|
||||
() => groupServersBySource(servers),
|
||||
[servers],
|
||||
);
|
||||
|
||||
// 动态计算服务器名称列的最大宽度(基于实际内容)
|
||||
const serverNameWidth = useMemo(() => {
|
||||
if (servers.length === 0) return 20;
|
||||
const maxLength = Math.max(...servers.map((s) => s.name.length));
|
||||
// 最小 20,最大 35,留一些余量
|
||||
return Math.min(Math.max(maxLength + 2, 20), 35);
|
||||
}, [servers]);
|
||||
|
||||
// 计算扁平化的服务器列表用于导航
|
||||
const flatServers = useMemo(() => {
|
||||
const result: MCPServerDisplayInfo[] = [];
|
||||
for (const group of groupedServers) {
|
||||
result.push(...group.servers);
|
||||
}
|
||||
return result;
|
||||
}, [groupedServers]);
|
||||
|
||||
// 键盘导航
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'up') {
|
||||
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||
} else if (key.name === 'down') {
|
||||
setSelectedIndex((prev) => Math.min(flatServers.length - 1, prev + 1));
|
||||
} else if (key.name === 'return') {
|
||||
onSelect(selectedIndex);
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('No MCP servers configured.')}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Add MCP servers to your settings to get started.')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 计算当前选中项在分组中的位置
|
||||
const getSelectionPosition = (globalIndex: number) => {
|
||||
let currentIndex = 0;
|
||||
for (const group of groupedServers) {
|
||||
if (globalIndex < currentIndex + group.servers.length) {
|
||||
return {
|
||||
groupIndex: groupedServers.indexOf(group),
|
||||
itemIndex: globalIndex - currentIndex,
|
||||
};
|
||||
}
|
||||
currentIndex += group.servers.length;
|
||||
}
|
||||
return { groupIndex: 0, itemIndex: 0 };
|
||||
};
|
||||
|
||||
const currentPosition = getSelectionPosition(selectedIndex);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* 服务器统计 */}
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{servers.length} {servers.length === 1 ? t('server') : t('servers')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 分组服务器列表 */}
|
||||
{groupedServers.map((group, groupIndex) => (
|
||||
<Box key={group.source} flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{group.displayName}
|
||||
{group.servers[0]?.configPath && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}
|
||||
({group.servers[0].configPath})
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{group.servers.map((server, itemIndex) => {
|
||||
const isSelected =
|
||||
groupIndex === currentPosition.groupIndex &&
|
||||
itemIndex === currentPosition.itemIndex;
|
||||
const statusColor = getStatusColor(server.status);
|
||||
|
||||
return (
|
||||
<Box key={server.name}>
|
||||
<Box minWidth={2}>
|
||||
<Text
|
||||
color={
|
||||
isSelected ? theme.text.accent : theme.text.primary
|
||||
}
|
||||
>
|
||||
{isSelected ? '❯' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
{/* 服务器名称 - 固定宽度 */}
|
||||
<Box width={serverNameWidth}>
|
||||
<Text
|
||||
color={
|
||||
isSelected ? theme.text.accent : theme.text.primary
|
||||
}
|
||||
wrap="truncate"
|
||||
>
|
||||
{server.name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.secondary}> · </Text>
|
||||
{/* 状态图标和文本 */}
|
||||
<Text
|
||||
color={
|
||||
statusColor === 'green'
|
||||
? theme.status.success
|
||||
: statusColor === 'yellow'
|
||||
? theme.status.warning
|
||||
: theme.status.error
|
||||
}
|
||||
>
|
||||
{getStatusIcon(server.status)} {t(server.status)}
|
||||
</Text>
|
||||
{/* 显示 Scope 和禁用状态 */}
|
||||
<Text color={theme.text.secondary}> [{server.scope}]</Text>
|
||||
{server.isDisabled && (
|
||||
<Text color={theme.status.warning}> {t('(disabled)')}</Text>
|
||||
)}
|
||||
{/* 显示无效工具警告 */}
|
||||
{!!server.invalidToolCount && server.invalidToolCount > 0 && (
|
||||
<Text color={theme.status.warning}>
|
||||
{' '}
|
||||
{t('{{count}} invalid tools', {
|
||||
count: String(server.invalidToolCount),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* 提示信息 */}
|
||||
{servers.some((s) => s.status === 'disconnected') && (
|
||||
<Box>
|
||||
<Text color={theme.status.warning}>
|
||||
※ {t('Run qwen --debug to see error logs')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
217
packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx
Normal file
217
packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { useKeypress } from '../../../hooks/useKeypress.js';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
import type { ToolDetailStepProps } from '../types.js';
|
||||
|
||||
/**
|
||||
* 截断过长的字符串
|
||||
*/
|
||||
const truncate = (str: string, maxLen: number = 50): string => {
|
||||
if (str.length <= maxLen) return str;
|
||||
return str.substring(0, maxLen - 3) + '...';
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染单个参数
|
||||
*/
|
||||
const renderParameter = (
|
||||
name: string,
|
||||
param: Record<string, unknown>,
|
||||
isRequired: boolean,
|
||||
): React.ReactNode => {
|
||||
const type = (param['type'] as string) || 'any';
|
||||
const description = (param['description'] as string) || '';
|
||||
const defaultValue = param['default'];
|
||||
const enumValues = param['enum'] as string[] | undefined;
|
||||
|
||||
return (
|
||||
<Box key={name} flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>• {name}</Text>
|
||||
{isRequired && (
|
||||
<Text color={theme.status.error}> ({t('required')})</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>{t('Type')}: </Text>
|
||||
<Text color={theme.status.success}>{type}</Text>
|
||||
</Box>
|
||||
{description && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{truncate(description, 80)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{enumValues && enumValues.length > 0 && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enum')}: {enumValues.join(', ')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{defaultValue !== undefined && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Default')}:{' '}
|
||||
{typeof defaultValue === 'string'
|
||||
? `"${truncate(defaultValue, 30)}"`
|
||||
: String(defaultValue)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染参数列表
|
||||
*/
|
||||
const ParametersList: React.FC<{
|
||||
properties: Record<string, unknown>;
|
||||
required: string[];
|
||||
}> = ({ properties, required }) => {
|
||||
const requiredSet = new Set(required);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.secondary}>{t('Parameters')}:</Text>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
{Object.entries(properties).map(([name, param]) =>
|
||||
renderParameter(
|
||||
name,
|
||||
param as Record<string, unknown>,
|
||||
requiredSet.has(name),
|
||||
),
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 提取并展示schema的关键信息,使用类似示例的格式
|
||||
*/
|
||||
const SchemaSummary: React.FC<{ schema: object }> = ({ schema }) => {
|
||||
const obj = schema as Record<string, unknown>;
|
||||
const properties = obj['properties'] as Record<string, unknown> | undefined;
|
||||
const required = (obj['required'] as string[]) || [];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* 参数列表 */}
|
||||
{properties && Object.keys(properties).length > 0 && (
|
||||
<ParametersList properties={properties} required={required} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolDetailStep: React.FC<ToolDetailStepProps> = ({
|
||||
tool,
|
||||
onBack,
|
||||
}) => {
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onBack();
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
if (!tool) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>{t('No tool selected')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{/* 无效工具警告 */}
|
||||
{!tool.isValid && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.status.error} bold>
|
||||
{t('Warning: This tool cannot be called by the LLM')}
|
||||
</Text>
|
||||
<Text color={theme.status.error}>
|
||||
{t('Reason')}: {tool.invalidReason || t('unknown')}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t(
|
||||
'Tools must have both name and description to be used by the LLM.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 工具描述 */}
|
||||
{tool.description && (
|
||||
<Box>
|
||||
<Text wrap="wrap">{tool.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 工具注解 */}
|
||||
{tool.annotations && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Annotations')}:</Text>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
{tool.annotations.title && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Title')}: {tool.annotations.title}
|
||||
</Text>
|
||||
)}
|
||||
{tool.annotations.readOnlyHint !== undefined && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Read Only')}:{' '}
|
||||
{tool.annotations.readOnlyHint ? t('Yes') : t('No')}
|
||||
</Text>
|
||||
)}
|
||||
{tool.annotations.destructiveHint !== undefined && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Destructive')}:{' '}
|
||||
{tool.annotations.destructiveHint ? t('Yes') : t('No')}
|
||||
</Text>
|
||||
)}
|
||||
{tool.annotations.idempotentHint !== undefined && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Idempotent')}:{' '}
|
||||
{tool.annotations.idempotentHint ? t('Yes') : t('No')}
|
||||
</Text>
|
||||
)}
|
||||
{tool.annotations.openWorldHint !== undefined && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Open World')}:{' '}
|
||||
{tool.annotations.openWorldHint ? t('Yes') : t('No')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Schema */}
|
||||
{tool.schema && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<SchemaSummary schema={tool.schema} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 所属服务器 */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Server')}: {tool.serverName}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
157
packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx
Normal file
157
packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { useKeypress } from '../../../hooks/useKeypress.js';
|
||||
import { t } from '../../../../i18n/index.js';
|
||||
import type { ToolListStepProps, MCPToolDisplayInfo } from '../types.js';
|
||||
import { VISIBLE_TOOLS_COUNT } from '../constants.js';
|
||||
|
||||
export const ToolListStep: React.FC<ToolListStepProps> = ({
|
||||
tools,
|
||||
serverName,
|
||||
onSelect,
|
||||
onBack,
|
||||
}) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
// 动态计算工具名称列的最大宽度(基于实际内容)
|
||||
const toolNameWidth = useMemo(() => {
|
||||
if (tools.length === 0) return 30;
|
||||
const maxLength = Math.max(...tools.map((t) => t.name.length));
|
||||
// 最小 30,最大 50,留一些余量
|
||||
return Math.min(Math.max(maxLength + 2, 30), 50);
|
||||
}, [tools]);
|
||||
|
||||
// 计算可视区域的起始索引(滚动窗口)
|
||||
const scrollOffset = useMemo(() => {
|
||||
if (tools.length <= VISIBLE_TOOLS_COUNT) {
|
||||
return 0;
|
||||
}
|
||||
// 确保选中项在可视区域内
|
||||
if (selectedIndex < VISIBLE_TOOLS_COUNT - 1) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(
|
||||
selectedIndex - VISIBLE_TOOLS_COUNT + 1,
|
||||
tools.length - VISIBLE_TOOLS_COUNT,
|
||||
);
|
||||
}, [selectedIndex, tools.length]);
|
||||
|
||||
// 当前可视的工具列表
|
||||
const displayTools = useMemo(
|
||||
() => tools.slice(scrollOffset, scrollOffset + VISIBLE_TOOLS_COUNT),
|
||||
[tools, scrollOffset],
|
||||
);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onBack();
|
||||
} else if (key.name === 'up') {
|
||||
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
||||
} else if (key.name === 'down') {
|
||||
setSelectedIndex((prev) => Math.min(tools.length - 1, prev + 1));
|
||||
} else if (key.name === 'return') {
|
||||
if (tools[selectedIndex]) {
|
||||
onSelect(tools[selectedIndex]);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
if (tools.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('No tools available for this server.')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const getToolAnnotations = (tool: MCPToolDisplayInfo): string => {
|
||||
const hints: string[] = [];
|
||||
if (tool.annotations?.destructiveHint) hints.push(t('destructive'));
|
||||
if (tool.annotations?.readOnlyHint) hints.push(t('read-only'));
|
||||
if (tool.annotations?.openWorldHint) hints.push(t('open-world'));
|
||||
if (tool.annotations?.idempotentHint) hints.push(t('idempotent'));
|
||||
return hints.join(', ');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* 标题 */}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>{t('Tools for {{name}}', { name: serverName })}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}
|
||||
({tools.length} {tools.length === 1 ? t('tool') : t('tools')})
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 工具列表 */}
|
||||
<Box flexDirection="column">
|
||||
{displayTools.map((tool, index) => {
|
||||
const actualIndex = scrollOffset + index;
|
||||
const isSelected = actualIndex === selectedIndex;
|
||||
const annotations = getToolAnnotations(tool);
|
||||
|
||||
return (
|
||||
<Box key={tool.name}>
|
||||
{/* 选择器和序号 */}
|
||||
<Box minWidth={4}>
|
||||
<Text
|
||||
color={isSelected ? theme.text.accent : theme.text.primary}
|
||||
>
|
||||
{isSelected ? '❯' : ' '}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>{actualIndex + 1}.</Text>
|
||||
</Box>
|
||||
{/* 工具名称 - 固定宽度 */}
|
||||
<Box width={toolNameWidth}>
|
||||
<Text
|
||||
color={isSelected ? theme.text.accent : theme.text.primary}
|
||||
wrap="truncate"
|
||||
>
|
||||
{tool.name}
|
||||
</Text>
|
||||
</Box>
|
||||
{/* 显示无效工具警告 */}
|
||||
{!tool.isValid && (
|
||||
<Text color={theme.status.warning}>
|
||||
{t('invalid: {{reason}}', {
|
||||
reason: tool.invalidReason || t('unknown'),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
{annotations && tool.isValid && (
|
||||
<Text color={theme.text.secondary}>{annotations}</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* 滚动提示 */}
|
||||
{tools.length > VISIBLE_TOOLS_COUNT && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{scrollOffset > 0 ? '↑ ' : ' '}
|
||||
{t('{{current}}/{{total}}', {
|
||||
current: (selectedIndex + 1).toString(),
|
||||
total: tools.length.toString(),
|
||||
})}
|
||||
{scrollOffset + VISIBLE_TOOLS_COUNT < tools.length ? ' ↓' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
180
packages/cli/src/ui/components/mcp/types.ts
Normal file
180
packages/cli/src/ui/components/mcp/types.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
MCPServerStatus,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
/**
|
||||
* MCP管理步骤定义
|
||||
*/
|
||||
export const MCP_MANAGEMENT_STEPS = {
|
||||
SERVER_LIST: 'server-list',
|
||||
SERVER_DETAIL: 'server-detail',
|
||||
DISABLE_SCOPE_SELECT: 'disable-scope-select',
|
||||
TOOL_LIST: 'tool-list',
|
||||
TOOL_DETAIL: 'tool-detail',
|
||||
} as const;
|
||||
|
||||
export type MCPManagementStep =
|
||||
(typeof MCP_MANAGEMENT_STEPS)[keyof typeof MCP_MANAGEMENT_STEPS];
|
||||
|
||||
/**
|
||||
* MCP服务器显示信息
|
||||
*/
|
||||
export interface MCPServerDisplayInfo {
|
||||
/** 服务器名称 */
|
||||
name: string;
|
||||
/** 连接状态 */
|
||||
status: MCPServerStatus;
|
||||
/** 来源类型 */
|
||||
source: 'user' | 'project' | 'extension';
|
||||
/** 配置所在的 scope */
|
||||
scope: 'user' | 'workspace' | 'extension';
|
||||
/** 配置文件路径 */
|
||||
configPath?: string;
|
||||
/** 服务器配置 */
|
||||
config: MCPServerConfig;
|
||||
/** 工具数量 */
|
||||
toolCount: number;
|
||||
/** 无效工具数量(缺少name或description) */
|
||||
invalidToolCount?: number;
|
||||
/** Prompt数量 */
|
||||
promptCount: number;
|
||||
/** 错误信息 */
|
||||
errorMessage?: string;
|
||||
/** 是否被禁用(在排除列表中) */
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP工具显示信息
|
||||
*/
|
||||
export interface MCPToolDisplayInfo {
|
||||
/** 工具名称 */
|
||||
name: string;
|
||||
/** 工具描述 */
|
||||
description?: string;
|
||||
/** 所属服务器 */
|
||||
serverName: string;
|
||||
/** 工具schema */
|
||||
schema?: object;
|
||||
/** 工具注解 */
|
||||
annotations?: {
|
||||
title?: string;
|
||||
readOnlyHint?: boolean;
|
||||
destructiveHint?: boolean;
|
||||
idempotentHint?: boolean;
|
||||
openWorldHint?: boolean;
|
||||
};
|
||||
/** 工具是否有效(有name和description才能被LLM调用) */
|
||||
isValid: boolean;
|
||||
/** 无效原因(当isValid为false时) */
|
||||
invalidReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Prompt显示信息
|
||||
*/
|
||||
export interface MCPPromptDisplayInfo {
|
||||
/** Prompt名称 */
|
||||
name: string;
|
||||
/** Prompt描述 */
|
||||
description?: string;
|
||||
/** 所属服务器 */
|
||||
serverName: string;
|
||||
/** 参数定义 */
|
||||
arguments?: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组后的服务器列表
|
||||
*/
|
||||
export interface GroupedServers {
|
||||
/** 来源标识 */
|
||||
source: string;
|
||||
/** 来源显示名称 */
|
||||
displayName: string;
|
||||
/** 配置文件路径 */
|
||||
configPath?: string;
|
||||
/** 服务器列表 */
|
||||
servers: MCPServerDisplayInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* ServerListStep组件属性
|
||||
*/
|
||||
export interface ServerListStepProps {
|
||||
/** 服务器列表 */
|
||||
servers: MCPServerDisplayInfo[];
|
||||
/** 选择回调 */
|
||||
onSelect: (index: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ServerDetailStep组件属性
|
||||
*/
|
||||
export interface ServerDetailStepProps {
|
||||
/** 选中的服务器 */
|
||||
server: MCPServerDisplayInfo | null;
|
||||
/** 查看工具列表回调 */
|
||||
onViewTools: () => void;
|
||||
/** 重新连接回调 */
|
||||
onReconnect?: () => void;
|
||||
/** 禁用服务器回调 */
|
||||
onDisable?: () => void;
|
||||
/** 返回回调 */
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* DisableScopeSelectStep组件属性
|
||||
*/
|
||||
export interface DisableScopeSelectStepProps {
|
||||
/** 选中的服务器 */
|
||||
server: MCPServerDisplayInfo | null;
|
||||
/** 选择 scope 回调 */
|
||||
onSelectScope: (scope: 'user' | 'workspace') => void;
|
||||
/** 返回回调 */
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ToolListStep组件属性
|
||||
*/
|
||||
export interface ToolListStepProps {
|
||||
/** 工具列表 */
|
||||
tools: MCPToolDisplayInfo[];
|
||||
/** 服务器名称 */
|
||||
serverName: string;
|
||||
/** 选择回调 */
|
||||
onSelect: (tool: MCPToolDisplayInfo) => void;
|
||||
/** 返回回调 */
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ToolDetailStep组件属性
|
||||
*/
|
||||
export interface ToolDetailStepProps {
|
||||
/** 工具信息 */
|
||||
tool: MCPToolDisplayInfo | null;
|
||||
/** 返回回调 */
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP管理对话框属性
|
||||
*/
|
||||
export interface MCPManagementDialogProps {
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
}
|
||||
159
packages/cli/src/ui/components/mcp/utils.test.ts
Normal file
159
packages/cli/src/ui/components/mcp/utils.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
groupServersBySource,
|
||||
getStatusColor,
|
||||
getStatusIcon,
|
||||
truncateText,
|
||||
formatServerCommand,
|
||||
isToolValid,
|
||||
getToolInvalidReasons,
|
||||
} from './utils.js';
|
||||
import type { MCPServerDisplayInfo } from './types.js';
|
||||
import { MCPServerStatus } from '@qwen-code/qwen-code-core';
|
||||
|
||||
describe('MCP utils', () => {
|
||||
describe('groupServersBySource', () => {
|
||||
it('should group servers by source', () => {
|
||||
const servers: MCPServerDisplayInfo[] = [
|
||||
{
|
||||
name: 'server1',
|
||||
status: MCPServerStatus.CONNECTED,
|
||||
source: 'user',
|
||||
scope: 'user',
|
||||
config: { command: 'cmd1' },
|
||||
toolCount: 1,
|
||||
promptCount: 0,
|
||||
isDisabled: false,
|
||||
},
|
||||
{
|
||||
name: 'server2',
|
||||
status: MCPServerStatus.CONNECTED,
|
||||
source: 'extension',
|
||||
scope: 'extension',
|
||||
config: { command: 'cmd2' },
|
||||
toolCount: 2,
|
||||
promptCount: 0,
|
||||
isDisabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const result = groupServersBySource(servers);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].source).toBe('user');
|
||||
expect(result[0].servers).toHaveLength(1);
|
||||
expect(result[1].source).toBe('extension');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatusColor', () => {
|
||||
it('should return correct colors for each status', () => {
|
||||
expect(getStatusColor(MCPServerStatus.CONNECTED)).toBe('green');
|
||||
expect(getStatusColor(MCPServerStatus.CONNECTING)).toBe('yellow');
|
||||
expect(getStatusColor(MCPServerStatus.DISCONNECTED)).toBe('red');
|
||||
expect(getStatusColor('unknown' as MCPServerStatus)).toBe('gray');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatusIcon', () => {
|
||||
it('should return correct icons for each status', () => {
|
||||
expect(getStatusIcon(MCPServerStatus.CONNECTED)).toBe('✓');
|
||||
expect(getStatusIcon(MCPServerStatus.CONNECTING)).toBe('…');
|
||||
expect(getStatusIcon(MCPServerStatus.DISCONNECTED)).toBe('✗');
|
||||
expect(getStatusIcon('unknown' as MCPServerStatus)).toBe('?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateText', () => {
|
||||
it('should truncate text longer than maxLength', () => {
|
||||
expect(truncateText('hello world', 8)).toBe('hello...');
|
||||
});
|
||||
|
||||
it('should not truncate text shorter than maxLength', () => {
|
||||
expect(truncateText('hello', 10)).toBe('hello');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatServerCommand', () => {
|
||||
it('should format http URL', () => {
|
||||
const server = {
|
||||
config: { httpUrl: 'http://localhost:3000' },
|
||||
} as MCPServerDisplayInfo;
|
||||
expect(formatServerCommand(server)).toBe('http://localhost:3000 (http)');
|
||||
});
|
||||
|
||||
it('should format stdio command', () => {
|
||||
const server = {
|
||||
config: { command: 'node', args: ['server.js'] },
|
||||
} as MCPServerDisplayInfo;
|
||||
expect(formatServerCommand(server)).toBe('node server.js (stdio)');
|
||||
});
|
||||
|
||||
it('should return Unknown for empty config', () => {
|
||||
const server = { config: {} } as MCPServerDisplayInfo;
|
||||
expect(formatServerCommand(server)).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isToolValid', () => {
|
||||
it('should return true for valid tool with name and description', () => {
|
||||
expect(isToolValid('toolName', 'A description')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for tool without name', () => {
|
||||
expect(isToolValid(undefined, 'A description')).toBe(false);
|
||||
expect(isToolValid('', 'A description')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for tool without description', () => {
|
||||
expect(isToolValid('toolName', undefined)).toBe(false);
|
||||
expect(isToolValid('toolName', '')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for tool without both name and description', () => {
|
||||
expect(isToolValid(undefined, undefined)).toBe(false);
|
||||
expect(isToolValid('', '')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolInvalidReasons', () => {
|
||||
it('should return empty array for valid tool', () => {
|
||||
expect(getToolInvalidReasons('toolName', 'A description')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return missing name reason', () => {
|
||||
expect(getToolInvalidReasons(undefined, 'A description')).toEqual([
|
||||
'missing name',
|
||||
]);
|
||||
expect(getToolInvalidReasons('', 'A description')).toEqual([
|
||||
'missing name',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return missing description reason', () => {
|
||||
expect(getToolInvalidReasons('toolName', undefined)).toEqual([
|
||||
'missing description',
|
||||
]);
|
||||
expect(getToolInvalidReasons('toolName', '')).toEqual([
|
||||
'missing description',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return both reasons when both are missing', () => {
|
||||
expect(getToolInvalidReasons(undefined, undefined)).toEqual([
|
||||
'missing name',
|
||||
'missing description',
|
||||
]);
|
||||
expect(getToolInvalidReasons('', '')).toEqual([
|
||||
'missing name',
|
||||
'missing description',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
129
packages/cli/src/ui/components/mcp/utils.ts
Normal file
129
packages/cli/src/ui/components/mcp/utils.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { MCPServerDisplayInfo, GroupedServers } from './types.js';
|
||||
import { SOURCE_DISPLAY_NAMES } from './constants.js';
|
||||
|
||||
/**
|
||||
* 按来源分组服务器
|
||||
*/
|
||||
export function groupServersBySource(
|
||||
servers: MCPServerDisplayInfo[],
|
||||
): GroupedServers[] {
|
||||
const groups = new Map<string, MCPServerDisplayInfo[]>();
|
||||
|
||||
for (const server of servers) {
|
||||
const existing = groups.get(server.source);
|
||||
if (existing) {
|
||||
existing.push(server);
|
||||
} else {
|
||||
groups.set(server.source, [server]);
|
||||
}
|
||||
}
|
||||
|
||||
// 按优先级排序: user > project > extension
|
||||
const sourceOrder = ['user', 'project', 'extension'];
|
||||
const result: GroupedServers[] = [];
|
||||
|
||||
for (const source of sourceOrder) {
|
||||
const servers = groups.get(source);
|
||||
if (servers && servers.length > 0) {
|
||||
result.push({
|
||||
source,
|
||||
displayName: SOURCE_DISPLAY_NAMES[source] || source,
|
||||
servers,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态颜色
|
||||
*/
|
||||
export function getStatusColor(
|
||||
status: string,
|
||||
): 'green' | 'yellow' | 'red' | 'gray' {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'green';
|
||||
case 'connecting':
|
||||
return 'yellow';
|
||||
case 'disconnected':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态图标
|
||||
*/
|
||||
export function getStatusIcon(status: string): string {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return '✓';
|
||||
case 'connecting':
|
||||
return '…';
|
||||
case 'disconnected':
|
||||
return '✗';
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断文本
|
||||
*/
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化服务器命令显示
|
||||
*/
|
||||
export function formatServerCommand(server: MCPServerDisplayInfo): string {
|
||||
const config = server.config;
|
||||
if (config.httpUrl) {
|
||||
return `${config.httpUrl} (http)`;
|
||||
}
|
||||
if (config.url) {
|
||||
return `${config.url} (sse)`;
|
||||
}
|
||||
if (config.command) {
|
||||
const args = config.args?.join(' ') || '';
|
||||
return `${config.command} ${args} (stdio)`.trim();
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool is valid (has both name and description required by LLM)
|
||||
* @param name - Tool name
|
||||
* @param description - Tool description
|
||||
* @returns boolean indicating if the tool is valid
|
||||
*/
|
||||
export function isToolValid(name?: string, description?: string): boolean {
|
||||
return !!name && !!description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the reason why a tool is invalid
|
||||
* @param name - Tool name
|
||||
* @param description - Tool description
|
||||
* @returns Array of missing fields
|
||||
*/
|
||||
export function getToolInvalidReasons(
|
||||
name?: string,
|
||||
description?: string,
|
||||
): string[] {
|
||||
const reasons: string[] = [];
|
||||
if (!name) reasons.push('missing name');
|
||||
if (!description) reasons.push('missing description');
|
||||
return reasons;
|
||||
}
|
||||
|
|
@ -130,7 +130,7 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
expect(output).toContain('Switch tabs');
|
||||
});
|
||||
|
||||
it('renders multi-select with checkboxes and submit option', () => {
|
||||
it('renders multi-select with checkboxes', () => {
|
||||
const details = createConfirmationDetails({
|
||||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
|
@ -145,8 +145,8 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('[ ]');
|
||||
expect(output).toContain('Submit');
|
||||
expect(output).toContain('Space/Enter: Toggle');
|
||||
expect(output).toContain('Space: Toggle');
|
||||
expect(output).toContain('Enter: Confirm');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -322,29 +322,7 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
unmount();
|
||||
});
|
||||
|
||||
it('toggles options with Enter', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Enter to toggle first option
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(lastFrame()).toContain('[✓]');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('selects option with Space and submits for multi-select question', async () => {
|
||||
it('submits multi-select with Space to toggle then Enter to confirm', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
|
|
@ -362,13 +340,7 @@ describe('<AskUserQuestionDialog />', () => {
|
|||
stdin.write(' ');
|
||||
await wait();
|
||||
|
||||
// Move to "Submit" option (3 options + custom input + submit)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
stdin.write('\u001B[B');
|
||||
await wait();
|
||||
}
|
||||
|
||||
// Space on submit option should submit selected values
|
||||
// Enter to confirm and submit
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
|
|
|
|||
|
|
@ -57,13 +57,8 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
|
|||
? null
|
||||
: confirmationDetails.questions[currentQuestionIndex];
|
||||
const isMultiSelect = currentQuestion?.multiSelect ?? false;
|
||||
// Multi-select: options + custom input + submit; Single-select: options + custom input
|
||||
const totalOptions = currentQuestion
|
||||
? currentQuestion.options.length + (isMultiSelect ? 2 : 1)
|
||||
: 2;
|
||||
const submitOptionIndex = currentQuestion
|
||||
? currentQuestion.options.length + 1
|
||||
: -1;
|
||||
// Options + custom input ("Other")
|
||||
const totalOptions = currentQuestion ? currentQuestion.options.length + 1 : 2;
|
||||
|
||||
// Check if the custom input option is selected
|
||||
const isCustomInputSelected =
|
||||
|
|
@ -246,9 +241,6 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
|
|||
}));
|
||||
}
|
||||
}
|
||||
if (selectedIndex === submitOptionIndex) {
|
||||
handleMultiSelectSubmit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -266,32 +258,13 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// Handle multi-select
|
||||
// Handle multi-select: Enter advances to next question / submits
|
||||
if (isMultiSelect && currentQuestion) {
|
||||
// Custom input is handled by TextInput's onSubmit
|
||||
if (selectedIndex === currentQuestion.options.length) {
|
||||
return;
|
||||
}
|
||||
// Submit option
|
||||
if (selectedIndex === submitOptionIndex) {
|
||||
handleMultiSelectSubmit();
|
||||
return;
|
||||
}
|
||||
// Toggle predefined option (same as Space)
|
||||
if (selectedIndex < currentQuestion.options.length) {
|
||||
const option = currentQuestion.options[selectedIndex];
|
||||
if (option) {
|
||||
const current = multiSelectedOptions[currentQuestionIndex] ?? [];
|
||||
const isChecked = current.includes(option.label);
|
||||
const updated = isChecked
|
||||
? current.filter((l) => l !== option.label)
|
||||
: [...current, option.label];
|
||||
setMultiSelectedOptions((prev) => ({
|
||||
...prev,
|
||||
[currentQuestionIndex]: updated,
|
||||
}));
|
||||
}
|
||||
}
|
||||
handleMultiSelectSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -450,7 +423,7 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
|
|||
)}
|
||||
|
||||
{/* Question */}
|
||||
<Box marginBottom={1}>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{!hasMultipleQuestions && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.accent} bold>
|
||||
|
|
@ -572,23 +545,6 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
|
|||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Submit option for multi-select */}
|
||||
{isMultiSelect && (
|
||||
<Box>
|
||||
<Text
|
||||
color={
|
||||
selectedIndex === submitOptionIndex
|
||||
? theme.text.accent
|
||||
: theme.text.primary
|
||||
}
|
||||
bold={selectedIndex === submitOptionIndex}
|
||||
>
|
||||
{selectedIndex === submitOptionIndex ? '❯ ' : ' '}
|
||||
{submitOptionIndex + 1}. {t('Submit')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Help text */}
|
||||
|
|
@ -596,11 +552,17 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
|
|||
<Box>
|
||||
<Text dimColor>
|
||||
{hasMultipleQuestions
|
||||
? t(
|
||||
'↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel',
|
||||
)
|
||||
? isMultiSelect
|
||||
? t(
|
||||
'↑/↓: Navigate | ←/→: Switch tabs | Space: Toggle | Enter: Confirm | Esc: Cancel',
|
||||
)
|
||||
: t(
|
||||
'↑/↓: Navigate | ←/→: Switch tabs | Enter: Select | Esc: Cancel',
|
||||
)
|
||||
: isMultiSelect
|
||||
? t('↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel')
|
||||
? t(
|
||||
'↑/↓: Navigate | Space: Toggle | Enter: Confirm | Esc: Cancel',
|
||||
)
|
||||
: t('↑/↓: Navigate | Enter: Select | Esc: Cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1369,6 +1369,105 @@ describe('KeypressContext - Kitty Protocol', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Kitty keypad private-use keys', () => {
|
||||
it.each([
|
||||
{ keyCode: 57399, digit: '0' },
|
||||
{ keyCode: 57400, digit: '1' },
|
||||
{ keyCode: 57401, digit: '2' },
|
||||
{ keyCode: 57402, digit: '3' },
|
||||
{ keyCode: 57403, digit: '4' },
|
||||
{ keyCode: 57404, digit: '5' },
|
||||
{ keyCode: 57405, digit: '6' },
|
||||
{ keyCode: 57406, digit: '7' },
|
||||
{ keyCode: 57407, digit: '8' },
|
||||
{ keyCode: 57408, digit: '9' },
|
||||
])(
|
||||
'parses kitty keypad digit keyCode $keyCode as "$digit"',
|
||||
({ keyCode, digit }) => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
act(() => stdin.sendKittySequence(`\x1b[${keyCode}u`));
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: digit,
|
||||
sequence: digit,
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ keyCode: 57409, char: '.' },
|
||||
{ keyCode: 57410, char: '/' },
|
||||
{ keyCode: 57411, char: '*' },
|
||||
{ keyCode: 57412, char: '-' },
|
||||
{ keyCode: 57413, char: '+' },
|
||||
{ keyCode: 57415, char: '=' },
|
||||
{ keyCode: 57416, char: ',' },
|
||||
])(
|
||||
'parses kitty keypad printable keyCode $keyCode as "$char"',
|
||||
({ keyCode, char }) => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
act(() => stdin.sendKittySequence(`\x1b[${keyCode}u`));
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: char,
|
||||
sequence: char,
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ keyCode: 57417, name: 'left' },
|
||||
{ keyCode: 57418, name: 'right' },
|
||||
{ keyCode: 57419, name: 'up' },
|
||||
{ keyCode: 57420, name: 'down' },
|
||||
{ keyCode: 57421, name: 'pageup' },
|
||||
{ keyCode: 57422, name: 'pagedown' },
|
||||
{ keyCode: 57423, name: 'home' },
|
||||
{ keyCode: 57424, name: 'end' },
|
||||
{ keyCode: 57425, name: 'insert' },
|
||||
{ keyCode: 57426, name: 'delete' },
|
||||
])(
|
||||
'parses kitty keypad functional keyCode $keyCode as $name',
|
||||
({ keyCode, name }) => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
act(() => stdin.sendKittySequence(`\x1b[${keyCode};5u`));
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name,
|
||||
ctrl: true,
|
||||
kittyProtocol: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not emit a placeholder for unmapped private-use keyCodes', () => {
|
||||
const keyHandler = vi.fn();
|
||||
const { result } = renderHook(() => useKeypressContext(), { wrapper });
|
||||
act(() => result.current.subscribe(keyHandler));
|
||||
|
||||
act(() => stdin.sendKittySequence(`\x1b[57398u`));
|
||||
|
||||
expect(keyHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shift+Tab forms', () => {
|
||||
it.each([
|
||||
{ sequence: `\x1b[Z`, description: 'legacy reverse Tab' },
|
||||
|
|
|
|||
|
|
@ -47,6 +47,42 @@ export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100m
|
|||
export const SINGLE_QUOTE = "'";
|
||||
export const DOUBLE_QUOTE = '"';
|
||||
|
||||
// Kitty keypad private-use keycodes (0xE000-0xE026)
|
||||
// Reference: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional-key-definitions
|
||||
const KITTY_KEYPAD_PRINTABLE_KEYCODE_TO_CHAR: Record<number, string> = {
|
||||
57399: '0',
|
||||
57400: '1',
|
||||
57401: '2',
|
||||
57402: '3',
|
||||
57403: '4',
|
||||
57404: '5',
|
||||
57405: '6',
|
||||
57406: '7',
|
||||
57407: '8',
|
||||
57408: '9',
|
||||
57409: '.',
|
||||
57410: '/',
|
||||
57411: '*',
|
||||
57412: '-',
|
||||
57413: '+',
|
||||
// 57414 is keypad Enter - handled separately via CSI~ sequence
|
||||
57415: '=',
|
||||
57416: ',',
|
||||
};
|
||||
|
||||
const KITTY_KEYPAD_FUNCTIONAL_KEYCODE_TO_NAME: Record<number, string> = {
|
||||
57417: 'left',
|
||||
57418: 'right',
|
||||
57419: 'up',
|
||||
57420: 'down',
|
||||
57421: 'pageup',
|
||||
57422: 'pagedown',
|
||||
57423: 'home',
|
||||
57424: 'end',
|
||||
57425: 'insert',
|
||||
57426: 'delete',
|
||||
};
|
||||
|
||||
export interface Key {
|
||||
name: string;
|
||||
ctrl: boolean;
|
||||
|
|
@ -332,14 +368,52 @@ export function KeypressProvider({
|
|||
};
|
||||
}
|
||||
|
||||
if (!ctrl) {
|
||||
const keypadChar = KITTY_KEYPAD_PRINTABLE_KEYCODE_TO_CHAR[keyCode];
|
||||
if (keypadChar) {
|
||||
return {
|
||||
key: {
|
||||
name: keypadChar,
|
||||
ctrl: false,
|
||||
meta: alt,
|
||||
shift,
|
||||
paste: false,
|
||||
sequence: keypadChar,
|
||||
kittyProtocol: true,
|
||||
},
|
||||
length: m[0].length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const keypadName = KITTY_KEYPAD_FUNCTIONAL_KEYCODE_TO_NAME[keyCode];
|
||||
if (keypadName) {
|
||||
return {
|
||||
key: {
|
||||
name: keypadName,
|
||||
ctrl,
|
||||
meta: alt,
|
||||
shift,
|
||||
paste: false,
|
||||
sequence: buffer.slice(0, m[0].length),
|
||||
kittyProtocol: true,
|
||||
},
|
||||
length: m[0].length,
|
||||
};
|
||||
}
|
||||
|
||||
// Printable CSI-u keys (including space) should behave like regular
|
||||
// character input so downstream text inputs receive the literal char.
|
||||
// Kitty uses the Unicode private use area for some functional keys
|
||||
// such as keypad events, so exclude that range from generic printable
|
||||
// conversion and handle mapped keys explicitly above.
|
||||
if (
|
||||
terminator === 'u' &&
|
||||
!ctrl &&
|
||||
keyCode >= 32 &&
|
||||
keyCode !== 127 &&
|
||||
keyCode <= 0x10ffff
|
||||
keyCode <= 0x10ffff &&
|
||||
!(keyCode >= 0xe000 && keyCode <= 0xf8ff)
|
||||
) {
|
||||
const char = String.fromCodePoint(keyCode);
|
||||
const printableName =
|
||||
|
|
|
|||
|
|
@ -74,6 +74,10 @@ export interface UIActions {
|
|||
// Subagent dialogs
|
||||
closeSubagentCreateDialog: () => void;
|
||||
closeAgentsManagerDialog: () => void;
|
||||
// Extensions manager dialog
|
||||
closeExtensionsManagerDialog: () => void;
|
||||
// MCP dialog
|
||||
closeMcpDialog: () => void;
|
||||
// Resume session dialog
|
||||
openResumeDialog: () => void;
|
||||
closeResumeDialog: () => void;
|
||||
|
|
|
|||
|
|
@ -125,6 +125,10 @@ export interface UIState {
|
|||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen: boolean;
|
||||
isAgentsManagerDialogOpen: boolean;
|
||||
// Extensions manager dialog
|
||||
isExtensionsManagerDialogOpen: boolean;
|
||||
// MCP dialog
|
||||
isMcpDialogOpen: boolean;
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ interface SlashCommandProcessorActions {
|
|||
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
||||
openSubagentCreateDialog: () => void;
|
||||
openAgentsManagerDialog: () => void;
|
||||
openExtensionsManagerDialog: () => void;
|
||||
openMcpDialog: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -476,12 +478,18 @@ export const useSlashCommandProcessor = (
|
|||
case 'subagent_list':
|
||||
actions.openAgentsManagerDialog();
|
||||
return { type: 'handled' };
|
||||
case 'mcp':
|
||||
actions.openMcpDialog();
|
||||
return { type: 'handled' };
|
||||
case 'approval-mode':
|
||||
actions.openApprovalModeDialog();
|
||||
return { type: 'handled' };
|
||||
case 'resume':
|
||||
actions.openResumeDialog();
|
||||
return { type: 'handled' };
|
||||
case 'extensions_manage':
|
||||
actions.openExtensionsManagerDialog();
|
||||
return { type: 'handled' };
|
||||
case 'help':
|
||||
return { type: 'handled' };
|
||||
default: {
|
||||
|
|
|
|||
33
packages/cli/src/ui/hooks/useExtensionsManagerDialog.ts
Normal file
33
packages/cli/src/ui/hooks/useExtensionsManagerDialog.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface UseExtensionsManagerDialogReturn {
|
||||
isExtensionsManagerDialogOpen: boolean;
|
||||
openExtensionsManagerDialog: () => void;
|
||||
closeExtensionsManagerDialog: () => void;
|
||||
}
|
||||
|
||||
export const useExtensionsManagerDialog =
|
||||
(): UseExtensionsManagerDialogReturn => {
|
||||
const [isExtensionsManagerDialogOpen, setIsExtensionsManagerDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
const openExtensionsManagerDialog = useCallback(() => {
|
||||
setIsExtensionsManagerDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeExtensionsManagerDialog = useCallback(() => {
|
||||
setIsExtensionsManagerDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isExtensionsManagerDialogOpen,
|
||||
openExtensionsManagerDialog,
|
||||
closeExtensionsManagerDialog,
|
||||
};
|
||||
};
|
||||
31
packages/cli/src/ui/hooks/useMcpDialog.ts
Normal file
31
packages/cli/src/ui/hooks/useMcpDialog.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface UseMcpDialogReturn {
|
||||
isMcpDialogOpen: boolean;
|
||||
openMcpDialog: () => void;
|
||||
closeMcpDialog: () => void;
|
||||
}
|
||||
|
||||
export const useMcpDialog = (): UseMcpDialogReturn => {
|
||||
const [isMcpDialogOpen, setIsMcpDialogOpen] = useState(false);
|
||||
|
||||
const openMcpDialog = useCallback(() => {
|
||||
setIsMcpDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeMcpDialog = useCallback(() => {
|
||||
setIsMcpDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isMcpDialogOpen,
|
||||
openMcpDialog,
|
||||
closeMcpDialog,
|
||||
};
|
||||
};
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import type { Config, ChatRecord } from '@qwen-code/qwen-code-core';
|
||||
import type { SessionContext } from '../../../acp-integration/session/types.js';
|
||||
import type * as acp from '../../../acp-integration/acp.js';
|
||||
import type { SessionUpdate, ToolCall } from '@agentclientprotocol/sdk';
|
||||
import { HistoryReplayer } from '../../../acp-integration/session/HistoryReplayer.js';
|
||||
import type { ExportMessage, ExportSessionData } from './types.js';
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ class ExportSessionContext implements SessionContext {
|
|||
this.config = config;
|
||||
}
|
||||
|
||||
async sendUpdate(update: acp.SessionUpdate): Promise<void> {
|
||||
async sendUpdate(update: SessionUpdate): Promise<void> {
|
||||
switch (update.sessionUpdate) {
|
||||
case 'user_message_chunk':
|
||||
this.handleMessageChunk('user', update.content);
|
||||
|
|
@ -108,7 +108,7 @@ class ExportSessionContext implements SessionContext {
|
|||
}
|
||||
}
|
||||
|
||||
private handleToolCallStart(update: acp.ToolCall): void {
|
||||
private handleToolCallStart(update: ToolCall): void {
|
||||
const toolCall: ExportMessage['toolCall'] = {
|
||||
toolCallId: update.toolCallId,
|
||||
kind: update.kind || 'other',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue