diff --git a/packages/vscode-ide-companion/src/WebViewProvider.ts b/packages/vscode-ide-companion/src/WebViewProvider.ts index 6a112ebd..30f5a7c7 100644 --- a/packages/vscode-ide-companion/src/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/WebViewProvider.ts @@ -12,6 +12,7 @@ import { 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'; export class WebViewProvider { private panel: vscode.WebviewPanel | null = null; @@ -21,6 +22,7 @@ export class WebViewProvider { private currentConversationId: string | null = null; private disposables: vscode.Disposable[] = []; private agentInitialized = false; // Track if agent has been initialized + private currentStreamContent = ''; // Track streaming content for saving constructor( private context: vscode.ExtensionContext, @@ -32,12 +34,23 @@ export class WebViewProvider { // Setup agent callbacks this.agentManager.onStreamChunk((chunk: string) => { + this.currentStreamContent += chunk; this.sendMessageToWebView({ type: 'streamChunk', data: { chunk }, }); }); + this.agentManager.onToolCall((update) => { + this.sendMessageToWebView({ + type: 'toolCall', + data: { + type: 'tool_call', + ...(update as unknown as Record), + }, + }); + }); + this.agentManager.onPermissionRequest( async (request: AcpPermissionRequest) => { // Send permission request to WebView @@ -123,78 +136,283 @@ export class WebViewProvider { const qwenEnabled = config.get('qwen.enabled', true); if (qwenEnabled) { - try { - console.log('[WebViewProvider] Connecting to agent...'); - const authInfo = await this.authStateManager.getAuthInfo(); - console.log('[WebViewProvider] Auth cache status:', authInfo); + // Check if CLI is installed before attempting to connect + const cliDetection = await CliDetector.detectQwenCli(); - await this.agentManager.connect(workingDir, this.authStateManager); - console.log('[WebViewProvider] Agent connected successfully'); - this.agentInitialized = true; + if (!cliDetection.isInstalled) { + console.log( + '[WebViewProvider] Qwen CLI not detected, skipping agent connection', + ); + console.log( + '[WebViewProvider] CLI detection error:', + cliDetection.error, + ); - // 显示成功通知 - vscode.window.showInformationMessage( - '✅ Qwen Code connected successfully!', - ); - } catch (error) { - console.error('[WebViewProvider] Agent connection error:', error); - // Clear auth cache on error - await this.authStateManager.clearAuthState(); - vscode.window.showWarningMessage( - `Failed to connect to Qwen CLI: ${error}\nYou can still use the chat UI, but messages won't be sent to AI.`, + // Show VSCode notification with installation option + await this.promptCliInstallation(); + + // Initialize empty conversation (can still browse history) + await this.initializeEmptyConversation(); + } else { + console.log( + '[WebViewProvider] Qwen CLI detected, attempting connection...', ); + console.log('[WebViewProvider] CLI path:', cliDetection.cliPath); + console.log('[WebViewProvider] CLI version:', cliDetection.version); + + try { + console.log('[WebViewProvider] Connecting to agent...'); + const authInfo = await this.authStateManager.getAuthInfo(); + console.log('[WebViewProvider] Auth cache status:', authInfo); + + await this.agentManager.connect(workingDir, this.authStateManager); + console.log('[WebViewProvider] Agent connected successfully'); + this.agentInitialized = true; + + // Load messages from the current Qwen session + await this.loadCurrentSessionMessages(); + } catch (error) { + console.error('[WebViewProvider] Agent connection error:', error); + // Clear auth cache on error (might be auth issue) + await this.authStateManager.clearAuthState(); + vscode.window.showWarningMessage( + `Failed to connect to Qwen CLI: ${error}\nYou can still use the chat UI, but messages won't be sent to AI.`, + ); + // Fallback to empty conversation + await this.initializeEmptyConversation(); + } } } else { console.log('[WebViewProvider] Qwen agent is disabled in settings'); + // Fallback to ConversationStore + await this.initializeEmptyConversation(); } } else { console.log( '[WebViewProvider] Agent already initialized, reusing existing connection', ); + // Reload current session messages + await this.loadCurrentSessionMessages(); } + } - // Load or create conversation (always do this, even if agent fails) + private async checkCliInstallation(): Promise { try { - console.log('[WebViewProvider] Loading conversations...'); - const conversations = await this.conversationStore.getAllConversations(); - console.log( - '[WebViewProvider] Found conversations:', - conversations.length, - ); + const result = await CliDetector.detectQwenCli(); - if (conversations.length > 0) { - const lastConv = conversations[conversations.length - 1]; - this.currentConversationId = lastConv.id; + this.sendMessageToWebView({ + type: 'cliDetectionResult', + data: { + isInstalled: result.isInstalled, + cliPath: result.cliPath, + version: result.version, + error: result.error, + installInstructions: result.isInstalled + ? undefined + : CliDetector.getInstallationInstructions(), + }, + }); + + if (!result.isInstalled) { + console.log('[WebViewProvider] Qwen CLI not detected:', result.error); + } else { console.log( - '[WebViewProvider] Loaded existing conversation:', - this.currentConversationId, + '[WebViewProvider] Qwen CLI detected:', + result.cliPath, + result.version, + ); + } + } catch (error) { + console.error('[WebViewProvider] CLI detection error:', error); + } + } + + private async loadCurrentSessionMessages(): Promise { + 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, + ); + const messages = + await this.agentManager.getSessionMessages(currentSessionId); + + // Set current conversation ID to the session ID + this.currentConversationId = currentSessionId; + + if (messages.length > 0) { + console.log( + '[WebViewProvider] Loaded', + messages.length, + 'messages from current Qwen session', ); this.sendMessageToWebView({ type: 'conversationLoaded', - data: lastConv, + data: { id: currentSessionId, messages }, }); } else { - console.log('[WebViewProvider] Creating new conversation...'); - const newConv = await this.conversationStore.createConversation(); - this.currentConversationId = newConv.id; + // Session exists but has no messages - show empty conversation console.log( - '[WebViewProvider] Created new conversation:', - this.currentConversationId, + '[WebViewProvider] Current session has no messages, showing empty conversation', ); this.sendMessageToWebView({ type: 'conversationLoaded', - data: newConv, + data: { id: currentSessionId, messages: [] }, }); } - console.log('[WebViewProvider] Initialization complete'); - } catch (convError) { + } catch (error) { console.error( - '[WebViewProvider] Failed to create conversation:', - convError, + '[WebViewProvider] Failed to load session messages:', + error, ); vscode.window.showErrorMessage( - `Failed to initialize conversation: ${convError}`, + `Failed to load session messages: ${error}`, ); + await this.initializeEmptyConversation(); + } + } + + private async promptCliInstallation(): Promise { + const selection = await vscode.window.showWarningMessage( + 'Qwen Code CLI is not installed. You can browse conversation history, but cannot send new messages.', + 'Install Now', + 'View Documentation', + 'Remind Me Later', + ); + + if (selection === 'Install Now') { + await this.installQwenCli(); + } else if (selection === 'View Documentation') { + vscode.env.openExternal( + vscode.Uri.parse('https://github.com/QwenLM/qwen-code#installation'), + ); + } + } + + private async installQwenCli(): Promise { + try { + // Show progress notification + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Installing Qwen Code CLI', + cancellable: false, + }, + async (progress) => { + progress.report({ + message: 'Running: npm install -g @qwen-code/qwen-code@latest', + }); + + const { exec } = await import('child_process'); + const { promisify } = await import('util'); + const execAsync = promisify(exec); + + try { + const { stdout, stderr } = await execAsync( + 'npm install -g @qwen-code/qwen-code@latest', + { timeout: 120000 }, // 2 minutes timeout + ); + + console.log('[WebViewProvider] Installation output:', stdout); + if (stderr) { + console.warn('[WebViewProvider] Installation stderr:', stderr); + } + + // Clear cache and recheck + CliDetector.clearCache(); + const detection = await CliDetector.detectQwenCli(); + + if (detection.isInstalled) { + vscode.window + .showInformationMessage( + `✅ Qwen Code CLI installed successfully! Version: ${detection.version}`, + 'Reload Window', + ) + .then((selection) => { + if (selection === 'Reload Window') { + vscode.commands.executeCommand( + 'workbench.action.reloadWindow', + ); + } + }); + + // Update webview with new detection result + await this.checkCliInstallation(); + } else { + throw new Error( + 'Installation completed but CLI still not detected', + ); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error( + '[WebViewProvider] Installation failed:', + errorMessage, + ); + + vscode.window + .showErrorMessage( + `Failed to install Qwen Code CLI: ${errorMessage}`, + 'Try Manual Installation', + 'View Documentation', + ) + .then((selection) => { + if (selection === 'Try Manual Installation') { + const terminal = vscode.window.createTerminal( + 'Qwen Code Installation', + ); + terminal.show(); + terminal.sendText( + 'npm install -g @qwen-code/qwen-code@latest', + ); + } else if (selection === 'View Documentation') { + vscode.env.openExternal( + vscode.Uri.parse( + 'https://github.com/QwenLM/qwen-code#installation', + ), + ); + } + }); + } + }, + ); + } catch (error) { + console.error('[WebViewProvider] Install CLI error:', error); + } + } + + private async initializeEmptyConversation(): Promise { + try { + console.log('[WebViewProvider] Initializing empty conversation'); + const newConv = await this.conversationStore.createConversation(); + this.currentConversationId = newConv.id; + this.sendMessageToWebView({ + type: 'conversationLoaded', + data: newConv, + }); + console.log( + '[WebViewProvider] Empty conversation initialized:', + this.currentConversationId, + ); + } catch (error) { + console.error( + '[WebViewProvider] Failed to initialize conversation:', + error, + ); + // Send empty state to WebView as fallback + this.sendMessageToWebView({ + type: 'conversationLoaded', + data: { id: 'temp', messages: [] }, + }); } } @@ -248,6 +466,12 @@ export class WebViewProvider { await this.handleSwitchQwenSession(message.data?.sessionId || ''); break; + case 'recheckCli': + // Clear cache and recheck CLI installation + CliDetector.clearCache(); + await this.checkCliInstallation(); + break; + default: console.warn('[WebViewProvider] Unknown message type:', message.type); break; @@ -258,7 +482,13 @@ export class WebViewProvider { console.log('[WebViewProvider] handleSendMessage called with:', text); if (!this.currentConversationId) { - console.error('[WebViewProvider] No current conversation ID'); + const errorMsg = 'No active conversation. Please restart the extension.'; + console.error('[WebViewProvider]', errorMsg); + vscode.window.showErrorMessage(errorMsg); + this.sendMessageToWebView({ + type: 'error', + data: { message: errorMsg }, + }); return; } @@ -299,6 +529,9 @@ export class WebViewProvider { // Send to agent try { + // Reset stream content + this.currentStreamContent = ''; + // Create placeholder for assistant message this.sendMessageToWebView({ type: 'streamStart', @@ -310,7 +543,20 @@ export class WebViewProvider { await this.agentManager.sendMessage(text); console.log('[WebViewProvider] Agent manager send complete'); - // Stream is complete + // Stream is complete - save assistant message + if (this.currentStreamContent && this.currentConversationId) { + const assistantMessage: ChatMessage = { + role: 'assistant', + content: this.currentStreamContent, + timestamp: Date.now(), + }; + await this.conversationStore.addMessage( + this.currentConversationId, + assistantMessage, + ); + console.log('[WebViewProvider] Assistant message saved to store'); + } + this.sendMessageToWebView({ type: 'streamEnd', data: { timestamp: Date.now() }, @@ -386,8 +632,6 @@ export class WebViewProvider { type: 'conversationCleared', data: {}, }); - - vscode.window.showInformationMessage('✅ New Qwen session created!'); } catch (error) { console.error('[WebViewProvider] Failed to create new session:', error); this.sendMessageToWebView({ @@ -401,6 +645,10 @@ export class WebViewProvider { try { console.log('[WebViewProvider] Switching to Qwen session:', sessionId); + // Set current conversation ID so we can send messages + this.currentConversationId = sessionId; + console.log('[WebViewProvider] Set currentConversationId to:', sessionId); + // Get session messages from local files const messages = await this.agentManager.getSessionMessages(sessionId); console.log( @@ -411,10 +659,26 @@ export class WebViewProvider { // Try to switch session in ACP (may fail if not supported) try { await this.agentManager.switchToSession(sessionId); + console.log('[WebViewProvider] Session switched successfully in ACP'); } catch (_switchError) { console.log( - '[WebViewProvider] session/switch not supported, but loaded messages anyway', + '[WebViewProvider] session/switch not supported or failed, creating new session', ); + // If switch fails, create a new session to continue conversation + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + try { + await this.agentManager.createNewSession(workingDir); + console.log('[WebViewProvider] Created new session as fallback'); + } catch (newSessionError) { + console.error( + '[WebViewProvider] Failed to create new session:', + newSessionError, + ); + vscode.window.showWarningMessage( + 'Could not switch to session. Created new session instead.', + ); + } } // Send messages to WebView @@ -422,16 +686,13 @@ export class WebViewProvider { type: 'qwenSessionSwitched', data: { sessionId, messages }, }); - - vscode.window.showInformationMessage( - `Loaded Qwen session with ${messages.length} messages`, - ); } catch (error) { console.error('[WebViewProvider] Failed to switch session:', error); this.sendMessageToWebView({ type: 'error', data: { message: `Failed to switch session: ${error}` }, }); + vscode.window.showErrorMessage(`Failed to switch session: ${error}`); } } diff --git a/packages/vscode-ide-companion/src/acp/AcpConnection.ts b/packages/vscode-ide-companion/src/acp/AcpConnection.ts index 8b0d2593..0db2c245 100644 --- a/packages/vscode-ide-companion/src/acp/AcpConnection.ts +++ b/packages/vscode-ide-companion/src/acp/AcpConnection.ts @@ -286,7 +286,23 @@ export class AcpConnection { 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; } @@ -317,12 +333,19 @@ export class AcpConnection { try { const response = await this.onPermissionRequest(params); const optionId = response.optionId; - const outcome = optionId.includes('reject') ? 'rejected' : 'selected'; + + // Handle cancel, reject, or allow + let outcome: string; + if (optionId.includes('reject') || optionId === 'cancel') { + outcome = 'rejected'; + } else { + outcome = 'selected'; + } return { outcome: { outcome, - optionId, + optionId: optionId === 'cancel' ? 'reject_once' : optionId, }, }; } catch (_error) { @@ -335,6 +358,83 @@ export class AcpConnection { } } + 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 { + 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 { const initializeParams = { protocolVersion: 1, @@ -439,4 +539,8 @@ export class AcpConnection { get hasActiveSession(): boolean { return this.sessionId !== null; } + + get currentSessionId(): string | null { + return this.sessionId; + } } diff --git a/packages/vscode-ide-companion/src/agents/QwenAgentManager.ts b/packages/vscode-ide-companion/src/agents/QwenAgentManager.ts index b89bd53b..9ef7bbe4 100644 --- a/packages/vscode-ide-companion/src/agents/QwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/agents/QwenAgentManager.ts @@ -22,11 +22,22 @@ export interface ChatMessage { timestamp: number; } +interface ToolCallUpdateData { + toolCallId: string; + kind?: string; + title?: string; + status?: string; + rawInput?: unknown; + content?: Array>; + locations?: Array<{ path: string; line?: number | null }>; +} + 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; @@ -373,19 +384,91 @@ export class QwenAgentManager { private handleSessionUpdate(data: AcpSessionUpdate): void { const update = data.update; - if (update.sessionUpdate === 'agent_message_chunk') { - if (update.content?.text && this.onStreamChunkCallback) { - this.onStreamChunkCallback(update.content.text); - } - } else if (update.sessionUpdate === 'tool_call') { - // Handle tool call updates - const toolCall = update as { title?: string; status?: string }; - const title = toolCall.title || 'Tool Call'; - const status = toolCall.status || 'pending'; + 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; - if (this.onStreamChunkCallback) { - this.onStreamChunkCallback(`\n🔧 ${title} [${status}]\n`); + 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> + | 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> + | 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; } } @@ -397,6 +480,10 @@ export class QwenAgentManager { this.onStreamChunkCallback = callback; } + onToolCall(callback: (update: ToolCallUpdateData) => void): void { + this.onToolCallCallback = callback; + } + onPermissionRequest( callback: (request: AcpPermissionRequest) => Promise, ): void { @@ -410,4 +497,8 @@ export class QwenAgentManager { get isConnected(): boolean { return this.connection.isConnected; } + + get currentSessionId(): string | null { + return this.connection.currentSessionId; + } } diff --git a/packages/vscode-ide-companion/src/shared/acpTypes.ts b/packages/vscode-ide-companion/src/shared/acpTypes.ts index 3b05354a..83dcaae2 100644 --- a/packages/vscode-ide-companion/src/shared/acpTypes.ts +++ b/packages/vscode-ide-companion/src/shared/acpTypes.ts @@ -38,17 +38,36 @@ export interface BaseSessionUpdate { sessionId: string; } +// Content block type +export interface ContentBlock { + type: 'text' | 'image'; + text?: string; + data?: string; + mimeType?: string; + uri?: string; +} + +// User message chunk update +export interface UserMessageChunkUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'user_message_chunk'; + content: ContentBlock; + }; +} + // Agent message chunk update export interface AgentMessageChunkUpdate extends BaseSessionUpdate { update: { sessionUpdate: 'agent_message_chunk'; - content: { - type: 'text' | 'image'; - text?: string; - data?: string; - mimeType?: string; - uri?: string; - }; + content: ContentBlock; + }; +} + +// Agent thought chunk update +export interface AgentThoughtChunkUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'agent_thought_chunk'; + content: ContentBlock; }; } @@ -59,7 +78,16 @@ export interface ToolCallUpdate extends BaseSessionUpdate { toolCallId: string; status: 'pending' | 'in_progress' | 'completed' | 'failed'; title: string; - kind: 'read' | 'edit' | 'execute'; + kind: + | 'read' + | 'edit' + | 'execute' + | 'delete' + | 'move' + | 'search' + | 'fetch' + | 'think' + | 'other'; rawInput?: unknown; content?: Array<{ type: 'content' | 'diff'; @@ -71,11 +99,59 @@ export interface ToolCallUpdate extends BaseSessionUpdate { oldText?: string | null; newText?: string; }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; + }; +} + +// Tool call status update +export interface ToolCallStatusUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'tool_call_update'; + toolCallId: string; + status?: 'pending' | 'in_progress' | 'completed' | 'failed'; + title?: string; + kind?: string; + rawInput?: unknown; + content?: Array<{ + type: 'content' | 'diff'; + content?: { + type: 'text'; + text: string; + }; + path?: string; + oldText?: string | null; + newText?: string; + }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; + }; +} + +// Plan update +export interface PlanUpdate extends BaseSessionUpdate { + update: { + sessionUpdate: 'plan'; + entries: Array<{ + content: string; + priority: 'high' | 'medium' | 'low'; + status: 'pending' | 'in_progress' | 'completed'; + }>; }; } // Union type for all session updates -export type AcpSessionUpdate = AgentMessageChunkUpdate | ToolCallUpdate; +export type AcpSessionUpdate = + | UserMessageChunkUpdate + | AgentMessageChunkUpdate + | AgentThoughtChunkUpdate + | ToolCallUpdate + | ToolCallStatusUpdate + | PlanUpdate; // Permission request export interface AcpPermissionRequest { diff --git a/packages/vscode-ide-companion/src/utils/CliDetector.ts b/packages/vscode-ide-companion/src/utils/CliDetector.ts new file mode 100644 index 00000000..c09ed250 --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/CliDetector.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); + +export interface CliDetectionResult { + isInstalled: boolean; + cliPath?: string; + version?: string; + error?: string; +} + +/** + * Detects if Qwen Code CLI is installed and accessible + */ +export class CliDetector { + private static cachedResult: CliDetectionResult | null = null; + private static lastCheckTime: number = 0; + private static readonly CACHE_DURATION_MS = 30000; // 30 seconds + + /** + * Checks if the Qwen Code CLI is installed + * @param forceRefresh - Force a new check, ignoring cache + * @returns Detection result with installation status and details + */ + static async detectQwenCli( + forceRefresh = false, + ): Promise { + const now = Date.now(); + + // Return cached result if available and not expired + if ( + !forceRefresh && + this.cachedResult && + now - this.lastCheckTime < this.CACHE_DURATION_MS + ) { + return this.cachedResult; + } + + try { + const isWindows = process.platform === 'win32'; + const whichCommand = isWindows ? 'where' : 'which'; + + // Check if qwen command exists + try { + const { stdout } = await execAsync(`${whichCommand} qwen`, { + timeout: 5000, + }); + const cliPath = stdout.trim().split('\n')[0]; + + // Try to get version + let version: string | undefined; + try { + const { stdout: versionOutput } = await execAsync('qwen --version', { + timeout: 5000, + }); + version = versionOutput.trim(); + } catch { + // Version check failed, but CLI is installed + } + + this.cachedResult = { + isInstalled: true, + cliPath, + version, + }; + this.lastCheckTime = now; + return this.cachedResult; + } catch (_error) { + // CLI not found + this.cachedResult = { + isInstalled: false, + error: `Qwen Code CLI not found in PATH. Please install it using: npm install -g @qwen-code/qwen-code@latest`, + }; + this.lastCheckTime = now; + return this.cachedResult; + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.cachedResult = { + isInstalled: false, + error: `Failed to detect Qwen Code CLI: ${errorMessage}`, + }; + this.lastCheckTime = now; + return this.cachedResult; + } + } + + /** + * Clears the cached detection result + */ + static clearCache(): void { + this.cachedResult = null; + this.lastCheckTime = 0; + } + + /** + * Gets installation instructions based on the platform + */ + static getInstallationInstructions(): { + title: string; + steps: string[]; + documentationUrl: string; + } { + return { + title: 'Qwen Code CLI is not installed', + steps: [ + 'Install via npm:', + ' npm install -g @qwen-code/qwen-code@latest', + '', + 'Or install from source:', + ' git clone https://github.com/QwenLM/qwen-code.git', + ' cd qwen-code', + ' npm install', + ' npm install -g .', + '', + 'After installation, reload VS Code or restart the extension.', + ], + documentationUrl: 'https://github.com/QwenLM/qwen-code#installation', + }; + } +} diff --git a/packages/vscode-ide-companion/src/webview/App.css b/packages/vscode-ide-companion/src/webview/App.css index 1f12993c..832cfdd6 100644 --- a/packages/vscode-ide-companion/src/webview/App.css +++ b/packages/vscode-ide-companion/src/webview/App.css @@ -338,3 +338,494 @@ body { font-family: monospace; } +/* Permission Request Component Styles */ +.permission-request-card { + margin: 16px 0; + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.permission-card-body { + background: linear-gradient( + 135deg, + rgba(79, 134, 247, 0.08) 0%, + rgba(79, 134, 247, 0.03) 100% + ); + border: 1.5px solid rgba(79, 134, 247, 0.3); + border-radius: 10px; + padding: 16px; + backdrop-filter: blur(10px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.permission-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.permission-icon-wrapper { + width: 40px; + height: 40px; + border-radius: 8px; + background: linear-gradient(135deg, rgba(79, 134, 247, 0.2), rgba(79, 134, 247, 0.1)); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; +} + +.permission-icon { + font-size: 20px; +} + +.permission-info { + flex: 1; +} + +.permission-title { + font-size: 14px; + font-weight: 600; + color: var(--vscode-editor-foreground); + margin-bottom: 2px; +} + +.permission-subtitle { + font-size: 12px; + color: rgba(255, 255, 255, 0.6); +} + +.permission-command-section { + margin-bottom: 12px; +} + +.permission-command-label { + font-size: 11px; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 4px; +} + +.permission-command-code { + display: block; + background: rgba(0, 0, 0, 0.2); + padding: 8px 12px; + border-radius: 4px; + font-size: 12px; + font-family: 'Courier New', monospace; + color: var(--vscode-editor-foreground); + overflow-x: auto; + word-break: break-all; +} + +.permission-locations-section { + margin-bottom: 12px; +} + +.permission-locations-label { + font-size: 11px; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 6px; +} + +.permission-location-item { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: rgba(0, 0, 0, 0.15); + border-radius: 4px; + margin-bottom: 4px; + font-size: 12px; +} + +.permission-location-icon { + font-size: 14px; +} + +.permission-location-path { + flex: 1; + font-family: 'Courier New', monospace; + overflow: hidden; + text-overflow: ellipsis; +} + +.permission-location-line { + color: rgba(255, 255, 255, 0.6); + font-size: 11px; +} + +.permission-options-section { + margin-top: 16px; +} + +.permission-options-label { + font-size: 12px; + margin-bottom: 8px; + color: var(--vscode-editor-foreground); +} + +.permission-options-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; +} + +.permission-option { + display: flex; + align-items: center; + padding: 10px 16px; + border: 1.5px solid transparent; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + position: relative; + overflow: hidden; +} + +.permission-option input[type="radio"] { + margin-right: 10px; + cursor: pointer; +} + +.permission-radio { + width: 16px; + height: 16px; +} + +.permission-option-content { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; +} + +.permission-always-badge { + font-size: 14px; + animation: pulse 1.5s ease-in-out infinite; +} + +.permission-option.allow { + background: linear-gradient(135deg, rgba(46, 160, 67, 0.15), rgba(46, 160, 67, 0.08)); + border-color: rgba(46, 160, 67, 0.3); +} + +.permission-option.allow.selected { + background: linear-gradient(135deg, rgba(46, 160, 67, 0.25), rgba(46, 160, 67, 0.15)); + border-color: rgba(46, 160, 67, 0.5); + box-shadow: 0 2px 8px rgba(46, 160, 67, 0.2); +} + +.permission-option.reject { + background: linear-gradient(135deg, rgba(200, 40, 40, 0.15), rgba(200, 40, 40, 0.08)); + border-color: rgba(200, 40, 40, 0.3); +} + +.permission-option.reject.selected { + background: linear-gradient(135deg, rgba(200, 40, 40, 0.25), rgba(200, 40, 40, 0.15)); + border-color: rgba(200, 40, 40, 0.5); + box-shadow: 0 2px 8px rgba(200, 40, 40, 0.2); +} + +.permission-option.always { + border-style: dashed; +} + +.permission-option:hover { + transform: translateY(-1px); +} + +.permission-actions { + display: flex; + justify-content: flex-start; + padding-left: 26px; +} + +.permission-confirm-button { + padding: 8px 20px; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.permission-confirm-button:hover:not(:disabled) { + background: var(--vscode-button-hoverBackground); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.permission-confirm-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.permission-no-options { + padding: 12px; + text-align: center; + color: rgba(255, 255, 255, 0.5); + font-size: 12px; +} + +.permission-success { + margin-top: 12px; + padding: 10px 16px; + background: rgba(46, 160, 67, 0.15); + border: 1px solid rgba(46, 160, 67, 0.4); + border-radius: 6px; + display: flex; + align-items: center; + gap: 8px; +} + +.permission-success-icon { + font-size: 16px; + color: #4ec9b0; +} + +.permission-success-text { + font-size: 13px; + color: #4ec9b0; +} + +/* Tool Call Component Styles */ +.tool-call-card { + margin: 12px 0; + padding: 12px 16px; + background: var(--vscode-input-background); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + animation: fadeIn 0.2s ease-in; +} + +.tool-call-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.tool-call-kind-icon { + font-size: 18px; +} + +.tool-call-title { + flex: 1; + font-size: 14px; + font-weight: 600; + color: var(--vscode-editor-foreground); +} + +.tool-call-status { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; +} + +.status-icon { + font-size: 12px; +} + +.status-pending { + background: rgba(79, 134, 247, 0.2); + color: #79b8ff; +} + +.status-in-progress { + background: rgba(255, 165, 0, 0.2); + color: #ffab70; +} + +.status-completed { + background: rgba(46, 160, 67, 0.2); + color: #4ec9b0; +} + +.status-failed { + background: rgba(200, 40, 40, 0.2); + color: #f48771; +} + +.status-unknown { + background: rgba(128, 128, 128, 0.2); + color: #888; +} + +.tool-call-raw-input { + margin-bottom: 12px; +} + +.raw-input-label { + font-size: 11px; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 4px; +} + +.raw-input-content { + background: rgba(0, 0, 0, 0.2); + padding: 8px 12px; + border-radius: 4px; + font-size: 12px; + font-family: 'Courier New', monospace; + overflow-x: auto; + margin: 0; + white-space: pre-wrap; + word-break: break-word; +} + +.tool-call-locations { + margin-bottom: 12px; +} + +.locations-label { + font-size: 11px; + color: rgba(255, 255, 255, 0.7); + margin-bottom: 6px; +} + +.location-item { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + background: rgba(0, 0, 0, 0.15); + border-radius: 4px; + margin-bottom: 4px; + font-size: 12px; +} + +.location-icon { + font-size: 14px; +} + +.location-path { + flex: 1; + font-family: 'Courier New', monospace; + overflow: hidden; + text-overflow: ellipsis; +} + +.location-line { + color: rgba(255, 255, 255, 0.6); + font-size: 11px; +} + +.tool-call-content-list { + margin-top: 12px; +} + +.tool-call-diff { + margin-top: 8px; +} + +.diff-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: rgba(79, 134, 247, 0.15); + border-radius: 4px 4px 0 0; + border: 1px solid rgba(79, 134, 247, 0.3); + border-bottom: none; +} + +.diff-icon { + font-size: 14px; +} + +.diff-filename { + font-size: 12px; + font-weight: 600; + font-family: 'Courier New', monospace; +} + +.diff-content { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 8px; + padding: 12px; + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(79, 134, 247, 0.3); + border-radius: 0 0 4px 4px; +} + +.diff-side { + min-width: 0; +} + +.diff-side-label { + font-size: 10px; + color: rgba(255, 255, 255, 0.6); + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.diff-code { + background: rgba(0, 0, 0, 0.3); + padding: 8px; + border-radius: 4px; + font-size: 11px; + font-family: 'Courier New', monospace; + overflow-x: auto; + margin: 0; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow-y: auto; +} + +.diff-arrow { + display: flex; + align-items: center; + color: rgba(255, 255, 255, 0.5); + font-size: 16px; + padding: 0 4px; +} + +.tool-call-content { + margin-top: 8px; +} + +.content-text { + background: rgba(0, 0, 0, 0.2); + padding: 10px 12px; + border-radius: 4px; + font-size: 12px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; +} + +.tool-call-footer { + margin-top: 12px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.tool-call-id { + font-size: 10px; + color: rgba(255, 255, 255, 0.5); + font-family: 'Courier New', monospace; +} + diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index e3e17d4b..a6013fa6 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -6,12 +6,48 @@ import React, { useState, useEffect, useRef } from 'react'; import { useVSCode } from './hooks/useVSCode.js'; -import type { ChatMessage } from '../agents/QwenAgentManager.js'; import type { Conversation } from '../storage/ConversationStore.js'; +import { + PermissionRequest, + type PermissionOption, + type ToolCall as PermissionToolCall, +} from './components/PermissionRequest.js'; +import { ToolCall, type ToolCallData } from './components/ToolCall.js'; + +interface ToolCallUpdate { + type: 'tool_call' | 'tool_call_update'; + toolCallId: string; + kind?: string; + title?: string; + status?: 'pending' | 'in_progress' | 'completed' | 'failed'; + rawInput?: unknown; + content?: Array<{ + type: 'content' | 'diff'; + content?: { + type: string; + text?: string; + [key: string]: unknown; + }; + path?: string; + oldText?: string | null; + newText?: string; + [key: string]: unknown; + }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; +} + +interface TextMessage { + role: 'user' | 'assistant' | 'thinking'; + content: string; + timestamp: number; +} export const App: React.FC = () => { const vscode = useVSCode(); - const [messages, setMessages] = useState([]); + const [messages, setMessages] = useState([]); const [inputText, setInputText] = useState(''); const [isStreaming, setIsStreaming] = useState(false); const [currentStreamContent, setCurrentStreamContent] = useState(''); @@ -19,30 +55,88 @@ export const App: React.FC = () => { Array> >([]); const [showSessionSelector, setShowSessionSelector] = useState(false); + const [permissionRequest, setPermissionRequest] = useState<{ + options: PermissionOption[]; + toolCall: PermissionToolCall; + } | null>(null); + const [toolCalls, setToolCalls] = useState>( + new Map(), + ); const messagesEndRef = useRef(null); const handlePermissionRequest = React.useCallback( (request: { - options: Array<{ name: string; kind: string; optionId: string }>; - toolCall: { title?: string }; + options: PermissionOption[]; + toolCall: PermissionToolCall; }) => { - const optionNames = request.options.map((opt) => opt.name).join(', '); - const confirmed = window.confirm( - `Tool permission request:\n${request.toolCall.title || 'Tool Call'}\n\nOptions: ${optionNames}\n\nAllow?`, - ); - - const selectedOption = confirmed - ? request.options.find((opt) => opt.kind === 'allow_once') - : request.options.find((opt) => opt.kind === 'reject_once'); + console.log('[WebView] Permission request received:', request); + setPermissionRequest(request); + }, + [], + ); + const handlePermissionResponse = React.useCallback( + (optionId: string) => { + console.log('[WebView] Sending permission response:', optionId); vscode.postMessage({ type: 'permissionResponse', - data: { optionId: selectedOption?.optionId || 'reject_once' }, + data: { optionId }, }); + setPermissionRequest(null); }, [vscode], ); + const handleToolCallUpdate = React.useCallback((update: ToolCallUpdate) => { + setToolCalls((prev) => { + const newMap = new Map(prev); + const existing = newMap.get(update.toolCallId); + + if (update.type === 'tool_call') { + // New tool call - cast content to proper type + const content = update.content?.map((item) => ({ + type: item.type as 'content' | 'diff', + content: item.content, + path: item.path, + oldText: item.oldText, + newText: item.newText, + })); + + newMap.set(update.toolCallId, { + toolCallId: update.toolCallId, + kind: update.kind || 'other', + title: update.title || 'Tool Call', + status: update.status || 'pending', + rawInput: update.rawInput as string | object | undefined, + content, + locations: update.locations, + }); + } else if (update.type === 'tool_call_update' && existing) { + // Update existing tool call + const updatedContent = update.content + ? update.content.map((item) => ({ + type: item.type as 'content' | 'diff', + content: item.content, + path: item.path, + oldText: item.oldText, + newText: item.newText, + })) + : undefined; + + newMap.set(update.toolCallId, { + ...existing, + ...(update.kind && { kind: update.kind }), + ...(update.title && { title: update.title }), + ...(update.status && { status: update.status }), + ...(updatedContent && { content: updatedContent }), + ...(update.locations && { locations: update.locations }), + }); + } + + return newMap; + }); + }, []); + useEffect(() => { // Listen for messages from extension const handleMessage = (event: MessageEvent) => { @@ -56,7 +150,7 @@ export const App: React.FC = () => { } case 'message': { - const newMessage = message.data as ChatMessage; + const newMessage = message.data as TextMessage; setMessages((prev) => [...prev, newMessage]); break; } @@ -66,14 +160,21 @@ export const App: React.FC = () => { setCurrentStreamContent(''); break; - case 'streamChunk': - setCurrentStreamContent((prev) => prev + message.data.chunk); + case 'streamChunk': { + const chunkData = message.data; + if (chunkData.role === 'thinking') { + // Handle thinking chunks separately if needed + setCurrentStreamContent((prev) => prev + chunkData.chunk); + } else { + setCurrentStreamContent((prev) => prev + chunkData.chunk); + } break; + } case 'streamEnd': // Finalize the streamed message if (currentStreamContent) { - const assistantMessage: ChatMessage = { + const assistantMessage: TextMessage = { role: 'assistant', content: currentStreamContent, timestamp: Date.now(), @@ -94,6 +195,12 @@ export const App: React.FC = () => { handlePermissionRequest(message.data); break; + case 'toolCall': + case 'toolCallUpdate': + // Handle tool call updates + handleToolCallUpdate(message.data); + break; + case 'qwenSessionList': setQwenSessions(message.data.sessions || []); break; @@ -107,11 +214,13 @@ export const App: React.FC = () => { setMessages([]); } setCurrentStreamContent(''); + setToolCalls(new Map()); break; case 'conversationCleared': setMessages([]); setCurrentStreamContent(''); + setToolCalls(new Map()); break; default: @@ -121,7 +230,7 @@ export const App: React.FC = () => { window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); - }, [currentStreamContent, handlePermissionRequest]); + }, [currentStreamContent, handlePermissionRequest, handleToolCallUpdate]); useEffect(() => { // Auto-scroll to bottom when messages change @@ -244,6 +353,20 @@ export const App: React.FC = () => { ))} + {/* Tool Calls */} + {Array.from(toolCalls.values()).map((toolCall) => ( + + ))} + + {/* Permission Request */} + {permissionRequest && ( + + )} + {isStreaming && currentStreamContent && (
{currentStreamContent}
diff --git a/packages/vscode-ide-companion/src/webview/components/PermissionRequest.tsx b/packages/vscode-ide-companion/src/webview/components/PermissionRequest.tsx new file mode 100644 index 00000000..8ddd72fe --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/PermissionRequest.tsx @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState } from 'react'; + +export interface PermissionOption { + name: string; + kind: string; + optionId: string; +} + +export interface ToolCall { + title?: string; + kind?: string; + toolCallId?: string; + rawInput?: { + command?: string; + description?: string; + [key: string]: unknown; + }; + content?: Array<{ + type: string; + [key: string]: unknown; + }>; + locations?: Array<{ + path: string; + line?: number | null; + }>; + status?: string; +} + +export interface PermissionRequestProps { + options: PermissionOption[]; + toolCall: ToolCall; + onResponse: (optionId: string) => void; +} + +export const PermissionRequest: React.FC = ({ + options, + toolCall, + onResponse, +}) => { + const [selected, setSelected] = useState(null); + const [isResponding, setIsResponding] = useState(false); + const [hasResponded, setHasResponded] = useState(false); + + const getToolInfo = () => { + if (!toolCall) { + return { + title: 'Permission Request', + description: 'Agent is requesting permission', + icon: '🔐', + }; + } + + const displayTitle = + toolCall.title || toolCall.rawInput?.description || 'Permission Request'; + + const kindIcons: Record = { + edit: '✏️', + read: '📖', + fetch: '🌐', + execute: '⚡', + delete: '🗑️', + move: '📦', + search: '🔍', + think: '💭', + other: '🔧', + }; + + return { + title: displayTitle, + icon: kindIcons[toolCall.kind || 'other'] || '🔧', + }; + }; + + const { title, icon } = getToolInfo(); + + const handleConfirm = async () => { + if (hasResponded || !selected) { + return; + } + + setIsResponding(true); + try { + await onResponse(selected); + setHasResponded(true); + } catch (error) { + console.error('Error confirming permission:', error); + } finally { + setIsResponding(false); + } + }; + + if (!toolCall) { + return null; + } + + return ( +
+
+ {/* Header with icon and title */} +
+
+ {icon} +
+
+
{title}
+
Waiting for your approval
+
+
+ + {/* Show command if available */} + {(toolCall.rawInput?.command || toolCall.title) && ( +
+
Command
+ + {toolCall.rawInput?.command || toolCall.title} + +
+ )} + + {/* Show file locations if available */} + {toolCall.locations && toolCall.locations.length > 0 && ( +
+
Affected Files
+ {toolCall.locations.map((location, index) => ( +
+ 📄 + + {location.path} + + {location.line !== null && location.line !== undefined && ( + + ::{location.line} + + )} +
+ ))} +
+ )} + + {/* Options */} + {!hasResponded && ( +
+
Choose an action:
+
+ {options && options.length > 0 ? ( + options.map((option) => { + const isSelected = selected === option.optionId; + const isAllow = option.kind.includes('allow'); + const isAlways = option.kind.includes('always'); + + return ( + + ); + }) + ) : ( +
+ No options available +
+ )} +
+
+ +
+
+ )} + + {/* Success message */} + {hasResponded && ( +
+ + + Response sent successfully + +
+ )} +
+
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/ToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/ToolCall.tsx new file mode 100644 index 00000000..9fc2180c --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/ToolCall.tsx @@ -0,0 +1,189 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; + +export interface ToolCallContent { + type: 'content' | 'diff'; + // For content type + content?: { + type: string; + text?: string; + [key: string]: unknown; + }; + // For diff type + path?: string; + oldText?: string | null; + newText?: string; +} + +export interface ToolCallData { + toolCallId: string; + kind: string; + title: string; + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + rawInput?: string | object; + content?: ToolCallContent[]; + locations?: Array<{ + path: string; + line?: number | null; + }>; +} + +export interface ToolCallProps { + toolCall: ToolCallData; +} + +const StatusTag: React.FC<{ status: string }> = ({ status }) => { + const getStatusInfo = () => { + switch (status) { + case 'pending': + return { className: 'status-pending', text: 'Pending', icon: '⏳' }; + case 'in_progress': + return { + className: 'status-in-progress', + text: 'In Progress', + icon: '🔄', + }; + case 'completed': + return { className: 'status-completed', text: 'Completed', icon: '✓' }; + case 'failed': + return { className: 'status-failed', text: 'Failed', icon: '✗' }; + default: + return { className: 'status-unknown', text: status, icon: '•' }; + } + }; + + const { className, text, icon } = getStatusInfo(); + return ( + + {icon} + {text} + + ); +}; + +const ContentView: React.FC<{ content: ToolCallContent }> = ({ content }) => { + // Handle diff type + if (content.type === 'diff') { + const fileName = + content.path?.split(/[/\\]/).pop() || content.path || 'Unknown file'; + const oldText = content.oldText || ''; + const newText = content.newText || ''; + + return ( +
+
+ 📝 + {fileName} +
+
+
+
Before
+
{oldText || '(empty)'}
+
+
+
+
After
+
{newText || '(empty)'}
+
+
+
+ ); + } + + // Handle content type with text + if (content.type === 'content' && content.content?.text) { + return ( +
+
{content.content.text}
+
+ ); + } + + return null; +}; + +const getKindDisplayName = (kind: string): { name: string; icon: string } => { + const kindMap: Record = { + edit: { name: 'File Edit', icon: '✏️' }, + read: { name: 'File Read', icon: '📖' }, + execute: { name: 'Shell Command', icon: '⚡' }, + fetch: { name: 'Web Fetch', icon: '🌐' }, + delete: { name: 'Delete', icon: '🗑️' }, + move: { name: 'Move/Rename', icon: '📦' }, + search: { name: 'Search', icon: '🔍' }, + think: { name: 'Thinking', icon: '💭' }, + other: { name: 'Other', icon: '🔧' }, + }; + + return kindMap[kind] || { name: kind, icon: '🔧' }; +}; + +const formatRawInput = (rawInput: string | object | undefined): string => { + if (rawInput === undefined) { + return ''; + } + if (typeof rawInput === 'string') { + return rawInput; + } + return JSON.stringify(rawInput, null, 2); +}; + +export const ToolCall: React.FC = ({ toolCall }) => { + const { kind, title, status, rawInput, content, locations, toolCallId } = + toolCall; + const kindInfo: { name: string; icon: string } = getKindDisplayName(kind); + + return ( +
+
+ {kindInfo.icon} + {title || kindInfo.name} + +
+ + {/* Show raw input if available */} + {rawInput !== undefined && rawInput !== null ? ( +
+
Input
+
{formatRawInput(rawInput)}
+
+ ) : null} + + {/* Show locations if available */} + {locations && locations.length > 0 && ( +
+
Files
+ {locations.map((location, index) => ( +
+ 📄 + {location.path} + {location.line !== null && location.line !== undefined && ( + :{location.line} + )} +
+ ))} +
+ )} + + {/* Show content if available */} + {content && content.length > 0 && ( +
+ {content.map((item, index) => ( + + ))} +
+ )} + +
+ + ID: {toolCallId.substring(0, 8)}... + +
+
+ ); +};