refactor(chat): tool display cleanup — split ChatBubble.kt god file, drop dead code (#762)

* refactor(chat): extract tool display subsystem from ChatBubble.kt

ChatBubble.kt was a 2578-line god file. Split it into four files in the
same package (zero import churn, no behavior change):

- ChatBubble.kt: user/assistant/system bubbles + dispatch + shared
  helpers (formatTimestamp, buildHighlightedString, InlineAttachment)
- ParsedToolData.kt: the parsed tool payload model
- ToolResultParser.kt: parseToolOutput + per-tool formatters + the
  Gson-compat JSON shims and tolerant exit_code parser
- ToolBubble.kt: ToolBubble + ExpandedToolContent + HeaderRow +
  SecurityRiskChip + CopyButton

formatTimestamp became internal (shared with ToolBubble.kt).
Verified: ktlintCheck + testDebugUnitTest green locally.

* refactor(chat): drop dead ParsedToolData fields and unused shim

Verified via grep: parsed.args / parsed.result / parsed.stderr /
parsed.isRunning / parsed.toolName had zero readers across app/src
(main + test + androidTest). toolName + isRunning are asserted by
ToolBubbleParsingTest, so they stay; args/result/stderr were built by
every formatter (21 construction sites) and never read — removing them
also kills a JSON.stringify-per-tool-complete wasted map build.

keySet() shim had no call sites. formatTodoToolOutput's args param
became unused with the field removal — dropped it too.

Verified: ktlintCheck + testDebugUnitTest green locally.

* refactor(chat): drop unused lastSessionId and ToolBubble isDarkTheme param

ChatScrollController.lastSessionId: introduced in #683, never read or
written since (namefreq scan + git log -S confirm single occurrence at
declaration). Session-change detection moved to lastTailKey/
lastMessageCount.

ToolBubble.isDarkTheme: never read in the body (MaterialTheme +
statusColors do the theming); single call site updated.

Verified: ktlintCheck + compileDebugKotlin green locally.

---------

Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andhy4ri authored and GitHub committed 2026-08-01 22:01:12 +03:00
1 parent 3a96edd4c1
commit 1daa03d686
5 files changed
+1875 -1867

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,20 @@
package com.m57.hermescontrol.ui.chat
/**
* Cleanly parsed representation of a tool call, extracted from the
* tool.complete JSON payload (which contains both args and result).
*/
data class ParsedToolData(
val toolName: String = "",
val isTerminal: Boolean = false,
val stdout: String? = null,
val exitCode: Int? = null,
val error: String? = null,
val summaryText: String? = null,
val durationSec: Double? = null,
val mainOutput: String? = null,
val extraFields: Map<String, String> = emptyMap(),
val isRunning: Boolean = false,
val diffOutput: String? = null,
val diffPath: String? = null,
)
@@ -0,0 +1,528 @@
package com.m57.hermescontrol.ui.chat
import android.content.ClipData
import android.text.format.DateFormat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
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.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
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 com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.components.DiffViewCard
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
private fun ExpandedToolContent(
parsed: ParsedToolData,
contentColor: Color,
statusColors: HermesStatusColors,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
// Summary line at top of expanded view
if (parsed.summaryText != null) {
Text(
text = parsed.summaryText,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.7f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
)
Spacer(modifier = Modifier.height(2.dp))
}
if (parsed.isTerminal) {
// ── Terminal output ──
parsed.stdout?.let {
Text(
text = it,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.9f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
)
}
parsed.error?.let {
Text(
text = stringResource(R.string.chat_tool_execution_error, it),
style =
MaterialTheme.typography.bodySmall.copy(
color = statusColors.error,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
)
}
parsed.exitCode?.let { code ->
if (code != 0) {
Text(
text = stringResource(R.string.chat_tool_exit_code, code),
style =
MaterialTheme.typography.labelSmall.copy(
color = statusColors.error,
fontWeight = FontWeight.Medium,
),
)
}
}
// Duration footer for terminal
parsed.durationSec?.let { dur ->
Text(
text = "Duration: ${"%.1f".format(dur)}s",
style =
MaterialTheme.typography.labelSmall.copy(
color = contentColor.copy(alpha = 0.5f),
),
)
}
} else {
// ── Generic tool output / Diff view ──
if (parsed.diffOutput != null) {
DiffViewCard(
diffText = parsed.diffOutput,
filePath = parsed.diffPath,
)
} else {
parsed.mainOutput?.let {
Text(
text = it,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.9f),
fontSize = 12.sp,
),
)
}
parsed.extraFields.forEach { (key, value) ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "$key:",
style =
MaterialTheme.typography.labelSmall.copy(
color = contentColor.copy(alpha = 0.6f),
fontWeight = FontWeight.Bold,
),
)
Text(
text = value,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.9f),
fontSize = 11.sp,
),
)
}
}
}
}
// Duration footer
parsed.durationSec?.let { dur ->
Text(
text = "Duration: ${"%.1f".format(dur)}s",
style =
MaterialTheme.typography.labelSmall.copy(
color = contentColor.copy(alpha = 0.5f),
),
)
}
}
}
@Composable
internal fun ToolBubble(
message: ChatMessage,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
var showRawJson by remember { mutableStateOf(false) }
val chipColor = MaterialTheme.colorScheme.surfaceContainerHigh
val contentColor = MaterialTheme.colorScheme.onSurfaceVariant
val statusColors = LocalHermesStatusColors.current
val parsed =
remember(message.content, message.toolName, message.toolStatus) {
parseToolOutput(message.content, message.toolName, message.toolStatus == ToolStatus.RUNNING)
}
val config = ToolSchemaRegistry.getDisplayConfig(message.toolName)
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
var showCopyButton by remember { mutableStateOf(false) }
// Auto-dismiss copy button after 4 seconds
LaunchedEffect(showCopyButton) {
if (showCopyButton) {
delay(4000)
showCopyButton = false
}
}
Box(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 1.dp),
contentAlignment = Alignment.CenterStart,
) {
Card(
onClick = { expanded = !expanded },
colors = CardDefaults.cardColors(containerColor = chipColor),
shape = RoundedCornerShape(8.dp),
) {
Column(
modifier =
Modifier
.animateContentSize()
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
// ── Header row: icon + tool name ──
HeaderRow(message, config, contentColor, statusColors)
// ── Tool progress preview (tool.progress) ──
if (message.toolStatus == ToolStatus.RUNNING && !message.progressPreview.isNullOrEmpty()) {
Text(
text = message.progressPreview,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.7f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp, start = 22.dp),
)
}
// ── Security risk chip (tool.output_risk) ──
val riskData = message.toolOutputRiskData
if (riskData != null && (riskData.risk == "medium" || riskData.risk == "high" || riskData.redacted)) {
SecurityRiskChip(riskData, contentColor)
}
// ── Summary line (always visible when collapsed) ──
if (!expanded && parsed?.summaryText != null) {
Text(
text = parsed.summaryText,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.7f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 4.dp, start = 22.dp),
)
}
// ── Expanded content ──
if (expanded) {
Spacer(modifier = Modifier.height(6.dp))
if (showRawJson) {
// Raw JSON view — selectable + copy button
Box {
SelectionContainer {
Text(
text = message.content,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.8f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
)
}
CopyButton(
visible = showCopyButton,
textToCopy = message.content,
onCopy = { showCopyButton = false },
modifier =
Modifier
.align(Alignment.TopEnd)
.offset(x = 8.dp, y = (-8).dp),
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.chat_tool_show_parsed),
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
),
modifier =
Modifier
.testTag("chat_tool_show_parsed")
.clickable(role = Role.Button) { showRawJson = false },
)
} else if (parsed != null) {
// Clean structured expanded view
Box {
SelectionContainer {
ExpandedToolContent(parsed, contentColor, statusColors)
}
CopyButton(
visible = showCopyButton,
textToCopy = message.content,
onCopy = { showCopyButton = false },
modifier =
Modifier
.align(Alignment.TopEnd)
.offset(x = 8.dp, y = (-8).dp),
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.chat_tool_show_raw),
style =
MaterialTheme.typography.labelSmall.copy(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
),
modifier =
Modifier
.testTag("chat_tool_show_raw")
.clickable(role = Role.Button) { showRawJson = true },
)
} else {
// Unparseable content — show raw JSON, selectable
Box {
SelectionContainer {
Text(
text = message.content,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.8f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
)
}
CopyButton(
visible = showCopyButton,
textToCopy = message.content,
onCopy = { showCopyButton = false },
modifier =
Modifier
.align(Alignment.TopEnd)
.offset(x = 8.dp, y = (-8).dp),
)
}
}
}
// ── Timestamp ──
Text(
text = formatTimestamp(message.timestamp, DateFormat.is24HourFormat(LocalContext.current)),
color = contentColor.copy(alpha = 0.5f),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.align(Alignment.End).padding(top = 4.dp),
)
}
}
}
}
@Composable
private fun HeaderRow(
message: ChatMessage,
config: ToolDisplayConfig,
contentColor: Color,
statusColors: HermesStatusColors,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Status icon or spinner
if (message.toolStatus == ToolStatus.RUNNING) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.secondary,
)
} else {
val icon =
when (message.toolStatus) {
ToolStatus.COMPLETED -> Icons.Filled.CheckCircle
ToolStatus.FAILED -> Icons.Filled.Error
else -> Icons.Filled.Build
}
val tint =
when (message.toolStatus) {
ToolStatus.COMPLETED -> statusColors.success
ToolStatus.FAILED -> statusColors.error
else -> contentColor.copy(alpha = 0.6f)
}
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = tint,
)
}
Text(
text = message.toolName ?: stringResource(R.string.chat_tool_fallback),
style =
MaterialTheme.typography.labelMedium.copy(
color = contentColor,
fontFamily = FontFamily.Monospace,
),
)
}
}
/**
* Security risk chip for [tool.output_risk] events.
*
* Shows a compact ⚠ badge when the backend flagged tool output as risky.
* Renders in the tool card between the header row and the summary line.
*/
@Composable
private fun SecurityRiskChip(
riskData: ToolOutputRiskData,
contentColor: Color,
modifier: Modifier = Modifier,
) {
val statusColors = LocalHermesStatusColors.current
val (chipColor, label) =
when {
riskData.risk == "high" -> statusColors.error to "Risky output"
riskData.risk == "medium" -> statusColors.warning to "Caution"
else -> statusColors.warning to "Redacted"
}
Row(
modifier =
modifier
.padding(top = 4.dp, start = 22.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "⚠",
style = MaterialTheme.typography.labelSmall,
color = chipColor,
)
Text(
text = label,
style =
MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Medium,
),
color = chipColor,
)
if (riskData.redacted && (riskData.risk == "high" || riskData.risk == "medium")) {
Text(
text = "· redacted",
style = MaterialTheme.typography.labelSmall,
color = chipColor.copy(alpha = 0.7f),
)
}
}
}
@Composable
private fun CopyButton(
visible: Boolean,
textToCopy: String,
onCopy: () -> Unit,
modifier: Modifier = Modifier,
) {
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
AnimatedVisibility(
visible = visible,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
modifier = modifier,
) {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
shadowElevation = 6.dp,
) {
IconButton(
onClick = {
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, textToCopy)))
}
onCopy()
},
modifier = Modifier.size(32.dp),
) {
Icon(
Icons.Filled.ContentCopy,
contentDescription = stringResource(R.string.content_desc_copy),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -54,7 +54,6 @@ class ChatScrollController(
var pendingCount by mutableStateOf(0)
private set
private var lastSessionId: String? = null
private var lastTailKey: Any? = null
private var lastMessageCount: Int = 0