feat(vscode-ide-companion): add specialized tool call components

- Added ExecuteNodeToolCall for specialized node/npm command execution
- Added UpdatedPlanToolCall for enhanced plan visualization with checkboxes
- Created ExecuteNode CSS styling
- Refactored ReadToolCall to new directory structure
- Updated tool call router to handle new component types
- Enhanced LayoutComponents with className prop support
- Added specialized handling for todo_write and updated_plan tool kinds

These additions improve the visualization and handling of various tool call types in the UI.
This commit is contained in:
yiliang114
2025-12-04 08:29:07 +08:00
parent 3053e6c41f
commit 5dec3e653c
7 changed files with 376 additions and 9 deletions

View File

@@ -35,7 +35,12 @@ export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// Error case // Error case
if (errors.length > 0) { if (errors.length > 0) {
return ( return (
<ToolCallContainer label="Execute" status="error" toolCallId={toolCallId}> <ToolCallContainer
label="Execute"
status="error"
toolCallId={toolCallId}
className="execute-default-toolcall"
>
{/* Branch connector summary (Claude-like) */} {/* 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"> <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 relative top-[-0.1em]"></span>

View File

@@ -0,0 +1,38 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* ExecuteNode tool call styles
*/
/* Error content styling */
.execute-node-error-content {
color: #c74e39;
margin-top: 4px;
}
/* Preformatted content */
.execute-node-pre {
margin: 0;
font-family: var(--app-monospace-font-family);
font-size: 0.85em;
white-space: pre-wrap;
word-break: break-word;
}
/* Error preformatted content */
.execute-node-error-pre {
color: #c74e39;
}
/* Output content styling */
.execute-node-output-content {
background-color: var(--app-code-background);
border: 0.5px solid var(--app-input-border);
border-radius: 5px;
margin: 8px 0;
padding: 8px;
max-width: 100%;
box-sizing: border-box;
}

View File

@@ -0,0 +1,75 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* ExecuteNode tool call component - specialized for node/npm 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 './ExecuteNode.css';
/**
* Specialized component for ExecuteNode tool calls
* Shows: Execute bullet + description + branch connector
*/
export const ExecuteNodeToolCall: 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}
className="execute-toolcall"
>
{/* 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 content */}
<div className="execute-node-error-content">
<pre className="execute-node-pre execute-node-error-pre">
{errors.join('\n')}
</pre>
</div>
</ToolCallContainer>
);
}
// Success case: show command with branch connector (similar to the example)
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>
{textOutputs.length > 0 && (
<div className="execute-node-output-content">
<pre className="execute-node-pre">{textOutputs.join('\n')}</pre>
</div>
)}
</ToolCallContainer>
);
};

View File

@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Read tool call component - specialized for file reading operations
*/
import type React from 'react';
import type { BaseToolCallProps } from '../shared/types.js';
import { ToolCallContainer } from '../shared/LayoutComponents.js';
import { groupContent } from '../shared/utils.js';
import { FileLink } from '../../ui/FileLink.js';
/**
* Specialized component for Read tool calls
* Optimized for displaying file reading operations
* Shows: Read filename (no content preview)
*/
export const ReadToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { content, locations, toolCallId } = toolCall;
// Group content by type
const { errors } = groupContent(content);
// Error case: show error
if (errors.length > 0) {
const path = locations?.[0]?.path || '';
return (
<ToolCallContainer
label={'Read'}
className="read-tool-call-error"
status="error"
toolCallId={toolCallId}
labelSuffix={
path ? (
<FileLink
path={path}
showFullPath={false}
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
/>
) : undefined
}
>
{errors.join('\n')}
</ToolCallContainer>
);
}
// Success case: show which file was read with filename in label
if (locations && locations.length > 0) {
const path = locations[0].path;
return (
<ToolCallContainer
label={'Read'}
className="read-tool-call-success"
status="success"
toolCallId={toolCallId}
labelSuffix={
path ? (
<FileLink
path={path}
showFullPath={false}
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
/>
) : undefined
}
>
{null}
</ToolCallContainer>
);
}
// No file info, don't show
return null;
};

View File

