diff --git a/packages/web-shell/client/components/MessageList.dom.test.tsx b/packages/web-shell/client/components/MessageList.dom.test.tsx index 6fe831faf0b..3555d60bc3a 100644 --- a/packages/web-shell/client/components/MessageList.dom.test.tsx +++ b/packages/web-shell/client/components/MessageList.dom.test.tsx @@ -560,6 +560,68 @@ describe('MessageList — failed prompt retry', () => { }); describe('MessageList — compact mode', () => { + it('keeps MCP Apps standalone', async () => { + const scrollIntoView = vi + .spyOn(Element.prototype, 'scrollIntoView') + .mockImplementation(() => {}); + try { + const mixed: ToolGroupMessage = { + id: 'mixed', + role: 'tool_group', + tools: [ + { callId: 'read', toolName: 'Read', status: 'completed' }, + { callId: 'edit', toolName: 'Edit', status: 'completed' }, + { + callId: 'app', + toolName: 'mcp__demo__dashboard', + status: 'completed', + rawOutput: { + type: 'mcp_app', + serverName: 'demo', + resourceUri: 'ui://demo/dashboard', + html: '
Dashboard
', + toolResult: { content: [] }, + toolArguments: {}, + fallbackText: 'Dashboard ready', + }, + }, + { callId: 'shell', toolName: 'Shell', status: 'completed' }, + { callId: 'glob', toolName: 'Glob', status: 'completed' }, + ], + }; + const ref = createRef(); + const container = mount([mixed], ref, { + compactMode: true, + customization: { collapseCompletedTurns: false }, + }); + + expect( + Array.from(container.querySelectorAll('[data-tool-ids]')).map((row) => + row.getAttribute('data-tool-ids'), + ), + ).toEqual(['read,edit', 'app', 'shell,glob']); + + let found = false; + act(() => { + found = ref.current!.scrollToMessage('mixed', 'app'); + }); + await nextFrame(); + expect(found).toBe(true); + const appRow = container.querySelector('[data-tool-ids="app"]'); + expect( + container + .querySelector('[data-tool-ids="read,edit"]') + ?.getAttribute('data-locate-flashing'), + ).toBeNull(); + expect(appRow?.getAttribute('data-locate-flashing')).toBe('true'); + expect(scrollIntoView.mock.contexts.at(-1)).toBe( + appRow?.closest('[data-index]'), + ); + } finally { + scrollIntoView.mockRestore(); + } + }); + it('updates a lone streaming thinking tail in place without nesting', () => { const user = userMsg('u1'); const thinking = { diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index b7ff415b603..cc743f1021e 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -338,6 +338,50 @@ function isForceExpandGroup( return false; } +function splitMcpAppToolGroups(messages: Message[]): Message[] { + const result: Message[] = []; + let changed = false; + + for (const message of messages) { + if ( + message.role !== 'tool_group' || + message.tools.length < 2 || + !message.tools.some((tool) => getMcpAppDisplay(tool.rawOutput)) + ) { + result.push(message); + continue; + } + + changed = true; + let segment: ACPToolCall[] = []; + let segmentIndex = 0; + const pushSegment = (tools: ACPToolCall[]) => { + if (tools.length === 0) return; + result.push({ + ...message, + id: + segmentIndex++ === 0 + ? message.id + : `${message.id}-${tools[0]!.callId}`, + tools, + }); + }; + + for (const tool of message.tools) { + if (getMcpAppDisplay(tool.rawOutput)) { + pushSegment(segment); + segment = []; + pushSegment([tool]); + } else { + segment.push(tool); + } + } + pushSegment(segment); + } + + return changed ? result : messages; +} + function mergeCompactToolGroups( messages: Message[], pendingApproval: PermissionRequest | null, @@ -346,7 +390,9 @@ function mergeCompactToolGroups( let i = 0; const isMergedToolGroup = (m: Message): boolean => - m.role === 'tool_group' && !isForceExpandGroup(m, pendingApproval); + m.role === 'tool_group' && + !isForceExpandGroup(m, pendingApproval) && + !m.tools.some((tool) => getMcpAppDisplay(tool.rawOutput)); while (i < messages.length) { const msg = messages[i]; @@ -2101,34 +2147,36 @@ export function applyTurnCollapse( } /** - * Locate a display item by message id, falling back to the tool call id for - * tool groups that were merged (compact mode) or grouped (parallel agents) - * under another message's id. + * Locate a tool by call id when available because compacting or splitting can + * move it under a different message id. Otherwise locate the message itself. */ export function findDisplayItemIndex( items: readonly DisplayItem[], messageId: string, callId?: string, ): number { - for (let i = 0; i < items.length; i++) { - const item = items[i]; - if (item.type === 'message') { - if (item.message.id === messageId) return i; + if (callId) { + for (let i = 0; i < items.length; i++) { + const item = items[i]; if ( - callId && - item.message.role === 'tool_group' && - item.message.tools.some((tool) => toolContainsCallId(tool, callId)) + (item.type === 'message' && + item.message.role === 'tool_group' && + item.message.tools.some((tool) => + toolContainsCallId(tool, callId), + )) || + (item.type === 'parallel_agents' && + item.agents.some((agent) => toolContainsCallId(agent, callId))) ) { return i; } - } else if ( - item.type === 'parallel_agents' && - callId && - item.agents.some((agent) => toolContainsCallId(agent, callId)) - ) { + } + return -1; + } + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.type === 'message' && item.message.id === messageId) { return i; - } else if (item.type === 'turn_outputs') { - continue; } } return -1; @@ -2141,12 +2189,10 @@ function displayItemMatchesLocateTarget( if (!target) return false; const callId = target.callId; if (item.type === 'message') { - if (item.message.id === target.messageId) return true; - return ( - !!callId && - item.message.role === 'tool_group' && - item.message.tools.some((tool) => toolContainsCallId(tool, callId)) - ); + return callId + ? item.message.role === 'tool_group' && + item.message.tools.some((tool) => toolContainsCallId(tool, callId)) + : item.message.id === target.messageId; } if (item.type === 'parallel_agents') { return ( @@ -2878,12 +2924,15 @@ export const MessageList = memo( } else if (tail?.role === 'thinking') { value = compactMode ? updateCompactStreamingThinkingTail(cached.value, tail) - : messages; + : splitMcpAppToolGroups(messages); } } - value ??= compactMode - ? mergeCompactToolGroups(messages, pendingApproval) - : messages; + if (!value) { + const standaloneMcpApps = splitMcpAppToolGroups(messages); + value = compactMode + ? mergeCompactToolGroups(standaloneMcpApps, pendingApproval) + : standaloneMcpApps; + } mergedMessagesCache.current = { sourceMessages: messages, compactMode, diff --git a/packages/web-shell/client/components/messages/ToolGroup.test.tsx b/packages/web-shell/client/components/messages/ToolGroup.test.tsx index 70709824fca..f3eaf4cd58d 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.test.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.test.tsx @@ -5,7 +5,7 @@ import { createRoot, type Root } from 'react-dom/client'; import type { ACPToolCall } from '../../adapters/types'; import type { SessionContentGenerator } from './AssistantMessage'; import { hasActiveAgents } from '../../adapters/toolClassification'; -import { I18nProvider } from '../../i18n'; +import { getTranslator, I18nProvider } from '../../i18n'; import { WebShellCustomizationProvider } from '../../customization'; import { TranscriptRenderModeProvider } from '../../transcriptRenderMode'; import { SubagentDetailsProvider } from '../../subagentDetailsContext'; @@ -29,7 +29,6 @@ const { getActiveTool, getRawFileDiff, getToolHeaderKind, - hasExpandableContent, isWebFetchToolName, languageForPath, shouldAutoExpand, @@ -366,6 +365,20 @@ describe('tool group summary logic', () => { ); }); + it('describes file tool counts as operation counts', () => { + const tools = [ + makeTool({ toolName: 'edit' }), + makeTool({ callId: 'edit-2', toolName: 'edit' }), + makeTool({ callId: 'read', toolName: 'read_file' }), + makeTool({ callId: 'search', toolName: 'grep' }), + makeTool({ callId: 'web-search', toolName: 'web_search' }), + ]; + + expect(formatToolGroupSummary(tools, getTranslator('zh-CN'))).toBe( + '已编辑文件 2 次 已读取文件 1 次 已搜索 2 次', + ); + }); + it('formats a single shell summary as only the semantic description', () => { expect( formatSingleToolSummary( @@ -538,30 +551,6 @@ describe('tool group summary logic', () => { expect((content as HTMLElement | null)?.style.display).toBe(''); }); - it('keeps an MCP App open when multiple tools share a summary', () => { - const container = renderToolGroup([ - makeTool({ callId: 'read', toolName: 'read_file' }), - makeTool({ - callId: 'app', - toolName: 'mcp__demo__show_dashboard', - rawOutput: { - type: 'mcp_app', - serverName: 'demo', - resourceUri: 'ui://demo/dashboard', - html: '
Dashboard
', - toolResult: { content: [] }, - toolArguments: {}, - fallbackText: 'Dashboard ready', - }, - }), - ]); - - expect( - container.querySelector('button')?.getAttribute('aria-expanded'), - ).toBe('true'); - expect(container.textContent).toContain('Dashboard ready'); - }); - it('renders fallbackText for a compacted MCP App without mounting the iframe', () => { const container = renderToolLine( makeTool({ @@ -583,26 +572,6 @@ describe('tool group summary logic', () => { expect(container.querySelector('[data-testid="mcp-app"]')).toBeNull(); }); - it('keeps an MCP App open in a summary-only row', () => { - const container = renderToolLine( - makeTool({ - toolName: 'mcp__demo__show_dashboard', - rawOutput: { - type: 'mcp_app', - serverName: 'demo', - resourceUri: 'ui://demo/dashboard', - html: '
Dashboard
', - toolResult: { content: [] }, - toolArguments: {}, - fallbackText: 'Dashboard ready', - }, - }), - { summaryOnly: true }, - ); - - expect(container.textContent).toContain('Dashboard ready'); - }); - it('uses action descriptions for shell rows inside grouped summaries', () => { const container = renderToolGroup([ makeTool({ @@ -690,53 +659,6 @@ describe('tool output session links', () => { }); }); -describe('tool expandability', () => { - it('only marks tools with actual detail views as expandable by output', () => { - expect( - hasExpandableContent( - makeTool({ - toolName: 'Shell', - content: [{ type: 'content', content: { text: 'first\nsecond' } }], - }), - ), - ).toBe(true); - expect( - hasExpandableContent( - makeTool({ - toolName: 'list_directory', - rawOutput: 'a\nb', - }), - ), - ).toBe(false); - }); - - it('does not expand skill rows that only have the skill name', () => { - expect( - hasExpandableContent( - makeTool({ - toolName: 'skill', - title: 'Skill: Use skill: "review"', - args: { skill: 'review' }, - }), - ), - ).toBe(false); - expect( - hasExpandableContent( - makeTool({ - toolName: 'skill', - args: { skill: 'review' }, - content: [ - { - type: 'content', - content: { type: 'text', text: '# Code Review' }, - }, - ], - }), - ), - ).toBe(true); - }); -}); - describe('tool kind logic', () => { it('classifies common tool names for summary icons', () => { expect(getToolHeaderKind(makeTool({ toolName: 'Shell' }))).toBe('shell'); @@ -944,6 +866,83 @@ describe('tool row rendering', () => { } }); + it('keeps a running shell collapsed and lets the user toggle it', () => { + const container = renderToolGroup([ + makeTool({ + toolName: 'Shell', + status: 'in_progress', + rawOutput: '{}', + }), + makeTool({ callId: 'read', toolName: 'ReadFile' }), + ]); + act(() => container.querySelector('button')?.click()); + + const shell = container.querySelector( + '[class*="lineExpandable"]', + ) as HTMLElement; + expect(shell.getAttribute('aria-expanded')).toBe('false'); + expect(container.querySelector('[class*="expandedCard"]')).toBeNull(); + + act(() => shell.click()); + expect(shell.getAttribute('aria-expanded')).toBe('true'); + expect(container.querySelector('[class*="expandedCard"]')).not.toBeNull(); + + act(() => shell.click()); + expect(shell.getAttribute('aria-expanded')).toBe('false'); + expect(container.querySelector('[class*="expandedCard"]')).toBeNull(); + }); + + it('keeps an empty shell expandable after it completes', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const render = (tool: ACPToolCall) => { + act(() => { + root.render( + + + , + ); + }); + }; + mounted.push({ root, container }); + + render(makeTool({ status: 'in_progress' })); + const shell = container.querySelector( + '[class*="lineExpandable"]', + ) as HTMLElement; + act(() => shell.click()); + + render(makeTool({ status: 'completed' })); + expect(shell.getAttribute('aria-expanded')).toBe('true'); + act(() => shell.click()); + expect(shell.getAttribute('aria-expanded')).toBe('false'); + expect(container.querySelector('[class*="expandedCard"]')).toBeNull(); + act(() => shell.click()); + expect(shell.getAttribute('aria-expanded')).toBe('true'); + expect(container.querySelector('[class*="expandedCard"]')).not.toBeNull(); + }); + + it.each(['glob', 'todo_write'])( + 'lets a contentless %s tool expand and collapse', + (toolName) => { + const container = renderToolLine(makeTool({ toolName }), { + summaryOnly: true, + }); + const row = container.querySelector( + '[class*="lineExpandable"]', + ) as HTMLElement; + + expect(row.getAttribute('aria-expanded')).toBe('false'); + act(() => row.click()); + expect(row.getAttribute('aria-expanded')).toBe('true'); + expect(container.querySelector('[class*="expandedCard"]')).not.toBeNull(); + act(() => row.click()); + expect(row.getAttribute('aria-expanded')).toBe('false'); + expect(container.querySelector('[class*="expandedCard"]')).toBeNull(); + }, + ); + it('keeps the failed label out of the collapsed chat summary', () => { const container = renderToolGroup([ makeTool({ toolName: 'Shell', status: 'failed' }), @@ -1388,7 +1387,7 @@ describe('tool row rendering', () => { root.render( - + , ); @@ -1427,7 +1426,7 @@ describe('tool row rendering', () => { root.render( - + , ); @@ -1451,7 +1450,7 @@ describe('tool row rendering', () => { expect(line.getAttribute('aria-expanded')).toBe('true'); }); - it('keeps a non-expandable monitor tool line static when details are unavailable', async () => { + it('expands an empty monitor inline when details are unavailable', async () => { const onOpen = vi.fn().mockResolvedValue(false); const tool = makeTool({ toolName: 'monitor', @@ -1480,8 +1479,11 @@ describe('tool row rendering', () => { }); expect(onOpen).toHaveBeenCalledWith(tool); - expect(line.getAttribute('role')).toBeNull(); - expect(line.getAttribute('aria-expanded')).toBeNull(); + expect(line.getAttribute('role')).toBe('button'); + expect(line.getAttribute('aria-expanded')).toBe('true'); + expect(container.querySelector('[class*="expandedCard"]')).not.toBeNull(); + act(() => line.click()); + expect(line.getAttribute('aria-expanded')).toBe('false'); }); it('keeps a mixed group static when only its background agent is active', () => { diff --git a/packages/web-shell/client/components/messages/ToolGroup.tsx b/packages/web-shell/client/components/messages/ToolGroup.tsx index 7341d48703b..0e57f1a0618 100644 --- a/packages/web-shell/client/components/messages/ToolGroup.tsx +++ b/packages/web-shell/client/components/messages/ToolGroup.tsx @@ -117,32 +117,6 @@ function openMonitorDetailsOnce( }); } -export function hasExpandableContent(tool: ACPToolCall): boolean { - if (getMcpAppDisplay(tool.rawOutput)) return true; - const name = tool.toolName.toLowerCase(); - if (isAskUserQuestionToolName(tool.toolName)) return !!extractText(tool); - // write_file shows content from args even before completion - if (name === 'write_file' || name === 'writefile') { - return !!getWriteContent(tool) || hasEditContent(tool); - } - if (tool.status !== 'completed' && tool.status !== 'failed') return false; - if (isShellToolName(name)) { - const text = extractText(tool); - return !!text && text.trim().length > 0 && text.split('\n').length > 1; - } - if (isSkillToolName(name)) { - return !!getFirstToolContentText(tool); - } - if (name === 'edit' || name === 'write' || name === 'editfile') { - return hasEditContent(tool); - } - if (name === 'read' || name === 'read_file' || name === 'readfile') { - const text = extractText(tool); - return !!text && text.split('\n').length > 3; - } - return false; -} - // Tools whose expanded row renders a kind-specific detail view (shell output / // diff / file content / Q&A). Must stay in sync with the renderers in // ToolLine's lineDetail block below. Tools NOT in this set have nothing extra @@ -165,15 +139,6 @@ function hasDetailView(tool: ACPToolCall): boolean { ); } -function hasDiffContent(tool: ACPToolCall): boolean { - if (tool.content?.some((b) => b.type === 'diff')) return true; - return !!getRawFileDiff(tool); -} - -function hasEditContent(tool: ACPToolCall): boolean { - return hasDiffContent(tool) || !!extractText(tool); -} - export function extractDiff(tool: ACPToolCall): string { const rawFileDiff = getRawFileDiff(tool); if (rawFileDiff) return rawFileDiff; @@ -390,19 +355,6 @@ function ToolExpandedCard({ ); } -function getWriteContent(tool: ACPToolCall): string { - if (tool.args?.content) return tool.args.content as string; - if (tool.args?.new_string) return tool.args.new_string as string; - const text = extractText(tool); - if (text) return text; - if (tool.rawOutput && typeof tool.rawOutput === 'object') { - const raw = tool.rawOutput as Record; - if (typeof raw.content === 'string') return raw.content; - if (typeof raw.newContent === 'string') return raw.newContent; - } - return ''; -} - // Collapsed by default: the diff of this todo_write call (just-completed and // just-started items), expanding to the full list on click. The per-snapshot // diff comes from the timeline context, so this is isolated in its own @@ -440,7 +392,6 @@ interface ToolLineProps { workspaceCwd?: string; summaryOnly?: boolean; forceExpanded?: boolean; - forceExpandable?: boolean; hideHeader?: boolean; hideCollapsedOutput?: boolean; } @@ -1010,7 +961,6 @@ function areToolLinePropsEqual( if (prev.workspaceCwd !== next.workspaceCwd) return false; if (prev.summaryOnly !== next.summaryOnly) return false; if (prev.forceExpanded !== next.forceExpanded) return false; - if (prev.forceExpandable !== next.forceExpandable) return false; if (prev.hideHeader !== next.hideHeader) return false; if (prev.hideCollapsedOutput !== next.hideCollapsedOutput) return false; const a = prev.tool; @@ -1110,7 +1060,6 @@ export const ToolLine = memo(function ToolLine({ workspaceCwd, summaryOnly = false, forceExpanded = false, - forceExpandable = false, hideHeader = false, hideCollapsedOutput = false, }: ToolLineProps) { @@ -1124,7 +1073,7 @@ export const ToolLine = memo(function ToolLine({ const [monitorDetailsUnavailable, setMonitorDetailsUnavailable] = useState(false); const [expanded, setExpanded] = useState( - () => isForcedExpanded || shouldAutoExpand(tool), + () => isForcedExpanded || (!summaryOnly && shouldAutoExpand(tool)), ); const monitorDetailsRequestRef = useRef(null); // Set once the user explicitly toggles this row, so auto-collapse-on- @@ -1133,14 +1082,20 @@ export const ToolLine = memo(function ToolLine({ useEffect( () => { - setExpanded(isForcedExpanded || shouldAutoExpand(tool)); + setExpanded(isForcedExpanded || (!summaryOnly && shouldAutoExpand(tool))); setMonitorDetailsUnavailable(false); monitorDetailsRequestRef.current = null; // A new tool identity resets the manual latch. userToggledRef.current = false; }, // eslint-disable-next-line react-hooks/exhaustive-deps - [isForcedExpanded, monitorDetailsAvailable, tool.callId, tool.toolName], + [ + isForcedExpanded, + monitorDetailsAvailable, + summaryOnly, + tool.callId, + tool.toolName, + ], ); const isAgent = isSubAgentToolCall(tool); const hasApproval = approval && approval.toolCallId === tool.callId; @@ -1329,15 +1284,12 @@ export const ToolLine = memo(function ToolLine({ name === 'search' || name === 'glob'; const isRead = name === 'read' || name === 'read_file' || name === 'readfile'; - // A row expands when it has a todo list to reveal, detail output - // (bash/diff/read content), or a description long enough to be ellipsised. + // Every regular tool row expands on demand. Content controls only what the + // expanded card shows, never whether the user can open or close it. // When a long description is expanded we move it out of the header into a // wrapped block below, so the header drops its single-line copy. const descExpandable = !isTodo && isDescriptionExpandable(description); - const expandable = - !isForcedExpanded && - (forceExpandable || - (isTodo ? hasTodoList : hasExpandableContent(tool) || descExpandable)); + const expandable = !isForcedExpanded; const interactive = opensMonitorDetails || expandable; const fallbackToMonitorInline = () => { setMonitorDetailsUnavailable(true); @@ -1362,13 +1314,9 @@ export const ToolLine = memo(function ToolLine({ const hideDescriptionInHeader = showDescriptionInDetail && !isShell && !isSearch && !isRead; const expandedCardDetail = fullDescription; - // A failed tool with no result text still gets the titled card so its - // title-row error icon remains visible when expanded. + // Contentless tools still get a titled card when the user opens them. const showExpandedSummaryPanel = - !isTodo && - expanded && - !detailView && - (showDescriptionInDetail || result || tool.status === 'failed'); + expanded && !detailView && (!isTodo || (!hasTodoList && !result)); return (
diff --git a/packages/web-shell/client/components/messages/tools/SubAgentPanel.tsx b/packages/web-shell/client/components/messages/tools/SubAgentPanel.tsx index 68da110f93a..125bd319fa7 100644 --- a/packages/web-shell/client/components/messages/tools/SubAgentPanel.tsx +++ b/packages/web-shell/client/components/messages/tools/SubAgentPanel.tsx @@ -99,13 +99,12 @@ function SubToolTime({ } const SubToolLine = memo(function SubToolLine({ tool }: { tool: ACPToolCall }) { - // Same row as the main transcript: one-line summary, expandable to - // the full output / diff / file content where the tool has any. + // Same expandable row as the main transcript. const body = tool.subTools || tool.subContent ? ( ) : ( - + ); return {body}; }); diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 66784696549..fc2155a5a33 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2658,11 +2658,11 @@ const EN: Messages = { 'toolGroup.summary.ranAgents': (v) => `Ran ${v?.count ?? 0} agent${v?.count === 1 ? '' : 's'}`, 'toolGroup.summary.editedFiles': (v) => - `Edited ${v?.count ?? 0} file${v?.count === 1 ? '' : 's'}`, + `Edited files ${v?.count ?? 0} time${v?.count === 1 ? '' : 's'}`, 'toolGroup.summary.ranCommands': (v) => `Ran ${v?.count ?? 0} command${v?.count === 1 ? '' : 's'}`, 'toolGroup.summary.readFiles': (v) => - `Read ${v?.count ?? 0} file${v?.count === 1 ? '' : 's'}`, + `Read files ${v?.count ?? 0} time${v?.count === 1 ? '' : 's'}`, 'toolGroup.summary.searched': (v) => `Searched ${v?.count ?? 0} time${v?.count === 1 ? '' : 's'}`, 'toolGroup.summary.updatedTodos': (v) => @@ -5608,9 +5608,9 @@ const ZH: Messages = { 'toolGroup.moreKinds': (v) => ` +${v?.count ?? 0}`, 'toolGroup.summary': (v) => `调用了 ${v?.count ?? 0} 个工具`, 'toolGroup.summary.ranAgents': (v) => `已运行 ${v?.count ?? 0} 个智能体`, - 'toolGroup.summary.editedFiles': (v) => `已编辑 ${v?.count ?? 0} 个文件`, + 'toolGroup.summary.editedFiles': (v) => `已编辑文件 ${v?.count ?? 0} 次`, 'toolGroup.summary.ranCommands': (v) => `已运行 ${v?.count ?? 0} 条命令`, - 'toolGroup.summary.readFiles': (v) => `已读取 ${v?.count ?? 0} 个文件`, + 'toolGroup.summary.readFiles': (v) => `已读取文件 ${v?.count ?? 0} 次`, 'toolGroup.summary.searched': (v) => `已搜索 ${v?.count ?? 0} 次`, 'toolGroup.summary.updatedTodos': (v) => Number(v?.count ?? 0) > 1