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 ...']]); + }); +});