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

This commit is contained in:
m57 authored and GitHub committed 2026-09-29 01:01:13 +03:00
1 parent c7134832c9
commit 4f14ecac4b
7 files changed
+118 -9

No files matched your search

@@ -111,6 +111,8 @@ fun MarkdownText(
code = block.code,
language = block.language,
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.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
}
}
}
@@ -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))
}
@@ -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,
@@ -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. */
@@ -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<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))
}
}