diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index fbec33117..b47c44b52 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -125,6 +125,18 @@ { "command": "qwen-code.showLogs", "title": "Qwen Code: Show Logs" + }, + { + "command": "qwen-code.copyMessage", + "title": "%qwen-code.copyMessage.title%" + }, + { + "command": "qwen-code.copyAllMessages", + "title": "%qwen-code.copyAllMessages.title%" + }, + { + "command": "qwen-code.copyLastReply", + "title": "%qwen-code.copyLastReply.title%" } ], "menus": { @@ -139,6 +151,35 @@ }, { "command": "qwen-code.auth" + }, + { + "command": "qwen-code.copyMessage", + "when": "false" + }, + { + "command": "qwen-code.copyAllMessages", + "when": "false" + }, + { + "command": "qwen-code.copyLastReply", + "when": "false" + } + ], + "webview/context": [ + { + "command": "qwen-code.copyMessage", + "when": "webviewSection == 'chat-messages'", + "group": "9_cutcopypaste@1" + }, + { + "command": "qwen-code.copyAllMessages", + "when": "webviewSection == 'chat-messages'", + "group": "9_cutcopypaste@2" + }, + { + "command": "qwen-code.copyLastReply", + "when": "webviewSection == 'chat-messages'", + "group": "9_cutcopypaste@3" } ], "editor/title": [ diff --git a/packages/vscode-ide-companion/package.nls.json b/packages/vscode-ide-companion/package.nls.json new file mode 100644 index 000000000..7402f312e --- /dev/null +++ b/packages/vscode-ide-companion/package.nls.json @@ -0,0 +1,5 @@ +{ + "qwen-code.copyMessage.title": "Copy Message", + "qwen-code.copyAllMessages.title": "Copy All Messages", + "qwen-code.copyLastReply.title": "Copy Last Reply" +} diff --git a/packages/vscode-ide-companion/package.nls.zh-cn.json b/packages/vscode-ide-companion/package.nls.zh-cn.json new file mode 100644 index 000000000..aed590438 --- /dev/null +++ b/packages/vscode-ide-companion/package.nls.zh-cn.json @@ -0,0 +1,5 @@ +{ + "qwen-code.copyMessage.title": "复制消息", + "qwen-code.copyAllMessages.title": "复制全部消息", + "qwen-code.copyLastReply.title": "复制最后回复" +} diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index 54b494024..2bfb1a1f2 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -213,6 +213,26 @@ export async function activate(context: vscode.ExtensionContext) { supportsSecondarySidebar, ); + // Register copy commands for webview context menu + // Only send to the first provider with an active webview (the one the user right-clicked) + const sendCopyToActive = (action: string) => { + for (const provider of chatProviderRegistry?.getPermissionAwareProviders() ?? + []) { + if (provider.sendCopyCommand(action)) break; + } + }; + context.subscriptions.push( + vscode.commands.registerCommand('qwen-code.copyMessage', () => + sendCopyToActive('copyMessage'), + ), + vscode.commands.registerCommand('qwen-code.copyAllMessages', () => + sendCopyToActive('copyAllMessages'), + ), + vscode.commands.registerCommand('qwen-code.copyLastReply', () => + sendCopyToActive('copyLastReply'), + ), + ); + context.subscriptions.push( vscode.workspace.onDidCloseTextDocument((doc) => { if (doc.uri.scheme === DIFF_SCHEME) { diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 1d90cc8fe..94c015557 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -26,7 +26,7 @@ import type { PermissionOption, PermissionToolCall } from '@qwen-code/webui'; import type { TextMessage } from './hooks/message/useMessageHandling.js'; import type { ToolCallData } from './components/messages/toolcalls/ToolCall.js'; import { ToolCall } from './components/messages/toolcalls/ToolCall.js'; -import { hasToolCallOutput } from './utils/utils.js'; +import { hasToolCallOutput, shouldShowToolCall } from './utils/utils.js'; import { Onboarding } from './components/layout/Onboarding.js'; import { type CompletionItem } from '../types/completionItemTypes.js'; import { useCompletionTrigger } from './hooks/useCompletionTrigger.js'; @@ -77,97 +77,148 @@ interface MessageListItem { interface MessageListProps { allMessages: MessageListItem[]; onFileClick: (path: string) => void; + /** + * After each render, this ref is updated with an array that maps + * DOM child position → allMessages index, only for items that + * actually render a DOM element (skipping nulls). + */ + childIndexMap: React.MutableRefObject; } const MessageList = React.memo( - ({ allMessages, onFileClick }) => { + ({ allMessages, onFileClick, childIndexMap }) => { let imageIndex = 0; - return ( - <> - {allMessages.map((item, index) => { - switch (item.type) { - case 'message': { - const msg = item.data as TextMessage; - if (msg.kind === 'image' && msg.imagePath) { - imageIndex += 1; - return ( - - ); - } + // Build child→allMessages index mapping: for each item that renders + // a non-null element, record its allMessages index. This array's + // position corresponds to the DOM child position in the container. + const mapping: number[] = []; - if (msg.role === 'thinking') { - return ( - - ); - } + const elements = allMessages.map((item, index) => { + let child: React.ReactNode; + switch (item.type) { + case 'message': { + const msg = item.data as TextMessage; - if (msg.role === 'user') { - return ( - - ); - } - - { - const content = (msg.content || '').trim(); - if ( - content === 'Interrupted' || - content === 'Tool interrupted' - ) { - return ( - - ); - } - return ( - - ); - } - } - - case 'in-progress-tool-call': - case 'completed-tool-call': { - return ( - - ); - } - - default: - return null; + if (msg.kind === 'image' && msg.imagePath) { + imageIndex += 1; + child = ( + + ); + break; } - })} - - ); + + if (msg.role === 'thinking') { + child = ( + + ); + break; + } + + if (msg.role === 'user') { + child = ( + + ); + break; + } + + { + const content = (msg.content || '').trim(); + if (!content) { + child = null; + break; + } + if (content === 'Interrupted' || content === 'Tool interrupted') { + child = ; + break; + } + child = ( + + ); + } + break; + } + + case 'in-progress-tool-call': + case 'completed-tool-call': { + const tc = item.data as ToolCallData; + if (!shouldShowToolCall(tc.kind)) { + child = null; + break; + } + child = ; + break; + } + + default: + child = null; + } + // No wrapper div — message components render directly as children + // of the scroll container, preserving the original CSS layout. + if (child == null) return null; + mapping.push(index); + return {child}; + }); + + // Update the mapping ref so the copy handler can use it + childIndexMap.current = mapping; + + return <>{elements}; }, ); MessageList.displayName = 'MessageList'; +/** + * Given a click target inside the messages container, find which + * allMessages index it belongs to by walking up from the target to + * the container's direct child, then mapping through childIndexMap. + * + * NOTE: childIndexMap indices correspond to MessageList's DOM children + * which must be the first N children of the container. Elements rendered + * after MessageList (InsightProgressCard, WaitingMessage, etc.) are + * excluded from the map and will correctly return -1. + */ +function findMessageIndex( + target: Element, + container: Element, + childIndexMap: number[], +): number { + // Walk up from the click target to find the direct child of the container. + // This works for all message types regardless of whether they have + // .qwen-message class (e.g. InterruptedMessage does not). + let directChild: Element | null = target; + while (directChild && directChild.parentElement !== container) { + directChild = directChild.parentElement; + } + if (!directChild) return -1; + + // Find DOM child position among container's children + const children = container.children; + for (let i = 0; i < children.length; i++) { + if (children[i] === directChild) { + return i < childIndexMap.length ? childIndexMap[i] : -1; + } + } + return -1; +} + export const App: React.FC = () => { const vscode = useVSCode(); @@ -215,6 +266,9 @@ export const App: React.FC = () => { const [showModelSelector, setShowModelSelector] = useState(false); const [accountInfo, setAccountInfo] = useState(null); const messagesEndRef = useRef(null); + // Maps DOM child position → allMessages index. Built during render by + // MessageList, only includes items that actually produce DOM elements. + const childIndexMapRef = useRef([]); // Scroll container for message list; used to keep the view anchored to the latest content const messagesContainerRef = useRef(null); const inputFieldRef = useRef(null); @@ -970,6 +1024,167 @@ export const App: React.FC = () => { [vscode], ); + // Build a markdown code fence that won't collide with content containing backticks + const buildFence = useCallback((content: string): string => { + const matches = (content ?? '').match(/`+/g); + const maxRun = matches ? Math.max(...matches.map((m) => m.length)) : 0; + return '`'.repeat(Math.max(3, maxRun + 1)); + }, []); + + // Format a tool call's content for clipboard copy + // wrapCodeBlock: true for Copy All (markdown), false for single Copy Message (plain text) + const formatToolCallForCopy = useCallback( + (tc: ToolCallData, wrapCodeBlock = false): string => { + const parts: string[] = []; + if (tc.content) { + for (const c of tc.content) { + if (c.type === 'content' && c.content?.text) { + if (wrapCodeBlock) { + const fence = buildFence(c.content.text); + parts.push(`${fence}\n${c.content.text}\n${fence}`); + } else { + parts.push(c.content.text); + } + } else if (c.type === 'diff') { + const filePath = c.path || ''; + if (c.oldText) { + const oldLines = c.oldText + .split('\n') + .map((l) => `-${l}`) + .join('\n'); + const newLines = (c.newText || '') + .split('\n') + .map((l) => `+${l}`) + .join('\n'); + const diffContent = `--- ${filePath}\n+++ ${filePath}\n${oldLines}\n${newLines}`; + if (wrapCodeBlock) { + const fence = buildFence(diffContent); + parts.push(`${fence}diff\n${diffContent}\n${fence}`); + } else { + parts.push(diffContent); + } + } else { + if (wrapCodeBlock) { + const fence = buildFence(c.newText || ''); + parts.push( + `${filePath}:\n${fence}\n${c.newText || ''}\n${fence}`, + ); + } else { + parts.push(`${filePath}:\n${c.newText || ''}`); + } + } + } + } + } + return parts.join('\n\n'); + }, + [buildFence], + ); + + // Track which message was right-clicked by resolving the index immediately. + // Storing the DOM element reference would be fragile: React re-renders between + // the right-click and the async copy command (routed via extension host) can + // detach the element, causing findMessageIndex to fail intermittently. + const contextMenuMsgIdxRef = useRef(-1); + useEffect(() => { + const trackTarget = (e: MouseEvent) => { + const container = messagesContainerRef.current; + if (container && e.target instanceof Element) { + contextMenuMsgIdxRef.current = findMessageIndex( + e.target, + container, + childIndexMapRef.current, + ); + } + // Notify extension that this webview was right-clicked, so copy commands route here + vscode.postMessage({ type: 'contextMenuTriggered', data: {} }); + }; + document.addEventListener('contextmenu', trackTarget, true); + return () => document.removeEventListener('contextmenu', trackTarget, true); + }, [vscode]); + + // Copy text via the extension host's clipboard API (more reliable than navigator.clipboard in webview) + const copyToClipboard = useCallback( + (text: string) => { + vscode.postMessage({ type: 'copyToClipboard', data: { text } }); + }, + [vscode], + ); + + // Handle copy commands from VSCode native context menu + useEffect(() => { + const handler = (event: MessageEvent) => { + const message = event.data; + if (message?.type !== 'copyCommand') return; + + const { action } = message.data as { action: string }; + + if (action === 'copyMessage') { + const idx = contextMenuMsgIdxRef.current; + if (idx >= 0 && idx < allMessages.length) { + const item = allMessages[idx]; + if (item.type === 'message') { + const msg = item.data as TextMessage; + if (msg.kind === 'image' && msg.imagePath) { + copyToClipboard(`![image](${msg.imagePath})`); + } else { + copyToClipboard(msg.content || ''); + } + } else if ( + item.type === 'completed-tool-call' || + item.type === 'in-progress-tool-call' + ) { + copyToClipboard(formatToolCallForCopy(item.data as ToolCallData)); + } + } + } else if (action === 'copyAllMessages') { + const parts: string[] = []; + for (const item of allMessages) { + if (item.type === 'message') { + const msg = item.data as TextMessage; + const content = + msg.kind === 'image' && msg.imagePath + ? `![image](${msg.imagePath})` + : (msg.content || '').trim(); + if (!content) continue; + if (msg.role === 'user') { + parts.push(`**User:** ${content}`); + } else if (msg.role === 'thinking') { + parts.push(`**Thinking:** ${content}`); + } else { + parts.push(`**Qwen Code:** ${content}`); + } + } else if ( + item.type === 'completed-tool-call' || + item.type === 'in-progress-tool-call' + ) { + const tc = item.data as ToolCallData; + if (!shouldShowToolCall(tc.kind)) continue; + const text = formatToolCallForCopy(tc, true); + if (text) { + parts.push(`**[Tool: ${tc.kind}]**\n\n${text}`); + } + } + } + copyToClipboard(parts.join('\n\n---\n\n')); + } else if (action === 'copyLastReply') { + for (let i = allMessages.length - 1; i >= 0; i--) { + const item = allMessages[i]; + if (item.type === 'message') { + const msg = item.data as TextMessage; + if (msg.role === 'assistant' && msg.content?.trim()) { + copyToClipboard(msg.content); + return; + } + } + } + } + }; + + window.addEventListener('message', handler); + return () => window.removeEventListener('message', handler); + }, [allMessages, copyToClipboard, formatToolCallForCopy]); + const hasContent = messageHandling.messages.length > 0 || messageHandling.isStreaming || @@ -1023,6 +1238,9 @@ export const App: React.FC = () => {
{!hasContent && !isLoading && !sessionManagement.isSwitchingSession ? ( isAuthenticated === false ? ( @@ -1052,6 +1270,7 @@ export const App: React.FC = () => { {insightProgress && ( diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index bcae4dd88..b9b287e0a 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -66,6 +66,8 @@ export class WebViewProvider { // Track current ACP mode id to influence permission/diff behavior private currentModeId: ApprovalModeValue | null = null; private authState: boolean | null = null; + /** Global tracker: the provider whose webview most recently received a contextmenu event */ + private static lastContextMenuProvider: WebViewProvider | null = null; /** Cached available commands for re-sending on webview ready */ private cachedAvailableCommands: AvailableCommand[] | null = null; /** Cached available models for re-sending on webview ready */ @@ -659,18 +661,7 @@ export class WebViewProvider { // Handle messages from WebView webview.onDidReceiveMessage( async (message: { type: string; data?: unknown }) => { - if (message.type === 'openDiff' && this.isAutoMode()) { - return; - } - if (message.type === 'webviewReady') { - this.handleWebviewReady(); - return; - } - if (message.type === 'resolveImagePaths') { - this.handleResolveImagePaths(message.data, webview); - return; - } - if (await this.handleOpenInsightReportMessage(message)) { + if (await this.handleCommonWebviewMessage(message, webview)) { return; } if (this.handleNewChatByContext(message)) { @@ -820,19 +811,7 @@ export class WebViewProvider { // Handle messages from WebView newPanel.webview.onDidReceiveMessage( async (message: { type: string; data?: unknown }) => { - // Suppress UI-originated diff opens in auto/yolo mode - if (message.type === 'openDiff' && this.isAutoMode()) { - return; - } - if (message.type === 'webviewReady') { - this.handleWebviewReady(); - return; - } - if (message.type === 'resolveImagePaths') { - this.handleResolveImagePaths(message.data, newPanel.webview); - return; - } - if (await this.handleOpenInsightReportMessage(message)) { + if (await this.handleCommonWebviewMessage(message, newPanel.webview)) { return; } // Allow webview to request updating the VS Code tab title @@ -1665,6 +1644,18 @@ export class WebViewProvider { return true; } + /** + * Send a copy command to the webview (triggered by native context menu). + * The webview resolves the content and posts back a 'copyToClipboard' message. + */ + sendCopyCommand(action: string): boolean { + if (WebViewProvider.lastContextMenuProvider !== this) return false; + const webview = this.getActiveWebview(); + if (!webview) return false; + webview.postMessage({ type: 'copyCommand', data: { action } }); + return true; + } + /** * Send message to WebView */ @@ -1720,6 +1711,41 @@ export class WebViewProvider { return this.currentModeId; } + /** + * Handle common webview message types shared across all host contexts + * (sidebar view, editor panel, restored panel). + * Returns true if the message was handled and no further processing is needed. + */ + private async handleCommonWebviewMessage( + message: { type: string; data?: unknown }, + webview: vscode.Webview, + ): Promise { + if (message.type === 'openDiff' && this.isAutoMode()) { + return true; + } + if (message.type === 'webviewReady') { + this.handleWebviewReady(); + return true; + } + if (message.type === 'contextMenuTriggered') { + WebViewProvider.lastContextMenuProvider = this; + return true; + } + if (message.type === 'copyToClipboard') { + const { text } = message.data as { text: string }; + await vscode.env.clipboard.writeText(text); + return true; + } + if (message.type === 'resolveImagePaths') { + this.handleResolveImagePaths(message.data, webview); + return true; + } + if (await this.handleOpenInsightReportMessage(message)) { + return true; + } + return false; + } + /** True if diffs/permissions should be auto-handled without prompting. */ isAutoMode(): boolean { return this.currentModeId === 'auto-edit' || this.currentModeId === 'yolo'; @@ -1843,19 +1869,7 @@ export class WebViewProvider { // Handle messages from WebView (restored panel) panel.webview.onDidReceiveMessage( async (message: { type: string; data?: unknown }) => { - // Suppress UI-originated diff opens in auto/yolo mode - if (message.type === 'openDiff' && this.isAutoMode()) { - return; - } - if (message.type === 'webviewReady') { - this.handleWebviewReady(); - return; - } - if (message.type === 'resolveImagePaths') { - this.handleResolveImagePaths(message.data, panel.webview); - return; - } - if (await this.handleOpenInsightReportMessage(message)) { + if (await this.handleCommonWebviewMessage(message, panel.webview)) { return; } if (message.type === 'updatePanelTitle') { @@ -2095,6 +2109,9 @@ export class WebViewProvider { this.pendingAskUserQuestionResolve = null; this.pendingAskUserQuestionRequest = null; } + if (WebViewProvider.lastContextMenuProvider === this) { + WebViewProvider.lastContextMenuProvider = null; + } this.panelManager.dispose(); this.agentManager.disconnect(); this.disposables.forEach((d) => d.dispose());