mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +00:00
fix(vscode-ide-companion): fix bugs & support terminal mode operation
This commit is contained in:
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { getFileName } from './utils/webviewUtils.js';
|
||||
|
||||
/**
|
||||
* File Operations Handler
|
||||
* Responsible for handling file opening and diff viewing functionality
|
||||
*/
|
||||
export class FileOperations {
|
||||
/**
|
||||
* Open file and optionally navigate to specified line and column
|
||||
* @param filePath File path, can include line and column numbers (format: path/to/file.ts:123 or path/to/file.ts:123:45)
|
||||
*/
|
||||
static async openFile(filePath?: string): Promise<void> {
|
||||
try {
|
||||
if (!filePath) {
|
||||
console.warn('[FileOperations] No file path provided');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[FileOperations] Opening file:', filePath);
|
||||
|
||||
// Parse file path, line number, and column number
|
||||
// Formats: path/to/file.ts, path/to/file.ts:123, path/to/file.ts:123:45
|
||||
const match = filePath.match(/^(.+?)(?::(\d+))?(?::(\d+))?$/);
|
||||
if (!match) {
|
||||
console.warn('[FileOperations] Invalid file path format:', filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const [, path, lineStr, columnStr] = match;
|
||||
const lineNumber = lineStr ? parseInt(lineStr, 10) - 1 : 0; // VS Code uses 0-based line numbers
|
||||
const columnNumber = columnStr ? parseInt(columnStr, 10) - 1 : 0; // VS Code uses 0-based column numbers
|
||||
|
||||
// Convert to absolute path if relative
|
||||
let absolutePath = path;
|
||||
if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:/)) {
|
||||
// Relative path - resolve against workspace
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (workspaceFolder) {
|
||||
absolutePath = vscode.Uri.joinPath(workspaceFolder.uri, path).fsPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Open the document
|
||||
const uri = vscode.Uri.file(absolutePath);
|
||||
const document = await vscode.workspace.openTextDocument(uri);
|
||||
const editor = await vscode.window.showTextDocument(document, {
|
||||
preview: false,
|
||||
preserveFocus: false,
|
||||
});
|
||||
|
||||
// Navigate to line and column if specified
|
||||
if (lineStr) {
|
||||
const position = new vscode.Position(lineNumber, columnNumber);
|
||||
editor.selection = new vscode.Selection(position, position);
|
||||
editor.revealRange(
|
||||
new vscode.Range(position, position),
|
||||
vscode.TextEditorRevealType.InCenter,
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[FileOperations] File opened successfully:', absolutePath);
|
||||
} catch (error) {
|
||||
console.error('[FileOperations] Failed to open file:', error);
|
||||
vscode.window.showErrorMessage(`Failed to open file: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open diff view to compare file changes
|
||||
* @param data Diff data, including file path, old content, and new content
|
||||
*/
|
||||
static async openDiff(data?: {
|
||||
path?: string;
|
||||
oldText?: string;
|
||||
newText?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
if (!data || !data.path) {
|
||||
console.warn('[FileOperations] No file path provided for diff');
|
||||
return;
|
||||
}
|
||||
|
||||
const { path, oldText = '', newText = '' } = data;
|
||||
console.log('[FileOperations] Opening diff for:', path);
|
||||
|
||||
// Convert to absolute path if relative
|
||||
let absolutePath = path;
|
||||
if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:/)) {
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (workspaceFolder) {
|
||||
absolutePath = vscode.Uri.joinPath(workspaceFolder.uri, path).fsPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the file name for display
|
||||
const fileName = getFileName(absolutePath);
|
||||
|
||||
// Create URIs for old and new content
|
||||
// Use untitled scheme for old content (before changes)
|
||||
const oldUri = vscode.Uri.parse(`untitled:${absolutePath}.old`).with({
|
||||
scheme: 'untitled',
|
||||
});
|
||||
|
||||
// Use the actual file URI for new content
|
||||
const newUri = vscode.Uri.file(absolutePath);
|
||||
|
||||
// Create a TextDocument for the old content using an in-memory document
|
||||
const _oldDocument = await vscode.workspace.openTextDocument(
|
||||
oldUri.with({ scheme: 'untitled' }),
|
||||
);
|
||||
|
||||
// Write old content to the document
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.insert(
|
||||
oldUri.with({ scheme: 'untitled' }),
|
||||
new vscode.Position(0, 0),
|
||||
oldText,
|
||||
);
|
||||
await vscode.workspace.applyEdit(edit);
|
||||
|
||||
// Check if new file exists, if not create it with new content
|
||||
try {
|
||||
await vscode.workspace.fs.stat(newUri);
|
||||
} catch {
|
||||
// File doesn't exist, create it
|
||||
const encoder = new TextEncoder();
|
||||
await vscode.workspace.fs.writeFile(newUri, encoder.encode(newText));
|
||||
}
|
||||
|
||||
// Open diff view
|
||||
await vscode.commands.executeCommand(
|
||||
'vscode.diff',
|
||||
oldUri.with({ scheme: 'untitled' }),
|
||||
newUri,
|
||||
`${fileName} (Before ↔ After)`,
|
||||
{
|
||||
viewColumn: vscode.ViewColumn.Beside,
|
||||
preview: false,
|
||||
preserveFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
console.log('[FileOperations] Diff opened successfully');
|
||||
} catch (error) {
|
||||
console.error('[FileOperations] Failed to open diff:', error);
|
||||
vscode.window.showErrorMessage(`Failed to open diff: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,34 +43,78 @@ export class PanelManager {
|
||||
return false; // Panel already exists
|
||||
}
|
||||
|
||||
// We want the chat webview to live in a dedicated, locked group on the
|
||||
// left. Create a new group on the far left and open the panel there.
|
||||
try {
|
||||
// Make sure we start from the first group, then create a group to its left
|
||||
await vscode.commands.executeCommand(
|
||||
'workbench.action.focusFirstEditorGroup',
|
||||
);
|
||||
await vscode.commands.executeCommand('workbench.action.newGroupLeft');
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[PanelManager] Failed to pre-create left editor group (continuing):',
|
||||
error,
|
||||
);
|
||||
}
|
||||
// First, check if there's an existing Qwen Code group
|
||||
const existingGroup = this.findExistingQwenCodeGroup();
|
||||
|
||||
this.panel = vscode.window.createWebviewPanel(
|
||||
'qwenCode.chat',
|
||||
'Qwen Code',
|
||||
{ viewColumn: vscode.ViewColumn.One, preserveFocus: false }, // Focus and place in leftmost group
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [
|
||||
vscode.Uri.joinPath(this.extensionUri, 'dist'),
|
||||
vscode.Uri.joinPath(this.extensionUri, 'assets'),
|
||||
],
|
||||
},
|
||||
);
|
||||
if (existingGroup) {
|
||||
// If Qwen Code webview already exists in a locked group, create the new panel in that same group
|
||||
console.log(
|
||||
'[PanelManager] Found existing Qwen Code group, creating panel in same group',
|
||||
);
|
||||
this.panel = vscode.window.createWebviewPanel(
|
||||
'qwenCode.chat',
|
||||
'Qwen Code',
|
||||
{ viewColumn: existingGroup.viewColumn, preserveFocus: false },
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [
|
||||
vscode.Uri.joinPath(this.extensionUri, 'dist'),
|
||||
vscode.Uri.joinPath(this.extensionUri, 'assets'),
|
||||
],
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// If no existing Qwen Code group, create a new group to the right of the active editor group
|
||||
try {
|
||||
// Create a new group to the right of the current active group
|
||||
await vscode.commands.executeCommand('workbench.action.newGroupRight');
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[PanelManager] Failed to create right editor group (continuing):',
|
||||
error,
|
||||
);
|
||||
// Fallback: create in current group
|
||||
const activeColumn =
|
||||
vscode.window.activeTextEditor?.viewColumn || vscode.ViewColumn.One;
|
||||
this.panel = vscode.window.createWebviewPanel(
|
||||
'qwenCode.chat',
|
||||
'Qwen Code',
|
||||
{ viewColumn: activeColumn, preserveFocus: false },
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [
|
||||
vscode.Uri.joinPath(this.extensionUri, 'dist'),
|
||||
vscode.Uri.joinPath(this.extensionUri, 'assets'),
|
||||
],
|
||||
},
|
||||
);
|
||||
// Lock the group after creation
|
||||
await this.autoLockEditorGroup();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get the new group's view column (should be the active one after creating right)
|
||||
const newGroupColumn = vscode.window.tabGroups.activeTabGroup.viewColumn;
|
||||
|
||||
this.panel = vscode.window.createWebviewPanel(
|
||||
'qwenCode.chat',
|
||||
'Qwen Code',
|
||||
{ viewColumn: newGroupColumn, preserveFocus: false },
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [
|
||||
vscode.Uri.joinPath(this.extensionUri, 'dist'),
|
||||
vscode.Uri.joinPath(this.extensionUri, 'assets'),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
// Lock the group after creation
|
||||
await this.autoLockEditorGroup();
|
||||
}
|
||||
|
||||
// Set panel icon to Qwen logo
|
||||
this.panel.iconPath = vscode.Uri.joinPath(
|
||||
|
||||
@@ -15,6 +15,7 @@ import { MessageHandler } from '../webview/MessageHandler.js';
|
||||
import { WebViewContent } from '../webview/WebViewContent.js';
|
||||
import { CliInstaller } from '../cli/cliInstaller.js';
|
||||
import { getFileName } from './utils/webviewUtils.js';
|
||||
import { authMethod } from '../auth/index.js';
|
||||
|
||||
export class WebViewProvider {
|
||||
private panelManager: PanelManager;
|
||||
@@ -150,8 +151,85 @@ export class WebViewProvider {
|
||||
type: string;
|
||||
data: { optionId: string };
|
||||
}) => {
|
||||
if (message.type === 'permissionResponse') {
|
||||
resolve(message.data.optionId);
|
||||
if (message.type !== 'permissionResponse') return;
|
||||
|
||||
const optionId = message.data.optionId || '';
|
||||
|
||||
// 1) First resolve the optionId back to ACP so the agent isn't blocked
|
||||
resolve(optionId);
|
||||
|
||||
// 2) If user cancelled/rejected, proactively stop current generation
|
||||
const isCancel =
|
||||
optionId === 'cancel' ||
|
||||
optionId.toLowerCase().includes('reject');
|
||||
|
||||
if (isCancel) {
|
||||
// Fire and forget – do not block the ACP resolve
|
||||
(async () => {
|
||||
try {
|
||||
// Stop server-side generation
|
||||
await this.agentManager.cancelCurrentPrompt();
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[WebViewProvider] cancelCurrentPrompt error:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure the webview exits streaming state immediately
|
||||
this.sendMessageToWebView({
|
||||
type: 'streamEnd',
|
||||
data: { timestamp: Date.now(), reason: 'user_cancelled' },
|
||||
});
|
||||
|
||||
// Synthesize a failed tool_call_update to match Claude/CLI UX
|
||||
try {
|
||||
const toolCallId =
|
||||
(request.toolCall as { toolCallId?: string } | undefined)
|
||||
?.toolCallId || '';
|
||||
const title =
|
||||
(request.toolCall as { title?: string } | undefined)
|
||||
?.title || '';
|
||||
// Normalize kind for UI – fall back to 'execute'
|
||||
let kind = ((
|
||||
request.toolCall as { kind?: string } | undefined
|
||||
)?.kind || 'execute') as string;
|
||||
if (!kind && title) {
|
||||
const t = title.toLowerCase();
|
||||
if (t.includes('read') || t.includes('cat')) kind = 'read';
|
||||
else if (t.includes('write') || t.includes('edit'))
|
||||
kind = 'edit';
|
||||
else kind = 'execute';
|
||||
}
|
||||
|
||||
this.sendMessageToWebView({
|
||||
type: 'toolCall',
|
||||
data: {
|
||||
type: 'tool_call_update',
|
||||
toolCallId,
|
||||
title,
|
||||
kind,
|
||||
status: 'failed',
|
||||
// Best-effort pass-through (used by UI hints)
|
||||
rawInput: (request.toolCall as { rawInput?: unknown })
|
||||
?.rawInput,
|
||||
locations: (
|
||||
request.toolCall as {
|
||||
locations?: Array<{
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}>;
|
||||
}
|
||||
)?.locations,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[WebViewProvider] failed to synthesize failed tool_call_update:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
};
|
||||
// Store handler in message handler
|
||||
@@ -339,10 +417,6 @@ export class WebViewProvider {
|
||||
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
|
||||
const config = vscode.workspace.getConfiguration('qwenCode');
|
||||
const openaiApiKey = config.get<string>('qwen.openaiApiKey', '');
|
||||
const authMethod = openaiApiKey ? 'openai' : 'qwen-oauth';
|
||||
|
||||
const hasValidAuth = await this.authStateManager.hasValidAuth(
|
||||
workingDir,
|
||||
authMethod,
|
||||
@@ -392,83 +466,71 @@ export class WebViewProvider {
|
||||
!!this.authStateManager,
|
||||
);
|
||||
|
||||
const config = vscode.workspace.getConfiguration('qwenCode');
|
||||
const qwenEnabled = config.get<boolean>('qwen.enabled', true);
|
||||
// Check if CLI is installed before attempting to connect
|
||||
const cliDetection = await CliDetector.detectQwenCli();
|
||||
|
||||
if (qwenEnabled) {
|
||||
// Check if CLI is installed before attempting to connect
|
||||
const cliDetection = await CliDetector.detectQwenCli();
|
||||
if (!cliDetection.isInstalled) {
|
||||
console.log(
|
||||
'[WebViewProvider] Qwen CLI not detected, skipping agent connection',
|
||||
);
|
||||
console.log('[WebViewProvider] CLI detection error:', cliDetection.error);
|
||||
|
||||
if (!cliDetection.isInstalled) {
|
||||
console.log(
|
||||
'[WebViewProvider] Qwen CLI not detected, skipping agent connection',
|
||||
);
|
||||
console.log(
|
||||
'[WebViewProvider] CLI detection error:',
|
||||
cliDetection.error,
|
||||
);
|
||||
// Show VSCode notification with installation option
|
||||
await CliInstaller.promptInstallation();
|
||||
|
||||
// Show VSCode notification with installation option
|
||||
await CliInstaller.promptInstallation();
|
||||
|
||||
// Initialize empty conversation (can still browse history)
|
||||
await this.initializeEmptyConversation();
|
||||
} else {
|
||||
console.log(
|
||||
'[WebViewProvider] Qwen CLI detected, attempting connection...',
|
||||
);
|
||||
console.log('[WebViewProvider] CLI path:', cliDetection.cliPath);
|
||||
console.log('[WebViewProvider] CLI version:', cliDetection.version);
|
||||
|
||||
try {
|
||||
console.log('[WebViewProvider] Connecting to agent...');
|
||||
console.log(
|
||||
'[WebViewProvider] Using authStateManager:',
|
||||
!!this.authStateManager,
|
||||
);
|
||||
const authInfo = await this.authStateManager.getAuthInfo();
|
||||
console.log('[WebViewProvider] Auth cache status:', authInfo);
|
||||
|
||||
// Pass the detected CLI path to ensure we use the correct installation
|
||||
await this.agentManager.connect(
|
||||
workingDir,
|
||||
this.authStateManager,
|
||||
cliDetection.cliPath,
|
||||
);
|
||||
console.log('[WebViewProvider] Agent connected successfully');
|
||||
this.agentInitialized = true;
|
||||
|
||||
// Load messages from the current Qwen session
|
||||
await this.loadCurrentSessionMessages();
|
||||
|
||||
// Notify webview that agent is connected
|
||||
this.sendMessageToWebView({
|
||||
type: 'agentConnected',
|
||||
data: {},
|
||||
});
|
||||
} 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();
|
||||
|
||||
// Notify webview that agent connection failed
|
||||
this.sendMessageToWebView({
|
||||
type: 'agentConnectionError',
|
||||
data: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('[WebViewProvider] Qwen agent is disabled in settings');
|
||||
// Fallback to ConversationStore
|
||||
// Initialize empty conversation (can still browse history)
|
||||
await this.initializeEmptyConversation();
|
||||
} else {
|
||||
console.log(
|
||||
'[WebViewProvider] Qwen CLI detected, attempting connection...',
|
||||
);
|
||||
console.log('[WebViewProvider] CLI path:', cliDetection.cliPath);
|
||||
console.log('[WebViewProvider] CLI version:', cliDetection.version);
|
||||
|
||||
try {
|
||||
console.log('[WebViewProvider] Connecting to agent...');
|
||||
console.log(
|
||||
'[WebViewProvider] Using authStateManager:',
|
||||
!!this.authStateManager,
|
||||
);
|
||||
const authInfo = await this.authStateManager.getAuthInfo();
|
||||
console.log('[WebViewProvider] Auth cache status:', authInfo);
|
||||
|
||||
// Pass the detected CLI path to ensure we use the correct installation
|
||||
await this.agentManager.connect(
|
||||
workingDir,
|
||||
this.authStateManager,
|
||||
cliDetection.cliPath,
|
||||
);
|
||||
console.log('[WebViewProvider] Agent connected successfully');
|
||||
this.agentInitialized = true;
|
||||
|
||||
// Load messages from the current Qwen session
|
||||
await this.loadCurrentSessionMessages();
|
||||
|
||||
// Notify webview that agent is connected
|
||||
this.sendMessageToWebView({
|
||||
type: 'agentConnected',
|
||||
data: {},
|
||||
});
|
||||
} 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();
|
||||
|
||||
// Notify webview that agent connection failed
|
||||
this.sendMessageToWebView({
|
||||
type: 'agentConnectionError',
|
||||
data: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,10 +676,6 @@ export class WebViewProvider {
|
||||
|
||||
// First, try to restore an existing session if we have cached auth
|
||||
if (this.authStateManager) {
|
||||
const config = vscode.workspace.getConfiguration('qwenCode');
|
||||
const openaiApiKey = config.get<string>('qwen.openaiApiKey', '');
|
||||
const authMethod = openaiApiKey ? 'openai' : 'qwen-oauth';
|
||||
|
||||
const hasValidAuth = await this.authStateManager.hasValidAuth(
|
||||
workingDir,
|
||||
authMethod,
|
||||
@@ -1001,6 +1059,29 @@ export class WebViewProvider {
|
||||
*/
|
||||
async createNewSession(): Promise<void> {
|
||||
console.log('[WebViewProvider] Creating new session in current panel');
|
||||
|
||||
// Check if terminal mode is enabled
|
||||
const config = vscode.workspace.getConfiguration('qwenCode');
|
||||
const useTerminal = config.get<boolean>('useTerminal', false);
|
||||
|
||||
if (useTerminal) {
|
||||
// In terminal mode, execute the runQwenCode command to open a new terminal
|
||||
try {
|
||||
await vscode.commands.executeCommand('qwen-code.runQwenCode');
|
||||
console.log('[WebViewProvider] Opened new terminal session');
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[WebViewProvider] Failed to open new terminal session:',
|
||||
error,
|
||||
);
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to open new terminal session: ${error}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// WebView mode - create new session via agent manager
|
||||
try {
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
|
||||
|
||||
@@ -10,6 +10,7 @@ import React from 'react';
|
||||
import type { ToolCallData } from './toolcalls/shared/types.js';
|
||||
import { FileLink } from './ui/FileLink.js';
|
||||
import { useVSCode } from '../hooks/useVSCode.js';
|
||||
import { handleOpenDiff } from '../utils/diffUtils.js';
|
||||
|
||||
interface InProgressToolCallProps {
|
||||
toolCall: ToolCallData;
|
||||
@@ -138,19 +139,12 @@ export const InProgressToolCall: React.FC<InProgressToolCallProps> = ({
|
||||
}
|
||||
|
||||
// Handle open diff
|
||||
const handleOpenDiff = () => {
|
||||
const handleOpenDiffInternal = () => {
|
||||
if (!diffData) {
|
||||
return;
|
||||
}
|
||||
const path = diffData.path || filePath || '';
|
||||
vscode.postMessage({
|
||||
type: 'openDiff',
|
||||
data: {
|
||||
path,
|
||||
oldText: diffData.oldText || '',
|
||||
newText: diffData.newText || '',
|
||||
},
|
||||
});
|
||||
handleOpenDiff(vscode, path, diffData.oldText, diffData.newText);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -179,7 +173,7 @@ export const InProgressToolCall: React.FC<InProgressToolCallProps> = ({
|
||||
{diffData && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenDiff}
|
||||
onClick={handleOpenDiffInternal}
|
||||
className="text-[11px] px-2 py-0.5 border border-[var(--app-input-border)] rounded-small text-[var(--app-primary-foreground)] bg-transparent hover:bg-[var(--app-ghost-button-hover-background)] cursor-pointer"
|
||||
>
|
||||
Open Diff
|
||||
|
||||
@@ -165,8 +165,8 @@
|
||||
|
||||
.markdown-content .code-block-wrapper pre {
|
||||
/* Reserve space so the copy button never overlaps code text */
|
||||
padding-top: 2rem; /* room for the button height */
|
||||
padding-right: 2.4rem; /* room for the button width */
|
||||
padding-top: 1.5rem; /* Reduced padding - room for the button height */
|
||||
padding-right: 2rem; /* Reduced padding - room for the button width */
|
||||
}
|
||||
|
||||
.markdown-content .code-block-wrapper .copy-button {
|
||||
@@ -178,21 +178,24 @@
|
||||
line-height: 1.6;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--app-primary-border-color);
|
||||
background-color: var(--app-elevated-background, rgba(255, 255, 255, 0.06));
|
||||
background-color: var(--app-primary-background, rgba(255, 255, 255, 0.1));
|
||||
color: var(--app-secondary-foreground);
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
opacity: 0; /* show on hover to reduce visual noise */
|
||||
transition: opacity 100ms ease-in-out;
|
||||
pointer-events: none; /* prevent blocking text selection */
|
||||
}
|
||||
|
||||
.markdown-content .code-block-wrapper:hover .copy-button,
|
||||
.markdown-content .code-block-wrapper .copy-button:focus {
|
||||
opacity: 1;
|
||||
pointer-events: auto; /* enable interaction when visible */
|
||||
}
|
||||
|
||||
.markdown-content .code-block-wrapper .copy-button:hover {
|
||||
background-color: var(--app-list-hover-background, rgba(127, 127, 127, 0.1));
|
||||
background-color: var(--app-list-hover-background, rgba(127, 127, 127, 0.2));
|
||||
border-color: var(--app-input-active-border, rgba(97, 95, 255, 0.5));
|
||||
}
|
||||
|
||||
.markdown-content .code-block-wrapper .copy-button:disabled {
|
||||
|
||||
@@ -131,9 +131,15 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Escape to close
|
||||
if (e.key === 'Escape' && onClose) {
|
||||
onClose();
|
||||
// Escape to cancel permission and close (align with CLI/Claude behavior)
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
const rejectOptionId =
|
||||
options.find((o) => o.kind.includes('reject'))?.optionId ||
|
||||
options.find((o) => o.optionId === 'cancel')?.optionId ||
|
||||
'cancel';
|
||||
onResponse(rejectOptionId);
|
||||
if (onClose) onClose();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -207,10 +213,10 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
return (
|
||||
<button
|
||||
key={option.optionId}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] transition-colors duration-150 text-[var(--app-primary-foreground)] hover:bg-[var(--app-input-background)] ${
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] transition-colors duration-150 text-[var(--app-primary-foreground)] hover:bg-[var(--app-list-hover-background)] ${
|
||||
isFocused
|
||||
? 'text-[var(--app-list-active-foreground)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0'
|
||||
: 'hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0'
|
||||
? 'text-[var(--app-list-active-foreground)] bg-[var(--app-list-active-background)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0'
|
||||
: 'hover:bg-[var(--app-button-background)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0'
|
||||
}`}
|
||||
onClick={() => onResponse(option.optionId)}
|
||||
onMouseEnter={() => setFocusedIndex(index)}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Playback and session control icons
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { IconProps } from './types.js';
|
||||
|
||||
/**
|
||||
* Play/resume icon (16x16)
|
||||
* Used for resume session
|
||||
*/
|
||||
export const PlayIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M5.33337 4L10.6667 8L5.33337 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Switch/arrow right icon (16x16)
|
||||
* Used for switch session
|
||||
*/
|
||||
export const SwitchIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M10.6666 4L13.3333 6.66667L10.6666 9.33333" />
|
||||
<path d="M2.66663 6.66667H13.3333" />
|
||||
</svg>
|
||||
);
|
||||
@@ -2,14 +2,9 @@
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Icons index - exports all icon components
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type { IconProps } from './types.js';
|
||||
|
||||
// File icons
|
||||
export { FileIcon, FileListIcon, FolderIcon } from './FileIcons.js';
|
||||
|
||||
// Navigation icons
|
||||
@@ -47,9 +42,6 @@ export {
|
||||
SelectionIcon,
|
||||
} from './StatusIcons.js';
|
||||
|
||||
// Action icons
|
||||
export { PlayIcon, SwitchIcon } from './ActionIcons.js';
|
||||
|
||||
// Special icons
|
||||
export { ThinkingIcon, TerminalIcon } from './SpecialIcons.js';
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from '../shared/utils.js';
|
||||
import { useVSCode } from '../../../hooks/useVSCode.js';
|
||||
import { FileLink } from '../../ui/FileLink.js';
|
||||
import { isDevelopmentMode } from '../../../utils/envUtils.js';
|
||||
import { handleOpenDiff } from '../../../utils/diffUtils.js';
|
||||
|
||||
/**
|
||||
* Calculate diff summary (added/removed lines)
|
||||
@@ -47,26 +47,13 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
|
||||
// Group content by type; memoize to avoid new array identities on every render
|
||||
const { errors, diffs } = useMemo(() => groupContent(content), [content]);
|
||||
// TODO:
|
||||
// console.log('EditToolCall', {
|
||||
// content,
|
||||
// locations,
|
||||
// toolCallId,
|
||||
// errors,
|
||||
// diffs,
|
||||
// });
|
||||
const handleOpenDiff = useCallback(
|
||||
const handleOpenDiffInternal = useCallback(
|
||||
(
|
||||
path: string | undefined,
|
||||
oldText: string | null | undefined,
|
||||
newText: string | undefined,
|
||||
) => {
|
||||
if (path) {
|
||||
vscode.postMessage({
|
||||
type: 'openDiff',
|
||||
data: { path, oldText: oldText || '', newText: newText || '' },
|
||||
});
|
||||
}
|
||||
handleOpenDiff(vscode, path, oldText, newText);
|
||||
},
|
||||
[vscode],
|
||||
);
|
||||
@@ -74,24 +61,9 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
// Extract filename from path
|
||||
const getFileName = (path: string): string => path.split('/').pop() || path;
|
||||
|
||||
// Keep a module-scoped set to ensure auto-open fires once per toolCallId across re-renders
|
||||
// const autoOpenedToolCallIds =
|
||||
// (
|
||||
// globalThis as unknown as {
|
||||
// __qwenAutoOpenedDiffIds?: Set<string>;
|
||||
// }
|
||||
// ).__qwenAutoOpenedDiffIds || new Set<string>();
|
||||
// (
|
||||
// globalThis as unknown as { __qwenAutoOpenedDiffIds: Set<string> }
|
||||
// ).__qwenAutoOpenedDiffIds = autoOpenedToolCallIds;
|
||||
|
||||
// Automatically trigger openDiff when diff content is detected (Claude Code style)
|
||||
// Automatically trigger openDiff when diff content is detected
|
||||
// Only trigger once per tool call by checking toolCallId
|
||||
useEffect(() => {
|
||||
// Guard: already auto-opened for this toolCallId in this webview session
|
||||
// if (autoOpenedToolCallIds.has(toolCallId)) {
|
||||
// return;
|
||||
// }
|
||||
// Only auto-open if there are diffs and we have the required data
|
||||
if (diffs.length > 0) {
|
||||
const firstDiff = diffs[0];
|
||||
@@ -104,8 +76,7 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
) {
|
||||
// Add a small delay to ensure the component is fully rendered
|
||||
const timer = setTimeout(() => {
|
||||
handleOpenDiff(path, firstDiff.oldText, firstDiff.newText);
|
||||
// autoOpenedToolCallIds.add(toolCallId);
|
||||
handleOpenDiffInternal(path, firstDiff.oldText, firstDiff.newText);
|
||||
}, 100);
|
||||
// Proper cleanup function
|
||||
return () => timer && clearTimeout(timer);
|
||||
@@ -142,17 +113,11 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
if (diffs.length > 0) {
|
||||
const firstDiff = diffs[0];
|
||||
const path = firstDiff.path || (locations && locations[0]?.path) || '';
|
||||
// const fileName = path ? getFileName(path) : '';
|
||||
const summary = getDiffSummary(firstDiff.oldText, firstDiff.newText);
|
||||
// No hooks here; define a simple click handler scoped to this block
|
||||
// const openFirstDiff = () =>
|
||||
// handleOpenDiff(path, firstDiff.oldText, firstDiff.newText);
|
||||
|
||||
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
|
||||
return (
|
||||
<div
|
||||
className={`qwen-message message-item relative py-2 select-text cursor-pointer hover:bg-[var(--app-input-background)] toolcall-container toolcall-status-${containerStatus}`}
|
||||
// onClick={openFirstDiff}
|
||||
className={`qwen-message message-item relative py-2 select-text toolcall-container toolcall-status-${containerStatus}`}
|
||||
title="Open diff in VS Code"
|
||||
>
|
||||
{/* IMPORTANT: Always include min-w-0/max-w-full on inner wrappers to prevent overflow. */}
|
||||
@@ -176,13 +141,6 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<span className="flex-shrink-0 w-full">{summary}</span>
|
||||
</div>
|
||||
|
||||
{/* Show toolCallId only in development/debug mode */}
|
||||
{toolCallId && isDevelopmentMode() && (
|
||||
<span className="text-[10px] opacity-30">
|
||||
[{toolCallId.slice(-8)}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { DiffDisplay } from './shared/DiffDisplay.js';
|
||||
import { safeTitle, groupContent } from './shared/utils.js';
|
||||
import { useVSCode } from '../../hooks/useVSCode.js';
|
||||
import { handleOpenDiff } from '../../utils/diffUtils.js';
|
||||
|
||||
/**
|
||||
* Generic tool call component that can display any tool call type
|
||||
@@ -31,19 +32,6 @@ export const GenericToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
// Group content by type
|
||||
const { textOutputs, errors, diffs } = groupContent(content);
|
||||
|
||||
const handleOpenDiff = (
|
||||
path: string | undefined,
|
||||
oldText: string | null | undefined,
|
||||
newText: string | undefined,
|
||||
) => {
|
||||
if (path) {
|
||||
vscode.postMessage({
|
||||
type: 'openDiff',
|
||||
data: { path, oldText: oldText || '', newText: newText || '' },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Error case: show operation + error in card layout
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
@@ -70,7 +58,7 @@ export const GenericToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
oldText={item.oldText}
|
||||
newText={item.newText}
|
||||
onOpenDiff={() =>
|
||||
handleOpenDiff(item.path, item.oldText, item.newText)
|
||||
handleOpenDiff(vscode, item.path, item.oldText, item.newText)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '../shared/utils.js';
|
||||
import { FileLink } from '../../ui/FileLink.js';
|
||||
import { useVSCode } from '../../../hooks/useVSCode.js';
|
||||
import { handleOpenDiff } from '../../../utils/diffUtils.js';
|
||||
|
||||
/**
|
||||
* Specialized component for Read tool calls
|
||||
@@ -30,18 +31,13 @@ export const ReadToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
const { errors, diffs } = useMemo(() => groupContent(content), [content]);
|
||||
|
||||
// Post a message to the extension host to open a VS Code diff tab
|
||||
const handleOpenDiff = useCallback(
|
||||
const handleOpenDiffInternal = useCallback(
|
||||
(
|
||||
path: string | undefined,
|
||||
oldText: string | null | undefined,
|
||||
newText: string | undefined,
|
||||
) => {
|
||||
if (path) {
|
||||
vscode.postMessage({
|
||||
type: 'openDiff',
|
||||
data: { path, oldText: oldText || '', newText: newText || '' },
|
||||
});
|
||||
}
|
||||
handleOpenDiff(vscode, path, oldText, newText);
|
||||
},
|
||||
[vscode],
|
||||
);
|
||||
@@ -59,7 +55,7 @@ export const ReadToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
firstDiff.newText !== undefined
|
||||
) {
|
||||
const timer = setTimeout(() => {
|
||||
handleOpenDiff(path, firstDiff.oldText, firstDiff.newText);
|
||||
handleOpenDiffInternal(path, firstDiff.oldText, firstDiff.newText);
|
||||
}, 100);
|
||||
return () => timer && clearTimeout(timer);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
import type React from 'react';
|
||||
import { FileLink } from '../../ui/FileLink.js';
|
||||
import { isDevelopmentMode } from '../../../utils/envUtils.js';
|
||||
import './LayoutComponents.css';
|
||||
|
||||
/**
|
||||
@@ -64,13 +63,6 @@ export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show toolCallId only in development/debug mode */}
|
||||
{_toolCallId && isDevelopmentMode() && (
|
||||
<span className="text-[10px] opacity-30">
|
||||
[{_toolCallId.slice(-8)}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,8 +9,8 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { BaseMessageHandler } from './BaseMessageHandler.js';
|
||||
import { FileOperations } from '../FileOperations.js';
|
||||
import { getFileName } from '../utils/webviewUtils.js';
|
||||
import { showDiffCommand } from '../../commands/index.js';
|
||||
|
||||
/**
|
||||
* File message handler
|
||||
@@ -316,14 +316,56 @@ export class FileMessageHandler extends BaseMessageHandler {
|
||||
/**
|
||||
* Open file
|
||||
*/
|
||||
private async handleOpenFile(path?: string): Promise<void> {
|
||||
if (!path) {
|
||||
private async handleOpenFile(filePath?: string): Promise<void> {
|
||||
if (!filePath) {
|
||||
console.warn('[FileMessageHandler] No path provided for openFile');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await FileOperations.openFile(path);
|
||||
console.log('[FileOperations] Opening file:', filePath);
|
||||
|
||||
// Parse file path, line number, and column number
|
||||
// Formats: path/to/file.ts, path/to/file.ts:123, path/to/file.ts:123:45
|
||||
const match = filePath.match(/^(.+?)(?::(\d+))?(?::(\d+))?$/);
|
||||
if (!match) {
|
||||
console.warn('[FileOperations] Invalid file path format:', filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const [, path, lineStr, columnStr] = match;
|
||||
const lineNumber = lineStr ? parseInt(lineStr, 10) - 1 : 0; // VS Code uses 0-based line numbers
|
||||
const columnNumber = columnStr ? parseInt(columnStr, 10) - 1 : 0; // VS Code uses 0-based column numbers
|
||||
|
||||
// Convert to absolute path if relative
|
||||
let absolutePath = path;
|
||||
if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:/)) {
|
||||
// Relative path - resolve against workspace
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (workspaceFolder) {
|
||||
absolutePath = vscode.Uri.joinPath(workspaceFolder.uri, path).fsPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Open the document
|
||||
const uri = vscode.Uri.file(absolutePath);
|
||||
const document = await vscode.workspace.openTextDocument(uri);
|
||||
const editor = await vscode.window.showTextDocument(document, {
|
||||
preview: false,
|
||||
preserveFocus: false,
|
||||
});
|
||||
|
||||
// Navigate to line and column if specified
|
||||
if (lineStr) {
|
||||
const position = new vscode.Position(lineNumber, columnNumber);
|
||||
editor.selection = new vscode.Selection(position, position);
|
||||
editor.revealRange(
|
||||
new vscode.Range(position, position),
|
||||
vscode.TextEditorRevealType.InCenter,
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[FileOperations] File opened successfully:', absolutePath);
|
||||
} catch (error) {
|
||||
console.error('[FileMessageHandler] Failed to open file:', error);
|
||||
vscode.window.showErrorMessage(`Failed to open file: ${error}`);
|
||||
@@ -342,7 +384,7 @@ export class FileMessageHandler extends BaseMessageHandler {
|
||||
}
|
||||
|
||||
try {
|
||||
await vscode.commands.executeCommand('qwenCode.showDiff', {
|
||||
await vscode.commands.executeCommand(showDiffCommand, {
|
||||
path: (data.path as string) || '',
|
||||
oldText: (data.oldText as string) || '',
|
||||
newText: (data.newText as string) || '',
|
||||
|
||||
30
packages/vscode-ide-companion/src/webview/utils/diffUtils.ts
Normal file
30
packages/vscode-ide-companion/src/webview/utils/diffUtils.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Shared utilities for handling diff operations in the webview
|
||||
*/
|
||||
|
||||
import type { WebviewApi } from 'vscode-webview';
|
||||
|
||||
/**
|
||||
* Handle opening a diff view for a file
|
||||
* @param vscode Webview API instance
|
||||
* @param path File path
|
||||
* @param oldText Original content (left side)
|
||||
* @param newText New content (right side)
|
||||
*/
|
||||
export const handleOpenDiff = (
|
||||
vscode: WebviewApi<unknown>,
|
||||
path: string | undefined,
|
||||
oldText: string | null | undefined,
|
||||
newText: string | undefined,
|
||||
): void => {
|
||||
if (path) {
|
||||
vscode.postMessage({
|
||||
type: 'openDiff',
|
||||
data: { path, oldText: oldText || '', newText: newText || '' },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export function isDevelopmentMode(): boolean {
|
||||
// TODO: 调试用
|
||||
// return false;
|
||||
return (
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
process.env.DEBUG === 'true' ||
|
||||
process.env.NODE_ENV !== 'production'
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user