+ // Members are indented under the header because nothing else marks the
+ // run's extent — flush rows are indistinguishable from the blocks after
+ // them, so the batch has no visible end.
+
{(() => {
const seen = new Map()
return blocks.map((block) => {
diff --git a/src/renderer/src/components/native-chat/native-chat-tool-summary.ts b/src/renderer/src/components/native-chat/native-chat-tool-summary.ts
index f786de33fff..02f5f7451ec 100644
--- a/src/renderer/src/components/native-chat/native-chat-tool-summary.ts
+++ b/src/renderer/src/components/native-chat/native-chat-tool-summary.ts
@@ -6,5 +6,7 @@ export {
summarizeToolInput,
summarizeToolRun,
toolFilePath,
+ toolRunSummaryMembers,
truncateToolDetail
} from '../../../../shared/native-chat-tool-summary'
+export type { ToolRunMember } from '../../../../shared/native-chat-tool-summary'
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 7a205ba816b..c533c1fde13 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -17039,6 +17039,7 @@
"result": "Result",
"countOne": "1 tool call",
"countN": "{{value0}} tool calls",
+ "moreCalls": "+{{value0}} more",
"runningPreview": "Running {{preview}}",
"runningCommand": "Running command",
"runningNamedPreview": "Running {{toolName}} {{preview}}",
diff --git a/src/shared/native-chat-tool-activity.ts b/src/shared/native-chat-tool-activity.ts
index 533dd75aecf..58a1179e9ac 100644
--- a/src/shared/native-chat-tool-activity.ts
+++ b/src/shared/native-chat-tool-activity.ts
@@ -13,7 +13,8 @@ export const NATIVE_CHAT_TOOL_ACTIVITY_COPY = {
runningNamedPreview: 'Running {{toolName}} {{preview}}',
runningNamed: 'Running {{toolName}}',
countOne: '1 tool call',
- countN: '{{value0}} tool calls'
+ countN: '{{value0}} tool calls',
+ moreCalls: '+{{value0}} more'
} as const
/** Tools whose call is a shell command, so the row reads as terminal activity
diff --git a/src/shared/native-chat-tool-summary.test.ts b/src/shared/native-chat-tool-summary.test.ts
index f11481b1136..d22f5031eea 100644
--- a/src/shared/native-chat-tool-summary.test.ts
+++ b/src/shared/native-chat-tool-summary.test.ts
@@ -9,6 +9,7 @@ import {
MAX_TOOL_DETAIL_LENGTH,
summarizeToolInput,
summarizeToolRun,
+ toolRunSummaryMembers,
toolFilePath,
truncateToolDetail
} from './native-chat-tool-summary'
@@ -254,6 +255,73 @@ describe('briefToolArg', () => {
})
})
+describe('toolRunSummaryMembers', () => {
+ it('keeps each call separate so a boundary can be drawn between them', () => {
+ const blocks: NativeChatBlock[] = [
+ { type: 'tool-call', name: 'Bash', input: { command: 'ls -la' } },
+ { type: 'tool-call', name: 'tools/read', input: { file_path: 'README.md' } }
+ ]
+
+ expect(toolRunSummaryMembers(blocks)).toEqual([
+ { name: 'Bash', arg: 'ls -la', mcpIdentity: undefined },
+ { name: 'tools/read', arg: 'README.md', mcpIdentity: undefined }
+ ])
+ })
+
+ // `url` is a PRIMARY_ARG_KEY but not a BRIEF_ARG_KEY, so a call carrying only
+ // a url falls through to the bounded JSON preview and is cut at 28 chars —
+ // mid-token, brace unbalanced. Pinned as-is: the header renders whatever this
+ // returns, and changing the key list would move mobile's summary too.
+ it('still falls through to a clipped JSON preview for a url-only call', () => {
+ const blocks: NativeChatBlock[] = [
+ { type: 'tool-call', name: 'browser.open', input: { url: 'https://example.com' } }
+ ]
+
+ expect(toolRunSummaryMembers(blocks)[0]?.arg).toBe('{"url":"https://example.com"')
+ })
+
+ it('caps at the summary limit and skips nameless calls, as the joined string does', () => {
+ const blocks: NativeChatBlock[] = [
+ { type: 'tool-call', name: ' ', input: {} },
+ { type: 'tool-call', name: 'Bash', input: { command: 'ls' } },
+ { type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' } },
+ { type: 'tool-call', name: 'Edit', input: { file_path: 'b.ts' } },
+ { type: 'tool-call', name: 'Write', input: { file_path: 'c.ts' } }
+ ]
+
+ const members = toolRunSummaryMembers(blocks)
+ expect(members.map((member) => member.name)).toEqual(['Bash', 'Read', 'Edit'])
+ // The joined string is derived from these, so the two can never disagree.
+ expect(summarizeToolRun(blocks)).toBe(
+ members.map((member) => `${member.name} ${member.arg}`).join(' · ')
+ )
+ })
+
+ it('carries provider MCP identity through, so a pill can draw the server glyph', () => {
+ const blocks: NativeChatBlock[] = [
+ {
+ type: 'tool-call',
+ name: 'mcp__linear__list_issues',
+ input: {},
+ mcpIdentity: { server: 'linear', tool: 'list_issues' }
+ }
+ ]
+
+ expect(toolRunSummaryMembers(blocks)[0]?.mcpIdentity).toEqual({
+ server: 'linear',
+ tool: 'list_issues'
+ })
+ })
+
+ it('reports a blank argument rather than standing raw JSON in for one', () => {
+ const blocks: NativeChatBlock[] = [{ type: 'tool-call', name: 'Bash', input: { command: '' } }]
+
+ expect(toolRunSummaryMembers(blocks)).toEqual([
+ { name: 'Bash', arg: '', mcpIdentity: undefined }
+ ])
+ })
+})
+
describe('summarizeToolRun', () => {
it('caps the run summary and skips nameless calls', () => {
const blocks: NativeChatBlock[] = [
diff --git a/src/shared/native-chat-tool-summary.ts b/src/shared/native-chat-tool-summary.ts
index 57b42607569..60cd4ba9d53 100644
--- a/src/shared/native-chat-tool-summary.ts
+++ b/src/shared/native-chat-tool-summary.ts
@@ -1,3 +1,4 @@
+import type { NativeChatMcpIdentity } from './native-chat-tool-identity'
import { isToolCallBlock, type NativeChatBlock } from './native-chat-types'
const MAX_PREVIEW_LENGTH = 80
@@ -260,8 +261,20 @@ function summarizePrimaryToolArg(input: unknown): string | null {
return null
}
-export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string {
- const parts: string[] = []
+/** One named call in a run header, kept apart rather than pre-joined so a
+ * surface can draw the boundary between members itself. */
+export type ToolRunMember = {
+ name: string
+ /** Brief argument, or '' when the call has none worth showing. */
+ arg: string
+ mcpIdentity?: NativeChatMcpIdentity
+}
+
+/** The run header's leading calls. Capped at the same limit the joined string
+ * has always used, so the two can never disagree about which calls speak for
+ * a run. */
+export function toolRunSummaryMembers(blocks: readonly NativeChatBlock[]): ToolRunMember[] {
+ const members: ToolRunMember[] = []
for (const block of blocks) {
if (!isToolCallBlock(block)) {
continue
@@ -270,13 +283,18 @@ export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string {
if (!name) {
continue
}
- const detail = briefToolArg(block.input)
- parts.push(detail ? `${name} ${detail}` : name)
- if (parts.length >= MAX_TOOL_RUN_SUMMARY_PARTS) {
+ members.push({ name, arg: briefToolArg(block.input), mcpIdentity: block.mcpIdentity })
+ if (members.length >= MAX_TOOL_RUN_SUMMARY_PARTS) {
break
}
}
- return parts.join(' · ')
+ return members
+}
+
+export function summarizeToolRun(blocks: readonly NativeChatBlock[]): string {
+ return toolRunSummaryMembers(blocks)
+ .map((member) => (member.arg ? `${member.name} ${member.arg}` : member.name))
+ .join(' · ')
}
export function countToolCalls(blocks: readonly NativeChatBlock[]): number {