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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
@@ -45,7 +48,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.HermesStatusColors import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
@@ -84,6 +90,7 @@ internal fun composeToolSummaryLines(
): Pair<String, String?>? { ): Pair<String, String?>? {
val titleIsGeneric = view.title.equals(ToolViewBuilder.genericTitleFor(toolName), ignoreCase = true) val titleIsGeneric = view.title.equals(ToolViewBuilder.genericTitleFor(toolName), ignoreCase = true)
val subtitle = view.subtitle.takeIf { it.isNotBlank() && it != view.title } val subtitle = view.subtitle.takeIf { it.isNotBlank() && it != view.title }
val statsSuffix = view.diffStats?.let { " (+${it.added}/-${it.removed})" } ?: ""
val firstLine: String val firstLine: String
val secondLine: String? val secondLine: String?
@@ -93,6 +100,7 @@ internal fun composeToolSummaryLines(
if (subtitle != null) { if (subtitle != null) {
append(subtitle) append(subtitle)
} }
append(statsSuffix)
view.countLabel?.let { append(" ($it)") } view.countLabel?.let { append(" ($it)") }
} }
secondLine = null secondLine = null
@@ -100,6 +108,7 @@ internal fun composeToolSummaryLines(
firstLine = firstLine =
buildString { buildString {
append(view.title) append(view.title)
append(statsSuffix)
view.countLabel?.let { append(" ($it)") } view.countLabel?.let { append(" ($it)") }
} }
secondLine = subtitle secondLine = subtitle
@@ -233,6 +242,11 @@ internal fun ToolBubble(
SelectionContainer { SelectionContainer {
Text( Text(
text = message.content, text = message.content,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
style = style =
MaterialTheme.typography.bodySmall.copy( MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.8f), color = contentColor.copy(alpha = 0.8f),
@@ -301,6 +315,11 @@ internal fun ToolBubble(
SelectionContainer { SelectionContainer {
Text( Text(
text = message.content, text = message.content,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
style = style =
MaterialTheme.typography.bodySmall.copy( MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.8f), color = contentColor.copy(alpha = 0.8f),
@@ -383,6 +402,11 @@ private fun ExpandedToolContent(
view.stdout?.let { view.stdout?.let {
Text( Text(
text = it, text = it,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
style = style =
MaterialTheme.typography.bodySmall.copy( MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.9f), color = contentColor.copy(alpha = 0.9f),
@@ -394,6 +418,11 @@ private fun ExpandedToolContent(
view.stderr?.let { view.stderr?.let {
Text( Text(
text = it, text = it,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
style = style =
MaterialTheme.typography.bodySmall.copy( MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.6f), color = contentColor.copy(alpha = 0.6f),
@@ -408,13 +437,27 @@ private fun ExpandedToolContent(
text = stringResource(R.string.chat_tool_exit_code, code), text = stringResource(R.string.chat_tool_exit_code, code),
style = style =
MaterialTheme.typography.labelSmall.copy( MaterialTheme.typography.labelSmall.copy(
color = statusColors.error, color = statusColors.warning,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
), ),
) )
} }
} }
} else { } 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 ── // ── File diff ──
if (view.inlineDiff != null) { if (view.inlineDiff != null) {
DiffViewCard( DiffViewCard(
@@ -479,6 +522,11 @@ private fun ExpandedToolContent(
if (view.detail.isNotBlank() && view.inlineDiff == null) { if (view.detail.isNotBlank() && view.inlineDiff == null) {
Text( Text(
text = view.detail, text = view.detail,
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
style = style =
MaterialTheme.typography.bodySmall.copy( MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.9f), color = contentColor.copy(alpha = 0.9f),
@@ -131,6 +131,12 @@ object ToolSchemaRegistry {
summaryArgKey = null, summaryArgKey = null,
icon = Icons.Filled.Checklist, icon = Icons.Filled.Checklist,
), ),
"todo_list" to
ToolDisplayConfig(
name = "todo_list",
summaryArgKey = null,
icon = Icons.Filled.Checklist,
),
"fact_store" to "fact_store" to
ToolDisplayConfig( ToolDisplayConfig(
name = "fact_store", name = "fact_store",
@@ -150,6 +156,12 @@ object ToolSchemaRegistry {
summaryArgKey = "action", summaryArgKey = "action",
icon = Icons.Filled.Schedule, icon = Icons.Filled.Schedule,
), ),
"cronjob_manage" to
ToolDisplayConfig(
name = "cronjob_manage",
summaryArgKey = "action",
icon = Icons.Filled.Schedule,
),
"memory" to "memory" to
ToolDisplayConfig( ToolDisplayConfig(
name = "memory", name = "memory",
@@ -168,6 +180,12 @@ object ToolSchemaRegistry {
summaryArgKey = "action", summaryArgKey = "action",
icon = Icons.Filled.Settings, icon = Icons.Filled.Settings,
), ),
"process_manage" to
ToolDisplayConfig(
name = "process_manage",
summaryArgKey = "action",
icon = Icons.Filled.Settings,
),
"skill_manage" to "skill_manage" to
ToolDisplayConfig( ToolDisplayConfig(
name = "skill_manage", name = "skill_manage",
@@ -187,6 +205,42 @@ object ToolSchemaRegistry {
summaryArgKey = null, summaryArgKey = null,
icon = Icons.Filled.Keyboard, 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 "browser_cdp" to
ToolDisplayConfig( ToolDisplayConfig(
name = "browser_cdp", name = "browser_cdp",
@@ -16,6 +16,18 @@ import kotlinx.serialization.json.JsonPrimitive
internal object ToolJson { internal object ToolJson {
private val URL_PATTERN = Regex("https?://[^\\s'\"<>)\\]]+", RegexOption.IGNORE_CASE) 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. */ /** First non-blank string value among [keys], trimmed. */
fun firstString( fun firstString(
record: JsonObject?, record: JsonObject?,
@@ -294,7 +306,11 @@ internal object ToolJson {
} }
fun durationLabel(result: JsonObject?): String? { 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) return formatDurationSeconds(seconds)
} }
@@ -1,11 +1,16 @@
package com.m57.hermescontrol.ui.chat.tool 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.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.BrowserNavigateRenderer
import com.m57.hermescontrol.ui.chat.tool.render.BrowserSnapshotRenderer 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.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.ComputerUseRenderer
import com.m57.hermescontrol.ui.chat.tool.render.CronjobRenderer 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.FactStoreRenderer
import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer 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.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.ProjectMutateRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ReadFileRenderer 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.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.SessionSearchRenderer
import com.m57.hermescontrol.ui.chat.tool.render.SkillManageRenderer 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.SkillViewRenderer
import com.m57.hermescontrol.ui.chat.tool.render.SkillsListRenderer 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.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.TodoRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ToolSearchRenderer 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.VisionAnalyzeRenderer
@@ -44,11 +51,14 @@ internal object ToolRendererRegistry {
"terminal" to TerminalRenderer, "terminal" to TerminalRenderer,
"execute_code" to TerminalRenderer, "execute_code" to TerminalRenderer,
"read_terminal" to ReadTerminalRenderer, "read_terminal" to ReadTerminalRenderer,
// Delegation
"delegate_task" to DelegateTaskRenderer,
// Files // Files
"read_file" to ReadFileRenderer, "read_file" to ReadFileRenderer,
"edit_file" to FileEditRenderer, "edit_file" to FileEditRenderer,
"patch" to FileEditRenderer, "patch" to FileEditRenderer,
"write_file" to FileEditRenderer, "write_file" to FileEditRenderer,
"search_files" to SearchFilesRenderer,
// Web // Web
"web_search" to WebSearchRenderer, "web_search" to WebSearchRenderer,
"web_extract" to WebExtractRenderer, "web_extract" to WebExtractRenderer,
@@ -59,14 +69,23 @@ internal object ToolRendererRegistry {
"browser_click" to BrowserClickRenderer, "browser_click" to BrowserClickRenderer,
"browser_fill" to BrowserTypeRenderer, "browser_fill" to BrowserTypeRenderer,
"browser_type" 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 / facts
"memory" to MemoryRenderer, "memory" to MemoryRenderer,
"fact_store" to FactStoreRenderer, "fact_store" to FactStoreRenderer,
// Task management // Task management
"cronjob" to CronjobRenderer, "cronjob" to CronjobRenderer,
"cronjob_manage" to CronjobRenderer,
"todo" to TodoRenderer, "todo" to TodoRenderer,
"todo_list" to TodoRenderer,
"session_search" to SessionSearchRenderer, "session_search" to SessionSearchRenderer,
"process" to ProcessRenderer, "process" to ProcessRenderer,
"process_manage" to ProcessRenderer,
// Skills / tool discovery // Skills / tool discovery
"skills_list" to SkillsListRenderer, "skills_list" to SkillsListRenderer,
"skill_view" to SkillViewRenderer, "skill_view" to SkillViewRenderer,
@@ -75,6 +94,7 @@ internal object ToolRendererRegistry {
// Media // Media
"image_generate" to ImageGenerateRenderer, "image_generate" to ImageGenerateRenderer,
"vision_analyze" to VisionAnalyzeRenderer, "vision_analyze" to VisionAnalyzeRenderer,
"text_to_speech" to TextToSpeechRenderer,
// Projects // Projects
"project_list" to ProjectListRenderer, "project_list" to ProjectListRenderer,
"project_create" to ProjectMutateRenderer, "project_create" to ProjectMutateRenderer,
@@ -89,19 +89,24 @@ object ToolViewBuilder {
ToolCounts.countLabelFor(call) 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( return ToolView(
status = status, status = status,
title = title, title = title,
subtitle = subtitle, subtitle = subtitle,
detail = detail, detail = clampedDetail,
detailLabel = extras.detailLabel, detailLabel = extras.detailLabel,
countLabel = countLabel?.let { (count, noun) -> "$count ${ToolCounts.pluralizeNoun(noun, count)}" }, countLabel = countLabel?.let { (count, noun) -> "$count ${ToolCounts.pluralizeNoun(noun, count)}" },
durationLabel = ToolJson.durationLabel(call.result), durationLabel = ToolJson.durationLabel(call.result),
stdout = extras.stdout, stdout = clampedStdout,
stderr = extras.stderr, stderr = clampedStderr,
exitCode = extras.exitCode, exitCode = extras.exitCode,
terminalCommand = extras.terminalCommand, terminalCommand = extras.terminalCommand,
inlineDiff = extras.inlineDiff?.ifEmpty { null }, inlineDiff = clampedDiff,
diffPath = extras.diffPath, diffPath = extras.diffPath,
diffStats = extras.diffStats, diffStats = extras.diffStats,
imageUrl = imageUrlFor(call.args, call.result), 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.ToolCall
import com.m57.hermescontrol.ui.chat.tool.ToolJson import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.ui.chat.tool.ToolRenderer import com.m57.hermescontrol.ui.chat.tool.ToolRenderer
import kotlinx.serialization.json.JsonArray
/** `browser_navigate`: hostname titles, including the failed-to-open form. */ /** `browser_navigate`: hostname titles, including the failed-to-open form. */
internal object BrowserNavigateRenderer : ToolRenderer { internal object BrowserNavigateRenderer : ToolRenderer {
@@ -104,3 +105,98 @@ internal object BrowserTypeRenderer : ToolRenderer {
.ifEmpty { "Filled page input" } .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")
}
@@ -265,4 +265,159 @@ class ToolViewBuilderTest {
assertEquals(ToolViewStatus.ERROR, view.status) assertEquals(ToolViewStatus.ERROR, view.status)
assertTrue(view.error?.contains("Permission denied") == true) assertTrue(view.error?.contains("Permission denied") == true)
} }
// ── name aliases ──────────────────────────────────────────────────────
@Test
fun `process_manage alias activates ProcessRenderer`() {
val view =
build(
"process_manage",
"""{"action":"list"}""",
"""{"processes":[{"pid":123,"command":"sleep 100","status":"running"}]}""",
)
assertEquals("list", view.subtitle)
assertTrue(view.detail.contains("sleep 100"))
}
@Test
fun `todo_list alias activates TodoRenderer`() {
val view =
build(
"todo_list",
"""{"action":"create"}""",
"""{"summary":{"total":1,"pending":1,"completed":0},"todos":""" +
"""[{"id":"1","content":"write tests","status":"pending"}]}""",
)
assertEquals("1 item (1 pending)", view.subtitle)
assertTrue(view.detail.contains("[ ] 1. write tests"))
}
@Test
fun `cronjob_manage alias activates CronjobRenderer`() {
val view =
build(
"cronjob_manage",
"""{"action":"list"}""",
"""{"jobs":[{"name":"backup","schedule":"0 0 * * *"}]}""",
)
assertEquals("1 cron job", view.subtitle)
assertTrue(view.detail.contains("- backup · 0 0 * * *"))
}
// ── new renderers ─────────────────────────────────────────────────────
@Test
fun `search_files dense format parses matches_text and count`() {
val view =
build(
"search_files",
"""{"pattern":"fun connect","path":"app/src"}""",
"""{"total_count":2,"matches_text":"app/src/A.kt:10: fun connect()\napp/src/B.kt:20: fun connect()"}""",
)
assertEquals("Searched “fun connect” · 2 matches", view.title)
assertEquals("app/src", view.subtitle)
assertTrue(view.detail.contains("app/src/A.kt:10: fun connect()"))
assertEquals("Matches", view.detailLabel)
}
@Test
fun `search_files sparse array format formats hits`() {
val view =
build(
"search_files",
"""{"pattern":"test","path":"."}""",
"""{"total_count":1,"matches":[{"path":"foo.kt","line":42,"content":"val test = 1"}]}""",
)
assertEquals("Searched “test” · 1 match", view.title)
assertTrue(view.detail.contains("foo.kt:42: val test = 1"))
}
@Test
fun `delegate_task handles joined spawn with status markers`() {
val view =
build(
"delegate_task",
"""{"action":"spawn","tasks":[{"goal":"Research architecture"},{"goal":"Write code"}]}""",
"""{"results":[{"task_index":0,"status":"completed","model":"claude-3-7","summary":"Researched OK",""" +
""""duration_seconds":12.5},{"task_index":1,"status":"failed","error":"Build broke",""" +
""""duration_seconds":4.0}]}""",
)
assertEquals("Delegated 2 tasks · 1 failed", view.title)
assertEquals("Research architecture", view.subtitle)
assertTrue(view.detail.contains("[x] Research architecture"))
assertTrue(view.detail.contains("claude-3-7"))
assertTrue(view.detail.contains("[!] Write code"))
assertTrue(view.detail.contains("Build broke"))
}
@Test
fun `delegate_task handles list action`() {
val view =
build(
"delegate_task",
"""{"action":"list"}""",
"""{"count":1,"subagents":[{"goal":"Background indexing","status":"running",""" +
""""running_seconds":35.0}]}""",
)
assertEquals("1 subagent", view.title)
assertTrue(view.detail.contains("- [running] Background indexing · 35s"))
}
@Test
fun `text_to_speech formats provider and audio path`() {
val view =
build(
"text_to_speech",
"""{"text":"Hello world"}""",
"""{"provider":"edge","chunk_count":1,"file_path":"/tmp/audio.mp3"}""",
)
assertEquals("Generated speech", view.title)
assertEquals("edge", view.subtitle)
assertTrue(view.detail.contains("“Hello world”"))
assertTrue(view.detail.contains("Saved to /tmp/audio.mp3"))
}
@Test
fun `browser action renderers format scroll, back, vision and console`() {
val scroll = build("browser_scroll", """{"direction":"down"}""", """{"scrolled":"down"}""")
assertEquals("Scrolled down", scroll.title)
val back = build("browser_back", """{}""", """{"url":"https://example.com/docs"}""")
assertEquals("Went back to example.com/docs", back.title)
val vision =
build("browser_vision", """{"question":"Is there a login button?"}""", """{"analysis":"Yes, top right"}""")
assertEquals("Analyzed page", vision.title)
assertEquals("Is there a login button?", vision.subtitle)
assertEquals("Yes, top right", vision.detail)
val console =
build(
"browser_console",
"""{}""",
"""{"total_messages":5,"total_errors":1,"js_errors":[{"message":"Uncaught TypeError"}]}""",
)
assertEquals("Console: 5 messages, 1 error", console.title)
assertTrue(console.detail.contains("[error] Uncaught TypeError"))
}
@Test
fun `output is clamped to maximum display chars`() {
val longOutput = "a".repeat(25_000)
val view = build("terminal", """{"command":"dump"}""", """{"output":"$longOutput"}""")
val stdout = view.stdout
assertNotNull(stdout)
assertTrue(stdout!!.length < 25_000)
assertTrue(stdout.contains("more characters truncated"))
}
} }
@@ -555,7 +555,7 @@ class ToolViewCharacterizationTest {
// ── delegation / messaging fall back to the generic engine ─────────── // ── delegation / messaging fall back to the generic engine ───────────
@Test @Test
fun `delegate_task uses generic title and unwrapped response`() { fun `delegate_task uses DelegateTaskRenderer`() {
val view = val view =
build( build(
"delegate_task", "delegate_task",
@@ -563,8 +563,8 @@ class ToolViewCharacterizationTest {
"""{"response":"All done","duration_s":63}""", """{"response":"All done","duration_s":63}""",
) )
assertEquals("Delegate Task", view.title) assertEquals("Delegated 1 task", view.title)
assertEquals("All done", view.subtitle) assertEquals("Fix tests", view.subtitle)
assertEquals("", view.detail) assertEquals("", view.detail)
assertEquals("1m 3s", view.durationLabel) assertEquals("1m 3s", view.durationLabel)
} }