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

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