diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/FileToolBubbleTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/FileToolBubbleTest.kt new file mode 100644 index 00000000..fa5d3736 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/FileToolBubbleTest.kt @@ -0,0 +1,95 @@ +package com.m57.hermescontrol.ui.chat + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.theme.HermesControlTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class FileToolBubbleTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun tappingReadFileBubbleShowsFileViewCardWithSyntaxViewer() { + val payload = + """ + { + "args": {"path": "/repo/src/Main.kt", "offset": 1, "limit": 2}, + "result": {"content": "1|fun main() {\n2| println(\"hi\")\n3|}"} + } + """.trimIndent() + + showBubble("read_file", payload) + + composeRule.onNodeWithText("Read Main.kt L1-2", substring = true).performClick() + composeRule.onNodeWithTag("file_view_card", useUnmergedTree = true).assertExists() + composeRule.onNodeWithText("/repo/src/Main.kt", useUnmergedTree = true).assertExists() + composeRule.onNodeWithText("3 lines", useUnmergedTree = true).assertExists() + } + + @Test + fun tappingWriteFileBubbleShowsFileViewCardWhenNoDiff() { + val payload = + """ + { + "args": { + "path": "/repo/src/Config.json", + "content": "{\n \"enabled\": true\n}" + }, + "result": {"verified": true, "success": true} + } + """.trimIndent() + + showBubble("write_file", payload) + + composeRule.onNodeWithText("Config.json", substring = true).performClick() + composeRule.onNodeWithTag("file_view_card", useUnmergedTree = true).assertExists() + composeRule.onNodeWithText("/repo/src/Config.json", useUnmergedTree = true).assertExists() + composeRule.onNodeWithText("3 lines", useUnmergedTree = true).assertExists() + } + + @Test + fun tappingWriteFileWithDiffShowsDiffViewCard() { + val payload = + """ + { + "args": {"path": "/repo/src/Utils.kt"}, + "result": { + "inline_diff": "--- a/Utils.kt\n+++ b/Utils.kt\n@@ -1,1 +1,1 @@\n-old\n+new" + } + } + """.trimIndent() + + showBubble("write_file", payload) + + composeRule.onNodeWithText("Utils.kt", substring = true).performClick() + composeRule.onNodeWithTag("diff_view_card", useUnmergedTree = true).assertExists() + composeRule.onNodeWithText("Utils.kt", useUnmergedTree = true).assertExists() + } + + private fun showBubble( + name: String, + content: String, + ) { + composeRule.setContent { + HermesControlTheme { + ToolBubble( + ChatMessage( + role = MessageRole.TOOL, + content = content, + toolName = name, + toolStatus = ToolStatus.COMPLETED, + ), + ) + } + } + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/FileViewCardTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/FileViewCardTest.kt new file mode 100644 index 00000000..eed04ae4 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/FileViewCardTest.kt @@ -0,0 +1,78 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class FileViewCardTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun shortFile_displaysPathLineCountAndFullContentWithoutExpandButton() { + val content = "fun hello() = println(\"world\")\nval x = 42" + var copiedText = "" + + composeTestRule.setContent { + FileViewCard( + content = content, + filePath = "src/main/Test.kt", + onCopy = { copiedText = it }, + ) + } + + composeTestRule.onNodeWithTag("file_view_card").assertIsDisplayed() + composeTestRule.onNodeWithText("src/main/Test.kt").assertIsDisplayed() + composeTestRule.onNodeWithText("2 lines").assertIsDisplayed() + composeTestRule.onNodeWithText("Show full file (2 lines)").assertDoesNotExist() + + composeTestRule.onNodeWithTag("file_view_card").assertIsDisplayed() + } + + @Test + fun longFile_showsExpandButtonAndTogglesFullFile() { + val lines = (1..25).map { "line $it: println($it)" } + val content = lines.joinToString("\n") + + composeTestRule.setContent { + FileViewCard( + content = content, + filePath = "src/Big.kt", + ) + } + + composeTestRule.onNodeWithText("Show full file (25 lines)").assertIsDisplayed() + composeTestRule.onNodeWithText("Show full file (25 lines)").performClick() + composeTestRule.onNodeWithText("Collapse file").assertIsDisplayed() + composeTestRule.onNodeWithText("Collapse file").performClick() + composeTestRule.onNodeWithText("Show full file (25 lines)").assertIsDisplayed() + } + + @Test + fun copyButton_invokesCallbackWithFullContent() { + val content = "1|fun main() = Unit" + var copied = "" + + composeTestRule.setContent { + FileViewCard( + content = content, + filePath = "src/App.kt", + onCopy = { copied = it }, + ) + } + composeTestRule.onNodeWithText("src/App.kt").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Copy file content").performClick() + assertEquals(content, copied) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt index 5ed735bd..54a8b4d7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt @@ -75,7 +75,9 @@ import com.m57.hermescontrol.theme.CodeTerminalMuted import com.m57.hermescontrol.theme.CodeTerminalText import com.m57.hermescontrol.theme.HermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.chat.components.CodeTerminalCard import com.m57.hermescontrol.ui.chat.components.DiffViewCard +import com.m57.hermescontrol.ui.chat.components.FileViewCard import com.m57.hermescontrol.ui.chat.components.highlightSyntax import com.m57.hermescontrol.ui.chat.tool.ToolJson import com.m57.hermescontrol.ui.chat.tool.ToolView @@ -423,6 +425,11 @@ private fun ExpandedToolContent( diffText = view.inlineDiff, filePath = view.diffPath, ) + } else if (view.fileContent != null) { + FileViewCard( + content = view.fileContent, + filePath = view.filePath, + ) } // ── Search hits ── @@ -478,7 +485,7 @@ private fun ExpandedToolContent( } // ── Plain detail body ── - if (view.detail.isNotBlank() && view.inlineDiff == null) { + if (view.detail.isNotBlank() && view.inlineDiff == null && view.fileContent == null) { Text( text = view.detail, modifier = @@ -636,99 +643,49 @@ private fun ToolRawJsonView( } } - val clipboard = LocalClipboard.current - val scope = rememberCoroutineScope() - var copied by remember { mutableStateOf(false) } - LaunchedEffect(copied) { - if (copied) { - delay(2000) - copied = false - } - } - - Surface( - modifier = - modifier - .fillMaxWidth() - .testTag("tool_raw_json"), - shape = RoundedCornerShape(8.dp), - color = CodeTerminalBg, - border = BorderStroke(1.dp, CodeTerminalBorder), + CodeTerminalCard( + textToCopy = displayText, + modifier = modifier, + testTag = "tool_raw_json", + title = "JSON", + copyContentDescription = stringResource(R.string.content_desc_copy), + headerActions = { + if (isFormatDifferent) { + Text( + text = + if (formatJson) { + stringResource(R.string.chat_tool_compact_json) + } else { + stringResource(R.string.chat_tool_format_json) + }, + style = + MaterialTheme.typography.labelSmall.copy( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + modifier = + Modifier + .testTag("tool_json_format_toggle") + .clickable(role = Role.Button) { formatJson = !formatJson } + .padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + }, ) { - Column { - Row( + SelectionContainer { + Text( + text = highlighted, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "JSON", - style = - MaterialTheme.typography.labelSmall.copy( - fontWeight = FontWeight.Bold, - color = CodeTerminalMuted, - ), - ) - Spacer(Modifier.weight(1f)) - if (isFormatDifferent) { - Text( - text = - if (formatJson) { - stringResource(R.string.chat_tool_compact_json) - } else { - stringResource(R.string.chat_tool_format_json) - }, - style = - MaterialTheme.typography.labelSmall.copy( - color = MaterialTheme.colorScheme.primary, - textDecoration = TextDecoration.Underline, - ), - modifier = - Modifier - .testTag("tool_json_format_toggle") - .clickable(role = Role.Button) { formatJson = !formatJson } - .padding(horizontal = 6.dp, vertical = 2.dp), - ) - } - IconButton( - onClick = { - scope.launch { - clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, displayText))) - } - copied = true - }, - modifier = Modifier.size(28.dp), - ) { - Icon( - imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, - contentDescription = - if (copied) { - stringResource(R.string.content_desc_copied) - } else { - stringResource(R.string.content_desc_copy) - }, - tint = CodeTerminalMuted, - modifier = Modifier.size(14.dp), - ) - } - } - SelectionContainer { - Text( - text = highlighted, - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 280.dp) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 10.dp, vertical = 6.dp), - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, - color = CodeTerminalText, - softWrap = true, - ) - } + .heightIn(max = 280.dp) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 10.dp, vertical = 6.dp), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + color = CodeTerminalText, + softWrap = true, + ) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeTerminalCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeTerminalCard.kt new file mode 100644 index 00000000..4f84be97 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/CodeTerminalCard.kt @@ -0,0 +1,193 @@ +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.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +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.material.icons.Icons +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.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +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 com.m57.hermescontrol.R +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 + +/** + * Shared container card for terminal-styled code, diff, and JSON viewers. + * + * Provides the dark terminal surface, consistent header row with leading icon/label, + * optional status badges/actions, animated copy-to-clipboard button, content body slot, + * and optional collapsible footer. + */ +@Composable +fun CodeTerminalCard( + textToCopy: String, + modifier: Modifier = Modifier, + testTag: String? = null, + title: String? = null, + icon: ImageVector? = null, + clipLabel: String? = null, + copyContentDescription: String = stringResource(R.string.content_desc_copy), + onCopy: ((String) -> Unit)? = null, + headerLeading: (@Composable RowScope.() -> Unit)? = null, + headerActions: (@Composable RowScope.() -> Unit)? = null, + totalLines: Int? = null, + collapsibleThreshold: Int = 16, + isExpanded: Boolean = false, + onToggleExpand: (() -> Unit)? = null, + expandLabel: String = "Show full", + collapseLabel: String = "Collapse", + content: @Composable () -> Unit, +) { + val context = LocalContext.current + var copied by remember { mutableStateOf(false) } + + LaunchedEffect(copied) { + if (copied) { + delay(2000) + copied = false + } + } + + Surface( + modifier = + modifier + .fillMaxWidth() + .then(if (testTag != null) Modifier.testTag(testTag) else Modifier), + 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, + ) { + if (headerLeading != null) { + headerLeading() + } else { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = CodeTerminalMuted, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(6.dp)) + } + if (!title.isNullOrBlank()) { + Text( + text = title, + 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)) + } + } + + headerActions?.invoke(this) + } + + IconButton( + onClick = { + val clipboard = + context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + clipboard?.setPrimaryClip(ClipData.newPlainText(clipLabel, textToCopy)) + copied = true + onCopy?.invoke(textToCopy) + }, + modifier = Modifier.size(28.dp), + ) { + Icon( + imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, + contentDescription = + if (copied) { + stringResource(R.string.content_desc_copied) + } else { + copyContentDescription + }, + tint = CodeTerminalMuted, + modifier = Modifier.size(14.dp), + ) + } + } + + // Body + content() + + // Expand / Collapse footer button + if (totalLines != null && totalLines > collapsibleThreshold && onToggleExpand != null) { + TextButton( + onClick = onToggleExpand, + modifier = Modifier.fillMaxWidth(), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (isExpanded) collapseLabel else expandLabel, + style = MaterialTheme.typography.labelSmall, + color = CodeTerminalMuted, + ) + Spacer(Modifier.width(4.dp)) + Icon( + imageVector = if (isExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = null, + tint = CodeTerminalMuted, + modifier = Modifier.size(16.dp), + ) + } + } + } + } + } +} 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 index af3f116e..105db8ac 100644 --- 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 @@ -1,51 +1,33 @@ 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.text.selection.SelectionContainer 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.produceState 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.AnnotatedString 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 @@ -54,11 +36,10 @@ 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 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext enum class DiffLineType { FILE_HEADER, @@ -169,17 +150,8 @@ fun DiffViewCard( 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) { @@ -188,166 +160,178 @@ fun DiffViewCard( parsed.lines.take(16) } - Surface( - modifier = - modifier - .fillMaxWidth() - .testTag("diff_view_card"), - shape = RoundedCornerShape(8.dp), - color = CodeTerminalBg, - border = BorderStroke(1.dp, CodeTerminalBorder), + CodeTerminalCard( + textToCopy = diffText, + modifier = modifier, + testTag = "diff_view_card", + title = parsed.filePath ?: "diff", + icon = Icons.AutoMirrored.Filled.InsertDriveFile, + clipLabel = "diff", + copyContentDescription = "Copy diff", + onCopy = onCopy, + headerActions = { + 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)) + } + }, + totalLines = parsed.lines.size, + isExpanded = expanded, + onToggleExpand = { expanded = !expanded }, + expandLabel = "Show full diff (${parsed.lines.size} lines)", + collapseLabel = "Collapse diff", ) { - 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, + 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), ) { - 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", + text = line.text, style = - MaterialTheme.typography.labelMedium.copy( - fontWeight = FontWeight.Bold, + MaterialTheme.typography.bodySmall.copy( fontFamily = FontFamily.Monospace, - color = CodeTerminalText, + fontSize = 11.sp, + color = textColor, ), - 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), - ) - } } } } } } + +/** + * Interactive file content view card for read_file & write_file tool outputs. + * Mirrors [DiffViewCard] with syntax highlighting and file path header. + */ +@Composable +fun FileViewCard( + content: String, + filePath: String? = null, + onCopy: (String) -> Unit = {}, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + val lines = remember(content) { content.lines() } + val displayText = + remember(content, expanded) { + if (expanded || lines.size <= 16) { + content + } else { + lines.take(16).joinToString("\n") + } + } + val highlighted by produceState( + initialValue = remember(displayText) { AnnotatedString(displayText) }, + key1 = displayText, + ) { + value = + withContext(Dispatchers.Default) { + highlightSyntax(displayText) + } + } + + CodeTerminalCard( + textToCopy = content, + modifier = modifier, + testTag = "file_view_card", + title = filePath?.takeIf { it.isNotBlank() } ?: "file", + icon = Icons.AutoMirrored.Filled.InsertDriveFile, + clipLabel = "file", + copyContentDescription = "Copy file content", + onCopy = onCopy, + headerActions = { + if (lines.isNotEmpty()) { + Text( + text = "${lines.size} lines", + style = + MaterialTheme.typography.labelSmall.copy( + fontWeight = FontWeight.Bold, + color = CodeTerminalMuted, + ), + ) + Spacer(Modifier.width(6.dp)) + } + }, + totalLines = lines.size, + isExpanded = expanded, + onToggleExpand = { expanded = !expanded }, + expandLabel = "Show full file (${lines.size} lines)", + collapseLabel = "Collapse file", + ) { + val verticalScrollModifier = + if (expanded) { + Modifier.heightIn(max = 400.dp).verticalScroll(rememberScrollState()) + } else { + Modifier + } + + SelectionContainer { + Box( + modifier = + Modifier + .fillMaxWidth() + .then(verticalScrollModifier) + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Text( + text = highlighted, + style = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + color = CodeTerminalText, + ), + softWrap = false, + ) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt index a66cfb6f..6bd00aaa 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt @@ -266,79 +266,35 @@ fun CodeBlockCard( onCopy: (String) -> Unit, modifier: Modifier = Modifier, ) { - val context = LocalContext.current - - Surface( - modifier = - modifier - .fillMaxWidth() - .testTag("code_block"), - shape = RoundedCornerShape(8.dp), - color = CodeTerminalBg, - border = BorderStroke(1.dp, CodeTerminalBorder), + val highlighted by produceState( + initialValue = remember(code) { AnnotatedString(code) }, + key1 = code, ) { - Column { - // Header row: language badge (left) + copy button (right) - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (!language.isNullOrBlank()) { - Text( - text = language.uppercase(), - style = - MaterialTheme.typography.labelSmall.copy( - fontWeight = FontWeight.Bold, - color = CodeTerminalMuted, - ), - ) - } - Spacer(Modifier.weight(1f)) - var copied by remember { mutableStateOf(false) } - LaunchedEffect(copied) { - if (copied) { - kotlinx.coroutines.delay(2000) - copied = false - } - } - IconButton( - onClick = { - copyToClipboard(context, code) - copied = true - onCopy(code) - }, - modifier = Modifier.size(28.dp), - ) { - Icon( - imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, - contentDescription = if (copied) "Copied" else "Copy code", - tint = CodeTerminalMuted, - modifier = Modifier.size(14.dp), - ) - } + value = + withContext(Dispatchers.Default) { + highlightSyntax(code) } - // Code content with syntax highlighting - val highlighted by produceState( - initialValue = remember(code) { AnnotatedString(code) }, - key1 = code, - ) { - value = - withContext(Dispatchers.Default) { - highlightSyntax(code) - } - } - Text( - text = highlighted, - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - color = CodeTerminalText, - softWrap = false, - modifier = - Modifier - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 10.dp, vertical = 6.dp), - ) - } + } + + CodeTerminalCard( + textToCopy = code, + modifier = modifier, + testTag = "code_block", + title = language?.takeIf { it.isNotBlank() }?.uppercase(), + onCopy = onCopy, + copyContentDescription = "Copy code", + ) { + Text( + text = highlighted, + fontFamily = FontFamily.Monospace, + fontSize = 13.sp, + color = CodeTerminalText, + softWrap = false, + modifier = + Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt index 3216b66f..272b5b21 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolRenderer.kt @@ -50,6 +50,8 @@ internal data class ToolViewExtras( val inlineDiff: String? = null, val diffPath: String? = null, val diffStats: DiffStats? = null, + val fileContent: String? = null, + val filePath: String? = null, val searchHits: List? = null, val searchQuery: String? = null, ) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt index f8c1394d..aed5a017 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolView.kt @@ -54,6 +54,9 @@ data class ToolView( /** File-edit tools: the file the diff applies to. */ val diffPath: String? = null, val diffStats: DiffStats? = null, + /** File contents shown in a syntax-highlighted code card. */ + val fileContent: String? = null, + val filePath: String? = null, /** Image-producing tools: renderable URL (data: or http(s) image). */ val imageUrl: String? = null, /** web_search: parsed result rows. */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt index b70a26ff..8666af00 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilder.kt @@ -116,6 +116,8 @@ object ToolViewBuilder { inlineDiff = clampedDiff, diffPath = extras.diffPath, diffStats = extras.diffStats, + fileContent = extras.fileContent?.let { ToolJson.clampForDisplay(it) }, + filePath = extras.filePath, imageUrl = imageUrlFor(call.args, call.result), searchHits = extras.searchHits, searchQuery = extras.searchQuery, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt index e053079d..ae8095df 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/tool/render/FileRenderers.kt @@ -158,6 +158,21 @@ internal object ReadFileRenderer : ToolRenderer { return ToolJson.firstString(call.result, listOf("content", "text", "data", "body")) } + + override fun extras( + call: ToolCall, + status: ToolViewStatus, + ): ToolViewExtras { + val content = ToolJson.firstString(call.result, listOf("content", "text", "data", "body")) + return if (content.isEmpty()) { + ToolViewExtras.NONE + } else { + ToolViewExtras( + fileContent = content, + filePath = ToolJson.firstString(call.args, listOf("path", "file", "filepath")), + ) + } + } } /** @@ -196,7 +211,22 @@ internal object FileEditRenderer : ToolRenderer { val inlineDiff = FileEditSupport.inlineDiffFromResult(call.rawResult) if (inlineDiff.isEmpty()) { - return ToolViewExtras.NONE + val content = + if (call.name == "write_file" && status == ToolViewStatus.SUCCESS && call.rawResult != null) { + ToolJson + .firstString(call.args, listOf("content")) + .ifEmpty { ToolJson.firstString(call.result, listOf("content", "text", "body")) } + } else { + "" + } + return if (content.isEmpty()) { + ToolViewExtras.NONE + } else { + ToolViewExtras( + fileContent = content, + filePath = FileEditSupport.editPath(call.args, call.result), + ) + } } return ToolViewExtras( diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt index b6ecac48..c375b5df 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/tool/ToolViewBuilderTest.kt @@ -81,6 +81,41 @@ class ToolViewBuilderTest { assertEquals("Read a.kt L10-14", view.title) assertEquals("/repo/src/a.kt", view.subtitle) + assertEquals("10|a\n11|b", view.fileContent) + assertEquals("/repo/src/a.kt", view.filePath) + assertEquals("10|a\n11|b", view.detail) + } + + @Test + fun `read_file missing content does not show a code viewer`() { + val view = build("read_file", """{"path":"/repo/missing.kt"}""", """{"error":"not found"}""") + + assertNull(view.fileContent) + } + + @Test + fun `write_file shows written content when no diff is returned`() { + val view = + build( + "write_file", + """{"path":"/repo/new.kt","content":"fun main() = Unit"}""", + """{"success":true,"verified":true}""", + ) + + assertEquals("fun main() = Unit", view.fileContent) + assertEquals("/repo/new.kt", view.filePath) + assertNull(view.inlineDiff) + } + + @Test + fun `write_file diff wins over content and failed writes show no written content`() { + val args = """{"path":"/repo/a.kt","content":"new"}""" + val diff = build("write_file", args, """{"inline_diff":"--- a/a.kt\n+++ b/a.kt\n-old\n+new"}""") + val failed = build("write_file", args, """{"success":false,"error":"denied"}""", isError = true) + + assertNotNull(diff.inlineDiff) + assertNull(diff.fileContent) + assertNull(failed.fileContent) } @Test