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