diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt index b6eb32da..269d2c80 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatModels.kt @@ -12,6 +12,10 @@ data class GroupChatToolCall( val name: String, val summary: String? = null, val isRunning: Boolean = false, + val command: String? = null, + val output: String? = null, + val exitCode: Int? = null, + val isError: Boolean = false, ) /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt index 424b9160..40444b94 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -37,6 +38,8 @@ import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings @@ -78,6 +81,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue @@ -1021,49 +1025,149 @@ private fun GroupChatToolChip( tool: GroupChatToolCall, modifier: Modifier = Modifier, ) { + var expanded by rememberSaveable(tool.id) { mutableStateOf(false) } + val isTerminal = + tool.name.equals("terminal", ignoreCase = true) || + tool.name.equals("execute_code", ignoreCase = true) + Surface( shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - modifier = modifier, - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - val icon = - when (tool.name.lowercase()) { - "terminal" -> Icons.Filled.Terminal - "web_search", "search_files" -> Icons.Filled.Search - "read_file", "write_file", "patch" -> Icons.Filled.Description - else -> Icons.Filled.Build - } - Icon( - imageVector = icon, - contentDescription = tool.name, - modifier = Modifier.size(13.dp), - tint = MaterialTheme.colorScheme.primary, - ) - val label = - if (!tool.summary.isNullOrBlank()) { - "${tool.name}: ${tool.summary}" + border = + BorderStroke( + 1.dp, + if (tool.isError) { + MaterialTheme.colorScheme.error.copy(alpha = 0.5f) } else { - tool.name - } - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (tool.isRunning) { - CircularProgressIndicator( - modifier = Modifier.size(10.dp), - strokeWidth = 1.5.dp, - color = MaterialTheme.colorScheme.primary, + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + }, + ), + modifier = + modifier + .fillMaxWidth() + .clickable { expanded = !expanded }, + ) { + Column( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth(), + ) { + val icon = + when (tool.name.lowercase()) { + "terminal", "execute_code" -> Icons.Filled.Terminal + "web_search", "search_files" -> Icons.Filled.Search + "read_file", "write_file", "patch" -> Icons.Filled.Description + else -> Icons.Filled.Build + } + Icon( + imageVector = icon, + contentDescription = tool.name, + modifier = Modifier.size(13.dp), + tint = + if (tool.isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, ) + val label = + if (!tool.summary.isNullOrBlank()) { + "${tool.name}: ${tool.summary}" + } else { + tool.name + } + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (tool.isRunning) { + CircularProgressIndicator( + modifier = Modifier.size(10.dp), + strokeWidth = 1.5.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (!tool.command.isNullOrBlank()) { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth(), + ) { + SelectionContainer { + Text( + text = if (isTerminal) "$ ${tool.command}" else tool.command, + style = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), + ) + } + } + } + + val outputText = + when { + tool.isRunning -> stringResource(R.string.group_chat_tool_running) + !tool.output.isNullOrBlank() -> tool.output.trim() + tool.exitCode == 0 -> stringResource(R.string.group_chat_tool_no_output_success) + else -> stringResource(R.string.group_chat_tool_no_output) + } + + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.6f), + modifier = Modifier.fillMaxWidth(), + ) { + SelectionContainer { + Text( + text = outputText, + style = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + color = + if (tool.isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 15, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), + ) + } + } + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt index 22d407f2..191e33b6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModel.kt @@ -112,6 +112,21 @@ class GroupChatViewModel( loadGroup() } + private fun extractToolCommand(data: Map?): String? { + if (data == null) return null + val cmd = (data["command"] as? String)?.trim() + if (!cmd.isNullOrBlank()) return cmd + val code = (data["code"] as? String)?.trim() + if (!code.isNullOrBlank()) return code + val query = (data["query"] as? String)?.trim() + if (!query.isNullOrBlank()) return query + val path = (data["path"] as? String)?.trim() + if (!path.isNullOrBlank()) return path + val pattern = (data["pattern"] as? String)?.trim() + if (!pattern.isNullOrBlank()) return pattern + return null + } + private fun extractToolSummary(data: Map?): String? { if (data == null) return null val cmd = (data["command"] as? String)?.trim() @@ -174,11 +189,13 @@ class GroupChatViewModel( (event.data?.get("tool_id") as? String)?.ifBlank { null } ?: UUID.randomUUID().toString() val summary = extractToolSummary(event.data) + val command = extractToolCommand(event.data) val toolCall = GroupChatToolCall( id = toolId, name = toolName, summary = summary, + command = command, isRunning = true, ) if (streamId != null && bot != null) { @@ -218,6 +235,20 @@ class GroupChatViewModel( val streamId = activeStreamMsgId[sid] val toolId = event.data?.get("tool_id") as? String val toolName = event.name + val output = + (event.data?.get("output") as? String) + ?: (event.data?.get("stdout") as? String) + ?: (event.data?.get("result") as? String) + val exitCode = + when (val rawCode = event.data?.get("exit_code")) { + is Number -> rawCode.toInt() + is String -> rawCode.toIntOrNull() + else -> null + } + val isError = + (exitCode != null && exitCode != 0) || + (event.data?.get("is_error") as? Boolean == true) || + (event.data?.get("error") != null) if (streamId != null) { _uiState.update { state -> val existing = state.messages.find { it.id == streamId } @@ -228,7 +259,12 @@ class GroupChatViewModel( (toolId == null && tool.name == toolName && tool.isRunning) || (toolId == null && toolName == null && tool.isRunning) ) { - tool.copy(isRunning = false) + tool.copy( + isRunning = false, + output = output ?: tool.output, + exitCode = exitCode ?: tool.exitCode, + isError = isError || tool.isError, + ) } else { tool } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 431a54a4..e7448f31 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1169,6 +1169,9 @@ أشر إلى بوت أو استخدم @all لبدء التعاون. %1$s يفكّر الآن… جارٍ الكتابة… +جارٍ التشغيل… +لا يوجد مخرجات +رمز الخروج 0 (لا يوجد مخرجات) %1$d أعضاء @all المحادثة متوقفة مؤقتًا (تم الوصول إلى الحد الأقصى لعدد الردود) — أرسل ردًا للمتابعة diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 108c8063..9b391dd1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1284,6 +1284,9 @@ Mention a bot or use @all to kick off collaboration. %1$s is thinking… Typing… + Running… + No output + Exit code 0 (no output) %1$d members @all Discussion paused (turn limit reached) — reply to continue diff --git a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt index 97943a25..f1a15178 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/bots/group/GroupChatViewModelTest.kt @@ -500,13 +500,19 @@ class GroupChatViewModelTest { val toolCall = msgWithTool.toolCalls.first() assertEquals("terminal", toolCall.name) assertEquals("date", toolCall.summary) + assertEquals("date", toolCall.command) assertTrue(toolCall.isRunning) // Emit ToolComplete eventsFlow.emit( WsEvent.ToolComplete( name = "terminal", - data = mapOf("tool_id" to "tool-123"), + data = + mapOf( + "tool_id" to "tool-123", + "output" to "Sat Aug 29 03:30:00 UTC 2026", + "exit_code" to 0, + ), sessionId = "session-scout-1", ), ) @@ -515,7 +521,11 @@ class GroupChatViewModelTest { val msgAfterToolDone = viewModel.uiState.value.messages .last() - assertFalse(msgAfterToolDone.toolCalls.first().isRunning) + val completedTool = msgAfterToolDone.toolCalls.first() + assertFalse(completedTool.isRunning) + assertEquals("Sat Aug 29 03:30:00 UTC 2026", completedTool.output) + assertEquals(0, completedTool.exitCode) + assertFalse(completedTool.isError) // Complete message eventsFlow.emit(