diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/SlashUsageStore.kt b/app/src/main/java/com/m57/hermescontrol/data/local/SlashUsageStore.kt new file mode 100644 index 00000000..9f6ac20c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/local/SlashUsageStore.kt @@ -0,0 +1,94 @@ +package com.m57.hermescontrol.data.local + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.Serializer +import androidx.datastore.dataStore +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import java.io.InputStream +import java.io.OutputStream + +/** + * Disk-backed per-command usage counts for the slash-autocomplete ranking + * (issue #865). + * + * The backend's `commands.catalog` carries no usage data — the usage-ranked + * ordering exists only in the desktop/TUI completion path. Mobile keeps its + * own local counts (keyed by command name, lowercase, WITH leading slash, + * e.g. "/model") and ranks the suggestion menu by them. + */ +@Serializable +private data class SlashUsageState( + // Command name (lowercase, WITH leading slash, e.g. "/model") → count. + val counts: Map = emptyMap(), +) + +private object SlashUsageSerializer : Serializer { + override val defaultValue: SlashUsageState = SlashUsageState() + + override suspend fun readFrom(input: InputStream): SlashUsageState = + try { + // Use the app's wire Json (ignoreUnknownKeys=true) so a future + // schema addition can't break the on-disk decode. + OkHttpProvider.json.decodeFromString( + SlashUsageState.serializer(), + input.readBytes().decodeToString(), + ) + } catch (e: Exception) { + defaultValue + } + + override suspend fun writeTo( + t: SlashUsageState, + output: OutputStream, + ) { + output.write( + OkHttpProvider.json + .encodeToString(SlashUsageState.serializer(), t) + .toByteArray(), + ) + } +} + +private val Context.slashUsageDataStore: DataStore by dataStore( + fileName = "slash_usage.json", + serializer = SlashUsageSerializer, +) + +/** + * Stores how often each slash command was dispatched so the autocomplete can + * surface most-used commands first. Best-effort: read/write failures degrade + * to the empty map / a dropped write — never a crash. + */ +open class SlashUsageStore( + private val context: Context, +) { + /** Current usage counts; empty map when nothing is recorded yet. */ + open fun counts(): Flow> = + context.slashUsageDataStore.data + .catch { emit(SlashUsageState()) } + .map { it.counts } + + /** Bump the count for [command] (e.g. "/model"). Never throws. */ + open suspend fun recordUse(command: String) { + withContext(Dispatchers.IO) { + try { + context.slashUsageDataStore.updateData { state -> + state.copy( + counts = + state.counts + + (command to ((state.counts[command] ?: 0) + 1)), + ) + } + } catch (e: Exception) { + // Best-effort: a failed write must never block the dispatch. + } + } + } +} 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 9ff199db..50a248d8 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 @@ -45,4 +45,15 @@ object ChatInputPolicy { * selection. */ fun commandFieldValue(command: String): TextFieldValue = TextFieldValue(command, TextRange(command.length)) + + /** + * Rank slash-command suggestions by how often the user has dispatched them + * (issue #865): most-used first, ties broken by the current (catalog) + * order via the stable sort. Commands with no recorded usage keep their + * catalog position, so a fresh install behaves exactly as before. + */ + fun sortSlashSuggestions( + commands: List, + usageCounts: Map, + ): List = commands.sortedByDescending { usageCounts[it.lowercase()] ?: 0 } } 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 91439dc8..13a6c615 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 @@ -681,6 +681,7 @@ fun ChatScreen( isAgentTyping = state.isAgentTyping, isConnected = state.isConnected, commandCatalog = state.commandCatalog, + slashUsageCounts = state.slashUsageCounts, pendingAttachments = state.pendingAttachments, onCameraTap = { try { 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 d9341ade..88a1feb6 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 @@ -9,6 +9,7 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.HermesDatabase +import com.m57.hermescontrol.data.local.SlashUsageStore import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.AttachmentSource import com.m57.hermescontrol.data.model.ModelProvider @@ -274,6 +275,10 @@ data class ChatUiState( val typingEffectDelayMs: Int = 30, // Commands catalog val commandCatalog: CommandCatalog = CommandCatalog(), + // 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. + val slashUsageCounts: Map = emptyMap(), // In-session model picker (issue #589) — surfaced when the user types /model // (or taps the top-bar model chip). Mirror of the global model screen's // picker, but the selection hot-swaps the CURRENT session via the slash path. @@ -378,6 +383,7 @@ class ChatViewModel( ChatPersistenceRepository( HermesDatabase.get(application).chatMessageDao(), ), + slashUsageStore: SlashUsageStore = SlashUsageStore(application.applicationContext), searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default, private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO, ) : AndroidViewModel(application) { @@ -456,6 +462,7 @@ class ChatViewModel( // ── Session persistence ────────────────────────────────────────────── private val repo: ChatPersistenceRepository = repo + private val slashUsageStore: SlashUsageStore = slashUsageStore private val slashDispatcher = SlashCommandDispatcher() /** @@ -565,6 +572,14 @@ class ChatViewModel( resetSessionState(sessionId = null, title = "Hermes", isLoading = true) } } + // Slash-command usage ranking (issue #865): mirror the local usage + // counts into state so the autocomplete can surface most-used + // commands first. Best-effort — the store never throws. + viewModelScope.launch { + slashUsageStore.counts().collect { counts -> + _uiState.update { it.copy(slashUsageCounts = counts) } + } + } if (wsClient.connectionStatus.value == ConnectionStatus.CONNECTED) { handleGatewayReady() } @@ -1390,6 +1405,15 @@ class ChatViewModel( return } + // Track per-command usage for the slash-autocomplete ranking (issue + // #865). Counted here, AFTER the blocklist guard, so commands that + // can never dispatch don't climb the ranking. Best-effort — a store + // failure must never block the dispatch itself. + val commandName = command.split(" ", limit = 2)[0].lowercase() + viewModelScope.launch { + slashUsageStore.recordUse(commandName) + } + when (val result = slashDispatcher.dispatch(command)) { is SlashResult.Interrupt -> { interruptSession() 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 3e30bdce..bb1240a7 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 @@ -78,6 +78,7 @@ fun ChatInputBar( isAgentTyping: Boolean, isConnected: Boolean, commandCatalog: CommandCatalog, + slashUsageCounts: Map = emptyMap(), pendingAttachments: List = emptyList(), onCameraTap: () -> Unit = {}, onImageTap: () -> Unit = {}, @@ -136,7 +137,10 @@ fun ChatInputBar( exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(), ) { val filteredCommands = - commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) } + ChatInputPolicy.sortSlashSuggestions( + commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) }, + slashUsageCounts, + ) if (filteredCommands.isNotEmpty()) { androidx.compose.material3.Surface( modifier = 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 8d2505fb..82df8f3e 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 @@ -107,4 +107,40 @@ class ChatInputPolicyTest { val value = ChatInputPolicy.commandFieldValue("/help") assertTrue("cursor must be past the shared /h prefix", value.selection.start > 2) } + + // ── Slash suggestion ranking (issue #865) ─────────────────────────────── + + @Test + fun sortSlashSuggestions_mostUsedFirst_tiesKeepCatalogOrder() { + val commands = listOf("/help", "/model", "/new", "/stop") + val usage = mapOf("/stop" to 5, "/model" to 2) + + assertEquals( + "most-used must surface first; equal counts keep catalog order", + listOf("/stop", "/model", "/help", "/new"), + ChatInputPolicy.sortSlashSuggestions(commands, usage), + ) + } + + @Test + fun sortSlashSuggestions_noUsage_keepsCatalogOrder() { + val commands = listOf("/help", "/model", "/new") + assertEquals( + "no history must fall back to catalog order unchanged", + commands, + ChatInputPolicy.sortSlashSuggestions(commands, emptyMap()), + ) + } + + @Test + fun sortSlashSuggestions_lookupIsCaseInsensitive() { + // Stored keys are always lowercase (normalized at dispatch time), but + // the lookup lowercases the catalog name so a mismatched case can + // never break the ranking. + val commands = listOf("/model", "/new") + assertEquals( + listOf("/model", "/new"), + ChatInputPolicy.sortSlashSuggestions(commands, mapOf("/MODEL" to 3)), + ) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 7810d1aa..eedfcaea 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -22,6 +22,7 @@ import com.m57.hermescontrol.data.ws.JsonRpcError import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository +import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore import io.mockk.* import io.mockk.coEvery import io.mockk.coVerify @@ -67,6 +68,7 @@ class ChatViewModelTest { private val mockSwitchFlow = MutableSharedFlow(extraBufferCapacity = 8) private lateinit var app: Application private lateinit var fakeRepo: FakeChatPersistenceRepository + private lateinit var fakeSlashUsageStore: FakeSlashUsageStore /** Counter used to generate unique WS request IDs. */ private var reqCount = 0 @@ -114,6 +116,7 @@ class ChatViewModelTest { app = mockk(relaxed = true) fakeRepo = FakeChatPersistenceRepository() + fakeSlashUsageStore = FakeSlashUsageStore() ActiveSessionHolder.set(null) mockConnectionStatus.value = ConnectionStatus.DISCONNECTED @@ -184,7 +187,7 @@ class ChatViewModelTest { private fun createViewModel(startCleanup: Boolean = false): ChatViewModel = // Both dispatchers injected — a real ioDispatcher would race the // test scheduler (repo writes hop it) and shuffle RPC ordering. - ChatViewModel(app, startCleanup, fakeRepo, testDispatcher, testDispatcher) + ChatViewModel(app, startCleanup, fakeRepo, fakeSlashUsageStore, testDispatcher, testDispatcher) /** * Create ViewModel, simulate GatewayReady, feed SESSION_CREATE result, @@ -402,6 +405,47 @@ class ChatViewModelTest { verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } } + // ── Slash usage ranking (issue #865) ──────────────────────────────────── + + @Test + fun slashUsage_dispatchIncrementsCountPerCommand() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + + every { + HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any()) + } returns + CompletableDeferred( + mapOf("type" to "exec", "output" to "ok"), + ) + + viewModel.sendMessage("/help") + advanceUntilIdle() + viewModel.sendMessage("/help") + advanceUntilIdle() + viewModel.sendMessage("/new") + advanceUntilIdle() + + assertEquals(2, viewModel.uiState.value.slashUsageCounts["/help"]) + assertEquals(1, viewModel.uiState.value.slashUsageCounts["/new"]) + } + + @Test + fun slashUsage_blockedCommand_notCounted() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + + viewModel.sendMessage("/clear") + advanceUntilIdle() + + // A blocklisted command can never dispatch — it must not climb + // the autocomplete ranking either. + assertTrue(viewModel.uiState.value.slashUsageCounts.isEmpty()) + verify(exactly = 0) { + HermesWsClient.request(WsMethods.COMMAND_DISPATCH, any(), any()) + } + } + @Test fun testSlashCommand_unknown_showsErrorMessage() = runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt index 48330033..43b61c2f 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt @@ -9,6 +9,7 @@ import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository +import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -126,7 +127,7 @@ class SlashCommandDispatchRpcTest { } private suspend fun TestScope.createViewModelWithSession(): Pair { - val vm = ChatViewModel(app, false, fakeRepo, ioDispatcher = testDispatcher) + val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher) advanceUntilIdle() mockConnectionStatus.value = ConnectionStatus.CONNECTED mockEventsFlow.emit(WsEvent.GatewayReady(null)) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeSlashUsageStore.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeSlashUsageStore.kt new file mode 100644 index 00000000..2ce4ccfc --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/fakes/FakeSlashUsageStore.kt @@ -0,0 +1,26 @@ +package com.m57.hermescontrol.ui.chat.fakes + +import com.m57.hermescontrol.data.local.SlashUsageStore +import io.mockk.mockk +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +/** + * In-memory [SlashUsageStore] for ChatViewModel tests (issue #865). + * + * Mirrors the real store's semantics: [recordUse] bumps the count and + * [counts] emits the current map. The real store needs an Android Context + * (DataStore), which unit tests don't have — the relaxed mock is a dummy + * the overrides never touch (same pattern as the mocked Application in + * ChatViewModelTest). + */ +class FakeSlashUsageStore : SlashUsageStore(mockk(relaxed = true)) { + private val countsFlow = MutableStateFlow>(emptyMap()) + + override fun counts(): Flow> = countsFlow + + override suspend fun recordUse(command: String) { + countsFlow.update { it + (command to ((it[command] ?: 0) + 1)) } + } +}