git.iko.soy/opencode-android
on 8bc2cce help sign in
124 lines 5.2 kB history raw
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
13/**
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.
18 */
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")
23 }
24 for (message in messages) {
25 val heading = when (message.info) {
26 is UserMessage -> "## You"
27 is AssistantMessage -> "## opencode"
28 else -> null
29 } ?: continue
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
35 .mapNotNull { part ->
36 when (part) {
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)
42 else -> null
43 }
44 }
45 .joinToString("\n\n")
46 .trim()
47 if (body.isEmpty()) continue
48 sb.append(heading).append("\n\n").append(body).append("\n\n")
49 }
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
60 else -> null
61 }
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)}" }
65 else -> null
66 }
67 val status = when (part.state) {
68 is ToolCompleted -> null
69 is ToolError -> " — error"
70 is ToolRunning -> " — running"
71 else -> null
72 }
73 val head = "**${part.tool}**${status ?: ""}"
74 val titleLine = title?.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
75 return buildList {
76 add("> $head")
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.
80 if (detail != null) {
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")
87 }
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 {
93 var max = 0
94 var current = 0
95 for (c in text) {
96 if (c == '`') {
97 current++
98 if (current > max) max = current
99 } else {
100 current = 0
101 }
102 }
103 return max
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)
117 for (c in text) {
118 when (c) {
119 '\\', '*', '_', '#', '`', '>' -> sb.append('\\')
120 }
121 sb.append(c)
122 }
123 return sb.toString()