diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt index d2e0a705..6b1fff67 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt @@ -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? { 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), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt index 067ff205..4af0ace6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolSchemaRegistry.kt @@ -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", diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt index e2bd3347..21ef8f3d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolJson.kt @@ -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) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt index dfacec8f..3df58405 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRendererRegistry.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt index 3eb334b0..14ae55fe 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt @@ -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), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/BrowserRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/BrowserRenderers.kt index e459bbce..26b05406 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/BrowserRenderers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/BrowserRenderers.kt @@ -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") + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/DelegateTaskRenderer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/DelegateTaskRenderer.kt new file mode 100644 index 00000000..7bcde710 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/DelegateTaskRenderer.kt @@ -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 { + 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") + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MediaRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MediaRenderers.kt index faece561..648c2223 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MediaRenderers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/MediaRenderers.kt @@ -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") + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SearchFilesRenderer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SearchFilesRenderer.kt new file mode 100644 index 00000000..40f913a4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/SearchFilesRenderer.kt @@ -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") +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt index 6f7a7bc9..7b408a4f 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt @@ -265,4 +265,159 @@ class ToolViewBuilderTest { assertEquals(ToolViewStatus.ERROR, view.status) 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")) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt index b9c08aab..e12fae36 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewCharacterizationTest.kt @@ -555,7 +555,7 @@ class ToolViewCharacterizationTest { // ── delegation / messaging fall back to the generic engine ─────────── @Test - fun `delegate_task uses generic title and unwrapped response`() { + fun `delegate_task uses DelegateTaskRenderer`() { val view = build( "delegate_task", @@ -563,8 +563,8 @@ class ToolViewCharacterizationTest { """{"response":"All done","duration_s":63}""", ) - assertEquals("Delegate Task", view.title) - assertEquals("All done", view.subtitle) + assertEquals("Delegated 1 task", view.title) + assertEquals("Fix tests", view.subtitle) assertEquals("", view.detail) assertEquals("1m 3s", view.durationLabel) }