Pretty-print JSON in tool inputs and preformatted blocks

Tool call inputs and structured data categories (hook_progress,
file_snapshot) now attempt JSON.parse + JSON.stringify(_, null, 2)
before escaping to HTML. Non-JSON content passes through unchanged.
The detection fast-paths by checking the first non-whitespace
character for { or [ before attempting parse.

Also renames the copy state variable from linkCopied to contentCopied
to match the current behavior of copying message content rather than
anchor links.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 13:35:45 -05:00
parent b0b330e0ba
commit 10f23ccecc

View File

@@ -36,7 +36,7 @@ export function MessageBubble({
// Collapsible state for thinking blocks and tool calls/results // Collapsible state for thinking blocks and tool calls/results
const isCollapsible = message.category === "thinking" || message.category === "tool_call" || message.category === "tool_result"; const isCollapsible = message.category === "thinking" || message.category === "tool_call" || message.category === "tool_result";
const [collapsed, setCollapsed] = useState(isCollapsible); const [collapsed, setCollapsed] = useState(isCollapsible);
const [linkCopied, setLinkCopied] = useState(false); const [contentCopied, setContentCopied] = useState(false);
const renderedHtml = useMemo(() => { const renderedHtml = useMemo(() => {
// Skip expensive rendering when content is collapsed and not visible // Skip expensive rendering when content is collapsed and not visible
@@ -46,7 +46,7 @@ export function MessageBubble({
if (msg.category === "tool_call") { if (msg.category === "tool_call") {
const inputHtml = msg.toolInput const inputHtml = msg.toolInput
? `<pre class="hljs mt-2"><code>${escapeHtml(msg.toolInput)}</code></pre>` ? `<pre class="hljs mt-2"><code>${escapeHtml(tryPrettyJson(msg.toolInput))}</code></pre>`
: ""; : "";
const html = `<div class="font-medium ${colors.text}">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`; const html = `<div class="font-medium ${colors.text}">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`;
return searchQuery ? highlightSearchText(html, searchQuery) : html; return searchQuery ? highlightSearchText(html, searchQuery) : html;
@@ -55,7 +55,7 @@ export function MessageBubble({
// Structured data categories: render as preformatted text, not markdown. // Structured data categories: render as preformatted text, not markdown.
// Avoids expensive marked.parse() on large JSON/log blobs. // Avoids expensive marked.parse() on large JSON/log blobs.
if (msg.category === "hook_progress" || msg.category === "file_snapshot") { if (msg.category === "hook_progress" || msg.category === "file_snapshot") {
const html = `<pre class="hljs"><code>${escapeHtml(msg.content)}</code></pre>`; const html = `<pre class="hljs"><code>${escapeHtml(tryPrettyJson(msg.content))}</code></pre>`;
return searchQuery ? highlightSearchText(html, searchQuery) : html; return searchQuery ? highlightSearchText(html, searchQuery) : html;
} }
@@ -157,14 +157,14 @@ export function MessageBubble({
? `${message.toolName || "Tool Call"}\n${message.toolInput || ""}` ? `${message.toolName || "Tool Call"}\n${message.toolInput || ""}`
: message.content; : message.content;
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(text).then(() => {
setLinkCopied(true); setContentCopied(true);
setTimeout(() => setLinkCopied(false), 1500); setTimeout(() => setContentCopied(false), 1500);
}).catch(() => {}); }).catch(() => {});
}} }}
className="flex items-center justify-center w-7 h-7 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors" className="flex items-center justify-center w-7 h-7 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors"
title="Copy message content" title="Copy message content"
> >
{linkCopied ? ( {contentCopied ? (
<svg className="w-4 h-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="w-4 h-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg> </svg>
@@ -260,3 +260,14 @@ function formatTimestamp(ts: string): string {
second: "2-digit", second: "2-digit",
}); });
} }
/** If the string is valid JSON, return it pretty-printed; otherwise return as-is. */
function tryPrettyJson(text: string): string {
const trimmed = text.trimStart();
if (trimmed[0] !== "{" && trimmed[0] !== "[") return text;
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}