mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
feat(vscode-ide-companion): 增加代码编辑功能和文件操作支持
- 实现了与 Claude Code 类似的代码编辑功能 - 添加了文件打开、保存等操作的支持 - 优化了消息显示,增加了代码高亮和文件路径点击功能 - 改进了用户界面,增加了编辑模式切换和思考模式功能
This commit is contained in:
@@ -194,6 +194,20 @@ export class WebViewProvider {
|
|||||||
this.disposables,
|
this.disposables,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Listen for active editor changes and notify WebView
|
||||||
|
const editorChangeDisposable = vscode.window.onDidChangeActiveTextEditor(
|
||||||
|
(editor) => {
|
||||||
|
const fileName = editor?.document.uri.fsPath
|
||||||
|
? this.getFileName(editor.document.uri.fsPath)
|
||||||
|
: null;
|
||||||
|
this.sendMessageToWebView({
|
||||||
|
type: 'activeEditorChanged',
|
||||||
|
data: { fileName },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.disposables.push(editorChangeDisposable);
|
||||||
|
|
||||||
// Initialize agent connection only once
|
// Initialize agent connection only once
|
||||||
if (!this.agentInitialized) {
|
if (!this.agentInitialized) {
|
||||||
await this.initializeAgentConnection();
|
await this.initializeAgentConnection();
|
||||||
@@ -481,7 +495,7 @@ export class WebViewProvider {
|
|||||||
|
|
||||||
private async handleWebViewMessage(message: {
|
private async handleWebViewMessage(message: {
|
||||||
type: string;
|
type: string;
|
||||||
data?: { text?: string; id?: string; sessionId?: string };
|
data?: { text?: string; id?: string; sessionId?: string; path?: string };
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
console.log('[WebViewProvider] Received message from webview:', message);
|
console.log('[WebViewProvider] Received message from webview:', message);
|
||||||
const self = this as {
|
const self = this as {
|
||||||
@@ -525,6 +539,19 @@ export class WebViewProvider {
|
|||||||
await this.handleGetQwenSessions();
|
await this.handleGetQwenSessions();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'getActiveEditor': {
|
||||||
|
// 发送当前激活编辑器的文件名给 WebView
|
||||||
|
const editor = vscode.window.activeTextEditor;
|
||||||
|
const fileName = editor?.document.uri.fsPath
|
||||||
|
? this.getFileName(editor.document.uri.fsPath)
|
||||||
|
: null;
|
||||||
|
this.sendMessageToWebView({
|
||||||
|
type: 'activeEditorChanged',
|
||||||
|
data: { fileName },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'switchQwenSession':
|
case 'switchQwenSession':
|
||||||
await this.handleSwitchQwenSession(message.data?.sessionId || '');
|
await this.handleSwitchQwenSession(message.data?.sessionId || '');
|
||||||
break;
|
break;
|
||||||
@@ -539,6 +566,16 @@ export class WebViewProvider {
|
|||||||
await this.handleCancelPrompt();
|
await this.handleCancelPrompt();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'openFile':
|
||||||
|
await this.handleOpenFile(message.data?.path);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'openDiff':
|
||||||
|
await this.handleOpenDiff(
|
||||||
|
message.data as { path?: string; oldText?: string; newText?: string },
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.warn('[WebViewProvider] Unknown message type:', message.type);
|
console.warn('[WebViewProvider] Unknown message type:', message.type);
|
||||||
break;
|
break;
|
||||||
@@ -849,10 +886,163 @@ export class WebViewProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle open file request from WebView
|
||||||
|
* Opens a file in VS Code editor, optionally at a specific line
|
||||||
|
*/
|
||||||
|
private async handleOpenFile(filePath?: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!filePath) {
|
||||||
|
console.warn('[WebViewProvider] No file path provided');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[WebViewProvider] Opening file:', filePath);
|
||||||
|
|
||||||
|
// Parse file path and line number (format: path/to/file.ts:123)
|
||||||
|
const match = filePath.match(/^(.+?)(?::(\d+))?$/);
|
||||||
|
if (!match) {
|
||||||
|
console.warn('[WebViewProvider] Invalid file path format:', filePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, path, lineStr] = match;
|
||||||
|
const lineNumber = lineStr ? parseInt(lineStr, 10) - 1 : 0; // VS Code uses 0-based line 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 if specified
|
||||||
|
if (lineStr) {
|
||||||
|
const position = new vscode.Position(lineNumber, 0);
|
||||||
|
editor.selection = new vscode.Selection(position, position);
|
||||||
|
editor.revealRange(
|
||||||
|
new vscode.Range(position, position),
|
||||||
|
vscode.TextEditorRevealType.InCenter,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[WebViewProvider] File opened successfully:', absolutePath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WebViewProvider] Failed to open file:', error);
|
||||||
|
vscode.window.showErrorMessage(`Failed to open file: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle open diff request from WebView
|
||||||
|
* Opens VS Code's diff viewer to compare old and new file contents
|
||||||
|
*/
|
||||||
|
private async handleOpenDiff(data?: {
|
||||||
|
path?: string;
|
||||||
|
oldText?: string;
|
||||||
|
newText?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!data || !data.path) {
|
||||||
|
console.warn('[WebViewProvider] No file path provided for diff');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { path, oldText = '', newText = '' } = data;
|
||||||
|
console.log('[WebViewProvider] 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 = this.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)`,
|
||||||
|
{
|
||||||
|
preview: false,
|
||||||
|
preserveFocus: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('[WebViewProvider] Diff opened successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WebViewProvider] Failed to open diff:', error);
|
||||||
|
vscode.window.showErrorMessage(`Failed to open diff: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sendMessageToWebView(message: unknown): void {
|
private sendMessageToWebView(message: unknown): void {
|
||||||
this.panel?.webview.postMessage(message);
|
this.panel?.webview.postMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从完整路径中提取文件名
|
||||||
|
* @param fsPath 文件的完整路径
|
||||||
|
* @returns 文件名(不含路径)
|
||||||
|
*/
|
||||||
|
private getFileName(fsPath: string): string {
|
||||||
|
// 使用 path.basename 的逻辑:找到最后一个路径分隔符后的部分
|
||||||
|
const lastSlash = Math.max(
|
||||||
|
fsPath.lastIndexOf('/'),
|
||||||
|
fsPath.lastIndexOf('\\'),
|
||||||
|
);
|
||||||
|
return lastSlash >= 0 ? fsPath.substring(lastSlash + 1) : fsPath;
|
||||||
|
}
|
||||||
|
|
||||||
private getWebviewContent(): string {
|
private getWebviewContent(): string {
|
||||||
const scriptUri = this.panel!.webview.asWebviewUri(
|
const scriptUri = this.panel!.webview.asWebviewUri(
|
||||||
vscode.Uri.joinPath(this.extensionUri, 'dist', 'webview.js'),
|
vscode.Uri.joinPath(this.extensionUri, 'dist', 'webview.js'),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
--app-qwen-clay-button-orange: #4f46e5;
|
--app-qwen-clay-button-orange: #4f46e5;
|
||||||
--app-qwen-ivory: #f5f5ff;
|
--app-qwen-ivory: #f5f5ff;
|
||||||
--app-qwen-slate: #141420;
|
--app-qwen-slate: #141420;
|
||||||
|
--app-qwen-green: #6bcf7f;
|
||||||
|
|
||||||
/* Spacing */
|
/* Spacing */
|
||||||
--app-spacing-small: 4px;
|
--app-spacing-small: 4px;
|
||||||
@@ -45,6 +46,11 @@
|
|||||||
--app-input-placeholder-foreground: var(--vscode-input-placeholderForeground);
|
--app-input-placeholder-foreground: var(--vscode-input-placeholderForeground);
|
||||||
--app-input-secondary-background: var(--vscode-menu-background);
|
--app-input-secondary-background: var(--vscode-menu-background);
|
||||||
|
|
||||||
|
/* Code Highlighting */
|
||||||
|
--app-code-background: var(--vscode-textCodeBlock-background, rgba(0, 0, 0, 0.05));
|
||||||
|
--app-link-foreground: var(--vscode-textLink-foreground, #007ACC);
|
||||||
|
--app-link-active-foreground: var(--vscode-textLink-activeForeground, #005A9E);
|
||||||
|
|
||||||
/* List Styles */
|
/* List Styles */
|
||||||
--app-list-hover-background: var(--vscode-list-hoverBackground);
|
--app-list-hover-background: var(--vscode-list-hoverBackground);
|
||||||
--app-list-active-background: var(--vscode-list-activeSelectionBackground);
|
--app-list-active-background: var(--vscode-list-activeSelectionBackground);
|
||||||
@@ -192,6 +198,11 @@ button {
|
|||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
color: var(--app-primary-foreground);
|
color: var(--app-primary-foreground);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-session-header-button:hover,
|
.new-session-header-button:hover,
|
||||||
@@ -607,52 +618,72 @@ button {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form (.u) - The actual input form with border and shadow */
|
/* Input Form Container - matches Claude Code style */
|
||||||
.input-form {
|
.input-form {
|
||||||
background: var(--app-input-background);
|
background: var(--app-input-secondary-background, var(--app-input-background));
|
||||||
border: 1px solid var(--app-input-border);
|
border: 1px solid var(--app-input-border);
|
||||||
border-radius: var(--corner-radius-large);
|
border-radius: var(--corner-radius-large);
|
||||||
color: var(--app-input-foreground);
|
color: var(--app-input-foreground);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-width: 680px;
|
// max-width: 680px;
|
||||||
margin: 0 auto;
|
// margin: 0 auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Banner/Warning area (.Wr) */
|
/* Inner background layer - creates depth effect */
|
||||||
|
.input-form-background {
|
||||||
|
background: var(--app-input-background);
|
||||||
|
position: absolute;
|
||||||
|
border-radius: var(--corner-radius-large);
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-form:focus-within {
|
||||||
|
border-color: var(--app-qwen-orange);
|
||||||
|
box-shadow: 0 0 0 1px var(--app-qwen-orange), 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Banner area - for warnings/messages */
|
||||||
.input-banner {
|
.input-banner {
|
||||||
/* Empty for now, can be used for warnings/banners */
|
/* Empty for now, can be used for warnings/banners */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Input wrapper (.fo) */
|
/* Input wrapper - contains the contenteditable field */
|
||||||
.input-wrapper {
|
.input-wrapper {
|
||||||
/* padding: 12px 12px 0; */
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Contenteditable input field (.d) */
|
/* Contenteditable input field - matches Claude Code */
|
||||||
.input-field-editable {
|
.input-field-editable {
|
||||||
width: 100%;
|
padding: 10px 14px;
|
||||||
min-height: 40px;
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
align-self: stretch;
|
||||||
|
user-select: text;
|
||||||
|
min-height: 1.5em;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
padding: 8px 10px;
|
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: var(--app-input-foreground);
|
color: var(--app-input-foreground);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
font-size: var(--vscode-chat-font-size, 13px);
|
font-size: var(--vscode-chat-font-size, 13px);
|
||||||
font-family: var(--vscode-chat-font-family);
|
|
||||||
outline: none;
|
|
||||||
line-height: 1.5;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-field-editable:focus {
|
.input-field-editable:focus {
|
||||||
/* No border change needed since we don't have a border */
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-field-editable:empty:before {
|
.input-field-editable:empty:before {
|
||||||
@@ -662,12 +693,22 @@ button {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Actions row (.ri) */
|
.input-field-editable:disabled,
|
||||||
|
.input-field-editable[contenteditable='false'] {
|
||||||
|
color: #999;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions row - matches Claude Code */
|
||||||
.input-actions {
|
.input-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding: 5px;
|
||||||
|
color: var(--app-secondary-foreground);
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
/* padding: 8px 12px 12px; */
|
min-width: 0;
|
||||||
|
border-top: 0.5px solid var(--app-input-border);
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Edit mode button (.l) */
|
/* Edit mode button (.l) */
|
||||||
@@ -682,8 +723,8 @@ button {
|
|||||||
border-radius: var(--corner-radius-small);
|
border-radius: var(--corner-radius-small);
|
||||||
color: var(--app-primary-foreground);
|
color: var(--app-primary-foreground);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
// font-weight: 500;
|
||||||
transition: background-color 0.15s;
|
transition: background-color 0.15s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -729,11 +770,58 @@ button {
|
|||||||
color: var(--app-primary-foreground);
|
color: var(--app-primary-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-icon-button.active {
|
||||||
|
background-color: var(--app-qwen-clay-button-orange);
|
||||||
|
color: var(--app-qwen-ivory);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon-button.active svg {
|
||||||
|
stroke: var(--app-qwen-ivory);
|
||||||
|
fill: var(--app-qwen-ivory);
|
||||||
|
}
|
||||||
|
|
||||||
.action-icon-button svg {
|
.action-icon-button svg {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Spacer to push file indicator to the right */
|
||||||
|
.input-actions-spacer {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active file indicator - shows current file selection (.vo in Claude Code) */
|
||||||
|
.active-file-indicator {
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--app-primary-foreground);
|
||||||
|
opacity: 0.6;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 2px 4px;
|
||||||
|
cursor: default;
|
||||||
|
text-align: right;
|
||||||
|
max-width: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-file-indicator:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide file indicator on very small screens */
|
||||||
|
@media screen and (max-width: 330px) {
|
||||||
|
.active-file-indicator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Send button (.r) */
|
/* Send button (.r) */
|
||||||
.send-button-icon {
|
.send-button-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -742,18 +830,18 @@ button {
|
|||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: var(--app-qwen-clay-button-orange);
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: var(--corner-radius-small);
|
border-radius: var(--corner-radius-small);
|
||||||
color: var(--app-primary-foreground);
|
color: var(--app-qwen-ivory);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.15s;
|
transition: background-color 0.15s, filter 0.15s;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-button-icon:hover:not(:disabled) {
|
.send-button-icon:hover:not(:disabled) {
|
||||||
background-color: var(--app-ghost-button-hover-background);
|
filter: brightness(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-button-icon:disabled {
|
.send-button-icon:disabled {
|
||||||
@@ -862,6 +950,80 @@ button {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
Diff Display Styles
|
||||||
|
=========================== */
|
||||||
|
.diff-display-container {
|
||||||
|
margin: 8px 0;
|
||||||
|
border: 1px solid var(--app-input-border);
|
||||||
|
border-radius: var(--corner-radius-medium);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--app-input-secondary-background);
|
||||||
|
border-bottom: 1px solid var(--app-input-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-file-path {
|
||||||
|
font-family: var(--app-monospace-font-family);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--app-primary-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-diff-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--app-input-border);
|
||||||
|
border-radius: var(--corner-radius-small);
|
||||||
|
color: var(--app-primary-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-diff-button:hover {
|
||||||
|
background: var(--app-ghost-button-hover-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-diff-button svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-section {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-label {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--app-primary-background);
|
||||||
|
border-bottom: 1px solid var(--app-input-border);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-secondary-foreground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-section .code-block {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
margin: 0;
|
||||||
|
max-height: none; /* Remove height limit for diffs */
|
||||||
|
overflow-y: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-section .code-content {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===========================
|
/* ===========================
|
||||||
Permission Request Card Styles
|
Permission Request Card Styles
|
||||||
=========================== */
|
=========================== */
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
import { PermissionDrawer } from './components/PermissionDrawer.js';
|
import { PermissionDrawer } from './components/PermissionDrawer.js';
|
||||||
import { ToolCall, type ToolCallData } from './components/ToolCall.js';
|
import { ToolCall, type ToolCallData } from './components/ToolCall.js';
|
||||||
import { EmptyState } from './components/EmptyState.js';
|
import { EmptyState } from './components/EmptyState.js';
|
||||||
|
import { PlanDisplay, type PlanEntry } from './components/PlanDisplay.js';
|
||||||
|
import { MessageContent } from './components/MessageContent.js';
|
||||||
|
|
||||||
interface ToolCallUpdate {
|
interface ToolCallUpdate {
|
||||||
type: 'tool_call' | 'tool_call_update';
|
type: 'tool_call' | 'tool_call_update';
|
||||||
@@ -185,6 +187,8 @@ const getRandomLoadingMessage = () =>
|
|||||||
Math.floor(Math.random() * WITTY_LOADING_PHRASES.length)
|
Math.floor(Math.random() * WITTY_LOADING_PHRASES.length)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type EditMode = 'ask' | 'auto' | 'plan';
|
||||||
|
|
||||||
export const App: React.FC = () => {
|
export const App: React.FC = () => {
|
||||||
const vscode = useVSCode();
|
const vscode = useVSCode();
|
||||||
const [messages, setMessages] = useState<TextMessage[]>([]);
|
const [messages, setMessages] = useState<TextMessage[]>([]);
|
||||||
@@ -208,10 +212,14 @@ export const App: React.FC = () => {
|
|||||||
const [toolCalls, setToolCalls] = useState<Map<string, ToolCallData>>(
|
const [toolCalls, setToolCalls] = useState<Map<string, ToolCallData>>(
|
||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
|
const [planEntries, setPlanEntries] = useState<PlanEntry[]>([]);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputFieldRef = useRef<HTMLDivElement>(null);
|
const inputFieldRef = useRef<HTMLDivElement>(null);
|
||||||
const [showBanner, setShowBanner] = useState(true);
|
const [showBanner, setShowBanner] = useState(true);
|
||||||
const currentStreamContentRef = useRef<string>('');
|
const currentStreamContentRef = useRef<string>('');
|
||||||
|
const [editMode, setEditMode] = useState<EditMode>('ask');
|
||||||
|
const [thinkingEnabled, setThinkingEnabled] = useState(false);
|
||||||
|
const [activeFileName, setActiveFileName] = useState<string | null>(null);
|
||||||
|
|
||||||
const handlePermissionRequest = React.useCallback(
|
const handlePermissionRequest = React.useCallback(
|
||||||
(request: {
|
(request: {
|
||||||
@@ -376,6 +384,14 @@ export const App: React.FC = () => {
|
|||||||
handlePermissionRequest(message.data);
|
handlePermissionRequest(message.data);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'plan':
|
||||||
|
// Update plan entries
|
||||||
|
console.log('[App] Plan received:', message.data);
|
||||||
|
if (message.data.entries && Array.isArray(message.data.entries)) {
|
||||||
|
setPlanEntries(message.data.entries as PlanEntry[]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case 'toolCall':
|
case 'toolCall':
|
||||||
case 'toolCallUpdate':
|
case 'toolCallUpdate':
|
||||||
// Handle tool call updates
|
// Handle tool call updates
|
||||||
@@ -448,6 +464,7 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
setCurrentStreamContent('');
|
setCurrentStreamContent('');
|
||||||
setToolCalls(new Map());
|
setToolCalls(new Map());
|
||||||
|
setPlanEntries([]); // Clear plan entries when switching sessions
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'conversationCleared':
|
case 'conversationCleared':
|
||||||
@@ -456,6 +473,13 @@ export const App: React.FC = () => {
|
|||||||
setToolCalls(new Map());
|
setToolCalls(new Map());
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'activeEditorChanged': {
|
||||||
|
// 从扩展接收当前激活编辑器的文件名
|
||||||
|
const fileName = message.data?.fileName as string | null;
|
||||||
|
setActiveFileName(fileName);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -475,6 +499,90 @@ export const App: React.FC = () => {
|
|||||||
vscode.postMessage({ type: 'getQwenSessions', data: {} });
|
vscode.postMessage({ type: 'getQwenSessions', data: {} });
|
||||||
}, [vscode]);
|
}, [vscode]);
|
||||||
|
|
||||||
|
// Request current active editor on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
vscode.postMessage({ type: 'getActiveEditor', data: {} });
|
||||||
|
}, [vscode]);
|
||||||
|
|
||||||
|
// Toggle edit mode: ask → auto → plan → ask
|
||||||
|
const handleToggleEditMode = () => {
|
||||||
|
setEditMode((prev) => {
|
||||||
|
if (prev === 'ask') {
|
||||||
|
return 'auto';
|
||||||
|
}
|
||||||
|
if (prev === 'auto') {
|
||||||
|
return 'plan';
|
||||||
|
}
|
||||||
|
return 'ask';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle thinking on/off
|
||||||
|
const handleToggleThinking = () => {
|
||||||
|
setThinkingEnabled((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get edit mode display info
|
||||||
|
const getEditModeInfo = () => {
|
||||||
|
switch (editMode) {
|
||||||
|
case 'ask':
|
||||||
|
return {
|
||||||
|
text: 'Ask before edits',
|
||||||
|
title: 'Qwen will ask before each edit. Click to switch modes.',
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M11.013 2.513a1.75 1.75 0 0 1 2.475 2.474L6.226 12.25a2.751 2.751 0 0 1-.892.596l-2.047.848a.75.75 0 0 1-.98-.98l.848-2.047a2.75 2.75 0 0 1 .596-.892l7.262-7.261Z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case 'auto':
|
||||||
|
return {
|
||||||
|
text: 'Edit automatically',
|
||||||
|
title: 'Qwen will edit files automatically. Click to switch modes.',
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M2.53 3.956A1 1 0 0 0 1 4.804v6.392a1 1 0 0 0 1.53.848l5.113-3.196c.16-.1.279-.233.357-.383v2.73a1 1 0 0 0 1.53.849l5.113-3.196a1 1 0 0 0 0-1.696L9.53 3.956A1 1 0 0 0 8 4.804v2.731a.992.992 0 0 0-.357-.383L2.53 3.956Z"></path>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
case 'plan':
|
||||||
|
return {
|
||||||
|
text: 'Plan mode',
|
||||||
|
title: 'Qwen will plan before executing. Click to switch modes.',
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M4.5 2a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-11a.5.5 0 0 0-.5-.5h-1ZM10.5 2a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-11a.5.5 0 0 0-.5-.5h-1Z"></path>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
text: 'Unknown mode',
|
||||||
|
title: 'Unknown edit mode',
|
||||||
|
icon: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -511,6 +619,8 @@ export const App: React.FC = () => {
|
|||||||
// Clear messages in UI
|
// Clear messages in UI
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setCurrentStreamContent('');
|
setCurrentStreamContent('');
|
||||||
|
setPlanEntries([]); // Clear plan entries
|
||||||
|
setToolCalls(new Map()); // Clear tool calls
|
||||||
};
|
};
|
||||||
|
|
||||||
// Time ago formatter (matching Claude Code)
|
// Time ago formatter (matching Claude Code)
|
||||||
@@ -624,7 +734,11 @@ export const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Check if there are any messages or active content
|
// Check if there are any messages or active content
|
||||||
const hasContent = messages.length > 0 || isStreaming || toolCalls.size > 0;
|
const hasContent =
|
||||||
|
messages.length > 0 ||
|
||||||
|
isStreaming ||
|
||||||
|
toolCalls.size > 0 ||
|
||||||
|
planEntries.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-container">
|
<div className="chat-container">
|
||||||
@@ -792,7 +906,15 @@ export const App: React.FC = () => {
|
|||||||
<span className="thinking-dot"></span>
|
<span className="thinking-dot"></span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{msg.content}
|
<MessageContent
|
||||||
|
content={msg.content}
|
||||||
|
onFileClick={(path) => {
|
||||||
|
vscode.postMessage({
|
||||||
|
type: 'openFile',
|
||||||
|
data: { path },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="message-timestamp">
|
<div className="message-timestamp">
|
||||||
{new Date(msg.timestamp).toLocaleTimeString()}
|
{new Date(msg.timestamp).toLocaleTimeString()}
|
||||||
@@ -806,6 +928,9 @@ export const App: React.FC = () => {
|
|||||||
<ToolCall key={toolCall.toolCallId} toolCall={toolCall} />
|
<ToolCall key={toolCall.toolCallId} toolCall={toolCall} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Plan Display - shows task list when available */}
|
||||||
|
{planEntries.length > 0 && <PlanDisplay entries={planEntries} />}
|
||||||
|
|
||||||
{/* Loading/Waiting Message - in message list */}
|
{/* Loading/Waiting Message - in message list */}
|
||||||
{isWaitingForResponse && loadingMessage && (
|
{isWaitingForResponse && loadingMessage && (
|
||||||
<div className="message assistant waiting-message">
|
<div className="message assistant waiting-message">
|
||||||
@@ -822,7 +947,17 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
{isStreaming && currentStreamContent && (
|
{isStreaming && currentStreamContent && (
|
||||||
<div className="message assistant streaming">
|
<div className="message assistant streaming">
|
||||||
<div className="message-content">{currentStreamContent}</div>
|
<div className="message-content">
|
||||||
|
<MessageContent
|
||||||
|
content={currentStreamContent}
|
||||||
|
onFileClick={(path) => {
|
||||||
|
vscode.postMessage({
|
||||||
|
type: 'openFile',
|
||||||
|
data: { path },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="streaming-indicator">●</div>
|
<div className="streaming-indicator">●</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -884,6 +1019,7 @@ export const App: React.FC = () => {
|
|||||||
<div className="input-form-container">
|
<div className="input-form-container">
|
||||||
<div className="input-form-wrapper">
|
<div className="input-form-wrapper">
|
||||||
<form className="input-form" onSubmit={handleSubmit}>
|
<form className="input-form" onSubmit={handleSubmit}>
|
||||||
|
<div className="input-form-background"></div>
|
||||||
<div className="input-banner"></div>
|
<div className="input-banner"></div>
|
||||||
<div className="input-wrapper">
|
<div className="input-wrapper">
|
||||||
<div
|
<div
|
||||||
@@ -911,27 +1047,18 @@ export const App: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="action-button edit-mode-button"
|
className="action-button edit-mode-button"
|
||||||
title="Qwen will ask before each edit. Click to switch modes."
|
title={getEditModeInfo().title}
|
||||||
|
onClick={handleToggleEditMode}
|
||||||
>
|
>
|
||||||
<svg
|
{getEditModeInfo().icon}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<span>{getEditModeInfo().text}</span>
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M11.013 2.513a1.75 1.75 0 0 1 2.475 2.474L6.226 12.25a2.751 2.751 0 0 1-.892.596l-2.047.848a.75.75 0 0 1-.98-.98l.848-2.047a2.75 2.75 0 0 1 .596-.892l7.262-7.261Z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
<span>Ask before edits</span>
|
|
||||||
</button>
|
</button>
|
||||||
<div className="action-divider"></div>
|
<div className="action-divider"></div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="action-icon-button thinking-button"
|
className={`action-icon-button thinking-button ${thinkingEnabled ? 'active' : ''}`}
|
||||||
title="Thinking off"
|
title={thinkingEnabled ? 'Thinking on' : 'Thinking off'}
|
||||||
|
onClick={handleToggleThinking}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
@@ -968,6 +1095,15 @@ export const App: React.FC = () => {
|
|||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<div className="input-actions-spacer"></div>
|
||||||
|
{activeFileName && (
|
||||||
|
<span
|
||||||
|
className="active-file-indicator"
|
||||||
|
title={`Showing Qwen Code your current file selection: ${activeFileName}`}
|
||||||
|
>
|
||||||
|
{activeFileName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="send-button-icon"
|
className="send-button-icon"
|
||||||
|
|||||||
@@ -369,6 +369,64 @@
|
|||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Code content wrapper for better scrolling */
|
||||||
|
.code-content {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Diff display container */
|
||||||
|
.diff-display-container {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px solid var(--app-primary-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-file-path {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--app-secondary-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-diff-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--app-ghost-button-hover-background);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--app-secondary-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-diff-button:hover {
|
||||||
|
background: var(--vscode-button-hoverBackground);
|
||||||
|
color: var(--vscode-button-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--app-secondary-foreground);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Status indicators for tool calls */
|
/* Status indicators for tool calls */
|
||||||
.tool-call-status-indicator {
|
.tool-call-status-indicator {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*
|
||||||
|
* MessageContent styles
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Code block styles */
|
||||||
|
.message-code-block {
|
||||||
|
background-color: var(--app-code-background, rgba(0, 0, 0, 0.05));
|
||||||
|
border: 1px solid var(--app-primary-border-color);
|
||||||
|
border-radius: var(--corner-radius-small, 4px);
|
||||||
|
padding: 12px;
|
||||||
|
margin: 8px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: var(--app-monospace-font-family, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-code-block code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--app-primary-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline code styles */
|
||||||
|
.message-inline-code {
|
||||||
|
background-color: var(--app-code-background, rgba(0, 0, 0, 0.05));
|
||||||
|
border: 1px solid var(--app-primary-border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-family: var(--app-monospace-font-family, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace);
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--app-primary-foreground);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File path link styles */
|
||||||
|
.message-file-path {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font-family: var(--app-monospace-font-family, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace);
|
||||||
|
font-size: 0.95em;
|
||||||
|
color: var(--app-link-foreground, #007ACC);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-file-path:hover {
|
||||||
|
color: var(--app-link-active-foreground, #005A9E);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-file-path:active {
|
||||||
|
color: var(--app-link-active-foreground, #005A9E);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*
|
||||||
|
* MessageContent component - renders message with code highlighting and clickable file paths
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import './MessageContent.css';
|
||||||
|
|
||||||
|
interface MessageContentProps {
|
||||||
|
content: string;
|
||||||
|
onFileClick?: (filePath: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regular expressions for parsing content
|
||||||
|
*/
|
||||||
|
const FILE_PATH_REGEX =
|
||||||
|
/([a-zA-Z]:)?([/\\][\w\-. ]+)+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)/gi;
|
||||||
|
const CODE_BLOCK_REGEX = /```(\w+)?\n([\s\S]*?)```/g;
|
||||||
|
const INLINE_CODE_REGEX = /`([^`]+)`/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses message content and renders with syntax highlighting and clickable file paths
|
||||||
|
*/
|
||||||
|
export const MessageContent: React.FC<MessageContentProps> = ({
|
||||||
|
content,
|
||||||
|
onFileClick,
|
||||||
|
}) => {
|
||||||
|
/**
|
||||||
|
* Parse and render content with special handling for code blocks, inline code, and file paths
|
||||||
|
*/
|
||||||
|
const renderContent = () => {
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let matchIndex = 0;
|
||||||
|
|
||||||
|
// First, handle code blocks
|
||||||
|
const codeBlockMatches = Array.from(content.matchAll(CODE_BLOCK_REGEX));
|
||||||
|
|
||||||
|
codeBlockMatches.forEach((match) => {
|
||||||
|
const [fullMatch, language, code] = match;
|
||||||
|
const startIndex = match.index!;
|
||||||
|
|
||||||
|
// Add text before code block
|
||||||
|
if (startIndex > lastIndex) {
|
||||||
|
const textBefore = content.slice(lastIndex, startIndex);
|
||||||
|
parts.push(...renderTextWithInlineCodeAndPaths(textBefore, matchIndex));
|
||||||
|
matchIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add code block
|
||||||
|
parts.push(
|
||||||
|
<pre key={`code-${matchIndex}`} className="message-code-block">
|
||||||
|
<code className={`language-${language || 'plaintext'}`}>{code}</code>
|
||||||
|
</pre>,
|
||||||
|
);
|
||||||
|
matchIndex++;
|
||||||
|
|
||||||
|
lastIndex = startIndex + fullMatch.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add remaining text
|
||||||
|
if (lastIndex < content.length) {
|
||||||
|
const remainingText = content.slice(lastIndex);
|
||||||
|
parts.push(
|
||||||
|
...renderTextWithInlineCodeAndPaths(remainingText, matchIndex),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts : content;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render text with inline code and file paths
|
||||||
|
*/
|
||||||
|
const renderTextWithInlineCodeAndPaths = (
|
||||||
|
text: string,
|
||||||
|
startIndex: number,
|
||||||
|
) => {
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let matchIndex = startIndex;
|
||||||
|
|
||||||
|
// Split by inline code first
|
||||||
|
const inlineCodeMatches = Array.from(text.matchAll(INLINE_CODE_REGEX));
|
||||||
|
|
||||||
|
if (inlineCodeMatches.length === 0) {
|
||||||
|
// No inline code, just check for file paths
|
||||||
|
return renderTextWithFilePaths(text, matchIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
inlineCodeMatches.forEach((match) => {
|
||||||
|
const [fullMatch, code] = match;
|
||||||
|
const startIdx = match.index!;
|
||||||
|
|
||||||
|
// Add text before inline code (may contain file paths)
|
||||||
|
if (startIdx > lastIndex) {
|
||||||
|
parts.push(
|
||||||
|
...renderTextWithFilePaths(
|
||||||
|
text.slice(lastIndex, startIdx),
|
||||||
|
matchIndex,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
matchIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add inline code
|
||||||
|
parts.push(
|
||||||
|
<code key={`inline-${matchIndex}`} className="message-inline-code">
|
||||||
|
{code}
|
||||||
|
</code>,
|
||||||
|
);
|
||||||
|
matchIndex++;
|
||||||
|
|
||||||
|
lastIndex = startIdx + fullMatch.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add remaining text
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(...renderTextWithFilePaths(text.slice(lastIndex), matchIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts : [text];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render text with file paths
|
||||||
|
*/
|
||||||
|
const renderTextWithFilePaths = (text: string, startIndex: number) => {
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let matchIndex = startIndex;
|
||||||
|
|
||||||
|
const filePathMatches = Array.from(text.matchAll(FILE_PATH_REGEX));
|
||||||
|
|
||||||
|
filePathMatches.forEach((match) => {
|
||||||
|
const fullMatch = match[0];
|
||||||
|
const startIdx = match.index!;
|
||||||
|
|
||||||
|
// Add text before file path
|
||||||
|
if (startIdx > lastIndex) {
|
||||||
|
parts.push(text.slice(lastIndex, startIdx));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add file path link
|
||||||
|
parts.push(
|
||||||
|
<button
|
||||||
|
key={`path-${matchIndex}`}
|
||||||
|
className="message-file-path"
|
||||||
|
onClick={() => onFileClick?.(fullMatch)}
|
||||||
|
title={`Open ${fullMatch}`}
|
||||||
|
>
|
||||||
|
{fullMatch}
|
||||||
|
</button>,
|
||||||
|
);
|
||||||
|
|
||||||
|
matchIndex++;
|
||||||
|
lastIndex = startIdx + fullMatch.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add remaining text
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(text.slice(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts : [text];
|
||||||
|
};
|
||||||
|
|
||||||
|
return <>{renderContent()}</>;
|
||||||
|
};
|
||||||
@@ -39,7 +39,9 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
|||||||
// Close drawer on Escape key
|
// Close drawer on Escape key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Close on Escape
|
// Close on Escape
|
||||||
if (e.key === 'Escape' && onClose) {
|
if (e.key === 'Escape' && onClose) {
|
||||||
|
|||||||
@@ -6,14 +6,15 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PlanDisplay.css - Styles for the task plan component
|
* PlanDisplay.css - Styles for the task plan component
|
||||||
|
* Simple, clean timeline-style design
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.plan-display {
|
.plan-display {
|
||||||
background-color: rgba(100, 150, 255, 0.05);
|
background: var(--app-secondary-background);
|
||||||
border: 1px solid rgba(100, 150, 255, 0.3);
|
border: 1px solid var(--app-transparent-inner-border);
|
||||||
border-radius: 8px;
|
border-radius: var(--corner-radius-medium);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin: 8px 0;
|
margin: 12px 0;
|
||||||
animation: fadeIn 0.3s ease-in;
|
animation: fadeIn 0.3s ease-in;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,92 +22,111 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 16px;
|
||||||
padding-bottom: 8px;
|
color: var(--app-primary-foreground);
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-icon {
|
.plan-header-icon {
|
||||||
font-size: 18px;
|
flex-shrink: 0;
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-title {
|
.plan-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: rgba(150, 180, 255, 1);
|
color: var(--app-primary-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-entries {
|
.plan-entries {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 0;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-entry {
|
.plan-entry {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vertical line on the left */
|
||||||
|
.plan-entry-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 7px;
|
||||||
|
top: 24px;
|
||||||
|
bottom: -8px;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--app-qwen-clay-button-orange);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-entry:last-child .plan-entry-line {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon container */
|
||||||
|
.plan-entry-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-icon {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.plan-entry-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px;
|
min-height: 24px;
|
||||||
background-color: var(--vscode-input-background);
|
padding-top: 1px;
|
||||||
border-radius: 4px;
|
|
||||||
border-left: 3px solid transparent;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-entry[data-priority="high"] {
|
.plan-entry-number {
|
||||||
border-left-color: #ff6b6b;
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--app-secondary-foreground);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-entry[data-priority="medium"] {
|
.plan-entry-text {
|
||||||
border-left-color: #ffd93d;
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--app-primary-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-entry[data-priority="low"] {
|
/* Status-specific styles */
|
||||||
border-left-color: #6bcf7f;
|
.plan-entry.completed .plan-entry-text {
|
||||||
}
|
|
||||||
|
|
||||||
.plan-entry.completed {
|
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
|
||||||
|
|
||||||
.plan-entry.completed .plan-entry-content {
|
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-entry.in_progress {
|
.plan-entry.in_progress .plan-entry-text {
|
||||||
background-color: rgba(100, 150, 255, 0.1);
|
font-weight: 500;
|
||||||
border-left-width: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-entry-header {
|
.plan-entry.in_progress .plan-entry-number {
|
||||||
display: flex;
|
color: var(--app-qwen-orange);
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plan-entry-status,
|
|
||||||
.plan-entry-priority {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plan-entry-index {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--vscode-descriptionForeground);
|
|
||||||
min-width: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plan-entry-content {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--vscode-foreground);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-10px);
|
transform: translateY(-8px);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
|||||||
@@ -21,55 +21,123 @@ interface PlanDisplayProps {
|
|||||||
* PlanDisplay component - displays AI's task plan/todo list
|
* PlanDisplay component - displays AI's task plan/todo list
|
||||||
*/
|
*/
|
||||||
export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
|
export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
|
||||||
const getPriorityIcon = (priority: string) => {
|
const getStatusIcon = (status: string, _index: number) => {
|
||||||
switch (priority) {
|
|
||||||
case 'high':
|
|
||||||
return '🔴';
|
|
||||||
case 'medium':
|
|
||||||
return '🟡';
|
|
||||||
case 'low':
|
|
||||||
return '🟢';
|
|
||||||
default:
|
|
||||||
return '⚪';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'pending':
|
|
||||||
return '⏱️';
|
|
||||||
case 'in_progress':
|
case 'in_progress':
|
||||||
return '⚙️';
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
className="plan-icon in-progress"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="2"
|
||||||
|
y="2"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
rx="2"
|
||||||
|
fill="var(--app-qwen-orange)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M7 4L7 12M10 8L4 8"
|
||||||
|
stroke="var(--app-qwen-ivory)"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return '✅';
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
className="plan-icon completed"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="2"
|
||||||
|
y="2"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
rx="2"
|
||||||
|
fill="var(--app-qwen-green, #6BCF7F)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5 8L7 10L11 6"
|
||||||
|
stroke="var(--app-qwen-ivory)"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return '❓';
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
className="plan-icon pending"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="2.5"
|
||||||
|
y="2.5"
|
||||||
|
width="11"
|
||||||
|
height="11"
|
||||||
|
rx="2"
|
||||||
|
stroke="var(--app-secondary-foreground)"
|
||||||
|
strokeWidth="1"
|
||||||
|
fill="transparent"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="plan-display">
|
<div className="plan-display">
|
||||||
<div className="plan-header">
|
<div className="plan-header">
|
||||||
<span className="plan-icon">📋</span>
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
className="plan-header-icon"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="3"
|
||||||
|
y="3"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
rx="2"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3 7H17M7 3V7M13 3V7"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
<span className="plan-title">Task Plan</span>
|
<span className="plan-title">Task Plan</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="plan-entries">
|
<div className="plan-entries">
|
||||||
{entries.map((entry, index) => (
|
{entries.map((entry, index) => (
|
||||||
<div
|
<div key={index} className={`plan-entry ${entry.status}`}>
|
||||||
key={index}
|
<div className="plan-entry-line"></div>
|
||||||
className={`plan-entry ${entry.status}`}
|
<div className="plan-entry-icon">
|
||||||
data-priority={entry.priority}
|
{getStatusIcon(entry.status, index)}
|
||||||
>
|
</div>
|
||||||
<div className="plan-entry-header">
|
<div className="plan-entry-content">
|
||||||
<span className="plan-entry-status">
|
<span className="plan-entry-number">{index + 1}.</span>
|
||||||
{getStatusIcon(entry.status)}
|
<span className="plan-entry-text">{entry.content}</span>
|
||||||
</span>
|
|
||||||
<span className="plan-entry-priority">
|
|
||||||
{getPriorityIcon(entry.priority)}
|
|
||||||
</span>
|
|
||||||
<span className="plan-entry-index">{index + 1}.</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="plan-entry-content">{entry.content}</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import {
|
|||||||
StatusIndicator,
|
StatusIndicator,
|
||||||
CodeBlock,
|
CodeBlock,
|
||||||
LocationsList,
|
LocationsList,
|
||||||
DiffDisplay,
|
|
||||||
} from './shared/LayoutComponents.js';
|
} from './shared/LayoutComponents.js';
|
||||||
|
import { DiffDisplay } from './shared/DiffDisplay.js';
|
||||||
import {
|
import {
|
||||||
formatValue,
|
formatValue,
|
||||||
safeTitle,
|
safeTitle,
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ import {
|
|||||||
StatusIndicator,
|
StatusIndicator,
|
||||||
CodeBlock,
|
CodeBlock,
|
||||||
LocationsList,
|
LocationsList,
|
||||||
DiffDisplay,
|
|
||||||
} from './shared/LayoutComponents.js';
|
} from './shared/LayoutComponents.js';
|
||||||
|
import { DiffDisplay } from './shared/DiffDisplay.js';
|
||||||
import { formatValue, safeTitle, groupContent } from './shared/utils.js';
|
import { formatValue, safeTitle, groupContent } from './shared/utils.js';
|
||||||
|
import { useVSCode } from '../../hooks/useVSCode.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Specialized component for Write/Edit tool calls
|
* Specialized component for Write/Edit tool calls
|
||||||
@@ -26,10 +27,24 @@ export const WriteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||||||
const { kind, title, status, rawInput, content, locations } = toolCall;
|
const { kind, title, status, rawInput, content, locations } = toolCall;
|
||||||
const titleText = safeTitle(title);
|
const titleText = safeTitle(title);
|
||||||
const isEdit = kind.toLowerCase() === 'edit';
|
const isEdit = kind.toLowerCase() === 'edit';
|
||||||
|
const vscode = useVSCode();
|
||||||
|
|
||||||
// Group content by type
|
// Group content by type
|
||||||
const { textOutputs, errors, diffs, otherData } = groupContent(content);
|
const { textOutputs, errors, diffs, otherData } = 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 || '' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolCallCard icon="✏️">
|
<ToolCallCard icon="✏️">
|
||||||
{/* Title row */}
|
{/* Title row */}
|
||||||
@@ -59,6 +74,9 @@ export const WriteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
|||||||
path={item.path}
|
path={item.path}
|
||||||
oldText={item.oldText}
|
oldText={item.oldText}
|
||||||
newText={item.newText}
|
newText={item.newText}
|
||||||
|
onOpenDiff={() =>
|
||||||
|
handleOpenDiff(item.path, item.oldText, item.newText)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</ToolCallRow>
|
</ToolCallRow>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Qwen Team
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*
|
||||||
|
* Diff display component for showing file changes
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for DiffDisplay
|
||||||
|
*/
|
||||||
|
interface DiffDisplayProps {
|
||||||
|
path?: string;
|
||||||
|
oldText?: string | null;
|
||||||
|
newText?: string;
|
||||||
|
onOpenDiff?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display diff with before/after sections and option to open in VSCode diff viewer
|
||||||
|
*/
|
||||||
|
export const DiffDisplay: React.FC<DiffDisplayProps> = ({
|
||||||
|
path,
|
||||||
|
oldText,
|
||||||
|
newText,
|
||||||
|
onOpenDiff,
|
||||||
|
}) => (
|
||||||
|
<div className="diff-display-container">
|
||||||
|
<div className="diff-header">
|
||||||
|
<div className="diff-file-path">
|
||||||
|
<strong>{path || 'Unknown file'}</strong>
|
||||||
|
</div>
|
||||||
|
{onOpenDiff && (
|
||||||
|
<button
|
||||||
|
className="open-diff-button"
|
||||||
|
onClick={onOpenDiff}
|
||||||
|
title="Open in VS Code diff viewer"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path
|
||||||
|
d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.25 8a.75.75 0 0 1 .75-.75h4a.75.75 0 0 1 0 1.5H6a.75.75 0 0 1-.75-.75z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Open Diff
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{oldText !== undefined && (
|
||||||
|
<div className="diff-section">
|
||||||
|
<div className="diff-label">Before:</div>
|
||||||
|
<pre className="code-block">
|
||||||
|
<div className="code-content">{oldText || '(empty)'}</div>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{newText !== undefined && (
|
||||||
|
<div className="diff-section">
|
||||||
|
<div className="diff-label">After:</div>
|
||||||
|
<pre className="code-block">
|
||||||
|
<div className="code-content">{newText}</div>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -107,55 +107,3 @@ export const LocationsList: React.FC<LocationsListProps> = ({ locations }) => (
|
|||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for DiffDisplay
|
|
||||||
*/
|
|
||||||
interface DiffDisplayProps {
|
|
||||||
path?: string;
|
|
||||||
oldText?: string | null;
|
|
||||||
newText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display diff with before/after sections
|
|
||||||
*/
|
|
||||||
export const DiffDisplay: React.FC<DiffDisplayProps> = ({
|
|
||||||
path,
|
|
||||||
oldText,
|
|
||||||
newText,
|
|
||||||
}) => (
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
<strong>{path || 'Unknown file'}</strong>
|
|
||||||
</div>
|
|
||||||
{oldText !== undefined && (
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: 0.5,
|
|
||||||
fontSize: '0.85em',
|
|
||||||
marginTop: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Before:
|
|
||||||
</div>
|
|
||||||
<pre className="code-block">{oldText || '(empty)'}</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{newText !== undefined && (
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: 0.5,
|
|
||||||
fontSize: '0.85em',
|
|
||||||
marginTop: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
After:
|
|
||||||
</div>
|
|
||||||
<pre className="code-block">{newText}</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|||||||
Reference in New Issue
Block a user