fix(#1368): highlight chat search hits in inline spans, code blocks and reasoning (#1372)

This commit is contained in:
m57
2026-09-29 01:01:13 +03:00
committed by GitHub
parent c7134832c9
commit 4f14ecac4b
7 changed files with 118 additions and 9 deletions
@@ -111,6 +111,8 @@ fun MarkdownText(
code = block.code, code = block.code,
language = block.language, language = block.language,
onCopy = { /* clipboard handled internally */ }, onCopy = { /* clipboard handled internally */ },
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
) )
} }
@@ -10,10 +10,16 @@ import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString 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.font.FontFamily
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.theme.CodeTerminalText 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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -27,7 +33,10 @@ fun CodeBlockCard(
language: String?, language: String?,
onCopy: (String) -> Unit, onCopy: (String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
searchQuery: String = "",
isCurrentMatch: Boolean = false,
) { ) {
val highlights = searchHighlightColors(LocalHermesStatusColors.current)
val highlighted by produceState( val highlighted by produceState(
initialValue = remember(code) { AnnotatedString(code) }, initialValue = remember(code) { AnnotatedString(code) },
key1 = code, key1 = code,
@@ -47,7 +56,10 @@ fun CodeBlockCard(
copyContentDescription = "Copy code", copyContentDescription = "Copy code",
) { ) {
Text( Text(
text = highlighted, text =
remember(highlighted, searchQuery, isCurrentMatch, highlights) {
highlighted.withSearchHighlights(searchQuery, isCurrentMatch, highlights)
},
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 13.sp, fontSize = 13.sp,
color = CodeTerminalText, 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
}
}
}
@@ -60,6 +60,9 @@ internal fun FullBleedAgentMessage(
message: ChatMessage, message: ChatMessage,
searchQuery: String = "", searchQuery: String = "",
isCurrentMatch: Boolean = false, isCurrentMatch: Boolean = false,
reasoningSearchQuery: String = "",
isCurrentReasoningMatch: Boolean = false,
reasoningSearchOffset: Int = 0,
showReasoning: Boolean = true, showReasoning: Boolean = true,
onOpenAttachment: (Attachment) -> Unit = {}, onOpenAttachment: (Attachment) -> Unit = {},
onSaveAttachment: (Attachment) -> Unit = {}, onSaveAttachment: (Attachment) -> Unit = {},
@@ -91,6 +94,9 @@ internal fun FullBleedAgentMessage(
ReasoningCard( ReasoningCard(
reasoningText = message.reasoningText, reasoningText = message.reasoningText,
isStreaming = message.isStreaming, isStreaming = message.isStreaming,
searchQuery = reasoningSearchQuery,
isCurrentMatch = isCurrentReasoningMatch,
searchOffset = reasoningSearchOffset,
) )
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
} }
@@ -426,6 +426,24 @@ fun FullBleedChatList(
searchState.currentIndex, searchState.currentIndex,
) == ) ==
SearchTarget.CONTENT, 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, showReasoning = !hoistedReasoning,
onOpenAttachment = actions.onOpenAttachment, onOpenAttachment = actions.onOpenAttachment,
onSaveAttachment = actions.onSaveAttachment, onSaveAttachment = actions.onSaveAttachment,
@@ -197,7 +197,14 @@ fun searchMatchToLazyIndex(
SearchTarget.REASONING -> "reasoning" SearchTarget.REASONING -> "reasoning"
SearchTarget.TOOL -> "tool" 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. */ /** Lazy row identities, including hoisted reasoning and grouped tool/system entries. */
@@ -102,7 +102,7 @@ object MarkdownInlineStyler {
background = textColor.copy(alpha = 0.08f), background = textColor.copy(alpha = 0.08f),
), ),
) { ) {
append(content) appendSearchable(content, searchQuery, searchHighlightColor)
} }
i = matchingEnd + runLength i = matchingEnd + runLength
} else { } else {
@@ -118,7 +118,7 @@ object MarkdownInlineStyler {
val raw = src.substring(i + 3, end) val raw = src.substring(i + 3, end)
val content = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw val content = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) { withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
append(content) appendSearchable(content, searchQuery, searchHighlightColor)
} }
i = end + 3 i = end + 3
} else { } else {
@@ -161,7 +161,7 @@ object MarkdownInlineStyler {
val raw = src.substring(i + 2, end) val raw = src.substring(i + 2, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
append(toAppend) appendSearchable(toAppend, searchQuery, searchHighlightColor)
} }
i = end + 2 i = end + 2
} else { } else {
@@ -201,7 +201,7 @@ object MarkdownInlineStyler {
val raw = src.substring(i + 2, end) val raw = src.substring(i + 2, end)
val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw val toAppend = if (isRtl) BidiUtils.wrapLtrIsolate(raw) else raw
withStyle(SpanStyle(background = highlights.markupBackground)) { withStyle(SpanStyle(background = highlights.markupBackground)) {
append(toAppend) appendSearchable(toAppend, searchQuery, searchHighlightColor)
} }
i = end + 2 i = end + 2
} else { } else {
@@ -215,7 +215,7 @@ object MarkdownInlineStyler {
val end = src.indexOf('^', i + 1) val end = src.indexOf('^', i + 1)
if (end != -1 && end > i + 1) { if (end != -1 && end > i + 1) {
withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) { withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) {
append(src.substring(i + 1, end)) appendSearchable(src.substring(i + 1, end), searchQuery, searchHighlightColor)
} }
i = end + 1 i = end + 1
} else { } else {
@@ -229,7 +229,7 @@ object MarkdownInlineStyler {
val end = src.indexOf('~', i + 1) val end = src.indexOf('~', i + 1)
if (end != -1 && end > i + 1) { if (end != -1 && end > i + 1) {
withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) { withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) {
append(src.substring(i + 1, end)) appendSearchable(src.substring(i + 1, end), searchQuery, searchHighlightColor)
} }
i = end + 1 i = end + 1
} else { } else {
@@ -250,7 +250,7 @@ object MarkdownInlineStyler {
background = textColor.copy(alpha = 0.12f), background = textColor.copy(alpha = 0.12f),
), ),
) { ) {
append(toAppend) appendSearchable(toAppend, searchQuery, searchHighlightColor)
} }
i = end + 6 i = end + 6
} else { } else {
@@ -382,4 +382,27 @@ object MarkdownInlineStyler {
} }
private fun isRtr(isRtl: Boolean): Boolean = isRtl 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<Color, Color>,
) {
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))
}
} }
@@ -40,6 +40,22 @@ class FullBleedItemKeysTest {
assertEquals(3, searchMatchToLazyIndex(turns, messages, SearchMatch(2, 0))) 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 @Test
fun toolAnchorSurvivesPrependThatExtendsItsAgentTurn() { fun toolAnchorSurvivesPrependThatExtendsItsAgentTurn() {
val tool = ChatMessage("live-tool", MessageRole.TOOL, "output") val tool = ChatMessage("live-tool", MessageRole.TOOL, "output")