feat(vscode-ide-companion): add cancel streaming functionality

- Add handleCancel callback to App component
- Implement cancelStreaming message posting to VS Code
- Add onCancel prop to InputForm component
- Replace send button with stop button during streaming
This commit is contained in:
yiliang114
2025-12-04 01:53:19 +08:00
parent 35f98723ca
commit e3c456a430
27 changed files with 730 additions and 286 deletions

View File

@@ -27,7 +27,7 @@ import type { ToolCallData } from './components/ToolCall.js';
import { PermissionDrawer } from './components/PermissionDrawer.js';
import { ToolCall } from './components/ToolCall.js';
import { hasToolCallOutput } from './components/toolcalls/shared/utils.js';
import { InProgressToolCall } from './components/InProgressToolCall.js';
// import { InProgressToolCall } from './components/InProgressToolCall.js';
import { EmptyState } from './components/ui/EmptyState.js';
import type { PlanEntry } from './components/PlanDisplay.js';
import { type CompletionItem } from './types/CompletionTypes.js';
@@ -172,6 +172,16 @@ export const App: React.FC = () => {
isStreaming: messageHandling.isStreaming,
});
// Handle cancel streaming
const handleCancel = useCallback(() => {
if (messageHandling.isStreaming) {
vscode.postMessage({
type: 'cancelStreaming',
data: {},
});
}
}, [messageHandling.isStreaming, vscode]);
// Message handling
useWebViewMessages({
sessionManagement,
@@ -531,49 +541,47 @@ export const App: React.FC = () => {
if (msg.role === 'thinking') {
return (
<div key={`message-${index}`} className="message-item">
<ThinkingMessage
content={msg.content || ''}
timestamp={msg.timestamp || 0}
onFileClick={handleFileClick}
/>
</div>
<ThinkingMessage
key={`message-${index}`}
content={msg.content || ''}
timestamp={msg.timestamp || 0}
onFileClick={handleFileClick}
/>
);
}
if (msg.role === 'user') {
return (
<div key={`message-${index}`} className="message-item">
<UserMessage
content={msg.content || ''}
timestamp={msg.timestamp || 0}
onFileClick={handleFileClick}
fileContext={msg.fileContext}
/>
</div>
<UserMessage
key={`message-${index}`}
content={msg.content || ''}
timestamp={msg.timestamp || 0}
onFileClick={handleFileClick}
fileContext={msg.fileContext}
/>
);
}
return (
<div key={`message-${index}`} className="message-item">
<AssistantMessage
content={msg.content || ''}
timestamp={msg.timestamp || 0}
onFileClick={handleFileClick}
/>
</div>
<AssistantMessage
key={`message-${index}`}
content={msg.content || ''}
timestamp={msg.timestamp || 0}
onFileClick={handleFileClick}
/>
);
}
case 'in-progress-tool-call':
return (
<InProgressToolCall
key={`in-progress-${(item.data as ToolCallData).toolCallId}`}
toolCall={item.data as ToolCallData}
// onFileClick={handleFileClick}
/>
);
// case 'in-progress-tool-call':
// return (
// <InProgressToolCall
// key={`in-progress-${(item.data as ToolCallData).toolCallId}`}
// toolCall={item.data as ToolCallData}
// // onFileClick={handleFileClick}
// />
// );
case 'in-progress-tool-call':
case 'completed-tool-call':
return (
<ToolCall
@@ -626,6 +634,7 @@ export const App: React.FC = () => {
onCompositionEnd={() => setIsComposing(false)}
onKeyDown={() => {}}
onSubmit={handleSubmit.handleSubmit}
onCancel={handleCancel}
onToggleEditMode={handleToggleEditMode}
onToggleThinking={handleToggleThinking}
onFocusActiveEditor={fileContext.focusActiveEditor}

View File

@@ -204,6 +204,17 @@ export class WebViewProvider {
// Handle messages from WebView
newPanel.webview.onDidReceiveMessage(
async (message: { type: string; data?: unknown }) => {
// Allow webview to request updating the VS Code tab title
if (message.type === 'updatePanelTitle') {
const title = String(
(message.data as { title?: unknown } | undefined)?.title ?? '',
).trim();
const panelRef = this.panelManager.getPanel();
if (panelRef) {
panelRef.title = title || 'Qwen Code';
}
return;
}
await this.messageHandler.route(message);
},
null,
@@ -790,9 +801,19 @@ export class WebViewProvider {
panel.webview.html = WebViewContent.generate(panel, this.extensionUri);
// Handle messages from WebView
// Handle messages from WebView (restored panel)
panel.webview.onDidReceiveMessage(
async (message: { type: string; data?: unknown }) => {
if (message.type === 'updatePanelTitle') {
const title = String(
(message.data as { title?: unknown } | undefined)?.title ?? '',
).trim();
const panelRef = this.panelManager.getPanel();
if (panelRef) {
panelRef.title = title || 'Qwen Code';
}
return;
}
await this.messageHandler.route(message);
},
null,

View File

@@ -154,41 +154,50 @@ export const InProgressToolCall: React.FC<InProgressToolCallProps> = ({
};
return (
<div className="relative py-2 toolcall-container">
<div className="flex items-center gap-2 mb-1 relative before:content-['\25cf'] before:absolute before:left-[-22px] before:top-1/2 before:-translate-y-1/2 before:text-[10px] before:leading-none before:text-[#e1c08d] before:animate-pulse-slow">
<span className={`text-xs font-medium ${kindColorClass}`}>
{kindLabel}
</span>
{filePath && (
<FileLink
path={filePath}
line={fileLine ?? undefined}
showFullPath={false}
className="text-xs"
/>
)}
{!filePath && fileName && (
<span className="text-xs text-[var(--app-secondary-foreground)] font-mono">
{fileName}
</span>
)}
{diffData && (
<button
type="button"
onClick={handleOpenDiff}
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"
<div className="relative pl-[30px] py-2 select-text toolcall-container in-progress-toolcall">
<div className="toolcall-content-wrapper flex flex-col gap-1 min-w-0 max-w-full">
<div className="flex items-center gap-2 relative min-w-0 toolcall-header">
<span
className={`text-[14px] leading-none font-bold ${kindColorClass}`}
>
Open Diff
</button>
{kindLabel}
</span>
{filePath && (
<FileLink
path={filePath}
line={fileLine ?? undefined}
showFullPath={false}
className="text-[14px]"
/>
)}
{!filePath && fileName && (
<span className="text-[14px] leading-none text-[var(--app-secondary-foreground)]">
{fileName}
</span>
)}
{diffData && (
<button
type="button"
onClick={handleOpenDiff}
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
</button>
)}
</div>
{contentText && (
<div className="text-[var(--app-secondary-foreground)]">
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="toolcall-content-text flex-shrink-0 w-full">
{contentText}
</span>
</div>
</div>
)}
</div>
{contentText && (
<div className="text-xs text-[var(--app-secondary-foreground)] font-mono mt-1 whitespace-pre-wrap break-words max-h-[200px] overflow-y-auto p-1 bg-[var(--app-input-background)] border border-[var(--app-input-border)] rounded">
{contentText}
</div>
)}
</div>
);
};

View File

@@ -14,6 +14,7 @@ import {
SlashCommandIcon,
LinkIcon,
ArrowUpIcon,
StopIcon,
} from './icons/index.js';
import { CompletionMenu } from './ui/CompletionMenu.js';
import type { CompletionItem } from '../types/CompletionTypes.js';
@@ -36,6 +37,7 @@ interface InputFormProps {
onCompositionEnd: () => void;
onKeyDown: (e: React.KeyboardEvent) => void;
onSubmit: (e: React.FormEvent) => void;
onCancel: () => void;
onToggleEditMode: () => void;
onToggleThinking: () => void;
onFocusActiveEditor: () => void;
@@ -91,6 +93,7 @@ export const InputForm: React.FC<InputFormProps> = ({
onCompositionEnd,
onKeyDown,
onSubmit,
onCancel,
onToggleEditMode,
onToggleThinking,
onFocusActiveEditor,
@@ -198,17 +201,6 @@ export const InputForm: React.FC<InputFormProps> = ({
</button>
)}
{/* Divider */}
<div
className="w-px h-[26px] mx-0.5 flex-shrink-0"
style={{
backgroundColor: 'var(--app-transparent-inner-border)',
}}
/>
{/* Spacer */}
<div className="flex-1 min-w-0" />
{/* Thinking button */}
<button
type="button"
@@ -239,14 +231,25 @@ export const InputForm: React.FC<InputFormProps> = ({
<LinkIcon />
</button>
{/* Send button */}
<button
type="submit"
className="btn-send-compact [&>svg]:w-5 [&>svg]:h-5"
disabled={isStreaming || !inputText.trim()}
>
<ArrowUpIcon />
</button>
{/* Send/Stop button */}
{isStreaming ? (
<button
type="button"
className="btn-send-compact [&>svg]:w-5 [&>svg]:h-5"
onClick={onCancel}
title="Stop generation"
>
<StopIcon />
</button>
) : (
<button
type="submit"
className="btn-send-compact [&>svg]:w-5 [&>svg]:h-5"
disabled={!inputText.trim()}
>
<ArrowUpIcon />
</button>
)}
</div>
</form>
</div>

View File

@@ -178,7 +178,7 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
<span
className={`inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-semibold rounded ${
isFocused
? 'bg-white/20 text-inherit'
? 'text-inherit'
: 'bg-[var(--app-list-hover-background)]'
}`}
>
@@ -211,7 +211,7 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
<span
className={`inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-semibold rounded ${
isFocused
? 'bg-white/20 text-inherit'
? 'text-inherit'
: 'bg-[var(--app-list-hover-background)]'
}`}
>

View File

@@ -1,88 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/* 容器 */
.plan-display {
@apply bg-transparent border-0 py-2 px-4 my-2;
}
/* 标题区 */
.plan-header {
@apply flex items-center gap-1.5 mb-2;
}
.plan-progress-icons {
@apply flex items-center gap-[2px];
}
.plan-progress-icon {
@apply shrink-0 text-[var(--app-secondary-foreground)] opacity-60;
}
.plan-title {
@apply text-xs font-normal text-[var(--app-secondary-foreground)] opacity-80;
}
/* 列表 */
.plan-entries {
@apply flex flex-col gap-px;
}
.plan-entry {
@apply flex items-center gap-2 py-[3px] min-h-[20px];
}
/* 图标容器(保留类名以兼容旧 DOM */
.plan-entry-icon {
@apply shrink-0 flex items-center justify-center w-[14px] h-[14px];
}
.plan-icon {
@apply block w-[14px] h-[14px];
}
/* 不同状态颜色(保留类名) */
.plan-icon.pending {
@apply text-[var(--app-secondary-foreground)] opacity-30;
}
.plan-icon.in-progress {
@apply text-[var(--app-secondary-foreground)] opacity-70;
}
.plan-icon.completed {
@apply text-[#4caf50] opacity-80; /* 绿色勾号 */
}
/* 内容 */
.plan-entry-content {
@apply flex-1 flex items-center;
}
.plan-entry-text {
@apply flex-1 text-xs leading-[1.5] text-[var(--app-primary-foreground)] opacity-80;
}
/* 状态化文本(保留选择器,兼容旧结构) */
.plan-entry.completed .plan-entry-text {
@apply opacity-50 line-through;
}
.plan-entry.in_progress .plan-entry-text {
@apply font-normal opacity-90;
}
/* 保留 fadeIn 动画,供 App.tsx 使用 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -5,7 +5,6 @@
*/
import type React from 'react';
import './PlanDisplay.css';
import { CheckboxDisplay } from './ui/CheckboxDisplay.js';
export interface PlanEntry {
@@ -35,16 +34,17 @@ export const PlanDisplay: React.FC<PlanDisplayProps> = ({ entries }) => {
return (
<div
className={[
'plan-display',
// Container: Similar to example .A/.e
'relative flex flex-col items-start py-2 pl-[30px] select-text text-[var(--app-primary-foreground)]',
// Left status dot, similar to example .e:before
'before:content-["\\25cf"] before:absolute before:left-[10px] before:top-[12px] before:text-[10px] before:z-[1]',
statusDotClass,
// Original plan-display styles: bg-transparent border-0 py-2 px-4 my-2
'bg-transparent border-0 my-2',
].join(' ')}
>
{/* Title area, similar to example summary/_e/or */}
<div className="plan-header w-full">
<div className="w-full flex items-center gap-1.5 mb-2">
<div className="relative">
<div className="list-none line-clamp-2 max-w-full overflow-hidden _e">
<span>

View File

@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Stop icon for canceling operations
*/
import type React from 'react';
import type { IconProps } from './types.js';
/**
* Stop/square icon (16x16)
* Used for stop/cancel operations
*/
export const StopIcon: React.FC<IconProps> = ({
size = 16,
className,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
width={size}
height={size}
className={className}
aria-hidden="true"
{...props}
>
<rect x="4" y="4" width="8" height="8" rx="1" />
</svg>
);

View File

@@ -51,3 +51,6 @@ export { PlayIcon, SwitchIcon } from './ActionIcons.js';
// Special icons
export { ThinkingIcon, TerminalIcon } from './SpecialIcons.js';
// Stop icon
export { StopIcon } from './StopIcon.js';

View File

@@ -56,7 +56,7 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
return (
<div
className={`assistant-message-container simple-timeline-container ${getStatusClass()}`}
className={`qwen-message message-item assistant-message-container simple-timeline-container ${getStatusClass()}`}
style={{
width: '100%',
alignItems: 'flex-start',

View File

@@ -0,0 +1,37 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Custom timeline styles for qwen-message message-item elements
*
* 实现原理:
* 1. 为所有AI消息项(qwen-message.message-item:not(.user-message-container))创建垂直连接线
* 2. 当用户消息(user-message-container)隔断AI消息序列时会自动重新开始一组新的时间线规则
* 3. 每组AI消息序列的开始元素(top设为15px),结束元素(bottom设为calc(100% - 15px))
*/
/* 默认的连接线样式 - 为所有AI消息项创建完整高度的连接线 */
.qwen-message.message-item:not(.user-message-container)::after {
content: '';
position: absolute;
left: 12px;
top: 0;
bottom: 0;
width: 1px;
background-color: var(--app-primary-border-color);
z-index: 0;
}
/* 处理每组AI消息序列的开始 - 包括整个消息列表的第一个AI消息和被用户消息隔断后的新AI消息 */
.qwen-message.message-item:not(.user-message-container):first-child::after,
.user-message-container + .qwen-message.message-item:not(.user-message-container)::after {
top: 15px;
}
/* 处理每组AI消息序列的结尾 */
.qwen-message.message-item:not(.user-message-container):last-child::after,
.qwen-message.message-item:not(.user-message-container):has(+ .user-message-container)::after {
bottom: calc(100% - 15px);
top: 0;
}

View File

@@ -18,7 +18,7 @@ export const ThinkingMessage: React.FC<ThinkingMessageProps> = ({
timestamp: _timestamp,
onFileClick,
}) => (
<div className="flex gap-0 items-start text-left py-2 flex-col relative opacity-80 italic pl-6 animate-[fadeIn_0.2s_ease-in]">
<div className="qwen-message thinking-message flex gap-0 items-start text-left py-2 flex-col relative opacity-80 italic pl-6 animate-[fadeIn_0.2s_ease-in]">
<div
className="inline-block my-1 relative whitespace-pre-wrap rounded-md max-w-full overflow-x-auto overflow-y-hidden select-text leading-[1.5]"
style={{

View File

@@ -44,7 +44,10 @@ export const UserMessage: React.FC<UserMessageProps> = ({
const fileContextDisplay = getFileContextDisplay();
return (
<div className="flex gap-0 items-start text-left flex-col relative animate-[fadeIn_0.2s_ease-in]">
<div
className="qwen-message message-item user-message-container flex gap-0 items-start text-left flex-col relative animate-[fadeIn_0.2s_ease-in]"
style={{ position: 'relative' }}
>
<div
className="inline-block relative whitespace-pre-wrap rounded-md max-w-full overflow-x-auto overflow-y-hidden select-text leading-[1.5]"
style={{

View File

@@ -10,7 +10,9 @@ import type React from 'react';
import type { BaseToolCallProps } from '../shared/types.js';
import { ToolCallContainer } from '../shared/LayoutComponents.js';
import { safeTitle, groupContent } from '../shared/utils.js';
import './ExecuteToolCall.css';
import { useVSCode } from '../../../hooks/useVSCode.js';
import { createAndOpenTempFile } from '../../../utils/tempFileManager.js';
import './Bash.css';
/**
* Specialized component for Execute/Bash tool calls
@@ -19,6 +21,7 @@ import './ExecuteToolCall.css';
export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { title, content, rawInput, toolCallId } = toolCall;
const commandText = safeTitle(title);
const vscode = useVSCode();
// Group content by type
const { textOutputs, errors } = groupContent(content);
@@ -32,6 +35,24 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
inputCommand = rawInput;
}
// Handle click on IN section
const handleInClick = () => {
createAndOpenTempFile(
vscode.postMessage,
inputCommand,
'bash-input',
'.sh',
);
};
// Handle click on OUT section
const handleOutClick = () => {
if (textOutputs.length > 0) {
const output = textOutputs.join('\n');
createAndOpenTempFile(vscode.postMessage, output, 'bash-output', '.txt');
}
};
// Error case
if (errors.length > 0) {
return (
@@ -45,7 +66,11 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
<div className="bash-toolcall-card">
<div className="bash-toolcall-content">
{/* IN row */}
<div className="bash-toolcall-row">
<div
className="bash-toolcall-row"
onClick={handleInClick}
style={{ cursor: 'pointer' }}
>
<div className="bash-toolcall-label">IN</div>
<div className="bash-toolcall-row-content">
<pre className="bash-toolcall-pre">{inputCommand}</pre>
@@ -84,7 +109,11 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
<div className="bash-toolcall-card">
<div className="bash-toolcall-content">
{/* IN row */}
<div className="bash-toolcall-row">
<div
className="bash-toolcall-row"
onClick={handleInClick}
style={{ cursor: 'pointer' }}
>
<div className="bash-toolcall-label">IN</div>
<div className="bash-toolcall-row-content">
<pre className="bash-toolcall-pre">{inputCommand}</pre>
@@ -92,7 +121,11 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
</div>
{/* OUT row */}
<div className="bash-toolcall-row">
<div
className="bash-toolcall-row"
onClick={handleOutClick}
style={{ cursor: 'pointer' }}
>
<div className="bash-toolcall-label">OUT</div>
<div className="bash-toolcall-row-content">
<div className="bash-toolcall-output-subtle">
@@ -109,7 +142,11 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// Success without output: show command with branch connector
return (
<ToolCallContainer label="Bash" status="success" toolCallId={toolCallId}>
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
<div
className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1"
onClick={handleInClick}
style={{ cursor: 'pointer' }}
>
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0 w-full">{commandText}</span>
</div>

View File

@@ -7,12 +7,12 @@
*/
import { useEffect, useCallback } from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import { ToolCallContainer } from './shared/LayoutComponents.js';
import { DiffDisplay } from './shared/DiffDisplay.js';
import { groupContent } from './shared/utils.js';
import { useVSCode } from '../../hooks/useVSCode.js';
import { FileLink } from '../ui/FileLink.js';
import type { BaseToolCallProps } from '../shared/types.js';
import { ToolCallContainer } from '../shared/LayoutComponents.js';
import { DiffDisplay } from '../shared/DiffDisplay.js';
import { groupContent } from '../shared/utils.js';
import { useVSCode } from '../../../hooks/useVSCode.js';
import { FileLink } from '../../ui/FileLink.js';
/**
* Calculate diff summary (added/removed lines)
@@ -76,11 +76,13 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
firstDiff.oldText !== undefined &&
firstDiff.newText !== undefined
) {
// TODO: 暂时注释
// Add a small delay to ensure the component is fully rendered
const timer = setTimeout(() => {
handleOpenDiff(path, firstDiff.oldText, firstDiff.newText);
}, 100);
return () => clearTimeout(timer);
// const timer = setTimeout(() => {
// handleOpenDiff(path, firstDiff.oldText, firstDiff.newText);
// }, 100);
let timer;
return () => timer && clearTimeout(timer);
}
}
}, [diffs, locations, handleOpenDiff]);
@@ -120,48 +122,47 @@ export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
handleOpenDiff(path, firstDiff.oldText, firstDiff.newText);
return (
<div>
<div
className="relative py-2 select-text cursor-pointer hover:bg-[var(--app-input-background)] toolcall-container toolcall-status-success"
onClick={openFirstDiff}
title="Open diff in VS Code"
>
{/* Keep content within overall width: pl-[30px] provides the bullet indent; */}
{/* IMPORTANT: Always include min-w-0/max-w-full on inner wrappers to prevent overflow. */}
<div className="toolcall-edit-content flex flex-col gap-1 pl-[30px] min-w-0 max-w-full">
<div className="flex items-center justify-between min-w-0">
<div className="flex items-center gap-2 min-w-0">
{/* Align the inline Edit label styling with shared toolcall label: larger + bold */}
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
Edit
</span>
{path && (
<FileLink
path={path}
showFullPath={false}
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
/>
)}
{/* {toolCallId && (
<div
className="qwen-message message-item relative py-2 select-text cursor-pointer hover:bg-[var(--app-input-background)] toolcall-container toolcall-status-success"
onClick={openFirstDiff}
title="Open diff in VS Code"
>
{/* Keep content within overall width: pl-[30px] provides the bullet indent; */}
{/* IMPORTANT: Always include min-w-0/max-w-full on inner wrappers to prevent overflow. */}
<div className="toolcall-edit-content flex flex-col gap-1 min-w-0 max-w-full pl-[30px]">
<div className="flex items-center justify-between min-w-0">
<div className="flex items-center gap-2 min-w-0">
{/* Align the inline Edit label styling with shared toolcall label: larger + bold */}
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
Edit
</span>
{path && (
<FileLink
path={path}
showFullPath={false}
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
/>
)}
{/* {toolCallId && (
<span className="text-[10px] opacity-30">
[{toolCallId.slice(-8)}]
</span>
)} */}
</div>
<span className="text-xs opacity-60 ml-2">open</span>
</div>
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0 w-full">{summary}</span>
</div>
<span className="text-xs opacity-60 ml-2">open</span>
</div>
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0 w-full">{summary}</span>
</div>
</div>
{/* Content area aligned with bullet indent. Do NOT exceed container width. */}
{/* For any custom blocks here, keep: min-w-0 max-w-full and avoid extra horizontal padding/margins. */}
<div className="pl-[30px] mt-1 min-w-0 max-w-full overflow-hidden">
{diffs.map(
(
item: import('./shared/types.js').ToolCallContent,
item: import('../shared/types.js').ToolCallContent,
idx: number,
) => (
<DiffDisplay

View File

@@ -0,0 +1,102 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Execute tool call styles - Enhanced styling with semantic class names
*/
/* Root container for execute tool call output */
.execute-toolcall-card {
border: 0.5px solid var(--app-input-border);
border-radius: 5px;
background: var(--app-tool-background);
margin: 8px 0;
max-width: 100%;
font-size: 1em;
align-items: start;
}
/* Content wrapper inside the card */
.execute-toolcall-content {
display: flex;
flex-direction: column;
gap: 3px;
padding: 4px;
}
/* Individual input/output row */
.execute-toolcall-row {
display: grid;
grid-template-columns: max-content 1fr;
border-top: 0.5px solid var(--app-input-border);
padding: 4px;
}
/* First row has no top border */
.execute-toolcall-row:first-child {
border-top: none;
}
/* Row label (IN/OUT/ERROR) */
.execute-toolcall-label {
grid-column: 1;
color: var(--app-secondary-foreground);
text-align: left;
opacity: 50%;
padding: 4px 8px 4px 4px;
font-family: var(--app-monospace-font-family);
font-size: 0.85em;
}
/* Row content area */
.execute-toolcall-row-content {
grid-column: 2;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
padding: 4px;
}
/* Truncated content styling */
.execute-toolcall-row-content:not(.execute-toolcall-full) {
max-height: 60px;
mask-image: linear-gradient(
to bottom,
var(--app-primary-background) 40px,
transparent 60px
);
overflow: hidden;
}
/* Preformatted content */
.execute-toolcall-pre {
margin-block: 0;
overflow: hidden;
font-family: var(--app-monospace-font-family);
font-size: 0.85em;
}
/* Code content */
.execute-toolcall-code {
margin: 0;
padding: 0;
font-family: var(--app-monospace-font-family);
font-size: 0.85em;
}
/* Output content with subtle styling */
.execute-toolcall-output-subtle {
background-color: var(--app-code-background);
white-space: pre;
overflow-x: auto;
max-width: 100%;
min-width: 0;
width: 100%;
box-sizing: border-box;
}
/* Error content styling */
.execute-toolcall-error-content {
color: #c74e39;
}

View File

@@ -0,0 +1,122 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Execute tool call component - specialized for command execution operations
*/
import type React from 'react';
import type { BaseToolCallProps } from '../shared/types.js';
import { ToolCallContainer } from '../shared/LayoutComponents.js';
import { safeTitle, groupContent } from '../shared/utils.js';
import './Execute.css';
/**
* Specialized component for Execute tool calls
* Shows: Execute bullet + description + IN/OUT card
*/
export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { title, content, rawInput, toolCallId } = toolCall;
const commandText = safeTitle(title);
// Group content by type
const { textOutputs, errors } = groupContent(content);
// Extract command from rawInput if available
let inputCommand = commandText;
if (rawInput && typeof rawInput === 'object') {
const inputObj = rawInput as { command?: string };
inputCommand = inputObj.command || commandText;
} else if (typeof rawInput === 'string') {
inputCommand = rawInput;
}
// Error case
if (errors.length > 0) {
return (
<ToolCallContainer label="Execute" status="error" toolCallId={toolCallId}>
{/* Branch connector summary (Claude-like) */}
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0 w-full">{commandText}</span>
</div>
{/* Error card - semantic DOM + Tailwind styles */}
<div className="execute-toolcall-card">
<div className="execute-toolcall-content">
{/* IN row */}
<div className="execute-toolcall-row">
<div className="execute-toolcall-label">IN</div>
<div className="execute-toolcall-row-content">
<pre className="execute-toolcall-pre">{inputCommand}</pre>
</div>
</div>
{/* ERROR row */}
<div className="execute-toolcall-row">
<div className="execute-toolcall-label">Error</div>
<div className="execute-toolcall-row-content">
<pre className="execute-toolcall-pre execute-toolcall-error-content">
{errors.join('\n')}
</pre>
</div>
</div>
</div>
</div>
</ToolCallContainer>
);
}
// Success with output
if (textOutputs.length > 0) {
const output = textOutputs.join('\n');
const truncatedOutput =
output.length > 500 ? output.substring(0, 500) + '...' : output;
return (
<ToolCallContainer
label="Execute"
status="success"
toolCallId={toolCallId}
>
{/* Branch connector summary (Claude-like) */}
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0 w-full">{commandText}</span>
</div>
{/* Output card - semantic DOM + Tailwind styles */}
<div className="execute-toolcall-card">
<div className="execute-toolcall-content">
{/* IN row */}
<div className="execute-toolcall-row">
<div className="execute-toolcall-label">IN</div>
<div className="execute-toolcall-row-content">
<pre className="execute-toolcall-pre">{inputCommand}</pre>
</div>
</div>
{/* OUT row */}
<div className="execute-toolcall-row">
<div className="execute-toolcall-label">OUT</div>
<div className="execute-toolcall-row-content">
<div className="execute-toolcall-output-subtle">
<pre className="execute-toolcall-pre">{truncatedOutput}</pre>
</div>
</div>
</div>
</div>
</div>
</ToolCallContainer>
);
}
// Success without output: show command with branch connector
return (
<ToolCallContainer label="Execute" status="success" toolCallId={toolCallId}>
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
<span className="flex-shrink-0 relative top-[-0.1em]"></span>
<span className="flex-shrink-0 w-full">{commandText}</span>
</div>
</ToolCallContainer>
);
};

View File

@@ -12,8 +12,9 @@ import { shouldShowToolCall } from './shared/utils.js';
import { GenericToolCall } from './GenericToolCall.js';
import { ReadToolCall } from './ReadToolCall.js';
import { WriteToolCall } from './WriteToolCall.js';
import { EditToolCall } from './EditToolCall.js';
import { ExecuteToolCall } from './Bash/Bash.js';
import { EditToolCall } from './Edit/EditToolCall.js';
import { ExecuteToolCall as BashExecuteToolCall } from './Bash/Bash.js';
import { ExecuteToolCall } from './Execute/Execute.js';
import { SearchToolCall } from './SearchToolCall.js';
import { ThinkToolCall } from './ThinkToolCall.js';
import { TodoWriteToolCall } from './TodoWriteToolCall.js';
@@ -38,9 +39,11 @@ export const getToolCallComponent = (
return EditToolCall;
case 'execute':
return ExecuteToolCall;
case 'bash':
case 'command':
return ExecuteToolCall;
return BashExecuteToolCall;
case 'search':
case 'grep':

View File

@@ -153,8 +153,53 @@
max-width: 100%;
}
/* Flex container with margin bottom */
/* ToolCall header with loading indicator */
.toolcall-header {
/* TODO: 应该不需要? 待删除 */
/* margin-bottom: 12px; */
position: relative;
}
.toolcall-header::before {
content: '\25cf';
position: absolute;
left: -22px;
top: 50%;
transform: translateY(-50%);
font-size: 10px;
line-height: 1;
z-index: 1;
color: #e1c08d;
animation: toolcallHeaderPulse 1.5s ease-in-out infinite;
}
/* Loading animation for toolcall header */
@keyframes toolcallHeaderPulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* In-progress toolcall specific styles */
.in-progress-toolcall .toolcall-content-wrapper {
display: flex;
flex-direction: column;
gap: 1;
min-width: 0;
max-width: 100%;
}
.in-progress-toolcall .toolcall-header {
display: flex;
align-items: center;
gap: 2;
position: relative;
min-width: 0;
}
.in-progress-toolcall .toolcall-content-text {
word-break: break-word;
white-space: pre-wrap;
width: 100%;
}

View File

@@ -43,11 +43,11 @@ export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
labelSuffix,
}) => (
<div
className={`relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
className={`qwen-message relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
>
{/* Timeline connector line using ::after pseudo-element */}
<div className="toolcall-content-wrapper flex flex-col gap-1 min-w-0 max-w-full">
<div className="flex items-center gap-2 relative min-w-0 toolcall-header">
<div className="flex items-center gap-2 relative min-w-0">
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
{label}
</span>

View File

@@ -3,10 +3,12 @@
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Simplified timeline styles for testing
* Simplified timeline styles for tool calls and messages
* Merged version of both SimpleTimeline.css files
*/
/* Basic timeline container */
.simple-toolcall-container,
.simple-timeline-container {
position: relative;
padding-left: 30px;
@@ -15,6 +17,7 @@
}
/* Timeline connector - simple version */
.simple-toolcall-container::after,
.simple-timeline-container::after {
content: '';
position: absolute;
@@ -26,11 +29,13 @@
}
/* First item connector starts lower */
.simple-toolcall-container:first-child::after,
.simple-timeline-container:first-child::after {
top: 24px;
}
/* Last item connector ends higher */
.simple-toolcall-container:last-child::after,
.simple-timeline-container:last-child::after {
height: calc(100% - 24px);
top: 0;
@@ -38,6 +43,7 @@
}
/* Bullet point */
.simple-toolcall-container::before,
.simple-timeline-container::before {
content: '\25cf';
position: absolute;
@@ -46,4 +52,4 @@
font-size: 10px;
color: var(--app-secondary-foreground);
z-index: 2;
}
}

View File

@@ -1,49 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Simplified timeline styles for tool calls
*/
/* Basic timeline container */
.simple-toolcall-container {
position: relative;
padding-left: 30px;
padding-top: 8px;
padding-bottom: 8px;
}
/* Timeline connector - simple version */
.simple-toolcall-container::after {
content: '';
position: absolute;
left: 12px;
top: 0;
bottom: 0;
width: 1px;
background-color: var(--app-primary-border-color);
}
/* First item connector starts lower */
.simple-toolcall-container:first-child::after {
top: 24px;
}
/* Last item connector ends higher */
.simple-toolcall-container:last-child::after {
height: calc(100% - 24px);
top: 0;
bottom: auto;
}
/* Bullet point */
.simple-toolcall-container::before {
content: '\25cf';
position: absolute;
left: 8px;
padding-top: 2px;
font-size: 10px;
color: var(--app-secondary-foreground);
z-index: 2;
}

View File

@@ -5,6 +5,9 @@
*/
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { BaseMessageHandler } from './BaseMessageHandler.js';
import { getFileName } from '../utils/webviewUtils.js';
@@ -20,6 +23,7 @@ export class FileMessageHandler extends BaseMessageHandler {
'getWorkspaceFiles',
'openFile',
'openDiff',
'createAndOpenTempFile',
].includes(messageType);
}
@@ -47,6 +51,10 @@ export class FileMessageHandler extends BaseMessageHandler {
await this.handleOpenDiff(data);
break;
case 'createAndOpenTempFile':
await this.handleCreateAndOpenTempFile(data);
break;
default:
console.warn(
'[FileMessageHandler] Unknown message type:',
@@ -347,4 +355,52 @@ export class FileMessageHandler extends BaseMessageHandler {
vscode.window.showErrorMessage(`Failed to open diff: ${error}`);
}
}
/**
* Create and open temporary file
*/
private async handleCreateAndOpenTempFile(
data: Record<string, unknown> | undefined,
): Promise<void> {
if (!data) {
console.warn(
'[FileMessageHandler] No data provided for createAndOpenTempFile',
);
return;
}
try {
const content = (data.content as string) || '';
const fileName = (data.fileName as string) || 'temp';
const fileExtension = (data.fileExtension as string) || '.txt';
// Create temporary file path
const tempDir = os.tmpdir();
const tempFileName = `${fileName}-${Date.now()}${fileExtension}`;
const tempFilePath = path.join(tempDir, tempFileName);
// Write content to temporary file
await fs.promises.writeFile(tempFilePath, content, 'utf8');
// Open the temporary file in VS Code
const uri = vscode.Uri.file(tempFilePath);
await vscode.window.showTextDocument(uri, {
preview: false,
preserveFocus: false,
});
console.log(
'[FileMessageHandler] Created and opened temporary file:',
tempFilePath,
);
} catch (error) {
console.error(
'[FileMessageHandler] Failed to create and open temporary file:',
error,
);
vscode.window.showErrorMessage(
`Failed to create and open temporary file: ${error}`,
);
}
}
}

View File

@@ -25,6 +25,7 @@ export class SessionMessageHandler extends BaseMessageHandler {
'getQwenSessions',
'saveSession',
'resumeSession',
'cancelStreaming',
// UI action: open a new chat tab (new WebviewPanel)
'openNewChatTab',
].includes(messageType);
@@ -102,6 +103,11 @@ export class SessionMessageHandler extends BaseMessageHandler {
}
break;
case 'cancelStreaming':
// Handle cancel streaming request from webview
await this.handleCancelStreaming();
break;
default:
console.warn(
'[SessionMessageHandler] Unknown message type:',
@@ -910,6 +916,34 @@ export class SessionMessageHandler extends BaseMessageHandler {
}
}
/**
* Handle cancel streaming request
*/
private async handleCancelStreaming(): Promise<void> {
try {
console.log('[SessionMessageHandler] Canceling streaming...');
// Cancel the current streaming operation in the agent manager
await this.agentManager.cancelCurrentPrompt();
// Send streamEnd message to WebView to update UI
this.sendToWebView({
type: 'streamEnd',
data: { timestamp: Date.now(), reason: 'user_cancelled' },
});
console.log('[SessionMessageHandler] Streaming cancelled successfully');
} catch (_error) {
console.log('[SessionMessageHandler] Streaming cancelled (interrupted)');
// Always send streamEnd to update UI, regardless of errors
this.sendToWebView({
type: 'streamEnd',
data: { timestamp: Date.now(), reason: 'user_cancelled' },
});
}
}
/**
* Handle resume session request
*/

View File

@@ -5,6 +5,7 @@
*/
import { useEffect, useRef, useCallback } from 'react';
import { useVSCode } from './useVSCode.js';
import type { Conversation } from '../../storage/conversationStore.js';
import type {
PermissionOption,
@@ -108,6 +109,8 @@ export const useWebViewMessages = ({
inputFieldRef,
setInputText,
}: UseWebViewMessagesProps) => {
// VS Code API for posting messages back to the extension host
const vscode = useVSCode();
// Use ref to store callbacks to avoid useEffect dependency issues
const handlersRef = useRef({
sessionManagement,
@@ -469,6 +472,8 @@ export const useWebViewMessages = ({
(session.name as string) ||
'Past Conversations';
handlers.sessionManagement.setCurrentSessionTitle(title);
// Update the VS Code webview tab title as well
vscode.postMessage({ type: 'updatePanelTitle', data: { title } });
}
if (message.data.messages) {
handlers.messageHandling.setMessages(message.data.messages);
@@ -487,6 +492,11 @@ export const useWebViewMessages = ({
handlers.sessionManagement.setCurrentSessionTitle(
'Past Conversations',
);
// Reset the VS Code tab title to default label
vscode.postMessage({
type: 'updatePanelTitle',
data: { title: 'Qwen Code' },
});
lastPlanSnapshotRef.current = null;
break;
@@ -496,6 +506,8 @@ export const useWebViewMessages = ({
if (sessionId && title) {
handlers.sessionManagement.setCurrentSessionId(sessionId);
handlers.sessionManagement.setCurrentSessionTitle(title);
// Ask extension host to reflect this title in the tab label
vscode.postMessage({ type: 'updatePanelTitle', data: { title } });
}
break;
}
@@ -563,11 +575,23 @@ export const useWebViewMessages = ({
break;
}
case 'cancelStreaming':
// Handle cancel streaming request from webview
handlers.messageHandling.endStreaming();
handlers.messageHandling.clearWaitingForResponse();
// Add interrupted message
handlers.messageHandling.addMessage({
role: 'assistant',
content: 'Interrupted',
timestamp: Date.now(),
});
break;
default:
break;
}
},
[inputFieldRef, setInputText],
[inputFieldRef, setInputText, vscode],
);
useEffect(() => {

View File

@@ -10,8 +10,8 @@
/* Import component styles */
@import '../components/toolcalls/shared/DiffDisplay.css';
@import '../components/messages/AssistantMessage.css';
@import '../components/messages/SimpleTimeline.css';
@import '../components/toolcalls/shared/SimpleTimeline.css';
@import '../components/toolcalls/shared/MergedSimpleTimeline.css';
@import '../components/messages/QwenMessageTimeline.css';
/* ===========================

View File

@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Temporary file manager for creating and opening temporary files in webview
*/
/**
* Creates a temporary file with the given content and opens it in VS Code
* @param content The content to write to the temporary file
* @param fileName Optional file name (without extension)
* @param fileExtension Optional file extension (defaults to .txt)
*/
export async function createAndOpenTempFile(
postMessage: (message: {
type: string;
data: Record<string, unknown>;
}) => void,
content: string,
fileName: string = 'temp',
fileExtension: string = '.txt',
): Promise<void> {
// Send message to VS Code extension to create and open temp file
postMessage({
type: 'createAndOpenTempFile',
data: {
content,
fileName,
fileExtension,
},
});
}