feat: improve MCP tool input/output rendering
Closes matt1398/claude-devtools#32
This commit is contained in:
parent
4f1e4012cf
commit
a00c7a459b
3 changed files with 174 additions and 5 deletions
|
|
@ -95,19 +95,92 @@ export function renderInput(toolName: string, input: Record<string, unknown>): R
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: JSON format
|
// Default: key-value format with readable string values
|
||||||
return (
|
return (
|
||||||
<pre className="whitespace-pre-wrap break-all" style={{ color: COLOR_TEXT }}>
|
<div className="space-y-2" style={{ color: COLOR_TEXT }}>
|
||||||
{JSON.stringify(input, null, 2)}
|
{Object.entries(input).map(([key, value]) => (
|
||||||
</pre>
|
<div key={key}>
|
||||||
|
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
|
||||||
|
{key}
|
||||||
|
</div>
|
||||||
|
<pre className="whitespace-pre-wrap break-all">{formatInputValue(value)}</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.
|
* 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<string, unknown>).type === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function renderOutput(content: string | unknown[]): React.ReactElement {
|
export function renderOutput(content: string | unknown[]): React.ReactElement {
|
||||||
const displayText = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
|
const displayText = extractOutputText(content);
|
||||||
return (
|
return (
|
||||||
<pre className="whitespace-pre-wrap break-all" style={{ color: COLOR_TEXT }}>
|
<pre className="whitespace-pre-wrap break-all" style={{ color: COLOR_TEXT }}>
|
||||||
{displayText}
|
{displayText}
|
||||||
|
|
|
||||||
37
test/renderer/components/renderOutput.test.ts
Normal file
37
test/renderer/components/renderOutput.test.ts
Normal file
|
|
@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
59
test/renderer/utils/renderHelpers.test.ts
Normal file
59
test/renderer/utils/renderHelpers.test.ts
Normal file
|
|
@ -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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue