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 8e09d120..5aa93e35 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 @@ -630,12 +630,23 @@ private fun ToolRawJsonView( 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 clampedRaw = remember(rawContent) { ToolJson.clampForDisplay(rawContent) } + + // Full pretty text (for copy) + clamped copy (for display); null when formatting changes nothing. + val formatted by produceState?>(initialValue = null, key1 = rawContent) { + value = + withContext(Dispatchers.Default) { + val full = ToolJson.prettyPrintJson(rawContent) + if (full != rawContent) full to ToolJson.clampForDisplay(full) else null + } + } + + val isFormatDifferent = formatted != null + val displayText = formatted?.takeIf { formatJson }?.second ?: clampedRaw + val copyText = formatted?.takeIf { formatJson }?.first ?: rawContent val highlighted by produceState( - initialValue = remember(displayText) { AnnotatedString(displayText) }, + initialValue = remember(clampedRaw) { AnnotatedString(clampedRaw) }, key1 = displayText, ) { value = @@ -645,7 +656,7 @@ private fun ToolRawJsonView( } CodeTerminalCard( - textToCopy = displayText, + textToCopy = copyText, modifier = modifier, testTag = "tool_raw_json", title = "JSON", diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCounts.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCounts.kt index fc0fd5be..dbd48a6a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCounts.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCounts.kt @@ -84,6 +84,23 @@ internal object ToolCounts { "sources" to "source", ) + private val S_ENDING_NOUN_REGEX = Regex("(xes|zes|ches|shes|sses)$") + private val CONSONANT_Y_NOUN_REGEX = Regex("[aeiou]y$", RegexOption.IGNORE_CASE) + private val SIBILANT_NOUN_REGEX = Regex("(s|x|z|ch|sh)$", RegexOption.IGNORE_CASE) + private val COUNT_OR_TOTAL_KEY_REGEX = Regex("_count$|_total$") + private val COUNT_OR_TOTAL_STRIP_REGEX = Regex("_(count|total)$") + private val NUM_PREFIX_STRIP_REGEX = Regex("^num_") + private val UNIT_COUNT_REGEX = + Regex( + """\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b""", + RegexOption.IGNORE_CASE, + ) + private val VERB_COUNT_REGEX = + Regex( + """\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b""", + RegexOption.IGNORE_CASE, + ) + private fun countFromUnknown(value: JsonElement?): Int? { if (value is JsonArray) { return if (value.isNotEmpty()) value.size else null @@ -109,7 +126,7 @@ internal object ToolCounts { return "${normalized.dropLast(3)}y" } - if (Regex("(xes|zes|ches|shes|sses)$").containsMatchIn(normalized) && normalized.length > 3) { + if (S_ENDING_NOUN_REGEX.containsMatchIn(normalized) && normalized.length > 3) { return normalized.dropLast(2) } @@ -134,12 +151,12 @@ internal object ToolCounts { if (noun.endsWith( "y", - ) && noun.length > 1 && !Regex("[aeiou]y$", RegexOption.IGNORE_CASE).containsMatchIn(noun) + ) && noun.length > 1 && !CONSONANT_Y_NOUN_REGEX.containsMatchIn(noun) ) { return "${noun.dropLast(1)}ies" } - if (Regex("(s|x|z|ch|sh)$", RegexOption.IGNORE_CASE).containsMatchIn(noun)) { + if (SIBILANT_NOUN_REGEX.containsMatchIn(noun)) { return "${noun}es" } @@ -171,12 +188,12 @@ internal object ToolCounts { if (key in COUNT_EXCLUDED_KEYS) { continue } - if (!Regex("_count$|_total$").containsMatchIn(key)) { + if (!COUNT_OR_TOTAL_KEY_REGEX.containsMatchIn(key)) { continue } val count = countFromUnknown(value) ?: continue - val stripped = key.lowercase().replace(Regex("_(count|total)$"), "").replace(Regex("^num_"), "") + val stripped = key.lowercase().replace(COUNT_OR_TOTAL_STRIP_REGEX, "").replace(NUM_PREFIX_STRIP_REGEX, "") return countMetric(count, singularizeNoun(stripped).ifEmpty { fallbackNoun }) } @@ -194,14 +211,8 @@ internal object ToolCounts { } val unitMatch = - Regex( - """\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b""", - RegexOption.IGNORE_CASE, - ).find(t) - ?: Regex( - """\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b""", - RegexOption.IGNORE_CASE, - ).find(t) + UNIT_COUNT_REGEX.find(t) + ?: VERB_COUNT_REGEX.find(t) val n = unitMatch?.groupValues?.get(1)?.toIntOrNull() ?: return null val noun = unitMatch.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: fallbackNoun diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummary.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummary.kt index 24f5ee9f..2535397a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummary.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummary.kt @@ -49,6 +49,14 @@ object ToolResultSummary { private val ERROR_MSG_KEYS = listOf("message", "reason", "detail") private val NON_ERROR_TEXT = setOf("", "0", "false", "none", "null", "nil", "ok", "success", "n/a", "na") + private val KEY_SPLIT_REGEX = Regex("[_\\-.]+") + private val WHITESPACE_REGEX = Regex("\\s+") + private val ERROR_SIGNAL_STATUS_REGEX = + Regex( + "\\b(error|failed|failure|fatal|exception)\\b", + RegexOption.IGNORE_CASE, + ) + // ── helpers ────────────────────────────────────────────────────────── private fun normalize(value: String): String = value.lowercase().trim() @@ -58,7 +66,7 @@ object ToolResultSummary { private fun titleCase(key: String): String = key - .split(Regex("[_\\-.]+")) + .split(KEY_SPLIT_REGEX) .filter { it.isNotEmpty() } .joinToString(" ") { capitalize(it) } @@ -72,7 +80,7 @@ object ToolResultSummary { value: String, max: Int = 180, ): String { - val c = value.replace(Regex("\\s+"), " ").trim() + val c = value.replace(WHITESPACE_REGEX, " ").trim() return if (c.length > max) "${c.take(max - 1)}…" else c } @@ -456,10 +464,7 @@ object ToolResultSummary { return ( (record["success"] as? JsonPrimitive)?.let { !it.isString && it.content == "false" } == true || (record["ok"] as? JsonPrimitive)?.let { !it.isString && it.content == "false" } == true || - Regex( - "\\b(error|failed|failure|fatal|exception)\\b", - RegexOption.IGNORE_CASE, - ).containsMatchIn(status) || + ERROR_SIGNAL_STATUS_REGEX.containsMatchIn(status) || ERROR_KEYS.any { k -> hasMeaningfulErrorValue(record[k]) } ) }