1package soy.iko.opencode.ui.chat
3import soy.iko.opencode.data.model.AssistantMessage
4import soy.iko.opencode.data.model.FilePart
5import soy.iko.opencode.data.model.MessageWithParts
6import soy.iko.opencode.data.model.ReasoningPart
7import soy.iko.opencode.data.model.TextPart
8import soy.iko.opencode.data.model.ToolCompleted
9import soy.iko.opencode.data.model.ToolError
10import soy.iko.opencode.data.model.ToolPart
11import soy.iko.opencode.data.model.ToolRunning
12import soy.iko.opencode.data.model.UserMessage
13import soy.iko.opencode.data.model.sourcePath
14import java.text.SimpleDateFormat
16import java.util.Locale
19 * Render a conversation as Markdown for sharing/exporting. Each message becomes a
20 * role-prefixed section containing its text parts; reasoning is exported as a blockquote
21 * and tool calls are summarized so the transcript shows what the agent *did*, not just
22 * what it said. Pure (no Android deps) so it can be unit-tested directly.
24fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?): String {
25 val sb = StringBuilder(messages.size * 128)
26 if (!title.isNullOrBlank()) {
27 sb.append("# ").append(escapeMarkdown(title.trim())).append("\n\n")
29 appendMetadataHeader(sb, messages)
30 for (message in messages) {
31 val heading = when (message.info) {
32 is UserMessage -> "## You"
33 is AssistantMessage -> "## opencode"
36 // The assistant's reply text is already Markdown (code fences, bold, headings);
37 // escaping it would render the export as backslash noise. Only escape user-authored
38 // text, which isn't expected to be Markdown.
39 val escapeText = message.info is UserMessage
40 val body = message.parts
43 is TextPart -> part.text.takeIf { it.isNotBlank() }
44 ?.let { if (escapeText) escapeMarkdown(it) else it }
45 is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
46 ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
47 is ToolPart -> formatToolCall(part)
49 // Image/file attachments aren't portable in a text export; emit a
50 // labeled placeholder so the transcript records that one was present
51 // (useful for debugging) instead of silently dropping it.
52 val name = part.sourcePath ?: part.url ?: part.filename ?: "attachment"
53 if (part.mime?.startsWith("image/") == true) "_[image: ${escapeMarkdown(name)}]_"
54 else "_[file: ${escapeMarkdown(name)}]_"
61 if (body.isEmpty()) continue
62 sb.append(heading).append("\n\n")
63 // Per-message timestamp (ISO, local zone) so an exported transcript is useful for
64 // support/debugging — without it only the metadata header's global range has timing.
65 message.info.time?.created?.let { ts -> sb.append("_").append(formatExportTimestamp(ts)).append("_\n\n") }
66 sb.append(body).append("\n\n")
68 return sb.toString().trimEnd()
72 * Render a single message as Markdown for per-message sharing. A trimmed-down version of
73 * [buildConversationMarkdown] that emits just this message's role heading + body (no metadata
74 * header), so a shared single reply/exchange is self-contained without dragging the whole
75 * transcript. Returns null when the message has no exportable body (e.g. an image-only prompt).
77fun buildMessageMarkdown(message: MessageWithParts): String? {
78 val heading = when (message.info) {
79 is UserMessage -> "## You"
80 is AssistantMessage -> "## opencode"
83 val escapeText = message.info is UserMessage
84 val body = message.parts
87 is TextPart -> part.text.takeIf { it.isNotBlank() }
88 ?.let { if (escapeText) escapeMarkdown(it) else it }
89 is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
90 ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
91 is ToolPart -> formatToolCall(part)
93 val name = part.sourcePath ?: part.url ?: part.filename ?: "attachment"
94 if (part.mime?.startsWith("image/") == true) "_[image: ${escapeMarkdown(name)}]_"
95 else "_[file: ${escapeMarkdown(name)}]_"
100 .joinToString("\n\n")
102 if (body.isEmpty()) return null
104 append(heading).append("\n\n")
105 message.info.time?.created?.let { ts -> append("_").append(formatExportTimestamp(ts)).append("_\n\n") }
110/** Format an epoch-millis as a locale-stable ISO-ish timestamp for the export. Thread-confined
111 * to the exporting coroutine, so a shared SimpleDateFormat (not thread-safe) is fine. */
112private fun formatExportTimestamp(epochMillis: Long): String =
113 SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(epochMillis))
115/** Append a metadata header (message counts + timestamp range) so a shared transcript has
116 * context for support/debugging. Skipped when there are no messages. */
117private fun appendMetadataHeader(sb: StringBuilder, messages: List<MessageWithParts>) {
118 if (messages.isEmpty()) return
119 val userCount = messages.count { it.info is UserMessage }
120 val assistantCount = messages.count { it.info is AssistantMessage }
121 val firstTime = messages.firstOrNull()?.info?.time?.created
122 val lastTime = messages.lastOrNull()?.info?.time?.created
124 sb.append("$userCount user · $assistantCount assistant message")
125 if (userCount != 1) sb.append("s")
126 if (firstTime != null && lastTime != null) {
127 sb.append(" · $firstTime → $lastTime")
129 sb.append("\n\n---\n\n")
132/** Render a tool call as a compact, readable blockquote summary: the tool name, its
133 * human-readable title (if any), and a truncated output/error. Keeps the exported
134 * transcript useful when the agent's work (edits, command runs) is the substance. */
135private fun formatToolCall(part: ToolPart): String? {
136 val title = when (val s = part.state) {
137 is ToolRunning -> s.title
138 is ToolCompleted -> s.title
141 val detail = when (val s = part.state) {
142 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }?.let { truncateOutput(it) }
143 is ToolError -> s.error?.takeIf { it.isNotBlank() }?.let { "Error: ${truncateOutput(it)}" }
146 val status = when (part.state) {
147 is ToolCompleted -> null
148 is ToolError -> " — error"
149 is ToolRunning -> " — running"
152 val head = "**${part.tool}**${status ?: ""}"
153 val titleLine = title?.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
156 if (titleLine != null) add("> $titleLine")
157 // Prefix every detail line with "> " so the fenced code block stays inside the
158 // blockquote — otherwise lines after the first fall out of the quote.
159 if (detail != null) {
160 val quoted = detail.lines().joinToString("\n") { "> $it" }
161 // Use a fence longer than the longest backtick run in the content, so tool output
162 // that itself contains a ``` sequence (shell output echoing markdown, etc.) can't
163 // prematurely close the block and make the rest of the transcript render broken.
164 val fence = "`".repeat(maxOf(3, longestBacktickRun(detail) + 1))
165 add(">\n> $fence\n$quoted\n> $fence")
167 }.joinToString("\n").takeIf { it.isNotBlank() }
170/** Length of the longest consecutive run of backticks in [text] (0 if none). */
171private fun longestBacktickRun(text: String): Int {
177 if (current > max) max = current
185private fun truncateOutput(output: String): String {
186 if (output.length <= TOOL_OUTPUT_LIMIT) return output
187 return output.take(TOOL_OUTPUT_LIMIT) + "\n… (truncated)"
190private const val TOOL_OUTPUT_LIMIT = 2000
192/** Escape markdown special characters so user/model text doesn't produce malformed markdown. */
193private fun escapeMarkdown(text: String): String {
194 if (text.isEmpty()) return text
195 val sb = StringBuilder(text.length + 16)
198 '\\', '*', '_', '#', '`', '>' -> sb.append('\\')
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