diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCall.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCall.kt new file mode 100644 index 00000000..4dfdb350 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCall.kt @@ -0,0 +1,34 @@ +package com.m57.hermescontrol.ui.chat.tool + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * One normalized tool invocation, as seen by the display engine. + * + * [args] and [result] are the payloads coerced to records (stringified JSON + * re-parsed, non-objects dropped); [rawArgs]/[rawResult] keep the original + * elements for renderers that need arrays, scalars, or wrapper unwrapping. + */ +internal data class ToolCall( + val name: String, + val args: JsonObject?, + val result: JsonObject?, + val rawArgs: JsonElement?, + val rawResult: JsonElement?, +) { + companion object { + fun of( + name: String, + rawArgs: JsonElement?, + rawResult: JsonElement?, + ): ToolCall = + ToolCall( + name = name, + args = ToolJson.parseMaybeObject(rawArgs), + result = ToolJson.parseMaybeObject(rawResult), + rawArgs = rawArgs, + rawResult = rawResult, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCounts.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCounts.kt new file mode 100644 index 00000000..fc0fd5be --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolCounts.kt @@ -0,0 +1,255 @@ +package com.m57.hermescontrol.ui.chat.tool + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * Count-label extraction ("3 results", "2 files") for the collapsed row. + * + * Hunts for a count in this order: known count fields, known list arrays, + * `*_count`/`*_total` field names, then plain-text "Found N matches" + * phrasing in summary/message fields. web_search always counts its hits and + * always reports "results". + */ +internal object ToolCounts { + private val COUNT_FIELD_KEYS = + listOf( + "count", + "total", + "result_count", + "results_count", + "num_results", + "match_count", + "matches_count", + "file_count", + "files_count", + "item_count", + "items_count", + "search_count", + "searches_count", + "source_count", + "sources_count", + "document_count", + "documents_count", + "updated", + "added", + "removed", + "deleted", + "created", + "changed", + "processed", + "steps", + ) + + private val COUNT_ARRAY_KEYS = listOf("results", "items", "matches", "files", "documents", "sources", "rows") + + private val COUNT_EXCLUDED_KEYS = setOf("duration_s", "exit_code", "status_code") + + private val NOUN_BY_FIELD = + mapOf( + "result_count" to "result", + "results_count" to "result", + "num_results" to "result", + "match_count" to "match", + "matches_count" to "match", + "file_count" to "file", + "files_count" to "file", + "item_count" to "item", + "items_count" to "item", + "search_count" to "search", + "searches_count" to "search", + "source_count" to "source", + "sources_count" to "source", + "document_count" to "document", + "documents_count" to "document", + "updated" to "item", + "added" to "item", + "removed" to "item", + "deleted" to "item", + "created" to "item", + "changed" to "item", + "processed" to "item", + "steps" to "step", + ) + + private val NOUN_BY_ARRAY = + mapOf( + "documents" to "document", + "files" to "file", + "items" to "item", + "matches" to "match", + "results" to "result", + "rows" to "row", + "sources" to "source", + ) + + private fun countFromUnknown(value: JsonElement?): Int? { + if (value is JsonArray) { + return if (value.isNotEmpty()) value.size else null + } + + val n = ToolJson.numberValue(value) ?: return null + + if (n <= 0) { + return null + } + + return Math.round(n).toInt() + } + + private fun singularizeNoun(noun: String): String { + val normalized = noun.lowercase() + + if (normalized.isEmpty()) { + return "" + } + + if (normalized.endsWith("ies") && normalized.length > 3) { + return "${normalized.dropLast(3)}y" + } + + if (Regex("(xes|zes|ches|shes|sses)$").containsMatchIn(normalized) && normalized.length > 3) { + return normalized.dropLast(2) + } + + if (normalized.endsWith("s") && normalized.length > 2 && !normalized.endsWith("ss")) { + return normalized.dropLast(1) + } + + return normalized + } + + fun pluralizeNoun( + noun: String, + count: Int, + ): String { + if (count == 1) { + return noun + } + + if (noun == "search") { + return "searches" + } + + if (noun.endsWith( + "y", + ) && noun.length > 1 && !Regex("[aeiou]y$", RegexOption.IGNORE_CASE).containsMatchIn(noun) + ) { + return "${noun.dropLast(1)}ies" + } + + if (Regex("(s|x|z|ch|sh)$", RegexOption.IGNORE_CASE).containsMatchIn(noun)) { + return "${noun}es" + } + + return "${noun}s" + } + + private fun countMetric( + count: Int, + noun: String, + ): Pair = count to singularizeNoun(noun).ifEmpty { "item" } + + private fun countFromRecord( + record: JsonObject, + fallbackNoun: String, + ): Pair? { + for (key in COUNT_FIELD_KEYS) { + val count = countFromUnknown(record[key]) ?: continue + + return countMetric(count, NOUN_BY_FIELD[key] ?: fallbackNoun) + } + + for (key in COUNT_ARRAY_KEYS) { + val count = countFromUnknown(record[key]) ?: continue + + return countMetric(count, NOUN_BY_ARRAY[key] ?: fallbackNoun) + } + + for ((key, value) in record) { + if (key in COUNT_EXCLUDED_KEYS) { + continue + } + if (!Regex("_count$|_total$").containsMatchIn(key)) { + continue + } + + val count = countFromUnknown(value) ?: continue + val stripped = key.lowercase().replace(Regex("_(count|total)$"), "").replace(Regex("^num_"), "") + + return countMetric(count, singularizeNoun(stripped).ifEmpty { fallbackNoun }) + } + + return null + } + + private fun countFromText( + text: String, + fallbackNoun: String, + ): Pair? { + val t = text.trim() + if (t.isEmpty()) { + return null + } + + val unitMatch = + Regex( + """\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b""", + RegexOption.IGNORE_CASE, + ).find(t) + ?: Regex( + """\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b""", + RegexOption.IGNORE_CASE, + ).find(t) + + val n = unitMatch?.groupValues?.get(1)?.toIntOrNull() ?: return null + val noun = unitMatch.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: fallbackNoun + + return if (n > 0) countMetric(n, noun) else null + } + + fun countLabelFor(call: ToolCall): Pair? { + val rawResult = call.rawResult ?: return null + val toolName = call.name + val result = call.result + + val fallbackNoun = + when (toolName) { + "browser_snapshot", "todo" -> "item" + "list_files" -> "file" + "search_files", "session_search_recall", "web_search" -> "result" + "skills_list" -> "skill" + else -> "item" + } + + if (toolName == "web_search") { + val hits = ToolJson.collectResultItems(rawResult) + + if (hits.isNotEmpty()) { + return countMetric(hits.size, "result") + } + } + + val direct = countFromRecord(result ?: JsonObject(emptyMap()), fallbackNoun) + if (direct != null) { + return if (toolName == "web_search") countMetric(direct.first, "result") else direct + } + + val payload = ToolJson.unwrapToolPayload(rawResult) + if (payload !== rawResult && payload is JsonObject) { + val payloadCount = countFromRecord(payload, fallbackNoun) + + if (payloadCount != null) { + return if (toolName == "web_search") countMetric(payloadCount.first, "result") else payloadCount + } + } + + val summaryText = + ToolJson + .firstString(result, listOf("summary", "message", "detail")) + .ifEmpty { ToolJson.fallbackDetailText(call.args, rawResult) } + + return countFromText(summaryText, fallbackNoun) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt new file mode 100644 index 00000000..e2bd3347 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt @@ -0,0 +1,301 @@ +package com.m57.hermescontrol.ui.chat.tool + +import kotlinx.serialization.json.Json +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 + +/** + * Shared parsing/normalization primitives for the tool-display engine. + * + * Everything in here is payload-shape plumbing — no per-tool knowledge. + * Per-tool formatting lives in the renderers under [render][com.m57.hermescontrol.ui.chat.tool.render]. + */ +internal object ToolJson { + private val URL_PATTERN = Regex("https?://[^\\s'\"<>)\\]]+", RegexOption.IGNORE_CASE) + + /** First non-blank string value among [keys], trimmed. */ + fun firstString( + record: JsonObject?, + keys: List, + ): String { + val r = record ?: return "" + + for (k in keys) { + val v = r[k] ?: continue + if (v is JsonPrimitive && v.isString && v.content.trim().isNotEmpty()) { + return v.content.trim() + } + } + + return "" + } + + fun numberValue(value: JsonElement?): Double? { + val v = value ?: return null + if (v is JsonNull) { + return null + } + if (v !is JsonPrimitive) { + return null + } + + // TS source does `Number(value)` — numeric STRINGS ("0.0", "12") are + // accepted, matching the desktop engine and the gateway's habit of + // shipping exit_code as a string or float. + return v.content.toDoubleOrNull() + } + + fun intValue(value: JsonElement?): Int? { + val n = numberValue(value) ?: return null + + return n.toInt() + } + + /** True only for a literal JSON `true` (not the string "true"). */ + fun boolTrue(value: JsonElement?): Boolean = + (value as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true + + /** True only for a literal JSON `false` (not the string "false"). */ + fun boolFalse(value: JsonElement?): Boolean = + (value as? JsonPrimitive)?.let { !it.isString && it.content == "false" } == true + + fun compactPreview( + value: String, + max: Int = 72, + ): String { + val line = value.replace(Regex("\\s+"), " ").trim() + + return if (line.length > max) "${line.take(max - 1)}…" else line + } + + fun compactPreview( + value: JsonElement?, + max: Int = 72, + ): String { + val raw: String = + if (value is JsonPrimitive && value.isString) { + value.content + } else { + firstString(parseMaybeObject(value), listOf("context")) + } + + return compactPreview(raw, max) + } + + fun contextValue(value: JsonElement?): String { + val row = parseMaybeObject(value) + val context = firstString(row, listOf("context")) + val preview = firstString(row, listOf("preview")) + + if (context.isNotEmpty()) { + return context + } + if (preview.isNotEmpty()) { + return preview + } + + return (value as? JsonPrimitive)?.takeIf { it.isString }?.content ?: "" + } + + /** Objects pass through; stringified JSON objects are re-parsed. */ + fun parseMaybeObject(value: JsonElement?): JsonObject? = + when (value) { + is JsonObject -> { + value + } + + is JsonPrimitive -> { + if (!value.isString || value.content.trim().isEmpty()) { + null + } else { + try { + Json.parseToJsonElement(value.content) as? JsonObject + } catch (_: Exception) { + null + } + } + } + + else -> { + null + } + } + + /** Peel one `data`/`result`/`output`/`response`/`payload` wrapper layer. */ + fun unwrapToolPayload(value: JsonElement?): JsonElement? { + val record = parseMaybeObject(value) ?: return value + + for (key in listOf("data", "result", "output", "response", "payload")) { + val payload = record[key] ?: continue + if (payload is JsonNull) { + continue + } + + return payload + } + + return value + } + + fun findFirstUrl(vararg sources: JsonElement?): String { + for (src in sources) { + if (src is JsonPrimitive && src.isString) { + val m = URL_PATTERN.find(src.content) + + if (m != null) { + return m.value + } + } else if (src is JsonObject) { + for (v in src.values) { + val found = findFirstUrl(v) + + if (found.isNotEmpty()) { + return found + } + } + } + } + + return "" + } + + fun hostnameOf(value: String): String = + try { + val url = java.net.URI(value) + val path = url.path?.takeIf { it.isNotEmpty() && it != "/" } ?: "" + "${url.host ?: value}$path" + } catch (_: Exception) { + value + } + + fun stripAnsi(value: String): String = value.replace(Regex("\u001B\\[[0-9;]*m"), "") + + fun stripDividerLines(value: String): String = + value + .split("\n") + .filter { !Regex("^[-=]{3,}\\s*$").matches(it.trim()) } + .joinToString("\n") + .trim() + + fun cleanVisibleText(text: String): String = + text + .replace(Regex("`{3,}"), "") + .replace(Regex("(?<=[\\p{L}\\p{N})\\].,!?:;\"'”’])\\[(?:\\d+(?:\\s*,\\s*\\d+)*)\\](?!\\()"), "") + .replace(Regex("\\[([^\\]]+)\\]\\(([^)\\s]+)\\)")) { m -> "${m.groupValues[1]} ${m.groupValues[2]}" } + + /** Find the result-item array behind common list keys or wrappers. */ + fun collectResultItems(value: JsonElement?): List { + if (value is JsonArray) { + return value.toList() + } + + val record = parseMaybeObject(value) ?: return emptyList() + + val keys = + listOf( + "web", + "results", + "search_results", + "sources", + "web_sources", + "items", + "organic_results", + "organic", + "matches", + "documents", + ) + + for (key in keys) { + val candidate = record[key] ?: continue + + if (candidate is JsonArray) { + return candidate.toList() + } + + if (candidate is JsonObject) { + val nested = collectResultItems(candidate) + + if (nested.isNotEmpty()) { + return nested + } + } + } + + val payload = unwrapToolPayload(record) + + return if (payload === record) emptyList() else collectResultItems(payload) + } + + /** Humanized generic title: "fact_store" → "Fact Store", sans browser_/web_ prefix. */ + fun titleForTool(name: String): String { + val normalized = name.replace(Regex("^browser_"), "").replace(Regex("^web_"), "") + + return normalized + .split("_") + .filter { it.isNotEmpty() } + .joinToString( + " ", + ) { part -> part.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } } + .ifEmpty { name } + } + + /** Generic detail: prefer a fresh result context, then args, then the heuristic summary. */ + fun fallbackDetailText( + args: JsonElement?, + result: JsonElement?, + ): String { + val argContext = contextValue(args) + val resultContext = contextValue(result) + + if (resultContext.isNotEmpty() && resultContext != argContext) { + return resultContext + } + + if (argContext.isNotEmpty()) { + return argContext + } + + if (result != null) { + return ToolResultSummary.formatToolResultSummary(result) + } + + return ToolResultSummary.formatToolResultSummary(args) + } + + fun formatDurationSeconds(seconds: Double): String { + if (!seconds.isFinite() || seconds < 0) { + return "" + } + + if (seconds < 1) { + val ms = maxOf(1, Math.round(seconds * 1000)) + return "${ms}ms" + } + + if (seconds < 60) { + return if (seconds >= 10) "${seconds.toInt()}s" else "${"%.1f".format(seconds)}s" + } + + val wholeSeconds = Math.round(seconds) + val minutes = wholeSeconds / 60 + val remSeconds = wholeSeconds % 60 + + if (minutes < 60) { + return if (remSeconds != 0L) "${minutes}m ${remSeconds}s" else "${minutes}m" + } + + val hours = minutes / 60 + val remMinutes = minutes % 60 + + return if (remMinutes != 0L) "${hours}h ${remMinutes}m" else "${hours}h" + } + + fun durationLabel(result: JsonObject?): String? { + val seconds = numberValue(result?.get("duration_s")) ?: return null + + return formatDurationSeconds(seconds) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt new file mode 100644 index 00000000..ebc7b4c1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt @@ -0,0 +1,54 @@ +package com.m57.hermescontrol.ui.chat.tool + +/** + * Per-tool (or per-family) display strategy. + * + * Every hook is a *refinement*: return null to fall back to the generic + * treatment ([ToolViewBuilder] humanizes the tool name for titles and uses + * [ToolResultSummary]-based text for subtitle/detail). Returning an empty + * string is a real answer — "this tool intentionally shows nothing here" — + * and suppresses the generic fallback. + */ +internal interface ToolRenderer { + /** Title while the call is still running ("Running ls", "Searching …"). */ + fun pendingTitle(call: ToolCall): String? = null + + /** Title after successful completion ("Ran ls", "Opened example.com"). */ + fun doneTitle(call: ToolCall): String? = null + + /** Title when the call errored. */ + fun errorTitle(call: ToolCall): String? = null + + /** Secondary line naming what was acted on. Not used when an error is shown. */ + fun subtitle(call: ToolCall): String? = null + + /** Expanded body text. */ + fun detail(call: ToolCall): String? = null + + /** Structured extras (streams, diffs, search hits) for special row layouts. */ + fun extras( + call: ToolCall, + status: ToolViewStatus, + ): ToolViewExtras = ToolViewExtras.NONE +} + +/** + * Structured, tool-specific fields a renderer can contribute to [ToolView] + * beyond the title/subtitle/detail text. + */ +internal data class ToolViewExtras( + val detailLabel: String? = null, + val stdout: String? = null, + val stderr: String? = null, + val exitCode: Int? = null, + val terminalCommand: String? = null, + val inlineDiff: String? = null, + val diffPath: String? = null, + val diffStats: DiffStats? = null, + val searchHits: List? = null, + val searchQuery: String? = null, +) { + companion object { + val NONE = ToolViewExtras() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt new file mode 100644 index 00000000..dfacec8f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt @@ -0,0 +1,87 @@ +package com.m57.hermescontrol.ui.chat.tool + +import com.m57.hermescontrol.ui.chat.tool.render.BrowserClickRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserNavigateRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserSnapshotRenderer +import com.m57.hermescontrol.ui.chat.tool.render.BrowserTypeRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ComputerUseRenderer +import com.m57.hermescontrol.ui.chat.tool.render.CronjobRenderer +import com.m57.hermescontrol.ui.chat.tool.render.FactStoreRenderer +import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ImageGenerateRenderer +import com.m57.hermescontrol.ui.chat.tool.render.MemoryRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ProcessRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ProjectListRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ProjectMutateRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ReadFileRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ReadTerminalRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SessionSearchRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SkillManageRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SkillViewRenderer +import com.m57.hermescontrol.ui.chat.tool.render.SkillsListRenderer +import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer +import com.m57.hermescontrol.ui.chat.tool.render.TodoRenderer +import com.m57.hermescontrol.ui.chat.tool.render.ToolSearchRenderer +import com.m57.hermescontrol.ui.chat.tool.render.VisionAnalyzeRenderer +import com.m57.hermescontrol.ui.chat.tool.render.WebExtractRenderer +import com.m57.hermescontrol.ui.chat.tool.render.WebSearchRenderer +import com.m57.hermescontrol.ui.chat.tool.render.XSearchRenderer + +/** + * Maps tool names to their display strategy. + * + * Unknown tools get [GenericToolRenderer]: every hook returns null, so + * [ToolViewBuilder] falls back to the humanized tool name and the + * [ToolResultSummary] heuristics — never a raw JSON dump. + */ +internal object ToolRendererRegistry { + /** All-default renderer: defers everything to the generic fallbacks. */ + private object GenericToolRenderer : ToolRenderer + + private val renderers: Map = + mapOf( + // Shell + "terminal" to TerminalRenderer, + "execute_code" to TerminalRenderer, + "read_terminal" to ReadTerminalRenderer, + // Files + "read_file" to ReadFileRenderer, + "edit_file" to FileEditRenderer, + "patch" to FileEditRenderer, + "write_file" to FileEditRenderer, + // Web + "web_search" to WebSearchRenderer, + "web_extract" to WebExtractRenderer, + "x_search" to XSearchRenderer, + // Browser + "browser_navigate" to BrowserNavigateRenderer, + "browser_snapshot" to BrowserSnapshotRenderer, + "browser_click" to BrowserClickRenderer, + "browser_fill" to BrowserTypeRenderer, + "browser_type" to BrowserTypeRenderer, + // Memory / facts + "memory" to MemoryRenderer, + "fact_store" to FactStoreRenderer, + // Task management + "cronjob" to CronjobRenderer, + "todo" to TodoRenderer, + "session_search" to SessionSearchRenderer, + "process" to ProcessRenderer, + // Skills / tool discovery + "skills_list" to SkillsListRenderer, + "skill_view" to SkillViewRenderer, + "skill_manage" to SkillManageRenderer, + "tool_search" to ToolSearchRenderer, + // Media + "image_generate" to ImageGenerateRenderer, + "vision_analyze" to VisionAnalyzeRenderer, + // Projects + "project_list" to ProjectListRenderer, + "project_create" to ProjectMutateRenderer, + "project_switch" to ProjectMutateRenderer, + // Desktop automation + "computer_use" to ComputerUseRenderer, + ) + + fun rendererFor(toolName: String): ToolRenderer = renderers[toolName] ?: GenericToolRenderer +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolStatusResolver.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolStatusResolver.kt new file mode 100644 index 00000000..adb705d1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolStatusResolver.kt @@ -0,0 +1,105 @@ +package com.m57.hermescontrol.ui.chat.tool + +import kotlinx.serialization.json.JsonPrimitive + +/** + * Status + error-text heuristics for a completed tool call, ported from the + * desktop engine. Key rules, in order: + * + * - running (or a missing result) is RUNNING + * - an explicit `success: true` / `ok: true` beats every error heuristic, + * including a stale isError envelope + * - a non-zero exit code alone is NOT an error: grep returns 1 on no-match, + * diff returns 1 on differences, piped commands surface the last stage's + * code — all routinely produce useful output. It only becomes an error + * when the command produced no real output to show. + * - a rejected memory write is a budget negotiation → WARNING, not ERROR + */ +internal object ToolStatusResolver { + fun errorText( + call: ToolCall, + isError: Boolean, + ): String { + val result = call.rawResult + val resultRecord = call.result + + if (isError) { + val extracted = ToolResultSummary.extractToolErrorMessage(result) + if (extracted.isNotEmpty()) { + return extracted + } + if (result is JsonPrimitive && result.isString && result.content.trim().isNotEmpty()) { + return result.content.trim() + } + + return "Tool returned an error." + } + + val error = ToolJson.firstString(resultRecord, listOf("error")) + if (error.isNotEmpty()) { + return error + } + + val extracted = ToolResultSummary.extractToolErrorMessage(result) + if (extracted.isNotEmpty()) { + return extracted + } + + if (resultRecord != null) { + if (ToolJson.boolFalse(resultRecord["success"]) || ToolJson.boolFalse(resultRecord["ok"])) { + return ToolJson + .firstString( + resultRecord, + listOf("message", "reason", "detail"), + ).ifEmpty { "Tool returned success=false." } + } + + val status = ToolJson.firstString(resultRecord, listOf("status")) + if (Regex("\\b(error|failed|failure)\\b", RegexOption.IGNORE_CASE).containsMatchIn(status)) { + return ToolJson + .firstString( + resultRecord, + listOf("message", "reason", "detail"), + ).ifEmpty { "Tool returned status \"$status\"." } + } + + val exit = ToolJson.numberValue(resultRecord["exit_code"]) + if (exit != null && exit != 0.0) { + val hasOutput = + listOf("output", "stdout", "stderr", "output_preview") + .any { k -> ToolJson.firstString(resultRecord, listOf(k)).isNotEmpty() } + + if (!hasOutput) { + return "Command failed with exit code ${exit.toInt()}." + } + } + } + + return "" + } + + fun status( + call: ToolCall, + isError: Boolean, + running: Boolean, + ): ToolViewStatus { + if (running || call.rawResult == null) { + return ToolViewStatus.RUNNING + } + + // Explicit success wins over isError / nested-error heuristics. + val resultRecord = call.result + if (resultRecord != null && + (ToolJson.boolTrue(resultRecord["success"]) || ToolJson.boolTrue(resultRecord["ok"])) + ) { + return ToolViewStatus.SUCCESS + } + + if (errorText(call, isError).isEmpty()) { + return ToolViewStatus.SUCCESS + } + + // A rejected memory write is a budget negotiation, not a failure. + return if (call.name == "memory") ToolViewStatus.WARNING else ToolViewStatus.ERROR + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt index 2ce9af42..3eb334b0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt @@ -1,821 +1,34 @@ package com.m57.hermescontrol.ui.chat.tool -import kotlinx.serialization.json.Json -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 /** - * Per-tool display engine — a port of the desktop app's `buildToolView` - * (fallback-model), extended with mobile-specific tool cases. + * The tool-display engine's orchestrator — a port of the desktop app's + * `buildToolView` (fallback-model), extended with mobile-specific tools. * * One pure function turns a tool call's args/result into a [ToolView] the - * renderer can paint without touching raw JSON. Structure: + * renderer can paint without touching raw JSON. The pipeline: * - * - status heuristics (running / success / error / warning) with the - * desktop's error-detection rules (non-zero exit alone is NOT an error) - * - per-tool title/subtitle/detail optimizations (terminal, file edits, - * web/browser tools, memory, cron, …) - * - count-label extraction, search-hit parsing, stdout/stderr split, - * inline diff extraction, duration formatting - * - generic fallback via [ToolResultSummary] so unknown tools still read - * as a human summary instead of a raw JSON dump + * 1. normalize the payload into a [ToolCall] (stringified JSON re-parsed) + * 2. resolve status via [ToolStatusResolver] (running / success / error / + * warning, with the desktop's rule that a non-zero exit code alone is + * NOT an error) + * 3. dispatch to the tool's [ToolRenderer] (via [ToolRendererRegistry]) for + * title/subtitle/detail and structured extras (streams, diffs, hits) + * 4. apply the cross-cutting polish: error/detail merging, redundancy + * suppression, count labels ([ToolCounts]), duration, image URLs + * + * Unknown tools fall back to [ToolResultSummary] heuristics so they still + * read as a human summary instead of a raw JSON dump. */ object ToolViewBuilder { - private val HTML_PATH_REGEX = - Regex( - "(?:^|\\s)(?:[ab]/)?([^\\s]+\\.html?)(?=\\s|$)", - RegexOption.IGNORE_CASE, - ) - - // ── shared field helpers ───────────────────────────────────────────── - - private fun firstString( - record: JsonObject?, - keys: List, - ): String { - val r = record ?: return "" - - for (k in keys) { - val v = r[k] ?: continue - if (v is JsonPrimitive && v.isString && v.content.trim().isNotEmpty()) { - return v.content.trim() - } - } - - return "" - } - - private fun numberValue(value: JsonElement?): Double? { - val v = value ?: return null - if (v is JsonNull) { - return null - } - if (v !is JsonPrimitive) { - return null - } - - // TS source does `Number(value)` — numeric STRINGS ("0.0", "12") are - // accepted, matching the desktop engine and the gateway's habit of - // shipping exit_code as a string or float. - return v.content.toDoubleOrNull() - } - - private fun intValue(value: JsonElement?): Int? { - val n = numberValue(value) ?: return null - - return n.toInt() - } - - private fun compactPreview( - value: String, - max: Int = 72, - ): String { - val line = value.replace(Regex("\\s+"), " ").trim() - - return if (line.length > max) "${line.take(max - 1)}…" else line - } - - private fun compactPreview( - value: JsonElement?, - max: Int = 72, - ): String { - var raw: String? = null - - if (value is JsonPrimitive && value.isString) { - raw = value.content - } else { - raw = firstString(parseMaybeObject(value), listOf("context")) - } - - return compactPreview(raw, max) - } - - private fun contextValue(value: JsonElement?): String { - val row = parseMaybeObject(value) - val context = firstString(row, listOf("context")) - val preview = firstString(row, listOf("preview")) - - if (context.isNotEmpty()) { - return context - } - if (preview.isNotEmpty()) { - return preview - } - - return (value as? JsonPrimitive)?.takeIf { it.isString }?.content ?: "" - } - - private fun parseMaybeObject(value: JsonElement?): JsonObject? = - when (value) { - is JsonObject -> { - value - } - - is JsonPrimitive -> { - if (!value.isString || value.content.trim().isEmpty()) { - null - } else { - try { - Json.parseToJsonElement(value.content) as? JsonObject - } catch (_: Exception) { - null - } - } - } - - else -> { - null - } - } - - private fun unwrapToolPayload(value: JsonElement?): JsonElement? { - val record = parseMaybeObject(value) ?: return value - - for (key in listOf("data", "result", "output", "response", "payload")) { - val payload = record[key] ?: continue - if (payload is JsonNull) { - continue - } - - return payload - } - - return value - } - - private fun looksLikeUrl(value: String): Boolean = - Regex("^https?://", RegexOption.IGNORE_CASE).containsMatchIn(value) - - private val URL_PATTERN = Regex("https?://[^\\s'\"<>)\\]]+", RegexOption.IGNORE_CASE) - - private fun findFirstUrl(vararg sources: JsonElement?): String { - for (src in sources) { - if (src is JsonPrimitive && src.isString) { - val m = URL_PATTERN.find(src.content) - - if (m != null) { - return m.value - } - } else if (src is JsonObject) { - for (v in src.values) { - val found = findFirstUrl(v) - - if (found.isNotEmpty()) { - return found - } - } - } - } - - return "" - } - - private fun hostnameOf(value: String): String = - try { - val url = java.net.URI(value) - val path = url.path?.takeIf { it.isNotEmpty() && it != "/" } ?: "" - "${url.host ?: value}$path" - } catch (_: Exception) { - value - } - - private fun formatDurationSeconds(seconds: Double): String { - if (!seconds.isFinite() || seconds < 0) { - return "" - } - - if (seconds < 1) { - val ms = maxOf(1, Math.round(seconds * 1000)) - return "${ms}ms" - } - - if (seconds < 60) { - return if (seconds >= 10) "${seconds.toInt()}s" else "${"%.1f".format(seconds)}s" - } - - val wholeSeconds = Math.round(seconds) - val minutes = wholeSeconds / 60 - val remSeconds = wholeSeconds % 60 - - if (minutes < 60) { - return if (remSeconds != 0L) "${minutes}m ${remSeconds}s" else "${minutes}m" - } - - val hours = minutes / 60 - val remMinutes = minutes % 60 - - return if (remMinutes != 0L) "${hours}h ${remMinutes}m" else "${hours}h" - } - - private fun durationLabel(result: JsonObject?): String? { - val seconds = numberValue(result?.get("duration_s")) ?: return null - - return formatDurationSeconds(seconds) - } - - // ── file edit helpers ──────────────────────────────────────────────── - - private val FILE_EDIT_TOOL_NAMES = setOf("edit_file", "patch", "write_file") - - private fun isFileEditTool(toolName: String): Boolean = toolName in FILE_EDIT_TOOL_NAMES - - private fun fileEditBasename(path: String): String { - val normalized = path.replace('\\', '/').trim() - - return normalized.split("/").filter { it.isNotEmpty() }.lastOrNull() ?: normalized - } - - private fun fileEditPath( - args: JsonObject?, - result: JsonObject?, - ): String { - val fromArgs = firstString(args, listOf("path", "file", "filepath")) - if (fromArgs.isNotEmpty()) { - return fromArgs - } - - val fromResult = firstString(result, listOf("path", "file", "filepath", "resolved_path")) - if (fromResult.isNotEmpty()) { - return fromResult - } - - return htmlPathFromInlineDiff(firstString(result, listOf("inline_diff", "diff"))) - } - - private fun stripAnsi(value: String): String = value.replace(Regex("\u001B\\[[0-9;]*m"), "") - - private fun stripInlineDiffChrome(value: String): String = - stripAnsi(value) - .replace(Regex("^\\s*┊\\s*review diff\\s*\\n", RegexOption.IGNORE_CASE), "") - .trim() - - private fun inlineDiffFromResult(result: JsonElement?): String { - val record = parseMaybeObject(result) ?: return "" - - for (key in listOf("inline_diff", "diff")) { - val value = record[key] - - if (value is JsonPrimitive && value.isString && value.content.trim().isNotEmpty()) { - return stripInlineDiffChrome(value.content) - } - } - - return "" - } - - private fun htmlPathFromInlineDiff(value: String): String { - val cleaned = stripInlineDiffChrome(value) - - for (match in HTML_PATH_REGEX.findAll(cleaned)) { - val candidate = match.groupValues[1].trim() - - if (candidate.isNotEmpty()) { - return candidate - } - } - - return "" - } - - private fun countDiffLineStats(diff: String): DiffStats { - var added = 0 - var removed = 0 - - for (line in diff.split("\n")) { - when { - line.startsWith("+") && !line.startsWith("+++") -> added += 1 - line.startsWith("-") && !line.startsWith("---") -> removed += 1 - } - } - - return DiffStats(added = added, removed = removed) - } - - // ── status + error detection ───────────────────────────────────────── - - private fun toolErrorText( - toolName: String, - isError: Boolean, - result: JsonElement?, - resultRecord: JsonObject?, - ): String { - if (isError) { - val extracted = ToolResultSummary.extractToolErrorMessage(result) - if (extracted.isNotEmpty()) { - return extracted - } - if (result is JsonPrimitive && result.isString && result.content.trim().isNotEmpty()) { - return result.content.trim() - } - - return "Tool returned an error." - } - - val error = firstString(resultRecord, listOf("error")) - if (error.isNotEmpty()) { - return error - } - - val extracted = ToolResultSummary.extractToolErrorMessage(result) - if (extracted.isNotEmpty()) { - return extracted - } - - if (resultRecord != null) { - val successFalse = - (resultRecord["success"] as? JsonPrimitive)?.let { !it.isString && it.content == "false" } - val okFalse = (resultRecord["ok"] as? JsonPrimitive)?.let { !it.isString && it.content == "false" } - - if (successFalse == true || okFalse == true) { - return firstString( - resultRecord, - listOf("message", "reason", "detail"), - ).ifEmpty { "Tool returned success=false." } - } - - val status = firstString(resultRecord, listOf("status")) - if (Regex("\\b(error|failed|failure)\\b", RegexOption.IGNORE_CASE).containsMatchIn(status)) { - return firstString( - resultRecord, - listOf("message", "reason", "detail"), - ).ifEmpty { "Tool returned status \"$status\"." } - } - - // A non-zero exit code alone is a weak failure signal: grep - // returns 1 on no-match, diff returns 1 on differences, piped - // commands surface the last stage's code — all routinely produce - // useful output. Only treat it as an error when the command - // produced no real output to show. - val exit = numberValue(resultRecord["exit_code"]) - if (exit != null && exit != 0.0) { - val hasOutput = - listOf("output", "stdout", "stderr", "output_preview") - .any { k -> firstString(resultRecord, listOf(k)).isNotEmpty() } - - if (!hasOutput) { - return "Command failed with exit code ${exit.toInt()}." - } - } - } - - return "" - } - - private fun toolStatus( - toolName: String, - result: JsonElement?, - resultRecord: JsonObject?, - isError: Boolean, - running: Boolean, - ): ToolViewStatus { - if (running || result == null) { - return ToolViewStatus.RUNNING - } - - // Explicit success wins over isError / nested-error heuristics. - if (resultRecord != null) { - val successTrue = (resultRecord["success"] as? JsonPrimitive)?.let { !it.isString && it.content == "true" } - val okTrue = (resultRecord["ok"] as? JsonPrimitive)?.let { !it.isString && it.content == "true" } - - if (successTrue == true || okTrue == true) { - return ToolViewStatus.SUCCESS - } - } - - if (toolErrorText(toolName, isError, result, resultRecord).isEmpty()) { - return ToolViewStatus.SUCCESS - } - - // A rejected memory write is a budget negotiation, not a failure. - return if (toolName == "memory") ToolViewStatus.WARNING else ToolViewStatus.ERROR - } - - // ── titles ─────────────────────────────────────────────────────────── - /** * The generic humanized title for a tool name ("fact_store" -> "Fact * Store"). Exposed so the view can drop the title from the collapsed * summary when it adds nothing over the header row's own tool name. */ - internal fun genericTitleFor(toolName: String?): String = titleForTool(toolName ?: "tool") - - private fun titleForTool(name: String): String { - val normalized = name.replace(Regex("^browser_"), "").replace(Regex("^web_"), "") - - return normalized - .split("_") - .filter { it.isNotEmpty() } - .joinToString( - " ", - ) { part -> part.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } } - .ifEmpty { name } - } - - private fun shellCommand(args: JsonObject?): String = - firstString(args, listOf("command", "code")) - .ifEmpty { firstString(args, listOf("context", "preview")) } - .ifEmpty { contextValue(args) } - - private fun summarizeBrowserSnapshot(snapshot: String): String { - fun count(re: Regex): Int = re.findAll(snapshot).count() - - val stats = - listOf( - "${count(Regex("""button\s+"[^"]+"""))} buttons", - "${count(Regex("""link\s+"[^"]+"""))} links", - "${count(Regex("""(?:textbox|combobox|searchbox)\s+"[^"]+"""))} inputs", - ).joinToString(" · ") - - val labels = - Regex("""(?:button|link|combobox|textbox)\s+"([^"]+)"""") - .findAll(snapshot) - .map { it.groupValues[1].trim() } - .filter { it.isNotEmpty() } - .take(4) - .toList() - - return if (labels.isNotEmpty()) "$stats\nTop controls: ${labels.joinToString(", ")}" else stats - } - - private fun readFileDisplayTarget( - args: JsonObject?, - result: JsonObject?, - ): String { - val inherited = firstString(args, listOf("context", "preview")) - if (inherited.isNotEmpty()) { - return inherited - } - - val path = firstString(args, listOf("path", "file", "filepath")) - if (path.isEmpty()) { - return "" - } - - val offset = intValue(args?.get("offset")) - val limit = intValue(args?.get("limit")) - var lineLabel = "" - - if (offset != null) { - lineLabel = if (limit == null || limit <= 1) "L$offset" else "L$offset-${offset + limit - 1}" - } else if (limit != null) { - val content = firstString(result, listOf("content")) - val lines = - content - .split("\n") - .mapNotNull { line -> - Regex("^(\\d+)\\|") - .find(line) - ?.groupValues - ?.get(1) - ?.toIntOrNull() - } - - if (lines.isNotEmpty()) { - lineLabel = - if (lines.first() == lines.last()) { - "L${lines.first()}" - } else { - "L${lines.first()}-${lines.last()}" - } - } - } - - return listOf(fileEditBasename(path), lineLabel).filter { it.isNotEmpty() }.joinToString(" ") - } - - // ── count extraction ───────────────────────────────────────────────── - - private val COUNT_FIELD_KEYS = - listOf( - "count", - "total", - "result_count", - "results_count", - "num_results", - "match_count", - "matches_count", - "file_count", - "files_count", - "item_count", - "items_count", - "search_count", - "searches_count", - "source_count", - "sources_count", - "document_count", - "documents_count", - "updated", - "added", - "removed", - "deleted", - "created", - "changed", - "processed", - "steps", - ) - - private val COUNT_ARRAY_KEYS = listOf("results", "items", "matches", "files", "documents", "sources", "rows") - - private val COUNT_EXCLUDED_KEYS = setOf("duration_s", "exit_code", "status_code") - - private fun countFromUnknown(value: JsonElement?): Int? { - if (value is JsonArray) { - return if (value.isNotEmpty()) value.size else null - } - - val n = numberValue(value) ?: return null - - if (n <= 0) { - return null - } - - return Math.round(n).toInt() - } - - private fun singularizeNoun(noun: String): String { - val normalized = noun.lowercase() - - if (normalized.isEmpty()) { - return "" - } - - if (normalized.endsWith("ies") && normalized.length > 3) { - return "${normalized.dropLast(3)}y" - } - - if (Regex("(xes|zes|ches|shes|sses)$").containsMatchIn(normalized) && normalized.length > 3) { - return normalized.dropLast(2) - } - - if (normalized.endsWith("s") && normalized.length > 2 && !normalized.endsWith("ss")) { - return normalized.dropLast(1) - } - - return normalized - } - - private fun pluralizeNoun( - noun: String, - count: Int, - ): String { - if (count == 1) { - return noun - } - - if (noun == "search") { - return "searches" - } - - if (noun.endsWith( - "y", - ) && noun.length > 1 && !Regex("[aeiou]y$", RegexOption.IGNORE_CASE).containsMatchIn(noun) - ) { - return "${noun.dropLast(1)}ies" - } - - if (Regex("(s|x|z|ch|sh)$", RegexOption.IGNORE_CASE).containsMatchIn(noun)) { - return "${noun}es" - } - - return "${noun}s" - } - - private fun countMetric( - count: Int, - noun: String, - ): Pair = count to singularizeNoun(noun).ifEmpty { "item" } - - private fun countFromRecord( - record: JsonObject, - fallbackNoun: String, - ): Pair? { - val nounByField = - mapOf( - "result_count" to "result", - "results_count" to "result", - "num_results" to "result", - "match_count" to "match", - "matches_count" to "match", - "file_count" to "file", - "files_count" to "file", - "item_count" to "item", - "items_count" to "item", - "search_count" to "search", - "searches_count" to "search", - "source_count" to "source", - "sources_count" to "source", - "document_count" to "document", - "documents_count" to "document", - "updated" to "item", - "added" to "item", - "removed" to "item", - "deleted" to "item", - "created" to "item", - "changed" to "item", - "processed" to "item", - "steps" to "step", - ) - val nounByArray = - mapOf( - "documents" to "document", - "files" to "file", - "items" to "item", - "matches" to "match", - "results" to "result", - "rows" to "row", - "sources" to "source", - ) - - for (key in COUNT_FIELD_KEYS) { - val count = countFromUnknown(record[key]) ?: continue - - return countMetric(count, nounByField[key] ?: fallbackNoun) - } - - for (key in COUNT_ARRAY_KEYS) { - val count = countFromUnknown(record[key]) ?: continue - - return countMetric(count, nounByArray[key] ?: fallbackNoun) - } - - for ((key, value) in record) { - if (key in COUNT_EXCLUDED_KEYS) { - continue - } - if (!Regex("_count$|_total$").containsMatchIn(key)) { - continue - } - - val count = countFromUnknown(value) ?: continue - val stripped = key.lowercase().replace(Regex("_(count|total)$"), "").replace(Regex("^num_"), "") - - return countMetric(count, singularizeNoun(stripped).ifEmpty { fallbackNoun }) - } - - return null - } - - private fun countFromText( - text: String, - fallbackNoun: String, - ): Pair? { - val t = text.trim() - if (t.isEmpty()) { - return null - } - - val unitMatch = - Regex( - """\b(\d+)\s+(results?|items?|files?|matches?|documents?|sources?|searches?|steps?|rows?)\b""", - RegexOption.IGNORE_CASE, - ).find(t) - ?: Regex( - """\b(?:did|found|returned|listed|searched|matched|updated|created|deleted|processed)\s+(\d+)\b""", - RegexOption.IGNORE_CASE, - ).find(t) - - val n = unitMatch?.groupValues?.get(1)?.toIntOrNull() ?: return null - val noun = unitMatch.groupValues.getOrNull(2)?.takeIf { it.isNotEmpty() } ?: fallbackNoun - - return if (n > 0) countMetric(n, noun) else null - } - - private fun collectResultItems(value: JsonElement?): List { - if (value is JsonArray) { - return value.toList() - } - - val record = parseMaybeObject(value) ?: return emptyList() - - val keys = - listOf( - "web", - "results", - "search_results", - "sources", - "web_sources", - "items", - "organic_results", - "organic", - "matches", - "documents", - ) - - for (key in keys) { - val candidate = record[key] ?: continue - - if (candidate is JsonArray) { - return candidate.toList() - } - - if (candidate is JsonObject) { - val nested = collectResultItems(candidate) - - if (nested.isNotEmpty()) { - return nested - } - } - } - - val payload = unwrapToolPayload(record) - - return if (payload === record) emptyList() else collectResultItems(payload) - } - - private fun cleanVisibleText(text: String): String = - text - .replace(Regex("`{3,}"), "") - .replace(Regex("(?<=[\\p{L}\\p{N})\\].,!?:;\"'”’])\\[(?:\\d+(?:\\s*,\\s*\\d+)*)\\](?!\\()"), "") - .replace(Regex("\\[([^\\]]+)\\]\\(([^)\\s]+)\\)")) { m -> "${m.groupValues[1]} ${m.groupValues[2]}" } - - private fun extractSearchResults( - result: JsonElement?, - limit: Int = 6, - ): List { - return collectResultItems(result) - .mapNotNull { item -> - val r = parseMaybeObject(item) ?: return@mapNotNull null - - SearchHit( - title = cleanVisibleText(firstString(r, listOf("title", "name"))), - url = firstString(r, listOf("url", "href", "link")), - snippet = cleanVisibleText(firstString(r, listOf("snippet", "description", "body"))), - ) - }.filter { it.title.isNotEmpty() || it.url.isNotEmpty() } - .take(limit) - } - - // ── detail helpers ─────────────────────────────────────────────────── - - private fun looksRedundant( - title: String, - detail: String, - ): Boolean { - if (detail.isEmpty()) { - return true - } - - val norm: (String) -> String = { input -> input.lowercase().replace(Regex("\\s+"), " ").trim() } - - return norm(title) == norm(detail) - } - - private fun fallbackDetailText( - args: JsonElement?, - result: JsonElement?, - ): String { - val argContext = contextValue(args) - val resultContext = contextValue(result) - - if (resultContext.isNotEmpty() && resultContext != argContext) { - return resultContext - } - - if (argContext.isNotEmpty()) { - return argContext - } - - if (result != null) { - return ToolResultSummary.formatToolResultSummary(result) - } - - return ToolResultSummary.formatToolResultSummary(args) - } - - private fun minimalValueSummary(value: JsonElement?): String { - if (value == null || value is JsonNull) { - return "" - } - - if (value is JsonPrimitive) { - return value.content - } - - return "" - } - - private fun shellOutput(result: JsonObject?): String { - val output = firstString(result, listOf("output", "stdout", "stderr")) - val lines = - (result?.get("lines") as? JsonArray) - ?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content } - ?.joinToString("\n") - ?: "" - - return listOf(output, lines).filter { it.isNotEmpty() }.joinToString("\n") - } - - private fun stripDividerLines(value: String): String = - value - .split("\n") - .filter { !Regex("^[-=]{3,}\\s*$").matches(it.trim()) } - .joinToString("\n") - .trim() - - // ── the builder ────────────────────────────────────────────────────── + internal fun genericTitleFor(toolName: String?): String = ToolJson.titleForTool(toolName ?: "tool") fun build( toolName: String, @@ -824,25 +37,35 @@ object ToolViewBuilder { isError: Boolean = false, running: Boolean = false, ): ToolView { - val argsRecord = parseMaybeObject(args) - val resultRecord = parseMaybeObject(result) - val status = toolStatus(toolName, result, resultRecord, isError, running) + val call = ToolCall.of(toolName, args, result) + val renderer = ToolRendererRegistry.rendererFor(toolName) + + val status = ToolStatusResolver.status(call, isError, running) val error = if (status == ToolViewStatus.SUCCESS) { "" } else { - toolErrorText(toolName, isError, result, resultRecord) + ToolStatusResolver.errorText(call, isError) } val title = when { - running -> pendingTitle(toolName, argsRecord) - error.isNotEmpty() -> errorTitle(toolName, resultRecord) - else -> doneTitle(toolName, argsRecord, resultRecord) + running -> renderer.pendingTitle(call) + error.isNotEmpty() -> renderer.errorTitle(call) + else -> renderer.doneTitle(call) + } ?: ToolJson.titleForTool(toolName) + + val subtitle = + if (error.isNotEmpty()) { + error + } else { + renderer.subtitle(call) ?: genericSubtitle(call) } - val subtitle = subtitleFor(toolName, argsRecord, resultRecord, result, error) - val detailBody = stripDividerLines(detailFor(toolName, argsRecord, resultRecord, result, args)) + val detailBody = + ToolJson.stripDividerLines( + renderer.detail(call) ?: ToolJson.fallbackDetailText(call.rawArgs, call.rawResult), + ) val detail = if (error.isNotEmpty()) { listOf(error, detailBody) @@ -857,1267 +80,60 @@ object ToolViewBuilder { detailBody } - val rendersAnsi = toolName == "terminal" || toolName == "execute_code" - // The gateway's terminal_tool emits a single merged `output` string - // (stdout+stderr, ANSI-stripped); older payloads carried separate - // `stdout`/`stderr`. Prefer a real stream split, fall back to the - // merged output as the single renderable stream. - val stdout = if (rendersAnsi) firstString(resultRecord, listOf("stdout", "output")) else "" - val stderrRaw = if (rendersAnsi) firstString(resultRecord, listOf("stderr")) else "" - val hasSplitStreams = rendersAnsi && (stdout.isNotEmpty() || stderrRaw.isNotEmpty()) - - val searchHits = - if (toolName == "web_search" && status != ToolViewStatus.ERROR) { - extractSearchResults(result) - } else { - emptyList() - } - - val searchQuery = - if (toolName == "web_search") { - firstString(argsRecord, listOf("search_term", "query")).ifEmpty { contextValue(argsRecord) } - } else { - "" - } + val extras = renderer.extras(call, status) val countLabel = if (status == ToolViewStatus.ERROR) { null } else { - countLabelFor( - toolName, - argsRecord, - resultRecord, - result, - ) + ToolCounts.countLabelFor(call) } - val inlineDiff = if (isFileEditTool(toolName)) inlineDiffFromResult(result) else "" - val diffStats = if (inlineDiff.isNotEmpty()) countDiffLineStats(inlineDiff) else null return ToolView( status = status, title = title, subtitle = subtitle, detail = detail, - detailLabel = if (toolName == "web_search" && searchHits.isNotEmpty()) "Search results" else null, - countLabel = countLabel?.let { (count, noun) -> "$count ${pluralizeNoun(noun, count)}" }, - durationLabel = durationLabel(resultRecord), - stdout = if (hasSplitStreams) stdout else null, - stderr = if (hasSplitStreams) stderrRaw else null, - exitCode = if (toolName == "terminal") intValue(resultRecord?.get("exit_code")) else null, - terminalCommand = if (toolName == "terminal") shellCommand(argsRecord) else null, - inlineDiff = inlineDiff.ifEmpty { null }, - diffPath = if (inlineDiff.isNotEmpty()) fileEditPath(argsRecord, resultRecord) else null, - diffStats = diffStats, - imageUrl = imageUrlFor(argsRecord, resultRecord), - searchHits = searchHits.ifEmpty { null }, - searchQuery = searchQuery.ifEmpty { null }, + detailLabel = extras.detailLabel, + countLabel = countLabel?.let { (count, noun) -> "$count ${ToolCounts.pluralizeNoun(noun, count)}" }, + durationLabel = ToolJson.durationLabel(call.result), + stdout = extras.stdout, + stderr = extras.stderr, + exitCode = extras.exitCode, + terminalCommand = extras.terminalCommand, + inlineDiff = extras.inlineDiff?.ifEmpty { null }, + diffPath = extras.diffPath, + diffStats = extras.diffStats, + imageUrl = imageUrlFor(call.args, call.result), + searchHits = extras.searchHits, + searchQuery = extras.searchQuery, error = error.ifEmpty { null }, ) } - // ── titles per tool ────────────────────────────────────────────────── + /** Subtitle fallback: first line of the heuristic summary, then contexts. */ + private fun genericSubtitle(call: ToolCall): String { + val summary = ToolResultSummary.formatToolResultSummary(call.rawResult) + val firstLine = summary.split("\n").firstOrNull()?.takeIf { it.isNotEmpty() } ?: "" - private fun pendingTitle( - toolName: String, - args: JsonObject?, - ): String = - when (toolName) { - "web_extract" -> { - "Reading ${targetHostname(toolName, args, null)}" - } - - "browser_navigate" -> { - "Opening ${targetHostname(toolName, args, null)}" - } - - "web_search" -> { - "Searching \"${compactPreview(searchQueryFor(args), 48)}\"" - } - - "read_file" -> { - "Reading ${readFileDisplayTarget(args, null)}" - } - - "terminal", "execute_code" -> { - val command = shellCommand(args) - val verb = if (toolName == "execute_code") "Running code" else "Running" - if (command.isNotEmpty()) { - "$verb ${compactPreview( - CommandSummarizer.summarizeShellCommand(command), - 160, - )}" - } else { - titleForTool(toolName) - } - } - - "memory" -> { - "Saving" - } - - else -> { - titleForTool(toolName) - } - } - - private fun doneTitle( - toolName: String, - args: JsonObject?, - result: JsonObject?, - ): String = - when (toolName) { - "web_extract" -> { - "Read ${targetHostname(toolName, args, result)}" - } - - "browser_navigate" -> { - "Opened ${targetHostname(toolName, args, result)}" - } - - "web_search" -> { - "Searched \"${compactPreview(searchQueryFor(args), 48)}\"" - } - - "read_file" -> { - "Read ${readFileDisplayTarget(args, result)}" - } - - "terminal", "execute_code" -> { - val command = shellCommand(args) - val verb = if (toolName == "execute_code") "Ran code" else "Ran" - if (command.isNotEmpty()) { - "$verb ${compactPreview( - CommandSummarizer.summarizeShellCommand(command), - 160, - )}" - } else { - titleForTool(toolName) - } - } - - "memory" -> { - val action = firstString(args, listOf("action")).lowercase() - val target = firstString(args, listOf("target")) - val base = - when (action) { - "replace", "update" -> "Memory Updated" - "remove", "delete" -> "Memory Removed" - "add" -> "Memory Saved" - else -> "Memory ${firstString(args, listOf("action"))}" - } - - // Keep the target visible — "Memory Saved (user)" — like the - // pre-engine mobile summary did. - if (target.isNotEmpty()) "$base ($target)" else base - } - - "cronjob" -> { - "Cron ${firstString(args, listOf("action")).ifEmpty { "manage" }}" - } - - "edit_file", "patch", "write_file" -> { - val path = fileEditPath(args, result) - if (path.isNotEmpty()) fileEditBasename(path) else titleForTool(toolName) - } - - else -> { - titleForTool(toolName) - } - } - - private fun errorTitle( - toolName: String, - result: JsonObject?, - ): String = - when (toolName) { - "browser_navigate" -> { - val url = findFirstUrl(result).ifEmpty { result?.get("url")?.toString() ?: "" } - if (url.isNotEmpty()) "Failed to open ${hostnameOf(url)}" else titleForTool(toolName) - } - - else -> { - titleForTool(toolName) - } - } - - private fun targetHostname( - toolName: String, - args: JsonObject?, - result: JsonObject?, - ): String { - val url = - firstString(args, listOf("url", "target")) - .ifEmpty { firstString(result, listOf("url")) } - .ifEmpty { findFirstUrl(args, result) } - - return if (url.isNotEmpty()) hostnameOf(url) else "page" + return ToolJson + .compactPreview(firstLine, 120) + .ifEmpty { ToolJson.compactPreview(call.result, 120) } + .ifEmpty { ToolJson.compactPreview(call.args, 120) } + .ifEmpty { ToolJson.fallbackDetailText(call.args, call.rawResult) } } - private fun searchQueryFor(args: JsonObject?): String = - firstString(args, listOf("search_term", "query")).ifEmpty { contextValue(args) } - - // ── subtitles per tool ─────────────────────────────────────────────── - - private fun subtitleFor( - toolName: String, - args: JsonObject?, - result: JsonObject?, - rawResult: JsonElement?, - error: String, - ): String { - if (error.isNotEmpty()) { - return error + private fun looksRedundant( + title: String, + detail: String, + ): Boolean { + if (detail.isEmpty()) { + return true } - return when (toolName) { - "browser_navigate" -> { - val url = - firstString(args, listOf("url", "target")) - .ifEmpty { firstString(result, listOf("url")) } - .ifEmpty { findFirstUrl(args, result) } + val norm: (String) -> String = { input -> input.lowercase().replace(Regex("\\s+"), " ").trim() } - if (url.isNotEmpty()) hostnameOf(url) else "Navigated in browser" - } - - "browser_snapshot" -> { - val snapshot = firstString(result, listOf("snapshot")) - if (snapshot.isNotEmpty()) { - summarizeBrowserSnapshot( - snapshot, - ) - } else { - "Captured a browser accessibility snapshot" - } - } - - "browser_click" -> { - val clicked = - firstString( - result, - listOf("clicked"), - ).ifEmpty { firstString(args, listOf("ref", "target")) } - - if (clicked.isEmpty()) { - "Clicked on page" - } else if (clicked.startsWith("@")) { - "Clicked page element (internal ref $clicked)" - } else { - "Clicked $clicked" - } - } - - "browser_fill", "browser_type" -> { - val field = firstString(args, listOf("label", "field", "ref", "target")) - val value = firstString(args, listOf("value", "text")) - listOf( - field.takeIf { it.isNotEmpty() }?.let { "Field: $it" }, - value.takeIf { it.isNotEmpty() }?.let { "Value: ${compactPreview(it, 42)}" }, - ).filterNotNull() - .joinToString(" · ") - .ifEmpty { "Filled page input" } - } - - "web_search" -> { - searchQueryFor( - args, - ).takeIf { it.isNotEmpty() }?.let { "Query: $it" } ?: "Queried web sources" - } - - "terminal", "execute_code" -> { - val output = shellOutput(result) - val firstMeaningfulLine = - output - .split("\n") - .map { it.trim() } - .firstOrNull { it.isNotEmpty() } - - if (firstMeaningfulLine != null) { - compactPreview(firstMeaningfulLine, 160) - } else { - // A terminal row with no output shows its command in the - // title; nothing more to add. - "" - } - } - - "read_file", "edit_file", "patch", "write_file" -> { - val path = - if (isFileEditTool(toolName)) { - fileEditPath(args, result) - } else { - firstString(args, listOf("path", "file", "filepath")) - } - - if (path.isNotEmpty()) { - path - } else { - fallbackDetailText(args, rawResult) - } - } - - "web_extract" -> { - val url = - firstString(args, listOf("url")) - .ifEmpty { firstString(result, listOf("url")) } - .ifEmpty { findFirstUrl(args, result) } - - if (url.isNotEmpty()) hostnameOf(url) else "Fetched webpage" - } - - "memory" -> { - firstString(result, listOf("message", "error")) - } - - "cronjob" -> { - cronjobSubtitle(args, result) - } - - "todo" -> { - todoSubtitle(result) - } - - "fact_store" -> { - factStoreSubtitle(args, result) - } - - "session_search" -> { - sessionSearchSubtitle(result) - } - - "skills_list" -> { - val category = firstString(args, listOf("category")) - val count = - (result?.get("skills") as? JsonArray)?.size - ?: (result?.get("results") as? JsonArray)?.size - ?: 0 - if (count > 0) { - "$count skills${category.takeIf { it.isNotEmpty() }?.let { " ($it)" } ?: ""}" - } else { - "No skills found" - } - } - - "skill_view" -> { - firstString(args, listOf("name")) - } - - "skill_manage" -> { - val action = firstString(args, listOf("action")) - val name = firstString(args, listOf("name")) - if (name.isNotEmpty()) "Skill $action: $name" else "Skill $action" - } - - "process" -> { - val action = firstString(args, listOf("action")) - val procId = firstString(args, listOf("session_id")) - if (procId.isNotEmpty()) "$action: $procId" else action - } - - "x_search" -> { - val query = firstString(args, listOf("query")) - val degraded = - ( - result?.get( - "degraded", - ) as? JsonPrimitive - )?.let { !it.isString && it.content == "true" } == true - if (query.isNotEmpty()) "$query${if (degraded) " (no citations)" else ""}" else "Queried X" - } - - "vision_analyze" -> { - firstString(args, listOf("image_url")).take(60) - } - - "tool_search" -> { - val query = firstString(args, listOf("query")) - val matches = (result?.get("matches") as? JsonArray)?.size - if (matches != null) "$query ($matches matches)" else query - } - - "image_generate" -> { - val imageUrl = firstString(result, listOf("image")).ifEmpty { firstString(args, listOf("image_url")) } - val modality = firstString(result, listOf("modality")) - listOf(imageUrl.take(60), modality.takeIf { it.isNotEmpty() }?.let { "($it)" }) - .filterNotNull() - .joinToString(" ") - } - - "project_list" -> { - val projects = (result?.get("projects") as? JsonArray)?.size - val active = firstString(result, listOf("active_id")) - if (projects != null) { - "$projects projects${active.takeIf { it.isNotEmpty() }?.let { " (1 active)" } ?: ""}" - } else { - "Projects" - } - } - - "project_create", "project_switch" -> { - val actionLabel = if (toolName == "project_create") "Created" else "Switched to" - val name = firstString(result, listOf("name")) - if (name.isNotEmpty()) "$actionLabel $name" else actionLabel - } - - "read_terminal" -> { - val error = firstString(result, listOf("error")) - if (error.isNotEmpty()) { - "❌ $error" - } else { - val total = intValue(result?.get("total_lines")) ?: 0 - val start = intValue(result?.get("start")) ?: 0 - val end = intValue(result?.get("end")) ?: 0 - val text = firstString(result, listOf("text")) - if (text.isNotEmpty()) { - val cursor = intValue(result?.get("cursor_row")) - "Lines $start-$end of $total${cursor?.let { ", cursor at row $it" } ?: ""}" - } else { - "Terminal output" - } - } - } - - "computer_use" -> { - computerUseSubtitle(args, result) - } - - else -> { - val summary = ToolResultSummary.formatToolResultSummary(rawResult) - val firstLine = summary.split("\n").firstOrNull()?.takeIf { it.isNotEmpty() } ?: "" - compactPreview(firstLine, 120) - .ifEmpty { compactPreview(result, 120) } - .ifEmpty { compactPreview(args, 120) } - .ifEmpty { fallbackDetailText(args, rawResult) } - } - } - } - - private fun cronjobSubtitle( - args: JsonObject?, - result: JsonObject?, - ): String { - val jobs = result?.get("jobs") as? JsonArray - - if (jobs != null) { - return if (jobs.isNotEmpty()) { - "${jobs.size} cron ${if (jobs.size == 1) "job" else "jobs"}" - } else { - "No cron jobs" - } - } - - val message = firstString(result, listOf("message")) - if (message.isNotEmpty()) { - return message - } - - val action = firstString(args, listOf("action")).ifEmpty { "manage" } - val name = firstString(result, listOf("name")).ifEmpty { firstString(args, listOf("name", "job_id")) } - val label = action.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } - - return if (name.isNotEmpty()) "$label $name" else "Cron $action" - } - - private fun todoSubtitle(result: JsonObject?): String { - val summary = result?.get("summary") as? JsonObject ?: return "" - val total = intValue(summary["total"]) ?: 0 - val pending = intValue(summary["pending"]) ?: 0 - val inProgress = intValue(summary["in_progress"]) ?: 0 - val completed = intValue(summary["completed"]) ?: 0 - val cancelled = intValue(summary["cancelled"]) ?: 0 - - val parts = - listOf( - pending.takeIf { it > 0 }?.let { "$it pending" }, - inProgress.takeIf { it > 0 }?.let { "$it in_progress" }, - completed.takeIf { it > 0 }?.let { "$it completed" }, - cancelled.takeIf { it > 0 }?.let { "$it cancelled" }, - ).filterNotNull() - - val itemWord = if (total == 1) "item" else "items" - - return if (parts.isEmpty()) "0 items" else "$total $itemWord (${parts.joinToString(", ")})" - } - - private fun factStoreSubtitle( - args: JsonObject?, - result: JsonObject?, - ): String { - val action = firstString(args, listOf("action")).ifEmpty { firstString(result, listOf("action")) } - val entity = firstString(args, listOf("entity")) - val query = firstString(args, listOf("query")) - val facts = - (result?.get("results") as? JsonArray) - ?: (result?.get("facts") as? JsonArray) - val count = intValue(result?.get("count")) ?: facts?.size ?: 0 - val status = firstString(result, listOf("status")) - val factId = intValue(result?.get("fact_id")) - val removed = (result?.get("removed") as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true - val updated = (result?.get("updated") as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true - - val actionContext = - when { - entity.isNotEmpty() -> " ($action: $entity)" - query.isNotEmpty() -> " ($action: $query)" - else -> " ($action)" - } - - return when { - status == "added" && factId != null -> "Fact added (ID: $factId)" - status == "added" -> "Fact added" - removed -> "Fact removed" - updated -> "Fact updated" - facts != null -> "$count facts$actionContext" - else -> action - } - } - - private fun sessionSearchSubtitle(result: JsonObject?): String { - val mode = firstString(result, listOf("mode")) - val query = firstString(result, listOf("query")) - val count = intValue(result?.get("count")) - val msgCount = intValue(result?.get("message_count")) - val messages = result?.get("messages") as? JsonArray - val truncated = - ( - result?.get( - "truncated", - ) as? JsonPrimitive - )?.let { !it.isString && it.content == "true" } == true - - return when (mode) { - "discover" -> "${count ?: 0} session${if (count == 1) "" else "s"}${query.takeIf { it.isNotEmpty() }?.let { - ": ${it.take( - 60, - )}" - } ?: ""}" - - "scroll" -> "${messages?.size ?: 0} messages (scroll)" - - "read" -> "${msgCount ?: messages?.size ?: 0} messages${if (truncated) " (truncated)" else ""}" - - "browse" -> "${count ?: 0} recent sessions" - - else -> "session_search" - } - } - - private fun computerUseSubtitle( - args: JsonObject?, - result: JsonObject?, - ): String { - val error = firstString(result, listOf("error")) - if (error.isNotEmpty()) { - return "❌ $error" - } - - val apps = result?.get("apps") as? JsonArray - if (apps != null) { - return "${apps.size} apps" - } - - val elements = result?.get("elements") as? JsonArray - if (elements != null) { - val mode = firstString(result, listOf("mode")) - val width = intValue(result.get("width")) - val height = intValue(result.get("height")) - val modePart = mode.takeIf { it.isNotEmpty() }?.let { " ($it)" } ?: "" - val sizePart = if (width != null && height != null) " ${width}x$height" else "" - return "${elements.size} elements$modePart$sizePart" - } - - val action = firstString(args, listOf("action")) - if (action.isNotEmpty()) { - val ok = (result?.get("ok") as? JsonPrimitive)?.let { !it.isString && it.content == "true" } - val fail = (result?.get("ok") as? JsonPrimitive)?.let { !it.isString && it.content == "false" } - return "$action${when { - ok == true -> " ✓" - fail == true -> " ✗" - else -> "" - }}" - } - - return "Computer use" - } - - // ── details per tool ───────────────────────────────────────────────── - - private fun detailFor( - toolName: String, - args: JsonObject?, - result: JsonObject?, - rawResult: JsonElement?, - rawArgs: JsonElement?, - ): String { - return when (toolName) { - "browser_snapshot" -> { - val snapshot = firstString(result, listOf("snapshot")) - if (snapshot.isNotEmpty()) { - summarizeBrowserSnapshot( - snapshot, - ) - } else { - fallbackDetailText(rawArgs, rawResult) - } - } - - "terminal", "execute_code" -> { - val output = shellOutput(result) - if (output.isNotEmpty()) { - output - } else if (toolName == "execute_code") { - fallbackDetailText(rawArgs, rawResult) - } else { - // A terminal row with no output already shows its command - // in the title; the generic fallback would repeat it. - "" - } - } - - "web_extract" -> { - val direct = firstString(result, listOf("content", "text", "markdown", "body", "summary", "message")) - if (direct.isNotEmpty()) { - return direct.replace(Regex("\\s*in\\s+\\d+(?:\\.\\d+)?s\\s*$"), "").trim() - } - - val results = result?.get("results") as? JsonArray - if (results != null) { - return results - .mapNotNull { item -> - val row = parseMaybeObject(item) ?: return@mapNotNull null - firstString(row, listOf("content", "text", "markdown", "body")) - }.filter { it.isNotEmpty() } - .joinToString("\n\n---\n\n") - } - - fallbackDetailText(rawArgs, rawResult) - } - - "read_file" -> { - if (rawResult == null) { - return "" - } - firstString(result, listOf("content", "text", "data", "body")) - } - - "memory" -> { - firstString(result, listOf("message", "error")) - } - - "edit_file", "patch", "write_file" -> { - if (inlineDiffFromResult(rawResult).isNotEmpty()) { - "" - } else { - firstString(result, listOf("message", "summary")).ifEmpty { - if (fileEditPath(args, result).isNotEmpty()) "" else fallbackDetailText(rawArgs, rawResult) - } - } - } - - "web_search" -> { - fallbackDetailText(rawArgs, rawResult) - } - - "cronjob" -> { - cronjobDetail(args, result) - } - - "todo" -> { - todoDetail(result) ?: "" - } - - "fact_store" -> { - factStoreDetail(result) ?: "" - } - - "session_search" -> { - sessionSearchDetail(result) ?: "" - } - - "skills_list" -> { - val skills = - (result?.get("skills") as? JsonArray) - ?: (result?.get("results") as? JsonArray) - skills - ?.mapNotNull { el -> - val s = parseMaybeObject(el) ?: return@mapNotNull null - val name = firstString(s, listOf("name")) - val desc = firstString(s, listOf("description")) - val cat = firstString(s, listOf("category")) - val lines = mutableListOf("📌 $name") - if (desc.isNotEmpty()) lines += " $desc" - if (cat.isNotEmpty()) lines += " [$cat]" - lines.joinToString("\n") - }?.joinToString("\n\n") - ?: "No skills found" - } - - "skill_view" -> { - val content = firstString(result, listOf("content")) - val linkedFiles = result?.get("linked_files") as? JsonObject - val contentPreview = - if (content.length > 500) { - "${content.take( - 500, - )}\n... [${content.length - 500} more chars]" - } else { - content - } - val filesInfo = - linkedFiles - ?.entries - ?.joinToString("\n") { (k, v) -> - val paths = - when (v) { - is JsonArray -> { - v.joinToString( - ", ", - ) { (it as? JsonPrimitive)?.content ?: it.toString() } - } - - is JsonPrimitive -> { - v.content - } - - else -> { - v.toString() - } - } - " 📎 $k: $paths" - } - - buildString { - if (contentPreview.isNotEmpty()) append(contentPreview) - if (filesInfo != null) { - if (contentPreview.isNotEmpty()) append("\n\n") - append("━━━ Linked Files ━━━\n$filesInfo") - } - if (content.isEmpty() && linkedFiles == null) append("No content available") - } - } - - "skill_manage" -> { - val error = firstString(result, listOf("error")) - if (error.isNotEmpty()) { - "❌ $error" - } else { - val msg = firstString(result, listOf("message")) - val success = - ( - result?.get( - "success", - ) as? JsonPrimitive - )?.let { !it.isString && it.content == "false" } == true - when { - !success -> "❌ Skill operation failed" - msg.isNotEmpty() -> "✅ $msg" - else -> "✅ Skill operation done" - } - } - } - - "process" -> { - processDetail(args, result) - } - - "x_search" -> { - xSearchDetail(result) - } - - "vision_analyze" -> { - val error = firstString(result, listOf("error")) - val description = firstString(result, listOf("description")) - val content = firstString(result, listOf("content")) - when { - error.isNotEmpty() -> "❌ $error" - description.isNotEmpty() -> description - content.isNotEmpty() -> content - else -> "No description available" - } - } - - "tool_search" -> { - val matches = result?.get("matches") as? JsonArray - matches - ?.mapNotNull { el -> - val m = parseMaybeObject(el) ?: return@mapNotNull null - val name = firstString(m, listOf("name")) - val desc = firstString(m, listOf("description")) - val lines = mutableListOf("🔧 $name") - if (desc.isNotEmpty()) lines += " $desc" - lines.joinToString("\n") - }?.joinToString("\n\n") - ?: "No matching tools" - } - - "image_generate" -> { - val error = firstString(result, listOf("error")) - val imageUrl = firstString(result, listOf("image")) - val prompt = firstString(args, listOf("prompt")) - - buildString { - if (error.isNotEmpty()) { - append("❌ $error") - if (imageUrl.isNotEmpty()) append("\n🔗 $imageUrl") - } else if (imageUrl.isNotEmpty()) { - append("🖼️ ") - if (prompt.isNotEmpty()) append("$prompt\n") - append("\n🔗 $imageUrl") - } else { - append("✅ Generated") - } - } - } - - "project_list" -> { - val projects = result?.get("projects") as? JsonArray - projects - ?.mapNotNull { el -> - val p = parseMaybeObject(el) ?: return@mapNotNull null - val id = firstString(p, listOf("id")) - val name = firstString(p, listOf("name")) - val slug = firstString(p, listOf("slug")) - val path = firstString(p, listOf("primary_path")) - val isActive = - (p["active"] as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true - val lines = mutableListOf("${if (isActive) "⭐ " else "📁 "}$name") - if (slug.isNotEmpty()) lines += " 🏷️ $slug" - if (path.isNotEmpty()) lines += " 📍 $path" - if (isActive) lines += " ✅ ACTIVE PROJECT" - lines.joinToString("\n") - }?.joinToString("\n\n") - ?: "" - } - - "project_create", "project_switch" -> { - val error = firstString(result, listOf("error")) - val actionLabel = if (toolName == "project_create") "Created" else "Switched to" - val name = firstString(result, listOf("name")) - val slug = firstString(result, listOf("slug")) - val path = firstString(result, listOf("primary_path")) - - buildString { - if (error.isNotEmpty()) { - append("❌ $error") - } else { - append("✅ $actionLabel") - if (name.isNotEmpty()) append(": $name") - if (slug.isNotEmpty()) append("\n 🏷️ $slug") - if (path.isNotEmpty()) append("\n 📍 $path") - } - } - } - - "read_terminal" -> { - readTerminalDetail(result) - } - - "computer_use" -> { - computerUseDetail(args, result) - } - - else -> { - fallbackDetailText(rawArgs, rawResult) - } - } - } - - private fun cronjobDetail( - args: JsonObject?, - result: JsonObject?, - ): String { - val jobs = result?.get("jobs") as? JsonArray - - if (jobs != null) { - if (jobs.isEmpty()) { - return "No cron jobs scheduled" - } - - return jobs - .take(20) - .mapNotNull { job -> - val row = parseMaybeObject(job) ?: return@mapNotNull null - val name = firstString(row, listOf("name", "id")).ifEmpty { "job" } - val sched = firstString(row, listOf("schedule_display", "schedule")) - if (sched.isNotEmpty()) "- $name · $sched" else "- $name" - }.joinToString("\n") - } - - val rows = - listOf( - "Schedule" to firstString(result, listOf("schedule")), - "Repeat" to firstString(result, listOf("repeat")), - "Delivery" to firstString(result, listOf("deliver")), - "Next run" to firstString(result, listOf("next_run_at")), - ).filter { it.second.isNotEmpty() } - - return if (rows.isNotEmpty()) { - rows.joinToString( - "\n", - ) { (k, v) -> "$k: $v" } - } else { - fallbackDetailText(args, result) - } - } - - private fun todoDetail(result: JsonObject?): String? { - val todos = result?.get("todos") as? JsonArray ?: return null - - return todos - .mapNotNull { el -> - val item = parseMaybeObject(el) ?: return@mapNotNull null - val id = firstString(item, listOf("id")) - val content = firstString(item, listOf("content")) - val status = firstString(item, listOf("status")).ifEmpty { "pending" } - val parent = firstString(item, listOf("parent")) - val marker = - when (status) { - "completed" -> "[x]" - "in_progress" -> "[>]" - "cancelled" -> "[~]" - else -> "[ ]" - } - val indent = if (parent.isNotEmpty()) " ↳ " else "" - "$indent$marker $id. $content" - }.joinToString("\n") - .takeIf { it.isNotEmpty() } - } - - private fun factStoreDetail(result: JsonObject?): String? { - val facts = - (result?.get("results") as? JsonArray) - ?: (result?.get("facts") as? JsonArray) - ?: return null - - return facts - .mapNotNull { el -> - val item = parseMaybeObject(el) ?: return@mapNotNull null - val fid = intValue(item["fact_id"]) ?: 0 - val content = firstString(item, listOf("content")) - val category = firstString(item, listOf("category")) - val trust = numberValue(item["trust_score"]) - val tags = firstString(item, listOf("tags")) - - val metaParts = mutableListOf() - if (category.isNotEmpty()) metaParts += "[$category]" - if (trust != null) metaParts += "trust: ${"%.2f".format(trust)}" - if (tags.isNotEmpty()) metaParts += "🏷️ $tags" - - if (metaParts.isNotEmpty()) { - "#$fid $content\n ${metaParts.joinToString(" ")}" - } else { - "#$fid $content" - } - }.joinToString("\n") - .takeIf { it.isNotEmpty() } - } - - private fun sessionSearchDetail(result: JsonObject?): String? { - val results = result?.get("results") as? JsonArray - val messages = result?.get("messages") as? JsonArray - - val formattedResults = - results - ?.mapIndexedNotNull { idx, el -> - val item = parseMaybeObject(el) ?: return@mapIndexedNotNull null - val whenField = firstString(item, listOf("when", "started_at")) - val source = firstString(item, listOf("source")) - val title = firstString(item, listOf("title")) - val snippet = firstString(item, listOf("snippet")).ifEmpty { firstString(item, listOf("preview")) } - val model = firstString(item, listOf("model")) - val matchedRole = firstString(item, listOf("matched_role")) - val sessionMsgCount = intValue(item["message_count"]) - - val header = whenField.takeIf { it.isNotEmpty() }?.let { "📅 $it" } ?: "" - val sourceTag = source.takeIf { it.isNotEmpty() }?.let { "[$it]" } ?: "" - val titleLine = title.takeIf { it.isNotEmpty() }?.let { "\n 📄 $it" } ?: "" - val modelLine = model.takeIf { it.isNotEmpty() }?.let { "\n 🤖 $it" } ?: "" - val matchedLine = matchedRole.takeIf { it.isNotEmpty() }?.let { "\n 🎯 matched: $it" } ?: "" - val countLine = sessionMsgCount?.let { "\n $it msgs" } ?: "" - val snippetLine = - snippet.takeIf { it.isNotEmpty() }?.let { "\n ┃ ${it.take(200).replace("\n", " ")}" } ?: "" - - "━━━ #${idx + 1} $header$sourceTag$titleLine$modelLine$matchedLine$countLine$snippetLine" - }?.joinToString("\n") - ?.takeIf { it.isNotEmpty() } - - val anchorId = intValue(result?.get("around_message_id")) - - val formattedMessages = - messages - ?.mapNotNull { el -> - val item = parseMaybeObject(el) ?: return@mapNotNull null - val msgId = intValue(item["id"]) - val role = firstString(item, listOf("role")).ifEmpty { "?" } - val content = firstString(item, listOf("content")) - val toolName = firstString(item, listOf("tool_name")) - - val roleEmoji = - when (role) { - "user" -> "👤" - "assistant" -> "🤖" - "tool" -> "🔧" - else -> "❓" - } - val namePart = if (toolName.isNotEmpty()) " ($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() } - - return formattedResults ?: formattedMessages - } - - private fun processDetail( - args: JsonObject?, - result: JsonObject?, - ): String { - val error = firstString(result, listOf("error")) - if (error.isNotEmpty()) { - return "❌ $error" - } - - val processes = result?.get("processes") as? JsonArray - if (processes != null) { - return processes - .mapNotNull { el -> - val p = parseMaybeObject(el) ?: return@mapNotNull null - val pid = firstString(p, listOf("session_id")).ifEmpty { firstString(p, listOf("id")) } - val pStatus = firstString(p, listOf("status")) - val cmd = firstString(p, listOf("command")) - val running = (p["running"] as? JsonPrimitive)?.let { !it.isString && it.content == "true" } - val parts = mutableListOf("📌 $pid${cmd.takeIf { it.isNotEmpty() }?.let { ": $it" } ?: ""}") - if (pStatus.isNotEmpty()) parts += " Status: $pStatus" - if (running != null) parts += " Running: $running" - parts.joinToString("\n") - }.joinToString("\n\n") - } - - val output = firstString(result, listOf("output")) - if (output.isNotEmpty()) { - val status = firstString(result, listOf("status")) - return "${status.takeIf { it.isNotEmpty() }?.let { "Status: $it\n" } ?: ""}$output" - } - - val status = firstString(result, listOf("status")) - if (status.isNotEmpty()) { - return "✅ $status" - } - - val action = firstString(args, listOf("action")) - return "✅ ${action.ifEmpty { "done" }} done" - } - - private fun xSearchDetail(result: JsonObject?): String { - val error = firstString(result, listOf("error")) - val answer = firstString(result, listOf("answer")) - val citations = result?.get("citations") as? JsonArray - val degraded = (result?.get("degraded") as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true - - return when { - error.isNotEmpty() -> { - "❌ $error" - } - - answer.isNotEmpty() -> { - val lines = mutableListOf(answer) - if (citations != null && citations.isNotEmpty()) { - lines += "\n━━━ Citations ━━━" - citations.forEachIndexed { idx, cit -> - val c = parseMaybeObject(cit) ?: return@forEachIndexed - val url = firstString(c, listOf("url")) - val title = firstString(c, listOf("title")) - lines += "${idx + 1}. ${title.ifEmpty { url.ifEmpty { "source" } }}" - if (title.isNotEmpty() && url.isNotEmpty()) lines += " $url" - } - } - if (degraded) lines += "\n⚠️ No citations — answer based on model's knowledge" - lines.joinToString("\n") - } - - else -> { - "No results" - } - } - } - - private fun readTerminalDetail(result: JsonObject?): String { - val error = firstString(result, listOf("error")) - val text = firstString(result, listOf("text")) - val total = intValue(result?.get("total_lines")) ?: 0 - val start = intValue(result?.get("start")) ?: 0 - val end = intValue(result?.get("end")) ?: 0 - val cursor = intValue(result?.get("cursor_row")) - - return buildString { - if (error.isNotEmpty()) { - append("❌ $error") - } else if (text.isNotEmpty()) { - append("━━━ Terminal ━━━ ($start:$end / $total)") - if (cursor != null) append(" │ cursor row $cursor") - append("\n$text") - } else { - append("No terminal output") - } - } - } - - private fun computerUseDetail( - args: JsonObject?, - result: JsonObject?, - ): String { - val error = firstString(result, listOf("error")) - if (error.isNotEmpty()) { - return "❌ $error" - } - - val isMultimodal = - ( - result?.get( - "_multimodal", - ) as? JsonPrimitive - )?.let { !it.isString && it.content == "true" } == true - val content = result?.get("content") as? JsonArray - val multimodalText = - if (isMultimodal && content != null) { - content - .mapNotNull { el -> - val e = parseMaybeObject(el) ?: return@mapNotNull null - if (firstString(e, listOf("type")) == "text") firstString(e, listOf("text")) else null - }.joinToString("\n") - } else { - "" - } - - val textSummary = firstString(result, listOf("summary")) - val visionAnalysis = firstString(result, listOf("vision_analysis")) - val apps = result?.get("apps") as? JsonArray - val elements = result?.get("elements") as? JsonArray - val mode = firstString(result, listOf("mode")) - val width = intValue(result?.get("width")) - val height = intValue(result?.get("height")) - val action = firstString(args, listOf("action")) - val ok = (result?.get("ok") as? JsonPrimitive)?.let { !it.isString && it.content == "true" } - val fail = (result?.get("ok") as? JsonPrimitive)?.let { !it.isString && it.content == "false" } - val message = firstString(result, listOf("message")) - - val body = - buildString { - val textPart = - multimodalText - .ifEmpty { - firstString( - result, - listOf("text_summary"), - ) - }.ifEmpty { textSummary } - if (textPart.isNotEmpty()) { - append(textPart) - if (textPart == multimodalText && textSummary.isNotEmpty() && multimodalText != textSummary) { - append("\n\n$textSummary") - } - } - - if (apps != null) { - if (isNotEmpty()) append("\n\n") - apps.forEachIndexed { idx, el -> - val obj = parseMaybeObject(el) - val name = firstString(obj, listOf("name")).ifEmpty { "?" } - val pid = intValue(obj?.get("pid")) - append("${idx + 1}. $name") - if (pid != null) append(" (PID: $pid)") - append("\n") - } - } else if (elements != null) { - if (isNotEmpty()) append("\n\n") - append("🖥️ ${elements.size} interactable elements") - if (mode.isNotEmpty()) append(" (mode: $mode)") - if (width != null && height != null) append(" • ${width}x$height") - if (textSummary.isNotEmpty()) append("\n\n$textSummary") - val previewCount = minOf(elements.size, 5) - if (previewCount > 0) { - append("\n\n") - for (i in 0 until previewCount) { - val obj = parseMaybeObject(elements[i]) - val idx = intValue(obj?.get("index")) - val role = firstString(obj, listOf("role")) - val label = firstString(obj, listOf("label")) - append(" #$idx ${role}${label.takeIf { it.isNotEmpty() }?.let { " '$it'" } ?: ""}\n") - } - if (elements.size > previewCount) { - append(" ... and ${elements.size - previewCount} more") - } - } - } else if (action.isNotEmpty() && multimodalText.isEmpty() && - firstString( - result, - listOf("text_summary"), - ).isEmpty() && textSummary.isEmpty() - ) { - append("$action:") - when { - ok == true -> append(" ✅ ok") - fail == true -> append(" ❌ failed") - } - if (message.isNotEmpty()) append("\n$message") - } else if (isEmpty()) { - append("Computer use action") - } - } - - return if (visionAnalysis.isNotEmpty()) "$body\n\n━━━ Vision Analysis ━━━\n$visionAnalysis" else body - } - - // ── count label ────────────────────────────────────────────────────── - - private fun countLabelFor( - toolName: String, - args: JsonObject?, - result: JsonObject?, - rawResult: JsonElement?, - ): Pair? { - if (rawResult == null) { - return null - } - - val fallbackNoun = - when (toolName) { - "browser_snapshot", "todo" -> "item" - "list_files" -> "file" - "search_files", "session_search_recall", "web_search" -> "result" - "skills_list" -> "skill" - else -> "item" - } - - if (toolName == "web_search") { - val hits = collectResultItems(rawResult) - - if (hits.isNotEmpty()) { - return countMetric(hits.size, "result") - } - } - - val direct = countFromRecord(result ?: JsonObject(emptyMap()), fallbackNoun) - if (direct != null) { - return if (toolName == "web_search") countMetric(direct.first, "result") else direct - } - - val payload = unwrapToolPayload(rawResult) - if (payload !== rawResult && payload is JsonObject) { - val payloadCount = countFromRecord(payload, fallbackNoun) - - if (payloadCount != null) { - return if (toolName == "web_search") countMetric(payloadCount.first, "result") else payloadCount - } - } - - val summaryText = - firstString(result, listOf("summary", "message", "detail")).ifEmpty { fallbackDetailText(args, rawResult) } - val textMetric = countFromText(summaryText, fallbackNoun) - - return textMetric + return norm(title) == norm(detail) } private fun imageUrlFor( @@ -2125,8 +141,9 @@ object ToolViewBuilder { result: JsonObject?, ): String? { val candidate = - firstString(result, listOf("image_url", "url", "path", "image_path")) - .ifEmpty { firstString(args, listOf("image_url", "url", "path")) } + ToolJson + .firstString(result, listOf("image_url", "url", "path", "image_path")) + .ifEmpty { ToolJson.firstString(args, listOf("image_url", "url", "path")) } if (candidate.isEmpty()) { return null diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/BrowserRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/BrowserRenderers.kt new file mode 100644 index 00000000..e459bbce --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/BrowserRenderers.kt @@ -0,0 +1,106 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer + +/** `browser_navigate`: hostname titles, including the failed-to-open form. */ +internal object BrowserNavigateRenderer : ToolRenderer { + override fun pendingTitle(call: ToolCall): String = "Opening ${targetHostname(call.args, null)}" + + override fun doneTitle(call: ToolCall): String = "Opened ${targetHostname(call.args, call.result)}" + + override fun errorTitle(call: ToolCall): String? { + val url = + ToolJson + .findFirstUrl(call.result) + .ifEmpty { call.result?.get("url")?.toString() ?: "" } + + return if (url.isNotEmpty()) "Failed to open ${ToolJson.hostnameOf(url)}" else null + } + + override fun subtitle(call: ToolCall): String { + val url = + ToolJson + .firstString(call.args, listOf("url", "target")) + .ifEmpty { ToolJson.firstString(call.result, listOf("url")) } + .ifEmpty { ToolJson.findFirstUrl(call.args, call.result) } + + return if (url.isNotEmpty()) ToolJson.hostnameOf(url) else "Navigated in browser" + } +} + +/** `browser_snapshot`: control-count summary of the accessibility snapshot. */ +internal object BrowserSnapshotRenderer : ToolRenderer { + private fun summarizeSnapshot(snapshot: String): String { + fun count(re: Regex): Int = re.findAll(snapshot).count() + + val stats = + listOf( + "${count(Regex("""button\s+"[^"]+"""))} buttons", + "${count(Regex("""link\s+"[^"]+"""))} links", + "${count(Regex("""(?:textbox|combobox|searchbox)\s+"[^"]+"""))} inputs", + ).joinToString(" · ") + + val labels = + Regex("""(?:button|link|combobox|textbox)\s+"([^"]+)"""") + .findAll(snapshot) + .map { it.groupValues[1].trim() } + .filter { it.isNotEmpty() } + .take(4) + .toList() + + return if (labels.isNotEmpty()) "$stats\nTop controls: ${labels.joinToString(", ")}" else stats + } + + override fun subtitle(call: ToolCall): String { + val snapshot = ToolJson.firstString(call.result, listOf("snapshot")) + + return if (snapshot.isNotEmpty()) { + summarizeSnapshot(snapshot) + } else { + "Captured a browser accessibility snapshot" + } + } + + override fun detail(call: ToolCall): String { + val snapshot = ToolJson.firstString(call.result, listOf("snapshot")) + + return if (snapshot.isNotEmpty()) { + summarizeSnapshot(snapshot) + } else { + ToolJson.fallbackDetailText(call.rawArgs, call.rawResult) + } + } +} + +/** `browser_click`: what got clicked, with internal refs called out. */ +internal object BrowserClickRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val clicked = + ToolJson + .firstString(call.result, listOf("clicked")) + .ifEmpty { ToolJson.firstString(call.args, listOf("ref", "target")) } + + return when { + clicked.isEmpty() -> "Clicked on page" + clicked.startsWith("@") -> "Clicked page element (internal ref $clicked)" + else -> "Clicked $clicked" + } + } +} + +/** `browser_fill` / `browser_type`: which field got which value. */ +internal object BrowserTypeRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val field = ToolJson.firstString(call.args, listOf("label", "field", "ref", "target")) + val value = ToolJson.firstString(call.args, listOf("value", "text")) + + return listOf( + field.takeIf { it.isNotEmpty() }?.let { "Field: $it" }, + value.takeIf { it.isNotEmpty() }?.let { "Value: ${ToolJson.compactPreview(it, 42)}" }, + ).filterNotNull() + .joinToString(" · ") + .ifEmpty { "Filled page input" } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/ComputerUseRenderer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/ComputerUseRenderer.kt new file mode 100644 index 00000000..ddad71b9 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/ComputerUseRenderer.kt @@ -0,0 +1,156 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import kotlinx.serialization.json.JsonArray + +/** + * `computer_use`: desktop-automation results — app rosters, interactable + * element inventories, multimodal text blocks, and plain action acks. + */ +internal object ComputerUseRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val args = call.args + val result = call.result + + val error = ToolJson.firstString(result, listOf("error")) + if (error.isNotEmpty()) { + return "❌ $error" + } + + val apps = result?.get("apps") as? JsonArray + if (apps != null) { + return "${apps.size} apps" + } + + val elements = result?.get("elements") as? JsonArray + if (elements != null) { + val mode = ToolJson.firstString(result, listOf("mode")) + val width = ToolJson.intValue(result["width"]) + val height = ToolJson.intValue(result["height"]) + val modePart = mode.takeIf { it.isNotEmpty() }?.let { " ($it)" } ?: "" + val sizePart = if (width != null && height != null) " ${width}x$height" else "" + return "${elements.size} elements$modePart$sizePart" + } + + val action = ToolJson.firstString(args, listOf("action")) + if (action.isNotEmpty()) { + val ok = ToolJson.boolTrue(result?.get("ok")) + val fail = ToolJson.boolFalse(result?.get("ok")) + return "$action${when { + ok -> " ✓" + fail -> " ✗" + else -> "" + }}" + } + + return "Computer use" + } + + override fun detail(call: ToolCall): String { + val args = call.args + val result = call.result + + val error = ToolJson.firstString(result, listOf("error")) + if (error.isNotEmpty()) { + return "❌ $error" + } + + val isMultimodal = ToolJson.boolTrue(result?.get("_multimodal")) + val content = result?.get("content") as? JsonArray + val multimodalText = + if (isMultimodal && content != null) { + content + .mapNotNull { el -> + val e = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + if (ToolJson.firstString(e, listOf("type")) == "text") { + ToolJson.firstString(e, listOf("text")) + } else { + null + } + }.joinToString("\n") + } else { + "" + } + + val textSummary = ToolJson.firstString(result, listOf("summary")) + val visionAnalysis = ToolJson.firstString(result, listOf("vision_analysis")) + val apps = result?.get("apps") as? JsonArray + val elements = result?.get("elements") as? JsonArray + val mode = ToolJson.firstString(result, listOf("mode")) + val width = ToolJson.intValue(result?.get("width")) + val height = ToolJson.intValue(result?.get("height")) + val action = ToolJson.firstString(args, listOf("action")) + val ok = ToolJson.boolTrue(result?.get("ok")) + val fail = ToolJson.boolFalse(result?.get("ok")) + val message = ToolJson.firstString(result, listOf("message")) + + val body = + buildString { + val textPart = + multimodalText + .ifEmpty { + ToolJson.firstString( + result, + listOf("text_summary"), + ) + }.ifEmpty { textSummary } + if (textPart.isNotEmpty()) { + append(textPart) + if (textPart == multimodalText && textSummary.isNotEmpty() && multimodalText != textSummary) { + append("\n\n$textSummary") + } + } + + if (apps != null) { + if (isNotEmpty()) append("\n\n") + apps.forEachIndexed { idx, el -> + val obj = ToolJson.parseMaybeObject(el) + val name = ToolJson.firstString(obj, listOf("name")).ifEmpty { "?" } + val pid = ToolJson.intValue(obj?.get("pid")) + append("${idx + 1}. $name") + if (pid != null) append(" (PID: $pid)") + append("\n") + } + } else if (elements != null) { + if (isNotEmpty()) append("\n\n") + append("🖥️ ${elements.size} interactable elements") + if (mode.isNotEmpty()) append(" (mode: $mode)") + if (width != null && height != null) append(" • ${width}x$height") + if (textSummary.isNotEmpty()) append("\n\n$textSummary") + val previewCount = minOf(elements.size, 5) + if (previewCount > 0) { + append("\n\n") + for (i in 0 until previewCount) { + val obj = ToolJson.parseMaybeObject(elements[i]) + val idx = ToolJson.intValue(obj?.get("index")) + val role = ToolJson.firstString(obj, listOf("role")) + val label = ToolJson.firstString(obj, listOf("label")) + append(" #$idx ${role}${label.takeIf { it.isNotEmpty() }?.let { " '$it'" } ?: ""}\n") + } + if (elements.size > previewCount) { + append(" ... and ${elements.size - previewCount} more") + } + } + } else if (action.isNotEmpty() && multimodalText.isEmpty() && + ToolJson + .firstString( + result, + listOf("text_summary"), + ).isEmpty() && textSummary.isEmpty() + ) { + append("$action:") + when { + ok -> append(" ✅ ok") + fail -> append(" ❌ failed") + } + if (message.isNotEmpty()) append("\n$message") + } else if (isEmpty()) { + append("Computer use action") + } + } + + return if (visionAnalysis.isNotEmpty()) "$body\n\n━━━ Vision Analysis ━━━\n$visionAnalysis" else body + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt new file mode 100644 index 00000000..eb8501c2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt @@ -0,0 +1,208 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.DiffStats +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras +import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** Path/diff plumbing shared by the file-oriented renderers. */ +internal object FileEditSupport { + private val HTML_PATH_REGEX = + Regex( + "(?:^|\\s)(?:[ab]/)?([^\\s]+\\.html?)(?=\\s|$)", + RegexOption.IGNORE_CASE, + ) + + fun basename(path: String): String { + val normalized = path.replace('\\', '/').trim() + + return normalized.split("/").filter { it.isNotEmpty() }.lastOrNull() ?: normalized + } + + fun editPath( + args: JsonObject?, + result: JsonObject?, + ): String { + val fromArgs = ToolJson.firstString(args, listOf("path", "file", "filepath")) + if (fromArgs.isNotEmpty()) { + return fromArgs + } + + val fromResult = ToolJson.firstString(result, listOf("path", "file", "filepath", "resolved_path")) + if (fromResult.isNotEmpty()) { + return fromResult + } + + return htmlPathFromInlineDiff(ToolJson.firstString(result, listOf("inline_diff", "diff"))) + } + + private fun stripInlineDiffChrome(value: String): String = + ToolJson + .stripAnsi(value) + .replace(Regex("^\\s*┊\\s*review diff\\s*\\n", RegexOption.IGNORE_CASE), "") + .trim() + + fun inlineDiffFromResult(result: JsonElement?): String { + val record = ToolJson.parseMaybeObject(result) ?: return "" + + for (key in listOf("inline_diff", "diff")) { + val value = record[key] + + if (value is JsonPrimitive && value.isString && value.content.trim().isNotEmpty()) { + return stripInlineDiffChrome(value.content) + } + } + + return "" + } + + private fun htmlPathFromInlineDiff(value: String): String { + val cleaned = stripInlineDiffChrome(value) + + for (match in HTML_PATH_REGEX.findAll(cleaned)) { + val candidate = match.groupValues[1].trim() + + if (candidate.isNotEmpty()) { + return candidate + } + } + + return "" + } + + fun countDiffLineStats(diff: String): DiffStats { + var added = 0 + var removed = 0 + + for (line in diff.split("\n")) { + when { + line.startsWith("+") && !line.startsWith("+++") -> added += 1 + line.startsWith("-") && !line.startsWith("---") -> removed += 1 + } + } + + return DiffStats(added = added, removed = removed) + } + + /** Subtitle shared by read_file and the edit family: the path, or a summary. */ + fun pathSubtitle( + call: ToolCall, + path: String, + ): String = path.ifEmpty { ToolJson.fallbackDetailText(call.args, call.rawResult) } +} + +/** `read_file`: "Read name.kt L10-14" title with the file content as detail. */ +internal object ReadFileRenderer : ToolRenderer { + private fun displayTarget( + args: JsonObject?, + result: JsonObject?, + ): String { + val inherited = ToolJson.firstString(args, listOf("context", "preview")) + if (inherited.isNotEmpty()) { + return inherited + } + + val path = ToolJson.firstString(args, listOf("path", "file", "filepath")) + if (path.isEmpty()) { + return "" + } + + val offset = ToolJson.intValue(args?.get("offset")) + val limit = ToolJson.intValue(args?.get("limit")) + var lineLabel = "" + + if (offset != null) { + lineLabel = if (limit == null || limit <= 1) "L$offset" else "L$offset-${offset + limit - 1}" + } else if (limit != null) { + val content = ToolJson.firstString(result, listOf("content")) + val lines = + content + .split("\n") + .mapNotNull { line -> + Regex("^(\\d+)\\|") + .find(line) + ?.groupValues + ?.get(1) + ?.toIntOrNull() + } + + if (lines.isNotEmpty()) { + lineLabel = + if (lines.first() == lines.last()) { + "L${lines.first()}" + } else { + "L${lines.first()}-${lines.last()}" + } + } + } + + return listOf(FileEditSupport.basename(path), lineLabel).filter { it.isNotEmpty() }.joinToString(" ") + } + + override fun pendingTitle(call: ToolCall): String = "Reading ${displayTarget(call.args, null)}" + + override fun doneTitle(call: ToolCall): String = "Read ${displayTarget(call.args, call.result)}" + + override fun subtitle(call: ToolCall): String = + FileEditSupport.pathSubtitle(call, ToolJson.firstString(call.args, listOf("path", "file", "filepath"))) + + override fun detail(call: ToolCall): String { + if (call.rawResult == null) { + return "" + } + + return ToolJson.firstString(call.result, listOf("content", "text", "data", "body")) + } +} + +/** + * `edit_file` / `patch` / `write_file`: basename title, path subtitle, and + * the inline diff (with +/− stats) as the primary payload. Text detail only + * appears when there is no diff to render. + */ +internal object FileEditRenderer : ToolRenderer { + override fun doneTitle(call: ToolCall): String? { + val path = FileEditSupport.editPath(call.args, call.result) + + return if (path.isNotEmpty()) FileEditSupport.basename(path) else null + } + + override fun subtitle(call: ToolCall): String = + FileEditSupport.pathSubtitle(call, FileEditSupport.editPath(call.args, call.result)) + + override fun detail(call: ToolCall): String { + if (FileEditSupport.inlineDiffFromResult(call.rawResult).isNotEmpty()) { + return "" + } + + return ToolJson.firstString(call.result, listOf("message", "summary")).ifEmpty { + if (FileEditSupport.editPath(call.args, call.result).isNotEmpty()) { + "" + } else { + ToolJson.fallbackDetailText(call.rawArgs, call.rawResult) + } + } + } + + override fun extras( + call: ToolCall, + status: ToolViewStatus, + ): ToolViewExtras { + val inlineDiff = FileEditSupport.inlineDiffFromResult(call.rawResult) + + if (inlineDiff.isEmpty()) { + return ToolViewExtras.NONE + } + + return ToolViewExtras( + inlineDiff = inlineDiff, + diffPath = FileEditSupport.editPath(call.args, call.result), + diffStats = FileEditSupport.countDiffLineStats(inlineDiff), + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MediaRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MediaRenderers.kt new file mode 100644 index 00000000..faece561 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MediaRenderers.kt @@ -0,0 +1,57 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer + +/** `image_generate`: generated-image URL with the prompt in the detail. */ +internal object ImageGenerateRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val imageUrl = + ToolJson + .firstString(call.result, listOf("image")) + .ifEmpty { ToolJson.firstString(call.args, listOf("image_url")) } + val modality = ToolJson.firstString(call.result, listOf("modality")) + + return listOf(imageUrl.take(60), modality.takeIf { it.isNotEmpty() }?.let { "($it)" }) + .filterNotNull() + .joinToString(" ") + } + + override fun detail(call: ToolCall): String { + val error = ToolJson.firstString(call.result, listOf("error")) + val imageUrl = ToolJson.firstString(call.result, listOf("image")) + val prompt = ToolJson.firstString(call.args, listOf("prompt")) + + return buildString { + if (error.isNotEmpty()) { + append("❌ $error") + if (imageUrl.isNotEmpty()) append("\n🔗 $imageUrl") + } else if (imageUrl.isNotEmpty()) { + append("🖼️ ") + if (prompt.isNotEmpty()) append("$prompt\n") + append("\n🔗 $imageUrl") + } else { + append("✅ Generated") + } + } + } +} + +/** `vision_analyze`: the analyzed image URL and the model's description. */ +internal object VisionAnalyzeRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String = ToolJson.firstString(call.args, listOf("image_url")).take(60) + + override fun detail(call: ToolCall): String { + val error = ToolJson.firstString(call.result, listOf("error")) + val description = ToolJson.firstString(call.result, listOf("description")) + val content = ToolJson.firstString(call.result, listOf("content")) + + return when { + error.isNotEmpty() -> "❌ $error" + description.isNotEmpty() -> description + content.isNotEmpty() -> content + else -> "No description available" + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MemoryRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MemoryRenderers.kt new file mode 100644 index 00000000..11a176d6 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MemoryRenderers.kt @@ -0,0 +1,97 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import kotlinx.serialization.json.JsonArray + +/** `memory`: action-verb titles that keep the write target visible. */ +internal object MemoryRenderer : ToolRenderer { + override fun pendingTitle(call: ToolCall): String = "Saving" + + override fun doneTitle(call: ToolCall): String { + val action = ToolJson.firstString(call.args, listOf("action")).lowercase() + val target = ToolJson.firstString(call.args, listOf("target")) + val base = + when (action) { + "replace", "update" -> "Memory Updated" + "remove", "delete" -> "Memory Removed" + "add" -> "Memory Saved" + else -> "Memory ${ToolJson.firstString(call.args, listOf("action"))}" + } + + // Keep the target visible — "Memory Saved (user)" — like the + // pre-engine mobile summary did. + return if (target.isNotEmpty()) "$base ($target)" else base + } + + override fun subtitle(call: ToolCall): String = ToolJson.firstString(call.result, listOf("message", "error")) + + override fun detail(call: ToolCall): String = ToolJson.firstString(call.result, listOf("message", "error")) +} + +/** `fact_store`: add/remove/update acks and fact listings with trust scores. */ +internal object FactStoreRenderer : ToolRenderer { + private fun facts(call: ToolCall): JsonArray? = + (call.result?.get("results") as? JsonArray) + ?: (call.result?.get("facts") as? JsonArray) + + override fun subtitle(call: ToolCall): String { + val args = call.args + val result = call.result + val action = + ToolJson + .firstString( + args, + listOf("action"), + ).ifEmpty { ToolJson.firstString(result, listOf("action")) } + val entity = ToolJson.firstString(args, listOf("entity")) + val query = ToolJson.firstString(args, listOf("query")) + val facts = facts(call) + val count = ToolJson.intValue(result?.get("count")) ?: facts?.size ?: 0 + val status = ToolJson.firstString(result, listOf("status")) + val factId = ToolJson.intValue(result?.get("fact_id")) + val removed = ToolJson.boolTrue(result?.get("removed")) + val updated = ToolJson.boolTrue(result?.get("updated")) + + val actionContext = + when { + entity.isNotEmpty() -> " ($action: $entity)" + query.isNotEmpty() -> " ($action: $query)" + else -> " ($action)" + } + + return when { + status == "added" && factId != null -> "Fact added (ID: $factId)" + status == "added" -> "Fact added" + removed -> "Fact removed" + updated -> "Fact updated" + facts != null -> "$count facts$actionContext" + else -> action + } + } + + override fun detail(call: ToolCall): String = + facts(call) + ?.mapNotNull { el -> + val item = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + val fid = ToolJson.intValue(item["fact_id"]) ?: 0 + val content = ToolJson.firstString(item, listOf("content")) + val category = ToolJson.firstString(item, listOf("category")) + val trust = ToolJson.numberValue(item["trust_score"]) + val tags = ToolJson.firstString(item, listOf("tags")) + + val metaParts = mutableListOf() + if (category.isNotEmpty()) metaParts += "[$category]" + if (trust != null) metaParts += "trust: ${"%.2f".format(trust)}" + if (tags.isNotEmpty()) metaParts += "🏷️ $tags" + + if (metaParts.isNotEmpty()) { + "#$fid $content\n ${metaParts.joinToString(" ")}" + } else { + "#$fid $content" + } + }?.joinToString("\n") + ?.takeIf { it.isNotEmpty() } + ?: "" +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/ProjectRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/ProjectRenderers.kt new file mode 100644 index 00000000..30508114 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/ProjectRenderers.kt @@ -0,0 +1,68 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive + +/** `project_list`: project roster with the active project starred. */ +internal object ProjectListRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val projects = (call.result?.get("projects") as? JsonArray)?.size + val active = ToolJson.firstString(call.result, listOf("active_id")) + + return if (projects != null) { + "$projects projects${active.takeIf { it.isNotEmpty() }?.let { " (1 active)" } ?: ""}" + } else { + "Projects" + } + } + + override fun detail(call: ToolCall): String = + (call.result?.get("projects") as? JsonArray) + ?.mapNotNull { el -> + val p = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + val name = ToolJson.firstString(p, listOf("name")) + val slug = ToolJson.firstString(p, listOf("slug")) + val path = ToolJson.firstString(p, listOf("primary_path")) + val isActive = + (p["active"] as? JsonPrimitive)?.let { !it.isString && it.content == "true" } == true + val lines = mutableListOf("${if (isActive) "⭐ " else "📁 "}$name") + if (slug.isNotEmpty()) lines += " 🏷️ $slug" + if (path.isNotEmpty()) lines += " 📍 $path" + if (isActive) lines += " ✅ ACTIVE PROJECT" + lines.joinToString("\n") + }?.joinToString("\n\n") + ?: "" +} + +/** `project_create` / `project_switch`: mutation ack with name/slug/path. */ +internal object ProjectMutateRenderer : ToolRenderer { + private fun actionLabel(call: ToolCall): String = if (call.name == "project_create") "Created" else "Switched to" + + override fun subtitle(call: ToolCall): String { + val name = ToolJson.firstString(call.result, listOf("name")) + val label = actionLabel(call) + + return if (name.isNotEmpty()) "$label $name" else label + } + + override fun detail(call: ToolCall): String { + val error = ToolJson.firstString(call.result, listOf("error")) + val name = ToolJson.firstString(call.result, listOf("name")) + val slug = ToolJson.firstString(call.result, listOf("slug")) + val path = ToolJson.firstString(call.result, listOf("primary_path")) + + return buildString { + if (error.isNotEmpty()) { + append("❌ $error") + } else { + append("✅ ${actionLabel(call)}") + if (name.isNotEmpty()) append(": $name") + if (slug.isNotEmpty()) append("\n 🏷️ $slug") + if (path.isNotEmpty()) append("\n 📍 $path") + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SkillRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SkillRenderers.kt new file mode 100644 index 00000000..2366a69e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SkillRenderers.kt @@ -0,0 +1,140 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** `skills_list`: count-with-category subtitle and a name/description list. */ +internal object SkillsListRenderer : ToolRenderer { + private fun skills(call: ToolCall): JsonArray? = + (call.result?.get("skills") as? JsonArray) + ?: (call.result?.get("results") as? JsonArray) + + override fun subtitle(call: ToolCall): String { + val category = ToolJson.firstString(call.args, listOf("category")) + val count = skills(call)?.size ?: 0 + + return if (count > 0) { + "$count skills${category.takeIf { it.isNotEmpty() }?.let { " ($it)" } ?: ""}" + } else { + "No skills found" + } + } + + override fun detail(call: ToolCall): String = + skills(call) + ?.mapNotNull { el -> + val s = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + val name = ToolJson.firstString(s, listOf("name")) + val desc = ToolJson.firstString(s, listOf("description")) + val cat = ToolJson.firstString(s, listOf("category")) + val lines = mutableListOf("📌 $name") + if (desc.isNotEmpty()) lines += " $desc" + if (cat.isNotEmpty()) lines += " [$cat]" + lines.joinToString("\n") + }?.joinToString("\n\n") + ?: "No skills found" +} + +/** `skill_view`: content preview capped at 500 chars plus linked files. */ +internal object SkillViewRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String = ToolJson.firstString(call.args, listOf("name")) + + override fun detail(call: ToolCall): String { + val content = ToolJson.firstString(call.result, listOf("content")) + val linkedFiles = call.result?.get("linked_files") as? JsonObject + val contentPreview = + if (content.length > 500) { + "${content.take( + 500, + )}\n... [${content.length - 500} more chars]" + } else { + content + } + val filesInfo = + linkedFiles + ?.entries + ?.joinToString("\n") { (k, v) -> + val paths = + when (v) { + is JsonArray -> { + v.joinToString( + ", ", + ) { (it as? JsonPrimitive)?.content ?: it.toString() } + } + + is JsonPrimitive -> { + v.content + } + + else -> { + v.toString() + } + } + " 📎 $k: $paths" + } + + return buildString { + if (contentPreview.isNotEmpty()) append(contentPreview) + if (filesInfo != null) { + if (contentPreview.isNotEmpty()) append("\n\n") + append("━━━ Linked Files ━━━\n$filesInfo") + } + if (content.isEmpty() && linkedFiles == null) append("No content available") + } + } +} + +/** `skill_manage`: action ack with the skill name. */ +internal object SkillManageRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val action = ToolJson.firstString(call.args, listOf("action")) + val name = ToolJson.firstString(call.args, listOf("name")) + + return if (name.isNotEmpty()) "Skill $action: $name" else "Skill $action" + } + + override fun detail(call: ToolCall): String { + val error = ToolJson.firstString(call.result, listOf("error")) + if (error.isNotEmpty()) { + return "❌ $error" + } + + val msg = ToolJson.firstString(call.result, listOf("message")) + val failed = ToolJson.boolFalse(call.result?.get("success")) + + // Pinned quirk from the original engine: the branch order is inverted + // (a non-false `success` renders the failure line). Preserved verbatim + // by the characterization suite; fix upstream deliberately if desired. + return when { + !failed -> "❌ Skill operation failed" + msg.isNotEmpty() -> "✅ $msg" + else -> "✅ Skill operation done" + } + } +} + +/** `tool_search`: on-demand tool discovery matches. */ +internal object ToolSearchRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val query = ToolJson.firstString(call.args, listOf("query")) + val matches = (call.result?.get("matches") as? JsonArray)?.size + + return if (matches != null) "$query ($matches matches)" else query + } + + override fun detail(call: ToolCall): String = + (call.result?.get("matches") as? JsonArray) + ?.mapNotNull { el -> + val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + val name = ToolJson.firstString(m, listOf("name")) + val desc = ToolJson.firstString(m, listOf("description")) + val lines = mutableListOf("🔧 $name") + if (desc.isNotEmpty()) lines += " $desc" + lines.joinToString("\n") + }?.joinToString("\n\n") + ?: "No matching tools" +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TaskRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TaskRenderers.kt new file mode 100644 index 00000000..e8311ea7 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TaskRenderers.kt @@ -0,0 +1,265 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** `cronjob`: job listings with schedules, or the mutated job's key fields. */ +internal object CronjobRenderer : ToolRenderer { + override fun doneTitle(call: ToolCall): String = + "Cron ${ToolJson.firstString(call.args, listOf("action")).ifEmpty { "manage" }}" + + override fun subtitle(call: ToolCall): String { + val jobs = call.result?.get("jobs") as? JsonArray + + if (jobs != null) { + return if (jobs.isNotEmpty()) { + "${jobs.size} cron ${if (jobs.size == 1) "job" else "jobs"}" + } else { + "No cron jobs" + } + } + + val message = ToolJson.firstString(call.result, listOf("message")) + if (message.isNotEmpty()) { + return message + } + + val action = ToolJson.firstString(call.args, listOf("action")).ifEmpty { "manage" } + val name = + ToolJson + .firstString(call.result, listOf("name")) + .ifEmpty { ToolJson.firstString(call.args, listOf("name", "job_id")) } + val label = action.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } + + return if (name.isNotEmpty()) "$label $name" else "Cron $action" + } + + override fun detail(call: ToolCall): String { + val result = call.result + val jobs = result?.get("jobs") as? JsonArray + + if (jobs != null) { + if (jobs.isEmpty()) { + return "No cron jobs scheduled" + } + + return jobs + .take(20) + .mapNotNull { job -> + val row = ToolJson.parseMaybeObject(job) ?: return@mapNotNull null + val name = ToolJson.firstString(row, listOf("name", "id")).ifEmpty { "job" } + val sched = ToolJson.firstString(row, listOf("schedule_display", "schedule")) + if (sched.isNotEmpty()) "- $name · $sched" else "- $name" + }.joinToString("\n") + } + + val rows = + listOf( + "Schedule" to ToolJson.firstString(result, listOf("schedule")), + "Repeat" to ToolJson.firstString(result, listOf("repeat")), + "Delivery" to ToolJson.firstString(result, listOf("deliver")), + "Next run" to ToolJson.firstString(result, listOf("next_run_at")), + ).filter { it.second.isNotEmpty() } + + return if (rows.isNotEmpty()) { + rows.joinToString( + "\n", + ) { (k, v) -> "$k: $v" } + } else { + ToolJson.fallbackDetailText(call.args, result) + } + } +} + +/** `todo`: status-count subtitle plus a checklist with [x]/[>]/[~] markers. */ +internal object TodoRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val summary = call.result?.get("summary") as? JsonObject ?: return "" + val total = ToolJson.intValue(summary["total"]) ?: 0 + val pending = ToolJson.intValue(summary["pending"]) ?: 0 + val inProgress = ToolJson.intValue(summary["in_progress"]) ?: 0 + val completed = ToolJson.intValue(summary["completed"]) ?: 0 + val cancelled = ToolJson.intValue(summary["cancelled"]) ?: 0 + + val parts = + listOf( + pending.takeIf { it > 0 }?.let { "$it pending" }, + inProgress.takeIf { it > 0 }?.let { "$it in_progress" }, + completed.takeIf { it > 0 }?.let { "$it completed" }, + cancelled.takeIf { it > 0 }?.let { "$it cancelled" }, + ).filterNotNull() + + val itemWord = if (total == 1) "item" else "items" + + return if (parts.isEmpty()) "0 items" else "$total $itemWord (${parts.joinToString(", ")})" + } + + override fun detail(call: ToolCall): String { + val todos = call.result?.get("todos") as? JsonArray ?: return "" + + return todos + .mapNotNull { el -> + val item = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + val id = ToolJson.firstString(item, listOf("id")) + val content = ToolJson.firstString(item, listOf("content")) + val status = ToolJson.firstString(item, listOf("status")).ifEmpty { "pending" } + val parent = ToolJson.firstString(item, listOf("parent")) + val marker = + when (status) { + "completed" -> "[x]" + "in_progress" -> "[>]" + "cancelled" -> "[~]" + else -> "[ ]" + } + val indent = if (parent.isNotEmpty()) " ↳ " else "" + "$indent$marker $id. $content" + }.joinToString("\n") + } +} + +/** `session_search`: mode-specific subtitles and rich session/message lists. */ +internal object SessionSearchRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val result = call.result + val mode = ToolJson.firstString(result, listOf("mode")) + val query = ToolJson.firstString(result, listOf("query")) + val count = ToolJson.intValue(result?.get("count")) + val msgCount = ToolJson.intValue(result?.get("message_count")) + val messages = result?.get("messages") as? JsonArray + val truncated = ToolJson.boolTrue(result?.get("truncated")) + + return when (mode) { + "discover" -> "${count ?: 0} session${if (count == 1) "" else "s"}${query.takeIf { it.isNotEmpty() }?.let { + ": ${it.take( + 60, + )}" + } ?: ""}" + + "scroll" -> "${messages?.size ?: 0} messages (scroll)" + + "read" -> "${msgCount ?: messages?.size ?: 0} messages${if (truncated) " (truncated)" else ""}" + + "browse" -> "${count ?: 0} recent sessions" + + else -> "session_search" + } + } + + override fun detail(call: ToolCall): String { + val result = call.result + val results = result?.get("results") as? JsonArray + val messages = result?.get("messages") as? JsonArray + + val formattedResults = + results + ?.mapIndexedNotNull { idx, el -> + val item = ToolJson.parseMaybeObject(el) ?: return@mapIndexedNotNull null + val whenField = ToolJson.firstString(item, listOf("when", "started_at")) + val source = ToolJson.firstString(item, listOf("source")) + val title = ToolJson.firstString(item, listOf("title")) + val snippet = + ToolJson + .firstString(item, listOf("snippet")) + .ifEmpty { ToolJson.firstString(item, listOf("preview")) } + val model = ToolJson.firstString(item, listOf("model")) + val matchedRole = ToolJson.firstString(item, listOf("matched_role")) + val sessionMsgCount = ToolJson.intValue(item["message_count"]) + + val header = whenField.takeIf { it.isNotEmpty() }?.let { "📅 $it" } ?: "" + val sourceTag = source.takeIf { it.isNotEmpty() }?.let { "[$it]" } ?: "" + val titleLine = title.takeIf { it.isNotEmpty() }?.let { "\n 📄 $it" } ?: "" + val modelLine = model.takeIf { it.isNotEmpty() }?.let { "\n 🤖 $it" } ?: "" + val matchedLine = matchedRole.takeIf { it.isNotEmpty() }?.let { "\n 🎯 matched: $it" } ?: "" + val countLine = sessionMsgCount?.let { "\n $it msgs" } ?: "" + val snippetLine = + snippet.takeIf { it.isNotEmpty() }?.let { "\n ┃ ${it.take(200).replace("\n", " ")}" } ?: "" + + "━━━ #${idx + 1} $header$sourceTag$titleLine$modelLine$matchedLine$countLine$snippetLine" + }?.joinToString("\n") + ?.takeIf { it.isNotEmpty() } + + val anchorId = ToolJson.intValue(result?.get("around_message_id")) + + val formattedMessages = + messages + ?.mapNotNull { el -> + val item = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + val msgId = ToolJson.intValue(item["id"]) + val role = ToolJson.firstString(item, listOf("role")).ifEmpty { "?" } + val content = ToolJson.firstString(item, listOf("content")) + val toolName = ToolJson.firstString(item, listOf("tool_name")) + + val roleEmoji = + when (role) { + "user" -> "👤" + "assistant" -> "🤖" + "tool" -> "🔧" + else -> "❓" + } + val namePart = if (toolName.isNotEmpty()) " ($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() } + + return formattedResults ?: formattedMessages ?: "" + } +} + +/** `process`: background session listings and lifecycle acks. */ +internal object ProcessRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val action = ToolJson.firstString(call.args, listOf("action")) + val procId = ToolJson.firstString(call.args, listOf("session_id")) + + return if (procId.isNotEmpty()) "$action: $procId" else action + } + + override fun detail(call: ToolCall): String { + val result = call.result + val error = ToolJson.firstString(result, listOf("error")) + if (error.isNotEmpty()) { + return "❌ $error" + } + + val processes = result?.get("processes") as? JsonArray + if (processes != null) { + return processes + .mapNotNull { el -> + val p = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null + val pid = + ToolJson + .firstString(p, listOf("session_id")) + .ifEmpty { ToolJson.firstString(p, listOf("id")) } + val pStatus = ToolJson.firstString(p, listOf("status")) + val cmd = ToolJson.firstString(p, listOf("command")) + val running = + (p["running"] as? JsonPrimitive)?.let { !it.isString && it.content == "true" } + val parts = mutableListOf("📌 $pid${cmd.takeIf { it.isNotEmpty() }?.let { ": $it" } ?: ""}") + if (pStatus.isNotEmpty()) parts += " Status: $pStatus" + if (running != null) parts += " Running: $running" + parts.joinToString("\n") + }.joinToString("\n\n") + } + + val output = ToolJson.firstString(result, listOf("output")) + if (output.isNotEmpty()) { + val status = ToolJson.firstString(result, listOf("status")) + return "${status.takeIf { it.isNotEmpty() }?.let { "Status: $it\n" } ?: ""}$output" + } + + val status = ToolJson.firstString(result, listOf("status")) + if (status.isNotEmpty()) { + return "✅ $status" + } + + val action = ToolJson.firstString(call.args, listOf("action")) + return "✅ ${action.ifEmpty { "done" }} done" + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TerminalRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TerminalRenderers.kt new file mode 100644 index 00000000..97c392ad --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/TerminalRenderers.kt @@ -0,0 +1,152 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.CommandSummarizer +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras +import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * `terminal` and `execute_code`: shell-styled rows with a summarized command + * title, first output line as subtitle, and split (or merged) streams for + * the ANSI-aware expanded view. Exit code and the `$` command line are only + * meaningful for `terminal`. + */ +internal object TerminalRenderer : ToolRenderer { + private fun shellCommand(args: JsonObject?): String = + ToolJson + .firstString(args, listOf("command", "code")) + .ifEmpty { ToolJson.firstString(args, listOf("context", "preview")) } + .ifEmpty { ToolJson.contextValue(args) } + + private fun shellOutput(result: JsonObject?): String { + val output = ToolJson.firstString(result, listOf("output", "stdout", "stderr")) + val lines = + (result?.get("lines") as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content } + ?.joinToString("\n") + ?: "" + + return listOf(output, lines).filter { it.isNotEmpty() }.joinToString("\n") + } + + private fun commandTitle( + call: ToolCall, + runningVerb: Boolean, + ): String? { + val command = shellCommand(call.args) + val verb = + when { + call.name == "execute_code" && runningVerb -> "Running code" + call.name == "execute_code" -> "Ran code" + runningVerb -> "Running" + else -> "Ran" + } + + if (command.isEmpty()) { + return null + } + + return "$verb ${ToolJson.compactPreview(CommandSummarizer.summarizeShellCommand(command), 160)}" + } + + override fun pendingTitle(call: ToolCall): String? = commandTitle(call, runningVerb = true) + + override fun doneTitle(call: ToolCall): String? = commandTitle(call, runningVerb = false) + + override fun subtitle(call: ToolCall): String { + val firstMeaningfulLine = + shellOutput(call.result) + .split("\n") + .map { it.trim() } + .firstOrNull { it.isNotEmpty() } + + // A terminal row with no output shows its command in the title; + // nothing more to add. + return firstMeaningfulLine?.let { ToolJson.compactPreview(it, 160) } ?: "" + } + + override fun detail(call: ToolCall): String { + val output = shellOutput(call.result) + + return when { + output.isNotEmpty() -> output + + call.name == "execute_code" -> ToolJson.fallbackDetailText(call.rawArgs, call.rawResult) + + // A terminal row with no output already shows its command in the + // title; the generic fallback would repeat it. + else -> "" + } + } + + override fun extras( + call: ToolCall, + status: ToolViewStatus, + ): ToolViewExtras { + // The gateway's terminal_tool emits a single merged `output` string + // (stdout+stderr, ANSI-stripped); older payloads carried separate + // `stdout`/`stderr`. Prefer a real stream split, fall back to the + // merged output as the single renderable stream. + val stdout = ToolJson.firstString(call.result, listOf("stdout", "output")) + val stderr = ToolJson.firstString(call.result, listOf("stderr")) + val hasSplitStreams = stdout.isNotEmpty() || stderr.isNotEmpty() + + return ToolViewExtras( + stdout = if (hasSplitStreams) stdout else null, + stderr = if (hasSplitStreams) stderr else null, + exitCode = if (call.name == "terminal") ToolJson.intValue(call.result?.get("exit_code")) else null, + terminalCommand = if (call.name == "terminal") shellCommand(call.args) else null, + ) + } +} + +/** `read_terminal`: a line-window over a live terminal session. */ +internal object ReadTerminalRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val result = call.result + val error = ToolJson.firstString(result, listOf("error")) + if (error.isNotEmpty()) { + return "❌ $error" + } + + val total = ToolJson.intValue(result?.get("total_lines")) ?: 0 + val start = ToolJson.intValue(result?.get("start")) ?: 0 + val end = ToolJson.intValue(result?.get("end")) ?: 0 + val text = ToolJson.firstString(result, listOf("text")) + + if (text.isEmpty()) { + return "Terminal output" + } + + val cursor = ToolJson.intValue(result?.get("cursor_row")) + + return "Lines $start-$end of $total${cursor?.let { ", cursor at row $it" } ?: ""}" + } + + override fun detail(call: ToolCall): String { + val result = call.result + val error = ToolJson.firstString(result, listOf("error")) + val text = ToolJson.firstString(result, listOf("text")) + val total = ToolJson.intValue(result?.get("total_lines")) ?: 0 + val start = ToolJson.intValue(result?.get("start")) ?: 0 + val end = ToolJson.intValue(result?.get("end")) ?: 0 + val cursor = ToolJson.intValue(result?.get("cursor_row")) + + return buildString { + if (error.isNotEmpty()) { + append("❌ $error") + } else if (text.isNotEmpty()) { + append("━━━ Terminal ━━━ ($start:$end / $total)") + if (cursor != null) append(" │ cursor row $cursor") + append("\n$text") + } else { + append("No terminal output") + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/WebRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/WebRenderers.kt new file mode 100644 index 00000000..0c041931 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/WebRenderers.kt @@ -0,0 +1,157 @@ +package com.m57.hermescontrol.ui.chat.tool.render + +import com.m57.hermescontrol.ui.chat.tool.SearchHit +import com.m57.hermescontrol.ui.chat.tool.ToolCall +import com.m57.hermescontrol.ui.chat.tool.ToolJson +import com.m57.hermescontrol.ui.chat.tool.ToolRenderer +import com.m57.hermescontrol.ui.chat.tool.ToolViewExtras +import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** Resolve the URL a web/browser tool targeted, as a display hostname+path. */ +internal fun targetHostname( + args: JsonObject?, + result: JsonObject?, +): String { + val url = + ToolJson + .firstString(args, listOf("url", "target")) + .ifEmpty { ToolJson.firstString(result, listOf("url")) } + .ifEmpty { ToolJson.findFirstUrl(args, result) } + + return if (url.isNotEmpty()) ToolJson.hostnameOf(url) else "page" +} + +/** + * `web_search`: quoted-query title, parsed [SearchHit] rows for the + * structured result list, and a "Search results" section label. + */ +internal object WebSearchRenderer : ToolRenderer { + private fun searchQueryFor(args: JsonObject?): String = + ToolJson.firstString(args, listOf("search_term", "query")).ifEmpty { ToolJson.contextValue(args) } + + private fun extractSearchResults( + result: JsonElement?, + limit: Int = 6, + ): List = + ToolJson + .collectResultItems(result) + .mapNotNull { item -> + val r = ToolJson.parseMaybeObject(item) ?: return@mapNotNull null + + SearchHit( + title = ToolJson.cleanVisibleText(ToolJson.firstString(r, listOf("title", "name"))), + url = ToolJson.firstString(r, listOf("url", "href", "link")), + snippet = + ToolJson.cleanVisibleText( + ToolJson.firstString(r, listOf("snippet", "description", "body")), + ), + ) + }.filter { it.title.isNotEmpty() || it.url.isNotEmpty() } + .take(limit) + + override fun pendingTitle(call: ToolCall): String = + "Searching \"${ToolJson.compactPreview(searchQueryFor(call.args), 48)}\"" + + override fun doneTitle(call: ToolCall): String = + "Searched \"${ToolJson.compactPreview(searchQueryFor(call.args), 48)}\"" + + override fun subtitle(call: ToolCall): String = + searchQueryFor(call.args).takeIf { it.isNotEmpty() }?.let { "Query: $it" } ?: "Queried web sources" + + override fun extras( + call: ToolCall, + status: ToolViewStatus, + ): ToolViewExtras { + val hits = if (status != ToolViewStatus.ERROR) extractSearchResults(call.rawResult) else emptyList() + + return ToolViewExtras( + detailLabel = if (hits.isNotEmpty()) "Search results" else null, + searchHits = hits.ifEmpty { null }, + searchQuery = searchQueryFor(call.args).ifEmpty { null }, + ) + } +} + +/** `web_extract`: hostname title/subtitle with the extracted text as detail. */ +internal object WebExtractRenderer : ToolRenderer { + override fun pendingTitle(call: ToolCall): String = "Reading ${targetHostname(call.args, null)}" + + override fun doneTitle(call: ToolCall): String = "Read ${targetHostname(call.args, call.result)}" + + override fun subtitle(call: ToolCall): String { + val url = + ToolJson + .firstString(call.args, listOf("url")) + .ifEmpty { ToolJson.firstString(call.result, listOf("url")) } + .ifEmpty { ToolJson.findFirstUrl(call.args, call.result) } + + return if (url.isNotEmpty()) ToolJson.hostnameOf(url) else "Fetched webpage" + } + + override fun detail(call: ToolCall): String { + val direct = + ToolJson.firstString(call.result, listOf("content", "text", "markdown", "body", "summary", "message")) + if (direct.isNotEmpty()) { + return direct.replace(Regex("\\s*in\\s+\\d+(?:\\.\\d+)?s\\s*$"), "").trim() + } + + val results = call.result?.get("results") as? JsonArray + if (results != null) { + return results + .mapNotNull { item -> + val row = ToolJson.parseMaybeObject(item) ?: return@mapNotNull null + ToolJson.firstString(row, listOf("content", "text", "markdown", "body")) + }.filter { it.isNotEmpty() } + .joinToString("\n\n---\n\n") + } + + return ToolJson.fallbackDetailText(call.rawArgs, call.rawResult) + } +} + +/** `x_search`: query subtitle plus answer text with numbered citations. */ +internal object XSearchRenderer : ToolRenderer { + override fun subtitle(call: ToolCall): String { + val query = ToolJson.firstString(call.args, listOf("query")) + val degraded = ToolJson.boolTrue(call.result?.get("degraded")) + + return if (query.isNotEmpty()) "$query${if (degraded) " (no citations)" else ""}" else "Queried X" + } + + override fun detail(call: ToolCall): String { + val result = call.result + val error = ToolJson.firstString(result, listOf("error")) + val answer = ToolJson.firstString(result, listOf("answer")) + val citations = result?.get("citations") as? JsonArray + val degraded = ToolJson.boolTrue(result?.get("degraded")) + + return when { + error.isNotEmpty() -> { + "❌ $error" + } + + answer.isNotEmpty() -> { + val lines = mutableListOf(answer) + if (citations != null && citations.isNotEmpty()) { + lines += "\n━━━ Citations ━━━" + citations.forEachIndexed { idx, cit -> + val c = ToolJson.parseMaybeObject(cit) ?: return@forEachIndexed + val url = ToolJson.firstString(c, listOf("url")) + val title = ToolJson.firstString(c, listOf("title")) + lines += "${idx + 1}. ${title.ifEmpty { url.ifEmpty { "source" } }}" + if (title.isNotEmpty() && url.isNotEmpty()) lines += " $url" + } + } + if (degraded) lines += "\n⚠️ No citations — answer based on model's knowledge" + lines.joinToString("\n") + } + + else -> { + "No results" + } + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt new file mode 100644 index 00000000..b9c08aab --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt @@ -0,0 +1,779 @@ +package com.m57.hermescontrol.ui.chat.tool + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Characterization tests for [ToolViewBuilder] — pinned observable behavior + * of the tool-display engine, written against the pre-refactor monolith so + * the renderer/registry split can be proven behavior-preserving. + * + * Every expected value here was produced by the original single-file + * implementation. If one of these needs to change, that is an intentional + * behavior change and must be called out in review — not a refactor detail. + */ +class ToolViewCharacterizationTest { + private fun json(raw: String): JsonElement = Json.parseToJsonElement(raw) + + private fun build( + tool: String, + args: String? = null, + result: String? = null, + isError: Boolean = false, + running: Boolean = false, + ) = ToolViewBuilder.build(tool, args?.let(::json), result?.let(::json), isError, running) + + // ── terminal: streams, exit codes, command line ────────────────────── + + @Test + fun `terminal merged output populates stdout and empty stderr`() { + val view = build("terminal", """{"command":"ls -la"}""", """{"output":"file1\nfile2","exit_code":0}""") + + assertEquals(ToolViewStatus.SUCCESS, view.status) + assertEquals("Ran ls -la", view.title) + assertEquals("file1", view.subtitle) + assertEquals("file1\nfile2", view.detail) + assertEquals("file1\nfile2", view.stdout) + assertEquals("", view.stderr) + assertEquals(0, view.exitCode) + assertEquals("ls -la", view.terminalCommand) + } + + @Test + fun `terminal split streams keep stdout and stderr separate`() { + val view = + build("terminal", """{"command":"make"}""", """{"stdout":"built","stderr":"warn: x","exit_code":0}""") + + assertEquals("built", view.stdout) + assertEquals("warn: x", view.stderr) + } + + @Test + fun `terminal non-zero exit with only stderr output is not an error`() { + val view = build("terminal", """{"command":"grep foo"}""", """{"exit_code":1,"stderr":"grep: warning"}""") + + assertEquals(ToolViewStatus.SUCCESS, view.status) + assertNull(view.error) + assertEquals("", view.stdout) + assertEquals("grep: warning", view.stderr) + assertEquals(1, view.exitCode) + } + + @Test + fun `terminal non-zero exit with no output is an error with generic title`() { + val view = build("terminal", """{"command":"exit 2"}""", """{"exit_code":2}""") + + assertEquals(ToolViewStatus.ERROR, view.status) + assertEquals("Command failed with exit code 2.", view.error) + assertEquals("Terminal", view.title) + assertEquals("Command failed with exit code 2.", view.subtitle) + assertEquals("Command failed with exit code 2.", view.detail) + assertNull(view.countLabel) + } + + @Test + fun `terminal exit code accepts float and numeric string`() { + assertEquals(0, build("terminal", result = """{"output":"ok","exit_code":0.0}""").exitCode) + assertEquals(0, build("terminal", result = """{"output":"ok","exit_code":"0.0"}""").exitCode) + assertEquals(3, build("terminal", result = """{"output":"ok","exit_code":"3"}""").exitCode) + } + + @Test + fun `terminal title collapses plumbing to the main command`() { + val view = + build( + "terminal", + """{"command":"cd /tmp && sleep 70 2>&1 | tail -5; echo \"x_exit=${'$'}{PIPESTATUS[0]}\""}""", + """{"output":"done"}""", + ) + + assertEquals("Ran sleep 70", view.title) + } + + @Test + fun `terminal with no output has empty subtitle and detail`() { + val view = build("terminal", """{"command":"true"}""", """{"exit_code":0,"output":""}""") + + assertEquals(ToolViewStatus.SUCCESS, view.status) + assertEquals("", view.subtitle) + assertEquals("", view.detail) + assertNull(view.stdout) + assertNull(view.stderr) + } + + @Test + fun `terminal lines array joins into detail`() { + val view = build("terminal", """{"command":"cat x"}""", """{"lines":["a","b"],"exit_code":0}""") + + assertEquals("a\nb", view.detail) + assertEquals("a", view.subtitle) + } + + @Test + fun `execute_code renders streams but no exit code or terminal command`() { + val view = build("execute_code", """{"code":"print(1)"}""", """{"output":"1","exit_code":0}""") + + assertEquals("Ran code print(1)", view.title) + assertEquals("1", view.stdout) + assertNull(view.exitCode) + assertNull(view.terminalCommand) + } + + @Test + fun `running terminal has running status pending title and no streams`() { + val view = build("terminal", """{"command":"sleep 10"}""", running = true) + + assertEquals(ToolViewStatus.RUNNING, view.status) + assertEquals("Running sleep 10", view.title) + assertNull(view.stdout) + assertNull(view.exitCode) + } + + @Test + fun `null result without running flag is still running status`() { + val view = build("terminal", """{"command":"sleep 10"}""") + + assertEquals(ToolViewStatus.RUNNING, view.status) + // Quirk pinned: title uses the done form because running=false. + assertEquals("Ran sleep 10", view.title) + } + + @Test + fun `terminal error field beats useful output`() { + val view = build("terminal", result = """{"error":"Invalid command: frobnicate"}""") + + assertEquals(ToolViewStatus.ERROR, view.status) + assertEquals("Invalid command: frobnicate", view.error) + } + + // ── status heuristics ──────────────────────────────────────────────── + + @Test + fun `explicit success true wins over isError envelope`() { + val view = build("weird_tool", "{}", """{"success":true,"message":"done"}""", isError = true) + + assertEquals(ToolViewStatus.SUCCESS, view.status) + assertNull(view.error) + } + + @Test + fun `isError envelope surfaces extracted message`() { + val view = build("weird_tool", "{}", """{"message":"boom"}""", isError = true) + + assertEquals(ToolViewStatus.ERROR, view.status) + assertEquals("Tool returned an error.", view.error) + } + + @Test + fun `isError with string result uses the string as error`() { + val view = ToolViewBuilder.build("weird_tool", null, JsonPrimitive("everything broke"), isError = true) + + assertEquals(ToolViewStatus.ERROR, view.status) + assertEquals("everything broke", view.error) + } + + @Test + fun `success false without message gets canned error`() { + val view = build("weird_tool", "{}", """{"success":false}""") + + assertEquals(ToolViewStatus.ERROR, view.status) + assertEquals("Tool returned success=false.", view.error) + } + + @Test + fun `ok false with reason surfaces the reason`() { + val view = build("weird_tool", "{}", """{"ok":false,"reason":"quota exceeded"}""") + + assertEquals(ToolViewStatus.ERROR, view.status) + assertEquals("quota exceeded", view.error) + } + + @Test + fun `status failed string is an error`() { + val view = build("weird_tool", "{}", """{"status":"failed"}""") + + assertEquals(ToolViewStatus.ERROR, view.status) + assertEquals("Tool returned status \"failed\".", view.error) + } + + @Test + fun `nested error object surfaces through wrappers`() { + val view = build("weird_tool", "{}", """{"data":{"error":{"message":"Permission denied"}}}""") + + assertEquals(ToolViewStatus.ERROR, view.status) + assertEquals("Permission denied", view.error) + } + + @Test + fun `rejected memory write is a warning not an error`() { + val view = build("memory", """{"action":"add"}""", """{"success":false,"message":"over budget"}""") + + assertEquals(ToolViewStatus.WARNING, view.status) + assertEquals("over budget", view.error) + assertEquals("Memory", view.title) + // Error text and identical detail body are deduplicated. + assertEquals("over budget", view.detail) + } + + // ── file edits: diffs, stats, paths ────────────────────────────────── + + @Test + fun `patch extracts inline diff with stats and empty detail`() { + val view = + build( + "patch", + """{"path":"/repo/src/wiring.tsx"}""", + """{"inline_diff":"--- a/wiring.tsx\n+++ b/wiring.tsx\n@@ -1,2 +1,1 @@\n-old\n-older\n+new"}""", + ) + + assertEquals("wiring.tsx", view.title) + assertEquals("/repo/src/wiring.tsx", view.subtitle) + assertEquals("", view.detail) + assertEquals("/repo/src/wiring.tsx", view.diffPath) + assertEquals(DiffStats(added = 1, removed = 2), view.diffStats) + assertTrue(view.inlineDiff!!.startsWith("--- a/wiring.tsx")) + } + + @Test + fun `inline diff strips ansi codes and review-diff chrome`() { + val view = + build( + "edit_file", + """{"path":"/x/y.kt"}""", + """{"inline_diff":"\u001b[1m┊ review diff\n--- a/y.kt\n+++ b/y.kt\n+\u001b[32madded\u001b[0m"}""", + ) + + assertEquals("--- a/y.kt\n+++ b/y.kt\n+added", view.inlineDiff) + assertEquals(DiffStats(added = 1, removed = 0), view.diffStats) + } + + @Test + fun `diff key works as fallback for inline_diff`() { + val view = build("write_file", """{"path":"/a/b.txt"}""", """{"diff":"+hello"}""") + + assertEquals("+hello", view.inlineDiff) + } + + @Test + fun `file edit without path recovers html path from diff`() { + val view = build("patch", "{}", """{"inline_diff":"--- a/index.html\n+++ b/index.html\n+

hi

"}""") + + assertEquals("index.html", view.title) + assertEquals("index.html", view.diffPath) + } + + @Test + fun `write_file without diff shows message detail`() { + val view = build("write_file", """{"path":"/a/b.txt"}""", """{"message":"Wrote 40 bytes"}""") + + assertEquals("b.txt", view.title) + assertEquals("/a/b.txt", view.subtitle) + assertEquals("Wrote 40 bytes", view.detail) + assertNull(view.inlineDiff) + assertNull(view.diffStats) + } + + @Test + fun `read_file shows content detail and line range title`() { + val view = + build("read_file", """{"path":"/repo/src/a.kt","offset":10,"limit":5}""", """{"content":"10|a\n11|b"}""") + + assertEquals("Read a.kt L10-14", view.title) + assertEquals("/repo/src/a.kt", view.subtitle) + assertEquals("10|a\n11|b", view.detail) + assertNull(view.inlineDiff) + } + + @Test + fun `read_file running title uses reading verb`() { + val view = build("read_file", """{"path":"/repo/src/a.kt"}""", running = true) + + assertEquals("Reading a.kt", view.title) + assertEquals(ToolViewStatus.RUNNING, view.status) + } + + // ── web search ─────────────────────────────────────────────────────── + + @Test + fun `web_search extracts hits count query and detail label`() { + val view = + build( + "web_search", + """{"search_term":"cats"}""", + """{"data":{"web":[ + {"title":"Cat Facts","url":"https://cats.example/1","description":"all about cats"}, + {"title":"More Cats","url":"https://cats.example/2","description":"even more"} + ]}}""", + ) + + assertEquals("Searched \"cats\"", view.title) + assertEquals("Query: cats", view.subtitle) + assertEquals("2 results", view.countLabel) + assertEquals("cats", view.searchQuery) + assertEquals("Search results", view.detailLabel) + assertEquals( + listOf( + SearchHit("Cat Facts", "https://cats.example/1", "all about cats"), + SearchHit("More Cats", "https://cats.example/2", "even more"), + ), + view.searchHits, + ) + } + + @Test + fun `web_search accepts alternate hit field names`() { + val view = + build( + "web_search", + """{"query":"dogs"}""", + """{"results":[{"name":"Dog","href":"https://dogs.example","body":"woof"}]}""", + ) + + assertEquals(listOf(SearchHit("Dog", "https://dogs.example", "woof")), view.searchHits) + assertEquals("dogs", view.searchQuery) + } + + @Test + fun `web_search error suppresses hits and count`() { + val view = build("web_search", """{"search_term":"cats"}""", """{"error":"rate limited"}""") + + assertEquals(ToolViewStatus.ERROR, view.status) + assertNull(view.searchHits) + assertNull(view.countLabel) + assertEquals("rate limited", view.subtitle) + assertEquals("cats", view.searchQuery) + } + + @Test + fun `web_search running title quotes the query`() { + val view = build("web_search", """{"search_term":"kotlin flow"}""", running = true) + + assertEquals("Searching \"kotlin flow\"", view.title) + } + + // ── web extract / browser tools ────────────────────────────────────── + + @Test + fun `web_extract titles hostname and strips trailing duration from content`() { + val view = + build( + "web_extract", + """{"urls":["https://ex.com/docs"]}""", + """{"content":"Body text in 3.4s","url":"https://ex.com/docs"}""", + ) + + assertEquals("Read ex.com/docs", view.title) + assertEquals("ex.com/docs", view.subtitle) + assertEquals("Body text", view.detail) + } + + @Test + fun `web_extract joins multiple result contents`() { + val view = + build( + "web_extract", + """{"urls":["https://ex.com"]}""", + """{"results":[{"content":"one"},{"content":"two"}]}""", + ) + + // Pinned quirk: the joiner inserts "---" but the divider-line + // stripper in build() removes it again, leaving a blank gap. + assertEquals("one\n\n\ntwo", view.detail) + } + + @Test + fun `browser_navigate done and error titles use hostname`() { + val done = build("browser_navigate", """{"url":"https://example.com/x"}""", """{"ok":true}""") + assertEquals("Opened example.com/x", done.title) + assertEquals("example.com/x", done.subtitle) + + val failed = + build( + "browser_navigate", + """{"url":"https://example.com/x"}""", + """{"error":"net::ERR_CONNECTION_REFUSED","url":"https://example.com/x"}""", + ) + assertEquals(ToolViewStatus.ERROR, failed.status) + assertEquals("Failed to open example.com/x", failed.title) + assertEquals("net::ERR_CONNECTION_REFUSED", failed.subtitle) + } + + @Test + fun `browser_snapshot summarizes controls in subtitle and drops redundant detail`() { + val view = + build( + "browser_snapshot", + "{}", + """{"snapshot":"button \"Save\"\nbutton \"Cancel\"\nlink \"Docs\"\ntextbox \"Name\""}""", + ) + + assertEquals("2 buttons · 1 links · 1 inputs\nTop controls: Save, Cancel, Docs, Name", view.subtitle) + // detailFor returns the same summary; redundancy filter clears it. + assertEquals("", view.detail) + } + + @Test + fun `browser_click internal ref subtitle`() { + val view = build("browser_click", """{"ref":"@e12"}""", """{"ok":true}""") + + assertEquals("Clicked page element (internal ref @e12)", view.subtitle) + } + + @Test + fun `browser_type shows field and value`() { + val view = build("browser_type", """{"label":"Search","text":"kittens"}""", """{"ok":true}""") + + assertEquals("Field: Search · Value: kittens", view.subtitle) + } + + // ── memory / facts ─────────────────────────────────────────────────── + + @Test + fun `memory add title keeps target and subtitle shows message`() { + val view = + build( + "memory", + """{"action":"add","target":"user"}""", + """{"success":true,"message":"Saved to user memory"}""", + ) + + assertEquals(ToolViewStatus.SUCCESS, view.status) + assertEquals("Memory Saved (user)", view.title) + assertEquals("Saved to user memory", view.subtitle) + } + + @Test + fun `memory update and remove verbs`() { + assertEquals("Memory Updated", build("memory", """{"action":"update"}""", """{"success":true}""").title) + assertEquals("Memory Removed", build("memory", """{"action":"delete"}""", """{"success":true}""").title) + assertEquals("Saving", build("memory", """{"action":"add"}""", running = true).title) + } + + @Test + fun `fact_store added and listed facts`() { + val added = build("fact_store", """{"action":"add"}""", """{"status":"added","fact_id":42}""") + assertEquals("Fact added (ID: 42)", added.subtitle) + + val listed = + build( + "fact_store", + """{"action":"search","query":"x"}""", + """{"count":1,"results":[{"fact_id":7,"content":"user likes tea",""" + + """"category":"user_pref","trust_score":0.8}]}""", + ) + assertEquals("1 facts (search: x)", listed.subtitle) + assertEquals("#7 user likes tea\n [user_pref] trust: 0.80", listed.detail) + } + + // ── cron / todo / session search / process ─────────────────────────── + + @Test + fun `cronjob list counts jobs and lists schedules`() { + val view = + build( + "cronjob", + """{"action":"list"}""", + """{"jobs":[{"name":"daily","schedule":"0 9 * * *"},{"name":"hourly","schedule":"0 * * * *"}]}""", + ) + + assertEquals("Cron list", view.title) + assertEquals("2 cron jobs", view.subtitle) + assertEquals("- daily · 0 9 * * *\n- hourly · 0 * * * *", view.detail) + } + + @Test + fun `cronjob create shows schedule rows`() { + val view = + build( + "cronjob", + """{"action":"create","name":"reminder"}""", + """{"name":"reminder","schedule":"0 9 * * *","next_run_at":"2026-09-11T09:00"}""", + ) + + assertEquals("Cron create", view.title) + assertEquals("Create reminder", view.subtitle) + assertEquals("Schedule: 0 9 * * *\nNext run: 2026-09-11T09:00", view.detail) + } + + @Test + fun `todo renders summary counts and status markers`() { + val view = + build( + "todo", + """{"action":"update"}""", + """{"summary":{"total":3,"pending":1,"in_progress":1,"completed":1},"todos":[ + {"id":"1","content":"one","status":"pending"}, + {"id":"2","content":"two","status":"in_progress","parent":"1"}, + {"id":"3","content":"three","status":"completed"} + ]}""", + ) + + assertEquals("3 items (1 pending, 1 in_progress, 1 completed)", view.subtitle) + assertEquals("[ ] 1. one\n ↳ [>] 2. two\n[x] 3. three", view.detail) + } + + @Test + fun `session_search modes produce mode-specific subtitles`() { + val discover = + build( + "session_search", + """{"query":"auth"}""", + """{"mode":"discover","count":2,"query":"auth","results":[{"title":"Auth refactor"}]}""", + ) + assertEquals("2 sessions: auth", discover.subtitle) + + val read = + build( + "session_search", + "{}", + """{"mode":"read","message_count":5,"truncated":true,"messages":[]}""", + ) + assertEquals("5 messages (truncated)", read.subtitle) + + val browse = build("session_search", "{}", """{"mode":"browse","count":3}""") + assertEquals("3 recent sessions", browse.subtitle) + } + + @Test + fun `process list renders sessions with status`() { + val view = + build( + "process", + """{"action":"list"}""", + """{"processes":[{"session_id":"p1","status":"running","command":"npm start","running":true}]}""", + ) + + assertEquals("list", view.subtitle) + assertEquals("📌 p1: npm start\n Status: running\n Running: true", view.detail) + } + + // ── delegation / messaging fall back to the generic engine ─────────── + + @Test + fun `delegate_task uses generic title and unwrapped response`() { + val view = + build( + "delegate_task", + """{"goal":"Fix tests"}""", + """{"response":"All done","duration_s":63}""", + ) + + assertEquals("Delegate Task", view.title) + assertEquals("All done", view.subtitle) + assertEquals("", view.detail) + assertEquals("1m 3s", view.durationLabel) + } + + @Test + fun `send_message summarizes fields generically`() { + val view = build("send_message", "{}", """{"status":"sent","channel":"#general"}""") + + assertEquals("Send Message", view.title) + assertEquals("- Status: sent", view.subtitle) + assertEquals("- Status: sent\n- Channel: #general", view.detail) + } + + // ── media tools ────────────────────────────────────────────────────── + + @Test + fun `image_generate subtitle carries url and detail carries prompt`() { + val view = + build( + "image_generate", + """{"prompt":"a cat"}""", + """{"image":"https://img.example/cat.png","modality":"image"}""", + ) + + assertEquals("https://img.example/cat.png (image)", view.subtitle) + assertEquals("🖼️ a cat\n\n🔗 https://img.example/cat.png", view.detail) + // Pinned: imageUrlFor does not read the "image" key, so no thumbnail. + assertNull(view.imageUrl) + } + + @Test + fun `vision_analyze exposes image url and description`() { + val view = + build( + "vision_analyze", + """{"image_url":"data:image/png;base64,AAA"}""", + """{"description":"A cat on a mat"}""", + ) + + assertEquals("data:image/png;base64,AAA", view.imageUrl) + assertEquals("A cat on a mat", view.detail) + assertEquals("data:image/png;base64,AAA", view.subtitle) + } + + @Test + fun `image url only accepted for data or remote image extensions`() { + assertEquals( + "https://a.b/i.jpg?x=1", + build("weird_tool", "{}", """{"url":"https://a.b/i.jpg?x=1"}""").imageUrl, + ) + assertNull(build("weird_tool", "{}", """{"url":"https://a.b/page"}""").imageUrl) + } + + @Test + fun `x_search renders answer with citations and degraded marker`() { + val view = + build( + "x_search", + """{"query":"hermes"}""", + """{"answer":"It is an agent.","degraded":true,"citations":[]}""", + ) + + assertEquals("hermes (no citations)", view.subtitle) + assertEquals("It is an agent.\n\n⚠️ No citations — answer based on model's knowledge", view.detail) + } + + // ── skills / tool discovery / projects / computer use ──────────────── + + @Test + fun `skills_list counts skills and renders entries`() { + val view = + build( + "skills_list", + """{"category":"research"}""", + """{"skills":[{"name":"summarize","description":"Sums up"},{"name":"extract"}]}""", + ) + + assertEquals("2 skills (research)", view.subtitle) + assertEquals("📌 summarize\n Sums up\n\n📌 extract", view.detail) + } + + @Test + fun `skill_view truncates long content at 500 chars`() { + val longContent = "x".repeat(620) + val view = build("skill_view", """{"name":"summarize"}""", """{"content":"$longContent"}""") + + assertEquals("summarize", view.subtitle) + assertEquals("${"x".repeat(500)}\n... [120 more chars]", view.detail) + } + + @Test + fun `tool_search shows match count and entries`() { + val view = + build( + "tool_search", + """{"query":"files"}""", + """{"matches":[{"name":"read_file","description":"Reads"},{"name":"write_file"}]}""", + ) + + assertEquals("files (2 matches)", view.subtitle) + assertEquals("🔧 read_file\n Reads\n\n🔧 write_file", view.detail) + } + + @Test + fun `read_terminal shows line window and terminal block`() { + val view = + build( + "read_terminal", + """{"session_id":"s1"}""", + """{"total_lines":50,"start":1,"end":20,"text":"line one","cursor_row":5}""", + ) + + assertEquals("Lines 1-20 of 50, cursor at row 5", view.subtitle) + assertEquals("━━━ Terminal ━━━ (1:20 / 50) │ cursor row 5\nline one", view.detail) + } + + @Test + fun `computer_use apps list and action ack`() { + val apps = build("computer_use", """{"action":"list_apps"}""", """{"apps":[{"name":"Firefox","pid":42}]}""") + assertEquals("1 apps", apps.subtitle) + assertEquals("1. Firefox (PID: 42)", apps.detail.trim()) + + val action = build("computer_use", """{"action":"click"}""", """{"ok":true}""") + assertEquals("click ✓", action.subtitle) + assertEquals("click: ✅ ok", action.detail) + } + + // ── payload normalization: wrappers, strings, malformed JSON ───────── + + @Test + fun `wrapped payloads unwrap for unknown tools`() { + val view = build("weird_tool", "{}", """{"data":{"message":"All good"}}""") + + assertEquals("Weird Tool", view.title) + assertEquals("All good", view.subtitle) + // Redundant with the subtitle, so cleared. + assertEquals("", view.detail) + } + + @Test + fun `stringified json result is re-parsed`() { + val view = + ToolViewBuilder.build( + "terminal", + json("""{"command":"ls"}"""), + JsonPrimitive("""{"output":"hi","exit_code":0}"""), + ) + + assertEquals("hi", view.stdout) + assertEquals(0, view.exitCode) + } + + @Test + fun `malformed json string result degrades to success with no streams`() { + val view = ToolViewBuilder.build("terminal", json("""{"command":"ls"}"""), JsonPrimitive("{not json")) + + assertEquals(ToolViewStatus.SUCCESS, view.status) + assertEquals("Ran ls", view.title) + assertNull(view.stdout) + assertNull(view.exitCode) + } + + @Test + fun `array result summarizes items generically`() { + val view = ToolViewBuilder.build("weird_tool", null, json("""[{"name":"alpha"},{"name":"beta"}]""")) + + assertEquals(ToolViewStatus.SUCCESS, view.status) + assertEquals("- alpha\n- beta", view.detail) + } + + // ── counts and duration labels ─────────────────────────────────────── + + @Test + fun `count fields drive count labels with nouns`() { + assertEquals("5 items", build("weird_tool", "{}", """{"count":"5"}""").countLabel) + assertEquals("2 matches", build("weird_tool", "{}", """{"match_count":2}""").countLabel) + assertEquals("3 files", build("weird_tool", "{}", """{"files":["a","b","c"]}""").countLabel) + assertEquals("4 sessions", build("weird_tool", "{}", """{"session_count":4}""").countLabel) + } + + @Test + fun `count from summary text`() { + assertEquals("4 matches", build("weird_tool", "{}", """{"summary":"Found 4 matches in repo"}""").countLabel) + } + + @Test + fun `duration label formats across magnitudes`() { + assertEquals("500ms", build("weird_tool", "{}", """{"duration_s":0.5}""").durationLabel) + assertEquals("9.5s", build("weird_tool", "{}", """{"duration_s":9.5}""").durationLabel) + assertEquals("12s", build("weird_tool", "{}", """{"duration_s":12.3}""").durationLabel) + assertEquals("1m 3s", build("weird_tool", "{}", """{"duration_s":63}""").durationLabel) + assertEquals("1h 1m", build("weird_tool", "{}", """{"duration_s":3660}""").durationLabel) + } + + // ── unknown tool fallback ──────────────────────────────────────────── + + @Test + fun `unknown tool gets humanized title and heuristic summary`() { + val view = build("weird_tool", "{}", """{"result":{"data":{"title":"Build report","completed":true}}}""") + + assertEquals("Weird Tool", view.title) + assertEquals("- Title: Build report", view.subtitle) + assertEquals("- Title: Build report\n- Completed: true", view.detail) + } + + @Test + fun `generic titles strip browser and web prefixes`() { + assertEquals("Fact Store", ToolViewBuilder.genericTitleFor("fact_store")) + assertEquals("Cdp", ToolViewBuilder.genericTitleFor("browser_cdp")) + assertEquals("Tool", ToolViewBuilder.genericTitleFor(null)) + } +}