From 4f14ecac4b19448e1176cedc71f5b26ce1df1927 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:01:13 +0300 Subject: [PATCH] fix(#1368): highlight chat search hits in inline spans, code blocks and reasoning (#1372) --- .../m57/hermescontrol/ui/chat/MarkdownText.kt | 2 + .../ui/chat/components/CodeBlockCard.kt | 39 ++++++++++++++++++- .../chat/fullbleed/FullBleedAgentMessage.kt | 6 +++ .../ui/chat/fullbleed/FullBleedChatList.kt | 18 +++++++++ .../ui/chat/fullbleed/FullBleedTurns.kt | 9 ++++- .../ui/chat/markdown/MarkdownInlineStyler.kt | 37 ++++++++++++++---- .../ui/chat/FullBleedItemKeysTest.kt | 16 ++++++++ 7 files changed, 118 insertions(+), 9 deletions(-) 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 5195c84e..6c91c1d9 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 @@ -111,6 +111,8 @@ fun MarkdownText( code = block.code, language = block.language, onCopy = { /* clipboard handled internally */ }, + searchQuery = searchQuery, + isCurrentMatch = isCurrentMatch, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeBlockCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeBlockCard.kt index 38f6ac92..649228b2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeBlockCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeBlockCard.kt @@ -10,10 +10,16 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.m57.hermescontrol.theme.CodeTerminalText +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.theme.SearchHighlightColors +import com.m57.hermescontrol.theme.searchHighlightColors import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -27,7 +33,10 @@ fun CodeBlockCard( language: String?, onCopy: (String) -> Unit, modifier: Modifier = Modifier, + searchQuery: String = "", + isCurrentMatch: Boolean = false, ) { + val highlights = searchHighlightColors(LocalHermesStatusColors.current) val highlighted by produceState( initialValue = remember(code) { AnnotatedString(code) }, key1 = code, @@ -47,7 +56,10 @@ fun CodeBlockCard( copyContentDescription = "Copy code", ) { Text( - text = highlighted, + text = + remember(highlighted, searchQuery, isCurrentMatch, highlights) { + highlighted.withSearchHighlights(searchQuery, isCurrentMatch, highlights) + }, fontFamily = FontFamily.Monospace, fontSize = 13.sp, color = CodeTerminalText, @@ -59,3 +71,28 @@ fun CodeBlockCard( ) } } + +/** Overlay search-hit spans on an already syntax-highlighted string, keeping its existing styles. */ +private fun AnnotatedString.withSearchHighlights( + query: String, + isCurrent: Boolean, + highlights: SearchHighlightColors, +): AnnotatedString { + if (query.isEmpty()) return this + val (bg, fg) = + if (isCurrent) { + highlights.currentSearchBackground to highlights.currentSearchForeground + } else { + highlights.searchBackground to highlights.searchForeground + } + return buildAnnotatedString { + append(this@withSearchHighlights) + var from = 0 + while (true) { + val hit = text.indexOf(query, from, ignoreCase = true) + if (hit < 0) break + addStyle(SpanStyle(background = bg, color = fg), hit, hit + query.length) + from = hit + query.length + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index ec21135c..3494f339 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -60,6 +60,9 @@ internal fun FullBleedAgentMessage( message: ChatMessage, searchQuery: String = "", isCurrentMatch: Boolean = false, + reasoningSearchQuery: String = "", + isCurrentReasoningMatch: Boolean = false, + reasoningSearchOffset: Int = 0, showReasoning: Boolean = true, onOpenAttachment: (Attachment) -> Unit = {}, onSaveAttachment: (Attachment) -> Unit = {}, @@ -91,6 +94,9 @@ internal fun FullBleedAgentMessage( ReasoningCard( reasoningText = message.reasoningText, isStreaming = message.isStreaming, + searchQuery = reasoningSearchQuery, + isCurrentMatch = isCurrentReasoningMatch, + searchOffset = reasoningSearchOffset, ) Spacer(modifier = Modifier.height(6.dp)) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt index eca2f7a0..126087a8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt @@ -426,6 +426,24 @@ fun FullBleedChatList( searchState.currentIndex, ) == SearchTarget.CONTENT, + reasoningSearchQuery = + if (searchState.isActive && + proseMessage.id in searchState.matchedIds + ) { + searchState.query + } else { + "" + }, + isCurrentReasoningMatch = + searchState.currentMatchId == proseMessage.id && + searchState.matchTargets.getOrNull( + searchState.currentIndex, + ) == + SearchTarget.REASONING, + reasoningSearchOffset = + searchState.matchOffsets.getOrElse( + searchState.currentIndex, + ) { 0 }, showReasoning = !hoistedReasoning, onOpenAttachment = actions.onOpenAttachment, onSaveAttachment = actions.onSaveAttachment, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt index 74c96e32..d8841774 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedTurns.kt @@ -197,7 +197,14 @@ fun searchMatchToLazyIndex( SearchTarget.REASONING -> "reasoning" SearchTarget.TOOL -> "tool" } - return fullBleedItemKeys(turns).indexOf("$prefix-${message.id}").takeIf { it >= 0 } + val keys = fullBleedItemKeys(turns) + keys.indexOf("$prefix-${message.id}").takeIf { it >= 0 }?.let { return it } + // Non-hoisted reasoning renders inside its message's prose row, not as its own row. + return if (match.target == SearchTarget.REASONING) { + keys.indexOf("prose-${message.id}").takeIf { it >= 0 } + } else { + null + } } /** Lazy row identities, including hoisted reasoning and grouped tool/system entries. */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt index 97cc6c41..eb0e6fe5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineStyler.kt @@ -102,7 +102,7 @@ object MarkdownInlineStyler { background = textColor.copy(alpha = 0.08f), ), ) { - append(content) + appendSearchable(content, searchQuery, searchHighlightColor) } i = matchingEnd + runLength } else { @@ -118,7 +118,7 @@ object MarkdownInlineStyler { val raw = src.substring(i + 3, end) val content = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) { - append(content) + appendSearchable(content, searchQuery, searchHighlightColor) } i = end + 3 } else { @@ -161,7 +161,7 @@ object MarkdownInlineStyler { val raw = src.substring(i + 2, end) val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { - append(toAppend) + appendSearchable(toAppend, searchQuery, searchHighlightColor) } i = end + 2 } else { @@ -201,7 +201,7 @@ object MarkdownInlineStyler { val raw = src.substring(i + 2, end) val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw withStyle(SpanStyle(background = highlights.markupBackground)) { - append(toAppend) + appendSearchable(toAppend, searchQuery, searchHighlightColor) } i = end + 2 } else { @@ -215,7 +215,7 @@ object MarkdownInlineStyler { val end = src.indexOf('^', i + 1) if (end != -1 && end > i + 1) { withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) { - append(src.substring(i + 1, end)) + appendSearchable(src.substring(i + 1, end), searchQuery, searchHighlightColor) } i = end + 1 } else { @@ -229,7 +229,7 @@ object MarkdownInlineStyler { val end = src.indexOf('~', i + 1) if (end != -1 && end > i + 1) { withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) { - append(src.substring(i + 1, end)) + appendSearchable(src.substring(i + 1, end), searchQuery, searchHighlightColor) } i = end + 1 } else { @@ -250,7 +250,7 @@ object MarkdownInlineStyler { background = textColor.copy(alpha = 0.12f), ), ) { - append(toAppend) + appendSearchable(toAppend, searchQuery, searchHighlightColor) } i = end + 6 } else { @@ -382,4 +382,27 @@ object MarkdownInlineStyler { } private fun isRtr(isRtl: Boolean): Boolean = isRtl + + /** Append [text], highlighting every [query] hit so styled spans (inline code, sub/sup, ...) stay searchable. */ + private fun AnnotatedString.Builder.appendSearchable( + text: String, + query: String, + highlight: Pair, + ) { + if (query.isEmpty()) { + append(text) + return + } + var from = 0 + while (from < text.length) { + val hit = text.indexOf(query, from, ignoreCase = true) + if (hit < 0) break + append(text.substring(from, hit)) + withStyle(SpanStyle(background = highlight.first, color = highlight.second)) { + append(text.substring(hit, hit + query.length)) + } + from = hit + query.length + } + append(text.substring(from)) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt index 31834295..1f4f08bf 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/FullBleedItemKeysTest.kt @@ -40,6 +40,22 @@ class FullBleedItemKeysTest { assertEquals(3, searchMatchToLazyIndex(turns, messages, SearchMatch(2, 0))) } + @Test + fun secondaryReasoningResolvesToItsProseRow() { + val messages = + listOf( + ChatMessage("a1", MessageRole.ASSISTANT, "one", reasoningText = "first"), + ChatMessage("a2", MessageRole.ASSISTANT, "two", reasoningText = "second"), + ) + val turns = groupIntoTurns(messages) + val keys = fullBleedItemKeys(turns) + + assertEquals( + keys.indexOf("prose-a2"), + searchMatchToLazyIndex(turns, messages, SearchMatch(1, 0, SearchTarget.REASONING)), + ) + } + @Test fun toolAnchorSurvivesPrependThatExtendsItsAgentTurn() { val tool = ChatMessage("live-tool", MessageRole.TOOL, "output")