@@ -0,0 +1,140 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* UpdatedPlan tool call component - specialized for plan update operations
*/
import type React from 'react';
import type { BaseToolCallProps } from '../shared/types.js';
import { ToolCallContainer } from '../shared/LayoutComponents.js';
import { groupContent, safeTitle } from '../shared/utils.js';
import { CheckboxDisplay } from '../../ui/CheckboxDisplay.js';
type EntryStatus = 'pending' | 'in_progress' | 'completed';
interface PlanEntry {
content: string;
status: EntryStatus;
}
const mapToolStatusToBullet = (
status: import('../shared/types.js').ToolCallStatus,
): 'success' | 'error' | 'warning' | 'loading' | 'default' => {
switch (status) {
case 'completed':
return 'success';
case 'failed':
return 'error';
case 'in_progress':
return 'warning';
case 'pending':
return 'loading';
default:
return 'default';
}
};
// Parse plan entries with - [ ] / - [x] from text as much as possible
const parsePlanEntries = (textOutputs: string[]): PlanEntry[] => {
const text = textOutputs.join('\n');
const lines = text.split(/\r?\n/);
const entries: PlanEntry[] = [];
const todoRe = /^(?:\s*(?:[-*]|\d+[.)])\s*)?\[( |x|X|-)\]\s+(.*)$/;
for (const line of lines) {
const m = line.match(todoRe);
if (m) {
const mark = m[1];
const title = m[2].trim();
const status: EntryStatus =
mark === 'x' || mark === 'X'
? 'completed'
: mark === '-'
? 'in_progress'
: 'pending';
if (title) {
entries.push({ content: title, status });
}
}
}
// If no match is found, fall back to treating non-empty lines as pending items
if (entries.length === 0) {
for (const line of lines) {
const title = line.trim();
if (title) {
entries.push({ content: title, status: 'pending' });
}
}
}
return entries;
};
/**
* Specialized component for UpdatedPlan tool calls
* Optimized for displaying plan update operations
*/
export const UpdatedPlanToolCall: React.FC<BaseToolCallProps> = ({
toolCall,
}) => {
const { content, status } = toolCall;
const { errors, textOutputs } = groupContent(content);
// Error-first display
if (errors.length > 0) {
return (
<ToolCallContainer label="Updated Plan" status="error">
{errors.join('\n')}
</ToolCallContainer>
);
}
const entries = parsePlanEntries(textOutputs);
const label = safeTitle(toolCall.title) || 'Updated Plan';
return (
<ToolCallContainer
label={label}
status={mapToolStatusToBullet(status)}
className="update-plan-toolcall"
>
<ul className="Fr list-none p-0 m-0 flex flex-col gap-1">
{entries.map((entry, idx) => {
const isDone = entry.status === 'completed';
const isIndeterminate = entry.status === 'in_progress';
return (
<li
key={idx}
className={[
'Hr flex items-start gap-2 p-0 rounded text-[var(--app-primary-foreground)]',
isDone ? 'fo opacity-70' : '',
].join(' ')}
>
<label className="flex items-start gap-2">
<CheckboxDisplay
checked={isDone}
indeterminate={isIndeterminate}
/>
</label>
<div
className={[
'vo flex-1 text-xs leading-[1.5] text-[var(--app-primary-foreground)]',
isDone
? 'line-through text-[var(--app-secondary-foreground)] opacity-70'
: 'opacity-85',
].join(' ')}
>
{entry.content}
</div>
</li>
);
})}
</ul>
</ToolCallContainer>
);
};

View File

