feat(chat): upgrade tool rendering, add missing renderers, and clamp output budget (#1086)

This commit is contained in:
M57 authored and GitHub committed 2026-09-12 01:14:14 +03:00
1 parent 1cdc0dbfc0
commit fad2dd3b86
11 files changed
+640 -9

No files matched your search

@@ -16,12 +16,15 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.CheckCircle
@@ -45,7 +48,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
@@ -58,6 +63,7 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.LocalHermesStatusColors
@@ -84,6 +90,7 @@ internal fun composeToolSummaryLines(
): Pair<String, String?>? {
val titleIsGeneric = view.title.equals(ToolViewBuilder.genericTitleFor(toolName), ignoreCase = true)
val subtitle = view.subtitle.takeIf { it.isNotBlank() && it != view.title }
val statsSuffix = view.diffStats?.let { " (+${it.added}/-${it.removed})" } ?: ""
val firstLine: String
val secondLine: String?
@@ -93,6 +100,7 @@ internal fun composeToolSummaryLines(
if (subtitle != null) {
append(subtitle)
}
append(statsSuffix)
view.countLabel?.let { append(" ($it)") }
}
secondLine = null
@@ -100,6 +108,7 @@ internal fun composeToolSummaryLines(
firstLine =
buildString {
append(view.title)
append(statsSuffix)
view.countLabel?.let { append(" ($it)") }
}
secondLine = subtitle
@@ -233,6 +242,11 @@ internal fun ToolBubble(
SelectionContainer {
Text(
text = message.content,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.8f),
@@ -301,6 +315,11 @@ internal fun ToolBubble(
SelectionContainer {
Text(
text = message.content,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.8f),
@@ -383,6 +402,11 @@ private fun ExpandedToolContent(
view.stdout?.let {
Text(
text = it,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.9f),
@@ -394,6 +418,11 @@ private fun ExpandedToolContent(
view.stderr?.let {
Text(
text = it,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.6f),
@@ -408,13 +437,27 @@ private fun ExpandedToolContent(
text = stringResource(R.string.chat_tool_exit_code, code),
style =
MaterialTheme.typography.labelSmall.copy(
color = statusColors.error,
color = statusColors.warning,
fontWeight = FontWeight.Medium,
),
)
}
}
} else {
// ── Generated/resolved image preview ──
view.imageUrl?.let { url ->
AsyncImage(
model = url,
contentDescription = view.title,
contentScale = ContentScale.Crop,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 200.dp)
.clip(RoundedCornerShape(8.dp)),
)
}
// ── File diff ──
if (view.inlineDiff != null) {
DiffViewCard(
@@ -479,6 +522,11 @@ private fun ExpandedToolContent(
if (view.detail.isNotBlank() && view.inlineDiff == null) {
Text(
text = view.detail,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.9f),
@@ -131,6 +131,12 @@ object ToolSchemaRegistry {
summaryArgKey = null,
icon = Icons.Filled.Checklist,
),
"todo_list" to
ToolDisplayConfig(
name = "todo_list",
summaryArgKey = null,
icon = Icons.Filled.Checklist,
),
"fact_store" to
ToolDisplayConfig(
name = "fact_store",
@@ -150,6 +156,12 @@ object ToolSchemaRegistry {
summaryArgKey = "action",
icon = Icons.Filled.Schedule,
),
"cronjob_manage" to
ToolDisplayConfig(
name = "cronjob_manage",
summaryArgKey = "action",
icon = Icons.Filled.Schedule,
),
"memory" to
ToolDisplayConfig(
name = "memory",
@@ -168,6 +180,12 @@ object ToolSchemaRegistry {
summaryArgKey = "action",
icon = Icons.Filled.Settings,
),
"process_manage" to
ToolDisplayConfig(
name = "process_manage",
summaryArgKey = "action",
icon = Icons.Filled.Settings,
),
"skill_manage" to
ToolDisplayConfig(
name = "skill_manage",
@@ -187,6 +205,42 @@ object ToolSchemaRegistry {
summaryArgKey = null,
icon = Icons.Filled.Keyboard,
),
"browser_scroll" to
ToolDisplayConfig(
name = "browser_scroll",
summaryArgKey = "direction",
icon = Icons.Filled.TouchApp,
),
"browser_back" to
ToolDisplayConfig(
name = "browser_back",
summaryArgKey = null,
icon = Icons.Filled.Language,
),
"browser_press" to
ToolDisplayConfig(
name = "browser_press",
summaryArgKey = "key",
icon = Icons.Filled.Keyboard,
),
"browser_get_images" to
ToolDisplayConfig(
name = "browser_get_images",
summaryArgKey = null,
icon = Icons.Filled.Image,
),
"browser_vision" to
ToolDisplayConfig(
name = "browser_vision",
summaryArgKey = "question",
icon = Icons.Filled.Visibility,
),
"browser_console" to
ToolDisplayConfig(
name = "browser_console",
summaryArgKey = null,
icon = Icons.Filled.Terminal,
),
"browser_cdp" to
ToolDisplayConfig(
name = "browser_cdp",
@@ -16,6 +16,18 @@ import kotlinx.serialization.json.JsonPrimitive
internal object ToolJson {
private val URL_PATTERN = Regex("https?://[^\\s'\"<>)\\]]+", RegexOption.IGNORE_CASE)
const val MAX_TOOL_RENDER_CHARS = 20_000
fun clampForDisplay(
value: String,
max: Int = MAX_TOOL_RENDER_CHARS,
): String {
if (value.length <= max) return value
val omitted = value.length - max
return value.take(max) +
"\n… $omitted more characters truncated — use Copy for the full output."
}
/** First non-blank string value among [keys], trimmed. */
fun firstString(
record: JsonObject?,
@@ -294,7 +306,11 @@ internal object ToolJson {
}
fun durationLabel(result: JsonObject?): String? {
val seconds = numberValue(result?.get("duration_s")) ?: return null
val seconds =
numberValue(result?.get("duration_s"))
?: numberValue(result?.get("duration_seconds"))
?: numberValue(result?.get("total_duration_seconds"))
?: return null
return formatDurationSeconds(seconds)
}
@@ -1,11 +1,16 @@
package com.m57.hermescontrol.ui.chat.tool
import com.m57.hermescontrol.ui.chat.tool.render.BrowserActionRenderer
import com.m57.hermescontrol.ui.chat.tool.render.BrowserClickRenderer
import com.m57.hermescontrol.ui.chat.tool.render.BrowserConsoleRenderer
import com.m57.hermescontrol.ui.chat.tool.render.BrowserImagesRenderer
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.BrowserVisionRenderer
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.DelegateTaskRenderer
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
@@ -15,11 +20,13 @@ 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.SearchFilesRenderer
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.TextToSpeechRenderer
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
@@ -44,11 +51,14 @@ internal object ToolRendererRegistry {
"terminal" to TerminalRenderer,
"execute_code" to TerminalRenderer,
"read_terminal" to ReadTerminalRenderer,
// Delegation
"delegate_task" to DelegateTaskRenderer,
// Files
"read_file" to ReadFileRenderer,
"edit_file" to FileEditRenderer,
"patch" to FileEditRenderer,
"write_file" to FileEditRenderer,
"search_files" to SearchFilesRenderer,
// Web
"web_search" to WebSearchRenderer,
"web_extract" to WebExtractRenderer,
@@ -59,14 +69,23 @@ internal object ToolRendererRegistry {
"browser_click" to BrowserClickRenderer,
"browser_fill" to BrowserTypeRenderer,
"browser_type" to BrowserTypeRenderer,
"browser_scroll" to BrowserActionRenderer,
"browser_back" to BrowserActionRenderer,
"browser_press" to BrowserActionRenderer,
"browser_get_images" to BrowserImagesRenderer,
"browser_vision" to BrowserVisionRenderer,
"browser_console" to BrowserConsoleRenderer,
// Memory / facts
"memory" to MemoryRenderer,
"fact_store" to FactStoreRenderer,
// Task management
"cronjob" to CronjobRenderer,
"cronjob_manage" to CronjobRenderer,
"todo" to TodoRenderer,
"todo_list" to TodoRenderer,
"session_search" to SessionSearchRenderer,
"process" to ProcessRenderer,
"process_manage" to ProcessRenderer,
// Skills / tool discovery
"skills_list" to SkillsListRenderer,
"skill_view" to SkillViewRenderer,
@@ -75,6 +94,7 @@ internal object ToolRendererRegistry {
// Media
"image_generate" to ImageGenerateRenderer,
"vision_analyze" to VisionAnalyzeRenderer,
"text_to_speech" to TextToSpeechRenderer,
// Projects
"project_list" to ProjectListRenderer,
"project_create" to ProjectMutateRenderer,
@@ -89,19 +89,24 @@ object ToolViewBuilder {
ToolCounts.countLabelFor(call)
}
val clampedDetail = ToolJson.clampForDisplay(detail)
val clampedStdout = extras.stdout?.let { ToolJson.clampForDisplay(it) }
val clampedStderr = extras.stderr?.let { ToolJson.clampForDisplay(it) }
val clampedDiff = extras.inlineDiff?.ifEmpty { null }?.let { ToolJson.clampForDisplay(it) }
return ToolView(
status = status,
title = title,
subtitle = subtitle,
detail = detail,
detail = clampedDetail,
detailLabel = extras.detailLabel,
countLabel = countLabel?.let { (count, noun) -> "$count ${ToolCounts.pluralizeNoun(noun, count)}" },
durationLabel = ToolJson.durationLabel(call.result),
stdout = extras.stdout,
stderr = extras.stderr,
stdout = clampedStdout,
stderr = clampedStderr,
exitCode = extras.exitCode,
terminalCommand = extras.terminalCommand,
inlineDiff = extras.inlineDiff?.ifEmpty { null },
inlineDiff = clampedDiff,
diffPath = extras.diffPath,
diffStats = extras.diffStats,
imageUrl = imageUrlFor(call.args, call.result),
@@ -3,6 +3,7 @@ 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
/** `browser_navigate`: hostname titles, including the failed-to-open form. */
internal object BrowserNavigateRenderer : ToolRenderer {
@@ -104,3 +105,98 @@ internal object BrowserTypeRenderer : ToolRenderer {
.ifEmpty { "Filled page input" }
}
}
/** browser_scroll / browser_back / browser_press: one-line acks. */
internal object BrowserActionRenderer : ToolRenderer {
override fun doneTitle(call: ToolCall): String? =
when (call.name) {
"browser_scroll" -> {
"Scrolled ${ToolJson.firstString(call.result, listOf("scrolled"))
.ifEmpty { ToolJson.firstString(call.args, listOf("direction")) }.ifEmpty { "page" }}"
}
"browser_back" -> {
val url = ToolJson.firstString(call.result, listOf("url"))
if (url.isNotEmpty()) "Went back to ${ToolJson.hostnameOf(url)}" else "Went back"
}
"browser_press" -> {
"Pressed ${ToolJson.firstString(call.result, listOf("pressed"))
.ifEmpty { ToolJson.firstString(call.args, listOf("key")) }}"
}
else -> {
null
}
}
override fun subtitle(call: ToolCall): String = ""
override fun detail(call: ToolCall): String = ""
}
/** browser_get_images: image inventory of the current page. */
internal object BrowserImagesRenderer : ToolRenderer {
override fun doneTitle(call: ToolCall): String {
val n = ToolJson.intValue(call.result?.get("count")) ?: 0
return "Found $n image${if (n == 1) "" else "s"}"
}
override fun detail(call: ToolCall): String =
(call.result?.get("images") as? JsonArray)
?.take(12)
?.mapNotNull { el ->
val img = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
val src = ToolJson.firstString(img, listOf("src"))
val alt = ToolJson.firstString(img, listOf("alt"))
listOf(alt, src)
.filter { it.isNotEmpty() }
.joinToString(" — ")
.takeIf { it.isNotEmpty() }
?.let { "- $it" }
}?.joinToString("\n") ?: ""
}
/** browser_vision: screenshot Q&A — the analysis text is the payload. */
internal object BrowserVisionRenderer : ToolRenderer {
override fun pendingTitle(call: ToolCall): String = "Looking at page"
override fun doneTitle(call: ToolCall): String = "Analyzed page"
override fun subtitle(call: ToolCall): String =
ToolJson.compactPreview(ToolJson.firstString(call.args, listOf("question")), 120)
override fun detail(call: ToolCall): String =
ToolJson
.firstString(call.result, listOf("analysis"))
.ifEmpty { ToolJson.firstString(call.result, listOf("note")) }
}
/** browser_console: message/error counts, or a JS eval result. */
internal object BrowserConsoleRenderer : ToolRenderer {
override fun doneTitle(call: ToolCall): String? {
val result = call.result ?: return null
if (result.containsKey("result")) return "Evaluated JS"
val msgs = ToolJson.intValue(result["total_messages"]) ?: 0
val errs = ToolJson.intValue(result["total_errors"]) ?: 0
return "Console: $msgs message${if (msgs == 1) "" else "s"}" +
if (errs > 0) ", $errs error${if (errs == 1) "" else "s"}" else ""
}
override fun detail(call: ToolCall): String {
val result = call.result ?: return ""
result["result"]?.let { return it.toString() }
val lines =
(result["console_messages"] as? JsonArray)?.take(30)?.mapNotNull { el ->
val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
"[${ToolJson.firstString(m, listOf("type")).ifEmpty { "log" }}] " +
ToolJson.compactPreview(ToolJson.firstString(m, listOf("text")), 200)
} ?: emptyList()
val errors =
(result["js_errors"] as? JsonArray)?.take(10)?.mapNotNull { el ->
val e = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
"[error] ${ToolJson.compactPreview(ToolJson.firstString(e, listOf("message")), 200)}"
} ?: emptyList()
return (errors + lines).joinToString("\n")
}
}
@@ -0,0 +1,139 @@
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
/**
* `delegate_task`: fan-out of subagent tasks.
* Covers joined results, background dispatch handles, and list/steer/stop acks.
*/
internal object DelegateTaskRenderer : ToolRenderer {
private fun goals(call: ToolCall): List<String> {
val tasks = call.args?.get("tasks") as? JsonArray
val fromTasks =
tasks?.mapIndexedNotNull { i, el ->
ToolJson
.parseMaybeObject(el)
?.let { ToolJson.firstString(it, listOf("goal")) }
?.ifEmpty { "Task ${i + 1}" }
} ?: emptyList()
if (fromTasks.isNotEmpty()) return fromTasks
return ToolJson
.firstString(call.args, listOf("goal"))
.takeIf { it.isNotEmpty() }
?.let { listOf(it) } ?: emptyList()
}
private fun action(call: ToolCall) = ToolJson.firstString(call.args, listOf("action")).ifEmpty { "spawn" }
override fun pendingTitle(call: ToolCall): String =
when (action(call)) {
"list" -> {
"Listing subagents"
}
"steer" -> {
"Steering subagent"
}
"stop" -> {
"Stopping subagent"
}
else -> {
val n = goals(call).size.coerceAtLeast(1)
"Delegating $n task${if (n == 1) "" else "s"}"
}
}
override fun doneTitle(call: ToolCall): String {
val result = call.result
return when {
action(call) == "list" -> {
val n = ToolJson.intValue(result?.get("count")) ?: 0
"$n subagent${if (n == 1) "" else "s"}"
}
action(call) == "steer" -> {
"Steer queued"
}
action(call) == "stop" -> {
"Stop requested"
}
ToolJson.firstString(result, listOf("status")) == "dispatched" -> {
val n = ToolJson.intValue(result?.get("count")) ?: goals(call).size
"Dispatched $n background task${if (n == 1) "" else "s"}"
}
else -> {
val results = result?.get("results") as? JsonArray
val n = results?.size ?: goals(call).size.coerceAtLeast(1)
val failed =
results?.count {
ToolJson.firstString(ToolJson.parseMaybeObject(it), listOf("status")) == "failed"
} ?: 0
if (failed > 0) {
"Delegated $n tasks · $failed failed"
} else {
"Delegated $n task${if (n == 1) "" else "s"}"
}
}
}
}
override fun subtitle(call: ToolCall): String = ToolJson.compactPreview(goals(call).firstOrNull() ?: "", 120)
override fun detail(call: ToolCall): String {
val result = call.result
// list action: one row per live subagent
(result?.get("subagents") as? JsonArray)?.let { subs ->
return subs
.mapNotNull { el ->
val s = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
val goal = ToolJson.compactPreview(ToolJson.firstString(s, listOf("goal")), 80)
val status = ToolJson.firstString(s, listOf("status"))
val secs = ToolJson.numberValue(s["running_seconds"])
val time = secs?.let { " · ${ToolJson.formatDurationSeconds(it)}" } ?: ""
"- [$status] $goal$time"
}.joinToString("\n")
}
// joined spawn: per-task result rows
val rows =
(result?.get("results") as? JsonArray)?.mapNotNull { el ->
val r = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
val idx = ToolJson.intValue(r["task_index"]) ?: 0
val ok = ToolJson.firstString(r, listOf("status")) != "failed"
val marker = if (ok) "[x]" else "[!]"
val goal = goals(call).getOrNull(idx) ?: "Task ${idx + 1}"
val model = ToolJson.firstString(r, listOf("model"))
val dur =
ToolJson
.numberValue(r["duration_seconds"])
?.let { ToolJson.formatDurationSeconds(it) } ?: ""
val meta = listOf(model, dur).filter { it.isNotEmpty() }.joinToString(" · ")
val summary =
ToolJson.compactPreview(
ToolJson.firstString(r, listOf("summary", "error")),
160,
)
buildString {
append("$marker ${ToolJson.compactPreview(goal, 80)}")
if (meta.isNotEmpty()) append(" ($meta)")
if (summary.isNotEmpty()) append("\n $summary")
}
} ?: emptyList()
if (rows.isNotEmpty()) return rows.joinToString("\n")
// dispatched handle / steer / stop acks
val note = ToolJson.firstString(result, listOf("note"))
val goalsArr =
(result?.get("goals") as? JsonArray)
?.mapNotNull { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }
?.joinToString("\n") { "- ${ToolJson.compactPreview(it, 100)}" } ?: ""
return listOf(goalsArr, note).filter { it.isNotEmpty() }.joinToString("\n\n")
}
}
@@ -55,3 +55,32 @@ internal object VisionAnalyzeRenderer : ToolRenderer {
}
}
}
/** `text_to_speech`: audio synthesis. Success payload carries file_path(s), provider, chunk_count. */
internal object TextToSpeechRenderer : ToolRenderer {
override fun pendingTitle(call: ToolCall): String = "Generating speech"
override fun doneTitle(call: ToolCall): String = "Generated speech"
override fun subtitle(call: ToolCall): String {
val provider = ToolJson.firstString(call.result, listOf("provider"))
val chunks = ToolJson.intValue(call.result?.get("chunk_count"))
val parts =
listOfNotNull(
provider.takeIf { it.isNotEmpty() },
chunks?.takeIf { it > 1 }?.let { "$it chunks" },
)
return parts
.joinToString(" · ")
.ifEmpty { ToolJson.compactPreview(ToolJson.firstString(call.args, listOf("text")), 120) }
}
override fun detail(call: ToolCall): String {
val text = ToolJson.compactPreview(ToolJson.firstString(call.args, listOf("text")), 400)
val path = ToolJson.firstString(call.result, listOf("file_path", "output_path"))
return listOfNotNull(
text.takeIf { it.isNotEmpty() }?.let { "“$it”" },
path.takeIf { it.isNotEmpty() }?.let { "Saved to $it" },
).joinToString("\n\n")
}
}
@@ -0,0 +1,69 @@
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 com.m57.hermescontrol.ui.chat.tool.ToolViewExtras
import com.m57.hermescontrol.ui.chat.tool.ToolViewStatus
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
/**
* `search_files`: ripgrep-backed content/file search.
* Handles both result shapes: dense path-grouped `matches_text` string (≥5 hits)
* and `matches:[{path,line,content}]` (<5 hits), plus `files:[path]`.
*/
internal object SearchFilesRenderer : ToolRenderer {
private fun pattern(call: ToolCall) = ToolJson.firstString(call.args, listOf("pattern"))
override fun pendingTitle(call: ToolCall): String? =
pattern(call).takeIf { it.isNotEmpty() }?.let { "Searching “${ToolJson.compactPreview(it, 48)}”" }
override fun doneTitle(call: ToolCall): String? {
val p = pattern(call).takeIf { it.isNotEmpty() } ?: return null
val total = ToolJson.intValue(call.result?.get("total_count"))
val suffix = total?.let { " · $it match${if (it == 1) "" else "es"}" } ?: ""
return "Searched “${ToolJson.compactPreview(p, 48)}”$suffix"
}
override fun subtitle(call: ToolCall): String {
val path = ToolJson.firstString(call.args, listOf("path")).takeIf { it != "." && it.isNotEmpty() } ?: ""
val glob = ToolJson.firstString(call.args, listOf("file_glob"))
return listOf(path, glob).filter { it.isNotEmpty() }.joinToString(" · ")
}
override fun detail(call: ToolCall): String {
val result = call.result ?: return ""
// Dense ≥5-match block: already ripgrep-grouped, use verbatim.
val dense = ToolJson.firstString(result, listOf("matches_text"))
val body =
if (dense.isNotEmpty()) {
dense
} else {
(result["matches"] as? JsonArray)
?.mapNotNull { el ->
val m = ToolJson.parseMaybeObject(el) ?: return@mapNotNull null
val path = ToolJson.firstString(m, listOf("path"))
val line = ToolJson.intValue(m["line"])
val content = ToolJson.firstString(m, listOf("content"))
"$path:${line ?: "?"}: ${content.trim()}"
}?.joinToString("\n")
?: (result["files"] as? JsonArray)
?.mapNotNull {
(it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content
}?.joinToString("\n") ?: ""
}
val hint =
if (ToolJson.boolTrue(result["truncated"])) {
ToolJson.firstString(result, listOf("_hint")).ifEmpty { "Results truncated" }
} else {
""
}
return listOf(body, hint).filter { it.isNotEmpty() }.joinToString("\n\n")
}
override fun extras(
call: ToolCall,
status: ToolViewStatus,
): ToolViewExtras = ToolViewExtras(detailLabel = "Matches")
}