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
+681 -346

No files matched your search

@@ -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,
)
}
}
}
@@ -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
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,
)
}
}
}
}
@@ -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),
)
}
}
@@ -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<SearchHit>? = null,
val searchQuery: String? = null,
) {
@@ -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. */
@@ -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,
@@ -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(