feat(#216): dynamic commands catalog + mid-turn slash command support (#395)

* feat(ws): add commands.catalog RPC constants and data models

* feat(catalog): fetch commands.catalog on WS gateway ready, store in state

* feat(commands): rewrite dispatcher to forward non-local commands via WS command.dispatch

* feat(input): always-visible send button + compact stop icon, allow / commands mid-turn

* test(commands): update tests for WS-based command dispatch

* fix: add missing CommandCatalog + JsonRpcError imports

* fix(test): assert errorMessage instead of messages for unknown command

* chore: remove unused CommandDispatchResponse data class

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-26 14:14:51 +03:00
1 parent ef4c14e550
commit fafba3231b
6 files changed
+241 -98

No files matched your search

@@ -0,0 +1,22 @@
package com.m57.hermescontrol.data.ws
/**
* Response from `commands.catalog` RPC — full catalog of available
* slash commands, subcommands, and alias mappings.
*/
data class CommandCatalog(
val pairs: List<List<String>> = emptyList(),
val sub: Map<String, List<String>> = emptyMap(),
val canon: Map<String, String> = emptyMap(),
val categories: List<CommandCategory> = emptyList(),
val skillCount: Int = 0,
val warning: String = "",
)
/**
* A named category within the command catalog.
*/
data class CommandCategory(
val name: String,
val pairs: List<List<String>>,
)
@@ -2,16 +2,24 @@ package com.m57.hermescontrol.data.ws
/** JSON-RPC method name constants used with [HermesWsClient.send]. */
object WsMethods {
// ── Session ───────────────────────────────────────────────────────────
const val SESSION_LIST = "session.list"
const val SESSION_ACTIVE_LIST = "session.active_list"
const val SESSION_STATUS = "session.status"
const val SESSION_HISTORY = "session.history"
const val SESSION_RESUME = "session.resume"
const val SESSION_CREATE = "session.create"
const val PROMPT_SUBMIT = "prompt.submit"
const val SESSION_INTERRUPT = "session.interrupt"
const val SESSION_DELETE = "session.delete"
const val SESSION_TITLE = "session.title"
// ── Interaction ───────────────────────────────────────────────────────
const val PROMPT_SUBMIT = "prompt.submit"
const val CLARIFY_RESPOND = "clarify.respond"
const val APPROVAL_RESPOND = "approval.respond"
// ── Commands catalog ──────────────────────────────────────────────────
const val COMMANDS_CATALOG = "commands.catalog"
const val COMMAND_DISPATCH = "command.dispatch"
const val COMMAND_RESOLVE = "command.resolve"
}
@@ -61,6 +61,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -368,7 +369,9 @@ private fun ChatInputBar(
isAgentTyping: Boolean,
isConnected: Boolean,
) {
val canSend = inputText.isNotBlank() && !isAgentTyping && isConnected
// Allow sending slash commands even while agent is typing
val isSlashCommand = inputText.startsWith("/")
val canSend = inputText.isNotBlank() && isConnected && (!isAgentTyping || isSlashCommand)
AnimatedVisibility(
visible = true,
@@ -508,40 +511,41 @@ private fun ChatInputBar(
Spacer(modifier = Modifier.width(6.dp))
// Send button — always visible, enabled for slash commands mid-turn
FilledTonalButton(
onClick = onSend,
enabled = canSend,
modifier =
Modifier
.size(40.dp)
.testTag("send_button"),
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.chat_send_desc),
)
}
// Compact stop icon — visible only while agent is streaming
if (isAgentTyping) {
// Interrupt button
FilledTonalButton(
Spacer(modifier = Modifier.width(4.dp))
FilledTonalIconButton(
onClick = onInterrupt,
modifier =
Modifier
.size(40.dp)
.size(32.dp)
.testTag("interrupt_button"),
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
) {
Icon(
imageVector = Icons.Filled.Stop,
contentDescription = stringResource(R.string.content_desc_interrupt),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error,
)
}
} else {
// Send button
FilledTonalButton(
onClick = onSend,
enabled = canSend,
modifier =
Modifier
.size(40.dp)
.testTag("send_button"),
shape = CircleShape,
contentPadding = PaddingValues(0.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.chat_send_desc),
)
}
}
}
}
@@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.CommandCatalog
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
@@ -58,6 +59,8 @@ data class ChatUiState(
// Cached settings
val typingEffectEnabled: Boolean = true,
val typingEffectDelayMs: Int = 30,
// Commands catalog
val commandCatalog: CommandCatalog = CommandCatalog(),
) {
/** Convenience — derived from [connectionStatus]. */
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
@@ -196,6 +199,7 @@ class ChatViewModel(
_uiState.update { it.copy(isLoading = false) }
addSystemMessage("Connected to Hermes")
loadSessions()
fetchCommandCatalog()
if (_uiState.value.currentSessionId == null) {
val initial = initialSessionId
if (!initial.isNullOrBlank()) {
@@ -421,6 +425,55 @@ class ChatViewModel(
streamingMessageId = null
addSystemMessage("Session interrupted")
}
WsMethods.COMMANDS_CATALOG -> {
val map = result as? Map<*, *> ?: return
val catalog = parseCommandCatalog(map)
if (catalog != null) {
_uiState.update { it.copy(commandCatalog = catalog) }
}
}
WsMethods.COMMAND_DISPATCH -> {
handleDispatchResult(result)
}
}
}
@Suppress("UNCHECKED_CAST")
private fun handleDispatchResult(result: Any?) {
val map = result as? Map<*, *> ?: return
val type = map["type"] as? String ?: return
when (type) {
"send" -> {
val message = map["message"] as? String ?: ""
submitPrompt(message)
}
"exec" -> {
val output = map["output"] as? String ?: map["message"] as? String ?: ""
addAssistantMessage(output)
}
"skill" -> {
val message = map["message"] as? String ?: ""
submitPrompt(message)
}
"plugin" -> {
val output = map["output"] as? String ?: ""
addAssistantMessage(output)
}
"alias" -> {
val target = map["target"] as? String ?: return
handleSlashCommand(target)
}
else -> {
val output = map["output"] as? String ?: map.toString()
addAssistantMessage(output)
}
}
}
@@ -497,10 +550,6 @@ class ChatViewModel(
}
when (val result = slashDispatcher.dispatch(command)) {
is SlashResult.Message -> {
addAssistantMessage(result.text)
}
is SlashResult.Interrupt -> {
interruptSession()
}
@@ -509,23 +558,45 @@ class ChatViewModel(
createNewSession()
}
is SlashResult.FetchStatus -> {
fetchAndDisplayStatus()
is SlashResult.RpcDispatch -> {
dispatchViaRpc(command)
}
}
}
is SlashResult.FetchSessions -> {
fetchAndDisplaySessions()
}
private fun dispatchViaRpc(command: String) {
val sessionId = _uiState.value.currentSessionId
if (sessionId == null) {
addAssistantMessage("No active session. Use `/new` to create one.")
return
}
val parts = command.split(" ", limit = 2)
val name = parts[0].lowercase().removePrefix("/")
val arg = parts.getOrElse(1) { "" }
viewModelScope.launch(Dispatchers.IO) {
wsClient.send(
WsMethods.COMMAND_DISPATCH,
mapOf("name" to name, "arg" to arg, "session_id" to sessionId),
onSent = { id -> trackRequest(id, WsMethods.COMMAND_DISPATCH) },
)
}
}
is SlashResult.FetchStats -> {
fetchAndDisplayStats()
}
is SlashResult.Unknown -> {
addAssistantMessage(
"Unknown command: `${result.cmd}`. Type `/help` to view a list of available commands.",
)
}
/**
* Submits [text] as a prompt to the current session via WS, without
* adding a duplicate user message. Used by [handleDispatchResult] when
* a slash command resolves to a normal user prompt (e.g. `/queue` → "help me").
*/
private fun submitPrompt(text: String) {
if (text.isBlank()) return
val sessionId = _uiState.value.currentSessionId ?: return
_uiState.update { it.copy(isAgentTyping = true) }
viewModelScope.launch(Dispatchers.IO) {
wsClient.sendMessage(
sessionId,
text,
onSent = { id -> trackRequest(id, WsMethods.PROMPT_SUBMIT) },
)
}
}
@@ -667,6 +738,15 @@ class ChatViewModel(
}
}
private fun fetchCommandCatalog() {
viewModelScope.launch(Dispatchers.IO) {
wsClient.send(
WsMethods.COMMANDS_CATALOG,
onSent = { id -> trackRequest(id, WsMethods.COMMANDS_CATALOG) },
)
}
}
fun refreshCurrentSession() {
val sessionId = _uiState.value.currentSessionId ?: return
loadCachedMessages(sessionId)
@@ -682,6 +762,18 @@ class ChatViewModel(
}
}
@Suppress("UNCHECKED_CAST")
private fun parseCommandCatalog(map: Map<*, *>): CommandCatalog? {
return try {
val gson = com.google.gson.Gson()
val json = gson.toJson(map)
gson.fromJson(json, CommandCatalog::class.java)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse command catalog", e)
null
}
}
fun switchSession(sessionId: String) {
if (sessionId == _uiState.value.currentSessionId) return
@@ -5,59 +5,33 @@ package com.m57.hermescontrol.ui.chat
* the ViewModel should take.
*
* Pure logic — no I/O, no Android dependencies.
*
* Only commands that MUST be handled client-side (immediate UX) are here.
* Everything else is forwarded to the backend via [SlashResult.RpcDispatch].
*/
class SlashCommandDispatcher {
fun dispatch(command: String): SlashResult {
val parts = command.split(" ")
val parts = command.split(" ", limit = 2)
val cmd = parts[0].lowercase()
return when (cmd) {
"/stop", "/interrupt" -> SlashResult.Interrupt
"/new" -> SlashResult.NewSession
"/help" -> SlashResult.Message(HELP_TEXT)
"/status" -> SlashResult.FetchStatus
"/sessions" -> SlashResult.FetchSessions
"/stats", "/system" -> SlashResult.FetchStats
else -> SlashResult.Unknown(cmd)
else -> SlashResult.RpcDispatch
}
}
companion object {
val HELP_TEXT =
"""
**Available Commands:**
• `/help` - Show this help menu
• `/status` - Check gateway and platform status
• `/sessions` - List all chat sessions
• `/stats` or `/system` - Check system resource usage
• `/new` - Create a new chat session
• `/stop` or `/interrupt` - Interrupt the active run
""".trimIndent()
}
}
/**
* The result of dispatching a slash command.
*/
sealed class SlashResult {
/** Display a static message in the chat. */
data class Message(val text: String) : SlashResult()
/** Interrupt the active session. */
/** Interrupt the active session (client-side immediate). */
data object Interrupt : SlashResult()
/** Create a new session. */
/** Create a new session (client-side immediate). */
data object NewSession : SlashResult()
/** Fetch gateway status via REST and display. */
data object FetchStatus : SlashResult()
/** Fetch session list via REST and display. */
data object FetchSessions : SlashResult()
/** Fetch system stats via REST and display. */
data object FetchStats : SlashResult()
/** Command not recognised. */
data class Unknown(val cmd: String) : SlashResult()
/** Forward to command.dispatch via WebSocket. */
data object RpcDispatch : SlashResult()
}