refactor(tool): split tool display engine

This commit is contained in:
Hy4ri committed 2026-09-10 22:23:41 +03:00
1 parent 065dd87d07
commit 91566dfdd7
17 files changed
+2301 -2052

No files matched your search

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