@@ -10,11 +10,13 @@ import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js'; import type { BaseToolCallProps } from './shared/types.js';
import { shouldShowToolCall } from './shared/utils.js'; import { shouldShowToolCall } from './shared/utils.js';
import { GenericToolCall } from './GenericToolCall.js'; import { GenericToolCall } from './GenericToolCall.js';
import { ReadToolCall } from './ReadToolCall.js'; import { ReadToolCall } from './Read/ReadToolCall.js';
import { WriteToolCall } from './WriteToolCall.js'; import { WriteToolCall } from './WriteToolCall.js';
import { EditToolCall } from './Edit/EditToolCall.js'; import { EditToolCall } from './Edit/EditToolCall.js';
import { ExecuteToolCall as BashExecuteToolCall } from './Bash/Bash.js'; import { ExecuteToolCall as BashExecuteToolCall } from './Bash/Bash.js';
import { ExecuteToolCall } from './Execute/Execute.js'; import { ExecuteToolCall } from './Execute/Execute.js';
import { UpdatedPlanToolCall } from './UpdatedPlan/UpdatedPlanToolCall.js';
import { ExecuteNodeToolCall } from './ExecuteNode/ExecuteNodeToolCall.js';
import { SearchToolCall } from './SearchToolCall.js'; import { SearchToolCall } from './SearchToolCall.js';
import { ThinkToolCall } from './ThinkToolCall.js'; import { ThinkToolCall } from './ThinkToolCall.js';
import { TodoWriteToolCall } from './TodoWriteToolCall.js'; import { TodoWriteToolCall } from './TodoWriteToolCall.js';
@@ -24,6 +26,7 @@ import { TodoWriteToolCall } from './TodoWriteToolCall.js';
*/ */
export const getToolCallComponent = ( export const getToolCallComponent = (
kind: string, kind: string,
toolCall?: import('./shared/types.js').ToolCallData,
): React.FC<BaseToolCallProps> => { ): React.FC<BaseToolCallProps> => {
const normalizedKind = kind.toLowerCase(); const normalizedKind = kind.toLowerCase();
@@ -39,12 +42,35 @@ export const getToolCallComponent = (
return EditToolCall; return EditToolCall;
case 'execute': case 'execute':
// Check if this is a node/npm version check command
if (toolCall) {
const commandText =
typeof toolCall.rawInput === 'string'
? toolCall.rawInput
: typeof toolCall.rawInput === 'object' &&
toolCall.rawInput !== null
? (toolCall.rawInput as { command?: string }).command || ''
: '';
// TODO:
if (
commandText.includes('node --version') ||
commandText.includes('npm --version')
) {
return ExecuteNodeToolCall;
}
}
return ExecuteToolCall; return ExecuteToolCall;
case 'bash': case 'bash':
case 'command': case 'command':
return BashExecuteToolCall; return BashExecuteToolCall;
case 'updated_plan':
case 'updatedplan':
case 'todo_write':
return UpdatedPlanToolCall;
case 'search': case 'search':
case 'grep': case 'grep':
case 'glob': case 'glob':
@@ -56,7 +82,8 @@ export const getToolCallComponent = (
return ThinkToolCall; return ThinkToolCall;
case 'todowrite': case 'todowrite':
case 'todo_write': return TodoWriteToolCall;
// case 'todo_write':
case 'update_todos': case 'update_todos':
return TodoWriteToolCall; return TodoWriteToolCall;
@@ -82,7 +109,7 @@ export const ToolCallRouter: React.FC<BaseToolCallProps> = ({ toolCall }) => {
} }
// Get the appropriate component for this kind // Get the appropriate component for this kind
const Component = getToolCallComponent(toolCall.kind); const Component = getToolCallComponent(toolCall.kind, toolCall);
// Render the specialized component // Render the specialized component
return <Component toolCall={toolCall} />; return <Component toolCall={toolCall} />;

View File

@@ -25,6 +25,8 @@ interface ToolCallContainerProps {
toolCallId?: string; toolCallId?: string;
/** Optional trailing content rendered next to label (e.g., clickable filename) */ /** Optional trailing content rendered next to label (e.g., clickable filename) */
labelSuffix?: React.ReactNode; labelSuffix?: React.ReactNode;
/** Optional custom class name */
className?: string;
} }
// NOTE: We previously computed a bullet color class in JS, but the current // NOTE: We previously computed a bullet color class in JS, but the current
@@ -40,9 +42,10 @@ export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
children, children,
toolCallId: _toolCallId, toolCallId: _toolCallId,
labelSuffix, labelSuffix,
className: _className,
}) => ( }) => (
<div <div
className={`qwen-message relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`} className={`qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
> >
{/* Timeline connector line using ::after pseudo-element */} {/* 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="toolcall-content-wrapper flex flex-col gap-1 min-w-0 max-w-full">
@@ -50,15 +53,18 @@ export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]"> <span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
{label} {label}
</span> </span>
{/* {toolCallId && ( {/* TODO: for 调试 */}
{_toolCallId && (
<span className="text-[10px] opacity-30"> <span className="text-[10px] opacity-30">
[{toolCallId.slice(-8)}] [{_toolCallId.slice(-8)}]
</span> </span>
)} */} )}
{labelSuffix} {labelSuffix}
</div> </div>
{children && ( {children && (
<div className="text-[var(--app-secondary-foreground)]">{children}</div> <div className="text-[var(--app-secondary-foreground)] py-2">
{children}
</div>
)} )}
</div> </div>
</div> </div>