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(" ")) + } +}