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) + } +}