feat: format and highlight raw JSON in tool cards (#1189) (#1191)

This commit is contained in:
M57 authored and GitHub committed 2026-09-18 14:32:56 +03:00
1 parent 7290b300ab
commit 89cd598c25
9 files changed
+239 -57

No files matched your search

@@ -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,
@@ -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),
)
/**
@@ -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
}
}
}