git.iko.soy/opencode-android
on 61cdc75 help sign in
208 lines 9.7 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 soy.iko.opencode.data.network.NetworkConfig
15import java.text.SimpleDateFormat
16import java.util.Date
17import java.util.Locale
19/**
20 * Render a conversation as Markdown for sharing/exporting. Each message becomes a
21 * role-prefixed section containing its text parts; reasoning is exported as a blockquote
22 * and tool calls are summarized so the transcript shows what the agent *did*, not just
23 * what it said. Pure (no Android deps) so it can be unit-tested directly.
24 */
25fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?): String {
26 val sb = StringBuilder(messages.size * 128)
27 if (!title.isNullOrBlank()) {
28 sb.append("# ").append(escapeMarkdown(title.trim())).append("\n\n")
29 }
30 appendMetadataHeader(sb, messages)
31 for (message in messages) {
32 val heading = when (message.info) {
33 is UserMessage -> "## You"
34 is AssistantMessage -> "## opencode"
35 else -> null
36 } ?: continue
37 // The assistant's reply text is already Markdown (code fences, bold, headings);
38 // escaping it would render the export as backslash noise. Only escape user-authored
39 // text, which isn't expected to be Markdown.
40 val escapeText = message.info is UserMessage
41 val body = message.parts
42 .mapNotNull { part ->
43 when (part) {
44 is TextPart -> part.text.takeIf { it.isNotBlank() }
45 ?.let { if (escapeText) escapeMarkdown(it) else it }
46 is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
47 ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
48 is ToolPart -> formatToolCall(part)
49 is FilePart -> {
50 // Image/file attachments aren't portable in a text export; emit a
51 // labeled placeholder so the transcript records that one was present
52 // (useful for debugging) instead of silently dropping it.
53 val name = part.sourcePath ?: part.url ?: part.filename ?: "attachment"
54 if (part.mime?.startsWith("image/") == true) "_[image: ${escapeMarkdown(name)}]_"
55 else "_[file: ${escapeMarkdown(name)}]_"
56 }
57 else -> null
58 }
59 }
60 .joinToString("\n\n")
61 .trim()
62 if (body.isEmpty()) continue
63 sb.append(heading).append("\n\n")
64 // Per-message timestamp (ISO, local zone) so an exported transcript is useful for
65 // support/debugging — without it only the metadata header's global range has timing.
66 message.info.time?.created?.let { ts -> sb.append("_").append(formatExportTimestamp(ts)).append("_\n\n") }
67 sb.append(body).append("\n\n")
68 }
69 return sb.toString().trimEnd()
72/**
73 * Render a single message as Markdown for per-message sharing. A trimmed-down version of
74 * [buildConversationMarkdown] that emits just this message's role heading + body (no metadata
75 * header), so a shared single reply/exchange is self-contained without dragging the whole
76 * transcript. Returns null when the message has no exportable body (e.g. an image-only prompt).
77 */
78fun buildMessageMarkdown(message: MessageWithParts): String? {
79 val heading = when (message.info) {
80 is UserMessage -> "## You"
81 is AssistantMessage -> "## opencode"
82 else -> return null
83 }
84 val escapeText = message.info is UserMessage
85 val body = message.parts
86 .mapNotNull { part ->
87 when (part) {
88 is TextPart -> part.text.takeIf { it.isNotBlank() }
89 ?.let { if (escapeText) escapeMarkdown(it) else it }
90 is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
91 ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
92 is ToolPart -> formatToolCall(part)
93 is FilePart -> {
94 val name = part.sourcePath ?: part.url ?: part.filename ?: "attachment"
95 if (part.mime?.startsWith("image/") == true) "_[image: ${escapeMarkdown(name)}]_"
96 else "_[file: ${escapeMarkdown(name)}]_"
97 }
98 else -> null
99 }
100 }
101 .joinToString("\n\n")
102 .trim()
103 if (body.isEmpty()) return null
104 return buildString {
105 append(heading).append("\n\n")
106 message.info.time?.created?.let { ts -> append("_").append(formatExportTimestamp(ts)).append("_\n\n") }
107 append(body)
108 }
111/** Format an epoch-millis as a locale-stable ISO-ish timestamp for the export. Thread-confined
112 * to the exporting coroutine, so a shared SimpleDateFormat (not thread-safe) is fine. */
113private fun formatExportTimestamp(epochMillis: Long): String =
114 SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(epochMillis))
116/** Append a metadata header (message counts + timestamp range) so a shared transcript has
117 * context for support/debugging. Skipped when there are no messages. */
118private fun appendMetadataHeader(sb: StringBuilder, messages: List<MessageWithParts>) {
119 if (messages.isEmpty()) return
120 val userCount = messages.count { it.info is UserMessage }
121 val assistantCount = messages.count { it.info is AssistantMessage }
122 val firstTime = messages.firstOrNull()?.info?.time?.created
123 val lastTime = messages.lastOrNull()?.info?.time?.created
124 sb.append("> ")
125 // Pluralize each noun independently (a prior bug keyed both on userCount, so "user" was
126 // never pluralized and "message" was pluralized based on the wrong count).
127 sb.append("$userCount user")
128 if (userCount != 1) sb.append("s")
129 sb.append(" · $assistantCount assistant message")
130 if (assistantCount != 1) sb.append("s")
131 if (firstTime != null && lastTime != null) {
132 sb.append(" · $firstTime → $lastTime")
133 }
134 sb.append("\n\n---\n\n")
137/** Render a tool call as a compact, readable blockquote summary: the tool name, its
138 * human-readable title (if any), and a truncated output/error. Keeps the exported
139 * transcript useful when the agent's work (edits, command runs) is the substance. */
140private fun formatToolCall(part: ToolPart): String? {
141 val title = when (val s = part.state) {
142 is ToolRunning -> s.title
143 is ToolCompleted -> s.title
144 else -> null
145 }
146 val detail = when (val s = part.state) {
147 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }?.let { truncateOutput(it) }
148 is ToolError -> s.error?.takeIf { it.isNotBlank() }?.let { "Error: ${truncateOutput(it)}" }
149 else -> null
150 }
151 val status = when (part.state) {
152 is ToolCompleted -> null
153 is ToolError -> " — error"
154 is ToolRunning -> " — running"
155 else -> null
156 }
157 val head = "**${part.tool}**${status ?: ""}"
158 val titleLine = title?.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
159 return buildList {
160 add("> $head")
161 if (titleLine != null) add("> $titleLine")
162 // Prefix every detail line with "> " so the fenced code block stays inside the
163 // blockquote — otherwise lines after the first fall out of the quote.
164 if (detail != null) {
165 val quoted = detail.lines().joinToString("\n") { "> $it" }
166 // Use a fence longer than the longest backtick run in the content, so tool output
167 // that itself contains a ``` sequence (shell output echoing markdown, etc.) can't
168 // prematurely close the block and make the rest of the transcript render broken.
169 val fence = "`".repeat(maxOf(3, longestBacktickRun(detail) + 1))
170 add(">\n> $fence\n$quoted\n> $fence")
171 }
172 }.joinToString("\n").takeIf { it.isNotBlank() }
175/** Length of the longest consecutive run of backticks in [text] (0 if none). */
176private fun longestBacktickRun(text: String): Int {
177 var max = 0
178 var current = 0
179 for (c in text) {
180 if (c == '`') {
181 current++
182 if (current > max) max = current
183 } else {
184 current = 0
185 }
186 }
187 return max
190private fun truncateOutput(output: String): String {
191 if (output.length <= NetworkConfig.exportToolOutputLimitChars) return output
192 return output.take(NetworkConfig.exportToolOutputLimitChars) + "\n… (truncated)"
195/** Escape markdown special characters so user/model text doesn't produce malformed markdown.
196 * Extends the basic set with [ ] ( ) - + ! so user text containing links, list markers, or
197 * image syntax doesn't render as live Markdown in the exported transcript. */
198private fun escapeMarkdown(text: String): String {
199 if (text.isEmpty()) return text
200 val sb = StringBuilder(text.length + 16)
201 for (c in text) {
202 when (c) {
203 '\\', '*', '_', '#', '`', '>', '[', ']', '(', ')', '!', '+', '-' -> sb.append('\\')
204 }
205 sb.append(c)
206 }
207 return sb.toString()