diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt new file mode 100644 index 00000000..282a8b69 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/CommandModels.kt @@ -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> = emptyList(), + val sub: Map> = emptyMap(), + val canon: Map = emptyMap(), + val categories: List = emptyList(), + val skillCount: Int = 0, + val warning: String = "", +) + +/** + * A named category within the command catalog. + */ +data class CommandCategory( + val name: String, + val pairs: List>, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index 7930315e..3d32f622 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -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" } 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 25773b06..b2d63791 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 @@ -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), - ) - } } } } 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 162ebf58..fcf27358 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 @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt index fe775fed..c5f5e883 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatcher.kt @@ -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() } 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 22ae7b94..1202353f 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 @@ -121,9 +121,27 @@ class ChatViewModelTest { mockEventsFlow.emit(WsEvent.RpcResult(createReqId, mapOf("session_id" to "session-123"))) advanceUntilIdle() + // Stub COMMAND_DISPATCH to capture the request ID + var dispatchReqId = "" + every { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) } answers { + dispatchReqId = "dispatch-help" + val onSent = arg<((String) -> Unit)?>(2) + onSent?.invoke(dispatchReqId) + dispatchReqId + } + viewModel.sendMessage("/help") advanceUntilIdle() + // Feed the dispatch result (the backend returns `{type: "exec", output: "..."}` for /help) + mockEventsFlow.emit( + WsEvent.RpcResult( + dispatchReqId, + mapOf("type" to "exec", "output" to "**Available Commands:**\n• `/status`\n• `/new`"), + ), + ) + advanceUntilIdle() + val state = viewModel.uiState.value assertTrue(state.messages.any { it.content.contains("Available Commands") }) assertTrue(state.messages.any { it.content.contains("/status") }) @@ -223,12 +241,32 @@ class ChatViewModelTest { mockEventsFlow.emit(WsEvent.RpcResult(createReqId, mapOf("session_id" to "session-123"))) advanceUntilIdle() + // Stub COMMAND_DISPATCH + var dispatchReqId = "" + every { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) } answers { + dispatchReqId = "dispatch-unk" + val onSent = arg<((String) -> Unit)?>(2) + onSent?.invoke(dispatchReqId) + dispatchReqId + } + viewModel.sendMessage("/nonexistent") advanceUntilIdle() + // Feed an error RPC result (backend returns error for unknown commands) + mockEventsFlow.emit( + WsEvent.RpcError( + dispatchReqId, + JsonRpcError( + code = -32601, + message = "Unknown command: nonexistent", + ), + ), + ) + advanceUntilIdle() + val state = viewModel.uiState.value - assertTrue(state.messages.any { it.content.contains("Unknown command") }) - assertTrue(state.messages.any { it.content.contains("/nonexistent") }) + assertTrue(state.errorMessage?.contains("Unknown command") == true) } @Test @@ -255,18 +293,19 @@ class ChatViewModelTest { mockEventsFlow.emit(WsEvent.RpcResult(createReqId, mapOf("session_id" to "session-123"))) advanceUntilIdle() - // Stub the API call — without this, mockkObject returns null mock and getStatus call fails - coEvery { ApiClient.hermesApi.getStatus() } returns - mockk(relaxed = true) { - every { isSuccessful } returns true - every { body() } returns null - } + var dispatchReqId = "" + every { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) } answers { + dispatchReqId = "dispatch-status" + val onSent = arg<((String) -> Unit)?>(2) + onSent?.invoke(dispatchReqId) + dispatchReqId + } viewModel.sendMessage("/status") advanceUntilIdle() - // Should have dispatched the API call - coVerify { ApiClient.hermesApi.getStatus() } + // Should have dispatched via RPC + verify { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) } } @Test @@ -292,16 +331,18 @@ class ChatViewModelTest { mockEventsFlow.emit(WsEvent.RpcResult(createReqId, mapOf("session_id" to "session-123"))) advanceUntilIdle() - coEvery { ApiClient.hermesApi.getSessions(any(), any(), any()) } returns - mockk(relaxed = true) { - every { isSuccessful } returns true - every { body() } returns null - } + var dispatchReqId = "" + every { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) } answers { + dispatchReqId = "dispatch-sessions" + val onSent = arg<((String) -> Unit)?>(2) + onSent?.invoke(dispatchReqId) + dispatchReqId + } viewModel.sendMessage("/sessions") advanceUntilIdle() - coVerify { ApiClient.hermesApi.getSessions(any(), any(), any()) } + verify { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) } } @Test @@ -327,16 +368,18 @@ class ChatViewModelTest { mockEventsFlow.emit(WsEvent.RpcResult(createReqId, mapOf("session_id" to "session-123"))) advanceUntilIdle() - coEvery { ApiClient.hermesApi.getSystemStats() } returns - mockk(relaxed = true) { - every { isSuccessful } returns true - every { body() } returns null - } + var dispatchReqId = "" + every { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) } answers { + dispatchReqId = "dispatch-stats" + val onSent = arg<((String) -> Unit)?>(2) + onSent?.invoke(dispatchReqId) + dispatchReqId + } viewModel.sendMessage("/stats") advanceUntilIdle() - coVerify { ApiClient.hermesApi.getSystemStats() } + verify { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) } } @Test