From 10f23ccecc3367fc0babb9e0161c9c8adce72ed9 Mon Sep 17 00:00:00 2001 From: teernisse Date: Fri, 30 Jan 2026 13:35:45 -0500 Subject: [PATCH] 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 --- src/client/components/MessageBubble.tsx | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/client/components/MessageBubble.tsx b/src/client/components/MessageBubble.tsx index 942c91b..d52c302 100644 --- a/src/client/components/MessageBubble.tsx +++ b/src/client/components/MessageBubble.tsx @@ -36,7 +36,7 @@ export function MessageBubble({ // Collapsible state for thinking blocks and tool calls/results const isCollapsible = message.category === "thinking" || message.category === "tool_call" || message.category === "tool_result"; const [collapsed, setCollapsed] = useState(isCollapsible); - const [linkCopied, setLinkCopied] = useState(false); + const [contentCopied, setContentCopied] = useState(false); const renderedHtml = useMemo(() => { // Skip expensive rendering when content is collapsed and not visible @@ -46,7 +46,7 @@ export function MessageBubble({ if (msg.category === "tool_call") { const inputHtml = msg.toolInput - ? `
${escapeHtml(msg.toolInput)}
` + ? `
${escapeHtml(tryPrettyJson(msg.toolInput))}
` : ""; const html = `
${escapeHtml(msg.toolName || "Unknown Tool")}
${inputHtml}`; return searchQuery ? highlightSearchText(html, searchQuery) : html; @@ -55,7 +55,7 @@ export function MessageBubble({ // Structured data categories: render as preformatted text, not markdown. // Avoids expensive marked.parse() on large JSON/log blobs. if (msg.category === "hook_progress" || msg.category === "file_snapshot") { - const html = `
${escapeHtml(msg.content)}
`; + const html = `
${escapeHtml(tryPrettyJson(msg.content))}
`; return searchQuery ? highlightSearchText(html, searchQuery) : html; } @@ -157,14 +157,14 @@ export function MessageBubble({ ? `${message.toolName || "Tool Call"}\n${message.toolInput || ""}` : message.content; navigator.clipboard.writeText(text).then(() => { - setLinkCopied(true); - setTimeout(() => setLinkCopied(false), 1500); + setContentCopied(true); + setTimeout(() => setContentCopied(false), 1500); }).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" title="Copy message content" > - {linkCopied ? ( + {contentCopied ? ( @@ -260,3 +260,14 @@ function formatTimestamp(ts: string): string { 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; + } +}