From 454cbfdde43ab81325e98834edc65ed96936592a Mon Sep 17 00:00:00 2001 From: yiliang114 <1204183885@qq.com> Date: Wed, 19 Nov 2025 15:42:35 +0800 Subject: [PATCH] =?UTF-8?q?refactor(webview):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E6=98=BE=E7=A4=BA=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增多个工具调用组件,分别处理不同类型的工具调用 - 优化工具调用卡片的样式和布局 - 添加加载状态和随机加载消息 - 重构 App 组件,支持新的工具调用显示逻辑 --- .../src/WebViewProvider.ts | 62 ++-- .../vscode-ide-companion/src/webview/App.css | 329 +++++++++++++++++- .../vscode-ide-companion/src/webview/App.tsx | 160 ++++++++- .../src/webview/ClaudeCodeStyles.css | 131 +++++++ .../src/webview/components/ToolCall.tsx | 207 ++--------- .../components/toolcalls/ExecuteToolCall.tsx | 70 ++++ .../components/toolcalls/GenericToolCall.tsx | 96 +++++ .../components/toolcalls/ReadToolCall.tsx | 76 ++++ .../components/toolcalls/SearchToolCall.tsx | 76 ++++ .../components/toolcalls/ThinkToolCall.tsx | 70 ++++ .../components/toolcalls/WriteToolCall.tsx | 91 +++++ .../webview/components/toolcalls/index.tsx | 80 +++++ .../toolcalls/shared/LayoutComponents.tsx | 161 +++++++++ .../components/toolcalls/shared/types.ts | 68 ++++ .../components/toolcalls/shared/utils.ts | 105 ++++++ 15 files changed, 1564 insertions(+), 218 deletions(-) create mode 100644 packages/vscode-ide-companion/src/webview/components/toolcalls/ExecuteToolCall.tsx create mode 100644 packages/vscode-ide-companion/src/webview/components/toolcalls/GenericToolCall.tsx create mode 100644 packages/vscode-ide-companion/src/webview/components/toolcalls/ReadToolCall.tsx create mode 100644 packages/vscode-ide-companion/src/webview/components/toolcalls/SearchToolCall.tsx create mode 100644 packages/vscode-ide-companion/src/webview/components/toolcalls/ThinkToolCall.tsx create mode 100644 packages/vscode-ide-companion/src/webview/components/toolcalls/WriteToolCall.tsx create mode 100644 packages/vscode-ide-companion/src/webview/components/toolcalls/index.tsx create mode 100644 packages/vscode-ide-companion/src/webview/components/toolcalls/shared/LayoutComponents.tsx create mode 100644 packages/vscode-ide-companion/src/webview/components/toolcalls/shared/types.ts create mode 100644 packages/vscode-ide-companion/src/webview/components/toolcalls/shared/utils.ts diff --git a/packages/vscode-ide-companion/src/WebViewProvider.ts b/packages/vscode-ide-companion/src/WebViewProvider.ts index 550e3c63..5d321ed2 100644 --- a/packages/vscode-ide-companion/src/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/WebViewProvider.ts @@ -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 }, diff --git a/packages/vscode-ide-companion/src/webview/App.css b/packages/vscode-ide-companion/src/webview/App.css index fcab8480..84c9e6e4 100644 --- a/packages/vscode-ide-companion/src/webview/App.css +++ b/packages/vscode-ide-companion/src/webview/App.css @@ -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; +} + diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 679ff4cc..f820163f 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -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([]); 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> @@ -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 }, diff --git a/packages/vscode-ide-companion/src/webview/ClaudeCodeStyles.css b/packages/vscode-ide-companion/src/webview/ClaudeCodeStyles.css index 6ad0eb4d..203546bd 100644 --- a/packages/vscode-ide-companion/src/webview/ClaudeCodeStyles.css +++ b/packages/vscode-ide-companion/src/webview/ClaudeCodeStyles.css @@ -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; } } diff --git a/packages/vscode-ide-companion/src/webview/components/ToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/ToolCall.tsx index 9fc2180c..2f6c60df 100644 --- a/packages/vscode-ide-companion/src/webview/components/ToolCall.tsx +++ b/packages/vscode-ide-companion/src/webview/components/ToolCall.tsx @@ -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 ( - - {icon} - {text} - - ); -}; - -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 ( -
-
- 📝 - {fileName} -
-
-
-
Before
-
{oldText || '(empty)'}
-
-
-
-
After
-
{newText || '(empty)'}
-
-
-
- ); - } - - // Handle content type with text - if (content.type === 'content' && content.content?.text) { - return ( -
-
{content.content.text}
-
- ); - } - - return null; -}; - -const getKindDisplayName = (kind: string): { name: string; icon: string } => { - const kindMap: Record = { - 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 = ({ toolCall }) => { - const { kind, title, status, rawInput, content, locations, toolCallId } = - toolCall; - const kindInfo: { name: string; icon: string } = getKindDisplayName(kind); - - return ( -
-
- {kindInfo.icon} - {title || kindInfo.name} - -
- - {/* Show raw input if available */} - {rawInput !== undefined && rawInput !== null ? ( -
-
Input
-
{formatRawInput(rawInput)}
-
- ) : null} - - {/* Show locations if available */} - {locations && locations.length > 0 && ( -
-
Files
- {locations.map((location, index) => ( -
- 📄 - {location.path} - {location.line !== null && location.line !== undefined && ( - :{location.line} - )} -
- ))} -
- )} - - {/* Show content if available */} - {content && content.length > 0 && ( -
- {content.map((item, index) => ( - - ))} -
- )} - -
- - ID: {toolCallId.substring(0, 8)}... - -
-
- ); -}; +/** + * 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 }) => ; diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/ExecuteToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/ExecuteToolCall.tsx new file mode 100644 index 00000000..6e8aef89 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/ExecuteToolCall.tsx @@ -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 = ({ toolCall }) => { + const { title, status, rawInput, content } = toolCall; + const titleText = safeTitle(title); + + // Group content by type + const { textOutputs, errors, otherData } = groupContent(content); + + return ( + + {/* Title row */} + + + + + {/* Command */} + {rawInput && ( + + {formatValue(rawInput)} + + )} + + {/* Standard output */} + {textOutputs.length > 0 && ( + + {textOutputs.join('\n')} + + )} + + {/* Standard error / Errors */} + {errors.length > 0 && ( + +
+ {errors.join('\n')} +
+
+ )} + + {/* Exit code or other execution details */} + {otherData.length > 0 && ( + + + {otherData.map((data: unknown) => formatValue(data)).join('\n\n')} + + + )} +
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/GenericToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/GenericToolCall.tsx new file mode 100644 index 00000000..2346b0f2 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/GenericToolCall.tsx @@ -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 = ({ 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 ( + + {/* Title row */} + + + + + {/* Input row */} + {rawInput && ( + + {formatValue(rawInput)} + + )} + + {/* Locations row */} + {locations && locations.length > 0 && ( + + + + )} + + {/* Output row - combined text outputs */} + {textOutputs.length > 0 && ( + + {textOutputs.join('\n')} + + )} + + {/* Error row - combined errors */} + {errors.length > 0 && ( + +
{errors.join('\n')}
+
+ )} + + {/* Diff rows */} + {diffs.map( + (item: import('./shared/types.js').ToolCallContent, idx: number) => ( + + + + ), + )} + + {/* Other data rows */} + {otherData.length > 0 && ( + + + {otherData.map((data: unknown) => formatValue(data)).join('\n\n')} + + + )} +
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/ReadToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/ReadToolCall.tsx new file mode 100644 index 00000000..5190d960 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/ReadToolCall.tsx @@ -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 = ({ toolCall }) => { + const { title, status, rawInput, content, locations } = toolCall; + const titleText = safeTitle(title); + + // Group content by type + const { textOutputs, errors, otherData } = groupContent(content); + + return ( + + {/* Title row */} + + + + + {/* File path(s) */} + {locations && locations.length > 0 && ( + + + + )} + + {/* Input parameters (e.g., line range, offset) */} + {rawInput && ( + + {formatValue(rawInput)} + + )} + + {/* File content output */} + {textOutputs.length > 0 && ( + + {textOutputs.join('\n')} + + )} + + {/* Error handling */} + {errors.length > 0 && ( + +
{errors.join('\n')}
+
+ )} + + {/* Other data */} + {otherData.length > 0 && ( + + + {otherData.map((data: unknown) => formatValue(data)).join('\n\n')} + + + )} +
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/SearchToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/SearchToolCall.tsx new file mode 100644 index 00000000..45bef89e --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/SearchToolCall.tsx @@ -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 = ({ toolCall }) => { + const { title, status, rawInput, content, locations } = toolCall; + const titleText = safeTitle(title); + + // Group content by type + const { textOutputs, errors, otherData } = groupContent(content); + + return ( + + {/* Title row */} + + + + + {/* Search query/pattern */} + {rawInput && ( + + {formatValue(rawInput)} + + )} + + {/* Search results - files found */} + {locations && locations.length > 0 && ( + + + + )} + + {/* Search output details */} + {textOutputs.length > 0 && ( + + {textOutputs.join('\n')} + + )} + + {/* Error handling */} + {errors.length > 0 && ( + +
{errors.join('\n')}
+
+ )} + + {/* Other search metadata */} + {otherData.length > 0 && ( + + + {otherData.map((data: unknown) => formatValue(data)).join('\n\n')} + + + )} +
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/ThinkToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/ThinkToolCall.tsx new file mode 100644 index 00000000..e25f11ba --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/ThinkToolCall.tsx @@ -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 = ({ toolCall }) => { + const { title, status, rawInput, content } = toolCall; + const titleText = safeTitle(title); + + // Group content by type + const { textOutputs, errors, otherData } = groupContent(content); + + return ( + + {/* Title row */} + + + + + {/* Thinking context/prompt */} + {rawInput && ( + + {formatValue(rawInput)} + + )} + + {/* Thought content */} + {textOutputs.length > 0 && ( + +
+ {textOutputs.join('\n\n')} +
+
+ )} + + {/* Error handling */} + {errors.length > 0 && ( + +
{errors.join('\n')}
+
+ )} + + {/* Other reasoning data */} + {otherData.length > 0 && ( + + + {otherData.map((data: unknown) => formatValue(data)).join('\n\n')} + + + )} +
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/WriteToolCall.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/WriteToolCall.tsx new file mode 100644 index 00000000..385d9475 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/WriteToolCall.tsx @@ -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 = ({ 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 ( + + {/* Title row */} + + + + + {/* File path(s) */} + {locations && locations.length > 0 && ( + + + + )} + + {/* Input parameters (e.g., old_string, new_string for edits) */} + {rawInput && ( + + {formatValue(rawInput)} + + )} + + {/* Diff display - most important for write/edit operations */} + {diffs.map( + (item: import('./shared/types.js').ToolCallContent, idx: number) => ( + + + + ), + )} + + {/* Success message or output */} + {textOutputs.length > 0 && ( + + {textOutputs.join('\n')} + + )} + + {/* Error handling */} + {errors.length > 0 && ( + +
{errors.join('\n')}
+
+ )} + + {/* Other data */} + {otherData.length > 0 && ( + + + {otherData.map((data: unknown) => formatValue(data)).join('\n\n')} + + + )} +
+ ); +}; diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/index.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/index.tsx new file mode 100644 index 00000000..2859abd3 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/index.tsx @@ -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 => { + 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 = ({ 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 ; +}; + +// Re-export types for convenience +export type { BaseToolCallProps, ToolCallData } from './shared/types.js'; diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/LayoutComponents.tsx b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/LayoutComponents.tsx new file mode 100644 index 00000000..9afd6911 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/LayoutComponents.tsx @@ -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 = ({ + icon, + children, +}) => ( +
+
{icon}
+
{children}
+
+); + +/** + * Props for ToolCallRow + */ +interface ToolCallRowProps { + label: string; + children: React.ReactNode; +} + +/** + * A single row in the tool call grid + */ +export const ToolCallRow: React.FC = ({ + label, + children, +}) => ( +
+
{label}
+
{children}
+
+); + +/** + * Props for StatusIndicator + */ +interface StatusIndicatorProps { + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + text: string; +} + +/** + * Status indicator with colored dot + */ +export const StatusIndicator: React.FC = ({ + status, + text, +}) => ( +
+ {text} +
+); + +/** + * Props for CodeBlock + */ +interface CodeBlockProps { + children: string; +} + +/** + * Code block for displaying formatted code or output + */ +export const CodeBlock: React.FC = ({ children }) => ( +
{children}
+); + +/** + * Props for LocationsList + */ +interface LocationsListProps { + locations: Array<{ + path: string; + line?: number | null; + }>; +} + +/** + * List of file locations + */ +export const LocationsList: React.FC = ({ locations }) => ( + <> + {locations.map((loc, idx) => ( +
+ {loc.path} + {loc.line !== null && loc.line !== undefined && `:${loc.line}`} +
+ ))} + +); + +/** + * Props for DiffDisplay + */ +interface DiffDisplayProps { + path?: string; + oldText?: string | null; + newText?: string; +} + +/** + * Display diff with before/after sections + */ +export const DiffDisplay: React.FC = ({ + path, + oldText, + newText, +}) => ( +
+
+ {path || 'Unknown file'} +
+ {oldText !== undefined && ( +
+
+ Before: +
+
{oldText || '(empty)'}
+
+ )} + {newText !== undefined && ( +
+
+ After: +
+
{newText}
+
+ )} +
+); diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/types.ts b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/types.ts new file mode 100644 index 00000000..0d2c0cbc --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/types.ts @@ -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[]; +} diff --git a/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/utils.ts b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/utils.ts new file mode 100644 index 00000000..2ac965c5 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/toolcalls/shared/utils.ts @@ -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 = { + 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 }; +};