From 28892996b348e578a33dea17d0ca70cd19d4c00f Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Tue, 18 Nov 2025 01:00:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(vscode):=20=E9=87=8D=E6=9E=84=20Qwen=20?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E6=A8=A1=E5=9E=8B=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=9D=83=E9=99=90=E8=AF=B7=E6=B1=82=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构 QwenAgentManager 类,支持处理多种类型的消息更新 - 改进权限请求界面,增加详细信息展示和选项选择功能 - 新增工具调用卡片组件,用于展示工具调用相关信息 - 优化消息流处理逻辑,支持不同类型的内容块 - 调整会话切换和新会话创建的处理方式 --- .../src/WebViewProvider.ts | 162 +++++-- .../src/acp/AcpConnection.ts | 4 + .../src/agents/QwenAgentManager.ts | 113 ++++- .../src/shared/acpTypes.ts | 94 +++- .../vscode-ide-companion/src/webview/App.css | 459 +++++++++++++++--- .../vscode-ide-companion/src/webview/App.tsx | 174 +++++-- .../webview/components/PermissionRequest.tsx | 212 ++++++++ .../src/webview/components/ToolCall.tsx | 189 ++++++++ 8 files changed, 1245 insertions(+), 162 deletions(-) create mode 100644 packages/vscode-ide-companion/src/webview/components/PermissionRequest.tsx create mode 100644 packages/vscode-ide-companion/src/webview/components/ToolCall.tsx diff --git a/packages/vscode-ide-companion/src/WebViewProvider.ts b/packages/vscode-ide-companion/src/WebViewProvider.ts index a2d485a8..43f323d3 100644 --- a/packages/vscode-ide-companion/src/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/WebViewProvider.ts @@ -21,6 +21,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 +33,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 @@ -132,10 +144,8 @@ export class WebViewProvider { console.log('[WebViewProvider] Agent connected successfully'); this.agentInitialized = true; - // 显示成功通知 - vscode.window.showInformationMessage( - '✅ Qwen Code connected successfully!', - ); + // Load messages from the current Qwen session + await this.loadCurrentSessionMessages(); } catch (error) { console.error('[WebViewProvider] Agent connection error:', error); // Clear auth cache on error @@ -143,58 +153,99 @@ export class WebViewProvider { 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 loadCurrentSessionMessages(): Promise { try { - console.log('[WebViewProvider] Loading conversations...'); - const conversations = await this.conversationStore.getAllConversations(); - console.log( - '[WebViewProvider] Found conversations:', - conversations.length, - ); + // Get the current active session ID + const currentSessionId = this.agentManager.currentSessionId; - if (conversations.length > 0) { - const lastConv = conversations[conversations.length - 1]; - this.currentConversationId = lastConv.id; + 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 existing conversation:', - this.currentConversationId, + '[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 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: [] }, + }); } } @@ -258,7 +309,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 +356,9 @@ export class WebViewProvider { // Send to agent try { + // Reset stream content + this.currentStreamContent = ''; + // Create placeholder for assistant message this.sendMessageToWebView({ type: 'streamStart', @@ -310,7 +370,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 +459,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 +472,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 +486,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 +513,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 41cb0fd5..0db2c245 100644 --- a/packages/vscode-ide-companion/src/acp/AcpConnection.ts +++ b/packages/vscode-ide-companion/src/acp/AcpConnection.ts @@ -539,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 bee8637d..1253a3e9 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; @@ -342,19 +353,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; } } @@ -366,6 +449,10 @@ export class QwenAgentManager { this.onStreamChunkCallback = callback; } + onToolCall(callback: (update: ToolCallUpdateData) => void): void { + this.onToolCallCallback = callback; + } + onPermissionRequest( callback: (request: AcpPermissionRequest) => Promise, ): void { @@ -379,4 +466,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/webview/App.css b/packages/vscode-ide-companion/src/webview/App.css index ccde6afd..832cfdd6 100644 --- a/packages/vscode-ide-companion/src/webview/App.css +++ b/packages/vscode-ide-companion/src/webview/App.css @@ -338,8 +338,8 @@ body { font-family: monospace; } -/* Claude-style Inline Permission Request */ -.permission-request-inline { +/* Permission Request Component Styles */ +.permission-request-card { margin: 16px 0; animation: slideIn 0.3s ease-out; } @@ -355,7 +355,7 @@ body { } } -.permission-card { +.permission-card-body { background: linear-gradient( 135deg, rgba(79, 134, 247, 0.08) 0%, @@ -368,7 +368,7 @@ body { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); } -.permission-card-header { +.permission-header { display: flex; align-items: center; gap: 12px; @@ -386,11 +386,15 @@ body { font-size: 20px; } +.permission-icon { + font-size: 20px; +} + .permission-info { flex: 1; } -.permission-tool-title { +.permission-title { font-size: 14px; font-weight: 600; color: var(--vscode-editor-foreground); @@ -402,85 +406,426 @@ body { color: rgba(255, 255, 255, 0.6); } -.permission-actions-row { - display: flex; - gap: 8px; - flex-wrap: wrap; +.permission-command-section { + margin-bottom: 12px; } -.permission-btn-inline { +.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; - min-width: 100px; + 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; - font-family: var(--vscode-font-family); +} + +.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; - justify-content: center; - gap: 6px; - position: relative; - overflow: hidden; + gap: 8px; } -.permission-btn-inline::before { - content: ''; - position: absolute; - top: 50%; - left: 50%; - width: 0; - height: 0; - border-radius: 50%; - background: rgba(255, 255, 255, 0.1); - transform: translate(-50%, -50%); - transition: width 0.3s, height 0.3s; -} - -.permission-btn-inline:hover::before { - width: 300px; - height: 300px; -} - -.permission-btn-inline.allow { - background: linear-gradient(135deg, rgba(46, 160, 67, 0.25), rgba(46, 160, 67, 0.15)); +.permission-success-icon { + font-size: 16px; color: #4ec9b0; - border-color: rgba(46, 160, 67, 0.4); } -.permission-btn-inline.allow:hover { - background: linear-gradient(135deg, rgba(46, 160, 67, 0.35), rgba(46, 160, 67, 0.25)); - border-color: rgba(46, 160, 67, 0.6); - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(46, 160, 67, 0.3); +.permission-success-text { + font-size: 13px; + color: #4ec9b0; } -.permission-btn-inline.reject { - background: linear-gradient(135deg, rgba(200, 40, 40, 0.25), rgba(200, 40, 40, 0.15)); - color: #f48771; - border-color: rgba(200, 40, 40, 0.4); +/* 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; } -.permission-btn-inline.reject:hover { - background: linear-gradient(135deg, rgba(200, 40, 40, 0.35), rgba(200, 40, 40, 0.25)); - border-color: rgba(200, 40, 40, 0.6); - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(200, 40, 40, 0.3); +.tool-call-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; } -.permission-btn-inline.always { - border-style: dashed; +.tool-call-kind-icon { + font-size: 18px; } -.always-badge { +.tool-call-title { + flex: 1; font-size: 14px; - animation: pulse 1.5s ease-in-out infinite; + font-weight: 600; + color: var(--vscode-editor-foreground); } -.permission-btn-inline:active { - transform: translateY(0); +.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 af3eabc9..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(''); @@ -20,18 +56,20 @@ export const App: React.FC = () => { >([]); const [showSessionSelector, setShowSessionSelector] = useState(false); const [permissionRequest, setPermissionRequest] = useState<{ - options: Array<{ name: string; kind: string; optionId: string }>; - toolCall: { title?: string }; + 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; }) => { console.log('[WebView] Permission request received:', request); - // Show custom modal instead of window.confirm() setPermissionRequest(request); }, [], @@ -49,6 +87,56 @@ export const App: React.FC = () => { [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) => { @@ -62,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; } @@ -72,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(), @@ -100,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; @@ -113,11 +214,13 @@ export const App: React.FC = () => { setMessages([]); } setCurrentStreamContent(''); + setToolCalls(new Map()); break; case 'conversationCleared': setMessages([]); setCurrentStreamContent(''); + setToolCalls(new Map()); break; default: @@ -127,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 @@ -250,43 +353,18 @@ export const App: React.FC = () => { ))} - {/* Claude-style Inline Permission Request */} + {/* Tool Calls */} + {Array.from(toolCalls.values()).map((toolCall) => ( + + ))} + + {/* Permission Request */} {permissionRequest && ( -
-
-
-
- 🔧 -
-
-
- {permissionRequest.toolCall.title || 'Tool Request'} -
-
- Waiting for your approval -
-
-
- -
- {permissionRequest.options.map((option) => { - const isAllow = option.kind.includes('allow'); - const isAlways = option.kind.includes('always'); - - return ( - - ); - })} -
-
-
+ )} {isStreaming && 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)}... + +
+
+ ); +};