diff --git a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx index 634ee641..4b64eed4 100644 --- a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx +++ b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx @@ -95,19 +95,92 @@ export function renderInput(toolName: string, input: Record): R ); } - // Default: JSON format + // Default: key-value format with readable string values return ( -
-      {JSON.stringify(input, null, 2)}
-    
+
+ {Object.entries(input).map(([key, value]) => ( +
+
+ {key} +
+
{formatInputValue(value)}
+
+ ))} +
); } +function formatInputValue(value: unknown): string { + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return JSON.stringify(value, null, 2); +} + /** * Renders the output section with theme-aware styling. */ +/** + * Extracts display text from tool output content. + * Handles content block arrays from the API by extracting text fields + * and pretty-printing JSON when possible. + */ +export function extractOutputText(content: string | unknown[]): string { + let displayText: string; + + // Normalize: if content is a string that parses to an array of content blocks, treat as array + let normalizedContent: string | unknown[] = content; + if (typeof content === 'string') { + try { + const parsed: unknown = JSON.parse(content); + if (Array.isArray(parsed) && parsed.length > 0 && isContentBlock(parsed[0])) { + normalizedContent = parsed as unknown[]; + } + } catch { + // Not JSON, keep as string + } + } + + if (typeof normalizedContent === 'string') { + displayText = normalizedContent; + } else if (Array.isArray(normalizedContent)) { + // Extract text from content blocks (e.g. [{"type":"text","text":"..."}]) + displayText = normalizedContent + .map((block) => + typeof block === 'object' && block !== null && 'text' in block + ? (block as { text: string }).text + : JSON.stringify(block, null, 2), + ) + .join('\n'); + } else { + displayText = JSON.stringify(normalizedContent, null, 2); + } + + // Try to pretty-print if the extracted text is valid JSON + try { + const parsed: unknown = JSON.parse(displayText); + displayText = JSON.stringify(parsed, null, 2); + } catch { + // Not JSON, use as-is + } + + return displayText; +} + +function isContentBlock(value: unknown): boolean { + return ( + typeof value === 'object' && + value !== null && + 'type' in value && + typeof (value as Record).type === 'string' + ); +} + export function renderOutput(content: string | unknown[]): React.ReactElement { - const displayText = typeof content === 'string' ? content : JSON.stringify(content, null, 2); + const displayText = extractOutputText(content); return (
       {displayText}
diff --git a/test/renderer/components/renderOutput.test.ts b/test/renderer/components/renderOutput.test.ts
new file mode 100644
index 00000000..da52e9e0
--- /dev/null
+++ b/test/renderer/components/renderOutput.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest';
+
+import { extractOutputText } from '../../../src/renderer/components/chat/items/linkedTool/renderHelpers';
+
+describe('extractOutputText', () => {
+  it('should return plain string as-is', () => {
+    expect(extractOutputText('hello world')).toBe('hello world');
+  });
+
+  it('should pretty-print a plain string that is valid JSON', () => {
+    expect(extractOutputText('{"key":"value"}')).toBe(JSON.stringify({ key: 'value' }, null, 2));
+  });
+
+  it('should extract text from content blocks with plain text', () => {
+    expect(extractOutputText([{ type: 'text', text: 'plain text' }])).toBe('plain text');
+  });
+
+  it('should extract and pretty-print JSON from content blocks', () => {
+    expect(extractOutputText([{ type: 'text', text: '{"key":"value"}' }])).toBe(
+      JSON.stringify({ key: 'value' }, null, 2),
+    );
+  });
+
+  it('should concatenate multiple content blocks with newline', () => {
+    expect(
+      extractOutputText([
+        { type: 'text', text: 'line one' },
+        { type: 'text', text: 'line two' },
+      ]),
+    ).toBe('line one\nline two');
+  });
+
+  it('should fallback to stringify for blocks without text field', () => {
+    const block = { type: 'image', url: 'http://example.com/img.png' };
+    expect(extractOutputText([block])).toBe(JSON.stringify(block, null, 2));
+  });
+});
diff --git a/test/renderer/utils/renderHelpers.test.ts b/test/renderer/utils/renderHelpers.test.ts
new file mode 100644
index 00000000..f1aaa35d
--- /dev/null
+++ b/test/renderer/utils/renderHelpers.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it } from 'vitest';
+
+import { extractOutputText } from '../../../src/renderer/components/chat/items/linkedTool/renderHelpers';
+
+describe('renderHelpers', () => {
+  describe('extractOutputText', () => {
+    it('should return plain string content as-is', () => {
+      expect(extractOutputText('hello world')).toBe('hello world');
+    });
+
+    it('should pretty-print string content that is valid JSON', () => {
+      const json = '{"name":"test","value":42}';
+      expect(extractOutputText(json)).toBe('{\n  "name": "test",\n  "value": 42\n}');
+    });
+
+    it('should extract text from content block arrays', () => {
+      const content = [{ type: 'text', text: 'hello world' }];
+      expect(extractOutputText(content)).toBe('hello world');
+    });
+
+    it('should extract and pretty-print JSON from content block arrays', () => {
+      const inner = { teams: [{ id: '1', name: 'Test' }] };
+      const content = [{ type: 'text', text: JSON.stringify(inner) }];
+      expect(extractOutputText(content)).toBe(JSON.stringify(inner, null, 2));
+    });
+
+    it('should handle serialized content block arrays (string wrapping content blocks)', () => {
+      // This is what SemanticStepExtractor produces when content is an array
+      const inner = { teams: [{ id: '1', name: 'Test' }] };
+      const contentBlocks = [{ type: 'text', text: JSON.stringify(inner) }];
+      const serialized = JSON.stringify(contentBlocks);
+
+      const result = extractOutputText(serialized);
+      expect(result).toBe(JSON.stringify(inner, null, 2));
+    });
+
+    it('should handle serialized content blocks with plain text', () => {
+      const contentBlocks = [{ type: 'text', text: 'Some plain text\nwith newlines' }];
+      const serialized = JSON.stringify(contentBlocks);
+
+      const result = extractOutputText(serialized);
+      expect(result).toBe('Some plain text\nwith newlines');
+    });
+
+    it('should join multiple content blocks with newlines', () => {
+      const content = [
+        { type: 'text', text: 'first' },
+        { type: 'text', text: 'second' },
+      ];
+      expect(extractOutputText(content)).toBe('first\nsecond');
+    });
+
+    it('should stringify non-text content blocks', () => {
+      const content = [{ type: 'image', url: 'http://example.com/img.png' }];
+      const result = extractOutputText(content);
+      expect(result).toContain('"type": "image"');
+    });
+  });
+});