@@ -5,23 +5,47 @@ import android.content.ClipboardManager
55import android.content.Context
66import android.widget.Toast
77import androidx.compose.foundation.ExperimentalFoundationApi
8+import androidx.compose.foundation.background
89import androidx.compose.foundation.combinedClickable
10+import androidx.compose.foundation.horizontalScroll
11+import androidx.compose.foundation.layout.Box
12+import androidx.compose.foundation.layout.fillMaxWidth
13+import androidx.compose.foundation.layout.padding
14+import androidx.compose.foundation.layout.size
15+import androidx.compose.foundation.rememberScrollState
16+import androidx.compose.foundation.shape.RoundedCornerShape
17+import androidx.compose.material.icons.Icons
18+import androidx.compose.material.icons.filled.ContentCopy
19+import androidx.compose.material3.Icon
20+import androidx.compose.material3.IconButton
921import androidx.compose.material3.MaterialTheme
22+import androidx.compose.material3.Surface
23+import androidx.compose.material3.Text
1024import androidx.compose.runtime.Composable
25+import androidx.compose.ui.Alignment
1126import androidx.compose.ui.Modifier
27+import androidx.compose.ui.draw.clip
1228import androidx.compose.ui.platform.LocalContext
1329import androidx.compose.ui.semantics.Role
1430import androidx.compose.ui.semantics.contentDescription
1531import androidx.compose.ui.semantics.role
1632import androidx.compose.ui.semantics.semantics
1733import androidx.compose.ui.text.TextStyle
34+import androidx.compose.ui.text.font.FontFamily
35+import androidx.compose.ui.unit.dp
36+import com.mikepenz.markdown.compose.components.MarkdownComponentModel
37+import com.mikepenz.markdown.compose.components.markdownComponents
1838import com.mikepenz.markdown.m3.Markdown
39+import org.intellij.markdown.MarkdownElementTypes
40+import org.intellij.markdown.ast.ASTNode
41+import org.intellij.markdown.ast.getTextInNode
1942import soy.iko.opencode.R
2043
2144/**
2245 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
2346 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
24− * Long-press copies the raw markdown to the system clipboard.
47+ * Long-press copies the raw markdown to the system clipboard. Code blocks/fences get an
48+ * inline copy button.
2549 */
2650@OptIn(ExperimentalFoundationApi::class)
2751@Composable
@@ -31,7 +55,6 @@ fun MarkdownText(
3155 style: TextStyle = MaterialTheme.typography.bodyLarge,
3256) {
3357 val context = LocalContext.current
34− val copyLabel = context.getString(R.string.copy)
3558 Markdown(
3659 content = markdown,
3760 modifier = modifier.semantics(mergeDescendants = true) {
@@ -41,9 +64,74 @@ fun MarkdownText(
4164 onClick = {},
4265 onLongClick = { copyToClipboard(context, markdown) },
4366 ),
67+ components = markdownComponents(
68+ codeFence = { CodeWithCopy(it) },
69+ codeBlock = { CodeWithCopy(it) },
70+ ),
4471 )
4572}
4673
74+/**
75+ * Renders a fenced or indented code block with a small copy button in the top-right corner.
76+ * Self-contained so it doesn't depend on the library's internal code-block composables.
77+ */
78+@Composable
79+private fun CodeWithCopy(model: MarkdownComponentModel) {
80+ val context = LocalContext.current
81+ val code = androidx.compose.runtime.remember(model) { extractCode(model.content, model.node) }
82+ val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
83+ Box(
84+ modifier = Modifier
85+ .fillMaxWidth()
86+ .clip(RoundedCornerShape(8.dp))
87+ .background(MaterialTheme.colorScheme.surfaceVariant),
88+ ) {
89+ Text(
90+ text = code,
91+ style = codeStyle,
92+ modifier = Modifier
93+ .horizontalScroll(rememberScrollState())
94+ .fillMaxWidth()
95+ .padding(start = 12.dp, top = 12.dp, end = 40.dp, bottom = 12.dp),
96+ )
97+ Surface(
98+ color = MaterialTheme.colorScheme.surface,
99+ shape = RoundedCornerShape(6.dp),
100+ modifier = Modifier
101+ .align(Alignment.TopEnd)
102+ .padding(4.dp),
103+ ) {
104+ IconButton(
105+ onClick = { copyToClipboard(context, "code", code) },
106+ modifier = Modifier.size(28.dp),
107+ ) {
108+ Icon(
109+ Icons.Filled.ContentCopy,
110+ contentDescription = context.getString(R.string.copy),
111+ modifier = Modifier.size(14.dp),
112+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
113+ )
114+ }
115+ }
116+ }
117+}
118+
119+/**
120+ * Extract the raw code text from a markdown code node, stripping fence markers
121+ * (```/~~~ and the language tag) so only the code content is copied.
122+ */
123+private fun extractCode(content: String, node: ASTNode): String {
124+ val raw = node.getTextInNode(content).toString()
125+ val isOpenFence = node.type == MarkdownElementTypes.CODE_FENCE
126+ if (!isOpenFence) return raw.trimIndent()
127+ val lines = raw.lines()
128+ val body = lines.drop(1).toMutableList()
129+ if (body.isNotEmpty() && (body.last().startsWith("```") || body.last().startsWith("~~~"))) {
130+ body.removeAt(body.lastIndex)
131+ }
132+ return body.joinToString("\n").trimEnd()
133+}
134+
47135/** Copy [text] to the system clipboard and show a confirmation toast. */
48136internal fun copyToClipboard(context: Context, label: String, text: String = label) {
49137 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return