chore(ui): replace emoji with Material icons across chat UI (#944)

* chore(ui): replace emoji with Material icons across chat UI

Removes emoji glyphs and swaps them for real Material icons / plain text:

- ToolSchemaRegistry: iconEmoji String -> ImageVector icon for all ~35 tools;
  summaryPrefix emoji stripped (terminal keeps '$ '); consumer renders Icon().
- ToolBubble: collapsed summary now shows Icon(config.icon) instead of an emoji
  Text prefix; web-search url drops the link emoji; warning badge uses
  Icons.Filled.Warning instead of a '⚠' Text glyph.
- ChatBubble SelfImprovementReviewCard: 🛠/🧠 String -> Build/Psychology Icon.
- ChatViewModel / ChatWsEventReducer: drop ⚠️/✅ prefixes from user-facing
  status/error messages (severity already conveyed by color/styling).
- SubagentInspectionSheet: ✅/🔄/❌/⭕ status markers -> CheckCircle/Autorenew/
  Cancel/RadioButtonUnchecked (tinted); ElectricBolt section headers ->
  FormatListBulleted + Groups.
- strings.xml (en/zh/ko): drop '✓' from the Copied label.

ktlint clean; compileDebugKotlin SUCCESSFUL. Remaining chat files
(ToolViewBuilder, ChatComposer, MessageCards, ChatReactions, ComposerToolbar,
FullBleedAgentMessage) tracked in follow-up commits on this branch.

* test: align unit tests with emoji-free summary + status messages

composeToolSummaryLines dropped the emoji parameter and no longer prepends
an emoji glyph to the collapsed summary line, so update ToolSummaryLinesTest
call sites + expected strings accordingly.

SlashCommandDispatchRpcTest asserted the old '⚠️ /cmd: ...' error messages;
those prefixes were removed in the UI cleanup, so drop them from the
expected strings.

ChatWsEventReducerTest emoji usages are model-output fixtures, not assertions
on changed code paths — left untouched.
This commit is contained in:
M57 authored and GitHub committed 2026-08-21 00:07:37 +03:00
1 parent fad49b7b66
commit 4549410a45
11 files changed
+148 -108

No files matched your search

@@ -143,7 +143,7 @@ sealed class WsEvent {
/**
* Self-improvement background review summary event.
* Emitted when background review patches a skill or saves a memory.
* Payload: `{ text: "💾 Self-improvement review: ..." }`
* Payload: `{ text: "Self-improvement review: ..." }`
*/
data class ReviewSummary(
val text: String,
@@ -25,10 +25,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Psychology
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -51,6 +53,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalConfiguration
@@ -65,7 +68,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.theme.DarkOnSurface
@@ -257,7 +259,7 @@ private fun SelfImprovementReviewCard(
val isSkill =
cleanText.contains("skill", ignoreCase = true) ||
cleanText.contains("SKILL.md", ignoreCase = true)
val icon = if (isSkill) "🛠" else "🧠"
val icon: ImageVector = if (isSkill) Icons.Filled.Build else Icons.Filled.Psychology
val title =
if (isSkill) {
"Self-Improvement Review • Skill Patched"
@@ -279,7 +281,12 @@ private fun SelfImprovementReviewCard(
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = icon, fontSize = 16.sp)
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(
text = title,
@@ -1112,7 +1112,7 @@ class ChatViewModel(
val map = result as? Map<*, *>
val resolved = (map?.get("resolved") as? Number)?.toInt() ?: 0
if (resolved > 0) {
addSystemMessage("✅ Approval submitted")
addSystemMessage("Approval submitted")
}
}
}
@@ -1308,7 +1308,7 @@ class ChatViewModel(
} catch (e: Exception) {
Log.e(TAG, "Failed to upload attachment ${attachment.name}", e)
_uiState.update {
it.copy(errorMessage = "⚠️ Upload failed: ${attachment.name}")
it.copy(errorMessage = "Upload failed: ${attachment.name}")
}
}
}
@@ -1424,7 +1424,7 @@ class ChatViewModel(
// (before any RPC fires) with a clear message instead of a doomed call.
if (CommandBlocklist.contains(command)) {
addAssistantMessage(
"⚠️ ${command.split(" ", limit = 2)[0]} is not supported on mobile",
"${command.split(" ", limit = 2)[0]} is not supported on mobile",
)
return
}
@@ -1608,11 +1608,11 @@ class ChatViewModel(
val output = (result as? Map<*, *>)?.get("output") as? String
if (!output.isNullOrBlank()) addAssistantMessage(output)
} catch (e2: HermesWsClient.HermesRpcException) {
addAssistantMessage("⚠️ /$name: ${e2.message}")
addAssistantMessage("/$name: ${e2.message}")
}
} else {
// Legit error from command.dispatch (busy, no history, etc.)
addAssistantMessage("⚠️ /$name: ${e.message}")
addAssistantMessage("/$name: ${e.message}")
}
}
}
@@ -3315,7 +3315,7 @@ class ChatViewModel(
private fun handleApprovalRequest(event: WsEvent.ApprovalRequest) {
val description = event.description ?: event.command ?: "Unknown command"
val content = "⚠️ **Approval Required**\n$description"
val content = "**Approval Required**\n$description"
val msg =
ChatMessage(
role = MessageRole.SYSTEM,
@@ -682,7 +682,7 @@ object ChatWsEventReducer {
state =
state.copy(
isLoading = false,
errorMessage = "⚠️ Backend error: ${event.message ?: "Unknown gateway error"}",
errorMessage = "Backend error: ${event.message ?: "Unknown gateway error"}",
),
streamingState = streamingState,
)
@@ -700,8 +700,8 @@ object ChatWsEventReducer {
when (val label = event.data?.get("label")) {
is String -> label
else -> event.data?.get("name") as? String
}?.let { "✅ Background job finished: $it" }
?: "✅ Background job finished"
}?.let { "Background job finished: $it" }
?: "Background job finished"
return ReducerResult(
state = state.copy(backgroundCompleteMessage = message),
streamingState = streamingState,
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.height
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.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
@@ -26,6 +27,7 @@ import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -79,7 +81,6 @@ import kotlinx.coroutines.launch
internal fun composeToolSummaryLines(
view: ToolView,
toolName: String?,
emoji: String,
): Pair<String, String?>? {
val titleIsGeneric = view.title.equals(ToolViewBuilder.genericTitleFor(toolName), ignoreCase = true)
val subtitle = view.subtitle.takeIf { it.isNotBlank() && it != view.title }
@@ -89,9 +90,8 @@ internal fun composeToolSummaryLines(
if (titleIsGeneric) {
firstLine =
buildString {
append(emoji)
if (subtitle != null) {
append(" $subtitle")
append(subtitle)
}
view.countLabel?.let { append(" ($it)") }
}
@@ -99,13 +99,13 @@ internal fun composeToolSummaryLines(
} else {
firstLine =
buildString {
append("$emoji ${view.title}")
append(view.title)
view.countLabel?.let { append(" ($it)") }
}
secondLine = subtitle
}
return firstLine.takeIf { it.trim() != emoji }?.let { it to secondLine }
return firstLine.takeIf { it.isNotBlank() }?.let { it to secondLine }
}
@Composable
@@ -180,21 +180,32 @@ internal fun ToolBubble(
SecurityRiskChip(riskData, contentColor)
}
// ── Collapsed summary: emoji + title, then subtitle ──
// ── Collapsed summary: icon + title, then subtitle ──
if (!expanded && view != null) {
composeToolSummaryLines(view, message.toolName, config.iconEmoji)?.let { (firstLine, secondLine) ->
Text(
text = firstLine,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.7f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
composeToolSummaryLines(view, message.toolName)?.let { (firstLine, secondLine) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 4.dp, start = 22.dp),
)
) {
Icon(
imageVector = config.icon,
contentDescription = null,
tint = contentColor.copy(alpha = 0.7f),
modifier = Modifier.size(13.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = firstLine,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.7f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (secondLine != null) {
Text(
text = secondLine,
@@ -450,7 +461,7 @@ private fun ExpandedToolContent(
}
if (hit.url.isNotEmpty()) {
Text(
text = "🔗 ${hit.url}",
text = hit.url,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.7f),
@@ -567,10 +578,11 @@ internal fun SecurityRiskChip(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "⚠",
style = MaterialTheme.typography.labelSmall,
color = chipColor,
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = chipColor,
modifier = Modifier.size(14.dp),
)
Text(
text = label,
@@ -1,5 +1,36 @@
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
/**
* Display configuration for a Hermes tool — controls the summary line,
* icon, and which arg field is shown when the bubble is collapsed.
@@ -11,10 +42,10 @@ data class ToolDisplayConfig(
val name: String,
/** Key into the `args` dict for the one-line summary (e.g. "command" for terminal). */
val summaryArgKey: String? = null,
/** Prefix for the summary line (e.g. "$ " for terminal, "📄 " for read_file). */
/** Prefix for the summary line (e.g. "$ " for terminal). Emoji-free. */
val summaryPrefix: String = "",
/** Emoji icon for this tool type. */
val iconEmoji: 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. */
@@ -26,240 +57,230 @@ object ToolSchemaRegistry {
name = "terminal",
summaryArgKey = "command",
summaryPrefix = "$ ",
iconEmoji = "💻",
icon = Icons.Filled.Terminal,
),
"read_file" to
ToolDisplayConfig(
name = "read_file",
summaryArgKey = "path",
summaryPrefix = "📄 ",
iconEmoji = "📄",
icon = Icons.Filled.Description,
),
"write_file" to
ToolDisplayConfig(
name = "write_file",
summaryArgKey = "path",
summaryPrefix = "✏️ ",
iconEmoji = "✏️",
icon = Icons.Filled.Edit,
),
"patch" to
ToolDisplayConfig(
name = "patch",
summaryArgKey = "path",
summaryPrefix = "🔧 ",
iconEmoji = "🔧",
icon = Icons.Filled.Build,
),
"search_files" to
ToolDisplayConfig(
name = "search_files",
summaryArgKey = "pattern",
summaryPrefix = "🔍 ",
iconEmoji = "🔍",
icon = Icons.Filled.Search,
),
"web_search" to
ToolDisplayConfig(
name = "web_search",
summaryArgKey = "query",
summaryPrefix = "🌐 ",
iconEmoji = "🌐",
icon = Icons.Filled.Public,
),
"browser_navigate" to
ToolDisplayConfig(
name = "browser_navigate",
summaryArgKey = "url",
summaryPrefix = "🌍 ",
iconEmoji = "🌍",
icon = Icons.Filled.Language,
),
"browser_click" to
ToolDisplayConfig(
name = "browser_click",
summaryArgKey = "ref",
summaryPrefix = "🖱 ",
iconEmoji = "🖱",
icon = Icons.Filled.TouchApp,
),
"browser_snapshot" to
ToolDisplayConfig(
name = "browser_snapshot",
summaryArgKey = null,
iconEmoji = "📋",
icon = Icons.Filled.Photo,
),
"clarify" to
ToolDisplayConfig(
name = "clarify",
summaryArgKey = "question",
summaryPrefix = "💬 ",
iconEmoji = "💬",
icon = Icons.Filled.ChatBubble,
),
"delegate_task" to
ToolDisplayConfig(
name = "delegate_task",
summaryArgKey = "goal",
summaryPrefix = "🔄 ",
iconEmoji = "🔄",
icon = Icons.Filled.AccountTree,
),
"execute_code" to
ToolDisplayConfig(
name = "execute_code",
summaryArgKey = "code",
summaryPrefix = "▶️ ",
iconEmoji = "▶️",
icon = Icons.Filled.PlayArrow,
),
"todo" to
ToolDisplayConfig(
name = "todo",
summaryArgKey = null,
iconEmoji = "📋",
icon = Icons.Filled.Checklist,
),
"fact_store" to
ToolDisplayConfig(
name = "fact_store",
summaryArgKey = null,
iconEmoji = "🧠",
icon = Icons.Filled.Psychology,
),
"session_search" to
ToolDisplayConfig(
name = "session_search",
summaryArgKey = null,
iconEmoji = "🔍",
icon = Icons.Filled.Search,
),
// ── Action-based tools ──
"cronjob" to
ToolDisplayConfig(
name = "cronjob",
summaryArgKey = "action",
iconEmoji = "🔄",
icon = Icons.Filled.Schedule,
),
"memory" to
ToolDisplayConfig(
name = "memory",
summaryArgKey = "action",
iconEmoji = "💾",
icon = Icons.Filled.Memory,
),
"fact_feedback" to
ToolDisplayConfig(
name = "fact_feedback",
summaryArgKey = "action",
iconEmoji = "👍",
icon = Icons.Filled.ThumbUp,
),
"process" to
ToolDisplayConfig(
name = "process",
summaryArgKey = "action",
iconEmoji = "⚙️",
icon = Icons.Filled.Settings,
),
"skill_manage" to
ToolDisplayConfig(
name = "skill_manage",
summaryArgKey = "action",
iconEmoji = "🛠️",
icon = Icons.Filled.Build,
),
// ── Web / Browser tools ──
"web_extract" to
ToolDisplayConfig(
name = "web_extract",
summaryArgKey = "urls",
iconEmoji = "🕸️",
icon = Icons.Filled.Language,
),
"browser_type" to
ToolDisplayConfig(
name = "browser_type",
summaryArgKey = null,
iconEmoji = "⌨️",
icon = Icons.Filled.Keyboard,
),
"browser_cdp" to
ToolDisplayConfig(
name = "browser_cdp",
summaryArgKey = "action",
iconEmoji = "🔧",
icon = Icons.Filled.Build,
),
"browser_dialog" to
ToolDisplayConfig(
name = "browser_dialog",
summaryArgKey = "action",
iconEmoji = "💬",
icon = Icons.Filled.ChatBubble,
),
// ── Media / Vision tools ──
"vision_analyze" to
ToolDisplayConfig(
name = "vision_analyze",
summaryArgKey = "image_url",
iconEmoji = "👁️",
icon = Icons.Filled.Visibility,
),
"text_to_speech" to
ToolDisplayConfig(
name = "text_to_speech",
summaryArgKey = null,
iconEmoji = "🔊",
icon = Icons.AutoMirrored.Filled.VolumeUp,
),
"video_generate" to
ToolDisplayConfig(
name = "video_generate",
summaryArgKey = null,
iconEmoji = "🎬",
icon = Icons.Filled.Movie,
),
"image_generate" to
ToolDisplayConfig(
name = "image_generate",
summaryArgKey = null,
iconEmoji = "🎨",
icon = Icons.Filled.Image,
),
// ── Social / Messaging tools ──
"x_search" to
ToolDisplayConfig(
name = "x_search",
summaryArgKey = "query",
iconEmoji = "𝕏",
icon = Icons.Filled.Forum,
),
"send_message" to
ToolDisplayConfig(
name = "send_message",
summaryArgKey = null,
iconEmoji = "📨",
icon = Icons.AutoMirrored.Filled.Send,
),
// ── Skills tools ──
"skills_list" to
ToolDisplayConfig(
name = "skills_list",
summaryArgKey = null,
iconEmoji = "📚",
icon = Icons.AutoMirrored.Filled.MenuBook,
),
"skill_view" to
ToolDisplayConfig(
name = "skill_view",
summaryArgKey = "name",
iconEmoji = "📖",
icon = Icons.Filled.AutoStories,
),
// ── On-demand tools ──
"tool_search" to
ToolDisplayConfig(
name = "tool_search",
summaryArgKey = "query",
iconEmoji = "🔍",
icon = Icons.Filled.Search,
),
"tool_describe" to
ToolDisplayConfig(
name = "tool_describe",
summaryArgKey = "name",
iconEmoji = "📖",
icon = Icons.Filled.AutoStories,
),
"tool_call" to
ToolDisplayConfig(
name = "tool_call",
summaryArgKey = "name",
iconEmoji = "🔧",
icon = Icons.Filled.Build,
),
// ── Misc ──
"read_terminal" to
ToolDisplayConfig(
name = "read_terminal",
summaryArgKey = "session_id",
iconEmoji = "🖥️",
icon = Icons.Filled.Computer,
),
"computer_use" to
ToolDisplayConfig(
name = "computer_use",
summaryArgKey = "action",
iconEmoji = "🖥️",
icon = Icons.Filled.Computer,
),
)
+1 -1
View File
@@ -414,7 +414,7 @@
<string name="profiles_action_auto_describe">자동 설명</string>
<string name="profiles_action_setup_command">설정 명령</string>
<string name="profiles_action_copy">복사</string>
<string name="profiles_action_copied">복사됨 ✓</string>
<string name="profiles_action_copied">복사됨</string>
<string name="profiles_title_rename">프로필 이름 변경: %1$s</string>
<string name="profiles_title_delete">프로필 삭제: %1$s</string>
<string name="profiles_title_setup_command">설정 명령: %1$s</string>
+1 -1
View File
@@ -557,7 +557,7 @@
<string name="profiles_action_auto_describe">自动描述</string>
<string name="profiles_action_setup_command">安装命令</string>
<string name="profiles_action_copy">复制</string>
<string name="profiles_action_copied">已复制 ✓</string>
<string name="profiles_action_copied">已复制</string>
<string name="profiles_title_rename">重命名配置:%1$s</string>
<string name="profiles_title_delete">删除配置:%1$s</string>
<string name="profiles_title_setup_command">安装命令:%1$s</string>
+1 -1
View File
@@ -643,7 +643,7 @@
<string name="profiles_action_auto_describe">Auto-Describe</string>
<string name="profiles_action_setup_command">Setup Command</string>
<string name="profiles_action_copy">Copy</string>
<string name="profiles_action_copied">Copied ✓</string>
<string name="profiles_action_copied">Copied</string>
<string name="profiles_title_rename">Rename Profile: %1$s</string>
<string name="profiles_title_delete">Delete Profile: %1$s</string>
<string name="profiles_title_setup_command">Setup Command: %1$s</string>
@@ -299,7 +299,7 @@ class SlashCommandDispatchRpcTest {
val last =
vm.uiState.value.messages
.lastOrNull()
assertEquals("⚠️ /help: session busy — /interrupt first", last?.content)
assertEquals("/help: session busy — /interrupt first", last?.content)
}
@Test
@@ -422,7 +422,7 @@ class SlashCommandDispatchRpcTest {
val last =
vm.uiState.value.messages
.lastOrNull()
assertEquals("⚠️ /status: slash worker start failed: boom", last?.content)
assertEquals("/status: slash worker start failed: boom", last?.content)
}
@Test
@@ -24,69 +24,69 @@ class ToolSummaryLinesTest {
)
@Test
fun `generic title merges into the emoji line`() {
val lines = composeToolSummaryLines(view("Fact Store", "Fact added (ID: 5)"), "fact_store", "🧠")
fun `generic title merges into the summary line`() {
val lines = composeToolSummaryLines(view("Fact Store", "Fact added (ID: 5)"), "fact_store")
assertEquals("🧠 Fact added (ID: 5)", lines!!.first)
assertEquals("Fact added (ID: 5)", lines!!.first)
assertNull(lines.second)
}
@Test
fun `todo shows counts on one line`() {
val lines = composeToolSummaryLines(view("Todo", "2 items (1 pending, 1 completed)"), "todo", "✅")
val lines = composeToolSummaryLines(view("Todo", "2 items (1 pending, 1 completed)"), "todo")
assertEquals("✅ 2 items (1 pending, 1 completed)", lines!!.first)
assertEquals("2 items (1 pending, 1 completed)", lines!!.first)
assertNull(lines.second)
}
@Test
fun `descriptive title stays with subtitle on line two`() {
val lines = composeToolSummaryLines(view("Searched \"cats\"", "Query: cats", "3 results"), "web_search", "🌐")
val lines = composeToolSummaryLines(view("Searched \"cats\"", "Query: cats", "3 results"), "web_search")
assertEquals("🌐 Searched \"cats\" (3 results)", lines!!.first)
assertEquals("Searched \"cats\" (3 results)", lines!!.first)
assertEquals("Query: cats", lines.second)
}
@Test
fun `terminal title stays with output preview on line two`() {
val lines = composeToolSummaryLines(view("Ran ls", "wiring.tsx"), "terminal", "💻")
val lines = composeToolSummaryLines(view("Ran ls", "wiring.tsx"), "terminal")
assertEquals("💻 Ran ls", lines!!.first)
assertEquals("Ran ls", lines!!.first)
assertEquals("wiring.tsx", lines.second)
}
@Test
fun `read file title stays with path on line two`() {
val lines = composeToolSummaryLines(view("Read a.kt L10-14", "/repo/src/a.kt"), "read_file", "📄")
val lines = composeToolSummaryLines(view("Read a.kt L10-14", "/repo/src/a.kt"), "read_file")
assertEquals("📄 Read a.kt L10-14", lines!!.first)
assertEquals("Read a.kt L10-14", lines!!.first)
assertEquals("/repo/src/a.kt", lines.second)
}
@Test
fun `generic title with no subtitle and no count is hidden`() {
assertNull(composeToolSummaryLines(view("Fact Store"), "fact_store", "🧠"))
assertNull(composeToolSummaryLines(view("Fact Store"), "fact_store"))
}
@Test
fun `unknown tool generic title merges with engine first line`() {
val lines = composeToolSummaryLines(view("Weird Tool", "- Title: Build report"), "weird_tool", "🔧")
val lines = composeToolSummaryLines(view("Weird Tool", "- Title: Build report"), "weird_tool")
assertEquals("🔧 - Title: Build report", lines!!.first)
assertEquals("- Title: Build report", lines!!.first)
assertNull(lines.second)
}
@Test
fun `null tool name resolves to the generic tool title`() {
val lines = composeToolSummaryLines(view("Tool", "raw payload text"), null, "🔧")
val lines = composeToolSummaryLines(view("Tool", "raw payload text"), null)
assertEquals("🔧 raw payload text", lines!!.first)
assertEquals("raw payload text", lines!!.first)
assertNull(lines.second)
}
@Test
fun `subtitle identical to title is not duplicated`() {
val lines = composeToolSummaryLines(view("Fact Store", "Fact Store"), "fact_store", "🧠")
val lines = composeToolSummaryLines(view("Fact Store", "Fact Store"), "fact_store")
assertNull(lines)
}