From 89cd598c25756361e13ab264314058754cc3e324 Mon Sep 17 00:00:00 2001
From: M57 <41645758+Hy4ri@users.noreply.github.com>
Date: Fri, 18 Sep 2026 14:32:56 +0300
Subject: [PATCH] feat: format and highlight raw JSON in tool cards (#1189)
(#1191)
---
.../m57/hermescontrol/ui/chat/ToolBubble.kt | 188 ++++++++++++------
.../ui/chat/components/MessageCards.kt | 3 +-
.../hermescontrol/ui/chat/tool/ToolJson.kt | 23 +++
app/src/main/res/values-ar/strings.xml | 3 +
app/src/main/res/values-ko/strings.xml | 3 +
app/src/main/res/values-zh/strings.xml | 3 +
app/src/main/res/values/strings.xml | 3 +
.../chat/components/SyntaxHighlightingTest.kt | 29 +++
.../ui/chat/tool/ToolJsonTest.kt | 41 ++++
9 files changed, 239 insertions(+), 57 deletions(-)
create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/components/SyntaxHighlightingTest.kt
create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolJsonTest.kt
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt
index 6b1fff67..ce0f49b6 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt
@@ -8,6 +8,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
+import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -27,6 +28,7 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
+import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Error
@@ -43,6 +45,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -57,6 +60,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
@@ -65,13 +69,21 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.m57.hermescontrol.R
+import com.m57.hermescontrol.theme.CodeTerminalBg
+import com.m57.hermescontrol.theme.CodeTerminalBorder
+import com.m57.hermescontrol.theme.CodeTerminalMuted
+import com.m57.hermescontrol.theme.CodeTerminalText
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.components.DiffViewCard
+import com.m57.hermescontrol.ui.chat.components.highlightSyntax
+import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.ui.chat.tool.ToolView
import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
/**
* Composes the collapsed summary lines for a tool card.
@@ -237,34 +249,7 @@ internal fun ToolBubble(
Spacer(modifier = Modifier.height(6.dp))
if (showRawJson) {
- // Raw JSON view — selectable + copy button
- Box {
- SelectionContainer {
- Text(
- text = message.content,
- modifier =
- Modifier
- .fillMaxWidth()
- .heightIn(max = 240.dp)
- .verticalScroll(rememberScrollState()),
- style =
- MaterialTheme.typography.bodySmall.copy(
- color = contentColor.copy(alpha = 0.8f),
- fontFamily = FontFamily.Monospace,
- fontSize = 11.sp,
- ),
- )
- }
- CopyButton(
- visible = showCopyButton,
- textToCopy = message.content,
- onCopy = { showCopyButton = false },
- modifier =
- Modifier
- .align(Alignment.TopEnd)
- .offset(x = 8.dp, y = (-8).dp),
- )
- }
+ ToolRawJsonView(rawContent = message.content)
Spacer(modifier = Modifier.height(8.dp))
Text(
@@ -310,34 +295,8 @@ internal fun ToolBubble(
.clickable(role = Role.Button) { showRawJson = true },
)
} else {
- // Unparseable content — show raw JSON, selectable
- Box {
- SelectionContainer {
- Text(
- text = message.content,
- modifier =
- Modifier
- .fillMaxWidth()
- .heightIn(max = 240.dp)
- .verticalScroll(rememberScrollState()),
- style =
- MaterialTheme.typography.bodySmall.copy(
- color = contentColor.copy(alpha = 0.8f),
- fontFamily = FontFamily.Monospace,
- fontSize = 11.sp,
- ),
- )
- }
- CopyButton(
- visible = showCopyButton,
- textToCopy = message.content,
- onCopy = { showCopyButton = false },
- modifier =
- Modifier
- .align(Alignment.TopEnd)
- .offset(x = 8.dp, y = (-8).dp),
- )
- }
+ // Unparseable content — show raw JSON
+ ToolRawJsonView(rawContent = message.content)
}
}
@@ -650,6 +609,123 @@ internal fun SecurityRiskChip(
}
}
+@Composable
+private fun ToolRawJsonView(
+ rawContent: String,
+ modifier: Modifier = Modifier,
+) {
+ var formatJson by remember { mutableStateOf(true) }
+ val formattedContent = remember(rawContent) { ToolJson.prettyPrintJson(rawContent) }
+ val isFormatDifferent = formattedContent != rawContent
+ val displayText = if (formatJson && isFormatDifferent) formattedContent else rawContent
+
+ val highlighted by produceState(
+ initialValue = remember(displayText) { AnnotatedString(displayText) },
+ key1 = displayText,
+ ) {
+ value =
+ withContext(Dispatchers.Default) {
+ highlightSyntax(displayText)
+ }
+ }
+
+ val clipboard = LocalClipboard.current
+ val scope = rememberCoroutineScope()
+ var copied by remember { mutableStateOf(false) }
+ LaunchedEffect(copied) {
+ if (copied) {
+ delay(2000)
+ copied = false
+ }
+ }
+
+ Surface(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .testTag("tool_raw_json"),
+ shape = RoundedCornerShape(8.dp),
+ color = CodeTerminalBg,
+ border = BorderStroke(1.dp, CodeTerminalBorder),
+ ) {
+ Column {
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "JSON",
+ style =
+ MaterialTheme.typography.labelSmall.copy(
+ fontWeight = FontWeight.Bold,
+ color = CodeTerminalMuted,
+ ),
+ )
+ Spacer(Modifier.weight(1f))
+ if (isFormatDifferent) {
+ Text(
+ text =
+ if (formatJson) {
+ stringResource(R.string.chat_tool_compact_json)
+ } else {
+ stringResource(R.string.chat_tool_format_json)
+ },
+ style =
+ MaterialTheme.typography.labelSmall.copy(
+ color = MaterialTheme.colorScheme.primary,
+ textDecoration = TextDecoration.Underline,
+ ),
+ modifier =
+ Modifier
+ .testTag("tool_json_format_toggle")
+ .clickable(role = Role.Button) { formatJson = !formatJson }
+ .padding(horizontal = 6.dp, vertical = 2.dp),
+ )
+ }
+ IconButton(
+ onClick = {
+ scope.launch {
+ clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, displayText)))
+ }
+ copied = true
+ },
+ modifier = Modifier.size(28.dp),
+ ) {
+ Icon(
+ imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
+ contentDescription =
+ if (copied) {
+ stringResource(R.string.content_desc_copied)
+ } else {
+ stringResource(R.string.content_desc_copy)
+ },
+ tint = CodeTerminalMuted,
+ modifier = Modifier.size(14.dp),
+ )
+ }
+ }
+ SelectionContainer {
+ Text(
+ text = highlighted,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .heightIn(max = 280.dp)
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 10.dp, vertical = 6.dp),
+ fontFamily = FontFamily.Monospace,
+ fontSize = 12.sp,
+ color = CodeTerminalText,
+ softWrap = true,
+ )
+ }
+ }
+ }
+}
+
@Composable
private fun CopyButton(
visible: Boolean,
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt
index d1702d40..a66cfb6f 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt
@@ -347,6 +347,7 @@ private val HIGHLIGHT_TOKENS =
listOf(
TokenPattern(Regex("""//[^\n]*"""), CodeComment),
TokenPattern(Regex("""/\*[\s\S]*?\*/"""), CodeComment),
+ TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*"(?=\s*:)"""), CodeKeyword),
TokenPattern(Regex(""""[^"\\]*(\\.[^"\\]*)*""""), CodeString),
TokenPattern(Regex("""'[^'\\]*(\\.[^'\\]*)*'"""), CodeString),
TokenPattern(Regex("""`[^`\\]*(\\.[^`\\]*)*`"""), CodeString),
@@ -363,7 +364,7 @@ private val HIGHLIGHT_TOKENS =
),
CodeKeyword,
),
- TokenPattern(Regex("""[{}()\[\];,.]"""), CodePunctuation),
+ TokenPattern(Regex("""[{}()\[\];:.]"""), CodePunctuation),
)
/**
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt
index 21ef8f3d..ee562d78 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt
@@ -314,4 +314,27 @@ internal object ToolJson {
return formatDurationSeconds(seconds)
}
+
+ private val PRETTY_JSON =
+ Json {
+ prettyPrint = true
+ prettyPrintIndent = " "
+ }
+
+ /**
+ * Pretty-prints a raw JSON string with 2-space indentation.
+ * Returns the original string if [raw] is not valid JSON.
+ */
+ fun prettyPrintJson(raw: String): String {
+ val trimmed = raw.trim()
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
+ return raw
+ }
+ return try {
+ val element = Json.parseToJsonElement(trimmed)
+ PRETTY_JSON.encodeToString(JsonElement.serializer(), element)
+ } catch (_: Exception) {
+ raw
+ }
+ }
}
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index dfddfbed..16eb0ee8 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -164,9 +164,12 @@
أداة
عرض ملف JSON الأولي
عرض المخرجات المعالجة
+تنسيق JSON
+ضغط JSON
محادثة جديدة
التمرير إلى الأسفل
نسخ
+تم النسخ
تعديل
%1$s رمز
%1$s رمز/ث
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 6e5a2abf..f81901cf 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -161,9 +161,12 @@
도구
Raw JSON 보기
파싱된 결과 보기
+ JSON 포맷팅
+ JSON 압축
새 채팅
맨 아래로
복사
+ 복사됨
편집
%1$s 토큰
%1$s t/s
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
index a14d975d..a47ee16a 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -164,9 +164,12 @@
工具
显示原始 JSON
显示解析输出
+ 格式化 JSON
+ 压缩 JSON
新建对话
滚动到底部
复制
+ 已复制
编辑
%1$s tok
%1$s t/s
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6769d05a..0e9eb13f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -182,9 +182,12 @@
Tool
Show Raw JSON
Show Parsed Output
+ Format JSON
+ Compact JSON
New Chat
Scroll to bottom
Copy
+ Copied
Edit
%1$s tok
%1$s t/s
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SyntaxHighlightingTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SyntaxHighlightingTest.kt
new file mode 100644
index 00000000..592c893b
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/SyntaxHighlightingTest.kt
@@ -0,0 +1,29 @@
+package com.m57.hermescontrol.ui.chat.components
+
+import com.m57.hermescontrol.theme.CodeKeyword
+import com.m57.hermescontrol.theme.CodeNumber
+import com.m57.hermescontrol.theme.CodePunctuation
+import com.m57.hermescontrol.theme.CodeString
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+
+class SyntaxHighlightingTest {
+ @Test
+ fun `highlightSyntax highlights json keys as keyword and values as string`() {
+ val json = """{"status": "running", "code": 0}"""
+ val result = highlightSyntax(json)
+
+ val styles = result.spanStyles
+ val keySpan = styles.find { it.item.color == CodeKeyword }
+ assertNotNull("JSON key should be highlighted with CodeKeyword", keySpan)
+
+ val stringSpan = styles.find { it.item.color == CodeString }
+ assertNotNull("String value should be highlighted with CodeString", stringSpan)
+
+ val numberSpan = styles.find { it.item.color == CodeNumber }
+ assertNotNull("Number value should be highlighted with CodeNumber", numberSpan)
+
+ val punctSpan = styles.find { it.item.color == CodePunctuation }
+ assertNotNull("Colon/braces should be highlighted with CodePunctuation", punctSpan)
+ }
+}
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolJsonTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolJsonTest.kt
new file mode 100644
index 00000000..bd8430bf
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolJsonTest.kt
@@ -0,0 +1,41 @@
+package com.m57.hermescontrol.ui.chat.tool
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ToolJsonTest {
+ @Test
+ fun `prettyPrintJson formats minified json object line-by-line with indentation`() {
+ val raw = """{"name":"test","count":42,"active":true}"""
+ val formatted = ToolJson.prettyPrintJson(raw)
+
+ assertTrue(formatted.contains("\n"))
+ assertTrue(formatted.contains(" \"name\": \"test\""))
+ assertTrue(formatted.contains(" \"count\": 42"))
+ assertTrue(formatted.contains(" \"active\": true"))
+ }
+
+ @Test
+ fun `prettyPrintJson formats json array with indentation`() {
+ val raw = """[{"id":1},{"id":2}]"""
+ val formatted = ToolJson.prettyPrintJson(raw)
+
+ assertTrue(formatted.contains("\n"))
+ assertTrue(formatted.contains(" {\n \"id\": 1\n }"))
+ }
+
+ @Test
+ fun `prettyPrintJson returns non-json content unchanged`() {
+ val raw = "Execution failed with exit code 1"
+ val formatted = ToolJson.prettyPrintJson(raw)
+
+ assertEquals(raw, formatted)
+ }
+
+ @Test
+ fun `prettyPrintJson returns empty and blank strings unchanged`() {
+ assertEquals("", ToolJson.prettyPrintJson(""))
+ assertEquals(" ", ToolJson.prettyPrintJson(" "))
+ }
+}