1package soy.iko.opencode.ui.chat
3import soy.iko.opencode.data.model.AssistantMessage
4import soy.iko.opencode.data.model.MessageWithParts
5import soy.iko.opencode.data.model.ReasoningPart
6import soy.iko.opencode.data.model.TextPart
7import soy.iko.opencode.data.model.ToolCompleted
8import soy.iko.opencode.data.model.ToolError
9import soy.iko.opencode.data.model.ToolPart
10import soy.iko.opencode.data.model.ToolRunning
11import soy.iko.opencode.data.model.UserMessage
14 * Render a conversation as Markdown for sharing/exporting. Each message becomes a
15 * role-prefixed section containing its text parts; reasoning is exported as a blockquote
16 * and tool calls are summarized so the transcript shows what the agent *did*, not just
17 * what it said. Pure (no Android deps) so it can be unit-tested directly.
19fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?): String {
20 val sb = StringBuilder(messages.size * 128)
21 if (!title.isNullOrBlank()) {
22 sb.append("# ").append(escapeMarkdown(title.trim())).append("\n\n")
24 for (message in messages) {
25 val heading = when (message.info) {
26 is UserMessage -> "## You"
27 is AssistantMessage -> "## opencode"
30 // The assistant's reply text is already Markdown (code fences, bold, headings);
31 // escaping it would render the export as backslash noise. Only escape user-authored
32 // text, which isn't expected to be Markdown.
33 val escapeText = message.info is UserMessage
34 val body = message.parts
37 is TextPart -> part.text.takeIf { it.isNotBlank() }
38 ?.let { if (escapeText) escapeMarkdown(it) else it }
39 is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
40 ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
41 is ToolPart -> formatToolCall(part)
47 if (body.isEmpty()) continue
48 sb.append(heading).append("\n\n").append(body).append("\n\n")
50 return sb.toString().trimEnd()
53/** Render a tool call as a compact, readable blockquote summary: the tool name, its
54 * human-readable title (if any), and a truncated output/error. Keeps the exported
55 * transcript useful when the agent's work (edits, command runs) is the substance. */
56private fun formatToolCall(part: ToolPart): String? {
57 val title = when (val s = part.state) {
58 is ToolRunning -> s.title
59 is ToolCompleted -> s.title
62 val detail = when (val s = part.state) {
63 is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }?.let { truncateOutput(it) }
64 is ToolError -> s.error?.takeIf { it.isNotBlank() }?.let { "Error: ${truncateOutput(it)}" }
67 val status = when (part.state) {
68 is ToolCompleted -> null
69 is ToolError -> " — error"
70 is ToolRunning -> " — running"
73 val head = "**${part.tool}**${status ?: ""}"
74 val titleLine = title?.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
77 if (titleLine != null) add("> $titleLine")
78 // Prefix every detail line with "> " so the fenced code block stays inside the
79 // blockquote — otherwise lines after the first fall out of the quote.
81 val quoted = detail.lines().joinToString("\n") { "> $it" }
82 // Use a fence longer than the longest backtick run in the content, so tool output
83 // that itself contains a ``` sequence (shell output echoing markdown, etc.) can't
84 // prematurely close the block and make the rest of the transcript render broken.
85 val fence = "`".repeat(maxOf(3, longestBacktickRun(detail) + 1))
86 add(">\n> $fence\n$quoted\n> $fence")
88 }.joinToString("\n").takeIf { it.isNotBlank() }
91/** Length of the longest consecutive run of backticks in [text] (0 if none). */
92private fun longestBacktickRun(text: String): Int {
98 if (current > max) max = current
106private fun truncateOutput(output: String): String {
107 if (output.length <= TOOL_OUTPUT_LIMIT) return output
108 return output.take(TOOL_OUTPUT_LIMIT) + "\n… (truncated)"
111private const val TOOL_OUTPUT_LIMIT = 2000
113/** Escape markdown special characters so user/model text doesn't produce malformed markdown. */
114private fun escapeMarkdown(text: String): String {
115 if (text.isEmpty()) return text
116 val sb = StringBuilder(text.length + 16)
119 '\\', '*', '_', '#', '`', '>' -> 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