diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt index 1807fcba..bd817b9b 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt @@ -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) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 0190317e..b36daac9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -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 = 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), + ), + ) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 2d47ff1b..62e32369 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -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() + + 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) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/DiffViewCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/DiffViewCard.kt new file mode 100644 index 00000000..d791fc37 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/DiffViewCard.kt @@ -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, + 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() + 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), + ) + } + } + } + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ToolBubbleParsingTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ToolBubbleParsingTest.kt index 83545edc..f7fdfc46 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ToolBubbleParsingTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ToolBubbleParsingTest.kt @@ -1,5 +1,7 @@ package com.m57.hermescontrol.ui.chat +import com.m57.hermescontrol.ui.chat.components.DiffLineType +import com.m57.hermescontrol.ui.chat.components.parseDiffText import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -244,4 +246,77 @@ class ToolBubbleParsingTest { assertEquals(0, parsed.exitCode) assertNull(parsed.summaryText) // no args → no summary } + + // ── Diff parsing & Patch tool tests ─────────────────────── + + @Test + fun testParseDiffText_unifiedDiff() { + val rawDiff = + """ + --- app/src/Main.kt + +++ app/src/Main.kt + @@ -1,3 +1,3 @@ + fun main() { + - println("hello") + + println("hello world") + } + """.trimIndent() + + val result = parseDiffText(rawDiff) + assertEquals("app/src/Main.kt", result.filePath) + assertEquals(1, result.additionsCount) + assertEquals(1, result.deletionsCount) + + val addedLine = result.lines.find { it.type == DiffLineType.ADDED } + assertNotNull(addedLine) + assertEquals("+ println(\"hello world\")", addedLine!!.text) + } + + @Test + fun testParseToolOutput_patchReplaceMode() { + val json = + """{ + "tool_id": "call_patch_1", + "name": "patch", + "args": { + "path": "app/config.json", + "old_string": "debug=false", + "new_string": "debug=true" + }, + "result": { + "output": "--- app/config.json\n+++ app/config.json\n@@ -1,1 +1,1 @@\n-debug=false\n+debug=true" + } + }""" + val parsed = parseToolOutput(json, "patch", false) + assertNotNull(parsed) + assertEquals("app/config.json", parsed!!.diffPath) + val diffOutput = parsed.diffOutput + assertNotNull(diffOutput) + assertTrue(diffOutput!!.contains("-debug=false")) + assertTrue(diffOutput.contains("+debug=true")) + } + + @Test + fun testParseToolOutput_editTool() { + val json = + """{ + "tool_id": "call_edit_1", + "name": "edit", + "args": { + "path": "app/src/Main.kt", + "old_string": "val x = 1", + "new_string": "val x = 2" + }, + "result": { + "output": "--- app/src/Main.kt\n+++ app/src/Main.kt\n@@ -1,1 +1,1 @@\n-val x = 1\n+val x = 2" + } + }""" + val parsed = parseToolOutput(json, "edit", false) + assertNotNull(parsed) + assertEquals("app/src/Main.kt", parsed!!.diffPath) + val editDiff = parsed.diffOutput + assertNotNull(editDiff) + assertTrue(editDiff!!.contains("-val x = 1")) + assertTrue(editDiff.contains("+val x = 2")) + } }