refactor(webview): 重构工具调用显示逻辑

- 新增多个工具调用组件,分别处理不同类型的工具调用
- 优化工具调用卡片的样式和布局
- 添加加载状态和随机加载消息
- 重构 App 组件,支持新的工具调用显示逻辑
This commit is contained in:
yiliang114
2025-11-19 15:42:35 +08:00
parent 04dfad7ab5
commit 454cbfdde4
15 changed files with 1564 additions and 218 deletions

View File

@@ -735,10 +735,6 @@ export class WebViewProvider {
try {
console.log('[WebViewProvider] Switching to Qwen session:', sessionId);
// Set current conversation ID so we can send messages
this.currentConversationId = sessionId;
console.log('[WebViewProvider] Set currentConversationId to:', sessionId);
// Get session messages from local files
const messages = await this.agentManager.getSessionMessages(sessionId);
console.log(
@@ -758,44 +754,38 @@ export class WebViewProvider {
console.log('[WebViewProvider] Could not get session details:', err);
}
// Try to switch session in ACP (may fail if not supported)
// IMPORTANT: CLI doesn't support loading old sessions
// So we always create a NEW ACP session for continuation
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
try {
await this.agentManager.switchToSession(sessionId);
console.log('[WebViewProvider] Session switched successfully in ACP');
} catch (_switchError) {
const newAcpSessionId =
await this.agentManager.createNewSession(workingDir);
console.log(
'[WebViewProvider] session/switch not supported or failed, creating new session',
'[WebViewProvider] Created new ACP session for conversation:',
newAcpSessionId,
);
// If switch fails, create a new session to continue conversation
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
const workingDir = workspaceFolder?.uri.fsPath || process.cwd();
try {
const newSessionId =
await this.agentManager.createNewSession(workingDir);
console.log(
'[WebViewProvider] Created new session as fallback:',
newSessionId,
);
if (newSessionId) {
// Update to the new session ID so messages can be sent
this.currentConversationId = newSessionId;
console.log(
'[WebViewProvider] Updated currentConversationId to new session:',
newSessionId,
);
}
} catch (newSessionError) {
console.error(
'[WebViewProvider] Failed to create new session:',
newSessionError,
);
vscode.window.showWarningMessage(
'Could not switch to session. Created new session instead.',
);
}
// Use the NEW ACP session ID for sending messages to CLI
this.currentConversationId = newAcpSessionId;
console.log(
'[WebViewProvider] Set currentConversationId (ACP) to:',
newAcpSessionId,
);
} catch (createError) {
console.error(
'[WebViewProvider] Failed to create new ACP session:',
createError,
);
vscode.window.showWarningMessage(
'Could not switch to session. Created new session instead.',
);
throw createError;
}
// Send messages and session details to WebView
// The historical messages are display-only, not sent to CLI
this.sendMessageToWebView({
type: 'qwenSessionSwitched',
data: { sessionId, messages, session: sessionDetails },

View File

@@ -206,7 +206,7 @@ button {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 20px 20px 40px;
padding: 20px 20px 120px;
display: flex;
flex-direction: column;
gap: var(--app-spacing-medium);
@@ -686,3 +686,330 @@ button {
height: 20px;
}
/* ===========================
Tool Call Card Styles (Grid Layout)
=========================== */
.tool-call-card {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--app-spacing-medium);
background: var(--app-input-background);
border: 1px solid var(--app-input-border);
border-radius: var(--corner-radius-medium);
padding: var(--app-spacing-large);
margin: var(--app-spacing-medium) 0;
animation: fadeIn 0.2s ease-in;
align-items: start;
}
.tool-call-icon {
font-size: 20px;
grid-row: 1;
padding-top: 2px;
}
.tool-call-grid {
display: flex;
flex-direction: column;
gap: var(--app-spacing-medium);
min-width: 0;
}
.tool-call-row {
display: grid;
grid-template-columns: 80px 1fr;
gap: var(--app-spacing-medium);
min-width: 0;
}
.tool-call-label {
font-size: 12px;
color: var(--app-secondary-foreground);
font-weight: 500;
padding-top: 2px;
}
.tool-call-value {
color: var(--app-primary-foreground);
min-width: 0;
word-break: break-word;
}
.tool-call-status-indicator {
display: inline-block;
font-weight: 500;
position: relative;
}
.tool-call-status-indicator::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.tool-call-status-indicator.pending::before {
background: #ffc107;
}
.tool-call-status-indicator.in_progress::before {
background: #2196f3;
}
.tool-call-status-indicator.completed::before {
background: #4caf50;
}
.tool-call-status-indicator.failed::before {
background: #f44336;
}
.code-block {
font-family: var(--app-monospace-font-family);
font-size: var(--app-monospace-font-size);
background: var(--app-primary-background);
border: 1px solid var(--app-input-border);
border-radius: var(--corner-radius-small);
padding: var(--app-spacing-medium);
overflow-x: auto;
margin: 4px 0 0 0;
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
}
/* ===========================
Permission Request Card Styles
=========================== */
.permission-request-card {
background: var(--app-input-background);
border: 1px solid var(--app-qwen-orange);
border-radius: var(--corner-radius-medium);
margin: var(--app-spacing-medium) 0;
margin-bottom: var(--app-spacing-xlarge);
overflow: visible;
animation: fadeIn 0.2s ease-in;
}
.permission-card-body {
padding: var(--app-spacing-large);
min-height: fit-content;
height: auto;
}
.permission-header {
display: flex;
align-items: center;
gap: var(--app-spacing-large);
margin-bottom: var(--app-spacing-large);
}
.permission-icon-wrapper {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(97, 95, 255, 0.1);
border-radius: var(--corner-radius-medium);
flex-shrink: 0;
}
.permission-icon {
font-size: 20px;
}
.permission-info {
flex: 1;
min-width: 0;
}
.permission-title {
font-weight: 600;
color: var(--app-primary-foreground);
margin-bottom: 2px;
}
.permission-subtitle {
font-size: 12px;
color: var(--app-secondary-foreground);
}
.permission-command-section {
margin-bottom: var(--app-spacing-large);
}
.permission-command-label {
font-size: 11px;
font-weight: 600;
color: var(--app-secondary-foreground);
margin-bottom: var(--app-spacing-small);
text-transform: uppercase;
}
.permission-command-code {
display: block;
font-family: var(--app-monospace-font-family);
font-size: var(--app-monospace-font-size);
color: var(--app-primary-foreground);
background: var(--app-primary-background);
padding: var(--app-spacing-medium);
border-radius: var(--corner-radius-small);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
.permission-locations-section {
margin-bottom: var(--app-spacing-large);
}
.permission-locations-label {
font-size: 11px;
font-weight: 600;
color: var(--app-secondary-foreground);
margin-bottom: var(--app-spacing-small);
text-transform: uppercase;
}
.permission-location-item {
display: flex;
align-items: center;
gap: var(--app-spacing-small);
padding: var(--app-spacing-small) 0;
font-size: 12px;
}
.permission-location-icon {
flex-shrink: 0;
}
.permission-location-path {
color: var(--app-primary-foreground);
font-family: var(--app-monospace-font-family);
}
.permission-location-line {
color: var(--app-secondary-foreground);
}
.permission-options-section {
margin-top: var(--app-spacing-large);
}
.permission-options-label {
font-size: 12px;
font-weight: 500;
color: var(--app-primary-foreground);
margin-bottom: var(--app-spacing-medium);
}
.permission-options-list {
display: flex;
flex-direction: column;
gap: var(--app-spacing-small);
}
.permission-option {
display: flex;
align-items: center;
gap: var(--app-spacing-medium);
padding: var(--app-spacing-medium) var(--app-spacing-large);
background: var(--app-primary-background);
border: 1px solid var(--app-input-border);
border-radius: var(--corner-radius-small);
cursor: pointer;
transition: all 0.15s ease;
}
.permission-option:hover {
background: var(--app-list-hover-background);
border-color: var(--app-input-active-border);
}
.permission-option.selected {
border-color: var(--app-qwen-orange);
background: rgba(97, 95, 255, 0.1);
}
.permission-option.allow {
/* Allow options */
}
.permission-option.reject {
/* Reject options */
}
.permission-radio {
flex-shrink: 0;
}
.permission-option-content {
display: flex;
align-items: center;
gap: var(--app-spacing-small);
flex: 1;
}
.permission-always-badge {
font-size: 12px;
}
.permission-no-options {
text-align: center;
padding: var(--app-spacing-large);
color: var(--app-secondary-foreground);
}
.permission-actions {
margin-top: var(--app-spacing-large);
display: flex;
justify-content: flex-end;
}
.permission-confirm-button {
padding: var(--app-spacing-medium) var(--app-spacing-xlarge);
background: var(--app-qwen-clay-button-orange);
color: var(--app-qwen-ivory);
border: none;
border-radius: var(--corner-radius-small);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: filter 0.15s ease;
}
.permission-confirm-button:hover:not(:disabled) {
filter: brightness(1.1);
}
.permission-confirm-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.permission-success {
display: flex;
align-items: center;
justify-content: center;
gap: var(--app-spacing-medium);
padding: var(--app-spacing-large);
background: rgba(76, 175, 80, 0.1);
border-radius: var(--corner-radius-small);
margin-top: var(--app-spacing-large);
}
.permission-success-icon {
color: #4caf50;
font-weight: bold;
}
.permission-success-text {
color: #4caf50;
font-size: 13px;
}

View File

@@ -46,11 +46,152 @@ interface TextMessage {
timestamp: number;
}
// Loading messages from Claude Code CLI
// Source: packages/cli/src/ui/hooks/usePhraseCycler.ts
const WITTY_LOADING_PHRASES = [
"I'm Feeling Lucky",
'Shipping awesomeness... ',
'Painting the serifs back on...',
'Navigating the slime mold...',
'Consulting the digital spirits...',
'Reticulating splines...',
'Warming up the AI hamsters...',
'Asking the magic conch shell...',
'Generating witty retort...',
'Polishing the algorithms...',
"Don't rush perfection (or my code)...",
'Brewing fresh bytes...',
'Counting electrons...',
'Engaging cognitive processors...',
'Checking for syntax errors in the universe...',
'One moment, optimizing humor...',
'Shuffling punchlines...',
'Untangling neural nets...',
'Compiling brilliance...',
'Loading wit.exe...',
'Summoning the cloud of wisdom...',
'Preparing a witty response...',
"Just a sec, I'm debugging reality...",
'Confuzzling the options...',
'Tuning the cosmic frequencies...',
'Crafting a response worthy of your patience...',
'Compiling the 1s and 0s...',
'Resolving dependencies... and existential crises...',
'Defragmenting memories... both RAM and personal...',
'Rebooting the humor module...',
'Caching the essentials (mostly cat memes)...',
'Optimizing for ludicrous speed',
"Swapping bits... don't tell the bytes...",
'Garbage collecting... be right back...',
'Assembling the interwebs...',
'Converting coffee into code...',
'Updating the syntax for reality...',
'Rewiring the synapses...',
'Looking for a misplaced semicolon...',
"Greasin' the cogs of the machine...",
'Pre-heating the servers...',
'Calibrating the flux capacitor...',
'Engaging the improbability drive...',
'Channeling the Force...',
'Aligning the stars for optimal response...',
'So say we all...',
'Loading the next great idea...',
"Just a moment, I'm in the zone...",
'Preparing to dazzle you with brilliance...',
"Just a tick, I'm polishing my wit...",
"Hold tight, I'm crafting a masterpiece...",
"Just a jiffy, I'm debugging the universe...",
"Just a moment, I'm aligning the pixels...",
"Just a sec, I'm optimizing the humor...",
"Just a moment, I'm tuning the algorithms...",
'Warp speed engaged...',
'Mining for more Dilithium crystals...',
"Don't panic...",
'Following the white rabbit...',
'The truth is in here... somewhere...',
'Blowing on the cartridge...',
'Loading... Do a barrel roll!',
'Waiting for the respawn...',
'Finishing the Kessel Run in less than 12 parsecs...',
"The cake is not a lie, it's just still loading...",
'Fiddling with the character creation screen...',
"Just a moment, I'm finding the right meme...",
"Pressing 'A' to continue...",
'Herding digital cats...',
'Polishing the pixels...',
'Finding a suitable loading screen pun...',
'Distracting you with this witty phrase...',
'Almost there... probably...',
'Our hamsters are working as fast as they can...',
'Giving Cloudy a pat on the head...',
'Petting the cat...',
'Rickrolling my boss...',
'Never gonna give you up, never gonna let you down...',
'Slapping the bass...',
'Tasting the snozberries...',
"I'm going the distance, I'm going for speed...",
'Is this the real life? Is this just fantasy?...',
"I've got a good feeling about this...",
'Poking the bear...',
'Doing research on the latest memes...',
'Figuring out how to make this more witty...',
'Hmmm... let me think...',
'What do you call a fish with no eyes? A fsh...',
'Why did the computer go to therapy? It had too many bytes...',
"Why don't programmers like nature? It has too many bugs...",
'Why do programmers prefer dark mode? Because light attracts bugs...',
'Why did the developer go broke? Because they used up all their cache...',
"What can you do with a broken pencil? Nothing, it's pointless...",
'Applying percussive maintenance...',
'Searching for the correct USB orientation...',
'Ensuring the magic smoke stays inside the wires...',
'Rewriting in Rust for no particular reason...',
'Trying to exit Vim...',
'Spinning up the hamster wheel...',
"That's not a bug, it's an undocumented feature...",
'Engage.',
"I'll be back... with an answer.",
'My other process is a TARDIS...',
'Communing with the machine spirit...',
'Letting the thoughts marinate...',
'Just remembered where I put my keys...',
'Pondering the orb...',
"I've seen things you people wouldn't believe... like a user who reads loading messages.",
'Initiating thoughtful gaze...',
"What's a computer's favorite snack? Microchips.",
"Why do Java developers wear glasses? Because they don't C#.",
'Charging the laser... pew pew!',
'Dividing by zero... just kidding!',
'Looking for an adult superviso... I mean, processing.',
'Making it go beep boop.',
'Buffering... because even AIs need a moment.',
'Entangling quantum particles for a faster response...',
'Polishing the chrome... on the algorithms.',
'Are you not entertained? (Working on it!)',
'Summoning the code gremlins... to help, of course.',
'Just waiting for the dial-up tone to finish...',
'Recalibrating the humor-o-meter.',
'My other loading screen is even funnier.',
"Pretty sure there's a cat walking on the keyboard somewhere...",
'Enhancing... Enhancing... Still loading.',
"It's not a bug, it's a feature... of this loading screen.",
'Have you tried turning it off and on again? (The loading screen, not me.)',
'Constructing additional pylons...',
"New line? That's Ctrl+J.",
];
const getRandomLoadingMessage = () =>
WITTY_LOADING_PHRASES[
Math.floor(Math.random() * WITTY_LOADING_PHRASES.length)
];
export const App: React.FC = () => {
const vscode = useVSCode();
const [messages, setMessages] = useState<TextMessage[]>([]);
const [inputText, setInputText] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [_isWaitingForResponse, setIsWaitingForResponse] = useState(false);
const [_loadingMessage, setLoadingMessage] = useState('');
const [currentStreamContent, setCurrentStreamContent] = useState('');
const [qwenSessions, setQwenSessions] = useState<
Array<Record<string, unknown>>
@@ -96,6 +237,17 @@ export const App: React.FC = () => {
const newMap = new Map(prev);
const existing = newMap.get(update.toolCallId);
// Helper function to safely convert title to string
const safeTitle = (title: unknown): string => {
if (typeof title === 'string') {
return title;
}
if (title && typeof title === 'object') {
return JSON.stringify(title);
}
return 'Tool Call';
};
if (update.type === 'tool_call') {
// New tool call - cast content to proper type
const content = update.content?.map((item) => ({
@@ -109,7 +261,7 @@ export const App: React.FC = () => {
newMap.set(update.toolCallId, {
toolCallId: update.toolCallId,
kind: update.kind || 'other',
title: update.title || 'Tool Call',
title: safeTitle(update.title),
status: update.status || 'pending',
rawInput: update.rawInput as string | object | undefined,
content,
@@ -130,7 +282,7 @@ export const App: React.FC = () => {
newMap.set(update.toolCallId, {
...existing,
...(update.kind && { kind: update.kind }),
...(update.title && { title: update.title }),
...(update.title && { title: safeTitle(update.title) }),
...(update.status && { status: update.status }),
...(updatedContent && { content: updatedContent }),
...(update.locations && { locations: update.locations }),
@@ -286,6 +438,10 @@ export const App: React.FC = () => {
return;
}
// Set waiting state with random loading message
setIsWaitingForResponse(true);
setLoadingMessage(getRandomLoadingMessage());
vscode.postMessage({
type: 'sendMessage',
data: { text: inputText },

View File

@@ -270,4 +270,135 @@
--app-menu-foreground: var(--vscode-menu-foreground);
--app-menu-selection-background: var(--vscode-menu-selectionBackground);
--app-menu-selection-foreground: var(--vscode-menu-selectionForeground);
/* Tool Call Styles */
--app-tool-background: var(--vscode-editor-background);
--app-code-background: var(--vscode-textCodeBlock-background);
}
/* ===========================
Tool Call Card (from Claude Code .Ne)
=========================== */
.tool-call-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;
}
/* Tool Call Grid Layout (from Claude Code .Ke) */
.tool-call-grid {
display: grid;
grid-template-columns: max-content 1fr;
}
/* Tool Call Row (from Claude Code .no) */
.tool-call-row {
grid-column: 1 / -1;
display: grid;
grid-template-columns: subgrid;
border-top: 0.5px solid var(--app-input-border);
padding: 4px;
}
.tool-call-row:first-child {
border-top: none;
}
/* Tool Call Label (from Claude Code .Je) */
.tool-call-label {
grid-column: 1;
color: var(--app-secondary-foreground);
text-align: left;
opacity: 0.5;
padding: 4px 8px 4px 4px;
font-family: var(--app-monospace-font-family);
font-size: 0.85em;
}
/* Tool Call Value (from Claude Code .m) */
.tool-call-value {
grid-column: 2;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
padding: 4px;
}
.tool-call-value:not(.expanded) {
max-height: 60px;
mask-image: linear-gradient(to bottom, var(--app-primary-background) 40px, transparent 60px);
overflow: hidden;
}
.tool-call-value pre {
margin-block: 0;
overflow: hidden;
font-family: var(--app-monospace-font-family);
font-size: 0.85em;
}
.tool-call-value code {
margin: 0;
padding: 0;
font-family: var(--app-monospace-font-family);
font-size: 0.85em;
}
/* Tool Call Icon (from Claude Code .to) */
.tool-call-icon {
margin: 8px;
opacity: 0.5;
}
/* Code Block (from Claude Code ._e) */
.code-block {
background-color: var(--app-code-background);
white-space: pre;
overflow-x: auto;
max-width: 100%;
min-width: 0;
width: 100%;
box-sizing: border-box;
padding: 8px;
border-radius: 4px;
font-family: var(--app-monospace-font-family);
font-size: 0.85em;
}
/* Status indicators for tool calls */
.tool-call-status-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
}
.tool-call-status-indicator::before {
content: "●";
font-size: 10px;
}
.tool-call-status-indicator.pending::before {
color: var(--app-secondary-foreground);
}
.tool-call-status-indicator.in-progress::before {
color: #e1c08d;
animation: blink 1s linear infinite;
}
.tool-call-status-indicator.completed::before {
color: #74c991;
}
.tool-call-status-indicator.failed::before {
color: #c74e39;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}

View File

@@ -2,188 +2,37 @@
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Main ToolCall component - uses factory pattern to route to specialized components
*
* This file serves as the public API for tool call rendering.
* It re-exports the router and types from the toolcalls module.
*/
import type React from 'react';
import { ToolCallRouter } from './toolcalls/index.js';
export interface ToolCallContent {
type: 'content' | 'diff';
// For content type
content?: {
type: string;
text?: string;
[key: string]: unknown;
};
// For diff type
path?: string;
oldText?: string | null;
newText?: string;
}
// Re-export types from the toolcalls module for backward compatibility
export type {
ToolCallData,
BaseToolCallProps as ToolCallProps,
} from './toolcalls/shared/types.js';
export interface ToolCallData {
toolCallId: string;
kind: string;
title: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
rawInput?: string | object;
content?: ToolCallContent[];
locations?: Array<{
path: string;
line?: number | null;
}>;
}
// Re-export the content type for external use
export type { ToolCallContent } from './toolcalls/shared/types.js';
export interface ToolCallProps {
toolCall: ToolCallData;
}
const StatusTag: React.FC<{ status: string }> = ({ status }) => {
const getStatusInfo = () => {
switch (status) {
case 'pending':
return { className: 'status-pending', text: 'Pending', icon: '⏳' };
case 'in_progress':
return {
className: 'status-in-progress',
text: 'In Progress',
icon: '🔄',
};
case 'completed':
return { className: 'status-completed', text: 'Completed', icon: '✓' };
case 'failed':
return { className: 'status-failed', text: 'Failed', icon: '✗' };
default:
return { className: 'status-unknown', text: status, icon: '•' };
}
};
const { className, text, icon } = getStatusInfo();
return (
<span className={`tool-call-status ${className}`}>
<span className="status-icon">{icon}</span>
{text}
</span>
);
};
const ContentView: React.FC<{ content: ToolCallContent }> = ({ content }) => {
// Handle diff type
if (content.type === 'diff') {
const fileName =
content.path?.split(/[/\\]/).pop() || content.path || 'Unknown file';
const oldText = content.oldText || '';
const newText = content.newText || '';
return (
<div className="tool-call-diff">
<div className="diff-header">
<span className="diff-icon">📝</span>
<span className="diff-filename">{fileName}</span>
</div>
<div className="diff-content">
<div className="diff-side">
<div className="diff-side-label">Before</div>
<pre className="diff-code">{oldText || '(empty)'}</pre>
</div>
<div className="diff-arrow"></div>
<div className="diff-side">
<div className="diff-side-label">After</div>
<pre className="diff-code">{newText || '(empty)'}</pre>
</div>
</div>
</div>
);
}
// Handle content type with text
if (content.type === 'content' && content.content?.text) {
return (
<div className="tool-call-content">
<div className="content-text">{content.content.text}</div>
</div>
);
}
return null;
};
const getKindDisplayName = (kind: string): { name: string; icon: string } => {
const kindMap: Record<string, { name: string; icon: string }> = {
edit: { name: 'File Edit', icon: '✏️' },
read: { name: 'File Read', icon: '📖' },
execute: { name: 'Shell Command', icon: '⚡' },
fetch: { name: 'Web Fetch', icon: '🌐' },
delete: { name: 'Delete', icon: '🗑️' },
move: { name: 'Move/Rename', icon: '📦' },
search: { name: 'Search', icon: '🔍' },
think: { name: 'Thinking', icon: '💭' },
other: { name: 'Other', icon: '🔧' },
};
return kindMap[kind] || { name: kind, icon: '🔧' };
};
const formatRawInput = (rawInput: string | object | undefined): string => {
if (rawInput === undefined) {
return '';
}
if (typeof rawInput === 'string') {
return rawInput;
}
return JSON.stringify(rawInput, null, 2);
};
export const ToolCall: React.FC<ToolCallProps> = ({ toolCall }) => {
const { kind, title, status, rawInput, content, locations, toolCallId } =
toolCall;
const kindInfo: { name: string; icon: string } = getKindDisplayName(kind);
return (
<div className="tool-call-card">
<div className="tool-call-header">
<span className="tool-call-kind-icon">{kindInfo.icon}</span>
<span className="tool-call-title">{title || kindInfo.name}</span>
<StatusTag status={status} />
</div>
{/* Show raw input if available */}
{rawInput !== undefined && rawInput !== null ? (
<div className="tool-call-raw-input">
<div className="raw-input-label">Input</div>
<pre className="raw-input-content">{formatRawInput(rawInput)}</pre>
</div>
) : null}
{/* Show locations if available */}
{locations && locations.length > 0 && (
<div className="tool-call-locations">
<div className="locations-label">Files</div>
{locations.map((location, index) => (
<div key={index} className="location-item">
<span className="location-icon">📄</span>
<span className="location-path">{location.path}</span>
{location.line !== null && location.line !== undefined && (
<span className="location-line">:{location.line}</span>
)}
</div>
))}
</div>
)}
{/* Show content if available */}
{content && content.length > 0 && (
<div className="tool-call-content-list">
{content.map((item, index) => (
<ContentView key={index} content={item} />
))}
</div>
)}
<div className="tool-call-footer">
<span className="tool-call-id">
ID: {toolCallId.substring(0, 8)}...
</span>
</div>
</div>
);
};
/**
* Main ToolCall component
* Routes to specialized components based on the tool call kind
*
* Supported kinds:
* - read: File reading operations
* - write/edit: File writing and editing operations
* - execute/bash/command: Command execution
* - search/grep/glob/find: Search operations
* - think/thinking: AI reasoning
* - All others: Generic display
*/
export const ToolCall: React.FC<{
toolCall: import('./toolcalls/shared/types.js').ToolCallData;
}> = ({ toolCall }) => <ToolCallRouter toolCall={toolCall} />;

View File

@@ -0,0 +1,70 @@
/**
* @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 {
ToolCallCard,
ToolCallRow,
StatusIndicator,
CodeBlock,
} from './shared/LayoutComponents.js';
import { formatValue, safeTitle, groupContent } from './shared/utils.js';
/**
* Specialized component for Execute tool calls
* Optimized for displaying command execution with stdout/stderr
*/
export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { title, status, rawInput, content } = toolCall;
const titleText = safeTitle(title);
// Group content by type
const { textOutputs, errors, otherData } = groupContent(content);
return (
<ToolCallCard icon="⚡">
{/* Title row */}
<ToolCallRow label="Execute">
<StatusIndicator status={status} text={titleText} />
</ToolCallRow>
{/* Command */}
{rawInput && (
<ToolCallRow label="Command">
<CodeBlock>{formatValue(rawInput)}</CodeBlock>
</ToolCallRow>
)}
{/* Standard output */}
{textOutputs.length > 0 && (
<ToolCallRow label="Output">
<CodeBlock>{textOutputs.join('\n')}</CodeBlock>
</ToolCallRow>
)}
{/* Standard error / Errors */}
{errors.length > 0 && (
<ToolCallRow label="Error">
<div style={{ color: '#c74e39' }}>
<CodeBlock>{errors.join('\n')}</CodeBlock>
</div>
</ToolCallRow>
)}
{/* Exit code or other execution details */}
{otherData.length > 0 && (
<ToolCallRow label="Details">
<CodeBlock>
{otherData.map((data: unknown) => formatValue(data)).join('\n\n')}
</CodeBlock>
</ToolCallRow>
)}
</ToolCallCard>
);
};

View File

@@ -0,0 +1,96 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Generic tool call component - handles all tool call types as fallback
*/
import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import {
ToolCallCard,
ToolCallRow,
StatusIndicator,
CodeBlock,
LocationsList,
DiffDisplay,
} from './shared/LayoutComponents.js';
import {
formatValue,
safeTitle,
getKindIcon,
groupContent,
} from './shared/utils.js';
/**
* Generic tool call component that can display any tool call type
* Used as fallback for unknown tool call kinds
*/
export const GenericToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { kind, title, status, rawInput, content, locations } = toolCall;
const kindIcon = getKindIcon(kind);
const titleText = safeTitle(title);
// Group content by type
const { textOutputs, errors, diffs, otherData } = groupContent(content);
return (
<ToolCallCard icon={kindIcon}>
{/* Title row */}
<ToolCallRow label="Tool">
<StatusIndicator status={status} text={titleText} />
</ToolCallRow>
{/* Input row */}
{rawInput && (
<ToolCallRow label="Input">
<CodeBlock>{formatValue(rawInput)}</CodeBlock>
</ToolCallRow>
)}
{/* Locations row */}
{locations && locations.length > 0 && (
<ToolCallRow label="Files">
<LocationsList locations={locations} />
</ToolCallRow>
)}
{/* Output row - combined text outputs */}
{textOutputs.length > 0 && (
<ToolCallRow label="Output">
<CodeBlock>{textOutputs.join('\n')}</CodeBlock>
</ToolCallRow>
)}
{/* Error row - combined errors */}
{errors.length > 0 && (
<ToolCallRow label="Error">
<div style={{ color: '#c74e39' }}>{errors.join('\n')}</div>
</ToolCallRow>
)}
{/* Diff rows */}
{diffs.map(
(item: import('./shared/types.js').ToolCallContent, idx: number) => (
<ToolCallRow key={`diff-${idx}`} label="Diff">
<DiffDisplay
path={item.path}
oldText={item.oldText}
newText={item.newText}
/>
</ToolCallRow>
),
)}
{/* Other data rows */}
{otherData.length > 0 && (
<ToolCallRow label="Data">
<CodeBlock>
{otherData.map((data: unknown) => formatValue(data)).join('\n\n')}
</CodeBlock>
</ToolCallRow>
)}
</ToolCallCard>
);
};

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 {
ToolCallCard,
ToolCallRow,
StatusIndicator,
CodeBlock,
LocationsList,
} from './shared/LayoutComponents.js';
import { formatValue, safeTitle, groupContent } from './shared/utils.js';
/**
* Specialized component for Read tool calls
* Optimized for displaying file reading operations
*/
export const ReadToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { title, status, rawInput, content, locations } = toolCall;
const titleText = safeTitle(title);
// Group content by type
const { textOutputs, errors, otherData } = groupContent(content);
return (
<ToolCallCard icon="📖">
{/* Title row */}
<ToolCallRow label="Read">
<StatusIndicator status={status} text={titleText} />
</ToolCallRow>
{/* File path(s) */}
{locations && locations.length > 0 && (
<ToolCallRow label="File">
<LocationsList locations={locations} />
</ToolCallRow>
)}
{/* Input parameters (e.g., line range, offset) */}
{rawInput && (
<ToolCallRow label="Options">
<CodeBlock>{formatValue(rawInput)}</CodeBlock>
</ToolCallRow>
)}
{/* File content output */}
{textOutputs.length > 0 && (
<ToolCallRow label="Content">
<CodeBlock>{textOutputs.join('\n')}</CodeBlock>
</ToolCallRow>
)}
{/* Error handling */}
{errors.length > 0 && (
<ToolCallRow label="Error">
<div style={{ color: '#c74e39' }}>{errors.join('\n')}</div>
</ToolCallRow>
)}
{/* Other data */}
{otherData.length > 0 && (
<ToolCallRow label="Details">
<CodeBlock>
{otherData.map((data: unknown) => formatValue(data)).join('\n\n')}
</CodeBlock>
</ToolCallRow>
)}
</ToolCallCard>
);
};

View File

@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Search tool call component - specialized for search operations
*/
import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import {
ToolCallCard,
ToolCallRow,
StatusIndicator,
CodeBlock,
LocationsList,
} from './shared/LayoutComponents.js';
import { formatValue, safeTitle, groupContent } from './shared/utils.js';
/**
* Specialized component for Search tool calls
* Optimized for displaying search operations and results
*/
export const SearchToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { title, status, rawInput, content, locations } = toolCall;
const titleText = safeTitle(title);
// Group content by type
const { textOutputs, errors, otherData } = groupContent(content);
return (
<ToolCallCard icon="🔍">
{/* Title row */}
<ToolCallRow label="Search">
<StatusIndicator status={status} text={titleText} />
</ToolCallRow>
{/* Search query/pattern */}
{rawInput && (
<ToolCallRow label="Query">
<CodeBlock>{formatValue(rawInput)}</CodeBlock>
</ToolCallRow>
)}
{/* Search results - files found */}
{locations && locations.length > 0 && (
<ToolCallRow label="Results">
<LocationsList locations={locations} />
</ToolCallRow>
)}
{/* Search output details */}
{textOutputs.length > 0 && (
<ToolCallRow label="Matches">
<CodeBlock>{textOutputs.join('\n')}</CodeBlock>
</ToolCallRow>
)}
{/* Error handling */}
{errors.length > 0 && (
<ToolCallRow label="Error">
<div style={{ color: '#c74e39' }}>{errors.join('\n')}</div>
</ToolCallRow>
)}
{/* Other search metadata */}
{otherData.length > 0 && (
<ToolCallRow label="Details">
<CodeBlock>
{otherData.map((data: unknown) => formatValue(data)).join('\n\n')}
</CodeBlock>
</ToolCallRow>
)}
</ToolCallCard>
);
};

View File

@@ -0,0 +1,70 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Think tool call component - specialized for thinking/reasoning operations
*/
import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import {
ToolCallCard,
ToolCallRow,
StatusIndicator,
CodeBlock,
} from './shared/LayoutComponents.js';
import { formatValue, safeTitle, groupContent } from './shared/utils.js';
/**
* Specialized component for Think tool calls
* Optimized for displaying AI reasoning and thought processes
*/
export const ThinkToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { title, status, rawInput, content } = toolCall;
const titleText = safeTitle(title);
// Group content by type
const { textOutputs, errors, otherData } = groupContent(content);
return (
<ToolCallCard icon="💭">
{/* Title row */}
<ToolCallRow label="Thinking">
<StatusIndicator status={status} text={titleText} />
</ToolCallRow>
{/* Thinking context/prompt */}
{rawInput && (
<ToolCallRow label="Context">
<CodeBlock>{formatValue(rawInput)}</CodeBlock>
</ToolCallRow>
)}
{/* Thought content */}
{textOutputs.length > 0 && (
<ToolCallRow label="Thoughts">
<div style={{ fontStyle: 'italic', opacity: 0.95 }}>
{textOutputs.join('\n\n')}
</div>
</ToolCallRow>
)}
{/* Error handling */}
{errors.length > 0 && (
<ToolCallRow label="Error">
<div style={{ color: '#c74e39' }}>{errors.join('\n')}</div>
</ToolCallRow>
)}
{/* Other reasoning data */}
{otherData.length > 0 && (
<ToolCallRow label="Details">
<CodeBlock>
{otherData.map((data: unknown) => formatValue(data)).join('\n\n')}
</CodeBlock>
</ToolCallRow>
)}
</ToolCallCard>
);
};

View File

@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Write/Edit tool call component - specialized for file writing and editing operations
*/
import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import {
ToolCallCard,
ToolCallRow,
StatusIndicator,
CodeBlock,
LocationsList,
DiffDisplay,
} from './shared/LayoutComponents.js';
import { formatValue, safeTitle, groupContent } from './shared/utils.js';
/**
* Specialized component for Write/Edit tool calls
* Optimized for displaying file writing and editing operations with diffs
*/
export const WriteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
const { kind, title, status, rawInput, content, locations } = toolCall;
const titleText = safeTitle(title);
const isEdit = kind.toLowerCase() === 'edit';
// Group content by type
const { textOutputs, errors, diffs, otherData } = groupContent(content);
return (
<ToolCallCard icon="✏️">
{/* Title row */}
<ToolCallRow label={isEdit ? 'Edit' : 'Write'}>
<StatusIndicator status={status} text={titleText} />
</ToolCallRow>
{/* File path(s) */}
{locations && locations.length > 0 && (
<ToolCallRow label="File">
<LocationsList locations={locations} />
</ToolCallRow>
)}
{/* Input parameters (e.g., old_string, new_string for edits) */}
{rawInput && (
<ToolCallRow label="Changes">
<CodeBlock>{formatValue(rawInput)}</CodeBlock>
</ToolCallRow>
)}
{/* Diff display - most important for write/edit operations */}
{diffs.map(
(item: import('./shared/types.js').ToolCallContent, idx: number) => (
<ToolCallRow key={`diff-${idx}`} label="Diff">
<DiffDisplay
path={item.path}
oldText={item.oldText}
newText={item.newText}
/>
</ToolCallRow>
),
)}
{/* Success message or output */}
{textOutputs.length > 0 && (
<ToolCallRow label="Result">
<CodeBlock>{textOutputs.join('\n')}</CodeBlock>
</ToolCallRow>
)}
{/* Error handling */}
{errors.length > 0 && (
<ToolCallRow label="Error">
<div style={{ color: '#c74e39' }}>{errors.join('\n')}</div>
</ToolCallRow>
)}
{/* Other data */}
{otherData.length > 0 && (
<ToolCallRow label="Details">
<CodeBlock>
{otherData.map((data: unknown) => formatValue(data)).join('\n\n')}
</CodeBlock>
</ToolCallRow>
)}
</ToolCallCard>
);
};

View File

@@ -0,0 +1,80 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Tool call component factory - routes to specialized components by kind
*/
import type React from 'react';
import type { BaseToolCallProps } from './shared/types.js';
import { shouldShowToolCall } from './shared/utils.js';
import { GenericToolCall } from './GenericToolCall.js';
import { ReadToolCall } from './ReadToolCall.js';
import { WriteToolCall } from './WriteToolCall.js';
import { ExecuteToolCall } from './ExecuteToolCall.js';
import { SearchToolCall } from './SearchToolCall.js';
import { ThinkToolCall } from './ThinkToolCall.js';
/**
* Factory function that returns the appropriate tool call component based on kind
*/
export const getToolCallComponent = (
kind: string,
): React.FC<BaseToolCallProps> => {
const normalizedKind = kind.toLowerCase();
// Route to specialized components
switch (normalizedKind) {
case 'read':
return ReadToolCall;
case 'write':
case 'edit':
return WriteToolCall;
case 'execute':
case 'bash':
case 'command':
return ExecuteToolCall;
case 'search':
case 'grep':
case 'glob':
case 'find':
return SearchToolCall;
case 'think':
case 'thinking':
return ThinkToolCall;
// Add more specialized components as needed
// case 'fetch':
// return FetchToolCall;
// case 'delete':
// return DeleteToolCall;
default:
// Fallback to generic component
return GenericToolCall;
}
};
/**
* Main tool call component that routes to specialized implementations
*/
export const ToolCallRouter: React.FC<BaseToolCallProps> = ({ toolCall }) => {
// Check if we should show this tool call (hide internal ones)
if (!shouldShowToolCall(toolCall.kind)) {
return null;
}
// Get the appropriate component for this kind
const Component = getToolCallComponent(toolCall.kind);
// Render the specialized component
return <Component toolCall={toolCall} />;
};
// Re-export types for convenience
export type { BaseToolCallProps, ToolCallData } from './shared/types.js';

View File

@@ -0,0 +1,161 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Shared layout components for tool call UI
*/
import type React from 'react';
/**
* Props for ToolCallCard wrapper
*/
interface ToolCallCardProps {
icon: string;
children: React.ReactNode;
}
/**
* Main card wrapper with icon
*/
export const ToolCallCard: React.FC<ToolCallCardProps> = ({
icon,
children,
}) => (
<div className="tool-call-card">
<div className="tool-call-icon">{icon}</div>
<div className="tool-call-grid">{children}</div>
</div>
);
/**
* Props for ToolCallRow
*/
interface ToolCallRowProps {
label: string;
children: React.ReactNode;
}
/**
* A single row in the tool call grid
*/
export const ToolCallRow: React.FC<ToolCallRowProps> = ({
label,
children,
}) => (
<div className="tool-call-row">
<div className="tool-call-label">{label}</div>
<div className="tool-call-value">{children}</div>
</div>
);
/**
* Props for StatusIndicator
*/
interface StatusIndicatorProps {
status: 'pending' | 'in_progress' | 'completed' | 'failed';
text: string;
}
/**
* Status indicator with colored dot
*/
export const StatusIndicator: React.FC<StatusIndicatorProps> = ({
status,
text,
}) => (
<div className={`tool-call-status-indicator ${status}`} title={status}>
{text}
</div>
);
/**
* Props for CodeBlock
*/
interface CodeBlockProps {
children: string;
}
/**
* Code block for displaying formatted code or output
*/
export const CodeBlock: React.FC<CodeBlockProps> = ({ children }) => (
<pre className="code-block">{children}</pre>
);
/**
* Props for LocationsList
*/
interface LocationsListProps {
locations: Array<{
path: string;
line?: number | null;
}>;
}
/**
* List of file locations
*/
export const LocationsList: React.FC<LocationsListProps> = ({ locations }) => (
<>
{locations.map((loc, idx) => (
<div key={idx}>
{loc.path}
{loc.line !== null && loc.line !== undefined && `:${loc.line}`}
</div>
))}
</>
);
/**
* Props for DiffDisplay
*/
interface DiffDisplayProps {
path?: string;
oldText?: string | null;
newText?: string;
}
/**
* Display diff with before/after sections
*/
export const DiffDisplay: React.FC<DiffDisplayProps> = ({
path,
oldText,
newText,
}) => (
<div>
<div>
<strong>{path || 'Unknown file'}</strong>
</div>
{oldText !== undefined && (
<div>
<div
style={{
opacity: 0.5,
fontSize: '0.85em',
marginTop: '4px',
}}
>
Before:
</div>
<pre className="code-block">{oldText || '(empty)'}</pre>
</div>
)}
{newText !== undefined && (
<div>
<div
style={{
opacity: 0.5,
fontSize: '0.85em',
marginTop: '4px',
}}
>
After:
</div>
<pre className="code-block">{newText}</pre>
</div>
)}
</div>
);

View File

@@ -0,0 +1,68 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Shared types for tool call components
*/
/**
* Tool call content types
*/
export interface ToolCallContent {
type: 'content' | 'diff';
// For content type
content?: {
type: string;
text?: string;
error?: unknown;
[key: string]: unknown;
};
// For diff type
path?: string;
oldText?: string | null;
newText?: string;
}
/**
* Tool call location type
*/
export interface ToolCallLocation {
path: string;
line?: number | null;
}
/**
* Tool call status type
*/
export type ToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
/**
* Base tool call data interface
*/
export interface ToolCallData {
toolCallId: string;
kind: string;
title: string | object;
status: ToolCallStatus;
rawInput?: string | object;
content?: ToolCallContent[];
locations?: ToolCallLocation[];
}
/**
* Base props for all tool call components
*/
export interface BaseToolCallProps {
toolCall: ToolCallData;
}
/**
* Grouped content structure for rendering
*/
export interface GroupedContent {
textOutputs: string[];
errors: string[];
diffs: ToolCallContent[];
otherData: unknown[];
}

View File

@@ -0,0 +1,105 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*
* Shared utility functions for tool call components
*/
import type { ToolCallContent, GroupedContent } from './types.js';
/**
* Format any value to a string for display
*/
export const formatValue = (value: unknown): string => {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'object') {
try {
return JSON.stringify(value, null, 2);
} catch (_e) {
return String(value);
}
}
return String(value);
};
/**
* Safely convert title to string, handling object types
*/
export const safeTitle = (title: unknown): string => {
if (typeof title === 'string') {
return title;
}
if (title && typeof title === 'object') {
return JSON.stringify(title);
}
return 'Tool Call';
};
/**
* Get icon emoji for a given tool kind
*/
export const getKindIcon = (kind: string): string => {
const kindMap: Record<string, string> = {
edit: '✏️',
write: '✏️',
read: '📖',
execute: '⚡',
fetch: '🌐',
delete: '🗑️',
move: '📦',
search: '🔍',
think: '💭',
diff: '📝',
};
return kindMap[kind.toLowerCase()] || '🔧';
};
/**
* Check if a tool call should be displayed
* Hides internal tool calls
*/
export const shouldShowToolCall = (kind: string): boolean =>
!kind.includes('internal');
/**
* Group tool call content by type to avoid duplicate labels
*/
export const groupContent = (content?: ToolCallContent[]): GroupedContent => {
const textOutputs: string[] = [];
const errors: string[] = [];
const diffs: ToolCallContent[] = [];
const otherData: unknown[] = [];
content?.forEach((item) => {
if (item.type === 'diff') {
diffs.push(item);
} else if (item.content) {
const contentObj = item.content;
// Handle error content
if (contentObj.type === 'error' || 'error' in contentObj) {
const errorMsg =
formatValue(contentObj.error) ||
formatValue(contentObj.text) ||
'An error occurred';
errors.push(errorMsg);
}
// Handle text content
else if (contentObj.text) {
textOutputs.push(formatValue(contentObj.text));
}
// Handle other content
else {
otherData.push(contentObj);
}
}
});
return { textOutputs, errors, diffs, otherData };
};