diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt index 50a248d8..7f843f42 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt @@ -56,4 +56,48 @@ object ChatInputPolicy { commands: List, usageCounts: Map, ): List = commands.sortedByDescending { usageCounts[it.lowercase()] ?: 0 } + + /** + * Extracts the active mention query (without the '@') if the cursor is currently + * positioned within an '@' mention token (e.g. "hello @res" -> "res"). + * Returns null if not in a mention context. + */ + fun extractMentionQuery( + text: String, + cursorPosition: Int, + ): String? { + if (text.isEmpty() || cursorPosition < 0 || cursorPosition > text.length) return null + val prefix = text.substring(0, cursorPosition) + val lastAt = prefix.lastIndexOf('@') + if (lastAt == -1) return null + + // Ensure '@' is at start of string or preceded by whitespace + if (lastAt > 0 && !prefix[lastAt - 1].isWhitespace()) return null + + val query = prefix.substring(lastAt + 1) + // Mention handle cannot contain whitespace + if (query.any { it.isWhitespace() }) return null + return query + } + + /** + * Inserts a selected bot handle into the TextFieldValue at the active '@' mention position. + */ + fun applyMention( + current: TextFieldValue, + botName: String, + ): TextFieldValue { + val text = current.text + val cursor = current.selection.end.coerceIn(0, text.length) + val prefix = text.substring(0, cursor) + val lastAt = prefix.lastIndexOf('@') + if (lastAt == -1) return current + + val beforeAt = text.substring(0, lastAt) + val afterCursor = text.substring(cursor) + val inserted = "@$botName " + val newText = "$beforeAt$inserted$afterCursor" + val newCursor = beforeAt.length + inserted.length + return TextFieldValue(newText, TextRange(newCursor)) + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 846b2d06..eec5e413 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -767,6 +767,7 @@ fun ChatScreen( isAgentTyping = state.isAgentTyping, isConnected = state.isConnected, commandCatalog = state.commandCatalog, + availableBots = state.availableBots, slashUsageCounts = state.slashUsageCounts, pendingAttachments = state.pendingAttachments, onCameraTap = { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 3cb9e9c7..8553a278 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -14,6 +14,7 @@ import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.ModelCapabilities import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.model.parseContextBreakdown import com.m57.hermescontrol.data.model.parseUsageSnapshot import com.m57.hermescontrol.data.remote.ApiClient @@ -304,6 +305,7 @@ data class ChatUiState( val typingEffectDelayMs: Int = 30, // Commands catalog val commandCatalog: CommandCatalog = CommandCatalog(), + val availableBots: List = emptyList(), // Per-command usage counts for the slash-autocomplete ranking (issue // #865). Empty until the local store loads; commands without recorded // usage keep their catalog order. @@ -1894,6 +1896,12 @@ class ChatViewModel( WsMethods.COMMANDS_CATALOG, onSent = { id -> trackRequest(id, WsMethods.COMMANDS_CATALOG) }, ) + // Fetch available bot profiles for @ autocomplete + val profilesResult = safeApiCall { ApiClient.hermesApi.getProfiles() } + if (profilesResult is NetworkResult.Success) { + val profiles = profilesResult.data.profiles.orEmpty() + _uiState.update { it.copy(availableBots = profiles) } + } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index 3e1fa8df..8a76b71d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -12,6 +12,7 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -60,9 +61,11 @@ import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment +import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.ws.CommandBlocklist import com.m57.hermescontrol.data.ws.CommandCatalog import com.m57.hermescontrol.ui.chat.ChatInputPolicy +import com.m57.hermescontrol.ui.common.BotAvatar /** * The chat input bar with a two-row layout: input+send on top, @@ -80,6 +83,7 @@ fun ChatInputBar( commandCatalog: CommandCatalog, slashUsageCounts: Map = emptyMap(), pendingAttachments: List = emptyList(), + availableBots: List = emptyList(), onCameraTap: () -> Unit = {}, onImageTap: () -> Unit = {}, onFileTap: () -> Unit = {}, @@ -177,6 +181,91 @@ fun ChatInputBar( } } + // Bot mention @ suggestions + val mentionQuery = + remember(inputFieldValue.text, inputFieldValue.selection.end) { + ChatInputPolicy.extractMentionQuery( + inputFieldValue.text, + inputFieldValue.selection.end, + ) + } + + androidx.compose.animation.AnimatedVisibility( + visible = mentionQuery != null && availableBots.isNotEmpty(), + enter = androidx.compose.animation.fadeIn() + androidx.compose.animation.expandVertically(), + exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(), + ) { + val filteredBots = + remember(mentionQuery, availableBots) { + if (mentionQuery == null) { + emptyList() + } else { + availableBots.filter { bot -> + bot.name.startsWith(mentionQuery, ignoreCase = true) || + bot.effectiveTitle.contains(mentionQuery, ignoreCase = true) + } + } + } + + if (filteredBots.isNotEmpty()) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + border = + BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), + ), + ) { + LazyColumn( + modifier = Modifier.heightIn(max = 200.dp), + ) { + items(filteredBots, key = { it.name }) { bot -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + onInputChange( + ChatInputPolicy.applyMention(inputFieldValue, bot.name), + ) + } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + BotAvatar( + name = bot.name, + avatar = bot.botMeta()?.avatar, + size = 28.dp, + showPresence = false, + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = "@${bot.name}", + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + if (bot.effectiveTitle != bot.name) { + Text( + text = bot.effectiveTitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + } + } + // Attachment preview chips AnimatedVisibility( visible = pendingAttachments.isNotEmpty(), diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt index 82df8f3e..02b4fdb0 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt @@ -1,5 +1,7 @@ package com.m57.hermescontrol.ui.chat +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -143,4 +145,22 @@ class ChatInputPolicyTest { ChatInputPolicy.sortSlashSuggestions(commands, mapOf("/MODEL" to 3)), ) } + + @Test + fun extractMentionQuery_variousPositions() { + assertEquals("res", ChatInputPolicy.extractMentionQuery("@res", 4)) + assertEquals("scout", ChatInputPolicy.extractMentionQuery("hey @scout", 10)) + assertEquals("", ChatInputPolicy.extractMentionQuery("ask @", 5)) + org.junit.Assert.assertNull(ChatInputPolicy.extractMentionQuery("test@example.com", 16)) + org.junit.Assert.assertNull(ChatInputPolicy.extractMentionQuery("hello world", 5)) + org.junit.Assert.assertNull(ChatInputPolicy.extractMentionQuery("@bot with spaces", 15)) + } + + @Test + fun applyMention_insertsHandleAndAdvancesCursor() { + val initial = TextFieldValue("ask @sc", selection = androidx.compose.ui.text.TextRange(7)) + val updated = ChatInputPolicy.applyMention(initial, "scout") + assertEquals("ask @scout ", updated.text) + assertEquals(11, updated.selection.end) + } }