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

View File

@@ -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(

View File

@@ -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}

View File

@@ -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 │

View File

@@ -0,0 +1,100 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { checkInput, tryParseJSON } from './jsonoutput.js';
describe('check tools output', () => {
it('accepts object-like JSON strings', () => {
const testJSON = '{"a":1, "b": 2}';
expect(checkInput(testJSON)).toBeTruthy();
});
it('accepts array JSON strings', () => {
expect(checkInput('[1,2,3]')).toBeTruthy();
});
it('rejects primitive strings/plaintext strings', () => {
expect(checkInput('test text')).toBeFalsy();
});
it('rejects empty strings', () => {
expect(checkInput('')).toBeFalsy();
});
it('rejects null and undefined', () => {
expect(checkInput(null)).toBeFalsy();
expect(checkInput(undefined)).toBeFalsy();
});
it('rejects malformed JSON-like strings', () => {
const malformedJSON = '"a":1,}';
expect(checkInput(malformedJSON)).toBeFalsy();
});
it('rejects mixed text and JSON text strings', () => {
const testJSON = 'text {"a":1, "b": 2}';
expect(checkInput(testJSON)).toBeFalsy();
});
it('rejects ANSI-tainted input', () => {
const text = '\u001B[32m{"a":1}\u001B[0m';
expect(checkInput(text)).toBeFalsy();
});
});
describe('check parsing json', () => {
it('returns parsed object for valid JSON', () => {
const testJSON = '{"a":1, "b": 2}';
const parsedTestJSON = JSON.parse(testJSON);
const output = tryParseJSON(testJSON);
expect(output).toEqual(parsedTestJSON);
});
it('returns parsed array for non-empty arrays', () => {
const testJSON = '[1,2,3]';
const parsedTestJSON = JSON.parse(testJSON);
const output = tryParseJSON(testJSON);
expect(output).toEqual(parsedTestJSON);
});
it('returns null for Malformed JSON', () => {
const text = '{"a":1,}';
expect(tryParseJSON(text)).toBeFalsy();
});
it('returns null for empty arrays', () => {
const testArr = '[]';
expect(tryParseJSON(testArr)).toBeFalsy();
});
it('returns null for empty objects', () => {
const testObj = '{}';
expect(tryParseJSON(testObj)).toBeFalsy();
});
it('trims whitespace and parse valid json', () => {
const text = '\n { "a": 1 } \n';
expect(tryParseJSON(text)).toBeTruthy();
});
it('returns null for plaintext', () => {
const testText = 'test plaintext';
const output = tryParseJSON(testText);
expect(output).toBeFalsy();
});
});

View File

@@ -0,0 +1,46 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import stripAnsi from 'strip-ansi';
export function checkInput(input: string | null | undefined): boolean {
if (input === null || input === undefined) {
return false;
}
const trimmed = input.trim();
if (!trimmed) {
return false;
}
if (!/^(?:\[|\{)/.test(trimmed)) {
return false;
}
if (stripAnsi(trimmed) !== trimmed) return false;
return true;
}
export function tryParseJSON(input: string): object | null {
if (!checkInput(input)) return null;
const trimmed = input.trim();
try {
const parsed = JSON.parse(trimmed);
if (parsed === null || typeof parsed !== 'object') {
return null;
}
if (Array.isArray(parsed) && parsed.length === 0) {
return null;
}
if (!Array.isArray(parsed) && Object.keys(parsed).length === 0) return null;
return parsed;
} catch (_err) {
return null;
}
}