git.iko.soy/opencode-android
on master help sign in
Add markdown rendering, diff viewer, agent/command pickers, and server quick-switch (M6)
commitf3d1d28773ed93a9516791a50619da4643633b34 authoriko <mail@iko.soy> dateMon 29 Jun 2026, 07:41 (3 months ago) parentf9ed438
15 files changed+1058−31
README.md+8−4 app/src/main/java/soy/iko/opencode/data/model/Agent.kt+29 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+8 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+2−2 app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt+85 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+34−1 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+47−4 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt+75 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+15−8 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt+131 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+321 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+24−10 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+59−1 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+41−1 app/src/test/java/soy/iko/opencode/M6Test.kt+179
README.md+8−4
@@ -6,7 +6,7 @@ Application id: `soy.iko.opencode`
66
77## Status
88
9−Milestones M0–M5 complete:
9+Milestones M0–M6 complete:
1010
1111- **M0–M4:** connect to a server → list/create/delete sessions → chat with **live SSE streaming**
1212 of the assistant's reply. Tool calls, reasoning, and token/cost are rendered.
@@ -15,9 +15,13 @@ Milestones M0–M5 complete:
1515 `POST /session/:id/permissions/:permissionID`, so tool runs don't stall; **file browser + viewer**
1616 (`/find/file` fuzzy search, `/file` directory listing, `/file/content` read-only view); **reconnect
1717 banner** (live SSE connection state) and a real **Settings** screen (theme mode, persisted).
18−
19−Not yet built: rich diff/patch rendering, agents/commands UI, markdown rendering, multi-server
20−quick-switch.
18+- **M6:** **markdown rendering** of assistant text (headings, code blocks, lists, blockquotes, inline
19+ bold/italic/code/links/strikethrough); **rich diff/patch rendering** — tool outputs and file-viewer
20+ content that contain unified diffs are syntax-highlighted (added lines green, removed lines red,
21+ hunk headers); **agents & commands UI** — `GET /agent` powers a per-message agent picker (sent via
22+ the `agent` field on the prompt body); `GET /command` powers a slash-command runner that sends the
23+ command template with its configured agent; **multi-server quick-switch** — the session list title
24+ is a dropdown that lists all saved servers and switches instantly without navigating back.
2125
2226## Architecture
2327
app/src/main/java/soy/iko/opencode/data/model/Agent.ktnew file+29
@@ -0,0 +1,29 @@
1+package soy.iko.opencode.data.model
2+
3+import kotlinx.serialization.Serializable
4+
5+/** Entry from `GET /agent`. */
6+@Serializable
7+data class Agent(
8+ val name: String,
9+ val description: String? = null,
10+ val mode: String? = null,
11+ val builtIn: Boolean = false,
12+ val prompt: String? = null,
13+) {
14+ val isPrimary: Boolean get() = mode == "primary"
15+ val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: "Custom agent"
16+}
17+
18+/** Entry from `GET /command`. */
19+@Serializable
20+data class Command(
21+ val name: String,
22+ val description: String? = null,
23+ val agent: String? = null,
24+ val model: String? = null,
25+ val template: String,
26+ val subtask: Boolean = false,
27+) {
28+ val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: template.take(80)
29+}
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+8
@@ -1,5 +1,7 @@
11package soy.iko.opencode.data.network
22
3+import soy.iko.opencode.data.model.Agent
4+import soy.iko.opencode.data.model.Command
35import soy.iko.opencode.data.model.CreateSessionRequest
46import soy.iko.opencode.data.model.FileContent
57import soy.iko.opencode.data.model.FileNode
@@ -82,6 +84,12 @@ class OpencodeApiClient(private val client: HttpClient) {
8284 suspend fun providers(): ProvidersResponse =
8385 client.get("config/providers").body()
8486
87+ suspend fun agents(): List<Agent> =
88+ client.get("agent").body()
89+
90+ suspend fun commands(): List<Command> =
91+ client.get("command").body()
92+
8593 /** Respond to a permission request so a paused tool run can proceed. */
8694 suspend fun respondPermission(
8795 sessionId: String,
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+2−2
@@ -33,8 +33,8 @@ class SessionRepository(
3333 suspend fun deleteSession(id: String) = api.deleteSession(id)
3434 suspend fun abort(sessionId: String) = api.abort(sessionId)
3535
36− suspend fun sendPrompt(sessionId: String, text: String, model: ModelRef?) =
37− api.sendPrompt(sessionId, text, model)
36+ suspend fun sendPrompt(sessionId: String, text: String, model: ModelRef?, agent: String? = null) =
37+ api.sendPrompt(sessionId, text, model, agent)
3838
3939 /**
4040 * A live, ordered view of [sessionId]'s messages. Begins collecting the event
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.ktnew file+85
@@ -0,0 +1,85 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.fillMaxWidth
6+import androidx.compose.foundation.layout.heightIn
7+import androidx.compose.foundation.layout.padding
8+import androidx.compose.foundation.lazy.LazyColumn
9+import androidx.compose.foundation.lazy.items
10+import androidx.compose.material3.ExperimentalMaterial3Api
11+import androidx.compose.material3.MaterialTheme
12+import androidx.compose.material3.ModalBottomSheet
13+import androidx.compose.material3.Text
14+import androidx.compose.runtime.Composable
15+import androidx.compose.ui.Modifier
16+import androidx.compose.ui.unit.dp
17+import soy.iko.opencode.data.model.Agent
18+
19+/** Bottom sheet that lists every agent and lets the user pick one. */
20+@OptIn(ExperimentalMaterial3Api::class)
21+@Composable
22+fun AgentPickerSheet(
23+ agents: List<Agent>,
24+ selected: String?,
25+ onSelect: (Agent?) -> Unit,
26+ onDismiss: () -> Unit,
27+) {
28+ ModalBottomSheet(onDismissRequest = onDismiss) {
29+ Text(
30+ "Agent",
31+ style = MaterialTheme.typography.titleMedium,
32+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
33+ )
34+ if (agents.isEmpty()) {
35+ Text(
36+ "No agents reported by the server.",
37+ style = MaterialTheme.typography.bodyMedium,
38+ color = MaterialTheme.colorScheme.onSurfaceVariant,
39+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
40+ )
41+ } else {
42+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
43+ item(key = "__default") {
44+ Column(
45+ modifier = Modifier
46+ .fillMaxWidth()
47+ .clickable { onSelect(null); onDismiss() }
48+ .padding(horizontal = 24.dp, vertical = 12.dp),
49+ ) {
50+ Text(
51+ "Default",
52+ style = MaterialTheme.typography.bodyLarge,
53+ color = if (selected == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
54+ )
55+ Text(
56+ "Server default agent" + if (selected == null) " ✓" else "",
57+ style = MaterialTheme.typography.bodySmall,
58+ color = MaterialTheme.colorScheme.onSurfaceVariant,
59+ )
60+ }
61+ }
62+ items(agents, key = { it.name }) { agent ->
63+ val isSelected = agent.name == selected
64+ Column(
65+ modifier = Modifier
66+ .fillMaxWidth()
67+ .clickable { onSelect(agent); onDismiss() }
68+ .padding(horizontal = 24.dp, vertical = 12.dp),
69+ ) {
70+ Text(
71+ agent.name,
72+ style = MaterialTheme.typography.bodyLarge,
73+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
74+ )
75+ Text(
76+ agent.displayDescription + if (isSelected) " ✓" else "",
77+ style = MaterialTheme.typography.bodySmall,
78+ color = MaterialTheme.colorScheme.onSurfaceVariant,
79+ )
80+ }
81+ }
82+ }
83+ }
84+ }
85+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+34−1
@@ -16,7 +16,9 @@ import androidx.compose.foundation.lazy.rememberLazyListState
1616import androidx.compose.material.icons.Icons
1717import androidx.compose.material.icons.automirrored.filled.ArrowBack
1818import androidx.compose.material.icons.automirrored.filled.Send
19+import androidx.compose.material.icons.filled.SmartToy
1920import androidx.compose.material.icons.filled.Stop
21+import androidx.compose.material.icons.filled.Terminal
2022import androidx.compose.material.icons.filled.Tune
2123import androidx.compose.material3.CircularProgressIndicator
2224import androidx.compose.material3.ExperimentalMaterial3Api
@@ -59,11 +61,16 @@ fun ChatScreen(
5961 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
6062 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
6163 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
64+ val agents by vm.agents.collectAsStateWithLifecycle()
65+ val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
66+ val commands by vm.commands.collectAsStateWithLifecycle()
6267
6368 val listState = rememberLazyListState()
6469 val snackbar = remember { SnackbarHostState() }
6570 var input by remember { mutableStateOf("") }
6671 var showModelPicker by remember { mutableStateOf(false) }
72+ var showAgentPicker by remember { mutableStateOf(false) }
73+ var showCommandPicker by remember { mutableStateOf(false) }
6774
6875 // Keep the newest content in view as parts stream in.
6976 LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size) {
@@ -83,7 +90,10 @@ fun ChatScreen(
8390 Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) {
8491 Text("Session")
8592 Text(
86− selectedModel?.modelLabel ?: "default model",
93+ buildString {
94+ append(selectedModel?.modelLabel ?: "default model")
95+ selectedAgent?.let { append(" • $it") }
96+ },
8797 style = MaterialTheme.typography.labelSmall,
8898 color = MaterialTheme.colorScheme.onSurfaceVariant,
8999 )
@@ -95,6 +105,12 @@ fun ChatScreen(
95105 }
96106 },
97107 actions = {
108+ IconButton(onClick = { showCommandPicker = true }, enabled = commands.isNotEmpty()) {
109+ Icon(Icons.Filled.Terminal, contentDescription = "Commands")
110+ }
111+ IconButton(onClick = { showAgentPicker = true }, enabled = agents.isNotEmpty()) {
112+ Icon(Icons.Filled.SmartToy, contentDescription = "Choose agent")
113+ }
98114 IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) {
99115 Icon(Icons.Filled.Tune, contentDescription = "Choose model")
100116 }
@@ -159,6 +175,23 @@ fun ChatScreen(
159175 )
160176 }
161177
178+ if (showAgentPicker) {
179+ AgentPickerSheet(
180+ agents = agents,
181+ selected = selectedAgent,
182+ onSelect = { vm.selectAgent(it?.name) },
183+ onDismiss = { showAgentPicker = false },
184+ )
185+ }
186+
187+ if (showCommandPicker) {
188+ CommandPickerSheet(
189+ commands = commands,
190+ onSelect = { vm.runCommand(it) },
191+ onDismiss = { showCommandPicker = false },
192+ )
193+ }
194+
162195 pendingPermission?.let { permission ->
163196 PermissionDialog(
164197 permission = permission,
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+47−4
@@ -2,6 +2,8 @@ package soy.iko.opencode.ui.chat
22
33import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.Agent
6+import soy.iko.opencode.data.model.Command
57import soy.iko.opencode.data.model.MessageWithParts
68import soy.iko.opencode.data.model.ModelOption
79import soy.iko.opencode.data.model.Permission
@@ -57,10 +59,21 @@ class ChatViewModel(
5759 private val _pendingPermission = MutableStateFlow<Permission?>(null)
5860 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
5961
62+ private val _agents = MutableStateFlow<List<Agent>>(emptyList())
63+ val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
64+
65+ private val _selectedAgent = MutableStateFlow<String?>(null)
66+ val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
67+
68+ private val _commands = MutableStateFlow<List<Command>>(emptyList())
69+ val commands: StateFlow<List<Command>> = _commands.asStateFlow()
70+
6071 fun clearError() { _error.value = null }
6172
6273 fun selectModel(option: ModelOption) { _selectedModel.value = option }
6374
75+ fun selectAgent(name: String?) { _selectedAgent.value = name }
76+
6477 init {
6578 // Watch the bus for run completion / errors / permission asks for this session.
6679 val conn = connection
@@ -90,6 +103,14 @@ class ChatViewModel(
90103 _selectedModel.value = resp.defaultOption(options)
91104 }
92105 }
106+ // Load the agent catalog (non-fatal).
107+ viewModelScope.launch {
108+ runCatching { conn.api.agents() }.getOrNull()?.let { _agents.value = it }
109+ }
110+ // Load the command catalog (non-fatal).
111+ viewModelScope.launch {
112+ runCatching { conn.api.commands() }.getOrNull()?.let { _commands.value = it }
113+ }
93114 }
94115 }
95116
@@ -100,10 +121,32 @@ class ChatViewModel(
100121 _running.value = true
101122 _error.value = null
102123 viewModelScope.launch {
103− runCatching { conn.repository.sendPrompt(sessionId, trimmed, model = _selectedModel.value?.ref) }
104− .onFailure { _error.value = it.message ?: "Failed to send" }
105− // session.idle is the authoritative finalizer; clear here too in case the
106− // POST returns first (idempotent).
124+ runCatching {
125+ conn.repository.sendPrompt(
126+ sessionId,
127+ trimmed,
128+ model = _selectedModel.value?.ref,
129+ agent = _selectedAgent.value,
130+ )
131+ }.onFailure { _error.value = it.message ?: "Failed to send" }
132+ _running.value = false
133+ }
134+ }
135+
136+ /** Invoke a slash-command: sends the command's template text with the command's agent/model if set. */
137+ fun runCommand(command: Command) {
138+ val conn = connection ?: return
139+ if (_running.value) return
140+ _running.value = true
141+ _error.value = null
142+ viewModelScope.launch {
143+ runCatching {
144+ conn.api.sendPrompt(
145+ sessionId,
146+ command.template,
147+ agent = command.agent,
148+ )
149+ }.onFailure { _error.value = it.message ?: "Failed to run command" }
107150 _running.value = false
108151 }
109152 }
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.ktnew file+75
@@ -0,0 +1,75 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.fillMaxWidth
6+import androidx.compose.foundation.layout.heightIn
7+import androidx.compose.foundation.layout.padding
8+import androidx.compose.foundation.lazy.LazyColumn
9+import androidx.compose.foundation.lazy.items
10+import androidx.compose.material.icons.Icons
11+import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
12+import androidx.compose.material3.ExperimentalMaterial3Api
13+import androidx.compose.material3.Icon
14+import androidx.compose.material3.MaterialTheme
15+import androidx.compose.material3.ModalBottomSheet
16+import androidx.compose.material3.Text
17+import androidx.compose.runtime.Composable
18+import androidx.compose.ui.Modifier
19+import androidx.compose.ui.text.style.TextOverflow
20+import androidx.compose.ui.unit.dp
21+import soy.iko.opencode.data.model.Command
22+
23+/** Bottom sheet that lists slash-commands; selecting one invokes it. */
24+@OptIn(ExperimentalMaterial3Api::class)
25+@Composable
26+fun CommandPickerSheet(
27+ commands: List<Command>,
28+ onSelect: (Command) -> Unit,
29+ onDismiss: () -> Unit,
30+) {
31+ ModalBottomSheet(onDismissRequest = onDismiss) {
32+ Text(
33+ "Commands",
34+ style = MaterialTheme.typography.titleMedium,
35+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
36+ )
37+ if (commands.isEmpty()) {
38+ Text(
39+ "No commands reported by the server.",
40+ style = MaterialTheme.typography.bodyMedium,
41+ color = MaterialTheme.colorScheme.onSurfaceVariant,
42+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
43+ )
44+ } else {
45+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
46+ items(commands, key = { it.name }) { cmd ->
47+ Column(
48+ modifier = Modifier
49+ .fillMaxWidth()
50+ .clickable { onSelect(cmd); onDismiss() }
51+ .padding(horizontal = 24.dp, vertical = 12.dp),
52+ ) {
53+ androidx.compose.foundation.layout.Row(
54+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
55+ ) {
56+ Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
57+ Text(
58+ " /${cmd.name}",
59+ style = MaterialTheme.typography.bodyLarge,
60+ )
61+ }
62+ Text(
63+ cmd.displayDescription,
64+ style = MaterialTheme.typography.bodySmall,
65+ color = MaterialTheme.colorScheme.onSurfaceVariant,
66+ maxLines = 2,
67+ overflow = TextOverflow.Ellipsis,
68+ modifier = Modifier.padding(start = 28.dp, top = 2.dp),
69+ )
70+ }
71+ }
72+ }
73+ }
74+ }
75+}
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt+15−8
@@ -42,17 +42,19 @@ import soy.iko.opencode.data.model.ToolRunning
4242import soy.iko.opencode.data.model.ToolState
4343import soy.iko.opencode.data.model.ToolUnknown
4444import soy.iko.opencode.data.model.UnknownPart
45+import soy.iko.opencode.ui.components.DiffView
46+import soy.iko.opencode.ui.components.MarkdownText
47+import soy.iko.opencode.ui.components.looksLikeDiff
4548
4649/**
4750 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
4851 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
49− * (Markdown rendering is deferred — text is shown as-is for the scaffold.)
5052 */
5153@Composable
5254fun PartView(part: Part, modifier: Modifier = Modifier) {
5355 when (part) {
5456 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
55− Text(part.text, modifier = modifier, style = MaterialTheme.typography.bodyLarge)
57+ MarkdownText(part.text, modifier = modifier)
5658 }
5759 is ReasoningPart -> ReasoningBlock(part.text, modifier)
5860 is ToolPart -> ToolCallView(part, modifier)
@@ -117,12 +119,17 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
117119 else -> null
118120 }
119121 if (detail != null) {
120− Text(
121− detail.take(2000),
122− style = MaterialTheme.typography.bodySmall,
123− fontFamily = FontFamily.Monospace,
124− modifier = Modifier.padding(top = 6.dp),
125− )
122+ val truncated = detail.take(5000)
123+ if (looksLikeDiff(truncated)) {
124+ DiffView(truncated)
125+ } else {
126+ Text(
127+ truncated.take(2000),
128+ style = MaterialTheme.typography.bodySmall,
129+ fontFamily = FontFamily.Monospace,
130+ modifier = Modifier.padding(top = 6.dp),
131+ )
132+ }
126133 }
127134 }
128135}
app/src/main/java/soy/iko/opencode/ui/components/DiffView.ktnew file+131
@@ -0,0 +1,131 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.background
4+import androidx.compose.foundation.horizontalScroll
5+import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.Row
7+import androidx.compose.foundation.layout.fillMaxWidth
8+import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.width
10+import androidx.compose.foundation.layout.widthIn
11+import androidx.compose.foundation.rememberScrollState
12+import androidx.compose.foundation.shape.RoundedCornerShape
13+import androidx.compose.foundation.verticalScroll
14+import androidx.compose.material3.MaterialTheme
15+import androidx.compose.material3.Text
16+import androidx.compose.runtime.Composable
17+import androidx.compose.runtime.remember
18+import androidx.compose.ui.Alignment
19+import androidx.compose.ui.Modifier
20+import androidx.compose.ui.draw.clip
21+import androidx.compose.ui.graphics.Color
22+import androidx.compose.ui.text.font.FontFamily
23+import androidx.compose.ui.unit.dp
24+
25+/** A single line of a parsed unified diff. */
26+sealed interface DiffLine {
27+ val text: String
28+
29+ data class Hunk(override val text: String) : DiffLine // @@ ... @@
30+ data class FileHeader(override val text: String) : DiffLine // --- / +++
31+ data class Context(override val text: String) : DiffLine
32+ data class Add(override val text: String) : DiffLine
33+ data class Remove(override val text: String) : DiffLine
34+}
35+
36+/** Parse a unified diff string into typed [DiffLine]s. */
37+fun parseDiff(diff: String): List<DiffLine> {
38+ val result = mutableListOf<DiffLine>()
39+ for (raw in diff.split("\n")) {
40+ when {
41+ raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
42+ raw.startsWith("---") || raw.startsWith("+++") -> result.add(DiffLine.FileHeader(raw))
43+ raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
44+ raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
45+ raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
46+ raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw))
47+ raw.isNotBlank() -> result.add(DiffLine.Context(raw))
48+ }
49+ }
50+ return result
51+}
52+
53+/** Heuristic: does this string look like a unified diff? */
54+fun looksLikeDiff(text: String): Boolean {
55+ val lines = text.lineSequence()
56+ var hunkCount = 0
57+ var addCount = 0
58+ var removeCount = 0
59+ for (line in lines) {
60+ when {
61+ line.startsWith("@@") -> hunkCount++
62+ line.startsWith("+") && !line.startsWith("+++") -> addCount++
63+ line.startsWith("-") && !line.startsWith("---") -> removeCount++
64+ }
65+ if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
66+ }
67+ return false
68+}
69+
70+@Composable
71+fun DiffView(diff: String, modifier: Modifier = Modifier) {
72+ val lines = remember(diff) { parseDiff(diff) }
73+ val addColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
74+ val removeColor = MaterialTheme.colorScheme.error.copy(alpha = 0.15f)
75+ val addText = MaterialTheme.colorScheme.primary
76+ val removeText = MaterialTheme.colorScheme.error
77+
78+ Column(
79+ modifier = modifier
80+ .fillMaxWidth()
81+ .padding(vertical = 4.dp)
82+ .clip(RoundedCornerShape(8.dp))
83+ .background(MaterialTheme.colorScheme.surfaceVariant)
84+ .verticalScroll(rememberScrollState())
85+ .horizontalScroll(rememberScrollState())
86+ .padding(10.dp),
87+ ) {
88+ for (line in lines) {
89+ when (line) {
90+ is DiffLine.Hunk -> Text(
91+ line.text,
92+ style = MaterialTheme.typography.bodySmall,
93+ fontFamily = FontFamily.Monospace,
94+ color = MaterialTheme.colorScheme.tertiary,
95+ modifier = Modifier.padding(vertical = 2.dp),
96+ )
97+ is DiffLine.FileHeader -> Text(
98+ line.text,
99+ style = MaterialTheme.typography.bodySmall,
100+ fontFamily = FontFamily.Monospace,
101+ color = MaterialTheme.colorScheme.onSurfaceVariant,
102+ modifier = Modifier.padding(vertical = 1.dp),
103+ )
104+ is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
105+ is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
106+ is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
107+ }
108+ }
109+ }
110+}
111+
112+@Composable
113+private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
114+ Row(
115+ modifier = Modifier.fillMaxWidth().background(bg),
116+ ) {
117+ Text(
118+ prefix,
119+ style = MaterialTheme.typography.bodySmall,
120+ fontFamily = FontFamily.Monospace,
121+ color = textColor,
122+ modifier = Modifier.width(16.dp),
123+ )
124+ Text(
125+ text,
126+ style = MaterialTheme.typography.bodySmall,
127+ fontFamily = FontFamily.Monospace,
128+ color = textColor,
129+ )
130+ }
131+}
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.ktnew file+321
@@ -0,0 +1,321 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.horizontalScroll
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
6+import androidx.compose.foundation.layout.Spacer
7+import androidx.compose.foundation.layout.fillMaxWidth
8+import androidx.compose.foundation.layout.height
9+import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.rememberScrollState
11+import androidx.compose.foundation.shape.RoundedCornerShape
12+import androidx.compose.material3.HorizontalDivider
13+import androidx.compose.material3.MaterialTheme
14+import androidx.compose.material3.Text
15+import androidx.compose.runtime.Composable
16+import androidx.compose.runtime.remember
17+import androidx.compose.ui.Modifier
18+import androidx.compose.ui.draw.clip
19+import androidx.compose.ui.draw.drawBehind
20+import androidx.compose.ui.geometry.Offset
21+import androidx.compose.ui.text.AnnotatedString
22+import androidx.compose.ui.text.SpanStyle
23+import androidx.compose.ui.text.TextStyle
24+import androidx.compose.ui.text.buildAnnotatedString
25+import androidx.compose.ui.text.font.FontFamily
26+import androidx.compose.ui.text.font.FontStyle
27+import androidx.compose.ui.text.font.FontWeight
28+import androidx.compose.ui.text.style.TextDecoration
29+import androidx.compose.ui.unit.dp
30+
31+// --- Block model ------------------------------------------------------------
32+
33+internal sealed interface MdBlock {
34+ data class Heading(val level: Int, val text: String) : MdBlock
35+ data class Code(val lang: String?, val code: String) : MdBlock
36+ data class ListBlock(val ordered: Boolean, val items: List<ListItem>) : MdBlock
37+ data class Quote(val text: String) : MdBlock
38+ data class Paragraph(val text: String) : MdBlock
39+ data object Hr : MdBlock
40+}
41+
42+internal data class ListItem(val text: String, val depth: Int)
43+
44+// --- Block parser -----------------------------------------------------------
45+
46+private val headingRegex = Regex("^(#{1,6})\\s+(.+)$")
47+private val hrRegex = Regex("^([-*_])\\1{2,}$")
48+private val listRegex = Regex("^(\\s*)([-*+]|\\d+\\.)\\s+(.+)$")
49+
50+internal fun parseMarkdown(src: String): List<MdBlock> {
51+ val lines = src.split("\n")
52+ val blocks = mutableListOf<MdBlock>()
53+ var i = 0
54+ while (i < lines.size) {
55+ val line = lines[i]
56+ val trimmed = line.trim()
57+
58+ if (trimmed.isEmpty()) { i++; continue }
59+
60+ // Fenced code block
61+ if (trimmed.startsWith("```")) {
62+ val lang = trimmed.removePrefix("```").trim().takeIf { it.isNotEmpty() }
63+ val sb = StringBuilder()
64+ i++
65+ while (i < lines.size && !lines[i].trim().startsWith("```")) {
66+ sb.append(lines[i]).append('\n')
67+ i++
68+ }
69+ i++
70+ blocks.add(MdBlock.Code(lang, sb.toString().trimEnd('\n')))
71+ continue
72+ }
73+
74+ // Horizontal rule
75+ if (trimmed.matches(hrRegex)) { blocks.add(MdBlock.Hr); i++; continue }
76+
77+ // Heading
78+ headingRegex.matchEntire(trimmed)?.let { m ->
79+ blocks.add(MdBlock.Heading(m.groupValues[1].length, m.groupValues[2].trim()))
80+ i++
81+ return@let
82+ } ?: run {
83+ // Blockquote
84+ if (trimmed.startsWith(">")) {
85+ val sb = StringBuilder()
86+ while (i < lines.size && lines[i].trim().startsWith(">")) {
87+ sb.appendLine(lines[i].trim().removePrefix(">").trim())
88+ i++
89+ }
90+ blocks.add(MdBlock.Quote(sb.toString().trim()))
91+ return@run
92+ }
93+
94+ // List
95+ if (listRegex.matchEntire(trimmed) != null) {
96+ val items = mutableListOf<ListItem>()
97+ val ordered = trimmed.matches(Regex("^\\s*\\d+\\.\\s+.+"))
98+ while (i < lines.size) {
99+ val l = lines[i]
100+ val lm = listRegex.matchEntire(l)
101+ if (lm != null) {
102+ val depth = (lm.groupValues[1].length / 2).coerceAtMost(4)
103+ items.add(ListItem(lm.groupValues[3].trim(), depth))
104+ i++
105+ } else if (l.isBlank()) {
106+ i++
107+ } else if (l.startsWith(" ") || l.startsWith("\t")) {
108+ if (items.isNotEmpty()) {
109+ items[items.lastIndex] = items.last().copy(text = items.last().text + " " + l.trim())
110+ }
111+ i++
112+ } else {
113+ break
114+ }
115+ }
116+ blocks.add(MdBlock.ListBlock(ordered, items))
117+ return@run
118+ }
119+
120+ // Paragraph
121+ val sb = StringBuilder(trimmed)
122+ i++
123+ while (i < lines.size && lines[i].isNotBlank() &&
124+ !lines[i].trim().startsWith("```") &&
125+ !lines[i].trim().startsWith("#") &&
126+ !lines[i].trim().startsWith(">") &&
127+ !lines[i].trim().matches(hrRegex) &&
128+ listRegex.matchEntire(lines[i].trim()) == null
129+ ) {
130+ sb.append('\n').append(lines[i].trim())
131+ i++
132+ }
133+ blocks.add(MdBlock.Paragraph(sb.toString()))
134+ }
135+ }
136+ return blocks
137+}
138+
139+// --- Inline parser ----------------------------------------------------------
140+
141+private data class InlineColors(
142+ val link: androidx.compose.ui.graphics.Color,
143+ val codeBg: androidx.compose.ui.graphics.Color,
144+)
145+
146+private fun renderInline(text: String, colors: InlineColors): AnnotatedString = buildAnnotatedString {
147+ var i = 0
148+
149+ fun pushText(s: String) { append(s) }
150+
151+ fun findClosing(start: Int, delim: String): Int {
152+ val idx = text.indexOf(delim, start)
153+ return if (idx >= 0) idx else -1
154+ }
155+
156+ while (i < text.length) {
157+ // Inline code
158+ if (text.startsWith("`", i)) {
159+ val end = findClosing(i + 1, "`")
160+ if (end > i) {
161+ pushStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = colors.codeBg))
162+ pushText(text.substring(i + 1, end))
163+ pop()
164+ i = end + 1
165+ continue
166+ }
167+ }
168+ // Bold
169+ if (text.startsWith("**", i)) {
170+ val end = findClosing(i + 2, "**")
171+ if (end > i) {
172+ pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
173+ pushText(text.substring(i + 2, end))
174+ pop()
175+ i = end + 2
176+ continue
177+ }
178+ }
179+ // Strikethrough
180+ if (text.startsWith("~~", i)) {
181+ val end = findClosing(i + 2, "~~")
182+ if (end > i) {
183+ pushStyle(SpanStyle(textDecoration = TextDecoration.LineThrough))
184+ pushText(text.substring(i + 2, end))
185+ pop()
186+ i = end + 2
187+ continue
188+ }
189+ }
190+ // Italic (*text*)
191+ if (text[i] == '*' && (i + 1 >= text.length || text[i + 1] != '*')) {
192+ val end = text.indexOf('*', i + 1)
193+ if (end > i + 1) {
194+ pushStyle(SpanStyle(fontStyle = FontStyle.Italic))
195+ pushText(text.substring(i + 1, end))
196+ pop()
197+ i = end + 1
198+ continue
199+ }
200+ }
201+ // Link [text](url)
202+ if (text[i] == '[') {
203+ val closeBracket = text.indexOf(']', i + 1)
204+ if (closeBracket > i && closeBracket + 1 < text.length && text[closeBracket + 1] == '(') {
205+ val closeParen = text.indexOf(')', closeBracket + 2)
206+ if (closeParen > closeBracket) {
207+ val label = text.substring(i + 1, closeBracket)
208+ pushStyle(SpanStyle(color = colors.link, textDecoration = TextDecoration.Underline))
209+ pushText(label)
210+ pop()
211+ i = closeParen + 1
212+ continue
213+ }
214+ }
215+ }
216+ pushText(text[i].toString())
217+ i++
218+ }
219+}
220+
221+// --- Composable -------------------------------------------------------------
222+
223+/**
224+ * Renders a markdown string with headings, code blocks, lists, blockquotes,
225+ * inline bold/italic/code/links, and horizontal rules.
226+ */
227+@Composable
228+fun MarkdownText(
229+ markdown: String,
230+ modifier: Modifier = Modifier,
231+ style: TextStyle = MaterialTheme.typography.bodyLarge,
232+) {
233+ val blocks = remember(markdown) { parseMarkdown(markdown) }
234+ val colors = InlineColors(
235+ link = MaterialTheme.colorScheme.primary,
236+ codeBg = MaterialTheme.colorScheme.surfaceVariant,
237+ )
238+ Column(modifier = modifier.fillMaxWidth()) {
239+ for (block in blocks) {
240+ when (block) {
241+ is MdBlock.Heading -> Text(
242+ text = renderInline(block.text, colors),
243+ style = when (block.level) {
244+ 1 -> MaterialTheme.typography.headlineSmall
245+ 2 -> MaterialTheme.typography.titleLarge
246+ 3 -> MaterialTheme.typography.titleMedium
247+ else -> MaterialTheme.typography.titleSmall
248+ },
249+ modifier = Modifier.padding(top = if (block.level <= 2) 8.dp else 4.dp, bottom = 2.dp),
250+ )
251+
252+ is MdBlock.Code -> CodeBlock(block.code)
253+
254+ is MdBlock.ListBlock -> {
255+ Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
256+ block.items.forEachIndexed { index, item ->
257+ Row(modifier = Modifier.padding(start = (item.depth * 12).dp, bottom = 2.dp)) {
258+ Text(
259+ if (block.ordered) "${index + 1}. " else "• ",
260+ style = style,
261+ )
262+ Text(
263+ renderInline(item.text, colors),
264+ style = style,
265+ modifier = Modifier.weight(1f),
266+ )
267+ }
268+ }
269+ }
270+ }
271+
272+ is MdBlock.Quote -> {
273+ val quoteColor = MaterialTheme.colorScheme.onSurfaceVariant
274+ Text(
275+ text = renderInline(block.text, colors),
276+ style = style.copy(fontStyle = FontStyle.Italic),
277+ modifier = Modifier
278+ .fillMaxWidth()
279+ .drawBehind {
280+ drawLine(
281+ quoteColor.copy(alpha = 0.4f),
282+ Offset(0f, 0f),
283+ Offset(0f, size.height),
284+ strokeWidth = 3.dp.toPx(),
285+ )
286+ }
287+ .padding(start = 12.dp, top = 2.dp, bottom = 2.dp),
288+ color = quoteColor,
289+ )
290+ }
291+
292+ is MdBlock.Paragraph -> Text(
293+ text = renderInline(block.text, colors),
294+ style = style,
295+ modifier = Modifier.padding(bottom = 4.dp),
296+ )
297+
298+ MdBlock.Hr -> HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp))
299+ }
300+ }
301+ }
302+}
303+
304+@Composable
305+private fun CodeBlock(code: String) {
306+ val bg = MaterialTheme.colorScheme.surfaceVariant
307+ Column(
308+ modifier = Modifier
309+ .fillMaxWidth()
310+ .padding(vertical = 4.dp)
311+ .clip(RoundedCornerShape(8.dp))
312+ .drawBehind { drawRect(bg) }
313+ .padding(10.dp)
314+ .horizontalScroll(rememberScrollState()),
315+ ) {
316+ Text(
317+ text = code,
318+ style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
319+ )
320+ }
321+}
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+24−10
@@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
2626import androidx.lifecycle.compose.collectAsStateWithLifecycle
2727import androidx.lifecycle.viewmodel.compose.viewModel
2828import soy.iko.opencode.di.AppContainer
29+import soy.iko.opencode.ui.components.DiffView
2930import soy.iko.opencode.ui.vmFactory
3031
3132@OptIn(ExperimentalMaterial3Api::class)
@@ -63,16 +64,29 @@ fun FileViewScreen(
6364 modifier = Modifier.align(Alignment.Center).padding(24.dp),
6465 color = MaterialTheme.colorScheme.onSurfaceVariant,
6566 )
66− else -> Text(
67− text = state.content?.content.orEmpty(),
68− modifier = Modifier
69− .fillMaxSize()
70− .verticalScroll(rememberScrollState())
71− .horizontalScroll(rememberScrollState())
72− .padding(12.dp),
73− style = MaterialTheme.typography.bodySmall,
74− fontFamily = FontFamily.Monospace,
75− )
67+ else -> {
68+ val content = state.content
69+ val scrollState = rememberScrollState()
70+ if (content?.diff != null && content.diff.isNotBlank()) {
71+ DiffView(
72+ diff = content.diff,
73+ modifier = Modifier
74+ .fillMaxSize()
75+ .padding(padding),
76+ )
77+ } else {
78+ Text(
79+ text = content?.content.orEmpty(),
80+ modifier = Modifier
81+ .fillMaxSize()
82+ .verticalScroll(scrollState)
83+ .horizontalScroll(rememberScrollState())
84+ .padding(12.dp),
85+ style = MaterialTheme.typography.bodySmall,
86+ fontFamily = FontFamily.Monospace,
87+ )
88+ }
89+ }
7690 }
7791 }
7892 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+59−1
@@ -9,17 +9,21 @@ import androidx.compose.foundation.layout.Row
99import androidx.compose.foundation.layout.fillMaxSize
1010import androidx.compose.foundation.layout.fillMaxWidth
1111import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.size
1213import androidx.compose.foundation.lazy.LazyColumn
1314import androidx.compose.foundation.lazy.items
1415import androidx.compose.material.icons.Icons
1516import androidx.compose.material.icons.automirrored.filled.Logout
1617import androidx.compose.material.icons.filled.Add
18+import androidx.compose.material.icons.filled.ArrowDropDown
1719import androidx.compose.material.icons.filled.Delete
1820import androidx.compose.material.icons.filled.Folder
1921import androidx.compose.material.icons.filled.Refresh
2022import androidx.compose.material.icons.filled.Settings
2123import androidx.compose.material3.Card
2224import androidx.compose.material3.CircularProgressIndicator
25+import androidx.compose.material3.DropdownMenu
26+import androidx.compose.material3.DropdownMenuItem
2327import androidx.compose.material3.ExperimentalMaterial3Api
2428import androidx.compose.material3.ExtendedFloatingActionButton
2529import androidx.compose.material3.Icon
@@ -30,6 +34,9 @@ import androidx.compose.material3.Text
3034import androidx.compose.material3.TopAppBar
3135import androidx.compose.runtime.Composable
3236import androidx.compose.runtime.getValue
37+import androidx.compose.runtime.mutableStateOf
38+import androidx.compose.runtime.remember
39+import androidx.compose.runtime.setValue
3340import androidx.compose.ui.Alignment
3441import androidx.compose.ui.Modifier
3542import androidx.compose.ui.text.style.TextOverflow
@@ -50,11 +57,62 @@ fun SessionListScreen(
5057) {
5158 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
5259 val state by vm.state.collectAsStateWithLifecycle()
60+ val serverLabel by vm.serverLabel.collectAsStateWithLifecycle()
61+ val profiles by vm.profiles.collectAsStateWithLifecycle()
62+ val switchingId by vm.switchingId.collectAsStateWithLifecycle()
63+ var showServerMenu by remember { mutableStateOf(false) }
5364
5465 Scaffold(
5566 topBar = {
5667 TopAppBar(
57− title = { Text(vm.serverLabel) },
68+ title = {
69+ Column {
70+ Box {
71+ Row(
72+ modifier = Modifier.clickable { showServerMenu = true },
73+ verticalAlignment = Alignment.CenterVertically,
74+ ) {
75+ Text(serverLabel, maxLines = 1, overflow = TextOverflow.Ellipsis)
76+ Icon(Icons.Filled.ArrowDropDown, contentDescription = "Switch server")
77+ }
78+ DropdownMenu(
79+ expanded = showServerMenu,
80+ onDismissRequest = { showServerMenu = false },
81+ ) {
82+ profiles.forEach { profile ->
83+ DropdownMenuItem(
84+ text = {
85+ Row(verticalAlignment = Alignment.CenterVertically) {
86+ Column(modifier = Modifier.weight(1f)) {
87+ Text(
88+ profile.displayLabel,
89+ style = MaterialTheme.typography.bodyLarge,
90+ maxLines = 1,
91+ overflow = TextOverflow.Ellipsis,
92+ )
93+ Text(
94+ profile.baseUrl,
95+ style = MaterialTheme.typography.bodySmall,
96+ color = MaterialTheme.colorScheme.onSurfaceVariant,
97+ maxLines = 1,
98+ overflow = TextOverflow.Ellipsis,
99+ )
100+ }
101+ if (switchingId == profile.id) {
102+ CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
103+ }
104+ }
105+ },
106+ onClick = {
107+ showServerMenu = false
108+ vm.switchServer(profile)
109+ },
110+ )
111+ }
112+ }
113+ }
114+ }
115+ },
58116 actions = {
59117 IconButton(onClick = { vm.refresh() }) {
60118 Icon(Icons.Filled.Refresh, contentDescription = "Refresh")
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+41−1
@@ -2,11 +2,14 @@ package soy.iko.opencode.ui.session
22
33import androidx.lifecycle.ViewModel
44import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.ServerProfile
56import soy.iko.opencode.data.model.Session
67import soy.iko.opencode.di.AppContainer
78import kotlinx.coroutines.flow.MutableStateFlow
9+import kotlinx.coroutines.flow.SharingStarted
810import kotlinx.coroutines.flow.StateFlow
911import kotlinx.coroutines.flow.asStateFlow
12+import kotlinx.coroutines.flow.stateIn
1013import kotlinx.coroutines.launch
1114
1215data class SessionListState(
@@ -17,12 +20,27 @@ data class SessionListState(
1720
1821class SessionListViewModel(private val container: AppContainer) : ViewModel() {
1922
20− val serverLabel: String =
23+ val profiles: StateFlow<List<ServerProfile>> =
24+ container.profileStore.profiles.stateIn(
25+ scope = viewModelScope,
26+ started = SharingStarted.WhileSubscribed(5_000),
27+ initialValue = emptyList(),
28+ )
29+
30+ private val _serverLabel = MutableStateFlow(
2131 container.activeConnection.value?.profile?.displayLabel ?: "opencode"
32+ )
33+ val serverLabel: StateFlow<String> = _serverLabel.asStateFlow()
34+
35+ private val _switchingId = MutableStateFlow<String?>(null)
36+ val switchingId: StateFlow<String?> = _switchingId.asStateFlow()
2237
2338 private val _state = MutableStateFlow(SessionListState())
2439 val state: StateFlow<SessionListState> = _state.asStateFlow()
2540
41+ private val activeProfileId: String?
42+ get() = container.activeConnection.value?.profile?.id
43+
2644 init { refresh() }
2745
2846 fun refresh() {
@@ -31,6 +49,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
3149 _state.value = SessionListState(loading = false, error = "Not connected")
3250 return
3351 }
52+ _serverLabel.value = conn.profile.displayLabel
3453 _state.value = _state.value.copy(loading = true, error = null)
3554 viewModelScope.launch {
3655 runCatching { conn.repository.listSessions() }
@@ -61,4 +80,25 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
6180 .onFailure { _state.value = _state.value.copy(error = it.message ?: "Failed to delete session") }
6281 }
6382 }
83+
84+ /** Quick-switch to a different saved server without leaving the session list. */
85+ fun switchServer(profile: ServerProfile) {
86+ if (profile.id == activeProfileId) return
87+ if (_switchingId.value != null) return
88+ _switchingId.value = profile.id
89+ _state.value = _state.value.copy(loading = true, error = null)
90+ viewModelScope.launch {
91+ val result = runCatching {
92+ val conn = container.connect(profile)
93+ conn.api.ping()
94+ }
95+ _switchingId.value = null
96+ result.onSuccess { refresh() }
97+ .onFailure {
98+ container.disconnect()
99+ _state.value = SessionListState(loading = false, error = it.message ?: "Could not reach ${profile.baseUrl}")
100+ refresh()
101+ }
102+ }
103+ }
64104}
app/src/test/java/soy/iko/opencode/M6Test.ktnew file+179
@@ -0,0 +1,179 @@
1+package soy.iko.opencode
2+
3+import org.junit.Assert.assertEquals
4+import org.junit.Assert.assertFalse
5+import org.junit.Assert.assertTrue
6+import org.junit.Test
7+import soy.iko.opencode.data.model.Agent
8+import soy.iko.opencode.data.model.Command
9+import soy.iko.opencode.data.network.OpencodeJson
10+import soy.iko.opencode.ui.components.DiffLine
11+import soy.iko.opencode.ui.components.MdBlock
12+import soy.iko.opencode.ui.components.looksLikeDiff
13+import soy.iko.opencode.ui.components.parseDiff
14+import soy.iko.opencode.ui.components.parseMarkdown
15+import kotlinx.serialization.builtins.ListSerializer
16+
17+class M6Test {
18+
19+ private fun <T> decode(s: kotlinx.serialization.KSerializer<T>, json: String): T =
20+ OpencodeJson.decodeFromString(s, json)
21+
22+ // --- Agent / Command decoding ---
23+
24+ @Test
25+ fun decodesAgentList() {
26+ val json = """
27+ [
28+ { "name": "build", "description": "Build and implement code", "mode": "primary",
29+ "builtIn": true, "tools": {}, "options": {} },
30+ { "name": "custom", "mode": "subagent", "builtIn": false }
31+ ]
32+ """.trimIndent()
33+ val agents = decode(ListSerializer(Agent.serializer()), json)
34+ assertEquals(2, agents.size)
35+ assertEquals("build", agents[0].name)
36+ assertTrue(agents[0].isPrimary)
37+ assertTrue(agents[0].builtIn)
38+ assertEquals("Build and implement code", agents[0].displayDescription)
39+ assertFalse(agents[1].isPrimary)
40+ assertEquals("Custom agent", agents[1].displayDescription)
41+ }
42+
43+ @Test
44+ fun decodesCommandList() {
45+ val json = """
46+ [
47+ { "name": "compact", "description": "Compact the session",
48+ "template": "Summarize the conversation so far", "subtask": false },
49+ { "name": "test", "agent": "build", "template": "Run the tests" }
50+ ]
51+ """.trimIndent()
52+ val commands = decode(ListSerializer(Command.serializer()), json)
53+ assertEquals(2, commands.size)
54+ assertEquals("compact", commands[0].name)
55+ assertEquals("Compact the session", commands[0].displayDescription)
56+ assertEquals("build", commands[1].agent)
57+ }
58+
59+ // --- Diff parsing ---
60+
61+ @Test
62+ fun parsesUnifiedDiff() {
63+ val diff = """
64+ --- a/src/Main.kt
65+ +++ b/src/Main.kt
66+ @@ -10,3 +10,4 @@
67+ fun main() {
68+ - println("old")
69+ + println("new")
70+ + // added
71+ }
72+ """.trimIndent()
73+ val lines = parseDiff(diff)
74+ assertTrue(lines[0] is DiffLine.FileHeader)
75+ assertTrue(lines[1] is DiffLine.FileHeader)
76+ assertTrue(lines[2] is DiffLine.Hunk)
77+ assertTrue(lines[3] is DiffLine.Context)
78+ assertTrue(lines[4] is DiffLine.Remove)
79+ assertTrue(lines[5] is DiffLine.Add)
80+ assertTrue(lines[6] is DiffLine.Add)
81+ assertTrue(lines[7] is DiffLine.Context)
82+ assertEquals(" println(\"old\")", (lines[4] as DiffLine.Remove).text)
83+ assertEquals(" println(\"new\")", (lines[5] as DiffLine.Add).text)
84+ }
85+
86+ @Test
87+ fun looksLikeDiffDetectsRealDiffs() {
88+ val realDiff = """
89+ @@ -1,2 +1,2 @@
90+ -old line
91+ +new line
92+ """.trimIndent()
93+ assertTrue(looksLikeDiff(realDiff))
94+
95+ val plainText = "This is just a regular message from the assistant."
96+ assertFalse(looksLikeDiff(plainText))
97+ }
98+
99+ @Test
100+ fun looksLikeDiffIgnoresStrayMarkers() {
101+ assertFalse(looksLikeDiff("not a diff at all"))
102+ assertFalse(looksLikeDiff("just some + text - here"))
103+ }
104+
105+ // --- Markdown parsing ---
106+
107+ @Test
108+ fun parsesMarkdownHeadingsAndParagraphs() {
109+ val md = """
110+ # Title
111+
112+ Some body text here.
113+ """.trimIndent()
114+ val blocks = parseMarkdown(md)
115+ assertEquals(2, blocks.size)
116+ val heading = blocks[0] as MdBlock.Heading
117+ assertEquals(1, heading.level)
118+ assertEquals("Title", heading.text)
119+ assertTrue(blocks[1] is MdBlock.Paragraph)
120+ }
121+
122+ @Test
123+ fun parsesMarkdownCodeBlock() {
124+ val md = """
125+ ```kotlin
126+ fun foo() = 42
127+ ```
128+ """.trimIndent()
129+ val blocks = parseMarkdown(md)
130+ assertEquals(1, blocks.size)
131+ val code = blocks[0] as MdBlock.Code
132+ assertEquals("kotlin", code.lang)
133+ assertEquals("fun foo() = 42", code.code)
134+ }
135+
136+ @Test
137+ fun parsesMarkdownLists() {
138+ val md = """
139+ - first
140+ - second
141+ - third
142+ """.trimIndent()
143+ val blocks = parseMarkdown(md)
144+ assertEquals(1, blocks.size)
145+ val list = blocks[0] as MdBlock.ListBlock
146+ assertFalse(list.ordered)
147+ assertEquals(3, list.items.size)
148+ assertEquals("first", list.items[0].text)
149+ }
150+
151+ @Test
152+ fun parsesMarkdownOrderedList() {
153+ val md = """
154+ 1. one
155+ 2. two
156+ """.trimIndent()
157+ val blocks = parseMarkdown(md)
158+ val list = blocks[0] as MdBlock.ListBlock
159+ assertTrue(list.ordered)
160+ assertEquals(2, list.items.size)
161+ }
162+
163+ @Test
164+ fun parsesMarkdownBlockquote() {
165+ val md = "> This is a quote"
166+ val blocks = parseMarkdown(md)
167+ assertEquals(1, blocks.size)
168+ val quote = blocks[0] as MdBlock.Quote
169+ assertEquals("This is a quote", quote.text)
170+ }
171+
172+ @Test
173+ fun parsesMarkdownHorizontalRule() {
174+ val md = "above\n---\nbelow"
175+ val blocks = parseMarkdown(md)
176+ assertEquals(3, blocks.size)
177+ assertTrue(blocks[1] is MdBlock.Hr)
178+ }
179+}