git.iko.soy/opencode-android
on master help sign in
Add markdown rendering, diff viewer, agent/command pickers, and server quick-switch (M6)
f3d1d28iko
README.md+8−4
@@ -6,7 +6,7 @@ Application id: `soy.iko.opencode`
6
6
7## Status
7## Status
8
8
9−Milestones M0–M5 complete:
9+Milestones M0–M6 complete:
10
10
11- **M0–M4:** connect to a server → list/create/delete sessions → chat with **live SSE streaming**
11- **M0–M4:** connect to a server → list/create/delete sessions → chat with **live SSE streaming**
12 of the assistant's reply. Tool calls, reasoning, and token/cost are rendered.
12 of the assistant's reply. Tool calls, reasoning, and token/cost are rendered.
@@ -15,9 +15,13 @@ Milestones M0–M5 complete:
15 `POST /session/:id/permissions/:permissionID`, so tool runs don't stall; **file browser + viewer**
15 `POST /session/:id/permissions/:permissionID`, so tool runs don't stall; **file browser + viewer**
16 (`/find/file` fuzzy search, `/file` directory listing, `/file/content` read-only view); **reconnect
16 (`/find/file` fuzzy search, `/file` directory listing, `/file/content` read-only view); **reconnect
17 banner** (live SSE connection state) and a real **Settings** screen (theme mode, persisted).
17 banner** (live SSE connection state) and a real **Settings** screen (theme mode, persisted).
18−
18+- **M6:** **markdown rendering** of assistant text (headings, code blocks, lists, blockquotes, inline
19−Not yet built: rich diff/patch rendering, agents/commands UI, markdown rendering, multi-server
19+ bold/italic/code/links/strikethrough); **rich diff/patch rendering** — tool outputs and file-viewer
20−quick-switch.
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.
21
25
22## Architecture
26## Architecture
23
27
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 @@
1package soy.iko.opencode.data.network
1package soy.iko.opencode.data.network
2
2
3+import soy.iko.opencode.data.model.Agent
4+import soy.iko.opencode.data.model.Command
3import soy.iko.opencode.data.model.CreateSessionRequest
5import soy.iko.opencode.data.model.CreateSessionRequest
4import soy.iko.opencode.data.model.FileContent
6import soy.iko.opencode.data.model.FileContent
5import soy.iko.opencode.data.model.FileNode
7import soy.iko.opencode.data.model.FileNode
@@ -82,6 +84,12 @@ class OpencodeApiClient(private val client: HttpClient) {
82 suspend fun providers(): ProvidersResponse =
84 suspend fun providers(): ProvidersResponse =
83 client.get("config/providers").body()
85 client.get("config/providers").body()
84
86
87+ suspend fun agents(): List<Agent> =
88+ client.get("agent").body()
89+
90+ suspend fun commands(): List<Command> =
91+ client.get("command").body()
92+
85 /** Respond to a permission request so a paused tool run can proceed. */
93 /** Respond to a permission request so a paused tool run can proceed. */
86 suspend fun respondPermission(
94 suspend fun respondPermission(
87 sessionId: String,
95 sessionId: String,
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+2−2
@@ -33,8 +33,8 @@ class SessionRepository(
33 suspend fun deleteSession(id: String) = api.deleteSession(id)
33 suspend fun deleteSession(id: String) = api.deleteSession(id)
34 suspend fun abort(sessionId: String) = api.abort(sessionId)
34 suspend fun abort(sessionId: String) = api.abort(sessionId)
35
35
36− suspend fun sendPrompt(sessionId: String, text: String, model: ModelRef?) =
36+ suspend fun sendPrompt(sessionId: String, text: String, model: ModelRef?, agent: String? = null) =
37− api.sendPrompt(sessionId, text, model)
37+ api.sendPrompt(sessionId, text, model, agent)
38
38
39 /**
39 /**
40 * A live, ordered view of [sessionId]'s messages. Begins collecting the event
40 * 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
16import androidx.compose.material.icons.Icons
16import androidx.compose.material.icons.Icons
17import androidx.compose.material.icons.automirrored.filled.ArrowBack
17import androidx.compose.material.icons.automirrored.filled.ArrowBack
18import androidx.compose.material.icons.automirrored.filled.Send
18import androidx.compose.material.icons.automirrored.filled.Send
19+import androidx.compose.material.icons.filled.SmartToy
19import androidx.compose.material.icons.filled.Stop
20import androidx.compose.material.icons.filled.Stop
21+import androidx.compose.material.icons.filled.Terminal
20import androidx.compose.material.icons.filled.Tune
22import androidx.compose.material.icons.filled.Tune
21import androidx.compose.material3.CircularProgressIndicator
23import androidx.compose.material3.CircularProgressIndicator
22import androidx.compose.material3.ExperimentalMaterial3Api
24import androidx.compose.material3.ExperimentalMaterial3Api
@@ -59,11 +61,16 @@ fun ChatScreen(
59 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
61 val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
60 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
62 val connectionState by vm.connectionState.collectAsStateWithLifecycle()
61 val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
63 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()
62
67
63 val listState = rememberLazyListState()
68 val listState = rememberLazyListState()
64 val snackbar = remember { SnackbarHostState() }
69 val snackbar = remember { SnackbarHostState() }
65 var input by remember { mutableStateOf("") }
70 var input by remember { mutableStateOf("") }
66 var showModelPicker by remember { mutableStateOf(false) }
71 var showModelPicker by remember { mutableStateOf(false) }
72+ var showAgentPicker by remember { mutableStateOf(false) }
73+ var showCommandPicker by remember { mutableStateOf(false) }
67
74
68 // Keep the newest content in view as parts stream in.
75 // Keep the newest content in view as parts stream in.
69 LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size) {
76 LaunchedEffect(messages.size, messages.lastOrNull()?.parts?.size) {
@@ -83,7 +90,10 @@ fun ChatScreen(
83 Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) {
90 Column(modifier = Modifier.clickable(enabled = models.isNotEmpty()) { showModelPicker = true }) {
84 Text("Session")
91 Text("Session")
85 Text(
92 Text(
93+ buildString {
86− selectedModel?.modelLabel ?: "default model",
94+ append(selectedModel?.modelLabel ?: "default model")
95+ selectedAgent?.let { append(" • $it") }
96+ },
87 style = MaterialTheme.typography.labelSmall,
97 style = MaterialTheme.typography.labelSmall,
88 color = MaterialTheme.colorScheme.onSurfaceVariant,
98 color = MaterialTheme.colorScheme.onSurfaceVariant,
89 )
99 )
@@ -95,6 +105,12 @@ fun ChatScreen(
95 }
105 }
96 },
106 },
97 actions = {
107 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+ }
98 IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) {
114 IconButton(onClick = { showModelPicker = true }, enabled = models.isNotEmpty()) {
99 Icon(Icons.Filled.Tune, contentDescription = "Choose model")
115 Icon(Icons.Filled.Tune, contentDescription = "Choose model")
100 }
116 }
@@ -159,6 +175,23 @@ fun ChatScreen(
159 )
175 )
160 }
176 }
161
177
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+
162 pendingPermission?.let { permission ->
195 pendingPermission?.let { permission ->
163 PermissionDialog(
196 PermissionDialog(
164 permission = permission,
197 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
2
2
3import androidx.lifecycle.ViewModel
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.Agent
6+import soy.iko.opencode.data.model.Command
5import soy.iko.opencode.data.model.MessageWithParts
7import soy.iko.opencode.data.model.MessageWithParts
6import soy.iko.opencode.data.model.ModelOption
8import soy.iko.opencode.data.model.ModelOption
7import soy.iko.opencode.data.model.Permission
9import soy.iko.opencode.data.model.Permission
@@ -57,10 +59,21 @@ class ChatViewModel(
57 private val _pendingPermission = MutableStateFlow<Permission?>(null)
59 private val _pendingPermission = MutableStateFlow<Permission?>(null)
58 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
60 val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
59
61
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+
60 fun clearError() { _error.value = null }
71 fun clearError() { _error.value = null }
61
72
62 fun selectModel(option: ModelOption) { _selectedModel.value = option }
73 fun selectModel(option: ModelOption) { _selectedModel.value = option }
63
74
75+ fun selectAgent(name: String?) { _selectedAgent.value = name }
76+
64 init {
77 init {
65 // Watch the bus for run completion / errors / permission asks for this session.
78 // Watch the bus for run completion / errors / permission asks for this session.
66 val conn = connection
79 val conn = connection
@@ -90,6 +103,14 @@ class ChatViewModel(
90 _selectedModel.value = resp.defaultOption(options)
103 _selectedModel.value = resp.defaultOption(options)
91 }
104 }
92 }
105 }
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+ }
93 }
114 }
94 }
115 }
95
116
@@ -100,10 +121,32 @@ class ChatViewModel(
100 _running.value = true
121 _running.value = true
101 _error.value = null
122 _error.value = null
102 viewModelScope.launch {
123 viewModelScope.launch {
124+ runCatching {
125+ conn.repository.sendPrompt(
126+ sessionId,
127+ trimmed,
103− runCatching { conn.repository.sendPrompt(sessionId, trimmed, model = _selectedModel.value?.ref) }
128+ model = _selectedModel.value?.ref,
129+ agent = _selectedAgent.value,
130+ )
104− .onFailure { _error.value = it.message ?: "Failed to send" }
131+ }.onFailure { _error.value = it.message ?: "Failed to send" }
105− // session.idle is the authoritative finalizer; clear here too in case the
132+ _running.value = false
106− // POST returns first (idempotent).
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" }
107 _running.value = false
150 _running.value = false
108 }
151 }
109 }
152 }
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
42import soy.iko.opencode.data.model.ToolState
42import soy.iko.opencode.data.model.ToolState
43import soy.iko.opencode.data.model.ToolUnknown
43import soy.iko.opencode.data.model.ToolUnknown
44import soy.iko.opencode.data.model.UnknownPart
44import 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
45
48
46/**
49/**
47 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
50 * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
48 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
51 * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
49− * (Markdown rendering is deferred — text is shown as-is for the scaffold.)
50 */
52 */
51@Composable
53@Composable
52fun PartView(part: Part, modifier: Modifier = Modifier) {
54fun PartView(part: Part, modifier: Modifier = Modifier) {
53 when (part) {
55 when (part) {
54 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
56 is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
55− Text(part.text, modifier = modifier, style = MaterialTheme.typography.bodyLarge)
57+ MarkdownText(part.text, modifier = modifier)
56 }
58 }
57 is ReasoningPart -> ReasoningBlock(part.text, modifier)
59 is ReasoningPart -> ReasoningBlock(part.text, modifier)
58 is ToolPart -> ToolCallView(part, modifier)
60 is ToolPart -> ToolCallView(part, modifier)
@@ -117,12 +119,17 @@ private fun ToolCallView(part: ToolPart, modifier: Modifier) {
117 else -> null
119 else -> null
118 }
120 }
119 if (detail != null) {
121 if (detail != null) {
120− Text(
121− detail.take(2000),
122+ val truncated = detail.take(5000)
123+ if (looksLikeDiff(truncated)) {
124+ DiffView(truncated)
125+ } else {
126+ Text(
127+ truncated.take(2000),
122− style = MaterialTheme.typography.bodySmall,
128+ style = MaterialTheme.typography.bodySmall,
123− fontFamily = FontFamily.Monospace,
129+ fontFamily = FontFamily.Monospace,
124− modifier = Modifier.padding(top = 6.dp),
130+ modifier = Modifier.padding(top = 6.dp),
125− )
131+ )
132+ }
126 }
133 }
127 }
134 }
128}
135}
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
26import androidx.lifecycle.compose.collectAsStateWithLifecycle
26import androidx.lifecycle.compose.collectAsStateWithLifecycle
27import androidx.lifecycle.viewmodel.compose.viewModel
27import androidx.lifecycle.viewmodel.compose.viewModel
28import soy.iko.opencode.di.AppContainer
28import soy.iko.opencode.di.AppContainer
29+import soy.iko.opencode.ui.components.DiffView
29import soy.iko.opencode.ui.vmFactory
30import soy.iko.opencode.ui.vmFactory
30
31
31@OptIn(ExperimentalMaterial3Api::class)
32@OptIn(ExperimentalMaterial3Api::class)
@@ -63,16 +64,29 @@ fun FileViewScreen(
63 modifier = Modifier.align(Alignment.Center).padding(24.dp),
64 modifier = Modifier.align(Alignment.Center).padding(24.dp),
64 color = MaterialTheme.colorScheme.onSurfaceVariant,
65 color = MaterialTheme.colorScheme.onSurfaceVariant,
65 )
66 )
66− else -> Text(
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(
67− text = state.content?.content.orEmpty(),
79+ text = content?.content.orEmpty(),
68− modifier = Modifier
80+ modifier = Modifier
69− .fillMaxSize()
81+ .fillMaxSize()
70− .verticalScroll(rememberScrollState())
82+ .verticalScroll(scrollState)
71− .horizontalScroll(rememberScrollState())
83+ .horizontalScroll(rememberScrollState())
72− .padding(12.dp),
84+ .padding(12.dp),
73− style = MaterialTheme.typography.bodySmall,
85+ style = MaterialTheme.typography.bodySmall,
74− fontFamily = FontFamily.Monospace,
86+ fontFamily = FontFamily.Monospace,
75− )
87+ )
88+ }
89+ }
76 }
90 }
77 }
91 }
78 }
92 }
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+59−1
@@ -9,17 +9,21 @@ import androidx.compose.foundation.layout.Row
9import androidx.compose.foundation.layout.fillMaxSize
9import androidx.compose.foundation.layout.fillMaxSize
10import androidx.compose.foundation.layout.fillMaxWidth
10import androidx.compose.foundation.layout.fillMaxWidth
11import androidx.compose.foundation.layout.padding
11import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.size
12import androidx.compose.foundation.lazy.LazyColumn
13import androidx.compose.foundation.lazy.LazyColumn
13import androidx.compose.foundation.lazy.items
14import androidx.compose.foundation.lazy.items
14import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.automirrored.filled.Logout
16import androidx.compose.material.icons.automirrored.filled.Logout
16import androidx.compose.material.icons.filled.Add
17import androidx.compose.material.icons.filled.Add
18+import androidx.compose.material.icons.filled.ArrowDropDown
17import androidx.compose.material.icons.filled.Delete
19import androidx.compose.material.icons.filled.Delete
18import androidx.compose.material.icons.filled.Folder
20import androidx.compose.material.icons.filled.Folder
19import androidx.compose.material.icons.filled.Refresh
21import androidx.compose.material.icons.filled.Refresh
20import androidx.compose.material.icons.filled.Settings
22import androidx.compose.material.icons.filled.Settings
21import androidx.compose.material3.Card
23import androidx.compose.material3.Card
22import androidx.compose.material3.CircularProgressIndicator
24import androidx.compose.material3.CircularProgressIndicator
25+import androidx.compose.material3.DropdownMenu
26+import androidx.compose.material3.DropdownMenuItem
23import androidx.compose.material3.ExperimentalMaterial3Api
27import androidx.compose.material3.ExperimentalMaterial3Api
24import androidx.compose.material3.ExtendedFloatingActionButton
28import androidx.compose.material3.ExtendedFloatingActionButton
25import androidx.compose.material3.Icon
29import androidx.compose.material3.Icon
@@ -30,6 +34,9 @@ import androidx.compose.material3.Text
30import androidx.compose.material3.TopAppBar
34import androidx.compose.material3.TopAppBar
31import androidx.compose.runtime.Composable
35import androidx.compose.runtime.Composable
32import androidx.compose.runtime.getValue
36import androidx.compose.runtime.getValue
37+import androidx.compose.runtime.mutableStateOf
38+import androidx.compose.runtime.remember
39+import androidx.compose.runtime.setValue
33import androidx.compose.ui.Alignment
40import androidx.compose.ui.Alignment
34import androidx.compose.ui.Modifier
41import androidx.compose.ui.Modifier
35import androidx.compose.ui.text.style.TextOverflow
42import androidx.compose.ui.text.style.TextOverflow
@@ -50,11 +57,62 @@ fun SessionListScreen(
50) {
57) {
51 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
58 val vm: SessionListViewModel = viewModel(factory = vmFactory { SessionListViewModel(container) })
52 val state by vm.state.collectAsStateWithLifecycle()
59 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) }
53
64
54 Scaffold(
65 Scaffold(
55 topBar = {
66 topBar = {
56 TopAppBar(
67 TopAppBar(
68+ title = {
69+ Column {
70+ Box {
71+ Row(
72+ modifier = Modifier.clickable { showServerMenu = true },
73+ verticalAlignment = Alignment.CenterVertically,
74+ ) {
57− title = { Text(vm.serverLabel) },
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+ },
58 actions = {
116 actions = {
59 IconButton(onClick = { vm.refresh() }) {
117 IconButton(onClick = { vm.refresh() }) {
60 Icon(Icons.Filled.Refresh, contentDescription = "Refresh")
118 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
2
2
3import androidx.lifecycle.ViewModel
3import androidx.lifecycle.ViewModel
4import androidx.lifecycle.viewModelScope
4import androidx.lifecycle.viewModelScope
5+import soy.iko.opencode.data.model.ServerProfile
5import soy.iko.opencode.data.model.Session
6import soy.iko.opencode.data.model.Session
6import soy.iko.opencode.di.AppContainer
7import soy.iko.opencode.di.AppContainer
7import kotlinx.coroutines.flow.MutableStateFlow
8import kotlinx.coroutines.flow.MutableStateFlow
9+import kotlinx.coroutines.flow.SharingStarted
8import kotlinx.coroutines.flow.StateFlow
10import kotlinx.coroutines.flow.StateFlow
9import kotlinx.coroutines.flow.asStateFlow
11import kotlinx.coroutines.flow.asStateFlow
12+import kotlinx.coroutines.flow.stateIn
10import kotlinx.coroutines.launch
13import kotlinx.coroutines.launch
11
14
12data class SessionListState(
15data class SessionListState(
@@ -17,12 +20,27 @@ data class SessionListState(
17
20
18class SessionListViewModel(private val container: AppContainer) : ViewModel() {
21class SessionListViewModel(private val container: AppContainer) : ViewModel() {
19
22
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(
21 container.activeConnection.value?.profile?.displayLabel ?: "opencode"
31 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()
22
37
23 private val _state = MutableStateFlow(SessionListState())
38 private val _state = MutableStateFlow(SessionListState())
24 val state: StateFlow<SessionListState> = _state.asStateFlow()
39 val state: StateFlow<SessionListState> = _state.asStateFlow()
25
40
41+ private val activeProfileId: String?
42+ get() = container.activeConnection.value?.profile?.id
43+
26 init { refresh() }
44 init { refresh() }
27
45
28 fun refresh() {
46 fun refresh() {
@@ -31,6 +49,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
31 _state.value = SessionListState(loading = false, error = "Not connected")
49 _state.value = SessionListState(loading = false, error = "Not connected")
32 return
50 return
33 }
51 }
52+ _serverLabel.value = conn.profile.displayLabel
34 _state.value = _state.value.copy(loading = true, error = null)
53 _state.value = _state.value.copy(loading = true, error = null)
35 viewModelScope.launch {
54 viewModelScope.launch {
36 runCatching { conn.repository.listSessions() }
55 runCatching { conn.repository.listSessions() }
@@ -61,4 +80,25 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
61 .onFailure { _state.value = _state.value.copy(error = it.message ?: "Failed to delete session") }
80 .onFailure { _state.value = _state.value.copy(error = it.message ?: "Failed to delete session") }
62 }
81 }
63 }
82 }
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+ }
64}
104}
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+}