refactor(chat): unify tool catalog and prewarm payloads (#1326, #1327) (#1356)

This commit is contained in:
m57 authored and GitHub committed 2026-09-28 13:39:17 +03:00
1 parent b199c0347a
commit cc010b451a
8 files changed
+339 -383

No files matched your search

@@ -40,6 +40,7 @@ import com.m57.hermescontrol.data.ws.toAny
import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.notification.captureTurnBoundary
import com.m57.hermescontrol.notification.correlationScopeId
import com.m57.hermescontrol.ui.chat.tool.ToolViewCache
import com.m57.hermescontrol.ui.common.ActionProgressController
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@@ -51,6 +52,9 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -734,6 +738,24 @@ class ChatViewModel(
_uiState.value,
)
init {
// Parse tool payloads off the main thread before the transcript
// composes them (issue #1327); ToolBubble then reads a cache hit.
viewModelScope.launch(searchDispatcher) {
_uiState
.map { it.messages }
.distinctUntilChanged()
.conflate()
.collect { messages ->
for (message in messages) {
if (message.role == MessageRole.TOOL) {
ToolViewCache.prewarm(message.content, message.toolName, message.isToolRunning)
}
}
}
}
}
/**
* Session ID to resume when the WebSocket connects. Set synchronously by
* [ChatScreen] via `SideEffect` during composition — before any WS event
@@ -73,6 +73,7 @@ import com.m57.hermescontrol.ui.chat.components.DiffViewCard
import com.m57.hermescontrol.ui.chat.components.FileViewCard
import com.m57.hermescontrol.ui.chat.tool.ToolView
import com.m57.hermescontrol.ui.chat.tool.ToolViewBuilder
import com.m57.hermescontrol.ui.chat.tool.ToolViewCache
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -133,7 +134,7 @@ internal fun ToolBubble(
val view =
remember(message.content, message.toolName, message.isToolRunning) {
parseToolOutput(message.content, message.toolName, message.isToolRunning)
ToolViewCache.getOrParse(message.content, message.toolName, message.isToolRunning)
}
val config = ToolSchemaRegistry.getDisplayConfig(message.toolName)
@@ -1,292 +1,19 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.MenuBook
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.AutoStories
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.Computer
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Forum
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Photo
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Psychology
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.ThumbUp
import androidx.compose.material.icons.filled.TouchApp
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.ui.graphics.vector.ImageVector
import com.m57.hermescontrol.ui.chat.tool.ToolCatalog
/**
* Display configuration for a Hermes tool — controls the summary line,
* icon, and which arg field is shown when the bubble is collapsed.
*
* Based on the canonical tool schemas defined in the Hermes Agent source
* at /opt/hermes-agent/tools/ (each tool has a `*_SCHEMA` dict).
*/
/** Display configuration for a Hermes tool — its name and header icon. */
data class ToolDisplayConfig(
val name: String,
/** Material icon for this tool type. */
val icon: ImageVector = Icons.Filled.Build,
)
/** Maps tool names to their display config. Ordered by expected frequency of use. */
/** Icon lookup backed by the single [ToolCatalog] (issue #1326). */
object ToolSchemaRegistry {
private val knownTools: Map<String, ToolDisplayConfig> =
mapOf(
"terminal" to
ToolDisplayConfig(
name = "terminal",
icon = Icons.Filled.Terminal,
),
"read_file" to
ToolDisplayConfig(
name = "read_file",
icon = Icons.Filled.Description,
),
"write_file" to
ToolDisplayConfig(
name = "write_file",
icon = Icons.Filled.Edit,
),
"patch" to
ToolDisplayConfig(
name = "patch",
icon = Icons.Filled.Build,
),
"search_files" to
ToolDisplayConfig(
name = "search_files",
icon = Icons.Filled.Search,
),
"web_search" to
ToolDisplayConfig(
name = "web_search",
icon = Icons.Filled.Public,
),
"browser_navigate" to
ToolDisplayConfig(
name = "browser_navigate",
icon = Icons.Filled.Language,
),
"browser_click" to
ToolDisplayConfig(
name = "browser_click",
icon = Icons.Filled.TouchApp,
),
"browser_snapshot" to
ToolDisplayConfig(
name = "browser_snapshot",
icon = Icons.Filled.Photo,
),
"clarify" to
ToolDisplayConfig(
name = "clarify",
icon = Icons.Filled.ChatBubble,
),
"delegate_task" to
ToolDisplayConfig(
name = "delegate_task",
icon = Icons.Filled.AccountTree,
),
"execute_code" to
ToolDisplayConfig(
name = "execute_code",
icon = Icons.Filled.PlayArrow,
),
"todo" to
ToolDisplayConfig(
name = "todo",
icon = Icons.Filled.Checklist,
),
"todo_list" to
ToolDisplayConfig(
name = "todo_list",
icon = Icons.Filled.Checklist,
),
"fact_store" to
ToolDisplayConfig(
name = "fact_store",
icon = Icons.Filled.Psychology,
),
"session_search" to
ToolDisplayConfig(
name = "session_search",
icon = Icons.Filled.Search,
),
// ── Action-based tools ──
"cronjob" to
ToolDisplayConfig(
name = "cronjob",
icon = Icons.Filled.Schedule,
),
"cronjob_manage" to
ToolDisplayConfig(
name = "cronjob_manage",
icon = Icons.Filled.Schedule,
),
"memory" to
ToolDisplayConfig(
name = "memory",
icon = Icons.Filled.Memory,
),
"fact_feedback" to
ToolDisplayConfig(
name = "fact_feedback",
icon = Icons.Filled.ThumbUp,
),
"process" to
ToolDisplayConfig(
name = "process",
icon = Icons.Filled.Settings,
),
"process_manage" to
ToolDisplayConfig(
name = "process_manage",
icon = Icons.Filled.Settings,
),
"skill_manage" to
ToolDisplayConfig(
name = "skill_manage",
icon = Icons.Filled.Build,
),
// ── Web / Browser tools ──
"web_extract" to
ToolDisplayConfig(
name = "web_extract",
icon = Icons.Filled.Language,
),
"browser_type" to
ToolDisplayConfig(
name = "browser_type",
icon = Icons.Filled.Keyboard,
),
"browser_scroll" to
ToolDisplayConfig(
name = "browser_scroll",
icon = Icons.Filled.TouchApp,
),
"browser_back" to
ToolDisplayConfig(
name = "browser_back",
icon = Icons.Filled.Language,
),
"browser_press" to
ToolDisplayConfig(
name = "browser_press",
icon = Icons.Filled.Keyboard,
),
"browser_get_images" to
ToolDisplayConfig(
name = "browser_get_images",
icon = Icons.Filled.Image,
),
"browser_vision" to
ToolDisplayConfig(
name = "browser_vision",
icon = Icons.Filled.Visibility,
),
"browser_console" to
ToolDisplayConfig(
name = "browser_console",
icon = Icons.Filled.Terminal,
),
"browser_cdp" to
ToolDisplayConfig(
name = "browser_cdp",
icon = Icons.Filled.Build,
),
"browser_dialog" to
ToolDisplayConfig(
name = "browser_dialog",
icon = Icons.Filled.ChatBubble,
),
// ── Media / Vision tools ──
"vision_analyze" to
ToolDisplayConfig(
name = "vision_analyze",
icon = Icons.Filled.Visibility,
),
"text_to_speech" to
ToolDisplayConfig(
name = "text_to_speech",
icon = Icons.AutoMirrored.Filled.VolumeUp,
),
"video_generate" to
ToolDisplayConfig(
name = "video_generate",
icon = Icons.Filled.Movie,
),
"image_generate" to
ToolDisplayConfig(
name = "image_generate",
icon = Icons.Filled.Image,
),
// ── Social / Messaging tools ──
"x_search" to
ToolDisplayConfig(
name = "x_search",
icon = Icons.Filled.Forum,
),
"send_message" to
ToolDisplayConfig(
name = "send_message",
icon = Icons.AutoMirrored.Filled.Send,
),
// ── Skills tools ──
"skills_list" to
ToolDisplayConfig(
name = "skills_list",
icon = Icons.AutoMirrored.Filled.MenuBook,
),
"skill_view" to
ToolDisplayConfig(
name = "skill_view",
icon = Icons.Filled.AutoStories,
),
// ── On-demand tools ──
"tool_search" to
ToolDisplayConfig(
name = "tool_search",
icon = Icons.Filled.Search,
),
"tool_describe" to
ToolDisplayConfig(
name = "tool_describe",
icon = Icons.Filled.AutoStories,
),
"tool_call" to
ToolDisplayConfig(
name = "tool_call",
icon = Icons.Filled.Build,
),
// ── Misc ──
"read_terminal" to
ToolDisplayConfig(
name = "read_terminal",
icon = Icons.Filled.Computer,
),
"computer_use" to
ToolDisplayConfig(
name = "computer_use",
icon = Icons.Filled.Computer,
),
)
fun getDisplayConfig(toolName: String?): ToolDisplayConfig =
toolName?.let { knownTools[it] } ?: ToolDisplayConfig(name = toolName ?: "tool")
ToolDisplayConfig(name = toolName ?: "tool", icon = ToolCatalog.definitionFor(toolName).icon)
}
@@ -0,0 +1,144 @@
package com.m57.hermescontrol.ui.chat.tool
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.MenuBook
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.AutoStories
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.Computer
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Forum
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Photo
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Psychology
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.ThumbUp
import androidx.compose.material.icons.filled.TouchApp
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.ui.graphics.vector.ImageVector
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
import com.m57.hermescontrol.ui.chat.tool.render.MemoryRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ProcessRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ProjectListRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ProjectMutateRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ReadFileRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ReadTerminalRenderer
import com.m57.hermescontrol.ui.chat.tool.render.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.ToolDescribeRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ToolSearchRenderer
import com.m57.hermescontrol.ui.chat.tool.render.VisionAnalyzeRenderer
import com.m57.hermescontrol.ui.chat.tool.render.WebExtractRenderer
import com.m57.hermescontrol.ui.chat.tool.render.WebSearchRenderer
import com.m57.hermescontrol.ui.chat.tool.render.XSearchRenderer
/** A tool's visual identity (header/status icon) and its [ToolRenderer]. */
internal data class ToolDefinition(
val icon: ImageVector,
val renderer: ToolRenderer,
)
/** All-default renderer: defers everything to the generic fallbacks. */
internal object GenericToolRenderer : ToolRenderer
/**
* The single tool catalog (issue #1326): every known tool declares its icon
* and renderer together, so the two can no longer drift apart.
*
* Unknown tools get the Build icon and [GenericToolRenderer]: every hook
* returns null, so [ToolViewBuilder] falls back to the humanized tool name
* and the [ToolResultSummary] heuristics — never a raw JSON dump.
*/
internal object ToolCatalog {
val FALLBACK = ToolDefinition(Icons.Filled.Build, GenericToolRenderer)
val entries: Map<String, ToolDefinition> =
mapOf(
"browser_back" to ToolDefinition(Icons.Filled.Language, BrowserActionRenderer),
"browser_cdp" to ToolDefinition(Icons.Filled.Build, GenericToolRenderer),
"browser_click" to ToolDefinition(Icons.Filled.TouchApp, BrowserClickRenderer),
"browser_console" to ToolDefinition(Icons.Filled.Terminal, BrowserConsoleRenderer),
"browser_dialog" to ToolDefinition(Icons.Filled.ChatBubble, GenericToolRenderer),
"browser_fill" to ToolDefinition(Icons.Filled.Keyboard, BrowserTypeRenderer),
"browser_get_images" to ToolDefinition(Icons.Filled.Image, BrowserImagesRenderer),
"browser_navigate" to ToolDefinition(Icons.Filled.Language, BrowserNavigateRenderer),
"browser_press" to ToolDefinition(Icons.Filled.Keyboard, BrowserActionRenderer),
"browser_scroll" to ToolDefinition(Icons.Filled.TouchApp, BrowserActionRenderer),
"browser_snapshot" to ToolDefinition(Icons.Filled.Photo, BrowserSnapshotRenderer),
"browser_type" to ToolDefinition(Icons.Filled.Keyboard, BrowserTypeRenderer),
"browser_vision" to ToolDefinition(Icons.Filled.Visibility, BrowserVisionRenderer),
"clarify" to ToolDefinition(Icons.Filled.ChatBubble, GenericToolRenderer),
"computer_use" to ToolDefinition(Icons.Filled.Computer, ComputerUseRenderer),
"cronjob" to ToolDefinition(Icons.Filled.Schedule, CronjobRenderer),
"cronjob_manage" to ToolDefinition(Icons.Filled.Schedule, CronjobRenderer),
"delegate_task" to ToolDefinition(Icons.Filled.AccountTree, DelegateTaskRenderer),
"edit_file" to ToolDefinition(Icons.Filled.Edit, FileEditRenderer),
"execute_code" to ToolDefinition(Icons.Filled.PlayArrow, TerminalRenderer),
"fact_feedback" to ToolDefinition(Icons.Filled.ThumbUp, GenericToolRenderer),
"fact_store" to ToolDefinition(Icons.Filled.Psychology, FactStoreRenderer),
"image_generate" to ToolDefinition(Icons.Filled.Image, ImageGenerateRenderer),
"memory" to ToolDefinition(Icons.Filled.Memory, MemoryRenderer),
"patch" to ToolDefinition(Icons.Filled.Build, FileEditRenderer),
"process" to ToolDefinition(Icons.Filled.Settings, ProcessRenderer),
"process_manage" to ToolDefinition(Icons.Filled.Settings, ProcessRenderer),
"project_create" to ToolDefinition(Icons.Filled.AccountTree, ProjectMutateRenderer),
"project_list" to ToolDefinition(Icons.Filled.AccountTree, ProjectListRenderer),
"project_switch" to ToolDefinition(Icons.Filled.AccountTree, ProjectMutateRenderer),
"read_file" to ToolDefinition(Icons.Filled.Description, ReadFileRenderer),
"read_terminal" to ToolDefinition(Icons.Filled.Computer, ReadTerminalRenderer),
"search_files" to ToolDefinition(Icons.Filled.Search, SearchFilesRenderer),
"send_message" to ToolDefinition(Icons.AutoMirrored.Filled.Send, GenericToolRenderer),
"session_search" to ToolDefinition(Icons.Filled.Search, SessionSearchRenderer),
"skill_manage" to ToolDefinition(Icons.Filled.Build, SkillManageRenderer),
"skill_view" to ToolDefinition(Icons.Filled.AutoStories, SkillViewRenderer),
"skills_list" to ToolDefinition(Icons.AutoMirrored.Filled.MenuBook, SkillsListRenderer),
"terminal" to ToolDefinition(Icons.Filled.Terminal, TerminalRenderer),
"text_to_speech" to ToolDefinition(Icons.AutoMirrored.Filled.VolumeUp, TextToSpeechRenderer),
"todo" to ToolDefinition(Icons.Filled.Checklist, TodoRenderer),
"todo_list" to ToolDefinition(Icons.Filled.Checklist, TodoRenderer),
"tool_call" to ToolDefinition(Icons.Filled.Build, GenericToolRenderer),
"tool_describe" to ToolDefinition(Icons.Filled.AutoStories, ToolDescribeRenderer),
"tool_search" to ToolDefinition(Icons.Filled.Search, ToolSearchRenderer),
"video_generate" to ToolDefinition(Icons.Filled.Movie, GenericToolRenderer),
"vision_analyze" to ToolDefinition(Icons.Filled.Visibility, VisionAnalyzeRenderer),
"web_extract" to ToolDefinition(Icons.Filled.Language, WebExtractRenderer),
"web_search" to ToolDefinition(Icons.Filled.Public, WebSearchRenderer),
"write_file" to ToolDefinition(Icons.Filled.Edit, FileEditRenderer),
"x_search" to ToolDefinition(Icons.Filled.Forum, XSearchRenderer),
)
fun definitionFor(toolName: String?): ToolDefinition = toolName?.let { entries[it] } ?: FALLBACK
}
@@ -1,109 +1,6 @@
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
import com.m57.hermescontrol.ui.chat.tool.render.MemoryRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ProcessRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ProjectListRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ProjectMutateRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ReadFileRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ReadTerminalRenderer
import com.m57.hermescontrol.ui.chat.tool.render.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.ToolDescribeRenderer
import com.m57.hermescontrol.ui.chat.tool.render.ToolSearchRenderer
import com.m57.hermescontrol.ui.chat.tool.render.VisionAnalyzeRenderer
import com.m57.hermescontrol.ui.chat.tool.render.WebExtractRenderer
import com.m57.hermescontrol.ui.chat.tool.render.WebSearchRenderer
import com.m57.hermescontrol.ui.chat.tool.render.XSearchRenderer
/**
* Maps tool names to their display strategy.
*
* Unknown tools get [GenericToolRenderer]: every hook returns null, so
* [ToolViewBuilder] falls back to the humanized tool name and the
* [ToolResultSummary] heuristics — never a raw JSON dump.
*/
/** Renderer lookup backed by the single [ToolCatalog]. */
internal object ToolRendererRegistry {
/** All-default renderer: defers everything to the generic fallbacks. */
private object GenericToolRenderer : ToolRenderer
private val renderers: Map<String, ToolRenderer> =
mapOf(
// Shell
"terminal" to TerminalRenderer,
"execute_code" to TerminalRenderer,
"read_terminal" to ReadTerminalRenderer,
// 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,
"x_search" to XSearchRenderer,
// Browser
"browser_navigate" to BrowserNavigateRenderer,
"browser_snapshot" to BrowserSnapshotRenderer,
"browser_click" to BrowserClickRenderer,
"browser_fill" to BrowserTypeRenderer,
"browser_type" to BrowserTypeRenderer,
"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,
"skill_manage" to SkillManageRenderer,
"tool_search" to ToolSearchRenderer,
"tool_describe" to ToolDescribeRenderer,
// Media
"image_generate" to ImageGenerateRenderer,
"vision_analyze" to VisionAnalyzeRenderer,
"text_to_speech" to TextToSpeechRenderer,
// Projects
"project_list" to ProjectListRenderer,
"project_create" to ProjectMutateRenderer,
"project_switch" to ProjectMutateRenderer,
// Desktop automation
"computer_use" to ComputerUseRenderer,
)
fun rendererFor(toolName: String): ToolRenderer = renderers[toolName] ?: GenericToolRenderer
fun rendererFor(toolName: String): ToolRenderer = ToolCatalog.definitionFor(toolName).renderer
}
@@ -0,0 +1,61 @@
package com.m57.hermescontrol.ui.chat.tool
import com.m57.hermescontrol.ui.chat.parseToolOutput
/**
* Bounded memo of parsed tool payloads (issue #1327).
*
* The ViewModel [prewarm]s tool rows on a background dispatcher whenever the
* transcript changes, so [ToolBubble] normally gets a cache hit during
* composition instead of parsing JSON on the main thread. A miss (e.g. a live
* event that lands before prewarm finishes) parses synchronously exactly like
* before, so the rendered result — and its height — never changes.
*
* Keyed by the payload itself (not its hash), so a hit is always the view the
* same inputs would produce.
*/
internal object ToolViewCache {
private const val MAX_ENTRIES = 512
private data class Key(
val content: String,
val toolName: String?,
val isRunning: Boolean,
)
/** Boxes the result so a null parse (non-JSON content) is cached too. */
private class Entry(
val view: ToolView?,
)
private val entries =
object : LinkedHashMap<Key, Entry>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Key, Entry>?): Boolean = size > MAX_ENTRIES
}
fun getOrParse(
content: String,
toolName: String?,
isRunning: Boolean,
): ToolView? {
val key = Key(content, toolName, isRunning)
synchronized(entries) { entries[key] }?.let { return it.view }
val view = parseToolOutput(content, toolName, isRunning)
synchronized(entries) { entries[key] = Entry(view) }
return view
}
fun prewarm(
content: String,
toolName: String?,
isRunning: Boolean,
) {
getOrParse(content, toolName, isRunning)
}
internal fun size(): Int = synchronized(entries) { entries.size }
internal fun clear() {
synchronized(entries) { entries.clear() }
}
}