feat(chat): show read and write file contents in code viewer card (#1238)

This commit is contained in:
m57 authored and GitHub committed 2026-09-23 14:00:55 +03:00
1 parent 0eb8d9a8c1
commit 4a732870d7
11 files changed
+565 -230

No files matched your search

@@ -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,
),
)
}
}
}
}
@@ -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)
}
}
@@ -75,7 +75,9 @@ import com.m57.hermescontrol.theme.CodeTerminalMuted
import com.m57.hermescontrol.theme.CodeTerminalText import com.m57.hermescontrol.theme.CodeTerminalText
import com.m57.hermescontrol.theme.HermesStatusColors import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.LocalHermesStatusColors 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.DiffViewCard
import com.m57.hermescontrol.ui.chat.components.FileViewCard
import com.m57.hermescontrol.ui.chat.components.highlightSyntax import com.m57.hermescontrol.ui.chat.components.highlightSyntax
import com.m57.hermescontrol.ui.chat.tool.ToolJson import com.m57.hermescontrol.ui.chat.tool.ToolJson
import com.m57.hermescontrol.ui.chat.tool.ToolView import com.m57.hermescontrol.ui.chat.tool.ToolView
@@ -423,6 +425,11 @@ private fun ExpandedToolContent(
diffText = view.inlineDiff, diffText = view.inlineDiff,
filePath = view.diffPath, filePath = view.diffPath,
) )
} else if (view.fileContent != null) {
FileViewCard(
content = view.fileContent,
filePath = view.filePath,
)
} }
// ── Search hits ── // ── Search hits ──
@@ -478,7 +485,7 @@ private fun ExpandedToolContent(
} }
// ── Plain detail body ── // ── Plain detail body ──
if (view.detail.isNotBlank() && view.inlineDiff == null) { if (view.detail.isNotBlank() && view.inlineDiff == null && view.fileContent == null) {
Text( Text(
text = view.detail, text = view.detail,
modifier = modifier =
@@ -636,42 +643,13 @@ private fun ToolRawJsonView(
} }
} }
val clipboard = LocalClipboard.current CodeTerminalCard(
val scope = rememberCoroutineScope() textToCopy = displayText,
var copied by remember { mutableStateOf(false) } modifier = modifier,
LaunchedEffect(copied) { testTag = "tool_raw_json",
if (copied) { title = "JSON",
delay(2000) copyContentDescription = stringResource(R.string.content_desc_copy),
copied = false headerActions = {
}
}
Surface(
modifier =
modifier
.fillMaxWidth()
.testTag("tool_raw_json"),
shape = RoundedCornerShape(8.dp),
color = CodeTerminalBg,
border = BorderStroke(1.dp, CodeTerminalBorder),
) {
Column {
Row(
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) { if (isFormatDifferent) {
Text( Text(
text = text =
@@ -692,28 +670,8 @@ private fun ToolRawJsonView(
.padding(horizontal = 6.dp, vertical = 2.dp), .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 { SelectionContainer {
Text( Text(
text = highlighted, text = highlighted,
@@ -731,7 +689,6 @@ private fun ToolRawJsonView(
} }
} }
} }
}
@Composable @Composable
private fun CopyButton( private fun CopyButton(
@@ -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),
)
}
}
}
}
}
}
@@ -1,51 +1,33 @@
package com.m57.hermescontrol.ui.chat.components 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.background
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState 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.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile 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.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.theme.CodeDiffAddBg 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.CodeDiffDeleteText
import com.m57.hermescontrol.theme.CodeDiffHunkBg import com.m57.hermescontrol.theme.CodeDiffHunkBg
import com.m57.hermescontrol.theme.CodeDiffHunkText 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.CodeTerminalMuted
import com.m57.hermescontrol.theme.CodeTerminalText import com.m57.hermescontrol.theme.CodeTerminalText
import kotlinx.coroutines.delay import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
enum class DiffLineType { enum class DiffLineType {
FILE_HEADER, FILE_HEADER,
@@ -169,17 +150,8 @@ fun DiffViewCard(
onCopy: (String) -> Unit = {}, onCopy: (String) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current
val parsed = remember(diffText, filePath) { parseDiffText(diffText, filePath) } val parsed = remember(diffText, filePath) { parseDiffText(diffText, filePath) }
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
var copied by remember { mutableStateOf(false) }
LaunchedEffect(copied) {
if (copied) {
delay(2000)
copied = false
}
}
val displayLines = val displayLines =
if (expanded || parsed.lines.size <= 16) { if (expanded || parsed.lines.size <= 16) {
@@ -188,50 +160,16 @@ fun DiffViewCard(
parsed.lines.take(16) parsed.lines.take(16)
} }
Surface( CodeTerminalCard(
modifier = textToCopy = diffText,
modifier modifier = modifier,
.fillMaxWidth() testTag = "diff_view_card",
.testTag("diff_view_card"), title = parsed.filePath ?: "diff",
shape = RoundedCornerShape(8.dp), icon = Icons.AutoMirrored.Filled.InsertDriveFile,
color = CodeTerminalBg, clipLabel = "diff",
border = BorderStroke(1.dp, CodeTerminalBorder), copyContentDescription = "Copy diff",
) { onCopy = onCopy,
Column(modifier = Modifier.animateContentSize()) { headerActions = {
// 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) { if (parsed.additionsCount > 0) {
Text( Text(
text = "+${parsed.additionsCount}", text = "+${parsed.additionsCount}",
@@ -254,28 +192,13 @@ fun DiffViewCard(
) )
Spacer(Modifier.width(6.dp)) 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), totalLines = parsed.lines.size,
isExpanded = expanded,
onToggleExpand = { expanded = !expanded },
expandLabel = "Show full diff (${parsed.lines.size} lines)",
collapseLabel = "Collapse diff",
) { ) {
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 = val verticalScrollModifier =
if (expanded) { if (expanded) {
Modifier.heightIn(max = 400.dp).verticalScroll(rememberScrollState()) Modifier.heightIn(max = 400.dp).verticalScroll(rememberScrollState())
@@ -320,34 +243,95 @@ fun DiffViewCard(
} }
} }
} }
}
}
// Expand / Collapse footer button for diffs > 16 lines /**
if (parsed.lines.size > 16) { * Interactive file content view card for read_file & write_file tool outputs.
TextButton( * Mirrors [DiffViewCard] with syntax highlighting and file path header.
onClick = { expanded = !expanded }, */
modifier = Modifier.fillMaxWidth(), @Composable
fun FileViewCard(
content: String,
filePath: String? = null,
onCopy: (String) -> Unit = {},
modifier: Modifier = Modifier,
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { var expanded by remember { mutableStateOf(false) }
Text( val lines = remember(content) { content.lines() }
text = val displayText =
if (expanded) { remember(content, expanded) {
"Collapse diff" if (expanded || lines.size <= 16) {
content
} else { } else {
"Show full diff (${parsed.lines.size} lines)" lines.take(16).joinToString("\n")
}, }
style = MaterialTheme.typography.labelSmall, }
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, color = CodeTerminalMuted,
),
) )
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(6.dp))
Icon( }
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, },
contentDescription = null, totalLines = lines.size,
tint = CodeTerminalMuted, isExpanded = expanded,
modifier = Modifier.size(16.dp), 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,
) )
} }
} }
} }
} }
}
}
@@ -266,58 +266,6 @@ fun CodeBlockCard(
onCopy: (String) -> Unit, onCopy: (String) -> Unit,
modifier: Modifier = Modifier, 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),
) {
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),
)
}
}
// Code content with syntax highlighting
val highlighted by produceState( val highlighted by produceState(
initialValue = remember(code) { AnnotatedString(code) }, initialValue = remember(code) { AnnotatedString(code) },
key1 = code, key1 = code,
@@ -327,6 +275,15 @@ fun CodeBlockCard(
highlightSyntax(code) highlightSyntax(code)
} }
} }
CodeTerminalCard(
textToCopy = code,
modifier = modifier,
testTag = "code_block",
title = language?.takeIf { it.isNotBlank() }?.uppercase(),
onCopy = onCopy,
copyContentDescription = "Copy code",
) {
Text( Text(
text = highlighted, text = highlighted,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
@@ -340,7 +297,6 @@ fun CodeBlockCard(
) )
} }
} }
}
// Hoisted token patterns for syntax highlighting — compiled once. // Hoisted token patterns for syntax highlighting — compiled once.
private val HIGHLIGHT_TOKENS = private val HIGHLIGHT_TOKENS =
@@ -50,6 +50,8 @@ internal data class ToolViewExtras(
val inlineDiff: String? = null, val inlineDiff: String? = null,
val diffPath: String? = null, val diffPath: String? = null,
val diffStats: DiffStats? = null, val diffStats: DiffStats? = null,
val fileContent: String? = null,
val filePath: String? = null,
val searchHits: List<SearchHit>? = null, val searchHits: List<SearchHit>? = null,
val searchQuery: String? = null, val searchQuery: String? = null,
) { ) {
@@ -54,6 +54,9 @@ data class ToolView(
/** File-edit tools: the file the diff applies to. */ /** File-edit tools: the file the diff applies to. */
val diffPath: String? = null, val diffPath: String? = null,
val diffStats: DiffStats? = 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). */ /** Image-producing tools: renderable URL (data: or http(s) image). */
val imageUrl: String? = null, val imageUrl: String? = null,
/** web_search: parsed result rows. */ /** web_search: parsed result rows. */
@@ -116,6 +116,8 @@ object ToolViewBuilder {
inlineDiff = clampedDiff, inlineDiff = clampedDiff,
diffPath = extras.diffPath, diffPath = extras.diffPath,
diffStats = extras.diffStats, diffStats = extras.diffStats,
fileContent = extras.fileContent?.let { ToolJson.clampForDisplay(it) },
filePath = extras.filePath,
imageUrl = imageUrlFor(call.args, call.result), imageUrl = imageUrlFor(call.args, call.result),
searchHits = extras.searchHits, searchHits = extras.searchHits,
searchQuery = extras.searchQuery, searchQuery = extras.searchQuery,
@@ -158,6 +158,21 @@ internal object ReadFileRenderer : ToolRenderer {
return ToolJson.firstString(call.result, listOf("content", "text", "data", "body")) 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) val inlineDiff = FileEditSupport.inlineDiffFromResult(call.rawResult)
if (inlineDiff.isEmpty()) { 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( return ToolViewExtras(
@@ -81,6 +81,41 @@ class ToolViewBuilderTest {
assertEquals("Read a.kt L10-14", view.title) assertEquals("Read a.kt L10-14", view.title)
assertEquals("/repo/src/a.kt", view.subtitle) 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 @Test