This commit is contained in:
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user