From 507bd5408cdde33a32783541d3a0eae8b6d1b0eb Mon Sep 17 00:00:00 2001
From: David Kaya
Date: Tue, 7 Apr 2026 14:58:22 +0200
Subject: [PATCH] feat: show expandable tool call details in chat activity
panel
Thread toolArguments from the sidecar through shared contracts, main-process pipeline, and into the renderer. Add ToolCallDetailPanel with inline summaries (command, path, pattern, etc.) and expandable argument details for every tool-call event in the activity timeline.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
src/main/AryxAppService.ts | 2 +
.../components/chat/ToolCallDetailPanel.tsx | 89 +++++++++
.../components/chat/TurnActivityPanel.tsx | 6 +
src/renderer/lib/toolCallSummary.ts | 116 ++++++++++++
src/shared/contracts/sidecar.ts | 1 +
src/shared/domain/event.ts | 1 +
src/shared/domain/runTimeline.ts | 3 +
tests/renderer/toolCallSummary.test.ts | 173 ++++++++++++++++++
8 files changed, 391 insertions(+)
create mode 100644 src/renderer/components/chat/ToolCallDetailPanel.tsx
create mode 100644 src/renderer/lib/toolCallSummary.ts
create mode 100644 tests/renderer/toolCallSummary.test.ts
diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts
index 0b553c4..c7620df 100644
--- a/src/main/AryxAppService.ts
+++ b/src/main/AryxAppService.ts
@@ -2543,6 +2543,7 @@ export class AryxAppService extends EventEmitter {
sourceAgentName: event.sourceAgentName,
toolName: event.toolName,
toolCallId: event.toolCallId,
+ toolArguments: event.toolArguments,
fileChanges: event.fileChanges,
}));
}
@@ -2563,6 +2564,7 @@ export class AryxAppService extends EventEmitter {
sourceAgentName: event.sourceAgentName,
toolName: event.toolName,
toolCallId: event.toolCallId,
+ toolArguments: event.toolArguments,
fileChanges: event.fileChanges,
});
}
diff --git a/src/renderer/components/chat/ToolCallDetailPanel.tsx b/src/renderer/components/chat/ToolCallDetailPanel.tsx
new file mode 100644
index 0000000..974b830
--- /dev/null
+++ b/src/renderer/components/chat/ToolCallDetailPanel.tsx
@@ -0,0 +1,89 @@
+import { useState } from 'react';
+import { ChevronRight } from 'lucide-react';
+
+import {
+ formatToolCallSummary,
+ formatToolArgumentValue,
+ getDisplayableArguments,
+} from '@renderer/lib/toolCallSummary';
+
+export interface ToolCallDetailPanelProps {
+ toolName?: string;
+ toolArguments?: Record;
+}
+
+export function ToolCallDetailPanel({ toolName, toolArguments }: ToolCallDetailPanelProps) {
+ const [expanded, setExpanded] = useState(false);
+
+ const summary = formatToolCallSummary(toolName, toolArguments);
+ const displayArgs = getDisplayableArguments(toolArguments);
+ const hasExpandableContent = displayArgs.length > 0;
+
+ if (!summary && !hasExpandableContent) return null;
+
+ return (
+
+ {/* Inline summary — always visible when summary exists */}
+
+
+ {/* Expanded argument list */}
+ {expanded && hasExpandableContent && (
+
+
+ {displayArgs.map(([key, value]) => {
+ const formatted = formatToolArgumentValue(value);
+ const isMultiline = formatted.includes('\n') || formatted.length > 120;
+
+ return (
+
+
+ {key}
+
+ {isMultiline ? (
+
+ {formatted}
+
+ ) : (
+
+ {formatted}
+
+ )}
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
diff --git a/src/renderer/components/chat/TurnActivityPanel.tsx b/src/renderer/components/chat/TurnActivityPanel.tsx
index 08077f4..38df009 100644
--- a/src/renderer/components/chat/TurnActivityPanel.tsx
+++ b/src/renderer/components/chat/TurnActivityPanel.tsx
@@ -15,6 +15,7 @@ import {
import { useElapsedTimer } from '@renderer/hooks/useElapsedTimer';
import { FileChangePreview } from '@renderer/components/chat/FileChangePreview';
+import { ToolCallDetailPanel } from '@renderer/components/chat/ToolCallDetailPanel';
import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummaryCard';
import { formatEventLabel, truncateContent, filterEventsByAgent, summarizeActivity, type ActivitySummary } from '@renderer/lib/runTimelineFormatting';
import type { ChatMessageRecord } from '@shared/domain/session';
@@ -186,6 +187,11 @@ function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord })
)}
+ {/* Tool call argument details */}
+ {event.kind === 'tool-call' && (
+
+ )}
+
{/* File change preview for tool-call events */}
{event.kind === 'tool-call' && event.fileChanges && event.fileChanges.length > 0 && (
diff --git a/src/renderer/lib/toolCallSummary.ts b/src/renderer/lib/toolCallSummary.ts
new file mode 100644
index 0000000..f0213c4
--- /dev/null
+++ b/src/renderer/lib/toolCallSummary.ts
@@ -0,0 +1,116 @@
+const MAX_SUMMARY_LENGTH = 80;
+
+function truncateSummary(value: string): string {
+ const firstLine = value.split('\n')[0] ?? '';
+ const cleaned = firstLine.trim();
+ if (cleaned.length <= MAX_SUMMARY_LENGTH) return cleaned;
+ return `${cleaned.slice(0, MAX_SUMMARY_LENGTH)}…`;
+}
+
+function stringArg(args: Record, key: string): string | undefined {
+ const value = args[key];
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
+}
+
+function summarizePath(args: Record): string | undefined {
+ const path = stringArg(args, 'path');
+ if (!path) return undefined;
+ const range = args['view_range'] ?? args['viewRange'];
+ if (Array.isArray(range) && range.length === 2) {
+ return truncateSummary(`${path}:${range[0]}-${range[1]}`);
+ }
+ return truncateSummary(path);
+}
+
+function summarizeGitHub(toolName: string, args: Record): string | undefined {
+ const owner = stringArg(args, 'owner');
+ const repo = stringArg(args, 'repo');
+ const query = stringArg(args, 'query');
+
+ if (query) return truncateSummary(query);
+ if (owner && repo) return truncateSummary(`${owner}/${repo}`);
+ return undefined;
+}
+
+type SummaryExtractor = (args: Record, toolName: string) => string | undefined;
+
+const toolSummarizers: Record = {
+ powershell: (args) => stringArg(args, 'command') ? truncateSummary(stringArg(args, 'command')!) : undefined,
+ view: (args) => summarizePath(args),
+ edit: (args) => summarizePath(args),
+ create: (args) => summarizePath(args),
+ grep: (args) => stringArg(args, 'pattern') ? truncateSummary(stringArg(args, 'pattern')!) : undefined,
+ glob: (args) => stringArg(args, 'pattern') ? truncateSummary(stringArg(args, 'pattern')!) : undefined,
+ lsp: (args) => {
+ const op = stringArg(args, 'operation');
+ const file = stringArg(args, 'file');
+ if (op && file) return truncateSummary(`${op} ${file}`);
+ return op ? truncateSummary(op) : undefined;
+ },
+ web_fetch: (args) => stringArg(args, 'url') ? truncateSummary(stringArg(args, 'url')!) : undefined,
+ sql: (args) => stringArg(args, 'description') ? truncateSummary(stringArg(args, 'description')!) : undefined,
+ task: (args) => stringArg(args, 'description') ? truncateSummary(stringArg(args, 'description')!) : undefined,
+ ask_user: (args) => stringArg(args, 'question') ? truncateSummary(stringArg(args, 'question')!) : undefined,
+ skill: (args) => stringArg(args, 'skill') ? truncateSummary(stringArg(args, 'skill')!) : undefined,
+ report_intent: (args) => stringArg(args, 'intent') ? truncateSummary(stringArg(args, 'intent')!) : undefined,
+};
+
+function fallbackSummary(args: Record): string | undefined {
+ for (const value of Object.values(args)) {
+ if (typeof value === 'string' && value.trim().length > 0 && value !== '[truncated]') {
+ return truncateSummary(value);
+ }
+ }
+ return undefined;
+}
+
+export function formatToolCallSummary(
+ toolName: string | undefined,
+ toolArguments: Record | undefined,
+): string | undefined {
+ if (!toolName || !toolArguments || Object.keys(toolArguments).length === 0) {
+ return undefined;
+ }
+
+ // Check for GitHub tools (github-*)
+ if (toolName.startsWith('github-')) {
+ return summarizeGitHub(toolName, toolArguments);
+ }
+
+ const summarizer = toolSummarizers[toolName];
+ if (summarizer) {
+ return summarizer(toolArguments, toolName);
+ }
+
+ return fallbackSummary(toolArguments);
+}
+
+export function formatToolArgumentValue(value: unknown): string {
+ if (value === null || value === undefined) return '';
+ if (typeof value === 'string') return value;
+ if (typeof value === 'boolean' || typeof value === 'number') return String(value);
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return String(value);
+ }
+}
+
+/** Keys that are redundant with the label itself or too noisy to display inline. */
+const HIDDEN_ARGUMENT_KEYS = new Set([
+ 'description', // often duplicates the summary
+]);
+
+export function getDisplayableArguments(
+ toolArguments: Record | undefined,
+): Array<[string, unknown]> {
+ if (!toolArguments) return [];
+
+ return Object.entries(toolArguments).filter(
+ ([key, value]) =>
+ !HIDDEN_ARGUMENT_KEYS.has(key)
+ && value !== null
+ && value !== undefined
+ && value !== '',
+ );
+}
diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts
index 5ddae5f..b9f25e4 100644
--- a/src/shared/contracts/sidecar.ts
+++ b/src/shared/contracts/sidecar.ts
@@ -290,6 +290,7 @@ export interface AgentActivityEvent {
sourceAgentName?: string;
toolName?: string;
toolCallId?: string;
+ toolArguments?: Record;
fileChanges?: ToolCallFileChangePreview[];
}
diff --git a/src/shared/domain/event.ts b/src/shared/domain/event.ts
index 1bbefee..bb566fc 100644
--- a/src/shared/domain/event.ts
+++ b/src/shared/domain/event.ts
@@ -46,6 +46,7 @@ export interface SessionEventRecord {
sourceAgentName?: string;
toolName?: string;
toolCallId?: string;
+ toolArguments?: Record;
fileChanges?: ToolCallFileChangePreview[];
run?: SessionRunRecord;
error?: string;
diff --git a/src/shared/domain/runTimeline.ts b/src/shared/domain/runTimeline.ts
index 2dff5e7..ffa6cf0 100644
--- a/src/shared/domain/runTimeline.ts
+++ b/src/shared/domain/runTimeline.ts
@@ -60,6 +60,7 @@ export interface RunTimelineEventRecord {
targetAgentName?: string;
toolName?: string;
toolCallId?: string;
+ toolArguments?: Record;
fileChanges?: ToolCallFileChangePreview[];
approvalId?: string;
approvalKind?: ApprovalCheckpointKind;
@@ -120,6 +121,7 @@ export interface AppendRunActivityEventInput {
sourceAgentName?: string;
toolName?: string;
toolCallId?: string;
+ toolArguments?: Record;
fileChanges?: ToolCallFileChangePreview[];
}
@@ -824,6 +826,7 @@ export function appendRunActivityEvent(
agentName: agent.agentName ?? existingEvent?.agentName,
toolName: normalizeOptionalString(input.toolName) ?? existingEvent?.toolName,
toolCallId,
+ toolArguments: input.toolArguments ?? existingEvent?.toolArguments,
fileChanges: mergeToolCallFileChanges(existingEvent?.fileChanges, input.fileChanges),
};
return existingIndex >= 0
diff --git a/tests/renderer/toolCallSummary.test.ts b/tests/renderer/toolCallSummary.test.ts
new file mode 100644
index 0000000..704da6c
--- /dev/null
+++ b/tests/renderer/toolCallSummary.test.ts
@@ -0,0 +1,173 @@
+import { describe, expect, test } from 'bun:test';
+
+import {
+ formatToolCallSummary,
+ formatToolArgumentValue,
+ getDisplayableArguments,
+} from '@renderer/lib/toolCallSummary';
+
+describe('formatToolCallSummary', () => {
+ test('returns undefined when toolName is missing', () => {
+ expect(formatToolCallSummary(undefined, { command: 'ls' })).toBeUndefined();
+ });
+
+ test('returns undefined when toolArguments is missing', () => {
+ expect(formatToolCallSummary('powershell', undefined)).toBeUndefined();
+ });
+
+ test('returns undefined when toolArguments is empty', () => {
+ expect(formatToolCallSummary('powershell', {})).toBeUndefined();
+ });
+
+ test('extracts command for powershell', () => {
+ expect(formatToolCallSummary('powershell', { command: 'git status' })).toBe('git status');
+ });
+
+ test('truncates long powershell commands', () => {
+ const longCommand = 'a'.repeat(100);
+ const result = formatToolCallSummary('powershell', { command: longCommand });
+ expect(result!.length).toBeLessThanOrEqual(81); // 80 + ellipsis
+ expect(result!.endsWith('…')).toBe(true);
+ });
+
+ test('extracts path for view tool', () => {
+ expect(formatToolCallSummary('view', { path: '/src/index.ts' })).toBe('/src/index.ts');
+ });
+
+ test('includes view range when present', () => {
+ expect(formatToolCallSummary('view', { path: '/src/index.ts', view_range: [10, 25] }))
+ .toBe('/src/index.ts:10-25');
+ });
+
+ test('supports viewRange camelCase variant', () => {
+ expect(formatToolCallSummary('view', { path: '/src/index.ts', viewRange: [1, 50] }))
+ .toBe('/src/index.ts:1-50');
+ });
+
+ test('extracts path for edit tool', () => {
+ expect(formatToolCallSummary('edit', { path: '/src/utils.ts', old_str: 'foo' }))
+ .toBe('/src/utils.ts');
+ });
+
+ test('extracts path for create tool', () => {
+ expect(formatToolCallSummary('create', { path: '/new-file.ts', file_text: 'content' }))
+ .toBe('/new-file.ts');
+ });
+
+ test('extracts pattern for grep', () => {
+ expect(formatToolCallSummary('grep', { pattern: 'TODO', path: '/src' })).toBe('TODO');
+ });
+
+ test('extracts pattern for glob', () => {
+ expect(formatToolCallSummary('glob', { pattern: '**/*.ts' })).toBe('**/*.ts');
+ });
+
+ test('extracts operation and file for lsp', () => {
+ expect(formatToolCallSummary('lsp', { operation: 'goToDefinition', file: '/src/app.ts' }))
+ .toBe('goToDefinition /src/app.ts');
+ });
+
+ test('extracts just operation when file is missing for lsp', () => {
+ expect(formatToolCallSummary('lsp', { operation: 'workspaceSymbol', query: 'Foo' }))
+ .toBe('workspaceSymbol');
+ });
+
+ test('extracts url for web_fetch', () => {
+ expect(formatToolCallSummary('web_fetch', { url: 'https://example.com' }))
+ .toBe('https://example.com');
+ });
+
+ test('extracts description for sql', () => {
+ expect(formatToolCallSummary('sql', { description: 'Insert todos', query: 'INSERT ...' }))
+ .toBe('Insert todos');
+ });
+
+ test('extracts description for task', () => {
+ expect(formatToolCallSummary('task', { description: 'Run tests' })).toBe('Run tests');
+ });
+
+ test('extracts question for ask_user', () => {
+ expect(formatToolCallSummary('ask_user', { question: 'Which database?' })).toBe('Which database?');
+ });
+
+ test('extracts intent for report_intent', () => {
+ expect(formatToolCallSummary('report_intent', { intent: 'Exploring codebase' }))
+ .toBe('Exploring codebase');
+ });
+
+ test('summarizes github tools with query', () => {
+ expect(formatToolCallSummary('github-search_code', { query: 'FunctionCallContent' }))
+ .toBe('FunctionCallContent');
+ });
+
+ test('summarizes github tools with owner/repo', () => {
+ expect(formatToolCallSummary('github-get_file_contents', { owner: 'octocat', repo: 'hello-world' }))
+ .toBe('octocat/hello-world');
+ });
+
+ test('falls back to first string value for unknown tools', () => {
+ expect(formatToolCallSummary('unknown_tool', { target: 'production', count: 42 }))
+ .toBe('production');
+ });
+
+ test('skips [truncated] values in fallback', () => {
+ expect(formatToolCallSummary('unknown_tool', { data: '[truncated]', label: 'test' }))
+ .toBe('test');
+ });
+
+ test('uses first line only for multiline commands', () => {
+ const result = formatToolCallSummary('powershell', { command: 'echo hello\necho world' });
+ expect(result).toBe('echo hello');
+ });
+});
+
+describe('formatToolArgumentValue', () => {
+ test('formats string values directly', () => {
+ expect(formatToolArgumentValue('hello')).toBe('hello');
+ });
+
+ test('formats numbers', () => {
+ expect(formatToolArgumentValue(42)).toBe('42');
+ });
+
+ test('formats booleans', () => {
+ expect(formatToolArgumentValue(true)).toBe('true');
+ });
+
+ test('formats null as empty string', () => {
+ expect(formatToolArgumentValue(null)).toBe('');
+ });
+
+ test('formats objects as pretty JSON', () => {
+ const result = formatToolArgumentValue({ a: 1, b: 'two' });
+ expect(result).toContain('"a": 1');
+ expect(result).toContain('"b": "two"');
+ });
+
+ test('formats arrays as pretty JSON', () => {
+ const result = formatToolArgumentValue([1, 2, 3]);
+ expect(result).toContain('1');
+ expect(result).toContain('3');
+ });
+});
+
+describe('getDisplayableArguments', () => {
+ test('returns empty array when toolArguments is undefined', () => {
+ expect(getDisplayableArguments(undefined)).toEqual([]);
+ });
+
+ test('filters out null and undefined values', () => {
+ const result = getDisplayableArguments({ a: 'value', b: null, c: undefined, d: 'ok' });
+ expect(result).toEqual([['a', 'value'], ['d', 'ok']]);
+ });
+
+ test('filters out empty string values', () => {
+ const result = getDisplayableArguments({ a: '', b: 'value' });
+ expect(result).toEqual([['b', 'value']]);
+ });
+
+ test('filters out description key', () => {
+ const result = getDisplayableArguments({ description: 'Insert todos', query: 'INSERT ...' });
+ expect(result).toEqual([['query', 'INSERT ...']]);
+ });
+});