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;
+ }
+}