* 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:
1 parent
e39544ebd5
commit
8ba92f2567
5 files changed
+535
-15
No files matched your search
@@ -98,3 +98,11 @@ val CodeTerminalBg = Color(0xFF1E1E1E)
|
|||||||
val CodeTerminalBorder = Color(0xFF333333)
|
val CodeTerminalBorder = Color(0xFF333333)
|
||||||
val CodeTerminalText = Color(0xFFD4D4D4)
|
val CodeTerminalText = Color(0xFFD4D4D4)
|
||||||
val CodeTerminalMuted = Color(0xFF808080)
|
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.LightOnSurface
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.theme.onColorFor
|
import com.m57.hermescontrol.theme.onColorFor
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.DiffViewCard
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -560,6 +561,8 @@ data class ParsedToolData(
|
|||||||
val mainOutput: String? = null,
|
val mainOutput: String? = null,
|
||||||
val extraFields: Map<String, String> = emptyMap(),
|
val extraFields: Map<String, String> = emptyMap(),
|
||||||
val isRunning: Boolean = false,
|
val isRunning: Boolean = false,
|
||||||
|
val diffOutput: String? = null,
|
||||||
|
val diffPath: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun formatTodoToolOutput(
|
private fun formatTodoToolOutput(
|
||||||
@@ -1688,7 +1691,19 @@ fun parseToolOutput(
|
|||||||
val hasStderr = dataSource.has("stderr")
|
val hasStderr = dataSource.has("stderr")
|
||||||
val hasExitCode = dataSource.has("exit_code") || dataSource.has("exitCode")
|
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 =
|
val terminalOutput =
|
||||||
(
|
(
|
||||||
dataSource.get("output")?.takeIf { !it.isJsonNull }?.asString
|
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
|
val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble
|
||||||
|
|
||||||
ParsedToolData(
|
ParsedToolData(
|
||||||
@@ -1771,6 +1830,8 @@ fun parseToolOutput(
|
|||||||
extraFields = extraFields,
|
extraFields = extraFields,
|
||||||
durationSec = duration,
|
durationSec = duration,
|
||||||
isRunning = isRunning,
|
isRunning = isRunning,
|
||||||
|
diffOutput = extractedDiffOutput,
|
||||||
|
diffPath = filePathArg,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -1848,7 +1909,13 @@ private fun ExpandedToolContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// ── Generic tool output ──
|
// ── Generic tool output / Diff view ──
|
||||||
|
if (parsed.diffOutput != null) {
|
||||||
|
DiffViewCard(
|
||||||
|
diffText = parsed.diffOutput,
|
||||||
|
filePath = parsed.diffPath,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
parsed.mainOutput?.let {
|
parsed.mainOutput?.let {
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = it,
|
||||||
@@ -1882,7 +1949,8 @@ private fun ExpandedToolContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Duration footer
|
// Duration footer
|
||||||
parsed.durationSec?.let { dur ->
|
parsed.durationSec?.let { dur ->
|
||||||
Text(
|
Text(
|
||||||
@@ -1895,7 +1963,6 @@ private fun ExpandedToolContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ToolBubble(
|
private fun ToolBubble(
|
||||||
|
|||||||
@@ -1571,21 +1571,39 @@ class ChatViewModel(
|
|||||||
withContext(Dispatchers.IO) { repo.persistMessages(incoming, sessionId) }
|
withContext(Dispatchers.IO) { repo.persistMessages(incoming, sessionId) }
|
||||||
_uiState.update { current ->
|
_uiState.update { current ->
|
||||||
if (current.currentSessionId != sessionId) return@update current
|
if (current.currentSessionId != sessionId) return@update current
|
||||||
val unmatched = incoming.map { it.role to it.content }.toMutableList()
|
val unmatchedIncoming = incoming.toMutableList()
|
||||||
val retained =
|
val mergedList = mutableListOf<ChatMessage>()
|
||||||
current.messages.filter { existing ->
|
|
||||||
if (serverMessageIndex(existing.id, sessionId) != null) {
|
for (existing in current.messages) {
|
||||||
true
|
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 {
|
} else {
|
||||||
val duplicate =
|
mergedList.add(existing)
|
||||||
unmatched.indexOfFirst { (role, content) ->
|
|
||||||
role == existing.role && content == existing.content
|
|
||||||
}
|
}
|
||||||
if (duplicate >= 0) unmatched.removeAt(duplicate)
|
} else {
|
||||||
duplicate < 0
|
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)
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.m57.hermescontrol.ui.chat
|
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.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
@@ -244,4 +246,77 @@ class ToolBubbleParsingTest {
|
|||||||
assertEquals(0, parsed.exitCode)
|
assertEquals(0, parsed.exitCode)
|
||||||
assertNull(parsed.summaryText) // no args → no summary
|
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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user