refactor(agent): improve tool call render ui/ux (#12540)

* refactor: Tool Permission Request Card and Streaming Tool Functionality

- Refactor ToolPermissionRequestCard to improve rendering of tool content based on tool type.
- Introduce a new ArgsTable component for displaying tool parameters.
- Implement streaming support for tool arguments in StreamProcessingService and related callbacks.
- Add new chunk type for streaming tool arguments in chunk types.
- Update MCPToolResponseStatus to include 'streaming' state.
- Create comprehensive tests for MessageAgentTools to cover various tool states and argument streaming.

* feat: 添加工具状态指示器组件并更新相关工具渲染逻辑

* refactor: 优化 BashTool 组件,移除多余的输出行数计算和标签展示

* feat: 添加输出截断功能以优化工具输出显示

* feat(i18n): add translations for tool labels and sections in multiple languages

- Updated Portuguese (pt-pt), Romanian (ro-ro), and Russian (ru-ru) translation files to include new labels and sections for various tools.
- Integrated translation functionality into BashOutputTool, BashTool, EditTool, ExitPlanModeTool, GlobTool, GrepTool, MultiEditTool, NotebookEditTool, ReadTool, SearchTool, SkillTool, TaskTool, TodoWriteTool, UnknownToolRenderer, WebFetchTool, and WebSearchTool components.
- Replaced hardcoded strings with translation keys for better localization support.

* chore(i18n): clarify

* refactor: 更新 ClaudeCodeService 和 transform 函数以增强工具调用语言提示和处理工具结果消息

* refactor: language instruction

* feat(ui): group consecutive tool calls for better readability

- Add ToolBlockGroup component to display multiple consecutive tool calls
  in a collapsible group
- Modify groupSimilarBlocks to group consecutive TOOL blocks together
- Add i18n translations for group header (e.g., "36 tool calls")
- Style group header consistent with individual tool blocks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(ui): group consecutive tool calls for better readability

- Add ToolBlockGroup component to display multiple consecutive tool calls
  in a collapsible group
- Modify groupSimilarBlocks to group consecutive TOOL blocks together
- Add i18n translations for group header (e.g., "36 tool calls")
- Style group header consistent with individual tool blocks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: 更新 truncateOutput 函数以统一返回结构,并调整相关工具的输出处理

* refactor: simplify code

* refactor(ToolBlockGroup):  tool block rendering with memoization and auto-expand feature

* fix(ui): use direct child selectors to prevent style leakage to nested tools

Use > selectors in ToolBlockGroup to only style the group collapse,
not the nested tool collapses inside it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(i18n): clean hardcoded ui string add ci

* refactor: add toolblockgroup

* refactor: simplify code

* fix: lint

* fix: lint 2

* fix: test

* fix: test2

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
SuYao 2026-01-27 10:26:02 +08:00 committed by GitHub
parent 0255cb8443
commit 2a3e157ee7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 3069 additions and 1216 deletions

View File

@ -345,6 +345,7 @@
"oxlint-tsgolint": "^0.2.0",
"p-queue": "^8.1.0",
"pako": "1.0.11",
"partial-json": "0.1.7",
"pdf-lib": "^1.17.1",
"pdf-parse": "^1.1.1",
"prosemirror-model": "1.25.2",

22
pnpm-lock.yaml generated
View File

@ -914,6 +914,9 @@ importers:
pako:
specifier: 1.0.11
version: 1.0.11
partial-json:
specifier: 0.1.7
version: 0.1.7
pdf-lib:
specifier: ^1.17.1
version: 1.17.1
@ -9736,6 +9739,9 @@ packages:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
partial-json@0.1.7:
resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==}
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
@ -17666,11 +17672,11 @@ snapshots:
- utf-8-validate
- vite
'@vitest/browser@3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(playwright@1.57.0)(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)':
'@vitest/browser@3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(playwright@1.57.0)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)':
dependencies:
'@testing-library/dom': 10.4.1
'@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
'@vitest/mocker': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/mocker': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/utils': 3.2.4
magic-string: 0.30.21
sirv: 3.0.2
@ -17724,14 +17730,14 @@ snapshots:
msw: 2.12.7(@types/node@22.17.2)(typescript@5.8.3)
vite: rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/mocker@3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))':
'@vitest/mocker@3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
msw: 2.12.7(@types/node@24.10.4)(typescript@5.8.3)
vite: rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)
vite: rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/pretty-format@3.2.4':
dependencies:
@ -17762,7 +17768,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.17.2)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(msw@2.12.7(@types/node@22.17.2)(typescript@5.8.3))(tsx@4.21.0)(yaml@2.8.2)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.4)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(esbuild@0.25.12)(jiti@2.6.1)(jsdom@26.1.0)(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(tsx@4.21.0)(yaml@2.8.2)
'@vitest/utils@3.2.4':
dependencies:
@ -22557,6 +22563,8 @@ snapshots:
parseurl@1.3.3: {}
partial-json@0.1.7: {}
path-browserify@1.0.1: {}
path-data-parser@0.1.0: {}
@ -25127,7 +25135,7 @@ snapshots:
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/mocker': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@ -25151,7 +25159,7 @@ snapshots:
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 24.10.4
'@vitest/browser': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(playwright@1.57.0)(rolldown-vite@7.3.0(@types/node@22.17.2)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)
'@vitest/browser': 3.2.4(msw@2.12.7(@types/node@24.10.4)(typescript@5.8.3))(playwright@1.57.0)(rolldown-vite@7.3.0(@types/node@24.10.4)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)
'@vitest/ui': 3.2.4(vitest@3.2.4)
jsdom: 26.1.0
transitivePeerDependencies:

View File

@ -16,6 +16,7 @@ import { loggerService } from '@logger'
import { config as apiConfigService } from '@main/apiServer/config'
import { validateModelId } from '@main/apiServer/utils'
import { isWin } from '@main/constant'
import { configManager } from '@main/services/ConfigManager'
import { autoDiscoverGitBash } from '@main/utils/process'
import getLoginShellEnvironment from '@main/utils/shell-env'
import { withoutTrailingApiVersion } from '@shared/utils'
@ -34,6 +35,9 @@ const DEFAULT_AUTO_ALLOW_TOOLS = new Set(['Read', 'Glob', 'Grep'])
const shouldAutoApproveTools = process.env.CHERRY_AUTO_ALLOW_TOOLS === '1'
const NO_RESUME_COMMANDS = ['/clear']
const getLanguageInstruction = () =>
`IMPORTANT: You MUST use ${configManager.getLanguage()} language for ALL your outputs, including: (1) text responses, (2) tool call parameters like "description" fields, and (3) any user-facing content. Never use English unless the content is code, file paths, or technical identifiers.`
type UserInputMessage = {
type: 'user'
parent_tool_use_id: string | null
@ -255,9 +259,13 @@ class ClaudeCodeService implements AgentServiceInterface {
? {
type: 'preset',
preset: 'claude_code',
append: session.instructions
append: `${session.instructions}\n\n${getLanguageInstruction()}`
}
: { type: 'preset', preset: 'claude_code' },
: {
type: 'preset',
preset: 'claude_code',
append: getLanguageInstruction()
},
settingSources: ['project'],
includePartialMessages: true,
permissionMode: session.configuration?.permission_mode,

View File

@ -77,7 +77,10 @@ const generateMessageId = (): string => `msg_${uuidv4().replace(/-/g, '')}`
* Removes any local command stdout/stderr XML wrappers that should never surface to the UI.
*/
export const stripLocalCommandTags = (text: string): string => {
return text.replace(/<local-command-(stdout|stderr)>(.*?)<\/local-command-\1>/gs, '$2')
return text
.replace(/<local-command-(stdout|stderr)>(.*?)<\/local-command-\1>/gs, '$2')
.replace('(no content)', '')
.trim()
}
/**
@ -321,7 +324,46 @@ function handleUserMessage(
const chunks: AgentStreamPart[] = []
const providerMetadata = sdkMessageToProviderMetadata(message)
const content = message.message.content
const isSynthetic = message.isSynthetic ?? false
// Check if content contains tool_result blocks (synthetic tool result messages)
// This handles both SDK-flagged messages and standard tool_result content
const contentArray = Array.isArray(content) ? content : []
const hasToolResults = contentArray.some((block: any) => block.type === 'tool_result')
if (hasToolResults || message.tool_use_result || message.parent_tool_use_id) {
if (!Array.isArray(content)) {
return chunks
}
for (const block of content) {
if (block.type === 'tool_result') {
const toolResult = block as ToolResultContent
const pendingCall = state.consumePendingToolCall(toolResult.tool_use_id)
const toolCallId = pendingCall?.toolCallId ?? state.getNamespacedToolCallId(toolResult.tool_use_id)
if (toolResult.is_error) {
chunks.push({
type: 'tool-error',
toolCallId,
toolName: pendingCall?.toolName ?? 'unknown',
input: pendingCall?.input,
error: toolResult.content,
providerExecuted: true
} as AgentStreamPart)
} else {
chunks.push({
type: 'tool-result',
toolCallId,
toolName: pendingCall?.toolName ?? 'unknown',
input: pendingCall?.input,
output: toolResult.content,
providerExecuted: true
})
}
}
}
return chunks
}
// For non-synthetic messages (user-initiated content), render text content
if (typeof content === 'string') {
if (!content) {
return chunks
@ -352,39 +394,12 @@ function handleUserMessage(
return chunks
}
if (!Array.isArray(content)) {
return chunks
}
// For non-synthetic array content, render text blocks
for (const block of content) {
if (block.type === 'tool_result') {
const toolResult = block as ToolResultContent
const pendingCall = state.consumePendingToolCall(toolResult.tool_use_id)
const toolCallId = pendingCall?.toolCallId ?? state.getNamespacedToolCallId(toolResult.tool_use_id)
if (toolResult.is_error) {
chunks.push({
type: 'tool-error',
toolCallId,
toolName: pendingCall?.toolName ?? 'unknown',
input: pendingCall?.input,
error: toolResult.content,
providerExecuted: true
} as AgentStreamPart)
} else {
chunks.push({
type: 'tool-result',
toolCallId,
toolName: pendingCall?.toolName ?? 'unknown',
input: pendingCall?.input,
output: toolResult.content,
providerExecuted: true
})
}
} else if (block.type === 'text' && !isSynthetic) {
if (block.type === 'text') {
const rawText = (block as { text: string }).text
const filteredText = filterCommandTags(rawText)
// Only push text chunks if there's content after filtering
if (filteredText) {
const id = message.uuid?.toString() || generateMessageId()
chunks.push({
@ -404,8 +419,6 @@ function handleUserMessage(
providerMetadata
})
}
} else {
logger.warn('Unhandled user content block', { type: (block as any).type })
}
}

View File

@ -238,15 +238,16 @@ export class AiSdkToChunkAdapter {
// === 工具调用相关事件(原始 AI SDK 事件,如果没有被中间件处理) ===
// case 'tool-input-start':
// case 'tool-input-delta':
// case 'tool-input-end':
// this.toolCallHandler.handleToolCallCreated(chunk)
// break
case 'tool-input-start':
this.toolCallHandler.handleToolInputStart(chunk)
break
case 'tool-input-delta':
this.toolCallHandler.handleToolInputDelta(chunk)
break
case 'tool-input-end':
this.toolCallHandler.handleToolInputEnd(chunk)
break
// case 'tool-input-delta':
// this.toolCallHandler.handleToolCallCreated(chunk)
// break
case 'tool-call':
this.toolCallHandler.handleToolCall(chunk)
break

View File

@ -5,18 +5,12 @@
*/
import { loggerService } from '@logger'
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'
import { processKnowledgeReferences } from '@renderer/services/KnowledgeService'
import type {
BaseTool,
MCPCallToolResponse,
MCPTool,
MCPToolResponse,
MCPToolResultContent,
NormalToolResponse
} from '@renderer/types'
import type { BaseTool, MCPTool, MCPToolResponse, NormalToolResponse } from '@renderer/types'
import type { Chunk } from '@renderer/types/chunk'
import { ChunkType } from '@renderer/types/chunk'
import type { ToolSet, TypedToolCall, TypedToolError, TypedToolResult } from 'ai'
import type { ProviderMetadata, ToolSet, TypedToolCall, TypedToolError, TypedToolResult } from 'ai'
const logger = loggerService.withContext('ToolCallChunkHandler')
@ -26,6 +20,8 @@ export type ToolcallsMap = {
args: any
// mcpTool 现在可以是 MCPTool 或我们为 Provider 工具创建的通用类型
tool: BaseTool
// Streaming arguments buffer
streamingArgs?: string
}
/**
*
@ -71,110 +67,169 @@ export class ToolCallChunkHandler {
return ToolCallChunkHandler.addActiveToolCallImpl(toolCallId, map)
}
// /**
// * 设置 onChunk 回调
// */
// public setOnChunk(callback: (chunk: Chunk) => void): void {
// this.onChunk = callback
// }
/**
*
*/
private determineToolType(toolName: string, toolCallId: string): BaseTool {
let mcpTool: MCPTool | undefined
if (toolName.startsWith('builtin_')) {
return {
id: toolCallId,
name: toolName,
description: toolName,
type: 'builtin'
} as BaseTool
} else if ((mcpTool = this.mcpTools.find((t) => t.id === toolName) as MCPTool)) {
return mcpTool
} else {
return {
id: toolCallId,
name: toolName,
description: toolName,
type: 'provider'
}
}
}
// handleToolCallCreated(
// chunk:
// | {
// type: 'tool-input-start'
// id: string
// toolName: string
// providerMetadata?: ProviderMetadata
// providerExecuted?: boolean
// }
// | {
// type: 'tool-input-end'
// id: string
// providerMetadata?: ProviderMetadata
// }
// | {
// type: 'tool-input-delta'
// id: string
// delta: string
// providerMetadata?: ProviderMetadata
// }
// ): void {
// switch (chunk.type) {
// case 'tool-input-start': {
// // 能拿到说明是mcpTool
// // if (this.activeToolCalls.get(chunk.id)) return
/**
* -
*/
public handleToolInputStart(chunk: {
type: 'tool-input-start'
id: string
toolName: string
providerMetadata?: ProviderMetadata
providerExecuted?: boolean
}): void {
const { id: toolCallId, toolName, providerExecuted } = chunk
// const tool: BaseTool | MCPTool = {
// id: chunk.id,
// name: chunk.toolName,
// description: chunk.toolName,
// type: chunk.toolName.startsWith('builtin_') ? 'builtin' : 'provider'
// }
// this.activeToolCalls.set(chunk.id, {
// toolCallId: chunk.id,
// toolName: chunk.toolName,
// args: '',
// tool
// })
// const toolResponse: MCPToolResponse | NormalToolResponse = {
// id: chunk.id,
// tool: tool,
// arguments: {},
// status: 'pending',
// toolCallId: chunk.id
// }
// this.onChunk({
// type: ChunkType.MCP_TOOL_PENDING,
// responses: [toolResponse]
// })
// break
// }
// case 'tool-input-delta': {
// const toolCall = this.activeToolCalls.get(chunk.id)
// if (!toolCall) {
// logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found: ${chunk.id}`)
// return
// }
// toolCall.args += chunk.delta
// break
// }
// case 'tool-input-end': {
// const toolCall = this.activeToolCalls.get(chunk.id)
// this.activeToolCalls.delete(chunk.id)
// if (!toolCall) {
// logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found: ${chunk.id}`)
// return
// }
// // const toolResponse: ToolCallResponse = {
// // id: toolCall.toolCallId,
// // tool: toolCall.tool,
// // arguments: toolCall.args,
// // status: 'pending',
// // toolCallId: toolCall.toolCallId
// // }
// // logger.debug('toolResponse', toolResponse)
// // this.onChunk({
// // type: ChunkType.MCP_TOOL_PENDING,
// // responses: [toolResponse]
// // })
// break
// }
// }
// // if (!toolCall) {
// // Logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found: ${chunk.id}`)
// // return
// // }
// // this.onChunk({
// // type: ChunkType.MCP_TOOL_CREATED,
// // tool_calls: [
// // {
// // id: chunk.id,
// // name: chunk.toolName,
// // status: 'pending'
// // }
// // ]
// // })
// }
if (!toolCallId || !toolName) {
logger.warn(`🔧 [ToolCallChunkHandler] Invalid tool-input-start chunk: missing id or toolName`)
return
}
// 如果已存在,跳过
if (this.activeToolCalls.has(toolCallId)) {
return
}
let tool: BaseTool
if (providerExecuted) {
tool = {
id: toolCallId,
name: toolName,
description: toolName,
type: 'provider'
} as BaseTool
} else {
tool = this.determineToolType(toolName, toolCallId)
}
// 初始化流式工具调用
this.addActiveToolCall(toolCallId, {
toolCallId,
toolName,
args: undefined,
tool,
streamingArgs: ''
})
logger.info(`🔧 [ToolCallChunkHandler] Tool input streaming started: ${toolName} (${toolCallId})`)
// 发送初始 streaming chunk
const toolResponse: MCPToolResponse | NormalToolResponse = {
id: toolCallId,
tool: tool,
arguments: undefined,
status: 'streaming',
toolCallId: toolCallId,
partialArguments: ''
}
this.onChunk({
type: ChunkType.MCP_TOOL_STREAMING,
responses: [toolResponse]
})
}
/**
* -
*/
public handleToolInputDelta(chunk: {
type: 'tool-input-delta'
id: string
delta: string
providerMetadata?: ProviderMetadata
}): void {
const { id: toolCallId, delta } = chunk
const toolCall = this.activeToolCalls.get(toolCallId)
if (!toolCall) {
logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found for delta: ${toolCallId}`)
return
}
// 累积流式参数
toolCall.streamingArgs = (toolCall.streamingArgs || '') + delta
// 发送 streaming chunk 更新
const toolResponse: MCPToolResponse | NormalToolResponse = {
id: toolCallId,
tool: toolCall.tool,
arguments: undefined,
status: 'streaming',
toolCallId: toolCallId,
partialArguments: toolCall.streamingArgs
}
this.onChunk({
type: ChunkType.MCP_TOOL_STREAMING,
responses: [toolResponse]
})
}
/**
* -
*/
public handleToolInputEnd(chunk: { type: 'tool-input-end'; id: string; providerMetadata?: ProviderMetadata }): void {
const { id: toolCallId } = chunk
const toolCall = this.activeToolCalls.get(toolCallId)
if (!toolCall) {
logger.warn(`🔧 [ToolCallChunkHandler] Tool call not found for end: ${toolCallId}`)
return
}
// 尝试解析完整的 JSON 参数
let parsedArgs: any = undefined
if (toolCall.streamingArgs) {
try {
parsedArgs = JSON.parse(toolCall.streamingArgs)
toolCall.args = parsedArgs
} catch (e) {
logger.warn(`🔧 [ToolCallChunkHandler] Failed to parse streaming args for ${toolCallId}:`, e as Error)
// 保留原始字符串
toolCall.args = toolCall.streamingArgs
}
}
logger.info(`🔧 [ToolCallChunkHandler] Tool input streaming completed: ${toolCall.toolName} (${toolCallId})`)
// 发送 streaming 完成 chunk
const toolResponse: MCPToolResponse | NormalToolResponse = {
id: toolCallId,
tool: toolCall.tool,
arguments: parsedArgs,
status: 'pending',
toolCallId: toolCallId,
partialArguments: toolCall.streamingArgs
}
this.onChunk({
type: ChunkType.MCP_TOOL_STREAMING,
responses: [toolResponse]
})
}
/**
*
@ -191,6 +246,15 @@ export class ToolCallChunkHandler {
return
}
// Check if this tool call was already processed via streaming events
const existingToolCall = this.activeToolCalls.get(toolCallId)
if (existingToolCall?.streamingArgs !== undefined) {
// Tool call was already processed via streaming events (tool-input-start/delta/end)
// Update args if needed, but don't emit duplicate pending chunk
existingToolCall.args = args
return
}
let tool: BaseTool
let mcpTool: MCPTool | undefined
// 根据 providerExecuted 标志区分处理逻辑
@ -216,11 +280,6 @@ export class ToolCallChunkHandler {
// 如果是客户端执行的 MCP 工具,沿用现有逻辑
// toolName is mcpTool.id (registered with id as key in convertMcpToolsToAiSdkTools)
logger.info(`[ToolCallChunkHandler] Handling client-side MCP tool: ${toolName}`)
// mcpTool = this.mcpTools.find((t) => t.name === toolName) as MCPTool
// if (!mcpTool) {
// logger.warn(`[ToolCallChunkHandler] MCP tool not found: ${toolName}`)
// return
// }
tool = mcpTool
} else {
tool = {
@ -357,40 +416,20 @@ export class ToolCallChunkHandler {
export const addActiveToolCall = ToolCallChunkHandler.addActiveToolCall.bind(ToolCallChunkHandler)
/**
* 使 MCP SDK
*/
function extractImagesFromToolOutput(output: unknown): string[] {
if (!output) {
return []
}
const contents: unknown[] = []
if (isMcpCallToolResponse(output)) {
contents.push(...output.content)
} else if (Array.isArray(output)) {
contents.push(...output)
} else if (hasContentArray(output)) {
contents.push(...output.content)
const result = CallToolResultSchema.safeParse(output)
if (result.success) {
return result.data.content
.filter((c) => c.type === 'image')
.map((content) => `data:${content.mimeType ?? 'image/png'};base64,${content.data}`)
}
return contents
.filter(isMcpImageContent)
.map((content) => `data:${content.mimeType ?? 'image/png'};base64,${content.data}`)
}
function isMcpCallToolResponse(value: unknown): value is MCPCallToolResponse {
return typeof value === 'object' && value !== null && Array.isArray((value as MCPCallToolResponse).content)
}
function hasContentArray(value: unknown): value is { content: unknown[] } {
return typeof value === 'object' && value !== null && Array.isArray((value as { content?: unknown }).content)
}
function isMcpImageContent(content: unknown): content is MCPToolResultContent & { data: string } {
if (typeof content !== 'object' || content === null) {
return false
}
const resultContent = content as MCPToolResultContent
return resultContent.type === 'image' && typeof resultContent.data === 'string'
return []
}

View File

@ -185,17 +185,13 @@ export function useAppInit() {
suggestionCount: payload.suggestions.length,
autoApprove: payload.autoApprove
})
dispatch(toolPermissionsActions.requestReceived(payload))
// Auto-approve if requested
if (payload.autoApprove) {
logger.debug('Auto-approving tool permission request', {
requestId: payload.requestId,
toolName: payload.toolName
})
dispatch(toolPermissionsActions.submissionSent({ requestId: payload.requestId, behavior: 'allow' }))
try {
const response = await window.api.agentTools.respondToPermission({
requestId: payload.requestId,
@ -214,9 +210,13 @@ export function useAppInit() {
})
} catch (error) {
logger.error('Failed to send auto-approval response', error as Error)
dispatch(toolPermissionsActions.submissionFailed({ requestId: payload.requestId }))
// Fall through to add to store for manual approval
dispatch(toolPermissionsActions.requestReceived(payload))
}
return
}
dispatch(toolPermissionsActions.requestReceived(payload))
}
const resultListener = (_event: Electron.IpcRendererEvent, payload: ToolPermissionResultPayload) => {

View File

@ -0,0 +1,292 @@
import type { MCPToolResponseStatus } from '@renderer/types'
import type { ToolMessageBlock } from '@renderer/types/newMessage'
import { Collapse, type CollapseProps } from 'antd'
import { Wrench } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { useToolApproval } from '../Tools/hooks/useToolApproval'
import MessageTools from '../Tools/MessageTools'
import ToolApprovalActionsComponent from '../Tools/ToolApprovalActions'
import ToolHeader from '../Tools/ToolHeader'
// ============ Styled Components ============
const Container = styled.div`
width: fit-content;
max-width: 100%;
/* Only style the direct group collapse, not nested tool collapses */
> .ant-collapse {
background: transparent;
border: none;
> .ant-collapse-item {
border: none !important;
> .ant-collapse-header {
padding: 8px 12px !important;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 0.75rem !important;
display: flex;
align-items: center;
.ant-collapse-expand-icon {
padding: 0 !important;
margin-left: 8px;
height: auto !important;
}
}
> .ant-collapse-content {
border: none;
background: transparent;
> .ant-collapse-content-box {
padding: 4px 0 0 0 !important;
display: flex;
flex-direction: column;
gap: 4px;
}
}
}
}
`
const GroupHeader = styled.div`
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
.tool-icon {
color: var(--color-primary);
}
.tool-count {
color: var(--color-text-1);
}
`
const ScrollableToolList = styled.div`
max-height: 300px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
`
const ToolItem = styled.div<{ $isCompleted: boolean }>`
opacity: ${(props) => (props.$isCompleted ? 0.7 : 1)};
transition: opacity 0.2s;
`
const AnimatedHeaderWrapper = styled(motion.div)`
display: inline-block;
`
const HeaderWithActions = styled.div`
display: flex;
align-items: center;
gap: 8px;
width: 100%;
justify-content: space-between;
`
// ============ Types & Helpers ============
interface Props {
blocks: ToolMessageBlock[]
}
function isCompletedStatus(status: MCPToolResponseStatus | undefined): boolean {
return status === 'done' || status === 'error' || status === 'cancelled'
}
function isWaitingStatus(status: MCPToolResponseStatus | undefined): boolean {
return status === 'pending'
}
// Animation variants for smooth header transitions
const headerVariants = {
enter: { x: 20, opacity: 0 },
center: { x: 0, opacity: 1, transition: { duration: 0.2, ease: 'easeOut' as const } },
exit: { x: -20, opacity: 0, transition: { duration: 0.15 } }
}
// ============ Sub-Components ============
// Component for rendering a block with approval actions
interface WaitingToolHeaderProps {
block: ToolMessageBlock
}
const WaitingToolHeader = React.memo(({ block }: WaitingToolHeaderProps) => {
const approval = useToolApproval(block)
return (
<HeaderWithActions>
<ToolHeader block={block} variant="collapse-label" showStatus={false} />
{(approval.isWaiting || approval.isExecuting) && <ToolApprovalActionsComponent {...approval} compact />}
</HeaderWithActions>
)
})
WaitingToolHeader.displayName = 'WaitingToolHeader'
interface GroupHeaderContentProps {
blocks: ToolMessageBlock[]
allCompleted: boolean
}
const GroupHeaderContent = React.memo(({ blocks, allCompleted }: GroupHeaderContentProps) => {
const { t } = useTranslation()
if (allCompleted) {
return (
<GroupHeader>
<Wrench size={14} className="tool-icon" />
<span className="tool-count">{t('message.tools.groupHeader', { count: blocks.length })}</span>
</GroupHeader>
)
}
// Find blocks needing approval (pending status)
const waitingBlocks = blocks.filter((block) => {
const status = block.metadata?.rawMcpToolResponse?.status
return isWaitingStatus(status)
})
// Prioritize showing waiting blocks that need approval
const lastWaitingBlock = waitingBlocks[waitingBlocks.length - 1]
if (lastWaitingBlock) {
return (
<AnimatePresence mode="wait">
<AnimatedHeaderWrapper
key={lastWaitingBlock.id}
variants={headerVariants}
initial="enter"
animate="center"
exit="exit">
<WaitingToolHeader block={lastWaitingBlock} />
</AnimatedHeaderWrapper>
</AnimatePresence>
)
}
const runningBlocks = blocks.filter((block) => {
const status = block.metadata?.rawMcpToolResponse?.status
return !isCompletedStatus(status) && !isWaitingStatus(status)
})
// Get the last running block (most recent) and render with animation
const lastRunningBlock = runningBlocks[runningBlocks.length - 1]
if (lastRunningBlock) {
return (
<AnimatePresence mode="wait">
<AnimatedHeaderWrapper
key={lastRunningBlock.id}
variants={headerVariants}
initial="enter"
animate="center"
exit="exit">
<ToolHeader block={lastRunningBlock} variant="collapse-label" />
</AnimatedHeaderWrapper>
</AnimatePresence>
)
}
// Fallback
return (
<GroupHeader>
<Wrench size={14} className="tool-icon" />
<span className="tool-count">{t('message.tools.groupHeader', { count: blocks.length })}</span>
</GroupHeader>
)
})
GroupHeaderContent.displayName = 'GroupHeaderContent'
// Component for tool list content with auto-scroll
interface ToolListContentProps {
blocks: ToolMessageBlock[]
scrollRef: React.RefObject<HTMLDivElement | null>
}
const ToolListContent = React.memo(({ blocks, scrollRef }: ToolListContentProps) => (
<ScrollableToolList ref={scrollRef}>
{blocks.map((block) => {
const status = block.metadata?.rawMcpToolResponse?.status
const isCompleted = isCompletedStatus(status)
return (
<ToolItem key={block.id} data-block-id={block.id} $isCompleted={isCompleted}>
<MessageTools block={block} />
</ToolItem>
)
})}
</ScrollableToolList>
))
ToolListContent.displayName = 'ToolListContent'
// ============ Main Component ============
const ToolBlockGroup: React.FC<Props> = ({ blocks }) => {
const [activeKey, setActiveKey] = useState<string[]>([])
const scrollRef = useRef<HTMLDivElement>(null)
const userExpandedRef = useRef(false)
const allCompleted = useMemo(() => {
return blocks.every((block) => {
const status = block.metadata?.rawMcpToolResponse?.status
return isCompletedStatus(status)
})
}, [blocks])
const currentRunningBlock = useMemo(() => {
return blocks.find((block) => {
const status = block.metadata?.rawMcpToolResponse?.status
return !isCompletedStatus(status)
})
}, [blocks])
useEffect(() => {
if (activeKey.includes('tool-group') && currentRunningBlock && scrollRef.current) {
const element = scrollRef.current.querySelector(`[data-block-id="${currentRunningBlock.id}"]`)
element?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}, [activeKey, currentRunningBlock])
const handleChange = (keys: string | string[]) => {
const keyArray = Array.isArray(keys) ? keys : [keys]
const isExpanding = keyArray.includes('tool-group')
userExpandedRef.current = isExpanding
setActiveKey(keyArray)
}
const items: CollapseProps['items'] = useMemo(() => {
return [
{
key: 'tool-group',
label: <GroupHeaderContent blocks={blocks} allCompleted={allCompleted} />,
children: <ToolListContent blocks={blocks} scrollRef={scrollRef} />
}
]
}, [blocks, allCompleted])
return (
<Container>
<Collapse
ghost
size="small"
expandIconPosition="end"
activeKey={activeKey}
onChange={handleChange}
items={items}
/>
</Container>
)
}
export default React.memo(ToolBlockGroup)

View File

@ -3,7 +3,7 @@ import type { RootState } from '@renderer/store'
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
import type { ImageMessageBlock, Message, MessageBlock } from '@renderer/types/newMessage'
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import { isMainTextBlock, isMessageProcessing, isVideoBlock } from '@renderer/utils/messageUtils/is'
import { isMainTextBlock, isMessageProcessing, isToolBlock, isVideoBlock } from '@renderer/utils/messageUtils/is'
import { AnimatePresence, motion, type Variants } from 'motion/react'
import React, { useMemo } from 'react'
import { useSelector } from 'react-redux'
@ -18,6 +18,7 @@ import MainTextBlock from './MainTextBlock'
import PlaceholderBlock from './PlaceholderBlock'
import ThinkingBlock from './ThinkingBlock'
import ToolBlock from './ToolBlock'
import ToolBlockGroup from './ToolBlockGroup'
import TranslationBlock from './TranslationBlock'
import VideoBlock from './VideoBlock'
@ -94,6 +95,14 @@ const groupSimilarBlocks = (blocks: MessageBlock[]): (MessageBlock[] | MessageBl
} else {
acc.push([currentBlock])
}
} else if (currentBlock.type === MessageBlockType.TOOL) {
// 对于TOOL类型按连续分组
const prevGroup = acc[acc.length - 1]
if (Array.isArray(prevGroup) && prevGroup[0].type === MessageBlockType.TOOL) {
prevGroup.push(currentBlock)
} else {
acc.push([currentBlock])
}
} else {
acc.push(currentBlock)
}
@ -147,6 +156,29 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
<VideoBlock key={firstVideoBlock.id} block={firstVideoBlock} />
</AnimatedBlockWrapper>
)
} else if (block[0].type === MessageBlockType.TOOL) {
// 对于连续的TOOL使用分组显示
if (block.length === 1) {
// 单个工具调用,直接渲染
if (!isToolBlock(block[0])) {
logger.warn('Expected tool block but got different type', block[0])
return null
}
return (
<AnimatedBlockWrapper key={groupKey} enableAnimation={message.status.includes('ing')}>
<ToolBlock key={block[0].id} block={block[0]} />
</AnimatedBlockWrapper>
)
}
// 多个工具调用,使用分组组件
const toolBlocks = block.filter(isToolBlock)
// Use first block ID as stable key to prevent remounting when new blocks are added
const stableGroupKey = `tool-group-${toolBlocks[0].id}`
return (
<AnimatedBlockWrapper key={stableGroupKey} enableAnimation={message.status.includes('ing')}>
<ToolBlockGroup blocks={toolBlocks} />
</AnimatedBlockWrapper>
)
}
return null
}

View File

@ -1,8 +1,10 @@
import type { CollapseProps } from 'antd'
import { Tag } from 'antd'
import { CheckCircle, Terminal, XCircle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { ToolTitle } from './GenericTools'
import { truncateOutput } from '../shared/truncateOutput'
import { ToolHeader, TruncatedIndicator } from './GenericTools'
import type { BashOutputToolInput, BashOutputToolOutput } from './types'
import { AgentToolsType } from './types'
@ -44,34 +46,6 @@ const parseBashOutput = (output?: BashOutputToolOutput): ParsedBashOutput | null
}
}
const getStatusConfig = (parsedOutput: ParsedBashOutput | null) => {
if (!parsedOutput) return null
if (parsedOutput.tool_use_error) {
return {
color: 'danger',
icon: <XCircle className="h-3.5 w-3.5" />,
text: 'Error'
} as const
}
const isCompleted = parsedOutput.status === 'completed'
const isSuccess = parsedOutput.exit_code === 0
return {
color: isCompleted && isSuccess ? 'success' : isCompleted && !isSuccess ? 'danger' : 'warning',
icon:
isCompleted && isSuccess ? (
<CheckCircle className="h-3.5 w-3.5" />
) : isCompleted && !isSuccess ? (
<XCircle className="h-3.5 w-3.5" />
) : (
<Terminal className="h-3.5 w-3.5" />
),
text: isCompleted ? (isSuccess ? 'Success' : 'Failed') : 'Running'
} as const
}
export function BashOutputTool({
input,
output
@ -79,15 +53,62 @@ export function BashOutputTool({
input?: BashOutputToolInput
output?: BashOutputToolOutput
}): NonNullable<CollapseProps['items']>[number] {
const { t } = useTranslation()
const parsedOutput = parseBashOutput(output)
const getStatusConfig = (parsed: ParsedBashOutput | null) => {
if (!parsed) return null
if (parsed.tool_use_error) {
return {
color: 'danger',
icon: <XCircle className="h-3.5 w-3.5" />,
text: t('message.tools.status.error')
} as const
}
const isCompleted = parsed.status === 'completed'
const isSuccess = parsed.exit_code === 0
if (isCompleted && isSuccess) {
return {
color: 'success',
icon: <CheckCircle className="h-3.5 w-3.5" />,
text: t('message.tools.status.success')
} as const
}
if (isCompleted) {
return {
color: 'danger',
icon: <XCircle className="h-3.5 w-3.5" />,
text: t('message.tools.status.failed')
} as const
}
return {
color: 'warning',
icon: <Terminal className="h-3.5 w-3.5" />,
text: t('message.tools.status.running')
} as const
}
const statusConfig = getStatusConfig(parsedOutput)
// Truncate stdout and stderr separately
const truncatedStdout = truncateOutput(parsedOutput?.stdout)
const truncatedStderr = truncateOutput(parsedOutput?.stderr)
const truncatedError = truncateOutput(parsedOutput?.tool_use_error)
const truncatedRawOutput = truncateOutput(output)
const children = parsedOutput ? (
<div className="flex flex-col gap-4">
{/* Status Info */}
<div className="flex flex-wrap items-center gap-2">
{parsedOutput.exit_code !== undefined && (
<Tag color={parsedOutput.exit_code === 0 ? 'success' : 'danger'}>Exit Code: {parsedOutput.exit_code}</Tag>
<Tag color={parsedOutput.exit_code === 0 ? 'success' : 'danger'}>
{t('message.tools.sections.exitCode')}: {parsedOutput.exit_code}
</Tag>
)}
{parsedOutput.timestamp && (
<Tag className="py-0 font-mono text-xs">{new Date(parsedOutput.timestamp).toLocaleString()}</Tag>
@ -95,73 +116,78 @@ export function BashOutputTool({
</div>
{/* Standard Output */}
{parsedOutput.stdout && (
{truncatedStdout.data && (
<div>
<div className="mb-2 font-medium text-default-600 text-xs">stdout:</div>
<div className="mb-2 font-medium text-default-600 text-xs">{t('message.tools.sections.stdout')}:</div>
<pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">
{parsedOutput.stdout}
{truncatedStdout.data}
</pre>
{truncatedStdout.isTruncated && <TruncatedIndicator originalLength={truncatedStdout.originalLength} />}
</div>
)}
{/* Standard Error */}
{parsedOutput.stderr && (
{truncatedStderr.data && (
<div className="border border-danger-200">
<div className="mb-2 font-medium text-danger-600 text-xs">stderr:</div>
<div className="mb-2 font-medium text-danger-600 text-xs">{t('message.tools.sections.stderr')}:</div>
<pre className="whitespace-pre-wrap font-mono text-danger-600 text-xs dark:text-danger-400">
{parsedOutput.stderr}
{truncatedStderr.data}
</pre>
{truncatedStderr.isTruncated && <TruncatedIndicator originalLength={truncatedStderr.originalLength} />}
</div>
)}
{/* Tool Use Error */}
{parsedOutput.tool_use_error && (
{truncatedError.data && (
<div className="border border-danger-200">
<div className="mb-2 flex items-center gap-2">
<XCircle className="h-4 w-4 text-danger" />
<span className="font-medium text-danger-600 text-xs">Error:</span>
<span className="font-medium text-danger-600 text-xs">{t('message.tools.status.error')}:</span>
</div>
<pre className="whitespace-pre-wrap font-mono text-danger-600 text-xs dark:text-danger-400">
{parsedOutput.tool_use_error}
{truncatedError.data}
</pre>
{truncatedError.isTruncated && <TruncatedIndicator originalLength={truncatedError.originalLength} />}
</div>
)}
</div>
) : (
// 原始输出(如果解析失败或非 XML 格式)
output && (
truncatedRawOutput.data && (
<div>
<pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">{output}</pre>
<pre className="whitespace-pre-wrap font-mono text-default-700 text-xs dark:text-default-300">
{truncatedRawOutput.data}
</pre>
{truncatedRawOutput.isTruncated && <TruncatedIndicator originalLength={truncatedRawOutput.originalLength} />}
</div>
)
)
return {
key: AgentToolsType.BashOutput,
label: (
<>
<ToolTitle
icon={<Terminal className="h-4 w-4" />}
label="Bash Output"
params={
<div className="flex items-center gap-2">
<Tag className="py-0 font-mono text-xs">{input?.bash_id}</Tag>
{statusConfig && (
<Tag
color={statusConfig.color}
icon={statusConfig.icon}
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '2px'
}}>
{statusConfig.text}
</Tag>
)}
</div>
}
/>
</>
<ToolHeader
toolName={AgentToolsType.BashOutput}
params={
<div className="flex items-center gap-2">
<Tag className="py-0 font-mono text-xs">{input?.bash_id}</Tag>
{statusConfig && (
<Tag
color={statusConfig.color}
icon={statusConfig.icon}
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: '2px'
}}>
{statusConfig.text}
</Tag>
)}
</div>
}
variant="collapse-label"
showStatus={false}
/>
),
children: children

View File

@ -1,9 +1,13 @@
import type { CollapseProps } from 'antd'
import { Popover, Tag } from 'antd'
import { Terminal } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { ToolTitle } from './GenericTools'
import type { BashToolInput as BashToolInputType, BashToolOutput as BashToolOutputType } from './types'
import { truncateOutput } from '../shared/truncateOutput'
import { SkeletonValue, ToolHeader, TruncatedIndicator } from './GenericTools'
import {
AgentToolsType,
type BashToolInput as BashToolInputType,
type BashToolOutput as BashToolOutputType
} from './types'
export function BashTool({
input,
@ -12,33 +16,45 @@ export function BashTool({
input?: BashToolInputType
output?: BashToolOutputType
}): NonNullable<CollapseProps['items']>[number] {
// 如果有输出,计算输出行数
const outputLines = output ? output.split('\n').length : 0
// 处理命令字符串,添加空值检查
const command = input?.command ?? ''
const tagContent = <Tag className="!m-0 max-w-full truncate font-mono">{command}</Tag>
const { t } = useTranslation()
const command = input?.command
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
return {
key: 'tool',
label: (
<>
<ToolTitle
icon={<Terminal className="h-4 w-4" />}
label="Bash"
params={input?.description}
stats={output ? `${outputLines} ${outputLines === 1 ? 'line' : 'lines'}` : undefined}
/>
<div className="mt-1 max-w-full">
<Popover
content={<div className="max-w-xl whitespace-pre-wrap break-all font-mono text-xs">{command}</div>}
trigger="hover">
{tagContent}
</Popover>
</div>
</>
<ToolHeader
toolName={AgentToolsType.Bash}
params={<SkeletonValue value={input?.description} width="150px" />}
variant="collapse-label"
showStatus={false}
/>
),
children: <div className="whitespace-pre-line">{output}</div>
children: (
<div className="flex flex-col gap-3">
{/* Command 输入区域 */}
{command && (
<div>
<div className="mb-1 font-medium text-muted-foreground text-xs">{t('message.tools.sections.command')}</div>
<div className="max-h-40 overflow-y-auto rounded-md bg-muted/50 p-2">
<code className="whitespace-pre-wrap break-all font-mono text-xs">{command}</code>
</div>
</div>
)}
{/* Output 输出区域 */}
{truncatedOutput ? (
<div>
<div className="mb-1 font-medium text-muted-foreground text-xs">{t('message.tools.sections.output')}</div>
<div className="max-h-60 overflow-y-auto rounded-md bg-muted/30 p-2">
<pre className="whitespace-pre-wrap font-mono text-xs">{truncatedOutput}</pre>
</div>
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
</div>
) : (
<SkeletonValue value={null} width="100%" fallback={null} />
)}
</div>
)
}
}

View File

@ -1,7 +1,6 @@
import type { CollapseProps } from 'antd'
import { FileEdit } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import { ToolHeader } from './GenericTools'
import type { EditToolInput, EditToolOutput } from './types'
import { AgentToolsType } from './types'
@ -37,7 +36,14 @@ export function EditTool({
}): NonNullable<CollapseProps['items']>[number] {
return {
key: AgentToolsType.Edit,
label: <ToolTitle icon={<FileEdit className="h-4 w-4" />} label="Edit" params={input?.file_path} />,
label: (
<ToolHeader
toolName={AgentToolsType.Edit}
params={input?.file_path}
variant="collapse-label"
showStatus={false}
/>
),
children: (
<>
{/* Diff View */}

View File

@ -1,8 +1,9 @@
import type { CollapseProps } from 'antd'
import { DoorOpen } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import ReactMarkdown from 'react-markdown'
import { ToolTitle } from './GenericTools'
import { truncateOutput } from '../shared/truncateOutput'
import { ToolHeader, TruncatedIndicator } from './GenericTools'
import type { ExitPlanModeToolInput, ExitPlanModeToolOutput } from './types'
import { AgentToolsType } from './types'
@ -13,16 +14,27 @@ export function ExitPlanModeTool({
input?: ExitPlanModeToolInput
output?: ExitPlanModeToolOutput
}): NonNullable<CollapseProps['items']>[number] {
const { t } = useTranslation()
const plan = input?.plan ?? ''
const combinedContent = plan + '\n\n' + (output ?? '')
const { data: truncatedContent, isTruncated, originalLength } = truncateOutput(combinedContent)
const planCount = plan.split('\n\n').length
return {
key: AgentToolsType.ExitPlanMode,
label: (
<ToolTitle
icon={<DoorOpen className="h-4 w-4" />}
label="ExitPlanMode"
stats={`${plan.split('\n\n').length} plans`}
<ToolHeader
toolName={AgentToolsType.ExitPlanMode}
stats={`${planCount} ${t(planCount === 1 ? 'message.tools.units.plan' : 'message.tools.units.plans')}`}
variant="collapse-label"
showStatus={false}
/>
),
children: <ReactMarkdown>{plan + '\n\n' + (output ?? '')}</ReactMarkdown>
children: (
<div>
<ReactMarkdown>{truncatedContent}</ReactMarkdown>
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
</div>
)
}
}

View File

@ -1,31 +1,60 @@
// 通用工具组件 - 减少重复代码
import type { ReactNode } from 'react'
import { LoadingIcon } from '@renderer/components/Icons'
import type { MCPToolResponseStatus } from '@renderer/types'
import { formatFileSize } from '@renderer/utils/file'
import { Skeleton } from 'antd'
import { Check, Ellipsis, TriangleAlert, X } from 'lucide-react'
import { createContext, type ReactNode, use } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
// 生成 AccordionItem 的标题
export function ToolTitle({
icon,
label,
params,
stats,
className = 'text-sm'
}: {
icon?: ReactNode
label: string
params?: string | ReactNode
stats?: string | ReactNode
className?: string
}) {
export { default as ToolHeader, type ToolHeaderProps } from '../ToolHeader'
// Streaming context - 用于传递流式状态给子组件
export const StreamingContext = createContext<boolean>(false)
export const useIsStreaming = () => use(StreamingContext)
export function SkeletonSpan({ width = '60px' }: { width?: string }) {
return (
<div className={`flex items-center gap-1 ${className}`}>
{icon && <span className="flex flex-shrink-0">{icon}</span>}
{label && <span className="flex-shrink-0 font-medium text-sm">{label}</span>}
{params && <span className="min-w-0 truncate text-muted-foreground text-xs">{params}</span>}
{stats && <span className="flex-shrink-0 text-muted-foreground text-xs">{stats}</span>}
</div>
<Skeleton.Input
active
size="small"
style={{
width,
minWidth: width,
height: '1em',
verticalAlign: 'middle'
}}
/>
)
}
/**
* SkeletonValue - skeleton
*/
export function SkeletonValue({
value,
width = '60px',
fallback
}: {
value: ReactNode
width?: string
fallback?: ReactNode
}) {
const isStreaming = useIsStreaming()
if (value !== undefined && value !== null && value !== '') {
return <>{value}</>
}
if (isStreaming) {
return <SkeletonSpan width={width} />
}
return <>{fallback ?? ''}</>
}
// 纯字符串输入工具 (Task, Bash, Search)
export function StringInputTool({
input,
@ -93,3 +122,112 @@ export function StringOutputTool({
</div>
)
}
// ToolStatus extends MCPToolResponseStatus with UI-derived statuses
// 'waiting' is a UI status derived from 'pending' + needs approval
export type ToolStatus = MCPToolResponseStatus | 'waiting'
/**
* Convert raw data layer status to UI display status
* @param status - Raw status from MCPToolResponseStatus
* @param isWaiting - Whether the tool is waiting for user approval
* @returns The effective UI status
*/
export function getEffectiveStatus(status: MCPToolResponseStatus | undefined, isWaiting: boolean): ToolStatus {
if (status === 'pending') {
return isWaiting ? 'waiting' : 'invoking'
}
return status ?? 'pending'
}
// 工具状态指示器 - 显示在 Collapse 标题右侧
export function ToolStatusIndicator({ status, hasError = false }: { status: ToolStatus; hasError?: boolean }) {
const { t } = useTranslation()
const getStatusInfo = (): { label: string; icon: ReactNode; color: StatusColor } | null => {
switch (status) {
case 'streaming':
return { label: t('message.tools.streaming', 'Streaming'), icon: <LoadingIcon />, color: 'primary' }
case 'waiting':
return { label: t('message.tools.pending', 'Awaiting Approval'), icon: <LoadingIcon />, color: 'warning' }
case 'pending':
case 'invoking':
return { label: t('message.tools.invoking'), icon: <LoadingIcon />, color: 'primary' }
case 'cancelled':
return {
label: t('message.tools.cancelled'),
icon: <X size={13} className="lucide-custom" />,
color: 'error'
}
case 'done':
return hasError
? {
label: t('message.tools.error'),
icon: <TriangleAlert size={13} className="lucide-custom" />,
color: 'error'
}
: {
label: t('message.tools.completed'),
icon: <Check size={13} className="lucide-custom" />,
color: 'success'
}
case 'error':
return {
label: t('message.tools.error'),
icon: <TriangleAlert size={13} className="lucide-custom" />,
color: 'error'
}
default:
return null
}
}
const info = getStatusInfo()
if (!info) return null
return (
<StatusIndicatorContainer $color={info.color}>
{info.label}
{info.icon}
</StatusIndicatorContainer>
)
}
export type StatusColor = 'primary' | 'success' | 'warning' | 'error'
function getStatusColor(color: StatusColor): string {
switch (color) {
case 'primary':
case 'success':
return 'var(--color-primary)'
case 'warning':
return 'var(--color-status-warning, #faad14)'
case 'error':
return 'var(--color-status-error, #ff4d4f)'
default:
return 'var(--color-text)'
}
}
export const StatusIndicatorContainer = styled.span<{ $color: StatusColor }>`
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 4px;
opacity: 0.85;
color: ${(props) => getStatusColor(props.$color)};
`
export function TruncatedIndicator({ originalLength }: { originalLength: number }) {
const { t } = useTranslation()
const sizeStr = formatFileSize(originalLength)
return (
<div className="mt-2 flex items-center gap-1 text-muted-foreground text-xs">
<Ellipsis size={14} />
<span className="rounded bg-muted px-1.5 py-0.5 font-mono">
{t('message.tools.truncated', { defaultValue: sizeStr, size: sizeStr })}
</span>
</div>
)
}

View File

@ -1,8 +1,13 @@
import type { CollapseProps } from 'antd'
import { FolderSearch } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { ToolTitle } from './GenericTools'
import type { GlobToolInput as GlobToolInputType, GlobToolOutput as GlobToolOutputType } from './types'
import { countLines, truncateOutput } from '../shared/truncateOutput'
import { ToolHeader, TruncatedIndicator } from './GenericTools'
import {
AgentToolsType,
type GlobToolInput as GlobToolInputType,
type GlobToolOutput as GlobToolOutputType
} from './types'
export function GlobTool({
input,
@ -11,19 +16,31 @@ export function GlobTool({
input?: GlobToolInputType
output?: GlobToolOutputType
}): NonNullable<CollapseProps['items']>[number] {
const { t } = useTranslation()
// 如果有输出,计算文件数量
const lineCount = output ? output.split('\n').filter((line) => line.trim()).length : 0
const lineCount = countLines(output)
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
return {
key: 'tool',
label: (
<ToolTitle
icon={<FolderSearch className="h-4 w-4" />}
label="Glob"
<ToolHeader
toolName={AgentToolsType.Glob}
params={input?.pattern}
stats={output ? `${lineCount} ${lineCount === 1 ? 'file' : 'files'}` : undefined}
stats={
output
? `${lineCount} ${t(lineCount === 1 ? 'message.tools.units.file' : 'message.tools.units.files')}`
: undefined
}
variant="collapse-label"
showStatus={false}
/>
),
children: <div>{output}</div>
children: (
<div>
<div>{truncatedOutput}</div>
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
</div>
)
}
}

View File

@ -1,8 +1,9 @@
import type { CollapseProps } from 'antd'
import { FileSearch } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { ToolTitle } from './GenericTools'
import type { GrepToolInput, GrepToolOutput } from './types'
import { countLines, truncateOutput } from '../shared/truncateOutput'
import { ToolHeader, TruncatedIndicator } from './GenericTools'
import { AgentToolsType, type GrepToolInput, type GrepToolOutput } from './types'
export function GrepTool({
input,
@ -11,24 +12,36 @@ export function GrepTool({
input?: GrepToolInput
output?: GrepToolOutput
}): NonNullable<CollapseProps['items']>[number] {
const { t } = useTranslation()
// 如果有输出,计算结果行数
const resultLines = output ? output.split('\n').filter((line) => line.trim()).length : 0
const resultLines = countLines(output)
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
return {
key: 'tool',
label: (
<ToolTitle
icon={<FileSearch className="h-4 w-4" />}
label="Grep"
<ToolHeader
toolName={AgentToolsType.Grep}
params={
<>
{input?.pattern}
{input?.output_mode && <span className="ml-1">({input.output_mode})</span>}
</>
}
stats={output ? `${resultLines} ${resultLines === 1 ? 'line' : 'lines'}` : undefined}
stats={
output
? `${resultLines} ${t(resultLines === 1 ? 'message.tools.units.line' : 'message.tools.units.lines')}`
: undefined
}
variant="collapse-label"
showStatus={false}
/>
),
children: <div>{output}</div>
children: (
<div>
<div>{truncatedOutput}</div>
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
</div>
)
}
}

View File

@ -1,8 +1,7 @@
import type { CollapseProps } from 'antd'
import { FileText } from 'lucide-react'
import { renderCodeBlock } from './EditTool'
import { ToolTitle } from './GenericTools'
import { ToolHeader } from './GenericTools'
import type { MultiEditToolInput, MultiEditToolOutput } from './types'
import { AgentToolsType } from './types'
@ -15,7 +14,14 @@ export function MultiEditTool({
const edits = Array.isArray(input?.edits) ? input.edits : []
return {
key: AgentToolsType.MultiEdit,
label: <ToolTitle icon={<FileText className="h-4 w-4" />} label="MultiEdit" params={input?.file_path} />,
label: (
<ToolHeader
toolName={AgentToolsType.MultiEdit}
params={input?.file_path}
variant="collapse-label"
showStatus={false}
/>
),
children: (
<div>
{edits.map((edit, index) => (

View File

@ -1,9 +1,9 @@
import type { CollapseProps } from 'antd'
import { Tag } from 'antd'
import { FileText } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import { ToolTitle } from './GenericTools'
import { truncateOutput } from '../shared/truncateOutput'
import { ToolHeader, TruncatedIndicator } from './GenericTools'
import type { NotebookEditToolInput, NotebookEditToolOutput } from './types'
import { AgentToolsType } from './types'
@ -14,16 +14,21 @@ export function NotebookEditTool({
input?: NotebookEditToolInput
output?: NotebookEditToolOutput
}): NonNullable<CollapseProps['items']>[number] {
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
return {
key: AgentToolsType.NotebookEdit,
label: (
<>
<ToolTitle icon={<FileText className="h-4 w-4" />} label="NotebookEdit" />
<Tag className="mt-1" color="blue">
{input?.notebook_path}{' '}
</Tag>
</>
<div className="flex items-center gap-2">
<ToolHeader toolName={AgentToolsType.NotebookEdit} variant="collapse-label" showStatus={false} />
<Tag color="blue">{input?.notebook_path}</Tag>
</div>
),
children: <ReactMarkdown>{output ?? ''}</ReactMarkdown>
children: (
<div>
<ReactMarkdown>{truncatedOutput}</ReactMarkdown>
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
</div>
)
}
}

View File

@ -1,8 +1,10 @@
import { formatFileSize } from '@renderer/utils/file'
import type { CollapseProps } from 'antd'
import { FileText } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import ReactMarkdown from 'react-markdown'
import { ToolTitle } from './GenericTools'
import { truncateOutput } from '../shared/truncateOutput'
import { SkeletonValue, ToolHeader, TruncatedIndicator } from './GenericTools'
import type { ReadToolInput as ReadToolInputType, ReadToolOutput as ReadToolOutputType, TextOutput } from './types'
import { AgentToolsType } from './types'
@ -28,17 +30,9 @@ const normalizeOutputString = (output?: ReadToolOutputType): string | null => {
const getOutputStats = (outputString: string | null) => {
if (!outputString) return null
const bytes = new Blob([outputString]).size
const formatSize = (size: number) => {
if (size < 1024) return `${size} B`
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
return `${(size / (1024 * 1024)).toFixed(1)} MB`
}
return {
lineCount: outputString.split('\n').length,
fileSize: bytes,
formatSize
fileSize: new Blob([outputString]).size
}
}
@ -49,19 +43,34 @@ export function ReadTool({
input?: ReadToolInputType
output?: ReadToolOutputType
}): NonNullable<CollapseProps['items']>[number] {
const { t } = useTranslation()
const outputString = normalizeOutputString(output)
const stats = getOutputStats(outputString)
const filename = input?.file_path?.split('/').pop()
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(outputString)
return {
key: AgentToolsType.Read,
label: (
<ToolTitle
icon={<FileText className="h-4 w-4" />}
label="Read File"
params={input?.file_path?.split('/').pop()}
stats={stats ? `${stats.lineCount} lines, ${stats.formatSize(stats.fileSize)}` : undefined}
<ToolHeader
toolName={AgentToolsType.Read}
params={<SkeletonValue value={filename} width="120px" />}
stats={
stats
? `${stats.lineCount} ${t(stats.lineCount === 1 ? 'message.tools.units.line' : 'message.tools.units.lines')}, ${formatFileSize(stats.fileSize)}`
: undefined
}
variant="collapse-label"
showStatus={false}
/>
),
children: outputString ? <ReactMarkdown>{outputString}</ReactMarkdown> : null
children: truncatedOutput ? (
<div>
<ReactMarkdown>{truncatedOutput}</ReactMarkdown>
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
</div>
) : (
<SkeletonValue value={null} width="100%" fallback={null} />
)
}
}

View File

@ -1,8 +1,13 @@
import type { CollapseProps } from 'antd'
import { Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { StringInputTool, StringOutputTool, ToolTitle } from './GenericTools'
import type { SearchToolInput as SearchToolInputType, SearchToolOutput as SearchToolOutputType } from './types'
import { countLines, truncateOutput } from '../shared/truncateOutput'
import { StringInputTool, StringOutputTool, ToolHeader, TruncatedIndicator } from './GenericTools'
import {
AgentToolsType,
type SearchToolInput as SearchToolInputType,
type SearchToolOutput as SearchToolOutputType
} from './types'
export function SearchTool({
input,
@ -11,25 +16,37 @@ export function SearchTool({
input?: SearchToolInputType
output?: SearchToolOutputType
}): NonNullable<CollapseProps['items']>[number] {
const { t } = useTranslation()
// 如果有输出,计算结果数量
const resultCount = output ? output.split('\n').filter((line) => line.trim()).length : 0
const resultCount = countLines(output)
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
return {
key: 'tool',
label: (
<ToolTitle
icon={<Search className="h-4 w-4" />}
label="Search"
<ToolHeader
toolName={AgentToolsType.Search}
params={input ? `"${input}"` : undefined}
stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined}
stats={
output
? `${resultCount} ${t(resultCount === 1 ? 'message.tools.units.result' : 'message.tools.units.results')}`
: undefined
}
variant="collapse-label"
showStatus={false}
/>
),
children: (
<div>
{input && <StringInputTool input={input} label="Search Query" />}
{output && (
{input && <StringInputTool input={input} label={t('message.tools.sections.searchQuery')} />}
{truncatedOutput && (
<div>
<StringOutputTool output={output} label="Search Results" textColor="text-yellow-600 dark:text-yellow-400" />
<StringOutputTool
output={truncatedOutput}
label={t('message.tools.sections.searchResults')}
textColor="text-yellow-600 dark:text-yellow-400"
/>
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
</div>
)}
</div>

View File

@ -1,8 +1,8 @@
import type { CollapseProps } from 'antd'
import { PencilRuler } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { SkillToolInput, SkillToolOutput } from './types'
import { truncateOutput } from '../shared/truncateOutput'
import { ToolHeader, TruncatedIndicator } from './GenericTools'
import { AgentToolsType, type SkillToolInput, type SkillToolOutput } from './types'
export function SkillTool({
input,
@ -11,9 +11,18 @@ export function SkillTool({
input?: SkillToolInput
output?: SkillToolOutput
}): NonNullable<CollapseProps['items']>[number] {
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
return {
key: 'tool',
label: <ToolTitle icon={<PencilRuler className="h-4 w-4" />} label="Skill" params={input?.command} />,
children: <div>{output}</div>
label: (
<ToolHeader toolName={AgentToolsType.Skill} params={input?.command} variant="collapse-label" showStatus={false} />
),
children: (
<div>
<div>{truncatedOutput}</div>
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
</div>
)
}
}

View File

@ -1,9 +1,15 @@
import type { CollapseProps } from 'antd'
import { Bot } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Markdown from 'react-markdown'
import { ToolTitle } from './GenericTools'
import type { TaskToolInput as TaskToolInputType, TaskToolOutput as TaskToolOutputType } from './types'
import { truncateOutput } from '../shared/truncateOutput'
import { SkeletonValue, ToolHeader, TruncatedIndicator } from './GenericTools'
import {
AgentToolsType,
type TaskToolInput as TaskToolInputType,
type TaskToolOutput as TaskToolOutputType
} from './types'
export function TaskTool({
input,
@ -12,17 +18,51 @@ export function TaskTool({
input?: TaskToolInputType
output?: TaskToolOutputType
}): NonNullable<CollapseProps['items']>[number] {
const { t } = useTranslation()
const hasOutput = Array.isArray(output) && output.length > 0
// Combine all text outputs and truncate
const { truncatedText, isTruncated, originalLength } = useMemo(() => {
if (!hasOutput) return { truncatedText: '', isTruncated: false, originalLength: 0 }
const combinedText = output!.map((item) => item.text).join('\n\n')
const result = truncateOutput(combinedText)
return { truncatedText: result.data, isTruncated: result.isTruncated, originalLength: result.originalLength }
}, [output, hasOutput])
return {
key: 'tool',
label: <ToolTitle icon={<Bot className="h-4 w-4" />} label="Task" params={input?.description} />,
label: (
<ToolHeader
toolName={AgentToolsType.Task}
params={<SkeletonValue value={input?.description} width="150px" />}
variant="collapse-label"
showStatus={false}
/>
),
children: (
<div>
{Array.isArray(output) &&
output.map((item) => (
<div key={item.type}>
<div>{item.type === 'text' ? <Markdown>{item.text}</Markdown> : item.text}</div>
<div className="flex flex-col gap-3">
{/* Prompt 输入区域 */}
{input?.prompt && (
<div>
<div className="mb-1 font-medium text-muted-foreground text-xs">{t('message.tools.sections.prompt')}</div>
<div className="max-h-40 overflow-y-auto rounded-md bg-muted/50 p-2 text-sm">
<Markdown>{input.prompt}</Markdown>
</div>
))}
</div>
)}
{/* Output 输出区域 */}
{hasOutput ? (
<div>
<div className="mb-1 font-medium text-muted-foreground text-xs">{t('message.tools.sections.output')}</div>
<div className="rounded-md bg-muted/30 p-2">
<Markdown>{truncatedText}</Markdown>
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
</div>
</div>
) : (
<SkeletonValue value={null} width="100%" fallback={null} />
)}
</div>
)
}

View File

@ -1,8 +1,9 @@
import type { CollapseProps } from 'antd'
import { Card } from 'antd'
import { CheckCircle, Circle, Clock, ListTodo } from 'lucide-react'
import { CheckCircle, Circle, Clock } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { ToolTitle } from './GenericTools'
import { ToolHeader } from './GenericTools'
import type { TodoItem, TodoWriteToolInput as TodoWriteToolInputType } from './types'
import { AgentToolsType } from './types'
@ -20,12 +21,6 @@ const getStatusConfig = (status: TodoItem['status']) => {
opacity: 0.9,
icon: <Clock className="h-4 w-4" strokeWidth={2.5} />
}
case 'pending':
return {
color: 'var(--color-border)',
opacity: 0.4,
icon: <Circle className="h-4 w-4" strokeWidth={2.5} />
}
default:
return {
color: 'var(--color-border)',
@ -40,17 +35,19 @@ export function TodoWriteTool({
}: {
input?: TodoWriteToolInputType
}): NonNullable<CollapseProps['items']>[number] {
const { t } = useTranslation()
const todos = Array.isArray(input?.todos) ? input.todos : []
const doneCount = todos.filter((todo) => todo.status === 'completed').length
return {
key: AgentToolsType.TodoWrite,
label: (
<ToolTitle
icon={<ListTodo className="h-4 w-4" />}
label="Todo Write"
params={`${doneCount} Done`}
stats={`${todos.length} ${todos.length === 1 ? 'item' : 'items'}`}
<ToolHeader
toolName={AgentToolsType.TodoWrite}
params={`${doneCount} ${t('message.tools.status.done')}`}
stats={`${todos.length} ${t(todos.length === 1 ? 'message.tools.units.item' : 'message.tools.units.items')}`}
variant="collapse-label"
showStatus={false}
/>
),
children: (

View File

@ -1,9 +1,9 @@
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import type { CollapseProps } from 'antd'
import { Wrench } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ToolTitle } from './GenericTools'
import { ToolArgsTable } from '../shared/ArgsTable'
import { ToolHeader } from './GenericTools'
interface UnknownToolProps {
toolName: string
@ -21,75 +21,54 @@ const getToolDisplayName = (name: string) => {
return name
}
const getToolDescription = (toolName: string) => {
if (toolName.startsWith('mcp__')) {
return 'MCP Server Tool'
}
return 'Tool'
}
const UnknownToolContent = ({ input, output }: { input?: unknown; output?: unknown }) => {
const { highlightCode } = useCodeStyle()
const [inputHtml, setInputHtml] = useState<string>('')
const [outputHtml, setOutputHtml] = useState<string>('')
useEffect(() => {
if (input !== undefined) {
const inputStr = JSON.stringify(input, null, 2)
highlightCode(inputStr, 'json').then(setInputHtml)
}
}, [input, highlightCode])
useEffect(() => {
if (output !== undefined) {
const outputStr = JSON.stringify(output, null, 2)
highlightCode(outputStr, 'json').then(setOutputHtml)
}
}, [output, highlightCode])
if (input === undefined && output === undefined) {
return <div className="text-foreground-500 text-xs">No data available for this tool</div>
}
return (
<div className="space-y-3">
{input !== undefined && (
<div>
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Input:</div>
<div
className="overflow-x-auto rounded bg-gray-50 dark:bg-gray-900"
dangerouslySetInnerHTML={{ __html: inputHtml }}
/>
</div>
)}
{output !== undefined && (
<div>
<div className="mb-1 font-semibold text-foreground-600 text-xs dark:text-foreground-400">Output:</div>
<div
className="rounded bg-gray-50 dark:bg-gray-900 [&>*]:whitespace-pre-line"
dangerouslySetInnerHTML={{ __html: outputHtml }}
/>
</div>
)}
</div>
)
}
/**
* Fallback renderer for unknown tool types
* Uses shared ArgsTable for consistent styling with MCP tools
*/
export function UnknownToolRenderer({
toolName = '',
input,
output
}: UnknownToolProps): NonNullable<CollapseProps['items']>[number] {
const { t } = useTranslation()
const getToolDescription = (name: string) => {
if (name.startsWith('mcp__')) {
return t('message.tools.labels.mcpServerTool')
}
return t('message.tools.labels.tool')
}
// Normalize input/output for table display
const normalizeArgs = (value: unknown): Record<string, unknown> | unknown[] | null => {
if (value === undefined || value === null) return null
if (typeof value === 'object') return value as Record<string, unknown> | unknown[]
// Wrap primitive values
return { value }
}
const normalizedInput = normalizeArgs(input)
const normalizedOutput = normalizeArgs(output)
return {
key: 'unknown-tool',
label: (
<ToolTitle
<ToolHeader
toolName={getToolDisplayName(toolName)}
icon={<Wrench className="h-4 w-4" />}
label={getToolDisplayName(toolName)}
params={getToolDescription(toolName)}
variant="collapse-label"
showStatus={false}
/>
),
children: <UnknownToolContent input={input} output={output} />
children: (
<div className="space-y-1">
{normalizedInput && <ToolArgsTable args={normalizedInput} title={t('message.tools.sections.input')} />}
{normalizedOutput && <ToolArgsTable args={normalizedOutput} title={t('message.tools.sections.output')} />}
{!normalizedInput && !normalizedOutput && (
<div className="p-3 text-foreground-500 text-xs">{t('message.tools.noData')}</div>
)}
</div>
)
}
}

View File

@ -1,8 +1,8 @@
import type { CollapseProps } from 'antd'
import { Globe } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { WebFetchToolInput, WebFetchToolOutput } from './types'
import { truncateOutput } from '../shared/truncateOutput'
import { ToolHeader, TruncatedIndicator } from './GenericTools'
import { AgentToolsType, type WebFetchToolInput, type WebFetchToolOutput } from './types'
export function WebFetchTool({
input,
@ -11,9 +11,18 @@ export function WebFetchTool({
input?: WebFetchToolInput
output?: WebFetchToolOutput
}): NonNullable<CollapseProps['items']>[number] {
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
return {
key: 'tool',
label: <ToolTitle icon={<Globe className="h-4 w-4" />} label="Web Fetch" params={input?.url} />,
children: <div>{output}</div>
label: (
<ToolHeader toolName={AgentToolsType.WebFetch} params={input?.url} variant="collapse-label" showStatus={false} />
),
children: (
<div>
<div>{truncatedOutput}</div>
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
</div>
)
}
}

View File

@ -1,8 +1,9 @@
import type { CollapseProps } from 'antd'
import { Globe } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { ToolTitle } from './GenericTools'
import type { WebSearchToolInput, WebSearchToolOutput } from './types'
import { countLines, truncateOutput } from '../shared/truncateOutput'
import { ToolHeader, TruncatedIndicator } from './GenericTools'
import { AgentToolsType, type WebSearchToolInput, type WebSearchToolOutput } from './types'
export function WebSearchTool({
input,
@ -11,19 +12,31 @@ export function WebSearchTool({
input?: WebSearchToolInput
output?: WebSearchToolOutput
}): NonNullable<CollapseProps['items']>[number] {
const { t } = useTranslation()
// 如果有输出,计算结果数量
const resultCount = output ? output.split('\n').filter((line) => line.trim()).length : 0
const resultCount = countLines(output)
const { data: truncatedOutput, isTruncated, originalLength } = truncateOutput(output)
return {
key: 'tool',
label: (
<ToolTitle
icon={<Globe className="h-4 w-4" />}
label="Web Search"
<ToolHeader
toolName={AgentToolsType.WebSearch}
params={input?.query}
stats={output ? `${resultCount} ${resultCount === 1 ? 'result' : 'results'}` : undefined}
stats={
output
? `${resultCount} ${t(resultCount === 1 ? 'message.tools.units.result' : 'message.tools.units.results')}`
: undefined
}
variant="collapse-label"
showStatus={false}
/>
),
children: <div>{output}</div>
children: (
<div>
<div>{truncatedOutput}</div>
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
</div>
)
}
}

View File

@ -1,8 +1,7 @@
import type { CollapseProps } from 'antd'
import { FileText } from 'lucide-react'
import { ToolTitle } from './GenericTools'
import type { WriteToolInput, WriteToolOutput } from './types'
import { ToolHeader } from './GenericTools'
import { AgentToolsType, type WriteToolInput, type WriteToolOutput } from './types'
export function WriteTool({
input
@ -12,7 +11,14 @@ export function WriteTool({
}): NonNullable<CollapseProps['items']>[number] {
return {
key: 'tool',
label: <ToolTitle icon={<FileText className="h-4 w-4" />} label="Write" params={input?.file_path} />,
label: (
<ToolHeader
toolName={AgentToolsType.Write}
params={input?.file_path}
variant="collapse-label"
showStatus={false}
/>
),
children: <div>{input?.content}</div>
}
}

View File

@ -1,10 +1,10 @@
import { loggerService } from '@logger'
import { useAppSelector } from '@renderer/store'
import { selectPendingPermission } from '@renderer/store/toolPermissions'
import type { NormalToolResponse } from '@renderer/types'
import type { CollapseProps } from 'antd'
import { Collapse, Spin } from 'antd'
import { useTranslation } from 'react-i18next'
import { Collapse } from 'antd'
import { parse as parsePartialJson } from 'partial-json'
import { useMemo } from 'react'
// 导出所有类型
export * from './types'
@ -15,6 +15,7 @@ import { BashOutputTool } from './BashOutputTool'
import { BashTool } from './BashTool'
import { EditTool } from './EditTool'
import { ExitPlanModeTool } from './ExitPlanModeTool'
import { getEffectiveStatus, StreamingContext, type ToolStatus, ToolStatusIndicator } from './GenericTools'
import { GlobTool } from './GlobTool'
import { GrepTool } from './GrepTool'
import { MultiEditTool } from './MultiEditTool'
@ -31,9 +32,7 @@ import { WebFetchTool } from './WebFetchTool'
import { WebSearchTool } from './WebSearchTool'
import { WriteTool } from './WriteTool'
const logger = loggerService.withContext('MessageAgentTools')
// 创建工具渲染器映射,这样就实现了完全的类型安全
// 创建工具渲染器映射
export const toolRenderers = {
[AgentToolsType.Read]: ReadTool,
[AgentToolsType.Task]: TaskTool,
@ -51,76 +50,116 @@ export const toolRenderers = {
[AgentToolsType.NotebookEdit]: NotebookEditTool,
[AgentToolsType.ExitPlanMode]: ExitPlanModeTool,
[AgentToolsType.Skill]: SkillTool
} as const
}
/**
* Type-safe tool renderer invocation function.
* Use this function to call a tool renderer with proper type checking,
* avoiding the need for `as any` type assertions at call sites.
*
* @param toolName - The name of the tool (must be a valid AgentToolsType)
* @param input - The input for the tool (accepts various input formats)
* @param output - Optional output from the tool
* @returns The rendered collapse item
*/
export function renderTool(
toolName: AgentToolsType,
input: ToolInput | Record<string, unknown> | string | undefined,
output?: ToolOutput | unknown
): NonNullable<CollapseProps['items']>[number] {
const renderer = toolRenderers[toolName] as (props: {
input?: unknown
output?: unknown
}) => NonNullable<CollapseProps['items']>[number]
return renderer({ input, output })
}
// 类型守卫函数
export function isValidAgentToolsType(toolName: unknown): toolName is AgentToolsType {
return typeof toolName === 'string' && Object.values(AgentToolsType).includes(toolName as AgentToolsType)
}
// 统一的渲染组件
function ToolContent({ toolName, input, output }: { toolName: AgentToolsType; input: ToolInput; output?: ToolOutput }) {
const Renderer = toolRenderers[toolName]
const renderedItem = Renderer
? Renderer({ input: input as any, output: output as any })
: UnknownToolRenderer({ input: input as any, output: output as any, toolName })
function ToolContent({
toolName,
input,
output,
isStreaming = false,
status,
hasError = false
}: {
toolName?: string
input?: ToolInput | Record<string, unknown>
output?: ToolOutput | unknown
isStreaming?: boolean
status?: ToolStatus
hasError?: boolean
}) {
const renderedItem = isValidAgentToolsType(toolName)
? renderTool(toolName, (input ?? {}) as Record<string, unknown>, output)
: UnknownToolRenderer({ toolName: toolName ?? 'Tool', input, output })
const toolContentItem: NonNullable<CollapseProps['items']>[number] = {
...renderedItem,
label: (
<div className="flex w-full items-start justify-between gap-2">
<div className="min-w-0">{renderedItem.label}</div>
{status && (
<div className="shrink-0">
<ToolStatusIndicator status={status} hasError={hasError} />
</div>
)}
</div>
),
classNames: {
body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 p-2 overflow-scroll'
body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-96 overflow-scroll'
}
}
return (
<Collapse
className="w-max max-w-full"
expandIconPosition="end"
size="small"
defaultActiveKey={toolName === AgentToolsType.TodoWrite ? [AgentToolsType.TodoWrite] : []}
items={[toolContentItem]}
/>
<StreamingContext value={isStreaming}>
<Collapse
className="w-max max-w-full"
expandIconPosition="end"
size="small"
defaultActiveKey={toolName === AgentToolsType.TodoWrite ? [AgentToolsType.TodoWrite] : []}
items={[toolContentItem]}
/>
</StreamingContext>
)
}
// 统一的组件渲染入口
export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolResponse }) {
const { arguments: args, response, tool, status } = toolResponse
logger.debug('Rendering agent tool response', {
tool: tool,
arguments: args,
status,
response
})
const { arguments: args, response, tool, status, partialArguments } = toolResponse
const pendingPermission = useAppSelector((state) =>
selectPendingPermission(state.toolPermissions, toolResponse.toolCallId)
)
if (status === 'pending') {
if (pendingPermission) {
return <ToolPermissionRequestCard toolResponse={toolResponse} />
const parsedPartialArgs = useMemo(() => {
if (!partialArguments) return undefined
try {
return parsePartialJson(partialArguments)
} catch {
return undefined
}
return <ToolPendingIndicator toolName={tool?.name} description={tool?.description} />
}, [partialArguments])
const effectiveStatus = getEffectiveStatus(status, !!pendingPermission)
if (effectiveStatus === 'waiting') {
return <ToolPermissionRequestCard toolResponse={toolResponse} />
}
const isLoading = effectiveStatus === 'streaming' || effectiveStatus === 'invoking'
return (
<ToolContent toolName={tool.name as AgentToolsType} input={args as ToolInput} output={response as ToolOutput} />
)
}
function ToolPendingIndicator({ toolName, description }: { toolName?: string; description?: string }) {
const { t } = useTranslation()
const label = toolName || t('agent.toolPermission.toolPendingFallback', 'Tool')
const detail = description?.trim() || t('agent.toolPermission.executing')
return (
<div className="flex w-full max-w-xl items-center gap-3 rounded-xl border border-default-200 bg-default-100 px-4 py-3 shadow-sm">
<Spin size="small" />
<div className="flex flex-col gap-1">
<span className="font-semibold text-default-700 text-sm">{label}</span>
<span className="text-default-500 text-xs">{detail}</span>
</div>
</div>
<ToolContent
toolName={tool?.name}
input={args ?? parsedPartialArgs}
output={isLoading ? undefined : response}
isStreaming={isLoading}
status={effectiveStatus}
hasError={status === 'error'}
/>
)
}

View File

@ -1,3 +1,5 @@
import type { CollapseProps } from 'antd'
export enum AgentToolsType {
Skill = 'Skill',
Read = 'Read',
@ -386,3 +388,52 @@ export type ToolOutput =
export interface ToolRenderer {
render: (props: { input: ToolInput; output?: ToolOutput }) => React.ReactElement
}
// 工具类型到输入类型的映射(用于文档和类型提示)
export interface ToolInputMap {
[AgentToolsType.Skill]: SkillToolInput
[AgentToolsType.Read]: ReadToolInput
[AgentToolsType.Task]: TaskToolInput
[AgentToolsType.Bash]: BashToolInput
[AgentToolsType.Search]: SearchToolInput
[AgentToolsType.Glob]: GlobToolInput
[AgentToolsType.TodoWrite]: TodoWriteToolInput
[AgentToolsType.WebSearch]: WebSearchToolInput
[AgentToolsType.Grep]: GrepToolInput
[AgentToolsType.Write]: WriteToolInput
[AgentToolsType.WebFetch]: WebFetchToolInput
[AgentToolsType.Edit]: EditToolInput
[AgentToolsType.MultiEdit]: MultiEditToolInput
[AgentToolsType.BashOutput]: BashOutputToolInput
[AgentToolsType.NotebookEdit]: NotebookEditToolInput
[AgentToolsType.ExitPlanMode]: ExitPlanModeToolInput
}
// 工具类型到输出类型的映射
export interface ToolOutputMap {
[AgentToolsType.Skill]: SkillToolOutput
[AgentToolsType.Read]: ReadToolOutput
[AgentToolsType.Task]: TaskToolOutput
[AgentToolsType.Bash]: BashToolOutput
[AgentToolsType.Search]: SearchToolOutput
[AgentToolsType.Glob]: GlobToolOutput
[AgentToolsType.TodoWrite]: TodoWriteToolOutput
[AgentToolsType.WebSearch]: WebSearchToolOutput
[AgentToolsType.Grep]: GrepToolOutput
[AgentToolsType.Write]: WriteToolOutput
[AgentToolsType.WebFetch]: WebFetchToolOutput
[AgentToolsType.Edit]: EditToolOutput
[AgentToolsType.MultiEdit]: MultiEditToolOutput
[AgentToolsType.BashOutput]: BashOutputToolOutput
[AgentToolsType.NotebookEdit]: NotebookEditToolOutput
[AgentToolsType.ExitPlanMode]: ExitPlanModeToolOutput
}
// 通用工具渲染器函数类型 - 接受宽松的输入类型
export type ToolRendererFn = (props: {
input?: ToolInput | Record<string, unknown> | string
output?: ToolOutput | unknown
}) => NonNullable<CollapseProps['items']>[number]
// 工具渲染器映射类型
export type ToolRenderersMap = Record<AgentToolsType, ToolRendererFn>

View File

@ -1,107 +1,66 @@
import { loggerService } from '@logger'
import { CopyIcon, LoadingIcon } from '@renderer/components/Icons'
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'
import { CopyIcon } from '@renderer/components/Icons'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { useSettings } from '@renderer/hooks/useSettings'
import { useTimer } from '@renderer/hooks/useTimer'
import type { MCPToolResponse } from '@renderer/types'
import type { ToolMessageBlock } from '@renderer/types/newMessage'
import { isToolAutoApproved } from '@renderer/utils/mcp-tools'
import { cancelToolAction, confirmToolAction } from '@renderer/utils/userConfirmation'
import type { MCPProgressEvent } from '@shared/config/types'
import { IpcChannel } from '@shared/IpcChannel'
import {
Button,
Collapse,
ConfigProvider,
Dropdown,
Flex,
message as antdMessage,
Modal,
Progress,
Tabs,
Tooltip
} from 'antd'
import { Collapse, ConfigProvider, Flex, message as antdMessage, Progress, Tooltip } from 'antd'
import { message } from 'antd'
import {
Check,
ChevronDown,
ChevronRight,
CirclePlay,
CircleX,
Maximize,
PauseCircle,
ShieldCheck,
TriangleAlert,
X
} from 'lucide-react'
import { Check, ChevronRight, ShieldCheck } from 'lucide-react'
import { parse as parsePartialJson } from 'partial-json'
import type { FC } from 'react'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { memo, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { useMcpToolApproval } from './hooks/useMcpToolApproval'
import {
getEffectiveStatus,
SkeletonSpan,
ToolStatusIndicator,
TruncatedIndicator
} from './MessageAgentTools/GenericTools'
import {
ArgKey,
ArgsSection,
ArgsSectionTitle,
ArgsTable,
ArgValue,
formatArgValue,
ResponseSection
} from './shared/ArgsTable'
import { truncateOutput } from './shared/truncateOutput'
import ToolApprovalActionsComponent from './ToolApprovalActions'
interface Props {
block: ToolMessageBlock
}
const logger = loggerService.withContext('MessageTools')
const COUNTDOWN_TIME = 30
const MessageMcpTool: FC<Props> = ({ block }) => {
const [activeKeys, setActiveKeys] = useState<string[]>([])
const [copiedMap, setCopiedMap] = useState<Record<string, boolean>>({})
const [countdown, setCountdown] = useState<number>(COUNTDOWN_TIME)
const { t } = useTranslation()
const { messageFont, fontSize } = useSettings()
const { mcpServers, updateMCPServer } = useMCPServers()
const [expandedResponse, setExpandedResponse] = useState<{ content: string; title: string } | null>(null)
const [progress, setProgress] = useState<number>(0)
const { setTimeoutTimer } = useTimer()
// Use the unified approval hook
const approval = useMcpToolApproval(block)
const toolResponse = block.metadata?.rawMcpToolResponse as MCPToolResponse
const { id, tool, status, response } = toolResponse as MCPToolResponse
const { id, tool, status, response, partialArguments } = toolResponse as MCPToolResponse
const isPending = status === 'pending'
const isDone = status === 'done'
const isError = status === 'error'
const isAutoApproved = useMemo(
() =>
isToolAutoApproved(
tool,
mcpServers.find((s) => s.id === tool.serverId)
),
[tool, mcpServers]
)
// 增加本地状态来跟踪用户确认
const [isConfirmed, setIsConfirmed] = useState(isAutoApproved)
// 判断不同的UI状态
const isWaitingConfirmation = isPending && !isAutoApproved && !isConfirmed
const isExecuting = isPending && (isAutoApproved || isConfirmed)
const timer = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (!isWaitingConfirmation) return
if (countdown > 0) {
timer.current = setTimeout(() => {
logger.debug(`countdown: ${countdown}`)
setCountdown((prev) => prev - 1)
}, 1000)
} else if (countdown === 0) {
setIsConfirmed(true)
confirmToolAction(id)
}
return () => {
if (timer.current) {
clearTimeout(timer.current)
}
}
}, [countdown, id, isWaitingConfirmation])
const isStreaming = status === 'streaming'
useEffect(() => {
const removeListener = window.electron.ipcRenderer.on(
@ -119,33 +78,16 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
}
}, [id])
const cancelCountdown = () => {
if (timer.current) {
clearTimeout(timer.current)
// Auto-expand when streaming, auto-collapse when done
useEffect(() => {
if (isStreaming) {
// Expand when streaming starts
setActiveKeys((prev) => (prev.includes(id) ? prev : [...prev, id]))
} else if (isDone || isError) {
// Collapse when streaming ends
setActiveKeys((prev) => prev.filter((key) => key !== id))
}
}
const argsString = useMemo(() => {
if (toolResponse?.arguments) {
return JSON.stringify(toolResponse.arguments, null, 2)
}
return 'No arguments'
}, [toolResponse])
const resultString = useMemo(() => {
try {
return JSON.stringify(
{
params: toolResponse?.arguments,
response: toolResponse?.response
},
null,
2
)
} catch (e) {
return 'Invalid Result'
}
}, [toolResponse])
}, [isStreaming, isDone, isError, id])
if (!toolResponse) {
return null
@ -162,17 +104,6 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
setActiveKeys(Array.isArray(keys) ? keys : [keys])
}
const handleConfirmTool = () => {
cancelCountdown()
setIsConfirmed(true)
confirmToolAction(id)
}
const handleCancelTool = () => {
cancelCountdown()
cancelToolAction(id)
}
const handleAbortTool = async () => {
if (toolResponse?.id) {
try {
@ -189,75 +120,8 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
}
}
const handleAutoApprove = async () => {
cancelCountdown()
if (!tool || !tool.name) {
return
}
const server = mcpServers.find((s) => s.id === tool.serverId)
if (!server) {
return
}
let disabledAutoApproveTools = [...(server.disabledAutoApproveTools || [])]
// Remove tool from disabledAutoApproveTools to enable auto-approve
disabledAutoApproveTools = disabledAutoApproveTools.filter((name) => name !== tool.name)
const updatedServer = {
...server,
disabledAutoApproveTools
}
updateMCPServer(updatedServer)
// Also confirm the current tool
setIsConfirmed(true)
confirmToolAction(id)
window.toast.success(t('message.tools.autoApproveEnabled', 'Auto-approve enabled for this tool'))
}
const renderStatusIndicator = (status: string, hasError: boolean) => {
let label = ''
let icon: React.ReactNode | null = null
if (status === 'pending') {
if (isWaitingConfirmation) {
label = t('message.tools.pending', 'Awaiting Approval')
icon = <LoadingIcon style={{ marginLeft: 6, color: 'var(--status-color-warning)' }} />
} else if (isExecuting) {
label = t('message.tools.invoking')
icon = <LoadingIcon style={{ marginLeft: 6 }} />
}
} else if (status === 'cancelled') {
label = t('message.tools.cancelled')
icon = <X size={13} style={{ marginLeft: 6 }} className="lucide-custom" />
} else if (status === 'done') {
if (hasError) {
label = t('message.tools.error')
icon = <TriangleAlert size={13} style={{ marginLeft: 6 }} className="lucide-custom" />
} else {
label = t('message.tools.completed')
icon = <Check size={13} style={{ marginLeft: 6 }} className="lucide-custom" />
}
} else if (status === 'error') {
label = t('message.tools.error')
icon = <TriangleAlert size={13} style={{ marginLeft: 6 }} className="lucide-custom" />
}
return (
<StatusIndicator status={status} hasError={hasError}>
{label}
{icon}
</StatusIndicator>
)
}
// Format tool responses for collapse items
const getCollapseItems = () => {
const getCollapseItems = (): { key: string; label: React.ReactNode; children: React.ReactNode }[] => {
const items: { key: string; label: React.ReactNode; children: React.ReactNode }[] = []
const hasError = response?.isError === true
const result = {
@ -282,22 +146,8 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
{progress > 0 ? (
<Progress type="circle" size={14} percent={Number((progress * 100)?.toFixed(0))} />
) : (
renderStatusIndicator(status, hasError)
<ToolStatusIndicator status={getEffectiveStatus(status, approval.isWaiting)} hasError={hasError} />
)}
<Tooltip title={t('common.expand')} mouseEnterDelay={0.5}>
<ActionButton
className="message-action-button"
onClick={(e) => {
e.stopPropagation()
setExpandedResponse({
content: JSON.stringify(response, null, 2),
title: tool.name
})
}}
aria-label={t('common.expand')}>
<Maximize size={14} />
</ActionButton>
</Tooltip>
{!isPending && (
<Tooltip title={t('common.copy')} mouseEnterDelay={0.5}>
<ActionButton
@ -315,65 +165,25 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
</ActionButtonsContainer>
</MessageTitleLabel>
),
children:
(isDone || isError) && result ? (
<ToolResponseContainer
style={{
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
fontSize
}}>
<CollapsedContent isExpanded={activeKeys.includes(id)} resultString={resultString} />
</ToolResponseContainer>
) : argsString ? (
<>
<ToolResponseContainer>
<CollapsedContent isExpanded={activeKeys.includes(id)} resultString={argsString} />
</ToolResponseContainer>
</>
) : null
children: (
<ToolResponseContainer
style={{
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
fontSize
}}>
<ToolResponseContent
isExpanded={activeKeys.includes(id)}
args={isStreaming ? partialArguments : toolResponse.arguments}
isStreaming={!!isStreaming}
response={isDone || isError ? toolResponse.response : undefined}
/>
</ToolResponseContainer>
)
})
return items
}
const renderPreview = (content: string) => {
if (!content) return null
try {
logger.debug(`renderPreview: ${content}`)
const parsedResult = JSON.parse(content)
switch (parsedResult.content[0]?.type) {
case 'text':
try {
return (
<CollapsedContent
isExpanded={true}
resultString={JSON.stringify(JSON.parse(parsedResult.content[0].text), null, 2)}
/>
)
} catch (e) {
return (
<CollapsedContent
isExpanded={true}
resultString={JSON.stringify(parsedResult.content[0].text, null, 2)}
/>
)
}
default:
return <CollapsedContent isExpanded={true} resultString={JSON.stringify(parsedResult, null, 2)} />
}
} catch (e) {
logger.error('failed to render the preview of mcp results:', e as Error)
return (
<CollapsedContent
isExpanded={true}
resultString={e instanceof Error ? e.message : JSON.stringify(e, null, 2)}
/>
)
}
}
return (
<>
<ConfigProvider
@ -401,155 +211,166 @@ const MessageMcpTool: FC<Props> = ({ block }) => {
{isPending && (
<ActionsBar>
<ActionLabel>
{isWaitingConfirmation
{approval.isWaiting
? t('settings.mcp.tools.autoApprove.tooltip.confirm')
: t('message.tools.invoking')}
</ActionLabel>
<ActionButtonsGroup>
{isWaitingConfirmation && (
<Button
color="danger"
variant="filled"
size="small"
onClick={() => {
handleCancelTool()
}}>
<CircleX size={15} className="lucide-custom" />
{t('common.cancel')}
</Button>
)}
{isExecuting && toolResponse?.id ? (
<Button
size="small"
color="danger"
variant="solid"
className="abort-button"
onClick={(e) => {
e.stopPropagation()
handleAbortTool()
}}>
<PauseCircle size={14} className="lucide-custom" />
{t('chat.input.pause')}
</Button>
) : (
isWaitingConfirmation && (
<StyledDropdownButton
size="small"
type="primary"
icon={<ChevronDown size={14} />}
onClick={() => {
handleConfirmTool()
}}
menu={{
items: [
{
key: 'autoApprove',
label: t('settings.mcp.tools.autoApprove.label'),
onClick: () => {
handleAutoApprove()
}
}
]
}}>
<CirclePlay size={15} className="lucide-custom" />
<CountdownText>
{t('settings.mcp.tools.run', 'Run')} ({countdown}s)
</CountdownText>
</StyledDropdownButton>
)
)}
</ActionButtonsGroup>
<ToolApprovalActionsComponent
{...approval}
showAbort={approval.isExecuting && !!toolResponse?.id}
onAbort={handleAbortTool}
/>
</ActionsBar>
)}
</ToolContentWrapper>
</ToolContainer>
</ConfigProvider>
<Modal
title={expandedResponse?.title}
open={!!expandedResponse}
onCancel={() => setExpandedResponse(null)}
footer={null}
width="80%"
centered
transitionName="animation-move-down"
styles={{ body: { maxHeight: '80vh', overflow: 'auto' } }}>
{expandedResponse && (
<ExpandedResponseContainer
style={{
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
fontSize
}}>
<Tabs
tabBarExtraContent={
<ActionButton
className="copy-expanded-button"
onClick={() => {
navigator.clipboard.writeText(
typeof expandedResponse.content === 'string'
? expandedResponse.content
: JSON.stringify(expandedResponse.content, null, 2)
)
antdMessage.success({ content: t('message.copied'), key: 'copy-expanded' })
}}
aria-label={t('common.copy')}>
<i className="iconfont icon-copy"></i>
</ActionButton>
}
items={[
{
key: 'preview',
label: t('message.tools.preview'),
children: renderPreview(expandedResponse.content)
},
{
key: 'raw',
label: t('message.tools.raw'),
children: (
<CollapsedContent
isExpanded={true}
resultString={
typeof expandedResponse.content === 'string'
? expandedResponse.content
: JSON.stringify(expandedResponse.content, null, 2)
}
/>
)
}
]}
/>
</ExpandedResponseContainer>
)}
</Modal>
</>
)
}
// New component to handle collapsed content
const CollapsedContent: FC<{ isExpanded: boolean; resultString: string }> = ({ isExpanded, resultString }) => {
const { highlightCode } = useCodeStyle()
const [styledResult, setStyledResult] = useState<string>('')
/**
* Extract preview content from MCP tool response using SDK schema
*/
const extractPreviewContent = (response: unknown): string => {
if (!response) return ''
useEffect(() => {
if (!isExpanded) {
return
const result = CallToolResultSchema.safeParse(response)
if (result.success) {
const contents = result.data.content
if (contents.length === 0) return ''
const textParts: string[] = []
for (const content of contents) {
switch (content.type) {
case 'text':
if (content.text) {
try {
const parsed = JSON.parse(content.text)
textParts.push(JSON.stringify(parsed, null, 2))
} catch {
textParts.push(content.text)
}
}
break
case 'image':
textParts.push(`[Image: ${content.mimeType ?? 'image/png'}]`)
break
case 'resource':
textParts.push(`[Resource: ${content.resource?.uri ?? 'unknown'}]`)
break
}
}
return textParts.join('\n\n')
}
// Fallback: return JSON string for unknown format
return JSON.stringify(response, null, 2)
}
// Unified tool response content component
const ToolResponseContent: FC<{
isExpanded: boolean
args: string | Record<string, unknown> | Record<string, unknown>[] | undefined
isStreaming: boolean
response?: unknown
}> = ({ isExpanded, args, isStreaming, response }) => {
const { highlightCode } = useCodeStyle()
const [highlightedResponse, setHighlightedResponse] = useState<string>('')
const [isTruncated, setIsTruncated] = useState(false)
const [originalLength, setOriginalLength] = useState(0)
// Parse args if it's a string (streaming partial JSON)
const parsedArgs = useMemo(() => {
if (!args) return null
if (typeof args === 'string') {
try {
return parsePartialJson(args)
} catch {
return null
}
}
return args
}, [args])
// Extract and highlight response when available
useEffect(() => {
if (!isExpanded || !response) return
const highlight = async () => {
const result = await highlightCode(resultString, 'json')
setStyledResult(result)
const previewContent = extractPreviewContent(response)
const {
data: truncatedContent,
isTruncated: wasTruncated,
originalLength: origLen
} = truncateOutput(previewContent)
setIsTruncated(wasTruncated)
setOriginalLength(origLen)
const result = await highlightCode(truncatedContent, 'json')
setHighlightedResponse(result)
}
const timer = setTimeout(highlight, 0)
return () => clearTimeout(timer)
}, [isExpanded, resultString, highlightCode])
}, [isExpanded, response, highlightCode])
if (!isExpanded) {
return null
if (!isExpanded) return null
// Handle both object and array args - for arrays, show as single entry
const getEntries = (): Array<[string, unknown]> => {
if (!parsedArgs || typeof parsedArgs !== 'object') return []
if (Array.isArray(parsedArgs)) {
return [['arguments', parsedArgs]]
}
return Object.entries(parsedArgs)
}
const entries = getEntries()
const renderArgsTable = (): React.ReactNode => {
if (entries.length === 0) return null
return (
<ArgsSection>
<ArgsSectionTitle>Arguments</ArgsSectionTitle>
<ArgsTable>
<tbody>
{entries.map(([key, value]) => (
<tr key={key}>
<ArgKey>{key}</ArgKey>
<ArgValue>{formatArgValue(value)}</ArgValue>
</tr>
))}
{isStreaming && (
<tr>
<ArgKey>
<SkeletonSpan width="60px" />
</ArgKey>
<ArgValue>
<SkeletonSpan width="120px" />
</ArgValue>
</tr>
)}
</tbody>
</ArgsTable>
</ArgsSection>
)
}
return <MarkdownContainer className="markdown" dangerouslySetInnerHTML={{ __html: styledResult }} />
return (
<div>
{/* Arguments Table */}
{renderArgsTable()}
{/* Response */}
{response !== undefined && response !== null && highlightedResponse && (
<ResponseSection>
<ArgsSectionTitle>Response</ArgsSectionTitle>
<MarkdownContainer className="markdown" dangerouslySetInnerHTML={{ __html: highlightedResponse }} />
{isTruncated && <TruncatedIndicator originalLength={originalLength} />}
</ResponseSection>
)}
</div>
)
}
const ToolContentWrapper = styled.div`
@ -586,22 +407,6 @@ const ActionLabel = styled.div`
white-space: nowrap;
`
const ActionButtonsGroup = styled.div`
display: flex;
gap: 10px;
`
const CountdownText = styled.span`
width: 65px;
text-align: left;
`
const StyledDropdownButton = styled(Dropdown.Button)`
.ant-btn-group {
border-radius: 6px;
}
`
const ExpandIcon = styled(ChevronRight)<{ $isActive?: boolean }>`
transition: transform 0.2s;
transform: ${({ $isActive }) => ($isActive ? 'rotate(90deg)' : 'rotate(0deg)')};
@ -670,31 +475,6 @@ const ToolName = styled(Flex)`
font-size: 13px;
`
const StatusIndicator = styled.span<{ status: string; hasError?: boolean }>`
color: ${(props) => {
switch (props.status) {
case 'pending':
return 'var(--status-color-warning)'
case 'invoking':
return 'var(--status-color-invoking)'
case 'cancelled':
return 'var(--status-color-error)'
case 'done':
return props.hasError ? 'var(--status-color-error)' : 'var(--status-color-success)'
case 'error':
return 'var(--status-color-error)'
default:
return 'var(--color-text)'
}
}};
font-size: 11px;
font-weight: ${(props) => (props.status === 'pending' ? '600' : '400')};
display: flex;
align-items: center;
opacity: ${(props) => (props.status === 'pending' ? '1' : '0.85')};
padding-left: 12px;
`
const ActionButtonsContainer = styled.div`
display: flex;
gap: 6px;
@ -752,27 +532,4 @@ const ToolResponseContainer = styled.div`
position: relative;
`
const ExpandedResponseContainer = styled.div`
background: var(--color-bg-1);
border-radius: 8px;
padding: 16px;
position: relative;
.copy-expanded-button {
position: absolute;
top: 10px;
right: 10px;
background-color: var(--color-bg-2);
border-radius: 4px;
z-index: 1;
}
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: var(--color-text);
}
`
export default memo(MessageMcpTool)

View File

@ -0,0 +1,165 @@
import { LoadingIcon } from '@renderer/components/Icons'
import { Button, Dropdown } from 'antd'
import { ChevronDown, CirclePlay, CircleX, ShieldCheck } from 'lucide-react'
import type { FC, MouseEvent } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import type { ToolApprovalActions, ToolApprovalState } from './hooks/useToolApproval'
export interface ToolApprovalActionsProps extends ToolApprovalState, ToolApprovalActions {
/** Compact mode for use in headers */
compact?: boolean
/** Show abort button when executing */
showAbort?: boolean
/** Abort handler */
onAbort?: () => void
}
/**
* Unified tool approval action buttons
* Used in both MessageMcpTool and ToolPermissionRequestCard
*/
export const ToolApprovalActionsComponent: FC<ToolApprovalActionsProps> = ({
isWaiting,
isExecuting,
remainingSeconds,
isExpired,
isSubmitting,
confirm,
cancel,
autoApprove,
compact = false,
showAbort = false,
onAbort
}) => {
const { t } = useTranslation()
// Stop event propagation to prevent collapse toggle
const handleClick = (e: MouseEvent, handler: () => void) => {
e.stopPropagation()
handler()
}
// Nothing to show if not waiting and not executing
if (!isWaiting && !isExecuting) return null
// Expired state for agent tools
if (isExpired && !isExecuting) {
return (
<ExpiredBadge $compact={compact} onClick={(e) => e.stopPropagation()}>
{t('agent.toolPermission.expired')}
</ExpiredBadge>
)
}
// Executing state - show loading or abort button
if (isExecuting) {
if (showAbort && onAbort) {
return (
<ActionsContainer $compact={compact} onClick={(e) => e.stopPropagation()}>
<Button size="small" color="danger" variant="solid" onClick={(e) => handleClick(e, onAbort)}>
{t('chat.input.pause')}
</Button>
</ActionsContainer>
)
}
return (
<ActionsContainer $compact={compact} onClick={(e) => e.stopPropagation()}>
<LoadingIndicator>
<LoadingIcon />
{!compact && <span>{t('message.tools.invoking')}</span>}
</LoadingIndicator>
</ActionsContainer>
)
}
// Waiting state - show confirm/cancel buttons
return (
<ActionsContainer $compact={compact} onClick={(e) => e.stopPropagation()}>
<Button
size="small"
color="danger"
variant={compact ? 'text' : 'outlined'}
disabled={isSubmitting}
onClick={(e) => handleClick(e, cancel)}>
<CircleX size={compact ? 13 : 14} className="lucide-custom" />
{!compact && t('common.cancel')}
</Button>
{autoApprove ? (
<StyledDropdownButton
size="small"
type="primary"
disabled={isSubmitting}
icon={<ChevronDown size={compact ? 12 : 14} />}
onClick={(e) => handleClick(e, confirm)}
menu={{
items: [
{
key: 'autoApprove',
label: t('settings.mcp.tools.autoApprove.label'),
icon: <ShieldCheck size={14} />,
onClick: () => autoApprove()
}
]
}}>
<CirclePlay size={compact ? 13 : 15} className="lucide-custom" />
<CountdownText $compact={compact}>
{compact ? `${remainingSeconds}s` : `${t('settings.mcp.tools.run', 'Run')} (${remainingSeconds}s)`}
</CountdownText>
</StyledDropdownButton>
) : (
<Button size="small" type="primary" disabled={isSubmitting} onClick={(e) => handleClick(e, confirm)}>
<CirclePlay size={compact ? 13 : 15} className="lucide-custom" />
<CountdownText $compact={compact}>
{compact ? `${remainingSeconds}s` : `${t('settings.mcp.tools.run', 'Run')} (${remainingSeconds}s)`}
</CountdownText>
</Button>
)}
</ActionsContainer>
)
}
// Styled components
const ActionsContainer = styled.div<{ $compact: boolean }>`
display: flex;
align-items: center;
gap: ${(props) => (props.$compact ? '4px' : '8px')};
.ant-btn-sm {
height: ${(props) => (props.$compact ? '24px' : '28px')};
padding: ${(props) => (props.$compact ? '0 6px' : '0 8px')};
font-size: ${(props) => (props.$compact ? '12px' : '13px')};
}
`
const ExpiredBadge = styled.span<{ $compact: boolean }>`
font-size: ${(props) => (props.$compact ? '11px' : '12px')};
color: var(--color-status-error, #ff4d4f);
padding: ${(props) => (props.$compact ? '2px 6px' : '4px 8px')};
background: var(--color-status-error-bg, rgba(255, 77, 79, 0.1));
border-radius: 4px;
`
const LoadingIndicator = styled.div`
display: flex;
align-items: center;
gap: 6px;
color: var(--color-primary);
font-size: 12px;
`
const CountdownText = styled.span<{ $compact: boolean }>`
min-width: ${(props) => (props.$compact ? '24px' : '65px')};
text-align: left;
`
const StyledDropdownButton = styled(Dropdown.Button)`
.ant-btn-group {
border-radius: 6px;
}
`
export default ToolApprovalActionsComponent

View File

@ -0,0 +1,272 @@
import type { MCPTool, MCPToolResponse, NormalToolResponse } from '@renderer/types'
import type { ToolMessageBlock } from '@renderer/types/newMessage'
import { isToolAutoApproved } from '@renderer/utils/mcp-tools'
import { Flex, Tooltip } from 'antd'
import {
Bot,
DoorOpen,
FileEdit,
FileSearch,
FileText,
FolderSearch,
Globe,
ListTodo,
NotebookPen,
PencilRuler,
Search,
ShieldCheck,
Terminal,
Wrench
} from 'lucide-react'
import type { FC, ReactNode } from 'react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { type ToolStatus, ToolStatusIndicator } from './MessageAgentTools/GenericTools'
import { AgentToolsType } from './MessageAgentTools/types'
export interface ToolHeaderProps {
block?: ToolMessageBlock
toolName?: string
icon?: ReactNode
params?: ReactNode
stats?: ReactNode
// Common config
status?: ToolStatus
hasError?: boolean
showStatus?: boolean // default true
// Style variant
variant?: 'standalone' | 'collapse-label'
}
const getAgentToolIcon = (toolName: string): ReactNode => {
switch (toolName) {
case AgentToolsType.Read:
return <FileText size={14} />
case AgentToolsType.Task:
return <Bot size={14} />
case AgentToolsType.Bash:
case AgentToolsType.BashOutput:
return <Terminal size={14} />
case AgentToolsType.Search:
return <Search size={14} />
case AgentToolsType.Glob:
return <FolderSearch size={14} />
case AgentToolsType.Grep:
return <FileSearch size={14} />
case AgentToolsType.Write:
return <FileText size={14} />
case AgentToolsType.Edit:
return <FileEdit size={14} />
case AgentToolsType.MultiEdit:
return <FileText size={14} />
case AgentToolsType.WebSearch:
case AgentToolsType.WebFetch:
return <Globe size={14} />
case AgentToolsType.NotebookEdit:
return <NotebookPen size={14} />
case AgentToolsType.TodoWrite:
return <ListTodo size={14} />
case AgentToolsType.ExitPlanMode:
return <DoorOpen size={14} />
case AgentToolsType.Skill:
return <PencilRuler size={14} />
default:
return <Wrench size={14} />
}
}
const getAgentToolLabel = (toolName: string, t: (key: string) => string): string => {
switch (toolName) {
case AgentToolsType.Read:
return t('message.tools.labels.readFile')
case AgentToolsType.Task:
return t('message.tools.labels.task')
case AgentToolsType.Bash:
return t('message.tools.labels.bash')
case AgentToolsType.BashOutput:
return t('message.tools.labels.bashOutput')
case AgentToolsType.Search:
return t('message.tools.labels.search')
case AgentToolsType.Glob:
return t('message.tools.labels.glob')
case AgentToolsType.Grep:
return t('message.tools.labels.grep')
case AgentToolsType.Write:
return t('message.tools.labels.write')
case AgentToolsType.Edit:
return t('message.tools.labels.edit')
case AgentToolsType.MultiEdit:
return t('message.tools.labels.multiEdit')
case AgentToolsType.WebSearch:
return t('message.tools.labels.webSearch')
case AgentToolsType.WebFetch:
return t('message.tools.labels.webFetch')
case AgentToolsType.NotebookEdit:
return t('message.tools.labels.notebookEdit')
case AgentToolsType.TodoWrite:
return t('message.tools.labels.todoWrite')
case AgentToolsType.ExitPlanMode:
return t('message.tools.labels.exitPlanMode')
case AgentToolsType.Skill:
return t('message.tools.labels.skill')
default:
return toolName
}
}
const getToolDescription = (toolResponse?: MCPToolResponse | NormalToolResponse): string | undefined => {
if (!toolResponse) return undefined
const args = toolResponse.arguments
if (!args || typeof args !== 'object' || Array.isArray(args)) return undefined
// Common description fields
return (
(args as Record<string, unknown>).description ||
(args as Record<string, unknown>).file_path ||
(args as Record<string, unknown>).pattern ||
(args as Record<string, unknown>).query ||
(args as Record<string, unknown>).command ||
(args as Record<string, unknown>).url
)?.toString()
}
// ============ Styled Components ============
const HeaderContainer = styled.div`
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
padding: 8px 12px;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
min-width: 0;
`
// Label variant: no border/padding, for use inside Collapse header
const LabelContainer = styled.div`
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
min-width: 0;
`
const ToolName = styled(Flex)`
font-weight: 500;
color: var(--color-text);
flex-shrink: 0;
.tool-icon {
color: var(--color-primary);
}
.name {
white-space: nowrap;
}
`
const Description = styled.span`
color: var(--color-text-2);
font-weight: 400;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
max-width: 300px;
`
const Stats = styled.span`
color: var(--color-text-2);
font-weight: 400;
font-size: 12px;
white-space: nowrap;
flex-shrink: 0;
`
const StatusWrapper = styled.div`
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: auto;
`
// ============ Main Component ============
const ToolHeader: FC<ToolHeaderProps> = ({
block,
toolName: propToolName,
icon: propIcon,
params,
stats,
status: propStatus,
hasError: propHasError,
showStatus = true,
variant = 'standalone'
}) => {
const { t } = useTranslation()
const toolResponse = block?.metadata?.rawMcpToolResponse
const tool = toolResponse?.tool
const toolName = propToolName || tool?.name || 'Tool'
const status = propStatus || (toolResponse?.status as ToolStatus)
const hasError = propHasError ?? toolResponse?.response?.isError === true
const description = params ?? getToolDescription(toolResponse)
const Container = variant === 'standalone' ? HeaderContainer : LabelContainer
if (block && tool?.type === 'mcp') {
const mcpTool = tool as MCPTool
return (
<Container>
<ToolName align="center" gap={6}>
<Wrench size={14} className="tool-icon" />
<span className="name">
{mcpTool.serverName} : {mcpTool.name}
</span>
{isToolAutoApproved(mcpTool) && (
<Tooltip title={t('message.tools.autoApproveEnabled')} mouseLeaveDelay={0}>
<ShieldCheck size={14} color="var(--color-primary)" />
</Tooltip>
)}
</ToolName>
{description && <Description>{description}</Description>}
{stats && <Stats>{stats}</Stats>}
{showStatus && status && (
<StatusWrapper>
<ToolStatusIndicator status={status} hasError={hasError} />
</StatusWrapper>
)}
</Container>
)
}
return (
<Container>
<ToolName align="center" gap={6}>
<span className="tool-icon">{propIcon || getAgentToolIcon(toolName)}</span>
<span className="name">{getAgentToolLabel(toolName, t)}</span>
</ToolName>
{description && <Description>{description}</Description>}
{stats && <Stats>{stats}</Stats>}
{showStatus && status && (
<StatusWrapper>
<ToolStatusIndicator status={status} hasError={hasError} />
</StatusWrapper>
)}
</Container>
)
}
export default memo(ToolHeader)

View File

@ -1,14 +1,16 @@
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import { loggerService } from '@logger'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { selectPendingPermission, toolPermissionsActions } from '@renderer/store/toolPermissions'
import { LoadingIcon } from '@renderer/components/Icons'
import type { NormalToolResponse } from '@renderer/types'
import { Button, Spin } from 'antd'
import { ChevronDown, CirclePlay, CircleX } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { CollapseProps } from 'antd'
import { Collapse } from 'antd'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const logger = loggerService.withContext('ToolPermissionRequestCard')
import { useAgentToolApproval } from './hooks/useAgentToolApproval'
import { type StatusColor, StatusIndicatorContainer, StreamingContext } from './MessageAgentTools/GenericTools'
import { isValidAgentToolsType, renderTool } from './MessageAgentTools/index'
import { UnknownToolRenderer } from './MessageAgentTools/UnknownToolRenderer'
import ToolApprovalActionsComponent from './ToolApprovalActions'
interface Props {
toolResponse: NormalToolResponse
@ -16,250 +18,115 @@ interface Props {
export function ToolPermissionRequestCard({ toolResponse }: Props) {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const request = useAppSelector((state) => selectPendingPermission(state.toolPermissions, toolResponse.toolCallId))
const [now, setNow] = useState(() => Date.now())
const [showDetails, setShowDetails] = useState(false)
useEffect(() => {
if (!request) return
const approval = useAgentToolApproval(null, { toolCallId: toolResponse.toolCallId })
logger.debug('Rendering inline tool permission card', {
requestId: request.requestId,
toolName: request.toolName,
expiresAt: request.expiresAt
})
setNow(Date.now())
const interval = window.setInterval(() => {
setNow(Date.now())
}, 500)
return () => {
window.clearInterval(interval)
const statusInfo = useMemo((): { color: StatusColor; text: string; showLoading: boolean } => {
if (approval.isExecuting) {
return { color: 'primary', text: t('message.tools.invoking'), showLoading: true }
}
}, [request])
if (approval.isExpired) {
return { color: 'error', text: t('agent.toolPermission.expired'), showLoading: false }
}
return {
color: 'warning',
text: t('agent.toolPermission.pending', { seconds: approval.remainingSeconds }),
showLoading: true
}
}, [approval.isExecuting, approval.isExpired, approval.remainingSeconds, t])
const remainingMs = useMemo(() => {
if (!request) return 0
return Math.max(0, request.expiresAt - now)
}, [request, now])
const renderToolContent = useCallback((): React.ReactNode => {
const toolName = toolResponse.tool?.name ?? ''
const input = (approval.input ?? toolResponse.arguments) as Record<string, unknown> | undefined
const remainingSeconds = useMemo(() => Math.ceil(remainingMs / 1000), [remainingMs])
const isExpired = remainingMs <= 0
const renderedItem = isValidAgentToolsType(toolName)
? renderTool(toolName, input)
: UnknownToolRenderer({ input, toolName })
const isSubmittingAllow = request?.status === 'submitting-allow'
const isSubmittingDeny = request?.status === 'submitting-deny'
const isSubmitting = isSubmittingAllow || isSubmittingDeny
const isInvoking = request?.status === 'invoking'
const handleDecision = useCallback(
async (
behavior: 'allow' | 'deny',
extra?: {
updatedInput?: Record<string, unknown>
updatedPermissions?: PermissionUpdate[]
message?: string
}
) => {
if (!request) return
logger.debug('Submitting inline tool permission decision', {
requestId: request.requestId,
toolName: request.toolName,
behavior
})
dispatch(toolPermissionsActions.submissionSent({ requestId: request.requestId, behavior }))
try {
const payload = {
requestId: request.requestId,
behavior,
...(behavior === 'allow'
? {
updatedInput: extra?.updatedInput ?? request.input,
updatedPermissions: extra?.updatedPermissions
}
: {
message: extra?.message ?? t('agent.toolPermission.defaultDenyMessage')
})
}
const response = await window.api.agentTools.respondToPermission(payload)
if (!response?.success) {
throw new Error('Renderer response rejected by main process')
}
logger.debug('Tool permission decision acknowledged by main process', {
requestId: request.requestId,
behavior
})
} catch (error) {
logger.error('Failed to send tool permission response', error as Error)
window.toast?.error?.(t('agent.toolPermission.error.sendFailed'))
dispatch(toolPermissionsActions.submissionFailed({ requestId: request.requestId }))
}
},
[dispatch, request, t]
)
if (!request) {
return (
<div className="rounded-xl border border-default-200 bg-default-100 px-4 py-3 text-default-500 text-sm">
{t('agent.toolPermission.waiting')}
</div>
const statusIndicator = (
<StatusIndicatorContainer $color={statusInfo.color}>
{statusInfo.text}
{statusInfo.showLoading && <LoadingIcon />}
</StatusIndicatorContainer>
)
}
if (isInvoking) {
return (
<div className="w-full max-w-xl rounded-xl border border-default-200 bg-default-100 px-4 py-3 shadow-sm">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Spin size="small" />
<div className="flex flex-col gap-1">
<div className="font-semibold text-default-700 text-sm">{request.toolName}</div>
<div className="text-default-500 text-xs">{t('agent.toolPermission.executing')}</div>
</div>
</div>
{request.inputPreview && (
<div className="flex items-center justify-end">
<Button
aria-label={
showDetails
? t('agent.toolPermission.aria.hideDetails')
: t('agent.toolPermission.aria.showDetails')
}
className="h-8 text-default-600 transition-colors hover:bg-default-200/50 hover:text-default-800"
onClick={() => setShowDetails((value) => !value)}
icon={<ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} />}
variant="text"
style={{ backgroundColor: 'transparent' }}
/>
</div>
)}
</div>
{showDetails && request.inputPreview && (
<div className="flex flex-col gap-3 border-default-200 border-t pt-3">
<div className="rounded-md border border-default-200 bg-default-100 p-3">
<p className="mb-2 font-medium text-default-400 text-xs uppercase tracking-wide">
{t('agent.toolPermission.inputPreview')}
</p>
<div className="max-h-[192px] overflow-auto font-mono text-xs">
<pre className="whitespace-pre-wrap break-all p-2 text-left">{request.inputPreview}</pre>
</div>
</div>
</div>
)}
const toolContentItem: NonNullable<CollapseProps['items']>[number] = {
...renderedItem,
label: (
<div className="flex w-full items-start justify-between gap-2">
<div className="min-w-0 flex-1">{renderedItem.label}</div>
<div className="shrink-0 pt-px">{statusIndicator}</div>
</div>
</div>
),
classNames: {
body: 'bg-foreground-50 p-2 text-foreground-900 dark:bg-foreground-100 max-h-60 overflow-auto'
}
}
return (
<StreamingContext value={false}>
<Collapse
className="w-full"
expandIconPosition="end"
size="small"
defaultActiveKey={[String(renderedItem.key ?? toolName)]}
items={[toolContentItem]}
/>
</StreamingContext>
)
}
}, [toolResponse.tool?.name, approval.input, toolResponse.arguments, statusInfo])
return (
<div className="w-full max-w-xl rounded-xl border border-default-200 bg-default-100 px-4 py-3 shadow-sm">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<div className="font-semibold text-default-700 text-sm">{request.toolName}</div>
<div className="text-default-500 text-xs">
{request.description?.trim() || t('agent.toolPermission.defaultDescription')}
</div>
</div>
<Container>
{/* Tool content area with status in header */}
{renderToolContent()}
<div className="flex flex-wrap items-center justify-end gap-2">
<div
className={`rounded px-2 py-0.5 font-medium text-xs ${
isExpired ? 'text-[var(--color-error)]' : 'text-[var(--color-status-warning)]'
}`}>
{isExpired
? t('agent.toolPermission.expired')
: t('agent.toolPermission.pending', { seconds: remainingSeconds })}
</div>
{/* Bottom action bar - only show when not invoking */}
{!approval.isExecuting && (
<ActionsBar>
<ToolApprovalActionsComponent {...approval} />
</ActionsBar>
)}
<div className="flex items-center gap-1">
<Button
aria-label={t('agent.toolPermission.aria.denyRequest')}
className="h-8"
color="danger"
disabled={isSubmitting || isExpired}
loading={isSubmittingDeny}
onClick={() => handleDecision('deny')}
icon={<CircleX size={16} />}
iconPosition={'start'}
variant="outlined">
{t('agent.toolPermission.button.cancel')}
</Button>
<Button
aria-label={t('agent.toolPermission.aria.allowRequest')}
className="h-8 px-3"
color="primary"
disabled={isSubmitting || isExpired}
loading={isSubmittingAllow}
onClick={() => handleDecision('allow')}
icon={<CirclePlay size={16} />}
iconPosition={'start'}
variant="solid">
{t('agent.toolPermission.button.run')}
</Button>
<Button
aria-label={
showDetails ? t('agent.toolPermission.aria.hideDetails') : t('agent.toolPermission.aria.showDetails')
}
className="h-8 text-default-600 transition-colors hover:bg-default-200/50 hover:text-default-800"
onClick={() => setShowDetails((value) => !value)}
icon={<ChevronDown className={`transition-transform ${showDetails ? 'rotate-180' : ''}`} size={16} />}
variant="text"
style={{ backgroundColor: 'transparent' }}
/>
</div>
</div>
{approval.isExpired && !approval.isSubmitting && !approval.isExecuting && (
<div className="px-3 pb-2 text-center text-danger-500 text-xs">
{t('agent.toolPermission.permissionExpired')}
</div>
{showDetails && (
<div className="flex flex-col gap-3 border-default-200 border-t pt-3">
<div className="rounded-lg bg-default-200/60 px-3 py-2 text-default-600 text-sm">
{t('agent.toolPermission.confirmation')}
</div>
<div className="rounded-md border border-default-200 bg-default-100 p-3">
<p className="mb-2 font-medium text-default-400 text-xs uppercase tracking-wide">
{t('agent.toolPermission.inputPreview')}
</p>
<div className="max-h-[192px] overflow-auto font-mono text-xs">
<pre className="whitespace-pre-wrap break-all p-2 text-left">{request.inputPreview}</pre>
</div>
</div>
{request.requiresPermissions && (
<div className="rounded-md border border-warning-300 bg-warning-50 p-3 text-warning-700 text-xs">
{t('agent.toolPermission.requiresElevatedPermissions')}
</div>
)}
{request.suggestions.length > 0 && (
<div className="rounded-md border border-default-200 bg-default-50 p-3 text-default-500 text-xs">
{request.suggestions.length === 1
? t('agent.toolPermission.suggestion.permissionUpdateSingle')
: t('agent.toolPermission.suggestion.permissionUpdateMultiple')}
</div>
)}
</div>
)}
{isExpired && !isSubmitting && (
<div className="text-center text-danger-500 text-xs">{t('agent.toolPermission.permissionExpired')}</div>
)}
</div>
</div>
)}
</Container>
)
}
const Container = styled.div`
width: 100%;
max-width: 36rem;
border-radius: 0.75rem;
border: 1px solid var(--color-border);
background-color: var(--color-background-soft);
overflow: hidden;
.ant-collapse {
border: none;
border-radius: 0;
background: transparent;
}
.ant-collapse-item {
border: none;
}
.ant-collapse-header {
padding: 8px 12px !important;
}
`
const ActionsBar = styled.div`
display: flex;
align-items: center;
justify-content: flex-end;
padding: 8px 12px;
border-top: 1px solid var(--color-border);
background-color: var(--color-background);
`
export default ToolPermissionRequestCard

View File

@ -0,0 +1,376 @@
import type { NormalToolResponse } from '@renderer/types'
import { render, screen } from '@testing-library/react'
import { parse as parsePartialJson } from 'partial-json'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { isValidAgentToolsType, MessageAgentTools } from '../MessageAgentTools'
vi.mock('@renderer/services/AssistantService', () => ({
getDefaultAssistant: vi.fn(() => ({
id: 'test-assistant',
name: 'Test Assistant',
settings: {}
})),
getDefaultTopic: vi.fn(() => ({
id: 'test-topic',
assistantId: 'test-assistant',
createdAt: new Date().toISOString()
}))
}))
// Mock dependencies
const mockUseAppSelector = vi.fn()
const mockUseTranslation = vi.fn()
vi.mock('@renderer/store', () => ({
useAppSelector: (selector: any) => mockUseAppSelector(selector)
}))
vi.mock('@renderer/store/toolPermissions', () => ({
selectPendingPermission: vi.fn()
}))
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
initReactI18next: {
type: '3rdParty',
init: vi.fn()
}
}))
vi.mock('@logger', () => ({
loggerService: {
withContext: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
})
}
}))
// Mock antd components
vi.mock('antd', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
Collapse: ({ items, defaultActiveKey, className }: any) => (
<div data-testid="collapse" className={className} data-active-key={JSON.stringify(defaultActiveKey)}>
{items?.map((item: any) => (
<div key={item.key} data-testid={`collapse-item-${item.key}`}>
<div data-testid={`collapse-header-${item.key}`}>{item.label}</div>
<div data-testid={`collapse-content-${item.key}`}>{item.children}</div>
</div>
))}
</div>
),
Spin: ({ size }: any) => <div data-testid="spin" data-size={size} />,
Skeleton: {
Input: ({ style }: any) => <span data-testid="skeleton-input" style={style} />
},
Tag: ({ children, className }: any) => (
<span data-testid="tag" className={className}>
{children}
</span>
),
Popover: ({ children }: any) => <>{children}</>,
Card: ({ children, className }: any) => (
<div data-testid="card" className={className}>
{children}
</div>
),
Button: ({ children, onClick, type, size, icon, disabled }: any) => (
<button
type="button"
data-testid="button"
onClick={onClick}
data-type={type}
data-size={size}
disabled={disabled}>
{icon}
{children}
</button>
)
}
})
// Mock lucide-react icons
vi.mock('lucide-react', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
Loader2: ({ className }: any) => <span data-testid="loader-icon" className={className} />,
FileText: () => <span data-testid="file-icon" />,
Terminal: () => <span data-testid="terminal-icon" />,
ListTodo: () => <span data-testid="list-icon" />,
Circle: () => <span data-testid="circle-icon" />,
CheckCircle: () => <span data-testid="check-circle-icon" />,
Clock: () => <span data-testid="clock-icon" />,
Check: () => <span data-testid="check-icon" />,
TriangleAlert: () => <span data-testid="triangle-alert-icon" />,
X: () => <span data-testid="x-icon" />,
Wrench: () => <span data-testid="wrench-icon" />,
ImageIcon: () => <span data-testid="image-icon" />
}
})
// Mock LoadingIcon
vi.mock('@renderer/components/Icons', () => ({
LoadingIcon: () => <span data-testid="loading-icon" />
}))
// Mock ToolPermissionRequestCard
vi.mock('../ToolPermissionRequestCard', () => ({
default: () => <div data-testid="permission-card">Permission Required</div>
}))
describe('MessageAgentTools', () => {
// Mock translations for tools
const mockTranslations: Record<string, string> = {
'message.tools.labels.bash': 'Bash',
'message.tools.labels.readFile': 'Read File',
'message.tools.labels.todoWrite': 'Todo Write',
'message.tools.labels.edit': 'Edit',
'message.tools.labels.write': 'Write',
'message.tools.labels.grep': 'Grep',
'message.tools.labels.glob': 'Glob',
'message.tools.labels.webSearch': 'Web Search',
'message.tools.labels.webFetch': 'Web Fetch',
'message.tools.labels.skill': 'Skill',
'message.tools.labels.task': 'Task',
'message.tools.labels.search': 'Search',
'message.tools.labels.exitPlanMode': 'ExitPlanMode',
'message.tools.labels.multiEdit': 'MultiEdit',
'message.tools.labels.notebookEdit': 'NotebookEdit',
'message.tools.labels.mcpServerTool': 'MCP Server Tool',
'message.tools.labels.tool': 'Tool',
'message.tools.sections.command': 'Command',
'message.tools.sections.output': 'Output',
'message.tools.sections.prompt': 'Prompt',
'message.tools.sections.input': 'Input',
'message.tools.status.done': 'Done',
'message.tools.units.item': 'item',
'message.tools.units.items': 'items',
'message.tools.units.line': 'line',
'message.tools.units.lines': 'lines',
'message.tools.units.file': 'file',
'message.tools.units.files': 'files',
'message.tools.units.result': 'result',
'message.tools.units.results': 'results'
}
beforeEach(() => {
mockUseAppSelector.mockReturnValue(null) // No pending permission
mockUseTranslation.mockReturnValue({
t: (key: string, fallback?: string) => mockTranslations[key] ?? fallback ?? key
})
})
afterEach(() => {
vi.clearAllMocks()
})
// Helper to create tool response
const createToolResponse = (overrides: Partial<NormalToolResponse> = {}): NormalToolResponse => ({
id: 'test-tool-1',
tool: {
id: 'Read',
name: 'Read',
description: 'Read a file',
type: 'provider'
},
arguments: undefined,
status: 'pending',
toolCallId: 'call-123',
...overrides
})
describe('isValidAgentToolsType', () => {
it('should return true for valid tool types', () => {
expect(isValidAgentToolsType('Read')).toBe(true)
expect(isValidAgentToolsType('Bash')).toBe(true)
expect(isValidAgentToolsType('TodoWrite')).toBe(true)
})
it('should return false for invalid tool types', () => {
expect(isValidAgentToolsType('InvalidTool')).toBe(false)
expect(isValidAgentToolsType('')).toBe(false)
expect(isValidAgentToolsType(null)).toBe(false)
expect(isValidAgentToolsType(undefined)).toBe(false)
})
})
describe('partial-json parsing', () => {
it('should parse partial JSON correctly', () => {
// Test partial-json library behavior
const partialJson = '{"file_path": "/test.ts"'
const parsed = parsePartialJson(partialJson)
expect(parsed).toEqual({ file_path: '/test.ts' })
})
it('should parse nested partial JSON', () => {
const partialJson = '{"todos": [{"content": "Task 1", "status": "pending"'
const parsed = parsePartialJson(partialJson)
expect(parsed).toEqual({
todos: [{ content: 'Task 1', status: 'pending' }]
})
})
it('should handle empty partial JSON', () => {
const partialJson = '{'
const parsed = parsePartialJson(partialJson)
expect(parsed).toEqual({})
})
})
describe('streaming tool rendering', () => {
it('should render dedicated tool renderer with partial arguments during streaming', () => {
const toolResponse = createToolResponse({
tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' },
status: 'streaming',
partialArguments: '{"file_path": "/test.ts"'
})
render(<MessageAgentTools toolResponse={toolResponse} />)
// Should render the DEDICATED ReadTool component, not StreamingToolContent
// ReadTool uses 'Read File' as label, not just 'Read'
expect(screen.getByText('Read File')).toBeInTheDocument()
// Should show the filename from partial args
expect(screen.getByText('test.ts')).toBeInTheDocument()
})
it('should pass parsed partial arguments to dedicated tool renderer', () => {
const toolResponse = createToolResponse({
tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' },
status: 'streaming',
partialArguments: '{"file_path": "/path/to/myfile.ts", "offset": 10'
})
render(<MessageAgentTools toolResponse={toolResponse} />)
// Should use dedicated ReadTool renderer
expect(screen.getByText('Read File')).toBeInTheDocument()
// Should show the filename extracted by ReadTool
expect(screen.getByText('myfile.ts')).toBeInTheDocument()
})
it('should update dedicated renderer as more arguments stream in', () => {
const initialResponse = createToolResponse({
tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' },
status: 'streaming',
partialArguments: '{"file_path": "/test/partial'
})
const { rerender } = render(<MessageAgentTools toolResponse={initialResponse} />)
// Should use dedicated renderer even with partial path
expect(screen.getByText('Read File')).toBeInTheDocument()
// Update with status changed to pending when arguments complete
const updatedResponse = createToolResponse({
tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' },
status: 'pending',
partialArguments: '{"file_path": "/test/complete.ts", "limit": 100}'
})
rerender(<MessageAgentTools toolResponse={updatedResponse} />)
// When pending with no permission, shows ToolStatusIndicator with loading icon
expect(screen.getByTestId('loading-icon')).toBeInTheDocument()
})
})
describe('completed tool rendering', () => {
it('should render tool with full arguments when done', () => {
const toolResponse = createToolResponse({
tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' },
status: 'done',
arguments: { file_path: '/test.ts', limit: 100 },
response: 'file content here'
})
render(<MessageAgentTools toolResponse={toolResponse} />)
// Should render the complete tool with output
expect(screen.getByText('Read File')).toBeInTheDocument()
})
it('should render error state correctly', () => {
const toolResponse = createToolResponse({
tool: { id: 'Read', name: 'Read', description: 'Read a file', type: 'provider' },
status: 'error',
arguments: { file_path: '/nonexistent.ts' },
response: 'File not found'
})
render(<MessageAgentTools toolResponse={toolResponse} />)
// Should still render the tool component
expect(screen.getByText('Read File')).toBeInTheDocument()
})
})
describe('pending without streaming', () => {
it('should show permission card when pending permission exists', () => {
mockUseAppSelector.mockReturnValue({ toolCallId: 'call-123' }) // Has pending permission
const toolResponse = createToolResponse({
status: 'pending',
partialArguments: undefined
})
render(<MessageAgentTools toolResponse={toolResponse} />)
expect(screen.getByTestId('permission-card')).toBeInTheDocument()
})
it('should show pending indicator when no streaming and no permission', () => {
const toolResponse = createToolResponse({
status: 'pending',
partialArguments: undefined
})
render(<MessageAgentTools toolResponse={toolResponse} />)
// Should show the ToolStatusIndicator with loading icon
expect(screen.getByTestId('loading-icon')).toBeInTheDocument()
})
})
describe('TodoWrite streaming', () => {
it('should render TodoWrite dedicated renderer with partial todos during streaming', () => {
const toolResponse = createToolResponse({
tool: { id: 'TodoWrite', name: 'TodoWrite', description: 'Write todos', type: 'provider' },
status: 'streaming',
partialArguments:
'{"todos": [{"content": "First task", "status": "pending", "activeForm": "Working on first task"}'
})
render(<MessageAgentTools toolResponse={toolResponse} />)
// Should render the DEDICATED TodoWriteTool component, not StreamingToolContent
// TodoWriteTool uses 'Todo Write' (with space) as label
expect(screen.getByText('Todo Write')).toBeInTheDocument()
// The partial todo content should be visible in the dedicated renderer
expect(screen.getByText(/First task/)).toBeInTheDocument()
})
})
describe('Bash streaming', () => {
it('should render Bash dedicated renderer with partial command during streaming', () => {
const toolResponse = createToolResponse({
tool: { id: 'Bash', name: 'Bash', description: 'Execute command', type: 'provider' },
status: 'streaming',
partialArguments: '{"command": "npm install",'
})
render(<MessageAgentTools toolResponse={toolResponse} />)
// Should render the DEDICATED BashTool component
expect(screen.getByText('Bash')).toBeInTheDocument()
// Command should be visible in the dedicated renderer
expect(screen.getByText(/npm install/)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,12 @@
// Tool approval hooks - unified abstraction for MCP and Agent tool approval
export {
isBlockWaitingApproval,
type ToolApprovalActions,
type ToolApprovalState,
useAgentToolApproval,
type UseAgentToolApprovalOptions,
useMcpToolApproval,
type UseMcpToolApprovalOptions,
useToolApproval,
type UseToolApprovalOptions
} from './useToolApproval'

View File

@ -0,0 +1,163 @@
import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import { loggerService } from '@logger'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { selectPendingPermission, toolPermissionsActions } from '@renderer/store/toolPermissions'
import type { NormalToolResponse } from '@renderer/types'
import type { ToolMessageBlock } from '@renderer/types/newMessage'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ToolApprovalActions, ToolApprovalState } from './useToolApproval'
const logger = loggerService.withContext('useAgentToolApproval')
export interface UseAgentToolApprovalOptions {
/** Direct toolCallId (alternative to extracting from block) */
toolCallId?: string
}
/**
* Hook for Agent tool approval logic
* Can be used with:
* - A ToolMessageBlock (extracts toolCallId from metadata)
* - A direct toolCallId via options
*/
export function useAgentToolApproval(
block?: ToolMessageBlock | null,
options: UseAgentToolApprovalOptions = {}
): ToolApprovalState & ToolApprovalActions {
const { t } = useTranslation()
const dispatch = useAppDispatch()
const toolResponse = block?.metadata?.rawMcpToolResponse as NormalToolResponse | undefined
const toolCallId = options.toolCallId ?? toolResponse?.toolCallId ?? ''
const request = useAppSelector((state) => selectPendingPermission(state.toolPermissions, toolCallId))
const [now, setNow] = useState(() => Date.now())
// Update time every 500ms to track expiration
useEffect(() => {
if (!request) return
logger.debug('Tracking agent tool permission', {
requestId: request.requestId,
toolName: request.toolName,
expiresAt: request.expiresAt
})
setNow(Date.now())
const interval = window.setInterval(() => {
setNow(Date.now())
}, 500)
return () => {
window.clearInterval(interval)
}
}, [request])
const remainingMs = useMemo(() => {
if (!request) return 0
return Math.max(0, request.expiresAt - now)
}, [request, now])
const remainingSeconds = useMemo(() => Math.ceil(remainingMs / 1000), [remainingMs])
const isExpired = remainingMs <= 0
const isSubmittingAllow = request?.status === 'submitting-allow'
const isSubmittingDeny = request?.status === 'submitting-deny'
const isSubmitting = isSubmittingAllow || isSubmittingDeny
const isInvoking = request?.status === 'invoking'
const isPending = request?.status === 'pending'
const handleDecision = useCallback(
async (
behavior: 'allow' | 'deny',
extra?: {
updatedInput?: Record<string, unknown>
updatedPermissions?: PermissionUpdate[]
message?: string
}
) => {
if (!request) return
logger.debug('Submitting agent tool permission decision', {
requestId: request.requestId,
toolName: request.toolName,
behavior
})
dispatch(toolPermissionsActions.submissionSent({ requestId: request.requestId, behavior }))
try {
const payload = {
requestId: request.requestId,
behavior,
...(behavior === 'allow'
? {
updatedInput: extra?.updatedInput ?? request.input,
updatedPermissions: extra?.updatedPermissions
}
: {
message: extra?.message ?? t('agent.toolPermission.defaultDenyMessage')
})
}
const response = await window.api.agentTools.respondToPermission(payload)
if (!response?.success) {
throw new Error('Renderer response rejected by main process')
}
logger.debug('Tool permission decision acknowledged by main process', {
requestId: request.requestId,
behavior
})
} catch (error) {
logger.error('Failed to send tool permission response', error as Error)
window.toast?.error?.(t('agent.toolPermission.error.sendFailed'))
dispatch(toolPermissionsActions.submissionFailed({ requestId: request.requestId }))
}
},
[dispatch, request, t]
)
const confirm = useCallback(() => {
handleDecision('allow')
}, [handleDecision])
const cancel = useCallback(() => {
handleDecision('deny')
}, [handleDecision])
// Auto-approve with suggestions if available
const autoApprove = useCallback(() => {
if (request?.suggestions?.length) {
handleDecision('allow', { updatedPermissions: request.suggestions })
}
}, [handleDecision, request?.suggestions])
// Determine isWaiting - only when pending and not expired
const isWaiting = !!request && isPending && !isExpired
// isExecuting - when invoking or submitting allow
const isExecuting = isInvoking || isSubmittingAllow
return {
// State
isWaiting,
isExecuting,
countdown: undefined,
expiresAt: request?.expiresAt,
remainingSeconds,
isExpired: !!request && isExpired,
isSubmitting,
// Agent-specific: input from permission request
input: request?.input,
// Actions
confirm,
cancel,
autoApprove: request?.suggestions?.length ? autoApprove : undefined
}
}

View File

@ -0,0 +1,139 @@
import { loggerService } from '@logger'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { useTimer } from '@renderer/hooks/useTimer'
import type { MCPToolResponse } from '@renderer/types'
import type { ToolMessageBlock } from '@renderer/types/newMessage'
import { isToolAutoApproved } from '@renderer/utils/mcp-tools'
import { cancelToolAction, confirmToolAction } from '@renderer/utils/userConfirmation'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ToolApprovalActions, ToolApprovalState } from './useToolApproval'
const logger = loggerService.withContext('useMcpToolApproval')
const COUNTDOWN_TIME = 30
export interface UseMcpToolApprovalOptions {
/** Disable countdown auto-approve */
disableCountdown?: boolean
}
/**
* Hook for MCP tool approval logic
* Extracts approval state management from MessageMcpTool
*/
export function useMcpToolApproval(
block: ToolMessageBlock,
options: UseMcpToolApprovalOptions = {}
): ToolApprovalState & ToolApprovalActions {
const { disableCountdown = false } = options
const { t } = useTranslation()
const { mcpServers, updateMCPServer } = useMCPServers()
const { setTimeoutTimer, clearTimeoutTimer } = useTimer()
const toolResponse = block.metadata?.rawMcpToolResponse as MCPToolResponse | undefined
const tool = toolResponse?.tool
const id = toolResponse?.id ?? ''
const status = toolResponse?.status
const isPending = status === 'pending'
const isAutoApproved = useMemo(() => {
if (!tool) return false
return isToolAutoApproved(
tool,
mcpServers.find((s) => s.id === tool.serverId)
)
}, [tool, mcpServers])
const [countdown, setCountdown] = useState<number>(COUNTDOWN_TIME)
const [isConfirmed, setIsConfirmed] = useState(isAutoApproved)
// Compute approval states
const isWaiting = isPending && !isAutoApproved && !isConfirmed
const isExecuting = isPending && (isAutoApproved || isConfirmed)
// Countdown timer effect
useEffect(() => {
if (!isWaiting || disableCountdown) return
if (countdown > 0) {
setTimeoutTimer(
`countdown-${id}`,
() => {
logger.debug(`countdown: ${countdown}`)
setCountdown((prev) => prev - 1)
},
1000
)
} else if (countdown === 0) {
setIsConfirmed(true)
confirmToolAction(id)
}
return () => clearTimeoutTimer(`countdown-${id}`)
}, [countdown, id, isWaiting, disableCountdown, setTimeoutTimer, clearTimeoutTimer])
const cancelCountdown = useCallback(() => {
clearTimeoutTimer(`countdown-${id}`)
}, [clearTimeoutTimer, id])
const confirm = useCallback(() => {
cancelCountdown()
setIsConfirmed(true)
confirmToolAction(id)
}, [cancelCountdown, id])
const cancel = useCallback(() => {
cancelCountdown()
cancelToolAction(id)
}, [cancelCountdown, id])
const autoApprove = useCallback(async () => {
cancelCountdown()
if (!tool || !tool.name) {
return
}
const server = mcpServers.find((s) => s.id === tool.serverId)
if (!server) {
return
}
let disabledAutoApproveTools = [...(server.disabledAutoApproveTools || [])]
// Remove tool from disabledAutoApproveTools to enable auto-approve
disabledAutoApproveTools = disabledAutoApproveTools.filter((name) => name !== tool.name)
const updatedServer = {
...server,
disabledAutoApproveTools
}
updateMCPServer(updatedServer)
// Also confirm the current tool
setIsConfirmed(true)
confirmToolAction(id)
window.toast.success(t('message.tools.autoApproveEnabled', 'Auto-approve enabled for this tool'))
}, [cancelCountdown, tool, mcpServers, updateMCPServer, id, t])
return {
// State
isWaiting,
isExecuting,
countdown,
remainingSeconds: countdown,
isExpired: false, // MCP tools don't expire, they auto-confirm
isSubmitting: false,
input: undefined, // MCP tools get input from toolResponse.arguments
// Actions
confirm,
cancel,
autoApprove: isWaiting ? autoApprove : undefined
}
}

View File

@ -0,0 +1,77 @@
import type { ToolMessageBlock } from '@renderer/types/newMessage'
import { useAgentToolApproval } from './useAgentToolApproval'
import { useMcpToolApproval, type UseMcpToolApprovalOptions } from './useMcpToolApproval'
/**
* Unified tool approval state
*/
export interface ToolApprovalState {
/** Whether the tool is waiting for user confirmation */
isWaiting: boolean
/** Whether the tool is currently executing after approval */
isExecuting: boolean
/** Countdown seconds (MCP only) */
countdown?: number
/** Expiration timestamp (Agent only) */
expiresAt?: number
/** Remaining seconds until auto-confirm (MCP) or expiration (Agent) */
remainingSeconds: number
/** Whether the request has expired (Agent only) */
isExpired: boolean
/** Whether a submission is in progress (Agent only) */
isSubmitting: boolean
/** Tool input from permission request (Agent only) */
input?: Record<string, unknown>
}
/**
* Unified tool approval actions
*/
export interface ToolApprovalActions {
/** Confirm/approve the tool execution */
confirm: () => void | Promise<void>
/** Cancel/deny the tool execution */
cancel: () => void | Promise<void>
/** Auto-approve this tool for future calls (if available) */
autoApprove?: () => void | Promise<void>
}
export interface UseToolApprovalOptions extends UseMcpToolApprovalOptions {
/** Force a specific approval type */
forceType?: 'mcp' | 'agent'
}
/**
* Unified hook for tool approval - automatically selects between MCP and Agent approval
* based on the tool type in the block metadata.
*
* @param block - The tool message block
* @param options - Optional configuration
* @returns Unified approval state and actions
*/
export function useToolApproval(
block: ToolMessageBlock,
options: UseToolApprovalOptions = {}
): ToolApprovalState & ToolApprovalActions {
const { forceType, ...mcpOptions } = options
const toolResponse = block.metadata?.rawMcpToolResponse
const tool = toolResponse?.tool
const isMcpTool = forceType === 'mcp' || (forceType !== 'agent' && tool?.type === 'mcp')
const mcpApproval = useMcpToolApproval(block, mcpOptions)
const agentApproval = useAgentToolApproval(block)
return isMcpTool ? mcpApproval : agentApproval
}
/**
* Determine if a block needs approval (either MCP or Agent)
*/
export function isBlockWaitingApproval(block: ToolMessageBlock): boolean {
return block.metadata?.rawMcpToolResponse?.status === 'pending'
}
export { useAgentToolApproval, type UseAgentToolApprovalOptions } from './useAgentToolApproval'
export { useMcpToolApproval, type UseMcpToolApprovalOptions } from './useMcpToolApproval'

View File

@ -0,0 +1,104 @@
import styled from 'styled-components'
import { SkeletonSpan } from '../MessageAgentTools/GenericTools'
/**
* Format argument value for display in table
*/
export const formatArgValue = (value: unknown): string => {
if (value === null) return 'null'
if (value === undefined) return ''
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
return JSON.stringify(value)
}
/**
* Shared argument table component for displaying tool parameters
* Used by both MCP tools and Agent tools
*/
export function ToolArgsTable({
args,
title,
isStreaming = false
}: {
args: Record<string, unknown> | unknown[] | null | undefined
title?: string
isStreaming?: boolean
}) {
if (!args) return null
// Handle both object and array args
const entries: Array<[string, unknown]> = Array.isArray(args) ? [['arguments', args]] : Object.entries(args)
if (entries.length === 0 && !isStreaming) return null
return (
<ArgsSection>
{title && <ArgsSectionTitle>{title}</ArgsSectionTitle>}
<ArgsTable>
<tbody>
{entries.map(([key, value]) => (
<tr key={key}>
<ArgKey>{key}</ArgKey>
<ArgValue>{formatArgValue(value)}</ArgValue>
</tr>
))}
{isStreaming && (
<tr>
<ArgKey>
<SkeletonSpan width="60px" />
</ArgKey>
<ArgValue>
<SkeletonSpan width="120px" />
</ArgValue>
</tr>
)}
</tbody>
</ArgsTable>
</ArgsSection>
)
}
// Styled components extracted from MessageMcpTool
export const ArgsSection = styled.div`
padding: 8px 12px;
font-family: var(--font-family-mono, monospace);
font-size: 12px;
line-height: 1.5;
`
export const ArgsSectionTitle = styled.div`
font-size: 11px;
font-weight: 600;
color: var(--color-text-3);
text-transform: uppercase;
margin-bottom: 8px;
`
export const ArgsTable = styled.table`
width: 100%;
border-collapse: collapse;
`
export const ArgKey = styled.td`
color: var(--color-primary);
padding: 4px 8px 4px 0;
white-space: nowrap;
vertical-align: top;
font-weight: 500;
width: 1%;
`
export const ArgValue = styled.td`
color: var(--color-text);
padding: 4px 0;
word-break: break-all;
white-space: pre-wrap;
`
export const ResponseSection = styled.div`
padding: 8px 12px;
border-top: 1px solid var(--color-border);
`

View File

@ -0,0 +1,44 @@
/**
* Truncate output string to prevent UI performance issues
* Tries to truncate at a newline boundary to avoid cutting in the middle of a line
*/
const MAX_OUTPUT_LENGTH = 50000
/**
* Count non-empty lines in a string
*/
export function countLines(output: string | undefined | null): number {
if (!output) return 0
return output.split('\n').filter((line) => line.trim()).length
}
export interface TruncateResult {
data: string
isTruncated: boolean
originalLength: number
}
export function truncateOutput(
output: string | undefined | null,
maxLength: number = MAX_OUTPUT_LENGTH
): TruncateResult {
if (!output) {
return { data: '', isTruncated: false, originalLength: 0 }
}
const originalLength = output.length
if (output.length <= maxLength) {
return { data: output, isTruncated: false, originalLength }
}
// Truncate and try to find a newline boundary
const truncated = output.slice(0, maxLength)
const lastNewline = truncated.lastIndexOf('\n')
// Only use newline boundary if it's reasonably close to maxLength (within 20%)
const data = lastNewline > maxLength * 0.8 ? truncated.slice(0, lastNewline) : truncated
return { data, isTruncated: true, originalLength }
}

View File

@ -1,5 +1,11 @@
import { loggerService } from '@logger'
import type { ExternalToolResult, GenerateImageResponse, MCPToolResponse, WebSearchResponse } from '@renderer/types'
import type {
ExternalToolResult,
GenerateImageResponse,
MCPToolResponse,
NormalToolResponse,
WebSearchResponse
} from '@renderer/types'
import type { Chunk } from '@renderer/types/chunk'
import { ChunkType } from '@renderer/types/chunk'
import type { Response } from '@renderer/types/newMessage'
@ -23,9 +29,11 @@ export interface StreamProcessorCallbacks {
onThinkingChunk?: (text: string, thinking_millsec?: number) => void
onThinkingComplete?: (text: string, thinking_millsec?: number) => void
// A tool call response chunk (from MCP)
onToolCallPending?: (toolResponse: MCPToolResponse) => void
onToolCallInProgress?: (toolResponse: MCPToolResponse) => void
onToolCallComplete?: (toolResponse: MCPToolResponse) => void
onToolCallPending?: (toolResponse: MCPToolResponse | NormalToolResponse) => void
onToolCallInProgress?: (toolResponse: MCPToolResponse | NormalToolResponse) => void
onToolCallComplete?: (toolResponse: MCPToolResponse | NormalToolResponse) => void
// Tool argument streaming (partial arguments during streaming)
onToolArgumentStreaming?: (toolResponse: MCPToolResponse | NormalToolResponse) => void
// External tool call in progress
onExternalToolInProgress?: () => void
// Citation data received (e.g., from Internet and Knowledge Base)
@ -109,6 +117,12 @@ export function createStreamProcessor(callbacks: StreamProcessorCallbacks = {})
}
break
}
case ChunkType.MCP_TOOL_STREAMING: {
if (callbacks.onToolArgumentStreaming) {
data.responses.forEach((toolResp) => callbacks.onToolArgumentStreaming!(toolResp))
}
break
}
case ChunkType.EXTERNEL_TOOL_IN_PROGRESS: {
if (callbacks.onExternalToolInProgress) callbacks.onExternalToolInProgress()
break

View File

@ -1,7 +1,7 @@
import { loggerService } from '@logger'
import type { AppDispatch } from '@renderer/store'
import { toolPermissionsActions } from '@renderer/store/toolPermissions'
import type { MCPToolResponse } from '@renderer/types'
import type { MCPToolResponse, NormalToolResponse } from '@renderer/types'
import { WebSearchSource } from '@renderer/types'
import type { ToolMessageBlock } from '@renderer/types/newMessage'
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
@ -11,6 +11,8 @@ import type { BlockManager } from '../BlockManager'
const logger = loggerService.withContext('ToolCallbacks')
type ToolResponse = MCPToolResponse | NormalToolResponse
interface ToolCallbacksDependencies {
blockManager: BlockManager
assistantMsgId: string
@ -26,7 +28,7 @@ export const createToolCallbacks = (deps: ToolCallbacksDependencies) => {
let citationBlockId: string | null = null
return {
onToolCallPending: (toolResponse: MCPToolResponse) => {
onToolCallPending: (toolResponse: ToolResponse) => {
logger.debug('onToolCallPending', toolResponse)
if (blockManager.hasInitialPlaceholder) {
@ -55,7 +57,46 @@ export const createToolCallbacks = (deps: ToolCallbacksDependencies) => {
}
},
onToolCallComplete: (toolResponse: MCPToolResponse) => {
onToolArgumentStreaming: (toolResponse: ToolResponse) => {
// Find or create the tool block for streaming updates
let existingBlockId = toolCallIdToBlockIdMap.get(toolResponse.id)
if (!existingBlockId) {
// Create a new tool block if one doesn't exist yet
if (blockManager.hasInitialPlaceholder) {
const changes = {
type: MessageBlockType.TOOL,
status: MessageBlockStatus.PENDING,
toolName: toolResponse.tool.name,
metadata: { rawMcpToolResponse: toolResponse }
}
toolBlockId = blockManager.initialPlaceholderBlockId!
blockManager.smartBlockUpdate(toolBlockId, changes, MessageBlockType.TOOL)
toolCallIdToBlockIdMap.set(toolResponse.id, toolBlockId)
existingBlockId = toolBlockId
} else {
const toolBlock = createToolBlock(assistantMsgId, toolResponse.id, {
toolName: toolResponse.tool.name,
status: MessageBlockStatus.PENDING,
metadata: { rawMcpToolResponse: toolResponse }
})
toolBlockId = toolBlock.id
blockManager.handleBlockTransition(toolBlock, MessageBlockType.TOOL)
toolCallIdToBlockIdMap.set(toolResponse.id, toolBlock.id)
existingBlockId = toolBlock.id
}
}
// Update the tool block with streaming arguments
const changes: Partial<ToolMessageBlock> = {
status: MessageBlockStatus.PENDING,
metadata: { rawMcpToolResponse: toolResponse }
}
blockManager.smartBlockUpdate(existingBlockId, changes, MessageBlockType.TOOL)
},
onToolCallComplete: (toolResponse: ToolResponse) => {
if (toolResponse?.id) {
dispatch(toolPermissionsActions.removeByToolCallId({ toolCallId: toolResponse.id }))
}

View File

@ -24,6 +24,7 @@ export enum ChunkType {
MCP_TOOL_PENDING = 'mcp_tool_pending',
MCP_TOOL_IN_PROGRESS = 'mcp_tool_in_progress',
MCP_TOOL_COMPLETE = 'mcp_tool_complete',
MCP_TOOL_STREAMING = 'mcp_tool_streaming', // NEW: Streaming tool arguments
EXTERNEL_TOOL_COMPLETE = 'externel_tool_complete',
LLM_RESPONSE_CREATED = 'llm_response_created',
LLM_RESPONSE_IN_PROGRESS = 'llm_response_in_progress',
@ -329,6 +330,20 @@ export interface MCPToolCompleteChunk {
type: ChunkType.MCP_TOOL_COMPLETE
}
/**
* Streaming tool arguments chunk - emitted during tool-input-delta events
*/
export interface MCPToolStreamingChunk {
/**
* The type of the chunk
*/
type: ChunkType.MCP_TOOL_STREAMING
/**
* The tool responses with streaming arguments
*/
responses: (MCPToolResponse | NormalToolResponse)[]
}
export interface LLMResponseCompleteChunk {
/**
* The response
@ -438,6 +453,7 @@ export type Chunk =
| MCPToolPendingChunk // MCP工具调用等待中
| MCPToolInProgressChunk // MCP工具调用中
| MCPToolCompleteChunk // MCP工具调用完成
| MCPToolStreamingChunk // MCP工具参数流式传输中
| ExternalToolCompleteChunk // 外部工具调用完成外部工具包含搜索互联网知识库MCP服务器
| LLMResponseCreatedChunk // 大模型响应创建,返回即将创建的块类型
| LLMResponseInProgressChunk // 大模型响应进行中

View File

@ -820,7 +820,7 @@ export interface MCPConfig {
isBunInstalled: boolean
}
export type MCPToolResponseStatus = 'pending' | 'cancelled' | 'invoking' | 'done' | 'error'
export type MCPToolResponseStatus = 'pending' | 'streaming' | 'cancelled' | 'invoking' | 'done' | 'error'
interface BaseToolResponse {
id: string // unique id
@ -828,6 +828,8 @@ interface BaseToolResponse {
arguments: Record<string, unknown> | Record<string, unknown>[] | string | undefined
status: MCPToolResponseStatus
response?: any
// Streaming arguments support
partialArguments?: string // Accumulated partial JSON string during streaming
}
export interface ToolUseResponse extends BaseToolResponse {
@ -844,11 +846,13 @@ export interface MCPToolResponse extends Omit<ToolUseResponse | ToolCallResponse
tool: MCPTool
toolCallId?: string
toolUseId?: string
parentToolUseId?: string
}
export interface NormalToolResponse extends Omit<ToolCallResponse, 'tool'> {
tool: BaseTool
toolCallId: string
parentToolUseId?: string
}
export interface MCPToolResultContent {