feat(ui): pretty JSON rendering tool outputs (#9767)

Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
This commit is contained in:
Aaron Smith
2026-01-27 12:55:06 +00:00
committed by GitHub
parent 88d3df912f
commit 312a72acb8
5 changed files with 304 additions and 2 deletions
@@ -6,13 +6,14 @@
import React from 'react';
import type { ToolMessageProps } from './ToolMessage.js';
import { describe, it, expect, vi } from 'vitest';
import { ToolMessage } from './ToolMessage.js';
import { StreamingState, ToolCallStatus } from '../../types.js';
import { Text } from 'ink';
import { StreamingContext } from '../../contexts/StreamingContext.js';
import type { AnsiOutput } from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { tryParseJSON } from '../../../utils/jsonoutput.js';
vi.mock('../TerminalOutput.js', () => ({
TerminalOutput: function MockTerminalOutput({
@@ -110,6 +111,133 @@ describe('<ToolMessage />', () => {
expect(output).toMatchSnapshot();
});
describe('JSON rendering', () => {
it('pretty prints valid JSON', () => {
const testJSONstring = '{"a": 1, "b": [2, 3]}';
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
resultDisplay={testJSONstring}
renderOutputAsMarkdown={false}
/>,
StreamingState.Idle,
);
const output = lastFrame();
// Verify the JSON utility correctly parses the input
expect(tryParseJSON(testJSONstring)).toBeTruthy();
// Verify pretty-printed JSON appears in output (with proper indentation)
expect(output).toContain('"a": 1');
expect(output).toContain('"b": [');
// Should not use markdown renderer for JSON
expect(output).not.toContain('MockMarkdown:');
});
it('renders pretty JSON in ink frame', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} resultDisplay='{"a":1,"b":2}' />,
StreamingState.Idle,
);
const frame = lastFrame();
expect(frame).toMatchSnapshot();
expect(frame).not.toContain('MockMarkdown:');
expect(frame).not.toContain('MockAnsiOutput:');
expect(frame).not.toMatch(/MockDiff:/);
});
it('uses JSON renderer even when renderOutputAsMarkdown=true is true', () => {
const testJSONstring = '{"a": 1, "b": [2, 3]}';
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
resultDisplay={testJSONstring}
renderOutputAsMarkdown={true}
/>,
StreamingState.Idle,
);
const output = lastFrame();
// Verify the JSON utility correctly parses the input
expect(tryParseJSON(testJSONstring)).toBeTruthy();
// Verify pretty-printed JSON appears in output
expect(output).toContain('"a": 1');
expect(output).toContain('"b": [');
// Should not use markdown renderer for JSON even when renderOutputAsMarkdown=true
expect(output).not.toContain('MockMarkdown:');
});
it('falls back to plain text for malformed JSON', () => {
const testJSONstring = 'a": 1, "b": [2, 3]}';
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
resultDisplay={testJSONstring}
renderOutputAsMarkdown={false}
/>,
StreamingState.Idle,
);
const output = lastFrame();
expect(tryParseJSON(testJSONstring)).toBeFalsy();
expect(typeof output === 'string').toBeTruthy();
});
it('rejects mixed text + JSON renders as plain text', () => {
const testJSONstring = `{"result": "count": 42,"items": ["apple", "banana"]},"meta": {"timestamp": "2025-09-28T12:34:56Z"}}End.`;
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
resultDisplay={testJSONstring}
renderOutputAsMarkdown={false}
/>,
StreamingState.Idle,
);
const output = lastFrame();
expect(tryParseJSON(testJSONstring)).toBeFalsy();
expect(typeof output === 'string').toBeTruthy();
});
it('rejects ANSI-tained JSON renders as plain text', () => {
const testJSONstring =
'\u001b[32mOK\u001b[0m {"status": "success", "data": {"id": 123, "values": [10, 20, 30]}}';
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
resultDisplay={testJSONstring}
renderOutputAsMarkdown={false}
/>,
StreamingState.Idle,
);
const output = lastFrame();
expect(tryParseJSON(testJSONstring)).toBeFalsy();
expect(typeof output === 'string').toBeTruthy();
});
it('pretty printing 10kb JSON completes in <50ms', () => {
const large = '{"key": "' + 'x'.repeat(10000) + '"}';
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
resultDisplay={large}
renderOutputAsMarkdown={false}
/>,
StreamingState.Idle,
);
const start = performance.now();
lastFrame();
expect(performance.now() - start).toBeLessThan(50);
});
});
describe('ToolStatusIndicator rendering', () => {
it('shows ✓ for Success status', () => {
const { lastFrame } = renderWithContext(
@@ -13,6 +13,7 @@ import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { theme } from '../../semantic-colors.js';
import type { AnsiOutput } from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
@@ -63,9 +64,26 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
if (!truncatedResultDisplay) return null;
// Check if string content is valid JSON and pretty-print it
const prettyJSON =
typeof truncatedResultDisplay === 'string'
? tryParseJSON(truncatedResultDisplay)
: null;
const formattedJSON = prettyJSON ? JSON.stringify(prettyJSON, null, 2) : null;
let content: React.ReactNode;
if (typeof truncatedResultDisplay === 'string' && renderOutputAsMarkdown) {
if (formattedJSON) {
// Render pretty-printed JSON
content = (
<Text wrap="wrap" color={theme.text.primary}>
{formattedJSON}
</Text>
);
} else if (
typeof truncatedResultDisplay === 'string' &&
renderOutputAsMarkdown
) {
content = (
<MarkdownDisplay
text={truncatedResultDisplay}
@@ -1,5 +1,15 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolMessage /> > JSON rendering > renders pretty JSON in ink frame 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
│ { │
│ "a": 1, │
│ "b": 2 │
│ } │"
`;
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows ? for Confirming status 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ? test-tool A tool for testing │