git.iko.soy/opencode-android
on 06fd41b help sign in
203 lines 9.2 kB history raw
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
15import java.util.Date
16import java.util.Locale
18/**
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.
23 */
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")
28 }
29 appendMetadataHeader(sb, messages)
30 for (message in messages) {
31 val heading = when (message.info) {
32 is UserMessage -> "## You"
33 is AssistantMessage -> "## opencode"
34 else -> null
35 } ?: continue
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
41 .mapNotNull { part ->
42 when (part) {
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)
48 is FilePart -> {
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)}]_"
55 }
56 else -> null
57 }
58 }
59 .joinToString("\n\n")
60 .trim()
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")
67 }
68 return sb.toString().trimEnd()
71/**
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).
76 */
77fun buildMessageMarkdown(message: MessageWithParts): String? {
78 val heading = when (message.info) {
79 is UserMessage -> "## You"
80 is AssistantMessage -> "## opencode"
81 else -> return null
82 }
83 val escapeText = message.info is UserMessage
84 val body = message.parts
85 .mapNotNull { part ->
86 when (part) {
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)
92 is FilePart -> {
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)}]_"
96 }
97 else -> null
98 }
99 }
100 .joinToString("\n\n")
101 .trim()
102 if (body.isEmpty()) return null
103 return buildString {
104 append(heading).append("\n\n")
105 message.info.time?.created?.let { ts -> append("_").append(formatExportTimestamp(ts)).append("_\n\n") }
106 append(body)
107 }
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
123 sb.append("> ")
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")
128 }
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
139 else -> null
140 }
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)}" }
144 else -> null
145 }
146 val status = when (part.state) {
147 is ToolCompleted -> null
148 is ToolError -> " — error"
149 is ToolRunning -> " — running"
150 else -> null
151 }
152 val head = "**${part.tool}**${status ?: ""}"
153 val titleLine = title?.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
154 return buildList {
155 add("> $head")
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")
166 }
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 {
172 var max = 0
173 var current = 0
174 for (c in text) {
175 if (c == '`') {
176 current++
177 if (current > max) max = current
178 } else {
179 current = 0
180 }
181 }
182 return max
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)
196 for (c in text) {
197 when (c) {
198 '\\', '*', '_', '#', '`', '>' -> sb.append('\\')
199 }
200 sb.append(c)
201 }
202 return sb.toString()