From 199771b7982321044ba8e5be48478a3ada9eb4cf Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:10:11 +0300 Subject: [PATCH 1/2] feat(group-chat): format non-string tool outputs using ToolResultSummary When tools return structured JSON payloads (like skill_view, read_file, web_search), casting event.data["result"] to String evaluates to null. Fall back to ToolResultSummary.formatToolResultSummary to format structured tool payloads into readable text when expanded in group chat cards. --- .../ui/bots/group/GroupChatViewModel.kt | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt index 6ee05151..65bc2243 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt @@ -18,6 +18,7 @@ import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.toJsonElement +import com.m57.hermescontrol.ui.chat.tool.ToolResultSummary import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -143,6 +144,21 @@ class GroupChatViewModel( return null } + private fun extractToolOutput(data: Map?): String? { + if (data == null) return null + val rawStr = + (data["output"] as? String) + ?: (data["stdout"] as? String) + ?: (data["result"] as? String) + if (!rawStr.isNullOrBlank()) { + return rawStr + } + + val resultElement = data["result"]?.toJsonElement() ?: data["output"]?.toJsonElement() ?: data.toJsonElement() + val summary = ToolResultSummary.formatToolResultSummary(resultElement) + return summary.ifBlank { null } + } + private fun observeWsEvents() { viewModelScope.launch(ioDispatcher) { HermesWsClient.events.collect { event -> @@ -241,10 +257,7 @@ class GroupChatViewModel( val streamId = activeStreamMsgId[sid] val toolId = event.data?.get("tool_id") as? String val toolName = event.name - val output = - (event.data?.get("output") as? String) - ?: (event.data?.get("stdout") as? String) - ?: (event.data?.get("result") as? String) + val output = extractToolOutput(event.data) val exitCode = when (val rawCode = event.data?.get("exit_code")) { is Number -> rawCode.toInt() From 5ce01e429b421447e197a16b4c9a700d32539e3b Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:22:22 +0300 Subject: [PATCH 2/2] fix(bidi): isolate leading and inline English words in RTL paragraphs - In parseInlineSource, wrap consecutive Latin/LTR tokens in LTR isolates (LRI...PDI) when rendering inside an RTL block, preventing leading English terms like "Okay" from flipping paragraph layout direction and causing BiDi line shuffling. - Switch textDirection from TextDirection.ContentOrRtl to explicit TextDirection.Rtl when isRtl is true across MarkdownText, ChatBubble, and GroupChatScreen, preventing first-strong character fallbacks from overriding resolved paragraph direction. --- .../ui/bots/group/GroupChatScreen.kt | 20 +++++------ .../m57/hermescontrol/ui/chat/ChatBubble.kt | 4 +-- .../m57/hermescontrol/ui/chat/MarkdownText.kt | 33 +++++++++++++++++-- .../ui/chat/MarkdownTextFeatureTest.kt | 8 +++++ 4 files changed, 50 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt index 7c95c70f..948ed979 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt @@ -292,9 +292,9 @@ fun GroupChatScreen( MaterialTheme.typography.bodySmall.copy( textDirection = if (isThinkingRtl) { - TextDirection.ContentOrRtl + TextDirection.Rtl } else { - TextDirection.ContentOrLtr + TextDirection.Ltr }, ), color = MaterialTheme.colorScheme.primary, @@ -914,9 +914,9 @@ private fun GroupMessageCard( MaterialTheme.typography.labelSmall.copy( textDirection = if (isSystemRtl) { - TextDirection.ContentOrRtl + TextDirection.Rtl } else { - TextDirection.ContentOrLtr + TextDirection.Ltr }, ), color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -946,9 +946,9 @@ private fun GroupMessageCard( MaterialTheme.typography.bodyMedium.copy( textDirection = if (isUserRtl) { - TextDirection.ContentOrRtl + TextDirection.Rtl } else { - TextDirection.ContentOrLtr + TextDirection.Ltr }, ), color = MaterialTheme.colorScheme.onPrimary, @@ -1017,9 +1017,9 @@ private fun GroupMessageCard( MaterialTheme.typography.labelMedium.copy( textDirection = if (isBotNameRtl) { - TextDirection.ContentOrRtl + TextDirection.Rtl } else { - TextDirection.ContentOrLtr + TextDirection.Ltr }, ), fontWeight = FontWeight.Bold, @@ -1168,7 +1168,7 @@ private fun GroupChatToolChip( text = label, style = MaterialTheme.typography.labelSmall.copy( - textDirection = if (isLabelRtl) TextDirection.ContentOrRtl else TextDirection.ContentOrLtr, + textDirection = if (isLabelRtl) TextDirection.Rtl else TextDirection.Ltr, ), color = MaterialTheme.colorScheme.onSurface, maxLines = 1, @@ -1246,7 +1246,7 @@ private fun GroupChatToolChip( fontFamily = FontFamily.Monospace, textDirection = if (isOutputRtl) { - TextDirection.ContentOrRtl + TextDirection.Rtl } else { TextDirection.Ltr }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 67c2552e..779788d2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -197,9 +197,9 @@ fun ChatBubble( MaterialTheme.typography.bodyMedium.copy( textDirection = if (isRtl) { - TextDirection.ContentOrRtl + TextDirection.Rtl } else { - TextDirection.ContentOrLtr + TextDirection.Ltr }, ), ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index 2b8b701e..71d8c628 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -71,8 +71,7 @@ private val BULLET_LINE_RE = Regex("""^(\s*)[-*+]\s+(.*)$""") private val ORDERED_LINE_RE = Regex("""^(\s*)(\d+)\.\s+(.*)$""") private val LIST_ITEM_LINE_RE = Regex("""^(\s*)(?:[-*+]\s+(?:\[([ xX])\]\s+)?|(\d+)\.\s+)(.*)$""") -private fun bidiTextDirection(isRtl: Boolean): TextDirection = - if (isRtl) TextDirection.ContentOrRtl else TextDirection.ContentOrLtr +private fun bidiTextDirection(isRtl: Boolean): TextDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr /** * Renders chat assistant text as Markdown — but ONLY once the message has finished streaming. @@ -551,7 +550,7 @@ private fun MarkdownInlineText( val direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current val resolvedStyle = style.copy( - textDirection = if (isRtl) TextDirection.ContentOrRtl else TextDirection.ContentOrLtr, + textDirection = if (isRtl) TextDirection.Rtl else TextDirection.Ltr, ) val processedText = remember(text, isRtl) { @@ -1499,6 +1498,34 @@ private fun parseInlineSource( i = match.range.last + 1 } + // Plain text / words: when inside RTL text, wrap sequences of Latin/LTR words in LTR isolate + // so an English word at the start/middle of an Arabic line doesn't flip the layout direction. + isRtl && Character.isLetterOrDigit(src.codePointAt(i)) && + Character.getDirectionality(src.codePointAt(i)) == Character.DIRECTIONALITY_LEFT_TO_RIGHT -> { + var end = i + Character.charCount(src.codePointAt(i)) + while (end < src.length) { + val cp = src.codePointAt(end) + val dir = Character.getDirectionality(cp) + if (dir == Character.DIRECTIONALITY_LEFT_TO_RIGHT || + dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER || + dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_SEPARATOR || + dir == Character.DIRECTIONALITY_EUROPEAN_NUMBER_TERMINATOR || + ( + dir == Character.DIRECTIONALITY_WHITESPACE && end + 1 < src.length && + Character.getDirectionality(src.codePointAt(end + 1)) == + Character.DIRECTIONALITY_LEFT_TO_RIGHT + ) + ) { + end += Character.charCount(cp) + } else { + break + } + } + val ltrSnippet = src.substring(i, end) + append(BidiUtils.wrapLtrIsolate(ltrSnippet)) + i = end + } + // search highlight searchQuery.isNotEmpty() && src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt index ac37ca12..c531107b 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt @@ -563,4 +563,12 @@ class MarkdownTextFeatureTest { val parsed = parseInline(input, Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS) assertEquals("Run val x = 1 here", parsed.toString()) } + + @Test + fun testArabicStartingWithEnglishWord_wrapsEnglishInLtrIsolate() { + val input = "Okay سكرت كلشي وصار كامل عربي متل ما بدك يا بوبو" + val parsed = parseInline(input, Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS) + val expected = "${BidiUtils.LRI}Okay${BidiUtils.PDI} سكرت كلشي وصار كامل عربي متل ما بدك يا بوبو" + assertEquals(expected, parsed.toString()) + } }