feat(json): add interactive JSON tree viewer with collapse/expand and rainbow colors (#786)

- Add JsonTreeViewer component with collapse/expand for nested objects and arrays
- Add rainbow colors by depth level using CSS color-mix() with syntax theme tokens
- Add virtualization support (@tanstack/react-virtual) for large JSON files (>200 nodes)
- Add JsonTreeView wrapper with Expand All / Collapse All toolbar
- Integrate into FilesView: tree/text toggle button, JSON file detection
- Integrate into ToolPart: auto-detect JSON tool outputs, render as tree
- Integrate into ToolOutputDialog: JSON tree view in expanded dialog
- Add jsonTreeUtils.ts: parse, flatten, path utilities

No new dependencies - uses existing @tanstack/react-virtual.
Colors derived from existing syntax.* theme tokens - works across all themes.

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Nguyễn Ngô Thượng
2026-04-01 17:30:28 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent d9fdd39f99
commit 6180f388e8
7 changed files with 755 additions and 1 deletions
@@ -20,10 +20,12 @@ import {
renderWebSearchOutput,
formatInputForDisplay,
parseReadToolOutput,
tryParseJsonOutput,
} from './toolRenderers';
import type { ToolPopupContent, DiffViewMode } from './types';
import { DiffViewToggle } from './DiffViewToggle';
import { VirtualizedCodeBlock, type CodeLine } from './parts/VirtualizedCodeBlock';
import { JsonTreeView } from '@/components/ui/JsonTreeView';
interface ToolOutputDialogProps {
popup: ToolPopupContent;
@@ -1184,6 +1186,18 @@ const ToolOutputDialog: React.FC<ToolOutputDialogProps> = ({ popup, onOpenChange
return <DialogReadContent popup={popup} syntaxTheme={syntaxTheme} pierreThemeConfig={pierreThemeConfig} />;
}
// JSON tree viewer for generic JSON outputs
const jsonResult = popup.content ? tryParseJsonOutput(popup.content) : { data: null, isJson: false };
if (jsonResult.isJson) {
return (
<JsonTreeView
jsonString={popup.content}
initiallyExpandedDepth={3}
maxHeight="70vh"
/>
);
}
return (
<SyntaxHighlighter
style={syntaxTheme}
@@ -30,7 +30,9 @@ import {
formatEditOutput,
detectLanguageFromOutput,
formatInputForDisplay,
tryParseJsonOutput,
} from '../toolRenderers';
import { JsonTreeViewer } from '@/components/ui/JsonTreeViewer';
import { DiffViewToggle, type DiffViewMode } from '../DiffViewToggle';
import { MinDurationShineText } from './MinDurationShineText';
import { ToolRevealOnMount } from './ToolRevealOnMount';
@@ -534,6 +536,19 @@ const ToolScrollableTextOutput: React.FC<{
}> = ({ output, part, metadata, input, syntaxTheme }) => {
const renderedOutput = getToolOutputText(output, part, metadata);
const outputLanguage = getToolOutputLanguage(output, part, metadata, input);
const jsonResult = React.useMemo(() => tryParseJsonOutput(renderedOutput), [renderedOutput]);
if (jsonResult.isJson) {
return (
<div className="tool-output-surface p-2 rounded-xl w-full min-w-0">
<JsonTreeViewer
data={jsonResult.data}
initiallyExpandedDepth={1}
maxHeight="400px"
/>
</div>
);
}
return (
<div className={part.tool === 'bash' ? 'typography-code text-muted-foreground/90' : undefined}>
@@ -28,6 +28,36 @@ const formatInputForDisplay = (input: Record<string, unknown>, toolName?: string
return formatToolInput(input, toolName || '');
};
export const tryParseJsonOutput = (output: string): { data: unknown; isJson: boolean } => {
if (!output || typeof output !== 'string') {
return { data: null, isJson: false };
}
const trimmed = output.trim();
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return { data: null, isJson: false };
}
if (!trimmed.endsWith('}') && !trimmed.endsWith(']')) {
return { data: null, isJson: false };
}
if (trimmed.length < 2) {
return { data: null, isJson: false };
}
try {
const parsed = JSON.parse(trimmed);
if (parsed !== null && typeof parsed === 'object') {
return { data: parsed, isJson: true };
}
return { data: null, isJson: false };
} catch {
return { data: null, isJson: false };
}
};
export const formatEditOutput = (output: string, toolName: string, metadata?: Record<string, unknown>): string => {
let cleaned = cleanOutput(output);