feat(vscode): 重构 Qwen 交互模型并优化权限请求 UI

- 重构 QwenAgentManager 类,支持处理多种类型的消息更新
- 改进权限请求界面,增加详细信息展示和选项选择功能
- 新增工具调用卡片组件,用于展示工具调用相关信息
- 优化消息流处理逻辑,支持不同类型的内容块
- 调整会话切换和新会话创建的处理方式
This commit is contained in:
yiliang114
2025-11-18 01:00:25 +08:00
parent eeeb1d490a
commit 28892996b3
8 changed files with 1245 additions and 162 deletions

View File

@@ -21,6 +21,7 @@ export class WebViewProvider {
private currentConversationId: string | null = null;
private disposables: vscode.Disposable[] = [];
private agentInitialized = false; // Track if agent has been initialized
private currentStreamContent = ''; // Track streaming content for saving
constructor(
private context: vscode.ExtensionContext,
@@ -32,12 +33,23 @@ export class WebViewProvider {
// Setup agent callbacks
this.agentManager.onStreamChunk((chunk: string) => {
this.currentStreamContent += chunk;
this.sendMessageToWebView({
type: 'streamChunk',
data: { chunk },
});
});
this.agentManager.onToolCall((update) => {
this.sendMessageToWebView({
type: 'toolCall',
data: {
type: 'tool_call',
...(update as unknown as Record<string, unknown>),
},
});
});
this.agentManager.onPermissionRequest(
async (request: AcpPermissionRequest) => {
// Send permission request to WebView
@@ -132,10 +144,8 @@ export class WebViewProvider {
console.log('[WebViewProvider] Agent connected successfully');
this.agentInitialized = true;
// 显示成功通知
vscode.window.showInformationMessage(
'✅ Qwen Code connected successfully!',
);
// Load messages from the current Qwen session
await this.loadCurrentSessionMessages();
} catch (error) {
console.error('[WebViewProvider] Agent connection error:', error);
// Clear auth cache on error
@@ -143,58 +153,99 @@ export class WebViewProvider {
vscode.window.showWarningMessage(
`Failed to connect to Qwen CLI: ${error}\nYou can still use the chat UI, but messages won't be sent to AI.`,
);
// Fallback to empty conversation
await this.initializeEmptyConversation();
}
} else {
console.log('[WebViewProvider] Qwen agent is disabled in settings');
// Fallback to ConversationStore
await this.initializeEmptyConversation();
}
} else {
console.log(
'[WebViewProvider] Agent already initialized, reusing existing connection',
);
// Reload current session messages
await this.loadCurrentSessionMessages();
}
}
// Load or create conversation (always do this, even if agent fails)
private async loadCurrentSessionMessages(): Promise<void> {
try {
console.log('[WebViewProvider] Loading conversations...');
const conversations = await this.conversationStore.getAllConversations();
console.log(
'[WebViewProvider] Found conversations:',
conversations.length,
);
// Get the current active session ID
const currentSessionId = this.agentManager.currentSessionId;
if (conversations.length > 0) {
const lastConv = conversations[conversations.length - 1];
this.currentConversationId = lastConv.id;
if (!currentSessionId) {
console.log('[WebViewProvider] No active session, initializing empty');
await this.initializeEmptyConversation();
return;
}
console.log(
'[WebViewProvider] Loading messages from current session:',
currentSessionId,
);
const messages =
await this.agentManager.getSessionMessages(currentSessionId);
// Set current conversation ID to the session ID
this.currentConversationId = currentSessionId;
if (messages.length > 0) {
console.log(
'[WebViewProvider] Loaded existing conversation:',
this.currentConversationId,
'[WebViewProvider] Loaded',
messages.length,
'messages from current Qwen session',
);
this.sendMessageToWebView({
type: 'conversationLoaded',
data: lastConv,
data: { id: currentSessionId, messages },
});
} else {
console.log('[WebViewProvider] Creating new conversation...');
const newConv = await this.conversationStore.createConversation();
this.currentConversationId = newConv.id;
// Session exists but has no messages - show empty conversation
console.log(
'[WebViewProvider] Created new conversation:',
this.currentConversationId,
'[WebViewProvider] Current session has no messages, showing empty conversation',
);
this.sendMessageToWebView({
type: 'conversationLoaded',
data: newConv,
data: { id: currentSessionId, messages: [] },
});
}
console.log('[WebViewProvider] Initialization complete');
} catch (convError) {
} catch (error) {
console.error(
'[WebViewProvider] Failed to create conversation:',
convError,
'[WebViewProvider] Failed to load session messages:',
error,
);
vscode.window.showErrorMessage(
`Failed to initialize conversation: ${convError}`,
`Failed to load session messages: ${error}`,
);
await this.initializeEmptyConversation();
}
}
private async initializeEmptyConversation(): Promise<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: [] },
});
}
}
@@ -258,7 +309,13 @@ export class WebViewProvider {
console.log('[WebViewProvider] handleSendMessage called with:', text);
if (!this.currentConversationId) {
console.error('[WebViewProvider] No current conversation ID');
const errorMsg = 'No active conversation. Please restart the extension.';
console.error('[WebViewProvider]', errorMsg);
vscode.window.showErrorMessage(errorMsg);
this.sendMessageToWebView({
type: 'error',
data: { message: errorMsg },
});
return;
}
@@ -299,6 +356,9 @@ export class WebViewProvider {
// Send to agent
try {
// Reset stream content
this.currentStreamContent = '';
// Create placeholder for assistant message
this.sendMessageToWebView({
type: 'streamStart',
@@ -310,7 +370,20 @@ export class WebViewProvider {
await this.agentManager.sendMessage(text);
console.log('[WebViewProvider] Agent manager send complete');
// Stream is complete
// Stream is complete - save assistant message
if (this.currentStreamContent && this.currentConversationId) {
const assistantMessage: ChatMessage = {
role: 'assistant',
content: this.currentStreamContent,
timestamp: Date.now(),
};
await this.conversationStore.addMessage(
this.currentConversationId,
assistantMessage,
);
console.log('[WebViewProvider] Assistant message saved to store');
}
this.sendMessageToWebView({
type: 'streamEnd',
data: { timestamp: Date.now() },
@@ -386,8 +459,6 @@ export class WebViewProvider {
type: 'conversationCleared',
data: {},
});
vscode.window.showInformationMessage('✅ New Qwen session created!');
} catch (error) {
console.error('[WebViewProvider] Failed to create new session:', error);
this.sendMessageToWebView({
@@ -401,6 +472,10 @@ export class WebViewProvider {
try {
console.log('[WebViewProvider] Switching to Qwen session:', sessionId);
// Set current conversation ID so we can send messages
this.currentConversationId = sessionId;
console.log('[WebViewProvider] Set currentConversationId to:', sessionId);
// Get session messages from local files
const messages = await this.agentManager.getSessionMessages(sessionId);
console.log(
@@ -411,10 +486,26 @@ export class WebViewProvider {
// Try to switch session in ACP (may fail if not supported)
try {
await this.agentManager.switchToSession(sessionId);
console.log('[WebViewProvider] Session switched successfully in ACP');
} catch (_switchError) {
console.log(
'[WebViewProvider] session/switch not supported, but loaded messages anyway',
'[WebViewProvider] session/switch not supported or failed, creating new session',
);
// If switch fails, create a new session to continue conversation
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
try {
await this.agentManager.createNewSession(workingDir);
console.log('[WebViewProvider] Created new session as fallback');
} catch (newSessionError) {
console.error(
'[WebViewProvider] Failed to create new session:',
newSessionError,
);
vscode.window.showWarningMessage(
'Could not switch to session. Created new session instead.',
);
}
}
// Send messages to WebView
@@ -422,16 +513,13 @@ export class WebViewProvider {
type: 'qwenSessionSwitched',
data: { sessionId, messages },
});
vscode.window.showInformationMessage(
`Loaded Qwen session with ${messages.length} messages`,
);
} catch (error) {
console.error('[WebViewProvider] Failed to switch session:', error);
this.sendMessageToWebView({
type: 'error',
data: { message: `Failed to switch session: ${error}` },
});
vscode.window.showErrorMessage(`Failed to switch session: ${error}`);
}
}

View File

@@ -539,4 +539,8 @@ export class AcpConnection {
get hasActiveSession(): boolean {
return this.sessionId !== null;
}
get currentSessionId(): string | null {
return this.sessionId;
}
}

View File

@@ -22,11 +22,22 @@ export interface ChatMessage {
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 {
private connection: AcpConnection;
private sessionReader: QwenSessionReader;
private onMessageCallback?: (message: ChatMessage) => void;
private onStreamChunkCallback?: (chunk: string) => void;
private onToolCallCallback?: (update: ToolCallUpdateData) => void;
private onPermissionRequestCallback?: (
request: AcpPermissionRequest,
) => Promise<string>;
@@ -342,19 +353,91 @@ export class QwenAgentManager {
private handleSessionUpdate(data: AcpSessionUpdate): void {
const update = data.update;
if (update.sessionUpdate === 'agent_message_chunk') {
if (update.content?.text && this.onStreamChunkCallback) {
this.onStreamChunkCallback(update.content.text);
}
} else if (update.sessionUpdate === 'tool_call') {
// Handle tool call updates
const toolCall = update as { title?: string; status?: string };
const title = toolCall.title || 'Tool Call';
const status = toolCall.status || 'pending';
switch (update.sessionUpdate) {
case 'user_message_chunk':
// Handle user message chunks if needed
if (update.content?.text && this.onStreamChunkCallback) {
this.onStreamChunkCallback(update.content.text);
}
break;
if (this.onStreamChunkCallback) {
this.onStreamChunkCallback(`\n🔧 ${title} [${status}]\n`);
case 'agent_message_chunk':
// Handle assistant message chunks
if (update.content?.text && this.onStreamChunkCallback) {
this.onStreamChunkCallback(update.content.text);
}
break;
case 'agent_thought_chunk':
// Handle thinking chunks - could be displayed differently in UI
if (update.content?.text && this.onStreamChunkCallback) {
this.onStreamChunkCallback(update.content.text);
}
break;
case 'tool_call': {
// Handle new tool call
if (this.onToolCallCallback && 'toolCallId' in update) {
this.onToolCallCallback({
toolCallId: update.toolCallId as string,
kind: (update.kind as string) || undefined,
title: (update.title as string) || undefined,
status: (update.status as string) || undefined,
rawInput: update.rawInput,
content: update.content as
| Array<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;
}
}
@@ -366,6 +449,10 @@ export class QwenAgentManager {
this.onStreamChunkCallback = callback;
}
onToolCall(callback: (update: ToolCallUpdateData) => void): void {
this.onToolCallCallback = callback;
}
onPermissionRequest(
callback: (request: AcpPermissionRequest) => Promise<string>,
): void {
@@ -379,4 +466,8 @@ export class QwenAgentManager {
get isConnected(): boolean {
return this.connection.isConnected;
}
get currentSessionId(): string | null {
return this.connection.currentSessionId;
}
}

View File

@@ -38,17 +38,36 @@ export interface BaseSessionUpdate {
sessionId: string;
}
// Content block type
export interface ContentBlock {
type: 'text' | 'image';
text?: string;
data?: string;
mimeType?: string;
uri?: string;
}
// User message chunk update
export interface UserMessageChunkUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'user_message_chunk';
content: ContentBlock;
};
}
// Agent message chunk update
export interface AgentMessageChunkUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'agent_message_chunk';
content: {
type: 'text' | 'image';
text?: string;
data?: string;
mimeType?: string;
uri?: string;
};
content: ContentBlock;
};
}
// Agent thought chunk update
export interface AgentThoughtChunkUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'agent_thought_chunk';
content: ContentBlock;
};
}
@@ -59,7 +78,16 @@ export interface ToolCallUpdate extends BaseSessionUpdate {
toolCallId: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
title: string;
kind: 'read' | 'edit' | 'execute';
kind:
| 'read'
| 'edit'
| 'execute'
| 'delete'
| 'move'
| 'search'
| 'fetch'
| 'think'
| 'other';
rawInput?: unknown;
content?: Array<{
type: 'content' | 'diff';
@@ -71,11 +99,59 @@ export interface ToolCallUpdate extends BaseSessionUpdate {
oldText?: string | null;
newText?: string;
}>;
locations?: Array<{
path: string;
line?: number | null;
}>;
};
}
// Tool call status update
export interface ToolCallStatusUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'tool_call_update';
toolCallId: string;
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
title?: string;
kind?: string;
rawInput?: unknown;
content?: Array<{
type: 'content' | 'diff';
content?: {
type: 'text';
text: string;
};
path?: string;
oldText?: string | null;
newText?: string;
}>;
locations?: Array<{
path: string;
line?: number | null;
}>;
};
}
// Plan update
export interface PlanUpdate extends BaseSessionUpdate {
update: {
sessionUpdate: 'plan';
entries: Array<{
content: string;
priority: 'high' | 'medium' | 'low';
status: 'pending' | 'in_progress' | 'completed';
}>;
};
}
// Union type for all session updates
export type AcpSessionUpdate = AgentMessageChunkUpdate | ToolCallUpdate;
export type AcpSessionUpdate =
| UserMessageChunkUpdate
| AgentMessageChunkUpdate
| AgentThoughtChunkUpdate
| ToolCallUpdate
| ToolCallStatusUpdate
| PlanUpdate;
// Permission request
export interface AcpPermissionRequest {

View File

@@ -338,8 +338,8 @@ body {
font-family: monospace;
}
/* Claude-style Inline Permission Request */
.permission-request-inline {
/* Permission Request Component Styles */
.permission-request-card {
margin: 16px 0;
animation: slideIn 0.3s ease-out;
}
@@ -355,7 +355,7 @@ body {
}
}
.permission-card {
.permission-card-body {
background: linear-gradient(
135deg,
rgba(79, 134, 247, 0.08) 0%,
@@ -368,7 +368,7 @@ body {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.permission-card-header {
.permission-header {
display: flex;
align-items: center;
gap: 12px;
@@ -386,11 +386,15 @@ body {
font-size: 20px;
}
.permission-icon {
font-size: 20px;
}
.permission-info {
flex: 1;
}
.permission-tool-title {
.permission-title {
font-size: 14px;
font-weight: 600;
color: var(--vscode-editor-foreground);
@@ -402,85 +406,426 @@ body {
color: rgba(255, 255, 255, 0.6);
}
.permission-actions-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
.permission-command-section {
margin-bottom: 12px;
}
.permission-btn-inline {
.permission-command-label {
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 4px;
}
.permission-command-code {
display: block;
background: rgba(0, 0, 0, 0.2);
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
font-family: 'Courier New', monospace;
color: var(--vscode-editor-foreground);
overflow-x: auto;
word-break: break-all;
}
.permission-locations-section {
margin-bottom: 12px;
}
.permission-locations-label {
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 6px;
}
.permission-location-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.15);
border-radius: 4px;
margin-bottom: 4px;
font-size: 12px;
}
.permission-location-icon {
font-size: 14px;
}
.permission-location-path {
flex: 1;
min-width: 100px;
font-family: 'Courier New', monospace;
overflow: hidden;
text-overflow: ellipsis;
}
.permission-location-line {
color: rgba(255, 255, 255, 0.6);
font-size: 11px;
}
.permission-options-section {
margin-top: 16px;
}
.permission-options-label {
font-size: 12px;
margin-bottom: 8px;
color: var(--vscode-editor-foreground);
}
.permission-options-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.permission-option {
display: flex;
align-items: center;
padding: 10px 16px;
border: 1.5px solid transparent;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}
.permission-option input[type="radio"] {
margin-right: 10px;
cursor: pointer;
}
.permission-radio {
width: 16px;
height: 16px;
}
.permission-option-content {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
}
.permission-always-badge {
font-size: 14px;
animation: pulse 1.5s ease-in-out infinite;
}
.permission-option.allow {
background: linear-gradient(135deg, rgba(46, 160, 67, 0.15), rgba(46, 160, 67, 0.08));
border-color: rgba(46, 160, 67, 0.3);
}
.permission-option.allow.selected {
background: linear-gradient(135deg, rgba(46, 160, 67, 0.25), rgba(46, 160, 67, 0.15));
border-color: rgba(46, 160, 67, 0.5);
box-shadow: 0 2px 8px rgba(46, 160, 67, 0.2);
}
.permission-option.reject {
background: linear-gradient(135deg, rgba(200, 40, 40, 0.15), rgba(200, 40, 40, 0.08));
border-color: rgba(200, 40, 40, 0.3);
}
.permission-option.reject.selected {
background: linear-gradient(135deg, rgba(200, 40, 40, 0.25), rgba(200, 40, 40, 0.15));
border-color: rgba(200, 40, 40, 0.5);
box-shadow: 0 2px 8px rgba(200, 40, 40, 0.2);
}
.permission-option.always {
border-style: dashed;
}
.permission-option:hover {
transform: translateY(-1px);
}
.permission-actions {
display: flex;
justify-content: flex-start;
padding-left: 26px;
}
.permission-confirm-button {
padding: 8px 20px;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
font-family: var(--vscode-font-family);
}
.permission-confirm-button:hover:not(:disabled) {
background: var(--vscode-button-hoverBackground);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.permission-confirm-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.permission-no-options {
padding: 12px;
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
}
.permission-success {
margin-top: 12px;
padding: 10px 16px;
background: rgba(46, 160, 67, 0.15);
border: 1px solid rgba(46, 160, 67, 0.4);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
position: relative;
overflow: hidden;
gap: 8px;
}
.permission-btn-inline::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
transform: translate(-50%, -50%);
transition: width 0.3s, height 0.3s;
}
.permission-btn-inline:hover::before {
width: 300px;
height: 300px;
}
.permission-btn-inline.allow {
background: linear-gradient(135deg, rgba(46, 160, 67, 0.25), rgba(46, 160, 67, 0.15));
.permission-success-icon {
font-size: 16px;
color: #4ec9b0;
border-color: rgba(46, 160, 67, 0.4);
}
.permission-btn-inline.allow:hover {
background: linear-gradient(135deg, rgba(46, 160, 67, 0.35), rgba(46, 160, 67, 0.25));
border-color: rgba(46, 160, 67, 0.6);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(46, 160, 67, 0.3);
.permission-success-text {
font-size: 13px;
color: #4ec9b0;
}
.permission-btn-inline.reject {
background: linear-gradient(135deg, rgba(200, 40, 40, 0.25), rgba(200, 40, 40, 0.15));
color: #f48771;
border-color: rgba(200, 40, 40, 0.4);
/* Tool Call Component Styles */
.tool-call-card {
margin: 12px 0;
padding: 12px 16px;
background: var(--vscode-input-background);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
animation: fadeIn 0.2s ease-in;
}
.permission-btn-inline.reject:hover {
background: linear-gradient(135deg, rgba(200, 40, 40, 0.35), rgba(200, 40, 40, 0.25));
border-color: rgba(200, 40, 40, 0.6);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(200, 40, 40, 0.3);
.tool-call-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.permission-btn-inline.always {
border-style: dashed;
.tool-call-kind-icon {
font-size: 18px;
}
.always-badge {
.tool-call-title {
flex: 1;
font-size: 14px;
animation: pulse 1.5s ease-in-out infinite;
font-weight: 600;
color: var(--vscode-editor-foreground);
}
.permission-btn-inline:active {
transform: translateY(0);
.tool-call-status {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
.status-icon {
font-size: 12px;
}
.status-pending {
background: rgba(79, 134, 247, 0.2);
color: #79b8ff;
}
.status-in-progress {
background: rgba(255, 165, 0, 0.2);
color: #ffab70;
}
.status-completed {
background: rgba(46, 160, 67, 0.2);
color: #4ec9b0;
}
.status-failed {
background: rgba(200, 40, 40, 0.2);
color: #f48771;
}
.status-unknown {
background: rgba(128, 128, 128, 0.2);
color: #888;
}
.tool-call-raw-input {
margin-bottom: 12px;
}
.raw-input-label {
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 4px;
}
.raw-input-content {
background: rgba(0, 0, 0, 0.2);
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
font-family: 'Courier New', monospace;
overflow-x: auto;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
.tool-call-locations {
margin-bottom: 12px;
}
.locations-label {
font-size: 11px;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 6px;
}
.location-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.15);
border-radius: 4px;
margin-bottom: 4px;
font-size: 12px;
}
.location-icon {
font-size: 14px;
}
.location-path {
flex: 1;
font-family: 'Courier New', monospace;
overflow: hidden;
text-overflow: ellipsis;
}
.location-line {
color: rgba(255, 255, 255, 0.6);
font-size: 11px;
}
.tool-call-content-list {
margin-top: 12px;
}
.tool-call-diff {
margin-top: 8px;
}
.diff-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: rgba(79, 134, 247, 0.15);
border-radius: 4px 4px 0 0;
border: 1px solid rgba(79, 134, 247, 0.3);
border-bottom: none;
}
.diff-icon {
font-size: 14px;
}
.diff-filename {
font-size: 12px;
font-weight: 600;
font-family: 'Courier New', monospace;
}
.diff-content {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 8px;
padding: 12px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(79, 134, 247, 0.3);
border-radius: 0 0 4px 4px;
}
.diff-side {
min-width: 0;
}
.diff-side-label {
font-size: 10px;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.diff-code {
background: rgba(0, 0, 0, 0.3);
padding: 8px;
border-radius: 4px;
font-size: 11px;
font-family: 'Courier New', monospace;
overflow-x: auto;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
}
.diff-arrow {
display: flex;
align-items: center;
color: rgba(255, 255, 255, 0.5);
font-size: 16px;
padding: 0 4px;
}
.tool-call-content {
margin-top: 8px;
}
.content-text {
background: rgba(0, 0, 0, 0.2);
padding: 10px 12px;
border-radius: 4px;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.tool-call-footer {
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.tool-call-id {
font-size: 10px;
color: rgba(255, 255, 255, 0.5);
font-family: 'Courier New', monospace;
}

View File

@@ -6,12 +6,48 @@
import React, { useState, useEffect, useRef } from 'react';
import { useVSCode } from './hooks/useVSCode.js';
import type { ChatMessage } from '../agents/QwenAgentManager.js';
import type { Conversation } from '../storage/ConversationStore.js';
import {
PermissionRequest,
type PermissionOption,
type ToolCall as PermissionToolCall,
} from './components/PermissionRequest.js';
import { ToolCall, type ToolCallData } from './components/ToolCall.js';
interface ToolCallUpdate {
type: 'tool_call' | 'tool_call_update';
toolCallId: string;
kind?: string;
title?: string;
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
rawInput?: unknown;
content?: Array<{
type: 'content' | 'diff';
content?: {
type: string;
text?: string;
[key: string]: unknown;
};
path?: string;
oldText?: string | null;
newText?: string;
[key: string]: unknown;
}>;
locations?: Array<{
path: string;
line?: number | null;
}>;
}
interface TextMessage {
role: 'user' | 'assistant' | 'thinking';
content: string;
timestamp: number;
}
export const App: React.FC = () => {
const vscode = useVSCode();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [messages, setMessages] = useState<TextMessage[]>([]);
const [inputText, setInputText] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [currentStreamContent, setCurrentStreamContent] = useState('');
@@ -20,18 +56,20 @@ export const App: React.FC = () => {
>([]);
const [showSessionSelector, setShowSessionSelector] = useState(false);
const [permissionRequest, setPermissionRequest] = useState<{
options: Array<{ name: string; kind: string; optionId: string }>;
toolCall: { title?: string };
options: PermissionOption[];
toolCall: PermissionToolCall;
} | null>(null);
const [toolCalls, setToolCalls] = useState<Map<string, ToolCallData>>(
new Map(),
);
const messagesEndRef = useRef<HTMLDivElement>(null);
const handlePermissionRequest = React.useCallback(
(request: {
options: Array<{ name: string; kind: string; optionId: string }>;
toolCall: { title?: string };
options: PermissionOption[];
toolCall: PermissionToolCall;
}) => {
console.log('[WebView] Permission request received:', request);
// Show custom modal instead of window.confirm()
setPermissionRequest(request);
},
[],
@@ -49,6 +87,56 @@ export const App: React.FC = () => {
[vscode],
);
const handleToolCallUpdate = React.useCallback((update: ToolCallUpdate) => {
setToolCalls((prev) => {
const newMap = new Map(prev);
const existing = newMap.get(update.toolCallId);
if (update.type === 'tool_call') {
// New tool call - cast content to proper type
const content = update.content?.map((item) => ({
type: item.type as 'content' | 'diff',
content: item.content,
path: item.path,
oldText: item.oldText,
newText: item.newText,
}));
newMap.set(update.toolCallId, {
toolCallId: update.toolCallId,
kind: update.kind || 'other',
title: update.title || 'Tool Call',
status: update.status || 'pending',
rawInput: update.rawInput as string | object | undefined,
content,
locations: update.locations,
});
} else if (update.type === 'tool_call_update' && existing) {
// Update existing tool call
const updatedContent = update.content
? update.content.map((item) => ({
type: item.type as 'content' | 'diff',
content: item.content,
path: item.path,
oldText: item.oldText,
newText: item.newText,
}))
: undefined;
newMap.set(update.toolCallId, {
...existing,
...(update.kind && { kind: update.kind }),
...(update.title && { title: update.title }),
...(update.status && { status: update.status }),
...(updatedContent && { content: updatedContent }),
...(update.locations && { locations: update.locations }),
});
}
return newMap;
});
}, []);
useEffect(() => {
// Listen for messages from extension
const handleMessage = (event: MessageEvent) => {
@@ -62,7 +150,7 @@ export const App: React.FC = () => {
}
case 'message': {
const newMessage = message.data as ChatMessage;
const newMessage = message.data as TextMessage;
setMessages((prev) => [...prev, newMessage]);
break;
}
@@ -72,14 +160,21 @@ export const App: React.FC = () => {
setCurrentStreamContent('');
break;
case 'streamChunk':
setCurrentStreamContent((prev) => prev + message.data.chunk);
case 'streamChunk': {
const chunkData = message.data;
if (chunkData.role === 'thinking') {
// Handle thinking chunks separately if needed
setCurrentStreamContent((prev) => prev + chunkData.chunk);
} else {
setCurrentStreamContent((prev) => prev + chunkData.chunk);
}
break;
}
case 'streamEnd':
// Finalize the streamed message
if (currentStreamContent) {
const assistantMessage: ChatMessage = {
const assistantMessage: TextMessage = {
role: 'assistant',
content: currentStreamContent,
timestamp: Date.now(),
@@ -100,6 +195,12 @@ export const App: React.FC = () => {
handlePermissionRequest(message.data);
break;
case 'toolCall':
case 'toolCallUpdate':
// Handle tool call updates
handleToolCallUpdate(message.data);
break;
case 'qwenSessionList':
setQwenSessions(message.data.sessions || []);
break;
@@ -113,11 +214,13 @@ export const App: React.FC = () => {
setMessages([]);
}
setCurrentStreamContent('');
setToolCalls(new Map());
break;
case 'conversationCleared':
setMessages([]);
setCurrentStreamContent('');
setToolCalls(new Map());
break;
default:
@@ -127,7 +230,7 @@ export const App: React.FC = () => {
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [currentStreamContent, handlePermissionRequest]);
}, [currentStreamContent, handlePermissionRequest, handleToolCallUpdate]);
useEffect(() => {
// Auto-scroll to bottom when messages change
@@ -250,43 +353,18 @@ export const App: React.FC = () => {
</div>
))}
{/* Claude-style Inline Permission Request */}
{/* Tool Calls */}
{Array.from(toolCalls.values()).map((toolCall) => (
<ToolCall key={toolCall.toolCallId} toolCall={toolCall} />
))}
{/* Permission Request */}
{permissionRequest && (
<div className="permission-request-inline">
<div className="permission-card">
<div className="permission-card-header">
<div className="permission-icon-wrapper">
<span className="permission-icon">🔧</span>
</div>
<div className="permission-info">
<div className="permission-tool-title">
{permissionRequest.toolCall.title || 'Tool Request'}
</div>
<div className="permission-subtitle">
Waiting for your approval
</div>
</div>
</div>
<div className="permission-actions-row">
{permissionRequest.options.map((option) => {
const isAllow = option.kind.includes('allow');
const isAlways = option.kind.includes('always');
return (
<button
key={option.optionId}
onClick={() => handlePermissionResponse(option.optionId)}
className={`permission-btn-inline ${isAllow ? 'allow' : 'reject'} ${isAlways ? 'always' : ''}`}
>
{isAlways && <span className="always-badge"></span>}
{option.name}
</button>
);
})}
</div>
</div>
</div>
<PermissionRequest
options={permissionRequest.options}
toolCall={permissionRequest.toolCall}
onResponse={handlePermissionResponse}
/>
)}
{isStreaming && currentStreamContent && (

View File

@@ -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>
);
};

View File

@@ -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>
);
};