feat(chat): interactive diff view for file edit and patch tool output (#738) (#741)

* feat(chat): interactive diff view for patch and file edit tool output

* fix(chat): address agy review feedback for diff viewer UX and layout

* fix(chat): suppress raw text/json when diff view renders and fix sync message ordering race (#741)

* fix(tests): resolve nullable smart-cast errors in ToolBubbleParsingTest

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-29 12:05:56 +03:00
1 parent e39544ebd5
commit 8ba92f2567
5 files changed
+569 -49

No files matched your search

@@ -98,3 +98,11 @@ val CodeTerminalBg = Color(0xFF1E1E1E)
val CodeTerminalBorder = Color(0xFF333333)
val CodeTerminalText = Color(0xFFD4D4D4)
val CodeTerminalMuted = Color(0xFF808080)
// ── Diff viewer tokens (issue #738) ──────────────────────────────────
val CodeDiffAddBg = Color(0x263DDC84)
val CodeDiffAddText = Color(0xFF3DDC84)
val CodeDiffDeleteBg = Color(0x26FF5C5C)
val CodeDiffDeleteText = Color(0xFFFF5C5C)
val CodeDiffHunkBg = Color(0x264DA8FF)
val CodeDiffHunkText = Color(0xFF4DA8FF)
@@ -85,6 +85,7 @@ import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.LightOnSurface
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.onColorFor
import com.m57.hermescontrol.ui.chat.components.DiffViewCard
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -560,6 +561,8 @@ data class ParsedToolData(
val mainOutput: String? = null,
val extraFields: Map<String, String> = emptyMap(),
val isRunning: Boolean = false,
val diffOutput: String? = null,
val diffPath: String? = null,
)
private fun formatTodoToolOutput(
@@ -1688,7 +1691,19 @@ fun parseToolOutput(
val hasStderr = dataSource.has("stderr")
val hasExitCode = dataSource.has("exit_code") || dataSource.has("exitCode")
if (hasOutput || hasStdout || hasStderr || hasExitCode) {
val isTerminalTool =
resolvedToolName == "terminal" ||
resolvedToolName == "bash" ||
resolvedToolName == "sh" ||
resolvedToolName == "cmd" ||
resolvedToolName == "exec"
val isDiffTool =
resolvedToolName == "patch" ||
resolvedToolName == "write_file" ||
resolvedToolName == "edit"
if (!isDiffTool && (isTerminalTool || hasOutput || hasStdout || hasStderr || hasExitCode)) {
val terminalOutput =
(
dataSource.get("output")?.takeIf { !it.isJsonNull }?.asString
@@ -1760,6 +1775,50 @@ fun parseToolOutput(
}
}
val diffCandidate =
dataSource.get("diff")?.takeIf { !it.isJsonNull }?.let {
if (it.isJsonPrimitive) it.asString else it.toString()
}
?: dataSource.get("patch")?.takeIf { !it.isJsonNull }?.let {
if (it.isJsonPrimitive) it.asString else it.toString()
}
?: mainOutput
val filePathArg =
args["path"]?.toString()
?: argsObj?.get("path")?.takeIf { !it.isJsonNull }?.asString
val oldStrArg = args["old_string"]?.toString()
val newStrArg = args["new_string"]?.toString()
val isDiffTool =
resolvedToolName == "patch" ||
resolvedToolName == "edit" ||
resolvedToolName == "write_file"
val extractedDiffOutput =
when {
diffCandidate != null && (
diffCandidate.contains("\n-") ||
diffCandidate.contains("\n+") ||
diffCandidate.startsWith("--- ") ||
diffCandidate.startsWith("+++ ") ||
diffCandidate.startsWith("*** ") ||
diffCandidate.startsWith("@@ ")
) -> diffCandidate
isDiffTool && oldStrArg != null && newStrArg != null -> {
val pathHeader = filePathArg ?: "file"
"--- $pathHeader\n+++ $pathHeader\n@@ -1,1 +1,1 @@\n-$oldStrArg\n+$newStrArg"
}
isDiffTool && args["patch"] != null -> {
args["patch"].toString()
}
else -> null
}
val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble
ParsedToolData(
@@ -1771,6 +1830,8 @@ fun parseToolOutput(
extraFields = extraFields,
durationSec = duration,
isRunning = isRunning,
diffOutput = extractedDiffOutput,
diffPath = filePathArg,
)
}
} catch (e: Exception) {
@@ -1848,51 +1909,57 @@ private fun ExpandedToolContent(
)
}
} else {
// ── Generic tool output ──
parsed.mainOutput?.let {
Text(
text = it,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.9f),
fontSize = 12.sp,
),
// ── Generic tool output / Diff view ──
if (parsed.diffOutput != null) {
DiffViewCard(
diffText = parsed.diffOutput,
filePath = parsed.diffPath,
)
}
parsed.extraFields.forEach { (key, value) ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
} else {
parsed.mainOutput?.let {
Text(
text = "$key:",
style =
MaterialTheme.typography.labelSmall.copy(
color = contentColor.copy(alpha = 0.6f),
fontWeight = FontWeight.Bold,
),
)
Text(
text = value,
text = it,
style =
MaterialTheme.typography.bodySmall.copy(
color = contentColor.copy(alpha = 0.9f),
fontSize = 11.sp,
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),
),
)
}
}
// Duration footer
parsed.durationSec?.let { dur ->
Text(
text = "Duration: ${"%.1f".format(dur)}s",
style =
MaterialTheme.typography.labelSmall.copy(
color = contentColor.copy(alpha = 0.5f),
),
)
}
}
}
@@ -1571,21 +1571,39 @@ class ChatViewModel(
withContext(Dispatchers.IO) { repo.persistMessages(incoming, sessionId) }
_uiState.update { current ->
if (current.currentSessionId != sessionId) return@update current
val unmatched = incoming.map { it.role to it.content }.toMutableList()
val retained =
current.messages.filter { existing ->
if (serverMessageIndex(existing.id, sessionId) != null) {
true
val unmatchedIncoming = incoming.toMutableList()
val mergedList = mutableListOf<ChatMessage>()
for (existing in current.messages) {
val existingServerIndex = serverMessageIndex(existing.id, sessionId)
if (existingServerIndex != null) {
val matchIdx = unmatchedIncoming.indexOfFirst { it.id == existing.id }
if (matchIdx >= 0) {
mergedList.add(unmatchedIncoming.removeAt(matchIdx))
} else {
val duplicate =
unmatched.indexOfFirst { (role, content) ->
role == existing.role && content == existing.content
}
if (duplicate >= 0) unmatched.removeAt(duplicate)
duplicate < 0
mergedList.add(existing)
}
} else {
val matchIdx =
unmatchedIncoming.indexOfFirst { inc ->
inc.role == existing.role && (
inc.content == existing.content ||
(
existing.role == MessageRole.TOOL &&
inc.toolName != null &&
inc.toolName == existing.toolName
)
)
}
if (matchIdx >= 0) {
mergedList.add(unmatchedIncoming.removeAt(matchIdx))
} else {
mergedList.add(existing)
}
}
val merged = (retained + incoming).distinctBy { it.id }
}
mergedList.addAll(unmatchedIncoming)
val merged = mergedList.distinctBy { it.id }
if (sameMessages(current.messages, merged)) current else current.copy(messages = merged)
}
}
@@ -0,0 +1,352 @@
package com.m57.hermescontrol.ui.chat.components
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
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.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
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.material3.TextButton
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.theme.CodeDiffAddBg
import com.m57.hermescontrol.theme.CodeDiffAddText
import com.m57.hermescontrol.theme.CodeDiffDeleteBg
import com.m57.hermescontrol.theme.CodeDiffDeleteText
import com.m57.hermescontrol.theme.CodeDiffHunkBg
import com.m57.hermescontrol.theme.CodeDiffHunkText
import com.m57.hermescontrol.theme.CodeTerminalBg
import com.m57.hermescontrol.theme.CodeTerminalBorder
import com.m57.hermescontrol.theme.CodeTerminalMuted
import com.m57.hermescontrol.theme.CodeTerminalText
import kotlinx.coroutines.delay
enum class DiffLineType {
FILE_HEADER,
HUNK_HEADER,
ADDED,
DELETED,
CONTEXT,
}
data class ParsedDiffLine(
val type: DiffLineType,
val text: String,
)
data class ParsedDiffResult(
val filePath: String?,
val lines: List<ParsedDiffLine>,
val additionsCount: Int,
val deletionsCount: Int,
)
/**
* Parses unified diff or patch text into structured lines and line counts.
*/
fun parseDiffText(
diffText: String,
defaultPath: String? = null,
): ParsedDiffResult {
if (diffText.isBlank()) {
return ParsedDiffResult(
filePath = defaultPath,
lines = emptyList(),
additionsCount = 0,
deletionsCount = 0,
)
}
val rawLines = diffText.lines()
val parsedLines = mutableListOf<ParsedDiffLine>()
var extractedPath: String? = defaultPath
var additions = 0
var deletions = 0
for (line in rawLines) {
when {
line.startsWith("--- ") || line.startsWith("+++ ") -> {
if (extractedPath == null || extractedPath == defaultPath) {
val candidate =
line.drop(4)
.trim()
.removePrefix("a/")
.removePrefix("b/")
.split("\t")
.firstOrNull()
if (!candidate.isNullOrBlank() && candidate != "/dev/null") {
extractedPath = candidate
}
}
parsedLines.add(ParsedDiffLine(DiffLineType.FILE_HEADER, line))
}
line.startsWith("*** Update File:") ||
line.startsWith("*** Add File:") ||
line.startsWith("*** Delete File:") -> {
val candidate = line.substringAfter(":").trim()
if (candidate.isNotBlank()) {
extractedPath = candidate
}
parsedLines.add(ParsedDiffLine(DiffLineType.FILE_HEADER, line))
}
line.startsWith("@@ ") || line.startsWith("*** ") -> {
parsedLines.add(ParsedDiffLine(DiffLineType.HUNK_HEADER, line))
}
line.startsWith("+") -> {
additions++
parsedLines.add(ParsedDiffLine(DiffLineType.ADDED, line))
}
line.startsWith("-") -> {
deletions++
parsedLines.add(ParsedDiffLine(DiffLineType.DELETED, line))
}
else -> {
parsedLines.add(ParsedDiffLine(DiffLineType.CONTEXT, line))
}
}
}
return ParsedDiffResult(
filePath = extractedPath ?: defaultPath,
lines = parsedLines,
additionsCount = additions,
deletionsCount = deletions,
)
}
/**
* Interactive diff view card for file edit & patch tool outputs.
*/
@Composable
fun DiffViewCard(
diffText: String,
filePath: String? = null,
onCopy: (String) -> Unit = {},
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val parsed = remember(diffText, filePath) { parseDiffText(diffText, filePath) }
var expanded by remember { mutableStateOf(false) }
var copied by remember { mutableStateOf(false) }
LaunchedEffect(copied) {
if (copied) {
delay(2000)
copied = false
}
}
val displayLines =
if (expanded || parsed.lines.size <= 16) {
parsed.lines
} else {
parsed.lines.take(16)
}
Surface(
modifier =
modifier
.fillMaxWidth()
.testTag("diff_view_card"),
shape = RoundedCornerShape(8.dp),
color = CodeTerminalBg,
border = BorderStroke(1.dp, CodeTerminalBorder),
) {
Column(modifier = Modifier.animateContentSize()) {
// Header bar
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.InsertDriveFile,
contentDescription = null,
tint = CodeTerminalMuted,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(6.dp))
Text(
text = parsed.filePath ?: "diff",
style =
MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
color = CodeTerminalText,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(Modifier.width(8.dp))
// Addition / Deletion badges
if (parsed.additionsCount > 0) {
Text(
text = "+${parsed.additionsCount}",
style =
MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
color = CodeDiffAddText,
),
)
Spacer(Modifier.width(6.dp))
}
if (parsed.deletionsCount > 0) {
Text(
text = "-${parsed.deletionsCount}",
style =
MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
color = CodeDiffDeleteText,
),
)
Spacer(Modifier.width(6.dp))
}
}
IconButton(
onClick = {
val clipboard =
context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
clipboard?.setPrimaryClip(ClipData.newPlainText("diff", diffText))
copied = true
onCopy(diffText)
},
modifier = Modifier.size(28.dp),
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription = if (copied) "Copied" else "Copy diff",
tint = CodeTerminalMuted,
modifier = Modifier.size(14.dp),
)
}
}
// Diff lines body
val verticalScrollModifier =
if (expanded) {
Modifier.heightIn(max = 400.dp).verticalScroll(rememberScrollState())
} else {
Modifier
}
Column(
modifier =
Modifier
.fillMaxWidth()
.then(verticalScrollModifier)
.horizontalScroll(rememberScrollState())
.width(IntrinsicSize.Max),
) {
displayLines.forEach { line ->
val (bgColor, textColor) =
when (line.type) {
DiffLineType.ADDED -> CodeDiffAddBg to CodeDiffAddText
DiffLineType.DELETED -> CodeDiffDeleteBg to CodeDiffDeleteText
DiffLineType.HUNK_HEADER -> CodeDiffHunkBg to CodeDiffHunkText
DiffLineType.FILE_HEADER -> Color.Transparent to CodeTerminalMuted
DiffLineType.CONTEXT -> Color.Transparent to CodeTerminalText
}
Box(
modifier =
Modifier
.fillMaxWidth()
.background(bgColor)
.padding(horizontal = 10.dp, vertical = 2.dp),
) {
Text(
text = line.text,
style =
MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = textColor,
),
)
}
}
}
// Expand / Collapse footer button for diffs > 16 lines
if (parsed.lines.size > 16) {
TextButton(
onClick = { expanded = !expanded },
modifier = Modifier.fillMaxWidth(),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text =
if (expanded) {
"Collapse diff"
} else {
"Show full diff (${parsed.lines.size} lines)"
},
style = MaterialTheme.typography.labelSmall,
color = CodeTerminalMuted,
)
Spacer(Modifier.width(4.dp))
Icon(
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
contentDescription = null,
tint = CodeTerminalMuted,
modifier = Modifier.size(16.dp),
)
}
}
}
}
}
}