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

@@ -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<String>,
usageCounts: Map<String, Int>,
): List<String> = commands.sortedByDescending { usageCounts[it.lowercase()] ?: 0 }
}
@@ -681,6 +681,7 @@ fun ChatScreen(
isAgentTyping = state.isAgentTyping,
isConnected = state.isConnected,
commandCatalog = state.commandCatalog,
slashUsageCounts = state.slashUsageCounts,
pendingAttachments = state.pendingAttachments,
onCameraTap = {
try {
@@ -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<String, Int> = 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()
@@ -78,6 +78,7 @@ fun ChatInputBar(
isAgentTyping: Boolean,
isConnected: Boolean,
commandCatalog: CommandCatalog,
slashUsageCounts: Map<String, Int> = emptyMap(),
pendingAttachments: List<Attachment> = 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 =