diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 6b660846..c17be07b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -30,16 +30,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.InsertDriveFile -import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Error import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -69,34 +65,22 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment -import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.theme.DarkOnSurface import com.m57.hermescontrol.theme.HermesStatusColors import com.m57.hermescontrol.theme.LightOnSurface import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.onColorFor -import com.m57.hermescontrol.ui.chat.components.DiffViewCard import com.m57.hermescontrol.ui.chat.components.ReasoningCard import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.boolean -import kotlinx.serialization.json.double -import kotlinx.serialization.json.int import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -158,7 +142,7 @@ fun ChatBubble( } MessageRole.TOOL -> { - ToolBubble(message, isDarkTheme, modifier) + ToolBubble(message, modifier) } } } @@ -601,1816 +585,7 @@ private fun SystemBubble( } } -/** - * Cleanly parsed representation of a tool call, extracted from the - * tool.complete JSON payload (which contains both args and result). - */ -data class ParsedToolData( - val toolName: String = "", - val args: Map = emptyMap(), - val result: Map = emptyMap(), - val isTerminal: Boolean = false, - val stdout: String? = null, - val stderr: String? = null, - val exitCode: Int? = null, - val error: String? = null, - val summaryText: String? = null, - val durationSec: Double? = null, - val mainOutput: String? = null, - val extraFields: Map = emptyMap(), - val isRunning: Boolean = false, - val diffOutput: String? = null, - val diffPath: String? = null, -) - -private fun formatTodoToolOutput( - dataSource: JsonObject, - args: Map, - obj: JsonObject, - resolvedToolName: String?, - isRunning: Boolean, -): ParsedToolData { - val todosArray = - dataSource - .get("todos") - ?.takeIf { !it.isJsonNull && it.isJsonArray } - ?.asJsonArray - val summaryObj = - dataSource - .get("summary") - ?.takeIf { !it.isJsonNull && it.isJsonObject } - ?.asJsonObject - - val todoSummaryText = - summaryObj?.let { s -> - val total = s.get("total")?.asDouble?.toInt() ?: 0 - val pending = s.get("pending")?.asDouble?.toInt() ?: 0 - val inProgress = s.get("in_progress")?.asDouble?.toInt() ?: 0 - val completed = s.get("completed")?.asDouble?.toInt() ?: 0 - val cancelled = s.get("cancelled")?.asDouble?.toInt() ?: 0 - val parts = mutableListOf() - if (pending > 0) parts.add("$pending pending") - if (inProgress > 0) parts.add("$inProgress in_progress") - if (completed > 0) parts.add("$completed completed") - if (cancelled > 0) parts.add("$cancelled cancelled") - val itemWord = if (total == 1) "item" else "items" - if (parts.isEmpty()) { - "šŸ“‹ 0 items" - } else { - "šŸ“‹ $total $itemWord (${parts.joinToString(", ")})" - } - } - - val formattedTodos = - todosArray - ?.mapNotNull { element -> - if (!element.isJsonObject) return@mapNotNull null - val item = element.asJsonObject - val id = item.get("id")?.asString ?: "" - val content = item.get("content")?.asString ?: "" - val status = item.get("status")?.asString ?: "pending" - val marker = - when (status) { - "completed" -> "[x]" - "in_progress" -> "[>]" - "cancelled" -> "[~]" - else -> "[ ]" - } - "$marker $id. $content" - }?.joinToString("\n") - ?.takeIf { it.isNotEmpty() } - - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - - return ParsedToolData( - toolName = resolvedToolName ?: "", - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = todoSummaryText, - mainOutput = formattedTodos, - durationSec = duration, - isRunning = isRunning, - ) -} - -/** - * Parses the tool.complete JSON payload into a [ParsedToolData]. - * The gateway sends a payload with `args` and `result` sub-objects - * (see tui_gateway/server.py `_on_tool_complete`). We extract both - * and use the tool name to build a one-line summary from the relevant arg. - */ -fun parseToolOutput( - content: String, - toolName: String?, - isRunning: Boolean, -): ParsedToolData? { - val trimmed = content.trim() - if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null - return try { - val element = OkHttpProvider.json.parseToJsonElement(trimmed) - if (!element.isJsonObject) return null - val obj = element.asJsonObject - - // Resolve tool name — from parameter first, then from payload (old sessions) - val resolvedToolName = - toolName - ?: obj.get("name")?.takeIf { !it.isJsonNull }?.asString - - val config = ToolSchemaRegistry.getDisplayConfig(resolvedToolName) - - // Extract args sub-object if present (new tool.complete format) - val argsObj = obj.get("args")?.takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject - val args: Map = - argsObj?.entrySet()?.associate { entry -> - entry.key to (if (entry.value.isJsonPrimitive) entry.value.asString else entry.value.toString()) - } ?: emptyMap() - - // Extract result sub-object if present, fall back to top-level (old format) - val resultObj = obj.get("result")?.takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject - val dataSource = resultObj ?: obj - - // Build summary line from args (new format) or context field (old tool.start) - val summaryText = - if (config.summaryArgKey != null) { - val raw = args[config.summaryArgKey]?.toString() ?: "" - val truncated = if (raw.length > 100) raw.take(100) + "…" else raw - if (truncated.isNotBlank()) "${config.summaryPrefix}$truncated" else null - } else { - obj - .get("context") - ?.takeIf { !it.isJsonNull } - ?.asString - ?.takeIf { it.isNotEmpty() } - ?.let { "${config.summaryPrefix}$it" } - } - - if (resolvedToolName == "todo") { - return formatTodoToolOutput( - dataSource = dataSource, - args = args, - obj = obj, - resolvedToolName = resolvedToolName, - isRunning = isRunning, - ) - } - - // ── Fact Store-specific formatting ── - // Extract structured fact results (probe/search/reason/related/contradict → results[], - // list → facts[], add/update/remove → simple status). Build a clean summary and - // formatted fact list with category, trust score, and tags. - if (resolvedToolName == "fact_store") { - val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: "" - val entity = argsObj?.get("entity")?.takeIf { !it.isJsonNull }?.asString - val query = argsObj?.get("query")?.takeIf { !it.isJsonNull }?.asString - - // Try both key shapes: results[] (probe/search/reason/related/contradict) and facts[] (list) - val factsArray = - dataSource - .get("results") - ?.takeIf { !it.isJsonNull && it.isJsonArray } - ?.asJsonArray - ?: dataSource - .get("facts") - ?.takeIf { !it.isJsonNull && it.isJsonArray } - ?.asJsonArray - - val count = - dataSource - .get("count") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() ?: 0 - val status = dataSource.get("status")?.takeIf { !it.isJsonNull }?.asString - val factId = - dataSource - .get("fact_id") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() - val removed = dataSource.get("removed")?.takeIf { !it.isJsonNull }?.asBoolean - val updated = dataSource.get("updated")?.takeIf { !it.isJsonNull }?.asBoolean - - // Build summary line - val actionContext = - when { - entity != null -> " ($action: $entity)" - query != null -> " ($action: $query)" - else -> " ($action)" - } - val factSummaryText = - when { - status == "added" && factId != null -> "🧠 Fact added (ID: $factId)" - status == "added" -> "🧠 Fact added" - removed == true -> "🧠 Fact removed" - updated == true -> "🧠 Fact updated" - factsArray != null -> "🧠 $count facts$actionContext" - else -> "🧠 $action" - } - - // Format each fact as a compact block: #ID + content on first line, meta on indented second line - val formattedFacts = - factsArray - ?.mapNotNull { element -> - if (!element.isJsonObject) return@mapNotNull null - val item = element.asJsonObject - val fid = item.get("fact_id")?.asDouble?.toInt() ?: 0 - val content = item.get("content")?.asString ?: "" - val category = item.get("category")?.asString - val trust = item.get("trust_score")?.asDouble - val tags = item.get("tags")?.asString?.takeIf { it.isNotEmpty() } - - val firstLine = "#$fid $content" - val metaParts = mutableListOf() - if (category != null) metaParts.add("[$category]") - if (trust != null) metaParts.add("trust: ${"%.2f".format(trust)}") - if (tags != null) metaParts.add("šŸ·ļø $tags") - if (metaParts.isNotEmpty()) { - "$firstLine\n ${metaParts.joinToString(" ")}" - } else { - firstLine - } - }?.joinToString("\n") - ?.takeIf { it.isNotEmpty() } - - val factMainOutput = - when { - status == "added" && factId != null -> "āœ… Fact #$factId stored" - status == "added" -> "āœ… Fact stored" - removed == true -> "āœ… Removed" - updated == true -> "āœ… Updated" - formattedFacts != null -> formattedFacts - else -> null - } - - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - - return ParsedToolData( - toolName = resolvedToolName, - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = factSummaryText, - mainOutput = factMainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Session Search-specific formatting ── - // Handles all 4 calling modes: discover (results[] with snippets/bookends), - // scroll (messages[] with anchor), read (messages[] with session dump), - // and browse (results[] with recent session metadata). - if (resolvedToolName == "session_search") { - val mode = dataSource.get("mode")?.takeIf { !it.isJsonNull }?.asString ?: "" - val query = dataSource.get("query")?.takeIf { !it.isJsonNull }?.asString - val resultsArray = - dataSource.get("results")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - val messagesArray = - dataSource.get("messages")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - val count = - dataSource - .get("count") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() - val msgCount = - dataSource - .get("message_count") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() - val truncated = dataSource.get("truncated")?.takeIf { !it.isJsonNull }?.asBoolean - - // Build summary line - val ssSummaryText = - when (mode) { - "discover" -> "šŸ” ${count ?: 0} session${if (count == 1) "" else "s"}${query?.let { - ": ${it.take( - 60, - )}" - } ?: ""}" - - "scroll" -> "šŸ“œ ${messagesArray?.size() ?: 0} messages (scroll)" - - "read" -> "šŸ“– ${msgCount ?: messagesArray?.size() ?: 0} messages${ - if (truncated == true) " (truncated)" else "" - }" - - "browse" -> "šŸ“‹ ${count ?: 0} recent sessions" - - else -> "šŸ” session_search" - } - - // Format results (discover / browse) - val formattedResults = - resultsArray - ?.mapIndexedNotNull { idx, element -> - if (!element.isJsonObject) return@mapIndexedNotNull null - val item = element.asJsonObject - val whenField = - item.get("when")?.takeIf { !it.isJsonNull }?.asString - ?: item.get("started_at")?.takeIf { !it.isJsonNull }?.asString - val source = item.get("source")?.takeIf { !it.isJsonNull }?.asString - val title = item.get("title")?.takeIf { !it.isJsonNull }?.asString - val snippet = item.get("snippet")?.takeIf { !it.isJsonNull }?.asString - val preview = item.get("preview")?.takeIf { !it.isJsonNull }?.asString - val model = item.get("model")?.takeIf { !it.isJsonNull }?.asString - val sessionMsgCount = - item - .get("message_count") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() - val matchedRole = item.get("matched_role")?.takeIf { !it.isJsonNull }?.asString - - val header = whenField?.let { "šŸ“… $it" } ?: "" - val sourceTag = source?.let { "[$it]" } ?: "" - val titleLine = title?.let { "\n šŸ“„ $it" } ?: "" - val modelLine = model?.let { "\n šŸ¤– $it" } ?: "" - val matchedLine = matchedRole?.let { "\n šŸŽÆ matched: $it" } ?: "" - val countLine = sessionMsgCount?.let { "\n $it msgs" } ?: "" - val snippetText = snippet ?: preview - val snippetLine = - snippetText?.let { - val clean = it.take(200).replace("\n", " ") - "\n ā”ƒ $clean" - } ?: "" - - "━━━ #${idx + 1} $header$sourceTag$titleLine$modelLine$matchedLine$countLine$snippetLine" - }?.joinToString("\n") - ?.takeIf { it.isNotEmpty() } - - // Format messages (scroll / read) - val anchorId = - dataSource - .get("around_message_id") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() - val formattedMessages = - messagesArray - ?.mapNotNull { element -> - if (!element.isJsonObject) return@mapNotNull null - val item = element.asJsonObject - val msgId = - item - .get("id") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() - val role = item.get("role")?.takeIf { !it.isJsonNull }?.asString ?: "?" - val content = item.get("content")?.takeIf { !it.isJsonNull }?.asString ?: "" - val toolName = item.get("tool_name")?.takeIf { !it.isJsonNull }?.asString - - val roleEmoji = - when (role) { - "user" -> "šŸ‘¤" - "assistant" -> "šŸ¤–" - "tool" -> "šŸ”§" - else -> "ā“" - } - val namePart = if (toolName != null) " ($toolName)" else "" - val anchor = if (anchorId != null && msgId == anchorId) " ā¬…ļø" else "" - val cleanContent = content.take(300).replace("\n", " ") - - "[$msgId] $roleEmoji $role$namePart$anchor\n $cleanContent" - }?.joinToString("\n") - ?.takeIf { it.isNotEmpty() } - - val ssMainOutput = formattedResults ?: formattedMessages - - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - - return ParsedToolData( - toolName = resolvedToolName, - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = ssSummaryText, - mainOutput = ssMainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Memory-specific formatting ── - if (resolvedToolName == "memory") { - val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: "" - val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true - val target = argsObj?.get("target")?.takeIf { !it.isJsonNull }?.asString - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - val usage = dataSource.get("usage")?.takeIf { !it.isJsonNull }?.asString - - val summaryText = - when (action.lowercase()) { - "add" -> "🧠 Memory Saved${target?.let { " ($it)" } ?: ""}" - "replace", "update" -> "🧠 Memory Updated${target?.let { " ($it)" } ?: ""}" - "remove", "delete" -> "🧠 Memory Removed${target?.let { " ($it)" } ?: ""}" - else -> "🧠 Memory $action${target?.let { " ($it)" } ?: ""}" - } - val mainOutput = - when { - errorMsg != null -> "āŒ $errorMsg" - - !success -> "āŒ Failed" - - usage != null -> "āœ… $action in ${target ?: "memory"}\n $usage" - - else -> "āœ… ${target?.replaceFirstChar { - if (it.isLowerCase()) { - it.titlecase( - java.util.Locale.getDefault(), - ) - } else { - it.toString() - } - } ?: "Memory"} $action done" - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Skill Manage-specific formatting (Self-Improvement) ── - if (resolvedToolName == "skill_manage") { - val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: "" - val name = argsObj?.get("name")?.takeIf { !it.isJsonNull }?.asString - val category = argsObj?.get("category")?.takeIf { !it.isJsonNull }?.asString - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true - val msg = dataSource.get("message")?.takeIf { !it.isJsonNull }?.asString - - val summaryText = - when (action.lowercase()) { - "create" -> "⚔ Skill Created${name?.let { ": $it" } ?: ""}" - "patch" -> "⚔ Skill Patched${name?.let { ": $it" } ?: ""}" - "edit" -> "⚔ Skill Edited${name?.let { ": $it" } ?: ""}" - "delete" -> "⚔ Skill Archived${name?.let { ": $it" } ?: ""}" - "write_file" -> "⚔ Skill File Written${name?.let { ": $it" } ?: ""}" - "remove_file" -> "⚔ Skill File Removed${name?.let { ": $it" } ?: ""}" - else -> - "⚔ Skill ${action.replaceFirstChar { - if (it.isLowerCase()) it.titlecase(java.util.Locale.getDefault()) else it.toString() - }}${name?.let { ": $it" } ?: ""}" - } - val mainOutput = - when { - errorMsg != null -> "āŒ $errorMsg" - !success -> "āŒ Skill operation failed" - msg != null -> "āœ… $msg" - name != null -> "āœ… Skill '$name' $action succeeded${category?.let { " [$it]" } ?: ""}" - else -> "āœ… Skill $action done" - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Cronjob-specific formatting ── - if (resolvedToolName == "cronjob") { - val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: "" - val jobsArray = dataSource.get("jobs")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - val jobId = - dataSource - .get("job_id") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() - val jobName = dataSource.get("name")?.takeIf { !it.isJsonNull }?.asString - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - - val summaryText = "šŸ”„ $action${jobName?.let { ": $it" } ?: jobId?.let { " (ID: $it)" } ?: ""}" - val mainOutput = - when { - errorMsg != null -> { - "āŒ $errorMsg" - } - - jobsArray != null -> { - jobsArray - .mapNotNull { el -> - if (!el.isJsonObject) return@mapNotNull null - val j = el.asJsonObject - val name = j.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "?" - val schedule = j.get("schedule")?.takeIf { !it.isJsonNull }?.asString - val status = j.get("status")?.takeIf { !it.isJsonNull }?.asString - val lastRun = j.get("last_run")?.takeIf { !it.isJsonNull }?.asString - val parts = mutableListOf("šŸ“Œ $name") - if (schedule != null) parts.add(" ā± $schedule") - if (status != null) parts.add(" šŸ“Š $status") - if (lastRun != null) parts.add(" ā® $lastRun") - parts.joinToString("\n") - }.joinToString("\n\n") - } - - jobId != null -> { - "āœ… $action (ID: $jobId)" - } - - else -> { - "āœ… $action done" - } - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Web Search-specific formatting ── - if (resolvedToolName == "web_search") { - val query = argsObj?.get("query")?.takeIf { !it.isJsonNull }?.asString ?: "" - val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - val webArr = - dataSource - .get("data") - ?.takeIf { !it.isJsonNull && it.isJsonObject } - ?.asJsonObject - ?.get("web") - ?.takeIf { !it.isJsonNull && it.isJsonArray } - ?.asJsonArray - - val summaryText = "🌐 $query${webArr?.let { " (${it.size()} results)" } ?: ""}" - val mainOutput = - when { - errorMsg != null -> { - "āŒ $errorMsg" - } - - webArr != null -> { - webArr - .mapNotNull { el -> - if (!el.isJsonObject) return@mapNotNull null - val item = el.asJsonObject - val title = item.get("title")?.takeIf { !it.isJsonNull }?.asString ?: "" - val url = item.get("url")?.takeIf { !it.isJsonNull }?.asString - val desc = item.get("description")?.takeIf { !it.isJsonNull }?.asString - val pos = - item - .get("position") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() - val lines = mutableListOf("${pos?.let { "$it. " } ?: ""}$title") - if (desc != null) lines.add(" $desc") - if (url != null) lines.add(" šŸ”— $url") - lines.joinToString("\n") - }.joinToString("\n\n") - } - - !success -> { - "āŒ Search failed" - } - - else -> { - "No results" - } - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Skills List-specific formatting ── - if (resolvedToolName == "skills_list") { - val category = argsObj?.get("category")?.takeIf { !it.isJsonNull }?.asString - val skillsArr = - (dataSource.get("skills") ?: dataSource.get("results")) - ?.takeIf { !it.isJsonNull && it.isJsonArray } - ?.asJsonArray - - val summaryText = "šŸ“š ${skillsArr?.size() ?: 0} skills${category?.let { " ($it)" } ?: ""}" - val mainOutput = - skillsArr - ?.mapNotNull { el -> - if (!el.isJsonObject) return@mapNotNull null - val s = el.asJsonObject - val name = s.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "" - val desc = s.get("description")?.takeIf { !it.isJsonNull }?.asString - val cat = s.get("category")?.takeIf { !it.isJsonNull }?.asString - val lines = mutableListOf("šŸ“Œ $name") - if (desc != null) lines.add(" $desc") - if (cat != null) lines.add(" [$cat]") - lines.joinToString("\n") - }?.joinToString("\n\n") ?: "šŸ“š No skills found" - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Skill View-specific formatting ── - if (resolvedToolName == "skill_view") { - val name = argsObj?.get("name")?.takeIf { !it.isJsonNull }?.asString - val content = dataSource.get("content")?.takeIf { !it.isJsonNull }?.asString - val linkedFiles = dataSource.get("linked_files")?.takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject - - val summaryText = "šŸ“– $name" - val contentPreview = - content?.let { c -> - if (c.length > 500) c.take(500) + "\n... [${c.length - 500} more chars]" else c - } - val filesInfo = - linkedFiles?.entrySet()?.joinToString("\n") { (k, v) -> - val paths = - when { - v.isJsonArray -> v.asJsonArray.joinToString(", ") { it.asString } - v.isJsonPrimitive -> v.asString - else -> v.toString() - } - " šŸ“Ž $k: $paths" - } - val mainOutput = - buildString { - if (contentPreview != null) append(contentPreview) - if (filesInfo != null) append("\n\n━━━ Linked Files ━━━\n$filesInfo") - if (content == null && linkedFiles == null) append("šŸ“– No content available") - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Process-specific formatting ── - if (resolvedToolName == "process") { - val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: "" - val procId = argsObj?.get("session_id")?.takeIf { !it.isJsonNull }?.asString - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - val status = dataSource.get("status")?.takeIf { !it.isJsonNull }?.asString - val outputText = dataSource.get("output")?.takeIf { !it.isJsonNull }?.asString - val processesArr = dataSource.get("processes")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - - val summaryText = "āš™ļø $action${procId?.let { ": $it" } ?: ""}" - val mainOutput = - when { - errorMsg != null -> { - "āŒ $errorMsg" - } - - processesArr != null -> { - processesArr - .mapNotNull { el -> - if (!el.isJsonObject) return@mapNotNull null - val p = el.asJsonObject - val pid = - p - .get( - "session_id", - )?.takeIf { !it.isJsonNull } - ?.asString ?: p.get("id")?.asString ?: "?" - val pStatus = p.get("status")?.takeIf { !it.isJsonNull }?.asString - val cmd = p.get("command")?.takeIf { !it.isJsonNull }?.asString - val running = p.get("running")?.takeIf { !it.isJsonNull }?.asBoolean - val parts = mutableListOf("šŸ“Œ $pid${cmd?.let { ": $it" } ?: ""}") - if (pStatus != null) parts.add(" Status: $pStatus") - if (running != null) parts.add(" Running: $running") - parts.joinToString("\n") - }.joinToString("\n\n") - } - - outputText != null -> { - "${status?.let { "Status: $it\n" } ?: ""}$outputText" - } - - status != null -> { - "āœ… $status" - } - - else -> { - "āœ… $action done" - } - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── X Search-specific formatting ── - if (resolvedToolName == "x_search") { - val query = argsObj?.get("query")?.takeIf { !it.isJsonNull }?.asString ?: "" - val answer = dataSource.get("answer")?.takeIf { !it.isJsonNull }?.asString - val citationsArr = dataSource.get("citations")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - val degraded = dataSource.get("degraded")?.takeIf { !it.isJsonNull }?.asBoolean - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - - val summaryText = "š• $query${if (degraded == true) " (no citations)" else ""}" - val mainOutput = - when { - errorMsg != null -> { - "āŒ $errorMsg" - } - - answer != null -> { - val lines = mutableListOf(answer) - if (citationsArr != null && citationsArr.size() > 0) { - lines.add("\n━━━ Citations ━━━") - citationsArr.forEachIndexed { idx, cit -> - if (cit.isJsonObject) { - val url = cit.asJsonObject.get("url")?.asString - val title = cit.asJsonObject.get("title")?.asString - lines.add("${idx + 1}. ${title ?: url ?: "source"}") - if (title != null && url != null) lines.add(" $url") - } - } - } - if (degraded == true) lines.add("\nāš ļø No citations — answer based on model's knowledge") - lines.joinToString("\n") - } - - else -> { - "š• No results" - } - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Vision Analyze-specific formatting ── - if (resolvedToolName == "vision_analyze") { - val imageUrl = argsObj?.get("image_url")?.takeIf { !it.isJsonNull }?.asString ?: "" - val description = dataSource.get("description")?.takeIf { !it.isJsonNull }?.asString - val content = dataSource.get("content")?.takeIf { !it.isJsonNull }?.asString - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - - val summaryText = "šŸ‘ļø ${imageUrl.take(60)}" - val mainOutput = - when { - errorMsg != null -> "āŒ $errorMsg" - description != null -> description - content != null -> content - else -> "šŸ‘ļø No description available" - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Web Extract-specific formatting ── - if (resolvedToolName == "web_extract") { - val urls = argsObj?.get("urls")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - val content = dataSource.get("content")?.takeIf { !it.isJsonNull }?.asString - val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - - val urlSummary = urls?.joinToString(", ") { it.asString.take(40) } ?: "" - val summaryText = "šŸ•øļø ${urls?.size() ?: 0} page(s)" - val mainOutput = - when { - errorMsg != null -> { - "āŒ $errorMsg" - } - - content != null -> { - content.take( - 3000, - ) + if (content.length > 3000) "\n... [${content.length - 3000} more chars]" else "" - } - - else -> { - "šŸ•øļø No content extracted" - } - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Tool Search-specific formatting ── - if (resolvedToolName == "tool_search") { - val query = argsObj?.get("query")?.takeIf { !it.isJsonNull }?.asString ?: "" - val limit = - argsObj - ?.get("limit") - ?.takeIf { !it.isJsonNull } - ?.asDouble - ?.toInt() - val matchesArr = dataSource.get("matches")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - - val summaryText = "šŸ” $query${matchesArr?.let { " (${it.size()} matches)" } ?: ""}" - val mainOutput = - matchesArr - ?.mapNotNull { el -> - if (!el.isJsonObject) return@mapNotNull null - val m = el.asJsonObject - val name = m.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "?" - val desc = m.get("description")?.takeIf { !it.isJsonNull }?.asString - val lines = mutableListOf("šŸ”§ $name") - if (desc != null) lines.add(" $desc") - lines.joinToString("\n") - }?.joinToString("\n\n") ?: "šŸ” No matching tools" - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Image Generate-specific formatting ── - if (resolvedToolName == "image_generate") { - val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - val imageUrl = dataSource.get("image")?.takeIf { !it.isJsonNull }?.asString - val argsImageUrl = argsObj?.get("image_url")?.takeIf { !it.isJsonNull }?.asString - val modality = dataSource.get("modality")?.takeIf { !it.isJsonNull }?.asString - val promptUrl = argsObj?.get("prompt")?.takeIf { !it.isJsonNull }?.asString - - val summaryText = - buildString { - append("šŸŽØ ") - if (errorMsg != null) { - append("āŒ $errorMsg") - } else { - append((imageUrl ?: argsImageUrl ?: "").take(60)) - if (modality != null) append(" ($modality)") - } - } - val mainOutput = - buildString { - if (errorMsg != null) { - append("āŒ $errorMsg") - if (imageUrl != null) append("\nšŸ”— $imageUrl") - } else if (imageUrl != null) { - append("šŸ–¼ļø ") - if (promptUrl != null) append("$promptUrl\n") - append("\nšŸ”— $imageUrl") - } else { - append("āœ… Generated") - } - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Project List-specific formatting ── - if (resolvedToolName == "project_list") { - val activeId = dataSource.get("active_id")?.takeIf { !it.isJsonNull }?.asString - val projectsArr = dataSource.get("projects")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - - val summaryText = "šŸ“‚ ${projectsArr?.size() ?: "?"} projects${activeId?.let { " (1 active)" } ?: ""}" - val mainOutput = - if (projectsArr != null) { - projectsArr - .mapNotNull { el -> - if (!el.isJsonObject) return@mapNotNull null - val p = el.asJsonObject - val id = p.get("id")?.takeIf { !it.isJsonNull }?.asString ?: "" - val name = p.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "" - val slug = p.get("slug")?.takeIf { !it.isJsonNull }?.asString - val path = p.get("primary_path")?.takeIf { !it.isJsonNull }?.asString - val isActive = p.get("active")?.takeIf { !it.isJsonNull }?.asBoolean ?: false - val lines = mutableListOf("${if (isActive) "⭐ " else "šŸ“ "}$name") - if (slug != null) lines.add(" šŸ·ļø $slug") - if (path != null) lines.add(" šŸ“ $path") - if (isActive) lines.add(" āœ… ACTIVE PROJECT") - lines.joinToString("\n") - }.joinToString("\n\n") - } else { - "" - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Project Create/Switch-specific formatting ── - if (resolvedToolName == "project_create" || resolvedToolName == "project_switch") { - val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - val projName = dataSource.get("name")?.takeIf { !it.isJsonNull }?.asString - val projSlug = dataSource.get("slug")?.takeIf { !it.isJsonNull }?.asString - val projPath = dataSource.get("primary_path")?.takeIf { !it.isJsonNull }?.asString - val actionLabel = if (resolvedToolName == "project_create") "Created" else "Switched to" - - val summaryText = "šŸ“‚ $actionLabel${projName?.let { " $it" } ?: ""}" - val mainOutput = - buildString { - if (errorMsg != null) { - append("āŒ $errorMsg") - } else { - append("āœ… $actionLabel") - if (projName != null) append(": $projName") - if (projSlug != null) append("\n šŸ·ļø $projSlug") - if (projPath != null) append("\n šŸ“ $projPath") - } - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Read Terminal-specific formatting ── - if (resolvedToolName == "read_terminal") { - val totalLines = dataSource.get("total_lines")?.takeIf { !it.isJsonNull }?.asInt ?: 0 - val startLine = dataSource.get("start")?.takeIf { !it.isJsonNull }?.asInt ?: 0 - val endLine = dataSource.get("end")?.takeIf { !it.isJsonNull }?.asInt ?: 0 - val cursorRow = dataSource.get("cursor_row")?.takeIf { !it.isJsonNull }?.asInt - val text = dataSource.get("text")?.takeIf { !it.isJsonNull }?.asString - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - - val summaryText = - buildString { - append("šŸ–„ļø ") - if (errorMsg != null) { - append("āŒ $errorMsg") - } else if (text != null) { - append("Lines $startLine-$endLine of $totalLines") - if (cursorRow != null) append(", cursor at row $cursorRow") - } else { - append("Terminal output") - } - } - val mainOutput = - buildString { - if (errorMsg != null) { - append("āŒ $errorMsg") - } else if (text != null) { - append("━━━ Terminal ━━━ ($startLine:$endLine / $totalLines)") - if (cursorRow != null) append(" │ cursor row $cursorRow") - append("\n$text") - } else { - append("šŸ–„ļø No terminal output") - } - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = summaryText, - mainOutput = mainOutput, - durationSec = duration, - isRunning = isRunning, - ) - } - - // ── Computer Use-specific formatting ── - if (resolvedToolName == "computer_use") { - val action = dataSource.get("action")?.takeIf { !it.isJsonNull }?.asString - val ok = dataSource.get("ok")?.takeIf { !it.isJsonNull }?.asBoolean - val message = dataSource.get("message")?.takeIf { !it.isJsonNull }?.asString - val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString - val isMultimodal = dataSource.get("_multimodal")?.takeIf { !it.isJsonNull }?.asBoolean ?: false - val mode = dataSource.get("mode")?.takeIf { !it.isJsonNull }?.asString - val elementsArr = dataSource.get("elements")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - val appsArr = dataSource.get("apps")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - val summaryText = dataSource.get("text_summary")?.takeIf { !it.isJsonNull }?.asString - val textSummary = dataSource.get("summary")?.takeIf { !it.isJsonNull }?.asString - val visionAnalysis = dataSource.get("vision_analysis")?.takeIf { !it.isJsonNull }?.asString - val totalElements = dataSource.get("total_elements")?.takeIf { !it.isJsonNull }?.asInt - val width = dataSource.get("width")?.takeIf { !it.isJsonNull }?.asInt - val height = dataSource.get("height")?.takeIf { !it.isJsonNull }?.asInt - - // Try to extract text from multimodal content array - val multimodalText = - if (isMultimodal) { - val contentArr = - dataSource.get("content")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray - contentArr - ?.mapNotNull { el -> - if (el.isJsonObject) { - val eObj = el.asJsonObject - if (eObj.get("type")?.takeIf { !it.isJsonNull }?.asString == "text") { - eObj.get("text")?.takeIf { !it.isJsonNull }?.asString - } else { - null - } - } else { - null - } - }?.joinToString("\n") - } else { - null - } - - val summaryLine = - buildString { - append("šŸ–„ļø ") - if (errorMsg != null) { - append("āŒ $errorMsg") - } else if (appsArr != null) { - append("${appsArr.size()} apps") - } else if (elementsArr != null) { - append("${elementsArr.size()} elements${mode?.let { " ($it)" } ?: ""}") - if (width != null && height != null) append(" ${width}x$height") - } else if (action != null) { - append("$action${ok?.let { if (it) " āœ“" else " āœ—" } ?: ""}") - } else { - append("Computer use") - } - } - val mainBody = - buildString { - if (errorMsg != null) { - append("āŒ $errorMsg") - } else { - val textPart = multimodalText ?: summaryText ?: textSummary - if (textPart != null) { - append(textPart) - if (textPart == multimodalText && textSummary != null && multimodalText != textSummary) { - append("\n\n$textSummary") - } - } - - if (appsArr != null) { - if (isNotEmpty()) append("\n\n") - appsArr.forEachIndexed { idx, el -> - if (el.isJsonObject) { - val obj = el.asJsonObject - val name = - obj - .get("name") - ?.takeIf { !it.isJsonNull } - ?.asString ?: "?" - val pid = - obj - .get("pid") - ?.takeIf { !it.isJsonNull } - ?.asInt - append("${idx + 1}. $name") - if (pid != null) append(" (PID: $pid)") - append("\n") - } - } - } else if (elementsArr != null) { - if (isNotEmpty()) append("\n\n") - append("šŸ–„ļø ${elementsArr.size()} interactable elements") - if (mode != null) append(" (mode: $mode)") - if (width != null && height != null) append(" • ${width}x$height") - if (textSummary != null) append("\n\n$textSummary") - // Show first few elements as preview - val previewCount = minOf(elementsArr.size(), 5) - if (previewCount > 0) { - append("\n\n") - for (i in 0 until previewCount) { - val el = elementsArr[i] - if (el.isJsonObject) { - val obj = el.asJsonObject - val idx = - obj - .get("index") - ?.takeIf { !it.isJsonNull } - ?.asInt - val role = - obj - .get("role") - ?.takeIf { !it.isJsonNull } - ?.asString - val label = - obj - .get("label") - ?.takeIf { !it.isJsonNull } - ?.asString - append(" #$idx ${role ?: ""}${label?.let { " '$it'" } ?: ""}\n") - } - } - if (elementsArr.size() > previewCount) { - append(" ... and ${elementsArr.size() - previewCount} more") - } - } - } else if (action != null && - (multimodalText == null && summaryText == null && textSummary == null) - ) { - append("$action:") - if (ok != null) append(" ${if (ok) "āœ… ok" else "āŒ failed"}") - if (message != null) append("\n$message") - } else if (isEmpty()) { - append("šŸ–„ļø Computer use action") - } - } - if (visionAnalysis != null) { - append("\n\n━━━ Vision Analysis ━━━\n$visionAnalysis") - } - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - return ParsedToolData( - toolName = resolvedToolName, - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = summaryLine, - mainOutput = mainBody, - durationSec = duration, - isRunning = isRunning, - ) - } - - // Terminal result. The backend (tools/terminal_tool.py) emits a single - // `output` string (stdout+stderr merged, ANSI-stripped), plus `exit_code` - // and an `error` string on failure. Older payloads used `stdout`/`stderr` - // — accept both for backward compat, but `output` is canonical. - val hasOutput = dataSource.has("output") - val hasStdout = dataSource.has("stdout") - val hasStderr = dataSource.has("stderr") - val hasExitCode = dataSource.has("exit_code") || dataSource.has("exitCode") - - val isTerminalTool = - resolvedToolName == "terminal" || - resolvedToolName == "bash" || - resolvedToolName == "sh" || - resolvedToolName == "cmd" || - resolvedToolName == "exec" - - val isDiffTool = - resolvedToolName == "patch" || - resolvedToolName == "write_file" || - resolvedToolName == "edit" - - if (!isDiffTool && (isTerminalTool || hasOutput || hasStdout || hasStderr || hasExitCode)) { - val terminalOutput = - ( - dataSource.get("output")?.takeIf { !it.isJsonNull }?.asString - ?: listOfNotNull( - dataSource - .get("stdout") - ?.takeIf { !it.isJsonNull } - ?.asString - ?.takeIf { it.isNotEmpty() }, - dataSource - .get("stderr") - ?.takeIf { !it.isJsonNull } - ?.asString - ?.takeIf { it.isNotEmpty() }, - ).joinToString("\n").takeIf { it.isNotEmpty() } - )?.takeIf { it.isNotEmpty() } - val exitCode = (dataSource.get("exit_code") ?: dataSource.get("exitCode"))?.toIntOrNull() - val error = - dataSource - .get("error") - ?.takeIf { !it.isJsonNull } - ?.asString - ?.takeIf { it.isNotEmpty() } - // Surface non-zero exit codes as an error line even when `error` is absent. - val effectiveError = - if (error != null) { - error - } else if (exitCode != null && exitCode != 0 && terminalOutput == null) { - "exit code $exitCode" - } else { - null - } - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - - ParsedToolData( - toolName = resolvedToolName ?: "", - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - isTerminal = true, - stdout = terminalOutput, - stderr = null, - exitCode = exitCode, - error = effectiveError, - summaryText = summaryText, - mainOutput = terminalOutput, - durationSec = duration, - isRunning = isRunning, - ) - } else { - // Generic tool display - val mainOutput = - dataSource - .let { r -> - (r.get("output") ?: r.get("result") ?: r.get("content") ?: r.get("text")) - ?.takeIf { !it.isJsonNull } - ?.let { if (it.isJsonPrimitive) it.asString else it.toString() } - ?.takeIf { it.isNotEmpty() } - } - - val extraFields = mutableMapOf() - dataSource.entrySet().forEach { (key, value) -> - if (key != "output" && key != "result" && key != "content" && key != "text" && - key != "name" && key != "tool_id" && key != "context" && !value.isJsonNull - ) { - val valStr = if (value.isJsonPrimitive) value.asString else value.toString() - if (valStr.isNotEmpty() && valStr != "null") { - extraFields[key] = valStr - } - } - } - - val diffCandidate = - dataSource.get("diff")?.takeIf { !it.isJsonNull }?.let { - if (it.isJsonPrimitive) it.asString else it.toString() - } - ?: dataSource.get("patch")?.takeIf { !it.isJsonNull }?.let { - if (it.isJsonPrimitive) it.asString else it.toString() - } - ?: mainOutput - - val filePathArg = - args["path"]?.toString() - ?: argsObj?.get("path")?.takeIf { !it.isJsonNull }?.asString - - val oldStrArg = args["old_string"]?.toString() - val newStrArg = args["new_string"]?.toString() - - val isDiffTool = - resolvedToolName == "patch" || - resolvedToolName == "edit" || - resolvedToolName == "write_file" - - val extractedDiffOutput = - when { - diffCandidate != null && ( - diffCandidate.contains("\n-") || - diffCandidate.contains("\n+") || - diffCandidate.startsWith("--- ") || - diffCandidate.startsWith("+++ ") || - diffCandidate.startsWith("*** ") || - diffCandidate.startsWith("@@ ") - ) -> diffCandidate - - isDiffTool && oldStrArg != null && newStrArg != null -> { - val pathHeader = filePathArg ?: "file" - "--- $pathHeader\n+++ $pathHeader\n@@ -1,1 +1,1 @@\n-$oldStrArg\n+$newStrArg" - } - - isDiffTool && args["patch"] != null -> { - args["patch"].toString() - } - - else -> null - } - - val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble - - ParsedToolData( - toolName = resolvedToolName ?: "", - args = args, - result = dataSource.entrySet().associate { it.key to it.value.toString() }, - summaryText = summaryText, - mainOutput = mainOutput, - extraFields = extraFields, - durationSec = duration, - isRunning = isRunning, - diffOutput = extractedDiffOutput, - diffPath = filePathArg, - ) - } - } catch (e: Exception) { - null - } -} - -@Composable -private fun ExpandedToolContent( - parsed: ParsedToolData, - contentColor: Color, - statusColors: HermesStatusColors, -) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - // Summary line at top of expanded view - if (parsed.summaryText != null) { - Text( - text = parsed.summaryText, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.7f), - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - ) - Spacer(modifier = Modifier.height(2.dp)) - } - - if (parsed.isTerminal) { - // ── Terminal output ── - parsed.stdout?.let { - Text( - text = it, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.9f), - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - ) - } - parsed.error?.let { - Text( - text = stringResource(R.string.chat_tool_execution_error, it), - style = - MaterialTheme.typography.bodySmall.copy( - color = statusColors.error, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - ) - } - parsed.exitCode?.let { code -> - if (code != 0) { - Text( - text = stringResource(R.string.chat_tool_exit_code, code), - style = - MaterialTheme.typography.labelSmall.copy( - color = statusColors.error, - fontWeight = FontWeight.Medium, - ), - ) - } - } - - // Duration footer for terminal - parsed.durationSec?.let { dur -> - Text( - text = "Duration: ${"%.1f".format(dur)}s", - style = - MaterialTheme.typography.labelSmall.copy( - color = contentColor.copy(alpha = 0.5f), - ), - ) - } - } else { - // ── Generic tool output / Diff view ── - if (parsed.diffOutput != null) { - DiffViewCard( - diffText = parsed.diffOutput, - filePath = parsed.diffPath, - ) - } else { - parsed.mainOutput?.let { - Text( - text = it, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.9f), - fontSize = 12.sp, - ), - ) - } - parsed.extraFields.forEach { (key, value) -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = "$key:", - style = - MaterialTheme.typography.labelSmall.copy( - color = contentColor.copy(alpha = 0.6f), - fontWeight = FontWeight.Bold, - ), - ) - Text( - text = value, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.9f), - fontSize = 11.sp, - ), - ) - } - } - } - } - // Duration footer - parsed.durationSec?.let { dur -> - Text( - text = "Duration: ${"%.1f".format(dur)}s", - style = - MaterialTheme.typography.labelSmall.copy( - color = contentColor.copy(alpha = 0.5f), - ), - ) - } - } -} - -@Composable -private fun ToolBubble( - message: ChatMessage, - isDarkTheme: Boolean, - modifier: Modifier = Modifier, -) { - var expanded by remember { mutableStateOf(false) } - var showRawJson by remember { mutableStateOf(false) } - val chipColor = MaterialTheme.colorScheme.surfaceContainerHigh - val contentColor = MaterialTheme.colorScheme.onSurfaceVariant - val statusColors = LocalHermesStatusColors.current - - val parsed = - remember(message.content, message.toolName, message.toolStatus) { - parseToolOutput(message.content, message.toolName, message.toolStatus == ToolStatus.RUNNING) - } - val config = ToolSchemaRegistry.getDisplayConfig(message.toolName) - - val clipboard = LocalClipboard.current - val scope = rememberCoroutineScope() - var showCopyButton by remember { mutableStateOf(false) } - - // Auto-dismiss copy button after 4 seconds - LaunchedEffect(showCopyButton) { - if (showCopyButton) { - delay(4000) - showCopyButton = false - } - } - - Box( - modifier = - modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 1.dp), - contentAlignment = Alignment.CenterStart, - ) { - Card( - onClick = { expanded = !expanded }, - colors = CardDefaults.cardColors(containerColor = chipColor), - shape = RoundedCornerShape(8.dp), - ) { - Column( - modifier = - Modifier - .animateContentSize() - .padding(horizontal = 10.dp, vertical = 6.dp), - ) { - // ── Header row: icon + tool name ── - HeaderRow(message, config, contentColor, statusColors) - - // ── Tool progress preview (tool.progress) ── - if (message.toolStatus == ToolStatus.RUNNING && !message.progressPreview.isNullOrEmpty()) { - Text( - text = message.progressPreview, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.7f), - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 4.dp, start = 22.dp), - ) - } - - // ── Security risk chip (tool.output_risk) ── - val riskData = message.toolOutputRiskData - if (riskData != null && (riskData.risk == "medium" || riskData.risk == "high" || riskData.redacted)) { - SecurityRiskChip(riskData, contentColor) - } - - // ── Summary line (always visible when collapsed) ── - if (!expanded && parsed?.summaryText != null) { - Text( - text = parsed.summaryText, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.7f), - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 4.dp, start = 22.dp), - ) - } - - // ── Expanded content ── - if (expanded) { - Spacer(modifier = Modifier.height(6.dp)) - - if (showRawJson) { - // Raw JSON view — selectable + copy button - Box { - SelectionContainer { - Text( - text = message.content, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.8f), - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - ) - } - CopyButton( - visible = showCopyButton, - textToCopy = message.content, - onCopy = { showCopyButton = false }, - modifier = - Modifier - .align(Alignment.TopEnd) - .offset(x = 8.dp, y = (-8).dp), - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.chat_tool_show_parsed), - style = - MaterialTheme.typography.labelSmall.copy( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline, - ), - modifier = - Modifier - .testTag("chat_tool_show_parsed") - .clickable(role = Role.Button) { showRawJson = false }, - ) - } else if (parsed != null) { - // Clean structured expanded view - Box { - SelectionContainer { - ExpandedToolContent(parsed, contentColor, statusColors) - } - CopyButton( - visible = showCopyButton, - textToCopy = message.content, - onCopy = { showCopyButton = false }, - modifier = - Modifier - .align(Alignment.TopEnd) - .offset(x = 8.dp, y = (-8).dp), - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.chat_tool_show_raw), - style = - MaterialTheme.typography.labelSmall.copy( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline, - ), - modifier = - Modifier - .testTag("chat_tool_show_raw") - .clickable(role = Role.Button) { showRawJson = true }, - ) - } else { - // Unparseable content — show raw JSON, selectable - Box { - SelectionContainer { - Text( - text = message.content, - style = - MaterialTheme.typography.bodySmall.copy( - color = contentColor.copy(alpha = 0.8f), - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - ), - ) - } - CopyButton( - visible = showCopyButton, - textToCopy = message.content, - onCopy = { showCopyButton = false }, - modifier = - Modifier - .align(Alignment.TopEnd) - .offset(x = 8.dp, y = (-8).dp), - ) - } - } - } - - // ── Timestamp ── - Text( - text = formatTimestamp(message.timestamp, DateFormat.is24HourFormat(LocalContext.current)), - color = contentColor.copy(alpha = 0.5f), - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.align(Alignment.End).padding(top = 4.dp), - ) - } - } - } -} - -@Composable -private fun HeaderRow( - message: ChatMessage, - config: ToolDisplayConfig, - contentColor: Color, - statusColors: HermesStatusColors, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Status icon or spinner - if (message.toolStatus == ToolStatus.RUNNING) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.secondary, - ) - } else { - val icon = - when (message.toolStatus) { - ToolStatus.COMPLETED -> Icons.Filled.CheckCircle - ToolStatus.FAILED -> Icons.Filled.Error - else -> Icons.Filled.Build - } - val tint = - when (message.toolStatus) { - ToolStatus.COMPLETED -> statusColors.success - ToolStatus.FAILED -> statusColors.error - else -> contentColor.copy(alpha = 0.6f) - } - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(14.dp), - tint = tint, - ) - } - - Text( - text = message.toolName ?: stringResource(R.string.chat_tool_fallback), - style = - MaterialTheme.typography.labelMedium.copy( - color = contentColor, - fontFamily = FontFamily.Monospace, - ), - ) - } -} - -/** - * Security risk chip for [tool.output_risk] events. - * - * Shows a compact ⚠ badge when the backend flagged tool output as risky. - * Renders in the tool card between the header row and the summary line. - */ -@Composable -private fun SecurityRiskChip( - riskData: ToolOutputRiskData, - contentColor: Color, - modifier: Modifier = Modifier, -) { - val statusColors = LocalHermesStatusColors.current - val (chipColor, label) = - when { - riskData.risk == "high" -> statusColors.error to "Risky output" - riskData.risk == "medium" -> statusColors.warning to "Caution" - else -> statusColors.warning to "Redacted" - } - - Row( - modifier = - modifier - .padding(top = 4.dp, start = 22.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = "⚠", - style = MaterialTheme.typography.labelSmall, - color = chipColor, - ) - Text( - text = label, - style = - MaterialTheme.typography.labelSmall.copy( - fontWeight = FontWeight.Medium, - ), - color = chipColor, - ) - if (riskData.redacted && (riskData.risk == "high" || riskData.risk == "medium")) { - Text( - text = "Ā· redacted", - style = MaterialTheme.typography.labelSmall, - color = chipColor.copy(alpha = 0.7f), - ) - } - } -} - -@Composable -private fun CopyButton( - visible: Boolean, - textToCopy: String, - onCopy: () -> Unit, - modifier: Modifier = Modifier, -) { - val clipboard = LocalClipboard.current - val scope = rememberCoroutineScope() - AnimatedVisibility( - visible = visible, - enter = fadeIn() + scaleIn(), - exit = fadeOut() + scaleOut(), - modifier = modifier, - ) { - Surface( - shape = RoundedCornerShape(50), - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f), - shadowElevation = 6.dp, - ) { - IconButton( - onClick = { - scope.launch { - clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, textToCopy))) - } - onCopy() - }, - modifier = Modifier.size(32.dp), - ) { - Icon( - Icons.Filled.ContentCopy, - contentDescription = stringResource(R.string.content_desc_copy), - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurface, - ) - } - } - } -} - -private fun formatTimestamp( +internal fun formatTimestamp( timestamp: Long, is24Hour: Boolean, ): String { @@ -2537,42 +712,3 @@ private fun InlineAttachment( } } } - -// Extensions for backward compatibility with Gson API under kotlinx.serialization -private val JsonElement.isJsonObject: Boolean get() = this is JsonObject -private val JsonElement.asJsonObject: JsonObject get() = this as JsonObject -private val JsonElement.isJsonArray: Boolean get() = this is JsonArray -private val JsonElement.asJsonArray: JsonArray get() = this as JsonArray -private val JsonElement.isJsonNull: Boolean get() = this is JsonNull -private val JsonElement.isJsonPrimitive: Boolean get() = this is JsonPrimitive -private val JsonElement.asString: String get() = (this as JsonPrimitive).content -private val JsonElement.asDouble: Double get() = (this as JsonPrimitive).double -private val JsonElement.asInt: Int get() = (this as JsonPrimitive).int -private val JsonElement.asBoolean: Boolean get() = (this as JsonPrimitive).boolean - -private fun JsonElement.has(key: String): Boolean = (this as? JsonObject)?.containsKey(key) == true - -private fun JsonObject.entrySet(): Set> = entries - -private fun JsonObject.keySet(): Set = keys - -private fun JsonArray.size(): Int = size - -/** - * Parse a numeric/string JSON primitive into an Int, tolerating backend quirks - * (e.g. terminal `exit_code` arrives as a FLOAT `0.0` or a STRING). Returns null - * if missing, null, or non-numeric — callers must not throw, or the whole tool - * parse is caught and the bubble falls back to raw JSON. - */ -private fun JsonElement?.toIntOrNull(): Int? { - if (this == null || this is JsonNull) return null - return when (this) { - is JsonPrimitive -> { - content.toIntOrNull() ?: content.toDoubleOrNull()?.toInt() - } - - else -> { - null - } - } -} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ParsedToolData.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ParsedToolData.kt new file mode 100644 index 00000000..6635b064 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ParsedToolData.kt @@ -0,0 +1,20 @@ +package com.m57.hermescontrol.ui.chat + +/** + * Cleanly parsed representation of a tool call, extracted from the + * tool.complete JSON payload (which contains both args and result). + */ +data class ParsedToolData( + val toolName: String = "", + val isTerminal: Boolean = false, + val stdout: String? = null, + val exitCode: Int? = null, + val error: String? = null, + val summaryText: String? = null, + val durationSec: Double? = null, + val mainOutput: String? = null, + val extraFields: Map = emptyMap(), + val isRunning: Boolean = false, + val diffOutput: String? = null, + val diffPath: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt new file mode 100644 index 00000000..17eb128d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt @@ -0,0 +1,528 @@ +package com.m57.hermescontrol.ui.chat + +import android.content.ClipData +import android.text.format.DateFormat +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors +import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.chat.components.DiffViewCard +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +private fun ExpandedToolContent( + parsed: ParsedToolData, + contentColor: Color, + statusColors: HermesStatusColors, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + // Summary line at top of expanded view + if (parsed.summaryText != null) { + Text( + text = parsed.summaryText, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.7f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + ) + Spacer(modifier = Modifier.height(2.dp)) + } + + if (parsed.isTerminal) { + // ── Terminal output ── + parsed.stdout?.let { + Text( + text = it, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.9f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + ) + } + parsed.error?.let { + Text( + text = stringResource(R.string.chat_tool_execution_error, it), + style = + MaterialTheme.typography.bodySmall.copy( + color = statusColors.error, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + ) + } + parsed.exitCode?.let { code -> + if (code != 0) { + Text( + text = stringResource(R.string.chat_tool_exit_code, code), + style = + MaterialTheme.typography.labelSmall.copy( + color = statusColors.error, + fontWeight = FontWeight.Medium, + ), + ) + } + } + + // Duration footer for terminal + parsed.durationSec?.let { dur -> + Text( + text = "Duration: ${"%.1f".format(dur)}s", + style = + MaterialTheme.typography.labelSmall.copy( + color = contentColor.copy(alpha = 0.5f), + ), + ) + } + } else { + // ── Generic tool output / Diff view ── + if (parsed.diffOutput != null) { + DiffViewCard( + diffText = parsed.diffOutput, + filePath = parsed.diffPath, + ) + } else { + parsed.mainOutput?.let { + Text( + text = it, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.9f), + fontSize = 12.sp, + ), + ) + } + parsed.extraFields.forEach { (key, value) -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "$key:", + style = + MaterialTheme.typography.labelSmall.copy( + color = contentColor.copy(alpha = 0.6f), + fontWeight = FontWeight.Bold, + ), + ) + Text( + text = value, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.9f), + fontSize = 11.sp, + ), + ) + } + } + } + } + // Duration footer + parsed.durationSec?.let { dur -> + Text( + text = "Duration: ${"%.1f".format(dur)}s", + style = + MaterialTheme.typography.labelSmall.copy( + color = contentColor.copy(alpha = 0.5f), + ), + ) + } + } +} + +@Composable +internal fun ToolBubble( + message: ChatMessage, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + var showRawJson by remember { mutableStateOf(false) } + val chipColor = MaterialTheme.colorScheme.surfaceContainerHigh + val contentColor = MaterialTheme.colorScheme.onSurfaceVariant + val statusColors = LocalHermesStatusColors.current + + val parsed = + remember(message.content, message.toolName, message.toolStatus) { + parseToolOutput(message.content, message.toolName, message.toolStatus == ToolStatus.RUNNING) + } + val config = ToolSchemaRegistry.getDisplayConfig(message.toolName) + + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + var showCopyButton by remember { mutableStateOf(false) } + + // Auto-dismiss copy button after 4 seconds + LaunchedEffect(showCopyButton) { + if (showCopyButton) { + delay(4000) + showCopyButton = false + } + } + + Box( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 1.dp), + contentAlignment = Alignment.CenterStart, + ) { + Card( + onClick = { expanded = !expanded }, + colors = CardDefaults.cardColors(containerColor = chipColor), + shape = RoundedCornerShape(8.dp), + ) { + Column( + modifier = + Modifier + .animateContentSize() + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + // ── Header row: icon + tool name ── + HeaderRow(message, config, contentColor, statusColors) + + // ── Tool progress preview (tool.progress) ── + if (message.toolStatus == ToolStatus.RUNNING && !message.progressPreview.isNullOrEmpty()) { + Text( + text = message.progressPreview, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.7f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp, start = 22.dp), + ) + } + + // ── Security risk chip (tool.output_risk) ── + val riskData = message.toolOutputRiskData + if (riskData != null && (riskData.risk == "medium" || riskData.risk == "high" || riskData.redacted)) { + SecurityRiskChip(riskData, contentColor) + } + + // ── Summary line (always visible when collapsed) ── + if (!expanded && parsed?.summaryText != null) { + Text( + text = parsed.summaryText, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.7f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp, start = 22.dp), + ) + } + + // ── Expanded content ── + if (expanded) { + Spacer(modifier = Modifier.height(6.dp)) + + if (showRawJson) { + // Raw JSON view — selectable + copy button + Box { + SelectionContainer { + Text( + text = message.content, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.8f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + ) + } + CopyButton( + visible = showCopyButton, + textToCopy = message.content, + onCopy = { showCopyButton = false }, + modifier = + Modifier + .align(Alignment.TopEnd) + .offset(x = 8.dp, y = (-8).dp), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.chat_tool_show_parsed), + style = + MaterialTheme.typography.labelSmall.copy( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + modifier = + Modifier + .testTag("chat_tool_show_parsed") + .clickable(role = Role.Button) { showRawJson = false }, + ) + } else if (parsed != null) { + // Clean structured expanded view + Box { + SelectionContainer { + ExpandedToolContent(parsed, contentColor, statusColors) + } + CopyButton( + visible = showCopyButton, + textToCopy = message.content, + onCopy = { showCopyButton = false }, + modifier = + Modifier + .align(Alignment.TopEnd) + .offset(x = 8.dp, y = (-8).dp), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.chat_tool_show_raw), + style = + MaterialTheme.typography.labelSmall.copy( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + modifier = + Modifier + .testTag("chat_tool_show_raw") + .clickable(role = Role.Button) { showRawJson = true }, + ) + } else { + // Unparseable content — show raw JSON, selectable + Box { + SelectionContainer { + Text( + text = message.content, + style = + MaterialTheme.typography.bodySmall.copy( + color = contentColor.copy(alpha = 0.8f), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + ), + ) + } + CopyButton( + visible = showCopyButton, + textToCopy = message.content, + onCopy = { showCopyButton = false }, + modifier = + Modifier + .align(Alignment.TopEnd) + .offset(x = 8.dp, y = (-8).dp), + ) + } + } + } + + // ── Timestamp ── + Text( + text = formatTimestamp(message.timestamp, DateFormat.is24HourFormat(LocalContext.current)), + color = contentColor.copy(alpha = 0.5f), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.align(Alignment.End).padding(top = 4.dp), + ) + } + } + } +} + +@Composable +private fun HeaderRow( + message: ChatMessage, + config: ToolDisplayConfig, + contentColor: Color, + statusColors: HermesStatusColors, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Status icon or spinner + if (message.toolStatus == ToolStatus.RUNNING) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.secondary, + ) + } else { + val icon = + when (message.toolStatus) { + ToolStatus.COMPLETED -> Icons.Filled.CheckCircle + ToolStatus.FAILED -> Icons.Filled.Error + else -> Icons.Filled.Build + } + val tint = + when (message.toolStatus) { + ToolStatus.COMPLETED -> statusColors.success + ToolStatus.FAILED -> statusColors.error + else -> contentColor.copy(alpha = 0.6f) + } + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = tint, + ) + } + + Text( + text = message.toolName ?: stringResource(R.string.chat_tool_fallback), + style = + MaterialTheme.typography.labelMedium.copy( + color = contentColor, + fontFamily = FontFamily.Monospace, + ), + ) + } +} + +/** + * Security risk chip for [tool.output_risk] events. + * + * Shows a compact ⚠ badge when the backend flagged tool output as risky. + * Renders in the tool card between the header row and the summary line. + */ +@Composable +private fun SecurityRiskChip( + riskData: ToolOutputRiskData, + contentColor: Color, + modifier: Modifier = Modifier, +) { + val statusColors = LocalHermesStatusColors.current + val (chipColor, label) = + when { + riskData.risk == "high" -> statusColors.error to "Risky output" + riskData.risk == "medium" -> statusColors.warning to "Caution" + else -> statusColors.warning to "Redacted" + } + + Row( + modifier = + modifier + .padding(top = 4.dp, start = 22.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "⚠", + style = MaterialTheme.typography.labelSmall, + color = chipColor, + ) + Text( + text = label, + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Medium, + ), + color = chipColor, + ) + if (riskData.redacted && (riskData.risk == "high" || riskData.risk == "medium")) { + Text( + text = "Ā· redacted", + style = MaterialTheme.typography.labelSmall, + color = chipColor.copy(alpha = 0.7f), + ) + } + } +} + +@Composable +private fun CopyButton( + visible: Boolean, + textToCopy: String, + onCopy: () -> Unit, + modifier: Modifier = Modifier, +) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + AnimatedVisibility( + visible = visible, + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut(), + modifier = modifier, + ) { + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f), + shadowElevation = 6.dp, + ) { + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, textToCopy))) + } + onCopy() + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.content_desc_copy), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt new file mode 100644 index 00000000..d0388422 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolResultParser.kt @@ -0,0 +1,1325 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.remote.OkHttpProvider +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.double +import kotlinx.serialization.json.int + +private fun formatTodoToolOutput( + dataSource: JsonObject, + obj: JsonObject, + resolvedToolName: String?, + isRunning: Boolean, +): ParsedToolData { + val todosArray = + dataSource + .get("todos") + ?.takeIf { !it.isJsonNull && it.isJsonArray } + ?.asJsonArray + val summaryObj = + dataSource + .get("summary") + ?.takeIf { !it.isJsonNull && it.isJsonObject } + ?.asJsonObject + + val todoSummaryText = + summaryObj?.let { s -> + val total = s.get("total")?.asDouble?.toInt() ?: 0 + val pending = s.get("pending")?.asDouble?.toInt() ?: 0 + val inProgress = s.get("in_progress")?.asDouble?.toInt() ?: 0 + val completed = s.get("completed")?.asDouble?.toInt() ?: 0 + val cancelled = s.get("cancelled")?.asDouble?.toInt() ?: 0 + val parts = mutableListOf() + if (pending > 0) parts.add("$pending pending") + if (inProgress > 0) parts.add("$inProgress in_progress") + if (completed > 0) parts.add("$completed completed") + if (cancelled > 0) parts.add("$cancelled cancelled") + val itemWord = if (total == 1) "item" else "items" + if (parts.isEmpty()) { + "šŸ“‹ 0 items" + } else { + "šŸ“‹ $total $itemWord (${parts.joinToString(", ")})" + } + } + + val formattedTodos = + todosArray + ?.mapNotNull { element -> + if (!element.isJsonObject) return@mapNotNull null + val item = element.asJsonObject + val id = item.get("id")?.asString ?: "" + val content = item.get("content")?.asString ?: "" + val status = item.get("status")?.asString ?: "pending" + val marker = + when (status) { + "completed" -> "[x]" + "in_progress" -> "[>]" + "cancelled" -> "[~]" + else -> "[ ]" + } + "$marker $id. $content" + }?.joinToString("\n") + ?.takeIf { it.isNotEmpty() } + + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + + return ParsedToolData( + toolName = resolvedToolName ?: "", + summaryText = todoSummaryText, + mainOutput = formattedTodos, + durationSec = duration, + isRunning = isRunning, + ) +} + +/** + * Parses the tool.complete JSON payload into a [ParsedToolData]. + * The gateway sends a payload with `args` and `result` sub-objects + * (see tui_gateway/server.py `_on_tool_complete`). We extract both + * and use the tool name to build a one-line summary from the relevant arg. + */ +fun parseToolOutput( + content: String, + toolName: String?, + isRunning: Boolean, +): ParsedToolData? { + val trimmed = content.trim() + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null + return try { + val element = OkHttpProvider.json.parseToJsonElement(trimmed) + if (!element.isJsonObject) return null + val obj = element.asJsonObject + + // Resolve tool name — from parameter first, then from payload (old sessions) + val resolvedToolName = + toolName + ?: obj.get("name")?.takeIf { !it.isJsonNull }?.asString + + val config = ToolSchemaRegistry.getDisplayConfig(resolvedToolName) + + // Extract args sub-object if present (new tool.complete format) + val argsObj = obj.get("args")?.takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject + val args: Map = + argsObj?.entrySet()?.associate { entry -> + entry.key to (if (entry.value.isJsonPrimitive) entry.value.asString else entry.value.toString()) + } ?: emptyMap() + + // Extract result sub-object if present, fall back to top-level (old format) + val resultObj = obj.get("result")?.takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject + val dataSource = resultObj ?: obj + + // Build summary line from args (new format) or context field (old tool.start) + val summaryText = + if (config.summaryArgKey != null) { + val raw = args[config.summaryArgKey]?.toString() ?: "" + val truncated = if (raw.length > 100) raw.take(100) + "…" else raw + if (truncated.isNotBlank()) "${config.summaryPrefix}$truncated" else null + } else { + obj + .get("context") + ?.takeIf { !it.isJsonNull } + ?.asString + ?.takeIf { it.isNotEmpty() } + ?.let { "${config.summaryPrefix}$it" } + } + + if (resolvedToolName == "todo") { + return formatTodoToolOutput( + dataSource = dataSource, + obj = obj, + resolvedToolName = resolvedToolName, + isRunning = isRunning, + ) + } + + // ── Fact Store-specific formatting ── + // Extract structured fact results (probe/search/reason/related/contradict → results[], + // list → facts[], add/update/remove → simple status). Build a clean summary and + // formatted fact list with category, trust score, and tags. + if (resolvedToolName == "fact_store") { + val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: "" + val entity = argsObj?.get("entity")?.takeIf { !it.isJsonNull }?.asString + val query = argsObj?.get("query")?.takeIf { !it.isJsonNull }?.asString + + // Try both key shapes: results[] (probe/search/reason/related/contradict) and facts[] (list) + val factsArray = + dataSource + .get("results") + ?.takeIf { !it.isJsonNull && it.isJsonArray } + ?.asJsonArray + ?: dataSource + .get("facts") + ?.takeIf { !it.isJsonNull && it.isJsonArray } + ?.asJsonArray + + val count = + dataSource + .get("count") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() ?: 0 + val status = dataSource.get("status")?.takeIf { !it.isJsonNull }?.asString + val factId = + dataSource + .get("fact_id") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() + val removed = dataSource.get("removed")?.takeIf { !it.isJsonNull }?.asBoolean + val updated = dataSource.get("updated")?.takeIf { !it.isJsonNull }?.asBoolean + + // Build summary line + val actionContext = + when { + entity != null -> " ($action: $entity)" + query != null -> " ($action: $query)" + else -> " ($action)" + } + val factSummaryText = + when { + status == "added" && factId != null -> "🧠 Fact added (ID: $factId)" + status == "added" -> "🧠 Fact added" + removed == true -> "🧠 Fact removed" + updated == true -> "🧠 Fact updated" + factsArray != null -> "🧠 $count facts$actionContext" + else -> "🧠 $action" + } + + // Format each fact as a compact block: #ID + content on first line, meta on indented second line + val formattedFacts = + factsArray + ?.mapNotNull { element -> + if (!element.isJsonObject) return@mapNotNull null + val item = element.asJsonObject + val fid = item.get("fact_id")?.asDouble?.toInt() ?: 0 + val content = item.get("content")?.asString ?: "" + val category = item.get("category")?.asString + val trust = item.get("trust_score")?.asDouble + val tags = item.get("tags")?.asString?.takeIf { it.isNotEmpty() } + + val firstLine = "#$fid $content" + val metaParts = mutableListOf() + if (category != null) metaParts.add("[$category]") + if (trust != null) metaParts.add("trust: ${"%.2f".format(trust)}") + if (tags != null) metaParts.add("šŸ·ļø $tags") + if (metaParts.isNotEmpty()) { + "$firstLine\n ${metaParts.joinToString(" ")}" + } else { + firstLine + } + }?.joinToString("\n") + ?.takeIf { it.isNotEmpty() } + + val factMainOutput = + when { + status == "added" && factId != null -> "āœ… Fact #$factId stored" + status == "added" -> "āœ… Fact stored" + removed == true -> "āœ… Removed" + updated == true -> "āœ… Updated" + formattedFacts != null -> formattedFacts + else -> null + } + + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + + return ParsedToolData( + toolName = resolvedToolName, + summaryText = factSummaryText, + mainOutput = factMainOutput, + durationSec = duration, + isRunning = isRunning, + ) + } + + // ── Session Search-specific formatting ── + // Handles all 4 calling modes: discover (results[] with snippets/bookends), + // scroll (messages[] with anchor), read (messages[] with session dump), + // and browse (results[] with recent session metadata). + if (resolvedToolName == "session_search") { + val mode = dataSource.get("mode")?.takeIf { !it.isJsonNull }?.asString ?: "" + val query = dataSource.get("query")?.takeIf { !it.isJsonNull }?.asString + val resultsArray = + dataSource.get("results")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray + val messagesArray = + dataSource.get("messages")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray + val count = + dataSource + .get("count") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() + val msgCount = + dataSource + .get("message_count") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() + val truncated = dataSource.get("truncated")?.takeIf { !it.isJsonNull }?.asBoolean + + // Build summary line + val ssSummaryText = + when (mode) { + "discover" -> "šŸ” ${count ?: 0} session${if (count == 1) "" else "s"}${query?.let { + ": ${it.take( + 60, + )}" + } ?: ""}" + + "scroll" -> "šŸ“œ ${messagesArray?.size() ?: 0} messages (scroll)" + + "read" -> "šŸ“– ${msgCount ?: messagesArray?.size() ?: 0} messages${ + if (truncated == true) " (truncated)" else "" + }" + + "browse" -> "šŸ“‹ ${count ?: 0} recent sessions" + + else -> "šŸ” session_search" + } + + // Format results (discover / browse) + val formattedResults = + resultsArray + ?.mapIndexedNotNull { idx, element -> + if (!element.isJsonObject) return@mapIndexedNotNull null + val item = element.asJsonObject + val whenField = + item.get("when")?.takeIf { !it.isJsonNull }?.asString + ?: item.get("started_at")?.takeIf { !it.isJsonNull }?.asString + val source = item.get("source")?.takeIf { !it.isJsonNull }?.asString + val title = item.get("title")?.takeIf { !it.isJsonNull }?.asString + val snippet = item.get("snippet")?.takeIf { !it.isJsonNull }?.asString + val preview = item.get("preview")?.takeIf { !it.isJsonNull }?.asString + val model = item.get("model")?.takeIf { !it.isJsonNull }?.asString + val sessionMsgCount = + item + .get("message_count") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() + val matchedRole = item.get("matched_role")?.takeIf { !it.isJsonNull }?.asString + + val header = whenField?.let { "šŸ“… $it" } ?: "" + val sourceTag = source?.let { "[$it]" } ?: "" + val titleLine = title?.let { "\n šŸ“„ $it" } ?: "" + val modelLine = model?.let { "\n šŸ¤– $it" } ?: "" + val matchedLine = matchedRole?.let { "\n šŸŽÆ matched: $it" } ?: "" + val countLine = sessionMsgCount?.let { "\n $it msgs" } ?: "" + val snippetText = snippet ?: preview + val snippetLine = + snippetText?.let { + val clean = it.take(200).replace("\n", " ") + "\n ā”ƒ $clean" + } ?: "" + + "━━━ #${idx + 1} $header$sourceTag$titleLine$modelLine$matchedLine$countLine$snippetLine" + }?.joinToString("\n") + ?.takeIf { it.isNotEmpty() } + + // Format messages (scroll / read) + val anchorId = + dataSource + .get("around_message_id") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() + val formattedMessages = + messagesArray + ?.mapNotNull { element -> + if (!element.isJsonObject) return@mapNotNull null + val item = element.asJsonObject + val msgId = + item + .get("id") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() + val role = item.get("role")?.takeIf { !it.isJsonNull }?.asString ?: "?" + val content = item.get("content")?.takeIf { !it.isJsonNull }?.asString ?: "" + val toolName = item.get("tool_name")?.takeIf { !it.isJsonNull }?.asString + + val roleEmoji = + when (role) { + "user" -> "šŸ‘¤" + "assistant" -> "šŸ¤–" + "tool" -> "šŸ”§" + else -> "ā“" + } + val namePart = if (toolName != null) " ($toolName)" else "" + val anchor = if (anchorId != null && msgId == anchorId) " ā¬…ļø" else "" + val cleanContent = content.take(300).replace("\n", " ") + + "[$msgId] $roleEmoji $role$namePart$anchor\n $cleanContent" + }?.joinToString("\n") + ?.takeIf { it.isNotEmpty() } + + val ssMainOutput = formattedResults ?: formattedMessages + + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + + return ParsedToolData( + toolName = resolvedToolName, + summaryText = ssSummaryText, + mainOutput = ssMainOutput, + durationSec = duration, + isRunning = isRunning, + ) + } + + // ── Memory-specific formatting ── + if (resolvedToolName == "memory") { + val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: "" + val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true + val target = argsObj?.get("target")?.takeIf { !it.isJsonNull }?.asString + val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString + val usage = dataSource.get("usage")?.takeIf { !it.isJsonNull }?.asString + + val summaryText = + when (action.lowercase()) { + "add" -> "🧠 Memory Saved${target?.let { " ($it)" } ?: ""}" + "replace", "update" -> "🧠 Memory Updated${target?.let { " ($it)" } ?: ""}" + "remove", "delete" -> "🧠 Memory Removed${target?.let { " ($it)" } ?: ""}" + else -> "🧠 Memory $action${target?.let { " ($it)" } ?: ""}" + } + val mainOutput = + when { + errorMsg != null -> "āŒ $errorMsg" + + !success -> "āŒ Failed" + + usage != null -> "āœ… $action in ${target ?: "memory"}\n $usage" + + else -> "āœ… ${target?.replaceFirstChar { + if (it.isLowerCase()) { + it.titlecase( + java.util.Locale.getDefault(), + ) + } else { + it.toString() + } + } ?: "Memory"} $action done" + } + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + return ParsedToolData( + toolName = resolvedToolName, + summaryText = summaryText, + mainOutput = mainOutput, + durationSec = duration, + isRunning = isRunning, + ) + } + + // ── Skill Manage-specific formatting (Self-Improvement) ── + if (resolvedToolName == "skill_manage") { + val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: "" + val name = argsObj?.get("name")?.takeIf { !it.isJsonNull }?.asString + val category = argsObj?.get("category")?.takeIf { !it.isJsonNull }?.asString + val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString + val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true + val msg = dataSource.get("message")?.takeIf { !it.isJsonNull }?.asString + + val summaryText = + when (action.lowercase()) { + "create" -> "⚔ Skill Created${name?.let { ": $it" } ?: ""}" + "patch" -> "⚔ Skill Patched${name?.let { ": $it" } ?: ""}" + "edit" -> "⚔ Skill Edited${name?.let { ": $it" } ?: ""}" + "delete" -> "⚔ Skill Archived${name?.let { ": $it" } ?: ""}" + "write_file" -> "⚔ Skill File Written${name?.let { ": $it" } ?: ""}" + "remove_file" -> "⚔ Skill File Removed${name?.let { ": $it" } ?: ""}" + else -> + "⚔ Skill ${action.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(java.util.Locale.getDefault()) else it.toString() + }}${name?.let { ": $it" } ?: ""}" + } + val mainOutput = + when { + errorMsg != null -> "āŒ $errorMsg" + !success -> "āŒ Skill operation failed" + msg != null -> "āœ… $msg" + name != null -> "āœ… Skill '$name' $action succeeded${category?.let { " [$it]" } ?: ""}" + else -> "āœ… Skill $action done" + } + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + return ParsedToolData( + toolName = resolvedToolName, + summaryText = summaryText, + mainOutput = mainOutput, + durationSec = duration, + isRunning = isRunning, + ) + } + + // ── Cronjob-specific formatting ── + if (resolvedToolName == "cronjob") { + val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: "" + val jobsArray = dataSource.get("jobs")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray + val jobId = + dataSource + .get("job_id") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() + val jobName = dataSource.get("name")?.takeIf { !it.isJsonNull }?.asString + val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString + + val summaryText = "šŸ”„ $action${jobName?.let { ": $it" } ?: jobId?.let { " (ID: $it)" } ?: ""}" + val mainOutput = + when { + errorMsg != null -> { + "āŒ $errorMsg" + } + + jobsArray != null -> { + jobsArray + .mapNotNull { el -> + if (!el.isJsonObject) return@mapNotNull null + val j = el.asJsonObject + val name = j.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "?" + val schedule = j.get("schedule")?.takeIf { !it.isJsonNull }?.asString + val status = j.get("status")?.takeIf { !it.isJsonNull }?.asString + val lastRun = j.get("last_run")?.takeIf { !it.isJsonNull }?.asString + val parts = mutableListOf("šŸ“Œ $name") + if (schedule != null) parts.add(" ā± $schedule") + if (status != null) parts.add(" šŸ“Š $status") + if (lastRun != null) parts.add(" ā® $lastRun") + parts.joinToString("\n") + }.joinToString("\n\n") + } + + jobId != null -> { + "āœ… $action (ID: $jobId)" + } + + else -> { + "āœ… $action done" + } + } + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + return ParsedToolData( + toolName = resolvedToolName, + summaryText = summaryText, + mainOutput = mainOutput, + durationSec = duration, + isRunning = isRunning, + ) + } + + // ── Web Search-specific formatting ── + if (resolvedToolName == "web_search") { + val query = argsObj?.get("query")?.takeIf { !it.isJsonNull }?.asString ?: "" + val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true + val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString + val webArr = + dataSource + .get("data") + ?.takeIf { !it.isJsonNull && it.isJsonObject } + ?.asJsonObject + ?.get("web") + ?.takeIf { !it.isJsonNull && it.isJsonArray } + ?.asJsonArray + + val summaryText = "🌐 $query${webArr?.let { " (${it.size()} results)" } ?: ""}" + val mainOutput = + when { + errorMsg != null -> { + "āŒ $errorMsg" + } + + webArr != null -> { + webArr + .mapNotNull { el -> + if (!el.isJsonObject) return@mapNotNull null + val item = el.asJsonObject + val title = item.get("title")?.takeIf { !it.isJsonNull }?.asString ?: "" + val url = item.get("url")?.takeIf { !it.isJsonNull }?.asString + val desc = item.get("description")?.takeIf { !it.isJsonNull }?.asString + val pos = + item + .get("position") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() + val lines = mutableListOf("${pos?.let { "$it. " } ?: ""}$title") + if (desc != null) lines.add(" $desc") + if (url != null) lines.add(" šŸ”— $url") + lines.joinToString("\n") + }.joinToString("\n\n") + } + + !success -> { + "āŒ Search failed" + } + + else -> { + "No results" + } + } + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + return ParsedToolData( + toolName = resolvedToolName, + summaryText = summaryText, + mainOutput = mainOutput, + durationSec = duration, + isRunning = isRunning, + ) + } + + // ── Skills List-specific formatting ── + if (resolvedToolName == "skills_list") { + val category = argsObj?.get("category")?.takeIf { !it.isJsonNull }?.asString + val skillsArr = + (dataSource.get("skills") ?: dataSource.get("results")) + ?.takeIf { !it.isJsonNull && it.isJsonArray } + ?.asJsonArray + + val summaryText = "šŸ“š ${skillsArr?.size() ?: 0} skills${category?.let { " ($it)" } ?: ""}" + val mainOutput = + skillsArr + ?.mapNotNull { el -> + if (!el.isJsonObject) return@mapNotNull null + val s = el.asJsonObject + val name = s.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "" + val desc = s.get("description")?.takeIf { !it.isJsonNull }?.asString + val cat = s.get("category")?.takeIf { !it.isJsonNull }?.asString + val lines = mutableListOf("šŸ“Œ $name") + if (desc != null) lines.add(" $desc") + if (cat != null) lines.add(" [$cat]") + lines.joinToString("\n") + }?.joinToString("\n\n") ?: "šŸ“š No skills found" + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + return ParsedToolData( + toolName = resolvedToolName, + summaryText = summaryText, + mainOutput = mainOutput, + durationSec = duration, + isRunning = isRunning, + ) + } + + // ── Skill View-specific formatting ── + if (resolvedToolName == "skill_view") { + val name = argsObj?.get("name")?.takeIf { !it.isJsonNull }?.asString + val content = dataSource.get("content")?.takeIf { !it.isJsonNull }?.asString + val linkedFiles = dataSource.get("linked_files")?.takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject + + val summaryText = "šŸ“– $name" + val contentPreview = + content?.let { c -> + if (c.length > 500) c.take(500) + "\n... [${c.length - 500} more chars]" else c + } + val filesInfo = + linkedFiles?.entrySet()?.joinToString("\n") { (k, v) -> + val paths = + when { + v.isJsonArray -> v.asJsonArray.joinToString(", ") { it.asString } + v.isJsonPrimitive -> v.asString + else -> v.toString() + } + " šŸ“Ž $k: $paths" + } + val mainOutput = + buildString { + if (contentPreview != null) append(contentPreview) + if (filesInfo != null) append("\n\n━━━ Linked Files ━━━\n$filesInfo") + if (content == null && linkedFiles == null) append("šŸ“– No content available") + } + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + return ParsedToolData( + toolName = resolvedToolName, + summaryText = summaryText, + mainOutput = mainOutput, + durationSec = duration, + isRunning = isRunning, + ) + } + + // ── Process-specific formatting ── + if (resolvedToolName == "process") { + val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: "" + val procId = argsObj?.get("session_id")?.takeIf { !it.isJsonNull }?.asString + val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString + val status = dataSource.get("status")?.takeIf { !it.isJsonNull }?.asString + val outputText = dataSource.get("output")?.takeIf { !it.isJsonNull }?.asString + val processesArr = dataSource.get("processes")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray + + val summaryText = "āš™ļø $action${procId?.let { ": $it" } ?: ""}" + val mainOutput = + when { + errorMsg != null -> { + "āŒ $errorMsg" + } + + processesArr != null -> { + processesArr + .mapNotNull { el -> + if (!el.isJsonObject) return@mapNotNull null + val p = el.asJsonObject + val pid = + p + .get( + "session_id", + )?.takeIf { !it.isJsonNull } + ?.asString ?: p.get("id")?.asString ?: "?" + val pStatus = p.get("status")?.takeIf { !it.isJsonNull }?.asString + val cmd = p.get("command")?.takeIf { !it.isJsonNull }?.asString + val running = p.get("running")?.takeIf { !it.isJsonNull }?.asBoolean + val parts = mutableListOf("šŸ“Œ $pid${cmd?.let { ": $it" } ?: ""}") + if (pStatus != null) parts.add(" Status: $pStatus") + if (running != null) parts.add(" Running: $running") + parts.joinToString("\n") + }.joinToString("\n\n") + } + + outputText != null -> { + "${status?.let { "Status: $it\n" } ?: ""}$outputText" + } + + status != null -> { + "āœ… $status" + } + + else -> { + "āœ… $action done" + } + } + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + return ParsedToolData( + toolName = resolvedToolName, + summaryText = summaryText, + mainOutput = mainOutput, + durationSec = duration, + isRunning = isRunning, + ) + } + + // ── X Search-specific formatting ── + if (resolvedToolName == "x_search") { + val query = argsObj?.get("query")?.takeIf { !it.isJsonNull }?.asString ?: "" + val answer = dataSource.get("answer")?.takeIf { !it.isJsonNull }?.asString + val citationsArr = dataSource.get("citations")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray + val degraded = dataSource.get("degraded")?.takeIf { !it.isJsonNull }?.asBoolean + val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString + + val summaryText = "š• $query${if (degraded == true) " (no citations)" else ""}" + val mainOutput = + when { + errorMsg != null -> { + "āŒ $errorMsg" + } + + answer != null -> { + val lines = mutableListOf(answer) + if (citationsArr != null && citationsArr.size() > 0) { + lines.add("\n━━━ Citations ━━━") + citationsArr.forEachIndexed { idx, cit -> + if (cit.isJsonObject) { + val url = cit.asJsonObject.get("url")?.asString + val title = cit.asJsonObject.get("title")?.asString + lines.add("${idx + 1}. ${title ?: url ?: "source"}") + if (title != null && url != null) lines.add(" $url") + } + } + } + if (degraded == true) lines.add("\nāš ļø No citations — answer based on model's knowledge") + lines.joinToString("\n") + } + + else -> { + "š• No results" + } + } + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + return ParsedToolData( + toolName = resolvedToolName, + summaryText = summaryText, + mainOutput = mainOutput, + durationSec = duration, + isRunning = isRunning, + ) + } + + // ── Vision Analyze-specific formatting ── + if (resolvedToolName == "vision_analyze") { + val imageUrl = argsObj?.get("image_url")?.takeIf { !it.isJsonNull }?.asString ?: "" + val description = dataSource.get("description")?.takeIf { !it.isJsonNull }?.asString + val content = dataSource.get("content")?.takeIf { !it.isJsonNull }?.asString + val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString + + val summaryText = "šŸ‘ļø ${imageUrl.take(60)}" + val mainOutput = + when { + errorMsg != null -> "āŒ $errorMsg" + description != null -> description + content != null -> content + else -> "šŸ‘ļø No description available" + } + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + return ParsedToolData( + toolName = resolvedToolName, + summaryText = summaryText, + mainOutput = mainOutput, + durationSec = duration, + isRunning = isRunning, + ) + } + + // ── Web Extract-specific formatting ── + if (resolvedToolName == "web_extract") { + val urls = argsObj?.get("urls")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray + val content = dataSource.get("content")?.takeIf { !it.isJsonNull }?.asString + val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true + val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString + + val urlSummary = urls?.joinToString(", ") { it.asString.take(40) } ?: "" + val summaryText = "šŸ•øļø ${urls?.size() ?: 0} page(s)" + val mainOutput = + when { + errorMsg != null -> { + "āŒ $errorMsg" + } + + content != null -> { + content.take( + 3000, + ) + if (content.length > 3000) "\n... [${content.length - 3000} more chars]" else "" + } + + else -> { + "šŸ•øļø No content extracted" + } + } + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + return ParsedToolData( + toolName = resolvedToolName, + summaryText = summaryText, + mainOutput = mainOutput, + durationSec = duration, + isRunning = isRunning, + ) + } + + // ── Tool Search-specific formatting ── + if (resolvedToolName == "tool_search") { + val query = argsObj?.get("query")?.takeIf { !it.isJsonNull }?.asString ?: "" + val limit = + argsObj + ?.get("limit") + ?.takeIf { !it.isJsonNull } + ?.asDouble + ?.toInt() + val matchesArr = dataSource.get("matches")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray + + val summaryText = "šŸ” $query${matchesArr?.let { " (${it.size()} matches)" } ?: ""}" + val mainOutput = + matchesArr + ?.mapNotNull { el -> + if (!el.isJsonObject) return@mapNotNull null + val m = el.asJsonObject + val name = m.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "?" + val desc = m.get("description")?.takeIf { !it.isJsonNull }?.asString + val lines = mutableListOf("šŸ”§ $name") + if (desc != null) lines.add(" $desc") + lines.joinToString("\n") + }?.joinToString("\n\n") ?: "šŸ” No matching tools" + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + return ParsedToolData( + toolName = resolvedToolName, + summaryText = summaryText, + mainOutput = mainOutput, + durationSec = duration, + isRunning = isRunning, + ) + } + + // ── Image Generate-specific formatting ── + if (resolvedToolName == "image_generate") { + val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true + val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString + val imageUrl = dataSource.get("image")?.takeIf { !it.isJsonNull }?.asString + val argsImageUrl = argsObj?.get("image_url")?.takeIf { !it.isJsonNull }?.asString + val modality = dataSource.get("modality")?.takeIf { !it.isJsonNull }?.asString + val promptUrl = argsObj?.get("prompt")?.takeIf { !it.isJsonNull }?.asString + + val summaryText = + buildString { + append("šŸŽØ ") + if (errorMsg != null) { + append("āŒ $errorMsg") + } else { + append((imageUrl ?: argsImageUrl ?: "").take(60)) + if (modality != null) append(" ($modality)") + } + } + val mainOutput = + buildString { + if (errorMsg != null) { + append("āŒ $errorMsg") + if (imageUrl != null) append("\nšŸ”— $imageUrl") + } else if (imageUrl != null) { + append("šŸ–¼ļø ") + if (promptUrl != null) append("$promptUrl\n") + append("\nšŸ”— $imageUrl") + } else { + append("āœ… Generated") + } + } + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + return ParsedToolData( + toolName = resolvedToolName, + summaryText = summaryText, + mainOutput = mainOutput, + durationSec = duration, + isRunning = isRunning, + ) + } + + // ── Project List-specific formatting ── + if (resolvedToolName == "project_list") { + val activeId = dataSource.get("active_id")?.takeIf { !it.isJsonNull }?.asString + val projectsArr = dataSource.get("projects")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray + + val summaryText = "šŸ“‚ ${projectsArr?.size() ?: "?"} projects${activeId?.let { " (1 active)" } ?: ""}" + val mainOutput = + if (projectsArr != null) { + projectsArr + .mapNotNull { el -> + if (!el.isJsonObject) return@mapNotNull null + val p = el.asJsonObject + val id = p.get("id")?.takeIf { !it.isJsonNull }?.asString ?: "" + val name = p.get("name")?.takeIf { !it.isJsonNull }?.asString ?: "" + val slug = p.get("slug")?.takeIf { !it.isJsonNull }?.asString + val path = p.get("primary_path")?.takeIf { !it.isJsonNull }?.asString + val isActive = p.get("active")?.takeIf { !it.isJsonNull }?.asBoolean ?: false + val lines = mutableListOf("${if (isActive) "⭐ " else "šŸ“ "}$name") + if (slug != null) lines.add(" šŸ·ļø $slug") + if (path != null) lines.add(" šŸ“ $path") + if (isActive) lines.add(" āœ… ACTIVE PROJECT") + lines.joinToString("\n") + }.joinToString("\n\n") + } else { + "" + } + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + return ParsedToolData( + toolName = resolvedToolName, + summaryText = summaryText, + mainOutput = mainOutput, + durationSec = duration, + isRunning = isRunning, + ) + } + + // ── Project Create/Switch-specific formatting ── + if (resolvedToolName == "project_create" || resolvedToolName == "project_switch") { + val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true + val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString + val projName = dataSource.get("name")?.takeIf { !it.isJsonNull }?.asString + val projSlug = dataSource.get("slug")?.takeIf { !it.isJsonNull }?.asString + val projPath = dataSource.get("primary_path")?.takeIf { !it.isJsonNull }?.asString + val actionLabel = if (resolvedToolName == "project_create") "Created" else "Switched to" + + val summaryText = "šŸ“‚ $actionLabel${projName?.let { " $it" } ?: ""}" + val mainOutput = + buildString { + if (errorMsg != null) { + append("āŒ $errorMsg") + } else { + append("āœ… $actionLabel") + if (projName != null) append(": $projName") + if (projSlug != null) append("\n šŸ·ļø $projSlug") + if (projPath != null) append("\n šŸ“ $projPath") + } + } + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + return ParsedToolData( + toolName = resolvedToolName, + summaryText = summaryText, + mainOutput = mainOutput, + durationSec = duration, + isRunning = isRunning, + ) + } + + // ── Read Terminal-specific formatting ── + if (resolvedToolName == "read_terminal") { + val totalLines = dataSource.get("total_lines")?.takeIf { !it.isJsonNull }?.asInt ?: 0 + val startLine = dataSource.get("start")?.takeIf { !it.isJsonNull }?.asInt ?: 0 + val endLine = dataSource.get("end")?.takeIf { !it.isJsonNull }?.asInt ?: 0 + val cursorRow = dataSource.get("cursor_row")?.takeIf { !it.isJsonNull }?.asInt + val text = dataSource.get("text")?.takeIf { !it.isJsonNull }?.asString + val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString + + val summaryText = + buildString { + append("šŸ–„ļø ") + if (errorMsg != null) { + append("āŒ $errorMsg") + } else if (text != null) { + append("Lines $startLine-$endLine of $totalLines") + if (cursorRow != null) append(", cursor at row $cursorRow") + } else { + append("Terminal output") + } + } + val mainOutput = + buildString { + if (errorMsg != null) { + append("āŒ $errorMsg") + } else if (text != null) { + append("━━━ Terminal ━━━ ($startLine:$endLine / $totalLines)") + if (cursorRow != null) append(" │ cursor row $cursorRow") + append("\n$text") + } else { + append("šŸ–„ļø No terminal output") + } + } + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + return ParsedToolData( + toolName = resolvedToolName, + summaryText = summaryText, + mainOutput = mainOutput, + durationSec = duration, + isRunning = isRunning, + ) + } + + // ── Computer Use-specific formatting ── + if (resolvedToolName == "computer_use") { + val action = dataSource.get("action")?.takeIf { !it.isJsonNull }?.asString + val ok = dataSource.get("ok")?.takeIf { !it.isJsonNull }?.asBoolean + val message = dataSource.get("message")?.takeIf { !it.isJsonNull }?.asString + val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString + val isMultimodal = dataSource.get("_multimodal")?.takeIf { !it.isJsonNull }?.asBoolean ?: false + val mode = dataSource.get("mode")?.takeIf { !it.isJsonNull }?.asString + val elementsArr = dataSource.get("elements")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray + val appsArr = dataSource.get("apps")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray + val summaryText = dataSource.get("text_summary")?.takeIf { !it.isJsonNull }?.asString + val textSummary = dataSource.get("summary")?.takeIf { !it.isJsonNull }?.asString + val visionAnalysis = dataSource.get("vision_analysis")?.takeIf { !it.isJsonNull }?.asString + val totalElements = dataSource.get("total_elements")?.takeIf { !it.isJsonNull }?.asInt + val width = dataSource.get("width")?.takeIf { !it.isJsonNull }?.asInt + val height = dataSource.get("height")?.takeIf { !it.isJsonNull }?.asInt + + // Try to extract text from multimodal content array + val multimodalText = + if (isMultimodal) { + val contentArr = + dataSource.get("content")?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray + contentArr + ?.mapNotNull { el -> + if (el.isJsonObject) { + val eObj = el.asJsonObject + if (eObj.get("type")?.takeIf { !it.isJsonNull }?.asString == "text") { + eObj.get("text")?.takeIf { !it.isJsonNull }?.asString + } else { + null + } + } else { + null + } + }?.joinToString("\n") + } else { + null + } + + val summaryLine = + buildString { + append("šŸ–„ļø ") + if (errorMsg != null) { + append("āŒ $errorMsg") + } else if (appsArr != null) { + append("${appsArr.size()} apps") + } else if (elementsArr != null) { + append("${elementsArr.size()} elements${mode?.let { " ($it)" } ?: ""}") + if (width != null && height != null) append(" ${width}x$height") + } else if (action != null) { + append("$action${ok?.let { if (it) " āœ“" else " āœ—" } ?: ""}") + } else { + append("Computer use") + } + } + val mainBody = + buildString { + if (errorMsg != null) { + append("āŒ $errorMsg") + } else { + val textPart = multimodalText ?: summaryText ?: textSummary + if (textPart != null) { + append(textPart) + if (textPart == multimodalText && textSummary != null && multimodalText != textSummary) { + append("\n\n$textSummary") + } + } + + if (appsArr != null) { + if (isNotEmpty()) append("\n\n") + appsArr.forEachIndexed { idx, el -> + if (el.isJsonObject) { + val obj = el.asJsonObject + val name = + obj + .get("name") + ?.takeIf { !it.isJsonNull } + ?.asString ?: "?" + val pid = + obj + .get("pid") + ?.takeIf { !it.isJsonNull } + ?.asInt + append("${idx + 1}. $name") + if (pid != null) append(" (PID: $pid)") + append("\n") + } + } + } else if (elementsArr != null) { + if (isNotEmpty()) append("\n\n") + append("šŸ–„ļø ${elementsArr.size()} interactable elements") + if (mode != null) append(" (mode: $mode)") + if (width != null && height != null) append(" • ${width}x$height") + if (textSummary != null) append("\n\n$textSummary") + // Show first few elements as preview + val previewCount = minOf(elementsArr.size(), 5) + if (previewCount > 0) { + append("\n\n") + for (i in 0 until previewCount) { + val el = elementsArr[i] + if (el.isJsonObject) { + val obj = el.asJsonObject + val idx = + obj + .get("index") + ?.takeIf { !it.isJsonNull } + ?.asInt + val role = + obj + .get("role") + ?.takeIf { !it.isJsonNull } + ?.asString + val label = + obj + .get("label") + ?.takeIf { !it.isJsonNull } + ?.asString + append(" #$idx ${role ?: ""}${label?.let { " '$it'" } ?: ""}\n") + } + } + if (elementsArr.size() > previewCount) { + append(" ... and ${elementsArr.size() - previewCount} more") + } + } + } else if (action != null && + (multimodalText == null && summaryText == null && textSummary == null) + ) { + append("$action:") + if (ok != null) append(" ${if (ok) "āœ… ok" else "āŒ failed"}") + if (message != null) append("\n$message") + } else if (isEmpty()) { + append("šŸ–„ļø Computer use action") + } + } + if (visionAnalysis != null) { + append("\n\n━━━ Vision Analysis ━━━\n$visionAnalysis") + } + } + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + return ParsedToolData( + toolName = resolvedToolName, + summaryText = summaryLine, + mainOutput = mainBody, + durationSec = duration, + isRunning = isRunning, + ) + } + + // Terminal result. The backend (tools/terminal_tool.py) emits a single + // `output` string (stdout+stderr merged, ANSI-stripped), plus `exit_code` + // and an `error` string on failure. Older payloads used `stdout`/`stderr` + // — accept both for backward compat, but `output` is canonical. + val hasOutput = dataSource.has("output") + val hasStdout = dataSource.has("stdout") + val hasStderr = dataSource.has("stderr") + val hasExitCode = dataSource.has("exit_code") || dataSource.has("exitCode") + + val isTerminalTool = + resolvedToolName == "terminal" || + resolvedToolName == "bash" || + resolvedToolName == "sh" || + resolvedToolName == "cmd" || + resolvedToolName == "exec" + + val isDiffTool = + resolvedToolName == "patch" || + resolvedToolName == "write_file" || + resolvedToolName == "edit" + + if (!isDiffTool && (isTerminalTool || hasOutput || hasStdout || hasStderr || hasExitCode)) { + val terminalOutput = + ( + dataSource.get("output")?.takeIf { !it.isJsonNull }?.asString + ?: listOfNotNull( + dataSource + .get("stdout") + ?.takeIf { !it.isJsonNull } + ?.asString + ?.takeIf { it.isNotEmpty() }, + dataSource + .get("stderr") + ?.takeIf { !it.isJsonNull } + ?.asString + ?.takeIf { it.isNotEmpty() }, + ).joinToString("\n").takeIf { it.isNotEmpty() } + )?.takeIf { it.isNotEmpty() } + val exitCode = (dataSource.get("exit_code") ?: dataSource.get("exitCode"))?.toIntOrNull() + val error = + dataSource + .get("error") + ?.takeIf { !it.isJsonNull } + ?.asString + ?.takeIf { it.isNotEmpty() } + // Surface non-zero exit codes as an error line even when `error` is absent. + val effectiveError = + if (error != null) { + error + } else if (exitCode != null && exitCode != 0 && terminalOutput == null) { + "exit code $exitCode" + } else { + null + } + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + + ParsedToolData( + toolName = resolvedToolName ?: "", + isTerminal = true, + stdout = terminalOutput, + exitCode = exitCode, + error = effectiveError, + summaryText = summaryText, + mainOutput = terminalOutput, + durationSec = duration, + isRunning = isRunning, + ) + } else { + // Generic tool display + val mainOutput = + dataSource + .let { r -> + (r.get("output") ?: r.get("result") ?: r.get("content") ?: r.get("text")) + ?.takeIf { !it.isJsonNull } + ?.let { if (it.isJsonPrimitive) it.asString else it.toString() } + ?.takeIf { it.isNotEmpty() } + } + + val extraFields = mutableMapOf() + dataSource.entrySet().forEach { (key, value) -> + if (key != "output" && key != "result" && key != "content" && key != "text" && + key != "name" && key != "tool_id" && key != "context" && !value.isJsonNull + ) { + val valStr = if (value.isJsonPrimitive) value.asString else value.toString() + if (valStr.isNotEmpty() && valStr != "null") { + extraFields[key] = valStr + } + } + } + + val diffCandidate = + dataSource.get("diff")?.takeIf { !it.isJsonNull }?.let { + if (it.isJsonPrimitive) it.asString else it.toString() + } + ?: dataSource.get("patch")?.takeIf { !it.isJsonNull }?.let { + if (it.isJsonPrimitive) it.asString else it.toString() + } + ?: mainOutput + + val filePathArg = + args["path"]?.toString() + ?: argsObj?.get("path")?.takeIf { !it.isJsonNull }?.asString + + val oldStrArg = args["old_string"]?.toString() + val newStrArg = args["new_string"]?.toString() + + val isDiffTool = + resolvedToolName == "patch" || + resolvedToolName == "edit" || + resolvedToolName == "write_file" + + val extractedDiffOutput = + when { + diffCandidate != null && ( + diffCandidate.contains("\n-") || + diffCandidate.contains("\n+") || + diffCandidate.startsWith("--- ") || + diffCandidate.startsWith("+++ ") || + diffCandidate.startsWith("*** ") || + diffCandidate.startsWith("@@ ") + ) -> diffCandidate + + isDiffTool && oldStrArg != null && newStrArg != null -> { + val pathHeader = filePathArg ?: "file" + "--- $pathHeader\n+++ $pathHeader\n@@ -1,1 +1,1 @@\n-$oldStrArg\n+$newStrArg" + } + + isDiffTool && args["patch"] != null -> { + args["patch"].toString() + } + + else -> null + } + + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + + ParsedToolData( + toolName = resolvedToolName ?: "", + summaryText = summaryText, + mainOutput = mainOutput, + extraFields = extraFields, + durationSec = duration, + isRunning = isRunning, + diffOutput = extractedDiffOutput, + diffPath = filePathArg, + ) + } + } catch (e: Exception) { + null + } +} + +// Extensions for backward compatibility with Gson API under kotlinx.serialization +private val JsonElement.isJsonObject: Boolean get() = this is JsonObject +private val JsonElement.asJsonObject: JsonObject get() = this as JsonObject +private val JsonElement.isJsonArray: Boolean get() = this is JsonArray +private val JsonElement.asJsonArray: JsonArray get() = this as JsonArray +private val JsonElement.isJsonNull: Boolean get() = this is JsonNull +private val JsonElement.isJsonPrimitive: Boolean get() = this is JsonPrimitive +private val JsonElement.asString: String get() = (this as JsonPrimitive).content +private val JsonElement.asDouble: Double get() = (this as JsonPrimitive).double +private val JsonElement.asInt: Int get() = (this as JsonPrimitive).int +private val JsonElement.asBoolean: Boolean get() = (this as JsonPrimitive).boolean + +private fun JsonElement.has(key: String): Boolean = (this as? JsonObject)?.containsKey(key) == true + +private fun JsonObject.entrySet(): Set> = entries + +private fun JsonArray.size(): Int = size + +/** + * Parse a numeric/string JSON primitive into an Int, tolerating backend quirks + * (e.g. terminal `exit_code` arrives as a FLOAT `0.0` or a STRING). Returns null + * if missing, null, or non-numeric — callers must not throw, or the whole tool + * parse is caught and the bubble falls back to raw JSON. + */ +private fun JsonElement?.toIntOrNull(): Int? { + if (this == null || this is JsonNull) return null + return when (this) { + is JsonPrimitive -> { + content.toIntOrNull() ?: content.toDoubleOrNull()?.toInt() + } + + else -> { + null + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt index 737782e8..c01f8cd4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatScrollController.kt @@ -54,7 +54,6 @@ class ChatScrollController( var pendingCount by mutableStateOf(0) private set - private var lastSessionId: String? = null private var lastTailKey: Any? = null private var lastMessageCount: Int = 0