diff --git a/packages/vscode-ide-companion/src/WebViewProvider.ts b/packages/vscode-ide-companion/src/WebViewProvider.ts index af7282a7..b6c985c4 100644 --- a/packages/vscode-ide-companion/src/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/WebViewProvider.ts @@ -50,6 +50,14 @@ export class WebViewProvider { // Setup agent callbacks this.agentManager.onStreamChunk((chunk: string) => { + // Ignore stream chunks from background /chat save commands + if (this.messageHandler.getIsSavingCheckpoint()) { + console.log( + '[WebViewProvider] Ignoring stream chunk from /chat save command', + ); + return; + } + this.messageHandler.appendStreamContent(chunk); this.sendMessageToWebView({ type: 'streamChunk', @@ -59,6 +67,14 @@ export class WebViewProvider { // Setup thought chunk handler this.agentManager.onThoughtChunk((chunk: string) => { + // Ignore thought chunks from background /chat save commands + if (this.messageHandler.getIsSavingCheckpoint()) { + console.log( + '[WebViewProvider] Ignoring thought chunk from /chat save command', + ); + return; + } + this.messageHandler.appendStreamContent(chunk); this.sendMessageToWebView({ type: 'thoughtChunk', @@ -69,11 +85,36 @@ export class WebViewProvider { // Note: Tool call updates are handled in handleSessionUpdate within QwenAgentManager // and sent via onStreamChunk callback this.agentManager.onToolCall((update) => { + // Ignore tool calls from background /chat save commands + if (this.messageHandler.getIsSavingCheckpoint()) { + console.log( + '[WebViewProvider] Ignoring tool call from /chat save command', + ); + return; + } + + // Cast update to access sessionUpdate property + const updateData = update as unknown as Record; + + // Determine message type from sessionUpdate field + // If sessionUpdate is missing, infer from content: + // - If has kind/title/rawInput, it's likely initial tool_call + // - If only has status/content updates, it's tool_call_update + let messageType = updateData.sessionUpdate as string | undefined; + if (!messageType) { + // Infer type: if has kind or title, assume initial call; otherwise update + if (updateData.kind || updateData.title || updateData.rawInput) { + messageType = 'tool_call'; + } else { + messageType = 'tool_call_update'; + } + } + this.sendMessageToWebView({ type: 'toolCall', data: { - type: 'tool_call', - ...(update as unknown as Record), + type: messageType, + ...updateData, }, }); }); diff --git a/packages/vscode-ide-companion/src/acp/acpSessionManager.ts b/packages/vscode-ide-companion/src/acp/acpSessionManager.ts index 9e9b92c5..c7bdc9e4 100644 --- a/packages/vscode-ide-companion/src/acp/acpSessionManager.ts +++ b/packages/vscode-ide-companion/src/acp/acpSessionManager.ts @@ -248,20 +248,46 @@ export class AcpSessionManager { pendingRequests: Map>, nextRequestId: { value: number }, ): Promise { - console.log('[ACP] Loading session:', sessionId); - const response = await this.sendRequest( - AGENT_METHODS.session_load, - { - sessionId, - cwd: process.cwd(), - mcpServers: [], - }, - child, - pendingRequests, - nextRequestId, - ); - console.log('[ACP] Session load response:', response); - return response; + console.log('[ACP] Sending session/load request for session:', sessionId); + console.log('[ACP] Request parameters:', { + sessionId, + cwd: process.cwd(), + mcpServers: [], + }); + + try { + const response = await this.sendRequest( + AGENT_METHODS.session_load, + { + sessionId, + cwd: process.cwd(), + mcpServers: [], + }, + child, + pendingRequests, + nextRequestId, + ); + + console.log( + '[ACP] Session load response:', + JSON.stringify(response).substring(0, 500), + ); + + // Check if response contains an error + if (response.error) { + console.error('[ACP] Session load returned error:', response.error); + } else { + console.log('[ACP] Session load succeeded'); + } + + return response; + } catch (error) { + console.error( + '[ACP] Session load request failed with exception:', + error instanceof Error ? error.message : String(error), + ); + throw error; + } } /** diff --git a/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts b/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts index c852e2d3..6c7b8355 100644 --- a/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/agents/qwenAgentManager.ts @@ -23,7 +23,6 @@ import type { } from './qwenTypes.js'; import { QwenConnectionHandler } from './qwenConnectionHandler.js'; import { QwenSessionUpdateHandler } from './qwenSessionUpdateHandler.js'; -import * as crypto from 'crypto'; export type { ChatMessage, PlanEntry, ToolCallUpdateData }; @@ -163,48 +162,155 @@ export class QwenAgentManager { } /** - * 通过 ACP session/save 方法保存会话 + * 通过发送 /chat save 命令保存会话 + * 由于 CLI 不支持 session/save ACP 方法,我们直接发送 /chat save 命令 * * @param sessionId - 会话ID * @param tag - 保存标签 * @returns 保存响应 */ + async saveSessionViaCommand( + sessionId: string, + tag: string, + ): Promise<{ success: boolean; message?: string }> { + try { + console.log( + '[QwenAgentManager] Saving session via /chat save command:', + sessionId, + 'with tag:', + tag, + ); + + // Send /chat save command as a prompt + // The CLI will handle this as a special command + await this.connection.sendPrompt(`/chat save "${tag}"`); + + console.log('[QwenAgentManager] /chat save command sent successfully'); + return { + success: true, + message: `Session saved with tag: ${tag}`, + }; + } catch (error) { + console.error('[QwenAgentManager] /chat save command failed:', error); + return { + success: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * 通过 ACP session/save 方法保存会话 (已废弃,CLI 不支持) + * + * @deprecated Use saveSessionViaCommand instead + * @param sessionId - 会话ID + * @param tag - 保存标签 + * @returns 保存响应 + */ async saveSessionViaAcp( sessionId: string, tag: string, ): Promise<{ success: boolean; message?: string }> { + // Fallback to command-based save since CLI doesn't support session/save ACP method + console.warn( + '[QwenAgentManager] saveSessionViaAcp is deprecated, using command-based save instead', + ); + return this.saveSessionViaCommand(sessionId, tag); + } + + /** + * 通过发送 /chat save 命令保存会话(CLI 方式) + * 这会调用 CLI 的原生保存功能,确保保存的内容完整 + * + * @param tag - Checkpoint 标签 + * @returns 保存结果 + */ + async saveCheckpointViaCommand( + tag: string, + ): Promise<{ success: boolean; tag?: string; message?: string }> { try { console.log( - '[QwenAgentManager] Saving session via ACP:', - sessionId, - 'with tag:', - tag, + '[QwenAgentManager] ===== SAVING VIA /chat save COMMAND =====', ); - const response = await this.connection.saveSession(tag); - console.log('[QwenAgentManager] Session save response:', response); - // Extract message from response result or error - let message = ''; - if (response?.result) { - if (typeof response.result === 'string') { - message = response.result; - } else if ( - typeof response.result === 'object' && - response.result !== null - ) { - // Try to get message from result object - message = - (response.result as { message?: string }).message || - JSON.stringify(response.result); - } else { - message = String(response.result); - } - } else if (response?.error) { - message = response.error.message; - } + console.log('[QwenAgentManager] Tag:', tag); - return { success: true, message }; + // Send /chat save command as a prompt + // The CLI will handle this as a special command and save the checkpoint + const command = `/chat save "${tag}"`; + console.log('[QwenAgentManager] Sending command:', command); + + await this.connection.sendPrompt(command); + + console.log( + '[QwenAgentManager] Command sent, checkpoint should be saved by CLI', + ); + + // Wait a bit for CLI to process the command + await new Promise((resolve) => setTimeout(resolve, 500)); + + return { + success: true, + tag, + message: `Checkpoint saved via CLI: ${tag}`, + }; } catch (error) { - console.error('[QwenAgentManager] Session save via ACP failed:', error); + console.error('[QwenAgentManager] /chat save command failed:', error); + return { + success: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * 保存会话为 checkpoint(使用 CLI 的格式) + * 保存到 ~/.qwen/tmp/{projectHash}/checkpoint-{tag}.json + * 同时用 sessionId 和 conversationId 保存两份,确保可以通过任一 ID 恢复 + * + * @param messages - 当前会话消息 + * @param conversationId - Conversation ID (from VSCode extension) + * @returns 保存结果 + */ + async saveCheckpoint( + messages: ChatMessage[], + conversationId: string, + ): Promise<{ success: boolean; tag?: string; message?: string }> { + try { + console.log('[QwenAgentManager] ===== CHECKPOINT SAVE START ====='); + console.log('[QwenAgentManager] Conversation ID:', conversationId); + console.log('[QwenAgentManager] Message count:', messages.length); + console.log( + '[QwenAgentManager] Current working dir:', + this.currentWorkingDir, + ); + console.log( + '[QwenAgentManager] Current session ID (from CLI):', + this.currentSessionId, + ); + + // Use CLI's /chat save command instead of manually writing files + // This ensures we save the complete session context including tool calls + if (this.currentSessionId) { + console.log( + '[QwenAgentManager] Using CLI /chat save command for complete save', + ); + return await this.saveCheckpointViaCommand(this.currentSessionId); + } else { + console.warn( + '[QwenAgentManager] No current session ID, cannot use /chat save', + ); + return { + success: false, + message: 'No active CLI session', + }; + } + } catch (error) { + console.error('[QwenAgentManager] ===== CHECKPOINT SAVE FAILED ====='); + console.error('[QwenAgentManager] Error:', error); + console.error( + '[QwenAgentManager] Error stack:', + error instanceof Error ? error.stack : 'N/A', + ); return { success: false, message: error instanceof Error ? error.message : String(error), @@ -223,37 +329,9 @@ export class QwenAgentManager { messages: ChatMessage[], sessionName: string, ): Promise<{ success: boolean; sessionId?: string; message?: string }> { - try { - console.log('[QwenAgentManager] Saving session directly:', sessionName); - - // 转换消息格式 - const qwenMessages = messages.map((msg) => ({ - id: crypto.randomUUID(), - timestamp: new Date(msg.timestamp).toISOString(), - type: msg.role === 'user' ? ('user' as const) : ('qwen' as const), - content: msg.content, - })); - - // 保存会话 - const sessionId = await this.sessionManager.saveSession( - qwenMessages, - sessionName, - this.currentWorkingDir, - ); - - console.log('[QwenAgentManager] Session saved directly:', sessionId); - return { - success: true, - sessionId, - message: `会话已保存: ${sessionName}`, - }; - } catch (error) { - console.error('[QwenAgentManager] Session save directly failed:', error); - return { - success: false, - message: error instanceof Error ? error.message : String(error), - }; - } + // Use checkpoint format instead of session format + // This matches CLI's /chat save behavior + return this.saveCheckpoint(messages, sessionName); } /** @@ -266,14 +344,42 @@ export class QwenAgentManager { async loadSessionViaAcp(sessionId: string): Promise { try { console.log( - '[QwenAgentManager] Testing session/load via ACP for:', + '[QwenAgentManager] Attempting session/load via ACP for session:', sessionId, ); const response = await this.connection.loadSession(sessionId); - console.log('[QwenAgentManager] Session load response:', response); + console.log( + '[QwenAgentManager] Session load succeeded. Response:', + JSON.stringify(response).substring(0, 200), + ); return response; } catch (error) { - console.error('[QwenAgentManager] Session load via ACP failed:', error); + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error( + '[QwenAgentManager] Session load via ACP failed for session:', + sessionId, + ); + console.error('[QwenAgentManager] Error type:', error?.constructor?.name); + console.error('[QwenAgentManager] Error message:', errorMessage); + + // Check if error is from ACP response + if (error && typeof error === 'object' && 'error' in error) { + const acpError = error as { + error?: { code?: number; message?: string }; + }; + if (acpError.error) { + console.error( + '[QwenAgentManager] ACP error code:', + acpError.error.code, + ); + console.error( + '[QwenAgentManager] ACP error message:', + acpError.error.message, + ); + } + } + throw error; } } diff --git a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts index 746adde3..05376ae6 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionManager.ts @@ -46,6 +46,244 @@ export class QwenSessionManager { return crypto.randomUUID(); } + /** + * Save current conversation as a checkpoint (matching CLI's /chat save format) + * Creates checkpoint with BOTH conversationId and sessionId as tags for compatibility + * + * @param messages - Current conversation messages + * @param conversationId - Conversation ID (from VSCode extension) + * @param sessionId - Session ID (from CLI tmp session file, optional) + * @param workingDir - Current working directory + * @returns Checkpoint tag + */ + async saveCheckpoint( + messages: QwenMessage[], + conversationId: string, + workingDir: string, + sessionId?: string, + ): Promise { + try { + console.log('[QwenSessionManager] ===== SAVEPOINT START ====='); + console.log('[QwenSessionManager] Conversation ID:', conversationId); + console.log( + '[QwenSessionManager] Session ID:', + sessionId || 'not provided', + ); + console.log('[QwenSessionManager] Working dir:', workingDir); + console.log('[QwenSessionManager] Message count:', messages.length); + + // Get project directory (parent of chats directory) + const projectHash = this.getProjectHash(workingDir); + console.log('[QwenSessionManager] Project hash:', projectHash); + + const projectDir = path.join(this.qwenDir, 'tmp', projectHash); + console.log('[QwenSessionManager] Project dir:', projectDir); + + if (!fs.existsSync(projectDir)) { + console.log('[QwenSessionManager] Creating project directory...'); + fs.mkdirSync(projectDir, { recursive: true }); + console.log('[QwenSessionManager] Directory created'); + } else { + console.log('[QwenSessionManager] Project directory already exists'); + } + + // Convert messages to checkpoint format (Gemini-style messages) + console.log( + '[QwenSessionManager] Converting messages to checkpoint format...', + ); + const checkpointMessages = messages.map((msg, index) => { + console.log( + `[QwenSessionManager] Message ${index}: type=${msg.type}, contentLength=${msg.content?.length || 0}`, + ); + return { + role: msg.type === 'user' ? 'user' : 'model', + parts: [ + { + text: msg.content, + }, + ], + }; + }); + + console.log( + '[QwenSessionManager] Converted', + checkpointMessages.length, + 'messages', + ); + + const jsonContent = JSON.stringify(checkpointMessages, null, 2); + console.log( + '[QwenSessionManager] JSON content length:', + jsonContent.length, + ); + + // Save with conversationId as primary tag + const convFilename = `checkpoint-${conversationId}.json`; + const convFilePath = path.join(projectDir, convFilename); + console.log( + '[QwenSessionManager] Saving checkpoint with conversationId:', + convFilePath, + ); + fs.writeFileSync(convFilePath, jsonContent, 'utf-8'); + + // Also save with sessionId if provided (for compatibility with CLI session/load) + if (sessionId) { + const sessionFilename = `checkpoint-${sessionId}.json`; + const sessionFilePath = path.join(projectDir, sessionFilename); + console.log( + '[QwenSessionManager] Also saving checkpoint with sessionId:', + sessionFilePath, + ); + fs.writeFileSync(sessionFilePath, jsonContent, 'utf-8'); + } + + // Verify primary file exists + if (fs.existsSync(convFilePath)) { + const stats = fs.statSync(convFilePath); + console.log( + '[QwenSessionManager] Primary checkpoint verified, size:', + stats.size, + ); + } else { + console.error( + '[QwenSessionManager] ERROR: Primary checkpoint does not exist after write!', + ); + } + + console.log('[QwenSessionManager] ===== CHECKPOINT SAVED ====='); + console.log('[QwenSessionManager] Primary path:', convFilePath); + if (sessionId) { + console.log( + '[QwenSessionManager] Secondary path (sessionId):', + path.join(projectDir, `checkpoint-${sessionId}.json`), + ); + } + return conversationId; + } catch (error) { + console.error('[QwenSessionManager] ===== CHECKPOINT SAVE FAILED ====='); + console.error('[QwenSessionManager] Error:', error); + console.error( + '[QwenSessionManager] Error stack:', + error instanceof Error ? error.stack : 'N/A', + ); + throw error; + } + } + + /** + * Find checkpoint file for a given sessionId + * Tries both checkpoint-{sessionId}.json and searches session files for matching sessionId + * + * @param sessionId - Session ID to find checkpoint for + * @param workingDir - Current working directory + * @returns Checkpoint tag if found, null otherwise + */ + async findCheckpointTag( + sessionId: string, + workingDir: string, + ): Promise { + try { + const projectHash = this.getProjectHash(workingDir); + const projectDir = path.join(this.qwenDir, 'tmp', projectHash); + + // First, try direct checkpoint with sessionId + const directCheckpoint = path.join( + projectDir, + `checkpoint-${sessionId}.json`, + ); + if (fs.existsSync(directCheckpoint)) { + console.log( + '[QwenSessionManager] Found direct checkpoint:', + directCheckpoint, + ); + return sessionId; + } + + // Second, look for session file with this sessionId to get conversationId + const sessionDir = path.join(projectDir, 'chats'); + if (fs.existsSync(sessionDir)) { + const files = fs.readdirSync(sessionDir); + for (const file of files) { + if (file.startsWith('session-') && file.endsWith('.json')) { + try { + const filePath = path.join(sessionDir, file); + const content = fs.readFileSync(filePath, 'utf-8'); + const session = JSON.parse(content) as QwenSession; + + if (session.sessionId === sessionId) { + console.log( + '[QwenSessionManager] Found matching session file:', + file, + ); + // Now check if there's a checkpoint with this conversationId + // We need to store conversationId in session files or use another strategy + // For now, return null and let it fallback + break; + } + } catch { + // Skip invalid files + } + } + } + } + + console.log( + '[QwenSessionManager] No checkpoint found for sessionId:', + sessionId, + ); + return null; + } catch (error) { + console.error('[QwenSessionManager] Error finding checkpoint:', error); + return null; + } + } + + /** + * Load a checkpoint by tag + * + * @param tag - Checkpoint tag + * @param workingDir - Current working directory + * @returns Loaded checkpoint messages or null if not found + */ + async loadCheckpoint( + tag: string, + workingDir: string, + ): Promise { + try { + const projectHash = this.getProjectHash(workingDir); + const projectDir = path.join(this.qwenDir, 'tmp', projectHash); + const filename = `checkpoint-${tag}.json`; + const filePath = path.join(projectDir, filename); + + if (!fs.existsSync(filePath)) { + console.log( + `[QwenSessionManager] Checkpoint file not found: ${filePath}`, + ); + return null; + } + + const content = fs.readFileSync(filePath, 'utf-8'); + const checkpointMessages = JSON.parse(content) as Array<{ + role: 'user' | 'model'; + parts: Array<{ text: string }>; + }>; + + // Convert back to QwenMessage format + const messages: QwenMessage[] = checkpointMessages.map((msg) => ({ + id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + type: msg.role === 'user' ? ('user' as const) : ('qwen' as const), + content: msg.parts[0]?.text || '', + })); + + console.log(`[QwenSessionManager] Checkpoint loaded: ${filePath}`); + return messages; + } catch (error) { + console.error('[QwenSessionManager] Failed to load checkpoint:', error); + return null; + } + } + /** * Save current conversation as a named session (checkpoint-like functionality) * @@ -66,16 +304,25 @@ export class QwenSessionManager { fs.mkdirSync(sessionDir, { recursive: true }); } - // Generate session ID and filename + // Generate session ID and filename using CLI's naming convention const sessionId = this.generateSessionId(); - const filename = `session-${sessionId}.json`; + const shortId = sessionId.split('-')[0]; // First part of UUID (8 chars) + const now = new Date(); + const isoDate = now.toISOString().split('T')[0]; // YYYY-MM-DD + const isoTime = now + .toISOString() + .split('T')[1] + .split(':') + .slice(0, 2) + .join('-'); // HH-MM + const filename = `session-${isoDate}T${isoTime}-${shortId}.json`; const filePath = path.join(sessionDir, filename); // Create session object const session: QwenSession = { sessionId, projectHash: this.getProjectHash(workingDir), - startTime: new Date().toISOString(), + startTime: messages[0]?.timestamp || new Date().toISOString(), lastUpdated: new Date().toISOString(), messages, }; diff --git a/packages/vscode-ide-companion/src/webview/App.scss b/packages/vscode-ide-companion/src/webview/App.scss index f4b550a4..7086dcd9 100644 --- a/packages/vscode-ide-companion/src/webview/App.scss +++ b/packages/vscode-ide-companion/src/webview/App.scss @@ -233,87 +233,6 @@ button { cursor: not-allowed; } -/* =========================== - Tool Call Card Styles (Grid Layout) - =========================== */ -.tool-call-card { - display: grid; - grid-template-columns: auto 1fr; - gap: var(--app-spacing-medium); - background: var(--app-input-background); - border: 1px solid var(--app-input-border); - border-radius: var(--corner-radius-medium); - padding: var(--app-spacing-large); - margin: var(--app-spacing-medium) 0; - animation: fadeIn 0.2s ease-in; - align-items: start; -} - -.tool-call-icon { - font-size: 20px; - grid-row: 1; - padding-top: 2px; -} - -.tool-call-grid { - display: flex; - flex-direction: column; - gap: var(--app-spacing-medium); - min-width: 0; -} - -.tool-call-row { - display: grid; - grid-template-columns: 80px 1fr; - gap: var(--app-spacing-medium); - min-width: 0; -} - -.tool-call-label { - font-size: 12px; - color: var(--app-secondary-foreground); - font-weight: 500; - padding-top: 2px; -} - -.tool-call-value { - color: var(--app-primary-foreground); - min-width: 0; - word-break: break-word; -} - -.tool-call-status-indicator { - display: inline-block; - font-weight: 500; - position: relative; -} - -.tool-call-status-indicator::before { - content: ''; - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - margin-right: 6px; - vertical-align: middle; -} - -.tool-call-status-indicator.pending::before { - background: #ffc107; -} - -.tool-call-status-indicator.in_progress::before { - background: #2196f3; -} - -.tool-call-status-indicator.completed::before { - background: #4caf50; -} - -.tool-call-status-indicator.failed::before { - background: #f44336; -} - /* =========================== In-Progress Tool Call Styles (Claude Code style) =========================== */ diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index e4bdff9d..287fc962 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -580,10 +580,29 @@ export const App: React.FC = () => { // Handle removing context attachment const handleToolCallUpdate = React.useCallback( (update: ToolCallUpdate) => { + console.log('[App] handleToolCallUpdate:', { + type: update.type, + toolCallId: update.toolCallId, + kind: update.kind, + title: update.title, + status: update.status, + }); + setToolCalls((prevToolCalls) => { const newMap = new Map(prevToolCalls); const existing = newMap.get(update.toolCallId); + console.log( + '[App] existing tool call:', + existing + ? { + kind: existing.kind, + title: existing.title, + status: existing.status, + } + : 'not found', + ); + // Helper function to safely convert title to string const safeTitle = (title: unknown): string => { if (typeof title === 'string') { @@ -627,13 +646,17 @@ export const App: React.FC = () => { : undefined; if (existing) { - // Update existing tool call + // Update existing tool call - merge content arrays + const mergedContent = updatedContent + ? [...(existing.content || []), ...updatedContent] + : existing.content; + newMap.set(update.toolCallId, { ...existing, ...(update.kind && { kind: update.kind }), ...(update.title && { title: safeTitle(update.title) }), ...(update.status && { status: update.status }), - ...(updatedContent && { content: updatedContent }), + content: mergedContent, ...(update.locations && { locations: update.locations }), }); } else { @@ -641,7 +664,7 @@ export const App: React.FC = () => { newMap.set(update.toolCallId, { toolCallId: update.toolCallId, kind: update.kind || 'other', - title: safeTitle(update.title), + title: update.title ? safeTitle(update.title) : '', status: update.status || 'pending', rawInput: update.rawInput as string | object | undefined, content: updatedContent, @@ -840,11 +863,16 @@ export const App: React.FC = () => { break; case 'toolCall': - case 'toolCallUpdate': + case 'toolCallUpdate': { // Handle tool call updates - handleToolCallUpdate(message.data); + // Convert sessionUpdate to type if needed + const toolCallData = message.data; + if (toolCallData.sessionUpdate && !toolCallData.type) { + toolCallData.type = toolCallData.sessionUpdate; + } + handleToolCallUpdate(toolCallData); break; - + } case 'qwenSessionList': { const sessions = message.data.sessions || []; setQwenSessions(sessions); diff --git a/packages/vscode-ide-companion/src/webview/ClaudeCodeStyles.css b/packages/vscode-ide-companion/src/webview/ClaudeCodeStyles.css index 4524195f..85e1e6cf 100644 --- a/packages/vscode-ide-companion/src/webview/ClaudeCodeStyles.css +++ b/packages/vscode-ide-companion/src/webview/ClaudeCodeStyles.css @@ -282,84 +282,6 @@ --app-warning-foreground: var(--vscode-editorWarning-foreground, #ffcc00); } -/* =========================== - Tool Call Card (from Claude Code .Ne) - =========================== */ -.tool-call-card { - border: 0.5px solid var(--app-input-border); - border-radius: 5px; - background: var(--app-tool-background); - margin: 8px 0; - max-width: 100%; - font-size: 1em; - align-items: start; -} - -/* Tool Call Grid Layout (from Claude Code .Ke) */ -.tool-call-grid { - display: grid; - grid-template-columns: max-content 1fr; -} - -/* Tool Call Row (from Claude Code .no) */ -.tool-call-row { - grid-column: 1 / -1; - display: grid; - grid-template-columns: subgrid; - border-top: 0.5px solid var(--app-input-border); - padding: 4px; -} - -.tool-call-row:first-child { - border-top: none; -} - -/* Tool Call Label (from Claude Code .Je) */ -.tool-call-label { - grid-column: 1; - color: var(--app-secondary-foreground); - text-align: left; - opacity: 0.5; - padding: 4px 8px 4px 4px; - font-family: var(--app-monospace-font-family); - font-size: 0.85em; -} - -/* Tool Call Value (from Claude Code .m) */ -.tool-call-value { - grid-column: 2; - white-space: pre-wrap; - word-break: break-word; - margin: 0; - padding: 4px; -} - -.tool-call-value:not(.expanded) { - max-height: 60px; - mask-image: linear-gradient(to bottom, var(--app-primary-background) 40px, transparent 60px); - overflow: hidden; -} - -.tool-call-value pre { - margin-block: 0; - overflow: hidden; - font-family: var(--app-monospace-font-family); - font-size: 0.85em; -} - -.tool-call-value code { - margin: 0; - padding: 0; - font-family: var(--app-monospace-font-family); - font-size: 0.85em; -} - -/* Tool Call Icon (from Claude Code .to) */ -.tool-call-icon { - margin: 8px; - opacity: 0.5; -} - /* Code Block (from Claude Code ._e) */ .code-block { background-color: var(--app-code-background); @@ -432,37 +354,3 @@ color: var(--app-secondary-foreground); font-size: 0.85em; } - -/* Status indicators for tool calls */ -.tool-call-status-indicator { - display: inline-flex; - align-items: center; - gap: 4px; -} - -.tool-call-status-indicator::before { - content: "●"; - font-size: 10px; -} - -.tool-call-status-indicator.pending::before { - color: var(--app-secondary-foreground); -} - -.tool-call-status-indicator.in-progress::before { - color: #e1c08d; - animation: blink 1s linear infinite; -} - -.tool-call-status-indicator.completed::before { - color: #74c991; -} - -.tool-call-status-indicator.failed::before { - color: #c74e39; -} - -@keyframes blink { - 0%, 100% { opacity: 1; } - 50% { opacity: 0; } -} diff --git a/packages/vscode-ide-companion/src/webview/MessageHandler.ts b/packages/vscode-ide-companion/src/webview/MessageHandler.ts index 9639e30a..8270a589 100644 --- a/packages/vscode-ide-companion/src/webview/MessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/MessageHandler.ts @@ -31,6 +31,8 @@ export class MessageHandler { private loginHandler?: () => Promise; // 待发送消息(登录后自动重发) private pendingMessage: string | null = null; + // 标记是否正在执行后台保存命令 + private isSavingCheckpoint = false; constructor( private agentManager: QwenAgentManager, @@ -46,6 +48,13 @@ export class MessageHandler { this.loginHandler = handler; } + /** + * 检查是否正在后台保存 checkpoint + */ + getIsSavingCheckpoint(): boolean { + return this.isSavingCheckpoint; + } + /** * 获取当前对话 ID */ @@ -459,6 +468,77 @@ export class MessageHandler { data: { timestamp: Date.now() }, }); console.log('[MessageHandler] Stream end sent'); + + // Auto-save session after response completes + // Use CLI's /chat save command for complete checkpoint with tool calls + if (this.currentConversationId) { + console.log( + '[MessageHandler] ===== STARTING AUTO-SAVE CHECKPOINT =====', + ); + console.log( + '[MessageHandler] Session ID (will be used as checkpoint tag):', + this.currentConversationId, + ); + + try { + // Get conversation messages + const conversation = await this.conversationStore.getConversation( + this.currentConversationId, + ); + console.log( + '[MessageHandler] Conversation loaded, message count:', + conversation?.messages.length, + ); + + // Save via CLI /chat save command (will trigger a response we need to ignore) + const messages = conversation?.messages || []; + console.log( + '[MessageHandler] Calling saveCheckpoint with', + messages.length, + 'messages', + ); + + // Set flag to ignore the upcoming response from /chat save + this.isSavingCheckpoint = true; + console.log('[MessageHandler] Set isSavingCheckpoint = true'); + + const result = await this.agentManager.saveCheckpoint( + messages, + this.currentConversationId, + ); + + console.log('[MessageHandler] Checkpoint save result:', result); + + // Reset flag after a delay (in case the command response comes late) + setTimeout(() => { + this.isSavingCheckpoint = false; + console.log('[MessageHandler] Reset isSavingCheckpoint = false'); + }, 2000); + + if (result.success) { + console.log( + '[MessageHandler] ===== CHECKPOINT SAVE SUCCESSFUL =====', + ); + console.log('[MessageHandler] Checkpoint tag:', result.tag); + } else { + console.error( + '[MessageHandler] ===== CHECKPOINT SAVE FAILED =====', + ); + console.error('[MessageHandler] Error:', result.message); + } + } catch (error) { + console.error( + '[MessageHandler] ===== CHECKPOINT SAVE EXCEPTION =====', + ); + console.error('[MessageHandler] Exception details:', error); + this.isSavingCheckpoint = false; + // Don't show error to user - this is a background operation + } + } else { + console.warn( + '[MessageHandler] Skipping checkpoint save: no current conversation ID', + ); + } } catch (error) { console.error('[MessageHandler] Error sending message:', error); @@ -563,10 +643,42 @@ export class MessageHandler { /** * 处理新建 Qwen 会话请求 + * 在创建新 session 前,先保存当前 session */ private async handleNewQwenSession(): Promise { try { console.log('[MessageHandler] Creating new Qwen session...'); + + // Save current session as checkpoint before switching to a new one + if (this.currentConversationId && this.agentManager.isConnected) { + try { + console.log( + '[MessageHandler] Auto-saving current session as checkpoint before creating new:', + this.currentConversationId, + ); + + const conversation = await this.conversationStore.getConversation( + this.currentConversationId, + ); + const messages = conversation?.messages || []; + + // Save as checkpoint using sessionId as tag + await this.agentManager.saveCheckpoint( + messages, + this.currentConversationId, + ); + console.log( + '[MessageHandler] Current session checkpoint saved successfully before creating new session', + ); + } catch (error) { + console.warn( + '[MessageHandler] Failed to auto-save current session checkpoint:', + error, + ); + // Don't block new session creation if save fails + } + } + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); @@ -588,17 +700,45 @@ export class MessageHandler { /** * 处理切换 Qwen 会话请求 + * 优先使用 CLI 的 checkpoint (session/load) 能力从保存的完整会话恢复 */ private async handleSwitchQwenSession(sessionId: string): Promise { try { console.log('[MessageHandler] Switching to Qwen session:', sessionId); - // Get session messages from local files - const messages = await this.agentManager.getSessionMessages(sessionId); - console.log( - '[MessageHandler] Loaded messages from session:', - messages.length, - ); + // Save current session as checkpoint before switching + if ( + this.currentConversationId && + this.currentConversationId !== sessionId && + this.agentManager.isConnected + ) { + try { + console.log( + '[MessageHandler] Auto-saving current session as checkpoint before switching:', + this.currentConversationId, + ); + + const conversation = await this.conversationStore.getConversation( + this.currentConversationId, + ); + const messages = conversation?.messages || []; + + // Save as checkpoint using sessionId as tag + await this.agentManager.saveCheckpoint( + messages, + this.currentConversationId, + ); + console.log( + '[MessageHandler] Current session checkpoint saved successfully before switching', + ); + } catch (error) { + console.warn( + '[MessageHandler] Failed to auto-save current session checkpoint:', + error, + ); + // Don't block session switching if save fails + } + } // Get session details for the header let sessionDetails = null; @@ -612,12 +752,16 @@ export class MessageHandler { console.log('[MessageHandler] Could not get session details:', err); } - // Try to load session via ACP first, fallback to creating new session const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; const workingDir = workspaceFolder?.uri.fsPath || process.cwd(); + // Try to load session via ACP checkpoint (session/load) first + // This will restore the full session with all tool calls and context try { - console.log('[MessageHandler] Testing session/load via ACP...'); + console.log( + '[MessageHandler] Loading session via CLI checkpoint (session/load):', + sessionId, + ); const loadResponse = await this.agentManager.loadSessionViaAcp(sessionId); console.log('[MessageHandler] session/load succeeded:', loadResponse); @@ -628,13 +772,39 @@ export class MessageHandler { '[MessageHandler] Set currentConversationId to loaded session:', sessionId, ); - } catch (_loadError) { + + // Get session messages for display from loaded session + // This will now have complete tool call information + const messages = await this.agentManager.getSessionMessages(sessionId); console.log( - '[MessageHandler] session/load not supported, creating new session', + '[MessageHandler] Loaded complete messages from checkpoint:', + messages.length, + ); + + // Send messages and session details to WebView + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages, session: sessionDetails }, + }); + } catch (loadError) { + const errorMessage = + loadError instanceof Error ? loadError.message : String(loadError); + console.warn( + '[MessageHandler] session/load failed, falling back to file-based restore.', + ); + console.warn('[MessageHandler] Load error details:', errorMessage); + console.warn( + '[MessageHandler] This may happen if the session was not saved via /chat save.', + ); + + // Fallback: Load messages from local files (may be incomplete) + // and create a new ACP session for continuation + const messages = await this.agentManager.getSessionMessages(sessionId); + console.log( + '[MessageHandler] Loaded messages from local files:', + messages.length, ); - // Fallback: CLI doesn't support loading old sessions - // So we create a NEW ACP session for continuation try { const newAcpSessionId = await this.agentManager.createNewSession(workingDir); @@ -649,24 +819,28 @@ export class MessageHandler { '[MessageHandler] Set currentConversationId (ACP) to:', newAcpSessionId, ); + + // Send messages and session details to WebView + // Note: These messages may be incomplete (no tool calls) + this.sendToWebView({ + type: 'qwenSessionSwitched', + data: { sessionId, messages, session: sessionDetails }, + }); + + vscode.window.showWarningMessage( + 'Session restored from local cache. Some context may be incomplete. Save sessions regularly for full restoration.', + ); } catch (createError) { console.error( '[MessageHandler] Failed to create new ACP session:', createError, ); - vscode.window.showWarningMessage( - 'Could not switch to session. Created new session instead.', + vscode.window.showErrorMessage( + 'Could not switch to session. Please try again.', ); throw createError; } } - - // Send messages and session details to WebView - // The historical messages are display-only, not sent to CLI - this.sendToWebView({ - type: 'qwenSessionSwitched', - data: { sessionId, messages, session: sessionDetails }, - }); } catch (error) { console.error('[MessageHandler] Failed to switch session:', error); this.sendToWebView({ diff --git a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer.tsx b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer.tsx index 0777ec9e..e1bb77b4 100644 --- a/packages/vscode-ide-companion/src/webview/components/PermissionDrawer.tsx +++ b/packages/vscode-ide-companion/src/webview/components/PermissionDrawer.tsx @@ -157,7 +157,6 @@ export const PermissionDrawer: React.FC = ({ {/* Options */}
{options.map((option, index) => { - const isAlways = option.kind.includes('always'); const isFocused = focusedIndex === index; return ( @@ -165,13 +164,15 @@ export const PermissionDrawer: React.FC = ({ key={option.optionId} className={`flex items-center gap-2 px-3 py-2 text-left rounded-small border transition-colors duration-150 ${ isFocused - ? 'bg-[var(--app-list-active-background)] text-[var(--app-list-active-foreground)] border-transparent' - : 'hover:bg-[var(--app-list-hover-background)] border-transparent' + ? 'bg-[var(--app-list-active-background)] text-[var(--app-list-active-foreground)]' + : 'hover:bg-[var(--app-list-hover-background)]' }`} style={{ color: isFocused ? 'var(--app-list-active-foreground)' : 'var(--app-primary-foreground)', + borderColor: + 'color-mix(in srgb, var(--app-secondary-foreground) 70%, transparent)', }} onClick={() => onResponse(option.optionId)} onMouseEnter={() => setFocusedIndex(index)} @@ -181,7 +182,7 @@ export const PermissionDrawer: React.FC = ({ className={`inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-semibold rounded ${ isFocused ? 'bg-white/20 text-inherit' - : 'bg-[var(--app-list-hover-background)] text-[var(--app-secondary-foreground)]' + : 'bg-[var(--app-list-hover-background)]' }`} > {index + 1} @@ -191,49 +192,44 @@ export const PermissionDrawer: React.FC = ({ {option.name} {/* Always badge */} - {isAlways && } + {/* {isAlways && } */} ); })} {/* Custom message input */} -
} + type="text" + placeholder="Tell Qwen what to do instead" + spellCheck={false} + className={`w-full px-3 py-2 text-sm rounded-small border transition-colors duration-150 ${ focusedIndex === options.length - ? 'bg-[var(--app-list-hover-background)] border-transparent' - : 'border-transparent' + ? 'bg-[var(--app-list-hover-background)]' + : 'bg-transparent' }`} + style={{ + color: 'var(--app-input-foreground)', + outline: 'none', + borderColor: + 'color-mix(in srgb, var(--app-secondary-foreground) 70%, transparent)', + }} + value={customMessage} + onChange={(e) => setCustomMessage(e.target.value)} + onFocus={() => setFocusedIndex(options.length)} onMouseEnter={() => setFocusedIndex(options.length)} - > -
- Tell Qwen what to do instead -
-
{ - const target = e.target as HTMLDivElement; - setCustomMessage(target.textContent || ''); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) { - e.preventDefault(); - const rejectOption = options.find((o) => - o.kind.includes('reject'), - ); - if (rejectOption) { - onResponse(rejectOption.optionId); - } + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) { + e.preventDefault(); + const rejectOption = options.find((o) => + o.kind.includes('reject'), + ); + if (rejectOption) { + onResponse(rejectOption.optionId); } - }} - /> -
+ } + }} + />
diff --git a/packages/vscode-ide-companion/src/webview/components/shared/FileLink.css b/packages/vscode-ide-companion/src/webview/components/shared/FileLink.css index 55cae240..8f918214 100644 --- a/packages/vscode-ide-companion/src/webview/components/shared/FileLink.css +++ b/packages/vscode-ide-companion/src/webview/components/shared/FileLink.css @@ -111,14 +111,6 @@ color: inherit; } -/** - * 在工具调用卡片中的样式调整 - */ -.tool-call-card .file-link { - /* 在工具调用中略微缩小字体 */ - font-size: 0.95em; -} - /** * 在代码块中的样式调整 */ diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/EditToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/EditToolCall.tsx new file mode 100644 index 00000000..c613f1ff --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/EditToolCall.tsx @@ -0,0 +1,166 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Edit tool call component - specialized for file editing operations + */ + +import type React from 'react'; +import { useState } from 'react'; +import type { BaseToolCallProps } from './shared/types.js'; +import { ToolCallContainer } from './shared/LayoutComponents.js'; +import { DiffDisplay } from './shared/DiffDisplay.js'; +import { groupContent } from './shared/utils.js'; +import { useVSCode } from '../../hooks/useVSCode.js'; +import { FileLink } from '../shared/FileLink.js'; + +/** + * Calculate diff summary (added/removed lines) + */ +const getDiffSummary = ( + oldText: string | null | undefined, + newText: string | undefined, +): string => { + const oldLines = oldText ? oldText.split('\n').length : 0; + const newLines = newText ? newText.split('\n').length : 0; + const diff = newLines - oldLines; + + if (diff > 0) { + return `+${diff} lines`; + } else if (diff < 0) { + return `${diff} lines`; + } else { + return 'Modified'; + } +}; + +/** + * Specialized component for Edit tool calls + * Optimized for displaying file editing operations with diffs + */ +export const EditToolCall: React.FC = ({ toolCall }) => { + const { content, locations, toolCallId } = toolCall; + const vscode = useVSCode(); + const [expanded, setExpanded] = useState(false); + + // Group content by type + const { errors, diffs } = groupContent(content); + + const handleOpenDiff = ( + path: string | undefined, + oldText: string | null | undefined, + newText: string | undefined, + ) => { + if (path) { + vscode.postMessage({ + type: 'openDiff', + data: { path, oldText: oldText || '', newText: newText || '' }, + }); + } + }; + + // Extract filename from path + const getFileName = (path: string): string => path.split('/').pop() || path; + + // Error case: show error + if (errors.length > 0) { + const path = diffs[0]?.path || locations?.[0]?.path || ''; + const fileName = path ? getFileName(path) : ''; + return ( + + {errors.join('\n')} + + ); + } + + // Success case with diff: show collapsible format + if (diffs.length > 0) { + const firstDiff = diffs[0]; + const path = firstDiff.path || (locations && locations[0]?.path) || ''; + const fileName = path ? getFileName(path) : ''; + const summary = getDiffSummary(firstDiff.oldText, firstDiff.newText); + + return ( +
+
setExpanded(!expanded)} + > + + ● + +
+
+
+ + Edit {fileName} + + {toolCallId && ( + + [{toolCallId.slice(-8)}] + + )} +
+ + {expanded ? '▼' : '▶'} + +
+
+ + {summary} +
+
+
+ {expanded && ( +
+ {diffs.map( + ( + item: import('./shared/types.js').ToolCallContent, + idx: number, + ) => ( + + handleOpenDiff(item.path, item.oldText, item.newText) + } + /> + ), + )} +
+ )} +
+ ); + } + + // Success case without diff: show file in compact format + if (locations && locations.length > 0) { + const fileName = getFileName(locations[0].path); + return ( + +
+ + +
+
+ ); + } + + // No output, don't show anything + return null; +}; diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/ExecuteToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/ExecuteToolCall.tsx index 283b6dd6..3acb7994 100644 --- a/packages/vscode-ide-companion/src/webview/components/toolcalls/ExecuteToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/ExecuteToolCall.tsx @@ -8,82 +8,100 @@ import type React from 'react'; import type { BaseToolCallProps } from './shared/types.js'; -import { ToolCallCard, ToolCallRow } from './shared/LayoutComponents.js'; +import { ToolCallContainer } from './shared/LayoutComponents.js'; import { safeTitle, groupContent } from './shared/utils.js'; /** - * Specialized component for Execute tool calls - * Optimized for displaying command execution with stdout/stderr - * Shows command + output (if any) or error + * Specialized component for Execute/Bash tool calls + * Shows: Bash bullet + description + IN/OUT card */ export const ExecuteToolCall: React.FC = ({ toolCall }) => { - const { title, content } = toolCall; + const { title, content, rawInput, toolCallId } = toolCall; const commandText = safeTitle(title); // Group content by type const { textOutputs, errors } = groupContent(content); - // Error case: show command + error + // Extract command from rawInput if available + let inputCommand = commandText; + if (rawInput && typeof rawInput === 'object') { + const inputObj = rawInput as { command?: string }; + inputCommand = inputObj.command || commandText; + } else if (typeof rawInput === 'string') { + inputCommand = rawInput; + } + + // Error case if (errors.length > 0) { return ( - - -
- {commandText} + +
+ + {commandText} +
+
+
+
+ IN +
+
+ {inputCommand} +
- - -
- {errors.join('\n')} +
+
+ Error +
+
+ {errors.join('\n')} +
- - +
+ ); } - // Success with output: show command + output (limited) + // Success with output if (textOutputs.length > 0) { const output = textOutputs.join('\n'); const truncatedOutput = output.length > 500 ? output.substring(0, 500) + '...' : output; return ( - - -
- {commandText} + +
+ + {commandText} +
+
+
+
+ IN +
+
+ {inputCommand} +
- - -
- {truncatedOutput} +
+
+ OUT +
+
+ {truncatedOutput} +
- - +
+ ); } - // Success without output: show command only + // Success without output: show command with branch connector return ( - - -
- {commandText} -
-
-
+ +
+ + {commandText} +
+
); }; diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/GenericToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/GenericToolCall.tsx index 960e4ac5..17c29a8c 100644 --- a/packages/vscode-ide-companion/src/webview/components/toolcalls/GenericToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/GenericToolCall.tsx @@ -9,6 +9,7 @@ import type React from 'react'; import type { BaseToolCallProps } from './shared/types.js'; import { + ToolCallContainer, ToolCallCard, ToolCallRow, LocationsList, @@ -23,7 +24,7 @@ import { useVSCode } from '../../hooks/useVSCode.js'; * Minimal display: show description and outcome */ export const GenericToolCall: React.FC = ({ toolCall }) => { - const { kind, title, content, locations } = toolCall; + const { kind, title, content, locations, toolCallId } = toolCall; const operationText = safeTitle(title); const vscode = useVSCode(); @@ -43,7 +44,7 @@ export const GenericToolCall: React.FC = ({ toolCall }) => { } }; - // Error case: show operation + error + // Error case: show operation + error in card layout if (errors.length > 0) { return ( @@ -51,15 +52,13 @@ export const GenericToolCall: React.FC = ({ toolCall }) => {
{operationText}
-
- {errors.join('\n')} -
+
{errors.join('\n')}
); } - // Success with diff: show diff + // Success with diff: show diff in card layout if (diffs.length > 0) { return ( @@ -81,44 +80,54 @@ export const GenericToolCall: React.FC = ({ toolCall }) => { ); } - // Success with output: show operation + output (truncated) + // Success with output: use card for long output, compact for short if (textOutputs.length > 0) { const output = textOutputs.join('\n'); - const truncatedOutput = - output.length > 300 ? output.substring(0, 300) + '...' : output; + const isLong = output.length > 150; + if (isLong) { + const truncatedOutput = + output.length > 300 ? output.substring(0, 300) + '...' : output; + + return ( + + +
{operationText}
+
+ +
+ {truncatedOutput} +
+
+
+ ); + } + + // Short output - compact format return ( - - -
{operationText}
-
- -
- {truncatedOutput} -
-
-
+ + {operationText || output} + ); } - // Success with files: show operation + file list + // Success with files: show operation + file list in compact format if (locations && locations.length > 0) { return ( - - - - - + + + + ); + } + + // No output - show just the operation + if (operationText) { + return ( + + {operationText} + ); } - // No output return null; }; diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/ReadToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/ReadToolCall.tsx index 2b64ed1d..b6fa7c4a 100644 --- a/packages/vscode-ide-companion/src/webview/components/toolcalls/ReadToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/ReadToolCall.tsx @@ -8,45 +8,47 @@ import type React from 'react'; import type { BaseToolCallProps } from './shared/types.js'; -import { - ToolCallCard, - ToolCallRow, - LocationsList, -} from './shared/LayoutComponents.js'; +import { ToolCallContainer } from './shared/LayoutComponents.js'; import { groupContent } from './shared/utils.js'; /** * Specialized component for Read tool calls * Optimized for displaying file reading operations - * Minimal display: just show file name, hide content (too verbose) + * Shows: Read filename (no content preview) */ export const ReadToolCall: React.FC = ({ toolCall }) => { - const { content, locations } = toolCall; + const { content, locations, toolCallId } = toolCall; // Group content by type const { errors } = groupContent(content); - // Error case: show error with operation label + // Extract filename from path + const getFileName = (path: string): string => path.split('/').pop() || path; + + // Error case: show error if (errors.length > 0) { + const path = locations?.[0]?.path || ''; + const fileName = path ? getFileName(path) : ''; return ( - - -
- {errors.join('\n')} -
-
-
+ + {errors.join('\n')} + ); } - // Success case: show which file was read + // Success case: show which file was read with filename in label if (locations && locations.length > 0) { + const fileName = getFileName(locations[0].path); return ( - - - - - + ); } diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/SearchToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/SearchToolCall.tsx index f3028083..55098bf0 100644 --- a/packages/vscode-ide-companion/src/webview/components/toolcalls/SearchToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/SearchToolCall.tsx @@ -9,6 +9,7 @@ import type React from 'react'; import type { BaseToolCallProps } from './shared/types.js'; import { + ToolCallContainer, ToolCallCard, ToolCallRow, LocationsList, @@ -27,19 +28,15 @@ export const SearchToolCall: React.FC = ({ toolCall }) => { // Group content by type const { errors } = groupContent(content); - // Error case: show search query + error + // Error case: show search query + error in card layout if (errors.length > 0) { return ( -
- {queryText} -
+
{queryText}
-
- {errors.join('\n')} -
+
{errors.join('\n')}
); @@ -47,20 +44,37 @@ export const SearchToolCall: React.FC = ({ toolCall }) => { // Success case with results: show search query + file list if (locations && locations.length > 0) { + // If multiple results, use card layout; otherwise use compact format + if (locations.length > 1) { + return ( + + +
{queryText}
+
+ + + +
+ ); + } + // Single result - compact format return ( - - -
- {queryText} -
-
- - - -
+ + {queryText} + + + + ); + } + + // No results - show query only + if (queryText) { + return ( + + {queryText} + ); } - // No results return null; }; diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/ThinkToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/ThinkToolCall.tsx index 58bf20f3..251367da 100644 --- a/packages/vscode-ide-companion/src/webview/components/toolcalls/ThinkToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/ThinkToolCall.tsx @@ -8,7 +8,11 @@ import type React from 'react'; import type { BaseToolCallProps } from './shared/types.js'; -import { ToolCallCard, ToolCallRow } from './shared/LayoutComponents.js'; +import { + ToolCallContainer, + ToolCallCard, + ToolCallRow, +} from './shared/LayoutComponents.js'; import { groupContent } from './shared/utils.js'; /** @@ -25,36 +29,37 @@ export const ThinkToolCall: React.FC = ({ toolCall }) => { // Error case (rare for thinking) if (errors.length > 0) { return ( - - -
- {errors.join('\n')} -
-
-
+ + {errors.join('\n')} + ); } - // Show thoughts with label + // Show thoughts - use card for long content, compact for short if (textOutputs.length > 0) { const thoughts = textOutputs.join('\n\n'); - const truncatedThoughts = - thoughts.length > 500 ? thoughts.substring(0, 500) + '...' : thoughts; + const isLong = thoughts.length > 200; + if (isLong) { + const truncatedThoughts = + thoughts.length > 500 ? thoughts.substring(0, 500) + '...' : thoughts; + + return ( + + +
+ {truncatedThoughts} +
+
+
+ ); + } + + // Short thoughts - compact format return ( - - -
- {truncatedThoughts} -
-
-
+ + {thoughts} + ); } diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/TodoWriteToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/TodoWriteToolCall.tsx new file mode 100644 index 00000000..9b73574b --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/TodoWriteToolCall.tsx @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * TodoWrite tool call component - specialized for todo list operations + */ + +import type React from 'react'; +import type { BaseToolCallProps } from './shared/types.js'; +import { ToolCallContainer } from './shared/LayoutComponents.js'; +import { groupContent } from './shared/utils.js'; + +/** + * Specialized component for TodoWrite tool calls + * Optimized for displaying todo list update operations + */ +export const TodoWriteToolCall: React.FC = ({ + toolCall, +}) => { + const { content } = toolCall; + + // Group content by type + const { errors, textOutputs } = groupContent(content); + + // Error case: show error + if (errors.length > 0) { + return ( + + {errors.join('\n')} + + ); + } + + // Success case: show simple confirmation + const outputText = + textOutputs.length > 0 ? textOutputs.join(' ') : 'Todos updated'; + + // Truncate if too long + const displayText = + outputText.length > 100 ? outputText.substring(0, 100) + '...' : outputText; + + return ( + + {displayText} + + ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/WriteToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/WriteToolCall.tsx index 1e50b063..55f1a288 100644 --- a/packages/vscode-ide-companion/src/webview/components/toolcalls/WriteToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/WriteToolCall.tsx @@ -3,89 +3,93 @@ * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 * - * Write/Edit tool call component - specialized for file writing and editing operations + * Write tool call component - specialized for file writing operations */ import type React from 'react'; import type { BaseToolCallProps } from './shared/types.js'; -import { - ToolCallCard, - ToolCallRow, - LocationsList, -} from './shared/LayoutComponents.js'; -import { DiffDisplay } from './shared/DiffDisplay.js'; +import { ToolCallContainer } from './shared/LayoutComponents.js'; import { groupContent } from './shared/utils.js'; -import { useVSCode } from '../../hooks/useVSCode.js'; /** - * Specialized component for Write/Edit tool calls - * Optimized for displaying file writing and editing operations with diffs - * Follows minimal display principle: only show what matters + * Specialized component for Write tool calls + * Shows: Write filename + error message + content preview */ export const WriteToolCall: React.FC = ({ toolCall }) => { - const { kind, status: _status, content, locations } = toolCall; - const isEdit = kind.toLowerCase() === 'edit'; - const vscode = useVSCode(); + const { content, locations, rawInput, toolCallId } = toolCall; // Group content by type - const { errors, diffs } = groupContent(content); + const { errors, textOutputs } = groupContent(content); - const handleOpenDiff = ( - path: string | undefined, - oldText: string | null | undefined, - newText: string | undefined, - ) => { - if (path) { - vscode.postMessage({ - type: 'openDiff', - data: { path, oldText: oldText || '', newText: newText || '' }, - }); - } - }; + // Extract filename from path + const getFileName = (path: string): string => path.split('/').pop() || path; - // Error case: show error with operation label + // Extract content to write from rawInput + let writeContent = ''; + if (rawInput && typeof rawInput === 'object') { + const inputObj = rawInput as { content?: string }; + writeContent = inputObj.content || ''; + } else if (typeof rawInput === 'string') { + writeContent = rawInput; + } + + // Error case: show filename + error message + content preview if (errors.length > 0) { + const path = locations?.[0]?.path || ''; + const fileName = path ? getFileName(path) : ''; + const errorMessage = errors.join('\n'); + + // Truncate content preview + const truncatedContent = + writeContent.length > 200 + ? writeContent.substring(0, 200) + '...' + : writeContent; + return ( - - -
- {errors.join('\n')} + +
+ + {errorMessage} +
+ {truncatedContent && ( +
+
+              {truncatedContent}
+            
- - - ); - } - - // Success case with diff: show diff (already has file path) - if (diffs.length > 0) { - return ( - - {diffs.map( - (item: import('./shared/types.js').ToolCallContent, idx: number) => ( -
- - handleOpenDiff(item.path, item.oldText, item.newText) - } - /> -
- ), )} -
+
); } - // Success case without diff: show operation + file + // Success case: show filename + line count if (locations && locations.length > 0) { + const fileName = getFileName(locations[0].path); + const lineCount = writeContent.split('\n').length; return ( - - - - - + +
+ + {lineCount} lines +
+
+ ); + } + + // Fallback: show generic success + if (textOutputs.length > 0) { + return ( + + {textOutputs.join('\n')} + ); } diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/index.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/index.tsx index 2859abd3..1c7783a9 100644 --- a/packages/vscode-ide-companion/src/webview/components/toolcalls/index.tsx +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/index.tsx @@ -12,9 +12,11 @@ import { shouldShowToolCall } from './shared/utils.js'; import { GenericToolCall } from './GenericToolCall.js'; import { ReadToolCall } from './ReadToolCall.js'; import { WriteToolCall } from './WriteToolCall.js'; +import { EditToolCall } from './EditToolCall.js'; import { ExecuteToolCall } from './ExecuteToolCall.js'; import { SearchToolCall } from './SearchToolCall.js'; import { ThinkToolCall } from './ThinkToolCall.js'; +import { TodoWriteToolCall } from './TodoWriteToolCall.js'; /** * Factory function that returns the appropriate tool call component based on kind @@ -30,9 +32,11 @@ export const getToolCallComponent = ( return ReadToolCall; case 'write': - case 'edit': return WriteToolCall; + case 'edit': + return EditToolCall; + case 'execute': case 'bash': case 'command': @@ -48,6 +52,11 @@ export const getToolCallComponent = ( case 'thinking': return ThinkToolCall; + case 'todowrite': + case 'todo_write': + case 'update_todos': + return TodoWriteToolCall; + // Add more specialized components as needed // case 'fetch': // return FetchToolCall; diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/LayoutComponents.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/LayoutComponents.tsx index 5ba9170f..107e54f6 100644 --- a/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/LayoutComponents.tsx +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/LayoutComponents.tsx @@ -4,13 +4,81 @@ * SPDX-License-Identifier: Apache-2.0 * * Shared layout components for tool call UI + * Uses Claude Code style: bullet point + label + content */ import type React from 'react'; import { FileLink } from '../../shared/FileLink.js'; /** - * Props for ToolCallCard wrapper + * Props for ToolCallContainer - Claude Code style layout + */ +interface ToolCallContainerProps { + /** Operation label (e.g., "Read", "Write", "Search") */ + label: string; + /** Status for bullet color: 'success' | 'error' | 'warning' | 'loading' | 'default' */ + status?: 'success' | 'error' | 'warning' | 'loading' | 'default'; + /** Main content to display */ + children: React.ReactNode; + /** Tool call ID for debugging */ + toolCallId?: string; +} + +/** + * Get bullet point color classes based on status + */ +const getBulletColorClass = ( + status: 'success' | 'error' | 'warning' | 'loading' | 'default', +): string => { + switch (status) { + case 'success': + return 'text-[#74c991]'; + case 'error': + return 'text-[#c74e39]'; + case 'warning': + return 'text-[#e1c08d]'; + case 'loading': + return 'text-[var(--app-secondary-foreground)] animate-pulse'; + default: + return 'text-[var(--app-secondary-foreground)]'; + } +}; + +/** + * Main container with Claude Code style bullet point + */ +export const ToolCallContainer: React.FC = ({ + label, + status = 'success', + children, + toolCallId, +}) => ( +
+ + ● + +
+
+ + {label} + + {toolCallId && ( + + [{toolCallId.slice(-8)}] + + )} +
+ {children && ( +
{children}
+ )} +
+
+); + +/** + * Props for ToolCallCard wrapper (legacy - for complex layouts) */ interface ToolCallCardProps { icon: string; @@ -18,15 +86,14 @@ interface ToolCallCardProps { } /** - * Main card wrapper with icon + * Legacy card wrapper - kept for backward compatibility with complex layouts like diffs */ export const ToolCallCard: React.FC = ({ icon: _icon, children, }) => ( -
- {/*
{icon}
*/} -
{children}
+
+
{children}
); @@ -39,15 +106,19 @@ interface ToolCallRowProps { } /** - * A single row in the tool call grid + * A single row in the tool call grid (legacy - for complex layouts) */ export const ToolCallRow: React.FC = ({ label, children, }) => ( -
-
{label}
-
{children}
+
+
+ {label} +
+
+ {children} +
); @@ -59,6 +130,26 @@ interface StatusIndicatorProps { text: string; } +/** + * Get status color class + */ +const getStatusColorClass = ( + status: 'pending' | 'in_progress' | 'completed' | 'failed', +): string => { + switch (status) { + case 'pending': + return 'bg-[#ffc107]'; + case 'in_progress': + return 'bg-[#2196f3]'; + case 'completed': + return 'bg-[#4caf50]'; + case 'failed': + return 'bg-[#f44336]'; + default: + return 'bg-gray-500'; + } +}; + /** * Status indicator with colored dot */ @@ -66,7 +157,10 @@ export const StatusIndicator: React.FC = ({ status, text, }) => ( -
+
+ {text}
); @@ -82,7 +176,9 @@ interface CodeBlockProps { * Code block for displaying formatted code or output */ export const CodeBlock: React.FC = ({ children }) => ( -
{children}
+
+    {children}
+  
); /** @@ -99,7 +195,7 @@ interface LocationsListProps { * List of file locations with clickable links */ export const LocationsList: React.FC = ({ locations }) => ( -
+
{locations.map((loc, idx) => ( ))} diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/utils.ts b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/utils.ts index 0de41a72..8f20355b 100644 --- a/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/utils.ts +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/utils.ts @@ -39,15 +39,16 @@ export const formatValue = (value: unknown): string => { /** * Safely convert title to string, handling object types + * Returns empty string if no meaningful title */ export const safeTitle = (title: unknown): string => { - if (typeof title === 'string') { + if (typeof title === 'string' && title.trim()) { return title; } if (title && typeof title === 'object') { return JSON.stringify(title); } - return 'Tool Call'; + return ''; }; /** @@ -88,6 +89,19 @@ export const hasToolCallOutput = ( return true; } + // Always show execute/bash/command tool calls (they show the command in title) + const kind = toolCall.kind.toLowerCase(); + if (kind === 'execute' || kind === 'bash' || kind === 'command') { + // But only if they have a title + if ( + toolCall.title && + typeof toolCall.title === 'string' && + toolCall.title.trim() + ) { + return true; + } + } + // Show if there are locations (file paths) if (toolCall.locations && toolCall.locations.length > 0) { return true; @@ -107,6 +121,15 @@ export const hasToolCallOutput = ( } } + // Show if there's a meaningful title for generic tool calls + if ( + toolCall.title && + typeof toolCall.title === 'string' && + toolCall.title.trim() + ) { + return true; + } + // No output, don't show return false; }; diff --git a/packages/vscode-ide-companion/tailwind.config.js b/packages/vscode-ide-companion/tailwind.config.js index d0cec1f1..278cac65 100644 --- a/packages/vscode-ide-companion/tailwind.config.js +++ b/packages/vscode-ide-companion/tailwind.config.js @@ -6,6 +6,7 @@ module.exports = { './src/webview/App.tsx', './src/webview/components/ui/**/*.{js,jsx,ts,tsx}', './src/webview/components/messages/**/*.{js,jsx,ts,tsx}', + './src/webview/components/toolcalls/**/*.{js,jsx,ts,tsx}', './src/webview/components/MessageContent.tsx', './src/webview/components/InfoBanner.tsx', './src/webview/components/InputForm.tsx',