wip(vscode-ide-companion): 实现 quick win 功能

- 将 WebView 调整到编辑器右侧
- 添加 ChatHeader 组件,实现会话下拉菜单
- 替换模态框为紧凑型下拉菜单
- 更新会话切换逻辑,显示当前标题
- 清理旧的会话选择器样式
基于 Claude Code v2.0.43 UI 分析实现。
This commit is contained in:
yiliang114
2025-11-19 00:16:45 +08:00
parent 729a3d0ab3
commit 732220e651
52 changed files with 16502 additions and 1420 deletions

View File

@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Qwen Team
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -11,11 +11,13 @@ import {
} from './agents/QwenAgentManager.js';
import { ConversationStore } from './storage/ConversationStore.js';
import type { AcpPermissionRequest } from './shared/acpTypes.js';
import { AuthStateManager } from './auth/AuthStateManager.js';
import { CliDetector } from './utils/CliDetector.js';
import { AuthStateManager } from './auth/AuthStateManager.js';
export class WebViewProvider {
private panel: vscode.WebviewPanel | null = null;
// Track the Webview tab (avoid pin/lock; use for reveal/visibility bookkeeping)
private panelTab: vscode.Tab | null = null;
private agentManager: QwenAgentManager;
private conversationStore: ConversationStore;
private authStateManager: AuthStateManager;
@@ -25,7 +27,7 @@ export class WebViewProvider {
private currentStreamContent = ''; // Track streaming content for saving
constructor(
private context: vscode.ExtensionContext,
context: vscode.ExtensionContext,
private extensionUri: vscode.Uri,
) {
this.agentManager = new QwenAgentManager();
@@ -41,6 +43,17 @@ export class WebViewProvider {
});
});
// Setup thought chunk handler
this.agentManager.onThoughtChunk((chunk: string) => {
this.currentStreamContent += chunk;
this.sendMessageToWebView({
type: 'thoughtChunk',
data: { chunk },
});
});
// Note: Tool call updates are handled in handleSessionUpdate within QwenAgentManager
// and sent via onStreamChunk callback
this.agentManager.onToolCall((update) => {
this.sendMessageToWebView({
type: 'toolCall',
@@ -51,6 +64,14 @@ export class WebViewProvider {
});
});
// Setup plan handler
this.agentManager.onPlan((entries) => {
this.sendMessageToWebView({
type: 'plan',
data: { entries },
});
});
this.agentManager.onPermissionRequest(
async (request: AcpPermissionRequest) => {
// Send permission request to WebView
@@ -78,22 +99,62 @@ export class WebViewProvider {
}
async show(): Promise<void> {
// Track if we're creating a new panel in a new column
let startedInNewColumn = false;
if (this.panel) {
this.panel.reveal();
// Reveal the existing panel via Tab API (Claude-style), fallback to panel.reveal
this.revealPanelTab(true);
this.capturePanelTab();
return;
}
// Mark that we're creating a new panel
startedInNewColumn = true;
this.panel = vscode.window.createWebviewPanel(
'qwenCode.chat',
'Qwen Code Chat',
vscode.ViewColumn.One,
{
viewColumn: vscode.ViewColumn.Beside, // Open on right side of active editor
preserveFocus: true, // Don't steal focus from editor
},
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, 'dist')],
localResourceRoots: [
vscode.Uri.joinPath(this.extensionUri, 'dist'),
vscode.Uri.joinPath(this.extensionUri, 'assets'),
],
},
);
// Capture the Tab that corresponds to our WebviewPanel (Claude-style)
this.capturePanelTab();
// Auto-lock editor group when opened in new column (Claude Code style)
if (startedInNewColumn) {
console.log(
'[WebViewProvider] Auto-locking editor group for Qwen Code chat',
);
try {
// Reveal panel without preserving focus to make it the active group
// This ensures the lock command targets the correct editor group
this.revealPanelTab(false);
await vscode.commands.executeCommand(
'workbench.action.lockEditorGroup',
);
console.log('[WebViewProvider] Editor group locked successfully');
} catch (error) {
console.warn('[WebViewProvider] Failed to lock editor group:', error);
// Non-fatal error, continue anyway
}
} else {
// For existing panel, reveal with preserving focus
this.revealPanelTab(true);
}
// Set panel icon to Qwen logo
this.panel.iconPath = vscode.Uri.joinPath(
this.extensionUri,
@@ -112,6 +173,17 @@ export class WebViewProvider {
this.disposables,
);
// Listen for view state changes (no pin/lock; just keep tab reference fresh)
this.panel.onDidChangeViewState(
() => {
if (this.panel && this.panel.visible) {
this.capturePanelTab();
}
},
null,
this.disposables,
);
this.panel.onDidDispose(
() => {
this.panel = null;
@@ -229,45 +301,28 @@ export class WebViewProvider {
private async loadCurrentSessionMessages(): Promise<void> {
try {
// Get the current active session ID
const currentSessionId = this.agentManager.currentSessionId;
if (!currentSessionId) {
console.log('[WebViewProvider] No active session, initializing empty');
await this.initializeEmptyConversation();
return;
}
console.log(
'[WebViewProvider] Loading messages from current session:',
currentSessionId,
'[WebViewProvider] Initializing with empty conversation and creating ACP session',
);
const messages =
await this.agentManager.getSessionMessages(currentSessionId);
// Set current conversation ID to the session ID
this.currentConversationId = currentSessionId;
// Create a new ACP session so user can send messages immediately
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
if (messages.length > 0) {
console.log(
'[WebViewProvider] Loaded',
messages.length,
'messages from current Qwen session',
try {
await this.agentManager.createNewSession(workingDir);
console.log('[WebViewProvider] ACP session created successfully');
} catch (sessionError) {
console.error(
'[WebViewProvider] Failed to create ACP session:',
sessionError,
);
this.sendMessageToWebView({
type: 'conversationLoaded',
data: { id: currentSessionId, messages },
});
} else {
// Session exists but has no messages - show empty conversation
console.log(
'[WebViewProvider] Current session has no messages, showing empty conversation',
vscode.window.showWarningMessage(
`Failed to create ACP session: ${sessionError}. You may need to authenticate first.`,
);
this.sendMessageToWebView({
type: 'conversationLoaded',
data: { id: currentSessionId, messages: [] },
});
}
await this.initializeEmptyConversation();
} catch (error) {
console.error(
'[WebViewProvider] Failed to load session messages:',
@@ -472,6 +527,10 @@ export class WebViewProvider {
await this.checkCliInstallation();
break;
case 'cancelPrompt':
await this.handleCancelPrompt();
break;
default:
console.warn('[WebViewProvider] Unknown message type:', message.type);
break;
@@ -481,8 +540,31 @@ export class WebViewProvider {
private async handleSendMessage(text: string): Promise<void> {
console.log('[WebViewProvider] handleSendMessage called with:', text);
// Ensure we have an active conversation - create one if needed
if (!this.currentConversationId) {
const errorMsg = 'No active conversation. Please restart the extension.';
console.log('[WebViewProvider] No active conversation, creating one...');
try {
await this.initializeEmptyConversation();
console.log(
'[WebViewProvider] Created conversation:',
this.currentConversationId,
);
} catch (error) {
const errorMsg = `Failed to create conversation: ${error}`;
console.error('[WebViewProvider]', errorMsg);
vscode.window.showErrorMessage(errorMsg);
this.sendMessageToWebView({
type: 'error',
data: { message: errorMsg },
});
return;
}
}
// Double check after creation attempt
if (!this.currentConversationId) {
const errorMsg =
'Failed to create conversation. Please restart the extension.';
console.error('[WebViewProvider]', errorMsg);
vscode.window.showErrorMessage(errorMsg);
this.sendMessageToWebView({
@@ -656,6 +738,18 @@ export class WebViewProvider {
messages.length,
);
// Get session details for the header
let sessionDetails = null;
try {
const allSessions = await this.agentManager.getSessionList();
sessionDetails = allSessions.find(
(s: { id?: string; sessionId?: string }) =>
s.id === sessionId || s.sessionId === sessionId,
);
} catch (err) {
console.log('[WebViewProvider] Could not get session details:', err);
}
// Try to switch session in ACP (may fail if not supported)
try {
await this.agentManager.switchToSession(sessionId);
@@ -681,10 +775,10 @@ export class WebViewProvider {
}
}
// Send messages to WebView
// Send messages and session details to WebView
this.sendMessageToWebView({
type: 'qwenSessionSwitched',
data: { sessionId, messages },
data: { sessionId, messages, session: sessionDetails },
});
} catch (error) {
console.error('[WebViewProvider] Failed to switch session:', error);
@@ -696,6 +790,36 @@ export class WebViewProvider {
}
}
/**
* Handle cancel prompt request from WebView
* Cancels the current AI response generation
*/
private async handleCancelPrompt(): Promise<void> {
try {
console.log('[WebViewProvider] Cancel prompt requested');
if (!this.agentManager.isConnected) {
console.warn('[WebViewProvider] Agent not connected, cannot cancel');
return;
}
await this.agentManager.cancelCurrentPrompt();
this.sendMessageToWebView({
type: 'promptCancelled',
data: { timestamp: Date.now() },
});
console.log('[WebViewProvider] Prompt cancelled successfully');
} catch (error) {
console.error('[WebViewProvider] Failed to cancel prompt:', error);
this.sendMessageToWebView({
type: 'error',
data: { message: `Failed to cancel: ${error}` },
});
}
}
private sendMessageToWebView(message: unknown): void {
this.panel?.webview.postMessage(message);
}
@@ -705,16 +829,21 @@ export class WebViewProvider {
vscode.Uri.joinPath(this.extensionUri, 'dist', 'webview.js'),
);
const iconUri = this.panel!.webview.asWebviewUri(
vscode.Uri.joinPath(this.extensionUri, 'assets', 'icon.png'),
);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src ${this.panel!.webview.cspSource}; style-src ${this.panel!.webview.cspSource} 'unsafe-inline';">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${this.panel!.webview.cspSource}; script-src ${this.panel!.webview.cspSource}; style-src ${this.panel!.webview.cspSource} 'unsafe-inline';">
<title>Qwen Code Chat</title>
</head>
<body>
<div id="root"></div>
<script>window.ICON_URI = "${iconUri}";</script>
<script src="${scriptUri}"></script>
</body>
</html>`;
@@ -731,6 +860,130 @@ export class WebViewProvider {
this.agentManager.disconnect();
}
/**
* Capture the VS Code Tab that corresponds to our WebviewPanel.
* We do not pin or lock the editor group, mirroring Claude's approach.
* Instead, we:
* - open beside the active editor
* - preserve focus to keep typing in the current file
* - keep a Tab reference for reveal/visibility bookkeeping if needed
*/
private capturePanelTab(): void {
if (!this.panel) {
return;
}
// Defer slightly so the tab model is updated after create/reveal
setTimeout(() => {
const allTabs = vscode.window.tabGroups.all.flatMap((g) => g.tabs);
const match = allTabs.find((t) => {
// Type guard for webview tab input
const input: unknown = (t as { input?: unknown }).input;
const isWebviewInput = (inp: unknown): inp is { viewType: string } =>
!!inp && typeof inp === 'object' && 'viewType' in inp;
const isWebview = isWebviewInput(input);
const sameViewType = isWebview && input.viewType === 'qwenCode.chat';
const sameLabel = t.label === this.panel!.title;
return !!(sameViewType || sameLabel);
});
this.panelTab = match ?? null;
}, 50);
}
/**
* Reveal the WebView panel (optionally preserving focus)
* We track the tab for bookkeeping, but use panel.reveal for actual reveal
*/
private revealPanelTab(preserveFocus: boolean = true): void {
if (this.panel) {
this.panel.reveal(vscode.ViewColumn.Beside, preserveFocus);
}
}
/**
* Restore an existing WebView panel (called during VSCode restart)
* This sets up the panel with all event listeners
*/
restorePanel(panel: vscode.WebviewPanel): void {
console.log('[WebViewProvider] Restoring WebView panel');
this.panel = panel;
// Set panel icon to Qwen logo
this.panel.iconPath = vscode.Uri.joinPath(
this.extensionUri,
'assets',
'icon.png',
);
// Set webview HTML
this.panel.webview.html = this.getWebviewContent();
// Handle messages from WebView
this.panel.webview.onDidReceiveMessage(
async (message) => {
await this.handleWebViewMessage(message);
},
null,
this.disposables,
);
// Listen for view state changes (track the tab only)
this.panel.onDidChangeViewState(
() => {
if (this.panel && this.panel.visible) {
this.capturePanelTab();
}
},
null,
this.disposables,
);
this.panel.onDidDispose(
() => {
this.panel = null;
this.disposables.forEach((d) => d.dispose());
},
null,
this.disposables,
);
// Track the tab reference on restore
this.capturePanelTab();
console.log('[WebViewProvider] Panel restored successfully');
}
/**
* Get the current state for serialization
* This is used when VSCode restarts to restore the WebView
*/
getState(): {
conversationId: string | null;
agentInitialized: boolean;
} {
return {
conversationId: this.currentConversationId,
agentInitialized: this.agentInitialized,
};
}
/**
* Restore state after VSCode restart
*/
restoreState(state: {
conversationId: string | null;
agentInitialized: boolean;
}): void {
console.log('[WebViewProvider] Restoring state:', state);
this.currentConversationId = state.conversationId;
this.agentInitialized = state.agentInitialized;
// Reload content after restore
if (this.panel) {
this.panel.webview.html = this.getWebviewContent();
}
}
dispose(): void {
this.panel?.dispose();
this.agentManager.disconnect();

View File

@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Qwen Team
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -8,36 +8,71 @@ import { JSONRPC_VERSION } from '../shared/acpTypes.js';
import type {
AcpBackend,
AcpMessage,
AcpNotification,
AcpPermissionRequest,
AcpRequest,
AcpResponse,
AcpSessionUpdate,
} from '../shared/acpTypes.js';
import type { ChildProcess, SpawnOptions } from 'child_process';
import { spawn } from 'child_process';
import type { PendingRequest, AcpConnectionCallbacks } from './AcpTypes.js';
import { AcpMessageHandler } from './AcpMessageHandler.js';
import { AcpSessionManager } from './AcpSessionManager.js';
interface PendingRequest<T = unknown> {
resolve: (value: T) => void;
reject: (error: Error) => void;
timeoutId?: NodeJS.Timeout;
method: string;
}
/**
* ACP Connection Handler for VSCode Extension
*
* This class implements the client side of the ACP (Agent Communication Protocol).
*
* Implementation Status:
*
* Client Methods (Methods this class implements, called by CLI):
* ✅ session/update - Handle session updates via onSessionUpdate callback
* ✅ session/request_permission - Request user permission for tool execution
* ✅ fs/read_text_file - Read file from workspace
* ✅ fs/write_text_file - Write file to workspace
*
* Agent Methods (Methods CLI implements, called by this class):
* ✅ initialize - Initialize ACP protocol connection
* ✅ authenticate - Authenticate with selected auth method
* ✅ session/new - Create new chat session
* ✅ session/prompt - Send user message to agent
* ✅ session/cancel - Cancel current generation
* ✅ session/load - Load previous session
*
* Custom Methods (Not in standard ACP):
* ⚠️ session/list - List available sessions (custom extension)
* ⚠️ session/switch - Switch to different session (custom extension)
*/
export class AcpConnection {
private child: ChildProcess | null = null;
private pendingRequests = new Map<number, PendingRequest<unknown>>();
private nextRequestId = 0;
private sessionId: string | null = null;
private isInitialized = false;
private nextRequestId = { value: 0 };
private backend: AcpBackend | null = null;
// 模块实例
private messageHandler: AcpMessageHandler;
private sessionManager: AcpSessionManager;
// 回调函数
onSessionUpdate: (data: AcpSessionUpdate) => void = () => {};
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
optionId: string;
}> = () => Promise.resolve({ optionId: 'allow' });
onEndTurn: () => void = () => {};
constructor() {
this.messageHandler = new AcpMessageHandler();
this.sessionManager = new AcpSessionManager();
}
/**
* 连接到ACP后端
*
* @param backend - 后端类型
* @param cliPath - CLI路径
* @param workingDir - 工作目录
* @param extraArgs - 额外的命令行参数
*/
async connect(
backend: AcpBackend,
cliPath: string,
@@ -53,8 +88,8 @@ export class AcpConnection {
const isWindows = process.platform === 'win32';
const env = { ...process.env };
// If proxy is configured in extraArgs, also set it as environment variables
// This ensures token refresh requests also use the proxy
// 如果在extraArgs中配置了代理也将其设置为环境变量
// 这确保token刷新请求也使用代理
const proxyArg = extraArgs.find(
(arg, i) => arg === '--proxy' && i + 1 < extraArgs.length,
);
@@ -63,18 +98,10 @@ export class AcpConnection {
const proxyUrl = extraArgs[proxyIndex + 1];
console.log('[ACP] Setting proxy environment variables:', proxyUrl);
// Set standard proxy env vars
env.HTTP_PROXY = proxyUrl;
env.HTTPS_PROXY = proxyUrl;
env.http_proxy = proxyUrl;
env.https_proxy = proxyUrl;
// For Node.js fetch (undici), we need to use NODE_OPTIONS with a custom agent
// Or use the global-agent package, but for now we'll rely on the --proxy flag
// and hope the CLI handles it properly for all requests
// Alternative: disable TLS verification for proxy (not recommended for production)
// env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
}
let spawnCommand: string;
@@ -102,13 +129,16 @@ export class AcpConnection {
await this.setupChildProcessHandlers(backend);
}
/**
* 设置子进程处理器
*
* @param backend - 后端名称
*/
private async setupChildProcessHandlers(backend: string): Promise<void> {
let spawnError: Error | null = null;
this.child!.stderr?.on('data', (data) => {
const message = data.toString();
// Many CLIs output informational messages to stderr, so use console.log instead of console.error
// Only treat it as error if it contains actual error keywords
if (
message.toLowerCase().includes('error') &&
!message.includes('Loaded cached')
@@ -129,7 +159,7 @@ export class AcpConnection {
);
});
// Wait for process to start
// 等待进程启动
await new Promise((resolve) => setTimeout(resolve, 1000));
if (spawnError) {
@@ -140,7 +170,7 @@ export class AcpConnection {
throw new Error(`${backend} ACP process failed to start`);
}
// Handle messages from ACP server
// 处理来自ACP服务器的消息
let buffer = '';
this.child.stdout?.on('data', (data) => {
buffer += data.toString();
@@ -153,373 +183,161 @@ export class AcpConnection {
const message = JSON.parse(line) as AcpMessage;
this.handleMessage(message);
} catch (_error) {
// Ignore non-JSON lines
// 忽略非JSON行
}
}
}
});
// Initialize protocol
await this.initialize();
}
private sendRequest<T = unknown>(
method: string,
params?: Record<string, unknown>,
): Promise<T> {
const id = this.nextRequestId++;
const message: AcpRequest = {
jsonrpc: JSONRPC_VERSION,
id,
method,
...(params && { params }),
};
return new Promise((resolve, reject) => {
const timeoutDuration = method === 'session/prompt' ? 120000 : 60000;
const timeoutId = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Request ${method} timed out`));
}, timeoutDuration);
const pendingRequest: PendingRequest<T> = {
resolve: (value: T) => {
clearTimeout(timeoutId);
resolve(value);
},
reject: (error: Error) => {
clearTimeout(timeoutId);
reject(error);
},
timeoutId,
method,
};
this.pendingRequests.set(id, pendingRequest as PendingRequest<unknown>);
this.sendMessage(message);
});
}
private sendMessage(message: AcpRequest | AcpNotification): void {
if (this.child?.stdin) {
const jsonString = JSON.stringify(message);
const lineEnding = process.platform === 'win32' ? '\r\n' : '\n';
this.child.stdin.write(jsonString + lineEnding);
}
}
private sendResponseMessage(response: AcpResponse): void {
if (this.child?.stdin) {
const jsonString = JSON.stringify(response);
const lineEnding = process.platform === 'win32' ? '\r\n' : '\n';
this.child.stdin.write(jsonString + lineEnding);
}
// 初始化协议
await this.sessionManager.initialize(
this.child,
this.pendingRequests,
this.nextRequestId,
);
}
/**
* 处理接收到的消息
*
* @param message - ACP消息
*/
private handleMessage(message: AcpMessage): void {
try {
if ('method' in message) {
// Request or notification
this.handleIncomingRequest(message).catch(() => {});
} else if (
'id' in message &&
typeof message.id === 'number' &&
this.pendingRequests.has(message.id)
) {
// Response
const pendingRequest = this.pendingRequests.get(message.id)!;
const { resolve, reject, method } = pendingRequest;
this.pendingRequests.delete(message.id);
if ('result' in message) {
console.log(
`[ACP] Response for ${method}:`,
JSON.stringify(message.result).substring(0, 200),
);
if (
message.result &&
typeof message.result === 'object' &&
'stopReason' in message.result &&
message.result.stopReason === 'end_turn'
) {
this.onEndTurn();
}
resolve(message.result);
} else if ('error' in message) {
const errorCode = message.error?.code || 'unknown';
const errorMsg = message.error?.message || 'Unknown ACP error';
const errorData = message.error?.data
? JSON.stringify(message.error.data)
: '';
console.error(`[ACP] Error response for ${method}:`, {
code: errorCode,
message: errorMsg,
data: errorData,
});
reject(
new Error(
`${errorMsg} (code: ${errorCode})${errorData ? '\nData: ' + errorData : ''}`,
),
);
}
}
} catch (error) {
console.error('[ACP] Error handling message:', error);
}
}
private async handleIncomingRequest(
message: AcpRequest | AcpNotification,
): Promise<void> {
const { method, params } = message;
try {
let result = null;
switch (method) {
case 'session/update':
this.onSessionUpdate(params as AcpSessionUpdate);
break;
case 'session/request_permission':
result = await this.handlePermissionRequest(
params as AcpPermissionRequest,
);
break;
case 'fs/read_text_file':
result = await this.handleReadTextFile(
params as {
path: string;
sessionId: string;
line: number | null;
limit: number | null;
},
);
break;
case 'fs/write_text_file':
result = await this.handleWriteTextFile(
params as { path: string; content: string; sessionId: string },
);
break;
default:
console.warn(`[ACP] Unhandled method: ${method}`);
break;
}
if ('id' in message && typeof message.id === 'number') {
this.sendResponseMessage({
jsonrpc: JSONRPC_VERSION,
id: message.id,
result,
});
}
} catch (error) {
if ('id' in message && typeof message.id === 'number') {
this.sendResponseMessage({
jsonrpc: JSONRPC_VERSION,
id: message.id,
error: {
code: -32603,
message: error instanceof Error ? error.message : String(error),
},
});
}
}
}
private async handlePermissionRequest(params: AcpPermissionRequest): Promise<{
outcome: { outcome: string; optionId: string };
}> {
try {
const response = await this.onPermissionRequest(params);
const optionId = response.optionId;
// Handle cancel, reject, or allow
let outcome: string;
if (optionId.includes('reject') || optionId === 'cancel') {
outcome = 'rejected';
} else {
outcome = 'selected';
}
return {
outcome: {
outcome,
optionId: optionId === 'cancel' ? 'reject_once' : optionId,
},
};
} catch (_error) {
return {
outcome: {
outcome: 'rejected',
optionId: 'reject_once',
},
};
}
}
private async handleReadTextFile(params: {
path: string;
sessionId: string;
line: number | null;
limit: number | null;
}): Promise<{ content: string }> {
const fs = await import('fs/promises');
console.log(`[ACP] fs/read_text_file request received for: ${params.path}`);
console.log(`[ACP] Parameters:`, {
line: params.line,
limit: params.limit,
sessionId: params.sessionId,
});
try {
const content = await fs.readFile(params.path, 'utf-8');
console.log(
`[ACP] Successfully read file: ${params.path} (${content.length} bytes)`,
);
// Handle line offset and limit if specified
if (params.line !== null || params.limit !== null) {
const lines = content.split('\n');
const startLine = params.line || 0;
const endLine = params.limit ? startLine + params.limit : lines.length;
const selectedLines = lines.slice(startLine, endLine);
const result = { content: selectedLines.join('\n') };
console.log(`[ACP] Returning ${selectedLines.length} lines`);
return result;
}
const result = { content };
console.log(`[ACP] Returning full file content`);
return result;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[ACP] Failed to read file ${params.path}:`, errorMsg);
// Throw a proper error that will be caught by handleIncomingRequest
throw new Error(`Failed to read file '${params.path}': ${errorMsg}`);
}
}
private async handleWriteTextFile(params: {
path: string;
content: string;
sessionId: string;
}): Promise<null> {
const fs = await import('fs/promises');
const path = await import('path');
console.log(
`[ACP] fs/write_text_file request received for: ${params.path}`,
);
console.log(`[ACP] Content size: ${params.content.length} bytes`);
try {
// Ensure directory exists
const dirName = path.dirname(params.path);
console.log(`[ACP] Ensuring directory exists: ${dirName}`);
await fs.mkdir(dirName, { recursive: true });
// Write file
await fs.writeFile(params.path, params.content, 'utf-8');
console.log(`[ACP] Successfully wrote file: ${params.path}`);
return null;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[ACP] Failed to write file ${params.path}:`, errorMsg);
// Throw a proper error that will be caught by handleIncomingRequest
throw new Error(`Failed to write file '${params.path}': ${errorMsg}`);
}
}
private async initialize(): Promise<AcpResponse> {
const initializeParams = {
protocolVersion: 1,
clientCapabilities: {
fs: {
readTextFile: true,
writeTextFile: true,
},
},
const callbacks: AcpConnectionCallbacks = {
onSessionUpdate: this.onSessionUpdate,
onPermissionRequest: this.onPermissionRequest,
onEndTurn: this.onEndTurn,
};
console.log('[ACP] Sending initialize request...');
const response = await this.sendRequest<AcpResponse>(
'initialize',
initializeParams,
);
this.isInitialized = true;
console.log('[ACP] Initialize successful');
return response;
}
async authenticate(methodId?: string): Promise<AcpResponse> {
// New version requires methodId to be provided
const authMethodId = methodId || 'default';
console.log(
'[ACP] Sending authenticate request with methodId:',
authMethodId,
);
const response = await this.sendRequest<AcpResponse>('authenticate', {
methodId: authMethodId,
});
console.log('[ACP] Authenticate successful');
return response;
}
async newSession(cwd: string = process.cwd()): Promise<AcpResponse> {
console.log('[ACP] Sending session/new request with cwd:', cwd);
const response = await this.sendRequest<
AcpResponse & { sessionId?: string }
>('session/new', {
cwd,
mcpServers: [],
});
this.sessionId = response.sessionId || null;
console.log('[ACP] Session created with ID:', this.sessionId);
return response;
}
async sendPrompt(prompt: string): Promise<AcpResponse> {
if (!this.sessionId) {
throw new Error('No active ACP session');
}
return await this.sendRequest('session/prompt', {
sessionId: this.sessionId,
prompt: [{ type: 'text', text: prompt }],
});
}
async listSessions(): Promise<AcpResponse> {
console.log('[ACP] Requesting session list...');
try {
const response = await this.sendRequest<AcpResponse>('session/list', {});
console.log(
'[ACP] Session list response:',
JSON.stringify(response).substring(0, 200),
// 处理消息
if ('method' in message) {
// 请求或通知
this.messageHandler
.handleIncomingRequest(message, callbacks)
.then((result) => {
if ('id' in message && typeof message.id === 'number') {
this.messageHandler.sendResponseMessage(this.child, {
jsonrpc: JSONRPC_VERSION,
id: message.id,
result,
});
}
})
.catch((error) => {
if ('id' in message && typeof message.id === 'number') {
this.messageHandler.sendResponseMessage(this.child, {
jsonrpc: JSONRPC_VERSION,
id: message.id,
error: {
code: -32603,
message: error instanceof Error ? error.message : String(error),
},
});
}
});
} else {
// 响应
this.messageHandler.handleMessage(
message,
this.pendingRequests,
callbacks,
);
return response;
} catch (error) {
console.error('[ACP] Failed to get session list:', error);
throw error;
}
}
async switchSession(sessionId: string): Promise<AcpResponse> {
console.log('[ACP] Switching to session:', sessionId);
this.sessionId = sessionId;
const response = await this.sendRequest<AcpResponse>('session/switch', {
sessionId,
});
console.log('[ACP] Session switched successfully');
return response;
/**
* 认证
*
* @param methodId - 认证方法ID
* @returns 认证响应
*/
async authenticate(methodId?: string): Promise<AcpResponse> {
return this.sessionManager.authenticate(
methodId,
this.child,
this.pendingRequests,
this.nextRequestId,
);
}
/**
* 创建新会话
*
* @param cwd - 工作目录
* @returns 新会话响应
*/
async newSession(cwd: string = process.cwd()): Promise<AcpResponse> {
return this.sessionManager.newSession(
cwd,
this.child,
this.pendingRequests,
this.nextRequestId,
);
}
/**
* 发送提示消息
*
* @param prompt - 提示内容
* @returns 响应
*/
async sendPrompt(prompt: string): Promise<AcpResponse> {
return this.sessionManager.sendPrompt(
prompt,
this.child,
this.pendingRequests,
this.nextRequestId,
);
}
/**
* 加载已有会话
*
* @param sessionId - 会话ID
* @returns 加载响应
*/
async loadSession(sessionId: string): Promise<AcpResponse> {
return this.sessionManager.loadSession(
sessionId,
this.child,
this.pendingRequests,
this.nextRequestId,
);
}
/**
* 获取会话列表
*
* @returns 会话列表响应
*/
async listSessions(): Promise<AcpResponse> {
return this.sessionManager.listSessions(
this.child,
this.pendingRequests,
this.nextRequestId,
);
}
/**
* 切换到指定会话
*
* @param sessionId - 会话ID
* @returns 切换响应
*/
async switchSession(sessionId: string): Promise<AcpResponse> {
return this.sessionManager.switchSession(sessionId, this.nextRequestId);
}
/**
* 取消当前会话的提示生成
*/
async cancelSession(): Promise<void> {
await this.sessionManager.cancelSession(this.child);
}
/**
* 断开连接
*/
disconnect(): void {
if (this.child) {
this.child.kill();
@@ -527,20 +345,28 @@ export class AcpConnection {
}
this.pendingRequests.clear();
this.sessionId = null;
this.isInitialized = false;
this.sessionManager.reset();
this.backend = null;
}
/**
* 检查是否已连接
*/
get isConnected(): boolean {
return this.child !== null && !this.child.killed;
}
/**
* 检查是否有活动会话
*/
get hasActiveSession(): boolean {
return this.sessionId !== null;
return this.sessionManager.getCurrentSessionId() !== null;
}
/**
* 获取当前会话ID
*/
get currentSessionId(): string | null {
return this.sessionId;
return this.sessionManager.getCurrentSessionId();
}
}

View File

@@ -0,0 +1,111 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* ACP文件操作处理器
*
* 负责处理ACP协议中的文件读写操作
*/
import { promises as fs } from 'fs';
import * as path from 'path';
/**
* ACP文件操作处理器类
* 提供文件读写功能符合ACP协议规范
*/
export class AcpFileHandler {
/**
* 处理读取文本文件请求
*
* @param params - 文件读取参数
* @param params.path - 文件路径
* @param params.sessionId - 会话ID
* @param params.line - 起始行号(可选)
* @param params.limit - 读取行数限制(可选)
* @returns 文件内容
* @throws 当文件读取失败时抛出错误
*/
async handleReadTextFile(params: {
path: string;
sessionId: string;
line: number | null;
limit: number | null;
}): Promise<{ content: string }> {
console.log(`[ACP] fs/read_text_file request received for: ${params.path}`);
console.log(`[ACP] Parameters:`, {
line: params.line,
limit: params.limit,
sessionId: params.sessionId,
});
try {
const content = await fs.readFile(params.path, 'utf-8');
console.log(
`[ACP] Successfully read file: ${params.path} (${content.length} bytes)`,
);
// 处理行偏移和限制
if (params.line !== null || params.limit !== null) {
const lines = content.split('\n');
const startLine = params.line || 0;
const endLine = params.limit ? startLine + params.limit : lines.length;
const selectedLines = lines.slice(startLine, endLine);
const result = { content: selectedLines.join('\n') };
console.log(`[ACP] Returning ${selectedLines.length} lines`);
return result;
}
const result = { content };
console.log(`[ACP] Returning full file content`);
return result;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[ACP] Failed to read file ${params.path}:`, errorMsg);
throw new Error(`Failed to read file '${params.path}': ${errorMsg}`);
}
}
/**
* 处理写入文本文件请求
*
* @param params - 文件写入参数
* @param params.path - 文件路径
* @param params.content - 文件内容
* @param params.sessionId - 会话ID
* @returns null表示成功
* @throws 当文件写入失败时抛出错误
*/
async handleWriteTextFile(params: {
path: string;
content: string;
sessionId: string;
}): Promise<null> {
console.log(
`[ACP] fs/write_text_file request received for: ${params.path}`,
);
console.log(`[ACP] Content size: ${params.content.length} bytes`);
try {
// 确保目录存在
const dirName = path.dirname(params.path);
console.log(`[ACP] Ensuring directory exists: ${dirName}`);
await fs.mkdir(dirName, { recursive: true });
// 写入文件
await fs.writeFile(params.path, params.content, 'utf-8');
console.log(`[ACP] Successfully wrote file: ${params.path}`);
return null;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error(`[ACP] Failed to write file ${params.path}:`, errorMsg);
throw new Error(`Failed to write file '${params.path}': ${errorMsg}`);
}
}
}

View File

@@ -0,0 +1,225 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* ACP消息处理器
*
* 负责处理ACP协议中的消息接收、解析和分发
*/
import type {
AcpMessage,
AcpRequest,
AcpNotification,
AcpResponse,
AcpSessionUpdate,
AcpPermissionRequest,
} from '../shared/acpTypes.js';
import { CLIENT_METHODS } from './schema.js';
import type { PendingRequest, AcpConnectionCallbacks } from './AcpTypes.js';
import { AcpFileHandler } from './AcpFileHandler.js';
import type { ChildProcess } from 'child_process';
/**
* ACP消息处理器类
* 负责消息的接收、解析和处理
*/
export class AcpMessageHandler {
private fileHandler: AcpFileHandler;
constructor() {
this.fileHandler = new AcpFileHandler();
}
/**
* 发送响应消息到子进程
*
* @param child - 子进程实例
* @param response - 响应消息
*/
sendResponseMessage(child: ChildProcess | null, response: AcpResponse): void {
if (child?.stdin) {
const jsonString = JSON.stringify(response);
const lineEnding = process.platform === 'win32' ? '\r\n' : '\n';
child.stdin.write(jsonString + lineEnding);
}
}
/**
* 处理接收到的消息
*
* @param message - ACP消息
* @param pendingRequests - 待处理请求映射表
* @param callbacks - 回调函数集合
*/
handleMessage(
message: AcpMessage,
pendingRequests: Map<number, PendingRequest<unknown>>,
callbacks: AcpConnectionCallbacks,
): void {
try {
if ('method' in message) {
// 请求或通知
this.handleIncomingRequest(message, callbacks).catch(() => {});
} else if (
'id' in message &&
typeof message.id === 'number' &&
pendingRequests.has(message.id)
) {
// 响应
this.handleResponse(message, pendingRequests, callbacks);
}
} catch (error) {
console.error('[ACP] Error handling message:', error);
}
}
/**
* 处理响应消息
*
* @param message - 响应消息
* @param pendingRequests - 待处理请求映射表
* @param callbacks - 回调函数集合
*/
private handleResponse(
message: AcpMessage,
pendingRequests: Map<number, PendingRequest<unknown>>,
callbacks: AcpConnectionCallbacks,
): void {
if (!('id' in message) || typeof message.id !== 'number') {
return;
}
const pendingRequest = pendingRequests.get(message.id);
if (!pendingRequest) {
return;
}
const { resolve, reject, method } = pendingRequest;
pendingRequests.delete(message.id);
if ('result' in message) {
console.log(
`[ACP] Response for ${method}:`,
JSON.stringify(message.result).substring(0, 200),
);
if (
message.result &&
typeof message.result === 'object' &&
'stopReason' in message.result &&
message.result.stopReason === 'end_turn'
) {
callbacks.onEndTurn();
}
resolve(message.result);
} else if ('error' in message) {
const errorCode = message.error?.code || 'unknown';
const errorMsg = message.error?.message || 'Unknown ACP error';
const errorData = message.error?.data
? JSON.stringify(message.error.data)
: '';
console.error(`[ACP] Error response for ${method}:`, {
code: errorCode,
message: errorMsg,
data: errorData,
});
reject(
new Error(
`${errorMsg} (code: ${errorCode})${errorData ? '\nData: ' + errorData : ''}`,
),
);
}
}
/**
* 处理进入的请求
*
* @param message - 请求或通知消息
* @param callbacks - 回调函数集合
* @returns 请求处理结果
*/
async handleIncomingRequest(
message: AcpRequest | AcpNotification,
callbacks: AcpConnectionCallbacks,
): Promise<unknown> {
const { method, params } = message;
let result = null;
switch (method) {
case CLIENT_METHODS.session_update:
callbacks.onSessionUpdate(params as AcpSessionUpdate);
break;
case CLIENT_METHODS.session_request_permission:
result = await this.handlePermissionRequest(
params as AcpPermissionRequest,
callbacks,
);
break;
case CLIENT_METHODS.fs_read_text_file:
result = await this.fileHandler.handleReadTextFile(
params as {
path: string;
sessionId: string;
line: number | null;
limit: number | null;
},
);
break;
case CLIENT_METHODS.fs_write_text_file:
result = await this.fileHandler.handleWriteTextFile(
params as { path: string; content: string; sessionId: string },
);
break;
default:
console.warn(`[ACP] Unhandled method: ${method}`);
break;
}
return result;
}
/**
* 处理权限请求
*
* @param params - 权限请求参数
* @param callbacks - 回调函数集合
* @returns 权限请求结果
*/
private async handlePermissionRequest(
params: AcpPermissionRequest,
callbacks: AcpConnectionCallbacks,
): Promise<{
outcome: { outcome: string; optionId: string };
}> {
try {
const response = await callbacks.onPermissionRequest(params);
const optionId = response.optionId;
// 处理取消、拒绝或允许
let outcome: string;
if (optionId.includes('reject') || optionId === 'cancel') {
outcome = 'rejected';
} else {
outcome = 'selected';
}
return {
outcome: {
outcome,
optionId: optionId === 'cancel' ? 'reject_once' : optionId,
},
};
} catch (_error) {
return {
outcome: {
outcome: 'rejected',
optionId: 'reject_once',
},
};
}
}
}

View File

@@ -0,0 +1,373 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* ACP会话管理器
*
* 负责管理ACP协议的会话操作包括初始化、认证、会话创建和切换等
*/
import { JSONRPC_VERSION } from '../shared/acpTypes.js';
import type {
AcpRequest,
AcpNotification,
AcpResponse,
} from '../shared/acpTypes.js';
import { AGENT_METHODS, CUSTOM_METHODS } from './schema.js';
import type { PendingRequest } from './AcpTypes.js';
import type { ChildProcess } from 'child_process';
/**
* ACP会话管理器类
* 提供会话的初始化、认证、创建、加载和切换功能
*/
export class AcpSessionManager {
private sessionId: string | null = null;
private isInitialized = false;
/**
* 发送请求到ACP服务器
*
* @param method - 请求方法名
* @param params - 请求参数
* @param child - 子进程实例
* @param pendingRequests - 待处理请求映射表
* @param nextRequestId - 请求ID计数器
* @returns 请求响应
*/
private sendRequest<T = unknown>(
method: string,
params: Record<string, unknown> | undefined,
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
): Promise<T> {
const id = nextRequestId.value++;
const message: AcpRequest = {
jsonrpc: JSONRPC_VERSION,
id,
method,
...(params && { params }),
};
return new Promise((resolve, reject) => {
const timeoutDuration =
method === AGENT_METHODS.session_prompt ? 120000 : 60000;
const timeoutId = setTimeout(() => {
pendingRequests.delete(id);
reject(new Error(`Request ${method} timed out`));
}, timeoutDuration);
const pendingRequest: PendingRequest<T> = {
resolve: (value: T) => {
clearTimeout(timeoutId);
resolve(value);
},
reject: (error: Error) => {
clearTimeout(timeoutId);
reject(error);
},
timeoutId,
method,
};
pendingRequests.set(id, pendingRequest as PendingRequest<unknown>);
this.sendMessage(message, child);
});
}
/**
* 发送消息到子进程
*
* @param message - 请求或通知消息
* @param child - 子进程实例
*/
private sendMessage(
message: AcpRequest | AcpNotification,
child: ChildProcess | null,
): void {
if (child?.stdin) {
const jsonString = JSON.stringify(message);
const lineEnding = process.platform === 'win32' ? '\r\n' : '\n';
child.stdin.write(jsonString + lineEnding);
}
}
/**
* 初始化ACP协议连接
*
* @param child - 子进程实例
* @param pendingRequests - 待处理请求映射表
* @param nextRequestId - 请求ID计数器
* @returns 初始化响应
*/
async initialize(
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
): Promise<AcpResponse> {
const initializeParams = {
protocolVersion: 1,
clientCapabilities: {
fs: {
readTextFile: true,
writeTextFile: true,
},
},
};
console.log('[ACP] Sending initialize request...');
const response = await this.sendRequest<AcpResponse>(
AGENT_METHODS.initialize,
initializeParams,
child,
pendingRequests,
nextRequestId,
);
this.isInitialized = true;
console.log('[ACP] Initialize successful');
return response;
}
/**
* 进行认证
*
* @param methodId - 认证方法ID
* @param child - 子进程实例
* @param pendingRequests - 待处理请求映射表
* @param nextRequestId - 请求ID计数器
* @returns 认证响应
*/
async authenticate(
methodId: string | undefined,
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
): Promise<AcpResponse> {
const authMethodId = methodId || 'default';
console.log(
'[ACP] Sending authenticate request with methodId:',
authMethodId,
);
const response = await this.sendRequest<AcpResponse>(
AGENT_METHODS.authenticate,
{
methodId: authMethodId,
},
child,
pendingRequests,
nextRequestId,
);
console.log('[ACP] Authenticate successful');
return response;
}
/**
* 创建新会话
*
* @param cwd - 工作目录
* @param child - 子进程实例
* @param pendingRequests - 待处理请求映射表
* @param nextRequestId - 请求ID计数器
* @returns 新会话响应
*/
async newSession(
cwd: string,
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
): Promise<AcpResponse> {
console.log('[ACP] Sending session/new request with cwd:', cwd);
const response = await this.sendRequest<
AcpResponse & { sessionId?: string }
>(
AGENT_METHODS.session_new,
{
cwd,
mcpServers: [],
},
child,
pendingRequests,
nextRequestId,
);
this.sessionId = response.sessionId || null;
console.log('[ACP] Session created with ID:', this.sessionId);
return response;
}
/**
* 发送提示消息
*
* @param prompt - 提示内容
* @param child - 子进程实例
* @param pendingRequests - 待处理请求映射表
* @param nextRequestId - 请求ID计数器
* @returns 响应
* @throws 当没有活动会话时抛出错误
*/
async sendPrompt(
prompt: string,
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
): Promise<AcpResponse> {
if (!this.sessionId) {
throw new Error('No active ACP session');
}
return await this.sendRequest(
AGENT_METHODS.session_prompt,
{
sessionId: this.sessionId,
prompt: [{ type: 'text', text: prompt }],
},
child,
pendingRequests,
nextRequestId,
);
}
/**
* 加载已有会话
*
* @param sessionId - 会话ID
* @param child - 子进程实例
* @param pendingRequests - 待处理请求映射表
* @param nextRequestId - 请求ID计数器
* @returns 加载响应
*/
async loadSession(
sessionId: string,
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
): Promise<AcpResponse> {
console.log('[ACP] Loading session:', sessionId);
const response = await this.sendRequest<AcpResponse>(
AGENT_METHODS.session_load,
{
sessionId,
cwd: process.cwd(),
mcpServers: [],
},
child,
pendingRequests,
nextRequestId,
);
console.log('[ACP] Session load response:', response);
return response;
}
/**
* 获取会话列表
*
* @param child - 子进程实例
* @param pendingRequests - 待处理请求映射表
* @param nextRequestId - 请求ID计数器
* @returns 会话列表响应
*/
async listSessions(
child: ChildProcess | null,
pendingRequests: Map<number, PendingRequest<unknown>>,
nextRequestId: { value: number },
): Promise<AcpResponse> {
console.log('[ACP] Requesting session list...');
try {
const response = await this.sendRequest<AcpResponse>(
CUSTOM_METHODS.session_list,
{},
child,
pendingRequests,
nextRequestId,
);
console.log(
'[ACP] Session list response:',
JSON.stringify(response).substring(0, 200),
);
return response;
} catch (error) {
console.error('[ACP] Failed to get session list:', error);
throw error;
}
}
/**
* 切换到指定会话
*
* @param sessionId - 会话ID
* @param nextRequestId - 请求ID计数器
* @returns 切换响应
*/
async switchSession(
sessionId: string,
nextRequestId: { value: number },
): Promise<AcpResponse> {
console.log('[ACP] Switching to session:', sessionId);
this.sessionId = sessionId;
const mockResponse: AcpResponse = {
jsonrpc: JSONRPC_VERSION,
id: nextRequestId.value++,
result: { sessionId },
};
console.log(
'[ACP] Session ID updated locally (switch not supported by CLI)',
);
return mockResponse;
}
/**
* 取消当前会话的提示生成
*
* @param child - 子进程实例
*/
async cancelSession(child: ChildProcess | null): Promise<void> {
if (!this.sessionId) {
console.warn('[ACP] No active session to cancel');
return;
}
console.log('[ACP] Cancelling session:', this.sessionId);
const cancelParams = {
sessionId: this.sessionId,
};
const message: AcpNotification = {
jsonrpc: JSONRPC_VERSION,
method: AGENT_METHODS.session_cancel,
params: cancelParams,
};
this.sendMessage(message, child);
console.log('[ACP] Cancel notification sent');
}
/**
* 重置会话管理器状态
*/
reset(): void {
this.sessionId = null;
this.isInitialized = false;
}
/**
* 获取当前会话ID
*/
getCurrentSessionId(): string | null {
return this.sessionId;
}
/**
* 检查是否已初始化
*/
getIsInitialized(): boolean {
return this.isInitialized;
}
}

View File

@@ -0,0 +1,63 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* ACP连接类型定义
*
* 包含了ACP连接所需的所有类型和接口定义
*/
import type { ChildProcess } from 'child_process';
import type {
AcpSessionUpdate,
AcpPermissionRequest,
} from '../shared/acpTypes.js';
/**
* 待处理的请求信息
*/
export interface PendingRequest<T = unknown> {
/** 成功回调 */
resolve: (value: T) => void;
/** 失败回调 */
reject: (error: Error) => void;
/** 超时定时器ID */
timeoutId?: NodeJS.Timeout;
/** 请求方法名 */
method: string;
}
/**
* ACP连接回调函数类型
*/
export interface AcpConnectionCallbacks {
/** 会话更新回调 */
onSessionUpdate: (data: AcpSessionUpdate) => void;
/** 权限请求回调 */
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
optionId: string;
}>;
/** 回合结束回调 */
onEndTurn: () => void;
}
/**
* ACP连接状态
*/
export interface AcpConnectionState {
/** 子进程实例 */
child: ChildProcess | null;
/** 待处理的请求映射表 */
pendingRequests: Map<number, PendingRequest<unknown>>;
/** 下一个请求ID */
nextRequestId: number;
/** 当前会话ID */
sessionId: string | null;
/** 是否已初始化 */
isInitialized: boolean;
/** 后端类型 */
backend: string | null;
}

View File

@@ -0,0 +1,57 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* ACP (Agent Communication Protocol) Method Definitions
*
* This file defines the protocol methods for communication between
* the VSCode extension (Client) and the qwen CLI (Agent/Server).
*/
/**
* Methods that the Agent (CLI) implements and receives from Client (VSCode)
*
* Status in qwen CLI:
* ✅ initialize - Protocol initialization
* ✅ authenticate - User authentication
* ✅ session/new - Create new session
* ❌ session/load - Load existing session (not implemented in CLI)
* ✅ session/prompt - Send user message to agent
* ✅ session/cancel - Cancel current generation
*/
export const AGENT_METHODS = {
authenticate: 'authenticate',
initialize: 'initialize',
session_cancel: 'session/cancel',
session_load: 'session/load',
session_new: 'session/new',
session_prompt: 'session/prompt',
} as const;
/**
* Methods that the Client (VSCode) implements and receives from Agent (CLI)
*
* Status in VSCode extension:
* ✅ fs/read_text_file - Read file content
* ✅ fs/write_text_file - Write file content
* ✅ session/request_permission - Request user permission for tool execution
* ✅ session/update - Stream session updates (notification)
*/
export const CLIENT_METHODS = {
fs_read_text_file: 'fs/read_text_file',
fs_write_text_file: 'fs/write_text_file',
session_request_permission: 'session/request_permission',
session_update: 'session/update',
} as const;
/**
* Custom methods (not in standard ACP protocol)
* These are VSCode extension specific extensions
*/
export const CUSTOM_METHODS = {
session_list: 'session/list',
session_switch: 'session/switch',
} as const;

View File

@@ -1,10 +1,9 @@
/**
* @license
* Copyright 2025 Qwen Team
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as vscode from 'vscode';
import { AcpConnection } from '../acp/AcpConnection.js';
import type {
AcpSessionUpdate,
@@ -15,317 +14,100 @@ import {
type QwenSession,
} from '../services/QwenSessionReader.js';
import type { AuthStateManager } from '../auth/AuthStateManager.js';
import type {
ChatMessage,
PlanEntry,
ToolCallUpdateData,
QwenAgentCallbacks,
} from './QwenTypes.js';
import { QwenConnectionHandler } from './QwenConnectionHandler.js';
import { QwenSessionUpdateHandler } from './QwenSessionUpdateHandler.js';
export interface ChatMessage {
role: 'user' | 'assistant';
content: string;
timestamp: number;
}
interface ToolCallUpdateData {
toolCallId: string;
kind?: string;
title?: string;
status?: string;
rawInput?: unknown;
content?: Array<Record<string, unknown>>;
locations?: Array<{ path: string; line?: number | null }>;
}
// 重新导出类型以保持向后兼容
export type { ChatMessage, PlanEntry, ToolCallUpdateData };
/**
* Qwen Agent管理器
*
* 协调各个模块,提供统一的接口
*/
export class QwenAgentManager {
private connection: AcpConnection;
private sessionReader: QwenSessionReader;
private onMessageCallback?: (message: ChatMessage) => void;
private onStreamChunkCallback?: (chunk: string) => void;
private onToolCallCallback?: (update: ToolCallUpdateData) => void;
private onPermissionRequestCallback?: (
request: AcpPermissionRequest,
) => Promise<string>;
private connectionHandler: QwenConnectionHandler;
private sessionUpdateHandler: QwenSessionUpdateHandler;
private currentWorkingDir: string = process.cwd();
// 回调函数存储
private callbacks: QwenAgentCallbacks = {};
constructor() {
this.connection = new AcpConnection();
this.sessionReader = new QwenSessionReader();
this.connectionHandler = new QwenConnectionHandler();
this.sessionUpdateHandler = new QwenSessionUpdateHandler({});
// Setup session update handler
// 设置ACP连接的回调
this.connection.onSessionUpdate = (data: AcpSessionUpdate) => {
this.handleSessionUpdate(data);
this.sessionUpdateHandler.handleSessionUpdate(data);
};
// Setup permission request handler
this.connection.onPermissionRequest = async (
data: AcpPermissionRequest,
) => {
if (this.onPermissionRequestCallback) {
const optionId = await this.onPermissionRequestCallback(data);
if (this.callbacks.onPermissionRequest) {
const optionId = await this.callbacks.onPermissionRequest(data);
return { optionId };
}
return { optionId: 'allow_once' };
};
// Setup end turn handler
this.connection.onEndTurn = () => {
// Notify UI that response is complete
// 通知UI响应完成
};
}
/**
* 连接到Qwen服务
*
* @param workingDir - 工作目录
* @param authStateManager - 认证状态管理器(可选)
*/
async connect(
workingDir: string,
authStateManager?: AuthStateManager,
): Promise<void> {
const connectId = Date.now();
console.log(`\n========================================`);
console.log(`[QwenAgentManager] 🚀 CONNECT() CALLED - ID: ${connectId}`);
console.log(`[QwenAgentManager] Call stack:\n${new Error().stack}`);
console.log(`========================================\n`);
this.currentWorkingDir = workingDir;
const config = vscode.workspace.getConfiguration('qwenCode');
const cliPath = config.get<string>('qwen.cliPath', 'qwen');
const openaiApiKey = config.get<string>('qwen.openaiApiKey', '');
const openaiBaseUrl = config.get<string>('qwen.openaiBaseUrl', '');
const model = config.get<string>('qwen.model', '');
const proxy = config.get<string>('qwen.proxy', '');
// Build additional CLI arguments
const extraArgs: string[] = [];
if (openaiApiKey) {
extraArgs.push('--openai-api-key', openaiApiKey);
}
if (openaiBaseUrl) {
extraArgs.push('--openai-base-url', openaiBaseUrl);
}
if (model) {
extraArgs.push('--model', model);
}
if (proxy) {
extraArgs.push('--proxy', proxy);
console.log('[QwenAgentManager] Using proxy:', proxy);
}
await this.connection.connect('qwen', cliPath, workingDir, extraArgs);
// Determine auth method based on configuration
const authMethod = openaiApiKey ? 'openai' : 'qwen-oauth';
// Check if we have valid cached authentication
let needsAuth = true;
if (authStateManager) {
const hasValidAuth = await authStateManager.hasValidAuth(
workingDir,
authMethod,
);
if (hasValidAuth) {
console.log('[QwenAgentManager] Using cached authentication');
needsAuth = false;
}
}
// Try to restore existing session or create new one
let sessionRestored = false;
// Try to get sessions from local files
console.log('[QwenAgentManager] Reading local session files...');
try {
const sessions = await this.sessionReader.getAllSessions(workingDir);
if (sessions.length > 0) {
// Use the most recent session
console.log(
'[QwenAgentManager] Found existing sessions:',
sessions.length,
);
const lastSession = sessions[0]; // Already sorted by lastUpdated
// Try to switch to it (this may fail if not supported)
try {
await this.connection.switchSession(lastSession.sessionId);
console.log(
'[QwenAgentManager] Restored session:',
lastSession.sessionId,
);
sessionRestored = true;
// If session restored successfully, we don't need to authenticate
needsAuth = false;
} catch (switchError) {
console.log(
'[QwenAgentManager] session/switch not supported or failed:',
switchError instanceof Error
? switchError.message
: String(switchError),
);
// Will create new session below
}
} else {
console.log('[QwenAgentManager] No existing sessions found');
}
} catch (error) {
// If reading local sessions fails, log and continue
const errorMessage =
error instanceof Error ? error.message : String(error);
console.log(
'[QwenAgentManager] Failed to read local sessions:',
errorMessage,
);
// Will create new session below
}
// Create new session if we couldn't restore one
if (!sessionRestored) {
console.log('[QwenAgentManager] Creating new session...');
console.log(
`[QwenAgentManager] ⚠️ WORKAROUND: Skipping explicit authenticate() call`,
);
console.log(
`[QwenAgentManager] ⚠️ Reason: newSession() internally calls refreshAuth(), which triggers device flow`,
);
console.log(
`[QwenAgentManager] ⚠️ Calling authenticate() first causes double authentication`,
);
// WORKAROUND: Skip explicit authenticate() call
// The newSession() method will internally call config.refreshAuth(),
// which will trigger device flow if no valid token exists.
// Calling authenticate() first causes a duplicate OAuth flow due to a bug in Qwen CLI
// where authenticate() doesn't properly save refresh token for newSession() to use.
// Try to create session (which will trigger auth internally if needed)
try {
console.log(
`\n🔐 [AUTO AUTH] newSession will handle authentication automatically\n`,
);
await this.newSessionWithRetry(workingDir, 3);
console.log('[QwenAgentManager] New session created successfully');
// Save auth state after successful session creation
if (authStateManager) {
console.log(
'[QwenAgentManager] Saving auth state after successful session creation',
);
await authStateManager.saveAuthState(workingDir, authMethod);
}
} catch (sessionError) {
console.log(`\n⚠ [SESSION FAILED] newSessionWithRetry threw error\n`);
console.log(`[QwenAgentManager] Error details:`, sessionError);
// If session creation failed, clear cache and let user retry
if (authStateManager) {
console.log('[QwenAgentManager] Clearing auth cache due to failure');
await authStateManager.clearAuthState();
}
throw sessionError;
}
}
console.log(`\n========================================`);
console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`);
console.log(`========================================\n`);
await this.connectionHandler.connect(
this.connection,
this.sessionReader,
workingDir,
authStateManager,
);
}
/**
* Authenticate with retry logic
* 发送消息
*
* @param message - 消息内容
*/
private async authenticateWithRetry(
authMethod: string,
maxRetries: number,
): Promise<void> {
const timestamp = new Date().toISOString();
const callStack = new Error().stack;
console.log(
`[QwenAgentManager] 🔐 AUTHENTICATION CALL STARTED at ${timestamp}`,
);
console.log(
`[QwenAgentManager] Auth method: ${authMethod}, Max retries: ${maxRetries}`,
);
console.log(`[QwenAgentManager] Call stack:\n${callStack}`);
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(
`[QwenAgentManager] 📝 Authenticating (attempt ${attempt}/${maxRetries})...`,
);
await this.connection.authenticate(authMethod);
console.log(
`[QwenAgentManager] ✅ Authentication successful on attempt ${attempt}`,
);
return;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(
`[QwenAgentManager] ❌ Authentication attempt ${attempt} failed:`,
errorMessage,
);
if (attempt === maxRetries) {
throw new Error(
`Authentication failed after ${maxRetries} attempts: ${errorMessage}`,
);
}
// Wait before retrying (exponential backoff)
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
console.log(
`[QwenAgentManager] ⏳ Retrying in ${delay}ms... (${maxRetries - attempt} retries remaining)`,
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
/**
* Create new session with retry logic
*/
private async newSessionWithRetry(
workingDir: string,
maxRetries: number,
): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(
`[QwenAgentManager] Creating session (attempt ${attempt}/${maxRetries})...`,
);
await this.connection.newSession(workingDir);
console.log('[QwenAgentManager] Session created successfully');
return;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(
`[QwenAgentManager] Session creation attempt ${attempt} failed:`,
errorMessage,
);
if (attempt === maxRetries) {
throw new Error(
`Session creation failed after ${maxRetries} attempts: ${errorMessage}`,
);
}
// Wait before retrying
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
console.log(`[QwenAgentManager] Retrying in ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
async sendMessage(message: string): Promise<void> {
await this.connection.sendPrompt(message);
}
/**
* 获取会话列表
*
* @returns 会话列表
*/
async getSessionList(): Promise<Array<Record<string, unknown>>> {
try {
// Read from local session files instead of ACP protocol
// Get all sessions from all projects
const sessions = await this.sessionReader.getAllSessions(undefined, true);
console.log(
'[QwenAgentManager] Session list from files (all projects):',
sessions.length,
);
// Transform to UI-friendly format
return sessions.map(
(session: QwenSession): Record<string, unknown> => ({
id: session.sessionId,
@@ -344,6 +126,12 @@ export class QwenAgentManager {
}
}
/**
* 获取会话消息
*
* @param sessionId - 会话ID
* @returns 消息列表
*/
async getSessionMessages(sessionId: string): Promise<ChatMessage[]> {
try {
const session = await this.sessionReader.getSession(
@@ -354,7 +142,6 @@ export class QwenAgentManager {
return [];
}
// Convert Qwen messages to ChatMessage format
return session.messages.map(
(msg: { type: string; content: string; timestamp: string }) => ({
role:
@@ -372,132 +159,112 @@ export class QwenAgentManager {
}
}
/**
* 创建新会话
*
* @param workingDir - 工作目录
*/
async createNewSession(workingDir: string): Promise<void> {
console.log('[QwenAgentManager] Creating new session...');
await this.connection.newSession(workingDir);
}
/**
* 切换到指定会话
*
* @param sessionId - 会话ID
*/
async switchToSession(sessionId: string): Promise<void> {
await this.connection.switchSession(sessionId);
}
private handleSessionUpdate(data: AcpSessionUpdate): void {
const update = data.update;
switch (update.sessionUpdate) {
case 'user_message_chunk':
// Handle user message chunks if needed
if (update.content?.text && this.onStreamChunkCallback) {
this.onStreamChunkCallback(update.content.text);
}
break;
case 'agent_message_chunk':
// Handle assistant message chunks
if (update.content?.text && this.onStreamChunkCallback) {
this.onStreamChunkCallback(update.content.text);
}
break;
case 'agent_thought_chunk':
// Handle thinking chunks - could be displayed differently in UI
if (update.content?.text && this.onStreamChunkCallback) {
this.onStreamChunkCallback(update.content.text);
}
break;
case 'tool_call': {
// Handle new tool call
if (this.onToolCallCallback && 'toolCallId' in update) {
this.onToolCallCallback({
toolCallId: update.toolCallId as string,
kind: (update.kind as string) || undefined,
title: (update.title as string) || undefined,
status: (update.status as string) || undefined,
rawInput: update.rawInput,
content: update.content as
| Array<Record<string, unknown>>
| undefined,
locations: update.locations as
| Array<{ path: string; line?: number | null }>
| undefined,
});
}
break;
}
case 'tool_call_update': {
// Handle tool call status update
if (this.onToolCallCallback && 'toolCallId' in update) {
this.onToolCallCallback({
toolCallId: update.toolCallId as string,
kind: (update.kind as string) || undefined,
title: (update.title as string) || undefined,
status: (update.status as string) || undefined,
rawInput: update.rawInput,
content: update.content as
| Array<Record<string, unknown>>
| undefined,
locations: update.locations as
| Array<{ path: string; line?: number | null }>
| undefined,
});
}
break;
}
case 'plan': {
// Handle plan updates - could be displayed as a task list
if ('entries' in update && this.onStreamChunkCallback) {
const entries = update.entries as Array<{
content: string;
priority: string;
status: string;
}>;
const planText =
'\n📋 Plan:\n' +
entries
.map(
(entry, i) => `${i + 1}. [${entry.priority}] ${entry.content}`,
)
.join('\n');
this.onStreamChunkCallback(planText);
}
break;
}
default:
console.log('[QwenAgentManager] Unhandled session update type');
break;
}
/**
* 取消当前提示
*/
async cancelCurrentPrompt(): Promise<void> {
console.log('[QwenAgentManager] Cancelling current prompt');
await this.connection.cancelSession();
}
/**
* 注册消息回调
*
* @param callback - 消息回调函数
*/
onMessage(callback: (message: ChatMessage) => void): void {
this.onMessageCallback = callback;
this.callbacks.onMessage = callback;
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}
/**
* 注册流式文本块回调
*
* @param callback - 流式文本块回调函数
*/
onStreamChunk(callback: (chunk: string) => void): void {
this.onStreamChunkCallback = callback;
this.callbacks.onStreamChunk = callback;
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}
/**
* 注册思考文本块回调
*
* @param callback - 思考文本块回调函数
*/
onThoughtChunk(callback: (chunk: string) => void): void {
this.callbacks.onThoughtChunk = callback;
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}
/**
* 注册工具调用回调
*
* @param callback - 工具调用回调函数
*/
onToolCall(callback: (update: ToolCallUpdateData) => void): void {
this.onToolCallCallback = callback;
this.callbacks.onToolCall = callback;
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}
/**
* 注册计划回调
*
* @param callback - 计划回调函数
*/
onPlan(callback: (entries: PlanEntry[]) => void): void {
this.callbacks.onPlan = callback;
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}
/**
* 注册权限请求回调
*
* @param callback - 权限请求回调函数
*/
onPermissionRequest(
callback: (request: AcpPermissionRequest) => Promise<string>,
): void {
this.onPermissionRequestCallback = callback;
this.callbacks.onPermissionRequest = callback;
this.sessionUpdateHandler.updateCallbacks(this.callbacks);
}
/**
* 断开连接
*/
disconnect(): void {
this.connection.disconnect();
}
/**
* 检查是否已连接
*/
get isConnected(): boolean {
return this.connection.isConnected;
}
/**
* 获取当前会话ID
*/
get currentSessionId(): string | null {
return this.connection.currentSessionId;
}

View File

@@ -0,0 +1,210 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Qwen连接处理器
*
* 负责Qwen Agent的连接建立、认证和会话创建
*/
import * as vscode from 'vscode';
import type { AcpConnection } from '../acp/AcpConnection.js';
import type { QwenSessionReader } from '../services/QwenSessionReader.js';
import type { AuthStateManager } from '../auth/AuthStateManager.js';
/**
* Qwen连接处理器类
* 处理连接、认证和会话初始化
*/
export class QwenConnectionHandler {
/**
* 连接到Qwen服务并建立会话
*
* @param connection - ACP连接实例
* @param sessionReader - 会话读取器实例
* @param workingDir - 工作目录
* @param authStateManager - 认证状态管理器(可选)
*/
async connect(
connection: AcpConnection,
sessionReader: QwenSessionReader,
workingDir: string,
authStateManager?: AuthStateManager,
): Promise<void> {
const connectId = Date.now();
console.log(`\n========================================`);
console.log(`[QwenAgentManager] 🚀 CONNECT() CALLED - ID: ${connectId}`);
console.log(`[QwenAgentManager] Call stack:\n${new Error().stack}`);
console.log(`========================================\n`);
const config = vscode.workspace.getConfiguration('qwenCode');
const cliPath = config.get<string>('qwen.cliPath', 'qwen');
const openaiApiKey = config.get<string>('qwen.openaiApiKey', '');
const openaiBaseUrl = config.get<string>('qwen.openaiBaseUrl', '');
const model = config.get<string>('qwen.model', '');
const proxy = config.get<string>('qwen.proxy', '');
// 构建额外的CLI参数
const extraArgs: string[] = [];
if (openaiApiKey) {
extraArgs.push('--openai-api-key', openaiApiKey);
}
if (openaiBaseUrl) {
extraArgs.push('--openai-base-url', openaiBaseUrl);
}
if (model) {
extraArgs.push('--model', model);
}
if (proxy) {
extraArgs.push('--proxy', proxy);
console.log('[QwenAgentManager] Using proxy:', proxy);
}
await connection.connect('qwen', cliPath, workingDir, extraArgs);
// 确定认证方法
const authMethod = openaiApiKey ? 'openai' : 'qwen-oauth';
// 检查是否有有效的缓存认证
if (authStateManager) {
const hasValidAuth = await authStateManager.hasValidAuth(
workingDir,
authMethod,
);
if (hasValidAuth) {
console.log('[QwenAgentManager] Using cached authentication');
}
}
// 尝试恢复现有会话或创建新会话
let sessionRestored = false;
// 尝试从本地文件获取会话
console.log('[QwenAgentManager] Reading local session files...');
try {
const sessions = await sessionReader.getAllSessions(workingDir);
if (sessions.length > 0) {
console.log(
'[QwenAgentManager] Found existing sessions:',
sessions.length,
);
const lastSession = sessions[0]; // 已按lastUpdated排序
try {
await connection.switchSession(lastSession.sessionId);
console.log(
'[QwenAgentManager] Restored session:',
lastSession.sessionId,
);
sessionRestored = true;
} catch (switchError) {
console.log(
'[QwenAgentManager] session/switch not supported or failed:',
switchError instanceof Error
? switchError.message
: String(switchError),
);
}
} else {
console.log('[QwenAgentManager] No existing sessions found');
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.log(
'[QwenAgentManager] Failed to read local sessions:',
errorMessage,
);
}
// 如果无法恢复会话则创建新会话
if (!sessionRestored) {
console.log('[QwenAgentManager] Creating new session...');
console.log(
`[QwenAgentManager] ⚠️ WORKAROUND: Skipping explicit authenticate() call`,
);
console.log(
`[QwenAgentManager] ⚠️ Reason: newSession() internally calls refreshAuth(), which triggers device flow`,
);
console.log(
`[QwenAgentManager] ⚠️ Calling authenticate() first causes double authentication`,
);
try {
console.log(
`\n🔐 [AUTO AUTH] newSession will handle authentication automatically\n`,
);
await this.newSessionWithRetry(connection, workingDir, 3);
console.log('[QwenAgentManager] New session created successfully');
// 保存认证状态
if (authStateManager) {
console.log(
'[QwenAgentManager] Saving auth state after successful session creation',
);
await authStateManager.saveAuthState(workingDir, authMethod);
}
} catch (sessionError) {
console.log(`\n⚠ [SESSION FAILED] newSessionWithRetry threw error\n`);
console.log(`[QwenAgentManager] Error details:`, sessionError);
// 清除缓存
if (authStateManager) {
console.log('[QwenAgentManager] Clearing auth cache due to failure');
await authStateManager.clearAuthState();
}
throw sessionError;
}
}
console.log(`\n========================================`);
console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`);
console.log(`========================================\n`);
}
/**
* 创建新会话(带重试)
*
* @param connection - ACP连接实例
* @param workingDir - 工作目录
* @param maxRetries - 最大重试次数
*/
private async newSessionWithRetry(
connection: AcpConnection,
workingDir: string,
maxRetries: number,
): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
console.log(
`[QwenAgentManager] Creating session (attempt ${attempt}/${maxRetries})...`,
);
await connection.newSession(workingDir);
console.log('[QwenAgentManager] Session created successfully');
return;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
console.error(
`[QwenAgentManager] Session creation attempt ${attempt} failed:`,
errorMessage,
);
if (attempt === maxRetries) {
throw new Error(
`Session creation failed after ${maxRetries} attempts: ${errorMessage}`,
);
}
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
console.log(`[QwenAgentManager] Retrying in ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
}

View File

@@ -0,0 +1,143 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Qwen会话更新处理器
*
* 负责处理来自ACP的会话更新并分发到相应的回调函数
*/
import type { AcpSessionUpdate } from '../shared/acpTypes.js';
import type { QwenAgentCallbacks } from './QwenTypes.js';
/**
* Qwen会话更新处理器类
* 处理各种会话更新事件并调用相应的回调
*/
export class QwenSessionUpdateHandler {
private callbacks: QwenAgentCallbacks;
constructor(callbacks: QwenAgentCallbacks) {
this.callbacks = callbacks;
}
/**
* 更新回调函数
*
* @param callbacks - 新的回调函数集合
*/
updateCallbacks(callbacks: QwenAgentCallbacks): void {
this.callbacks = callbacks;
}
/**
* 处理会话更新
*
* @param data - ACP会话更新数据
*/
handleSessionUpdate(data: AcpSessionUpdate): void {
const update = data.update;
switch (update.sessionUpdate) {
case 'user_message_chunk':
// 处理用户消息块
if (update.content?.text && this.callbacks.onStreamChunk) {
this.callbacks.onStreamChunk(update.content.text);
}
break;
case 'agent_message_chunk':
// 处理助手消息块
if (update.content?.text && this.callbacks.onStreamChunk) {
this.callbacks.onStreamChunk(update.content.text);
}
break;
case 'agent_thought_chunk':
// 处理思考块 - 使用特殊回调
if (update.content?.text) {
if (this.callbacks.onThoughtChunk) {
this.callbacks.onThoughtChunk(update.content.text);
} else if (this.callbacks.onStreamChunk) {
// 回退到常规流处理
this.callbacks.onStreamChunk(update.content.text);
}
}
break;
case 'tool_call': {
// 处理新的工具调用
if (this.callbacks.onToolCall && 'toolCallId' in update) {
this.callbacks.onToolCall({
toolCallId: update.toolCallId as string,
kind: (update.kind as string) || undefined,
title: (update.title as string) || undefined,
status: (update.status as string) || undefined,
rawInput: update.rawInput,
content: update.content as
| Array<Record<string, unknown>>
| undefined,
locations: update.locations as
| Array<{ path: string; line?: number | null }>
| undefined,
});
}
break;
}
case 'tool_call_update': {
// 处理工具调用状态更新
if (this.callbacks.onToolCall && 'toolCallId' in update) {
this.callbacks.onToolCall({
toolCallId: update.toolCallId as string,
kind: (update.kind as string) || undefined,
title: (update.title as string) || undefined,
status: (update.status as string) || undefined,
rawInput: update.rawInput,
content: update.content as
| Array<Record<string, unknown>>
| undefined,
locations: update.locations as
| Array<{ path: string; line?: number | null }>
| undefined,
});
}
break;
}
case 'plan': {
// 处理计划更新
if ('entries' in update) {
const entries = update.entries as Array<{
content: string;
priority: 'high' | 'medium' | 'low';
status: 'pending' | 'in_progress' | 'completed';
}>;
if (this.callbacks.onPlan) {
this.callbacks.onPlan(entries);
} else if (this.callbacks.onStreamChunk) {
// 回退到流处理
const planText =
'\n📋 Plan:\n' +
entries
.map(
(entry, i) =>
`${i + 1}. [${entry.priority}] ${entry.content}`,
)
.join('\n');
this.callbacks.onStreamChunk(planText);
}
}
break;
}
default:
console.log('[QwenAgentManager] Unhandled session update type');
break;
}
}
}

View File

@@ -0,0 +1,75 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Qwen Agent Manager 类型定义
*
* 包含所有相关的接口和类型定义
*/
import type { AcpPermissionRequest } from '../shared/acpTypes.js';
/**
* 聊天消息
*/
export interface ChatMessage {
/** 消息角色:用户或助手 */
role: 'user' | 'assistant';
/** 消息内容 */
content: string;
/** 时间戳 */
timestamp: number;
}
/**
* 计划条目
*/
export interface PlanEntry {
/** 条目内容 */
content: string;
/** 优先级 */
priority: 'high' | 'medium' | 'low';
/** 状态 */
status: 'pending' | 'in_progress' | 'completed';
}
/**
* 工具调用更新数据
*/
export interface ToolCallUpdateData {
/** 工具调用ID */
toolCallId: string;
/** 工具类型 */
kind?: string;
/** 工具标题 */
title?: string;
/** 状态 */
status?: string;
/** 原始输入 */
rawInput?: unknown;
/** 内容 */
content?: Array<Record<string, unknown>>;
/** 位置信息 */
locations?: Array<{ path: string; line?: number | null }>;
}
/**
* 回调函数集合
*/
export interface QwenAgentCallbacks {
/** 消息回调 */
onMessage?: (message: ChatMessage) => void;
/** 流式文本块回调 */
onStreamChunk?: (chunk: string) => void;
/** 思考文本块回调 */
onThoughtChunk?: (chunk: string) => void;
/** 工具调用回调 */
onToolCall?: (update: ToolCallUpdateData) => void;
/** 计划回调 */
onPlan?: (entries: PlanEntry[]) => void;
/** 权限请求回调 */
onPermissionRequest?: (request: AcpPermissionRequest) => Promise<string>;
}

View File

@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Qwen Team
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

View File

@@ -120,6 +120,36 @@ export async function activate(context: vscode.ExtensionContext) {
// Initialize WebView Provider
webViewProvider = new WebViewProvider(context, context.extensionUri);
// Register WebView panel serializer for persistence across reloads
context.subscriptions.push(
vscode.window.registerWebviewPanelSerializer('qwenCode.chat', {
async deserializeWebviewPanel(
webviewPanel: vscode.WebviewPanel,
state: unknown,
) {
console.log(
'[Extension] Deserializing WebView panel with state:',
state,
);
// Restore the WebView provider with the existing panel
webViewProvider.restorePanel(webviewPanel);
// Restore state if available
if (state && typeof state === 'object') {
webViewProvider.restoreState(
state as {
conversationId: string | null;
agentInitialized: boolean;
},
);
}
log('WebView panel restored from serialization');
},
}),
);
context.subscriptions.push(
vscode.workspace.onDidCloseTextDocument((doc) => {
if (doc.uri.scheme === DIFF_SCHEME) {

View File

@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Qwen Team
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

View File

@@ -1,9 +1,15 @@
/**
* @license
* Copyright 2025 Qwen Team
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* ACP Types for VSCode Extension
*
* This file provides types for ACP protocol communication.
*/
// ACP JSON-RPC Protocol Types
export const JSONRPC_VERSION = '2.0' as const;
@@ -20,6 +26,9 @@ export interface AcpResponse {
jsonrpc: typeof JSONRPC_VERSION;
id: number;
result?: unknown;
capabilities?: {
[key: string]: unknown;
};
error?: {
code: number;
message: string;
@@ -38,7 +47,7 @@ export interface BaseSessionUpdate {
sessionId: string;
}
// Content block type
// Content block type (simplified version, use schema.ContentBlock for validation)
export interface ContentBlock {
type: 'text' | 'image';
text?: string;
@@ -153,7 +162,7 @@ export type AcpSessionUpdate =
| ToolCallStatusUpdate
| PlanUpdate;
// Permission request
// Permission request (simplified version, use schema.RequestPermissionRequest for validation)
export interface AcpPermissionRequest {
sessionId: string;
options: Array<{

View File

@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Qwen Team
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ import {
type ToolCall as PermissionToolCall,
} from './components/PermissionRequest.js';
import { ToolCall, type ToolCallData } from './components/ToolCall.js';
import { EmptyState } from './components/EmptyState.js';
interface ToolCallUpdate {
type: 'tool_call' | 'tool_call_update';
@@ -54,6 +55,7 @@ export const App: React.FC = () => {
const [qwenSessions, setQwenSessions] = useState<
Array<Record<string, unknown>>
>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [showSessionSelector, setShowSessionSelector] = useState(false);
const [permissionRequest, setPermissionRequest] = useState<{
options: PermissionOption[];
@@ -63,6 +65,7 @@ export const App: React.FC = () => {
new Map(),
);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputFieldRef = useRef<HTMLDivElement>(null);
const handlePermissionRequest = React.useCallback(
(request: {
@@ -201,12 +204,26 @@ export const App: React.FC = () => {
handleToolCallUpdate(message.data);
break;
case 'qwenSessionList':
setQwenSessions(message.data.sessions || []);
case 'qwenSessionList': {
const sessions = message.data.sessions || [];
setQwenSessions(sessions);
// If no current session is selected and there are sessions, select the first one
if (!currentSessionId && sessions.length > 0) {
const firstSessionId =
(sessions[0].id as string) || (sessions[0].sessionId as string);
if (firstSessionId) {
setCurrentSessionId(firstSessionId);
}
}
break;
}
case 'qwenSessionSwitched':
setShowSessionSelector(false);
// Update current session ID
if (message.data.sessionId) {
setCurrentSessionId(message.data.sessionId as string);
}
// Load messages from the session
if (message.data.messages) {
setMessages(message.data.messages);
@@ -230,13 +247,23 @@ export const App: React.FC = () => {
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [currentStreamContent, handlePermissionRequest, handleToolCallUpdate]);
}, [
currentStreamContent,
currentSessionId,
handlePermissionRequest,
handleToolCallUpdate,
]);
useEffect(() => {
// Auto-scroll to bottom when messages change
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, currentStreamContent]);
// Load sessions on component mount
useEffect(() => {
vscode.postMessage({ type: 'getQwenSessions', data: {} });
}, [vscode]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -251,7 +278,11 @@ export const App: React.FC = () => {
data: { text: inputText },
});
// Clear input field
setInputText('');
if (inputFieldRef.current) {
inputFieldRef.current.textContent = '';
}
};
const handleLoadQwenSessions = () => {
@@ -262,25 +293,39 @@ export const App: React.FC = () => {
const handleNewQwenSession = () => {
vscode.postMessage({ type: 'newQwenSession', data: {} });
setShowSessionSelector(false);
setCurrentSessionId(null);
// Clear messages in UI
setMessages([]);
setCurrentStreamContent('');
};
const handleSwitchSession = (sessionId: string) => {
if (sessionId === currentSessionId) {
return;
}
vscode.postMessage({
type: 'switchQwenSession',
data: { sessionId },
});
setCurrentSessionId(sessionId);
setShowSessionSelector(false);
};
// Check if there are any messages or active content
const hasContent =
messages.length > 0 ||
isStreaming ||
toolCalls.size > 0 ||
permissionRequest !== null;
return (
<div className="chat-container">
{showSessionSelector && (
<div className="session-selector-overlay">
<div className="session-selector">
<div className="session-selector-header">
<h3>Qwen Sessions</h3>
<h3>Past Conversations</h3>
<button onClick={() => setShowSessionSelector(false)}></button>
</div>
<div className="session-selector-actions">
@@ -338,62 +383,196 @@ export const App: React.FC = () => {
)}
<div className="chat-header">
<button className="session-button" onClick={handleLoadQwenSessions}>
📋 Sessions
<button
className="header-conversations-button"
onClick={handleLoadQwenSessions}
title="Past conversations"
>
<span className="button-content">
<span className="button-text">Past Conversations</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
className="dropdown-icon"
>
<path
fillRule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clipRule="evenodd"
></path>
</svg>
</span>
</button>
<div className="header-spacer"></div>
<button
className="new-session-header-button"
onClick={handleNewQwenSession}
title="New Session"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
data-slot="icon"
className="icon-svg"
>
<path d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"></path>
</svg>
</button>
</div>
<div className="messages-container">
{messages.map((msg, index) => (
<div key={index} className={`message ${msg.role}`}>
<div className="message-content">{msg.content}</div>
<div className="message-timestamp">
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
))}
{!hasContent ? (
<EmptyState />
) : (
<>
{messages.map((msg, index) => (
<div key={index} className={`message ${msg.role}`}>
<div className="message-content">{msg.content}</div>
<div className="message-timestamp">
{new Date(msg.timestamp).toLocaleTimeString()}
</div>
</div>
))}
{/* Tool Calls */}
{Array.from(toolCalls.values()).map((toolCall) => (
<ToolCall key={toolCall.toolCallId} toolCall={toolCall} />
))}
{/* Tool Calls */}
{Array.from(toolCalls.values()).map((toolCall) => (
<ToolCall key={toolCall.toolCallId} toolCall={toolCall} />
))}
{/* Permission Request */}
{permissionRequest && (
<PermissionRequest
options={permissionRequest.options}
toolCall={permissionRequest.toolCall}
onResponse={handlePermissionResponse}
/>
{/* Permission Request */}
{permissionRequest && (
<PermissionRequest
options={permissionRequest.options}
toolCall={permissionRequest.toolCall}
onResponse={handlePermissionResponse}
/>
)}
{isStreaming && currentStreamContent && (
<div className="message assistant streaming">
<div className="message-content">{currentStreamContent}</div>
<div className="streaming-indicator"></div>
</div>
)}
<div ref={messagesEndRef} />
</>
)}
{isStreaming && currentStreamContent && (
<div className="message assistant streaming">
<div className="message-content">{currentStreamContent}</div>
<div className="streaming-indicator"></div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<form className="input-form" onSubmit={handleSubmit}>
<input
type="text"
className="input-field"
placeholder="Type your message..."
value={inputText}
onChange={(e) => setInputText((e.target as HTMLInputElement).value)}
disabled={isStreaming}
/>
<button
type="submit"
className="send-button"
disabled={isStreaming || !inputText.trim()}
>
Send
</button>
</form>
<div className="input-form-container">
<div className="input-form-wrapper">
<form className="input-form" onSubmit={handleSubmit}>
<div className="input-banner"></div>
<div className="input-wrapper">
<div
ref={inputFieldRef}
contentEditable="plaintext-only"
className="input-field-editable"
role="textbox"
aria-label="Message input"
aria-multiline="true"
data-placeholder="Ask Claude to edit…"
onInput={(e) => {
const target = e.target as HTMLDivElement;
setInputText(target.textContent || '');
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
suppressContentEditableWarning
/>
</div>
<div className="input-actions">
<button
type="button"
className="action-button edit-mode-button"
title="Claude will ask before each edit. Click to switch modes."
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M11.013 2.513a1.75 1.75 0 0 1 2.475 2.474L6.226 12.25a2.751 2.751 0 0 1-.892.596l-2.047.848a.75.75 0 0 1-.98-.98l.848-2.047a2.75 2.75 0 0 1 .596-.892l7.262-7.261Z"
clipRule="evenodd"
></path>
</svg>
<span>Ask before edits</span>
</button>
<div className="action-divider"></div>
<button
type="button"
className="action-icon-button thinking-button"
title="Thinking off"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.00293 1.11523L8.35059 1.12402H8.35352C11.9915 1.30834 14.8848 4.31624 14.8848 8C14.8848 11.8025 11.8025 14.8848 8 14.8848C4.19752 14.8848 1.11523 11.8025 1.11523 8C1.11523 7.67691 1.37711 7.41504 1.7002 7.41504C2.02319 7.41514 2.28516 7.67698 2.28516 8C2.28516 11.1563 4.84369 13.7148 8 13.7148C11.1563 13.7148 13.7148 11.1563 13.7148 8C13.7148 4.94263 11.3141 2.4464 8.29492 2.29297V2.29199L7.99609 2.28516H7.9873V2.28418L7.89648 2.27539L7.88281 2.27441V2.27344C7.61596 2.21897 7.41513 1.98293 7.41504 1.7002C7.41504 1.37711 7.67691 1.11523 8 1.11523H8.00293ZM8 3.81543C8.32309 3.81543 8.58496 4.0773 8.58496 4.40039V7.6377L10.9619 8.82715C11.2505 8.97169 11.3678 9.32256 11.2236 9.61133C11.0972 9.86425 10.8117 9.98544 10.5488 9.91504L10.5352 9.91211V9.91016L10.4502 9.87891L10.4385 9.87402V9.87305L7.73828 8.52344C7.54007 8.42433 7.41504 8.22155 7.41504 8V4.40039C7.41504 4.0773 7.67691 3.81543 8 3.81543ZM2.44336 5.12695C2.77573 5.19517 3.02597 5.48929 3.02637 5.8418C3.02637 6.19456 2.7761 6.49022 2.44336 6.55859L2.2959 6.57324C1.89241 6.57324 1.56543 6.24529 1.56543 5.8418C1.56588 5.43853 1.89284 5.1123 2.2959 5.1123L2.44336 5.12695ZM3.46094 2.72949C3.86418 2.72984 4.19017 3.05712 4.19043 3.45996V3.46094C4.19009 3.86393 3.86392 4.19008 3.46094 4.19043H3.45996C3.05712 4.19017 2.72983 3.86419 2.72949 3.46094V3.45996C2.72976 3.05686 3.05686 2.72976 3.45996 2.72949H3.46094ZM5.98926 1.58008C6.32235 1.64818 6.57324 1.94276 6.57324 2.2959L6.55859 2.44336C6.49022 2.7761 6.19456 3.02637 5.8418 3.02637C5.43884 3.02591 5.11251 2.69895 5.1123 2.2959L5.12695 2.14844C5.19504 1.81591 5.48906 1.56583 5.8418 1.56543L5.98926 1.58008Z"
strokeWidth="0.27"
style={{
stroke: 'var(--app-secondary-foreground)',
fill: 'var(--app-secondary-foreground)',
}}
></path>
</svg>
</button>
<button
type="button"
className="action-icon-button command-button"
title="Show command menu (/)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12.528 3.047a.75.75 0 0 1 .449.961L8.433 16.504a.75.75 0 1 1-1.41-.512l4.544-12.496a.75.75 0 0 1 .961-.449Z"
clipRule="evenodd"
></path>
</svg>
</button>
<button
type="submit"
className="send-button-icon"
disabled={isStreaming || !inputText.trim()}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10 17a.75.75 0 0 1-.75-.75V5.612L5.29 9.77a.75.75 0 0 1-1.08-1.04l5.25-5.5a.75.75 0 0 1 1.08 0l5.25 5.5a.75.75 0 1 1-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0 1 10 17Z"
clipRule="evenodd"
></path>
</svg>
</button>
</div>
</form>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,229 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Styles extracted from Claude Code extension (v2.0.43)
* Path: /Users/jinjing/Downloads/Anthropic.claude-code-2.0.43/extension/webview/index.css
*/
/* ===========================
Header Styles (from Claude Code .he)
=========================== */
.chat-header {
display: flex;
border-bottom: 1px solid var(--app-primary-border-color);
padding: 6px 10px;
gap: 4px;
background-color: var(--app-header-background);
justify-content: flex-start;
user-select: none;
}
/* ===========================
Session Selector Button (from Claude Code .E)
=========================== */
.session-selector-button {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
outline: none;
min-width: 0;
max-width: 300px;
overflow: hidden;
font-size: var(--vscode-chat-font-size, 13px);
font-family: var(--vscode-chat-font-family);
}
.session-selector-button:focus,
.session-selector-button:hover {
background: var(--app-ghost-button-hover-background);
}
/* Session Selector Button Internal Elements */
.session-selector-button-content {
display: flex;
align-items: center;
gap: 4px;
max-width: 300px;
overflow: hidden;
}
.session-selector-button-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.session-selector-button-icon {
flex-shrink: 0;
}
.session-selector-button-icon svg {
width: 16px;
height: 16px;
min-width: 16px;
}
/* ===========================
Icon Button (from Claude Code .j)
=========================== */
.icon-button {
flex: 0 0 auto;
padding: 0;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
outline: none;
width: 24px;
height: 24px;
}
.icon-button:focus,
.icon-button:hover {
background: var(--app-ghost-button-hover-background);
}
/* ===========================
Session Selector Modal (from Claude Code .Wt)
=========================== */
.session-selector-modal {
position: fixed;
background: var(--app-menu-background);
border: 1px solid var(--app-menu-border);
border-radius: var(--corner-radius-small);
width: min(400px, calc(100vw - 32px));
max-height: min(500px, 50vh);
display: flex;
flex-direction: column;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
z-index: 1000;
outline: none;
font-size: var(--vscode-chat-font-size, 13px);
font-family: var(--vscode-chat-font-family);
}
/* Modal Content Area (from Claude Code .It) */
.session-selector-modal-content {
padding: 8px;
overflow-y: auto;
flex: 1;
user-select: none;
}
/* Group Header (from Claude Code .te) */
.session-group-header {
padding: 4px 8px;
color: var(--app-primary-foreground);
opacity: 0.5;
font-size: 0.9em;
}
.session-group-header:not(:first-child) {
margin-top: 8px;
}
/* Session List Container (from Claude Code .St) */
.session-list {
display: flex;
flex-direction: column;
padding: var(--app-list-padding);
gap: var(--app-list-gap);
}
/* Session List Item (from Claude Code .s and .s.U) */
.session-item {
display: flex;
align-items: center;
padding: var(--app-list-item-padding);
justify-content: space-between;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
text-align: left;
width: 100%;
font-size: inherit;
font-family: inherit;
}
.session-item:hover,
.session-item.hovering {
background: var(--app-list-hover-background);
}
.session-item.active {
background: var(--app-list-active-background);
color: var(--app-list-active-foreground);
}
/* Session Item Check Icon (from Claude Code .ne) */
.session-item-check {
width: 16px;
height: 16px;
margin-right: 8px;
flex-shrink: 0;
visibility: hidden;
}
.session-item.active .session-item-check {
visibility: visible;
}
/* Session Item Label (from Claude Code .ae) */
.session-item-label {
flex: 1;
color: var(--app-primary-foreground);
font-size: 1em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-item.active .session-item-label {
font-weight: 600;
color: var(--app-list-active-foreground);
}
/* Session Item Meta Info (from Claude Code .Et) */
.session-item-meta {
opacity: 0.5;
font-size: 0.9em;
flex-shrink: 0;
margin-left: 12px;
}
/* ===========================
CSS Variables (from Claude Code root styles)
=========================== */
:root {
/* Header */
--app-header-background: var(--vscode-sideBar-background);
/* List Styles */
--app-list-padding: 0px;
--app-list-item-padding: 4px 8px;
--app-list-border-color: transparent;
--app-list-border-radius: 4px;
--app-list-hover-background: var(--vscode-list-hoverBackground);
--app-list-active-background: var(--vscode-list-activeSelectionBackground);
--app-list-active-foreground: var(--vscode-list-activeSelectionForeground);
--app-list-gap: 2px;
/* Menu Styles */
--app-menu-background: var(--vscode-menu-background);
--app-menu-border: var(--vscode-menu-border);
--app-menu-foreground: var(--vscode-menu-foreground);
--app-menu-selection-background: var(--vscode-menu-selectionBackground);
--app-menu-selection-foreground: var(--vscode-menu-selectionForeground);
}

View File

@@ -0,0 +1,119 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 40px 20px;
}
.empty-state-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 32px;
max-width: 600px;
width: 100%;
}
.empty-state-logo {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.empty-state-logo-image {
width: 120px;
height: 120px;
object-fit: contain;
}
.empty-state-text {
text-align: center;
}
.empty-state-title {
font-size: 15px;
color: var(--app-primary-foreground);
line-height: 1.5;
font-weight: 400;
max-width: 400px;
}
/* Banner Styles */
.empty-state-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
background-color: var(--app-input-secondary-background);
border: 1px solid var(--app-primary-border-color);
border-radius: var(--corner-radius-medium);
width: 100%;
max-width: 500px;
}
.banner-content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.banner-icon {
flex-shrink: 0;
width: 16px;
height: 16px;
fill: var(--app-primary-foreground);
}
.banner-content label {
font-size: 13px;
color: var(--app-primary-foreground);
margin: 0;
line-height: 1.4;
}
.banner-link {
color: var(--app-claude-orange);
text-decoration: none;
cursor: pointer;
}
.banner-link:hover {
text-decoration: underline;
}
.banner-close {
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
background: transparent;
border: none;
border-radius: var(--corner-radius-small);
color: var(--app-primary-foreground);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.banner-close:hover {
background-color: var(--app-ghost-button-hover-background);
}
.banner-close svg {
width: 10px;
height: 10px;
}

View File

@@ -0,0 +1,86 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import './EmptyState.css';
// Extend Window interface to include ICON_URI
declare global {
interface Window {
ICON_URI?: string;
}
}
export const EmptyState: React.FC = () => {
// Get icon URI from window, fallback to empty string if not available
const iconUri = window.ICON_URI || '';
return (
<div className="empty-state">
<div className="empty-state-content">
{/* Qwen Logo */}
<div className="empty-state-logo">
{iconUri && (
<img
src={iconUri}
alt="Qwen Logo"
className="empty-state-logo-image"
/>
)}
<div className="empty-state-text">
<div className="empty-state-title">
What to do first? Ask about this codebase or we can start writing
code.
</div>
</div>
</div>
{/* Info Banner */}
<div className="empty-state-banner">
<div className="banner-content">
<svg
className="banner-icon"
width="16"
height="16"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M5.14648 7.14648C5.34175 6.95122 5.65825 6.95122 5.85352 7.14648L8.35352 9.64648C8.44728 9.74025 8.5 9.86739 8.5 10C8.5 10.0994 8.47037 10.1958 8.41602 10.2773L8.35352 10.3535L5.85352 12.8535C5.65825 13.0488 5.34175 13.0488 5.14648 12.8535C4.95122 12.6583 4.95122 12.3417 5.14648 12.1465L7.29297 10L5.14648 7.85352C4.95122 7.65825 4.95122 7.34175 5.14648 7.14648Z"></path>
<path d="M14.5 12C14.7761 12 15 12.2239 15 12.5C15 12.7761 14.7761 13 14.5 13H9.5C9.22386 13 9 12.7761 9 12.5C9 12.2239 9.22386 12 9.5 12H14.5Z"></path>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16.5 4C17.3284 4 18 4.67157 18 5.5V14.5C18 15.3284 17.3284 16 16.5 16H3.5C2.67157 16 2 15.3284 2 14.5V5.5C2 4.67157 2.67157 4 3.5 4H16.5ZM3.5 5C3.22386 5 3 5.22386 3 5.5V14.5C3 14.7761 3.22386 15 3.5 15H16.5C16.7761 15 17 14.7761 17 14.5V5.5C17 5.22386 16.7761 5 16.5 5H3.5Z"
></path>
</svg>
<label>
Prefer the Terminal experience?{' '}
<a href="#" className="banner-link">
Switch back in Settings.
</a>
</label>
</div>
<button className="banner-close" aria-label="Close banner">
<svg
width="10"
height="10"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 1L13 13M1 13L13 1"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
></path>
</svg>
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* PlanDisplay.css - Styles for the task plan component
*/
.plan-display {
background-color: rgba(100, 150, 255, 0.05);
border: 1px solid rgba(100, 150, 255, 0.3);
border-radius: 8px;
padding: 16px;
margin: 8px 0;
animation: fadeIn 0.3s ease-in;
}
.plan-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.plan-icon {
font-size: 18px;
}
.plan-title {
font-size: 14px;
font-weight: 600;
color: rgba(150, 180, 255, 1);
}
.plan-entries {
display: flex;
flex-direction: column;
gap: 8px;
}
.plan-entry {
display: flex;
gap: 8px;
padding: 8px;
background-color: var(--vscode-input-background);
border-radius: 4px;
border-left: 3px solid transparent;
transition: all 0.2s ease;
}
.plan-entry[data-priority="high"] {
border-left-color: #ff6b6b;
}
.plan-entry[data-priority="medium"] {
border-left-color: #ffd93d;
}
.plan-entry[data-priority="low"] {
border-left-color: #6bcf7f;
}
.plan-entry.completed {
opacity: 0.6;
}
.plan-entry.completed .plan-entry-content {
text-decoration: line-through;
}
.plan-entry.in_progress {
background-color: rgba(100, 150, 255, 0.1);
border-left-width: 4px;
}
.plan-entry-header {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.plan-entry-status,
.plan-entry-priority {
font-size: 14px;
}
.plan-entry-index {
font-size: 12px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
min-width: 20px;
}
.plan-entry-content {
flex: 1;
font-size: 13px;
line-height: 1.5;
color: var(--vscode-foreground);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,78 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import './PlanDisplay.css';
export interface PlanEntry {
content: string;
priority: 'high' | 'medium' | 'low';
status: 'pending' | 'in_progress' | 'completed';
}
interface PlanDisplayProps {
entries: PlanEntry[];
}
/**
* PlanDisplay component - displays AI's task plan/todo list
*/
export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
const getPriorityIcon = (priority: string) => {
switch (priority) {
case 'high':
return '🔴';
case 'medium':
return '🟡';
case 'low':
return '🟢';
default:
return '⚪';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'pending':
return '⏱️';
case 'in_progress':
return '⚙️';
case 'completed':
return '✅';
default:
return '❓';
}
};
return (
<div className="plan-display">
<div className="plan-header">
<span className="plan-icon">📋</span>
<span className="plan-title">Task Plan</span>
</div>
<div className="plan-entries">
{entries.map((entry, index) => (
<div
key={index}
className={`plan-entry ${entry.status}`}
data-priority={entry.priority}
>
<div className="plan-entry-header">
<span className="plan-entry-status">
{getStatusIcon(entry.status)}
</span>
<span className="plan-entry-priority">
{getPriorityIcon(entry.priority)}
</span>
<span className="plan-entry-index">{index + 1}.</span>
</div>
<div className="plan-entry-content">{entry.content}</div>
</div>
))}
</div>
</div>
);
};

View File

@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Qwen Team
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

View File

@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Qwen Team
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/