1package soy.iko.opencode.ui.components
3import android.content.ClipData
4import android.content.ClipboardManager
5import android.content.Context
6import android.widget.Toast
7import androidx.compose.foundation.ExperimentalFoundationApi
8import androidx.compose.foundation.background
9import androidx.compose.foundation.combinedClickable
10import androidx.compose.foundation.horizontalScroll
11import androidx.compose.foundation.layout.Box
12import androidx.compose.foundation.layout.fillMaxWidth
13import androidx.compose.foundation.layout.padding
14import androidx.compose.foundation.layout.size
15import androidx.compose.foundation.rememberScrollState
16import androidx.compose.foundation.shape.RoundedCornerShape
17import androidx.compose.material.icons.Icons
18import androidx.compose.material.icons.filled.ContentCopy
19import androidx.compose.material3.Icon
20import androidx.compose.material3.IconButton
21import androidx.compose.material3.MaterialTheme
22import androidx.compose.material3.Surface
23import androidx.compose.material3.Text
24import androidx.compose.runtime.Composable
25import androidx.compose.runtime.LaunchedEffect
26import androidx.compose.runtime.getValue
27import androidx.compose.runtime.mutableStateOf
28import androidx.compose.runtime.remember
29import androidx.compose.runtime.setValue
30import androidx.compose.ui.Alignment
31import androidx.compose.ui.Modifier
32import androidx.compose.ui.draw.clip
33import androidx.compose.ui.platform.LocalContext
34import androidx.compose.ui.text.TextStyle
35import androidx.compose.ui.text.font.FontFamily
36import androidx.compose.ui.unit.dp
37import kotlinx.coroutines.delay
38import com.mikepenz.markdown.compose.components.MarkdownComponentModel
39import com.mikepenz.markdown.compose.components.markdownComponents
40import com.mikepenz.markdown.m3.Markdown
41import org.intellij.markdown.MarkdownElementTypes
42import org.intellij.markdown.ast.ASTNode
43import org.intellij.markdown.ast.getTextInNode
44import soy.iko.opencode.R
47 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
48 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
49 * Long-press copies the raw markdown to the system clipboard. Code blocks/fences get an
52 * During streaming, the full markdown is re-parsed on every token (the library re-parses
53 * whenever the content string changes). To avoid O(n²) work during long responses, the
54 * rendered content is throttled — the latest [markdown] is committed to the renderer at
55 * most once per frame (~16ms), so a burst of tokens coalesces into a single re-parse.
57@OptIn(ExperimentalFoundationApi::class)
61 modifier: Modifier = Modifier,
62 style: TextStyle = MaterialTheme.typography.bodyLarge,
64 val context = LocalContext.current
65 // Throttle the rendered content so a rapid burst of streaming tokens coalesces
66 // into a single markdown re-parse per frame instead of one per token. The delay
67 // is only applied when the content is growing incrementally (streaming) — a
68 // content switch or initial render proceeds immediately.
69 var renderedContent by remember { mutableStateOf(markdown) }
70 LaunchedEffect(markdown) {
71 if (renderedContent.isNotEmpty() &&
72 markdown.startsWith(renderedContent) &&
73 markdown != renderedContent
77 renderedContent = markdown
80 content = renderedContent,
81 modifier = modifier.combinedClickable(
83 onLongClick = { copyToClipboard(context, markdown) },
85 components = markdownComponents(
86 codeFence = { CodeWithCopy(it) },
87 codeBlock = { CodeWithCopy(it) },
93 * Renders a fenced or indented code block with a small copy button in the top-right corner.
94 * Self-contained so it doesn't depend on the library's internal code-block composables.
97private fun CodeWithCopy(model: MarkdownComponentModel) {
98 val context = LocalContext.current
99 val code = androidx.compose.runtime.remember(model) { extractCode(model.content, model.node) }
100 val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
104 .clip(RoundedCornerShape(8.dp))
105 .background(MaterialTheme.colorScheme.surfaceVariant),
111 .horizontalScroll(rememberScrollState())
113 .padding(start = 12.dp, top = 12.dp, end = 40.dp, bottom = 12.dp),
116 color = MaterialTheme.colorScheme.surface,
117 shape = RoundedCornerShape(6.dp),
119 .align(Alignment.TopEnd)
123 onClick = { copyToClipboard(context, "code", code) },
124 modifier = Modifier.size(48.dp),
127 Icons.Filled.ContentCopy,
128 contentDescription = context.getString(R.string.copy),
129 modifier = Modifier.size(14.dp),
130 tint = MaterialTheme.colorScheme.onSurfaceVariant,
138 * Extract the raw code text from a markdown code node, stripping fence markers
139 * (```/~~~ and the language tag) so only the code content is copied.
141private fun extractCode(content: String, node: ASTNode): String {
142 val raw = node.getTextInNode(content).toString()
143 val isOpenFence = node.type == MarkdownElementTypes.CODE_FENCE
144 if (!isOpenFence) return raw.trimIndent()
145 val lines = raw.lines()
146 val body = lines.drop(1).toMutableList()
147 if (body.isNotEmpty() && (body.last().startsWith("```") || body.last().startsWith("~~~"))) {
148 body.removeAt(body.lastIndex)
150 return body.joinToString("\n").trimEnd()
153/** Copy [text] to the system clipboard and show a confirmation toast. */
154internal fun copyToClipboard(context: Context, label: String, text: String = label) {
155 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
156 clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
157 Toast.makeText(context, context.getString(R.string.copied), Toast.LENGTH_SHORT).show()
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list