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
17import java.util.Locale
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.
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")
30 appendMetadataHeader(sb, messages)
31 for (message in messages) {
32 val heading = when (message.info) {
33 is UserMessage -> "## You"
34 is AssistantMessage -> "## opencode"
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
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)
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)}]_"
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")
69 return sb.toString().trimEnd()
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).
78fun buildMessageMarkdown(message: MessageWithParts): String? {
79 val heading = when (message.info) {
80 is UserMessage -> "## You"
81 is AssistantMessage -> "## opencode"
84 val escapeText = message.info is UserMessage
85 val body = message.parts
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)
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)}]_"
101 .joinToString("\n\n")
103 if (body.isEmpty()) return null
105 append(heading).append("\n\n")
106 message.info.time?.created?.let { ts -> append("_").append(formatExportTimestamp(ts)).append("_\n\n") }
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
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")
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
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)}" }
151 val status = when (part.state) {
152 is ToolCompleted -> null
153 is ToolError -> " — error"
154 is ToolRunning -> " — running"
157 val head = "**${part.tool}**${status ?: ""}"
158 val titleLine = title?.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
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")
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 {
182 if (current > max) max = current
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)
203 '\\', '*', '_', '#', '`', '>', '[', ']', '(', ')', '!', '+', '-' -> 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