feat(chat): rank slash autocomplete by usage (issue #865) (#874)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-10 19:56:01 +03:00
1 parent 5ab7ad16df
commit a9c3cd0691
9 files changed
+244 -3

No files matched your search

@@ -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<String, Int> = emptyMap(),
)
private object SlashUsageSerializer : Serializer<SlashUsageState> {
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<SlashUsageState> 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<Map<String, Int>> =
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.
}
}
}
}
@@ -45,4 +45,15 @@ object ChatInputPolicy {
* selection. * selection.
*/ */
fun commandFieldValue(command: String): TextFieldValue = TextFieldValue(command, TextRange(command.length)) 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<String>,
usageCounts: Map<String, Int>,
): List<String> = commands.sortedByDescending { usageCounts[it.lowercase()] ?: 0 }
} }
@@ -681,6 +681,7 @@ fun ChatScreen(
isAgentTyping = state.isAgentTyping, isAgentTyping = state.isAgentTyping,
isConnected = state.isConnected, isConnected = state.isConnected,
commandCatalog = state.commandCatalog, commandCatalog = state.commandCatalog,
slashUsageCounts = state.slashUsageCounts,
pendingAttachments = state.pendingAttachments, pendingAttachments = state.pendingAttachments,
onCameraTap = { onCameraTap = {
try { try {
@@ -9,6 +9,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.HermesDatabase 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.Attachment
import com.m57.hermescontrol.data.model.AttachmentSource import com.m57.hermescontrol.data.model.AttachmentSource
import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.ModelProvider
@@ -274,6 +275,10 @@ data class ChatUiState(
val typingEffectDelayMs: Int = 30, val typingEffectDelayMs: Int = 30,
// Commands catalog // Commands catalog
val commandCatalog: CommandCatalog = CommandCatalog(), 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<String, Int> = emptyMap(),
// In-session model picker (issue #589) — surfaced when the user types /model // 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 // (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. // picker, but the selection hot-swaps the CURRENT session via the slash path.
@@ -378,6 +383,7 @@ class ChatViewModel(
ChatPersistenceRepository( ChatPersistenceRepository(
HermesDatabase.get(application).chatMessageDao(), HermesDatabase.get(application).chatMessageDao(),
), ),
slashUsageStore: SlashUsageStore = SlashUsageStore(application.applicationContext),
searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default, searchDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.Default,
private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO, private val ioDispatcher: kotlinx.coroutines.CoroutineDispatcher = kotlinx.coroutines.Dispatchers.IO,
) : AndroidViewModel(application) { ) : AndroidViewModel(application) {
@@ -456,6 +462,7 @@ class ChatViewModel(
// ── Session persistence ────────────────────────────────────────────── // ── Session persistence ──────────────────────────────────────────────
private val repo: ChatPersistenceRepository = repo private val repo: ChatPersistenceRepository = repo
private val slashUsageStore: SlashUsageStore = slashUsageStore
private val slashDispatcher = SlashCommandDispatcher() private val slashDispatcher = SlashCommandDispatcher()
/** /**
@@ -565,6 +572,14 @@ class ChatViewModel(
resetSessionState(sessionId = null, title = "Hermes", isLoading = true) 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) { if (wsClient.connectionStatus.value == ConnectionStatus.CONNECTED) {
handleGatewayReady() handleGatewayReady()
} }
@@ -1390,6 +1405,15 @@ class ChatViewModel(
return 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)) { when (val result = slashDispatcher.dispatch(command)) {
is SlashResult.Interrupt -> { is SlashResult.Interrupt -> {
interruptSession() interruptSession()
@@ -78,6 +78,7 @@ fun ChatInputBar(
isAgentTyping: Boolean, isAgentTyping: Boolean,
isConnected: Boolean, isConnected: Boolean,
commandCatalog: CommandCatalog, commandCatalog: CommandCatalog,
slashUsageCounts: Map<String, Int> = emptyMap(),
pendingAttachments: List<Attachment> = emptyList(), pendingAttachments: List<Attachment> = emptyList(),
onCameraTap: () -> Unit = {}, onCameraTap: () -> Unit = {},
onImageTap: () -> Unit = {}, onImageTap: () -> Unit = {},
@@ -136,7 +137,10 @@ fun ChatInputBar(
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(), exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.shrinkVertically(),
) { ) {
val filteredCommands = val filteredCommands =
commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) } ChatInputPolicy.sortSlashSuggestions(
commandNames.filter { it.startsWith(inputFieldValue.text, ignoreCase = true) },
slashUsageCounts,
)
if (filteredCommands.isNotEmpty()) { if (filteredCommands.isNotEmpty()) {
androidx.compose.material3.Surface( androidx.compose.material3.Surface(
modifier = modifier =
@@ -107,4 +107,40 @@ class ChatInputPolicyTest {
val value = ChatInputPolicy.commandFieldValue("/help") val value = ChatInputPolicy.commandFieldValue("/help")
assertTrue("cursor must be past the shared /h prefix", value.selection.start > 2) 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)),
)
}
} }
@@ -22,6 +22,7 @@ import com.m57.hermescontrol.data.ws.JsonRpcError
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
import io.mockk.* import io.mockk.*
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
@@ -67,6 +68,7 @@ class ChatViewModelTest {
private val mockSwitchFlow = MutableSharedFlow<String>(extraBufferCapacity = 8) private val mockSwitchFlow = MutableSharedFlow<String>(extraBufferCapacity = 8)
private lateinit var app: Application private lateinit var app: Application
private lateinit var fakeRepo: FakeChatPersistenceRepository private lateinit var fakeRepo: FakeChatPersistenceRepository
private lateinit var fakeSlashUsageStore: FakeSlashUsageStore
/** Counter used to generate unique WS request IDs. */ /** Counter used to generate unique WS request IDs. */
private var reqCount = 0 private var reqCount = 0
@@ -114,6 +116,7 @@ class ChatViewModelTest {
app = mockk(relaxed = true) app = mockk(relaxed = true)
fakeRepo = FakeChatPersistenceRepository() fakeRepo = FakeChatPersistenceRepository()
fakeSlashUsageStore = FakeSlashUsageStore()
ActiveSessionHolder.set(null) ActiveSessionHolder.set(null)
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
@@ -184,7 +187,7 @@ class ChatViewModelTest {
private fun createViewModel(startCleanup: Boolean = false): ChatViewModel = private fun createViewModel(startCleanup: Boolean = false): ChatViewModel =
// Both dispatchers injected — a real ioDispatcher would race the // Both dispatchers injected — a real ioDispatcher would race the
// test scheduler (repo writes hop it) and shuffle RPC ordering. // 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, * Create ViewModel, simulate GatewayReady, feed SESSION_CREATE result,
@@ -402,6 +405,47 @@ class ChatViewModelTest {
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) } 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 @Test
fun testSlashCommand_unknown_showsErrorMessage() = fun testSlashCommand_unknown_showsErrorMessage() =
runTest { runTest {
@@ -9,6 +9,7 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkObject import io.mockk.mockkObject
@@ -126,7 +127,7 @@ class SlashCommandDispatchRpcTest {
} }
private suspend fun TestScope.createViewModelWithSession(): Pair<ChatViewModel, String> { private suspend fun TestScope.createViewModelWithSession(): Pair<ChatViewModel, String> {
val vm = ChatViewModel(app, false, fakeRepo, ioDispatcher = testDispatcher) val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
advanceUntilIdle() advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null)) mockEventsFlow.emit(WsEvent.GatewayReady(null))
@@ -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<Map<String, Int>>(emptyMap())
override fun counts(): Flow<Map<String, Int>> = countsFlow
override suspend fun recordUse(command: String) {
countsFlow.update { it + (command to ((it[command] ?: 0) + 1)) }
}
}