@@ -13,28 +13,44 @@ import androidx.compose.foundation.layout.size
13import androidx.compose.foundation.lazy.LazyColumn
13import androidx.compose.foundation.lazy.LazyColumn
14import androidx.compose.foundation.lazy.items
14import androidx.compose.foundation.lazy.items
15import androidx.compose.material.icons.Icons
15import androidx.compose.material.icons.Icons
16+import androidx.compose.material.icons.filled.Add
16import androidx.compose.material.icons.filled.CloudOff
17import androidx.compose.material.icons.filled.CloudOff
17import androidx.compose.material.icons.filled.CloudQueue
18import androidx.compose.material.icons.filled.CloudQueue
18import androidx.compose.material.icons.filled.Computer
19import androidx.compose.material.icons.filled.Computer
19import androidx.compose.material.icons.filled.ErrorOutline
20import androidx.compose.material.icons.filled.ErrorOutline
21+import androidx.compose.material.icons.filled.CheckCircle
22+import androidx.compose.material.icons.filled.Error
20import androidx.compose.material.icons.filled.Hub
23import androidx.compose.material.icons.filled.Hub
21import androidx.compose.material.icons.filled.Refresh
24import androidx.compose.material.icons.filled.Refresh
25+import androidx.compose.material3.AlertDialog
22import androidx.compose.material3.Card
26import androidx.compose.material3.Card
23import androidx.compose.material3.CircularProgressIndicator
27import androidx.compose.material3.CircularProgressIndicator
24import androidx.compose.material3.ExperimentalMaterial3Api
28import androidx.compose.material3.ExperimentalMaterial3Api
29+import androidx.compose.material3.FilterChip
30+import androidx.compose.material3.FloatingActionButton
25import androidx.compose.material3.Icon
31import androidx.compose.material3.Icon
26import androidx.compose.material3.IconButton
32import androidx.compose.material3.IconButton
27import androidx.compose.material3.MaterialTheme
33import androidx.compose.material3.MaterialTheme
34+import androidx.compose.material3.OutlinedTextField
28import androidx.compose.material3.Scaffold
35import androidx.compose.material3.Scaffold
36+import androidx.compose.material3.SnackbarHost
37+import androidx.compose.material3.SnackbarHostState
29import androidx.compose.material3.Text
38import androidx.compose.material3.Text
39+import androidx.compose.material3.TextButton
30import androidx.compose.material3.pulltorefresh.PullToRefreshBox
40import androidx.compose.material3.pulltorefresh.PullToRefreshBox
31import androidx.compose.runtime.Composable
41import androidx.compose.runtime.Composable
42+import androidx.compose.runtime.LaunchedEffect
32import androidx.compose.runtime.getValue
43import androidx.compose.runtime.getValue
44+import androidx.compose.runtime.mutableStateOf
45+import androidx.compose.runtime.remember
46+import androidx.compose.runtime.saveable.rememberSaveable
47+import androidx.compose.runtime.setValue
33import androidx.compose.ui.Alignment
48import androidx.compose.ui.Alignment
34import androidx.compose.ui.Modifier
49import androidx.compose.ui.Modifier
35import androidx.compose.ui.res.stringResource
50import androidx.compose.ui.res.stringResource
36import androidx.compose.ui.semantics.contentDescription
51import androidx.compose.ui.semantics.contentDescription
37import androidx.compose.ui.semantics.semantics
52import androidx.compose.ui.semantics.semantics
53+import androidx.compose.ui.text.font.FontFamily
38import androidx.compose.ui.text.style.TextOverflow
54import androidx.compose.ui.text.style.TextOverflow
39import androidx.compose.ui.unit.dp
55import androidx.compose.ui.unit.dp
40import androidx.lifecycle.compose.collectAsStateWithLifecycle
56import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -42,6 +58,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
42import soy.iko.opencode.R
58import soy.iko.opencode.R
43import soy.iko.opencode.di.AppContainer
59import soy.iko.opencode.di.AppContainer
44import soy.iko.opencode.ui.components.AppTopBar
60import soy.iko.opencode.ui.components.AppTopBar
61+import soy.iko.opencode.ui.components.ConnectionBannerFor
45import soy.iko.opencode.ui.components.EmptyState
62import soy.iko.opencode.ui.components.EmptyState
46import soy.iko.opencode.ui.vmFactory
63import soy.iko.opencode.ui.vmFactory
@@ -51,6 +68,24 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
51 val vm: McpViewModel = viewModel(factory = vmFactory { McpViewModel(container) })
68 val vm: McpViewModel = viewModel(factory = vmFactory { McpViewModel(container) })
52 val state by vm.state.collectAsStateWithLifecycle()
69 val state by vm.state.collectAsStateWithLifecycle()
53 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
70 val refreshing by vm.refreshing.collectAsStateWithLifecycle()
71+ val adding by vm.adding.collectAsStateWithLifecycle()
72+ val snackbar = remember { SnackbarHostState() }
73+ val addedMsg = stringResource(R.string.mcp_added)
74+ val addFailedMsg = stringResource(R.string.mcp_add_failed)
75+ val notConnectedMsg = stringResource(R.string.not_connected)
77+ LaunchedEffect(Unit) {
78+ vm.messages.collect { msg ->
79+ val text = when (msg) {
80+ McpMessage.ADDED -> addedMsg
81+ McpMessage.ADD_FAILED -> addFailedMsg
82+ McpMessage.NOT_CONNECTED -> notConnectedMsg
84+ snackbar.showSnackbar(text)
88+ var showAddDialog by rememberSaveable { mutableStateOf(false) }
56 topBar = {
91 topBar = {
@@ -64,6 +99,16 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
102+ snackbarHost = { SnackbarHost(snackbar) },
103+ floatingActionButton = {
104+ // Register a new MCP server dynamically (POST /mcp). Hidden until a connection is
105+ // established — adding requires an active server.
106+ if (state !is McpViewModel.State.Disconnected) {
107+ FloatingActionButton(onClick = { showAddDialog = true }) {
108+ Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.mcp_add))
67 ) { padding ->
112 ) { padding ->
68 PullToRefreshBox(
113 PullToRefreshBox(
69 isRefreshing = refreshing,
114 isRefreshing = refreshing,
@@ -71,6 +116,7 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
71 modifier = Modifier.fillMaxSize().padding(padding),
116 modifier = Modifier.fillMaxSize().padding(padding),
73 Box(modifier = Modifier.fillMaxSize()) {
118 Box(modifier = Modifier.fillMaxSize()) {
119+ ConnectionBannerFor(container)
74 val loadingLabel = stringResource(R.string.loading)
120 val loadingLabel = stringResource(R.string.loading)
75 when (val s = state) {
121 when (val s = state) {
76 is McpViewModel.State.Loading -> Column(
122 is McpViewModel.State.Loading -> Column(
@@ -105,7 +151,9 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
106 LazyColumn(
152 LazyColumn(
107 modifier = Modifier.fillMaxSize(),
153 modifier = Modifier.fillMaxSize(),
108− contentPadding = PaddingValues(16.dp),
154+ contentPadding = PaddingValues(
155+ start = 16.dp, end = 16.dp, top = 16.dp, bottom = 96.dp,
109 verticalArrangement = Arrangement.spacedBy(12.dp),
157 verticalArrangement = Arrangement.spacedBy(12.dp),
111 items(s.servers, key = { it.name }) { McpServerCard(it) }
159 items(s.servers, key = { it.name }) { McpServerCard(it) }
@@ -115,40 +163,188 @@ fun McpScreen(container: AppContainer, onBack: () -> Unit) {
167+ if (showAddDialog) {
170+ onDismiss = { if (!adding) showAddDialog = false },
171+ onAdd = { name, kind, target, env ->
172+ vm.addServer(name, kind, target, env)
173+ // Close immediately; the snackbar reports the outcome and a successful add
174+ // triggers a reload so the new server appears in the list.
175+ showAddDialog = false
120@Composable
181@Composable
121private fun McpServerCard(server: McpServerInfo) {
182private fun McpServerCard(server: McpServerInfo) {
183+ val remote = server.type.equals("remote", ignoreCase = true)
184+ val connected = server.connected
122 Card(modifier = Modifier.fillMaxWidth()) {
185 Card(modifier = Modifier.fillMaxWidth()) {
186+ Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
123− Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
187+ Row(verticalAlignment = Alignment.CenterVertically) {
124− val remote = server.type.equals("remote", ignoreCase = true)
126− if (remote) Icons.Filled.CloudQueue else Icons.Filled.Computer,
189+ if (remote) Icons.Filled.CloudQueue else Icons.Filled.Computer,
127− contentDescription = null,
190+ contentDescription = null,
128− tint = if (server.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
191+ tint = if (server.enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
130− Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
193+ Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) {
131− Text(server.name, style = MaterialTheme.typography.titleSmall)
194+ Text(server.name, style = MaterialTheme.typography.titleSmall)
132− server.target?.let {
195+ server.target?.let {
135− style = MaterialTheme.typography.bodySmall,
198+ style = MaterialTheme.typography.bodySmall,
136− color = MaterialTheme.colorScheme.onSurfaceVariant,
199+ color = MaterialTheme.colorScheme.onSurfaceVariant,
137− maxLines = 2,
200+ maxLines = 2,
138− overflow = TextOverflow.Ellipsis,
201+ overflow = TextOverflow.Ellipsis,
202+ fontFamily = FontFamily.Monospace,
206+ // Enabled/disabled reflects the *configured* state; the live connected/error chip
207+ // below reflects the runtime state when the server exposes /mcp status.
208+ val stateLabel = if (server.enabled) {
209+ stringResource(R.string.mcp_enabled)
211+ stringResource(R.string.mcp_disabled)
215+ style = MaterialTheme.typography.labelMedium,
216+ color = if (server.enabled) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurfaceVariant,
142− val stateLabel = if (server.enabled) {
219+ // Live status row: connected (with tool count) / error / unknown. Only shown when the
220+ // server reported a runtime state via /mcp, so a static config-only view isn't
221+ // cluttered with "unknown" chips.
222+ if (connected != null || server.error != null) {
223+ Spacer(Modifier.size(10.dp))
224+ Row(verticalAlignment = Alignment.CenterVertically) {
225+ if (server.error != null && connected != true) {
228+ contentDescription = null,
229+ modifier = Modifier.size(16.dp),
230+ tint = MaterialTheme.colorScheme.error,
234+ style = MaterialTheme.typography.labelSmall,
235+ color = MaterialTheme.colorScheme.error,
237+ overflow = TextOverflow.Ellipsis,
238+ modifier = Modifier.padding(start = 6.dp).weight(1f),
240+ } else if (connected == true) {
242+ Icons.Filled.CheckCircle,
243+ contentDescription = null,
244+ modifier = Modifier.size(16.dp),
245+ tint = MaterialTheme.colorScheme.primary,
247+ val connectedLabel = server.toolCount?.let {
248+ stringResource(R.string.mcp_connected) + " · " +
143− stringResource(R.string.mcp_enabled)
249+ stringResource(R.string.mcp_tools_count, it)
250+ } ?: stringResource(R.string.mcp_connected)
253+ style = MaterialTheme.typography.labelSmall,
254+ color = MaterialTheme.colorScheme.primary,
255+ modifier = Modifier.padding(start = 6.dp),
144− } else {
257+ } else {
145− stringResource(R.string.mcp_disabled)
259+ stringResource(R.string.mcp_disconnected),
260+ style = MaterialTheme.typography.labelSmall,
261+ color = MaterialTheme.colorScheme.onSurfaceVariant,
262+ modifier = Modifier.padding(start = 22.dp),
149− style = MaterialTheme.typography.labelMedium,
150− color = if (server.enabled) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurfaceVariant,
271+/** Dialog collecting the bits needed to register an MCP server via POST /mcp: a name, whether
272+ * it's a local command or a remote URL, the command line / URL, and optional env vars. */
274+private fun AddMcpDialog(
276+ onDismiss: () -> Unit,
277+ onAdd: (name: String, kind: McpKind, target: String, env: String) -> Unit,
279+ var name by rememberSaveable { mutableStateOf("") }
280+ var kind by rememberSaveable { mutableStateOf(McpKind.LOCAL) }
281+ var target by rememberSaveable { mutableStateOf("") }
282+ var env by rememberSaveable { mutableStateOf("") }
283+ val nameValid = name.trim().isNotEmpty()
284+ val targetValid = target.trim().isNotEmpty()
285+ val canAdd = !adding && nameValid && targetValid
288+ onDismissRequest = onDismiss,
289+ title = { Text(stringResource(R.string.mcp_add)) },
291+ Column(modifier = Modifier.fillMaxWidth()) {
294+ onValueChange = { name = it },
295+ modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
296+ label = { Text(stringResource(R.string.mcp_add_name)) },
300+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
302+ selected = kind == McpKind.LOCAL,
303+ onClick = { kind = McpKind.LOCAL },
304+ label = { Text(stringResource(R.string.mcp_kind_local)) },
308+ selected = kind == McpKind.REMOTE,
309+ onClick = { kind = McpKind.REMOTE },
310+ label = { Text(stringResource(R.string.mcp_kind_remote)) },
314+ Spacer(Modifier.size(12.dp))
317+ onValueChange = { target = it },
318+ modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
319+ label = { Text(if (kind == McpKind.LOCAL) stringResource(R.string.mcp_add_command) else stringResource(R.string.mcp_add_url)) },
321+ Text(if (kind == McpKind.LOCAL) stringResource(R.string.mcp_add_command_hint) else stringResource(R.string.mcp_add_url_hint))
328+ onValueChange = { env = it },
329+ modifier = Modifier.fillMaxWidth(),
330+ label = { Text(stringResource(R.string.mcp_add_env)) },
331+ placeholder = { Text(stringResource(R.string.mcp_add_env_hint)) },
338+ TextButton(onClick = { onAdd(name, kind, target, env) }, enabled = canAdd) {
340+ CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
342+ Text(stringResource(R.string.mcp_add_button))
347+ TextButton(onClick = onDismiss, enabled = !adding) { Text(stringResource(R.string.cancel)) }