diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/CommandSummarizer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/CommandSummarizer.kt
new file mode 100644
index 00000000..7b5cca51
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/CommandSummarizer.kt
@@ -0,0 +1,215 @@
+package com.m57.hermescontrol.ui.chat.tool
+
+/**
+ * Reduce a verbose shell command to the "main" command, for display only.
+ *
+ * Ported from the desktop app's `lib/summarize-command.ts`. Agents wrap real
+ * work in plumbing — `cd
&& 2>&1 | tail -N; echo "x_exit=..."` —
+ * which buries the command the user actually cares about. This peels that
+ * wrapper off using small head-word allowlists instead of one giant regex:
+ *
+ * 1. split into segments on top-level `&&` `||` `;` (quote-aware)
+ * 2. strip trailing pipe tails (`| head`, `| tail`, `| wc`, ...)
+ * 3. clean env var prefixes / redirects
+ * 4. drop setup/banner/status segments
+ *
+ * If one real command survives, show it. If multiple real commands survive,
+ * show a short `first command + N commands` label instead of flooding the row
+ * with every probe. The full command is always still available via Copy.
+ */
+object CommandSummarizer {
+ private val SILENT_HEADS =
+ setOf("cd", "pushd", "popd", "export", "set", "unset", "source", ".", "true", "false", ":")
+ private val PIPE_TAIL_HEADS = setOf("head", "tail", "wc", "sort", "uniq")
+
+ private fun basename(head: String): String = head.substringAfterLast('/').ifEmpty { head }
+
+ /** Split on command-chain separators, but NOT pipe. */
+ private fun splitCompoundCommand(input: String): List {
+ val segments = mutableListOf()
+ val buf = StringBuilder()
+ var quote: Char? = null
+
+ var i = 0
+ while (i < input.length) {
+ val ch = input[i]
+
+ if (quote != null) {
+ buf.append(ch)
+ if (ch == quote && input.getOrNull(i - 1) != '\\') {
+ quote = null
+ }
+ i += 1
+ continue
+ }
+
+ if (ch == '"' || ch == '\'') {
+ quote = ch
+ buf.append(ch)
+ i += 1
+ continue
+ }
+
+ val op =
+ when {
+ input.startsWith("&&", i) || input.startsWith("||", i) -> input.substring(i, i + 2)
+ ch == ';' || ch == '\n' -> ch.toString()
+ else -> ""
+ }
+
+ if (op.isNotEmpty()) {
+ segments += buf.toString()
+ buf.clear()
+ i += op.length
+ continue
+ }
+
+ buf.append(ch)
+ i += 1
+ }
+
+ segments += buf.toString()
+
+ return segments.map { stripPipeTail(it.trim()) }.filter { it.isNotEmpty() }
+ }
+
+ private fun splitWords(segment: String): List {
+ val words = mutableListOf()
+ val buf = StringBuilder()
+ var quote: Char? = null
+
+ for ((idx, ch) in segment.withIndex()) {
+ if (quote != null) {
+ buf.append(ch)
+ if (ch == quote && segment.getOrNull(idx - 1) != '\\') {
+ quote = null
+ }
+ continue
+ }
+
+ if (ch == '"' || ch == '\'') {
+ quote = ch
+ buf.append(ch)
+ continue
+ }
+
+ if (ch.isWhitespace()) {
+ if (buf.isNotEmpty()) {
+ words += buf.toString()
+ buf.clear()
+ }
+ continue
+ }
+
+ buf.append(ch)
+ }
+
+ if (buf.isNotEmpty()) {
+ words += buf.toString()
+ }
+
+ return words
+ }
+
+ /** The command word of a segment, skipping any `FOO=bar` env assignments. */
+ private fun headWord(segment: String): String {
+ val tokens = splitWords(segment)
+ var index = 0
+
+ while (index < tokens.size && Regex("^[A-Za-z_]\\w*=").containsMatchIn(tokens[index])) {
+ index += 1
+ }
+
+ return basename(tokens.getOrNull(index) ?: "")
+ }
+
+ private fun stripPipeTail(segment: String): String {
+ val words = splitWords(segment)
+ val out = mutableListOf()
+
+ for (i in words.indices) {
+ val word = words[i]
+
+ if (word == "|" && basename(words.getOrNull(i + 1) ?: "") in PIPE_TAIL_HEADS) {
+ break
+ }
+
+ out += word
+ }
+
+ return out.joinToString(" ").trim()
+ }
+
+ private fun cleanSegment(segment: String): String {
+ val words = splitWords(segment)
+ val out = mutableListOf()
+
+ var i = 0
+ while (i < words.size) {
+ val word = words[i]
+
+ // `> file`, `2> file`, `>> file`, `2>&1` — drop the operator + its target.
+ if (Regex("^\\d*(?:>>?|<)$").matches(word)) {
+ i += 2
+ continue
+ }
+
+ if (Regex("^\\d*(?:>&|<&)\\d+$").matches(word)) {
+ i += 1
+ continue
+ }
+
+ out += word
+ i += 1
+ }
+
+ return out.joinToString(" ").trim()
+ }
+
+ private fun isBoundaryEcho(segment: String): Boolean {
+ val words = splitWords(segment)
+
+ if (basename(words.firstOrNull() ?: "") != "echo") {
+ return false
+ }
+
+ // Banner/status echoes are UI plumbing. Do not treat arbitrary
+ // `echo $VALUE` as noise; it may be the command's actual output.
+ val rest = words.drop(1).joinToString(" ")
+
+ return Regex("-{2,}|_exit=|(?:^|\\s|=)\\$[?{]|PIPESTATUS").containsMatchIn(rest)
+ }
+
+ fun summarizeShellCommand(raw: String?): String {
+ val original = (raw ?: "").trim()
+
+ if (original.isEmpty()) {
+ return ""
+ }
+
+ val segments = splitCompoundCommand(original)
+
+ if (segments.size <= 1) {
+ return cleanSegment(original).ifEmpty { original }
+ }
+
+ val core =
+ segments
+ .map { cleanSegment(it) }
+ .filter { segment ->
+ val head = headWord(segment)
+
+ segment.isNotEmpty() && head !in SILENT_HEADS && !isBoundaryEcho(segment)
+ }
+
+ if (core.isEmpty()) {
+ return original
+ }
+
+ if (core.size == 1) {
+ return core[0]
+ }
+
+ return "${core[0]} + ${core.size - 1} ${if (core.size == 2) "command" else "commands"}"
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummary.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummary.kt
new file mode 100644
index 00000000..ab9f6f34
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummary.kt
@@ -0,0 +1,567 @@
+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
+
+/**
+ * Heuristic JSON → human summary engine for tool results.
+ *
+ * Ported from the Hermes desktop app's `lib/tool-result-summary.ts`
+ * (verified against its vitest suite). Pure functions over a parsed
+ * [JsonElement] — no UI deps, fully unit-testable.
+ *
+ * Two exports:
+ * - [formatToolResultSummary]: best-effort one-line/block human summary of
+ * any tool result payload (wrapper unwrap, priority keys, depth caps).
+ * - [extractToolErrorMessage]: deep hunt for a real error message through
+ * nested wrappers, with placeholder-value filtering.
+ */
+object ToolResultSummary {
+ private val WRAPPER_KEYS = listOf("data", "result", "output", "response", "payload")
+
+ private val PRIORITY_KEYS =
+ listOf(
+ "title",
+ "name",
+ "path",
+ "file",
+ "filepath",
+ "url",
+ "href",
+ "link",
+ "status",
+ "id",
+ "message",
+ "summary",
+ "description",
+ )
+
+ private val ERROR_KEYS = listOf("error", "errors", "failure", "exception")
+
+ // 'stderr' deliberately excluded: many CLIs emit informational lines on
+ // stderr (npm progress, git's hint:, gcc's `In file included from`) that
+ // aren't errors. Treating those as error signal flipped tool cards into
+ // destructive styling for healthy commands.
+ private val ERROR_MSG_KEYS = listOf("message", "reason", "detail")
+ private val NON_ERROR_TEXT = setOf("", "0", "false", "none", "null", "nil", "ok", "success", "n/a", "na")
+
+ // ── helpers ──────────────────────────────────────────────────────────
+
+ private fun normalize(value: String): String = value.lowercase().trim()
+
+ private fun capitalize(value: String): String =
+ value.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
+
+ private fun titleCase(key: String): String =
+ key
+ .split(Regex("[_\\-.]+"))
+ .filter { it.isNotEmpty() }
+ .joinToString(" ") { capitalize(it) }
+
+ private fun pluralize(
+ n: Int,
+ noun: String,
+ ): String = "$n $noun${if (n == 1) "" else "s"}"
+
+ /** Trim + collapse whitespace, truncate with ellipsis. */
+ private fun clipInline(
+ value: String,
+ max: Int = 180,
+ ): String {
+ val c = value.replace(Regex("\\s+"), " ").trim()
+
+ return if (c.length > max) "${c.take(max - 1)}…" else c
+ }
+
+ /** First N lines + char cap, with a trailing ellipsis when clipped. */
+ private fun clipBlock(
+ value: String,
+ maxChars: Int = 1800,
+ maxLines: Int = 18,
+ ): String {
+ val t = value.trim()
+
+ if (t.isEmpty()) {
+ return ""
+ }
+
+ val lines = t.split("\n")
+ var text = lines.take(maxLines).joinToString("\n")
+ val clipped = lines.size > maxLines || text.length > maxChars
+
+ if (text.length > maxChars) {
+ text = text.take(maxChars - 1).trimEnd()
+ }
+
+ return if (clipped && !text.endsWith("…")) "$text…" else text
+ }
+
+ private fun firstString(
+ record: JsonObject,
+ keys: List,
+ ): String {
+ for (k in keys) {
+ val v = record[k] ?: continue
+ if (v is JsonPrimitive && v.isString && v.content.trim().isNotEmpty()) {
+ return v.content.trim()
+ }
+ }
+
+ return ""
+ }
+
+ /** Re-parse stringified JSON payloads; everything else passes through. */
+ private fun norm(value: JsonElement?): JsonElement? {
+ val v = value ?: return null
+ if (v is JsonNull) {
+ return null
+ }
+ if (v !is JsonPrimitive || !v.isString) {
+ return v
+ }
+ val t = v.content.trim()
+ if (t.isEmpty() || !(t.startsWith("{") || t.startsWith("[") || t.startsWith("\""))) {
+ return v
+ }
+
+ return try {
+ Json.parseToJsonElement(t)
+ } catch (_: Exception) {
+ v
+ }
+ }
+
+ private fun isWrapperKey(k: String): Boolean = k in WRAPPER_KEYS
+
+ /** `success: true` / `ok: true` are noise — never rendered. */
+ private fun skipField(
+ k: String,
+ v: JsonElement?,
+ ): Boolean {
+ if (isWrapperKey(k)) {
+ return true
+ }
+
+ if ((k == "success" || k == "ok") && v is JsonPrimitive && !v.isString && v.content == "true") {
+ return true
+ }
+
+ return false
+ }
+
+ private fun scalarString(v: JsonElement?): String? {
+ if (v is JsonPrimitive && v.isString) {
+ return v.content
+ }
+
+ if (v is JsonPrimitive && !v.isString) {
+ val c = v.content
+ if (c == "true" || c == "false" || c.toDoubleOrNull() != null) {
+ return c
+ }
+ }
+
+ return null
+ }
+
+ private fun summarizeScalar(v: JsonElement?): String {
+ if (v is JsonPrimitive && v.isString) {
+ return clipInline(v.content)
+ }
+
+ if (v is JsonPrimitive && !v.isString) {
+ return v.content
+ }
+
+ return ""
+ }
+
+ private fun orderedKeys(keys: List): List {
+ val priority = PRIORITY_KEYS.filter { it in keys }
+ val rest = keys.filter { it !in PRIORITY_KEYS }
+
+ return priority + rest
+ }
+
+ private fun summarizeRecordInline(
+ record: JsonObject,
+ depth: Int,
+ ): String {
+ if (depth > 3) {
+ return pluralize(record.keys.size, "field")
+ }
+
+ val title =
+ firstString(record, listOf("title", "name", "path", "file", "filepath", "url", "href", "link", "id"))
+ val status = firstString(record, listOf("status", "category", "type"))
+ val message = firstString(record, listOf("snippet", "summary", "description", "message"))
+
+ if (title.isNotEmpty() && status.isNotEmpty()) {
+ return "${clipInline(title, 110)} (${clipInline(status, 54)})"
+ }
+
+ if (title.isNotEmpty() && message.isNotEmpty() && title != message) {
+ return "${clipInline(title, 90)} - ${clipInline(message, 84)}"
+ }
+
+ if (title.isNotEmpty()) {
+ return clipInline(title, 150)
+ }
+
+ val pairs =
+ orderedKeys(record.keys.toList())
+ .filter { k -> !skipField(k, record[k]) }
+ .mapNotNull { k ->
+ val s = summarizeScalar(record[k])
+ if (s.isEmpty()) null else "${titleCase(k)}: $s"
+ }
+ .take(2)
+
+ return if (pairs.isNotEmpty()) pairs.joinToString(" · ") else pluralize(record.keys.size, "field")
+ }
+
+ private fun summarizeListItem(
+ item: JsonElement?,
+ depth: Int,
+ ): String {
+ val v = norm(item)
+ if (v is JsonNull || v == null) {
+ return ""
+ }
+
+ val scalar = scalarString(v)
+ if (scalar != null) {
+ return clipInline(scalar)
+ }
+
+ if (v is JsonArray) {
+ return pluralize(v.size, "item")
+ }
+
+ if (v is JsonObject) {
+ return summarizeRecordInline(v, depth + 1)
+ }
+
+ return clipInline(v.toString())
+ }
+
+ private fun formatFieldValue(
+ value: JsonElement?,
+ depth: Int,
+ ): String {
+ val v = norm(value)
+ val scalar = summarizeScalar(v)
+
+ if (scalar.isNotEmpty()) {
+ return scalar
+ }
+
+ if (v == null || v is JsonNull) {
+ return ""
+ }
+
+ if (v is JsonArray) {
+ if (v.isEmpty()) {
+ return ""
+ }
+
+ val scalars = v.mapNotNull { summarizeScalar(it).takeIf { s -> s.isNotEmpty() } }
+
+ if (scalars.size == v.size && v.size <= 4) {
+ return clipInline(scalars.joinToString(", "))
+ }
+
+ val first = summarizeListItem(v.firstOrNull(), depth + 1)
+
+ return if (first.isNotEmpty()) "${pluralize(v.size, "item")} ($first)" else pluralize(v.size, "item")
+ }
+
+ if (v is JsonObject) {
+ return summarizeRecordInline(v, depth + 1)
+ }
+
+ return clipInline(v.toString())
+ }
+
+ /**
+ * "Returned N items" / "0 items" / "Returned an empty object" are all
+ * noise — better to render nothing and let the title carry the signal.
+ */
+ private fun formatArraySummary(
+ value: JsonArray,
+ depth: Int,
+ ): String {
+ if (value.isEmpty()) {
+ return ""
+ }
+
+ val max = 6
+ val lines =
+ value
+ .take(max)
+ .mapNotNull { item -> summarizeListItem(item, depth + 1).takeIf { it.isNotEmpty() } }
+ .map { "- $it" }
+ .toMutableList()
+
+ if (lines.isEmpty()) {
+ return ""
+ }
+
+ if (value.size > max) {
+ val remaining = value.size - max
+ lines += "- … $remaining more ${if (remaining == 1) "item" else "items"}"
+ }
+
+ return lines.joinToString("\n")
+ }
+
+ private fun formatRecordSummary(
+ record: JsonObject,
+ depth: Int,
+ ): String {
+ val keys = record.keys.toList()
+
+ if (keys.isEmpty()) {
+ return ""
+ }
+
+ if (depth <= 2) {
+ val direct = firstString(record, listOf("message", "summary", "description", "preview", "text", "content"))
+ val meaningful = keys.filter { k -> !skipField(k, record[k]) && !isWrapperKey(k) }
+
+ if (direct.isNotEmpty() && meaningful.size <= 1) {
+ return clipBlock(direct)
+ }
+ }
+
+ val candidates = orderedKeys(keys).filter { k -> !skipField(k, record[k]) }
+ val max = 8
+ val lines = mutableListOf()
+
+ for (k in candidates) {
+ val v = formatFieldValue(record[k], depth + 1)
+
+ if (v.isEmpty()) {
+ continue
+ }
+
+ lines += "- ${titleCase(k)}: $v"
+
+ if (lines.size >= max) {
+ break
+ }
+ }
+
+ if (lines.isEmpty()) {
+ return ""
+ }
+
+ if (candidates.size > lines.size) {
+ val remaining = candidates.size - lines.size
+ lines += "- … ${pluralize(remaining, "field")}"
+ }
+
+ return lines.joinToString("\n")
+ }
+
+ private fun formatSummaryValue(
+ value: JsonElement?,
+ depth: Int,
+ ): String {
+ if (depth > 4) {
+ return ""
+ }
+
+ val v = norm(value)
+
+ if (v is JsonPrimitive && v.isString) {
+ return clipBlock(v.content)
+ }
+
+ if (v is JsonPrimitive && !v.isString) {
+ return v.content
+ }
+
+ if (v == null || v is JsonNull) {
+ return ""
+ }
+
+ if (v is JsonArray) {
+ return formatArraySummary(v, depth + 1)
+ }
+
+ if (v is JsonObject) {
+ return formatRecordSummary(v, depth + 1)
+ }
+
+ return clipInline(v.toString())
+ }
+
+ /** Peel up to 4 wrapper layers (`data` / `result` / `output` / …). */
+ private fun unwrapPayload(value: JsonElement?): JsonElement? {
+ var cur: JsonElement? = norm(value)
+
+ for (i in 0 until 4) {
+ val record = cur as? JsonObject ?: return cur
+ val key = WRAPPER_KEYS.firstOrNull { k -> record[k] != null && record[k] !is JsonNull } ?: return record
+
+ cur = norm(record[key])
+ }
+
+ return cur
+ }
+
+ // ── public API ───────────────────────────────────────────────────────
+
+ fun formatToolResultSummary(value: JsonElement?): String {
+ val unwrapped = formatSummaryValue(unwrapPayload(value), 0)
+
+ return if (unwrapped.isNotEmpty()) unwrapped else formatSummaryValue(value, 0)
+ }
+
+ // ── error extraction ─────────────────────────────────────────────────
+
+ private fun hasMeaningfulErrorValue(value: JsonElement?): Boolean {
+ val v = norm(value)
+
+ if (v == null || v is JsonNull) {
+ return false
+ }
+
+ if (v is JsonPrimitive) {
+ if (v.isString) {
+ return normalize(v.content) !in NON_ERROR_TEXT
+ }
+
+ return v.content != "0" && v.content != "false"
+ }
+
+ if (v is JsonArray) {
+ return v.any { hasMeaningfulErrorValue(it) }
+ }
+
+ if (v is JsonObject) {
+ return v.keys.isNotEmpty()
+ }
+
+ return true
+ }
+
+ private fun hasErrorSignal(record: JsonObject): Boolean {
+ val status = (record["status"] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: ""
+
+ return (
+ (record["success"] as? JsonPrimitive)?.let { !it.isString && it.content == "false" } == true ||
+ (record["ok"] as? JsonPrimitive)?.let { !it.isString && it.content == "false" } == true ||
+ Regex(
+ "\\b(error|failed|failure|fatal|exception)\\b",
+ RegexOption.IGNORE_CASE,
+ ).containsMatchIn(status) ||
+ ERROR_KEYS.any { k -> hasMeaningfulErrorValue(record[k]) }
+ )
+ }
+
+ private fun valueErrorText(value: JsonElement?): String {
+ val v = norm(value)
+
+ if (v is JsonPrimitive && v.isString) {
+ return if (hasMeaningfulErrorValue(v)) clipBlock(v.content, 700, 12) else ""
+ }
+
+ if (v is JsonArray) {
+ return clipBlock(
+ v.mapNotNull {
+ valueErrorText(it).takeIf {
+ s ->
+ s.isNotEmpty()
+ }
+ }.take(3).joinToString("; "),
+ 700,
+ 12,
+ )
+ }
+
+ if (v is JsonObject) {
+ val direct = firstString(v, ERROR_MSG_KEYS)
+
+ if (direct.isNotEmpty()) {
+ return clipBlock(direct, 700, 12)
+ }
+ }
+
+ return ""
+ }
+
+ private fun findNestedError(
+ value: JsonElement?,
+ depth: Int,
+ seen: MutableSet,
+ ): String {
+ if (depth > 5) {
+ return ""
+ }
+
+ val v = norm(value) ?: return ""
+
+ if (v is JsonNull) {
+ return ""
+ }
+
+ if (v in seen) {
+ return ""
+ }
+ seen.add(v)
+
+ if (v is JsonArray) {
+ for (item in v) {
+ val nested = findNestedError(item, depth + 1, seen)
+
+ if (nested.isNotEmpty()) {
+ return nested
+ }
+ }
+
+ return ""
+ }
+
+ if (v !is JsonObject) {
+ return ""
+ }
+
+ for (k in ERROR_KEYS) {
+ if (!hasMeaningfulErrorValue(v[k])) {
+ continue
+ }
+
+ val text = valueErrorText(v[k])
+
+ if (text.isNotEmpty()) {
+ return text
+ }
+ }
+
+ if (hasErrorSignal(v)) {
+ val direct = firstString(v, ERROR_MSG_KEYS)
+
+ if (direct.isNotEmpty()) {
+ return clipBlock(direct, 700, 12)
+ }
+ }
+
+ for (k in ERROR_KEYS + WRAPPER_KEYS + listOf("details", "meta")) {
+ val nested = findNestedError(v[k], depth + 1, seen)
+
+ if (nested.isNotEmpty()) {
+ return nested
+ }
+ }
+
+ return ""
+ }
+
+ fun extractToolErrorMessage(value: JsonElement?): String = findNestedError(value, 0, mutableSetOf())
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt
new file mode 100644
index 00000000..112d6039
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt
@@ -0,0 +1,62 @@
+package com.m57.hermescontrol.ui.chat.tool
+
+/** Render state of a tool call row. */
+enum class ToolViewStatus {
+ RUNNING,
+ SUCCESS,
+ ERROR,
+ WARNING,
+}
+
+data class SearchHit(
+ val title: String,
+ val url: String,
+ val snippet: String,
+)
+
+data class DiffStats(
+ val added: Int,
+ val removed: Int,
+)
+
+/**
+ * The full display model for one tool call, produced by [ToolViewBuilder].
+ *
+ * Ported shape from the desktop app's `ToolView` (fallback-model): the
+ * renderer reads only these fields — no raw JSON spelunking in UI code.
+ */
+data class ToolView(
+ val status: ToolViewStatus,
+ /** One-line headline: "Read /tmp/x", "Ran sleep 10 + 2 commands". */
+ val title: String,
+ /** Secondary line naming what was acted on (path, hostname, query…). */
+ val subtitle: String = "",
+ /** Expanded body text. */
+ val detail: String = "",
+ /** Optional section label above [detail] (e.g. "Search results"). */
+ val detailLabel: String? = null,
+ /** Compact count, e.g. "3 results". */
+ val countLabel: String? = null,
+ /** Human duration, e.g. "1.2s". */
+ val durationLabel: String? = null,
+ /** Terminal tools: stdout stream when the backend split streams. */
+ val stdout: String? = null,
+ /** Terminal tools: stderr stream when the backend split streams. */
+ val stderr: String? = null,
+ /** Terminal tools: numeric exit code. */
+ val exitCode: Int? = null,
+ /** Terminal tools: the command that actually ran (for the `$` line). */
+ val terminalCommand: String? = null,
+ /** File-edit tools: the diff to render. */
+ val inlineDiff: String? = null,
+ /** File-edit tools: the file the diff applies to. */
+ val diffPath: String? = null,
+ val diffStats: DiffStats? = null,
+ /** Image-producing tools: renderable URL (data: or http(s) image). */
+ val imageUrl: String? = null,
+ /** web_search: parsed result rows. */
+ val searchHits: List? = null,
+ val searchQuery: String? = null,
+ /** Detected error text (destructive styling in the view). */
+ val error: String? = null,
+)
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
new file mode 100644
index 00000000..3df8d128
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt
@@ -0,0 +1,1981 @@
+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.
+ *
+ * One pure function turns a tool call's args/result into a [ToolView] the
+ * renderer can paint without touching raw JSON. Structure:
+ *
+ * - 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
+ */
+object ToolViewBuilder {
+ // ── 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 || v.isString) {
+ return null
+ }
+
+ 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 Regex(
+ "(?:^|\\s)(?:[ab]/)?([^\\s]+\\.html?)(?=\\s|$)",
+ RegexOption.IGNORE_CASE,
+ ).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 ───────────────────────────────────────────────────────────
+
+ 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 {
+ return 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 ──────────────────────────────────────────────────────
+
+ fun build(
+ toolName: String,
+ args: JsonElement?,
+ result: JsonElement?,
+ isError: Boolean = false,
+ running: Boolean = false,
+ ): ToolView {
+ val argsRecord = parseMaybeObject(args)
+ val resultRecord = parseMaybeObject(result)
+ val status = toolStatus(toolName, result, resultRecord, isError, running)
+ val error =
+ if (status == ToolViewStatus.SUCCESS) {
+ ""
+ } else {
+ toolErrorText(toolName, isError, result, resultRecord)
+ }
+
+ val title =
+ when {
+ running -> pendingTitle(toolName, argsRecord)
+ error.isNotEmpty() -> errorTitle(toolName, resultRecord)
+ else -> doneTitle(toolName, argsRecord, resultRecord)
+ }
+
+ val subtitle = subtitleFor(toolName, argsRecord, resultRecord, result, error)
+ val detailBody = stripDividerLines(detailFor(toolName, argsRecord, resultRecord, result, args))
+ val detail =
+ if (error.isNotEmpty()) {
+ listOf(error, detailBody)
+ .filter { it.isNotEmpty() }
+ .distinct()
+ .joinToString("\n\n")
+ } else if (looksRedundant(title, detailBody) || looksRedundant(subtitle, detailBody)) {
+ // Detail that repeats the header is noise — e.g. a read_file
+ // whose content equals its own title. The header carries it.
+ ""
+ } else {
+ 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 countLabel =
+ if (status == ToolViewStatus.ERROR) {
+ null
+ } else {
+ countLabelFor(
+ toolName,
+ argsRecord,
+ resultRecord,
+ result,
+ )
+ }
+ 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 },
+ error = error.ifEmpty { null },
+ )
+ }
+
+ // ── titles per tool ──────────────────────────────────────────────────
+
+ private fun pendingTitle(
+ toolName: String,
+ args: JsonObject?,
+ ): String {
+ return 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 {
+ return 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" -> {
+ when (firstString(args, listOf("action")).lowercase()) {
+ "replace", "update" -> "Memory Updated"
+ "remove", "delete" -> "Memory Removed"
+ "add" -> "Memory Saved"
+ else -> "Memory ${firstString(args, listOf("action"))}"
+ }
+ }
+ "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 {
+ return 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"
+ }
+
+ 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
+ }
+
+ return when (toolName) {
+ "browser_navigate" -> {
+ val url =
+ firstString(args, listOf("url", "target"))
+ .ifEmpty { firstString(result, listOf("url")) }
+ .ifEmpty { findFirstUrl(args, result) }
+
+ 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 marker =
+ when (status) {
+ "completed" -> "[x]"
+ "in_progress" -> "[>]"
+ "cancelled" -> "[~]"
+ else -> "[ ]"
+ }
+ "$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
+ }
+
+ private fun imageUrlFor(
+ args: JsonObject?,
+ result: JsonObject?,
+ ): String? {
+ val candidate =
+ firstString(result, listOf("image_url", "url", "path", "image_path"))
+ .ifEmpty { firstString(args, listOf("image_url", "url", "path")) }
+
+ if (candidate.isEmpty()) {
+ return null
+ }
+
+ val isDataImage = candidate.lowercase().startsWith("data:image/")
+ val isRemoteImage =
+ Regex("^https?://", RegexOption.IGNORE_CASE).containsMatchIn(candidate) &&
+ Regex("\\.(png|jpe?g|gif|webp|bmp|svg)(\\?|#|$)", RegexOption.IGNORE_CASE).containsMatchIn(candidate)
+
+ return if (isDataImage || isRemoteImage) candidate else null
+ }
+}
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/CommandSummarizerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/CommandSummarizerTest.kt
new file mode 100644
index 00000000..d1b176a0
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/CommandSummarizerTest.kt
@@ -0,0 +1,71 @@
+package com.m57.hermescontrol.ui.chat.tool
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class CommandSummarizerTest {
+ @Test
+ fun `simple command passes through`() {
+ assertEquals("ls -la", CommandSummarizer.summarizeShellCommand("ls -la"))
+ }
+
+ @Test
+ fun `plumbing wrapper peels to the real command`() {
+ val summarized =
+ CommandSummarizer.summarizeShellCommand(
+ """cd /tmp && sleep 70 2>&1 | tail -5; echo "x_exit=${'$'}{PIPESTATUS[0]}"""",
+ )
+
+ assertEquals("sleep 70", summarized)
+ }
+
+ @Test
+ fun `multiple real commands become first plus count`() {
+ // Faithful to the TS port: the FIRST surviving segment leads the
+ // count line — echo is not a silent head, so it leads here.
+ assertEquals("echo hi + 2 commands", CommandSummarizer.summarizeShellCommand("echo hi && sleep 2 && ls"))
+ }
+
+ @Test
+ fun `redirects are cleaned`() {
+ // Env-prefix removal happens in headWord (classification only), so
+ // the output keeps FOO=bar — mirrors the TS behavior exactly.
+ assertEquals(
+ "FOO=bar python run.py",
+ CommandSummarizer.summarizeShellCommand("FOO=bar python run.py > /tmp/out.log 2>&1"),
+ )
+ }
+
+ @Test
+ fun `single-segment commands keep their pipe tail`() {
+ // Faithful to the TS port: the single-segment path re-cleans the
+ // ORIGINAL command (redirects only, no pipe handling) — pipe-tail
+ // stripping applies in the multi-segment path below.
+ assertEquals(
+ "grep foo bar.txt | tail -20",
+ CommandSummarizer.summarizeShellCommand("grep foo bar.txt | tail -20"),
+ )
+ }
+
+ @Test
+ fun `multi-segment commands strip pipe tails`() {
+ assertEquals(
+ "grep foo bar.txt + 1 command",
+ CommandSummarizer.summarizeShellCommand("cd /tmp && grep foo bar.txt | tail -20 && echo done"),
+ )
+ }
+
+ @Test
+ fun `quote-aware compound split keeps echo arguments intact`() {
+ // The `&&` inside quotes is not a separator, so this is two segments
+ // and echo (not silent) leads the count line.
+ val summarized = CommandSummarizer.summarizeShellCommand("""echo "a && b" && git status""")
+
+ assertEquals("""echo "a && b" + 1 command""", summarized)
+ }
+
+ @Test
+ fun `empty input stays empty`() {
+ assertEquals("", CommandSummarizer.summarizeShellCommand(" "))
+ }
+}
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummaryTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummaryTest.kt
new file mode 100644
index 00000000..ed857aa4
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolResultSummaryTest.kt
@@ -0,0 +1,141 @@
+package com.m57.hermescontrol.ui.chat.tool
+
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonPrimitive
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Ported contract tests from the desktop app's
+ * `lib/tool-result-summary.test.ts` (vitest) — the engine must satisfy the
+ * same cases here.
+ */
+class ToolResultSummaryTest {
+ private fun parse(json: String) = Json.parseToJsonElement(json)
+
+ // ── formatToolResultSummary ─────────────────────────────────────────
+
+ @Test
+ fun `unwraps wrapper payloads into structured key-value lines`() {
+ val summary =
+ ToolResultSummary.formatToolResultSummary(
+ parse(
+ """{"success":true,"result":{"data":{"path":"/tmp/demo.txt","status":"ok",""" +
+ """"lines_written":12,"checksum":"abc123"}}}""",
+ ),
+ )
+
+ assertTrue(summary.contains("- Path: /tmp/demo.txt"))
+ assertTrue(summary.contains("- Status: ok"))
+ assertTrue(summary.contains("- Lines Written: 12"))
+ assertFalse(summary.contains("\"path\""))
+ }
+
+ @Test
+ fun `summarizes object arrays as readable list items`() {
+ val json =
+ """[
+ {"title":"First result","snippet":"alpha preview text"},
+ {"title":"Second result","status":"cached"},
+ {"title":"Third result","summary":"more details"},
+ {"title":"Fourth result","summary":"line 4"},
+ {"title":"Fifth result","summary":"line 5"},
+ {"title":"Sixth result","summary":"line 6"},
+ {"title":"Seventh result","summary":"line 7"}
+ ]"""
+ val summary = ToolResultSummary.formatToolResultSummary(parse(json))
+
+ assertTrue("got:\n$summary", summary.contains("- First result - alpha preview text"))
+ assertTrue(summary.contains("- Second result (cached)"))
+ assertTrue("got:\n$summary", summary.contains("- … 1 more item"))
+ }
+
+ @Test
+ fun `truncates long field values for compact display`() {
+ val summary =
+ ToolResultSummary.formatToolResultSummary(
+ parse("""{"message":"ok","details":"prefix ${"x".repeat(500)}"}"""),
+ )
+ val detailsLine = summary.split("\n").firstOrNull { it.startsWith("- Details:") }
+
+ assertTrue(detailsLine != null)
+ assertTrue(detailsLine!!.length < 230)
+ assertTrue(detailsLine.contains("…"))
+ }
+
+ @Test
+ fun `formats stringified json payloads without raw dumps`() {
+ val summary =
+ ToolResultSummary.formatToolResultSummary(
+ JsonPrimitive("""{"data":{"title":"Build report","completed":true}}"""),
+ )
+
+ assertTrue(summary.contains("- Title: Build report"))
+ assertTrue(summary.contains("- Completed: true"))
+ }
+
+ // ── extractToolErrorMessage ─────────────────────────────────────────
+
+ @Test
+ fun `finds nested error messages through wrappers`() {
+ val error =
+ ToolResultSummary.extractToolErrorMessage(
+ parse(
+ """{"success":false,"result":{"output":{"error":{"message":""" +
+ """"Permission denied writing /tmp/demo.txt"}}}}""",
+ ),
+ )
+
+ assertEquals("Permission denied writing /tmp/demo.txt", error)
+ }
+
+ @Test
+ fun `does not treat successful payload messages as errors`() {
+ val error =
+ ToolResultSummary.extractToolErrorMessage(
+ parse("""{"success":true,"message":"Completed successfully","data":{"count":3}}"""),
+ )
+
+ assertEquals("", error)
+ }
+
+ @Test
+ fun `ignores placeholder error fields in successful payloads`() {
+ val error =
+ ToolResultSummary.extractToolErrorMessage(
+ parse("""{"success":true,"data":{"error":"none","status":"ok"}}"""),
+ )
+
+ assertEquals("", error)
+ }
+
+ // ── extra edge cases beyond the desktop suite ───────────────────────
+
+ @Test
+ fun `empty array renders nothing instead of noise`() {
+ val summary = ToolResultSummary.formatToolResultSummary(parse("""{"results":[]}"""))
+
+ assertEquals("", summary)
+ }
+
+ @Test
+ fun `unwraps up to four wrapper levels`() {
+ val summary =
+ ToolResultSummary.formatToolResultSummary(
+ parse(
+ """{"data":{"result":{"output":{"response":{"title":"deep","count":2}}}}}""",
+ ),
+ )
+
+ assertTrue(summary.contains("- Title: deep"))
+ assertTrue(summary.contains("- Count: 2"))
+ }
+
+ @Test
+ fun `scalar results pass through`() {
+ assertEquals("hello", ToolResultSummary.formatToolResultSummary(JsonPrimitive("hello")))
+ assertEquals("42", ToolResultSummary.formatToolResultSummary(JsonPrimitive(42)))
+ }
+}
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt
new file mode 100644
index 00000000..6f7a7bc9
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt
@@ -0,0 +1,268 @@
+package com.m57.hermescontrol.ui.chat.tool
+
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ToolViewBuilderTest {
+ private fun parse(json: String): JsonObject? = Json.parseToJsonElement(json) as? JsonObject
+
+ private fun build(
+ tool: String,
+ args: String,
+ result: String,
+ isError: Boolean = false,
+ running: Boolean = false,
+ ) = ToolViewBuilder.build(tool, parse(args), parse(result), isError, running)
+
+ // ── status heuristics ────────────────────────────────────────────────
+
+ @Test
+ fun `terminal success with exit zero is success`() {
+ val view = build("terminal", """{"command":"ls -la"}""", """{"output":"wiring.tsx","exit_code":0}""")
+
+ assertEquals(ToolViewStatus.SUCCESS, view.status)
+ assertNull(view.error)
+ assertEquals("wiring.tsx", view.stdout)
+ assertEquals(0, view.exitCode)
+ }
+
+ @Test
+ fun `terminal non-zero exit without output is an error`() {
+ val view = build("terminal", """{"command":"exit 1"}""", """{"exit_code":1}""")
+
+ assertEquals(ToolViewStatus.ERROR, view.status)
+ assertEquals("Command failed with exit code 1.", view.error)
+ }
+
+ @Test
+ fun `terminal non-zero exit with output is not an error`() {
+ val view = build("terminal", """{"command":"grep foo bar"}""", """{"exit_code":1,"output":"nothing"}""")
+
+ assertEquals(ToolViewStatus.SUCCESS, view.status)
+ assertNull(view.error)
+ assertEquals("nothing", view.stdout)
+ }
+
+ @Test
+ fun `explicit success wins over stale isError envelope`() {
+ val view = build("memory", """{"action":"add","target":"user"}""", """{"success":true}""", isError = true)
+
+ assertEquals(ToolViewStatus.SUCCESS, view.status)
+ }
+
+ @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)
+ }
+
+ @Test
+ fun `running tool gets running status and pending title`() {
+ val view = build("terminal", """{"command":"sleep 10"}""", """{}""", running = true)
+
+ assertEquals(ToolViewStatus.RUNNING, view.status)
+ assertTrue(view.title.startsWith("Running"))
+ }
+
+ // ── titles / subtitles ───────────────────────────────────────────────
+
+ @Test
+ fun `read_file title names the target with line range`() {
+ 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)
+ }
+
+ @Test
+ fun `terminal title uses summarized command`() {
+ val view =
+ build(
+ "terminal",
+ """{"command":"cd /tmp && sleep 70 2>&1 | tail -5; echo \"x_exit=${'$'}{PIPESTATUS[0]}\""}""",
+ """{"output":"done"}""",
+ )
+
+ assertEquals(ToolViewStatus.SUCCESS, view.status)
+ assertTrue(view.title, view.title.startsWith("Ran "))
+ assertTrue(view.title, view.title.contains("sleep 70"))
+ assertFalse(view.title.contains("tail"))
+ }
+
+ @Test
+ fun `web_search parses hits and count`() {
+ 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"},
+ {"title":"Cat Videos","url":"https://cats.example/3","description":"videos"}
+ ]}}""",
+ )
+
+ assertEquals("Searched \"cats\"", view.title)
+ assertEquals("3 results", view.countLabel)
+ assertEquals(3, view.searchHits?.size)
+ assertEquals("Cat Facts", view.searchHits?.first()?.title)
+ assertEquals("cats", view.searchQuery)
+ assertEquals("Search results", view.detailLabel)
+ }
+
+ @Test
+ fun `patch extracts inline diff with stats`() {
+ val view =
+ build(
+ "patch",
+ """{"path":"/repo/src/wiring.tsx"}""",
+ """{"inline_diff":"--- a/wiring.tsx\n+++ b/wiring.tsx\n@@ -1,1 +1,1 @@\n-old\n+new"}""",
+ )
+
+ assertNotNull(view.inlineDiff)
+ assertEquals("/repo/src/wiring.tsx", view.diffPath)
+ assertEquals(1, view.diffStats?.added)
+ assertEquals(1, view.diffStats?.removed)
+ assertEquals("wiring.tsx", view.title)
+ }
+
+ @Test
+ fun `browser_snapshot subtitle summarizes accessibility stats`() {
+ val view =
+ build(
+ "browser_snapshot",
+ """{}""",
+ """{"snapshot":"button \"Save\"\nbutton \"Cancel\"\nlink \"Docs\"\ntextbox \"Name\""}""",
+ )
+
+ assertTrue(view.subtitle, view.subtitle.contains("2 buttons"))
+ assertTrue(view.subtitle.contains("1 links"))
+ assertTrue(view.subtitle.contains("Top controls: Save, Cancel"))
+ }
+
+ // ── mobile-specific tools ────────────────────────────────────────────
+
+ @Test
+ fun `fact_store added fact gets clean summary and detail`() {
+ val view =
+ build(
+ "fact_store",
+ """{"action":"add"}""",
+ """{"status":"added","fact_id":42}""",
+ )
+
+ assertEquals("Fact added (ID: 42)", view.subtitle)
+ }
+
+ @Test
+ fun `fact_store list renders facts with trust scores`() {
+ val view =
+ 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)", view.subtitle)
+ assertTrue(view.detail, view.detail.contains("#7 user likes tea"))
+ assertTrue(view.detail.contains("trust: 0.80"))
+ }
+
+ @Test
+ fun `todo gets count subtitle and markers`() {
+ val view =
+ build(
+ "todo",
+ """{"action":"create"}""",
+ """{"summary":{"total":2,"pending":1,"completed":1},"todos":[
+ {"id":"1","content":"one","status":"pending"},
+ {"id":"2","content":"two","status":"completed"}
+ ]}""",
+ )
+
+ assertEquals("2 items (1 pending, 1 completed)", view.subtitle)
+ assertTrue(view.detail, view.detail.contains("[ ] 1. one"))
+ assertTrue(view.detail.contains("[x] 2. two"))
+ }
+
+ @Test
+ fun `cronjob list subtitle counts jobs`() {
+ val view =
+ build(
+ "cronjob",
+ """{"action":"list"}""",
+ """{"jobs":[{"name":"daily","schedule":"0 9 * * *"},{"name":"hourly","schedule":"0 * * * *"}]}""",
+ )
+
+ assertEquals("2 cron jobs", view.subtitle)
+ assertTrue(view.detail, view.detail.contains("- daily · 0 9 * * *"))
+ }
+
+ @Test
+ fun `session_search discover mode`() {
+ val view =
+ build(
+ "session_search",
+ """{"query":"auth"}""",
+ """{"mode":"discover","count":2,"results":[{"title":"Auth refactor",""" +
+ """"when":"2026-07-01","snippet":"..."}]}""",
+ )
+
+ assertTrue(view.subtitle, view.subtitle.startsWith("2 sessions"))
+ assertTrue(view.detail, view.detail.contains("Auth refactor"))
+ }
+
+ @Test
+ fun `read_terminal custom detail`() {
+ 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)
+ assertTrue(view.detail, view.detail.contains("━━━ Terminal ━━━"))
+ assertTrue(view.detail.contains("line one"))
+ }
+
+ // ── unknown tools fall back to the generic engine ────────────────────
+
+ @Test
+ fun `unknown tool gets a heuristic summary not raw json`() {
+ val view =
+ build(
+ "weird_tool",
+ """{}""",
+ """{"result":{"data":{"title":"Build report","completed":true}}}""",
+ )
+
+ assertEquals("Weird Tool", view.title)
+ assertTrue(view.detail, view.detail.contains("Title: Build report"))
+ assertTrue(view.detail.contains("Completed: true"))
+ assertFalse(view.detail.contains("\"data\""))
+ }
+
+ @Test
+ fun `unknown tool error surfaces through the generic engine`() {
+ val view =
+ build(
+ "weird_tool",
+ """{}""",
+ """{"success":false,"error":{"message":"Permission denied"}}""",
+ )
+
+ assertEquals(ToolViewStatus.ERROR, view.status)
+ assertTrue(view.error?.contains("Permission denied") == true)
+ }
+}