From 312a72acb8e90c187861da948e3d49ac60b049d1 Mon Sep 17 00:00:00 2001 From: Aaron Smith <60046611+medic-code@users.noreply.github.com> Date: Tue, 27 Jan 2026 12:55:06 +0000 Subject: [PATCH] feat(ui): pretty JSON rendering tool outputs (#9767) Co-authored-by: Bryan Morgan --- .../components/messages/ToolMessage.test.tsx | 130 +++++++++++++++++- .../components/messages/ToolResultDisplay.tsx | 20 ++- .../__snapshots__/ToolMessage.test.tsx.snap | 10 ++ packages/cli/src/utils/jsonoutput.test.ts | 100 ++++++++++++++ packages/cli/src/utils/jsonoutput.ts | 46 +++++++ 5 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/utils/jsonoutput.test.ts create mode 100644 packages/cli/src/utils/jsonoutput.ts diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index fb01c4d9bc..c3ed4fcc76 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -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('', () => { expect(output).toMatchSnapshot(); }); + describe('JSON rendering', () => { + it('pretty prints valid JSON', () => { + const testJSONstring = '{"a": 1, "b": [2, 3]}'; + const { lastFrame } = renderWithContext( + , + 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( + , + 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( + , + 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( + , + 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( + , + 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( + , + 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( + , + StreamingState.Idle, + ); + + const start = performance.now(); + lastFrame(); + expect(performance.now() - start).toBeLessThan(50); + }); + }); + describe('ToolStatusIndicator rendering', () => { it('shows ✓ for Success status', () => { const { lastFrame } = renderWithContext( diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index f8dde62057..aa005c3e43 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -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 = ({ 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 = ( + + {formattedJSON} + + ); + } else if ( + typeof truncatedResultDisplay === 'string' && + renderOutputAsMarkdown + ) { content = ( > JSON rendering > renders pretty JSON in ink frame 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ✓ test-tool A tool for testing │ +│ │ +│ { │ +│ "a": 1, │ +│ "b": 2 │ +│ } │" +`; + exports[` > ToolStatusIndicator rendering > shows ? for Confirming status 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ? test-tool A tool for testing │ diff --git a/packages/cli/src/utils/jsonoutput.test.ts b/packages/cli/src/utils/jsonoutput.test.ts new file mode 100644 index 0000000000..c80f1097ff --- /dev/null +++ b/packages/cli/src/utils/jsonoutput.test.ts @@ -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(); + }); +}); diff --git a/packages/cli/src/utils/jsonoutput.ts b/packages/cli/src/utils/jsonoutput.ts new file mode 100644 index 0000000000..ae170ec591 --- /dev/null +++ b/packages/cli/src/utils/jsonoutput.ts @@ -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; + } +}